diff --git a/backend/services/navi_offroute/router.py b/backend/services/navi_offroute/router.py index 82d7fe6..0afeca1 100755 --- a/backend/services/navi_offroute/router.py +++ b/backend/services/navi_offroute/router.py @@ -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,10 +802,18 @@ 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 - last_error = 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 @@ -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, diff --git a/backend/services/navi_offroute/tests/test_offroute.py b/backend/services/navi_offroute/tests/test_offroute.py index fbad7b4..e34a495 100644 --- a/backend/services/navi_offroute/tests/test_offroute.py +++ b/backend/services/navi_offroute/tests/test_offroute.py @@ -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 diff --git a/frontend/src/components/DirectionsPanel.jsx b/frontend/src/components/DirectionsPanel.jsx index b925422..beb590c 100644 --- a/frontend/src/components/DirectionsPanel.jsx +++ b/frontend/src/components/DirectionsPanel.jsx @@ -316,14 +316,18 @@ export default function DirectionsPanel({ onClose }) { })} - {/* 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 && (
- {`Auto chose ${SELECTED_MODE_LABEL[routeResult.selected_mode] || routeResult.selected_mode}`} + { + (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}` + }
)}