navi/backend/services/navi_offroute/router.py
malice ac504cab79
navi-offroute: Auto bypass — trust _auto_eligible_modes vehicle judgment (PR 48) (#48)
Co-authored-by: mj <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:38:15 -06:00

2325 lines
99 KiB
Python
Executable file

"""
OFFROUTE Router — Bidirectional wilderness-to-network path orchestration.
Supports four routing scenarios:
A: off-network start → on-network end (wilderness then Valhalla)
B: off-network start → off-network end (wilderness, Valhalla, wilderness)
C: on-network start → off-network end (Valhalla then wilderness)
D: on-network start → on-network end (pure Valhalla passthrough)
Off-network detection: Valhalla /locate snap distance > 500m = off-network.
IMPORTANT: The wilderness segment ALWAYS uses foot mode for pathfinding.
The user's selected mode affects:
1. Which entry points are valid (foot=any, 2w=tracks+roads, vehicle=roads only)
2. The Valhalla costing profile for the network segment
"""
import gc
import json
import logging
from datetime import datetime
import math
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
import numpy as np
import psutil
import requests
import psycopg2
import psycopg2.extras
from shapely.geometry import LineString, Point
from .astar import (astar_multigoal, astar_multigoal_multimode, astar_hpa_multimode,
inflate_cost_multiplier)
from .mvum_surface_change import get_surface_change_candidates
from .mvum_parking import load_parking_index # noqa: F401 (singleton injected by handler)
from shared.dem import DEMReader, dem_path
from .cost import (compute_cost_grid, compute_cost_multiplier_grid, MODE_PROFILES,
compute_unified_cost_layers)
from .transitions import MODE_INDEX
from .friction import FrictionReader, friction_to_multiplier
from .barriers import BarrierReader, WildernessReader, wilderness_tif_path
from .trails import TrailReader
from .mvum import get_mvum_access_grid
from .mvum_annotate import annotate_network_edges
from .mvum_exclude import build_exclude_polygons
logger = logging.getLogger("navi_offroute.router")
# Configuration via env vars (extraction #8: was profile.offroute.* in recon;
# promoted to dedicated env vars here — no deployment_config machinery). Read at
# import; the systemd unit's EnvironmentFile supplies them in prod.
OSM_PBF_PATH = Path(os.environ.get("NAVI_OFFROUTE_OSM_PBF", "/mnt/nav/sources/idaho-latest.osm.pbf"))
DENSIFY_INTERVAL_M = int(os.environ.get("NAVI_OFFROUTE_DENSIFY_M", "100"))
POSTGIS_DSN = os.environ.get("NAVI_OFFROUTE_POSTGIS_DSN", "dbname=padus")
# Valhalla endpoint (recon-side network router, HTTP)
VALHALLA_URL = os.environ.get("NAVI_OFFROUTE_VALHALLA_URL", "http://localhost:8002")
# HPA* cost-tile DB (HPA-SPEC.md §8/§9, Phase H3). Unset (None) -> HPA* never engages and
# Auto routing is byte-identical to the unified-graph path. Set to a tile DB (built by
# hpa_build) to enable the two-level fast path for covered, pragmatic, no-affinity routes.
HPA_TILE_DB = os.environ.get("NAVI_OFFROUTE_HPA_DB")
# Search radius for entry points (km)
DEFAULT_SEARCH_RADIUS_KM = 50
EXPANDED_SEARCH_RADIUS_KM = 100
# Memory limit
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
# Spatial eligibility vocabulary. Valhalla's verbose /locate exposes the road grade
# under classification.classification (PAVED below) and the edge purpose under
# classification.use (TRACK/PATH below) — track/path/footway are NOT road grades.
PAVED_HIGHWAY_CLASSES = frozenset({
"motorway", "trunk", "primary", "secondary", "tertiary",
"unclassified", "residential", "service", "service_other",
})
TRACK_USE_VALUES = frozenset({"track"})
PATH_USE_VALUES = frozenset({
"path", "footway", "cycleway", "bridleway", "steps", "pedestrian",
})
# Mode to Valhalla costing mapping
MODE_TO_COSTING = {
"auto": "auto",
"foot": "pedestrian",
"2w": "bicycle",
"4w": "auto",
"vehicle": "auto",
}
# Fixed mode index ordering for the unified-graph kernel (spec §2.1; == MODE_INDEX in
# transitions.py). The cost_mult_stack / per-mode arrays are packed in this order.
MODE_ORDER = ["foot", "2w", "4w", "vehicle"]
# Phase 4.5 perf: the unified search is masked to a great-circle corridor between the
# endpoints -- cells farther than CORRIDOR_PAD_KM perpendicular (or past the endpoints by
# CORRIDOR_ENDPOINT_PAD_KM along the line) are made impassable, so the kernel doesn't flood
# the off-route wilderness on long trips. The DEM bbox stays axis-aligned (it can't shrink
# below the endpoint box); this masks within it. CORRIDOR_PAD_KM is the widen-if-needed
# knob: a routable off-trail egress farther than this from the straight line gets cut.
CORRIDOR_PAD_KM = 10.0
CORRIDOR_ENDPOINT_PAD_KM = 5.0
# 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", "4w", "2w", "foot"})
_MODES_TRACK = frozenset({"4w", "2w", "foot"})
_MODES_PATH = frozenset({"2w", "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 -> 4w/2w/foot
"highway:track": _MODES_TRACK, "highway:trailhead": _MODES_TRACK,
# Path-like -> 2w/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, 2w = tracks and roads, vehicle = roads only
MODE_TO_VALID_HIGHWAYS = {
"auto": {"primary", "secondary", "tertiary", "unclassified", "residential",
"service"},
"foot": {"primary", "secondary", "tertiary", "unclassified", "residential",
"service", "track", "path", "footway", "bridleway"},
"2w": {"primary", "secondary", "tertiary", "unclassified", "residential",
"service", "track"},
"4w": {"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:
"""Calculate distance between two points in meters."""
R = 6371000
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon/2)**2
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
return R * c
def check_memory_usage() -> float:
"""Current process RSS in GB."""
return psutil.Process().memory_info().rss / (1024**3)
class EntryPointIndex:
"""
PostGIS-backed spatial index of road/trail entry points.
Uses ST_DWithin for fast radius queries with meter-accurate distances.
Densifies highway LineStrings at 100m intervals for better coverage.
"""
def __init__(self, dsn: str = None):
self.dsn = dsn or POSTGIS_DSN
self._conn: Optional[psycopg2.extensions.connection] = None
def _get_conn(self) -> psycopg2.extensions.connection:
if self._conn is None or self._conn.closed:
self._conn = psycopg2.connect(self.dsn)
return self._conn
def table_exists(self) -> bool:
"""Check if entry_points table exists."""
conn = self._get_conn()
with conn.cursor() as cur:
cur.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'entry_points'
)
""")
return cur.fetchone()[0]
def get_entry_point_count(self) -> int:
"""Return the number of entry points in the index."""
if not self.table_exists():
return 0
conn = self._get_conn()
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM entry_points")
return cur.fetchone()[0]
def has_entry_points(self) -> bool:
"""Fast non-emptiness check. SELECT EXISTS short-circuits at the first row,
unlike SELECT COUNT(*) which scans the entire table (~73s on 2.94M rows).
Returns False if the table is absent."""
if not self.table_exists():
return False
conn = self._get_conn()
with conn.cursor() as cur:
cur.execute("SELECT EXISTS (SELECT 1 FROM entry_points LIMIT 1)")
return cur.fetchone()[0]
def query_bbox(
self,
south: float,
north: float,
west: float,
east: float,
valid_highways: Optional[Set[str]] = None
) -> List[Dict]:
"""Find entry points within a bounding box."""
if not self.table_exists():
return []
conn = self._get_conn()
highway_filter = ""
params = [west, south, east, north]
if valid_highways:
placeholders = ','.join(['%s'] * len(valid_highways))
highway_filter = f"AND highway_class IN ({placeholders})"
params.extend(list(valid_highways))
query = f"""
SELECT
id,
ST_Y(geom) as lat,
ST_X(geom) as lon,
highway_class,
name,
land_status
FROM entry_points
WHERE geom && ST_MakeEnvelope(%s, %s, %s, %s, 4326)
{highway_filter}
"""
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(query, params)
return [dict(row) for row in cur.fetchall()]
def query_radius(
self,
lat: float,
lon: float,
radius_km: float,
valid_highways: Optional[Set[str]] = None,
limit: int = 50
) -> List[Dict]:
"""
Find the nearest entry points to (lat, lon), ordered by true geodesic distance.
Uses PostGIS k-NN ordering (the geography ``<->`` operator), which is
index-assisted by the GiST index on ``(geom::geography)``: it walks the index
nearest-first and stops after ``limit`` rows, instead of scanning every point
inside a radius (the old ST_DWithin approach returned ~226k candidates near
dense areas before sorting). ``radius_km`` is retained as a *soft cap* applied
in Python after the fetch — rows beyond it are dropped, so callers'
expanded-radius fallback still works (though it is now effectively a no-op,
since k-NN already returns the globally nearest K regardless of radius).
"""
if not self.table_exists():
return []
conn = self._get_conn()
# SELECT distance_m needs the point; optional highway filter; ORDER BY <-> needs
# the point again; then LIMIT. Param order follows the placeholders top-to-bottom.
highway_filter = ""
params = [lon, lat]
if valid_highways:
placeholders = ','.join(['%s'] * len(valid_highways))
highway_filter = f"WHERE highway_class IN ({placeholders})"
params.extend(list(valid_highways))
params.extend([lon, lat, limit])
query = f"""
SELECT
id,
ST_Y(geom) as lat,
ST_X(geom) as lon,
highway_class,
name,
land_status,
ST_Distance(
geom::geography,
ST_SetSRID(ST_Point(%s, %s), 4326)::geography
) as distance_m
FROM entry_points
{highway_filter}
ORDER BY geom::geography <-> ST_SetSRID(ST_Point(%s, %s), 4326)::geography
LIMIT %s
"""
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(query, params)
rows = [dict(row) for row in cur.fetchall()]
# radius_km soft cap (backward compat): drop rows beyond it.
radius_m = radius_km * 1000
return [r for r in rows if r["distance_m"] <= radius_m]
def build_index(self, osm_pbf_path: Path = None) -> Dict:
"""
Build the entry point index from OSM PBF.
Densifies LineStrings to sample points every 100m.
Tags points with land_status from PAD-US.
"""
if osm_pbf_path is None:
osm_pbf_path = OSM_PBF_PATH
if not osm_pbf_path.exists():
raise FileNotFoundError(f"OSM PBF not found: {osm_pbf_path}")
print(f"Building entry point index from {osm_pbf_path}...")
start_time = time.time()
highway_types = [
"primary", "secondary", "tertiary", "unclassified",
"residential", "service", "track", "path", "footway", "bridleway"
]
stats = {"total": 0, "by_class": {}, "lines_processed": 0}
with tempfile.TemporaryDirectory() as tmpdir:
geojson_path = Path(tmpdir) / "highways.geojson"
# Extract highways with osmium
print(" Extracting highways with osmium...")
cmd = ["osmium", "tags-filter", str(osm_pbf_path)]
for ht in highway_types:
cmd.append(f"w/highway={ht}")
cmd.extend(["-o", str(Path(tmpdir) / "filtered.osm.pbf"), "--overwrite"])
subprocess.run(cmd, check=True, capture_output=True)
# Convert to GeoJSON
print(" Converting to GeoJSON with ogr2ogr...")
cmd = [
"ogr2ogr", "-f", "GeoJSON",
str(geojson_path),
str(Path(tmpdir) / "filtered.osm.pbf"),
"lines", "-t_srs", "EPSG:4326"
]
subprocess.run(cmd, check=True, capture_output=True)
# Load GeoJSON
print(" Loading GeoJSON...")
with open(geojson_path) as f:
data = json.load(f)
# Process features and densify
print(f" Densifying LineStrings at {DENSIFY_INTERVAL_M}m intervals...")
points_to_insert = []
seen_keys = set()
features = data.get("features", [])
total_features = len(features)
for idx, feature in enumerate(features):
if idx > 0 and idx % 100000 == 0:
print(f" Processed {idx}/{total_features} features...")
props = feature.get("properties", {})
geom = feature.get("geometry", {})
if geom.get("type") != "LineString":
continue
coords = geom.get("coordinates", [])
if len(coords) < 2:
continue
highway_class = props.get("highway", "unknown")
name = props.get("name", "")
stats["lines_processed"] += 1
# Densify this LineString
densified = self._densify_line(coords, DENSIFY_INTERVAL_M)
for lon, lat in densified:
# Deduplicate by rounding to 5 decimal places (~1m precision)
key = (round(lat, 5), round(lon, 5))
if key in seen_keys:
continue
seen_keys.add(key)
points_to_insert.append((lon, lat, highway_class, name))
# Insert into PostGIS
print(f" Inserting {len(points_to_insert)} entry points into PostGIS...")
conn = self._get_conn()
with conn.cursor() as cur:
# Truncate existing data
cur.execute("TRUNCATE entry_points RESTART IDENTITY")
# Batch insert with execute_values for speed
batch_size = 50000
for i in range(0, len(points_to_insert), batch_size):
batch = points_to_insert[i:i+batch_size]
psycopg2.extras.execute_values(
cur,
"""
INSERT INTO entry_points (geom, highway_class, name)
VALUES %s
""",
batch,
template="(ST_SetSRID(ST_Point(%s, %s), 4326), %s, %s)",
page_size=10000
)
if i > 0 and i % 500000 == 0:
print(f" Inserted {i}/{len(points_to_insert)} points...")
conn.commit()
# Tag land_status from PAD-US
print(" Tagging land_status from PAD-US subdivided polygons...")
with conn.cursor() as cur:
cur.execute("""
UPDATE entry_points e
SET land_status = 'public'
FROM padus_sub p
WHERE ST_Intersects(e.geom, p.geom)
""")
public_count = cur.rowcount
print(f" Tagged {public_count} points as public land")
conn.commit()
# Gather stats
elapsed = time.time() - start_time
stats["total"] = len(points_to_insert)
stats["build_time_sec"] = round(elapsed, 1)
for lon, lat, hc, name in points_to_insert:
stats["by_class"][hc] = stats["by_class"].get(hc, 0) + 1
print(f" Done in {elapsed:.1f}s. Total: {stats['total']} entry points from {stats['lines_processed']} lines")
for hc, count in sorted(stats["by_class"].items(), key=lambda x: -x[1]):
print(f" {hc}: {count}")
return stats
def _densify_line(self, coords: List[List[float]], interval_m: float) -> List[tuple]:
"""
Sample points along a LineString at regular intervals.
coords: [[lon, lat], ...] in GeoJSON order
Returns: [(lon, lat), ...] sampled points including first and last
"""
if len(coords) < 2:
return [(coords[0][0], coords[0][1])] if coords else []
# Calculate line length in meters using haversine on segments
total_m = 0
for i in range(len(coords) - 1):
lon1, lat1 = coords[i]
lon2, lat2 = coords[i + 1]
total_m += haversine_distance(lat1, lon1, lat2, lon2)
if total_m == 0:
return [(coords[0][0], coords[0][1])]
# Create Shapely LineString
line = LineString(coords)
# Calculate number of points needed
n_points = max(2, int(total_m / interval_m) + 1)
# Sample using normalized interpolation
result = []
for i in range(n_points):
fraction = min(i / (n_points - 1), 1.0) if n_points > 1 else 0
point = line.interpolate(fraction, normalized=True)
result.append((point.x, point.y)) # (lon, lat)
# Always ensure first and last original coordinates are included
first_coord = (coords[0][0], coords[0][1])
last_coord = (coords[-1][0], coords[-1][1])
if result[0] != first_coord:
result[0] = first_coord
if result[-1] != last_coord:
result[-1] = last_coord
return result
def _highway_priority(self, highway_class: str) -> int:
"""Lower number = better priority for entry points."""
priority = {
"primary": 1, "secondary": 2, "tertiary": 3,
"unclassified": 4, "residential": 5, "service": 6,
"track": 7, "path": 8, "footway": 9, "bridleway": 10
}
return priority.get(highway_class, 99)
def close(self):
if self._conn and not self._conn.closed:
self._conn.close()
self._conn = None
class OffrouteRouter:
"""
OFFROUTE Router — orchestrates wilderness pathfinding and Valhalla stitching.
Supports four scenarios:
A: off-network start → on-network end
B: off-network start → off-network end
C: on-network start → off-network end
D: on-network start → on-network end (pure Valhalla)
IMPORTANT: Wilderness segment ALWAYS uses foot mode for pathfinding.
User's mode affects entry point selection and Valhalla costing only.
"""
def __init__(self):
self.dem_reader = None
self.friction_reader = None
self.barrier_reader = None
self.wilderness_reader = None
self.trail_reader = None
self.entry_index = EntryPointIndex()
self.spatial_index = None # MVUMSpatialIndex (Layer 0), injected by the handler
self.mvum_on_date = None # optional datetime for seasonal MVUM checks
self._exclude_polygons = None # MVUM Layer 2c, set per route() call
self.trailhead_index = None # TrailheadIndex (Layer 3a), injected by the handler
self.parking_index = None # OSMParkingIndex (Layer 3b), injected by the handler
def _init_readers(self):
"""Lazy init readers."""
if self.dem_reader is None:
self.dem_reader = DEMReader(dem_path()) # NAVI_DEM_PMTILES via shared.dem
if self.friction_reader is None:
self.friction_reader = FrictionReader()
if self.barrier_reader is None:
self.barrier_reader = BarrierReader()
if self.wilderness_reader is None and wilderness_tif_path().exists():
self.wilderness_reader = WildernessReader()
if self.trail_reader is None:
self.trail_reader = TrailReader()
def _locate_on_network(self, lat: float, lon: float, mode: str) -> Dict:
"""
Check if a point is on the routable network using Valhalla's /locate.
Returns:
{
"on_network": bool,
"snap_distance_m": float,
"snapped_lat": float,
"snapped_lon": float
}
"""
costing = MODE_TO_COSTING.get(mode, "pedestrian")
try:
resp = requests.post(
f"{VALHALLA_URL}/locate",
json={"locations": [{"lat": lat, "lon": lon}], "costing": costing, "verbose": True},
timeout=10
)
if resp.status_code == 200:
data = resp.json()
if data and len(data) > 0 and data[0].get("edges"):
edge = data[0]["edges"][0]
snap_lat = edge.get("correlated_lat", lat)
snap_lon = edge.get("correlated_lon", lon)
snap_dist = haversine_distance(lat, lon, snap_lat, snap_lon)
return {
"on_network": snap_dist <= OFF_NETWORK_THRESHOLD_M,
"snap_distance_m": snap_dist,
"snapped_lat": snap_lat,
"snapped_lon": snap_lon,
# Valhalla (verbose=true) puts the road grade under
# edge.classification.classification and the edge purpose under
# edge.classification.use; defensive .get chains -> None if absent.
"road_class": edge.get("edge", {}).get("classification", {}).get("classification"),
"use": edge.get("edge", {}).get("classification", {}).get("use"),
}
except Exception:
pass
return {
"on_network": False,
"snap_distance_m": float('inf'),
"snapped_lat": lat,
"snapped_lon": lon,
"road_class": None,
"use": None,
}
def route(
self,
start_lat: float,
start_lon: float,
end_lat: float,
end_lon: float,
mode: Literal["auto", "foot", "2w", "4w", "vehicle"] = "foot",
boundary_mode: Literal["strict", "pragmatic", "emergency"] = "pragmatic",
start_category: Optional[str] = None,
end_category: Optional[str] = None,
annotate_mvum: bool = True,
network_affinity: Optional[Dict[str, float]] = None,
) -> Dict:
"""
Route between two points, handling all four scenarios.
Scenarios:
A: off-network start → on-network end (wilderness then network)
B: off-network start → off-network end (wilderness, network, wilderness)
C: on-network start → off-network end (network then wilderness)
D: on-network start → on-network end (pure network)
Args:
start_lat, start_lon: Starting coordinates
end_lat, end_lon: Destination coordinates
mode: Travel mode (foot, 2w, 4w, vehicle)
boundary_mode: How to handle private land (strict, pragmatic, emergency)
Returns a GeoJSON FeatureCollection with route segments.
"""
if mode == "auto":
return self._route_auto(
start_lat, start_lon, end_lat, end_lon, boundary_mode,
start_category, end_category, network_affinity
)
if mode not in MODE_TO_COSTING:
return {"status": "error", "message": f"Unknown mode: {mode}"}
# MVUM Layer 2c: precompute closed-segment exclusion polygons for this mode
# (strict boundary only). Both Valhalla call sites read self._exclude_polygons.
self._exclude_polygons = build_exclude_polygons(
start_lat, start_lon, end_lat, end_lon, mode,
getattr(self, "spatial_index", None), getattr(self, "mvum_on_date", None),
boundary_mode)
# Vehicle is pure Valhalla road routing: Valhalla snaps endpoints to the
# nearest road automatically, so the off-network classifier is irrelevant
# (and a tight threshold would wrongly push normal road routes into
# wilderness pathfinding). Foot/MTB/ATV still use the threshold gate so
# users can intentionally pin backcountry points. Auto inherits this via
# its recursive self.route(..., mode="vehicle", ...) probe.
if mode == "vehicle":
result = self._route_D_network_only(
start_lat, start_lon, end_lat, end_lon, mode
)
else:
# Detect network status for both endpoints
start_status = self._locate_on_network(start_lat, start_lon, mode)
end_status = self._locate_on_network(end_lat, end_lon, mode)
start_off_network = not start_status["on_network"]
end_off_network = not end_status["on_network"]
# Dispatch to appropriate handler
if not start_off_network and not end_off_network:
# Scenario D: on-network → on-network (pure Valhalla)
result = self._route_D_network_only(
start_lat, start_lon, end_lat, end_lon, mode
)
elif not start_off_network and end_off_network:
# Scenario C: on-network → off-network
result = self._route_C_network_to_wilderness(
start_lat, start_lon, end_lat, end_lon, mode, boundary_mode
)
elif start_off_network and not end_off_network:
# Scenario A: off-network → on-network
result = self._route_A_wilderness_to_network(
start_lat, start_lon, end_lat, end_lon, mode, boundary_mode
)
else:
# Scenario B: off-network → off-network
result = self._route_B_wilderness_both(
start_lat, start_lon, end_lat, end_lon, mode, boundary_mode
)
# MVUM Layer 1: annotate the network leg in one central pass. Auto annotates only
# its winning candidate (see _route_auto), so probing does not re-annotate.
if annotate_mvum and isinstance(result, dict) and result.get("status") == "ok":
self._annotate_network_segments(result, mode)
return result
def _annotate_network_segments(self, result, mode):
"""Mutate result in place: attach edge_mvum to each network feature and write
mvum_closed_crossings + mvum_segments_annotated into the summary."""
if not isinstance(result, dict) or result.get("status") != "ok":
return
if getattr(self, "spatial_index", None) is None:
return
on_date = getattr(self, "mvum_on_date", None) or datetime.now()
features = (result.get("route") or {}).get("features", [])
total_closed = 0
total_annotated = 0
for feat in features:
props = feat.get("properties") or {}
# network features are tagged segment_type == "network"
if props.get("segment_type") != "network":
continue
coords = (feat.get("geometry") or {}).get("coordinates") or []
if len(coords) < 2:
continue
edges = annotate_network_edges(
[(c[1], c[0]) for c in coords], mode, self.spatial_index, on_date)
props["edge_mvum"] = [e.to_dict() for e in edges]
total_closed += sum(1 for e in edges if e.mvum_status == "closed")
total_annotated += len(edges)
summary = result.setdefault("summary", {})
summary["mvum_closed_crossings"] = total_closed
summary["mvum_segments_annotated"] = total_annotated
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.
Spec §6 reframe: under unified-graph Auto this is now a SEED generator (it
feeds origin/goal modes to astar_multigoal_multimode), not the load-bearing
single-mode chooser it was. Behaviour/return shape are unchanged. The §6
single-combined-/locate batch is a Phase-4 follow-up; the 3-call pattern stays
until the batch response shape is confirmed against the live instance."""
# auto costing -> vehicle/4w reach, bicycle -> 2w reach, pedestrian -> foot
costing_modes = {"auto": "vehicle", "pedestrian": "foot", "bicycle": "2w"}
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 = auto_snap["snap_distance_m"]
cls_auto, use_auto = auto_snap.get("road_class"), auto_snap.get("use")
d_bike = bike_snap["snap_distance_m"]
cls_bike, use_bike = bike_snap.get("road_class"), bike_snap.get("use")
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")
# 4w: near a paved road OR a track (auto costing)
if d_auto <= AUTO_SNAP_RELAXED_M and (
cls_auto in PAVED_HIGHWAY_CLASSES or use_auto in TRACK_USE_VALUES):
modes.add("4w")
# 2w: near a paved road OR a track/path (bicycle costing)
if d_bike <= AUTO_SNAP_RELAXED_M and (
cls_bike in PAVED_HIGHWAY_CLASSES
or use_bike in (TRACK_USE_VALUES | PATH_USE_VALUES)):
modes.add("2w")
return frozenset(modes)
def _route_auto(
self,
start_lat: float, start_lon: float,
end_lat: float, end_lon: float,
boundary_mode: str,
start_category: Optional[str] = None,
end_category: Optional[str] = None,
network_affinity: Optional[Dict[str, float]] = None,
) -> Dict:
"""Unified-graph Auto (spec §2.4): ONE A* over (row, col, mode). Endpoint
eligibility only SEEDS the start/goal modes (§6); the optimizer decides where to
switch modes (parking / trailhead / road terminus / surface change) instead of
committing to a single trip-wide mode. Supersedes the capability pick + single
self.route() + _try_hybrid_auto + foot-fallback flow (deleted in Phase 5).
No auto_fallback_from (§7)."""
# 1. Bootstrap eligibility -> seeds (not a routing decision).
snap_cache: dict = {}
start_eligible = self._auto_eligible_modes(start_lat, start_lon, start_category, snap_cache)
end_eligible = self._auto_eligible_modes(end_lat, end_lon, end_category, snap_cache)
seed_set = sorted(start_eligible | end_eligible)
# === Valhalla bypass: pure road↔road skips the raster pipeline entirely ===
# Fires when both endpoints have "vehicle" in _auto_eligible_modes's judgment (computed
# above) -- covers tagged road categories AND raw map clicks that land tight OR
# relaxed+flat+paved near a road. The _is_terrain_flat check inside
# _spatial_eligible_modes gates out wilderness/mountain false positives, so a relaxed
# (<=100m) snap only grants vehicle on benign flat ground (lot/shoulder/field) where a
# pure road route is the right answer. Hands off to the same inline-Valhalla path
# explicit vehicle requests use, saving the ~7.5s raster build + unified A*. Boundary-mode
# MVUM exclusions aren't applied here (urban roads aren't MVUM-gated).
if "vehicle" in start_eligible and "vehicle" in end_eligible:
_bt0 = time.perf_counter()
bypass = self._route_D_network_only(start_lat, start_lon, end_lat, end_lon, "vehicle")
if bypass.get("status") == "ok":
bypass["selected_mode"] = "vehicle"
bypass["selected_mode_set"] = ["vehicle"]
bypass.setdefault("summary", {})["auto_bypass"] = True
logger.info("auto bypass: vehicle road↔road via Valhalla in %.3fs",
time.perf_counter() - _bt0)
return bypass
logger.warning("auto bypass attempted but Valhalla returned %s; falling through to unified",
bypass.get("status"))
# === end bypass ===
# 2-3. bbox covering both endpoints + the shared rasters (one fetch, all modes).
try:
(elevation, friction_mult, friction_raw, trails, barriers,
wilderness, mvum_by_mode, meta) = self._fetch_auto_rasters(
start_lat, start_lon, end_lat, end_lon)
except Exception as e:
logger.exception("auto: raster fetch failed")
return {"status": "error", "message": f"Failed to load terrain: {e}",
"selected_mode_set": seed_set}
rows, cols = elevation.shape
# Endpoints -> pixels (mirror the existing entry-point convention).
origin_row, origin_col = self.dem_reader.latlon_to_pixel(start_lat, start_lon, meta)
goal_row, goal_col = self.dem_reader.latlon_to_pixel(end_lat, end_lon, meta)
if not (0 <= origin_row < rows and 0 <= origin_col < cols
and 0 <= goal_row < rows and 0 <= goal_col < cols):
return {"status": "error", "message": "Endpoint outside grid bounds",
"selected_mode_set": seed_set}
# 4. Per-mode cost layers (+ MVUM closures + network_affinity) and transition cells.
layers = compute_unified_cost_layers(
elevation, friction_mult, friction_raw, trails, wilderness, meta,
modes=tuple(MODE_ORDER), boundary_mode=boundary_mode,
endpoint_line=((start_lat, start_lon), (end_lat, end_lon)),
valhalla_url=VALHALLA_URL,
mvum_by_mode=mvum_by_mode, network_affinity=network_affinity,
corridor_pad_km=CORRIDOR_PAD_KM, corridor_endpoint_pad_km=CORRIDOR_ENDPOINT_PAD_KM)
# 5-6. Pack the per-mode arrays the kernel expects (MODE_ORDER == MODE_INDEX order).
n_modes = len(MODE_ORDER)
cost_mult = layers["cost_mult"]
cost_mult_stack = np.empty((rows, cols, n_modes), dtype=np.float64)
trail_friction_stack = np.full((n_modes, 256), np.inf, dtype=np.float64)
max_grade_arr = np.empty(n_modes, dtype=np.float64)
speed_function_ids = np.empty(n_modes, dtype=np.int64)
base_speed_kmh_arr = np.empty(n_modes, dtype=np.float64)
sf_id = {"tobler": 0, "herzog": 1, "linear": 2}
net_aff = network_affinity or {}
for mi, mname in enumerate(MODE_ORDER):
cost_mult_stack[:, :, mi] = cost_mult[mname]
prof = MODE_PROFILES[mname]
# §8 network_affinity also scales this mode's on-network (trail) edges: the
# kernel costs on-trail edges from trail_friction, NOT cost_mult, so the cost_mult
# bias in compute_unified_cost_layers only reaches off-network cells. >1 penalizes
# staying on the network, <1 biases toward it; default 1.0 is a no-op.
aff = float(net_aff.get(mname, 1.0))
for tv, fric in prof.trail_friction.items():
trail_friction_stack[mi, tv] = np.inf if fric is None else float(fric) * aff
max_grade_arr[mi] = float(np.tan(np.radians(prof.max_slope_deg)))
speed_function_ids[mi] = sf_id.get(prof.speed_function, 0)
base_speed_kmh_arr[mi] = float(prof.base_speed_kmh)
origin_modes = np.array(sorted(MODE_INDEX[m] for m in start_eligible), dtype=np.int64)
goal_modes = np.array(sorted(MODE_INDEX[m] for m in end_eligible), dtype=np.int64)
# HPA* fast path (Phase H3): when a tile DB is configured + covers the route, search
# the precomputed abstract chunk graph instead of flooding the full bbox. Whole-route
# fallback to the unified kernel below on any miss (HPA-SPEC.md §8/§9). When
# NAVI_OFFROUTE_HPA_DB is unset this block is skipped entirely (behaviour unchanged).
if self._hpa_eligible(boundary_mode, network_affinity):
_h0 = time.perf_counter()
_cache = {"raster": {}, "layer": {}}
hidx, hpath, hcost, hreason = astar_hpa_multimode(
HPA_TILE_DB, meta, start_lat, start_lon, end_lat, end_lon,
origin_modes, goal_modes, boundary_mode, network_affinity,
chunk_layer=lambda cx, cy, mi: self._hpa_chunk_layer(cx, cy, mi, _cache),
dem_reader=self.dem_reader)
if hidx >= 0 and hpath.shape[0] > 0:
logger.info("auto: HPA* (chunks=%d) in %.2fs",
len(_cache["raster"]), time.perf_counter() - _h0)
return self._render_unified_path(hpath, hcost, meta, boundary_mode)
logger.info("auto: HPA fallback reason=%s -> unified A*", hreason)
elif HPA_TILE_DB and os.path.exists(HPA_TILE_DB):
_r = "boundary_mode" if boundary_mode != "pragmatic" else "affinity"
logger.info("auto: HPA fallback reason=%s -> unified A*", _r)
# 7. Unpack transition cells into the kernel's flat 1D arrays.
tc = layers["transition_cells"]
nt = len(tc)
trans_rows = np.empty(nt, dtype=np.int64)
trans_cols = np.empty(nt, dtype=np.int64)
trans_from = np.empty(nt, dtype=np.int64)
trans_to = np.empty(nt, dtype=np.int64)
trans_cost = np.empty(nt, dtype=np.float64)
for i, (tr, tcl, fm, tm, cs) in enumerate(tc):
trans_rows[i], trans_cols[i], trans_from[i], trans_to[i], trans_cost[i] = tr, tcl, fm, tm, cs
# 8. Single unified search.
cell_m = float(meta["cell_size_m"])
trail_grid = np.ascontiguousarray(
trails if trails is not None else np.zeros((rows, cols), np.uint8), dtype=np.uint8)
barrier_grid = np.ascontiguousarray(
barriers if barriers is not None else np.zeros((rows, cols), np.uint8), dtype=np.uint8)
boundary_mode_id = {"strict": 0, "pragmatic": 1, "emergency": 2}.get(boundary_mode, 1)
_t0 = time.perf_counter()
best_idx, path, total_cost = astar_multigoal_multimode(
np.ascontiguousarray(cost_mult_stack), elevation, cell_m, cell_m,
max_grade_arr, speed_function_ids, base_speed_kmh_arr,
trail_grid, trail_friction_stack, barrier_grid, boundary_mode_id,
int(origin_row), int(origin_col), origin_modes,
np.array([goal_row], dtype=np.int64), np.array([goal_col], dtype=np.int64), goal_modes,
trans_rows, trans_cols, trans_from, trans_to, trans_cost)
logger.info("auto: unified A* (%d transition cells) in %.2fs",
nt, time.perf_counter() - _t0)
if best_idx < 0 or path.shape[0] == 0:
return {"status": "error", "message": "No unified route found",
"selected_mode_set": seed_set}
# 9. Render per-mode segments + transition markers (no auto_fallback_from, §7).
return self._render_unified_path(path, total_cost, meta, boundary_mode)
def _auto_eligible_modes(self, lat, lon, category, snap_cache):
"""Seed modes for one endpoint: category type-hint when present, else the
reframed spatial probe (§6). foot is always eligible."""
typed = self._eligible_modes_from_category(category)
if typed is not None:
return frozenset(typed) | {"foot"}
return frozenset(self._spatial_eligible_modes(lat, lon, snap_cache)) | {"foot"}
def _fetch_auto_rasters(self, start_lat, start_lon, end_lat, end_lon):
"""Fetch the shared rasters for one unified Auto search over a bbox covering both
endpoints (+ pad, clamped), via the existing reader objects. Returns
(elevation, friction_mult, friction_raw, trails, barriers, wilderness,
mvum_by_mode, meta)."""
self._init_readers()
pad = 0.02
bbox = {"south": min(start_lat, end_lat) - pad, "north": max(start_lat, end_lat) + pad,
"west": min(start_lon, end_lon) - pad, "east": max(start_lon, end_lon) + pad}
MAX = 2.0
if (bbox["north"] - bbox["south"] > MAX) or (bbox["east"] - bbox["west"] > MAX):
clat, clon, h = (start_lat + end_lat) / 2, (start_lon + end_lon) / 2, MAX / 2
bbox = {"south": clat - h, "north": clat + h, "west": clon - h, "east": clon + h}
elevation, meta = self.dem_reader.get_elevation_grid(
south=bbox["south"], north=bbox["north"], west=bbox["west"], east=bbox["east"])
shape = elevation.shape
friction_raw = self.friction_reader.get_friction_grid(
south=bbox["south"], north=bbox["north"], west=bbox["west"], east=bbox["east"],
target_shape=shape)
friction_mult = friction_to_multiplier(friction_raw)
barriers = self.barrier_reader.get_barrier_grid(
south=bbox["south"], north=bbox["north"], west=bbox["west"], east=bbox["east"],
target_shape=shape)
trails = self.trail_reader.get_trails_grid(
south=bbox["south"], north=bbox["north"], west=bbox["west"], east=bbox["east"],
target_shape=shape)
wilderness = None
if self.wilderness_reader is not None:
wilderness = self.wilderness_reader.get_wilderness_grid(
south=bbox["south"], north=bbox["north"], west=bbox["west"], east=bbox["east"],
target_shape=shape)
mvum_by_mode = self._build_mvum_by_mode(bbox, shape)
elevation = np.ascontiguousarray(elevation, dtype=np.float64)
return (elevation, friction_mult, friction_raw, trails, barriers,
wilderness, mvum_by_mode, meta)
def _build_mvum_by_mode(self, bbox, shape):
"""Per-mode MVUM access rasters for the motorized modes, baked into the cost
layers (spec §9): get_mvum_access_grid -> 0=unknown/1=open/255=closed. Best-effort
-> returns None (graceful degradation, spec §16 risk register) when the MVUM DB is
unavailable (e.g. tests without navi.db)."""
mvum_mode = {"2w": "mtb", "4w": "atv", "vehicle": "vehicle"}
on_date = getattr(self, "mvum_on_date", None)
check = on_date.strftime("%m/%d") if on_date else None
out = {}
for mode, mv_name in mvum_mode.items():
try:
out[mode] = get_mvum_access_grid(
bbox["south"], bbox["north"], bbox["west"], bbox["east"],
target_shape=shape, mode=mv_name, check_date=check)
except Exception as e:
logger.info("auto: MVUM grid unavailable for %s: %s", mode, e)
return out or None
def _append_unified_segment(self, features, seg_coords, mode_idx):
"""One per-mode LineString feature (skipped if < 2 coords, e.g. a transition-only
cell). network_mode drives the map's per-segment colour (Phase 6)."""
if len(seg_coords) < 2:
return
features.append({
"type": "Feature",
"properties": {"segment_type": "unified", "mode": MODE_ORDER[mode_idx],
"network_mode": MODE_ORDER[mode_idx]},
"geometry": {"type": "LineString", "coordinates": list(seg_coords)},
})
def _render_unified_path(self, path, total_cost, meta, boundary_mode):
"""Render the (N,3) (row, col, mode) unified path into the GeoJSON response shape
(per-mode LineString segments + transition Point markers at mode-change cells + a
combined full-path line), matching _build_response.
selected_mode_set = sorted distinct modes used; NO auto_fallback_from (spec §7)."""
n = path.shape[0]
coords = []
for i in range(n):
lat, lon = self.dem_reader.pixel_to_latlon(int(path[i, 0]), int(path[i, 1]), meta)
coords.append([lon, lat])
modes = [int(path[i, 2]) for i in range(n)]
features = []
transitions = []
seg_start = 0
for i in range(1, n):
if modes[i] != modes[i - 1]:
self._append_unified_segment(features, coords[seg_start:i], modes[i - 1])
transitions.append((coords[i], modes[i - 1], modes[i]))
seg_start = i
self._append_unified_segment(features, coords[seg_start:n], modes[n - 1])
for (xy, fm, tm) in transitions:
features.append({
"type": "Feature",
"properties": {"segment_type": "transition", "kind": "transition",
"lat": xy[1], "lon": xy[0],
"from_mode": MODE_ORDER[fm], "to_mode": MODE_ORDER[tm]},
"geometry": {"type": "Point", "coordinates": [xy[0], xy[1]]},
})
combined = []
for xy in coords:
if not combined or combined[-1] != xy:
combined.append(xy)
if len(combined) >= 2:
features.append({
"type": "Feature",
"properties": {"segment_type": "combined", "boundary_mode": boundary_mode,
"scenario": "unified"},
"geometry": {"type": "LineString", "coordinates": combined},
})
total_m = sum(haversine_distance(combined[i][1], combined[i][0],
combined[i + 1][1], combined[i + 1][0])
for i in range(len(combined) - 1))
selected_mode_set = sorted({MODE_ORDER[m] for m in modes})
return {
"status": "ok",
"route": {"type": "FeatureCollection", "features": features},
"summary": {
"total_distance_km": total_m / 1000.0,
"total_effort_minutes": float(total_cost) / 60.0,
"scenario": "unified",
"boundary_mode": boundary_mode,
"selected_mode_set": selected_mode_set,
},
"selected_mode": selected_mode_set[0] if len(selected_mode_set) == 1 else "hybrid",
"selected_mode_set": selected_mode_set,
"scenario": "unified",
}
def _hpa_eligible(self, boundary_mode, network_affinity):
"""HPA* engages only with a configured + existing tile DB, the default boundary mode,
and no network_affinity — the tiles are pure-terrain/pragmatic (HPA-SPEC.md §8), so
other configs would change the answer and must use the unified fallback."""
if not (HPA_TILE_DB and os.path.exists(HPA_TILE_DB)):
return False
if boundary_mode != "pragmatic":
return False
if network_affinity and any(float(v) != 1.0 for v in network_affinity.values()):
return False
return True
def _hpa_chunk_layer(self, cx, cy, mode_idx, cache):
"""Live native-30m cost layer for one chunk (tile-grid-aligned), for HPA* refinement.
Pure terrain only (no MVUM/barriers/network_affinity/corridor-mask), matching the H2
tile build so entrance cells and costs line up. Rasters cached per chunk, layers per
(chunk, mode)."""
from . import hpa_build as hb
if (cx, cy) not in cache["raster"]:
s, w, n, e = hb.chunk_bounds(cx, cy)
elev, cmeta = self.dem_reader.get_elevation_grid(south=s, north=n, west=w, east=e)
shape = elev.shape
fraw = self.friction_reader.get_friction_grid(
south=s, north=n, west=w, east=e, target_shape=shape)
fmult = friction_to_multiplier(fraw)
trails = self.trail_reader.get_trails_grid(
south=s, north=n, west=w, east=e, target_shape=shape)
wild = None
if self.wilderness_reader is not None:
wild = self.wilderness_reader.get_wilderness_grid(
south=s, north=n, west=w, east=e, target_shape=shape)
cache["raster"][(cx, cy)] = (
np.ascontiguousarray(elev, np.float64), fmult, fraw, trails, wild, cmeta)
elev, fmult, fraw, trails, wild, cmeta = cache["raster"][(cx, cy)]
key = (cx, cy, mode_idx)
if key not in cache["layer"]:
mode = MODE_ORDER[mode_idx]
prof = MODE_PROFILES[mode]
cs = float(cmeta["cell_size_m"])
cm = compute_cost_multiplier_grid(
elev, cell_size_lat_m=cs, cell_size_lon_m=cs,
friction=fmult, friction_raw=fraw, wilderness=wild, mode=mode)
cm = np.ascontiguousarray(inflate_cost_multiplier(cm), np.float64)
tfl = np.full(256, np.inf, np.float64)
for tv, fr in prof.trail_friction.items():
tfl[tv] = np.inf if fr is None else float(fr)
shape = elev.shape
cache["layer"][key] = {
"cost_mult": cm, "elevation": elev,
"trail_grid": np.ascontiguousarray(
trails if trails is not None else np.zeros(shape, np.uint8), np.uint8),
"trail_friction_lookup": tfl,
"barrier_grid": np.zeros(shape, np.uint8),
"max_grade": float(np.tan(np.radians(prof.max_slope_deg))),
"speed_function_id": {"tobler": 0, "herzog": 1, "linear": 2}.get(prof.speed_function, 0),
"base_speed_kmh": float(prof.base_speed_kmh),
"cell_size_m": cs,
"entrance_cells": hb._entrance_cells(*shape),
"meta": cmeta,
}
return cache["layer"][key]
def _route_D_network_only(
self,
start_lat: float, start_lon: float,
end_lat: float, end_lon: float,
mode: str
) -> Dict:
"""
Scenario D: Both endpoints on-network. Pure Valhalla routing.
"""
t0 = time.time()
costing = MODE_TO_COSTING.get(mode, "pedestrian")
valhalla_request = {
"locations": [
{"lat": start_lat, "lon": start_lon},
{"lat": end_lat, "lon": end_lon}
],
"costing": costing,
"directions_options": {"units": "kilometers"}
}
# MVUM Layer 2c: Valhalla wants array-of-rings, not GeoJSON Polygons.
_ex = getattr(self, "_exclude_polygons", None)
if _ex:
valhalla_request["exclude_polygons"] = [p["coordinates"][0] for p in _ex]
try:
resp = requests.post(f"{VALHALLA_URL}/route", json=valhalla_request, timeout=30)
if resp.status_code != 200:
return {
"status": "error",
"message": f"Network routing failed: {resp.text[:200]}"
}
valhalla_data = resp.json()
trip = valhalla_data.get("trip", {})
legs = trip.get("legs", [])
if not legs:
return {"status": "error", "message": "No route found"}
leg = legs[0]
shape = leg.get("shape", "")
network_coords = self._decode_polyline(shape)
maneuvers = []
for m in leg.get("maneuvers", []):
maneuvers.append({
"instruction": m.get("instruction", ""),
"type": m.get("type", 0),
"distance_km": m.get("length", 0),
"time_seconds": m.get("time", 0),
"street_names": m.get("street_names", []),
})
summary = trip.get("summary", {})
distance_km = summary.get("length", 0)
duration_min = summary.get("time", 0) / 60
# Build response in same format as wilderness routes
network_feature = {
"type": "Feature",
"properties": {
"segment_type": "network",
"distance_km": distance_km,
"duration_minutes": duration_min,
"maneuvers": maneuvers,
"network_mode": mode,
},
"geometry": {"type": "LineString", "coordinates": network_coords}
}
combined_feature = {
"type": "Feature",
"properties": {
"segment_type": "combined",
"network_mode": mode,
},
"geometry": {"type": "LineString", "coordinates": network_coords}
}
geojson = {"type": "FeatureCollection", "features": [network_feature, combined_feature]}
result = {
"status": "ok",
"route": geojson,
"summary": {
"total_distance_km": float(distance_km),
"total_effort_minutes": float(duration_min),
"wilderness_distance_km": 0.0,
"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,
"scenario": "D",
"computation_time_s": time.time() - t0,
}
}
return result
except Exception as e:
return {"status": "error", "message": f"Network routing failed: {e}"}
def _route_A_wilderness_to_network(
self,
start_lat: float, start_lon: float,
end_lat: float, end_lon: float,
mode: str, boundary_mode: str
) -> Dict:
"""
Scenario A: Off-network start → on-network end.
Wilderness pathfinding from start to entry point, then Valhalla to end.
"""
t0 = time.time()
# Ensure entry point index exists
if not self.entry_index.has_entry_points():
return {
"status": "error",
"message": "Trail entry point index not built. Run build_entry_index() first."
}
# 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 = 5 # tighter wilderness bbox: origin + 5 nearest entry points
entry_points = self.entry_index.query_radius(
start_lat, start_lon, DEFAULT_SEARCH_RADIUS_KM, valid_highways
)
if not entry_points:
entry_points = self.entry_index.query_radius(
start_lat, start_lon, EXPANDED_SEARCH_RADIUS_KM, valid_highways
)
if not entry_points:
if mode == "vehicle":
msg = f"No roads found within {EXPANDED_SEARCH_RADIUS_KM}km. Try a different mode."
elif mode in ("2w", "4w"):
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}
entry_points = entry_points[:MAX_ENTRY_POINTS]
# Run wilderness pathfinding
wilderness_result = self._pathfind_wilderness(
start_lat, start_lon, end_lat, end_lon,
entry_points, boundary_mode, "start", mode=mode
)
if wilderness_result.get("status") == "error":
return wilderness_result
# Extract results
wilderness_coords = wilderness_result["coords"]
wilderness_stats = wilderness_result["stats"]
wilderness_elevations = wilderness_result.get("elevations", [])
best_entry = wilderness_result["entry_point"]
entry_lat = best_entry["lat"]
entry_lon = best_entry["lon"]
# Call Valhalla from entry point to destination
network_result = self._valhalla_route(entry_lat, entry_lon, end_lat, end_lon, mode)
# Build response
return self._build_response(
wilderness_start=wilderness_coords,
wilderness_start_stats=wilderness_stats,
wilderness_start_elevations=wilderness_elevations,
network_segment=network_result.get("segment"),
wilderness_end=None,
wilderness_end_stats=None,
wilderness_end_elevations=None,
mode=mode,
boundary_mode=boundary_mode,
entry_start=best_entry,
entry_end=None,
scenario="A",
t0=t0,
valhalla_error=network_result.get("error")
)
def _route_C_network_to_wilderness(
self,
start_lat: float, start_lon: float,
end_lat: float, end_lon: float,
mode: str, boundary_mode: str
) -> Dict:
"""
Scenario C: On-network start → off-network end.
Valhalla from start to entry point, then wilderness pathfinding to end.
"""
t0 = time.time()
if not self.entry_index.has_entry_points():
return {
"status": "error",
"message": "Trail entry point index not built. Run build_entry_index() first."
}
valid_highways = MODE_TO_VALID_HIGHWAYS.get(mode)
# Find entry points near END (destination)
MAX_ENTRY_POINTS = 5 # tighter wilderness bbox: origin + 5 nearest entry points
entry_points = self.entry_index.query_radius(
end_lat, end_lon, DEFAULT_SEARCH_RADIUS_KM, valid_highways
)
if not entry_points:
entry_points = self.entry_index.query_radius(
end_lat, end_lon, EXPANDED_SEARCH_RADIUS_KM, valid_highways
)
if not entry_points:
if mode == "vehicle":
msg = f"No roads found within {EXPANDED_SEARCH_RADIUS_KM}km of destination. Try a different mode."
elif mode in ("2w", "4w"):
msg = f"No tracks or roads found within {EXPANDED_SEARCH_RADIUS_KM}km of destination. Try foot mode."
else:
msg = f"No trail entry points found within {EXPANDED_SEARCH_RADIUS_KM}km of destination."
return {"status": "error", "message": msg}
entry_points = entry_points[:MAX_ENTRY_POINTS]
# Run wilderness pathfinding FROM END toward entry points
wilderness_result = self._pathfind_wilderness(
end_lat, end_lon, start_lat, start_lon,
entry_points, boundary_mode, "end", mode=mode
)
if wilderness_result.get("status") == "error":
return wilderness_result
# The path is from end→entry, reverse it for display (entry→end)
wilderness_coords = list(reversed(wilderness_result["coords"]))
wilderness_stats = wilderness_result["stats"]
wilderness_elevations = list(reversed(wilderness_result.get("elevations", [])))
best_entry = wilderness_result["entry_point"]
entry_lat = best_entry["lat"]
entry_lon = best_entry["lon"]
# Call Valhalla from start to entry point
network_result = self._valhalla_route(start_lat, start_lon, entry_lat, entry_lon, mode)
# Build response (network first, then wilderness)
return self._build_response(
wilderness_start=None,
wilderness_start_stats=None,
wilderness_start_elevations=None,
network_segment=network_result.get("segment"),
wilderness_end=wilderness_coords,
wilderness_end_stats=wilderness_stats,
wilderness_end_elevations=wilderness_elevations,
mode=mode,
boundary_mode=boundary_mode,
entry_start=None,
entry_end=best_entry,
scenario="C",
t0=t0,
valhalla_error=network_result.get("error")
)
def _route_B_wilderness_both(
self,
start_lat: float, start_lon: float,
end_lat: float, end_lon: float,
mode: str, boundary_mode: str
) -> Dict:
"""
Scenario B: Off-network start → off-network end.
Wilderness from start to entry_A, Valhalla entry_A to entry_B, wilderness from entry_B to end.
"""
t0 = time.time()
if not self.entry_index.has_entry_points():
return {
"status": "error",
"message": "Trail entry point index not built. Run build_entry_index() first."
}
valid_highways = MODE_TO_VALID_HIGHWAYS.get(mode)
MAX_ENTRY_POINTS = 5 # tighter wilderness bbox: origin + 5 nearest entry points
# Find entry points near START
entry_points_start = self.entry_index.query_radius(
start_lat, start_lon, DEFAULT_SEARCH_RADIUS_KM, valid_highways
)
if not entry_points_start:
entry_points_start = self.entry_index.query_radius(
start_lat, start_lon, EXPANDED_SEARCH_RADIUS_KM, valid_highways
)
if not entry_points_start:
return {"status": "error", "message": f"No entry points found near start within {EXPANDED_SEARCH_RADIUS_KM}km."}
entry_points_start = entry_points_start[:MAX_ENTRY_POINTS]
# Find entry points near END
entry_points_end = self.entry_index.query_radius(
end_lat, end_lon, DEFAULT_SEARCH_RADIUS_KM, valid_highways
)
if not entry_points_end:
entry_points_end = self.entry_index.query_radius(
end_lat, end_lon, EXPANDED_SEARCH_RADIUS_KM, valid_highways
)
if not entry_points_end:
return {"status": "error", "message": f"No entry points found near destination within {EXPANDED_SEARCH_RADIUS_KM}km."}
entry_points_end = entry_points_end[:MAX_ENTRY_POINTS]
# Phase 1: Wilderness pathfinding from START
wilderness_start_result = self._pathfind_wilderness(
start_lat, start_lon, end_lat, end_lon,
entry_points_start, boundary_mode, "start", mode=mode
)
if wilderness_start_result.get("status") == "error":
return wilderness_start_result
wilderness_start_coords = wilderness_start_result["coords"]
wilderness_start_stats = wilderness_start_result["stats"]
wilderness_start_elevations = wilderness_start_result.get("elevations", [])
entry_A = wilderness_start_result["entry_point"]
# Phase 2: Wilderness pathfinding from END (run after freeing phase 1 memory)
wilderness_end_result = self._pathfind_wilderness(
end_lat, end_lon, start_lat, start_lon,
entry_points_end, boundary_mode, "end", mode=mode
)
if wilderness_end_result.get("status") == "error":
return wilderness_end_result
# Reverse the end wilderness path (it's end→entry, we want entry→end for display)
wilderness_end_coords = list(reversed(wilderness_end_result["coords"]))
wilderness_end_stats = wilderness_end_result["stats"]
wilderness_end_elevations = list(reversed(wilderness_end_result.get("elevations", [])))
entry_B = wilderness_end_result["entry_point"]
# Phase 3: Valhalla from entry_A to entry_B
network_result = self._valhalla_route(
entry_A["lat"], entry_A["lon"],
entry_B["lat"], entry_B["lon"],
mode
)
# Build response
return self._build_response(
wilderness_start=wilderness_start_coords,
wilderness_start_stats=wilderness_start_stats,
wilderness_start_elevations=wilderness_start_elevations,
network_segment=network_result.get("segment"),
wilderness_end=wilderness_end_coords,
wilderness_end_stats=wilderness_end_stats,
wilderness_end_elevations=wilderness_end_elevations,
mode=mode,
boundary_mode=boundary_mode,
entry_start=entry_A,
entry_end=entry_B,
scenario="B",
t0=t0,
valhalla_error=network_result.get("error")
)
def _pathfind_wilderness(
self,
origin_lat: float, origin_lon: float,
dest_lat: float, dest_lon: float,
entry_points: List[Dict],
boundary_mode: str,
label: str,
mode: str = "foot",
) -> Dict:
"""
Run MCP wilderness pathfinding from origin toward entry points.
Args:
origin_lat, origin_lon: Starting point for pathfinding
dest_lat, dest_lon: Ultimate destination (for bbox calculation)
entry_points: List of candidate entry points
boundary_mode: How to handle barriers
label: "start" or "end" for error messages
Returns:
{"status": "ok", "coords": [...], "stats": {...}, "entry_point": {...}}
or {"status": "error", "message": "..."}
"""
# Build a tight bbox around origin + the (<=5) nearest entry points, NOT the distant
# destination (Valhalla handles the network leg). A small pad keeps the pathfinder
# off the grid edge; typical spans land ~3-5 km/side. MAX_BBOX_DEGREES is the
# absolute safety clamp, unchanged.
MAX_BBOX_DEGREES = 2.0
all_lats = [origin_lat] + [p["lat"] for p in entry_points]
all_lons = [origin_lon] + [p["lon"] for p in entry_points]
padding = 0.015 # ~1.5 km
bbox = {
"south": min(all_lats) - padding,
"north": max(all_lats) + padding,
"west": min(all_lons) - padding,
"east": max(all_lons) + padding,
}
# Clamp bbox size, centering on origin
lat_span = bbox["north"] - bbox["south"]
lon_span = bbox["east"] - bbox["west"]
if lat_span > MAX_BBOX_DEGREES or lon_span > MAX_BBOX_DEGREES:
half_span = MAX_BBOX_DEGREES / 2
bbox = {
"south": origin_lat - half_span,
"north": origin_lat + half_span,
"west": origin_lon - half_span,
"east": origin_lon + half_span,
}
# Initialize readers
self._init_readers()
# Load elevation
try:
elevation, meta = self.dem_reader.get_elevation_grid(
south=bbox["south"], north=bbox["north"],
west=bbox["west"], east=bbox["east"],
)
except Exception as e:
return {"status": "error", "message": f"Failed to load elevation for {label}: {e}"}
# Check memory
mem = check_memory_usage()
if mem > MEMORY_LIMIT_GB:
return {"status": "error", "message": f"Memory limit exceeded: {mem:.1f}GB > {MEMORY_LIMIT_GB}GB"}
# Load friction
friction_raw = self.friction_reader.get_friction_grid(
south=bbox["south"], north=bbox["north"],
west=bbox["west"], east=bbox["east"],
target_shape=elevation.shape
)
friction_mult = friction_to_multiplier(friction_raw)
# Load barriers
barriers = self.barrier_reader.get_barrier_grid(
south=bbox["south"], north=bbox["north"],
west=bbox["west"], east=bbox["east"],
target_shape=elevation.shape
)
# Load trails
trails = self.trail_reader.get_trails_grid(
south=bbox["south"], north=bbox["north"],
west=bbox["west"], east=bbox["east"],
target_shape=elevation.shape
)
# ── Anisotropic A* pathfinding (replaces isotropic MCP) ──
# Wilderness pathfinding ALWAYS uses foot effort, regardless of the user's mode.
# The off-trail cost math for MTB/ATV/vehicle is not well-grounded (no peer-reviewed
# off-road model), and real-world wilderness traversal is foot anyway: you push the
# bike and walk past where the vehicle stops. The user's mode still affects entry-point
# eligibility (the highway filter at the query_radius call sites) and the Valhalla
# network-leg costing -- just not this wilderness leg.
_ = mode # reserved for future flexibility; unused for wilderness cost
cost_mode = "foot"
profile = MODE_PROFILES[cost_mode]
cell_size_m = meta["cell_size_m"]
# Wilderness grid only matters for modes that treat it as impassable.
wilderness = None
if profile.wilderness_impassable and self.wilderness_reader is not None:
wilderness = self.wilderness_reader.get_wilderness_grid(
south=bbox["south"], north=bbox["north"],
west=bbox["west"], east=bbox["east"],
target_shape=elevation.shape,
)
# Per-cell context multiplier (slope-free; trails/barriers are per-edge in A*),
# then exponentially inflated so hard cells bleed a decaying penalty outward.
cost_mult = compute_cost_multiplier_grid(
elevation,
cell_size_lat_m=cell_size_m,
cell_size_lon_m=cell_size_m,
friction=friction_mult,
friction_raw=friction_raw,
wilderness=wilderness,
mode=cost_mode,
)
cost_mult = inflate_cost_multiplier(cost_mult)
# Free intermediate arrays
del friction_mult, friction_raw
gc.collect()
# Trail friction lookup (length-256, indexed by trail value; inf = impassable).
trail_friction_lookup = np.full(256, np.inf, dtype=np.float64)
for tv, fric in profile.trail_friction.items():
trail_friction_lookup[tv] = np.inf if fric is None else float(fric)
speed_function_id = {"tobler": 0, "herzog": 1, "linear": 2}.get(profile.speed_function, 0)
max_grade = float(np.tan(np.radians(profile.max_slope_deg)))
# Convert origin to pixel coordinates
origin_row, origin_col = self.dem_reader.latlon_to_pixel(origin_lat, origin_lon, meta)
rows, cols = elevation.shape
if not (0 <= origin_row < rows and 0 <= origin_col < cols):
return {"status": "error", "message": f"{label.capitalize()} point outside grid bounds"}
# Map entry points to pixels (these are the A* goals).
entry_pixels = []
for ep in entry_points:
row, col = self.dem_reader.latlon_to_pixel(ep["lat"], ep["lon"], meta)
if 0 <= row < rows and 0 <= col < cols:
entry_pixels.append({"row": row, "col": col, "entry_point": ep})
if not entry_pixels:
return {"status": "error", "message": f"No entry points map to grid bounds for {label}"}
goal_rows = np.array([ep["row"] for ep in entry_pixels], dtype=np.int64)
goal_cols = np.array([ep["col"] for ep in entry_pixels], dtype=np.int64)
boundary_mode_id = {"strict": 0, "pragmatic": 1, "emergency": 2}.get(boundary_mode, 1)
# Run multi-goal A* (first goal popped wins).
elevation = np.ascontiguousarray(elevation, dtype=np.float64)
best_goal_idx, path_indices, best_cost = astar_multigoal(
cost_mult, elevation,
float(cell_size_m), float(cell_size_m),
max_grade, speed_function_id, float(profile.base_speed_kmh),
np.ascontiguousarray(trails, dtype=np.uint8), trail_friction_lookup,
np.ascontiguousarray(barriers, dtype=np.uint8), boundary_mode_id,
int(origin_row), int(origin_col),
goal_rows, goal_cols,
)
if best_goal_idx < 0 or len(path_indices) == 0 or np.isinf(best_cost):
return {
"status": "error",
"message": f"No path found from {label} to any entry point (blocked by impassable terrain)"
}
best_entry = entry_pixels[best_goal_idx]
# Convert to coordinates and collect stats
coords = []
elevations = []
trail_values = []
barrier_crossings = 0
for row, col in path_indices:
lat, lon = self.dem_reader.pixel_to_latlon(row, col, meta)
coords.append([lon, lat])
elevations.append(elevation[row, col])
trail_values.append(trails[row, col])
if barriers[row, col] == 255:
barrier_crossings += 1
# Calculate distance
distance_m = 0
for i in range(1, len(coords)):
lon1, lat1 = coords[i-1]
lon2, lat2 = coords[i]
distance_m += haversine_distance(lat1, lon1, lat2, lon2)
# Elevation stats
elev_arr = np.array(elevations)
elev_diff = np.diff(elev_arr)
elev_gain = float(np.sum(elev_diff[elev_diff > 0]))
elev_loss = float(np.sum(np.abs(elev_diff[elev_diff < 0])))
# Trail stats
trail_arr = np.array(trail_values)
on_trail_cells = np.sum(trail_arr > 0)
total_cells = len(trail_arr)
on_trail_pct = float(100 * on_trail_cells / total_cells) if total_cells > 0 else 0
# Free memory
del cost_mult, trails, barriers, elevation
gc.collect()
return {
"status": "ok",
"coords": coords,
"elevations": elevations, # Raw elevation values for maneuver generation
"stats": {
"distance_km": distance_m / 1000,
"effort_minutes": best_cost / 60,
"elevation_gain_m": elev_gain,
"elevation_loss_m": elev_loss,
"on_trail_pct": on_trail_pct,
"barrier_crossings": barrier_crossings,
"cell_count": total_cells,
},
"entry_point": best_entry["entry_point"]
}
def _valhalla_route(
self,
start_lat: float, start_lon: float,
end_lat: float, end_lon: float,
mode: str
) -> Dict:
"""
Call Valhalla for network routing.
Returns:
{"segment": {...}, "error": None} on success
{"segment": None, "error": "..."} on failure
"""
costing = MODE_TO_COSTING.get(mode, "pedestrian")
valhalla_request = {
"locations": [
{"lat": start_lat, "lon": start_lon},
{"lat": end_lat, "lon": end_lon}
],
"costing": costing,
"directions_options": {"units": "kilometers"}
}
# MVUM Layer 2c: Valhalla wants array-of-rings, not GeoJSON Polygons.
_ex = getattr(self, "_exclude_polygons", None)
if _ex:
valhalla_request["exclude_polygons"] = [p["coordinates"][0] for p in _ex]
try:
resp = requests.post(f"{VALHALLA_URL}/route", json=valhalla_request, timeout=30)
if resp.status_code == 200:
valhalla_data = resp.json()
trip = valhalla_data.get("trip", {})
legs = trip.get("legs", [])
if legs:
leg = legs[0]
shape = leg.get("shape", "")
coords = self._decode_polyline(shape)
maneuvers = []
for m in leg.get("maneuvers", []):
maneuvers.append({
"instruction": m.get("instruction", ""),
"type": m.get("type", 0),
"distance_km": m.get("length", 0),
"time_seconds": m.get("time", 0),
"street_names": m.get("street_names", []),
})
summary = trip.get("summary", {})
return {
"segment": {
"coordinates": coords,
"distance_km": summary.get("length", 0),
"duration_minutes": summary.get("time", 0) / 60,
"maneuvers": maneuvers,
},
"error": None
}
return {"segment": None, "error": f"Valhalla returned {resp.status_code}: {resp.text[:200]}"}
except Exception as e:
return {"segment": None, "error": f"Valhalla request failed: {e}"}
def _generate_wilderness_maneuvers(
self,
coords: List[List[float]],
elevations: List[float],
position: str = "start"
) -> List[Dict]:
"""
Generate turn-by-turn maneuvers for a wilderness segment.
Segment breaks occur when:
- Bearing changes more than 30° from segment start
- Grade category changes (flat→steep etc)
- Distance exceeds 0.5 miles without a break
Args:
coords: [[lon, lat], ...] coordinate list
elevations: Elevation values (meters) for each coord
position: "start" or "end" for labeling
Returns:
List of maneuver dicts with instruction, distance, elevation, grade, bearing
"""
if not coords or len(coords) < 2:
return []
# Constants
COMPASS = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]
MAX_SEGMENT_M = 804.672 # 0.5 miles in meters
BEARING_THRESHOLD = 30 # degrees
M_TO_FT = 3.28084
M_TO_MI = 0.000621371
def get_bearing(lat1, lon1, lat2, lon2):
"""Calculate bearing between two points (degrees 0-360)."""
dlon = math.radians(lon2 - lon1)
lat1_r, lat2_r = math.radians(lat1), math.radians(lat2)
x = math.sin(dlon) * math.cos(lat2_r)
y = math.cos(lat1_r) * math.sin(lat2_r) - math.sin(lat1_r) * math.cos(lat2_r) * math.cos(dlon)
return (math.degrees(math.atan2(x, y)) + 360) % 360
def bearing_to_cardinal(bearing):
"""Convert bearing to 16-point compass direction."""
return COMPASS[round(bearing / 22.5) % 16]
def get_grade_category(grade_deg):
"""Categorize grade angle: flat (0-2°), gentle (2-5°), moderate (5-10°), steep (10-15°), very steep (15°+)."""
grade_abs = abs(grade_deg)
if grade_abs < 2:
return "flat"
elif grade_abs < 5:
return "gentle"
elif grade_abs < 10:
return "moderate"
elif grade_abs < 15:
return "steep"
else:
return "very steep"
def format_distance(meters):
"""Format distance: feet with commas if under 1 mile, miles with one decimal if over."""
miles = meters * M_TO_MI
if miles < 1.0:
feet = round(meters * M_TO_FT)
return f"{feet:,} ft"
else:
return f"{miles:.1f} mi"
def build_instruction(cardinal, gain_ft, loss_ft, grade_cat, distance_m):
"""Build instruction string per spec."""
dist_str = format_distance(distance_m)
if grade_cat == "flat":
return f"Head {cardinal} on level ground — {dist_str}"
elif gain_ft > loss_ft:
return f"Head {cardinal}, gaining {gain_ft:,} ft ({grade_cat} uphill) — {dist_str}"
else:
return f"Head {cardinal}, descending {loss_ft:,} ft ({grade_cat} downhill) — {dist_str}"
maneuvers = []
i = 0
while i < len(coords) - 1:
seg_start_idx = i
seg_start_lon, seg_start_lat = coords[i]
seg_start_elev = elevations[i] if i < len(elevations) else 0
# Initial bearing for this segment
next_lon, next_lat = coords[i + 1]
seg_bearing = get_bearing(seg_start_lat, seg_start_lon, next_lat, next_lon)
# Accumulate elevation changes within segment
seg_distance_m = 0
seg_elev_gain = 0
seg_elev_loss = 0
prev_elev = seg_start_elev
# Calculate initial grade category
step_dist = haversine_distance(seg_start_lat, seg_start_lon, next_lat, next_lon)
step_elev_change = (elevations[i + 1] if i + 1 < len(elevations) else seg_start_elev) - seg_start_elev
initial_grade = math.degrees(math.atan(step_elev_change / step_dist)) if step_dist > 0 else 0
seg_grade_cat = get_grade_category(initial_grade)
j = i
while j < len(coords) - 1:
lon1, lat1 = coords[j]
lon2, lat2 = coords[j + 1]
elev1 = elevations[j] if j < len(elevations) else prev_elev
elev2 = elevations[j + 1] if j + 1 < len(elevations) else elev1
step_dist = haversine_distance(lat1, lon1, lat2, lon2)
step_bearing = get_bearing(lat1, lon1, lat2, lon2)
step_elev_change = elev2 - elev1
step_grade = math.degrees(math.atan(step_elev_change / step_dist)) if step_dist > 0 else 0
step_grade_cat = get_grade_category(step_grade)
# Check break conditions
bearing_diff = abs(step_bearing - seg_bearing)
if bearing_diff > 180:
bearing_diff = 360 - bearing_diff
# Break if: bearing changed >30°, grade category changed, or distance >0.5mi
if seg_distance_m > 0: # Don't break on first step
if bearing_diff > BEARING_THRESHOLD:
break
if step_grade_cat != seg_grade_cat:
break
if seg_distance_m >= MAX_SEGMENT_M:
break
# Accumulate
seg_distance_m += step_dist
if step_elev_change > 0:
seg_elev_gain += step_elev_change
else:
seg_elev_loss += abs(step_elev_change)
prev_elev = elev2
j += 1
# Compute segment stats
seg_end_idx = j
gain_ft = round(seg_elev_gain * M_TO_FT)
loss_ft = round(seg_elev_loss * M_TO_FT)
# Net elevation change for grade calculation
net_elev_change = seg_elev_gain - seg_elev_loss
grade_deg = math.degrees(math.atan(net_elev_change / seg_distance_m)) if seg_distance_m > 0 else 0
grade_cat = get_grade_category(grade_deg)
cardinal = bearing_to_cardinal(seg_bearing)
instruction = build_instruction(cardinal, gain_ft, loss_ft, grade_cat, seg_distance_m)
maneuvers.append({
"instruction": instruction,
"type": "wilderness",
"distance_m": round(seg_distance_m, 1),
"elevation_gain_ft": gain_ft,
"elevation_loss_ft": loss_ft,
"grade_degrees": round(grade_deg, 1),
"grade_category": grade_cat,
"bearing": round(seg_bearing, 1),
"cardinal": cardinal,
})
i = seg_end_idx
# Add arrival maneuver
arrival_text = "Arrive at trail/road" if position == "start" else "Arrive at destination"
last_bearing = maneuvers[-1]["bearing"] if maneuvers else 0
last_cardinal = maneuvers[-1]["cardinal"] if maneuvers else "N"
maneuvers.append({
"instruction": arrival_text,
"type": "arrival",
"distance_m": 0,
"elevation_gain_ft": 0,
"elevation_loss_ft": 0,
"grade_degrees": 0,
"grade_category": "flat",
"bearing": last_bearing,
"cardinal": last_cardinal,
})
return maneuvers
def _build_response(
self,
wilderness_start: Optional[List],
wilderness_start_stats: Optional[Dict],
wilderness_start_elevations: Optional[List],
network_segment: Optional[Dict],
wilderness_end: Optional[List],
wilderness_end_stats: Optional[Dict],
wilderness_end_elevations: Optional[List],
mode: str,
boundary_mode: str,
entry_start: Optional[Dict],
entry_end: Optional[Dict],
scenario: str,
t0: float,
valhalla_error: Optional[str]
) -> Dict:
"""Build the final GeoJSON response."""
features = []
# Wilderness start segment
if wilderness_start and wilderness_start_stats:
wild_start_maneuvers = []
if wilderness_start_elevations:
wild_start_maneuvers = self._generate_wilderness_maneuvers(
wilderness_start, wilderness_start_elevations, position="start"
)
features.append({
"type": "Feature",
"properties": {
"segment_type": "wilderness",
"segment_position": "start",
"effort_minutes": float(wilderness_start_stats["effort_minutes"]),
"distance_km": float(wilderness_start_stats["distance_km"]),
"elevation_gain_m": wilderness_start_stats["elevation_gain_m"],
"elevation_loss_m": wilderness_start_stats["elevation_loss_m"],
"boundary_mode": boundary_mode,
"on_trail_pct": wilderness_start_stats["on_trail_pct"],
"barrier_crossings": wilderness_start_stats["barrier_crossings"],
"wilderness_mode": "foot",
"maneuvers": wild_start_maneuvers,
},
"geometry": {"type": "LineString", "coordinates": wilderness_start}
})
# Network segment
if network_segment:
features.append({
"type": "Feature",
"properties": {
"segment_type": "network",
"distance_km": network_segment["distance_km"],
"duration_minutes": network_segment["duration_minutes"],
"maneuvers": network_segment["maneuvers"],
"network_mode": mode,
},
"geometry": {"type": "LineString", "coordinates": network_segment["coordinates"]}
})
# Wilderness end segment
if wilderness_end and wilderness_end_stats:
wild_end_maneuvers = []
if wilderness_end_elevations:
wild_end_maneuvers = self._generate_wilderness_maneuvers(
wilderness_end, wilderness_end_elevations, position="end"
)
features.append({
"type": "Feature",
"properties": {
"segment_type": "wilderness",
"segment_position": "end",
"effort_minutes": float(wilderness_end_stats["effort_minutes"]),
"distance_km": float(wilderness_end_stats["distance_km"]),
"elevation_gain_m": wilderness_end_stats["elevation_gain_m"],
"elevation_loss_m": wilderness_end_stats["elevation_loss_m"],
"boundary_mode": boundary_mode,
"on_trail_pct": wilderness_end_stats["on_trail_pct"],
"barrier_crossings": wilderness_end_stats["barrier_crossings"],
"wilderness_mode": "foot",
"maneuvers": wild_end_maneuvers,
},
"geometry": {"type": "LineString", "coordinates": wilderness_end}
})
# Combined path
combined_coords = []
if wilderness_start:
combined_coords.extend(wilderness_start)
if network_segment:
# Skip first coord if we already have wilderness_start (avoid duplicate)
start_idx = 1 if wilderness_start else 0
combined_coords.extend(network_segment["coordinates"][start_idx:])
if wilderness_end:
# Skip first coord (avoid duplicate with network end)
start_idx = 1 if (wilderness_start or network_segment) else 0
combined_coords.extend(wilderness_end[start_idx:])
if combined_coords:
features.append({
"type": "Feature",
"properties": {
"segment_type": "combined",
"wilderness_mode": "foot",
"network_mode": mode,
"boundary_mode": boundary_mode,
"scenario": scenario,
},
"geometry": {"type": "LineString", "coordinates": combined_coords}
})
geojson = {"type": "FeatureCollection", "features": features}
# Calculate totals
total_distance_km = 0.0
total_effort_minutes = 0.0
wilderness_distance_km = 0.0
wilderness_effort_minutes = 0.0
network_distance_km = 0.0
network_duration_minutes = 0.0
barrier_crossings = 0
on_trail_pct = 0.0
if wilderness_start_stats:
wilderness_distance_km += wilderness_start_stats["distance_km"]
wilderness_effort_minutes += wilderness_start_stats["effort_minutes"]
barrier_crossings += wilderness_start_stats["barrier_crossings"]
on_trail_pct = wilderness_start_stats["on_trail_pct"]
if wilderness_end_stats:
wilderness_distance_km += wilderness_end_stats["distance_km"]
wilderness_effort_minutes += wilderness_end_stats["effort_minutes"]
barrier_crossings += wilderness_end_stats["barrier_crossings"]
# Average on-trail percentage if we have both
if wilderness_start_stats:
on_trail_pct = (on_trail_pct + wilderness_end_stats["on_trail_pct"]) / 2
else:
on_trail_pct = wilderness_end_stats["on_trail_pct"]
if network_segment:
network_distance_km = network_segment["distance_km"]
network_duration_minutes = network_segment["duration_minutes"]
total_distance_km = wilderness_distance_km + network_distance_km
total_effort_minutes = wilderness_effort_minutes + network_duration_minutes
summary = {
"total_distance_km": float(total_distance_km),
"total_effort_minutes": float(total_effort_minutes),
"wilderness_distance_km": float(wilderness_distance_km),
"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,
"wilderness_mode": "foot",
"network_mode": mode,
"scenario": scenario,
"computation_time_s": time.time() - t0,
}
if entry_start:
summary["entry_point_start"] = {
"lat": entry_start["lat"],
"lon": entry_start["lon"],
"highway_class": entry_start["highway_class"],
"name": entry_start.get("name", ""),
}
if entry_end:
summary["entry_point_end"] = {
"lat": entry_end["lat"],
"lon": entry_end["lon"],
"highway_class": entry_end["highway_class"],
"name": entry_end.get("name", ""),
}
result = {"status": "ok", "route": geojson, "summary": summary}
if valhalla_error:
result["warning"] = f"Network segment incomplete: {valhalla_error}"
return result
def _decode_polyline(self, encoded: str, precision: int = 6) -> List[List[float]]:
"""Decode a polyline string into coordinates [lon, lat]."""
coords = []
index = 0
lat = 0
lon = 0
while index < len(encoded):
shift = 0
result = 0
while True:
b = ord(encoded[index]) - 63
index += 1
result |= (b & 0x1f) << shift
shift += 5
if b < 0x20:
break
dlat = ~(result >> 1) if result & 1 else result >> 1
lat += dlat
shift = 0
result = 0
while True:
b = ord(encoded[index]) - 63
index += 1
result |= (b & 0x1f) << shift
shift += 5
if b < 0x20:
break
dlon = ~(result >> 1) if result & 1 else result >> 1
lon += dlon
coords.append([lon / (10 ** precision), lat / (10 ** precision)])
return coords
def close(self):
"""Close all readers."""
if self.dem_reader:
self.dem_reader.close()
if self.friction_reader:
self.friction_reader.close()
if self.barrier_reader:
self.barrier_reader.close()
if self.wilderness_reader:
self.wilderness_reader.close()
if self.trail_reader:
self.trail_reader.close()
self.entry_index.close()
def build_entry_index():
"""Build the trail entry point index."""
index = EntryPointIndex()
stats = index.build_index()
index.close()
return stats
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "build":
print("Building trail entry point index...")
stats = build_entry_index()
print(f"\nDone. Total entry points: {stats['total']}")
elif len(sys.argv) > 1 and sys.argv[1] == "test":
print("Testing router (all scenarios)...")
print("=" * 60)
router = OffrouteRouter()
# Test points
wilderness_start = (44.0543, -115.4237) # Off-network
wilderness_end = (45.2, -115.5) # Deep wilderness (Frank Church)
road_start = (43.6150, -116.2023) # Boise downtown (on-network)
road_end = (43.5867, -116.5625) # Nampa (on-network)
tests = [
("A: wilderness→road", wilderness_start, (44.0814, -115.5021)),
("B: wilderness→wilderness", wilderness_start, wilderness_end),
("C: road→wilderness", road_start, wilderness_start),
("D: road→road", road_start, road_end),
]
for label, (slat, slon), (elat, elon) in tests:
print(f"\n{label}")
print("-" * 40)
result = router.route(
start_lat=slat, start_lon=slon,
end_lat=elat, end_lon=elon,
mode="foot", boundary_mode="pragmatic"
)
if result["status"] == "ok":
s = result["summary"]
print(f" Scenario: {s.get('scenario', '?')}")
print(f" Total: {s['total_distance_km']:.2f} km, {s['total_effort_minutes']:.1f} min")
print(f" Wilderness: {s['wilderness_distance_km']:.2f} km")
print(f" Network: {s['network_distance_km']:.2f} km")
if s.get('entry_point_start'):
ep = s['entry_point_start']
print(f" Entry (start): {ep['highway_class']} at {ep['lat']:.4f}, {ep['lon']:.4f}")
if s.get('entry_point_end'):
ep = s['entry_point_end']
print(f" Entry (end): {ep['highway_class']} at {ep['lat']:.4f}, {ep['lon']:.4f}")
else:
print(f" ERROR: {result['message']}")
router.close()
else:
print("Usage:")
print(" python router.py build # Build entry point index")
print(" python router.py test # Test all scenarios")