mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-08-26 17:31:37 +00:00
MVUM Layer 3a: multi-modal Auto via MVUM trailhead transitions (#26)
Auto now also considers "drive in to a trailhead, switch vehicles, continue
on foot/2w/4w" trips and picks one when it is meaningfully faster than the
single-mode winner. Implicit — no new chip; Auto just returns the fastest plan.
Backend:
- mvum_transitions.py: TrailheadIndex (STRtree over trail_entry_points), built
once per process via load_trailheads() (mirrors the MVUMSpatialIndex singleton).
query_trailheads_near_line(coords, buffer_m=2000) with a precise distance filter.
- router.py: _route_auto, after the single-mode probe and only when the winner is
ok AND total_distance_km >= MIN_HYBRID_DISTANCE_KM (8.0), tries hybrids. For each
candidate trailhead near the winning polyline (closest first, capped at 20) and
each (drive, offroad) pair in HYBRID_PAIRS, it routes both legs (annotate_mvum
off) and sums leg times with NO transition cost. A hybrid wins only if it beats
the single-mode winner by >= HYBRID_MIN_TIME_SAVINGS_MIN (15 min); trivial
offroad detours (< HYBRID_MIN_OFFROAD_KM = 0.8 km) are skipped. The winner is
combined into a new "multi" scenario: leg1 features + a kind=transition marker
+ leg2 features; summary carries total_*, per-leg legs[], summed MVUM counts;
selected_mode="hybrid". Each leg is annotated separately.
- app.py / offroute_route.py: load + inject the trailhead index singleton.
Frontend (additive — no api.js signature change):
- DirectionsPanel: per-leg breakdown row for hybrid/multi ("Drive X mi (Ymin)
-> 4W X mi (Zmin) - total Wmin", lucide Repeat between legs); existing Auto
badge still shows.
- MapView: network polylines colored by network_mode (vehicle/auto blue, 4w
orange, 2w green, foot red); transition points rendered as a circle marker with
the lucide Repeat icon + "Switch to <mode>" tooltip; bounds fit skips Points.
Tests: test_mvum_transitions.py — index load, near-line close-only query, short
trip stays single-mode, big-savings hybrid wins, trivial-detour + below-threshold
+ no-trailheads all fall back. 7 new tests; full offroute suite 71 passed.
Note: the DB column is trail_entry_points.highway_class; surfaced as record
"road_class" per the spec.
Co-authored-by: Matt <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0c43c872bb
commit
5ba9527c02
7 changed files with 561 additions and 7 deletions
|
|
@ -11,6 +11,7 @@ from shared.git_sha import git_short_sha
|
|||
|
||||
from . import offroute_route, admin
|
||||
from .mvum import MVUMSpatialIndex
|
||||
from .mvum_transitions import load_trailheads
|
||||
|
||||
# Process-wide singleton: build the MVUM spatial index once per process (per gunicorn
|
||||
# worker in prod; once across create_app() calls in tests), not once per app instance.
|
||||
|
|
@ -54,6 +55,13 @@ def create_app():
|
|||
app.logger.warning("MVUM spatial index failed to load: %s", e)
|
||||
app.config['MVUM_SPATIAL_INDEX'] = None
|
||||
|
||||
# Layer 3a: trailhead transition index (process-wide singleton, logs its own line).
|
||||
try:
|
||||
app.config['MVUM_TRAILHEAD_INDEX'] = load_trailheads()
|
||||
except Exception as e:
|
||||
app.logger.warning("MVUM trailhead index failed to load: %s", e)
|
||||
app.config['MVUM_TRAILHEAD_INDEX'] = None
|
||||
|
||||
app.register_blueprint(offroute_route.bp)
|
||||
app.register_blueprint(admin.bp)
|
||||
return app
|
||||
|
|
|
|||
98
backend/services/navi_offroute/mvum_transitions.py
Normal file
98
backend/services/navi_offroute/mvum_transitions.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"""
|
||||
MVUM Layer 3a: trailhead transition index for multi-modal Auto routing.
|
||||
|
||||
Loads the ``trail_entry_points`` table from navi.db into a shapely STRtree of
|
||||
trailhead points and supports finding the trailheads near a route polyline. This
|
||||
is pure spatial lookup — no routing logic — mirroring the MVUMSpatialIndex
|
||||
(Layer 0) pattern: built once per process as a singleton via load_trailheads().
|
||||
|
||||
The router (``_route_auto``) uses these points as drive->offroad transition
|
||||
candidates: a hybrid "drive to a trailhead, switch vehicles, continue offroad"
|
||||
plan is considered when it beats the single-mode winner by a comfortable margin.
|
||||
"""
|
||||
import logging
|
||||
import sqlite3
|
||||
import time as _time
|
||||
from pathlib import Path
|
||||
|
||||
from shapely.geometry import Point, LineString
|
||||
from shapely.strtree import STRtree
|
||||
|
||||
from .mvum import navi_db_path, _buffer_degrees_for_meters
|
||||
|
||||
logger = logging.getLogger("navi_offroute.mvum_transitions")
|
||||
|
||||
|
||||
class TrailheadIndex:
|
||||
"""In-memory STRtree over ``trail_entry_points`` (trailhead/road access points).
|
||||
|
||||
Keeps the STRtree of point geometries plus a parallel ``records`` list of
|
||||
``{lat, lon, name, road_class}`` dicts aligned with the tree's geometries.
|
||||
(The DB column is ``highway_class``; it is surfaced here as ``road_class`` for
|
||||
consistency with the entry-point records the router already emits.)
|
||||
"""
|
||||
|
||||
def __init__(self, db_path=None):
|
||||
t0 = _time.perf_counter()
|
||||
self.db_path = Path(db_path) if db_path else navi_db_path()
|
||||
self.records = [] # aligned with self._points
|
||||
self._points = []
|
||||
|
||||
conn = sqlite3.connect(f"file:{self.db_path}?mode=ro", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"SELECT lat, lon, name, highway_class FROM trail_entry_points "
|
||||
"WHERE lat IS NOT NULL AND lon IS NOT NULL"
|
||||
)
|
||||
for row in cur:
|
||||
lat = float(row["lat"])
|
||||
lon = float(row["lon"])
|
||||
self.records.append({
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"name": row["name"] or "",
|
||||
"road_class": row["highway_class"] or "",
|
||||
})
|
||||
self._points.append(Point(lon, lat))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
self._tree = STRtree(self._points) if self._points else None
|
||||
self.count = len(self.records)
|
||||
self.build_time_seconds = _time.perf_counter() - t0
|
||||
logger.info(
|
||||
"Trailhead index loaded: %d entry points in %.2f seconds",
|
||||
self.count, self.build_time_seconds,
|
||||
)
|
||||
|
||||
def query_trailheads_near_line(self, coords, buffer_m=2000):
|
||||
"""Trailhead records within ~``buffer_m`` of a (lat, lon) polyline.
|
||||
|
||||
Coarse STRtree bbox prefilter followed by a precise degree-distance check
|
||||
so only points genuinely close to the line are returned (the bbox alone
|
||||
would admit corner points up to ~1.4x buffer away).
|
||||
"""
|
||||
if not coords or self._tree is None:
|
||||
return []
|
||||
pts = [(lon, lat) for (lat, lon) in coords]
|
||||
geom = LineString(pts) if len(pts) >= 2 else Point(pts[0])
|
||||
avg_lat = sum(lat for (lat, lon) in coords) / len(coords)
|
||||
buffer_deg = _buffer_degrees_for_meters(buffer_m, avg_lat)
|
||||
out = []
|
||||
for i in self._tree.query(geom.buffer(buffer_deg)):
|
||||
if geom.distance(self._points[i]) <= buffer_deg:
|
||||
out.append(self.records[i])
|
||||
return out
|
||||
|
||||
|
||||
# Process-wide singleton, mirroring app.py's _MVUM_INDEX handling.
|
||||
_TRAILHEAD_INDEX = None
|
||||
|
||||
|
||||
def load_trailheads(db_path=None):
|
||||
"""Return the process-wide TrailheadIndex singleton, building it on first call."""
|
||||
global _TRAILHEAD_INDEX
|
||||
if _TRAILHEAD_INDEX is None:
|
||||
_TRAILHEAD_INDEX = TrailheadIndex(db_path)
|
||||
return _TRAILHEAD_INDEX
|
||||
|
|
@ -73,6 +73,8 @@ def api_offroute():
|
|||
router = OffrouteRouter()
|
||||
# Inject the Layer-0 MVUM spatial index singleton for Layer-1 annotation.
|
||||
router.spatial_index = current_app.config.get('MVUM_SPATIAL_INDEX')
|
||||
# Inject the Layer-3a trailhead index for multi-modal Auto transitions.
|
||||
router.trailhead_index = current_app.config.get('MVUM_TRAILHEAD_INDEX')
|
||||
try:
|
||||
result = router.route(
|
||||
start_lat=start_lat, start_lon=start_lon,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import psutil
|
|||
import requests
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from shapely.geometry import LineString
|
||||
from shapely.geometry import LineString, Point
|
||||
from .astar import astar_multigoal, inflate_cost_multiplier
|
||||
|
||||
from shared.dem import DEMReader, dem_path
|
||||
|
|
@ -98,6 +98,18 @@ MODE_TO_COSTING = {
|
|||
# demanding terrain) and uses the first that yields a usable route.
|
||||
AUTO_MODE_PRIORITY = ["vehicle", "4w", "2w", "foot"]
|
||||
|
||||
# MVUM Layer 3a: implicit multi-modal Auto. On long trips, "drive in to a trailhead,
|
||||
# switch vehicles, continue offroad" can beat the single-mode winner; Auto picks that
|
||||
# hybrid plan when it does. Leg times are summed with NO transition penalty, and the
|
||||
# minimums below keep the suggestions sensible (no short trips / trivial detours).
|
||||
MIN_HYBRID_DISTANCE_KM = 8.0 # ~5 mi: shorter single-mode wins stay as-is
|
||||
HYBRID_MIN_TIME_SAVINGS_MIN = 15.0 # a hybrid must beat the winner by at least this
|
||||
HYBRID_MIN_OFFROAD_KM = 0.8 # ~0.5 mi: reject trivial offroad detours
|
||||
HYBRID_MAX_TRAILHEADS = 20 # cap candidates (closest to the route first)
|
||||
HYBRID_TRAILHEAD_BUFFER_M = 2000 # candidate trailheads within 2 km of the route
|
||||
# (drive_mode, offroad_mode) transition pairs, tried at each candidate trailhead.
|
||||
HYBRID_PAIRS = [("vehicle", "4w"), ("vehicle", "2w"), ("vehicle", "foot"), ("4w", "foot")]
|
||||
|
||||
# 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"})
|
||||
|
|
@ -536,6 +548,7 @@ class OffrouteRouter:
|
|||
self.spatial_index = None # MVUMSpatialIndex (Layer 0), injected by the handler
|
||||
self.mvum_on_date = None # optional datetime for seasonal MVUM checks
|
||||
self._exclude_polygons = None # MVUM Layer 2c, set per route() call
|
||||
self.trailhead_index = None # TrailheadIndex (Layer 3a), injected by the handler
|
||||
|
||||
def _init_readers(self):
|
||||
"""Lazy init readers."""
|
||||
|
|
@ -863,6 +876,14 @@ class OffrouteRouter:
|
|||
last_error = result
|
||||
|
||||
if best_result is not None:
|
||||
# MVUM Layer 3a: a "drive to a trailhead, switch, continue offroad" plan may
|
||||
# beat the single-mode winner on long trips. If so, return it instead.
|
||||
hybrid = self._try_hybrid_auto(
|
||||
start_lat, start_lon, end_lat, end_lon, boundary_mode,
|
||||
best_result, best_minutes, intersection)
|
||||
if hybrid is not None:
|
||||
hybrid["selected_mode_set"] = mode_set
|
||||
return hybrid
|
||||
best_result["selected_mode_set"] = mode_set
|
||||
self._annotate_network_segments(best_result, best_result["selected_mode"])
|
||||
return best_result
|
||||
|
|
@ -876,6 +897,176 @@ class OffrouteRouter:
|
|||
"selected_mode_set": mode_set,
|
||||
}
|
||||
|
||||
def _route_coords_latlon(self, result):
|
||||
"""Flatten a route response's polyline to [(lat, lon), ...]. Prefers the
|
||||
single "combined" full-path feature; otherwise concatenates LineStrings."""
|
||||
feats = (result.get("route") or {}).get("features", [])
|
||||
for f in feats:
|
||||
if (f.get("properties") or {}).get("segment_type") == "combined":
|
||||
cs = (f.get("geometry") or {}).get("coordinates") or []
|
||||
return [(c[1], c[0]) for c in cs]
|
||||
out = []
|
||||
for f in feats:
|
||||
if (f.get("geometry") or {}).get("type") != "LineString":
|
||||
continue
|
||||
out.extend((c[1], c[0]) for c in (f["geometry"].get("coordinates") or []))
|
||||
return out
|
||||
|
||||
def _try_hybrid_auto(self, start_lat, start_lon, end_lat, end_lon,
|
||||
boundary_mode, best_result, best_minutes, intersection):
|
||||
"""MVUM Layer 3a: consider drive->trailhead->offroad hybrid plans.
|
||||
|
||||
Returns a combined "multi" response if some trailhead transition beats the
|
||||
single-mode winner by HYBRID_MIN_TIME_SAVINGS_MIN, else None (caller keeps
|
||||
the single-mode winner). Leg times are summed with no transition penalty.
|
||||
"""
|
||||
idx = getattr(self, "trailhead_index", None)
|
||||
if idx is None:
|
||||
return None
|
||||
best_summary = best_result.get("summary") or {}
|
||||
if best_summary.get("total_distance_km", 0.0) < MIN_HYBRID_DISTANCE_KM:
|
||||
return None
|
||||
|
||||
coords = self._route_coords_latlon(best_result)
|
||||
if len(coords) < 2:
|
||||
return None
|
||||
candidates = idx.query_trailheads_near_line(
|
||||
coords, buffer_m=HYBRID_TRAILHEAD_BUFFER_M)
|
||||
if not candidates:
|
||||
return None
|
||||
# Closest-to-route first, then cap.
|
||||
line = LineString([(lon, lat) for (lat, lon) in coords])
|
||||
candidates.sort(key=lambda th: line.distance(Point(th["lon"], th["lat"])))
|
||||
candidates = candidates[:HYBRID_MAX_TRAILHEADS]
|
||||
|
||||
# Per trailhead, leg1 depends only on drive_mode and leg2 only on offroad_mode,
|
||||
# so route each distinct mode once and recombine across pairs.
|
||||
drive_modes = sorted({d for d, _ in HYBRID_PAIRS})
|
||||
offroad_modes = sorted({o for _, o in HYBRID_PAIRS})
|
||||
|
||||
threshold = best_minutes - HYBRID_MIN_TIME_SAVINGS_MIN
|
||||
winner = None
|
||||
winner_minutes = None
|
||||
for th in candidates:
|
||||
leg1_by_mode = {}
|
||||
for dm in drive_modes:
|
||||
r = self.route(start_lat, start_lon, th["lat"], th["lon"],
|
||||
mode=dm, boundary_mode=boundary_mode, annotate_mvum=False)
|
||||
if r.get("status") == "ok":
|
||||
leg1_by_mode[dm] = r
|
||||
leg2_by_mode = {}
|
||||
for om in offroad_modes:
|
||||
r = self.route(th["lat"], th["lon"], end_lat, end_lon,
|
||||
mode=om, boundary_mode=boundary_mode, annotate_mvum=False)
|
||||
if r.get("status") != "ok":
|
||||
continue
|
||||
if (r.get("summary") or {}).get("total_distance_km", 0.0) < HYBRID_MIN_OFFROAD_KM:
|
||||
continue # no trivial offroad detours
|
||||
leg2_by_mode[om] = r
|
||||
|
||||
for dm, om in HYBRID_PAIRS:
|
||||
leg1 = leg1_by_mode.get(dm)
|
||||
leg2 = leg2_by_mode.get(om)
|
||||
if leg1 is None or leg2 is None:
|
||||
continue
|
||||
total = ((leg1.get("summary") or {}).get("total_effort_minutes", float("inf"))
|
||||
+ (leg2.get("summary") or {}).get("total_effort_minutes", float("inf")))
|
||||
if total < threshold and (winner_minutes is None or total < winner_minutes):
|
||||
winner_minutes = total
|
||||
winner = (leg1, leg2, dm, om, th)
|
||||
|
||||
if winner is None:
|
||||
return None
|
||||
leg1, leg2, drive_mode, offroad_mode, th = winner
|
||||
return self._build_hybrid_response(leg1, leg2, drive_mode, offroad_mode, th)
|
||||
|
||||
def _build_hybrid_response(self, leg1, leg2, drive_mode, offroad_mode, trailhead):
|
||||
"""Combine two route legs into one "multi" scenario response with a transition
|
||||
marker at the trailhead. Each leg is annotated separately (probing ran with
|
||||
annotate_mvum=False); summary fields are summed across legs."""
|
||||
self._annotate_network_segments(leg1, drive_mode)
|
||||
self._annotate_network_segments(leg2, offroad_mode)
|
||||
|
||||
def leg_features(leg, leg_no, mode):
|
||||
out = []
|
||||
for f in (leg.get("route") or {}).get("features", []):
|
||||
props = dict(f.get("properties") or {})
|
||||
if props.get("segment_type") == "combined":
|
||||
continue # drop per-leg full-path lines; we keep network/wilderness
|
||||
# network_mode drives the map's per-mode polyline color; wilderness=foot
|
||||
if "network_mode" not in props:
|
||||
props["network_mode"] = (
|
||||
"foot" if props.get("segment_type") == "wilderness" else mode)
|
||||
props["leg"] = leg_no
|
||||
out.append({"type": "Feature", "properties": props,
|
||||
"geometry": f.get("geometry")})
|
||||
return out
|
||||
|
||||
features = leg_features(leg1, 1, drive_mode)
|
||||
features.append({
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"segment_type": "transition",
|
||||
"kind": "transition",
|
||||
"lat": trailhead["lat"],
|
||||
"lon": trailhead["lon"],
|
||||
"name": trailhead.get("name", ""),
|
||||
"from_mode": drive_mode,
|
||||
"to_mode": offroad_mode,
|
||||
},
|
||||
"geometry": {"type": "Point",
|
||||
"coordinates": [trailhead["lon"], trailhead["lat"]]},
|
||||
})
|
||||
features.extend(leg_features(leg2, 2, offroad_mode))
|
||||
|
||||
s1 = leg1.get("summary") or {}
|
||||
s2 = leg2.get("summary") or {}
|
||||
|
||||
def leg_summary(s, mode):
|
||||
return {
|
||||
"mode": mode,
|
||||
"distance_km": float(s.get("total_distance_km", 0.0)),
|
||||
"minutes": float(s.get("total_effort_minutes", 0.0)),
|
||||
"segments_summary": {
|
||||
"scenario": s.get("scenario"),
|
||||
"network_km": float(s.get("network_distance_km", 0.0)),
|
||||
"wilderness_km": float(s.get("wilderness_distance_km", 0.0)),
|
||||
},
|
||||
}
|
||||
|
||||
total_distance = (float(s1.get("total_distance_km", 0.0))
|
||||
+ float(s2.get("total_distance_km", 0.0)))
|
||||
total_minutes = (float(s1.get("total_effort_minutes", 0.0))
|
||||
+ float(s2.get("total_effort_minutes", 0.0)))
|
||||
summary = {
|
||||
"total_distance_km": total_distance,
|
||||
"total_effort_minutes": total_minutes,
|
||||
"wilderness_minutes": (float(s1.get("wilderness_effort_minutes", 0.0))
|
||||
+ float(s2.get("wilderness_effort_minutes", 0.0))),
|
||||
"network_minutes": (float(s1.get("network_duration_minutes", 0.0))
|
||||
+ float(s2.get("network_duration_minutes", 0.0))),
|
||||
"mvum_closed_crossings": (int(s1.get("mvum_closed_crossings", 0) or 0)
|
||||
+ int(s2.get("mvum_closed_crossings", 0) or 0)),
|
||||
"mvum_segments_annotated": (int(s1.get("mvum_segments_annotated", 0) or 0)
|
||||
+ int(s2.get("mvum_segments_annotated", 0) or 0)),
|
||||
"scenario": "multi",
|
||||
"network_mode": offroad_mode,
|
||||
"wilderness_mode": "foot",
|
||||
"legs": [leg_summary(s1, drive_mode), leg_summary(s2, offroad_mode)],
|
||||
"transition": {
|
||||
"lat": trailhead["lat"], "lon": trailhead["lon"],
|
||||
"name": trailhead.get("name", ""),
|
||||
"from_mode": drive_mode, "to_mode": offroad_mode,
|
||||
},
|
||||
}
|
||||
return {
|
||||
"status": "ok",
|
||||
"route": {"type": "FeatureCollection", "features": features},
|
||||
"summary": summary,
|
||||
"selected_mode": "hybrid",
|
||||
"scenario": "multi",
|
||||
}
|
||||
|
||||
def _route_D_network_only(
|
||||
self,
|
||||
start_lat: float, start_lon: float,
|
||||
|
|
|
|||
201
backend/services/navi_offroute/tests/test_mvum_transitions.py
Normal file
201
backend/services/navi_offroute/tests/test_mvum_transitions.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
"""MVUM Layer 3a tests: trailhead transition index + multi-modal Auto hybrids.
|
||||
|
||||
The index tests build a TrailheadIndex from a synthetic trail_entry_points table.
|
||||
The hybrid tests drive OffrouteRouter._try_hybrid_auto on a bare instance with a
|
||||
stubbed self.route, so no Valhalla/DEM dependencies are exercised.
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from services.navi_offroute.mvum_transitions import TrailheadIndex
|
||||
from services.navi_offroute.router import OffrouteRouter
|
||||
|
||||
|
||||
def _trailhead_db(tmp_path, points):
|
||||
"""points: list of (lat, lon, highway_class, name)."""
|
||||
db = tmp_path / "navi.db"
|
||||
conn = sqlite3.connect(db)
|
||||
conn.execute(
|
||||
"CREATE TABLE trail_entry_points "
|
||||
"(id INTEGER PRIMARY KEY, lat REAL, lon REAL, highway_class TEXT, name TEXT)"
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO trail_entry_points (lat, lon, highway_class, name) VALUES (?,?,?,?)",
|
||||
points,
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return db
|
||||
|
||||
|
||||
# ── index ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_trailhead_index_loads(tmp_path):
|
||||
db = _trailhead_db(tmp_path, [
|
||||
(44.00, -114.00, "track", "Trailhead A"),
|
||||
(44.01, -114.02, "residential", "Road B"),
|
||||
])
|
||||
idx = TrailheadIndex(db_path=db)
|
||||
assert idx.count == 2
|
||||
assert len(idx.records) == len(idx._points) == 2
|
||||
rec = idx.records[0]
|
||||
assert rec["name"] == "Trailhead A"
|
||||
assert rec["road_class"] == "track" # highway_class surfaced as road_class
|
||||
assert rec["lat"] == 44.00 and rec["lon"] == -114.00
|
||||
|
||||
|
||||
def test_query_trailheads_near_line_returns_close_only(tmp_path):
|
||||
# One point sits right on the line; one is ~30 km away (well outside 2 km).
|
||||
db = _trailhead_db(tmp_path, [
|
||||
(44.000, -114.000, "track", "On Line"),
|
||||
(44.300, -114.000, "track", "Far Away"),
|
||||
])
|
||||
idx = TrailheadIndex(db_path=db)
|
||||
line = [(44.000, -114.010), (44.000, 113.990 * -1)] # ~horizontal segment at lat 44
|
||||
near = idx.query_trailheads_near_line(line, buffer_m=2000)
|
||||
names = {r["name"] for r in near}
|
||||
assert "On Line" in names
|
||||
assert "Far Away" not in names
|
||||
|
||||
|
||||
# ── hybrid selection (stubbed self.route) ──────────────────────────────────
|
||||
|
||||
def _ok_leg(distance_km, minutes, scenario="D"):
|
||||
return {
|
||||
"status": "ok",
|
||||
"route": {"type": "FeatureCollection", "features": [
|
||||
{"type": "Feature",
|
||||
"properties": {"segment_type": "network", "network_mode": "x"},
|
||||
"geometry": {"type": "LineString",
|
||||
"coordinates": [[-114.0, 44.0], [-114.1, 44.1]]}},
|
||||
]},
|
||||
"summary": {
|
||||
"total_distance_km": distance_km,
|
||||
"total_effort_minutes": minutes,
|
||||
"network_distance_km": distance_km,
|
||||
"network_duration_minutes": minutes,
|
||||
"wilderness_distance_km": 0.0,
|
||||
"wilderness_effort_minutes": 0.0,
|
||||
"scenario": scenario,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class _FakeTrailheads:
|
||||
def __init__(self, records):
|
||||
self._records = records
|
||||
|
||||
def query_trailheads_near_line(self, coords, buffer_m=2000):
|
||||
return list(self._records)
|
||||
|
||||
|
||||
def _bare_router(trailheads=None):
|
||||
r = object.__new__(OffrouteRouter)
|
||||
r.spatial_index = None
|
||||
r.trailhead_index = trailheads
|
||||
return r
|
||||
|
||||
|
||||
def _winning_single_mode(distance_km, minutes):
|
||||
"""A single-mode best_result with a combined polyline of the given distance."""
|
||||
res = _ok_leg(distance_km, minutes)
|
||||
res["route"]["features"].append({
|
||||
"type": "Feature",
|
||||
"properties": {"segment_type": "combined"},
|
||||
"geometry": {"type": "LineString",
|
||||
"coordinates": [[-114.0, 44.0], [-114.5, 44.0]]},
|
||||
})
|
||||
res["selected_mode"] = "vehicle"
|
||||
return res
|
||||
|
||||
|
||||
def test_hybrid_meets_mitigations(monkeypatch):
|
||||
# Short trip (< MIN_HYBRID_DISTANCE_KM) -> never goes hybrid.
|
||||
th = _FakeTrailheads([{"lat": 44.0, "lon": -114.25, "name": "TH", "road_class": "track"}])
|
||||
r = _bare_router(th)
|
||||
monkeypatch.setattr(OffrouteRouter, "route",
|
||||
lambda self, *a, **k: _ok_leg(1.0, 5.0))
|
||||
best = _winning_single_mode(distance_km=5.0, minutes=60.0) # 5 km < 8 km
|
||||
out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic",
|
||||
best, 60.0, frozenset({"vehicle", "4w", "2w", "foot"}))
|
||||
assert out is None
|
||||
|
||||
|
||||
def test_hybrid_wins_with_big_savings(monkeypatch):
|
||||
th = _FakeTrailheads([{"lat": 44.0, "lon": -114.25, "name": "Sawtooth TH",
|
||||
"road_class": "track"}])
|
||||
r = _bare_router(th)
|
||||
|
||||
# Drive legs are fast; offroad legs are short-but-meaningful and fast. Any leg
|
||||
# combo sums to ~50 min vs the 120 min single-mode winner -> saves > 15 min.
|
||||
def fake_route(self, s_lat, s_lon, e_lat, e_lon, mode="foot",
|
||||
boundary_mode="pragmatic", annotate_mvum=True, **k):
|
||||
if mode == "vehicle":
|
||||
return _ok_leg(12.0, 20.0)
|
||||
return _ok_leg(4.0, 30.0) # 4w/2w/foot offroad legs (>= 0.8 km)
|
||||
monkeypatch.setattr(OffrouteRouter, "route", fake_route)
|
||||
|
||||
best = _winning_single_mode(distance_km=20.0, minutes=120.0)
|
||||
out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic",
|
||||
best, 120.0, frozenset({"vehicle", "4w", "2w", "foot"}))
|
||||
assert out is not None
|
||||
assert out["selected_mode"] == "hybrid"
|
||||
assert out["summary"]["scenario"] == "multi"
|
||||
assert len(out["summary"]["legs"]) == 2
|
||||
assert out["summary"]["total_effort_minutes"] == pytest.approx(50.0)
|
||||
# one transition marker present in the combined feature collection
|
||||
kinds = [f["properties"].get("kind") for f in out["route"]["features"]]
|
||||
assert kinds.count("transition") == 1
|
||||
trans = next(f for f in out["route"]["features"]
|
||||
if f["properties"].get("kind") == "transition")
|
||||
assert trans["properties"]["name"] == "Sawtooth TH"
|
||||
assert trans["geometry"]["type"] == "Point"
|
||||
|
||||
|
||||
def test_hybrid_skips_trivial_offroad_detour(monkeypatch):
|
||||
th = _FakeTrailheads([{"lat": 44.0, "lon": -114.25, "name": "TH", "road_class": "track"}])
|
||||
r = _bare_router(th)
|
||||
|
||||
# Offroad legs are below HYBRID_MIN_OFFROAD_KM (0.8 km) -> rejected, so even
|
||||
# though the time math would otherwise win, no hybrid is produced.
|
||||
def fake_route(self, s_lat, s_lon, e_lat, e_lon, mode="foot",
|
||||
boundary_mode="pragmatic", annotate_mvum=True, **k):
|
||||
if mode == "vehicle":
|
||||
return _ok_leg(12.0, 20.0)
|
||||
return _ok_leg(0.3, 5.0) # < 0.8 km offroad
|
||||
monkeypatch.setattr(OffrouteRouter, "route", fake_route)
|
||||
|
||||
best = _winning_single_mode(distance_km=20.0, minutes=120.0)
|
||||
out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic",
|
||||
best, 120.0, frozenset({"vehicle", "4w", "2w", "foot"}))
|
||||
assert out is None
|
||||
|
||||
|
||||
def test_no_trailheads_falls_back_to_single_mode(monkeypatch):
|
||||
r = _bare_router(_FakeTrailheads([])) # no candidates near the line
|
||||
monkeypatch.setattr(OffrouteRouter, "route",
|
||||
lambda self, *a, **k: _ok_leg(5.0, 10.0))
|
||||
best = _winning_single_mode(distance_km=20.0, minutes=120.0)
|
||||
out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic",
|
||||
best, 120.0, frozenset({"vehicle", "4w", "2w", "foot"}))
|
||||
assert out is None
|
||||
|
||||
|
||||
def test_hybrid_not_taken_when_savings_below_threshold(monkeypatch):
|
||||
# Hybrid total (40 min) is faster than the winner (50 min) but only by 10 min
|
||||
# (< HYBRID_MIN_TIME_SAVINGS_MIN = 15) -> single-mode winner is kept.
|
||||
th = _FakeTrailheads([{"lat": 44.0, "lon": -114.25, "name": "TH", "road_class": "track"}])
|
||||
r = _bare_router(th)
|
||||
|
||||
def fake_route(self, s_lat, s_lon, e_lat, e_lon, mode="foot",
|
||||
boundary_mode="pragmatic", annotate_mvum=True, **k):
|
||||
if mode == "vehicle":
|
||||
return _ok_leg(12.0, 20.0)
|
||||
return _ok_leg(4.0, 20.0)
|
||||
monkeypatch.setattr(OffrouteRouter, "route", fake_route)
|
||||
|
||||
best = _winning_single_mode(distance_km=20.0, minutes=50.0)
|
||||
out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic",
|
||||
best, 50.0, frozenset({"vehicle", "4w", "2w", "foot"}))
|
||||
assert out is None
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useMemo } from "react"
|
||||
import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2, GripVertical } from "lucide-react"
|
||||
import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2, GripVertical, Repeat } from "lucide-react"
|
||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
|
|
@ -16,7 +16,9 @@ const TRAVEL_MODES = [
|
|||
]
|
||||
|
||||
// Maps the backend's selected_mode to the chip label shown in the "Auto chose X" badge.
|
||||
const SELECTED_MODE_LABEL = { vehicle: "Drive", "4w": "4W", "2w": "2W", foot: "Foot" }
|
||||
const SELECTED_MODE_LABEL = { vehicle: "Drive", "4w": "4W", "2w": "2W", foot: "Foot", hybrid: "Multi-modal", multi: "Multi-modal" }
|
||||
|
||||
const KM_TO_MI = 0.621371
|
||||
|
||||
const BOUNDARY_MODES = [
|
||||
{ id: "strict", label: "Strict", Icon: Shield, title: "Avoid barriers" },
|
||||
|
|
@ -331,6 +333,23 @@ export default function DirectionsPanel({ onClose }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* MVUM Layer 3a: per-leg breakdown for a multi-modal (transition) trip */}
|
||||
{(routeResult?.selected_mode === "hybrid" || routeResult?.selected_mode === "multi")
|
||||
&& Array.isArray(routeResult?.summary?.legs) && (
|
||||
<div
|
||||
className="flex items-center justify-center gap-1.5 py-1.5 text-xs rounded-lg"
|
||||
style={{ background: "var(--accent-muted)", color: "var(--accent)" }}
|
||||
>
|
||||
{routeResult.summary.legs.map((leg, i) => (
|
||||
<span key={i} className="flex items-center gap-1.5">
|
||||
{i > 0 && <Repeat size={12} />}
|
||||
<span>{`${SELECTED_MODE_LABEL[leg.mode] || leg.mode} ${(leg.distance_km * KM_TO_MI).toFixed(1)} mi (${Math.round(leg.minutes)}min)`}</span>
|
||||
</span>
|
||||
))}
|
||||
<span style={{ opacity: 0.8 }}>{`— total ${Math.round(routeResult.summary.total_effort_minutes)}min`}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MVUM Layer 1: network-leg closures crossed for the selected mode */}
|
||||
{routeResult?.summary?.mvum_closed_crossings > 0 && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import { useStore } from '../store'
|
|||
import { decodePolyline } from '../utils/decode'
|
||||
import { fetchReverse, requestOffroute } from '../api'
|
||||
import { getConfig, hasFeature } from '../config'
|
||||
import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Star, Ruler, X, Trash2, Plus } from 'lucide-react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Star, Ruler, X, Trash2, Plus, Repeat } from 'lucide-react'
|
||||
import RadialMenu from './RadialMenu'
|
||||
import useContextMenu from '../hooks/useContextMenu'
|
||||
import toast from 'react-hot-toast'
|
||||
|
|
@ -31,6 +32,9 @@ const OFFROUTE_SOURCE = 'offroute-source'
|
|||
const OFFROUTE_WILDERNESS_LAYER = 'offroute-wilderness'
|
||||
const OFFROUTE_NETWORK_LAYER = 'offroute-network'
|
||||
const OFFROUTE_MARKERS_LAYER = 'offroute-markers'
|
||||
// MVUM Layer 3a: per-mode network polyline palette + labels for transition tooltips.
|
||||
const MODE_COLORS = { vehicle: '#1f78b4', auto: '#1f78b4', '4w': '#ff7f00', '2w': '#33a02c', foot: '#e31a1c' }
|
||||
const MODE_LABELS = { vehicle: 'Drive', auto: 'Drive', '4w': '4W', '2w': '2W', foot: 'Foot' }
|
||||
const HILLSHADE_SOURCE = 'hillshade-dem'
|
||||
const HILLSHADE_LAYER = 'hillshade-layer'
|
||||
const TRAFFIC_SOURCE = 'traffic-tiles'
|
||||
|
|
@ -1359,6 +1363,10 @@ function clearRouteDisplay(map) {
|
|||
if (map.getLayer(OFFROUTE_NETWORK_LAYER)) map.removeLayer(OFFROUTE_NETWORK_LAYER)
|
||||
if (map.getLayer(OFFROUTE_MARKERS_LAYER)) map.removeLayer(OFFROUTE_MARKERS_LAYER)
|
||||
if (map.getSource(OFFROUTE_SOURCE)) map.removeSource(OFFROUTE_SOURCE)
|
||||
if (map._offrouteTransitionMarkers) {
|
||||
map._offrouteTransitionMarkers.forEach((m) => m.remove())
|
||||
map._offrouteTransitionMarkers = []
|
||||
}
|
||||
}
|
||||
|
||||
/** Update offroute display with route GeoJSON */
|
||||
|
|
@ -1406,16 +1414,43 @@ function updateRouteDisplay(map, routeGeojson) {
|
|||
filter: ["==", ["get", "segment_type"], "network"],
|
||||
layout: { "line-join": "round", "line-cap": "round" },
|
||||
paint: {
|
||||
"line-color": "#3b82f6", // blue-500
|
||||
// Layer 3a: color each network leg by its travel mode (hybrid trips mix modes).
|
||||
"line-color": [
|
||||
"match", ["get", "network_mode"],
|
||||
"vehicle", MODE_COLORS.vehicle, "auto", MODE_COLORS.auto,
|
||||
"4w", MODE_COLORS["4w"], "2w", MODE_COLORS["2w"], "foot", MODE_COLORS.foot,
|
||||
"#3b82f6", // default (single-mode legacy blue)
|
||||
],
|
||||
"line-width": 5,
|
||||
"line-opacity": 0.85,
|
||||
},
|
||||
}, beforeId)
|
||||
|
||||
// Fit bounds to route
|
||||
const features = routeGeojson.features || []
|
||||
|
||||
// Layer 3a: transition markers (drive -> offroad vehicle switch) at trailheads.
|
||||
map._offrouteTransitionMarkers = map._offrouteTransitionMarkers || []
|
||||
for (const f of features) {
|
||||
if (f.properties?.kind !== "transition" || !f.geometry?.coordinates) continue
|
||||
const el = document.createElement("div")
|
||||
el.style.cssText = "width:26px;height:26px;border-radius:50%;background:#fff;" +
|
||||
"border:2px solid #333;display:flex;align-items:center;justify-content:center;" +
|
||||
"cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,0.4)"
|
||||
createRoot(el).render(<Repeat size={15} color="#333" />)
|
||||
const toLabel = MODE_LABELS[f.properties.to_mode] || f.properties.to_mode
|
||||
const marker = new maplibregl.Marker({ element: el })
|
||||
.setLngLat(f.geometry.coordinates)
|
||||
.setPopup(new maplibregl.Popup({ offset: 14, closeButton: false })
|
||||
.setText(`Switch to ${toLabel}`))
|
||||
.addTo(map)
|
||||
el.addEventListener("mouseenter", () => marker.togglePopup())
|
||||
el.addEventListener("mouseleave", () => marker.togglePopup())
|
||||
map._offrouteTransitionMarkers.push(marker)
|
||||
}
|
||||
|
||||
// Fit bounds to route (LineString segments only; transition points are Points)
|
||||
const allCoords = features
|
||||
.filter(f => f.geometry?.coordinates)
|
||||
.filter(f => f.geometry?.type === "LineString")
|
||||
.flatMap(f => f.geometry.coordinates)
|
||||
|
||||
if (allCoords.length > 0) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue