diff --git a/backend/scripts/README-osm-parking-ingest.md b/backend/scripts/README-osm-parking-ingest.md new file mode 100644 index 0000000..77a9a95 --- /dev/null +++ b/backend/scripts/README-osm-parking-ingest.md @@ -0,0 +1,70 @@ +# OSM parking ingest + +How `/mnt/nav/osm-parking.db` is **produced** from OSM data. navi-offroute's Layer 3b +(`backend/services/navi_offroute/mvum_parking.py`, `OSMParkingIndex`) **consumes** it +read-only as multi-modal Auto transition candidates (drive → parking → foot/2w/4w). +This is the producer; nothing here touches `navi.db`. + +## Source + +geofabrik regional extract (North America used in production): + (+ the `.md5`). + +Keep downloads/intermediates under `/mnt/nav/sources/osm/`. The final DB lives at +`/mnt/nav/osm-parking.db` (separate from `navi.db`). + +> Tools: `osmium` (osmium-tool, CLI) and the repo venv's Python (`shapely` — no +> pyosmium/GDAL needed). No service writes here; the index loads the DB read-only. + +## Refresh recipe + +```bash +cd /mnt/nav/sources/osm + +# 1. download + verify +curl -L --fail -o north-america-latest.osm.pbf.md5 \ + https://download.geofabrik.de/north-america-latest.osm.pbf.md5 +curl -L --fail -C - -o north-america-latest.osm.pbf \ + https://download.geofabrik.de/north-america-latest.osm.pbf +exp=$(awk '{print $1}' north-america-latest.osm.pbf.md5) +act=$(md5sum north-america-latest.osm.pbf | awk '{print $1}') +[ "$exp" = "$act" ] && echo "MD5 OK" || { echo "MD5 MISMATCH"; exit 1; } + +# 2. filter to amenity=parking (nwr = nodes+ways+relations; referenced nodes kept +# by default so way/relation polygons stay buildable) +osmium tags-filter north-america-latest.osm.pbf nwr/amenity=parking \ + -o north-america-parking.osm.pbf --overwrite + +# 3. export to GeoJSONSeq with osm type+id attributes +osmium export north-america-parking.osm.pbf -f geojsonseq -a type,id \ + -o north-america-parking.geojsonseq --overwrite + +# 4. ingest -> osm-parking.db (repo venv python; ~3 min for NA) +/home/zvx/projects/repos/navi-mono/backend/.venv/bin/python \ + /home/zvx/projects/repos/navi-mono/backend/scripts/ingest_parking.py \ + --geojsonseq north-america-parking.geojsonseq --db /mnt/nav/osm-parking.db + +# 5. (optional) reclaim space after the dedupe re-write +sqlite3 /mnt/nav/osm-parking.db "VACUUM;" + +# 6. pick up the new data: each navi-offroute worker rebuilds OSMParkingIndex at boot +sudo systemctl restart navi-offroute +``` + +## Schema + +`parking(id INTEGER PK, osm_id TEXT, osm_type TEXT, name TEXT, capacity INTEGER NULL, +access TEXT NULL, parking_type TEXT NULL, lat REAL, lon REAL, shape BLOB)` + index +`idx_parking_latlon(lat, lon)`. Geometry: WKB Point for nodes, (Multi)Polygon for +areas; `lat`/`lon` hold an interior `representative_point()` (the index builds its +STRtree from these directly, skipping per-row WKB parsing at boot). + +## Notes + +- `osmium export` emits each closed area-way **twice** (raw LineString + assembled + polygon); the ingest drops Line geometries to keep one geometry per object. Rare + genuinely-open parking ways (data errors) are dropped with them. +- `OSMParkingIndex` further drops `access` in (`private`, `no`, `permit`) at load — + off-limits lots are useless as transition candidates. +- North America ≈ 1.67M parking objects after dedupe (~500 MB DB). Verify: + `sqlite3 /mnt/nav/osm-parking.db "SELECT COUNT(*), osm_type FROM parking GROUP BY osm_type;"` diff --git a/backend/scripts/ingest_parking.py b/backend/scripts/ingest_parking.py new file mode 100644 index 0000000..e65e102 --- /dev/null +++ b/backend/scripts/ingest_parking.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Ingest OSM amenity=parking into the OSM parking SQLite DB consumed by +navi-offroute's Layer 3b (mvum_parking.OSMParkingIndex). + +Pipeline (see README-osm-parking-ingest.md): + geofabrik -latest.osm.pbf + -> osmium tags-filter nwr/amenity=parking -> -parking.osm.pbf + -> osmium export -f geojsonseq -a type,id -> -parking.geojsonseq + -> THIS SCRIPT -> osm-parking.db + +Reads the GeoJSONSeq stream (one RFC-8142 record per line, 0x1e-prefixed), +keeps only features tagged amenity=parking, and writes one row per parking +object: a Point for nodes, a (Multi)Polygon for closed-area ways/relations. + +osmium export emits each closed area-way TWICE -- once as the raw LineString and +once as the assembled (Multi)Polygon -- so we drop Line geometries to keep a +single geometry per object (rare genuinely-open parking ways, which are data +errors, are dropped). lat/lon store an interior representative_point used by the +index to build its STRtree without re-parsing the WKB. +""" +import argparse +import json +import sqlite3 + +from shapely.geometry import shape +from shapely import to_wkb + +DEFAULT_DB = "/mnt/nav/osm-parking.db" + + +def parse_capacity(v): + """Leading-integer parse of an OSM capacity value (e.g. '120', '12;disabled'); None if non-numeric.""" + if v is None: + return None + digits = "" + for ch in str(v): + if ch.isdigit(): + digits += ch + else: + break + return int(digits) if digits else None + + +def main(): + ap = argparse.ArgumentParser(description="Ingest amenity=parking GeoJSONSeq into osm-parking.db") + ap.add_argument("--geojsonseq", required=True, + help="input GeoJSONSeq from `osmium export -f geojsonseq -a type,id`") + ap.add_argument("--db", default=DEFAULT_DB, + help=f"output SQLite DB (default {DEFAULT_DB})") + args = ap.parse_args() + + con = sqlite3.connect(args.db) + con.execute("PRAGMA journal_mode=OFF") + con.execute("PRAGMA synchronous=OFF") + con.execute("DROP TABLE IF EXISTS parking") + con.execute("""CREATE TABLE parking ( + id INTEGER PRIMARY KEY, + osm_id TEXT, osm_type TEXT, name TEXT, + capacity INTEGER, access TEXT, parking_type TEXT, + lat REAL, lon REAL, shape BLOB)""") + + INS = ("INSERT INTO parking (osm_id,osm_type,name,capacity,access,parking_type,lat,lon,shape)" + " VALUES (?,?,?,?,?,?,?,?,?)") + + batch, n, skipped, bad = [], 0, 0, 0 + with open(args.geojsonseq, "rb") as f: + for raw in f: + raw = raw.strip().lstrip(b"\x1e").strip() + if not raw: + continue + try: + feat = json.loads(raw) + except Exception: + bad += 1; continue + props = feat.get("properties") or {} + if props.get("amenity") != "parking": + skipped += 1; continue + gj = feat.get("geometry") + if not gj: + skipped += 1; continue + try: + geom = shape(gj) + if geom.is_empty: + bad += 1; continue + # Drop the duplicate LineString osmium emits for each closed area-way. + if geom.geom_type in ("LineString", "MultiLineString"): + skipped += 1; continue + rep = geom.representative_point() + wkb = to_wkb(geom, output_dimension=2, byte_order=1) + except Exception: + bad += 1; continue + batch.append((str(props.get("@id")), props.get("@type"), props.get("name"), + parse_capacity(props.get("capacity")), props.get("access"), + props.get("parking"), rep.y, rep.x, wkb)) + n += 1 + if len(batch) >= 5000: + con.executemany(INS, batch); batch = [] + if batch: + con.executemany(INS, batch) + con.execute("CREATE INDEX idx_parking_latlon ON parking(lat, lon)") + con.commit() + print(f"inserted={n} skipped_non_parking_or_line={skipped} bad_geom={bad}") + con.close() + + +if __name__ == "__main__": + main() diff --git a/backend/services/navi_offroute/admin.py b/backend/services/navi_offroute/admin.py index 34315c5..4e0dc8e 100644 --- a/backend/services/navi_offroute/admin.py +++ b/backend/services/navi_offroute/admin.py @@ -149,3 +149,17 @@ def mvum_spatial_info(): 'build_time_seconds': round(idx.build_time_seconds, 3), 'memory_estimate_mb': round(idx.memory_estimate_mb, 1), }) + + +@bp.route('/api/admin/osm-parking/info') +@require_auth +def osm_parking_info(): + """Read-only stats for the in-memory OSM parking index (Layer 3b).""" + idx = current_app.config.get('OSM_PARKING_INDEX') + if idx is None: + return jsonify({'status': 'error', 'message': 'OSM parking 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), + }) diff --git a/backend/services/navi_offroute/app.py b/backend/services/navi_offroute/app.py index 639a99c..595b7bc 100644 --- a/backend/services/navi_offroute/app.py +++ b/backend/services/navi_offroute/app.py @@ -12,6 +12,7 @@ from shared.git_sha import git_short_sha from . import offroute_route, admin from .mvum import MVUMSpatialIndex from .mvum_transitions import load_trailheads +from .mvum_parking import load_parking_index # 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. @@ -62,6 +63,13 @@ def create_app(): app.logger.warning("MVUM trailhead index failed to load: %s", e) app.config['MVUM_TRAILHEAD_INDEX'] = None + # Layer 3b: OSM parking index (process-wide singleton, logs its own line). + try: + app.config['OSM_PARKING_INDEX'] = load_parking_index() + except Exception as e: + app.logger.warning("OSM parking index failed to load: %s", e) + app.config['OSM_PARKING_INDEX'] = None + app.register_blueprint(offroute_route.bp) app.register_blueprint(admin.bp) return app diff --git a/backend/services/navi_offroute/mvum_parking.py b/backend/services/navi_offroute/mvum_parking.py new file mode 100644 index 0000000..7634eff --- /dev/null +++ b/backend/services/navi_offroute/mvum_parking.py @@ -0,0 +1,121 @@ +""" +MVUM Layer 3b: OSM parking as multi-modal Auto transition candidates. + +Loads ``/mnt/nav/osm-parking.db`` (amenity=parking objects ingested from the +geofabrik North America extract) into a shapely STRtree of parking points, 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. Read-only, pure spatial lookup; mirrors the +MVUMSpatialIndex (Layer 0) / TrailheadIndex (Layer 3a) singleton pattern. +""" +import logging +import os +import sqlite3 +import time as _time +from pathlib import Path + +import psutil +from shapely.geometry import Point, LineString +from shapely.strtree import STRtree + +from .mvum import _buffer_degrees_for_meters + +logger = logging.getLogger("navi_offroute.mvum_parking") + +DEFAULT_PARKING_DB = Path("/mnt/nav/osm-parking.db") + +# Parking that is off-limits as a public transition point. +_BLOCKED_ACCESS = frozenset({"private", "no", "permit"}) + + +def parking_db_path() -> Path: + """osm-parking.db path, env-overridable via NAVI_OFFROUTE_PARKING_DB.""" + return Path(os.environ.get("NAVI_OFFROUTE_PARKING_DB", str(DEFAULT_PARKING_DB))) + + +class OSMParkingIndex: + """In-memory STRtree over OSM parking points from osm-parking.db. + + Keeps the STRtree plus a parallel ``records`` list of + ``{lat, lon, name, road_class, parking_type, access}`` dicts. Records whose + ``access`` is private/no/permit are dropped at load (useless as candidates). + """ + + 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 parking_db_path() + self.records = [] # aligned with self._points + self._points = [] + skipped_access = 0 + + conn = sqlite3.connect(f"file:{self.db_path}?mode=ro", uri=True) + conn.row_factory = sqlite3.Row + try: + cur = conn.execute( + "SELECT name, capacity, access, parking_type, lat, lon FROM parking") + for row in cur: + access = row["access"] + if access in _BLOCKED_ACCESS: + skipped_access += 1 + continue + lat, lon = row["lat"], row["lon"] + if lat is None or lon is None: + continue + # The ingest already stored representative_point() (an interior point + # of each parking polygon) in the lat/lon columns, so build the STRtree + # straight from them -- parsing the 1.6M WKB shape blobs here would add + # minutes to every worker boot for an identical point. + self.records.append({ + "lat": float(lat), + "lon": float(lon), + "name": row["name"] or "", + "road_class": "parking", + "parking_type": row["parking_type"], + "access": access, + }) + self._points.append(Point(float(lon), float(lat))) + finally: + conn.close() + + self._tree = STRtree(self._points) if self._points else None + self.count = len(self.records) + self.skipped_access = skipped_access + 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( + "OSM parking index loaded: %d parking objects (%d access-blocked skipped) " + "in %.2f seconds", self.count, skipped_access, self.build_time_seconds) + + def query_parking_near_line(self, coords, buffer_m=2000): + """Parking records within ~``buffer_m`` of a (lat, lon) polyline. + + Coarse STRtree bbox prefilter then a precise degree-distance check, matching + TrailheadIndex.query_trailheads_near_line. + """ + if not coords or self._tree is None: + 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) + 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]) + return out + + +# Process-wide singleton, mirroring app.py's _MVUM_INDEX / trailhead handling. +_PARKING_INDEX = None + + +def load_parking_index(db_path=None): + """Return the process-wide OSMParkingIndex singleton, building it on first call.""" + global _PARKING_INDEX + if _PARKING_INDEX is None: + _PARKING_INDEX = OSMParkingIndex(db_path) + return _PARKING_INDEX diff --git a/backend/services/navi_offroute/offroute_route.py b/backend/services/navi_offroute/offroute_route.py index 7029b92..71f24f0 100644 --- a/backend/services/navi_offroute/offroute_route.py +++ b/backend/services/navi_offroute/offroute_route.py @@ -75,6 +75,8 @@ def api_offroute(): 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, diff --git a/backend/services/navi_offroute/router.py b/backend/services/navi_offroute/router.py index 4307873..fab6597 100755 --- a/backend/services/navi_offroute/router.py +++ b/backend/services/navi_offroute/router.py @@ -35,6 +35,7 @@ import psycopg2.extras from shapely.geometry import LineString, Point from .astar import astar_multigoal, inflate_cost_multiplier from .mvum_surface_change import get_surface_change_candidates +from .mvum_parking import load_parking_index # noqa: F401 (singleton injected by handler) from shared.dem import DEMReader, dem_path from .cost import compute_cost_grid, compute_cost_multiplier_grid, MODE_PROFILES @@ -550,6 +551,7 @@ class OffrouteRouter: self.mvum_on_date = None # optional datetime for seasonal MVUM checks self._exclude_polygons = None # MVUM Layer 2c, set per route() call self.trailhead_index = None # TrailheadIndex (Layer 3a), injected by the handler + self.parking_index = None # OSMParkingIndex (Layer 3b), injected by the handler def _init_readers(self): """Lazy init readers.""" @@ -921,9 +923,6 @@ class OffrouteRouter: single-mode winner by HYBRID_MIN_TIME_SAVINGS_MIN, else None (caller keeps the single-mode winner). Leg times are summed with no transition penalty. """ - idx = getattr(self, "trailhead_index", None) - if idx is None: - return None best_summary = best_result.get("summary") or {} if best_summary.get("total_distance_km", 0.0) < MIN_HYBRID_DISTANCE_KM: return None @@ -931,12 +930,24 @@ class OffrouteRouter: coords = self._route_coords_latlon(best_result) if len(coords) < 2: return None - candidates = idx.query_trailheads_near_line( - coords, buffer_m=HYBRID_TRAILHEAD_BUFFER_M) - # Layer 3c: also treat surface-category boundaries along the winning polyline - # (e.g. pavement -> dirt) as transition candidates. Same record shape, so they - # mix freely with trailheads below. - candidates = candidates + get_surface_change_candidates(coords, VALHALLA_URL) + + # Gather transition candidates from every available source; each yields the + # same {lat, lon, name, road_class, ...} record shape, so they mix freely and + # share the closest-first sort + cap below. + candidates = [] + # Layer 3a: MVUM/USFS trailheads near the winning polyline. + th_idx = getattr(self, "trailhead_index", None) + if th_idx is not None: + candidates += th_idx.query_trailheads_near_line( + coords, buffer_m=HYBRID_TRAILHEAD_BUFFER_M) + # Layer 3c: surface-category boundaries along the polyline (e.g. pavement -> dirt). + candidates += get_surface_change_candidates(coords, VALHALLA_URL) + # Layer 3b: OSM parking -- covers BLM/state/private land + urban areas where + # MVUM trailheads don't exist. + pk_idx = getattr(self, "parking_index", None) + if pk_idx is not None: + candidates += pk_idx.query_parking_near_line( + coords, buffer_m=HYBRID_TRAILHEAD_BUFFER_M) if not candidates: return None # Closest-to-route first, then cap the combined list. diff --git a/backend/services/navi_offroute/tests/test_mvum_parking.py b/backend/services/navi_offroute/tests/test_mvum_parking.py new file mode 100644 index 0000000..6da6fdd --- /dev/null +++ b/backend/services/navi_offroute/tests/test_mvum_parking.py @@ -0,0 +1,75 @@ +"""MVUM Layer 3b tests: OSMParkingIndex over a synthetic osm-parking.db.""" +import sqlite3 + +import pytest + +from services.navi_offroute.mvum_parking import OSMParkingIndex + + +def _parking_db(tmp_path, rows): + """rows: list of (osm_id, osm_type, name, capacity, access, parking_type, lat, lon).""" + db = tmp_path / "osm-parking.db" + conn = sqlite3.connect(db) + conn.execute( + "CREATE TABLE parking (id INTEGER PRIMARY KEY, osm_id TEXT, osm_type TEXT, " + "name TEXT, capacity INTEGER, access TEXT, parking_type TEXT, " + "lat REAL, lon REAL, shape BLOB)") + conn.executemany( + "INSERT INTO parking (osm_id,osm_type,name,capacity,access,parking_type,lat,lon) " + "VALUES (?,?,?,?,?,?,?,?)", rows) + conn.commit() + conn.close() + return db + + +def test_parking_index_loads(tmp_path): + db = _parking_db(tmp_path, [ + ("1", "node", "Lot A", 20, None, "surface", 44.00, -114.00), + ("2", "way", "Lot B", None, "yes", "surface", 44.01, -114.02), + ("3", "way", "", None, "customers", None, 44.02, -114.03), + ]) + idx = OSMParkingIndex(db_path=db) + assert idx.count == 3 + assert len(idx.records) == len(idx._points) == 3 + rec = idx.records[0] + assert rec["name"] == "Lot A" + assert rec["road_class"] == "parking" + assert rec["parking_type"] == "surface" + assert rec["lat"] == 44.00 and rec["lon"] == -114.00 + + +def test_query_parking_near_line_returns_close_only(tmp_path): + db = _parking_db(tmp_path, [ + ("1", "node", "On Line", None, None, "surface", 44.000, -114.000), + ("2", "node", "Far Away", None, None, "surface", 44.300, -114.000), # ~33 km N + ]) + idx = OSMParkingIndex(db_path=db) + line = [(44.000, -114.010), (44.000, -113.990)] # ~1.6 km segment through the close pt + near = idx.query_parking_near_line(line, buffer_m=2000) + names = {r["name"] for r in near} + assert "On Line" in names + assert "Far Away" not in names + + +def test_private_parking_filtered_out(tmp_path): + db = _parking_db(tmp_path, [ + ("1", "way", "Public", None, "yes", "surface", 44.00, -114.00), + ("2", "way", "Private", None, "private", "surface", 44.01, -114.01), + ("3", "way", "NoAccess", None, "no", "surface", 44.02, -114.02), + ("4", "way", "PermitOnly", None, "permit", "surface", 44.03, -114.03), + ]) + idx = OSMParkingIndex(db_path=db) + names = {r["name"] for r in idx.records} + assert names == {"Public"} + assert idx.count == 1 + assert idx.skipped_access == 3 + + +def test_no_access_field_kept(tmp_path): + # Most OSM parking rows have NULL access -> must be kept (not treated as blocked). + db = _parking_db(tmp_path, [ + ("1", "way", "Unspecified", None, None, "surface", 44.00, -114.00), + ]) + idx = OSMParkingIndex(db_path=db) + assert idx.count == 1 + assert idx.records[0]["access"] is None diff --git a/backend/services/navi_offroute/tests/test_offroute.py b/backend/services/navi_offroute/tests/test_offroute.py index 50dbd05..06f8053 100644 --- a/backend/services/navi_offroute/tests/test_offroute.py +++ b/backend/services/navi_offroute/tests/test_offroute.py @@ -884,3 +884,78 @@ def test_route_auto_annotates_only_winner(monkeypatch): out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic") assert out["selected_mode"] == "4w" assert annotated == ["4w"] # annotated once, on the winner only + + +# ── Layer 3b: parking as a hybrid transition candidate source ── + +def _hybrid_ok_leg(distance_km, minutes): + return { + "status": "ok", + "route": {"type": "FeatureCollection", "features": [ + {"type": "Feature", + "properties": {"segment_type": "network", "network_mode": "x"}, + "geometry": {"type": "LineString", + "coordinates": [[-114.0, 44.0], [-114.1, 44.0]]}}, + ]}, + "summary": {"total_distance_km": distance_km, "total_effort_minutes": minutes, + "network_distance_km": distance_km, "network_duration_minutes": minutes, + "wilderness_distance_km": 0.0, "wilderness_effort_minutes": 0.0, + "scenario": "D"}, + } + + +def _hybrid_winning_single_mode(distance_km, minutes): + return { + "status": "ok", + "route": {"type": "FeatureCollection", "features": [ + {"type": "Feature", "properties": {"segment_type": "combined"}, + "geometry": {"type": "LineString", + "coordinates": [[-114.0, 44.0], [-114.5, 44.0]]}}, + ]}, + "summary": {"total_distance_km": distance_km, "total_effort_minutes": minutes, + "scenario": "D"}, + "selected_mode": "vehicle", + } + + +class _FakeParking: + def __init__(self, records): + self._records = records + + def query_parking_near_line(self, coords, buffer_m=2000): + return list(self._records) + + +def test_hybrid_consumes_parking_candidates(monkeypatch): + # Only the parking index supplies candidates (no trailhead index, no surface + # changes); the parking lot must be probed as a leg-1 destination and win. + parking = {"lat": 44.0, "lon": -114.25, "name": "BLM Trailhead Lot", + "road_class": "parking", "parking_type": "surface", "access": None} + monkeypatch.setattr("services.navi_offroute.router.get_surface_change_candidates", + lambda coords, url: []) + + seen_dests = [] + + def fake_route(self, s_lat, s_lon, e_lat, e_lon, mode="foot", + boundary_mode="pragmatic", annotate_mvum=True, **k): + seen_dests.append((round(e_lat, 4), round(e_lon, 4))) + if mode == "vehicle": + return _hybrid_ok_leg(12.0, 20.0) + return _hybrid_ok_leg(4.0, 30.0) + monkeypatch.setattr(OffrouteRouter, "route", fake_route) + + r = object.__new__(OffrouteRouter) + r.spatial_index = None + r.trailhead_index = None # no trailheads -> parking must still be gathered + r.parking_index = _FakeParking([parking]) + + best = _hybrid_winning_single_mode(distance_km=20.0, minutes=120.0) + out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic", + best, 120.0, frozenset({"vehicle", "4w", "2w", "foot"})) + assert out is not None + assert out["selected_mode"] == "hybrid" + # the parking lot was probed as a drive-to (leg-1) destination + assert (round(parking["lat"], 4), round(parking["lon"], 4)) in seen_dests + trans = next(f for f in out["route"]["features"] + if f["properties"].get("kind") == "transition") + assert trans["properties"]["name"] == "BLM Trailhead Lot"