mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-08-26 17:31:37 +00:00
Annotate each network-leg segment with its MVUM access status for the selected mode,
using the Layer-0 MVUMSpatialIndex. No routing-decision or Valhalla changes.
- mvum_annotate.py (new): annotate_network_edges(coords,(lat,lon)), mode, spatial_index,
on_date) -> [EdgeAnnotation{coord_pair_index, matched_features, mvum_status}]. Walks
consecutive pairs, queries the index (10m buffer), applies a parallelism filter (acute
angle to the edge <=45deg, both directions), resolves per-mode access via the existing
check_access/get_mode_field/symbol_to_access (worst/most-restrictive across matches).
Mode map: foot->open (skip), 2w->e_bike_class1, 4w->atv, vehicle->highclearancevehicle;
auto is already resolved to a concrete candidate upstream.
- router.py: _route_D_network_only and _build_response annotate the network leg using the
injected self.spatial_index (getattr-guarded; skipped + debug-logged if None), attach
edge_mvum to the network feature, and add summary.mvum_closed_crossings +
summary.mvum_segments_annotated.
- offroute_route.py: inject app.config[MVUM_SPATIAL_INDEX] onto the router per request.
- DirectionsPanel.jsx: warning row when mvum_closed_crossings>0.
- tests/test_mvum_annotate.py: parallel match, perpendicular reject, seasonal closure,
symbol fallback, summary count.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
130 lines
5 KiB
Python
130 lines
5 KiB
Python
"""MVUM Layer 1 — per-edge access annotation.
|
|
|
|
Walks a network leg's coordinate pairs, finds *parallel* MVUM features via the Layer-0
|
|
spatial index, and tags each edge with its access status for the travel mode. Pure
|
|
annotation — no routing-decision changes and no Valhalla changes (that is Layer 2c).
|
|
"""
|
|
import math
|
|
from dataclasses import dataclass
|
|
from typing import List, Optional
|
|
|
|
from shapely.geometry import Point
|
|
|
|
from .mvum import check_access, get_mode_field, symbol_to_access
|
|
|
|
PARALLEL_TOLERANCE_M = 10.0 # query buffer around each edge
|
|
PARALLEL_MAX_ANGLE_DEG = 45.0 # accept features whose acute angle to the edge <= this
|
|
|
|
# Route travel mode -> MVUM access-class key understood by get_mode_field / symbol_to_access.
|
|
# foot is handled separately (MVUM is motor-vehicle specific). "auto" never reaches here:
|
|
# _route_auto resolves it to a concrete candidate before the scenario handler runs.
|
|
_ROUTE_MODE_TO_MVUM = {
|
|
"2w": "mtb", # -> e_bike_class1
|
|
"4w": "atv", # -> atv
|
|
"vehicle": "vehicle", # -> highclearancevehicle
|
|
}
|
|
|
|
# Restrictiveness ranking for picking the WORST status across matched features.
|
|
_RANK = {"open": 0, "unknown": 1, "closed": 2}
|
|
|
|
|
|
@dataclass
|
|
class EdgeAnnotation:
|
|
coord_pair_index: int
|
|
matched_features: List[str]
|
|
mvum_status: str # "open" | "closed" | "unknown"
|
|
|
|
def to_dict(self):
|
|
return {
|
|
"coord_pair_index": self.coord_pair_index,
|
|
"matched_features": self.matched_features,
|
|
"mvum_status": self.mvum_status,
|
|
}
|
|
|
|
|
|
def _bearing(lat1, lon1, lat2, lon2):
|
|
"""Initial bearing in degrees [0,360) from point 1 to point 2."""
|
|
p1, p2 = math.radians(lat1), math.radians(lat2)
|
|
dl = math.radians(lon2 - lon1)
|
|
y = math.sin(dl) * math.cos(p2)
|
|
x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl)
|
|
return (math.degrees(math.atan2(y, x)) + 360.0) % 360.0
|
|
|
|
|
|
def _acute_angle(b1, b2):
|
|
"""Acute angle between two bearings, treating opposite directions as parallel (0..90)."""
|
|
d = abs(b1 - b2) % 360.0
|
|
if d > 180.0:
|
|
d = 360.0 - d
|
|
return min(d, 180.0 - d)
|
|
|
|
|
|
def _feature_bearing_near(geom, lon, lat):
|
|
"""Local bearing (deg) of a (Multi)LineString near point (lon,lat); None if not derivable."""
|
|
try:
|
|
length = geom.length
|
|
if length <= 0:
|
|
return None
|
|
t = geom.project(Point(lon, lat))
|
|
step = min(max(length * 0.05, 1e-9), length)
|
|
a = geom.interpolate(max(0.0, t - step))
|
|
b = geom.interpolate(min(length, t + step))
|
|
if a.equals(b):
|
|
return None
|
|
return _bearing(a.y, a.x, b.y, b.x)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _status_for_feature(record, status_field, dates_field, mvum_mode, check_date):
|
|
acc = check_access(record.get(status_field), record.get(dates_field),
|
|
record.get("seasonal"), check_date)
|
|
if acc is None: # per-class field null -> SYMBOL fallback
|
|
acc = symbol_to_access(record.get("symbol"), mvum_mode,
|
|
record.get("operationalmaintlevel"))
|
|
if acc is True:
|
|
return "open"
|
|
if acc is False:
|
|
return "closed"
|
|
return "unknown"
|
|
|
|
|
|
def annotate_network_edges(coords, mode, spatial_index, on_date=None):
|
|
"""Annotate each consecutive coordinate pair of a network leg with MVUM access.
|
|
|
|
coords: list[(lat, lon)]. Returns list[EdgeAnnotation], one per pair (len(coords)-1).
|
|
foot -> always "open" (MVUM is motor-vehicle specific). spatial_index None or an
|
|
unmappable mode -> "unknown" for every edge (no crash)."""
|
|
if not coords or len(coords) < 2:
|
|
return []
|
|
n = len(coords) - 1
|
|
|
|
if mode == "foot":
|
|
return [EdgeAnnotation(i, [], "open") for i in range(n)]
|
|
if spatial_index is None:
|
|
return [EdgeAnnotation(i, [], "unknown") for i in range(n)]
|
|
mvum_mode = _ROUTE_MODE_TO_MVUM.get(mode)
|
|
if mvum_mode is None:
|
|
return [EdgeAnnotation(i, [], "unknown") for i in range(n)]
|
|
|
|
status_field, dates_field = get_mode_field(mvum_mode)
|
|
check_date = (on_date.month, on_date.day) if on_date else None
|
|
|
|
out = []
|
|
for i in range(n):
|
|
(lat1, lon1), (lat2, lon2) = coords[i], coords[i + 1]
|
|
edge_brg = _bearing(lat1, lon1, lat2, lon2)
|
|
midlon, midlat = (lon1 + lon2) / 2.0, (lat1 + lat2) / 2.0
|
|
matched = []
|
|
worst = None
|
|
for rec in spatial_index.query_buffered_line(
|
|
[(lat1, lon1), (lat2, lon2)], PARALLEL_TOLERANCE_M):
|
|
fb = _feature_bearing_near(rec.get("geometry"), midlon, midlat)
|
|
if fb is None or _acute_angle(edge_brg, fb) > PARALLEL_MAX_ANGLE_DEG:
|
|
continue # crossing-but-not-parallel -> reject
|
|
matched.append(rec.get("feature_id"))
|
|
st = _status_for_feature(rec, status_field, dates_field, mvum_mode, check_date)
|
|
if worst is None or _RANK[st] > _RANK[worst]:
|
|
worst = st
|
|
out.append(EdgeAnnotation(i, matched, worst if worst is not None else "unknown"))
|
|
return out
|