navi/backend/services/navi_offroute/offroute_route.py
malice f9f2eb9b8f
MVUM Layer 3b: OSM parking as multi-modal Auto transition candidates (#31)
* MVUM Layer 3b: OSM parking as multi-modal Auto transition candidates

Adds OSM parking lots as a third multi-modal-Auto transition source alongside
MVUM trailheads (3a) and surface-change points (3c), so Auto can suggest
"drive to a parking lot, switch to foot/2w/4w" trips where no MVUM trailhead
exists -- BLM/state land, urban edges, anywhere OSM has parking but the USFS
trailhead layer does not. Backend-only; consumes the already-ingested
/mnt/nav/osm-parking.db read-only (no data-pipeline change).

- mvum_parking.py: OSMParkingIndex (process-wide singleton via load_parking_index)
  over a shapely STRtree of parking points, mirroring MVUMSpatialIndex /
  TrailheadIndex. Read-only SQLite. Drops access in (private,no,permit) at load.
  query_parking_near_line(coords, buffer_m=2000) with the same coarse-bbox +
  precise-distance filter as TrailheadIndex. Records carry
  {lat, lon, name, road_class="parking", parking_type, access}.
  Perf note: the ingest already stored representative_point() in lat/lon, so the
  STRtree is built straight from those columns -- parsing the 1.6M WKB blobs at
  boot would add minutes for an identical point.
- router.py: _try_hybrid_auto generalized to gather candidates from each AVAILABLE
  source (trailhead index if present + surface-change always + parking index if
  present) instead of hard-returning when trailhead_index is None, so parking-only
  candidates still work. Combined list keeps the existing closest-first sort +
  HYBRID_MAX_TRAILHEADS cap. Signature unchanged; record shape already compatible.
- app.py / offroute_route.py: load + inject the OSM parking singleton, mirroring
  MVUM_SPATIAL_INDEX / MVUM_TRAILHEAD_INDEX. Failure logs a warning, degrades None.
- admin.py: GET /api/admin/osm-parking/info -> {count, build_time_seconds,
  memory_estimate_mb}, mirroring /api/admin/mvum-spatial/info.
- backend/scripts/ingest_parking.py + README-osm-parking-ingest.md: the
  data-pipeline ingest lifted to the repo with argparse (--geojsonseq/--db, no
  /tmp) + the download/filter/export/ingest/restart refresh recipe.

Tests: test_mvum_parking.py (loads, near-line close-only, private/no/permit
filtered, null-access kept) + test_offroute.py::test_hybrid_consumes_parking_
candidates (parking-only source probed as a leg-1 destination). Full offroute
suite: 82 passed.

Real-DB sanity (not deployed): index loads 1,489,054 usable parking objects
(182,945 access-blocked dropped) in ~12 s using ~950 MB RSS per worker; a Redfish
Lake/Sawtooth corridor query returns 8 lots. The ~950 MB/worker memory cost is
notable -- flagging for review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Numpy-pack OSMParkingIndex coords + lazy records to cut RSS (~950->~570 MB/worker)

Store parking coords as packed float64 numpy arrays (_lats/_lons) and the
attribute columns as interned lists (_names/_parking_types/_accesses), and build
candidate record dicts lazily in query_parking_near_line instead of materializing
1.5M dicts + 1.5M shapely Point objects up front. road_class is the constant
"parking" so it is not stored per row.

Measured on the real /mnt/nav/osm-parking.db (1,489,054 usable rows):
RSS/worker ~950 MB -> ~570 MB (~40%), build ~11 s. Across 2 gunicorn workers that
is ~1.9 GB -> ~1.14 GB.

NOTE: this does NOT reach the ~250 MB originally targeted. The remaining cost is
the shapely STRtree itself: it permanently retains the input geometries
(tree.geometries len == row count), so the transient `del points` does not free
them. Attribution on the real DB: columns-only 137 MB, retained Point objects
+230 MB, STRtree index +110 MB. Reaching ~250 MB would require dropping the
shapely STRtree for a coordinate-only structure (e.g. scipy cKDTree over the
lon/lat arrays), which changes the line-buffer query into a per-vertex radius
query -- a behavior change beyond this fix-up's scope. Flagged for a follow-up.

Tests unchanged except one assertion (`len(idx.records) == idx.count`); full
offroute suite 82 passed.

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>
2026-05-26 14:48:35 -06:00

169 lines
7.2 KiB
Python

"""navi-offroute API blueprint — faithful port of recon's offroute routes.
POST /api/offroute off-network effort-based routing (OffrouteRouter)
GET /api/mvum MVUM road/trail access lookup (MVUMReader)
Both public (no auth), matching recon. Same request/response shapes and status
codes. Ported from recon's lib/api.py:api_offroute / api_mvum.
"""
import logging
import re
from flask import Blueprint, request, jsonify, current_app
from .router import OffrouteRouter
from .mvum import MVUMReader
logger = logging.getLogger('navi_offroute.route')
bp = Blueprint('offroute', __name__)
VALID_MODES = ("auto", "foot", "2w", "4w", "vehicle")
VALID_BOUNDARY_MODES = ("strict", "pragmatic", "emergency")
@bp.route("/api/offroute", methods=["POST"])
def api_offroute():
"""
Off-network routing from wilderness to destination.
Request body:
{start:[lat,lon], end:[lat,lon],
mode: auto|foot|2w|4w|vehicle (default foot),
boundary_mode: strict|pragmatic|emergency (default pragmatic)}
Response: {status:"ok", route:<GeoJSON FeatureCollection>, summary:{...}}
or {status:"error", message}. 400 on bad input / router error, 500 on uncaught.
"""
try:
data = request.get_json()
if not data:
return jsonify({"status": "error", "message": "No JSON body provided"}), 400
start = data.get("start")
end = data.get("end")
if not start or not end:
return jsonify({"status": "error", "message": "Missing start or end coordinates"}), 400
if not isinstance(start, (list, tuple)) or len(start) != 2:
return jsonify({"status": "error", "message": "start must be [lat, lon]"}), 400
if not isinstance(end, (list, tuple)) or len(end) != 2:
return jsonify({"status": "error", "message": "end must be [lat, lon]"}), 400
start_lat, start_lon = float(start[0]), float(start[1])
end_lat, end_lon = float(end[0]), float(end[1])
mode = data.get("mode", "foot")
# Backward-compat: map legacy mode names from bookmarked URLs.
if mode == "mtb":
mode = "2w"
elif mode == "atv":
mode = "4w"
if mode not in VALID_MODES:
return jsonify({"status": "error", "message": "mode must be auto, foot, 2w, 4w, or vehicle"}), 400
boundary_mode = data.get("boundary_mode", "pragmatic")
if boundary_mode not in VALID_BOUNDARY_MODES:
return jsonify({"status": "error", "message": "boundary_mode must be strict, pragmatic, or emergency"}), 400
# Optional per-endpoint OSM "key:value" category hints. Only used by Auto
# mode to gate eligible travel modes; ignored for concrete modes.
start_category = data.get("start_category")
end_category = data.get("end_category")
router = OffrouteRouter()
# Inject the Layer-0 MVUM spatial index singleton for Layer-1 annotation.
router.spatial_index = current_app.config.get('MVUM_SPATIAL_INDEX')
# Inject the Layer-3a trailhead index for multi-modal Auto transitions.
router.trailhead_index = current_app.config.get('MVUM_TRAILHEAD_INDEX')
# Inject the Layer-3b OSM parking index for multi-modal Auto transitions.
router.parking_index = current_app.config.get('OSM_PARKING_INDEX')
try:
result = router.route(
start_lat=start_lat, start_lon=start_lon,
end_lat=end_lat, end_lon=end_lon,
mode=mode, boundary_mode=boundary_mode,
start_category=start_category, end_category=end_category,
)
finally:
router.close()
if result.get("status") == "error":
return jsonify(result), 400
return jsonify(result)
except Exception as e:
logger.exception("Offroute error")
return jsonify({"status": "error", "message": str(e)}), 500
@bp.route("/api/mvum", methods=["GET"])
def api_mvum():
"""MVUM (Motor Vehicle Use Map) access near a point. Roads first, then trails.
GET /api/mvum?lat=&lon=&radius= (radius default 50 m)
Returns {status:"ok", feature:{...}|null}. 400 missing coords, 500 on uncaught.
"""
try:
lat = request.args.get("lat", type=float)
lon = request.args.get("lon", type=float)
radius = request.args.get("radius", 50, type=float)
if lat is None or lon is None:
return jsonify({"status": "error", "message": "lat and lon required"}), 400
reader = MVUMReader()
try:
feature = reader.query_nearest(lat, lon, radius, "mvum_roads")
if feature is None:
feature = reader.query_nearest(lat, lon, radius, "mvum_trails")
if feature is None:
return jsonify({"status": "ok", "feature": None})
access = {
"passenger_vehicle": {"status": feature.get("passengervehicle"),
"dates": feature.get("passengervehicle_datesopen")},
"high_clearance": {"status": feature.get("highclearancevehicle"),
"dates": feature.get("highclearancevehicle_datesopen")},
"atv": {"status": feature.get("atv"), "dates": feature.get("atv_datesopen")},
"motorcycle": {"status": feature.get("motorcycle"),
"dates": feature.get("motorcycle_datesopen")},
"4wd_gt50": {"status": feature.get("fourwd_gt50inches"),
"dates": feature.get("fourwd_gt50_datesopen")},
"2wd_gt50": {"status": feature.get("twowd_gt50inches"),
"dates": feature.get("twowd_gt50_datesopen")},
"e_bike_class1": {"status": feature.get("e_bike_class1"),
"dates": feature.get("e_bike_class1_dur")},
"e_bike_class2": {"status": feature.get("e_bike_class2"),
"dates": feature.get("e_bike_class2_dur")},
"e_bike_class3": {"status": feature.get("e_bike_class3"),
"dates": feature.get("e_bike_class3_dur")},
}
maint_level = feature.get("operationalmaintlevel", "")
maint_num = None
if maint_level:
match = re.match(r"(\d+)", maint_level)
if match:
maint_num = int(match.group(1))
result = {
"id": feature.get("id"),
"name": feature.get("name"),
"forest": feature.get("forestname"),
"district": feature.get("districtname"),
"surface": feature.get("surfacetype"),
"maintenance_level": maint_num,
"seasonal": feature.get("seasonal"),
"symbol": feature.get("symbol"),
"trail_class": feature.get("trailclass"),
"trail_system": feature.get("trailsystem"),
"access": access,
"geometry": feature.get("geojson"),
}
return jsonify({"status": "ok", "feature": result})
finally:
reader.close()
except Exception as e:
logger.exception("MVUM query error")
return jsonify({"status": "error", "message": str(e)}), 500