diff --git a/backend/services/navi_offroute/admin.py b/backend/services/navi_offroute/admin.py index 4e0dc8e..6f76f52 100644 --- a/backend/services/navi_offroute/admin.py +++ b/backend/services/navi_offroute/admin.py @@ -163,3 +163,17 @@ def osm_parking_info(): 'build_time_seconds': round(idx.build_time_seconds, 3), 'memory_estimate_mb': round(idx.memory_estimate_mb, 1), }) + + +@bp.route('/api/admin/trailhead/info') +@require_auth +def trailhead_info(): + """Read-only stats for the in-memory trailhead index (Layer 3a).""" + idx = current_app.config.get('MVUM_TRAILHEAD_INDEX') + if idx is None: + return jsonify({'status': 'error', 'message': 'trailhead index not loaded'}), 503 + return jsonify({ + 'count': idx.count, + 'build_time_seconds': round(idx.build_time_seconds, 3), + 'memory_estimate_mb': round(idx.memory_estimate_mb, 1), + }) diff --git a/backend/services/navi_offroute/mvum_transitions.py b/backend/services/navi_offroute/mvum_transitions.py index f1544bf..2fdad7f 100644 --- a/backend/services/navi_offroute/mvum_transitions.py +++ b/backend/services/navi_offroute/mvum_transitions.py @@ -9,12 +9,20 @@ is pure spatial lookup — no routing logic — mirroring the MVUMSpatialIndex The router (``_route_auto``) uses these points as drive->offroad transition candidates: a hybrid "drive to a trailhead, switch vehicles, continue offroad" plan is considered when it beats the single-mode winner by a comfortable margin. + +Coordinates are stored in packed numpy arrays and the attribute columns as plain +(interned) lists; the shapely Point objects exist only long enough to build the +STRtree and are then released. Record dicts are reconstructed lazily in +query_trailheads_near_line — same memory-pack pattern as OSMParkingIndex. """ import logging import sqlite3 +import sys import time as _time from pathlib import Path +import numpy as np +import psutil from shapely.geometry import Point, LineString from shapely.strtree import STRtree @@ -26,17 +34,21 @@ logger = logging.getLogger("navi_offroute.mvum_transitions") class TrailheadIndex: """In-memory STRtree over ``trail_entry_points`` (trailhead/road access points). - Keeps the STRtree of point geometries plus a parallel ``records`` list of - ``{lat, lon, name, road_class}`` dicts aligned with the tree's geometries. - (The DB column is ``highway_class``; it is surfaced here as ``road_class`` for + Storage is columnar: ``_lats``/``_lons`` (float64 numpy arrays) plus + ``_names``/``_road_classes`` (lists, aligned by index). query_trailheads_near_line() + builds the ``{lat, lon, name, road_class}`` record dicts lazily from these columns. + (The DB column is ``highway_class``; it is surfaced as ``road_class`` for consistency with the entry-point records the router already emits.) """ def __init__(self, db_path=None): t0 = _time.perf_counter() + proc = psutil.Process() + rss_before = proc.memory_info().rss + self.db_path = Path(db_path) if db_path else navi_db_path() - self.records = [] # aligned with self._points - self._points = [] + lats, lons = [], [] + self._names, self._road_classes = [], [] conn = sqlite3.connect(f"file:{self.db_path}?mode=ro", uri=True) conn.row_factory = sqlite3.Row @@ -46,26 +58,48 @@ class TrailheadIndex: "WHERE lat IS NOT NULL AND lon IS NOT NULL" ) for row in cur: - lat = float(row["lat"]) - lon = float(row["lon"]) - self.records.append({ - "lat": lat, - "lon": lon, - "name": row["name"] or "", - "road_class": row["highway_class"] or "", - }) - self._points.append(Point(lon, lat)) + lats.append(float(row["lat"])) + lons.append(float(row["lon"])) + self._names.append(row["name"] or "") + # intern the small-cardinality road-class strings so duplicate values + # share one object instead of ~740k separate ones. + rc = row["highway_class"] or "" + self._road_classes.append(sys.intern(rc)) finally: conn.close() - self._tree = STRtree(self._points) if self._points else None - self.count = len(self.records) + self._lats = np.asarray(lats, dtype=np.float64) + self._lons = np.asarray(lons, dtype=np.float64) + + # Build the STRtree from transient Point objects, then release them; the tree + # internalizes its own geometry storage and we reconstruct points on demand. + points = [Point(lon, lat) for lon, lat in zip(lons, lats)] + self._tree = STRtree(points) if points else None + del points + + self.count = len(self._lats) self.build_time_seconds = _time.perf_counter() - t0 + self.memory_estimate_mb = max( + 0.0, (proc.memory_info().rss - rss_before) / (1024 * 1024)) logger.info( "Trailhead index loaded: %d entry points in %.2f seconds", self.count, self.build_time_seconds, ) + def _record(self, i): + """Construct a trailhead record dict for column index ``i``.""" + return { + "lat": float(self._lats[i]), + "lon": float(self._lons[i]), + "name": self._names[i], + "road_class": self._road_classes[i], + } + + @property + def records(self): + """All records, built lazily (used by tests / introspection — not the hot path).""" + return [self._record(i) for i in range(self.count)] + def query_trailheads_near_line(self, coords, buffer_m=2000): """Trailhead records within ~``buffer_m`` of a (lat, lon) polyline. @@ -81,8 +115,8 @@ class TrailheadIndex: buffer_deg = _buffer_degrees_for_meters(buffer_m, avg_lat) out = [] for i in self._tree.query(geom.buffer(buffer_deg)): - if geom.distance(self._points[i]) <= buffer_deg: - out.append(self.records[i]) + if geom.distance(Point(self._lons[i], self._lats[i])) <= buffer_deg: + out.append(self._record(i)) return out diff --git a/backend/services/navi_offroute/tests/test_mvum_transitions.py b/backend/services/navi_offroute/tests/test_mvum_transitions.py index abc8809..9fbba4b 100644 --- a/backend/services/navi_offroute/tests/test_mvum_transitions.py +++ b/backend/services/navi_offroute/tests/test_mvum_transitions.py @@ -6,6 +6,8 @@ stubbed self.route, so no Valhalla/DEM dependencies are exercised. """ import sqlite3 +import numpy as np + import pytest from services.navi_offroute.mvum_transitions import TrailheadIndex @@ -38,13 +40,26 @@ def test_trailhead_index_loads(tmp_path): ]) idx = TrailheadIndex(db_path=db) assert idx.count == 2 - assert len(idx.records) == len(idx._points) == 2 + assert len(idx.records) == idx.count == 2 rec = idx.records[0] assert rec["name"] == "Trailhead A" assert rec["road_class"] == "track" # highway_class surfaced as road_class assert rec["lat"] == 44.00 and rec["lon"] == -114.00 +def test_trailhead_index_numpy_backing(tmp_path): + db = _trailhead_db(tmp_path, [ + (44.00, -114.00, "track", "A"), + (44.01, -114.02, "residential", "B"), + (44.02, -114.03, "path", "C"), + ]) + idx = TrailheadIndex(db_path=db) + assert idx._lats.dtype == np.float64 + assert idx._lons.dtype == np.float64 + assert len(idx._lats) == len(idx._lons) == idx.count == 3 + assert idx._lats[1] == 44.01 and idx._lons[1] == -114.02 + + def test_query_trailheads_near_line_returns_close_only(tmp_path): # One point sits right on the line; one is ~30 km away (well outside 2 km). db = _trailhead_db(tmp_path, [