MVUM Layer 2c: exclude_polygons for closure-avoiding routing (#24)

Backend-only. For strict-boundary motorized routes, turn MVUM-closed segments into
buffered Valhalla exclude_polygons so routing actively avoids them. pragmatic/emergency
keep Layer-1 annotate-only behavior; foot is never excluded.

- mvum_exclude.py (new): build_exclude_polygons(start,end,mode,spatial_index,on_date,
  boundary_mode) -> GeoJSON Polygon dicts, or None when not applicable (foot / non-strict
  / no index / unmappable mode). Queries the Layer-0 index over a 5km-expanded bbox,
  keeps only features closed to the mode (via mvum_annotate._status_for_feature), buffers
  each ~15m (lat-corrected), emits one Polygon per part (MultiPolygon split). Caps at 500
  with a warning.
- router.py: route() computes self._exclude_polygons once per call (after mode validation;
  has boundary_mode), so each Auto candidate probes against its own exclusions. Both
  Valhalla /route builders (_route_D_network_only and _valhalla_route) inject
  exclude_polygons in array-of-rings form (outer ring per Polygon); omitted when None/empty.
- 6 tests: strict builds polygons, pragmatic/emergency/foot -> None, open not excluded,
  1000 closed -> capped at 500 + warning.

No frontend changes (Layer-1 closure warning still fires for residual closures). Wilderness
pathfinder, Layer-0 index, and Layer-1 annotation untouched.

Co-authored-by: Matt <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-05-25 19:54:28 -06:00 committed by GitHub
commit d8de7ff777
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 164 additions and 0 deletions

View file

@ -0,0 +1,78 @@
"""MVUM Layer 2c — build Valhalla exclude_polygons for closure-avoiding routing.
For strict-boundary motorized routes, turn MVUM-closed segments into buffered
exclusion polygons so Valhalla actively routes around them. Only strict boundary mode
excludes; pragmatic/emergency keep Layer-1 annotate-only behavior. No wilderness, Layer-0
or Layer-1 changes.
"""
import logging
from datetime import datetime
from typing import List, Optional
from shapely.geometry import mapping
from .mvum import get_mode_field, _buffer_degrees_for_meters
from .mvum_annotate import _ROUTE_MODE_TO_MVUM, _status_for_feature
logger = logging.getLogger("navi_offroute.mvum_exclude")
EXCLUDE_BUFFER_M = 15.0 # buffer around a closed segment
BBOX_EXPAND_M = 5000.0 # candidate search box expansion around the route
MAX_EXCLUDE_POLYGONS = 500 # Valhalla request guard
def build_exclude_polygons(start_lat, start_lon, end_lat, end_lon, mode,
spatial_index, on_date, boundary_mode) -> Optional[List[dict]]:
"""Return GeoJSON Polygon dicts for MVUM segments closed to `mode`, or None when
exclusion does not apply (foot, non-strict boundary, or no spatial index).
The router converts these to Valhalla's array-of-rings exclude_polygons format.
"""
if mode == "foot":
return None
if boundary_mode != "strict":
return None
if spatial_index is None:
return None
mvum_mode = _ROUTE_MODE_TO_MVUM.get(mode)
if mvum_mode is None: # unmappable / auto -> nothing to exclude
return None
on_date = on_date or datetime.now()
status_field, dates_field = get_mode_field(mvum_mode)
check_date = (on_date.month, on_date.day)
min_lat, max_lat = min(start_lat, end_lat), max(start_lat, end_lat)
min_lon, max_lon = min(start_lon, end_lon), max(start_lon, end_lon)
mid_lat = (min_lat + max_lat) / 2.0
bbox_buf = _buffer_degrees_for_meters(BBOX_EXPAND_M, mid_lat)
seg_buf = _buffer_degrees_for_meters(EXCLUDE_BUFFER_M, mid_lat)
candidates = spatial_index.query_bbox(
min_lat - bbox_buf, min_lon - bbox_buf, max_lat + bbox_buf, max_lon + bbox_buf)
polys: List[dict] = []
for rec in candidates:
if _status_for_feature(rec, status_field, dates_field, mvum_mode, check_date) != "closed":
continue
geom = rec.get("geometry")
if geom is None or geom.is_empty:
continue
buffered = geom.buffer(seg_buf)
if buffered.is_empty:
continue
if buffered.geom_type == "Polygon":
parts = [buffered]
elif buffered.geom_type == "MultiPolygon":
parts = list(buffered.geoms)
else:
continue
for part in parts:
polys.append(mapping(part))
if len(polys) > MAX_EXCLUDE_POLYGONS:
logger.warning(
"MVUM exclude polygons (%d) exceed cap; emitting first %d",
len(polys), MAX_EXCLUDE_POLYGONS)
return polys[:MAX_EXCLUDE_POLYGONS]
return polys

View file

@ -42,6 +42,7 @@ 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")
@ -534,6 +535,7 @@ class OffrouteRouter:
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
def _init_readers(self):
"""Lazy init readers."""
@ -636,6 +638,13 @@ class OffrouteRouter:
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
@ -887,6 +896,10 @@ class OffrouteRouter:
"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)
@ -1484,6 +1497,10 @@ class OffrouteRouter:
"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)

View file

@ -0,0 +1,69 @@
"""MVUM Layer 2c exclude_polygons tests (isolated; fake spatial index, no DB)."""
import logging
from shapely.geometry import LineString
from services.navi_offroute.mvum_exclude import build_exclude_polygons, MAX_EXCLUDE_POLYGONS
START = (43.600, -116.200)
END = (43.700, -116.300)
LINE = LineString([(-116.250, 43.640), (-116.245, 43.650)]) # (lon, lat) in the route bbox
def _feat(geom=LINE, fid="trail:1", **cols):
rec = {"geometry": geom, "feature_id": fid}
rec.update(cols)
return rec
class _FakeIndex:
def __init__(self, recs):
self.recs = recs
def query_bbox(self, min_lat, min_lon, max_lat, max_lon):
return self.recs
def _call(idx, mode="4w", boundary_mode="strict"):
return build_exclude_polygons(START[0], START[1], END[0], END[1], mode,
idx, None, boundary_mode)
def test_strict_builds_exclude_polygons():
idx = _FakeIndex([_feat(atv=None, symbol="3")]) # closed to motorized (4w -> atv)
out = _call(idx, mode="4w", boundary_mode="strict")
assert out and len(out) >= 1
assert out[0]["type"] == "Polygon"
assert isinstance(out[0]["coordinates"][0], (list, tuple)) and len(out[0]["coordinates"][0]) >= 4
def test_pragmatic_returns_none():
idx = _FakeIndex([_feat(atv=None, symbol="3")])
assert _call(idx, mode="4w", boundary_mode="pragmatic") is None
def test_emergency_returns_none():
idx = _FakeIndex([_feat(atv=None, symbol="3")])
assert _call(idx, mode="4w", boundary_mode="emergency") is None
def test_foot_returns_none():
idx = _FakeIndex([_feat(atv=None, symbol="3")])
assert _call(idx, mode="foot", boundary_mode="strict") is None
assert _call(idx, mode="foot", boundary_mode="pragmatic") is None
def test_open_features_not_excluded():
idx = _FakeIndex([_feat(atv="open", seasonal="yearlong", symbol="1")])
out = _call(idx, mode="4w", boundary_mode="strict")
assert out == [] # nothing closed -> empty (router omits empty)
def test_polygon_cap(caplog):
recs = [_feat(geom=LineString([(-116.25 + i * 1e-4, 43.64), (-116.25 + i * 1e-4, 43.645)]),
fid=f"trail:{i}", atv=None, symbol="3") for i in range(1000)]
idx = _FakeIndex(recs)
with caplog.at_level(logging.WARNING, logger="navi_offroute.mvum_exclude"):
out = _call(idx, mode="4w", boundary_mode="strict")
assert len(out) == MAX_EXCLUDE_POLYGONS == 500
assert any("exceed cap" in r.message for r in caplog.records)