mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-08-26 17:31:37 +00:00
navi-offroute: numpy-pack TrailheadIndex (memory opt #2)
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: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f9f2eb9b8f
commit
e65c5b15f8
3 changed files with 82 additions and 19 deletions
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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, [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue