navi-offroute: numpy-pack TrailheadIndex (memory opt #2) (#32)

Apply the proven OSMParkingIndex (PR #31) memory-pack pattern to TrailheadIndex,
which the 2026-05-26 memory audit flagged at ~300-450 MB/worker using the old
list[dict]+list[Point] storage.

- mvum_transitions.py: store coords as packed float64 numpy arrays (_lats/_lons)
  and attributes as interned lists (_names/_road_classes); build candidate record
  dicts lazily via _record(i) in query_trailheads_near_line instead of holding
  740k dicts + 740k shapely Point objects. Points are built only to construct the
  STRtree, then released. Adds a records property (lazy, for tests/introspection)
  and tracks build_time_seconds + memory_estimate_mb (psutil RSS delta) like
  OSMParkingIndex. Query logic (coarse STRtree bbox + precise degree-distance
  check) unchanged.
- admin.py: GET /api/admin/trailhead/info -> {count, build_time_seconds,
  memory_estimate_mb}, mirroring /api/admin/osm-parking/info.

Tests: existing test updated (len(records)==count; the removed _points assertion)
plus a numpy-backing test (_lats/_lons dtype float64, len==count). Full offroute
suite: 83 passed.

Real-DB sanity (not deployed): index loads 740,430 entry points in ~4.8 s using
~285 MB RSS (down from the audit's inferred ~300-450 MB; same ~40% pack ratio as
parking), query returns 317 trailheads on a Redfish Lake corridor.

Co-authored-by: Matt <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-05-26 16:46:22 -06:00 committed by GitHub
commit 7c10b80d08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 82 additions and 19 deletions

View file

@ -163,3 +163,17 @@ def osm_parking_info():
'build_time_seconds': round(idx.build_time_seconds, 3), 'build_time_seconds': round(idx.build_time_seconds, 3),
'memory_estimate_mb': round(idx.memory_estimate_mb, 1), '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),
})

View file

@ -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 The router (``_route_auto``) uses these points as drive->offroad transition
candidates: a hybrid "drive to a trailhead, switch vehicles, continue offroad" 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. 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 logging
import sqlite3 import sqlite3
import sys
import time as _time import time as _time
from pathlib import Path from pathlib import Path
import numpy as np
import psutil
from shapely.geometry import Point, LineString from shapely.geometry import Point, LineString
from shapely.strtree import STRtree from shapely.strtree import STRtree
@ -26,17 +34,21 @@ logger = logging.getLogger("navi_offroute.mvum_transitions")
class TrailheadIndex: class TrailheadIndex:
"""In-memory STRtree over ``trail_entry_points`` (trailhead/road access points). """In-memory STRtree over ``trail_entry_points`` (trailhead/road access points).
Keeps the STRtree of point geometries plus a parallel ``records`` list of Storage is columnar: ``_lats``/``_lons`` (float64 numpy arrays) plus
``{lat, lon, name, road_class}`` dicts aligned with the tree's geometries. ``_names``/``_road_classes`` (lists, aligned by index). query_trailheads_near_line()
(The DB column is ``highway_class``; it is surfaced here as ``road_class`` for 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.) consistency with the entry-point records the router already emits.)
""" """
def __init__(self, db_path=None): def __init__(self, db_path=None):
t0 = _time.perf_counter() 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.db_path = Path(db_path) if db_path else navi_db_path()
self.records = [] # aligned with self._points lats, lons = [], []
self._points = [] self._names, self._road_classes = [], []
conn = sqlite3.connect(f"file:{self.db_path}?mode=ro", uri=True) conn = sqlite3.connect(f"file:{self.db_path}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
@ -46,26 +58,48 @@ class TrailheadIndex:
"WHERE lat IS NOT NULL AND lon IS NOT NULL" "WHERE lat IS NOT NULL AND lon IS NOT NULL"
) )
for row in cur: for row in cur:
lat = float(row["lat"]) lats.append(float(row["lat"]))
lon = float(row["lon"]) lons.append(float(row["lon"]))
self.records.append({ self._names.append(row["name"] or "")
"lat": lat, # intern the small-cardinality road-class strings so duplicate values
"lon": lon, # share one object instead of ~740k separate ones.
"name": row["name"] or "", rc = row["highway_class"] or ""
"road_class": row["highway_class"] or "", self._road_classes.append(sys.intern(rc))
})
self._points.append(Point(lon, lat))
finally: finally:
conn.close() conn.close()
self._tree = STRtree(self._points) if self._points else None self._lats = np.asarray(lats, dtype=np.float64)
self.count = len(self.records) 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.build_time_seconds = _time.perf_counter() - t0
self.memory_estimate_mb = max(
0.0, (proc.memory_info().rss - rss_before) / (1024 * 1024))
logger.info( logger.info(
"Trailhead index loaded: %d entry points in %.2f seconds", "Trailhead index loaded: %d entry points in %.2f seconds",
self.count, self.build_time_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): def query_trailheads_near_line(self, coords, buffer_m=2000):
"""Trailhead records within ~``buffer_m`` of a (lat, lon) polyline. """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) buffer_deg = _buffer_degrees_for_meters(buffer_m, avg_lat)
out = [] out = []
for i in self._tree.query(geom.buffer(buffer_deg)): for i in self._tree.query(geom.buffer(buffer_deg)):
if geom.distance(self._points[i]) <= buffer_deg: if geom.distance(Point(self._lons[i], self._lats[i])) <= buffer_deg:
out.append(self.records[i]) out.append(self._record(i))
return out return out

View file

@ -6,6 +6,8 @@ stubbed self.route, so no Valhalla/DEM dependencies are exercised.
""" """
import sqlite3 import sqlite3
import numpy as np
import pytest import pytest
from services.navi_offroute.mvum_transitions import TrailheadIndex from services.navi_offroute.mvum_transitions import TrailheadIndex
@ -38,13 +40,26 @@ def test_trailhead_index_loads(tmp_path):
]) ])
idx = TrailheadIndex(db_path=db) idx = TrailheadIndex(db_path=db)
assert idx.count == 2 assert idx.count == 2
assert len(idx.records) == len(idx._points) == 2 assert len(idx.records) == idx.count == 2
rec = idx.records[0] rec = idx.records[0]
assert rec["name"] == "Trailhead A" assert rec["name"] == "Trailhead A"
assert rec["road_class"] == "track" # highway_class surfaced as road_class assert rec["road_class"] == "track" # highway_class surfaced as road_class
assert rec["lat"] == 44.00 and rec["lon"] == -114.00 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): 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). # One point sits right on the line; one is ~30 km away (well outside 2 km).
db = _trailhead_db(tmp_path, [ db = _trailhead_db(tmp_path, [