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 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue