feat(offroute): Phase O4 — multi-mode cost functions (foot/mtb/atv/vehicle)

- Add ModeProfile dataclass for data-driven mode configuration
- Implement three speed functions:
  * Tobler off-path hiking (foot)
  * Herzog wheeled-transport polynomial (mtb/atv)
  * Linear speed degradation (vehicle)
- Add WildernessReader for PAD-US Des_Tp=WA wilderness areas
- Mode-specific terrain friction overrides:
  * Forest impassable for ATV/vehicle, high friction for MTB
  * Wetland/mangrove impassable for all wheeled modes
- Trail access rules:
  * Foot trails (value 25) impassable for ATV/vehicle
- Wilderness blocking for mtb/atv/vehicle modes
- Vehicle mode allows flat grassland/cropland traversal
- Memory optimization: limit entry points, constrain bbox size
- Update router to pass mode and wilderness to cost function
- Add vehicle to API mode validation

Validated all four modes with test route:
- foot: 0.46km off-network, 12.11km network, 89% on trail
- mtb: 0.47km off-network, 13.13km network, 90% on trail
- atv: 0.47km off-network, 12.81km network, 90% on trail
- vehicle: 0.46km off-network, 12.81km network, 89% on trail

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-05-08 14:11:56 +00:00
commit bc463188d5
4 changed files with 744 additions and 331 deletions

View file

@ -1,11 +1,12 @@
"""
PAD-US barrier layer for OFFROUTE.
PAD-US barrier and wilderness layers for OFFROUTE.
Provides access to the PAD-US land ownership raster for routing decisions.
Cells with value 255 represent closed/restricted areas (Pub_Access = XA).
Provides access to:
1. Barrier raster (Pub_Access = 'XA' - closed/restricted areas)
2. Wilderness raster (Des_Tp = 'WA' - designated wilderness areas)
Build function rasterizes PAD-US geodatabase to aligned GeoTIFF.
Runtime functions read the raster and resample to match elevation grids.
Build functions rasterize PAD-US geodatabase to aligned GeoTIFFs.
Runtime functions read the rasters and resample to match elevation grids.
"""
import numpy as np
from pathlib import Path
@ -23,6 +24,7 @@ except ImportError:
# Paths
DEFAULT_BARRIERS_PATH = Path("/mnt/nav/worldcover/padus_barriers.tif")
DEFAULT_WILDERNESS_PATH = Path("/mnt/nav/worldcover/wilderness.tif")
PADUS_GDB_PATH = Path("/mnt/nav/padus/PADUS4_0_Geodatabase.gdb")
PADUS_LAYER = "PADUS4_0Combined_Proclamation_Marine_Fee_Designation_Easement"
@ -39,7 +41,7 @@ PIXEL_SIZE = 0.0003 # ~33m
class BarrierReader:
"""Reader for PAD-US barrier raster."""
"""Reader for PAD-US barrier raster (closed/restricted areas)."""
def __init__(self, barrier_path: Path = DEFAULT_BARRIERS_PATH):
self.barrier_path = barrier_path
@ -77,32 +79,86 @@ class BarrierReader:
0 = public/accessible
"""
ds = self._open()
# Create a window from the bounding box
window = from_bounds(west, south, east, north, ds.transform)
# Read with resampling to target shape
barriers = ds.read(
1,
window=window,
out_shape=target_shape,
resampling=Resampling.nearest
)
return barriers
def sample_point(self, lat: float, lon: float) -> int:
"""Sample barrier value at a single point."""
ds = self._open()
# Get pixel coordinates
row, col = ds.index(lon, lat)
# Check bounds
if row < 0 or row >= ds.height or col < 0 or col >= ds.width:
return 0 # Out of bounds = accessible
return 0
window = rasterio.windows.Window(col, row, 1, 1)
value = ds.read(1, window=window)
return int(value[0, 0])
# Read single pixel
def close(self):
"""Close the dataset."""
if self._dataset is not None:
self._dataset.close()
self._dataset = None
class WildernessReader:
"""Reader for PAD-US wilderness raster (designated wilderness areas)."""
def __init__(self, wilderness_path: Path = DEFAULT_WILDERNESS_PATH):
self.wilderness_path = wilderness_path
self._dataset = None
def _open(self):
"""Lazy open the dataset."""
if self._dataset is None:
if not self.wilderness_path.exists():
raise FileNotFoundError(
f"Wilderness raster not found at {self.wilderness_path}. "
f"Run build_wilderness_raster() first."
)
self._dataset = rasterio.open(self.wilderness_path)
return self._dataset
def get_wilderness_grid(
self,
south: float,
north: float,
west: float,
east: float,
target_shape: Tuple[int, int]
) -> np.ndarray:
"""
Get wilderness values for a bounding box, resampled to target shape.
Args:
south, north, west, east: Bounding box coordinates (WGS84)
target_shape: (rows, cols) to resample to (matches elevation grid)
Returns:
np.ndarray of uint8 wilderness values:
255 = designated wilderness area
0 = not wilderness
"""
ds = self._open()
window = from_bounds(west, south, east, north, ds.transform)
wilderness = ds.read(
1,
window=window,
out_shape=target_shape,
resampling=Resampling.nearest
)
return wilderness
def sample_point(self, lat: float, lon: float) -> int:
"""Sample wilderness value at a single point."""
ds = self._open()
row, col = ds.index(lon, lat)
if row < 0 or row >= ds.height or col < 0 or col >= ds.width:
return 0
window = rasterio.windows.Window(col, row, 1, 1)
value = ds.read(1, window=window)
return int(value[0, 0])
@ -124,22 +180,12 @@ def build_barriers_raster(
Build the PAD-US barriers raster from the source geodatabase.
Extracts polygons where Pub_Access = 'XA' (Closed) and rasterizes them.
Args:
output_path: Output GeoTIFF path
gdb_path: Path to PAD-US geodatabase
pixel_size: Pixel size in degrees
bounds: CONUS bounding box
Returns:
Path to the created raster
"""
import shutil
if not gdb_path.exists():
raise FileNotFoundError(f"PAD-US geodatabase not found at {gdb_path}")
# Check for required tools
if not shutil.which('ogr2ogr'):
raise RuntimeError("ogr2ogr not found. Install GDAL.")
if not shutil.which('gdal_rasterize'):
@ -154,7 +200,6 @@ def build_barriers_raster(
print(f" Bounds: {bounds}")
with tempfile.TemporaryDirectory() as tmpdir:
# Step 1: Extract closed areas and reproject to WGS84
closed_gpkg = Path(tmpdir) / "closed_areas.gpkg"
print(f"\n[1/3] Extracting closed areas (Pub_Access = 'XA')...")
@ -176,28 +221,23 @@ def build_barriers_raster(
print(f"STDERR: {result.stderr}")
raise RuntimeError(f"ogr2ogr failed: {result.stderr}")
# Check feature count
info_cmd = ["ogrinfo", "-so", str(closed_gpkg), "closed_areas"]
info_result = subprocess.run(info_cmd, capture_output=True, text=True)
print(f" Extraction result:\n{info_result.stdout}")
# Step 2: Create empty raster
print(f"\n[2/3] Creating raster grid...")
width = int((bounds['east'] - bounds['west']) / pixel_size)
height = int((bounds['north'] - bounds['south']) / pixel_size)
print(f" Grid size: {width} x {height} pixels")
print(f" Memory estimate: {width * height / 1e6:.1f} MB")
# Step 3: Rasterize
print(f"\n[3/3] Rasterizing closed areas...")
rasterize_cmd = [
"gdal_rasterize",
"-burn", "255",
"-init", "0",
"-a_nodata", "0", # No nodata - 0 means accessible
"-a_nodata", "0",
"-te", str(bounds['west']), str(bounds['south']),
str(bounds['east']), str(bounds['north']),
"-tr", str(pixel_size), str(pixel_size),
@ -214,14 +254,10 @@ def build_barriers_raster(
print(f"STDERR: {result.stderr}")
raise RuntimeError(f"gdal_rasterize failed: {result.stderr}")
# Verify output
print(f"\n[Done] Verifying output...")
with rasterio.open(output_path) as ds:
print(f" Size: {ds.width} x {ds.height}")
print(f" CRS: {ds.crs}")
print(f" Bounds: {ds.bounds}")
# Sample a few tiles to check
sample = ds.read(1, window=rasterio.windows.Window(0, 0, 1000, 1000))
closed_count = np.sum(sample == 255)
print(f" Sample (1000x1000): {closed_count} closed cells")
@ -232,17 +268,140 @@ def build_barriers_raster(
return output_path
def build_wilderness_raster(
output_path: Path = DEFAULT_WILDERNESS_PATH,
gdb_path: Path = PADUS_GDB_PATH,
pixel_size: float = PIXEL_SIZE,
bounds: dict = CONUS_BOUNDS,
) -> Path:
"""
Build the PAD-US wilderness raster from the source geodatabase.
Extracts polygons where Des_Tp = 'WA' (Wilderness Area) and rasterizes them.
"""
import shutil
if not gdb_path.exists():
raise FileNotFoundError(f"PAD-US geodatabase not found at {gdb_path}")
if not shutil.which('ogr2ogr'):
raise RuntimeError("ogr2ogr not found. Install GDAL.")
if not shutil.which('gdal_rasterize'):
raise RuntimeError("gdal_rasterize not found. Install GDAL.")
output_path.parent.mkdir(parents=True, exist_ok=True)
print(f"Building PAD-US wilderness raster...")
print(f" Source: {gdb_path}")
print(f" Output: {output_path}")
print(f" Pixel size: {pixel_size} degrees (~{pixel_size * 111000:.0f}m)")
print(f" Bounds: {bounds}")
with tempfile.TemporaryDirectory() as tmpdir:
wilderness_gpkg = Path(tmpdir) / "wilderness_areas.gpkg"
print(f"\n[1/3] Extracting wilderness areas (Des_Tp = 'WA')...")
ogr_cmd = [
"ogr2ogr",
"-f", "GPKG",
str(wilderness_gpkg),
str(gdb_path),
PADUS_LAYER,
"-where", "Des_Tp = 'WA'",
"-t_srs", "EPSG:4326",
"-nlt", "MULTIPOLYGON",
"-nln", "wilderness_areas",
]
result = subprocess.run(ogr_cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"STDERR: {result.stderr}")
raise RuntimeError(f"ogr2ogr failed: {result.stderr}")
info_cmd = ["ogrinfo", "-so", str(wilderness_gpkg), "wilderness_areas"]
info_result = subprocess.run(info_cmd, capture_output=True, text=True)
print(f" Extraction result:\n{info_result.stdout}")
print(f"\n[2/3] Creating raster grid...")
width = int((bounds['east'] - bounds['west']) / pixel_size)
height = int((bounds['north'] - bounds['south']) / pixel_size)
print(f" Grid size: {width} x {height} pixels")
print(f"\n[3/3] Rasterizing wilderness areas...")
rasterize_cmd = [
"gdal_rasterize",
"-burn", "255",
"-init", "0",
"-a_nodata", "0",
"-te", str(bounds['west']), str(bounds['south']),
str(bounds['east']), str(bounds['north']),
"-tr", str(pixel_size), str(pixel_size),
"-ot", "Byte",
"-co", "COMPRESS=LZW",
"-co", "TILED=YES",
"-l", "wilderness_areas",
str(wilderness_gpkg),
str(output_path),
]
result = subprocess.run(rasterize_cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"STDERR: {result.stderr}")
raise RuntimeError(f"gdal_rasterize failed: {result.stderr}")
print(f"\n[Done] Verifying output...")
with rasterio.open(output_path) as ds:
print(f" Size: {ds.width} x {ds.height}")
print(f" CRS: {ds.crs}")
sample = ds.read(1, window=rasterio.windows.Window(0, 0, 1000, 1000))
wilderness_count = np.sum(sample == 255)
print(f" Sample (1000x1000): {wilderness_count} wilderness cells")
file_size = output_path.stat().st_size / (1024**2)
print(f" File size: {file_size:.1f} MB")
return output_path
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "build":
# Build the raster
print("="*60)
print("PAD-US Barriers Raster Build")
print("="*60)
build_barriers_raster()
if len(sys.argv) > 1:
cmd = sys.argv[1]
if cmd == "build":
print("=" * 60)
print("PAD-US Barriers Raster Build")
print("=" * 60)
build_barriers_raster()
elif cmd == "build-wilderness":
print("=" * 60)
print("PAD-US Wilderness Raster Build")
print("=" * 60)
build_wilderness_raster()
elif cmd == "build-all":
print("=" * 60)
print("Building all PAD-US rasters")
print("=" * 60)
build_barriers_raster()
print("\n")
build_wilderness_raster()
else:
print(f"Unknown command: {cmd}")
print("Usage:")
print(" python barriers.py build # Build barriers raster")
print(" python barriers.py build-wilderness # Build wilderness raster")
print(" python barriers.py build-all # Build both rasters")
sys.exit(1)
else:
# Test the reader
# Test readers
print("Testing BarrierReader...")
if not DEFAULT_BARRIERS_PATH.exists():
@ -251,16 +410,31 @@ if __name__ == "__main__":
sys.exit(1)
reader = BarrierReader()
# Test grid read for Idaho area
barriers = reader.get_barrier_grid(
south=42.2, north=42.6, west=-114.8, east=-113.8,
target_shape=(400, 1000)
)
print(f"\nGrid test shape: {barriers.shape}")
print(f"\nBarrier grid shape: {barriers.shape}")
print(f"Unique values: {np.unique(barriers)}")
closed_cells = np.sum(barriers == 255)
print(f"Closed cells: {closed_cells} ({100*closed_cells/barriers.size:.2f}%)")
reader.close()
print("\nBarrierReader test complete.")
print("\nTesting WildernessReader...")
if not DEFAULT_WILDERNESS_PATH.exists():
print(f"Wilderness raster not found at {DEFAULT_WILDERNESS_PATH}")
print(f"Run: python barriers.py build-wilderness")
else:
wilderness_reader = WildernessReader()
wilderness = wilderness_reader.get_wilderness_grid(
south=42.2, north=42.6, west=-114.8, east=-113.8,
target_shape=(400, 1000)
)
print(f"Wilderness grid shape: {wilderness.shape}")
print(f"Unique values: {np.unique(wilderness)}")
wilderness_cells = np.sum(wilderness == 255)
print(f"Wilderness cells: {wilderness_cells} ({100*wilderness_cells/wilderness.size:.2f}%)")
wilderness_reader.close()
print("\nDone.")