navi-offroute: corridor bbox + parallel cost layers (Phase 4.5 perf) (#42)

Co-authored-by: mj <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-05-27 14:56:11 -06:00 committed by GitHub
commit 8d2ee9b7cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 135 additions and 9 deletions

View file

@ -515,6 +515,35 @@ def compute_cost_grid(
# UNIFIED COST LAYERS (unified-graph Auto, spec §3.2)
# ═══════════════════════════════════════════════════════════════════════════════
def _corridor_mask(shape, meta, endpoint_line, pad_km, endpoint_pad_km):
"""Boolean grid, True where a cell lies OUTSIDE the great-circle corridor: its
perpendicular distance to the startend line exceeds `pad_km`, or it falls more than
`endpoint_pad_km` past either endpoint along the line. Vectorised local-equirectangular
planar approximation (the corridor is one local region; sub-km error over ~200 km).
Phase 4.5 perf: a `dem_reader.get_elevation_grid` bbox is axis-aligned, so it cannot be
shrunk below the endpoint bounding box. Instead of shrinking the grid we mask it cells
outside the corridor are made impassable so the A* kernel never floods the off-route
wilderness, which is where the long-route kernel time goes."""
(lat0, lon0), (lat1, lon1) = endpoint_line
rows, cols = shape
ky = 111.0
kx = 111.0 * math.cos(math.radians((lat0 + lat1) / 2.0))
lat = meta["origin_lat"] + np.arange(rows)[:, None] * meta["pixel_size_lat"] # (rows,1)
lon = meta["origin_lon"] + np.arange(cols)[None, :] * meta["pixel_size_lon"] # (1,cols)
y = (lat - lat0) * ky # km north of start (rows,1)
x = (lon - lon0) * kx # km east of start (1,cols)
ex = (lon1 - lon0) * kx
ey = (lat1 - lat0) * ky
L = math.hypot(ex, ey)
if L < 1e-6:
return np.zeros(shape, dtype=bool) # degenerate (start==end): no corridor
ux, uy = ex / L, ey / L
t = x * ux + y * uy # along-line km (rows,cols)
d = np.abs(x * uy - y * ux) # perpendicular km (rows,cols)
return (d > pad_km) | (t < -endpoint_pad_km) | (t > L + endpoint_pad_km)
def compute_unified_cost_layers(
elevation: np.ndarray,
friction: Optional[np.ndarray],
@ -528,6 +557,8 @@ def compute_unified_cost_layers(
valhalla_url=None,
mvum_by_mode: Optional[Dict[str, np.ndarray]] = None,
network_affinity: Optional[Dict[str, float]] = None,
corridor_pad_km: Optional[float] = None,
corridor_endpoint_pad_km: float = 0.0,
) -> dict:
"""Per-mode inflated cost layers + mode-transition cells for one Auto search
(spec §3.2 / §4 / §5 / §8). Returns {"cost_mult": {mode: ndarray}, "transition_cells":
@ -537,25 +568,30 @@ def compute_unified_cost_layers(
Rasters are INJECTED, not fetched: the raster IO lives on the router's reader objects
(router.py::_pathfind_wilderness) and is not duplicated Phase 4 passes the rasters +
DEMReader `meta` straight in; tests pass synthetic arrays. Each mode's multiplier comes
from compute_cost_multiplier_grid(...), then (still pre-inflation) network_affinity (§8)
and MVUM closures (§9) are applied, then inflate_cost_multiplier(...).
from compute_cost_multiplier_grid(...), then (still pre-inflation) MVUM closures (§9) and
(post-inflation) network_affinity (§8) are applied, then inflate_cost_multiplier(...).
The four per-mode layers are built CONCURRENTLY (Phase 4.5 perf): numpy/scipy release the
GIL on the heavy compute, and the layers are independent. Assembly is deterministic
(ordered by `modes`, not completion order).
mvum_by_mode: optional {mode: uint8[rows,cols]} (1=open, 255=closed, 0=unknown). For
each MOTORIZED mode, closed cells become impassable under `strict`, ×PRAGMATIC under
`pragmatic`, ignored under `emergency`; foot skips MVUM entirely. network_affinity:
optional {mode: float} multiplying that mode's on-network cells (trails != 0); default
1.0 is a no-op. boundary_mode also governs barrier rules, which stay PER-EDGE in the
kernel threaded into the returned meta for Phase 4 (mvum_by_mode=network_affinity=None
reproduces the Phase-3 layers exactly, keeping the parity test green).
1.0 is a no-op. corridor_pad_km: when set (with endpoint_line), cells > pad_km from the
great-circle line are made impassable in every layer (Phase 4.5 kernel speedup, §A).
mvum_by_mode=network_affinity=corridor_pad_km=None reproduces the Phase-3 layers exactly,
keeping the parity test green.
"""
from .astar import inflate_cost_multiplier
from .transitions import gather_transition_cells
from concurrent.futures import ThreadPoolExecutor
cell_size_m = float(meta["cell_size_m"])
network_affinity = network_affinity or {}
on_network = (trails != 0) if trails is not None else None
cost_mult = {}
for mode in modes:
def _build_mode_layer(mode):
m = compute_cost_multiplier_grid(
elevation,
cell_size_lat_m=cell_size_m,
@ -584,7 +620,24 @@ def compute_unified_cost_layers(
aff = float(network_affinity.get(mode, 1.0))
if aff != 1.0 and on_network is not None:
inflated[on_network & np.isfinite(inflated)] *= aff
cost_mult[mode] = inflated
return mode, inflated
# Build all modes concurrently; assemble deterministically in `modes` order.
with ThreadPoolExecutor(max_workers=max(1, len(modes))) as ex:
built = dict(ex.map(_build_mode_layer, modes))
cost_mult = {mode: built[mode] for mode in modes}
# Corridor mask (Phase 4.5, §A): cells outside the great-circle corridor are made
# impassable so the A* kernel never floods the off-route wilderness. Applied AFTER
# inflation so the wall is crisp (no blur bleed inward). Off-trail only -- the kernel
# costs on-trail edges from trail_friction, not cost_mult, so a road that bulges past
# the band stays usable. A routable off-trail detour wider than corridor_pad_km is cut;
# widen the pad knob (router.CORRIDOR_PAD_KM) if that ever bites.
if endpoint_line is not None and corridor_pad_km is not None:
mask = _corridor_mask(elevation.shape, meta, endpoint_line,
corridor_pad_km, corridor_endpoint_pad_km)
for mode in modes:
cost_mult[mode][mask] = np.inf
transition_cells = gather_transition_cells(
meta,

View file

@ -102,6 +102,15 @@ MODE_TO_COSTING = {
# transitions.py). The cost_mult_stack / per-mode arrays are packed in this order.
MODE_ORDER = ["foot", "2w", "4w", "vehicle"]
# Phase 4.5 perf: the unified search is masked to a great-circle corridor between the
# endpoints -- cells farther than CORRIDOR_PAD_KM perpendicular (or past the endpoints by
# CORRIDOR_ENDPOINT_PAD_KM along the line) are made impassable, so the kernel doesn't flood
# the off-route wilderness on long trips. The DEM bbox stays axis-aligned (it can't shrink
# below the endpoint box); this masks within it. CORRIDOR_PAD_KM is the widen-if-needed
# knob: a routable off-trail egress farther than this from the straight line gets cut.
CORRIDOR_PAD_KM = 10.0
CORRIDOR_ENDPOINT_PAD_KM = 5.0
# Per-endpoint travel-mode eligibility from an OSM-style "key:value" category hint.
# Looked up exact first, then "key:*" wildcard (see _eligible_modes_from_category).
_MODES_ALL = frozenset({"vehicle", "4w", "2w", "foot"})
@ -848,7 +857,8 @@ class OffrouteRouter:
modes=tuple(MODE_ORDER), boundary_mode=boundary_mode,
endpoint_line=((start_lat, start_lon), (end_lat, end_lon)),
valhalla_url=VALHALLA_URL,
mvum_by_mode=mvum_by_mode, network_affinity=network_affinity)
mvum_by_mode=mvum_by_mode, network_affinity=network_affinity,
corridor_pad_km=CORRIDOR_PAD_KM, corridor_endpoint_pad_km=CORRIDOR_ENDPOINT_PAD_KM)
# 5-6. Pack the per-mode arrays the kernel expects (MODE_ORDER == MODE_INDEX order).
n_modes = len(MODE_ORDER)

View file

@ -1229,3 +1229,66 @@ def test_route_auto_network_affinity_biases_path(monkeypatch):
biased = r2._route_auto(s_lat, s_lon, e_lat, e_lon, "pragmatic", network_affinity=affinity)
assert base["status"] == "ok" and biased["status"] == "ok"
assert _on_network_count(biased, trails, meta) < _on_network_count(base, trails, meta)
# ── PHASE 4.5 — corridor mask + parallel cost layers (perf) ───────────────────
import time as _p45time
from services.navi_offroute.cost import (compute_unified_cost_layers as _cu45,
compute_cost_multiplier_grid as _ccmg45)
from services.navi_offroute.astar import inflate_cost_multiplier as _infl45
def _eq_with_inf(a, b):
return _p4np.array_equal(_p4np.isinf(a), _p4np.isinf(b)) and _p4np.allclose(
a[~_p4np.isinf(a)], b[~_p4np.isinf(b)])
def test_unified_cost_layers_parallel_matches_sequential():
# The concurrent per-mode build must produce byte-identical layers to a serial
# reference (and be deterministic run-to-run): catches threading-introduced bugs.
rows, cols = 40, 50
rng = _p4np.random.default_rng(11)
elevation = (1000.0 + rng.normal(0, 40, (rows, cols))).astype(_p4np.float64)
elevation[5, 6] = _p4np.nan
friction = (1.0 + rng.random((rows, cols))).astype(_p4np.float64)
friction_raw = rng.choice([10, 20, 30, 60], size=(rows, cols)).astype(_p4np.uint8)
trails = _p4np.zeros((rows, cols), _p4np.uint8); trails[20, :] = 5
wild = _p4np.zeros((rows, cols), _p4np.uint8)
meta = _p4_meta(rows, cols)
cm = float(meta["cell_size_m"])
modes = ("foot", "2w", "4w", "vehicle")
seq = {m: _infl45(_ccmg45(elevation, cell_size_lat_m=cm, cell_size_lon_m=cm,
friction=friction, friction_raw=friction_raw,
wilderness=wild, mode=m)) for m in modes}
run1 = _cu45(elevation, friction, friction_raw, trails, wild, meta,
modes=modes, endpoint_line=None)["cost_mult"]
run2 = _cu45(elevation, friction, friction_raw, trails, wild, meta,
modes=modes, endpoint_line=None)["cost_mult"]
for m in modes:
assert _eq_with_inf(run1[m], seq[m]), f"parallel != sequential for {m}"
assert _eq_with_inf(run1[m], run2[m]), f"non-deterministic for {m}"
def test_route_auto_perf_under_5s(monkeypatch):
# ~50 km synthetic grid (no DB/Valhalla): a full _route_auto must finish well under the
# old wilderness-route wall-clock. Loose 5 s bound — a CI guard against kernel/cost-layer
# regressions, exercising the corridor mask + parallel layers.
n = 500 # 500 cells * 100 m = ~50 km/side
elevation = _p4np.full((n, n), 1000.0)
friction_raw = _p4np.full((n, n), 30, dtype=_p4np.uint8) # grass: foot passable
trails = _p4np.zeros((n, n), dtype=_p4np.uint8)
barriers = _p4np.zeros((n, n), dtype=_p4np.uint8)
meta = _p4_meta(n, n)
s_lat, s_lon = _px2ll(250, 20, meta)
e_lat, e_lon = _px2ll(250, 480, meta) # ~46 km along row 250
elig = lambda lat, lon: frozenset({"foot"})
r = _auto_router(monkeypatch, elevation, friction_raw, trails, barriers, meta, elig)
r._route_auto(s_lat, s_lon, e_lat, e_lon, "pragmatic") # warm JIT + caches
t0 = _p45time.perf_counter()
out = r._route_auto(s_lat, s_lon, e_lat, e_lon, "pragmatic")
elapsed = _p45time.perf_counter() - t0
assert out["status"] == "ok", out
assert out["selected_mode_set"] == ["foot"]
assert elapsed <= 5.0, f"_route_auto on ~50 km grid took {elapsed:.2f}s > 5.0s"