navi/backend/services/navi_offroute/mvum_exclude.py
Matt 5943d199e2 MVUM Layer 2c: exclude_polygons for closure-avoiding routing
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: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 01:39:44 +00:00

78 lines
3 KiB
Python

"""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