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),
'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
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

View file

@ -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, [