mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-08-26 17:31:37 +00:00
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>
This commit is contained in:
parent
2453c20669
commit
5943d199e2
3 changed files with 164 additions and 0 deletions
78
backend/services/navi_offroute/mvum_exclude.py
Normal file
78
backend/services/navi_offroute/mvum_exclude.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
69
backend/services/navi_offroute/tests/test_mvum_exclude.py
Normal file
69
backend/services/navi_offroute/tests/test_mvum_exclude.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue