mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-08-26 17:31:37 +00:00
MVUM Layer 0: spatial index foundation (#22)
* MVUM Layer 0: spatial index foundation Additive only — no routing logic, no response-format, no Valhalla changes. - mvum.py: add MVUMSpatialIndex. Loads mvum_roads + mvum_trails from navi.db (read-only), decodes the pure-WKB shape blobs with shapely, builds a shapely.strtree.STRtree, and keeps a parallel list of full feature records (all columns minus the blob, plus the parsed geometry) with a by_id lookup. Exposes query_bbox(min_lat,min_lon,max_lat,max_lon) and query_buffered_line(coords, tolerance_m) returning candidate records (coarse bbox + buffer; full parallelism filter is a TODO for PR-B). Reports road_count, trail_count, bbox, build_time_seconds, memory_estimate_mb (RSS delta). - app.py: build the index once per process (singleton) at service init; stored on app.config[MVUM_SPATIAL_INDEX]. Failure is logged and degrades to None. - admin.py: GET /api/admin/mvum-spatial/info (Authentik-gated, read-only) returning the counts/bbox/build-time/memory stats. - tests/test_mvum_spatial.py: index loads, query_bbox returns Boise-area features, query_buffered_line returns a feature, admin endpoint returns counts. Diagnostic before coding (read-only): roads_with_shape=150568/null=68, trails_with_shape=21995/null=6746 (green), shape blobs are pure WKB MultiLineString. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix lat-aware buffer + count WKB parse failures - query_buffered_line: replace the static _DEG_PER_M with _buffer_degrees_for_meters(), which scales longitude degrees by cos(lat) and uses the larger lat/lon equivalent; buffer at the polyline avg latitude. Early-return [] for empty coords. - _load_table: count WKB parse failures and logger.warning once per table when > 0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- 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
a1785127de
commit
5e83a6e63a
4 changed files with 204 additions and 1 deletions
|
|
@ -133,3 +133,19 @@ def navi_offroute_info():
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return jsonify(info)
|
return jsonify(info)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/admin/mvum-spatial/info')
|
||||||
|
@require_auth
|
||||||
|
def mvum_spatial_info():
|
||||||
|
"""Read-only stats for the in-memory MVUM spatial index (Layer 0)."""
|
||||||
|
idx = current_app.config.get('MVUM_SPATIAL_INDEX')
|
||||||
|
if idx is None:
|
||||||
|
return jsonify({'status': 'error', 'message': 'MVUM spatial index not loaded'}), 503
|
||||||
|
return jsonify({
|
||||||
|
'road_count': idx.road_count,
|
||||||
|
'trail_count': idx.trail_count,
|
||||||
|
'bbox': idx.bbox,
|
||||||
|
'build_time_seconds': round(idx.build_time_seconds, 3),
|
||||||
|
'memory_estimate_mb': round(idx.memory_estimate_mb, 1),
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,19 @@ from flask import Flask
|
||||||
from shared.git_sha import git_short_sha
|
from shared.git_sha import git_short_sha
|
||||||
|
|
||||||
from . import offroute_route, admin
|
from . import offroute_route, admin
|
||||||
|
from .mvum import MVUMSpatialIndex
|
||||||
|
|
||||||
|
# Process-wide singleton: build the MVUM spatial index once per process (per gunicorn
|
||||||
|
# worker in prod; once across create_app() calls in tests), not once per app instance.
|
||||||
|
_MVUM_INDEX = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_mvum_index():
|
||||||
|
global _MVUM_INDEX
|
||||||
|
if _MVUM_INDEX is None:
|
||||||
|
_MVUM_INDEX = MVUMSpatialIndex()
|
||||||
|
return _MVUM_INDEX
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
|
|
@ -34,6 +47,13 @@ def create_app():
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
# Load the MVUM spatial index once at service init (logs its own load line).
|
||||||
|
try:
|
||||||
|
app.config['MVUM_SPATIAL_INDEX'] = _get_mvum_index()
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.warning("MVUM spatial index failed to load: %s", e)
|
||||||
|
app.config['MVUM_SPATIAL_INDEX'] = None
|
||||||
|
|
||||||
app.register_blueprint(offroute_route.bp)
|
app.register_blueprint(offroute_route.bp)
|
||||||
app.register_blueprint(admin.bp)
|
app.register_blueprint(admin.bp)
|
||||||
return app
|
return app
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,22 @@ indicating which roads/trails are open or closed to specific vehicle modes.
|
||||||
|
|
||||||
MVUM is motor-vehicle specific — foot mode should skip this layer entirely.
|
MVUM is motor-vehicle specific — foot mode should skip this layer entirely.
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import time as _time
|
||||||
import warnings
|
import warnings
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Tuple, Literal
|
from typing import Dict, List, Optional, Tuple, Literal
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import psutil
|
||||||
from shapely import wkb
|
from shapely import wkb
|
||||||
from shapely.geometry import Point
|
from shapely.geometry import Point, LineString, box
|
||||||
|
from shapely.strtree import STRtree
|
||||||
|
|
||||||
# Path to navi.db (single source of truth); env-overridable.
|
# Path to navi.db (single source of truth); env-overridable.
|
||||||
DEFAULT_NAVI_DB_PATH = Path("/mnt/nav/navi.db")
|
DEFAULT_NAVI_DB_PATH = Path("/mnt/nav/navi.db")
|
||||||
|
|
@ -27,6 +32,119 @@ def navi_db_path() -> Path:
|
||||||
return Path(os.environ.get("NAVI_OFFROUTE_NAVI_DB", str(DEFAULT_NAVI_DB_PATH)))
|
return Path(os.environ.get("NAVI_OFFROUTE_NAVI_DB", str(DEFAULT_NAVI_DB_PATH)))
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("navi_offroute.mvum_spatial")
|
||||||
|
|
||||||
|
def _buffer_degrees_for_meters(meters: float, lat: float) -> float:
|
||||||
|
"""Approximate buffer radius in degrees for a metre tolerance at a given latitude.
|
||||||
|
Longitude degrees shrink with cos(lat); use the larger of the lat/lon equivalents so
|
||||||
|
the bbox-coarse buffer stays conservative."""
|
||||||
|
cos_lat = max(math.cos(math.radians(lat)), 0.01)
|
||||||
|
lat_deg = meters / 111320.0
|
||||||
|
lon_deg = meters / (111320.0 * cos_lat)
|
||||||
|
return max(lat_deg, lon_deg)
|
||||||
|
|
||||||
|
|
||||||
|
class MVUMSpatialIndex:
|
||||||
|
"""In-memory STRtree over MVUM road + trail geometries from navi.db.
|
||||||
|
|
||||||
|
Layer 0 of the MVUM/Valhalla spatial-join work: pure spatial lookup. It parses
|
||||||
|
each row's WKB ``shape`` blob with shapely and indexes it in an STRtree, keeping a
|
||||||
|
parallel list of full feature records (every column except the raw blob, plus the
|
||||||
|
parsed ``geometry``). No routing logic and no response formats are touched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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._geoms
|
||||||
|
self._geoms = []
|
||||||
|
self.by_id = {} # feature_id -> record (full-row lookup)
|
||||||
|
self._min_lon = self._min_lat = float("inf")
|
||||||
|
self._max_lon = self._max_lat = float("-inf")
|
||||||
|
|
||||||
|
self.road_count = self._load_table("mvum_roads", "road")
|
||||||
|
self.trail_count = self._load_table("mvum_trails", "trail")
|
||||||
|
|
||||||
|
self._tree = STRtree(self._geoms) if self._geoms else None
|
||||||
|
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(
|
||||||
|
"MVUM spatial index loaded: %d roads + %d trails in %.2f seconds",
|
||||||
|
self.road_count, self.trail_count, self.build_time_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_table(self, table, kind):
|
||||||
|
count = 0
|
||||||
|
parse_errors = 0
|
||||||
|
conn = sqlite3.connect(f"file:{self.db_path}?mode=ro", uri=True)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
cur = conn.execute(f"SELECT * FROM {table} WHERE shape IS NOT NULL")
|
||||||
|
cols = [c[0] for c in cur.description]
|
||||||
|
for row in cur:
|
||||||
|
try:
|
||||||
|
geom = wkb.loads(bytes(row["shape"]))
|
||||||
|
except Exception:
|
||||||
|
parse_errors += 1
|
||||||
|
continue
|
||||||
|
if geom.is_empty:
|
||||||
|
continue
|
||||||
|
rec = {c: row[c] for c in cols if c != "shape"}
|
||||||
|
rec["kind"] = kind
|
||||||
|
rec["feature_id"] = f"{kind}:{row['ogc_fid']}"
|
||||||
|
rec["geometry"] = geom
|
||||||
|
self._records.append(rec)
|
||||||
|
self._geoms.append(geom)
|
||||||
|
self.by_id[rec["feature_id"]] = rec
|
||||||
|
minx, miny, maxx, maxy = geom.bounds
|
||||||
|
self._min_lon = min(self._min_lon, minx)
|
||||||
|
self._min_lat = min(self._min_lat, miny)
|
||||||
|
self._max_lon = max(self._max_lon, maxx)
|
||||||
|
self._max_lat = max(self._max_lat, maxy)
|
||||||
|
count += 1
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if parse_errors > 0:
|
||||||
|
logger.warning("%s: %d rows had unparseable WKB shape blobs", table, parse_errors)
|
||||||
|
return count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bbox(self):
|
||||||
|
"""Overall extent as [min_lon, min_lat, max_lon, max_lat]."""
|
||||||
|
if not self._records:
|
||||||
|
return [0.0, 0.0, 0.0, 0.0]
|
||||||
|
return [self._min_lon, self._min_lat, self._max_lon, self._max_lat]
|
||||||
|
|
||||||
|
def _query_geom(self, geom):
|
||||||
|
if self._tree is None:
|
||||||
|
return []
|
||||||
|
return [self._records[i] for i in self._tree.query(geom)]
|
||||||
|
|
||||||
|
def query_bbox(self, min_lat, min_lon, max_lat, max_lon):
|
||||||
|
"""Feature records whose bounding box intersects the lat/lon box (coarse)."""
|
||||||
|
return self._query_geom(box(min_lon, min_lat, max_lon, max_lat))
|
||||||
|
|
||||||
|
def query_buffered_line(self, coords, tolerance_m):
|
||||||
|
"""Feature records near a (lat, lon) polyline, within ~tolerance_m.
|
||||||
|
|
||||||
|
Coarse bbox+buffer candidate filter only.
|
||||||
|
TODO(PR-B): apply the full parallelism filter (heading / overlap) so that
|
||||||
|
MVUM features which merely cross the route are rejected, keeping only those
|
||||||
|
that run alongside it.
|
||||||
|
"""
|
||||||
|
if not coords:
|
||||||
|
return []
|
||||||
|
pts = [(lon, lat) for (lat, lon) in coords]
|
||||||
|
geom = LineString(pts) if len(pts) >= 2 else Point(pts[0])
|
||||||
|
avg_lat = sum(lat for (lat, lon) in coords) / len(coords)
|
||||||
|
return self._query_geom(geom.buffer(_buffer_degrees_for_meters(tolerance_m, avg_lat)))
|
||||||
|
|
||||||
|
|
||||||
def parse_date_range(date_str: str) -> List[Tuple[int, int, int, int]]:
|
def parse_date_range(date_str: str) -> List[Tuple[int, int, int, int]]:
|
||||||
"""
|
"""
|
||||||
Parse MVUM date range strings like "05/01-11/30" or "06/15-10/15,12/01-03/31".
|
Parse MVUM date range strings like "05/01-11/30" or "06/15-10/15,12/01-03/31".
|
||||||
|
|
|
||||||
49
backend/services/navi_offroute/tests/test_mvum_spatial.py
Normal file
49
backend/services/navi_offroute/tests/test_mvum_spatial.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""MVUM spatial index foundation (Layer 0) tests.
|
||||||
|
|
||||||
|
Exercises MVUMSpatialIndex against the real navi.db on this host (read-only) and the
|
||||||
|
admin-info endpoint. Pure spatial lookup — no routing or response-format coverage.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.navi_offroute.mvum import MVUMSpatialIndex
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def index():
|
||||||
|
return MVUMSpatialIndex()
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_loads_without_error(index):
|
||||||
|
assert index.road_count > 0
|
||||||
|
assert index.trail_count > 0
|
||||||
|
assert len(index.by_id) == index.road_count + index.trail_count
|
||||||
|
assert len(index.bbox) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_bbox_returns_features(index):
|
||||||
|
# Generous Boise National Forest area bbox.
|
||||||
|
feats = index.query_bbox(43.8, -116.3, 44.4, -115.5)
|
||||||
|
assert len(feats) > 0
|
||||||
|
assert all("geometry" in f and "feature_id" in f for f in feats)
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_buffered_line_returns_features(index):
|
||||||
|
# Take a point on an actual indexed feature and query a small buffer around it.
|
||||||
|
sample = next(iter(index.by_id.values()))
|
||||||
|
pt = sample["geometry"].representative_point()
|
||||||
|
feats = index.query_buffered_line([(pt.y, pt.x)], tolerance_m=50)
|
||||||
|
assert len(feats) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_info_endpoint_returns_counts(index, monkeypatch):
|
||||||
|
import services.navi_offroute.app as app_mod
|
||||||
|
# Reuse the already-built fixture index instead of rebuilding in create_app.
|
||||||
|
monkeypatch.setattr(app_mod, "MVUMSpatialIndex", lambda *a, **k: index)
|
||||||
|
client = app_mod.create_app().test_client()
|
||||||
|
resp = client.get("/api/admin/mvum-spatial/info",
|
||||||
|
headers={"X-Authentik-Username": "matt"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
d = resp.get_json()
|
||||||
|
assert d["road_count"] > 0 and d["trail_count"] > 0
|
||||||
|
assert len(d["bbox"]) == 4
|
||||||
|
assert "build_time_seconds" in d and "memory_estimate_mb" in d
|
||||||
Loading…
Add table
Add a link
Reference in a new issue