generated from Grigo/AndroidTemplate
added opentopo
This commit is contained in:
+10
-6
@@ -40,14 +40,17 @@ docker compose up -d --build
|
||||
curl http://127.0.0.1:7634/api/health | jq
|
||||
```
|
||||
|
||||
Ожидается `"elevation_ok": true` если локальный Open-Meteo доступен с хоста/контейнера.
|
||||
Ожидается `"elevation_ok": true` если OpenTopoData (основной) или Open-Meteo (fallback) доступны с хоста/контейнера.
|
||||
|
||||
Переопределить URL высот (`.env` рядом с `docker-compose.yml`):
|
||||
|
||||
```env
|
||||
LORATESTER_ELEVATION_URL=http://192.168.1.109:8085/v1/elevation
|
||||
LORATESTER_ELEVATION_OPENTOPO_URL=http://grigowashere.ru:5300/v1/srtm30
|
||||
LORATESTER_ELEVATION_FALLBACK_URL=http://192.168.1.109:8085/v1/elevation
|
||||
```
|
||||
|
||||
`LORATESTER_ELEVATION_URL` — устаревший alias для fallback (Open-Meteo-compatible).
|
||||
|
||||
БД хранится в volume `loratester-data` (`/data/loratester.db` внутри контейнера).
|
||||
|
||||
## Деплой (lora.grigowashere.ru)
|
||||
@@ -63,7 +66,8 @@ docker compose up -d --build
|
||||
cd /srv/storage/disk2/services/LoraTester/server
|
||||
pip install -r requirements.txt
|
||||
export LORATESTER_DB=/srv/storage/disk2/services/LoraTester/loratester.db
|
||||
export LORATESTER_ELEVATION_URL=http://192.168.1.109:8085/v1/elevation
|
||||
export LORATESTER_ELEVATION_OPENTOPO_URL=http://grigowashere.ru:5300/v1/srtm30
|
||||
export LORATESTER_ELEVATION_FALLBACK_URL=http://192.168.1.109:8085/v1/elevation
|
||||
uvicorn fastapi_app:app --host 0.0.0.0 --port 7634
|
||||
```
|
||||
|
||||
@@ -99,7 +103,7 @@ curl http://127.0.0.1:7634/api/health
|
||||
- `POST /api/tracks/{id}/points` — `{points: [{ts, lat, lon, altitude_gps?, rssi?, role?, meta?}]}`
|
||||
- `POST /api/tracks/{id}/finish`
|
||||
- `GET /api/tracks?device_id=`
|
||||
- `GET /api/tracks/{id}` — метаданные + точки (высота terrain через локальный Open-Meteo)
|
||||
- `GET /api/tracks/{id}` — метаданные + точки (высота terrain через OpenTopoData → Open-Meteo fallback)
|
||||
|
||||
### Команды (очередь на устройство)
|
||||
|
||||
@@ -110,9 +114,9 @@ curl http://127.0.0.1:7634/api/health
|
||||
|
||||
### Профиль высот (веб, треки)
|
||||
|
||||
- `POST /api/elevation/profile` — `{points: [{lat, lon}], step_m?: 10}` → срез рельефа (локальный Open-Meteo)
|
||||
- `POST /api/elevation/profile` — `{points: [{lat, lon}], step_m?: 10}` → срез рельефа (OpenTopoData → Open-Meteo)
|
||||
- `GET /api/tracks/{id}/elevation-profile?step_m=10` — то же по сохранённому треку
|
||||
- `GET /api/elevation/nearest-hill?lat=&lon=&radius_m=5000` — ближайшая возвышенность (прокси Open-Meteo)
|
||||
- `GET /api/elevation/nearest-hill?lat=&lon=&radius_m=5000` — ближайшая возвышенность
|
||||
- `GET /api/elevation/grid?lat=&lon=&radius_m=200&step_m=0` — сетка высот для хитмапы (100–500 m, step_m=0 авто)
|
||||
- `GET /api/commands/pending?device_id=` — Android, доставка + `delivered_at`
|
||||
- `GET /api/commands?to_device_id=&limit=` — история (веб)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+10
-1
@@ -9,10 +9,19 @@ HOST = os.environ.get("LORATESTER_HOST", "0.0.0.0")
|
||||
PORT = int(os.environ.get("LORATESTER_PORT", "7634"))
|
||||
TELEMETRY_LIMIT = int(os.environ.get("LORATESTER_TELEMETRY_LIMIT", "5000"))
|
||||
TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "10000"))
|
||||
ELEVATION_API_URL = os.environ.get(
|
||||
ELEVATION_OPENTOPO_URL = os.environ.get(
|
||||
"LORATESTER_ELEVATION_OPENTOPO_URL",
|
||||
"http://grigowashere.ru:5300/v1/srtm30",
|
||||
).rstrip("/")
|
||||
ELEVATION_FALLBACK_URL = os.environ.get(
|
||||
"LORATESTER_ELEVATION_FALLBACK_URL",
|
||||
os.environ.get(
|
||||
"LORATESTER_ELEVATION_URL",
|
||||
"http://192.168.1.109:8085/v1/elevation",
|
||||
),
|
||||
).rstrip("/")
|
||||
# Backward-compatible alias for Open-Meteo-compatible fallback API.
|
||||
ELEVATION_API_URL = ELEVATION_FALLBACK_URL
|
||||
ELEVATION_PROBE_TTL_SEC = float(
|
||||
os.environ.get("LORATESTER_ELEVATION_PROBE_TTL", "60")
|
||||
)
|
||||
|
||||
+235
-41
@@ -1,4 +1,4 @@
|
||||
"""Terrain elevation via self-hosted Open-Meteo-compatible API."""
|
||||
"""Terrain elevation: OpenTopoData primary, Open-Meteo-compatible fallback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,8 +10,9 @@ from typing import Any, Optional
|
||||
import httpx
|
||||
|
||||
from .config import (
|
||||
ELEVATION_API_URL,
|
||||
ELEVATION_CONNECT_TIMEOUT,
|
||||
ELEVATION_FALLBACK_URL,
|
||||
ELEVATION_OPENTOPO_URL,
|
||||
ELEVATION_PROBE_TTL_SEC,
|
||||
)
|
||||
|
||||
@@ -21,8 +22,11 @@ _BATCH_SIZE = 100
|
||||
_MAX_PROFILE_POINTS = 500
|
||||
_CACHE: dict[tuple[float, float], Optional[float]] = {}
|
||||
_probe_checked_at = 0.0
|
||||
_probe_ok = False
|
||||
_probe_error: Optional[str] = None
|
||||
_probe_opentopo_ok = False
|
||||
_probe_opentopo_error: Optional[str] = None
|
||||
_probe_fallback_ok = False
|
||||
_probe_fallback_error: Optional[str] = None
|
||||
_last_fetch_source: Optional[str] = None
|
||||
|
||||
|
||||
def _cache_key(lat: float, lon: float) -> tuple[float, float]:
|
||||
@@ -42,9 +46,8 @@ def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
|
||||
def probe_elevation_api(force: bool = False) -> dict[str, Any]:
|
||||
"""Ping elevation service before batch requests (cached for TTL)."""
|
||||
global _probe_checked_at, _probe_ok, _probe_error
|
||||
def _probe_opentopodata(force: bool = False) -> dict[str, Any]:
|
||||
global _probe_checked_at, _probe_opentopo_ok, _probe_opentopo_error
|
||||
|
||||
now = time.monotonic()
|
||||
if (
|
||||
@@ -53,35 +56,129 @@ def probe_elevation_api(force: bool = False) -> dict[str, Any]:
|
||||
and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC
|
||||
):
|
||||
return {
|
||||
"ok": _probe_ok,
|
||||
"url": ELEVATION_API_URL,
|
||||
"error": _probe_error,
|
||||
"ok": _probe_opentopo_ok,
|
||||
"url": ELEVATION_OPENTOPO_URL,
|
||||
"error": _probe_opentopo_error,
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
|
||||
r = client.get(
|
||||
ELEVATION_API_URL,
|
||||
ELEVATION_OPENTOPO_URL,
|
||||
params={"locations": "0.000000,0.000000"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if data.get("status") != "OK":
|
||||
raise ValueError(f"status={data.get('status')}")
|
||||
results = data.get("results") or []
|
||||
if not results or results[0].get("elevation") is None:
|
||||
raise ValueError("response has no elevation values")
|
||||
_probe_opentopo_ok = True
|
||||
_probe_opentopo_error = None
|
||||
logger.info("OpenTopoData ok: %s", ELEVATION_OPENTOPO_URL)
|
||||
except Exception as e:
|
||||
_probe_opentopo_ok = False
|
||||
_probe_opentopo_error = str(e)
|
||||
logger.warning(
|
||||
"OpenTopoData unreachable %s: %s", ELEVATION_OPENTOPO_URL, e
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": _probe_opentopo_ok,
|
||||
"url": ELEVATION_OPENTOPO_URL,
|
||||
"error": _probe_opentopo_error,
|
||||
}
|
||||
|
||||
|
||||
def _probe_fallback(force: bool = False) -> dict[str, Any]:
|
||||
global _probe_checked_at, _probe_fallback_ok, _probe_fallback_error
|
||||
|
||||
now = time.monotonic()
|
||||
if (
|
||||
not force
|
||||
and _probe_checked_at > 0
|
||||
and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC
|
||||
):
|
||||
return {
|
||||
"ok": _probe_fallback_ok,
|
||||
"url": ELEVATION_FALLBACK_URL,
|
||||
"error": _probe_fallback_error,
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
|
||||
r = client.get(
|
||||
ELEVATION_FALLBACK_URL,
|
||||
params={"latitude": "0.000000", "longitude": "0.000000"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if "elevation" not in data:
|
||||
raise ValueError("response has no elevation field")
|
||||
_probe_checked_at = now
|
||||
_probe_ok = True
|
||||
_probe_error = None
|
||||
logger.info("elevation API ok: %s", ELEVATION_API_URL)
|
||||
_probe_fallback_ok = True
|
||||
_probe_fallback_error = None
|
||||
logger.info("elevation fallback ok: %s", ELEVATION_FALLBACK_URL)
|
||||
except Exception as e:
|
||||
_probe_checked_at = now
|
||||
_probe_ok = False
|
||||
_probe_error = str(e)
|
||||
logger.warning("elevation API unreachable %s: %s", ELEVATION_API_URL, e)
|
||||
_probe_fallback_ok = False
|
||||
_probe_fallback_error = str(e)
|
||||
logger.warning(
|
||||
"elevation fallback unreachable %s: %s", ELEVATION_FALLBACK_URL, e
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": _probe_ok,
|
||||
"url": ELEVATION_API_URL,
|
||||
"error": _probe_error,
|
||||
"ok": _probe_fallback_ok,
|
||||
"url": ELEVATION_FALLBACK_URL,
|
||||
"error": _probe_fallback_error,
|
||||
}
|
||||
|
||||
|
||||
def probe_elevation_api(force: bool = False) -> dict[str, Any]:
|
||||
"""Ping elevation providers before batch requests (cached for TTL)."""
|
||||
global _probe_checked_at
|
||||
|
||||
now = time.monotonic()
|
||||
if (
|
||||
not force
|
||||
and _probe_checked_at > 0
|
||||
and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC
|
||||
):
|
||||
op = {
|
||||
"ok": _probe_opentopo_ok,
|
||||
"url": ELEVATION_OPENTOPO_URL,
|
||||
"error": _probe_opentopo_error,
|
||||
}
|
||||
fb = {
|
||||
"ok": _probe_fallback_ok,
|
||||
"url": ELEVATION_FALLBACK_URL,
|
||||
"error": _probe_fallback_error,
|
||||
}
|
||||
else:
|
||||
op = _probe_opentopodata(force=True)
|
||||
fb = _probe_fallback(force=True)
|
||||
_probe_checked_at = now
|
||||
|
||||
ok = op["ok"] or fb["ok"]
|
||||
if op["ok"]:
|
||||
url = op["url"]
|
||||
error = None
|
||||
elif fb["ok"]:
|
||||
url = fb["url"]
|
||||
error = None
|
||||
else:
|
||||
url = ELEVATION_OPENTOPO_URL
|
||||
error = f"opentopodata: {op['error']}; fallback: {fb['error']}"
|
||||
|
||||
return {
|
||||
"ok": ok,
|
||||
"url": url,
|
||||
"error": error,
|
||||
"opentopodata_ok": op["ok"],
|
||||
"opentopodata_url": op["url"],
|
||||
"opentopodata_error": op["error"],
|
||||
"fallback_ok": fb["ok"],
|
||||
"fallback_url": fb["url"],
|
||||
"fallback_error": fb["error"],
|
||||
}
|
||||
|
||||
|
||||
@@ -91,10 +188,40 @@ def elevation_status(force: bool = False) -> dict[str, Any]:
|
||||
"elevation_ok": probe["ok"],
|
||||
"elevation_url": probe["url"],
|
||||
"elevation_error": probe["error"],
|
||||
"elevation_opentopodata_ok": probe.get("opentopodata_ok"),
|
||||
"elevation_opentopodata_url": probe.get("opentopodata_url"),
|
||||
"elevation_fallback_ok": probe.get("fallback_ok"),
|
||||
"elevation_fallback_url": probe.get("fallback_url"),
|
||||
}
|
||||
|
||||
|
||||
def _fetch_elevation_batch(
|
||||
def _fetch_opentopodata_batch(
|
||||
batch_lat: list[float], batch_lon: list[float]
|
||||
) -> list[Optional[float]]:
|
||||
if not batch_lat:
|
||||
return []
|
||||
locations = "|".join(
|
||||
f"{lat:.6f},{lon:.6f}" for lat, lon in zip(batch_lat, batch_lon)
|
||||
)
|
||||
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
|
||||
r = client.get(ELEVATION_OPENTOPO_URL, params={"locations": locations})
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if data.get("status") != "OK":
|
||||
raise ValueError(f"OpenTopoData status={data.get('status')}")
|
||||
results = data.get("results") or []
|
||||
out: list[Optional[float]] = []
|
||||
for j, item in enumerate(results):
|
||||
if j >= len(batch_lat):
|
||||
break
|
||||
elev = item.get("elevation")
|
||||
out.append(None if elev is None else float(elev))
|
||||
while len(out) < len(batch_lat):
|
||||
out.append(None)
|
||||
return out
|
||||
|
||||
|
||||
def _fetch_fallback_batch(
|
||||
batch_lat: list[float], batch_lon: list[float]
|
||||
) -> list[Optional[float]]:
|
||||
if not batch_lat:
|
||||
@@ -104,7 +231,7 @@ def _fetch_elevation_batch(
|
||||
"longitude": ",".join(f"{lon:.6f}" for lon in batch_lon),
|
||||
}
|
||||
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
|
||||
r = client.get(ELEVATION_API_URL, params=params)
|
||||
r = client.get(ELEVATION_FALLBACK_URL, params=params)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
elevations = data.get("elevation") or []
|
||||
@@ -112,15 +239,81 @@ def _fetch_elevation_batch(
|
||||
for j, elev in enumerate(elevations):
|
||||
if j >= len(batch_lat):
|
||||
break
|
||||
if elev is None:
|
||||
out.append(None)
|
||||
else:
|
||||
out.append(float(elev))
|
||||
out.append(None if elev is None else float(elev))
|
||||
while len(out) < len(batch_lat):
|
||||
out.append(None)
|
||||
return out
|
||||
|
||||
|
||||
def _fetch_batch_with_fallback(
|
||||
batch_lat: list[float], batch_lon: list[float]
|
||||
) -> list[Optional[float]]:
|
||||
global _last_fetch_source
|
||||
|
||||
probe = probe_elevation_api()
|
||||
op_ok = probe.get("opentopodata_ok", False)
|
||||
fb_ok = probe.get("fallback_ok", False)
|
||||
if not op_ok and not fb_ok:
|
||||
return [None] * len(batch_lat)
|
||||
|
||||
out: list[Optional[float]] = [None] * len(batch_lat)
|
||||
used_opentopo = False
|
||||
used_fallback = False
|
||||
|
||||
if op_ok:
|
||||
try:
|
||||
out = _fetch_opentopodata_batch(batch_lat, batch_lon)
|
||||
used_opentopo = any(v is not None for v in out)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"OpenTopoData batch failed (%s points): %s", len(batch_lat), e
|
||||
)
|
||||
out = [None] * len(batch_lat)
|
||||
|
||||
missing_idx = [i for i, v in enumerate(out) if v is None]
|
||||
if missing_idx and fb_ok:
|
||||
miss_lat = [batch_lat[i] for i in missing_idx]
|
||||
miss_lon = [batch_lon[i] for i in missing_idx]
|
||||
try:
|
||||
fb_vals = _fetch_fallback_batch(miss_lat, miss_lon)
|
||||
for j, idx in enumerate(missing_idx):
|
||||
out[idx] = fb_vals[j] if j < len(fb_vals) else None
|
||||
if any(v is not None for v in fb_vals):
|
||||
used_fallback = True
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"elevation fallback batch failed (%s points): %s",
|
||||
len(miss_lat),
|
||||
e,
|
||||
)
|
||||
|
||||
if used_opentopo and used_fallback:
|
||||
_last_fetch_source = "opentopodata+openmeteo"
|
||||
elif used_opentopo:
|
||||
_last_fetch_source = "opentopodata"
|
||||
elif used_fallback:
|
||||
_last_fetch_source = "openmeteo"
|
||||
else:
|
||||
_last_fetch_source = None
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _active_elevation_url() -> str:
|
||||
probe = probe_elevation_api()
|
||||
if probe.get("opentopodata_ok"):
|
||||
return ELEVATION_OPENTOPO_URL
|
||||
if probe.get("fallback_ok"):
|
||||
return ELEVATION_FALLBACK_URL
|
||||
return ELEVATION_OPENTOPO_URL
|
||||
|
||||
|
||||
def _active_api_source() -> str:
|
||||
return _last_fetch_source or (
|
||||
"opentopodata" if probe_elevation_api().get("opentopodata_ok") else "openmeteo"
|
||||
)
|
||||
|
||||
|
||||
def fetch_elevation_m(lat: float, lon: float) -> Optional[float]:
|
||||
vals = fetch_elevations_batch([lat], [lon])
|
||||
return vals[0] if vals else None
|
||||
@@ -135,7 +328,7 @@ def fetch_elevations_batch(
|
||||
probe = probe_elevation_api()
|
||||
if not probe["ok"]:
|
||||
logger.warning(
|
||||
"skip elevation fetch: API unreachable (%s)",
|
||||
"skip elevation fetch: all providers unreachable (%s)",
|
||||
probe.get("error"),
|
||||
)
|
||||
return [None] * len(lats)
|
||||
@@ -159,14 +352,15 @@ def fetch_elevations_batch(
|
||||
batch_lat = pending_lat[start : start + _BATCH_SIZE]
|
||||
batch_lon = pending_lon[start : start + _BATCH_SIZE]
|
||||
try:
|
||||
batch_vals = _fetch_elevation_batch(batch_lat, batch_lon)
|
||||
batch_vals = _fetch_batch_with_fallback(batch_lat, batch_lon)
|
||||
for j, val in enumerate(batch_vals):
|
||||
lat = batch_lat[j]
|
||||
lon = batch_lon[j]
|
||||
_CACHE[_cache_key(lat, lon)] = val
|
||||
out[batch_i[j]] = val
|
||||
logger.info(
|
||||
"elevation ok: %s points, sample=%s",
|
||||
"elevation ok (%s): %s points, sample=%s",
|
||||
_last_fetch_source,
|
||||
len(batch_lat),
|
||||
batch_vals[0] if batch_vals else None,
|
||||
)
|
||||
@@ -178,7 +372,7 @@ def fetch_elevations_batch(
|
||||
)
|
||||
for j in range(len(batch_lat)):
|
||||
try:
|
||||
single = _fetch_elevation_batch(
|
||||
single = _fetch_batch_with_fallback(
|
||||
[batch_lat[j]], [batch_lon[j]]
|
||||
)
|
||||
val = single[0] if single else None
|
||||
@@ -329,7 +523,7 @@ def build_elevation_profile(
|
||||
"total_m": 0.0,
|
||||
"api_source": "elevation",
|
||||
"api_error": f"elevation API unreachable: {probe['error']}",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
"elevation_url": _active_elevation_url(),
|
||||
}
|
||||
|
||||
lats = [s["lat"] for s in samples]
|
||||
@@ -356,8 +550,8 @@ def build_elevation_profile(
|
||||
"min_elevation_m": min(elev_vals) if elev_vals else None,
|
||||
"max_elevation_m": max(elev_vals) if elev_vals else None,
|
||||
"points": profile,
|
||||
"api_source": "elevation",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
"api_source": _active_api_source(),
|
||||
"elevation_url": _active_elevation_url(),
|
||||
}
|
||||
if not elev_vals:
|
||||
result["api_error"] = "elevation API returned no values"
|
||||
@@ -429,7 +623,7 @@ def build_elevation_grid(
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"elevation API unreachable: {probe['error']}",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
"elevation_url": _active_elevation_url(),
|
||||
}
|
||||
|
||||
radius_m = max(50.0, min(float(radius_m), 500.0))
|
||||
@@ -481,8 +675,8 @@ def build_elevation_grid(
|
||||
"points": points,
|
||||
"min_delta_m": round(min(deltas), 1),
|
||||
"max_delta_m": round(max(deltas), 1),
|
||||
"api_source": "elevation",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
"api_source": _active_api_source(),
|
||||
"elevation_url": _active_elevation_url(),
|
||||
}
|
||||
|
||||
|
||||
@@ -499,7 +693,7 @@ def find_nearest_hill(
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"elevation API unreachable: {probe['error']}",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
"elevation_url": _active_elevation_url(),
|
||||
}
|
||||
|
||||
radius_m = max(500.0, min(float(radius_m), 15_000.0))
|
||||
@@ -590,6 +784,6 @@ def find_nearest_hill(
|
||||
"candidates": len(candidates),
|
||||
"radius_m": radius_m,
|
||||
"step_m": step_m,
|
||||
"api_source": "elevation",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
"api_source": _active_api_source(),
|
||||
"elevation_url": _active_elevation_url(),
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ services:
|
||||
environment:
|
||||
LORATESTER_DB: /data/loratester.db
|
||||
LORATESTER_PORT: "7634"
|
||||
LORATESTER_ELEVATION_OPENTOPO_URL: ${LORATESTER_ELEVATION_OPENTOPO_URL:-http://grigowashere.ru:5300/v1/srtm30}
|
||||
LORATESTER_ELEVATION_FALLBACK_URL: ${LORATESTER_ELEVATION_FALLBACK_URL:-http://192.168.1.109:8085/v1/elevation}
|
||||
LORATESTER_ELEVATION_URL: ${LORATESTER_ELEVATION_URL:-http://192.168.1.109:8085/v1/elevation}
|
||||
LORATESTER_ELEVATION_PROBE_TTL: ${LORATESTER_ELEVATION_PROBE_TTL:-60}
|
||||
LORATESTER_ELEVATION_TIMEOUT: ${LORATESTER_ELEVATION_TIMEOUT:-8}
|
||||
|
||||
Binary file not shown.
@@ -23,6 +23,16 @@ class _FakeClient:
|
||||
return False
|
||||
|
||||
def get(self, url, params=None):
|
||||
params = params or {}
|
||||
if "locations" in params:
|
||||
locs = params["locations"].split("|")
|
||||
return _FakeResponse({
|
||||
"status": "OK",
|
||||
"results": [
|
||||
{"elevation": 11.0 + i, "location": {"lat": 0, "lng": 0}}
|
||||
for i, _ in enumerate(locs)
|
||||
],
|
||||
})
|
||||
return _FakeResponse({"elevation": [152.0]})
|
||||
|
||||
|
||||
@@ -34,13 +44,20 @@ def test_probe_elevation_api_ok(monkeypatch):
|
||||
|
||||
assert status["ok"] is True
|
||||
assert status["error"] is None
|
||||
assert status["opentopodata_ok"] is True
|
||||
|
||||
|
||||
def test_fetch_skips_when_unreachable(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"},
|
||||
lambda force=False: {
|
||||
"ok": False,
|
||||
"url": elev.ELEVATION_OPENTOPO_URL,
|
||||
"error": "down",
|
||||
"opentopodata_ok": False,
|
||||
"fallback_ok": False,
|
||||
},
|
||||
)
|
||||
|
||||
vals = elev.fetch_elevations_batch([55.75], [37.62])
|
||||
@@ -52,7 +69,13 @@ def test_build_profile_reports_unreachable(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"},
|
||||
lambda force=False: {
|
||||
"ok": False,
|
||||
"url": elev.ELEVATION_OPENTOPO_URL,
|
||||
"error": "down",
|
||||
"opentopodata_ok": False,
|
||||
"fallback_ok": False,
|
||||
},
|
||||
)
|
||||
|
||||
profile = elev.build_elevation_profile(
|
||||
@@ -76,7 +99,16 @@ def test_resample_track_path_count_even_spacing():
|
||||
|
||||
def test_build_profile_target_points(monkeypatch):
|
||||
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
||||
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {
|
||||
"ok": True,
|
||||
"error": None,
|
||||
"opentopodata_ok": True,
|
||||
"fallback_ok": True,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"fetch_elevations_batch",
|
||||
@@ -96,7 +128,13 @@ def test_find_nearest_hill_unreachable(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"},
|
||||
lambda force=False: {
|
||||
"ok": False,
|
||||
"url": elev.ELEVATION_OPENTOPO_URL,
|
||||
"error": "down",
|
||||
"opentopodata_ok": False,
|
||||
"fallback_ok": False,
|
||||
},
|
||||
)
|
||||
result = elev.find_nearest_hill(55.75, 37.62)
|
||||
assert result["ok"] is False
|
||||
@@ -104,7 +142,16 @@ def test_find_nearest_hill_unreachable(monkeypatch):
|
||||
|
||||
def test_find_nearest_hill_picks_nearest_peak(monkeypatch):
|
||||
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
||||
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {
|
||||
"ok": True,
|
||||
"error": None,
|
||||
"opentopodata_ok": True,
|
||||
"fallback_ok": True,
|
||||
},
|
||||
)
|
||||
|
||||
def fake_batch(lats, lons):
|
||||
out = []
|
||||
@@ -125,7 +172,16 @@ def test_find_nearest_hill_picks_nearest_peak(monkeypatch):
|
||||
|
||||
def test_build_elevation_grid_delta(monkeypatch):
|
||||
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
||||
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {
|
||||
"ok": True,
|
||||
"error": None,
|
||||
"opentopodata_ok": True,
|
||||
"fallback_ok": True,
|
||||
},
|
||||
)
|
||||
|
||||
def fake_batch(lats, lons):
|
||||
return [100.0 + (la - 55.75) * 1000.0 for la, lo in zip(lats, lons)]
|
||||
@@ -143,7 +199,16 @@ def test_build_elevation_grid_delta(monkeypatch):
|
||||
|
||||
def test_build_elevation_grid_fine_step_small_radius(monkeypatch):
|
||||
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
||||
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {
|
||||
"ok": True,
|
||||
"error": None,
|
||||
"opentopodata_ok": True,
|
||||
"fallback_ok": True,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 120.0)
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
@@ -159,7 +224,16 @@ def test_build_elevation_grid_fine_step_small_radius(monkeypatch):
|
||||
|
||||
def test_build_elevation_grid_limits_points(monkeypatch):
|
||||
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
||||
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {
|
||||
"ok": True,
|
||||
"error": None,
|
||||
"opentopodata_ok": True,
|
||||
"fallback_ok": True,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 50.0)
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
@@ -170,3 +244,31 @@ def test_build_elevation_grid_limits_points(monkeypatch):
|
||||
step = elev._resolve_grid_step(55.75, 37.62, 500.0, 5.0)
|
||||
cells = elev._sample_circular_grid(55.75, 37.62, 500.0, step)
|
||||
assert len(cells) <= elev._MAX_GRID_POINTS
|
||||
|
||||
|
||||
def test_fetch_uses_fallback_when_opentopo_missing(monkeypatch):
|
||||
monkeypatch.setattr(elev, "_CACHE", {})
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {
|
||||
"ok": True,
|
||||
"error": None,
|
||||
"opentopodata_ok": True,
|
||||
"fallback_ok": True,
|
||||
},
|
||||
)
|
||||
|
||||
def fake_opentopo(batch_lat, batch_lon):
|
||||
return [None] * len(batch_lat)
|
||||
|
||||
def fake_fallback(batch_lat, batch_lon):
|
||||
return [42.0] * len(batch_lat)
|
||||
|
||||
monkeypatch.setattr(elev, "_fetch_opentopodata_batch", fake_opentopo)
|
||||
monkeypatch.setattr(elev, "_fetch_fallback_batch", fake_fallback)
|
||||
|
||||
vals = elev.fetch_elevations_batch([55.75], [37.62])
|
||||
|
||||
assert vals == [42.0]
|
||||
assert elev._last_fetch_source == "openmeteo"
|
||||
|
||||
Reference in New Issue
Block a user