mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-08-26 17:31:37 +00:00
Auto picks min-time mode + per-leg breakdown
_route_auto now probes every eligible candidate (eligibility filter unchanged) and picks the one with the smallest summary.total_effort_minutes, instead of returning the first that succeeds. Fixes the case where a wilderness start + on-road end fell through to foot and computed the entire network leg at foot pace. selected_mode = winning candidate; ties keep AUTO_MODE_PRIORITY order; all-fail still returns the last error. Wilderness leg still always foot (unchanged). Per-leg breakdown: summary now carries wilderness_minutes + network_minutes (mirrors the existing wilderness_effort_minutes/network_duration_minutes) in _build_response (A/B/C) and _route_D. Frontend DirectionsPanel shows a transition badge "Auto: Foot Xmin + <mode> Ymin" when wilderness_minutes>0 and selected_mode!=foot, else the existing "Auto chose <mode>". Tests: add min-time pick + per-leg breakdown; update probe-all assertions (no early return) and make the selected-mode test time-based. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ede6852657
commit
e054a4f933
3 changed files with 79 additions and 16 deletions
|
|
@ -751,9 +751,9 @@ class OffrouteRouter:
|
|||
|
||||
Each endpoint's eligible modes come from its category type-hint
|
||||
(CATEGORY_ELIGIBLE_MODES); an untyped endpoint falls back to a spatial
|
||||
Valhalla-snap probe. Auto probes only the intersection of both endpoints'
|
||||
eligible sets, in AUTO_MODE_PRIORITY order, returning the first route() that
|
||||
succeeds. selected_mode + selected_mode_set are added for visibility.
|
||||
Valhalla-snap probe. Auto probes the intersection of both endpoints'
|
||||
eligible sets and returns the candidate that minimises total trip time.
|
||||
selected_mode + selected_mode_set are added for visibility.
|
||||
"""
|
||||
snap_cache = {}
|
||||
start_typed = self._eligible_modes_from_category(start_category)
|
||||
|
|
@ -788,6 +788,13 @@ class OffrouteRouter:
|
|||
|
||||
priority = [m for m in AUTO_MODE_PRIORITY if m in intersection]
|
||||
|
||||
# Probe every eligible candidate and pick the one that minimises total trip
|
||||
# time, NOT the first that succeeds. When the start is in wilderness and the
|
||||
# end is on-road, falling through to foot would compute the whole network leg
|
||||
# at foot pace. The wilderness leg always uses foot regardless; the candidate
|
||||
# only changes the network leg.
|
||||
best_result = None
|
||||
best_minutes = None
|
||||
last_error = None
|
||||
for candidate in priority:
|
||||
result = self.route(
|
||||
|
|
@ -795,11 +802,19 @@ class OffrouteRouter:
|
|||
mode=candidate, boundary_mode=boundary_mode
|
||||
)
|
||||
if result.get("status") == "ok":
|
||||
result["selected_mode"] = candidate
|
||||
result["selected_mode_set"] = mode_set
|
||||
return result
|
||||
minutes = (result.get("summary") or {}).get(
|
||||
"total_effort_minutes", float("inf"))
|
||||
if best_minutes is None or minutes < best_minutes:
|
||||
best_minutes = minutes
|
||||
best_result = result
|
||||
best_result["selected_mode"] = candidate
|
||||
else:
|
||||
last_error = result
|
||||
|
||||
if best_result is not None:
|
||||
best_result["selected_mode_set"] = mode_set
|
||||
return best_result
|
||||
|
||||
if last_error is not None:
|
||||
last_error["selected_mode_set"] = mode_set
|
||||
return last_error
|
||||
|
|
@ -898,6 +913,8 @@ class OffrouteRouter:
|
|||
"wilderness_effort_minutes": 0.0,
|
||||
"network_distance_km": float(distance_km),
|
||||
"network_duration_minutes": float(duration_min),
|
||||
"wilderness_minutes": 0.0,
|
||||
"network_minutes": float(duration_min),
|
||||
"on_trail_pct": 100.0,
|
||||
"barrier_crossings": 0,
|
||||
"network_mode": mode,
|
||||
|
|
@ -1800,6 +1817,8 @@ class OffrouteRouter:
|
|||
"wilderness_effort_minutes": float(wilderness_effort_minutes),
|
||||
"network_distance_km": float(network_distance_km),
|
||||
"network_duration_minutes": float(network_duration_minutes),
|
||||
"wilderness_minutes": float(wilderness_effort_minutes),
|
||||
"network_minutes": float(network_duration_minutes),
|
||||
"on_trail_pct": float(on_trail_pct),
|
||||
"barrier_crossings": barrier_crossings,
|
||||
"boundary_mode": boundary_mode,
|
||||
|
|
|
|||
|
|
@ -312,7 +312,9 @@ def _typed_all(monkeypatch):
|
|||
lambda self, cat: ALL_MODES)
|
||||
|
||||
|
||||
def test_route_auto_first_probe_ok_returns_vehicle(monkeypatch):
|
||||
def test_route_auto_equal_times_keep_priority(monkeypatch):
|
||||
# All candidates succeed with no recorded time -> tie -> highest priority wins,
|
||||
# and every candidate is probed (no early return under min-time selection).
|
||||
_typed_all(monkeypatch)
|
||||
calls = []
|
||||
monkeypatch.setattr(OffrouteRouter, "route", _stub_route(_all_ok(), calls))
|
||||
|
|
@ -321,7 +323,7 @@ def test_route_auto_first_probe_ok_returns_vehicle(monkeypatch):
|
|||
assert out["status"] == "ok"
|
||||
assert out["selected_mode"] == "vehicle"
|
||||
assert out["selected_mode_set"] == sorted(ALL_MODES)
|
||||
assert calls == ["vehicle"]
|
||||
assert calls == ["vehicle", "4w", "2w", "foot"]
|
||||
|
||||
|
||||
def test_route_auto_falls_through_to_foot(monkeypatch):
|
||||
|
|
@ -359,15 +361,15 @@ def test_route_auto_selected_mode_present_in_ok_response(monkeypatch):
|
|||
calls = []
|
||||
per_mode = {
|
||||
"vehicle": {"status": "error", "message": "No roads found"},
|
||||
"4w": {"status": "ok", "route": {"type": "FeatureCollection", "features": []}},
|
||||
"2w": {"status": "ok"},
|
||||
"foot": {"status": "ok"},
|
||||
"4w": {"status": "ok", "summary": {"total_effort_minutes": 90.0}},
|
||||
"2w": {"status": "ok", "summary": {"total_effort_minutes": 40.0}},
|
||||
"foot": {"status": "ok", "summary": {"total_effort_minutes": 600.0}},
|
||||
}
|
||||
monkeypatch.setattr(OffrouteRouter, "route", _stub_route(per_mode, calls))
|
||||
r = object.__new__(OffrouteRouter)
|
||||
out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic")
|
||||
assert out["status"] == "ok"
|
||||
assert out["selected_mode"] == "4w"
|
||||
assert out["selected_mode"] == "2w" # fastest success wins
|
||||
|
||||
|
||||
# ── _route_auto with category type hints (real _eligible_modes_from_category) ──
|
||||
|
|
@ -379,7 +381,7 @@ def test_route_auto_address_to_address_picks_vehicle(monkeypatch):
|
|||
out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic",
|
||||
start_category="building:house", end_category="highway:residential")
|
||||
assert out["selected_mode"] == "vehicle"
|
||||
assert calls == ["vehicle"]
|
||||
assert calls == ["vehicle", "4w", "2w", "foot"]
|
||||
|
||||
|
||||
def test_route_auto_address_to_trailhead_picks_atv(monkeypatch):
|
||||
|
|
@ -823,3 +825,41 @@ def test_pathfind_wilderness_bbox_pad_is_1_5km(monkeypatch):
|
|||
assert abs((bounds["north"] - 44.0) - 0.015) < 1e-9
|
||||
assert abs((44.0 - bounds["south"]) - 0.015) < 1e-9
|
||||
assert abs((bounds["east"] - (-115.0)) - 0.015) < 1e-9
|
||||
|
||||
|
||||
# ── Auto min-time selection + per-leg breakdown (feat/auto-picks-fastest) ──
|
||||
|
||||
def test_route_auto_picks_min_time(monkeypatch):
|
||||
# vehicle is first in AUTO_MODE_PRIORITY but slow; 4w is fastest -> 4w wins.
|
||||
_typed_all(monkeypatch)
|
||||
calls = []
|
||||
per_mode = {
|
||||
"vehicle": {"status": "ok", "summary": {"total_effort_minutes": 200.0}},
|
||||
"4w": {"status": "ok", "summary": {"total_effort_minutes": 50.0}},
|
||||
"2w": {"status": "ok", "summary": {"total_effort_minutes": 120.0}},
|
||||
"foot": {"status": "ok", "summary": {"total_effort_minutes": 800.0}},
|
||||
}
|
||||
monkeypatch.setattr(OffrouteRouter, "route", _stub_route(per_mode, calls))
|
||||
r = object.__new__(OffrouteRouter)
|
||||
out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic")
|
||||
assert out["status"] == "ok"
|
||||
assert out["selected_mode"] == "4w" # fastest, not first-priority
|
||||
assert calls == ["vehicle", "4w", "2w", "foot"] # probed every candidate
|
||||
|
||||
|
||||
def test_route_auto_per_leg_breakdown():
|
||||
# Scenario A (_build_response): foot wilderness leg + network leg -> both > 0.
|
||||
r = object.__new__(OffrouteRouter)
|
||||
ws = [[-116.20, 43.60], [-116.21, 43.61]]
|
||||
ws_stats = {"effort_minutes": 12.0, "distance_km": 1.0, "elevation_gain_m": 10.0,
|
||||
"elevation_loss_m": 5.0, "on_trail_pct": 50.0, "barrier_crossings": 0}
|
||||
net = {"distance_km": 60.0, "duration_minutes": 45.0, "maneuvers": [],
|
||||
"coordinates": [[-116.21, 43.61], [-116.30, 43.70]]}
|
||||
out = r._build_response(ws, ws_stats, None, net, None, None, None,
|
||||
"vehicle", "pragmatic", None, None, "A", 0.0, None)
|
||||
assert out["status"] == "ok"
|
||||
summ = out["summary"]
|
||||
assert summ["wilderness_minutes"] > 0
|
||||
assert summ["network_minutes"] > 0
|
||||
# approx adds up to total
|
||||
assert abs((summ["wilderness_minutes"] + summ["network_minutes"]) - summ["total_effort_minutes"]) < 1e-6
|
||||
|
|
|
|||
|
|
@ -316,14 +316,18 @@ export default function DirectionsPanel({ onClose }) {
|
|||
})}
|
||||
</div>
|
||||
|
||||
{/* Auto mode: show which travel mode feasibility picked */}
|
||||
{/* Auto mode: show which travel mode was picked (+ leg breakdown on transitions) */}
|
||||
{routeMode === "auto" && routeResult?.selected_mode && (
|
||||
<div
|
||||
className="flex items-center justify-center gap-1 py-1.5 text-xs rounded-lg"
|
||||
style={{ background: "var(--accent-muted)", color: "var(--accent)" }}
|
||||
>
|
||||
<Zap size={14} />
|
||||
<span>{`Auto chose ${SELECTED_MODE_LABEL[routeResult.selected_mode] || routeResult.selected_mode}`}</span>
|
||||
<span>{
|
||||
(routeResult?.summary?.wilderness_minutes > 0 && routeResult.selected_mode !== "foot")
|
||||
? `Auto: Foot ${Math.round(routeResult.summary.wilderness_minutes)}min + ${SELECTED_MODE_LABEL[routeResult.selected_mode] || routeResult.selected_mode} ${Math.round(routeResult.summary.network_minutes)}min`
|
||||
: `Auto chose ${SELECTED_MODE_LABEL[routeResult.selected_mode] || routeResult.selected_mode}`
|
||||
}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue