navi/backend/services/navi_offroute/transitions.py
malice 2ac005ab16
navi-offroute: unified cost layers + transition cells (Phase 3) (#39)
Co-authored-by: mj <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:53:26 -06:00

206 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Transition-cell sourcing for unified-graph Auto (spec §4§5).
Gathers mode-switch cells from four sources (parking, trailheads, road termini,
surface-change boundaries), maps each to a DEM grid pixel, de-dupes, and applies the
per-type closest-15-within-5 km cap. Returns the flat directed (row, col, from_idx,
to_idx, cost_s) list astar_multigoal_multimode consumes. Pure sourcing + grid mapping;
no raster math (cost.py), no router wiring (Phase 4). lat/lon → pixel mirrors
DEMReader.latlon_to_pixel (shared/dem.py).
"""
import math
import numpy as np
from .cost import (
TRANSITION_COST_PARKING_S,
TRANSITION_COST_TRAILHEAD_S,
TRANSITION_COST_ROAD_TERMINUS_S,
TRANSITION_COST_SURFACE_CHANGE_S,
)
from .mvum_parking import load_parking_index
from .mvum_transitions import load_trailheads
from .mvum_surface_change import get_surface_change_candidates
# Fixed mode ordering (spec §2.1; matches astar_multigoal_multimode).
MODE_INDEX = {"foot": 0, "2w": 1, "4w": 2, "vehicle": 3}
_CAP_PER_TYPE = 15 # §5: keep the closest 15 cells per transition type
_CAP_RADIUS_M = 5000.0 # §5: within 5 km of the endpoint line
_EARTH_R_M = 6_371_000.0
_BLOCKED_ACCESS = frozenset({"private", "no", "permit"}) # defensive; index already drops these
# ── lat/lon ↔ pixel (mirror DEMReader, shared/dem.py) ───────────────────────────
def _latlon_to_pixel(lat, lon, meta):
# Mirrors DEMReader.latlon_to_pixel; +1e-6 stabilises the center round-trip (float error).
row = int((meta["origin_lat"] - lat) / abs(meta["pixel_size_lat"]) + 1e-6)
col = int((lon - meta["origin_lon"]) / meta["pixel_size_lon"] + 1e-6)
return row, col
def _pixel_to_latlon(row, col, meta):
lat = meta["origin_lat"] + row * meta["pixel_size_lat"]
lon = meta["origin_lon"] + col * meta["pixel_size_lon"]
return lat, lon
def _bidir(lat, lon, pairs, cost_s):
"""Expand each bidirectional m↔m' pair into two directed (lat, lon, from, to, cost) tuples (§4)."""
out = []
for a, b in pairs:
ia, ib = MODE_INDEX[a], MODE_INDEX[b]
out.append((lat, lon, ia, ib, cost_s))
out.append((lat, lon, ib, ia, cost_s))
return out
def _bearing(p1, l1, p2, l2):
return math.atan2(math.sin(l2 - l1) * math.cos(p2),
math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(l2 - l1))
def _cross_track_distance_m(lat, lon, line):
"""Great-circle perpendicular distance (m) from a point to the line through the two
endpoints (spec §5). Falls back to point distance for a degenerate line."""
(lat1, lon1), (lat2, lon2) = line
p1, l1 = math.radians(lat1), math.radians(lon1)
p3, l3 = math.radians(lat), math.radians(lon)
h = math.sin((p3 - p1) / 2) ** 2 + math.cos(p1) * math.cos(p3) * math.sin((l3 - l1) / 2) ** 2
d13 = 2 * math.asin(min(1.0, math.sqrt(h))) # haversine angle, start->point
if lat1 == lat2 and lon1 == lon2:
return d13 * _EARTH_R_M
dth = _bearing(p1, l1, p3, l3) - _bearing(p1, l1, math.radians(lat2), math.radians(lon2))
return abs(math.asin(max(-1.0, min(1.0, math.sin(d13) * math.sin(dth))))) * _EARTH_R_M
def _cap_candidates(raw, line):
"""§5 cap for one transition type: group by (lat, lon) so a point's several directed
tuples count as ONE candidate, keep the closest _CAP_PER_TYPE points within
_CAP_RADIUS_M of `line`, flatten. line=None -> uncapped (test convenience)."""
if not raw:
return []
if line is None:
return list(raw)
groups = {}
for t in raw:
groups.setdefault((t[0], t[1]), []).append(t)
scored = []
for (lat, lon), tuples in groups.items():
d = _cross_track_distance_m(lat, lon, line)
if d <= _CAP_RADIUS_M:
scored.append((d, tuples))
scored.sort(key=lambda x: x[0])
out = []
for _d, tuples in scored[:_CAP_PER_TYPE]:
out.extend(tuples)
return out
def parking_transitions_near_line(line, buffer_m=5000):
"""Parking mode switches near the line (§4): foot↔{vehicle,4w,2w} at
TRANSITION_COST_PARKING_S. Blocked-access lots skipped."""
coords = [tuple(line[0]), tuple(line[1])]
index = load_parking_index()
pairs = (("foot", "vehicle"), ("foot", "4w"), ("foot", "2w"))
out = []
for rec in index.query_parking_near_line(coords, buffer_m):
if rec.get("access") in _BLOCKED_ACCESS:
continue
out.extend(_bidir(rec["lat"], rec["lon"], pairs, TRANSITION_COST_PARKING_S))
return out
def trailhead_transitions_near_line(line, buffer_m=5000):
"""Trailhead mode switches near the line (§4): foot↔4w, foot↔2w at
TRANSITION_COST_TRAILHEAD_S (no full-size vehicle — the tow vehicle stays parked)."""
coords = [tuple(line[0]), tuple(line[1])]
index = load_trailheads()
pairs = (("foot", "4w"), ("foot", "2w"))
out = []
for rec in index.query_trailheads_near_line(coords, buffer_m):
out.extend(_bidir(rec["lat"], rec["lon"], pairs, TRANSITION_COST_TRAILHEAD_S))
return out
def road_terminus_transitions(meta, trail_grid, elevation=None):
"""Road-terminus mode switches from the trail raster (spec §4): a road(5)/track(15) cell
with a passable off-network 8-neighbour (value 0; finite elev when `elevation` given).
foot↔vehicle at TRANSITION_COST_ROAD_TERMINUS_S. Pure raster scan, no DB — fixes §1."""
rows, cols = trail_grid.shape
pairs = (("foot", "vehicle"),)
road = (trail_grid == 5) | (trail_grid == 15)
rs, cs = np.nonzero(road)
out = []
for r, c in zip(rs.tolist(), cs.tolist()):
is_terminus = False
for dr in (-1, 0, 1):
for dc in (-1, 0, 1):
if dr == 0 and dc == 0:
continue
nr, nc = r + dr, c + dc
if nr < 0 or nr >= rows or nc < 0 or nc >= cols:
continue
if trail_grid[nr, nc] != 0:
continue
if elevation is not None and not np.isfinite(elevation[nr, nc]):
continue
is_terminus = True
break
if is_terminus:
break
if is_terminus:
lat, lon = _pixel_to_latlon(r, c, meta)
out.extend(_bidir(lat, lon, pairs, TRANSITION_COST_ROAD_TERMINUS_S))
return out
def surface_change_transitions_near_line(line, valhalla_url, buffer_m=5000):
"""Surface-change mode switches along the line (§4) at TRANSITION_COST_SURFACE_CHANGE_S
(free). The candidate record encodes no mode info, so the default wheeled swaps
vehicle↔4w and 4w↔2w are used. (buffer_m accepted for signature parity.)"""
coords = [tuple(line[0]), tuple(line[1])]
pairs = (("vehicle", "4w"), ("4w", "2w"))
out = []
for rec in get_surface_change_candidates(coords, valhalla_url):
out.extend(_bidir(rec["lat"], rec["lon"], pairs, TRANSITION_COST_SURFACE_CHANGE_S))
return out
def gather_transition_cells(meta, endpoint_line=None, trail_grid=None,
elevation=None, valhalla_url=None, buffer_m=5000):
"""All transition cells for one Auto search (spec §4§5). Sources the four types,
caps each independently (closest 15 within 5 km of `endpoint_line`), maps lat/lon →
grid pixel via `meta`, drops out-of-bounds, de-dupes per (row, col, from, to), and
returns the flat directed list. Sources lacking their input are skipped: the
line-based ones need `endpoint_line`, road-terminus needs `trail_grid`, surface
additionally needs `valhalla_url`. endpoint_line=None -> uncapped (test convenience).
"""
rows, cols = meta["shape"]
per_type = []
if endpoint_line is not None:
per_type.append(_cap_candidates(
parking_transitions_near_line(endpoint_line, buffer_m), endpoint_line))
per_type.append(_cap_candidates(
trailhead_transitions_near_line(endpoint_line, buffer_m), endpoint_line))
if trail_grid is not None:
per_type.append(_cap_candidates(
road_terminus_transitions(meta, trail_grid, elevation), endpoint_line))
if endpoint_line is not None and valhalla_url:
per_type.append(_cap_candidates(
surface_change_transitions_near_line(endpoint_line, valhalla_url, buffer_m),
endpoint_line))
seen = set()
out = []
for raw in per_type:
for (lat, lon, from_m, to_m, cost_s) in raw:
row, col = _latlon_to_pixel(lat, lon, meta)
if not (0 <= row < rows and 0 <= col < cols):
continue
key = (row, col, from_m, to_m)
if key in seen:
continue
seen.add(key)
out.append((row, col, from_m, to_m, cost_s))
return out