mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-08-26 17:31:37 +00:00
Co-authored-by: mj <mj@k7zvx.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
157 lines
6.8 KiB
Python
157 lines
6.8 KiB
Python
"""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
|