mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-08-26 17:31:37 +00:00
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:
parent
0d503bb4c8
commit
5894be6d03
2 changed files with 158 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue