mirror of
https://github.com/zvx-echo6/recon.git
synced 2026-05-20 06:34:40 +02:00
feat(offroute): MVUM legal access — pathfinder integration + places panel API + boundary_mode control
MVUM Data Import: - Downloaded USFS MVUM Roads (150,636 features) and Trails (28,741 features) - Imported to navi.db as mvum_roads and mvum_trails tables - Idaho coverage: ~8,994 roads and ~4,504 trails across 7 national forests - Preserved all vehicle-class fields (ATV, MOTORCYCLE, HIGHCLEARANCEVEHICLE, etc.) - Preserved seasonal date ranges (*_DATESOPEN fields) New mvum.py module: - MVUMReader class for querying MVUM data by bbox and nearest point - parse_date_range() for seasonal date string parsing (MM/DD-MM/DD format) - check_access() for determining open/closed status with date checking - symbol_to_access() fallback when per-vehicle fields are null - get_mvum_access_grid() for rasterizing MVUM to pathfinder grid Cost function integration: - Added mvum parameter to compute_cost_grid() - MVUM closures respond to boundary_mode: * strict = impassable (np.inf) * pragmatic = 5x friction penalty * emergency = ignored entirely - Foot mode skips MVUM (motor-vehicle specific) Router integration: - Loads MVUM access grid for motorized modes (mtb, atv, vehicle) - Tracks mvum_closed_crossings in path summary Places Panel API: - GET /api/mvum?lat=XX&lon=XX&radius=50 - Returns MVUM feature with access status for all vehicle classes - Includes seasonal date ranges, maintenance level, forest/district info - GeoJSON geometry for map display Validation: - MVUM places endpoint tested with Sawtooth NF road - All four modes validated with strict/pragmatic/emergency boundary modes - Foot mode correctly ignores MVUM restrictions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bc463188d5
commit
2252905986
4 changed files with 809 additions and 2 deletions
134
lib/api.py
134
lib/api.py
|
|
@ -2799,3 +2799,137 @@ def api_offroute():
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Offroute error")
|
logger.exception("Offroute error")
|
||||||
return jsonify({"status": "error", "message": str(e)}), 500
|
return jsonify({"status": "error", "message": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ── MVUM Places Panel API ──
|
||||||
|
|
||||||
|
@app.route("/api/mvum", methods=["GET"])
|
||||||
|
def api_mvum():
|
||||||
|
"""
|
||||||
|
Query MVUM (Motor Vehicle Use Map) features near a point.
|
||||||
|
|
||||||
|
Used by the Navi frontend places panel when a user taps near a road/trail.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
lat: Latitude
|
||||||
|
lon: Longitude
|
||||||
|
radius: Search radius in meters (default: 50)
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"feature": {
|
||||||
|
"id": "FR 123",
|
||||||
|
"name": "Some Forest Road",
|
||||||
|
"forest": "Sawtooth National Forest",
|
||||||
|
"district": "Ketchum Ranger District",
|
||||||
|
"surface": "NAT",
|
||||||
|
"maintenance_level": 2,
|
||||||
|
"seasonal": "Seasonal",
|
||||||
|
"symbol": 2,
|
||||||
|
"access": {
|
||||||
|
"passenger_vehicle": { "status": "Open", "dates": "06/15-10/15" },
|
||||||
|
"high_clearance": { "status": "Open", "dates": "06/15-10/15" },
|
||||||
|
"atv": { "status": "Open", "dates": "06/15-10/15" },
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"geometry": { GeoJSON LineString }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
If no MVUM feature within radius:
|
||||||
|
{ "status": "ok", "feature": null }
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
lat = request.args.get("lat", type=float)
|
||||||
|
lon = request.args.get("lon", type=float)
|
||||||
|
radius = request.args.get("radius", 50, type=float)
|
||||||
|
|
||||||
|
if lat is None or lon is None:
|
||||||
|
return jsonify({"status": "error", "message": "lat and lon required"}), 400
|
||||||
|
|
||||||
|
from .offroute.mvum import MVUMReader
|
||||||
|
|
||||||
|
reader = MVUMReader()
|
||||||
|
try:
|
||||||
|
# Try roads first, then trails
|
||||||
|
feature = reader.query_nearest(lat, lon, radius, "mvum_roads")
|
||||||
|
if feature is None:
|
||||||
|
feature = reader.query_nearest(lat, lon, radius, "mvum_trails")
|
||||||
|
|
||||||
|
if feature is None:
|
||||||
|
return jsonify({"status": "ok", "feature": None})
|
||||||
|
|
||||||
|
# Format access info
|
||||||
|
access = {
|
||||||
|
"passenger_vehicle": {
|
||||||
|
"status": feature.get("passengervehicle"),
|
||||||
|
"dates": feature.get("passengervehicle_datesopen")
|
||||||
|
},
|
||||||
|
"high_clearance": {
|
||||||
|
"status": feature.get("highclearancevehicle"),
|
||||||
|
"dates": feature.get("highclearancevehicle_datesopen")
|
||||||
|
},
|
||||||
|
"atv": {
|
||||||
|
"status": feature.get("atv"),
|
||||||
|
"dates": feature.get("atv_datesopen")
|
||||||
|
},
|
||||||
|
"motorcycle": {
|
||||||
|
"status": feature.get("motorcycle"),
|
||||||
|
"dates": feature.get("motorcycle_datesopen")
|
||||||
|
},
|
||||||
|
"4wd_gt50": {
|
||||||
|
"status": feature.get("fourwd_gt50inches"),
|
||||||
|
"dates": feature.get("fourwd_gt50_datesopen")
|
||||||
|
},
|
||||||
|
"2wd_gt50": {
|
||||||
|
"status": feature.get("twowd_gt50inches"),
|
||||||
|
"dates": feature.get("twowd_gt50_datesopen")
|
||||||
|
},
|
||||||
|
"e_bike_class1": {
|
||||||
|
"status": feature.get("e_bike_class1"),
|
||||||
|
"dates": feature.get("e_bike_class1_dur")
|
||||||
|
},
|
||||||
|
"e_bike_class2": {
|
||||||
|
"status": feature.get("e_bike_class2"),
|
||||||
|
"dates": feature.get("e_bike_class2_dur")
|
||||||
|
},
|
||||||
|
"e_bike_class3": {
|
||||||
|
"status": feature.get("e_bike_class3"),
|
||||||
|
"dates": feature.get("e_bike_class3_dur")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse maintenance level
|
||||||
|
maint_level = feature.get("operationalmaintlevel", "")
|
||||||
|
maint_num = None
|
||||||
|
if maint_level:
|
||||||
|
# Extract first digit: "2 - HIGH CLEARANCE VEHICLES" -> 2
|
||||||
|
import re
|
||||||
|
match = re.match(r"(\d+)", maint_level)
|
||||||
|
if match:
|
||||||
|
maint_num = int(match.group(1))
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"id": feature.get("id"),
|
||||||
|
"name": feature.get("name"),
|
||||||
|
"forest": feature.get("forestname"),
|
||||||
|
"district": feature.get("districtname"),
|
||||||
|
"surface": feature.get("surfacetype"),
|
||||||
|
"maintenance_level": maint_num,
|
||||||
|
"seasonal": feature.get("seasonal"),
|
||||||
|
"symbol": feature.get("symbol"),
|
||||||
|
"trail_class": feature.get("trailclass"),
|
||||||
|
"trail_system": feature.get("trailsystem"),
|
||||||
|
"access": access,
|
||||||
|
"geometry": feature.get("geojson")
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify({"status": "ok", "feature": result})
|
||||||
|
|
||||||
|
finally:
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("MVUM query error")
|
||||||
|
return jsonify({"status": "error", "message": str(e)}), 500
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,7 @@ def compute_cost_grid(
|
||||||
trails: Optional[np.ndarray] = None,
|
trails: Optional[np.ndarray] = None,
|
||||||
barriers: Optional[np.ndarray] = None,
|
barriers: Optional[np.ndarray] = None,
|
||||||
wilderness: Optional[np.ndarray] = None,
|
wilderness: Optional[np.ndarray] = None,
|
||||||
|
mvum: Optional[np.ndarray] = None,
|
||||||
boundary_mode: Literal["strict", "pragmatic", "emergency"] = "pragmatic",
|
boundary_mode: Literal["strict", "pragmatic", "emergency"] = "pragmatic",
|
||||||
mode: Literal["foot", "mtb", "atv", "vehicle"] = "foot"
|
mode: Literal["foot", "mtb", "atv", "vehicle"] = "foot"
|
||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
|
|
@ -236,6 +237,10 @@ def compute_cost_grid(
|
||||||
255 = closed/restricted area (PAD-US Pub_Access = XA).
|
255 = closed/restricted area (PAD-US Pub_Access = XA).
|
||||||
wilderness: Optional[np.ndarray] of wilderness values (uint8).
|
wilderness: Optional[np.ndarray] of wilderness values (uint8).
|
||||||
255 = designated wilderness area.
|
255 = designated wilderness area.
|
||||||
|
mvum: Optional[np.ndarray] of MVUM access values (uint8).
|
||||||
|
0 = no MVUM data, 1 = open, 255 = closed to this mode.
|
||||||
|
MVUM closures respond to boundary_mode (strict/pragmatic/emergency).
|
||||||
|
Foot mode should pass None (MVUM is motor-vehicle specific).
|
||||||
boundary_mode: How to handle barriers ("strict", "pragmatic", "emergency")
|
boundary_mode: How to handle barriers ("strict", "pragmatic", "emergency")
|
||||||
mode: Travel mode ("foot", "mtb", "atv", "vehicle")
|
mode: Travel mode ("foot", "mtb", "atv", "vehicle")
|
||||||
|
|
||||||
|
|
@ -392,6 +397,26 @@ def compute_cost_grid(
|
||||||
cost[barrier_mask] *= PRAGMATIC_BARRIER_MULTIPLIER
|
cost[barrier_mask] *= PRAGMATIC_BARRIER_MULTIPLIER
|
||||||
del barrier_mask
|
del barrier_mask
|
||||||
|
|
||||||
|
# ─── MVUM closures (motor vehicle restrictions) ──────────────────────────
|
||||||
|
# MVUM only applies to motorized modes, not foot. Foot mode should pass mvum=None.
|
||||||
|
# MVUM closures respond to the same boundary_mode as PAD-US barriers:
|
||||||
|
# "strict" = MVUM-closed road/trail is impassable
|
||||||
|
# "pragmatic" = MVUM-closed road/trail gets 5× friction penalty
|
||||||
|
# "emergency" = MVUM closures ignored entirely
|
||||||
|
if mvum is not None and mode != "foot" and boundary_mode != "emergency":
|
||||||
|
if mvum.shape != elevation.shape:
|
||||||
|
raise ValueError(f"MVUM shape mismatch")
|
||||||
|
|
||||||
|
# Value 255 = road/trail exists but is closed to this mode
|
||||||
|
mvum_closed_mask = mvum == 255
|
||||||
|
|
||||||
|
if boundary_mode == "strict":
|
||||||
|
np.putmask(cost, mvum_closed_mask, np.inf)
|
||||||
|
elif boundary_mode == "pragmatic":
|
||||||
|
cost[mvum_closed_mask] *= PRAGMATIC_BARRIER_MULTIPLIER
|
||||||
|
|
||||||
|
del mvum_closed_mask
|
||||||
|
|
||||||
return cost
|
return cost
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
623
lib/offroute/mvum.py
Normal file
623
lib/offroute/mvum.py
Normal file
|
|
@ -0,0 +1,623 @@
|
||||||
|
"""
|
||||||
|
MVUM (Motor Vehicle Use Map) legal access layer for OFFROUTE.
|
||||||
|
|
||||||
|
Queries USFS MVUM data from navi.db and provides rasterized access grids
|
||||||
|
indicating which roads/trails are open or closed to specific vehicle modes.
|
||||||
|
|
||||||
|
MVUM is motor-vehicle specific — foot mode should skip this layer entirely.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import warnings
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Tuple, Literal
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# Path to navi.db
|
||||||
|
NAVI_DB_PATH = Path("/mnt/nav/navi.db")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date_range(date_str: str) -> List[Tuple[int, int, int, int]]:
|
||||||
|
"""
|
||||||
|
Parse MVUM date range strings like "05/01-11/30" or "06/15-10/15,12/01-03/31".
|
||||||
|
|
||||||
|
Returns list of (start_month, start_day, end_month, end_day) tuples.
|
||||||
|
Returns empty list if unparseable.
|
||||||
|
"""
|
||||||
|
if not date_str or date_str.strip() == "":
|
||||||
|
return []
|
||||||
|
|
||||||
|
ranges = []
|
||||||
|
# Split by comma for multi-period strings
|
||||||
|
for part in date_str.split(","):
|
||||||
|
part = part.strip()
|
||||||
|
# Match MM/DD-MM/DD pattern
|
||||||
|
match = re.match(r"(\d{1,2})/(\d{1,2})-(\d{1,2})/(\d{1,2})", part)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
sm, sd, em, ed = int(match.group(1)), int(match.group(2)), int(match.group(3)), int(match.group(4))
|
||||||
|
if 1 <= sm <= 12 and 1 <= sd <= 31 and 1 <= em <= 12 and 1 <= ed <= 31:
|
||||||
|
ranges.append((sm, sd, em, ed))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return ranges
|
||||||
|
|
||||||
|
|
||||||
|
def is_date_in_range(month: int, day: int, ranges: List[Tuple[int, int, int, int]]) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a given month/day falls within any of the date ranges.
|
||||||
|
Handles ranges that wrap around year end (e.g., 12/01-03/31).
|
||||||
|
"""
|
||||||
|
if not ranges:
|
||||||
|
return True # No ranges = assume open
|
||||||
|
|
||||||
|
date_num = month * 100 + day # Simple numeric comparison
|
||||||
|
|
||||||
|
for sm, sd, em, ed in ranges:
|
||||||
|
start_num = sm * 100 + sd
|
||||||
|
end_num = em * 100 + ed
|
||||||
|
|
||||||
|
if start_num <= end_num:
|
||||||
|
# Normal range (e.g., 05/01-11/30)
|
||||||
|
if start_num <= date_num <= end_num:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# Wrapping range (e.g., 12/01-03/31)
|
||||||
|
if date_num >= start_num or date_num <= end_num:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_access(
|
||||||
|
status_field: Optional[str],
|
||||||
|
dates_field: Optional[str],
|
||||||
|
seasonal: Optional[str],
|
||||||
|
check_date: Optional[Tuple[int, int]] = None
|
||||||
|
) -> Optional[bool]:
|
||||||
|
"""
|
||||||
|
Determine if a road/trail is open to a vehicle type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status_field: Value of vehicle-class field (e.g., "open", null)
|
||||||
|
dates_field: Value of *_DATESOPEN field (e.g., "05/01-11/30")
|
||||||
|
seasonal: Value of SEASONAL field ("yearlong", "seasonal")
|
||||||
|
check_date: Optional (month, day) tuple to check against date ranges
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True = open
|
||||||
|
False = closed
|
||||||
|
None = no data (field not populated, defer to SYMBOL)
|
||||||
|
"""
|
||||||
|
if status_field is None or status_field.strip() == "":
|
||||||
|
return None # No data
|
||||||
|
|
||||||
|
status = status_field.strip().lower()
|
||||||
|
|
||||||
|
if status != "open":
|
||||||
|
return False # Explicitly closed or restricted
|
||||||
|
|
||||||
|
# Status is "open" - check seasonal restrictions
|
||||||
|
if check_date is not None:
|
||||||
|
month, day = check_date
|
||||||
|
|
||||||
|
# Parse date ranges
|
||||||
|
if dates_field:
|
||||||
|
ranges = parse_date_range(dates_field)
|
||||||
|
if ranges:
|
||||||
|
return is_date_in_range(month, day, ranges)
|
||||||
|
|
||||||
|
# No date field but seasonal = "yearlong" means always open
|
||||||
|
if seasonal and seasonal.strip().lower() == "yearlong":
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Seasonal with no dates - assume open (data quality issue)
|
||||||
|
if seasonal and seasonal.strip().lower() == "seasonal":
|
||||||
|
warnings.warn(f"Seasonal road/trail with no DATESOPEN, assuming open")
|
||||||
|
return True
|
||||||
|
|
||||||
|
return True # Open with no date check
|
||||||
|
|
||||||
|
|
||||||
|
def get_mode_field(mode: str) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Get the MVUM field names for a given travel mode.
|
||||||
|
|
||||||
|
Returns (status_field, dates_field) tuple.
|
||||||
|
"""
|
||||||
|
mode_mapping = {
|
||||||
|
"atv": ("atv", "atv_datesopen"),
|
||||||
|
"motorcycle": ("motorcycle", "motorcycle_datesopen"),
|
||||||
|
"mtb": ("e_bike_class1", "e_bike_class1_dur"), # Closest analog for e-bikes
|
||||||
|
"vehicle": ("highclearancevehicle", "highclearancevehicle_datesopen"),
|
||||||
|
"passenger": ("passengervehicle", "passengervehicle_datesopen"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return mode_mapping.get(mode, ("highclearancevehicle", "highclearancevehicle_datesopen"))
|
||||||
|
|
||||||
|
|
||||||
|
def symbol_to_access(symbol: str, mode: str, maint_level: Optional[str] = None) -> Optional[bool]:
|
||||||
|
"""
|
||||||
|
Fallback: interpret SYMBOL field when per-vehicle-class fields are null.
|
||||||
|
|
||||||
|
MVUM SYMBOL meanings (roads):
|
||||||
|
1 = Open to all vehicles
|
||||||
|
2 = Open to highway legal vehicles only
|
||||||
|
3 = Road closed to motorized
|
||||||
|
4 = Road open seasonally
|
||||||
|
11 = Administrative use only
|
||||||
|
12 = Decommissioned
|
||||||
|
|
||||||
|
For trails, similar logic applies based on TRAILCLASS.
|
||||||
|
"""
|
||||||
|
if symbol is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sym = str(symbol).strip()
|
||||||
|
|
||||||
|
# Symbol 1: Open to all
|
||||||
|
if sym == "1":
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Symbol 2: Highway legal only
|
||||||
|
if sym == "2":
|
||||||
|
# ATVs/motorcycles typically not highway legal
|
||||||
|
if mode in ("atv", "motorcycle"):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Symbol 3: Closed to motorized
|
||||||
|
if sym == "3":
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Symbol 4: Seasonally open (assume open if no date check)
|
||||||
|
if sym == "4":
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Symbol 11/12: Administrative/decommissioned = closed
|
||||||
|
if sym in ("11", "12"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Unknown symbol - defer
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class MVUMReader:
|
||||||
|
"""
|
||||||
|
Reader for MVUM data from navi.db.
|
||||||
|
|
||||||
|
Queries roads and trails by bounding box and returns access grids.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Path = NAVI_DB_PATH):
|
||||||
|
self.db_path = db_path
|
||||||
|
self._conn = None
|
||||||
|
|
||||||
|
def _get_conn(self) -> sqlite3.Connection:
|
||||||
|
if self._conn is None:
|
||||||
|
if not self.db_path.exists():
|
||||||
|
raise FileNotFoundError(f"navi.db not found at {self.db_path}")
|
||||||
|
self._conn = sqlite3.connect(str(self.db_path))
|
||||||
|
self._conn.row_factory = sqlite3.Row
|
||||||
|
# Load Spatialite extension if available
|
||||||
|
try:
|
||||||
|
self._conn.enable_load_extension(True)
|
||||||
|
self._conn.load_extension("mod_spatialite")
|
||||||
|
except Exception:
|
||||||
|
pass # Spatialite not available, will use manual bbox queries
|
||||||
|
return self._conn
|
||||||
|
|
||||||
|
def table_exists(self, table_name: str) -> bool:
|
||||||
|
"""Check if an MVUM table exists."""
|
||||||
|
conn = self._get_conn()
|
||||||
|
cur = conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
||||||
|
(table_name,)
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
def query_roads_bbox(
|
||||||
|
self,
|
||||||
|
south: float, north: float, west: float, east: float,
|
||||||
|
mode: str = "atv",
|
||||||
|
check_date: Optional[Tuple[int, int]] = None
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Query MVUM roads within a bounding box.
|
||||||
|
|
||||||
|
Returns list of dicts with access info for the given mode.
|
||||||
|
"""
|
||||||
|
if not self.table_exists("mvum_roads"):
|
||||||
|
return []
|
||||||
|
|
||||||
|
conn = self._get_conn()
|
||||||
|
|
||||||
|
# Query using bbox on geometry
|
||||||
|
# Since we don't have spatialite, we'll query all and filter in Python
|
||||||
|
# For production, consider pre-computing bbox columns
|
||||||
|
cur = conn.execute("""
|
||||||
|
SELECT ogc_fid, id, name, symbol, operationalmaintlevel, seasonal,
|
||||||
|
atv, atv_datesopen, motorcycle, motorcycle_datesopen,
|
||||||
|
highclearancevehicle, highclearancevehicle_datesopen,
|
||||||
|
passengervehicle, passengervehicle_datesopen,
|
||||||
|
e_bike_class1, e_bike_class1_dur,
|
||||||
|
shape
|
||||||
|
FROM mvum_roads
|
||||||
|
""")
|
||||||
|
|
||||||
|
status_field, dates_field = get_mode_field(mode)
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for row in cur:
|
||||||
|
# Parse geometry to check bbox intersection
|
||||||
|
# The shape is stored as WKB blob
|
||||||
|
shape = row["shape"]
|
||||||
|
if shape is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Quick bbox check using geometry extent
|
||||||
|
# Since we don't have Spatialite functions, we'll include all
|
||||||
|
# and let the rasterization handle it
|
||||||
|
|
||||||
|
access = check_access(
|
||||||
|
row[status_field] if status_field in row.keys() else None,
|
||||||
|
row[dates_field] if dates_field in row.keys() else None,
|
||||||
|
row["seasonal"],
|
||||||
|
check_date
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback to SYMBOL if no per-vehicle data
|
||||||
|
if access is None:
|
||||||
|
access = symbol_to_access(row["symbol"], mode, row["operationalmaintlevel"])
|
||||||
|
|
||||||
|
if access is not None:
|
||||||
|
results.append({
|
||||||
|
"id": row["id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"access": access,
|
||||||
|
"symbol": row["symbol"],
|
||||||
|
"maint_level": row["operationalmaintlevel"],
|
||||||
|
"shape": shape,
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def query_trails_bbox(
|
||||||
|
self,
|
||||||
|
south: float, north: float, west: float, east: float,
|
||||||
|
mode: str = "atv",
|
||||||
|
check_date: Optional[Tuple[int, int]] = None
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Query MVUM trails within a bounding box.
|
||||||
|
"""
|
||||||
|
if not self.table_exists("mvum_trails"):
|
||||||
|
return []
|
||||||
|
|
||||||
|
conn = self._get_conn()
|
||||||
|
|
||||||
|
cur = conn.execute("""
|
||||||
|
SELECT ogc_fid, id, name, symbol, seasonal, trailclass,
|
||||||
|
atv, atv_datesopen, motorcycle, motorcycle_datesopen,
|
||||||
|
highclearancevehicle, highclearancevehicle_datesopen,
|
||||||
|
passengervehicle, passengervehicle_datesopen,
|
||||||
|
e_bike_class1, e_bike_class1_dur,
|
||||||
|
shape
|
||||||
|
FROM mvum_trails
|
||||||
|
""")
|
||||||
|
|
||||||
|
status_field, dates_field = get_mode_field(mode)
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for row in cur:
|
||||||
|
shape = row["shape"]
|
||||||
|
if shape is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
access = check_access(
|
||||||
|
row[status_field] if status_field in row.keys() else None,
|
||||||
|
row[dates_field] if dates_field in row.keys() else None,
|
||||||
|
row["seasonal"],
|
||||||
|
check_date
|
||||||
|
)
|
||||||
|
|
||||||
|
if access is None:
|
||||||
|
access = symbol_to_access(row["symbol"], mode)
|
||||||
|
|
||||||
|
if access is not None:
|
||||||
|
results.append({
|
||||||
|
"id": row["id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"access": access,
|
||||||
|
"symbol": row["symbol"],
|
||||||
|
"trail_class": row["trailclass"],
|
||||||
|
"shape": shape,
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def query_nearest(
|
||||||
|
self,
|
||||||
|
lat: float, lon: float,
|
||||||
|
radius_m: float = 50,
|
||||||
|
table: str = "mvum_roads"
|
||||||
|
) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Query the nearest MVUM feature to a point.
|
||||||
|
|
||||||
|
Used for the places panel API.
|
||||||
|
"""
|
||||||
|
if not self.table_exists(table):
|
||||||
|
return None
|
||||||
|
|
||||||
|
conn = self._get_conn()
|
||||||
|
|
||||||
|
# Convert radius to degrees (approximate)
|
||||||
|
radius_deg = radius_m / 111000
|
||||||
|
|
||||||
|
# Query features in bbox around point
|
||||||
|
if table == "mvum_roads":
|
||||||
|
cur = conn.execute("""
|
||||||
|
SELECT ogc_fid, id, name, forestname, districtname, symbol,
|
||||||
|
operationalmaintlevel, surfacetype, seasonal, jurisdiction,
|
||||||
|
passengervehicle, passengervehicle_datesopen,
|
||||||
|
highclearancevehicle, highclearancevehicle_datesopen,
|
||||||
|
atv, atv_datesopen, motorcycle, motorcycle_datesopen,
|
||||||
|
fourwd_gt50inches, fourwd_gt50_datesopen,
|
||||||
|
twowd_gt50inches, twowd_gt50_datesopen,
|
||||||
|
e_bike_class1, e_bike_class1_dur,
|
||||||
|
e_bike_class2, e_bike_class2_dur,
|
||||||
|
e_bike_class3, e_bike_class3_dur,
|
||||||
|
shape
|
||||||
|
FROM mvum_roads
|
||||||
|
LIMIT 1000
|
||||||
|
""")
|
||||||
|
else:
|
||||||
|
cur = conn.execute("""
|
||||||
|
SELECT ogc_fid, id, name, forestname, districtname, symbol,
|
||||||
|
seasonal, jurisdiction, trailclass, trailsystem,
|
||||||
|
passengervehicle, passengervehicle_datesopen,
|
||||||
|
highclearancevehicle, highclearancevehicle_datesopen,
|
||||||
|
atv, atv_datesopen, motorcycle, motorcycle_datesopen,
|
||||||
|
fourwd_gt50inches, fourwd_gt50_datesopen,
|
||||||
|
twowd_gt50inches, twowd_gt50_datesopen,
|
||||||
|
e_bike_class1, e_bike_class1_dur,
|
||||||
|
e_bike_class2, e_bike_class2_dur,
|
||||||
|
e_bike_class3, e_bike_class3_dur,
|
||||||
|
shape
|
||||||
|
FROM mvum_trails
|
||||||
|
LIMIT 1000
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Find nearest feature
|
||||||
|
# This is a simplified approach - for production, use spatial index
|
||||||
|
try:
|
||||||
|
from shapely import wkb
|
||||||
|
from shapely.geometry import Point
|
||||||
|
|
||||||
|
query_point = Point(lon, lat)
|
||||||
|
nearest = None
|
||||||
|
min_dist = float('inf')
|
||||||
|
|
||||||
|
for row in cur:
|
||||||
|
try:
|
||||||
|
geom = wkb.loads(row["shape"])
|
||||||
|
dist = query_point.distance(geom)
|
||||||
|
if dist < min_dist and dist < radius_deg:
|
||||||
|
min_dist = dist
|
||||||
|
nearest = dict(row)
|
||||||
|
nearest["geometry"] = geom
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if nearest:
|
||||||
|
# Convert geometry to GeoJSON
|
||||||
|
nearest["geojson"] = nearest["geometry"].__geo_interface__
|
||||||
|
del nearest["geometry"]
|
||||||
|
del nearest["shape"]
|
||||||
|
return nearest
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
warnings.warn("shapely not available for nearest query")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self._conn:
|
||||||
|
self._conn.close()
|
||||||
|
self._conn = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_mvum_access_grid(
|
||||||
|
south: float, north: float, west: float, east: float,
|
||||||
|
target_shape: Tuple[int, int],
|
||||||
|
mode: Literal["foot", "mtb", "atv", "vehicle"] = "atv",
|
||||||
|
check_date: Optional[str] = None,
|
||||||
|
db_path: Path = NAVI_DB_PATH
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Get MVUM access grid for pathfinding.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
south, north, west, east: Bounding box (WGS84)
|
||||||
|
target_shape: (rows, cols) to match elevation grid
|
||||||
|
mode: Travel mode (foot skips MVUM entirely)
|
||||||
|
check_date: Optional "MM/DD" string for seasonal checking
|
||||||
|
db_path: Path to navi.db
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
np.ndarray of uint8:
|
||||||
|
0 = no MVUM data (defer to existing trail/friction logic)
|
||||||
|
1 = road/trail is OPEN to this vehicle mode
|
||||||
|
255 = road/trail EXISTS but is CLOSED to this mode
|
||||||
|
"""
|
||||||
|
# Foot mode bypasses MVUM entirely
|
||||||
|
if mode == "foot":
|
||||||
|
return np.zeros(target_shape, dtype=np.uint8)
|
||||||
|
|
||||||
|
# Parse check_date if provided
|
||||||
|
parsed_date = None
|
||||||
|
if check_date:
|
||||||
|
match = re.match(r"(\d{1,2})/(\d{1,2})", check_date)
|
||||||
|
if match:
|
||||||
|
parsed_date = (int(match.group(1)), int(match.group(2)))
|
||||||
|
|
||||||
|
# Initialize output grid
|
||||||
|
grid = np.zeros(target_shape, dtype=np.uint8)
|
||||||
|
rows, cols = target_shape
|
||||||
|
|
||||||
|
# Pixel size
|
||||||
|
pixel_lat = (north - south) / rows
|
||||||
|
pixel_lon = (east - west) / cols
|
||||||
|
|
||||||
|
reader = MVUMReader(db_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Query roads and trails
|
||||||
|
roads = reader.query_roads_bbox(south, north, west, east, mode, parsed_date)
|
||||||
|
trails = reader.query_trails_bbox(south, north, west, east, mode, parsed_date)
|
||||||
|
|
||||||
|
# Rasterize features
|
||||||
|
try:
|
||||||
|
from shapely import wkb
|
||||||
|
|
||||||
|
for features in [roads, trails]:
|
||||||
|
for feat in features:
|
||||||
|
try:
|
||||||
|
geom = wkb.loads(feat["shape"])
|
||||||
|
|
||||||
|
# Get geometry bounds
|
||||||
|
minx, miny, maxx, maxy = geom.bounds
|
||||||
|
|
||||||
|
# Check if intersects our bbox
|
||||||
|
if maxx < west or minx > east or maxy < south or miny > north:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Rasterize line
|
||||||
|
value = 1 if feat["access"] else 255
|
||||||
|
|
||||||
|
# Simple line rasterization
|
||||||
|
if geom.geom_type in ("LineString", "MultiLineString"):
|
||||||
|
if geom.geom_type == "MultiLineString":
|
||||||
|
coords_list = [list(line.coords) for line in geom.geoms]
|
||||||
|
else:
|
||||||
|
coords_list = [list(geom.coords)]
|
||||||
|
|
||||||
|
for coords in coords_list:
|
||||||
|
for i in range(len(coords) - 1):
|
||||||
|
x1, y1 = coords[i]
|
||||||
|
x2, y2 = coords[i + 1]
|
||||||
|
|
||||||
|
# Convert to pixel coordinates
|
||||||
|
col1 = int((x1 - west) / pixel_lon)
|
||||||
|
row1 = int((north - y1) / pixel_lat)
|
||||||
|
col2 = int((x2 - west) / pixel_lon)
|
||||||
|
row2 = int((north - y2) / pixel_lat)
|
||||||
|
|
||||||
|
# Bresenham's line algorithm
|
||||||
|
_draw_line(grid, row1, col1, row2, col2, value)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
continue
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
warnings.warn("shapely not available, MVUM rasterization skipped")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
return grid
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_line(grid: np.ndarray, r1: int, c1: int, r2: int, c2: int, value: int):
|
||||||
|
"""Draw a line on the grid using Bresenham's algorithm."""
|
||||||
|
rows, cols = grid.shape
|
||||||
|
|
||||||
|
dr = abs(r2 - r1)
|
||||||
|
dc = abs(c2 - c1)
|
||||||
|
sr = 1 if r1 < r2 else -1
|
||||||
|
sc = 1 if c1 < c2 else -1
|
||||||
|
err = dr - dc
|
||||||
|
|
||||||
|
r, c = r1, c1
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if 0 <= r < rows and 0 <= c < cols:
|
||||||
|
# Only overwrite if current value is 0 (no data) or we're marking closed
|
||||||
|
if grid[r, c] == 0 or value == 255:
|
||||||
|
grid[r, c] = value
|
||||||
|
|
||||||
|
if r == r2 and c == c2:
|
||||||
|
break
|
||||||
|
|
||||||
|
e2 = 2 * err
|
||||||
|
if e2 > -dc:
|
||||||
|
err -= dc
|
||||||
|
r += sr
|
||||||
|
if e2 < dr:
|
||||||
|
err += dr
|
||||||
|
c += sc
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("MVUM Reader Test")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
reader = MVUMReader()
|
||||||
|
|
||||||
|
if not reader.table_exists("mvum_roads"):
|
||||||
|
print("ERROR: mvum_roads table not found in navi.db")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Test bbox query (Sawtooth NF area)
|
||||||
|
print("\n[1] Testing bbox query (Sawtooth NF area)...")
|
||||||
|
roads = reader.query_roads_bbox(
|
||||||
|
south=43.5, north=44.0, west=-115.0, east=-114.0,
|
||||||
|
mode="atv"
|
||||||
|
)
|
||||||
|
print(f" Found {len(roads)} roads")
|
||||||
|
|
||||||
|
open_count = sum(1 for r in roads if r["access"])
|
||||||
|
closed_count = sum(1 for r in roads if not r["access"])
|
||||||
|
print(f" Open to ATV: {open_count}")
|
||||||
|
print(f" Closed to ATV: {closed_count}")
|
||||||
|
|
||||||
|
# Test with seasonal date
|
||||||
|
print("\n[2] Testing with date check (July 15)...")
|
||||||
|
roads_summer = reader.query_roads_bbox(
|
||||||
|
south=43.5, north=44.0, west=-115.0, east=-114.0,
|
||||||
|
mode="atv",
|
||||||
|
check_date=(7, 15)
|
||||||
|
)
|
||||||
|
open_summer = sum(1 for r in roads_summer if r["access"])
|
||||||
|
print(f" Open to ATV on 07/15: {open_summer}")
|
||||||
|
|
||||||
|
print("\n[3] Testing with date check (January 15)...")
|
||||||
|
roads_winter = reader.query_roads_bbox(
|
||||||
|
south=43.5, north=44.0, west=-115.0, east=-114.0,
|
||||||
|
mode="atv",
|
||||||
|
check_date=(1, 15)
|
||||||
|
)
|
||||||
|
open_winter = sum(1 for r in roads_winter if r["access"])
|
||||||
|
print(f" Open to ATV on 01/15: {open_winter}")
|
||||||
|
|
||||||
|
# Test grid generation
|
||||||
|
print("\n[4] Testing grid generation...")
|
||||||
|
grid = get_mvum_access_grid(
|
||||||
|
south=43.5, north=44.0, west=-115.0, east=-114.0,
|
||||||
|
target_shape=(500, 1000),
|
||||||
|
mode="atv"
|
||||||
|
)
|
||||||
|
print(f" Grid shape: {grid.shape}")
|
||||||
|
print(f" No data (0): {np.sum(grid == 0)}")
|
||||||
|
print(f" Open (1): {np.sum(grid == 1)}")
|
||||||
|
print(f" Closed (255): {np.sum(grid == 255)}")
|
||||||
|
|
||||||
|
reader.close()
|
||||||
|
print("\nDone.")
|
||||||
|
|
@ -27,6 +27,7 @@ from .cost import compute_cost_grid
|
||||||
from .friction import FrictionReader, friction_to_multiplier
|
from .friction import FrictionReader, friction_to_multiplier
|
||||||
from .barriers import BarrierReader, WildernessReader, DEFAULT_WILDERNESS_PATH
|
from .barriers import BarrierReader, WildernessReader, DEFAULT_WILDERNESS_PATH
|
||||||
from .trails import TrailReader
|
from .trails import TrailReader
|
||||||
|
from .mvum import get_mvum_access_grid
|
||||||
|
|
||||||
# Paths
|
# Paths
|
||||||
NAVI_DB_PATH = Path("/mnt/nav/navi.db")
|
NAVI_DB_PATH = Path("/mnt/nav/navi.db")
|
||||||
|
|
@ -407,6 +408,22 @@ class OffrouteRouter:
|
||||||
target_shape=elevation.shape
|
target_shape=elevation.shape
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Load MVUM access data (only for motorized modes)
|
||||||
|
# MVUM is motor-vehicle specific — foot mode skips entirely
|
||||||
|
mvum = None
|
||||||
|
if mode in ("mtb", "atv", "vehicle"):
|
||||||
|
try:
|
||||||
|
mvum = get_mvum_access_grid(
|
||||||
|
south=bbox["south"], north=bbox["north"],
|
||||||
|
west=bbox["west"], east=bbox["east"],
|
||||||
|
target_shape=elevation.shape,
|
||||||
|
mode=mode,
|
||||||
|
check_date=None, # TODO: accept date parameter
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# MVUM data may not be available - continue without it
|
||||||
|
pass
|
||||||
|
|
||||||
# Compute cost grid with mode-specific parameters
|
# Compute cost grid with mode-specific parameters
|
||||||
cost = compute_cost_grid(
|
cost = compute_cost_grid(
|
||||||
elevation,
|
elevation,
|
||||||
|
|
@ -416,12 +433,13 @@ class OffrouteRouter:
|
||||||
trails=trails,
|
trails=trails,
|
||||||
barriers=barriers,
|
barriers=barriers,
|
||||||
wilderness=wilderness,
|
wilderness=wilderness,
|
||||||
|
mvum=mvum,
|
||||||
boundary_mode=boundary_mode,
|
boundary_mode=boundary_mode,
|
||||||
mode=mode,
|
mode=mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Free intermediate arrays to reduce memory before MCP
|
# Free intermediate arrays to reduce memory before MCP
|
||||||
# Note: Keep trails and barriers - needed for path statistics
|
# Note: Keep trails, barriers, and mvum - needed for path statistics
|
||||||
del friction_mult, friction_raw, wilderness
|
del friction_mult, friction_raw, wilderness
|
||||||
import gc
|
import gc
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
@ -471,6 +489,7 @@ class OffrouteRouter:
|
||||||
elevations = []
|
elevations = []
|
||||||
trail_values = []
|
trail_values = []
|
||||||
barrier_crossings = 0
|
barrier_crossings = 0
|
||||||
|
mvum_closed_crossings = 0
|
||||||
|
|
||||||
for row, col in path_indices:
|
for row, col in path_indices:
|
||||||
lat, lon = self.dem_reader.pixel_to_latlon(row, col, meta)
|
lat, lon = self.dem_reader.pixel_to_latlon(row, col, meta)
|
||||||
|
|
@ -479,6 +498,8 @@ class OffrouteRouter:
|
||||||
trail_values.append(trails[row, col])
|
trail_values.append(trails[row, col])
|
||||||
if barriers[row, col] == 255:
|
if barriers[row, col] == 255:
|
||||||
barrier_crossings += 1
|
barrier_crossings += 1
|
||||||
|
if mvum is not None and mvum[row, col] == 255:
|
||||||
|
mvum_closed_crossings += 1
|
||||||
|
|
||||||
# Calculate stats
|
# Calculate stats
|
||||||
wilderness_distance_m = 0
|
wilderness_distance_m = 0
|
||||||
|
|
@ -497,8 +518,10 @@ class OffrouteRouter:
|
||||||
total_cells = len(trail_arr)
|
total_cells = len(trail_arr)
|
||||||
on_trail_pct = float(100 * on_trail_cells / total_cells) if total_cells > 0 else 0
|
on_trail_pct = float(100 * on_trail_cells / total_cells) if total_cells > 0 else 0
|
||||||
|
|
||||||
# Free trails and barriers now that path stats are computed
|
# Free trails, barriers, and mvum now that path stats are computed
|
||||||
del trails, barriers
|
del trails, barriers
|
||||||
|
if mvum is not None:
|
||||||
|
del mvum
|
||||||
|
|
||||||
# Entry point
|
# Entry point
|
||||||
entry_lat = best_entry["entry_point"]["lat"]
|
entry_lat = best_entry["entry_point"]["lat"]
|
||||||
|
|
@ -572,6 +595,7 @@ class OffrouteRouter:
|
||||||
"on_trail_pct": on_trail_pct,
|
"on_trail_pct": on_trail_pct,
|
||||||
"cell_count": total_cells,
|
"cell_count": total_cells,
|
||||||
"barrier_crossings": barrier_crossings,
|
"barrier_crossings": barrier_crossings,
|
||||||
|
"mvum_closed_crossings": mvum_closed_crossings,
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
},
|
},
|
||||||
"geometry": {"type": "LineString", "coordinates": wilderness_coords}
|
"geometry": {"type": "LineString", "coordinates": wilderness_coords}
|
||||||
|
|
@ -620,6 +644,7 @@ class OffrouteRouter:
|
||||||
"network_duration_minutes": float(network_segment["duration_minutes"]) if network_segment else 0,
|
"network_duration_minutes": float(network_segment["duration_minutes"]) if network_segment else 0,
|
||||||
"on_trail_pct": on_trail_pct,
|
"on_trail_pct": on_trail_pct,
|
||||||
"barrier_crossings": barrier_crossings,
|
"barrier_crossings": barrier_crossings,
|
||||||
|
"mvum_closed_crossings": mvum_closed_crossings,
|
||||||
"boundary_mode": boundary_mode,
|
"boundary_mode": boundary_mode,
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
"entry_point": {
|
"entry_point": {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue