Auto picks min-time mode + per-leg breakdown (#21)

_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: Matt <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-05-25 16:03:08 -06:00 committed by GitHub
commit a1785127de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 79 additions and 16 deletions

View file

@ -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,

View file

@ -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

View file

@ -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>
)}