fix(offroute): Auto vehicle eligibility — 3-tier paved+flat snap discriminator

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) <noreply@anthropic.com>
This commit is contained in:
Matt 2026-05-25 01:29:03 +00:00
commit 5894be6d03
2 changed files with 158 additions and 11 deletions

View file

@ -59,6 +59,24 @@ MEMORY_LIMIT_GB = 12
# Off-network detection threshold (meters) # Off-network detection threshold (meters)
OFF_NETWORK_THRESHOLD_M = 10 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 Valhalla costing mapping
MODE_TO_COSTING = { MODE_TO_COSTING = {
"auto": "auto", "auto": "auto",
@ -497,7 +515,8 @@ class OffrouteRouter:
"on_network": snap_dist <= OFF_NETWORK_THRESHOLD_M, "on_network": snap_dist <= OFF_NETWORK_THRESHOLD_M,
"snap_distance_m": snap_dist, "snap_distance_m": snap_dist,
"snapped_lat": snap_lat, "snapped_lat": snap_lat,
"snapped_lon": snap_lon "snapped_lon": snap_lon,
"road_class": edge.get("road_class"),
} }
except Exception: except Exception:
pass pass
@ -506,9 +525,46 @@ class OffrouteRouter:
"on_network": False, "on_network": False,
"snap_distance_m": float('inf'), "snap_distance_m": float('inf'),
"snapped_lat": lat, "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( def route(
self, self,
start_lat: float, start_lat: float,
@ -598,8 +654,16 @@ class OffrouteRouter:
so the first status="ok" is the most road-capable feasible mode. The chosen 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. 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 last_error = None
for candidate in AUTO_MODE_PRIORITY: for candidate in priority:
result = self.route( result = self.route(
start_lat, start_lon, end_lat, end_lon, start_lat, start_lon, end_lat, end_lon,
mode=candidate, boundary_mode=boundary_mode mode=candidate, boundary_mode=boundary_mode

View file

@ -272,11 +272,39 @@ def _stub_route(per_mode, calls):
return stub 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): def test_route_auto_first_probe_ok_returns_vehicle(monkeypatch):
calls = [] calls = []
per_mode = {m: {"status": "ok"} for m in AUTO_MODE_PRIORITY} per_mode = {m: {"status": "ok"} for m in AUTO_MODE_PRIORITY}
monkeypatch.setattr(OffrouteRouter, "route", _stub_route(per_mode, calls)) r = _bare_router(monkeypatch, route_stub=_stub_route(per_mode, calls), vehicle_eligible=True)
r = object.__new__(OffrouteRouter)
out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic") out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic")
assert out["status"] == "ok" assert out["status"] == "ok"
assert out["selected_mode"] == "vehicle" 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"}, "mtb": {"status": "error", "message": "No tracks found"},
"foot": {"status": "ok"}, "foot": {"status": "ok"},
} }
monkeypatch.setattr(OffrouteRouter, "route", _stub_route(per_mode, calls)) r = _bare_router(monkeypatch, route_stub=_stub_route(per_mode, calls), vehicle_eligible=True)
r = object.__new__(OffrouteRouter)
out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic") out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic")
assert out["status"] == "ok" assert out["status"] == "ok"
assert out["selected_mode"] == "foot" 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): def test_route_auto_all_error_returns_error(monkeypatch):
calls = [] calls = []
per_mode = {m: {"status": "error", "message": f"{m} failed"} for m in AUTO_MODE_PRIORITY} 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 = _bare_router(monkeypatch, route_stub=_stub_route(per_mode, calls), vehicle_eligible=True)
r = object.__new__(OffrouteRouter)
out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic") out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic")
assert out["status"] == "error" assert out["status"] == "error"
assert "selected_mode" not in out assert "selected_mode" not in out
@ -318,8 +344,65 @@ def test_route_auto_selected_mode_present_in_ok_response(monkeypatch):
"mtb": {"status": "ok"}, "mtb": {"status": "ok"},
"foot": {"status": "ok"}, "foot": {"status": "ok"},
} }
monkeypatch.setattr(OffrouteRouter, "route", _stub_route(per_mode, calls)) r = _bare_router(monkeypatch, route_stub=_stub_route(per_mode, calls), vehicle_eligible=True)
r = object.__new__(OffrouteRouter)
out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic") out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic")
assert out["status"] == "ok" assert out["status"] == "ok"
assert "selected_mode" in out and out["selected_mode"] == "atv" 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