From b1c20585862715c47651d5fedf93f0442b2738f1 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 25 May 2026 03:19:12 +0000 Subject: [PATCH] fix(offroute): use classification.use for track/path; add service_other to paved Align spatial eligibility with Valhalla's actual /locate vocabulary: - road grade lives in classification.classification (8-value enum incl service_other, which was missing from PAVED_HIGHWAY_CLASSES); track/path/footway are NOT grades, they live in classification.use. - _locate_on_network now also returns use (defensive .get chain; None in fallback). - Renamed TRACK/PATH_HIGHWAY_CLASSES -> TRACK_USE_VALUES/PATH_USE_VALUES; atv/mtb now match on use, vehicle still on the paved grade. - 4 tests: service_other->vehicle, use=track->atv/mtb/foot, use=footway->mtb/foot, none->foot. Live-verified: the Boise end point (43.626,-116.215, service_other) now yields [atv,mtb,vehicle,foot] instead of [foot]; Auto intersection picks vehicle. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/services/navi_offroute/router.py | 37 +++++++++------ .../navi_offroute/tests/test_offroute.py | 47 +++++++++++++++++++ 2 files changed, 70 insertions(+), 14 deletions(-) diff --git a/backend/services/navi_offroute/router.py b/backend/services/navi_offroute/router.py index 7fdab1a..7bfc27d 100755 --- a/backend/services/navi_offroute/router.py +++ b/backend/services/navi_offroute/router.py @@ -67,14 +67,16 @@ AUTO_SNAP_RELAXED_M = 100 # near the edge -> vehicle needs paved + flat terrain FLAT_TERRAIN_DELTA_M = 5 FLAT_SAMPLE_RADIUS_M = 50 -# Highway-class buckets used by the spatial eligibility rules. +# Spatial eligibility vocabulary. Valhalla's verbose /locate exposes the road grade +# under classification.classification (PAVED below) and the edge purpose under +# classification.use (TRACK/PATH below) — track/path/footway are NOT road grades. PAVED_HIGHWAY_CLASSES = frozenset({ "motorway", "trunk", "primary", "secondary", "tertiary", - "unclassified", "residential", "service", + "unclassified", "residential", "service", "service_other", }) -TRACK_HIGHWAY_CLASSES = frozenset({"track"}) -PATH_HIGHWAY_CLASSES = frozenset({ - "path", "footway", "bridleway", "steps", "pedestrian", "cycleway", +TRACK_USE_VALUES = frozenset({"track"}) +PATH_USE_VALUES = frozenset({ + "path", "footway", "cycleway", "bridleway", "steps", "pedestrian", }) # Mode to Valhalla costing mapping @@ -553,9 +555,11 @@ class OffrouteRouter: "snap_distance_m": snap_dist, "snapped_lat": snap_lat, "snapped_lon": snap_lon, - # Valhalla puts the highway class under edge.classification.classification - # (verbose=true); defensive .get chain so missing keys yield None. + # Valhalla (verbose=true) puts the road grade under + # edge.classification.classification and the edge purpose under + # edge.classification.use; defensive .get chains -> None if absent. "road_class": edge.get("edge", {}).get("classification", {}).get("classification"), + "use": edge.get("edge", {}).get("classification", {}).get("use"), } except Exception: pass @@ -566,6 +570,7 @@ class OffrouteRouter: "snapped_lat": lat, "snapped_lon": lon, "road_class": None, + "use": None, } def route( @@ -693,8 +698,10 @@ class OffrouteRouter: auto_snap = snap_cache[(lat, lon, "auto")] bike_snap = snap_cache[(lat, lon, "bicycle")] - d_auto, cls_auto = auto_snap["snap_distance_m"], auto_snap.get("road_class") - d_bike, cls_bike = bike_snap["snap_distance_m"], bike_snap.get("road_class") + d_auto = auto_snap["snap_distance_m"] + cls_auto, use_auto = auto_snap.get("road_class"), auto_snap.get("use") + d_bike = bike_snap["snap_distance_m"] + cls_bike, use_bike = bike_snap.get("road_class"), bike_snap.get("use") modes = {"foot"} # foot is always eligible # vehicle: on a paved road (tight), or near one if paved AND flat (relaxed) @@ -702,12 +709,14 @@ class OffrouteRouter: (d_auto <= AUTO_SNAP_RELAXED_M and cls_auto in PAVED_HIGHWAY_CLASSES and self._is_terrain_flat(lat, lon)): modes.add("vehicle") - # atv: on/near a paved or track edge - if d_auto <= AUTO_SNAP_RELAXED_M and cls_auto in (PAVED_HIGHWAY_CLASSES | TRACK_HIGHWAY_CLASSES): + # atv: near a paved road OR a track (auto costing) + if d_auto <= AUTO_SNAP_RELAXED_M and ( + cls_auto in PAVED_HIGHWAY_CLASSES or use_auto in TRACK_USE_VALUES): modes.add("atv") - # mtb: on/near a paved, track, or path edge (bicycle costing) - if d_bike <= AUTO_SNAP_RELAXED_M and cls_bike in ( - PAVED_HIGHWAY_CLASSES | TRACK_HIGHWAY_CLASSES | PATH_HIGHWAY_CLASSES): + # mtb: near a paved road OR a track/path (bicycle costing) + if d_bike <= AUTO_SNAP_RELAXED_M and ( + cls_bike in PAVED_HIGHWAY_CLASSES + or use_bike in (TRACK_USE_VALUES | PATH_USE_VALUES)): modes.add("mtb") return frozenset(modes) diff --git a/backend/services/navi_offroute/tests/test_offroute.py b/backend/services/navi_offroute/tests/test_offroute.py index eeaabb6..0a9fb52 100644 --- a/backend/services/navi_offroute/tests/test_offroute.py +++ b/backend/services/navi_offroute/tests/test_offroute.py @@ -418,3 +418,50 @@ def test_route_auto_both_unknown_uses_spatial_fallback(monkeypatch): out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic") # no categories assert len(spatial_calls) == 2 # both endpoints resolved spatially assert out["selected_mode"] == "vehicle" + + +# ── _spatial_eligible_modes — Valhalla classification.classification + .use ── +# _locate_on_network is monkeypatched to inject snap fixtures (road_class + use). + +def _stub_locate_fixed(snap): + def stub(self, lat, lon, mode="vehicle"): + return dict(snap) + return stub + + +def test_spatial_service_other_picks_vehicle(monkeypatch): + # (a) paved grade "service_other" close in -> vehicle eligible (tight tier) + snap = {"snap_distance_m": 3.0, "road_class": "service_other", "use": "road"} + monkeypatch.setattr(OffrouteRouter, "_locate_on_network", _stub_locate_fixed(snap)) + r = object.__new__(OffrouteRouter) + modes = r._spatial_eligible_modes(43.6, -116.2, {}) + assert "vehicle" in modes + assert modes == frozenset({"vehicle", "atv", "mtb", "foot"}) + + +def test_spatial_use_track_picks_atv_mtb_foot(monkeypatch): + # (b) no road grade, use="track" -> atv/mtb/foot, NOT vehicle + snap = {"snap_distance_m": 20.0, "road_class": None, "use": "track"} + monkeypatch.setattr(OffrouteRouter, "_locate_on_network", _stub_locate_fixed(snap)) + r = object.__new__(OffrouteRouter) + modes = r._spatial_eligible_modes(43.6, -116.2, {}) + assert modes == frozenset({"atv", "mtb", "foot"}) + assert "vehicle" not in modes + + +def test_spatial_use_footway_picks_mtb_foot(monkeypatch): + # (c) no road grade, use="footway" -> mtb/foot (path, not track -> no atv) + snap = {"snap_distance_m": 20.0, "road_class": None, "use": "footway"} + monkeypatch.setattr(OffrouteRouter, "_locate_on_network", _stub_locate_fixed(snap)) + r = object.__new__(OffrouteRouter) + modes = r._spatial_eligible_modes(43.6, -116.2, {}) + assert modes == frozenset({"mtb", "foot"}) + + +def test_spatial_no_class_no_use_picks_foot(monkeypatch): + # (d) nothing recognized -> foot only + snap = {"snap_distance_m": 20.0, "road_class": None, "use": None} + monkeypatch.setattr(OffrouteRouter, "_locate_on_network", _stub_locate_fixed(snap)) + r = object.__new__(OffrouteRouter) + modes = r._spatial_eligible_modes(43.6, -116.2, {}) + assert modes == frozenset({"foot"})