offroute: wilderness always uses foot mode for pathfinding

The wilderness segment now ALWAYS uses foot mode for MCP pathfinding.
The user's selected mode only affects:
1. Entry point selection (MODE_TO_VALID_HIGHWAYS filtering)
2. Valhalla costing for the network segment

This ensures vehicles can navigate through wilderness (on foot) to
reach roads, rather than failing when no vehicle-accessible path exists.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-05-08 19:03:31 +00:00
commit ff0721c23e

View file

@ -7,7 +7,10 @@ Entry points are extracted from OSM highways and stored in /mnt/nav/navi.db.
The pathfinder routes from a wilderness start to the nearest entry point, The pathfinder routes from a wilderness start to the nearest entry point,
then Valhalla completes the route to the destination. then Valhalla completes the route to the destination.
Supports four travel modes: foot, mtb, atv, vehicle. IMPORTANT: The wilderness segment ALWAYS uses foot mode for pathfinding.
The user's selected mode affects:
1. Which entry points are valid (foot=any, mtb=tracks+roads, vehicle=roads only)
2. The Valhalla costing profile for the network segment
""" """
import json import json
import math import math
@ -51,6 +54,19 @@ MODE_TO_COSTING = {
"vehicle": "auto", "vehicle": "auto",
} }
# Mode to valid entry point highway classes
# foot = any trail/track/road, mtb = tracks and roads, vehicle = roads only
MODE_TO_VALID_HIGHWAYS = {
"foot": {"primary", "secondary", "tertiary", "unclassified", "residential",
"service", "track", "path", "footway", "bridleway"},
"mtb": {"primary", "secondary", "tertiary", "unclassified", "residential",
"service", "track"},
"atv": {"primary", "secondary", "tertiary", "unclassified", "residential",
"service", "track"},
"vehicle": {"primary", "secondary", "tertiary", "unclassified", "residential",
"service"},
}
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Calculate distance between two points in meters.""" """Calculate distance between two points in meters."""
@ -122,8 +138,16 @@ class EntryPointIndex:
return [dict(row) for row in cur.fetchall()] return [dict(row) for row in cur.fetchall()]
def query_radius(self, lat: float, lon: float, radius_km: float) -> List[Dict]: def query_radius(self, lat: float, lon: float, radius_km: float,
"""Query entry points within radius of a point.""" valid_highways: Optional[set] = None) -> List[Dict]:
"""
Query entry points within radius of a point.
Args:
lat, lon: Center point
radius_km: Search radius in kilometers
valid_highways: Optional set of valid highway classes to filter by
"""
lat_delta = radius_km / 111.0 lat_delta = radius_km / 111.0
lon_delta = radius_km / (111.0 * math.cos(math.radians(lat))) lon_delta = radius_km / (111.0 * math.cos(math.radians(lat)))
@ -134,6 +158,10 @@ class EntryPointIndex:
result = [] result = []
for p in points: for p in points:
# Filter by highway class if specified
if valid_highways and p['highway_class'] not in valid_highways:
continue
dist = haversine_distance(lat, lon, p['lat'], p['lon']) dist = haversine_distance(lat, lon, p['lat'], p['lon'])
if dist <= radius_km * 1000: if dist <= radius_km * 1000:
p['distance_m'] = dist p['distance_m'] = dist
@ -262,7 +290,8 @@ class OffrouteRouter:
""" """
OFFROUTE Router orchestrates wilderness pathfinding and Valhalla stitching. OFFROUTE Router orchestrates wilderness pathfinding and Valhalla stitching.
Supports modes: foot, mtb, atv, vehicle IMPORTANT: Wilderness segment ALWAYS uses foot mode for pathfinding.
User's mode affects entry point selection and Valhalla costing only.
""" """
def __init__(self): def __init__(self):
@ -301,9 +330,14 @@ class OffrouteRouter:
Args: Args:
start_lat, start_lon: Starting coordinates (wilderness) start_lat, start_lon: Starting coordinates (wilderness)
end_lat, end_lon: Destination coordinates end_lat, end_lon: Destination coordinates
mode: Travel mode (foot, mtb, atv, vehicle) mode: Travel mode (foot, mtb, atv, vehicle) - affects entry points and network routing
boundary_mode: How to handle private land (strict, pragmatic, emergency) boundary_mode: How to handle private land (strict, pragmatic, emergency)
IMPORTANT: Wilderness pathfinding ALWAYS uses foot mode.
The user's mode only affects:
1. Which entry points are valid targets
2. The Valhalla costing for the network segment
Returns a GeoJSON FeatureCollection with wilderness and network segments. Returns a GeoJSON FeatureCollection with wilderness and network segments.
""" """
t0 = time.time() t0 = time.time()
@ -318,17 +352,29 @@ class OffrouteRouter:
"message": "Trail entry point index not built. Run build_entry_index() first." "message": "Trail entry point index not built. Run build_entry_index() first."
} }
# Find entry points near start (limit to nearest 10 to control bbox size) # Get valid highway classes for this mode
valid_highways = MODE_TO_VALID_HIGHWAYS.get(mode)
# Find entry points near start, filtered by mode
MAX_ENTRY_POINTS = 10 MAX_ENTRY_POINTS = 10
entry_points = self.entry_index.query_radius(start_lat, start_lon, DEFAULT_SEARCH_RADIUS_KM) entry_points = self.entry_index.query_radius(
start_lat, start_lon, DEFAULT_SEARCH_RADIUS_KM, valid_highways
)
if not entry_points: if not entry_points:
entry_points = self.entry_index.query_radius(start_lat, start_lon, EXPANDED_SEARCH_RADIUS_KM) # Try expanded radius
entry_points = self.entry_index.query_radius(
start_lat, start_lon, EXPANDED_SEARCH_RADIUS_KM, valid_highways
)
if not entry_points: if not entry_points:
return { # For non-foot modes, the error is about no suitable roads/trails
"status": "error", if mode == "vehicle":
"message": f"No trail entry points found within {EXPANDED_SEARCH_RADIUS_KM}km of start" msg = f"No roads found within {EXPANDED_SEARCH_RADIUS_KM}km. Try a different mode."
} elif mode in ("mtb", "atv"):
msg = f"No tracks or roads found within {EXPANDED_SEARCH_RADIUS_KM}km. Try foot mode."
else:
msg = f"No trail entry points found within {EXPANDED_SEARCH_RADIUS_KM}km of start."
return {"status": "error", "message": msg}
# Limit to nearest entry points to prevent huge bounding boxes # Limit to nearest entry points to prevent huge bounding boxes
entry_points = entry_points[:MAX_ENTRY_POINTS] entry_points = entry_points[:MAX_ENTRY_POINTS]
@ -392,15 +438,6 @@ class OffrouteRouter:
target_shape=elevation.shape target_shape=elevation.shape
) )
# Load wilderness (if available and mode requires it)
wilderness = None
if self.wilderness_reader is not None and mode in ("mtb", "atv", "vehicle"):
wilderness = self.wilderness_reader.get_wilderness_grid(
south=bbox["south"], north=bbox["north"],
west=bbox["west"], east=bbox["east"],
target_shape=elevation.shape
)
# Load trails # Load trails
trails = self.trail_reader.get_trails_grid( trails = self.trail_reader.get_trails_grid(
south=bbox["south"], north=bbox["north"], south=bbox["south"], north=bbox["north"],
@ -408,23 +445,11 @@ class OffrouteRouter:
target_shape=elevation.shape target_shape=elevation.shape
) )
# Load MVUM access data (only for motorized modes) # WILDERNESS PATHFINDING ALWAYS USES FOOT MODE
# MVUM is motor-vehicle specific — foot mode skips entirely # This is the key change: we don't load wilderness grid or MVUM for pathfinding
mvum = None # because foot mode can traverse wilderness and doesn't need motor-vehicle access
if mode in ("mtb", "atv", "vehicle"):
try:
mvum = get_mvum_access_grid(
south=bbox["south"], north=bbox["north"],
west=bbox["west"], east=bbox["east"],
target_shape=elevation.shape,
mode=mode,
check_date=None, # TODO: accept date parameter
)
except Exception as e:
# MVUM data may not be available - continue without it
pass
# Compute cost grid with mode-specific parameters # Compute cost grid with FOOT MODE (always for wilderness segment)
cost = compute_cost_grid( cost = compute_cost_grid(
elevation, elevation,
cell_size_m=meta["cell_size_m"], cell_size_m=meta["cell_size_m"],
@ -432,15 +457,14 @@ class OffrouteRouter:
friction_raw=friction_raw, friction_raw=friction_raw,
trails=trails, trails=trails,
barriers=barriers, barriers=barriers,
wilderness=wilderness, wilderness=None, # Foot mode ignores wilderness restrictions
mvum=mvum, mvum=None, # Foot mode doesn't use MVUM
boundary_mode=boundary_mode, boundary_mode=boundary_mode,
mode=mode, mode="foot", # ALWAYS foot for wilderness pathfinding
) )
# Free intermediate arrays to reduce memory before MCP # Free intermediate arrays to reduce memory before MCP
# Note: Keep trails, barriers, and mvum - needed for path statistics del friction_mult, friction_raw
del friction_mult, friction_raw, wilderness
import gc import gc
gc.collect() gc.collect()
@ -489,7 +513,6 @@ class OffrouteRouter:
elevations = [] elevations = []
trail_values = [] trail_values = []
barrier_crossings = 0 barrier_crossings = 0
mvum_closed_crossings = 0
for row, col in path_indices: for row, col in path_indices:
lat, lon = self.dem_reader.pixel_to_latlon(row, col, meta) lat, lon = self.dem_reader.pixel_to_latlon(row, col, meta)
@ -498,8 +521,6 @@ class OffrouteRouter:
trail_values.append(trails[row, col]) trail_values.append(trails[row, col])
if barriers[row, col] == 255: if barriers[row, col] == 255:
barrier_crossings += 1 barrier_crossings += 1
if mvum is not None and mvum[row, col] == 255:
mvum_closed_crossings += 1
# Calculate stats # Calculate stats
wilderness_distance_m = 0 wilderness_distance_m = 0
@ -518,10 +539,8 @@ class OffrouteRouter:
total_cells = len(trail_arr) total_cells = len(trail_arr)
on_trail_pct = float(100 * on_trail_cells / total_cells) if total_cells > 0 else 0 on_trail_pct = float(100 * on_trail_cells / total_cells) if total_cells > 0 else 0
# Free trails, barriers, and mvum now that path stats are computed # Free trails and barriers
del trails, barriers del trails, barriers
if mvum is not None:
del mvum
# Entry point # Entry point
entry_lat = best_entry["entry_point"]["lat"] entry_lat = best_entry["entry_point"]["lat"]
@ -529,7 +548,7 @@ class OffrouteRouter:
entry_class = best_entry["entry_point"]["highway_class"] entry_class = best_entry["entry_point"]["highway_class"]
entry_name = best_entry["entry_point"].get("name", "") entry_name = best_entry["entry_point"].get("name", "")
# Call Valhalla # Call Valhalla with USER'S SELECTED MODE (not foot)
valhalla_costing = MODE_TO_COSTING.get(mode, "pedestrian") valhalla_costing = MODE_TO_COSTING.get(mode, "pedestrian")
valhalla_request = { valhalla_request = {
@ -595,8 +614,7 @@ class OffrouteRouter:
"on_trail_pct": on_trail_pct, "on_trail_pct": on_trail_pct,
"cell_count": total_cells, "cell_count": total_cells,
"barrier_crossings": barrier_crossings, "barrier_crossings": barrier_crossings,
"mvum_closed_crossings": mvum_closed_crossings, "wilderness_mode": "foot", # Always foot for wilderness
"mode": mode,
}, },
"geometry": {"type": "LineString", "coordinates": wilderness_coords} "geometry": {"type": "LineString", "coordinates": wilderness_coords}
} }
@ -610,6 +628,7 @@ class OffrouteRouter:
"distance_km": network_segment["distance_km"], "distance_km": network_segment["distance_km"],
"duration_minutes": network_segment["duration_minutes"], "duration_minutes": network_segment["duration_minutes"],
"maneuvers": network_segment["maneuvers"], "maneuvers": network_segment["maneuvers"],
"network_mode": mode, # User's selected mode
}, },
"geometry": {"type": "LineString", "coordinates": network_segment["coordinates"]} "geometry": {"type": "LineString", "coordinates": network_segment["coordinates"]}
} }
@ -621,7 +640,12 @@ class OffrouteRouter:
combined_feature = { combined_feature = {
"type": "Feature", "type": "Feature",
"properties": {"segment_type": "combined", "mode": mode, "boundary_mode": boundary_mode}, "properties": {
"segment_type": "combined",
"wilderness_mode": "foot",
"network_mode": mode,
"boundary_mode": boundary_mode
},
"geometry": {"type": "LineString", "coordinates": combined_coords} "geometry": {"type": "LineString", "coordinates": combined_coords}
} }
features.append(combined_feature) features.append(combined_feature)
@ -644,9 +668,9 @@ class OffrouteRouter:
"network_duration_minutes": float(network_segment["duration_minutes"]) if network_segment else 0, "network_duration_minutes": float(network_segment["duration_minutes"]) if network_segment else 0,
"on_trail_pct": on_trail_pct, "on_trail_pct": on_trail_pct,
"barrier_crossings": barrier_crossings, "barrier_crossings": barrier_crossings,
"mvum_closed_crossings": mvum_closed_crossings,
"boundary_mode": boundary_mode, "boundary_mode": boundary_mode,
"mode": mode, "wilderness_mode": "foot", # Always foot
"network_mode": mode, # User's selection
"entry_point": { "entry_point": {
"lat": entry_lat, "lon": entry_lon, "lat": entry_lat, "lon": entry_lon,
"highway_class": entry_class, "name": entry_name, "highway_class": entry_class, "name": entry_name,
@ -730,6 +754,7 @@ if __name__ == "__main__":
elif len(sys.argv) > 1 and sys.argv[1] == "test": elif len(sys.argv) > 1 and sys.argv[1] == "test":
print("Testing router (all modes)...") print("Testing router (all modes)...")
print("NOTE: Wilderness always uses foot mode. User mode affects entry points + network.")
router = OffrouteRouter() router = OffrouteRouter()
@ -746,8 +771,8 @@ if __name__ == "__main__":
if result["status"] == "ok": if result["status"] == "ok":
s = result["summary"] s = result["summary"]
print(f" Wilderness: {s['wilderness_distance_km']:.2f} km, {s['wilderness_effort_minutes']:.1f} min") print(f" Wilderness: {s['wilderness_distance_km']:.2f} km, {s['wilderness_effort_minutes']:.1f} min (foot)")
print(f" Network: {s['network_distance_km']:.2f} km, {s['network_duration_minutes']:.1f} min") print(f" Network: {s['network_distance_km']:.2f} km, {s['network_duration_minutes']:.1f} min ({mode})")
print(f" On-trail: {s['on_trail_pct']:.1f}%") print(f" On-trail: {s['on_trail_pct']:.1f}%")
print(f" Entry: {s['entry_point']['highway_class']}") print(f" Entry: {s['entry_point']['highway_class']}")
else: else: