navi-offroute: HPA* runtime kernel + router dispatch (Phase H3)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mj 2026-05-27 17:32:32 -06:00
commit 6a60600021
3 changed files with 480 additions and 1 deletions

View file

@ -12,7 +12,12 @@ Cliffs (|grade| > max_grade) incur a smooth exponential penalty rather than a ha
wall, so a single noisy DEM cell can't fabricate an impassable edge; only truly
absurd grades (penalty > SLOPE_PENALTY_CAP) are dropped.
"""
import hashlib
import heapq
import itertools
import math
import sqlite3
from collections import defaultdict
import numpy as np
from numba import njit
@ -597,3 +602,231 @@ def astar_multigoal_multimode(
break
return -1, np.empty((0, 3), dtype=np.int64), INF
# ═══════════════════════════════════════════════════════════════════════════════
# HPA* TWO-LEVEL RUNTIME (unified-graph perf, HPA-SPEC.md §8/§9, Phase H3)
# ═══════════════════════════════════════════════════════════════════════════════
#
# astar_hpa_multimode is a PURE-PYTHON sibling of astar_multigoal_multimode (NOT @njit:
# it does SQLite I/O + per-chunk Python loops). It searches the precomputed abstract chunk
# graph (cost tiles from hpa_build), then refines each hop with the existing @njit
# astar_multigoal on that chunk's live cost layer. astar_multigoal / astar_multigoal_multimode
# are byte-unchanged; HPA* engages only when the dispatcher passes a tile DB.
#
# v1 limitations (HPA-SPEC.md §5/§8, PR #44): border entrances ONLY (no transition-cell
# entrances ≥20), so there are NO mode-switch edges -> the abstract path is SINGLE-MODE
# (a mode m in start_modes ∩ goal_modes). A route whose optimum needs a mode switch (the §1
# wilderness→home walk-then-drive) will here degrade to single-mode or find no path; the
# dispatcher then falls back to astar_multigoal_multimode. **This means enabling HPA* on a
# mixed-mode route can return a worse selected_mode_set than unified A* — keep disabled in
# prod until transition-cell entrances land (a follow-up) or H5 gates it.**
# The abstract search uses Dijkstra (h≡0, trivially admissible/optimal); the abstract graph
# is tiny, so a §10-style heuristic isn't needed for v1.
HPA_BORDER_ENTRANCES = 20
def _hpa_profile_hash():
from .cost import MODE_PROFILES
return hashlib.sha256(repr(MODE_PROFILES).encode()).hexdigest()
def _hpa_coverage_reason(conn, needed_chunks, boundary_mode, network_affinity):
"""Return a fallback reason string (HPA cannot/should-not engage), or None if clear.
Order: config gates first (cheap), then freshness, then tile coverage."""
if boundary_mode != "pragmatic":
return "boundary_mode"
if network_affinity and any(float(v) != 1.0 for v in network_affinity.values()):
return "affinity"
row = conn.execute("SELECT value FROM meta WHERE key='mode_profile_hash'").fetchone()
if not row or row[0] != _hpa_profile_hash():
return "stale_profile"
cxs = [c[0] for c in needed_chunks]
cys = [c[1] for c in needed_chunks]
present = {(r[0], r[1]) for r in conn.execute(
"SELECT DISTINCT chunk_x, chunk_y FROM chunk_costs "
"WHERE chunk_x BETWEEN ? AND ? AND chunk_y BETWEEN ? AND ?",
(min(cxs), max(cxs), min(cys), max(cys)))}
if any(ch not in present for ch in needed_chunks):
return "missing_chunk"
return None
def _hpa_abstract_search(conn, needed_chunks, relevant_modes, start_edges, goal_edges):
"""Dijkstra over the abstract graph. Nodes are (cx, cy, entrance, mode) plus virtual
"START"/"GOAL". Edges: intra-chunk (precomputed tile costs), inter-chunk seams (free,
same physical border cell), and the start/goal pseudo-edges. Returns
(node_seq_excl_endpoints, total_cost) or (None, INF). v1: no cross-mode edges."""
present = set(needed_chunks)
rmodes = set(relevant_modes)
cxs = [c[0] for c in needed_chunks]
cys = [c[1] for c in needed_chunks]
adj = defaultdict(list)
# Intra-chunk directed edges (border entrances 0..19 only — transition cells deferred).
for cx, cy, m, fe, te, cost in conn.execute(
"SELECT chunk_x, chunk_y, mode_idx, from_entrance, to_entrance, cost_s FROM chunk_costs "
"WHERE chunk_x BETWEEN ? AND ? AND chunk_y BETWEEN ? AND ?",
(min(cxs), max(cxs), min(cys), max(cys))):
if m in rmodes and fe < HPA_BORDER_ENTRANCES and te < HPA_BORDER_ENTRANCES:
adj[(cx, cy, fe, m)].append(((cx, cy, te, m), float(cost)))
# Inter-chunk seams (free, both directions). Right 5..9 ↔ left 15..19 of (cx+1,cy);
# bottom 10..14 ↔ top 0..4 of (cx,cy+1) — same fraction, same physical cell (spec §8).
for (cx, cy) in needed_chunks:
for m in rmodes:
if (cx + 1, cy) in present:
for k in range(5):
a, b = (cx, cy, 5 + k, m), (cx + 1, cy, 15 + k, m)
adj[a].append((b, 0.0)); adj[b].append((a, 0.0))
if (cx, cy + 1) in present:
for k in range(5):
a, b = (cx, cy, 10 + k, m), (cx, cy + 1, k, m)
adj[a].append((b, 0.0)); adj[b].append((a, 0.0))
for node, cost in start_edges.items():
adj["START"].append((node, float(cost)))
for node, cost in goal_edges.items():
adj[node].append(("GOAL", float(cost)))
counter = itertools.count()
dist = {"START": 0.0}
prev = {}
pq = [(0.0, next(counter), "START")]
while pq:
d, _, u = heapq.heappop(pq)
if u == "GOAL":
break
if d > dist.get(u, INF):
continue
for v, w in adj[u]:
nd = d + w
if nd < dist.get(v, INF):
dist[v] = nd
prev[v] = u
heapq.heappush(pq, (nd, next(counter), v))
if "GOAL" not in dist:
return None, INF
seq, node = [], "GOAL"
while node != "START":
if node != "GOAL":
seq.append(node)
node = prev[node]
seq.reverse()
return seq, dist["GOAL"]
def _hpa_inchunk(layer, fr, fc, gr, gc):
"""Least-time path + cost between two cells of a chunk's live cost layer (single mode)."""
_, path, cost = astar_multigoal(
layer["cost_mult"], layer["elevation"], layer["cell_size_m"], layer["cell_size_m"],
layer["max_grade"], layer["speed_function_id"], layer["base_speed_kmh"],
layer["trail_grid"], layer["trail_friction_lookup"], layer["barrier_grid"], 1,
int(fr), int(fc), np.array([gr], dtype=np.int64), np.array([gc], dtype=np.int64))
return path, cost
def _hpa_emit(layer, path, mode, dem_reader, full_meta, out):
"""Append a chunk-local cell path to `out` as (full_row, full_col, mode), via lat/lon
(chunk grid -> full-bbox grid) since the chunk fetch and full fetch have different pixel
origins. Consecutive duplicates (e.g. at seams) are dropped."""
for k in range(path.shape[0]):
lat, lon = dem_reader.pixel_to_latlon(int(path[k, 0]), int(path[k, 1]), layer["meta"])
r, c = dem_reader.latlon_to_pixel(lat, lon, full_meta)
node = (int(r), int(c), int(mode))
if not out or out[-1] != node:
out.append(node)
def astar_hpa_multimode(tile_db_path, full_meta, start_lat, start_lon, end_lat, end_lon,
origin_modes, goal_modes, boundary_mode, network_affinity,
chunk_layer=None, dem_reader=None):
"""Two-level HPA* (HPA-SPEC.md §8). Returns (idx, path_Nx3_int64, total_cost, reason)
matching astar_multigoal_multimode's render contract: idx==0 on success (reason None),
idx==-1 on fallback (reason in {boundary_mode, affinity, stale_profile, missing_chunk,
no_abstract_path, refine_failed}). chunk_layer(cx, cy, mode_idx)->layer dict (live,
native-30m, tile-grid-aligned) is supplied by the dispatcher for refinement."""
from . import hpa_build as hb
empty = np.empty((0, 3), dtype=np.int64)
south, north, west, east = full_meta["bounds"]
needed = hb.chunks_in_bbox(south, west, north, east)
conn = sqlite3.connect(f"file:{tile_db_path}?mode=ro", uri=True)
try:
reason = _hpa_coverage_reason(conn, needed, boundary_mode, network_affinity)
if reason is not None:
return -1, empty, INF, reason
relevant = sorted(set(int(x) for x in origin_modes) & set(int(x) for x in goal_modes))
if not relevant:
return -1, empty, INF, "no_abstract_path"
start_chunk = hb.chunk_coords(start_lat, start_lon)
goal_chunk = hb.chunk_coords(end_lat, end_lon)
# Same chunk: a direct in-chunk A* beats routing out to a border entrance and back.
if start_chunk == goal_chunk:
best = None
for m in relevant:
L = chunk_layer(start_chunk[0], start_chunk[1], m)
sr, sc = dem_reader.latlon_to_pixel(start_lat, start_lon, L["meta"])
gr, gc = dem_reader.latlon_to_pixel(end_lat, end_lon, L["meta"])
path, cost = _hpa_inchunk(L, sr, sc, gr, gc)
if np.isfinite(cost) and (best is None or cost < best[0]):
best = (cost, L, path, m)
if best is None:
return -1, empty, INF, "no_abstract_path"
out = []
_hpa_emit(best[1], best[2], best[3], dem_reader, full_meta, out)
return (0, np.array(out, dtype=np.int64), best[0], None) if len(out) >= 2 \
else (-1, empty, INF, "refine_failed")
# Pseudo-edges: start cell -> each start-chunk entrance; each goal-chunk entrance -> end.
start_edges, goal_edges = {}, {}
for m in relevant:
Ls = chunk_layer(start_chunk[0], start_chunk[1], m)
sr, sc = dem_reader.latlon_to_pixel(start_lat, start_lon, Ls["meta"])
for ei, (er, ec) in enumerate(Ls["entrance_cells"]):
_, cost = _hpa_inchunk(Ls, sr, sc, er, ec)
if np.isfinite(cost):
start_edges[(start_chunk[0], start_chunk[1], ei, m)] = cost
Lg = chunk_layer(goal_chunk[0], goal_chunk[1], m)
gr, gc = dem_reader.latlon_to_pixel(end_lat, end_lon, Lg["meta"])
for ei, (er, ec) in enumerate(Lg["entrance_cells"]):
_, cost = _hpa_inchunk(Lg, er, ec, gr, gc)
if np.isfinite(cost):
goal_edges[(goal_chunk[0], goal_chunk[1], ei, m)] = cost
seq, cost = _hpa_abstract_search(conn, needed, relevant, start_edges, goal_edges)
if seq is None:
return -1, empty, INF, "no_abstract_path"
finally:
conn.close()
# Refinement: stitch the real cells for each hop. Single mode throughout (v1).
m = seq[0][3]
out = []
Ls = chunk_layer(start_chunk[0], start_chunk[1], m)
sr, sc = dem_reader.latlon_to_pixel(start_lat, start_lon, Ls["meta"])
fr, fc = Ls["entrance_cells"][seq[0][2]]
path, c = _hpa_inchunk(Ls, sr, sc, fr, fc)
if not np.isfinite(c):
return -1, np.empty((0, 3), dtype=np.int64), INF, "refine_failed"
_hpa_emit(Ls, path, m, dem_reader, full_meta, out)
for a, b in zip(seq, seq[1:]):
if a[0] == b[0] and a[1] == b[1]: # intra-chunk hop -> refine
L = chunk_layer(a[0], a[1], a[3])
ar, ac = L["entrance_cells"][a[2]]
br, bc = L["entrance_cells"][b[2]]
path, c = _hpa_inchunk(L, ar, ac, br, bc)
if not np.isfinite(c):
return -1, np.empty((0, 3), dtype=np.int64), INF, "refine_failed"
_hpa_emit(L, path, m, dem_reader, full_meta, out)
# else: inter-chunk seam (same physical cell) -> no refinement
Lg = chunk_layer(goal_chunk[0], goal_chunk[1], m)
lr, lc = Lg["entrance_cells"][seq[-1][2]]
gr, gc = dem_reader.latlon_to_pixel(end_lat, end_lon, Lg["meta"])
path, c = _hpa_inchunk(Lg, lr, lc, gr, gc)
if not np.isfinite(c):
return -1, np.empty((0, 3), dtype=np.int64), INF, "refine_failed"
_hpa_emit(Lg, path, m, dem_reader, full_meta, out)
if len(out) < 2:
return -1, np.empty((0, 3), dtype=np.int64), INF, "refine_failed"
return 0, np.array(out, dtype=np.int64), cost, None

View file

@ -33,7 +33,8 @@ import requests
import psycopg2
import psycopg2.extras
from shapely.geometry import LineString, Point
from .astar import astar_multigoal, astar_multigoal_multimode, inflate_cost_multiplier
from .astar import (astar_multigoal, astar_multigoal_multimode, astar_hpa_multimode,
inflate_cost_multiplier)
from .mvum_surface_change import get_surface_change_candidates
from .mvum_parking import load_parking_index # noqa: F401 (singleton injected by handler)
@ -60,6 +61,11 @@ POSTGIS_DSN = os.environ.get("NAVI_OFFROUTE_POSTGIS_DSN", "dbname=padus")
# Valhalla endpoint (recon-side network router, HTTP)
VALHALLA_URL = os.environ.get("NAVI_OFFROUTE_VALHALLA_URL", "http://localhost:8002")
# HPA* cost-tile DB (HPA-SPEC.md §8/§9, Phase H3). Unset (None) -> HPA* never engages and
# Auto routing is byte-identical to the unified-graph path. Set to a tile DB (built by
# hpa_build) to enable the two-level fast path for covered, pragmatic, no-affinity routes.
HPA_TILE_DB = os.environ.get("NAVI_OFFROUTE_HPA_DB")
# Search radius for entry points (km)
DEFAULT_SEARCH_RADIUS_KM = 50
EXPANDED_SEARCH_RADIUS_KM = 100
@ -887,6 +893,27 @@ class OffrouteRouter:
origin_modes = np.array(sorted(MODE_INDEX[m] for m in start_eligible), dtype=np.int64)
goal_modes = np.array(sorted(MODE_INDEX[m] for m in end_eligible), dtype=np.int64)
# HPA* fast path (Phase H3): when a tile DB is configured + covers the route, search
# the precomputed abstract chunk graph instead of flooding the full bbox. Whole-route
# fallback to the unified kernel below on any miss (HPA-SPEC.md §8/§9). When
# NAVI_OFFROUTE_HPA_DB is unset this block is skipped entirely (behaviour unchanged).
if self._hpa_eligible(boundary_mode, network_affinity):
_h0 = time.perf_counter()
_cache = {"raster": {}, "layer": {}}
hidx, hpath, hcost, hreason = astar_hpa_multimode(
HPA_TILE_DB, meta, start_lat, start_lon, end_lat, end_lon,
origin_modes, goal_modes, boundary_mode, network_affinity,
chunk_layer=lambda cx, cy, mi: self._hpa_chunk_layer(cx, cy, mi, _cache),
dem_reader=self.dem_reader)
if hidx >= 0 and hpath.shape[0] > 0:
logger.info("auto: HPA* (chunks=%d) in %.2fs",
len(_cache["raster"]), time.perf_counter() - _h0)
return self._render_unified_path(hpath, hcost, meta, boundary_mode)
logger.info("auto: HPA fallback reason=%s -> unified A*", hreason)
elif HPA_TILE_DB and os.path.exists(HPA_TILE_DB):
_r = "boundary_mode" if boundary_mode != "pragmatic" else "affinity"
logger.info("auto: HPA fallback reason=%s -> unified A*", _r)
# 7. Unpack transition cells into the kernel's flat 1D arrays.
tc = layers["transition_cells"]
nt = len(tc)
@ -1059,6 +1086,68 @@ class OffrouteRouter:
"scenario": "unified",
}
def _hpa_eligible(self, boundary_mode, network_affinity):
"""HPA* engages only with a configured + existing tile DB, the default boundary mode,
and no network_affinity the tiles are pure-terrain/pragmatic (HPA-SPEC.md §8), so
other configs would change the answer and must use the unified fallback."""
if not (HPA_TILE_DB and os.path.exists(HPA_TILE_DB)):
return False
if boundary_mode != "pragmatic":
return False
if network_affinity and any(float(v) != 1.0 for v in network_affinity.values()):
return False
return True
def _hpa_chunk_layer(self, cx, cy, mode_idx, cache):
"""Live native-30m cost layer for one chunk (tile-grid-aligned), for HPA* refinement.
Pure terrain only (no MVUM/barriers/network_affinity/corridor-mask), matching the H2
tile build so entrance cells and costs line up. Rasters cached per chunk, layers per
(chunk, mode)."""
from . import hpa_build as hb
if (cx, cy) not in cache["raster"]:
s, w, n, e = hb.chunk_bounds(cx, cy)
elev, cmeta = self.dem_reader.get_elevation_grid(south=s, north=n, west=w, east=e)
shape = elev.shape
fraw = self.friction_reader.get_friction_grid(
south=s, north=n, west=w, east=e, target_shape=shape)
fmult = friction_to_multiplier(fraw)
trails = self.trail_reader.get_trails_grid(
south=s, north=n, west=w, east=e, target_shape=shape)
wild = None
if self.wilderness_reader is not None:
wild = self.wilderness_reader.get_wilderness_grid(
south=s, north=n, west=w, east=e, target_shape=shape)
cache["raster"][(cx, cy)] = (
np.ascontiguousarray(elev, np.float64), fmult, fraw, trails, wild, cmeta)
elev, fmult, fraw, trails, wild, cmeta = cache["raster"][(cx, cy)]
key = (cx, cy, mode_idx)
if key not in cache["layer"]:
mode = MODE_ORDER[mode_idx]
prof = MODE_PROFILES[mode]
cs = float(cmeta["cell_size_m"])
cm = compute_cost_multiplier_grid(
elev, cell_size_lat_m=cs, cell_size_lon_m=cs,
friction=fmult, friction_raw=fraw, wilderness=wild, mode=mode)
cm = np.ascontiguousarray(inflate_cost_multiplier(cm), np.float64)
tfl = np.full(256, np.inf, np.float64)
for tv, fr in prof.trail_friction.items():
tfl[tv] = np.inf if fr is None else float(fr)
shape = elev.shape
cache["layer"][key] = {
"cost_mult": cm, "elevation": elev,
"trail_grid": np.ascontiguousarray(
trails if trails is not None else np.zeros(shape, np.uint8), np.uint8),
"trail_friction_lookup": tfl,
"barrier_grid": np.zeros(shape, np.uint8),
"max_grade": float(np.tan(np.radians(prof.max_slope_deg))),
"speed_function_id": {"tobler": 0, "herzog": 1, "linear": 2}.get(prof.speed_function, 0),
"base_speed_kmh": float(prof.base_speed_kmh),
"cell_size_m": cs,
"entrance_cells": hb._entrance_cells(*shape),
"meta": cmeta,
}
return cache["layer"][key]
def _route_D_network_only(
self,
start_lat: float, start_lon: float,

View file

@ -0,0 +1,157 @@
"""HPA* two-level runtime tests (Phase H3). Abstract search + coverage/freshness fallback on
synthetic tile DBs; dispatcher gating end-to-end. Refinement on live rasters is exercised in
H4 ops / H5 path-quality, not here (these inputs are deterministic without the real builder)."""
import logging
import sqlite3
import numpy as np
import pytest
from services.navi_offroute import astar, hpa_build as hb
import services.navi_offroute.router as router_mod
import services.navi_offroute.transitions as p_trans
from services.navi_offroute.router import OffrouteRouter
from services.navi_offroute.transitions import _latlon_to_pixel as _ll2px, _pixel_to_latlon as _px2ll
# Four-chunk region (0,0),(1,0),(0,1),(1,1) with a known path 0,0 -> 1,0 -> 1,1.
_CHUNKS = [(0, 0), (0, 1), (1, 0), (1, 1)]
# bounds = (south, north, west, east); spans chunks 0..1 in both axes.
_BOUNDS = (0.001, hb.CHUNK_DEG + 0.001, 0.001, hb.CHUNK_DEG + 0.001)
def _make_tile_db(path, rows, hash_ok=True):
conn = sqlite3.connect(path)
hb._init_schema(conn)
conn.executemany(hb._INSERT, rows)
h = astar._hpa_profile_hash() if hash_ok else "wrong-hash"
conn.execute("INSERT OR REPLACE INTO meta (key, value) VALUES ('mode_profile_hash', ?)", (h,))
conn.commit()
conn.close()
def _base_rows():
# One trivial row per chunk so all four count as "present", plus the (1,0) hop 15->10
# that carries the only non-seam cost on the 0,0 -> 1,0 -> 1,1 route.
rows = [(cx, cy, 0, 0, 1, 99.0) for (cx, cy) in _CHUNKS]
rows.append((1, 0, 0, 15, 10, 5.0)) # left entrance -> bottom entrance, foot, cost 5
return rows
def test_hpa_abstract_search_finds_path_through_chunks(tmp_path):
db = str(tmp_path / "tiles.db")
_make_tile_db(db, _base_rows())
conn = sqlite3.connect(db)
# START -> right entrance of (0,0); top entrance of (1,1) -> GOAL (both free pseudo-edges).
start_edges = {(0, 0, 5, 0): 0.0}
goal_edges = {(1, 1, 0, 0): 0.0}
seq, cost = astar._hpa_abstract_search(conn, _CHUNKS, [0], start_edges, goal_edges)
conn.close()
assert seq is not None
chunks_visited = [s[:2] for s in seq]
assert chunks_visited[0] == (0, 0) and chunks_visited[-1] == (1, 1)
# 0,0 -> (seam) 1,0 -> (intra 5s) -> (seam) 1,1 ; only the (1,0) intra row is non-free.
assert cost == pytest.approx(5.0)
assert (1, 0) in chunks_visited
def test_hpa_falls_back_on_missing_chunk(tmp_path):
db = str(tmp_path / "tiles.db")
rows = [r for r in _base_rows() if not (r[0] == 1 and r[1] == 1)] # drop chunk (1,1)
_make_tile_db(db, rows)
idx, path, cost, reason = astar.astar_hpa_multimode(
db, {"bounds": _BOUNDS}, 0.002, 0.002, hb.CHUNK_DEG + 0.0005, hb.CHUNK_DEG + 0.0005,
np.array([0]), np.array([0]), "pragmatic", None)
assert idx == -1 and reason == "missing_chunk"
assert path.shape == (0, 3) and not np.isfinite(cost)
def test_hpa_falls_back_on_stale_profile_hash(tmp_path):
db = str(tmp_path / "tiles.db")
_make_tile_db(db, _base_rows(), hash_ok=False)
idx, path, cost, reason = astar.astar_hpa_multimode(
db, {"bounds": _BOUNDS}, 0.002, 0.002, hb.CHUNK_DEG + 0.0005, hb.CHUNK_DEG + 0.0005,
np.array([0]), np.array([0]), "pragmatic", None)
assert idx == -1 and reason == "stale_profile"
# ── dispatcher gating (end-to-end through _route_auto, stubbed readers) ───────
def _p_meta(rows, cols, cell_m=100.0):
import math
dlat = cell_m / 111000.0
dlon = cell_m / (111000.0 * math.cos(math.radians(40.0)))
return {"bounds": (40.0, 40.0 + rows * dlat, -111.0, -111.0 + cols * dlon),
"pixel_size_lat": -dlat, "pixel_size_lon": dlon,
"origin_lat": 40.0 + rows * dlat, "origin_lon": -111.0,
"cell_size_m": cell_m, "shape": (rows, cols)}
class _Grid:
def __init__(self, a): self._a = a
def get_friction_grid(self, **k): return self._a
def get_barrier_grid(self, **k): return self._a
def get_trails_grid(self, **k): return self._a
def get_wilderness_grid(self, **k): return self._a
def close(self): pass
class _StubDem:
def __init__(self, e, m): self._e, self._m = e, m
def get_elevation_grid(self, **k): return self._e, self._m
def latlon_to_pixel(self, lat, lon, m): return _ll2px(lat, lon, m)
def pixel_to_latlon(self, r, c, m): return _px2ll(r, c, m)
def close(self): pass
def _stub_router(monkeypatch):
n = 20
meta = _p_meta(n, n)
r = OffrouteRouter()
r.dem_reader = _StubDem(np.full((n, n), 1000.0), meta)
fr = np.full((n, n), 30, dtype=np.uint8)
r.friction_reader = _Grid(fr)
r.barrier_reader = _Grid(np.zeros((n, n), np.uint8))
r.trail_reader = _Grid(np.zeros((n, n), np.uint8))
r.wilderness_reader = _Grid(np.zeros((n, n), np.uint8))
monkeypatch.setattr(router_mod, "get_mvum_access_grid",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("no mvum")))
monkeypatch.setattr(p_trans, "load_parking_index",
lambda *a, **k: type("I", (), {"query_parking_near_line": lambda s, c, buffer_m=2000: []})())
monkeypatch.setattr(p_trans, "load_trailheads",
lambda *a, **k: type("I", (), {"query_trailheads_near_line": lambda s, c, buffer_m=2000: []})())
monkeypatch.setattr(p_trans, "get_surface_change_candidates", lambda *a, **k: [])
monkeypatch.setattr(OffrouteRouter, "_spatial_eligible_modes",
lambda self, lat, lon, cache: frozenset({"foot"}))
s_lat, s_lon = _px2ll(5, 5, meta)
e_lat, e_lon = _px2ll(15, 15, meta)
return r, (s_lat, s_lon, e_lat, e_lon)
def test_hpa_dispatcher_uses_hpa_when_tile_db_set(tmp_path, monkeypatch, caplog):
db = str(tmp_path / "tiles.db")
_make_tile_db(db, _base_rows()) # a real file so os.path.exists passes
monkeypatch.setattr(router_mod, "HPA_TILE_DB", db)
calls = []
def spy(*a, **k):
calls.append(True)
return 0, np.array([[5, 5, 0], [6, 6, 0]], dtype=np.int64), 123.0, None
monkeypatch.setattr(router_mod, "astar_hpa_multimode", spy)
r, (s_lat, s_lon, e_lat, e_lon) = _stub_router(monkeypatch)
with caplog.at_level(logging.INFO, logger="navi_offroute.router"):
out = r._route_auto(s_lat, s_lon, e_lat, e_lon, "pragmatic")
assert out["status"] == "ok"
assert calls == [True] # HPA was attempted + taken
assert "auto: HPA*" in caplog.text
# strict boundary mode -> ineligible -> HPA not attempted, logged fallback, unified used.
calls.clear()
caplog.clear()
r2, _ = _stub_router(monkeypatch)
with caplog.at_level(logging.INFO, logger="navi_offroute.router"):
out2 = r2._route_auto(s_lat, s_lon, e_lat, e_lon, "strict")
assert out2["status"] == "ok"
assert calls == [] # spy never called
assert "HPA fallback reason=boundary_mode" in caplog.text