trips/router/README.md
Greg Pomerantz 33ed5d9fe6 multi-trip mock + Colombia trip with a real walking router
The mock can now work several trips at once: a trip picker in the
topbar, per-trip state in localStorage (edits + version history survive
switching), and a per-trip router key so each trip routes on its own
OSRM extract.

Colombia (Cartagena -> Santa Marta, 8 days) becomes the default working
trip, inside a new walking-profile OSRM instance:
- scripts/crop_pbf.py: streaming 3-pass PBF cropper (no PBF library;
  the country file is too big for osmium extract -s complete_ways on
  this box). Framing validated against osmium test fixtures and
  osrm-extract. Drops relations (foot profile ignores restrictions).
- scripts/setup-osrm-colombia.sh: download -> crop (~4.6 MB) ->
  extract/partition/customize with the official foot.lua -> serve
  :5003. Verified: Getsemaní -> Castillo San Felipe 7.98 km / 96 min.
- server.js: ROUTERS map, ?router=colombia|northeast on /route,
  /table, /router-status (per-router probe points).
- app.js: legs touching a transit stop are fixed (flight/taxi/bus via
  M.multimodal), never re-routed; distance-0 router answers (both
  points on one node) read as adjacent stops, not out-of-coverage.
- multi-hotel fix: day-scope map fit uses the stays covering THAT day,
  so a two-city trip no longer fits the whole country per day.

Also: pre-cache-tiles.sh (Cartagena+Santa Marta z14-16, 2560 tiles),
tile cache write fix (Node rejects flags:'x'; buffer-then-write),
favicon, renderHotelMks load-order guard, tests updated for the new
default trip (all four puppeteer suites green, zero console errors).
2026-09-09 09:44:29 -04:00

