Compare commits

..

4 commits

Author SHA1 Message Date
dc7591b101
Merge PR #3: landclass: filter antimeridian-wrapping PAD-US records
landclass: filter antimeridian-wrapping PAD-US records
2026-05-20 10:41:46 -06:00
484dfbd1e0 landclass: filter antimeridian-wrapping PAD-US records
47 PAD-US units (Aleutian/Bering-Sea BOEM marine features, all is_valid=False)
are stored as antimeridian-wrapping polygons whose bbox spans ~360 deg of
longitude. Their invalid planar geometry forms latitude bands that ST_Intersects
false-matches for non-US points (e.g. London/Germany at ~51N matched
"Rat Islands" ogc_fid 3974).

Fix: add `AND (ST_XMax(geom) - ST_XMin(geom)) < 60` to the lookup_landclass
SELECT. No DB writes; two cheap ST_XMax/XMin evals on the already
spatial-index-filtered result set. Verified live: total 651088 rows,
filtered 651041 (exactly 47 excluded); Yosemite/Grand Canyon retained,
London/Germany now empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:34:14 +00:00
573347a2ee
Merge PR #2: Switch /api/reverse/<lat>/<lon> elevation source from Valhalla to planet-DEM
Switch /api/reverse/<lat>/<lon> elevation source from Valhalla to planet-DEM
2026-05-20 09:34:15 -06:00
3d2d69cd56 Switch /api/reverse/<lat>/<lon> elevation source from Valhalla to planet-DEM
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) <noreply@anthropic.com>
2026-05-20 15:20:35 +00:00
5 changed files with 124 additions and 17 deletions

View file

@ -214,6 +214,9 @@ def lookup_landclass(lat, lon):
des_tp, gap_sts, pub_access, category, gis_acres, state_nm des_tp, gap_sts, pub_access, category, gis_acres, state_nm
FROM pad_units FROM pad_units
WHERE ST_Intersects(geom, ST_SetSRID(ST_MakePoint(%s, %s), 4326)) WHERE ST_Intersects(geom, ST_SetSRID(ST_MakePoint(%s, %s), 4326))
-- exclude antimeridian-wrapping polygons: 47 BOEM marine artifacts
-- span ~360 deg longitude and false-match non-US points at their lat band
AND (ST_XMax(geom) - ST_XMin(geom)) < 60
ORDER BY gis_acres ASC ORDER BY gis_acres ASC
LIMIT 10""", LIMIT 10""",
(lon, lat) (lon, lat)

44
lib/landclass_test.py Normal file
View file

