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
|
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`):
|
Переопределить URL высот (`.env` рядом с `docker-compose.yml`):
|
||||||
|
|
||||||
```env
|
```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` внутри контейнера).
|
БД хранится в volume `loratester-data` (`/data/loratester.db` внутри контейнера).
|
||||||
|
|
||||||
## Деплой (lora.grigowashere.ru)
|
## Деплой (lora.grigowashere.ru)
|
||||||
@@ -63,7 +66,8 @@ docker compose up -d --build
|
|||||||
cd /srv/storage/disk2/services/LoraTester/server
|
cd /srv/storage/disk2/services/LoraTester/server
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
export LORATESTER_DB=/srv/storage/disk2/services/LoraTester/loratester.db
|
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
|
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}/points` — `{points: [{ts, lat, lon, altitude_gps?, rssi?, role?, meta?}]}`
|
||||||
- `POST /api/tracks/{id}/finish`
|
- `POST /api/tracks/{id}/finish`
|
||||||
- `GET /api/tracks?device_id=`
|
- `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/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/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/pending?device_id=` — Android, доставка + `delivered_at`
|
||||||
- `GET /api/commands?to_device_id=&limit=` — история (веб)
|
- `GET /api/commands?to_device_id=&limit=` — история (веб)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+12
-3
@@ -9,10 +9,19 @@ HOST = os.environ.get("LORATESTER_HOST", "0.0.0.0")
|
|||||||
PORT = int(os.environ.get("LORATESTER_PORT", "7634"))
|
PORT = int(os.environ.get("LORATESTER_PORT", "7634"))
|
||||||
TELEMETRY_LIMIT = int(os.environ.get("LORATESTER_TELEMETRY_LIMIT", "5000"))
|
TELEMETRY_LIMIT = int(os.environ.get("LORATESTER_TELEMETRY_LIMIT", "5000"))
|
||||||
TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "10000"))
|
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_URL",
|
"LORATESTER_ELEVATION_OPENTOPO_URL",
|
||||||
"http://192.168.1.109:8085/v1/elevation",
|
"http://grigowashere.ru:5300/v1/srtm30",
|
||||||
).rstrip("/")
|
).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(
|
ELEVATION_PROBE_TTL_SEC = float(
|
||||||
os.environ.get("LORATESTER_ELEVATION_PROBE_TTL", "60")
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -10,8 +10,9 @@ from typing import Any, Optional
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from .config import (
|
from .config import (
|
||||||
ELEVATION_API_URL,
|
|
||||||
ELEVATION_CONNECT_TIMEOUT,
|
ELEVATION_CONNECT_TIMEOUT,
|
||||||
|
ELEVATION_FALLBACK_URL,
|
||||||
|
ELEVATION_OPENTOPO_URL,
|
||||||
ELEVATION_PROBE_TTL_SEC,
|
ELEVATION_PROBE_TTL_SEC,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,8 +22,11 @@ _BATCH_SIZE = 100
|
|||||||
_MAX_PROFILE_POINTS = 500
|
_MAX_PROFILE_POINTS = 500
|
||||||
_CACHE: dict[tuple[float, float], Optional[float]] = {}
|
_CACHE: dict[tuple[float, float], Optional[float]] = {}
|
||||||
_probe_checked_at = 0.0
|
_probe_checked_at = 0.0
|
||||||
_probe_ok = False
|
_probe_opentopo_ok = False
|
||||||
_probe_error: Optional[str] = None
|
_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]:
|
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))
|
return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||||
|
|
||||||
|
|
||||||
def probe_elevation_api(force: bool = False) -> dict[str, Any]:
|
def _probe_opentopodata(force: bool = False) -> dict[str, Any]:
|
||||||
"""Ping elevation service before batch requests (cached for TTL)."""
|
global _probe_checked_at, _probe_opentopo_ok, _probe_opentopo_error
|
||||||
global _probe_checked_at, _probe_ok, _probe_error
|
|
||||||
|
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if (
|
if (
|
||||||
@@ -53,35 +56,129 @@ def probe_elevation_api(force: bool = False) -> dict[str, Any]:
|
|||||||
and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC
|
and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC
|
||||||
):
|
):
|
||||||
return {
|
return {
|
||||||
"ok": _probe_ok,
|
"ok": _probe_opentopo_ok,
|
||||||
"url": ELEVATION_API_URL,
|
"url": ELEVATION_OPENTOPO_URL,
|
||||||
"error": _probe_error,
|
"error": _probe_opentopo_error,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
|
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
|
||||||
r = client.get(
|
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"},
|
params={"latitude": "0.000000", "longitude": "0.000000"},
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
if "elevation" not in data:
|
if "elevation" not in data:
|
||||||
raise ValueError("response has no elevation field")
|
raise ValueError("response has no elevation field")
|
||||||
_probe_checked_at = now
|
_probe_fallback_ok = True
|
||||||
_probe_ok = True
|
_probe_fallback_error = None
|
||||||
_probe_error = None
|
logger.info("elevation fallback ok: %s", ELEVATION_FALLBACK_URL)
|
||||||
logger.info("elevation API ok: %s", ELEVATION_API_URL)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_probe_checked_at = now
|
_probe_fallback_ok = False
|
||||||
_probe_ok = False
|
_probe_fallback_error = str(e)
|
||||||
_probe_error = str(e)
|
logger.warning(
|
||||||
logger.warning("elevation API unreachable %s: %s", ELEVATION_API_URL, e)
|
"elevation fallback unreachable %s: %s", ELEVATION_FALLBACK_URL, e
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ok": _probe_ok,
|
"ok": _probe_fallback_ok,
|
||||||
"url": ELEVATION_API_URL,
|
"url": ELEVATION_FALLBACK_URL,
|
||||||
"error": _probe_error,
|
"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_ok": probe["ok"],
|
||||||
"elevation_url": probe["url"],
|
"elevation_url": probe["url"],
|
||||||
"elevation_error": probe["error"],
|
"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]
|
batch_lat: list[float], batch_lon: list[float]
|
||||||
) -> list[Optional[float]]:
|
) -> list[Optional[float]]:
|
||||||
if not batch_lat:
|
if not batch_lat:
|
||||||
@@ -104,7 +231,7 @@ def _fetch_elevation_batch(
|
|||||||
"longitude": ",".join(f"{lon:.6f}" for lon in batch_lon),
|
"longitude": ",".join(f"{lon:.6f}" for lon in batch_lon),
|
||||||
}
|
}
|
||||||
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
|
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()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
elevations = data.get("elevation") or []
|
elevations = data.get("elevation") or []
|
||||||
@@ -112,15 +239,81 @@ def _fetch_elevation_batch(
|
|||||||
for j, elev in enumerate(elevations):
|
for j, elev in enumerate(elevations):
|
||||||
if j >= len(batch_lat):
|
if j >= len(batch_lat):
|
||||||
break
|
break
|
||||||
if elev is None:
|
out.append(None if elev is None else float(elev))
|
||||||
out.append(None)
|
|
||||||
else:
|
|
||||||
out.append(float(elev))
|
|
||||||
while len(out) < len(batch_lat):
|
while len(out) < len(batch_lat):
|
||||||
out.append(None)
|
out.append(None)
|
||||||
return out
|
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]:
|
def fetch_elevation_m(lat: float, lon: float) -> Optional[float]:
|
||||||
vals = fetch_elevations_batch([lat], [lon])
|
vals = fetch_elevations_batch([lat], [lon])
|
||||||
return vals[0] if vals else None
|
return vals[0] if vals else None
|
||||||
@@ -135,7 +328,7 @@ def fetch_elevations_batch(
|
|||||||
probe = probe_elevation_api()
|
probe = probe_elevation_api()
|
||||||
if not probe["ok"]:
|
if not probe["ok"]:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"skip elevation fetch: API unreachable (%s)",
|
"skip elevation fetch: all providers unreachable (%s)",
|
||||||
probe.get("error"),
|
probe.get("error"),
|
||||||
)
|
)
|
||||||
return [None] * len(lats)
|
return [None] * len(lats)
|
||||||
@@ -159,14 +352,15 @@ def fetch_elevations_batch(
|
|||||||
batch_lat = pending_lat[start : start + _BATCH_SIZE]
|
batch_lat = pending_lat[start : start + _BATCH_SIZE]
|
||||||
batch_lon = pending_lon[start : start + _BATCH_SIZE]
|
batch_lon = pending_lon[start : start + _BATCH_SIZE]
|
||||||
try:
|
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):
|
for j, val in enumerate(batch_vals):
|
||||||
lat = batch_lat[j]
|
lat = batch_lat[j]
|
||||||
lon = batch_lon[j]
|
lon = batch_lon[j]
|
||||||
_CACHE[_cache_key(lat, lon)] = val
|
_CACHE[_cache_key(lat, lon)] = val
|
||||||
out[batch_i[j]] = val
|
out[batch_i[j]] = val
|
||||||
logger.info(
|
logger.info(
|
||||||
"elevation ok: %s points, sample=%s",
|
"elevation ok (%s): %s points, sample=%s",
|
||||||
|
_last_fetch_source,
|
||||||
len(batch_lat),
|
len(batch_lat),
|
||||||
batch_vals[0] if batch_vals else None,
|
batch_vals[0] if batch_vals else None,
|
||||||
)
|
)
|
||||||
@@ -178,7 +372,7 @@ def fetch_elevations_batch(
|
|||||||
)
|
)
|
||||||
for j in range(len(batch_lat)):
|
for j in range(len(batch_lat)):
|
||||||
try:
|
try:
|
||||||
single = _fetch_elevation_batch(
|
single = _fetch_batch_with_fallback(
|
||||||
[batch_lat[j]], [batch_lon[j]]
|
[batch_lat[j]], [batch_lon[j]]
|
||||||
)
|
)
|
||||||
val = single[0] if single else None
|
val = single[0] if single else None
|
||||||
@@ -329,7 +523,7 @@ def build_elevation_profile(
|
|||||||
"total_m": 0.0,
|
"total_m": 0.0,
|
||||||
"api_source": "elevation",
|
"api_source": "elevation",
|
||||||
"api_error": f"elevation API unreachable: {probe['error']}",
|
"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]
|
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,
|
"min_elevation_m": min(elev_vals) if elev_vals else None,
|
||||||
"max_elevation_m": max(elev_vals) if elev_vals else None,
|
"max_elevation_m": max(elev_vals) if elev_vals else None,
|
||||||
"points": profile,
|
"points": profile,
|
||||||
"api_source": "elevation",
|
"api_source": _active_api_source(),
|
||||||
"elevation_url": ELEVATION_API_URL,
|
"elevation_url": _active_elevation_url(),
|
||||||
}
|
}
|
||||||
if not elev_vals:
|
if not elev_vals:
|
||||||
result["api_error"] = "elevation API returned no values"
|
result["api_error"] = "elevation API returned no values"
|
||||||
@@ -429,7 +623,7 @@ def build_elevation_grid(
|
|||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": f"elevation API unreachable: {probe['error']}",
|
"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))
|
radius_m = max(50.0, min(float(radius_m), 500.0))
|
||||||
@@ -481,8 +675,8 @@ def build_elevation_grid(
|
|||||||
"points": points,
|
"points": points,
|
||||||
"min_delta_m": round(min(deltas), 1),
|
"min_delta_m": round(min(deltas), 1),
|
||||||
"max_delta_m": round(max(deltas), 1),
|
"max_delta_m": round(max(deltas), 1),
|
||||||
"api_source": "elevation",
|
"api_source": _active_api_source(),
|
||||||
"elevation_url": ELEVATION_API_URL,
|
"elevation_url": _active_elevation_url(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -499,7 +693,7 @@ def find_nearest_hill(
|
|||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": f"elevation API unreachable: {probe['error']}",
|
"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))
|
radius_m = max(500.0, min(float(radius_m), 15_000.0))
|
||||||
@@ -590,6 +784,6 @@ def find_nearest_hill(
|
|||||||
"candidates": len(candidates),
|
"candidates": len(candidates),
|
||||||
"radius_m": radius_m,
|
"radius_m": radius_m,
|
||||||
"step_m": step_m,
|
"step_m": step_m,
|
||||||
"api_source": "elevation",
|
"api_source": _active_api_source(),
|
||||||
"elevation_url": ELEVATION_API_URL,
|
"elevation_url": _active_elevation_url(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
LORATESTER_DB: /data/loratester.db
|
LORATESTER_DB: /data/loratester.db
|
||||||
LORATESTER_PORT: "7634"
|
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_URL: ${LORATESTER_ELEVATION_URL:-http://192.168.1.109:8085/v1/elevation}
|
||||||
LORATESTER_ELEVATION_PROBE_TTL: ${LORATESTER_ELEVATION_PROBE_TTL:-60}
|
LORATESTER_ELEVATION_PROBE_TTL: ${LORATESTER_ELEVATION_PROBE_TTL:-60}
|
||||||
LORATESTER_ELEVATION_TIMEOUT: ${LORATESTER_ELEVATION_TIMEOUT:-8}
|
LORATESTER_ELEVATION_TIMEOUT: ${LORATESTER_ELEVATION_TIMEOUT:-8}
|
||||||
|
|||||||
Binary file not shown.
@@ -23,6 +23,16 @@ class _FakeClient:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def get(self, url, params=None):
|
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]})
|
return _FakeResponse({"elevation": [152.0]})
|
||||||
|
|
||||||
|
|
||||||
@@ -34,13 +44,20 @@ def test_probe_elevation_api_ok(monkeypatch):
|
|||||||
|
|
||||||
assert status["ok"] is True
|
assert status["ok"] is True
|
||||||
assert status["error"] is None
|
assert status["error"] is None
|
||||||
|
assert status["opentopodata_ok"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_skips_when_unreachable(monkeypatch):
|
def test_fetch_skips_when_unreachable(monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
elev,
|
elev,
|
||||||
"probe_elevation_api",
|
"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])
|
vals = elev.fetch_elevations_batch([55.75], [37.62])
|
||||||
@@ -52,7 +69,13 @@ def test_build_profile_reports_unreachable(monkeypatch):
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
elev,
|
elev,
|
||||||
"probe_elevation_api",
|
"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(
|
profile = elev.build_elevation_profile(
|
||||||
@@ -76,7 +99,16 @@ def test_resample_track_path_count_even_spacing():
|
|||||||
|
|
||||||
def test_build_profile_target_points(monkeypatch):
|
def test_build_profile_target_points(monkeypatch):
|
||||||
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
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(
|
monkeypatch.setattr(
|
||||||
elev,
|
elev,
|
||||||
"fetch_elevations_batch",
|
"fetch_elevations_batch",
|
||||||
@@ -96,7 +128,13 @@ def test_find_nearest_hill_unreachable(monkeypatch):
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
elev,
|
elev,
|
||||||
"probe_elevation_api",
|
"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)
|
result = elev.find_nearest_hill(55.75, 37.62)
|
||||||
assert result["ok"] is False
|
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):
|
def test_find_nearest_hill_picks_nearest_peak(monkeypatch):
|
||||||
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
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):
|
def fake_batch(lats, lons):
|
||||||
out = []
|
out = []
|
||||||
@@ -125,7 +172,16 @@ def test_find_nearest_hill_picks_nearest_peak(monkeypatch):
|
|||||||
|
|
||||||
def test_build_elevation_grid_delta(monkeypatch):
|
def test_build_elevation_grid_delta(monkeypatch):
|
||||||
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
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):
|
def fake_batch(lats, lons):
|
||||||
return [100.0 + (la - 55.75) * 1000.0 for la, lo in zip(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):
|
def test_build_elevation_grid_fine_step_small_radius(monkeypatch):
|
||||||
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
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, "fetch_elevation_m", lambda lat, lon: 120.0)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
elev,
|
elev,
|
||||||
@@ -159,7 +224,16 @@ def test_build_elevation_grid_fine_step_small_radius(monkeypatch):
|
|||||||
|
|
||||||
def test_build_elevation_grid_limits_points(monkeypatch):
|
def test_build_elevation_grid_limits_points(monkeypatch):
|
||||||
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
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, "fetch_elevation_m", lambda lat, lon: 50.0)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
elev,
|
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)
|
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)
|
cells = elev._sample_circular_grid(55.75, 37.62, 500.0, step)
|
||||||
assert len(cells) <= elev._MAX_GRID_POINTS
|
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