From 5894be6d036ea1555dbb42e7e5c43c9b5b14625b Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 25 May 2026 01:29:03 +0000 Subject: [PATCH] =?UTF-8?q?fix(offroute):=20Auto=20vehicle=20eligibility?= =?UTF-8?q?=20=E2=80=94=203-tier=20paved+flat=20snap=20discriminator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vehicle skips the off-network gate (PR #8), so Auto would pick vehicle for any Valhalla-snappable point. Add a 3-tier eligibility check before Auto probes vehicle: <=5m -> on the road, vehicle always ok 5m..100m -> vehicle ok only if snapped road is paved AND terrain is flat >100m -> vehicle not ok Rationale: aggressive 100m snapping in wilderness can route from the wrong start point; only paved+flat (urban/suburban) earns the grace zone. - _locate_on_network now returns road_class. - _is_terrain_flat samples DEM at center + 4 cardinals (FLAT_SAMPLE_RADIUS_M), flat if max-min < FLAT_TERRAIN_DELTA_M; DEM errors -> False (conservative). - _vehicle_eligible implements the 3 tiers; _route_auto drops vehicle from the probe order unless BOTH endpoints are eligible. - 5 new tests cover the tiers; existing probe-iteration tests stub _vehicle_eligible. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/services/navi_offroute/router.py | 70 ++++++++++++- .../navi_offroute/tests/test_offroute.py | 99 +++++++++++++++++-- 2 files changed, 158 insertions(+), 11 deletions(-) diff --git a/backend/services/navi_offroute/router.py b/backend/services/navi_offroute/router.py index 8ab0fd9..1784688 100755 --- a/backend/services/navi_offroute/router.py +++ b/backend/services/navi_offroute/router.py @@ -59,6 +59,24 @@ MEMORY_LIMIT_GB = 12 # Off-network detection threshold (meters) OFF_NETWORK_THRESHOLD_M = 10 +# Auto-mode vehicle-eligibility (3-tier snap discriminator). Vehicle skips the +# off-network gate (pure Valhalla), so Auto must decide whether snapping to a road +# is *safe* before probing vehicle: +# <= AUTO_SNAP_TIGHT_M : literally on the road -> vehicle always ok +# <= AUTO_SNAP_RELAXED_M : grace zone -> vehicle ok ONLY if the snapped road +# is paved AND local terrain is flat (urban/suburban); +# in wilderness you can't see 100m, so aggressive +# snapping could route from the wrong start point +# > AUTO_SNAP_RELAXED_M : too far from any road -> vehicle not ok +AUTO_SNAP_TIGHT_M = 5 +AUTO_SNAP_RELAXED_M = 100 +FLAT_TERRAIN_DELTA_M = 5 +FLAT_SAMPLE_RADIUS_M = 50 +PAVED_HIGHWAY_CLASSES = frozenset({ + "motorway", "trunk", "primary", "secondary", "tertiary", + "unclassified", "residential", "service", +}) + # Mode to Valhalla costing mapping MODE_TO_COSTING = { "auto": "auto", @@ -497,7 +515,8 @@ class OffrouteRouter: "on_network": snap_dist <= OFF_NETWORK_THRESHOLD_M, "snap_distance_m": snap_dist, "snapped_lat": snap_lat, - "snapped_lon": snap_lon + "snapped_lon": snap_lon, + "road_class": edge.get("road_class"), } except Exception: pass @@ -506,9 +525,46 @@ class OffrouteRouter: "on_network": False, "snap_distance_m": float('inf'), "snapped_lat": lat, - "snapped_lon": lon + "snapped_lon": lon, + "road_class": None, } + def _is_terrain_flat(self, lat: float, lon: float) -> bool: + """ + True if the DEM is flat (max-min elevation < FLAT_TERRAIN_DELTA_M) across the + center and four cardinal points FLAT_SAMPLE_RADIUS_M away. Conservative: any + DEM read failure (untiled / ocean / error) returns False, so an unknown area + never earns the relaxed-snap grace. + """ + try: + if self.dem_reader is None: + self.dem_reader = DEMReader(dem_path()) + dlat = FLAT_SAMPLE_RADIUS_M / 111320.0 + dlon = FLAT_SAMPLE_RADIUS_M / (111320.0 * max(0.01, math.cos(math.radians(lat)))) + points = [ + (lat, lon), + (lat + dlat, lon), (lat - dlat, lon), + (lat, lon + dlon), (lat, lon - dlon), + ] + elevs = [self.dem_reader.sample_point(plat, plon) for plat, plon in points] + if any(e is None for e in elevs): + return False + return (max(elevs) - min(elevs)) < FLAT_TERRAIN_DELTA_M + except Exception: + return False + + def _vehicle_eligible(self, lat: float, lon: float) -> bool: + """ + Whether Auto should consider vehicle for an endpoint (3-tier, see constants). + """ + snap = self._locate_on_network(lat, lon, "vehicle") + d = snap["snap_distance_m"] + if d <= AUTO_SNAP_TIGHT_M: + return True + if d > AUTO_SNAP_RELAXED_M: + return False + return (snap.get("road_class") in PAVED_HIGHWAY_CLASSES) and self._is_terrain_flat(lat, lon) + def route( self, start_lat: float, @@ -598,8 +654,16 @@ class OffrouteRouter: so the first status="ok" is the most road-capable feasible mode. The chosen mode is reported back as result["selected_mode"] for the UI. """ + # Vehicle skips the off-network gate, so only probe it when BOTH endpoints + # are vehicle-eligible (on/near a safe paved+flat road); otherwise drop vehicle + # and start at atv so genuinely off-road routes demo multi-mode behavior. + if self._vehicle_eligible(start_lat, start_lon) and self._vehicle_eligible(end_lat, end_lon): + priority = AUTO_MODE_PRIORITY + else: + priority = AUTO_MODE_PRIORITY[1:] + last_error = None - for candidate in AUTO_MODE_PRIORITY: + for candidate in priority: result = self.route( start_lat, start_lon, end_lat, end_lon, mode=candidate, boundary_mode=boundary_mode diff --git a/backend/services/navi_offroute/tests/test_offroute.py b/backend/services/navi_offroute/tests/test_offroute.py index 10e38db..e8e3396 100644 --- a/backend/services/navi_offroute/tests/test_offroute.py +++ b/backend/services/navi_offroute/tests/test_offroute.py @@ -272,11 +272,39 @@ def _stub_route(per_mode, calls): return stub +def _stub_locate(snap_distance_m, road_class=None): + def stub(self, lat, lon, mode="vehicle"): + return { + "on_network": snap_distance_m <= 10, + "snap_distance_m": snap_distance_m, + "snapped_lat": lat, + "snapped_lon": lon, + "road_class": road_class, + } + return stub + + +def _bare_router(monkeypatch, *, route_stub=None, vehicle_eligible=None, + locate=None, terrain_flat=None): + if route_stub is not None: + monkeypatch.setattr(OffrouteRouter, "route", route_stub) + if vehicle_eligible is not None: + monkeypatch.setattr(OffrouteRouter, "_vehicle_eligible", + lambda self, lat, lon: vehicle_eligible) + if locate is not None: + monkeypatch.setattr(OffrouteRouter, "_locate_on_network", locate) + if terrain_flat is not None: + monkeypatch.setattr(OffrouteRouter, "_is_terrain_flat", + lambda self, lat, lon: terrain_flat) + return object.__new__(OffrouteRouter) + + +# ── probe-iteration logic (vehicle eligibility stubbed True) ────────────── + def test_route_auto_first_probe_ok_returns_vehicle(monkeypatch): calls = [] per_mode = {m: {"status": "ok"} for m in AUTO_MODE_PRIORITY} - monkeypatch.setattr(OffrouteRouter, "route", _stub_route(per_mode, calls)) - r = object.__new__(OffrouteRouter) + r = _bare_router(monkeypatch, route_stub=_stub_route(per_mode, calls), vehicle_eligible=True) out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic") assert out["status"] == "ok" assert out["selected_mode"] == "vehicle" @@ -291,8 +319,7 @@ def test_route_auto_falls_through_to_foot(monkeypatch): "mtb": {"status": "error", "message": "No tracks found"}, "foot": {"status": "ok"}, } - monkeypatch.setattr(OffrouteRouter, "route", _stub_route(per_mode, calls)) - r = object.__new__(OffrouteRouter) + r = _bare_router(monkeypatch, route_stub=_stub_route(per_mode, calls), vehicle_eligible=True) out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic") assert out["status"] == "ok" assert out["selected_mode"] == "foot" @@ -302,8 +329,7 @@ def test_route_auto_falls_through_to_foot(monkeypatch): def test_route_auto_all_error_returns_error(monkeypatch): calls = [] per_mode = {m: {"status": "error", "message": f"{m} failed"} for m in AUTO_MODE_PRIORITY} - monkeypatch.setattr(OffrouteRouter, "route", _stub_route(per_mode, calls)) - r = object.__new__(OffrouteRouter) + r = _bare_router(monkeypatch, route_stub=_stub_route(per_mode, calls), vehicle_eligible=True) out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic") assert out["status"] == "error" assert "selected_mode" not in out @@ -318,8 +344,65 @@ def test_route_auto_selected_mode_present_in_ok_response(monkeypatch): "mtb": {"status": "ok"}, "foot": {"status": "ok"}, } - monkeypatch.setattr(OffrouteRouter, "route", _stub_route(per_mode, calls)) - r = object.__new__(OffrouteRouter) + r = _bare_router(monkeypatch, route_stub=_stub_route(per_mode, calls), vehicle_eligible=True) out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic") assert out["status"] == "ok" assert "selected_mode" in out and out["selected_mode"] == "atv" + + +# ── 3-tier vehicle eligibility (real _vehicle_eligible via stubbed locate/terrain) ── + +def test_vehicle_eligibility_a_on_road_picks_vehicle(monkeypatch): + # (a) snap 3m -> tight branch -> vehicle always ok + calls = [] + per_mode = {m: {"status": "ok"} for m in AUTO_MODE_PRIORITY} + r = _bare_router(monkeypatch, route_stub=_stub_route(per_mode, calls), + locate=_stub_locate(3.0, "residential"), terrain_flat=False) + out = r._route_auto(43.6, -116.2, 43.7, -116.3, "pragmatic") + assert out["selected_mode"] == "vehicle" + assert calls == ["vehicle"] + + +def test_vehicle_eligibility_b_relaxed_paved_flat_picks_vehicle(monkeypatch): + # (b) snap 60m + paved + flat -> relaxed branch grants vehicle + calls = [] + per_mode = {m: {"status": "ok"} for m in AUTO_MODE_PRIORITY} + r = _bare_router(monkeypatch, route_stub=_stub_route(per_mode, calls), + locate=_stub_locate(60.0, "secondary"), terrain_flat=True) + out = r._route_auto(43.6, -116.2, 43.7, -116.3, "pragmatic") + assert out["selected_mode"] == "vehicle" + assert calls == ["vehicle"] + + +def test_vehicle_eligibility_c_relaxed_paved_not_flat_skips_vehicle(monkeypatch): + # (c) snap 60m + paved + NOT flat -> vehicle skipped + calls = [] + per_mode = {m: {"status": "ok"} for m in AUTO_MODE_PRIORITY} + r = _bare_router(monkeypatch, route_stub=_stub_route(per_mode, calls), + locate=_stub_locate(60.0, "secondary"), terrain_flat=False) + out = r._route_auto(43.6, -116.2, 43.7, -116.3, "pragmatic") + assert out["selected_mode"] == "atv" + assert "vehicle" not in calls + assert calls[0] == "atv" + + +def test_vehicle_eligibility_d_relaxed_not_paved_skips_vehicle(monkeypatch): + # (d) snap 60m + NOT paved + flat -> vehicle skipped + calls = [] + per_mode = {m: {"status": "ok"} for m in AUTO_MODE_PRIORITY} + r = _bare_router(monkeypatch, route_stub=_stub_route(per_mode, calls), + locate=_stub_locate(60.0, "track"), terrain_flat=True) + out = r._route_auto(43.6, -116.2, 43.7, -116.3, "pragmatic") + assert out["selected_mode"] == "atv" + assert "vehicle" not in calls + + +def test_vehicle_eligibility_e_over_relaxed_limit_skips_vehicle(monkeypatch): + # (e) snap 150m -> beyond relaxed limit -> vehicle skipped regardless of paved/flat + calls = [] + per_mode = {m: {"status": "ok"} for m in AUTO_MODE_PRIORITY} + r = _bare_router(monkeypatch, route_stub=_stub_route(per_mode, calls), + locate=_stub_locate(150.0, "primary"), terrain_flat=True) + out = r._route_auto(43.6, -116.2, 43.7, -116.3, "pragmatic") + assert out["selected_mode"] == "atv" + assert "vehicle" not in calls