mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-08-26 17:31:37 +00:00
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:
parent
f9f2eb9b8f
commit
7c10b80d08
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),
|
'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),
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue