mirror of
https://github.com/zvx-echo6/recon.git
synced 2026-05-20 06:34:40 +02:00
- Add barriers.py: PAD-US raster reader + build_barriers_raster() function - Rasterize PAD-US Pub_Access=XA (Closed) polygons to CONUS GeoTIFF - Modify cost.py: boundary_mode parameter (strict/pragmatic/emergency) - strict: private land = impassable (np.inf) - pragmatic: private land = 5x friction penalty (default) - emergency: private land barriers ignored - Modify prototype.py: three-way comparison output - Output: padus_barriers.tif at /mnt/nav/worldcover/ (144MB, ~33m resolution) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
392 lines
13 KiB
Python
Executable file
392 lines
13 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""
|
|
OFFROUTE Phase O2c Prototype
|
|
|
|
Validates the PMTiles decoder, Tobler cost function, WorldCover friction,
|
|
PAD-US barriers integration, and MCP pathfinder on a real Idaho bounding box.
|
|
|
|
Runs THREE pathfinding passes with different boundary modes:
|
|
1. boundary_mode="strict" - private land is impassable
|
|
2. boundary_mode="pragmatic" - private land has 5x friction penalty
|
|
3. boundary_mode="emergency" - private land barriers ignored
|
|
|
|
Outputs comparison showing impact of boundary mode on routing.
|
|
"""
|
|
import json
|
|
import time
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
from skimage.graph import MCP_Geometric
|
|
|
|
# Add parent to path for imports
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
|
|
from lib.offroute.dem import DEMReader
|
|
from lib.offroute.cost import compute_cost_grid
|
|
from lib.offroute.friction import FrictionReader, friction_to_multiplier
|
|
from lib.offroute.barriers import BarrierReader, DEFAULT_BARRIERS_PATH
|
|
|
|
# Test bounding box - Idaho area known to have mixed public/private land
|
|
BBOX = {
|
|
"south": 42.21,
|
|
"north": 42.60,
|
|
"west": -114.76,
|
|
"east": -113.79,
|
|
}
|
|
|
|
# Start point: wilderness area south of Twin Falls
|
|
START_LAT = 42.36
|
|
START_LON = -114.55
|
|
|
|
# End point: near Burley, ID (on road network)
|
|
END_LAT = 42.55
|
|
END_LON = -114.25
|
|
|
|
# Output files
|
|
OUTPUT_PATHS = {
|
|
"strict": Path("/opt/recon/data/offroute-test-strict.geojson"),
|
|
"pragmatic": Path("/opt/recon/data/offroute-test-pragmatic.geojson"),
|
|
"emergency": Path("/opt/recon/data/offroute-test-emergency.geojson"),
|
|
}
|
|
|
|
# Old files to delete
|
|
OLD_FILES = [
|
|
Path("/opt/recon/data/offroute-test-barriers-on.geojson"),
|
|
Path("/opt/recon/data/offroute-test-barriers-off.geojson"),
|
|
]
|
|
|
|
# Memory limit in GB
|
|
MEMORY_LIMIT_GB = 12
|
|
|
|
|
|
def check_memory_usage():
|
|
"""Check current memory usage and abort if over limit."""
|
|
try:
|
|
import psutil
|
|
process = psutil.Process()
|
|
mem_gb = process.memory_info().rss / (1024**3)
|
|
if mem_gb > MEMORY_LIMIT_GB:
|
|
print(f"ERROR: Memory usage {mem_gb:.1f}GB exceeds {MEMORY_LIMIT_GB}GB limit")
|
|
sys.exit(1)
|
|
return mem_gb
|
|
except ImportError:
|
|
return 0
|
|
|
|
|
|
def run_pathfinder(
|
|
elevation: np.ndarray,
|
|
meta: dict,
|
|
friction_mult: np.ndarray,
|
|
barriers: np.ndarray,
|
|
boundary_mode: str,
|
|
start_row: int,
|
|
start_col: int,
|
|
end_row: int,
|
|
end_col: int,
|
|
dem_reader: DEMReader,
|
|
) -> dict:
|
|
"""
|
|
Run the MCP pathfinder with given parameters.
|
|
|
|
Returns dict with path info and stats.
|
|
"""
|
|
# Compute cost grid
|
|
cost = compute_cost_grid(
|
|
elevation,
|
|
cell_size_m=meta["cell_size_m"],
|
|
friction=friction_mult,
|
|
barriers=barriers,
|
|
boundary_mode=boundary_mode,
|
|
)
|
|
|
|
# Count impassable cells
|
|
impassable_count = np.sum(np.isinf(cost))
|
|
barrier_count = np.sum(barriers == 255) if barriers is not None else 0
|
|
|
|
# Run MCP
|
|
mcp = MCP_Geometric(cost, fully_connected=True)
|
|
cumulative_costs, traceback = mcp.find_costs([(start_row, start_col)])
|
|
|
|
end_cost = cumulative_costs[end_row, end_col]
|
|
|
|
if np.isinf(end_cost):
|
|
return {
|
|
"success": False,
|
|
"reason": "No path found (blocked by impassable terrain)",
|
|
"impassable_cells": int(impassable_count),
|
|
"barrier_cells": int(barrier_count),
|
|
}
|
|
|
|
# Traceback path
|
|
path_indices = mcp.traceback((end_row, end_col))
|
|
|
|
# Convert to coordinates
|
|
coordinates = []
|
|
elevations = []
|
|
barrier_values = []
|
|
|
|
for row, col in path_indices:
|
|
lat, lon = dem_reader.pixel_to_latlon(row, col, meta)
|
|
elev = elevation[row, col]
|
|
barr = barriers[row, col] if barriers is not None else 0
|
|
coordinates.append([lon, lat])
|
|
elevations.append(elev)
|
|
barrier_values.append(barr)
|
|
|
|
# Compute distance
|
|
total_distance_m = 0
|
|
for i in range(1, len(coordinates)):
|
|
lon1, lat1 = coordinates[i-1]
|
|
lon2, lat2 = coordinates[i]
|
|
R = 6371000
|
|
dlat = np.radians(lat2 - lat1)
|
|
dlon = np.radians(lon2 - lon1)
|
|
a = np.sin(dlat/2)**2 + np.cos(np.radians(lat1)) * np.cos(np.radians(lat2)) * np.sin(dlon/2)**2
|
|
c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1-a))
|
|
total_distance_m += R * c
|
|
|
|
# Elevation stats
|
|
elev_arr = np.array(elevations)
|
|
elev_diff = np.diff(elev_arr)
|
|
elev_gain = np.sum(elev_diff[elev_diff > 0])
|
|
elev_loss = np.sum(np.abs(elev_diff[elev_diff < 0]))
|
|
|
|
# Barrier crossings on path
|
|
barr_arr = np.array(barrier_values)
|
|
barrier_crossings = np.sum(barr_arr == 255)
|
|
|
|
return {
|
|
"success": True,
|
|
"coordinates": coordinates,
|
|
"total_time_seconds": float(end_cost),
|
|
"total_time_minutes": float(end_cost / 60),
|
|
"total_distance_m": float(total_distance_m),
|
|
"total_distance_km": float(total_distance_m / 1000),
|
|
"elevation_gain_m": float(elev_gain),
|
|
"elevation_loss_m": float(elev_loss),
|
|
"min_elevation_m": float(np.min(elev_arr)),
|
|
"max_elevation_m": float(np.max(elev_arr)),
|
|
"cell_count": len(path_indices),
|
|
"impassable_cells": int(impassable_count),
|
|
"barrier_cells": int(barrier_count),
|
|
"barrier_crossings": int(barrier_crossings),
|
|
}
|
|
|
|
|
|
def main():
|
|
print("=" * 80)
|
|
print("OFFROUTE Phase O2c Prototype (Three-Mode Boundary Respect)")
|
|
print("=" * 80)
|
|
|
|
t0 = time.time()
|
|
|
|
# Delete old output files
|
|
for old_file in OLD_FILES:
|
|
if old_file.exists():
|
|
old_file.unlink()
|
|
print(f"Deleted old file: {old_file}")
|
|
|
|
# Check if barrier raster exists
|
|
if not DEFAULT_BARRIERS_PATH.exists():
|
|
print(f"\nERROR: Barrier raster not found at {DEFAULT_BARRIERS_PATH}")
|
|
print(f"Run first: python /opt/recon/lib/offroute/barriers.py build")
|
|
sys.exit(1)
|
|
|
|
# Step 1: Load elevation data
|
|
print(f"\n[1] Loading DEM for bbox: {BBOX}")
|
|
dem_reader = DEMReader()
|
|
|
|
elevation, meta = dem_reader.get_elevation_grid(
|
|
south=BBOX["south"],
|
|
north=BBOX["north"],
|
|
west=BBOX["west"],
|
|
east=BBOX["east"],
|
|
)
|
|
|
|
print(f" Elevation grid shape: {elevation.shape}")
|
|
print(f" Cell count: {elevation.size:,}")
|
|
print(f" Cell size: {meta['cell_size_m']:.1f} m")
|
|
|
|
mem = check_memory_usage()
|
|
if mem > 0:
|
|
print(f" Memory usage: {mem:.1f} GB")
|
|
|
|
# Step 2: Load friction data
|
|
print(f"\n[2] Loading WorldCover friction layer...")
|
|
friction_reader = FrictionReader()
|
|
|
|
friction_raw = 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)
|
|
|
|
print(f" Friction grid shape: {friction_raw.shape}")
|
|
print(f" Water/impassable cells: {np.sum(np.isinf(friction_mult)):,}")
|
|
|
|
# Step 3: Load barrier data
|
|
print(f"\n[3] Loading PAD-US barrier layer...")
|
|
barrier_reader = BarrierReader()
|
|
|
|
barriers = barrier_reader.get_barrier_grid(
|
|
south=BBOX["south"],
|
|
north=BBOX["north"],
|
|
west=BBOX["west"],
|
|
east=BBOX["east"],
|
|
target_shape=elevation.shape
|
|
)
|
|
|
|
closed_cells = np.sum(barriers == 255)
|
|
print(f" Barrier grid shape: {barriers.shape}")
|
|
print(f" Closed/restricted cells: {closed_cells:,} ({100*closed_cells/barriers.size:.2f}%)")
|
|
|
|
if closed_cells == 0:
|
|
print("\n WARNING: No closed/restricted areas in this bbox.")
|
|
print(" The test may not show meaningful differences between modes.")
|
|
|
|
mem = check_memory_usage()
|
|
if mem > 0:
|
|
print(f" Memory usage: {mem:.1f} GB")
|
|
|
|
# Step 4: Convert start/end to pixel coordinates
|
|
print(f"\n[4] Converting coordinates...")
|
|
start_row, start_col = dem_reader.latlon_to_pixel(START_LAT, START_LON, meta)
|
|
end_row, end_col = dem_reader.latlon_to_pixel(END_LAT, END_LON, meta)
|
|
|
|
print(f" Start: ({START_LAT}, {START_LON}) -> pixel ({start_row}, {start_col})")
|
|
print(f" End: ({END_LAT}, {END_LON}) -> pixel ({end_row}, {end_col})")
|
|
|
|
# Validate coordinates are within bounds
|
|
rows, cols = elevation.shape
|
|
if not (0 <= start_row < rows and 0 <= start_col < cols):
|
|
print(f"ERROR: Start point outside grid bounds")
|
|
sys.exit(1)
|
|
if not (0 <= end_row < rows and 0 <= end_col < cols):
|
|
print(f"ERROR: End point outside grid bounds")
|
|
sys.exit(1)
|
|
|
|
# Step 5: Run pathfinder THREE times
|
|
results = {}
|
|
modes = ["strict", "pragmatic", "emergency"]
|
|
|
|
for i, mode in enumerate(modes, start=5):
|
|
print(f"\n[{i}] Running pathfinder (boundary_mode=\"{mode}\")...")
|
|
t_start = time.time()
|
|
results[mode] = run_pathfinder(
|
|
elevation, meta, friction_mult, barriers,
|
|
boundary_mode=mode,
|
|
start_row=start_row, start_col=start_col,
|
|
end_row=end_row, end_col=end_col,
|
|
dem_reader=dem_reader,
|
|
)
|
|
t_end = time.time()
|
|
print(f" Completed in {t_end - t_start:.1f}s")
|
|
|
|
# Step 6: Save GeoJSON outputs
|
|
print(f"\n[8] Saving GeoJSON outputs...")
|
|
|
|
OUTPUT_PATHS["strict"].parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
for mode, result in results.items():
|
|
output_path = OUTPUT_PATHS[mode]
|
|
if result["success"]:
|
|
geojson = {
|
|
"type": "Feature",
|
|
"properties": {
|
|
"type": f"offroute_{mode}",
|
|
"phase": "O2c",
|
|
"boundary_mode": mode,
|
|
"start": {"lat": START_LAT, "lon": START_LON},
|
|
"end": {"lat": END_LAT, "lon": END_LON},
|
|
**{k: v for k, v in result.items() if k not in ["success", "coordinates"]},
|
|
},
|
|
"geometry": {
|
|
"type": "LineString",
|
|
"coordinates": result["coordinates"],
|
|
}
|
|
}
|
|
with open(output_path, "w") as f:
|
|
json.dump(geojson, f, indent=2)
|
|
print(f" Saved: {output_path}")
|
|
else:
|
|
print(f" SKIPPED ({mode}): {result['reason']}")
|
|
|
|
t_total = time.time()
|
|
|
|
# Final report - three-way comparison
|
|
print(f"\n" + "=" * 80)
|
|
print("THREE-WAY COMPARISON")
|
|
print("=" * 80)
|
|
|
|
# Check how many succeeded
|
|
success_count = sum(1 for r in results.values() if r["success"])
|
|
|
|
if success_count == 3:
|
|
print(f"{'Metric':<22} {'STRICT':<18} {'PRAGMATIC':<18} {'EMERGENCY':<18}")
|
|
print("-" * 80)
|
|
|
|
metrics = [
|
|
("Distance (km)", "total_distance_km", ".2f"),
|
|
("Effort time (min)", "total_time_minutes", ".1f"),
|
|
("Cell count", "cell_count", "d"),
|
|
("Elevation gain (m)", "elevation_gain_m", ".0f"),
|
|
("Elevation loss (m)", "elevation_loss_m", ".0f"),
|
|
("Barrier crossings", "barrier_crossings", "d"),
|
|
("Impassable cells", "impassable_cells", ",d"),
|
|
]
|
|
|
|
for label, key, fmt in metrics:
|
|
vals = [results[m][key] for m in modes]
|
|
print(f"{label:<22} {vals[0]:<18{fmt}} {vals[1]:<18{fmt}} {vals[2]:<18{fmt}}")
|
|
|
|
# Analysis
|
|
print(f"\n" + "-" * 80)
|
|
print("ANALYSIS")
|
|
print("-" * 80)
|
|
|
|
strict_crossings = results["strict"]["barrier_crossings"]
|
|
pragmatic_crossings = results["pragmatic"]["barrier_crossings"]
|
|
emergency_crossings = results["emergency"]["barrier_crossings"]
|
|
|
|
print(f"Barrier crossings: strict={strict_crossings}, pragmatic={pragmatic_crossings}, emergency={emergency_crossings}")
|
|
|
|
if strict_crossings == 0 and pragmatic_crossings == 0 and emergency_crossings == 0:
|
|
print("No path crosses private land - terrain naturally avoids barriers.")
|
|
else:
|
|
if emergency_crossings > pragmatic_crossings:
|
|
print(f"Pragmatic mode reduces barrier crossings vs emergency: {emergency_crossings} -> {pragmatic_crossings}")
|
|
if pragmatic_crossings > 0 and strict_crossings == 0:
|
|
print(f"Strict mode completely avoids private land (pragmatic crosses {pragmatic_crossings} cells)")
|
|
|
|
# Time/distance comparison
|
|
if results["strict"]["total_time_minutes"] > results["emergency"]["total_time_minutes"]:
|
|
time_penalty = results["strict"]["total_time_minutes"] - results["emergency"]["total_time_minutes"]
|
|
print(f"Time cost of strict boundary respect: +{time_penalty:.1f} min")
|
|
|
|
else:
|
|
print(f"Only {success_count}/3 modes found a path:")
|
|
for mode, result in results.items():
|
|
if result["success"]:
|
|
print(f" {mode}: {result['total_distance_km']:.2f} km, {result['total_time_minutes']:.1f} min")
|
|
else:
|
|
print(f" {mode}: FAILED - {result.get('reason', 'unknown')}")
|
|
|
|
print(f"\n" + "-" * 80)
|
|
print(f"Total wall time: {t_total - t0:.1f}s")
|
|
print(f"Closed cells in bbox: {closed_cells:,}")
|
|
|
|
# Cleanup
|
|
dem_reader.close()
|
|
friction_reader.close()
|
|
barrier_reader.close()
|
|
|
|
print("\nPrototype completed.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|