diff --git a/server/README.md b/server/README.md index 316713c..945401e 100644 --- a/server/README.md +++ b/server/README.md @@ -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=` — история (веб) diff --git a/server/core/__pycache__/config.cpython-313.pyc b/server/core/__pycache__/config.cpython-313.pyc index 707a869..e8ebb66 100644 Binary files a/server/core/__pycache__/config.cpython-313.pyc and b/server/core/__pycache__/config.cpython-313.pyc differ diff --git a/server/core/__pycache__/elevation.cpython-313.pyc b/server/core/__pycache__/elevation.cpython-313.pyc index 5d547ac..9e88231 100644 Binary files a/server/core/__pycache__/elevation.cpython-313.pyc and b/server/core/__pycache__/elevation.cpython-313.pyc differ diff --git a/server/core/config.py b/server/core/config.py index ed1142b..c2307cc 100644 --- a/server/core/config.py +++ b/server/core/config.py @@ -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( - "LORATESTER_ELEVATION_URL", - "http://192.168.1.109:8085/v1/elevation", +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") ) diff --git a/server/core/elevation.py b/server/core/elevation.py index 4b2bb91..13d5b23 100644 --- a/server/core/elevation.py +++ b/server/core/elevation.py @@ -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(), } diff --git a/server/docker-compose.yml b/server/docker-compose.yml index be5dafe..6447493 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -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} diff --git a/server/tests/__pycache__/test_elevation.cpython-313-pytest-9.0.3.pyc b/server/tests/__pycache__/test_elevation.cpython-313-pytest-9.0.3.pyc index 1959b8a..0cda348 100644 Binary files a/server/tests/__pycache__/test_elevation.cpython-313-pytest-9.0.3.pyc and b/server/tests/__pycache__/test_elevation.cpython-313-pytest-9.0.3.pyc differ diff --git a/server/tests/test_elevation.py b/server/tests/test_elevation.py index a1ee76e..0d9c9cc 100644 --- a/server/tests/test_elevation.py +++ b/server/tests/test_elevation.py @@ -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"