added opentopo

This commit is contained in:
2026-06-17 13:03:11 +03:00
parent f4ef87705c
commit 4891933879
8 changed files with 369 additions and 58 deletions
+10 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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(),
}
+2
View File
@@ -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}
+110 -8
View File
@@ -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"