diff --git a/backend/services/navi_offroute/router.py b/backend/services/navi_offroute/router.py index 3ee044d..40f444b 100755 --- a/backend/services/navi_offroute/router.py +++ b/backend/services/navi_offroute/router.py @@ -68,6 +68,10 @@ MODE_TO_COSTING = { "vehicle": "auto", } +# Auto mode probes these concrete modes in capability order (most -> least +# demanding terrain) and uses the first that yields a usable route. +AUTO_MODE_PRIORITY = ["vehicle", "atv", "mtb", "foot"] + # Mode to valid entry point highway classes # foot = any trail/track/road, mtb = tracks and roads, vehicle = roads only MODE_TO_VALID_HIGHWAYS = { @@ -511,7 +515,7 @@ class OffrouteRouter: start_lon: float, end_lat: float, end_lon: float, - mode: Literal["foot", "mtb", "atv", "vehicle"] = "foot", + mode: Literal["auto", "foot", "mtb", "atv", "vehicle"] = "foot", boundary_mode: Literal["strict", "pragmatic", "emergency"] = "pragmatic" ) -> Dict: """ @@ -531,6 +535,11 @@ class OffrouteRouter: Returns a GeoJSON FeatureCollection with route segments. """ + if mode == "auto": + return self._route_auto( + start_lat, start_lon, end_lat, end_lon, boundary_mode + ) + if mode not in MODE_TO_COSTING: return {"status": "error", "message": f"Unknown mode: {mode}"} @@ -563,6 +572,37 @@ class OffrouteRouter: start_lat, start_lon, end_lat, end_lon, mode, boundary_mode ) + def _route_auto( + self, + start_lat: float, start_lon: float, + end_lat: float, end_lon: float, + boundary_mode: str + ) -> Dict: + """ + Auto mode: pick the best concrete travel mode by terrain feasibility. + + Probes AUTO_MODE_PRIORITY (vehicle -> atv -> mtb -> foot) and returns the + first mode whose network can serve the route. Each candidate's route() + already reports status="error" when its network can't reach an endpoint, + 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. + """ + last_error = None + for candidate in AUTO_MODE_PRIORITY: + result = self.route( + start_lat, start_lon, end_lat, end_lon, + mode=candidate, boundary_mode=boundary_mode + ) + if result.get("status") == "ok": + result["selected_mode"] = candidate + return result + last_error = result + + return last_error or { + "status": "error", + "message": "No route found in any mode" + } + def _route_D_network_only( self, start_lat: float, start_lon: float, diff --git a/backend/services/navi_offroute/tests/test_offroute.py b/backend/services/navi_offroute/tests/test_offroute.py index 2531782..10e38db 100644 --- a/backend/services/navi_offroute/tests/test_offroute.py +++ b/backend/services/navi_offroute/tests/test_offroute.py @@ -256,3 +256,70 @@ def test_admin_info_no_secrets_and_probes(client, monkeypatch): assert {'dem', 'osm_pbf', 'navi_db', 'barriers_tif', 'wilderness_tif', 'trails_tif', 'friction_vrt'} == fs_names assert all(set(f) == {'name', 'path', 'exists', 'readable'} for f in d['filesystem']) + + +# ── OffrouteRouter._route_auto — feasibility-based mode selection ───────── +# Tested in isolation: a bare router (no __init__/readers) with OffrouteRouter.route +# monkeypatched to a per-mode fixture. Exercises _route_auto directly, not the blueprint. + +from services.navi_offroute.router import OffrouteRouter, AUTO_MODE_PRIORITY + + +def _stub_route(per_mode, calls): + def stub(self, start_lat, start_lon, end_lat, end_lon, mode="foot", boundary_mode="pragmatic"): + calls.append(mode) + return dict(per_mode[mode]) + return stub + + +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) + out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic") + assert out["status"] == "ok" + assert out["selected_mode"] == "vehicle" + assert calls == ["vehicle"] # stops probing after first success + + +def test_route_auto_falls_through_to_foot(monkeypatch): + calls = [] + per_mode = { + "vehicle": {"status": "error", "message": "No roads found"}, + "atv": {"status": "error", "message": "No tracks found"}, + "mtb": {"status": "error", "message": "No tracks found"}, + "foot": {"status": "ok"}, + } + 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"] == "foot" + assert calls == ["vehicle", "atv", "mtb", "foot"] + + +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) + out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic") + assert out["status"] == "error" + assert "selected_mode" not in out + assert calls == ["vehicle", "atv", "mtb", "foot"] + + +def test_route_auto_selected_mode_present_in_ok_response(monkeypatch): + calls = [] + per_mode = { + "vehicle": {"status": "error", "message": "No roads found"}, + "atv": {"status": "ok", "route": {"type": "FeatureCollection", "features": []}}, + "mtb": {"status": "ok"}, + "foot": {"status": "ok"}, + } + 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 "selected_mode" in out and out["selected_mode"] == "atv"