232 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# maps/router — route-aware planning primitives
The Go backend for the "search along the route" capability
([DESIGN.md](../DESIGN.md), "Driving trips" section). Everything the LLM
would otherwise *guess* about a trip — route shape, travel times, detour
costs — is computed here and returned with provenance.
## What's in here
```
internal/geo/ dependency-free geodesy (haversine, point-to-line)
internal/route/ Router interface + backends
- valhalla.go: hosted/self-hosted Valhalla (works today;
no geometry on the hosted instance → chord fallback)
- osrm.go: local OSRM (full geometry; scripts/setup-osrm.sh)
internal/plan/ the primitives:
- stopcost.go: stop_cost = route(A,C via B) route(A,C)
- optimize.go: optimize_stops (exhaustive ≤10 stops, greedy above)
- corridor.go: corridor = buffer band around the route
cmd/routectl/ manual CLI: route | stopcost | optimize
internal/traffic/ live-traffic pipeline (511NY → Overpass match → speeds)
cmd/traffic/ fetch | match | apply (segment-speed CSV → re-customize)
cmd/bench/ benchmark runner (bench/tasks.json)
bench/tasks.json 5 real NE-corridor tasks with recorded goldens
scripts/setup-osrm.sh self-host OSRM over the NH+MA+CT+NY extract
```
Zero third-party Go dependencies (stdlib only).
## Try it (hosted Valhalla, no setup)
```sh
go run ./cmd/routectl route --from 44.054,-71.650 --to 40.729,-73.966
# Lincoln NH → Queens NY: ~5h32m, 531 km, I-93/90/495/290 (NOT via Boston)
go run ./cmd/routectl stopcost --from 44.054,-71.650 --to 40.729,-73.966 \
--stop 42.2767,-71.806:60:cascade \
--stop 43.507,-71.548:50:ladd \
--stop 43.642,-71.156:120:mtmajor
# cascade detour= 6 min (on the I-495 corridor)
# ladd detour=17 min
# mtmajor detour=80 min (off-corridor)
go run ./cmd/routectl optimize --from 44.054,-71.650 --to 40.729,-73.966 --k 2 \
--stop 42.2767,-71.806:60:cascade --stop 43.507,-71.548:50:ladd \
--stop 43.902,-71.64:60:plymouth --stop 42.425,-71.68:60:brewsters
# order: ladd -> cascade detour: 23 min
```
## Tests
```sh
go test ./... # unit tests, offline, fast
go test -tags integration ./... # live router (Valhalla by default)
ROUTER_BACKEND=osrm ROUTER_URL=http://localhost:5000 \
go test -tags integration ./... # against local OSRM
```
The integration tests encode the key regression: the fastest
Lincoln→Queens drive stays ≥15 km from Boston (it goes via Worcester,
I-90/I-495/I-290). That is exactly the fact an LLM confidently gets wrong.
## Benchmark
```sh
go run ./cmd/bench # valhalla goldens (bench/tasks.json)
go run ./cmd/bench --tasks bench/tasks.osrm.json # local-OSRM goldens
go run ./cmd/bench --record # re-record into the file you're running
```
Tasks are real OD pairs across NH/MA/CT/NY with real POI candidates and
dwell times. **Goldens are per backend+profile** (see profile deltas
below): `bench/tasks.json` carries Valhalla goldens, `bench/tasks.osrm.json`
local-OSRM goldens. Both currently **26 PASS / 0 FAIL**, and both
routers pick the *same* optimized orders for all 5 tasks despite their
different clocks. Check types:
| type | meaning |
|---|---|
| `golden_route_min` | direct route time within a recorded band (±10%) |
| `route_avoids` | route stays ≥ N km from a point (geometry, or bbox when the backend has none) |
| `stop_free` / `stop_detour_at_least` / `stop_detour_band` | computed detour of a candidate vs an expected band |
| `optimize_order` | the exhaustive optimum matches the recorded order |
| `crosscheck` | single multi-waypoint route ≈ matrix-summed prediction (≤ maxPct drift) |
Current state: **26 PASS / 0 FAIL** on both hosted Valhalla and local
OSRM (separate golden files).
## Self-hosted OSRM (full geometry, exact corridors) — verified
`scripts/setup-osrm.sh` is the full, tested path (no sudo, no conan):
`deps.sh` builds bzip2, Lua 5.2, oneTBB, Boost 1.84 (b2 + hand-rolled
CMake config files — boost release tarballs ship no CMake support) and
osmium-tool; then it builds OSRM 26.4.1, merges the 4 state PBFs
(osrm-extract takes one input), extracts (~8 GB peak RAM, ~4 min),
partitions, customizes, and serves on `:5000`. Artifacts live in
`$HOME/osm-build` by default (override with `W=`).
```sh
ROUTER_BACKEND=osrm ROUTER_URL=http://localhost:5000 go test -tags integration ./...
go run ./cmd/routectl route --backend osrm --from 44.054,-71.650 --to 40.729,-73.966
# 6h50m, 514.4 km, 9068 geometry points (exact corridor available)
```
With OSRM, `Route.Geometry` is populated, so the corridor is exact
(distances to the actual routed polyline) instead of the chord fallback
used with the hosted Valhalla. This is what makes
`search_along_route`'s spatial pre-filter (PostGIS `ST_DWithin` on the
corridor polygon in the v1 design) sound.
### Profile deltas matter (measured, same OD pair)
| backend | Lincoln NH → 39-84 46th St, Sunnyside |
|---|---|
| Valhalla `auto` | 327 min, 529 km (97 km/h avg) |
| OSRM stock `car.lua` | 406 min, 511 km (76 km/h avg) |
| OSRM US-tuned speed table | 377 min, 526 km (84 km/h avg) |
All three avoid Boston and take the I-93/90/495/290 corridor — same
route *shape*, different *clocks*.
Why the clocks differ: **the times are the profile's speed assumptions,
not measurements.** OSRM's stock `car.lua` lists `motorway = 90` km/h
and applies a global `speed_reduction = 0.8` → untagged motorway miles
run at ~72 km/h (45 mph). That's a generic/European-flavored default
(90 km/h mirrors the European "unsigned road = 90" convention), and US
interstates are `maxspeed`-untagged in large stretches, so the default
rules. Re-extracting the same data with a US-tuned table
(`profiles/car-us.lua`: motorway 135, trunk 130, primary 110, ...
through the same 0.8 factor) closes two-thirds of the gap (406 → 377
min) and even changes the *route choice* (526 km now — faster
motorways make longer interstates win). Valhalla's `auto` is simply
calibrated more aggressively (97 km/h average).
Consequences: (a) goldens in the bench files are per backend+profile —
re-record (`--record`) after switching; (b) any user-facing
arrival-time math must use one backend consistently per trip and say
which; (c) "computed" provenance should carry the profile name,
because two computed numbers for the same leg can differ by 2025%.
### Second dataset: Colombia walking router (`:5003`) — verified
`scripts/setup-osrm-colombia.sh` builds a second OSRM instance over the
**official `foot.lua` profile** (real walking speeds, ~5 km/h) for the
Colombia trip (Cartagena + Santa Marta boxes).
The country PBF (329 MB) is too big for `osmium extract -s complete_ways`
on this box (~2 GB free RAM), so `crop_pbf.py` streams it in three passes
and keeps only the two city boxes (~4.6 MB, 565k nodes / 118k ways, ~3 min).
Verified: Cartagena Getsemaní → Castillo San Felipe routes 7.98 km / 96 min
(≈ 5 km/h). `mock/server.js` maps `?router=colombia` to `:5003` and
`?router=northeast` (default) to `:5000`, so each trip in the mock is
routed on its own extract.
Notes:
- The cropper **drops relations** (the foot profile ignores turn
restrictions) and node metadata; ways touching a box are kept whole.
- OSRM v26 returns `Ok` with distance 0 for out-of-coverage point pairs
(not `NoTable`); the mock treats a zero-distance answer as "adjacent
stops" and a no-route answer as fallback-to-estimate.
- `crop_pbf.py` is a from-scratch PBF reader/writer (no PBF library
available); framing = `[uint32 BE BlobHeader len][BlobHeader][Blob]`,
validated against osmium's own test fixtures and `osrm-extract`.
## Live traffic (511NY → OSRM segment speeds) — verified
OSRM v26 has a first-class mechanism for this: `osrm-customize
--segment-speed-file speeds.csv` (CSV = `nodeA,nodeB,km/h`, OSM node
pairs) rewrites edge weights and re-customizes the MLD in **~15 s — no
re-extract**. The traffic layer is a renamed copy of the dataset
(customize writes the contract files in place; v26 has no
`--output-prefix`), served by its own `osrm-routed` process.
`internal/traffic` + `cmd/traffic` implement the whole loop:
```sh
# 1. pull incidents (needs a 511NY key — 511ny.org → Sign Up →
# account → API key; the key can't be requested programmatically)
go run ./cmd/traffic fetch --key "$FIFTYONE_NY_KEY" --out events.json --raw raw.json
# 2. match each incident to OSM node pairs (Overpass: ways around the
# point; within 150 m the highest-class road wins, so "I-90" beats a
# closer service road) and expand to the affected stretch
go run ./cmd/traffic match --events events.json --out speeds.csv
# 3. copy the base dataset (CoW) + re-customize the traffic layer
go run ./cmd/traffic apply --csv speeds.csv --data $HOME/osm-build/data
# → serve: osrm-routed --algorithm mld --port 5002 $HOME/osm-build/data/northeast-traffic.osrm
```
Verified end-to-end: a synthetic `Road Closed` on a 0.87 km I-90
stretch routed at 38 s (static `:5000`) becomes 142 s on the traffic
layer (`:5002`) — same path, closure speed. `match` was tested against
live Overpass data (real OSM node IDs; class-window selection; unknown
event types ignored).
Notes:
- **Incident policy speeds are `assumed`** (policy map in
`internal/traffic/policy.go`), not measurements. Routes from the
traffic layer must carry provenance
`computed(traffic, as_of <fetch time>, policy=assumed)` per DESIGN.md
R4 — and the `as_of` age is a product-visible confidence signal.
- **Closures at 5 km/h are slow, not blocked.** True blocking needs the
`--turn-penalty-file` mechanism (untested) or a graph edit; 511NY
"Road Closed" events should probably map to blocking, not 5 km/h.
- **Free coverage is patchy**: 511NY (NY) + MassDOT (MA, separate
application) give incident-level data; there is no free
floating-car feed, so per-leg traffic coverage is a confidence tier,
not a boolean.
- 511NY API: base `https://511ny.org/api`, OData-v3 shape
(`{"d":[...]}`), 10 calls/min throttle. `fetch` decodes leniently and
archives the raw body for schema confirmation on first live run.
## Design notes
- **The route is computed, never narrated.** No function in this module
returns a time estimate from model knowledge; every number is router
output (`computed` provenance per DESIGN.md R4).
- **Matrix via pairwise route calls.** Valhalla's
`sources_to_targets` caps at 150 km, so the common path is
`BuildMatrix` (pairwise `/route`). OSRM's `Table` service is available
for the local backend when a single-call matrix matters.
- **optimize_stops semantics**: the user asked for *k* stops ("two
hikes"), so the objective minimizes total = detour + dwell + overhead
over ordered subsets of **exactly k**, with fallback (a) to the best
feasible smaller set when a time budget is given, then (b) to the best
exactly-k ignoring the budget, flagged `Feasible=false`.
"At most k" without a budget degenerates (zero stops always wins).
- **Stop coordinates are POI centroids.** The router snaps to the
network; a 200 m centroid error moves a 531 km route's detour by
seconds, not minutes.