mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-08-26 17:31:37 +00:00
navi-offroute: unified-graph refactor spec (Phase 1 docs) (#37)
Co-authored-by: mj <mj@k7zvx.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
51bad71be1
commit
b738227453
1 changed files with 572 additions and 0 deletions
572
backend/services/navi_offroute/UNIFIED-GRAPH-SPEC.md
Normal file
572
backend/services/navi_offroute/UNIFIED-GRAPH-SPEC.md
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
# Unified-Graph Auto Routing — Architecture Spec (Path A)
|
||||
|
||||
**Status:** Phase 1 of 7 — design only, no code.
|
||||
**Scope:** This document specifies the unified `(row, col, mode)` A* refactor of
|
||||
Auto routing for `navi_offroute`. It supersedes the brute-force hybrid pair
|
||||
enumeration (`_try_hybrid_auto`) and the per-scenario dispatch (`_route_A/B/C/D`)
|
||||
for the **Auto** mode only. Explicit-mode requests (Foot/2w/4w/Drive) are
|
||||
untouched.
|
||||
|
||||
Baseline: PR #36 (`51bad71`) shipped classify-once / route-once and killed the
|
||||
4-mode contest.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem statement
|
||||
|
||||
`_route_auto` (`router.py` ~810) today picks the highest-priority mode in
|
||||
`start_class ∩ end_class`, calls `self.route(mode=picked)` once, then probes
|
||||
`_try_hybrid_auto` (`router.py` ~945) for a drive→walk pair. Two engines are
|
||||
stitched at `entry_points`: Valhalla (on-network, mode-pinned) plus the custom
|
||||
A* `astar_multigoal` (`astar.py`, off-network, mode-pinned, DEM-grid). Scenario
|
||||
dispatch (`_route_A/B/C/D`, `router.py` ~1242–1500) routes per on/off-network
|
||||
endpoint combination.
|
||||
|
||||
### Failure case driving the refactor
|
||||
|
||||
> Click in wilderness (no road within `AUTO_SNAP_RELAXED_M=100` m) → home
|
||||
> (tagged address).
|
||||
>
|
||||
> **Trace:** `start_class={foot}`, `end_class={vehicle,4w,2w,foot}`,
|
||||
> intersection=`{foot}`, mode=foot, routed entirely on foot.
|
||||
>
|
||||
> `_try_hybrid_auto` only goes drive→walk and can't drive out of a wilderness
|
||||
> start. Whole 60+ km routed on foot. User expected walk-out-to-road, then
|
||||
> drive.
|
||||
|
||||
The root defect is structural: Auto commits to **one mode for the entire trip**
|
||||
(the intersection of endpoint eligibilities), and the only escape hatch —
|
||||
`_try_hybrid_auto` — is hard-wired to a `drive → switch → offroad` ordering
|
||||
(`HYBRID_PAIRS`, `router.py` ~115). A trip that must begin off-network on foot
|
||||
and then *acquire* a faster mode at a road has no representation in the current
|
||||
model. The intersection collapses to `{foot}` and the whole route is walked.
|
||||
|
||||
The fix is to stop choosing a single trip-wide mode. Mode becomes part of the
|
||||
search state, and switching modes becomes an *edge* the planner can take where
|
||||
the world permits it (a parking lot, a trailhead, a road terminus). This is the
|
||||
OTP `CAR_PICKUP` analogue: the optimizer decides where the mode change happens,
|
||||
not a fixed leg ordering.
|
||||
|
||||
---
|
||||
|
||||
## 2. Target architecture
|
||||
|
||||
### 2.1 State space
|
||||
|
||||
The search state is the triple:
|
||||
|
||||
```
|
||||
state = (row, col, mode)
|
||||
```
|
||||
|
||||
- `row, col` — DEM/cost-grid pixel, as today (`astar_multigoal`).
|
||||
- `mode ∈ {foot, 2w, 4w, vehicle}` — the **active travel mode** in that cell.
|
||||
|
||||
`n_modes = 4`, fixed-index ordering:
|
||||
|
||||
```
|
||||
MODE_INDEX = {"foot": 0, "2w": 1, "4w": 2, "vehicle": 3}
|
||||
MODE_ORDER = ["foot", "2w", "4w", "vehicle"]
|
||||
```
|
||||
|
||||
Two kinds of edges connect states:
|
||||
|
||||
1. **Movement edges** — `(r, c, m) → (r', c', m)`: same mode, 8-neighbour grid
|
||||
step, cost identical to today's per-edge anisotropic cost computed against
|
||||
mode `m`'s cost layer (slope-aware speed, trail friction, barrier/boundary
|
||||
rule). Mode does not change.
|
||||
2. **Transition edges** — `(r, c, m) → (r, c, m')`: same cell, mode changes
|
||||
`m → m'`, cost is a flat per-transition-type time penalty (§4). Allowed only
|
||||
at **transition cells** (§4–§5) and only for the directed `(m, m')` pairs
|
||||
that type permits.
|
||||
|
||||
### 2.2 Mode set
|
||||
|
||||
`{foot, 2w, 4w, vehicle}` — exactly the keys of `MODE_PROFILES` (`cost.py`
|
||||
~105). No new modes. Each mode keeps its existing `ModeProfile`
|
||||
(speed function, max slope, trail friction, terrain overrides, wilderness
|
||||
flag). foot is the universal mode: always passable (`wilderness_impassable=False`),
|
||||
present in every transition pair.
|
||||
|
||||
### 2.3 New A* signature
|
||||
|
||||
A new kernel `astar_multigoal_multimode` extends `astar_multigoal`
|
||||
(`astar.py` ~83) to the 3D state space. It is JIT-compiled the same way
|
||||
(`@njit(cache=True)`). Single-mode `astar_multigoal` is **retained unchanged**
|
||||
for explicit-mode requests; the multimode kernel is invoked only by Auto.
|
||||
|
||||
```python
|
||||
@njit(cache=True)
|
||||
def astar_multigoal_multimode(
|
||||
cost_mult_stack, # 3D float64 [rows, cols, n_modes]: per-cell context
|
||||
# multiplier per mode, post-inflation (inf=impassable)
|
||||
elevation, # 2D float64: metres (NaN = impassable)
|
||||
cell_size_lat_m, # float
|
||||
cell_size_lon_m, # float
|
||||
max_grade_arr, # 1D float64 [n_modes]: tan(max_slope) per mode
|
||||
speed_function_ids, # 1D int [n_modes]: 0=tobler 1=herzog 2=linear
|
||||
base_speed_kmh_arr, # 1D float64 [n_modes]
|
||||
trail_grid, # 2D uint8: 0=none else trail value (5/15/25)
|
||||
trail_friction_stack, # 2D float64 [n_modes, 256]: friction by trail value
|
||||
# per mode (inf=impassable)
|
||||
barrier_grid, # 2D uint8: 255=barrier
|
||||
boundary_mode_id, # int: 0=strict 1=pragmatic 2=emergency
|
||||
origin_row, origin_col,
|
||||
origin_modes, # 1D int: allowed start modes (seeds) -- see §6
|
||||
goal_rows, goal_cols, # 1D int arrays
|
||||
goal_modes, # 1D int: allowed end modes -- see §6, §10
|
||||
trans_rows, # 1D int [n_trans]: transition cell rows
|
||||
trans_cols, # 1D int [n_trans]: transition cell cols
|
||||
trans_from_mode, # 1D int [n_trans]: source mode index
|
||||
trans_to_mode, # 1D int [n_trans]: target mode index
|
||||
trans_cost_s, # 1D float64 [n_trans]: transition penalty seconds
|
||||
):
|
||||
"""A* over (row, col, mode). The first (goal cell, allowed goal mode) state
|
||||
popped wins; optimal under the per-mode admissible heuristic (§10). Returns
|
||||
(best_goal_idx, path, total_cost) where path is int64 (N,3) of (row,col,mode)
|
||||
from an origin-mode seed to the goal. (-1, empty, inf) if unreachable."""
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Goal acceptance: a popped state `(r, c, m)` is a goal iff `(r, c)` is a goal
|
||||
cell **and** `m ∈ goal_modes`. This lets the search arrive in any mode the
|
||||
destination supports (§6).
|
||||
- Multiple origin seeds: every mode in `origin_modes` is pushed at the origin
|
||||
cell with `g=0`, so the search may *start* in any eligible start mode and
|
||||
switch later.
|
||||
- Transition edges are looked up per popped cell: a small per-cell index built
|
||||
from `trans_*` arrays (a dict `(r,c) -> list of (from,to,cost)` constructed
|
||||
once before the loop, numba-typed) lets the relaxation step add same-cell
|
||||
mode-change neighbours alongside the 8 grid neighbours.
|
||||
|
||||
### 2.4 New `_route_auto` flow (pseudocode)
|
||||
|
||||
```
|
||||
def _route_auto(start, end, boundary_mode, start_category, end_category):
|
||||
# 1. Bootstrap eligibility (classifier, NOT routing decision) -- §6
|
||||
allowed_start_modes = eligible_modes(start, start_category) # combined /locate
|
||||
allowed_end_modes = eligible_modes(end, end_category)
|
||||
# foot is always in both sets.
|
||||
|
||||
# 2. Build the bbox covering both endpoints (+ margin), as the scenario
|
||||
# handlers do today via _build_grid (shared elevation/friction/trails/
|
||||
# barriers fetch).
|
||||
bbox = bbox_for(start, end, margin)
|
||||
|
||||
# 3. Per-mode cost layers + transition cells -- §3, §4, §5
|
||||
layers = compute_unified_cost_layers(
|
||||
bbox, modes={foot,2w,4w,vehicle}, boundary_mode=boundary_mode)
|
||||
# layers.cost_mult[mode] -> inflated cost-mult grid
|
||||
# layers.transition_cells -> [(row,col,from_mode,to_mode,cost_s), ...]
|
||||
|
||||
# 4. Single unified search
|
||||
origin_modes = modes_to_indices(allowed_start_modes)
|
||||
goal_modes = modes_to_indices(allowed_end_modes)
|
||||
idx, path, cost = astar_multigoal_multimode(
|
||||
cost_mult_stack=stack(layers.cost_mult),
|
||||
elevation=..., trail_grid=..., barrier_grid=...,
|
||||
boundary_mode_id=id(boundary_mode),
|
||||
origin_row, origin_col, origin_modes,
|
||||
goal_rows=[dest_row], goal_cols=[dest_col], goal_modes,
|
||||
trans_*=unpack(layers.transition_cells),
|
||||
)
|
||||
|
||||
# 5. Render: split path into per-mode segments at the mode-change indices,
|
||||
# build the GeoJSON FeatureCollection, attach selected_mode_set =
|
||||
# sorted(distinct modes used along the path). No auto_fallback_from (§7).
|
||||
return build_response(path, cost, allowed_start_modes | allowed_end_modes)
|
||||
```
|
||||
|
||||
The destination is a single goal cell (the click/address), not a set of network
|
||||
`entry_points` — the unified search reaches the network naturally via movement
|
||||
edges. `entry_points` / `EntryPointIndex` remain in use for the explicit-mode
|
||||
scenario handlers (§13).
|
||||
|
||||
---
|
||||
|
||||
## 3. Cost model
|
||||
|
||||
### 3.1 Reuse `MODE_PROFILES` as-is
|
||||
|
||||
No rework of `MODE_PROFILES` (`cost.py` ~105) or the speed functions
|
||||
(`tobler_off_path_speed`, `herzog_wheeled_speed`, `linear_degrade_speed`). Each
|
||||
mode's per-cell context cost is still produced by `compute_cost_multiplier_grid`
|
||||
(`cost.py` ~221) and inflated by `inflate_cost_multiplier` (`astar.py` ~36). The
|
||||
multimode kernel reads one inflated grid **per mode**, stacked along a new axis.
|
||||
|
||||
### 3.2 New helper
|
||||
|
||||
```python
|
||||
def compute_unified_cost_layers(
|
||||
bbox, modes, boundary_mode,
|
||||
) -> dict:
|
||||
"""Build the per-mode cost stack and the mode-transition cell list for one
|
||||
Auto search over `bbox`.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"cost_mult": {mode: np.ndarray[rows, cols] float64}, # inflated
|
||||
"transition_cells": [ (row, col, from_mode, to_mode, cost_s), ... ],
|
||||
}
|
||||
|
||||
For each mode it calls compute_cost_multiplier_grid(...) with that mode's
|
||||
profile + the shared elevation/friction/trails/barriers/wilderness rasters
|
||||
for the bbox, then inflate_cost_multiplier(...). boundary_mode flows
|
||||
straight through to the underlying barrier/MVUM rules (§9). The transition
|
||||
cell list is gathered from the four sources in §4, mapped to grid pixels,
|
||||
de-duplicated per (cell, from, to), and capped per §5."""
|
||||
```
|
||||
|
||||
`compute_unified_cost_layers` lives in `cost.py` (cost-layer assembly) and
|
||||
delegates transition-cell gathering to a new `transitions.py` (the index
|
||||
queries + grid-pixel mapping + cap), keeping the raster math and the spatial
|
||||
sourcing separable. Phase 3 builds both.
|
||||
|
||||
---
|
||||
|
||||
## 4. Transition edges (named constants in `cost.py`)
|
||||
|
||||
Each transition type contributes directed mode-change edges at its cells with a
|
||||
fixed time penalty. Constants are defined in `cost.py` alongside
|
||||
`PRAGMATIC_BARRIER_MULTIPLIER`:
|
||||
|
||||
```python
|
||||
# Mode-switch transition penalties (seconds), unified-graph Auto.
|
||||
TRANSITION_COST_PARKING_S = 60.0 # park & switch at a lot
|
||||
TRANSITION_COST_TRAILHEAD_S = 30.0 # stage at a trailhead
|
||||
TRANSITION_COST_ROAD_TERMINUS_S = 60.0 # leave/meet vehicle at road end
|
||||
TRANSITION_COST_SURFACE_CHANGE_S = 0.0 # surface boundary, free swap
|
||||
```
|
||||
|
||||
| Source | Index / fn | Allowed switches (bidirectional) | Cost |
|
||||
|---|---|---|---|
|
||||
| Parking | `OSMParkingIndex` (`mvum_parking.py` ~44) | foot↔vehicle, foot↔4w, foot↔2w | **60 s** |
|
||||
| Trailheads | `TrailheadIndex` (`mvum_transitions.py` ~34) | foot↔4w, foot↔2w | **30 s** |
|
||||
| Road termini | last on-network OSM node before off-grid | foot↔vehicle | **60 s** |
|
||||
| Surface-change | `get_surface_change_candidates` (`mvum_surface_change.py` ~191) | mode swap along route | **0 s** |
|
||||
|
||||
Semantics:
|
||||
- "Bidirectional" means each listed `m↔m'` expands to two directed edges
|
||||
`(r,c,m)→(r,c,m')` and `(r,c,m')→(r,c,m)`, each carrying the type's cost.
|
||||
- Parking is the broadest switch (covers leaving any wheeled mode for foot and
|
||||
vice-versa), reflecting BLM/state/private + urban lots where MVUM trailheads
|
||||
don't exist.
|
||||
- Trailheads stage foot↔wheeled-offroad only (no full-size `vehicle`): trailhead
|
||||
parking is for the tow vehicle; you continue on 4w/2w or foot.
|
||||
- Road termini model the wilderness failure case directly: the foot path out of
|
||||
the backcountry meets the road network at the last drivable OSM node, where a
|
||||
foot→vehicle switch becomes available. **This is the edge that fixes §1.**
|
||||
- Surface-change cells are free (0 s): they represent a mode the route was
|
||||
already going to want (pavement→dirt), not a physical staging delay.
|
||||
|
||||
Road-terminus sourcing: the last on-network OSM node before off-grid is derived
|
||||
from a Valhalla `/locate`-style snap at the off-network boundary; Phase 3 wires
|
||||
the exact query (candidate: reuse `_locate_on_network`, `router.py` ~571, at the
|
||||
foot path's network-contact cells). Listed as a named source here; its precise
|
||||
extraction is a Phase 3 design item flagged in §14.
|
||||
|
||||
---
|
||||
|
||||
## 5. Transition cell cap
|
||||
|
||||
Unbounded transition cells would blow up both the per-cell index and the
|
||||
state-space branching. Cap:
|
||||
|
||||
> For each transition **type**, keep the closest **15** cells that lie within
|
||||
> **5 km** of the great-circle line between the two endpoints.
|
||||
|
||||
- Distance is measured to the great-circle line (start↔end), matching the
|
||||
"closest to route" spirit of the existing hybrid candidate sort
|
||||
(`_try_hybrid_auto`, `router.py` ~982–985) but using the straight endpoint
|
||||
line rather than a routed polyline (there is no pre-route polyline in the
|
||||
unified flow).
|
||||
- "Per type" means up to 15 parking + 15 trailhead + 15 road-terminus +
|
||||
15 surface-change cells, gathered and capped independently, then merged.
|
||||
- The 5 km band keeps the candidate set near the corridor of interest without
|
||||
requiring a first-pass route.
|
||||
|
||||
---
|
||||
|
||||
## 6. Spatial probe disposition (KEPT, reframed)
|
||||
|
||||
`_spatial_eligible_modes` (`router.py` ~771) is **kept** as the bootstrap
|
||||
classifier for **untagged** endpoints, but its role changes:
|
||||
|
||||
- **Before:** load-bearing. It produced the eligible-mode set that, intersected
|
||||
across endpoints, *chose the single trip mode*.
|
||||
- **After:** a *seed* generator. It produces `allowed_start_modes` /
|
||||
`allowed_end_modes`, which become `origin_modes` / `goal_modes` for the
|
||||
unified A*. It no longer decides the route — the search does.
|
||||
|
||||
Reframe to **one combined `/locate` call**:
|
||||
|
||||
- Today it fires three parallel costings (`auto`/`pedestrian`/`bicycle`) via a
|
||||
`ThreadPoolExecutor` (`router.py` ~777–784).
|
||||
- Valhalla `/locate` accepts multiple costings / a batch of locations in one
|
||||
request; collapse the three probes into a single combined `/locate` call per
|
||||
endpoint. Same per-mode snap-distance + road-class rules
|
||||
(`AUTO_SNAP_TIGHT_M`, `AUTO_SNAP_RELAXED_M`, `PAVED_HIGHWAY_CLASSES`,
|
||||
`TRACK_USE_VALUES`, `PATH_USE_VALUES`), same `snap_cache` dedupe.
|
||||
- foot stays universally eligible. Tagged endpoints still resolve via
|
||||
`_eligible_modes_from_category` (`router.py` ~740) with no probe.
|
||||
|
||||
Net: the probe seeds the search and is no longer load-bearing for routing
|
||||
decisions. A mis-classified seed degrades gracefully — the search can still
|
||||
switch into a mode at a transition cell even if that mode wasn't an origin/goal
|
||||
seed, as long as the mode is reachable via a transition edge from a seeded mode.
|
||||
|
||||
---
|
||||
|
||||
## 7. `auto_fallback_from` response field — REMOVED
|
||||
|
||||
The PR #36 foot-as-last-resort patch sets `auto_fallback_from` on the response
|
||||
when the capability-picked mode fails and foot succeeds (`router.py`
|
||||
~888–902). Under the unified search there is **no single picked mode to fall
|
||||
back from** — foot is one mode among four in the same search, and a foot-only
|
||||
result simply means the optimizer found no cheaper mixed-mode path. The field is
|
||||
**removed** from the Auto response shape. Fallback is implicit.
|
||||
|
||||
Frontend impact: `auto_fallback_from` consumers must be removed (§16 risk:
|
||||
response-shape break). Tracked in the Phase 6 frontend cleanup.
|
||||
|
||||
---
|
||||
|
||||
## 8. New `network_affinity` request parameter
|
||||
|
||||
A new optional request parameter:
|
||||
|
||||
```
|
||||
network_affinity: dict[mode -> float] # default {} -> 1.0 per mode
|
||||
```
|
||||
|
||||
A per-mode multiplier applied to **on-network cells** (cells whose `trail_grid`
|
||||
marks a road/track, value 5/15) in that mode's cost layer. `< 1.0` biases the
|
||||
search toward staying on the network in that mode; `> 1.0` penalizes it.
|
||||
Default `1.0` is a no-op (identical behaviour to today).
|
||||
|
||||
- **Lands in Phase 4** (rewire), **backend-only this round** — accepted and
|
||||
threaded through `compute_unified_cost_layers`, no frontend control yet.
|
||||
- Applied after `compute_cost_multiplier_grid`, before
|
||||
`inflate_cost_multiplier`, on the network-cell mask only.
|
||||
|
||||
---
|
||||
|
||||
## 9. Boundary mode
|
||||
|
||||
`strict` / `pragmatic` / `emergency` flows into
|
||||
`compute_unified_cost_layers(bbox, modes, boundary_mode)` exactly as it does
|
||||
today into `compute_cost_grid` / `compute_cost_multiplier_grid` — it governs the
|
||||
barrier (PAD-US) and MVUM-closure rules per mode (`cost.py` ~474–503) and is
|
||||
passed to the kernel as `boundary_mode_id` (`0/1/2`).
|
||||
|
||||
**Mode-switch edges are barrier-free transitions.** A transition edge changes
|
||||
mode in place at a known staging cell; it carries no terrain, no barrier, and no
|
||||
boundary-mode semantics — only its flat per-type time penalty (§4). Boundary
|
||||
mode affects only the movement edges (the cost layers), never the switch edges.
|
||||
|
||||
---
|
||||
|
||||
## 10. Heuristic admissibility
|
||||
|
||||
The remaining-cost heuristic for a state `(r, c, m)` must never overestimate the
|
||||
true cost to any goal, across **all** reachable goal modes. Use the
|
||||
**fastest mode that can reach the goal**:
|
||||
|
||||
```
|
||||
h(r, c, m) = straight_line_distance(cell, nearest_goal_cell) * 3.6
|
||||
/ max(base_speed_kmh[g] for g in allowed_end_modes)
|
||||
```
|
||||
|
||||
- Take the **maximum** base speed over `allowed_end_modes` (`goal_modes`) — i.e.
|
||||
the *fastest* permissible finishing mode. Dividing distance by the largest
|
||||
speed yields the smallest possible time, so `h` underestimates → admissible.
|
||||
- This generalizes the single-mode heuristic (`astar.py` ~120–128), which
|
||||
divides by the one mode's `base_speed_kmh`. With one allowed end mode the two
|
||||
expressions coincide.
|
||||
- The heuristic ignores the current state's mode `m` deliberately: a state in a
|
||||
slow mode might still switch to a fast mode before the goal, so bounding by the
|
||||
fastest goal-reachable mode keeps `h` admissible (never optimistic-violating)
|
||||
for every state regardless of its current mode.
|
||||
- Transition penalties are ≥ 0, so omitting them from `h` only makes `h` smaller
|
||||
— still admissible.
|
||||
|
||||
---
|
||||
|
||||
## 11. Numba state encoding
|
||||
|
||||
Heap id packs the 3D state into a single int64:
|
||||
|
||||
```
|
||||
heap_id = mode * (rows * cols) + row * cols + col
|
||||
# decode:
|
||||
mode = heap_id // (rows * cols)
|
||||
rem = heap_id % (rows * cols)
|
||||
row = rem // cols
|
||||
col = rem % cols
|
||||
```
|
||||
|
||||
Per-state arrays grow from 2D to 3D, indexed `[row, col, mode]`:
|
||||
|
||||
```
|
||||
closed : np.zeros((rows, cols, n_modes), dtype=np.bool_)
|
||||
g_score : np.full((rows, cols, n_modes), INF, dtype=np.float64)
|
||||
parent : np.full((rows, cols, n_modes), -1, dtype=np.int64) # stores parent heap_id
|
||||
```
|
||||
|
||||
- Memory is ~`n_modes` (= 4) × the single-mode arrays; bounded by the bbox (the
|
||||
same bbox the scenario handlers build today), so the cap is the bbox cap.
|
||||
- `parent` stores the parent's packed `heap_id` (not a cell id), so traceback
|
||||
can recover the mode at each step and thus the mode-change indices for
|
||||
rendering (§2.4 step 5).
|
||||
- Heap capacity scales accordingly: `cap = rows * cols * n_modes * <fanout>`
|
||||
(today's `rows*cols*4`, `astar.py` ~113, generalizes — confirm fanout headroom
|
||||
in Phase 2 perf test).
|
||||
|
||||
---
|
||||
|
||||
## 12. What gets ripped
|
||||
|
||||
- `_try_hybrid_auto` (`router.py` ~945) — the brute-force drive→offroad pair
|
||||
enumeration over `HYBRID_PAIRS`. The unified search subsumes it. (Phase 5
|
||||
deprecates, Phase 4/5 stops calling it.)
|
||||
- `_route_A/B/C/D` **as separate Auto dispatch paths**. Scenario D ("on-network
|
||||
→ on-network") becomes simply "the A* search happened to never leave the
|
||||
on-network cells / never took a non-network movement edge." The handlers
|
||||
remain for explicit-mode routing (§13); only Auto stops dispatching through
|
||||
them.
|
||||
- The 4-mode contest — already gone in PR #36; noted for completeness.
|
||||
- foot-as-last-resort fallback (PR #36 patch, `router.py` ~885–906) and its
|
||||
`auto_fallback_from` field (§7).
|
||||
|
||||
---
|
||||
|
||||
## 13. What stays
|
||||
|
||||
- `MODE_PROFILES` and the speed functions (`cost.py`) — unchanged (§3).
|
||||
- The `astar_multigoal` core search loop (`astar.py` ~83) — **extended, not
|
||||
replaced**. `astar_multigoal_multimode` is a sibling kernel; single-mode stays
|
||||
for explicit modes.
|
||||
- `EntryPointIndex` (`router.py` ~185), `TrailheadIndex`
|
||||
(`mvum_transitions.py` ~34), `OSMParkingIndex` (`mvum_parking.py` ~44),
|
||||
`get_surface_change_candidates` (`mvum_surface_change.py` ~191) — reused as
|
||||
transition-cell sources (§4).
|
||||
- `_locate_on_network` (`router.py` ~571) — on/off-network classification and
|
||||
road-terminus sourcing.
|
||||
- `_spatial_eligible_modes` (`router.py` ~771) — kept, **reframed** as a seed
|
||||
generator (§6).
|
||||
- `self.route(mode=...)` for explicit-mode requests — Drive / Foot / 2w / 4w
|
||||
picked manually still go through the existing scenario dispatch
|
||||
(`router.py` ~650–710). **Only `mode="auto"` switches to the unified search.**
|
||||
|
||||
---
|
||||
|
||||
## 14. Migration plan (file-by-file diff sketch)
|
||||
|
||||
Each phase ≤ 400 lines, HALT-at-diff at every boundary.
|
||||
|
||||
### Phase 1 — spec doc (THIS PR)
|
||||
- `+ backend/services/navi_offroute/UNIFIED-GRAPH-SPEC.md` (this file).
|
||||
- No code.
|
||||
|
||||
### Phase 2 — multi-mode kernel
|
||||
- `astar.py`: `+ astar_multigoal_multimode` (new `@njit` kernel, §2.3, §10,
|
||||
§11). Factor the shared per-edge cost math so the two kernels don't diverge
|
||||
(helper `@njit` functions for slope penalty + speed, already partly inlined as
|
||||
`_speed_kmh`). `astar_multigoal` untouched behaviourally.
|
||||
- `astar.py`: transition-cell per-cell index builder (numba-typed dict
|
||||
`(r,c) -> typed list`), built from `trans_*` arrays before the search loop.
|
||||
- No `router.py`/`cost.py` wiring yet — kernel callable + unit-tested in
|
||||
isolation.
|
||||
|
||||
### Phase 3 — cost layers + transition sources
|
||||
- `cost.py`: `+ TRANSITION_COST_*` constants (§4); `+ compute_unified_cost_layers`
|
||||
(§3.2).
|
||||
- `+ transitions.py` (new): gather transition cells from the four sources (§4),
|
||||
map lat/lon → grid pixels for a given bbox/meta, de-dupe per (cell,from,to),
|
||||
apply the per-type closest-15-within-5km cap (§5). Road-terminus extraction
|
||||
finalized here (the §4 Phase-3 flag).
|
||||
- No `_route_auto` rewire yet.
|
||||
|
||||
### Phase 4 — rewire Auto
|
||||
- `router.py`: rewrite `_route_auto` (§2.4) to build the bbox + cost layers,
|
||||
call `astar_multigoal_multimode`, and render per-mode segments. Thread
|
||||
`network_affinity` through (§8, backend-only).
|
||||
- `router.py`: `_spatial_eligible_modes` → one combined `/locate` call, return
|
||||
reframed as seeds (§6).
|
||||
- `_try_hybrid_auto` still present but no longer called from `_route_auto`.
|
||||
- Remove `auto_fallback_from` from the Auto path (§7).
|
||||
|
||||
### Phase 5 — remove hybrid + collapse scenario dispatch
|
||||
- `router.py`: delete `_try_hybrid_auto` and the `HYBRID_*` constants
|
||||
(`router.py` ~107–115). Collapse `_route_A/B/C/D` Auto usage; keep them for
|
||||
explicit-mode dispatch (§13). Where an A/B/C/D handler is now only reachable
|
||||
by explicit mode, simplify its Auto-specific branches.
|
||||
|
||||
### Phase 6 — frontend cleanup
|
||||
- `MapView.jsx`: color route by **route-mode-per-segment** (the §2.4 step-5
|
||||
segments), not by surface class. Consume `selected_mode_set` + per-segment
|
||||
mode.
|
||||
- `DirectionsPanel.jsx`: drop `auto_fallback_from` handling (§7); render the
|
||||
mode-switch points (parking/trailhead/road-terminus) as step transitions.
|
||||
|
||||
### Phase 7 — deploy + smoke
|
||||
- Deploy clone to VM 1130; run the Phase 4 integration + Phase 6 browser smoke
|
||||
(§15) on the deployed build. Repeated at every PR's deploy boundary.
|
||||
|
||||
---
|
||||
|
||||
## 15. Test plan
|
||||
|
||||
### Phase 2 (kernel unit, `tests/`)
|
||||
- **foot-only parity:** with `origin_modes=goal_modes={foot}` and **no**
|
||||
transition cells, `astar_multigoal_multimode` returns the same path + cost as
|
||||
`astar_multigoal` for foot on a fixture grid (the extension is a strict
|
||||
superset).
|
||||
- **parking-cell two-mode switch:** a grid with a foot-passable corridor to a
|
||||
parking cell, then a vehicle-fast road; assert the optimal path switches
|
||||
foot→vehicle at the parking cell and beats foot-only.
|
||||
- **no-transition degradation:** transition list empty ⇒ behaves exactly like
|
||||
`n_modes` independent single-mode searches (no cross-mode coupling).
|
||||
- **admissibility check:** assert `h(state) ≤ true_remaining_cost` over a sweep
|
||||
of states for a multi-goal-mode fixture (§10); assert the popped-goal-is-optimal
|
||||
invariant holds (compare against a Dijkstra/no-heuristic run).
|
||||
|
||||
### Phase 3 (cost-layer unit, `tests/`)
|
||||
- **per-mode cost layers:** `compute_unified_cost_layers` returns one grid per
|
||||
mode matching `compute_cost_multiplier_grid` + `inflate_cost_multiplier` for
|
||||
that mode (per-mode wilderness/terrain overrides applied).
|
||||
- **transition cell detection:** seeded parking/trailhead/road-terminus/surface
|
||||
fixtures map to the expected grid pixels with the correct directed
|
||||
`(from,to,cost)` triples.
|
||||
- **cap correctness:** > 15 candidates of a type ⇒ exactly the closest 15 within
|
||||
5 km of the endpoint line survive (§5).
|
||||
- **perf:** `≤ 1 s` to build all four layers + transition cells for a **50 km**
|
||||
bbox.
|
||||
|
||||
### Phase 4 (integration, `tests/`)
|
||||
- **wilderness → home (the §1 failure case):** assert the result walks out to a
|
||||
road/parking/terminus, switches to vehicle, and the foot leg is short — *not*
|
||||
a 60 km foot route.
|
||||
- **foot → off-path:** pure off-network foot trip still routes foot-only
|
||||
(no spurious switches).
|
||||
- **road → road:** in-town trip stays on-network in a wheeled mode (the old
|
||||
Scenario D); fast, no off-grid excursion.
|
||||
- **dirt-road untagged:** untagged dirt-road endpoint classified via the
|
||||
reframed combined `/locate` seed; routes in 4w/2w as appropriate.
|
||||
|
||||
### Phase 6 (browser smoke)
|
||||
- Load each Phase 4 integration case in the UI; confirm per-segment mode
|
||||
coloring, mode-switch step markers, and absence of any `auto_fallback_from`
|
||||
reference.
|
||||
|
||||
---
|
||||
|
||||
## 16. Risk register
|
||||
|
||||
| Risk | Detail | Mitigation |
|
||||
|---|---|---|
|
||||
| Numba JIT on 3D arrays | `@njit` typing of 3D `closed`/`g_score`/`parent` + a numba-typed transition dict may fail to compile or fall to object mode | Phase 2 builds + benchmarks the kernel in isolation before any wiring; assert no object-mode fallback (numba `nopython`); keep `cache=True` warm-compile |
|
||||
| 4× state-space explosion | `(row,col,mode)` is ~4× the single-mode search; heap + arrays grow, latency risk on large bbox | Bounded by the same bbox the scenario handlers build today; Phase 3 perf gate (≤1 s layers / 50 km) + Phase 2 fanout headroom check; per-mode admissible heuristic (§10) prunes hard |
|
||||
| Scenario-removal regressions | Collapsing `_route_A/B/C/D` for Auto could regress edge cases those branches handled | Keep handlers for explicit mode (§13); Phase 4 integration covers all four old scenarios (road→road = old D, etc.); HALT-at-diff per phase |
|
||||
| Response-shape break to frontend | `auto_fallback_from` removed (§7); per-segment mode shape changes | Phase 6 ships frontend cleanup in lockstep; Phase 4 keeps `selected_mode_set`; document the shape delta in the Phase 4/6 PR bodies |
|
||||
| Deploy clone / remote drift | VM 1130 deploy clone may drift from `main`; smoke runs against stale build | Phase 7 redeploys the clone at every PR boundary before smoke; pin the deployed commit in the smoke report |
|
||||
Loading…
Add table
Add a link
Reference in a new issue