@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Tests for lib.landclass PAD-US lookups.
Live-PostgreSQL regression test using the skip-if-not-available pattern
(matching test_real_timezone_db in reverse_bundle_test.py). Plain asserts +
a __main__ runner, matching the rest of lib/*_test.py.
Note: lookup_landclass swallows DB errors and returns [] (it never raises),
so PG availability is probed via a known US point (Boise) rather than by
catching an exception.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from lib import landclass
def test_landclass_no_antimeridian_false_match():
# Yosemite doubles as the liveness probe: a point on real US public land.
# (lookup_landclass returns [] when PG is unreachable AND when the point is
# off public land, so the probe must be a known-public-land point — e.g.
# downtown Boise is private and would yield [] even with PG up.)
yosemite = landclass.lookup_landclass(37.85, -119.55)
if not yosemite:
print(" SKIP: live PG not available (Yosemite returned no rows)")
return
# Filter must NOT drop legitimate (non-wrapping) US units.
assert len(yosemite) >= 1, f"Yosemite should match >=1 PAD-US unit, got {len(yosemite)}"
# London (51.5074 N) previously false-matched the antimeridian-wrapping
# 'Rat Islands' record (ogc_fid 3974, ~360 deg lon span). The < 60 deg
# filter must now drop it -> empty result.
london = landclass.lookup_landclass(51.5074, -0.1278)
assert london == [], f"London should match no PAD-US unit, got {[r.get('unit_name') for r in london]}"
print(" PASS: antimeridian filter drops London false-match, keeps Yosemite coverage")
if __name__ == '__main__':
print("Running landclass tests...")
test_landclass_no_antimeridian_false_match()
print("All tests passed.")

View file

@ -17,6 +17,7 @@ from . import netsyms
from . import address_book from . import address_book
from . import nav_tools from . import nav_tools
from .geocode import PHOTON_URL from .geocode import PHOTON_URL
from .offroute.dem import DEMReader
from .utils import setup_logging from .utils import setup_logging
logger = setup_logging('recon.netsyms_api') 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. # Sibling to the query-string /api/reverse above; that route is unchanged.
# Every component is sourced from localhost only (Photon, timezones.sqlite, # Every component is sourced from localhost only (Photon, timezones.sqlite,
# in-process landclass/PostGIS, Valhalla). Each lookup is independent: a # in-process landclass/PostGIS, planet-DEM PMTiles). Each lookup is
# component failure logs a warning and yields null — never a 5xx. # independent: a component failure logs a warning and yields null — never 5xx.
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
_TZ_DB_PATH = "/mnt/nav/sources/timezones.sqlite" _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. # 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) _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', _BUNDLE_KEYS = ('name', 'city', 'county', 'state', 'country',
'postal_code', 'timezone', 'landclass', 'elevation_m') '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): def _spatialite_blob_to_wkb(blob):
"""Recover standard WKB from a SpatiaLite geometry BLOB. """Recover standard WKB from a SpatiaLite geometry BLOB.
@ -224,16 +232,12 @@ def _reverse_landclass(lat, lon):
def _reverse_elevation(lat, lon): def _reverse_elevation(lat, lon):
"""Elevation in metres from local Valhalla /height. None on failure.""" """Elevation in metres from the planet-DEM PMTiles — the single elevation
import requests as http_requests source per OFFROUTE-ARCHITECTURE.md §9. None on failure, on untiled points
resp = http_requests.post( (e.g. true ocean), or if DEMReader could not be initialized at startup."""
_VALHALLA_HEIGHT_URL, if _DEM is None:
json={"shape": [{"lat": lat, "lon": lon}]}, return None
timeout=10, return _DEM.sample_point(lat, lon)
)
resp.raise_for_status()
heights = resp.json().get("height", [])
return heights[0] if heights else None
@geocode_bp.route('/api/reverse/<lat>/<lon>') @geocode_bp.route('/api/reverse/<lat>/<lon>')

View file

@ -158,7 +158,28 @@ class DEMReader:
} }
return elevation, metadata 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]: def pixel_to_latlon(self, row: int, col: int, metadata: dict) -> Tuple[float, float]:
"""Convert pixel coordinates to lat/lon.""" """Convert pixel coordinates to lat/lon."""
lat = metadata["origin_lat"] + row * metadata["pixel_size_lat"] lat = metadata["origin_lat"] + row * metadata["pixel_size_lat"]

View file

@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Tests for the /api/reverse/<lat>/<lon> enrichment bundle (lib.netsyms_api). """Tests for the /api/reverse/<lat>/<lon> 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 one timezone test exercises the real SpatiaLite DB when it is present. Plain
asserts + a __main__ runner, matching the rest of lib/*_test.py. 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 flask import Flask
from lib import netsyms_api from lib import netsyms_api
EXPECTED_KEYS = {'name', 'city', 'county', 'state', 'country', EXPECTED_KEYS = set(netsyms_api._BUNDLE_KEYS)
'postal_code', 'timezone', 'landclass', 'elevation_m'}
def _client(): def _client():
@ -124,6 +123,40 @@ def test_real_timezone_db():
print(" PASS: real timezones.sqlite point-in-polygon") 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__': if __name__ == '__main__':
print("Running reverse-bundle tests...") print("Running reverse-bundle tests...")
test_happy_path() test_happy_path()
@ -133,4 +166,6 @@ if __name__ == '__main__':
test_invalid_input_400() test_invalid_input_400()
test_cache_hit_serves_without_recompute() test_cache_hit_serves_without_recompute()
test_real_timezone_db() test_real_timezone_db()
test_elevation_from_dem_reader_mock()
test_elevation_dem_unavailable()
print("All tests passed.") print("All tests passed.")