From 3d2d69cd56504af44ea54062e6dd3ac048e09345 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 20 May 2026 15:20:35 +0000 Subject: [PATCH] Switch /api/reverse// elevation source from Valhalla to planet-DEM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per OFFROUTE-ARCHITECTURE.md §9 ("planet-dem.pmtiles as single elevation source"). The bundle endpoint previously called Valhalla /height, which only has 48 Idaho HGT tiles; it now reads the planet-scale Terrarium PMTiles that already back the frontend hillshade and contours. - dem.py: add DEMReader.sample_point(lat, lon) — one z12 tile (LRU-cached), Web-Mercator pixel index, None outside the +/-85.05 pole cap or when untiled. - netsyms_api.py: module-level DEMReader singleton (lazy mmap, None if init fails); _reverse_elevation now calls _DEM.sample_point; drop the Valhalla HTTP call and _VALHALLA_HEIGHT_URL. - tests: DEM-mock and DEM-unavailable cases; EXPECTED_KEYS derives from _BUNDLE_KEYS. All 9 tests pass. Verified live: Boise 824m, London 8m, Tokyo 35m, Yosemite 2804m, pole -> None. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/netsyms_api.py | 30 ++++++++++++++++------------ lib/offroute/dem.py | 23 ++++++++++++++++++++- lib/reverse_bundle_test.py | 41 +++++++++++++++++++++++++++++++++++--- 3 files changed, 77 insertions(+), 17 deletions(-) diff --git a/lib/netsyms_api.py b/lib/netsyms_api.py index e530d15..d217eb0 100644 --- a/lib/netsyms_api.py +++ b/lib/netsyms_api.py @@ -17,6 +17,7 @@ from . import netsyms from . import address_book from . import nav_tools from .geocode import PHOTON_URL +from .offroute.dem import DEMReader from .utils import setup_logging logger = setup_logging('recon.netsyms_api') @@ -137,12 +138,11 @@ def api_reverse(): # # Sibling to the query-string /api/reverse above; that route is unchanged. # Every component is sourced from localhost only (Photon, timezones.sqlite, -# in-process landclass/PostGIS, Valhalla). Each lookup is independent: a -# component failure logs a warning and yields null — never a 5xx. +# in-process landclass/PostGIS, planet-DEM PMTiles). Each lookup is +# independent: a component failure logs a warning and yields null — never 5xx. # ───────────────────────────────────────────────────────────────────────── _TZ_DB_PATH = "/mnt/nav/sources/timezones.sqlite" -_VALHALLA_HEIGHT_URL = "http://localhost:8002/height" # Full bundle cache: key=(round(lat,4), round(lon,4)) -> dict. ~10k entries, 24h TTL. _REVERSE_BUNDLE_CACHE = TTLCache(maxsize=10_000, ttl=86_400) @@ -151,6 +151,14 @@ _REVERSE_BUNDLE_LOCK = threading.Lock() _BUNDLE_KEYS = ('name', 'city', 'county', 'state', 'country', 'postal_code', 'timezone', 'landclass', 'elevation_m') +# planet-DEM elevation source (single PMTiles, replaces Valhalla /height). +# Instantiated once at import; the underlying mmap is lazy. None if unavailable. +try: + _DEM = DEMReader() +except Exception as e: # pragma: no cover - depends on PMTiles availability + logger.warning("DEMReader unavailable, elevation will be null: %s", e) + _DEM = None + def _spatialite_blob_to_wkb(blob): """Recover standard WKB from a SpatiaLite geometry BLOB. @@ -224,16 +232,12 @@ def _reverse_landclass(lat, lon): def _reverse_elevation(lat, lon): - """Elevation in metres from local Valhalla /height. None on failure.""" - import requests as http_requests - resp = http_requests.post( - _VALHALLA_HEIGHT_URL, - json={"shape": [{"lat": lat, "lon": lon}]}, - timeout=10, - ) - resp.raise_for_status() - heights = resp.json().get("height", []) - return heights[0] if heights else None + """Elevation in metres from the planet-DEM PMTiles — the single elevation + source per OFFROUTE-ARCHITECTURE.md §9. None on failure, on untiled points + (e.g. true ocean), or if DEMReader could not be initialized at startup.""" + if _DEM is None: + return None + return _DEM.sample_point(lat, lon) @geocode_bp.route('/api/reverse//') diff --git a/lib/offroute/dem.py b/lib/offroute/dem.py index f715611..06cfcea 100644 --- a/lib/offroute/dem.py +++ b/lib/offroute/dem.py @@ -158,7 +158,28 @@ class DEMReader: } return elevation, metadata - + + def sample_point(self, lat: float, lon: float) -> Optional[float]: + """Return elevation in meters at a single point, or None if untiled. + + Reads one z12 Terrarium tile (LRU-cached) and indexes the matching + pixel. Sub-ms warm, ~15 ms cold per tile via NFS. Returns None when the + tile is absent (e.g. true ocean nodata) or lat is outside the + Web-Mercator pole cap (~+/-85.05 deg). + """ + if not -85.05112878 <= lat <= 85.05112878: + return None + n = 2 ** ZOOM_LEVEL + fx = (lon + 180.0) / 360.0 * n + fy = (1.0 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2.0 * n + tx, ty = int(fx), int(fy) + tile = self._decode_tile(ZOOM_LEVEL, tx, ty) + if tile is None: + return None + row = min(TILE_SIZE - 1, int((fy - ty) * TILE_SIZE)) + col = min(TILE_SIZE - 1, int((fx - tx) * TILE_SIZE)) + return float(tile[row, col]) + def pixel_to_latlon(self, row: int, col: int, metadata: dict) -> Tuple[float, float]: """Convert pixel coordinates to lat/lon.""" lat = metadata["origin_lat"] + row * metadata["pixel_size_lat"] diff --git a/lib/reverse_bundle_test.py b/lib/reverse_bundle_test.py index d825b71..6defd9e 100644 --- a/lib/reverse_bundle_test.py +++ b/lib/reverse_bundle_test.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Tests for the /api/reverse// enrichment bundle (lib.netsyms_api). -Photon/Valhalla/landclass are mocked so the suite runs without live services; +Photon/DEM/landclass are mocked so the suite runs without live services; one timezone test exercises the real SpatiaLite DB when it is present. Plain asserts + a __main__ runner, matching the rest of lib/*_test.py. """ @@ -15,8 +15,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from flask import Flask from lib import netsyms_api -EXPECTED_KEYS = {'name', 'city', 'county', 'state', 'country', - 'postal_code', 'timezone', 'landclass', 'elevation_m'} +EXPECTED_KEYS = set(netsyms_api._BUNDLE_KEYS) def _client(): @@ -124,6 +123,40 @@ def test_real_timezone_db(): print(" PASS: real timezones.sqlite point-in-polygon") +def test_elevation_from_dem_reader_mock(): + # elevation_m comes from DEMReader.sample_point (not Valhalla); other + # components stubbed to null so the bundle is hermetic. + _clear_cache() + fake_dem = mock.Mock() + fake_dem.sample_point.return_value = 824 + with mock.patch.object(netsyms_api, '_DEM', fake_dem), \ + mock.patch.object(netsyms_api, '_reverse_photon', return_value={}), \ + mock.patch.object(netsyms_api, '_reverse_timezone', return_value=None), \ + mock.patch.object(netsyms_api, '_reverse_landclass', return_value=None): + resp = _client().get('/api/reverse/43.6150/-116.2023') + assert resp.status_code == 200, resp.status_code + data = resp.get_json() + assert set(data.keys()) == EXPECTED_KEYS + assert data['elevation_m'] == 824, data['elevation_m'] + fake_dem.sample_point.assert_called_once() + print(" PASS: elevation_m sourced from DEMReader.sample_point") + + +def test_elevation_dem_unavailable(): + # DEMReader failed to init at startup (_DEM is None) -> elevation_m null, 200. + _clear_cache() + with mock.patch.object(netsyms_api, '_DEM', None), \ + mock.patch.object(netsyms_api, '_reverse_photon', return_value={}), \ + mock.patch.object(netsyms_api, '_reverse_timezone', return_value=None), \ + mock.patch.object(netsyms_api, '_reverse_landclass', return_value=None): + resp = _client().get('/api/reverse/43.6150/-116.2023') + assert resp.status_code == 200, resp.status_code + data = resp.get_json() + assert set(data.keys()) == EXPECTED_KEYS + assert data['elevation_m'] is None + print(" PASS: DEMReader unavailable -> elevation_m null, still 200") + + if __name__ == '__main__': print("Running reverse-bundle tests...") test_happy_path() @@ -133,4 +166,6 @@ if __name__ == '__main__': test_invalid_input_400() test_cache_hit_serves_without_recompute() test_real_timezone_db() + test_elevation_from_dem_reader_mock() + test_elevation_dem_unavailable() print("All tests passed.")