From 8d2ee9b7cda5b0f372cc7bd83d8f29c679c517f8 Mon Sep 17 00:00:00 2001 From: malice Date: Wed, 27 May 2026 14:56:11 -0600 Subject: [PATCH] navi-offroute: corridor bbox + parallel cost layers (Phase 4.5 perf) (#42) Co-authored-by: mj Co-authored-by: Claude Opus 4.7 (1M context) --- backend/services/navi_offroute/cost.py | 69 ++++++++++++++++--- backend/services/navi_offroute/router.py | 12 +++- .../navi_offroute/tests/test_offroute.py | 63 +++++++++++++++++ 3 files changed, 135 insertions(+), 9 deletions(-) diff --git a/backend/services/navi_offroute/cost.py b/backend/services/navi_offroute/cost.py index 93ae364..e78a46e 100755 --- a/backend/services/navi_offroute/cost.py +++ b/backend/services/navi_offroute/cost.py @@ -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 start↔end 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, diff --git a/backend/services/navi_offroute/router.py b/backend/services/navi_offroute/router.py index 31f8fab..2196992 100755 --- a/backend/services/navi_offroute/router.py +++ b/backend/services/navi_offroute/router.py @@ -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) diff --git a/backend/services/navi_offroute/tests/test_offroute.py b/backend/services/navi_offroute/tests/test_offroute.py index 2e9cbfc..feb3bb9 100644 --- a/backend/services/navi_offroute/tests/test_offroute.py +++ b/backend/services/navi_offroute/tests/test_offroute.py @@ -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"