From 5943d199e2dec7d239ab05b47cf2f5421d21d4c2 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 26 May 2026 01:39:44 +0000 Subject: [PATCH] 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) --- .../services/navi_offroute/mvum_exclude.py | 78 +++++++++++++++++++ backend/services/navi_offroute/router.py | 17 ++++ .../navi_offroute/tests/test_mvum_exclude.py | 69 ++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 backend/services/navi_offroute/mvum_exclude.py create mode 100644 backend/services/navi_offroute/tests/test_mvum_exclude.py diff --git a/backend/services/navi_offroute/mvum_exclude.py b/backend/services/navi_offroute/mvum_exclude.py new file mode 100644 index 0000000..2db38fd --- /dev/null +++ b/backend/services/navi_offroute/mvum_exclude.py @@ -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 diff --git a/backend/services/navi_offroute/router.py b/backend/services/navi_offroute/router.py index 44e18d0..45e5b52 100755 --- a/backend/services/navi_offroute/router.py +++ b/backend/services/navi_offroute/router.py @@ -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) diff --git a/backend/services/navi_offroute/tests/test_mvum_exclude.py b/backend/services/navi_offroute/tests/test_mvum_exclude.py new file mode 100644 index 0000000..8eff760 --- /dev/null +++ b/backend/services/navi_offroute/tests/test_mvum_exclude.py @@ -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)