diff --git a/backend/services/navi_offroute/offroute_route.py b/backend/services/navi_offroute/offroute_route.py index e4a64c1..be15aa9 100644 --- a/backend/services/navi_offroute/offroute_route.py +++ b/backend/services/navi_offroute/offroute_route.py @@ -60,12 +60,18 @@ def api_offroute(): if boundary_mode not in VALID_BOUNDARY_MODES: return jsonify({"status": "error", "message": "boundary_mode must be strict, pragmatic, or emergency"}), 400 + # Optional per-endpoint OSM "key:value" category hints. Only used by Auto + # mode to gate eligible travel modes; ignored for concrete modes. + start_category = data.get("start_category") + end_category = data.get("end_category") + router = OffrouteRouter() try: result = router.route( start_lat=start_lat, start_lon=start_lon, end_lat=end_lat, end_lon=end_lon, mode=mode, boundary_mode=boundary_mode, + start_category=start_category, end_category=end_category, ) finally: router.close() diff --git a/backend/services/navi_offroute/router.py b/backend/services/navi_offroute/router.py index 8ab0fd9..322c62c 100755 --- a/backend/services/navi_offroute/router.py +++ b/backend/services/navi_offroute/router.py @@ -21,6 +21,7 @@ import os import subprocess import tempfile import time +from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from typing import Dict, List, Optional, Tuple, Literal, Set @@ -59,6 +60,23 @@ MEMORY_LIMIT_GB = 12 # Off-network detection threshold (meters) OFF_NETWORK_THRESHOLD_M = 10 +# Auto-mode spatial-eligibility snap thresholds (meters). +AUTO_SNAP_TIGHT_M = 5 # on the edge -> eligible without a flatness check +AUTO_SNAP_RELAXED_M = 100 # near the edge -> vehicle needs paved + flat terrain +# Terrain-flatness probe for vehicle's relaxed-snap grace. +FLAT_TERRAIN_DELTA_M = 5 +FLAT_SAMPLE_RADIUS_M = 50 + +# Highway-class buckets used by the spatial eligibility rules. +PAVED_HIGHWAY_CLASSES = frozenset({ + "motorway", "trunk", "primary", "secondary", "tertiary", + "unclassified", "residential", "service", +}) +TRACK_HIGHWAY_CLASSES = frozenset({"track"}) +PATH_HIGHWAY_CLASSES = frozenset({ + "path", "footway", "bridleway", "steps", "pedestrian", "cycleway", +}) + # Mode to Valhalla costing mapping MODE_TO_COSTING = { "auto": "auto", @@ -72,6 +90,43 @@ MODE_TO_COSTING = { # demanding terrain) and uses the first that yields a usable route. AUTO_MODE_PRIORITY = ["vehicle", "atv", "mtb", "foot"] +# Per-endpoint travel-mode eligibility from an OSM-style "key:value" category hint. +# Looked up exact first, then "key:*" wildcard (see _eligible_modes_from_category). +_MODES_ALL = frozenset({"vehicle", "atv", "mtb", "foot"}) +_MODES_TRACK = frozenset({"atv", "mtb", "foot"}) +_MODES_PATH = frozenset({"mtb", "foot"}) +_MODES_FOOT = frozenset({"foot"}) + +CATEGORY_ELIGIBLE_MODES = { + # Address-like -> full access + "highway:motorway": _MODES_ALL, "highway:trunk": _MODES_ALL, + "highway:primary": _MODES_ALL, "highway:secondary": _MODES_ALL, + "highway:tertiary": _MODES_ALL, "highway:unclassified": _MODES_ALL, + "highway:residential": _MODES_ALL, "highway:service": _MODES_ALL, + "building:*": _MODES_ALL, "amenity:*": _MODES_ALL, + "shop:*": _MODES_ALL, "office:*": _MODES_ALL, + "tourism:hotel": _MODES_ALL, "tourism:motel": _MODES_ALL, + "tourism:guest_house": _MODES_ALL, "tourism:hostel": _MODES_ALL, + "tourism:apartment": _MODES_ALL, + "leisure:park": _MODES_ALL, + "place:city": _MODES_ALL, "place:town": _MODES_ALL, + "place:village": _MODES_ALL, "place:hamlet": _MODES_ALL, + "place:suburb": _MODES_ALL, "place:neighbourhood": _MODES_ALL, + "railway:station": _MODES_ALL, + # Track-like -> atv/mtb/foot + "highway:track": _MODES_TRACK, "highway:trailhead": _MODES_TRACK, + # Path-like -> mtb/foot + "highway:path": _MODES_PATH, "highway:bridleway": _MODES_PATH, + # Foot-only + "highway:footway": _MODES_FOOT, "highway:steps": _MODES_FOOT, + "highway:pedestrian": _MODES_FOOT, + "natural:*": _MODES_FOOT, + "place:roadless_area": _MODES_FOOT, "place:protected_area": _MODES_FOOT, + "landuse:forest": _MODES_FOOT, "landuse:nature_reserve": _MODES_FOOT, + "tourism:camp_site": _MODES_FOOT, "tourism:picnic_site": _MODES_FOOT, + "tourism:viewpoint": _MODES_FOOT, +} + # Mode to valid entry point highway classes # foot = any trail/track/road, mtb = tracks and roads, vehicle = roads only MODE_TO_VALID_HIGHWAYS = { @@ -497,7 +552,8 @@ class OffrouteRouter: "on_network": snap_dist <= OFF_NETWORK_THRESHOLD_M, "snap_distance_m": snap_dist, "snapped_lat": snap_lat, - "snapped_lon": snap_lon + "snapped_lon": snap_lon, + "road_class": edge.get("road_class"), } except Exception: pass @@ -506,7 +562,8 @@ class OffrouteRouter: "on_network": False, "snap_distance_m": float('inf'), "snapped_lat": lat, - "snapped_lon": lon + "snapped_lon": lon, + "road_class": None, } def route( @@ -516,7 +573,9 @@ class OffrouteRouter: end_lat: float, end_lon: float, mode: Literal["auto", "foot", "mtb", "atv", "vehicle"] = "foot", - boundary_mode: Literal["strict", "pragmatic", "emergency"] = "pragmatic" + boundary_mode: Literal["strict", "pragmatic", "emergency"] = "pragmatic", + start_category: Optional[str] = None, + end_category: Optional[str] = None ) -> Dict: """ Route between two points, handling all four scenarios. @@ -537,7 +596,8 @@ class OffrouteRouter: """ if mode == "auto": return self._route_auto( - start_lat, start_lon, end_lat, end_lon, boundary_mode + start_lat, start_lon, end_lat, end_lon, boundary_mode, + start_category, end_category ) if mode not in MODE_TO_COSTING: @@ -583,35 +643,141 @@ class OffrouteRouter: start_lat, start_lon, end_lat, end_lon, mode, boundary_mode ) + def _eligible_modes_from_category(self, category: Optional[str]): + """Eligible travel modes for an OSM "key:value" category hint, or None if the + category is empty/unknown. Exact match first, then a "key:*" wildcard.""" + if not category: + return None + modes = CATEGORY_ELIGIBLE_MODES.get(category) + if modes is not None: + return modes + if ":" in category: + key = category.split(":", 1)[0] + return CATEGORY_ELIGIBLE_MODES.get(f"{key}:*") + return None + + def _is_terrain_flat(self, lat: float, lon: float) -> bool: + """True if the DEM is flat (max-min < 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 unknown terrain earns no 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)))) + pts = [(lat, lon), (lat + dlat, lon), (lat - dlat, lon), + (lat, lon + dlon), (lat, lon - dlon)] + elevs = [self.dem_reader.sample_point(la, lo) for la, lo in pts] + 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 _spatial_eligible_modes(self, lat: float, lon: float, snap_cache: dict): + """Eligible modes for an UNTYPED endpoint, derived from Valhalla /locate snaps. + Runs the three distinct costings (auto/pedestrian/bicycle) in parallel and + applies the per-mode snap-distance + road-class rules. snap_cache dedupes + /locate results within a single request.""" + # auto costing -> vehicle/atv reach, bicycle -> mtb reach, pedestrian -> foot + costing_modes = {"auto": "vehicle", "pedestrian": "foot", "bicycle": "mtb"} + need = [c for c in costing_modes if (lat, lon, c) not in snap_cache] + if need: + with ThreadPoolExecutor(max_workers=3) as ex: + futs = {ex.submit(self._locate_on_network, lat, lon, costing_modes[c]): c + for c in need} + for fut in as_completed(futs): + snap_cache[(lat, lon, futs[fut])] = fut.result() + + auto_snap = snap_cache[(lat, lon, "auto")] + bike_snap = snap_cache[(lat, lon, "bicycle")] + d_auto, cls_auto = auto_snap["snap_distance_m"], auto_snap.get("road_class") + d_bike, cls_bike = bike_snap["snap_distance_m"], bike_snap.get("road_class") + + modes = {"foot"} # foot is always eligible + # vehicle: on a paved road (tight), or near one if paved AND flat (relaxed) + if (d_auto <= AUTO_SNAP_TIGHT_M and cls_auto in PAVED_HIGHWAY_CLASSES) or \ + (d_auto <= AUTO_SNAP_RELAXED_M and cls_auto in PAVED_HIGHWAY_CLASSES + and self._is_terrain_flat(lat, lon)): + modes.add("vehicle") + # atv: on/near a paved or track edge + if d_auto <= AUTO_SNAP_RELAXED_M and cls_auto in (PAVED_HIGHWAY_CLASSES | TRACK_HIGHWAY_CLASSES): + modes.add("atv") + # mtb: on/near a paved, track, or path edge (bicycle costing) + if d_bike <= AUTO_SNAP_RELAXED_M and cls_bike in ( + PAVED_HIGHWAY_CLASSES | TRACK_HIGHWAY_CLASSES | PATH_HIGHWAY_CLASSES): + modes.add("mtb") + return frozenset(modes) + def _route_auto( self, start_lat: float, start_lon: float, end_lat: float, end_lon: float, - boundary_mode: str + boundary_mode: str, + start_category: Optional[str] = None, + end_category: Optional[str] = None ) -> Dict: """ - Auto mode: pick the best concrete travel mode by terrain feasibility. + Auto mode: per-endpoint eligible-mode-set intersection. - 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. + 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. """ + snap_cache = {} + start_typed = self._eligible_modes_from_category(start_category) + end_typed = self._eligible_modes_from_category(end_category) + + jobs = {} + if start_typed is None: + jobs["start"] = (start_lat, start_lon) + if end_typed is None: + jobs["end"] = (end_lat, end_lon) + + spatial = {} + if len(jobs) == 2: + # Both endpoints untyped: resolve them in parallel. + with ThreadPoolExecutor(max_workers=2) as ex: + futs = {ex.submit(self._spatial_eligible_modes, la, lo, snap_cache): name + for name, (la, lo) in jobs.items()} + for fut in as_completed(futs): + spatial[futs[fut]] = fut.result() + else: + for name, (la, lo) in jobs.items(): + spatial[name] = self._spatial_eligible_modes(la, lo, snap_cache) + + start_eligible = start_typed if start_typed is not None else spatial["start"] + end_eligible = end_typed if end_typed is not None else spatial["end"] + + intersection = start_eligible & end_eligible + if not intersection: + # foot is always eligible, so this is defensive only. + intersection = frozenset({"foot"}) + mode_set = sorted(intersection) + + priority = [m for m in AUTO_MODE_PRIORITY if m in intersection] + last_error = None - for candidate in AUTO_MODE_PRIORITY: + for candidate in 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 + result["selected_mode_set"] = mode_set return result last_error = result - return last_error or { + if last_error is not None: + last_error["selected_mode_set"] = mode_set + return last_error + return { "status": "error", - "message": "No route found in any mode" + "message": "No route found in any mode", + "selected_mode_set": mode_set, } def _route_D_network_only( diff --git a/backend/services/navi_offroute/tests/test_offroute.py b/backend/services/navi_offroute/tests/test_offroute.py index 10e38db..eeaabb6 100644 --- a/backend/services/navi_offroute/tests/test_offroute.py +++ b/backend/services/navi_offroute/tests/test_offroute.py @@ -258,12 +258,15 @@ def test_admin_info_no_secrets_and_probes(client, monkeypatch): 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. +# ── OffrouteRouter._route_auto — eligible-mode-set selection ────────────── +# Tested in isolation on a bare router (no __init__/readers): OffrouteRouter.route is +# monkeypatched per-mode; eligibility comes from category hints or a stubbed spatial +# fallback. Exercises _route_auto directly, not the Flask blueprint. from services.navi_offroute.router import OffrouteRouter, AUTO_MODE_PRIORITY +ALL_MODES = frozenset({"vehicle", "atv", "mtb", "foot"}) + def _stub_route(per_mode, calls): def stub(self, start_lat, start_lon, end_lat, end_lon, mode="foot", boundary_mode="pragmatic"): @@ -272,18 +275,57 @@ def _stub_route(per_mode, calls): return stub +def _all_ok(): + return {m: {"status": "ok"} for m in AUTO_MODE_PRIORITY} + + +# ── _eligible_modes_from_category ───────────────────────────────────────── + +def test_eligible_modes_exact_match(): + r = object.__new__(OffrouteRouter) + assert r._eligible_modes_from_category("highway:residential") == ALL_MODES + assert r._eligible_modes_from_category("highway:track") == frozenset({"atv", "mtb", "foot"}) + assert r._eligible_modes_from_category("highway:path") == frozenset({"mtb", "foot"}) + assert r._eligible_modes_from_category("highway:footway") == frozenset({"foot"}) + + +def test_eligible_modes_wildcard_match(): + r = object.__new__(OffrouteRouter) + # building:house -> building:* -> all modes + assert r._eligible_modes_from_category("building:house") == ALL_MODES + assert r._eligible_modes_from_category("amenity:cafe") == ALL_MODES + # natural:* -> foot only + assert r._eligible_modes_from_category("natural:peak") == frozenset({"foot"}) + + +def test_eligible_modes_none_or_unknown(): + r = object.__new__(OffrouteRouter) + assert r._eligible_modes_from_category(None) is None + assert r._eligible_modes_from_category("") is None + assert r._eligible_modes_from_category("bogus:thing") is None + + +# ── probe-iteration logic (both endpoints typed -> no spatial calls) ────── + +def _typed_all(monkeypatch): + monkeypatch.setattr(OffrouteRouter, "_eligible_modes_from_category", + lambda self, cat: ALL_MODES) + + def test_route_auto_first_probe_ok_returns_vehicle(monkeypatch): + _typed_all(monkeypatch) calls = [] - per_mode = {m: {"status": "ok"} for m in AUTO_MODE_PRIORITY} - monkeypatch.setattr(OffrouteRouter, "route", _stub_route(per_mode, calls)) + monkeypatch.setattr(OffrouteRouter, "route", _stub_route(_all_ok(), 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 + assert out["selected_mode_set"] == sorted(ALL_MODES) + assert calls == ["vehicle"] def test_route_auto_falls_through_to_foot(monkeypatch): + _typed_all(monkeypatch) calls = [] per_mode = { "vehicle": {"status": "error", "message": "No roads found"}, @@ -300,6 +342,7 @@ def test_route_auto_falls_through_to_foot(monkeypatch): def test_route_auto_all_error_returns_error(monkeypatch): + _typed_all(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)) @@ -307,10 +350,12 @@ def test_route_auto_all_error_returns_error(monkeypatch): out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic") assert out["status"] == "error" assert "selected_mode" not in out + assert out["selected_mode_set"] == sorted(ALL_MODES) assert calls == ["vehicle", "atv", "mtb", "foot"] def test_route_auto_selected_mode_present_in_ok_response(monkeypatch): + _typed_all(monkeypatch) calls = [] per_mode = { "vehicle": {"status": "error", "message": "No roads found"}, @@ -322,4 +367,54 @@ def test_route_auto_selected_mode_present_in_ok_response(monkeypatch): 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" + assert out["selected_mode"] == "atv" + + +# ── _route_auto with category type hints (real _eligible_modes_from_category) ── + +def test_route_auto_address_to_address_picks_vehicle(monkeypatch): + calls = [] + monkeypatch.setattr(OffrouteRouter, "route", _stub_route(_all_ok(), calls)) + r = object.__new__(OffrouteRouter) + 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"] + + +def test_route_auto_address_to_trailhead_picks_atv(monkeypatch): + calls = [] + monkeypatch.setattr(OffrouteRouter, "route", _stub_route(_all_ok(), calls)) + r = object.__new__(OffrouteRouter) + out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic", + start_category="building:house", end_category="highway:trailhead") + assert out["selected_mode"] == "atv" + assert "vehicle" not in calls + assert out["selected_mode_set"] == sorted({"atv", "mtb", "foot"}) + + +def test_route_auto_address_to_peak_picks_foot(monkeypatch): + calls = [] + monkeypatch.setattr(OffrouteRouter, "route", _stub_route(_all_ok(), calls)) + r = object.__new__(OffrouteRouter) + out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic", + start_category="building:house", end_category="natural:peak") + assert out["selected_mode"] == "foot" + assert calls == ["foot"] + assert out["selected_mode_set"] == ["foot"] + + +def test_route_auto_both_unknown_uses_spatial_fallback(monkeypatch): + calls = [] + spatial_calls = [] + monkeypatch.setattr(OffrouteRouter, "route", _stub_route(_all_ok(), calls)) + + def fake_spatial(self, lat, lon, snap_cache): + spatial_calls.append((lat, lon)) + return ALL_MODES + + monkeypatch.setattr(OffrouteRouter, "_spatial_eligible_modes", fake_spatial) + r = object.__new__(OffrouteRouter) + out = r._route_auto(42.0, -114.0, 42.5, -114.5, "pragmatic") # no categories + assert len(spatial_calls) == 2 # both endpoints resolved spatially + assert out["selected_mode"] == "vehicle" diff --git a/frontend/src/api.js b/frontend/src/api.js index 388e000..f2a0869 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -333,15 +333,20 @@ const MVUM_URL = "/api/mvum" * @param {object} end - { lat, lon } * @param {string} mode - auto | foot | mtb | atv | vehicle * @param {string} boundaryMode - strict | pragmatic | emergency + * @param {string} [startCategory] - OSM "key:value" hint for the origin (Auto mode) + * @param {string} [endCategory] - OSM "key:value" hint for the destination (Auto mode) * @returns {Promise} Offroute response with GeoJSON route */ -export async function requestOffroute(start, end, mode = "foot", boundaryMode = "strict") { +export async function requestOffroute(start, end, mode = "foot", boundaryMode = "strict", startCategory = null, endCategory = null) { const body = { start: [start.lat, start.lon], end: [end.lat, end.lon], mode, boundary_mode: boundaryMode, } + // Optional OSM "key:value" hints; Auto mode uses them to gate eligible modes. + if (startCategory) body.start_category = startCategory + if (endCategory) body.end_category = endCategory console.log('[TRACE-API] requestOffroute body:', JSON.stringify(body)) const controller = new AbortController() diff --git a/frontend/src/store.js b/frontend/src/store.js index f988ed3..90d8a06 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -134,7 +134,7 @@ export const useStore = create((set, get) => ({ try { if (waypoints.length === 2) { // Simple 2-point routing - const data = await requestOffroute(routeStart, routeEnd, routeMode, boundaryMode) + const data = await requestOffroute(routeStart, routeEnd, routeMode, boundaryMode, routeStart.category, routeEnd.category) if (data.status === "ok" && data.route) { set({ routeResult: data, routeError: null }) if (_updateRouteDisplay) _updateRouteDisplay(data.route) @@ -151,7 +151,7 @@ export const useStore = create((set, get) => ({ for (let i = 0; i < waypoints.length - 1; i++) { const from = waypoints[i] const to = waypoints[i + 1] - const segmentData = await requestOffroute(from, to, routeMode, boundaryMode) + const segmentData = await requestOffroute(from, to, routeMode, boundaryMode, from.category, to.category) if (segmentData.status !== "ok" || !segmentData.route) { throw new Error("No route found between " + (from.name || "waypoint") + " and " + (to.name || "waypoint")) @@ -214,6 +214,7 @@ export const useStore = create((set, get) => ({ name: place.name, source: place.source, matchCode: place.matchCode, + category: place.category ?? null, // preserve OSM key:value hint for Auto mode } let origin = null