trips/router/internal/traffic/overpass.go
Greg Pomerantz fc9cf44052 traffic: live-traffic pipeline (511NY → Overpass match → OSRM segment speeds)
Verified end-to-end on the local OSRM stack:
- internal/traffic: 511NY OData client (lenient decode, raw archive),
  Overpass way matcher (class-window selection: highest-class road within
  150m beats a closer service road), incident policy map, CSV builder
- cmd/traffic: fetch | match | apply; apply copies the dataset (CoW) and
  re-customizes the traffic layer in ~15s (v26 customize has no
  --output-prefix; it writes contract files in place)
- Proof: synthetic I-90 closure → 38s (static :5000) vs 142s (traffic
  :5002) on the same 0.87 km stretch
- Policy speeds are 'assumed' provenance; R4: traffic routes must carry
  computed(traffic, as_of, policy=assumed)
2026-09-06 21:22:36 -04:00

208 lines
5.9 KiB
Go

// Package traffic implements the live-traffic pipeline: external incident
// feeds → OSM node-pair speeds → OSRM segment-speed CSV → re-customize.
//
// The matcher works on the OSM side (Overpass returns OSM node IDs directly),
// so no OSRM binary parsing is needed: the segment-speed CSV is keyed by OSM
// node pairs, exactly what the OSRM updater consumes.
package traffic
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"maps/router/internal/geo"
)
const defaultOverpassURL = "https://overpass-api.de/api/interpreter"
// WayNode is one OSM node on a way.
type WayNode struct {
ID int64 `json:"id"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
// Way is a highway way with its full node list.
type Way struct {
ID int64 `json:"id"`
Highway string `json:"highway"`
Ref string `json:"ref"`
Nodes []WayNode `json:"-"`
}
// QueryWaysAround fetches highway ways within radiusM of (lat, lon).
func QueryWaysAround(client *http.Client, overpassURL string, lat, lon, radiusM float64, timeoutSec int) ([]Way, error) {
if client == nil {
client = &http.Client{Timeout: time.Duration(timeoutSec+30) * time.Second}
}
if overpassURL == "" {
overpassURL = defaultOverpassURL
}
ql := fmt.Sprintf("[out:json][timeout:%d];way(around:%d,%g,%g)[highway];out geom;",
timeoutSec, int(radiusM), lat, lon)
req, err := http.NewRequest(http.MethodPost, overpassURL,
strings.NewReader(url.Values{"data": {ql}}.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// public Overpass WAFs reject the default Go user agent
req.Header.Set("User-Agent", "voyage-router/0.1 (traffic pipeline; contact: gmp)")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("overpass: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
if err != nil {
return nil, fmt.Errorf("overpass read: %w", err)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("overpass: status %d: %s", resp.StatusCode, truncate(string(body), 200))
}
var doc struct {
Elements []struct {
Type string `json:"type"`
ID int64 `json:"id"`
Tags map[string]string `json:"tags"`
Geom []struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
} `json:"geometry"`
NodeIDs []int64 `json:"nodes"` // parallel to Geom
} `json:"elements"`
}
if err := json.Unmarshal(body, &doc); err != nil {
return nil, fmt.Errorf("overpass json: %w (body: %s)", err, truncate(string(body), 120))
}
var ways []Way
for _, e := range doc.Elements {
if e.Type != "way" || len(e.Geom) < 2 || len(e.NodeIDs) != len(e.Geom) {
continue
}
w := Way{ID: e.ID, Highway: e.Tags["highway"], Ref: e.Tags["ref"]}
for i, g := range e.Geom {
w.Nodes = append(w.Nodes, WayNode{ID: e.NodeIDs[i], Lat: g.Lat, Lon: g.Lon})
}
ways = append(ways, w)
}
return ways, nil
}
// classRank orders highway classes by importance (higher = more likely the
// intended road for an incident reported near it).
func classRank(h string) int {
switch h {
case "motorway":
return 7
case "trunk":
return 6
case "primary":
return 5
case "secondary":
return 4
case "tertiary":
return 3
case "unclassified":
return 2
default:
return 1
}
}
// classWindowM is the distance within which a higher-class road beats a
// closer lower-class one: an incident reported "at I-90" means the motorway
// even when a service road or footway is nearer.
const classWindowM = 150.0
// ClosestNodePair finds the highway segment the incident is on and returns
// its two OSM endpoint node IDs. Selection: within classWindowM, the
// highest-class road wins (ties → nearest); beyond it, plain nearest.
func ClosestNodePair(ways []Way, lat, lon float64) (way Way, nodeA, nodeB int64, distM float64, ok bool) {
p := geo.Point{Lat: lat, Lon: lon}
type seg struct {
way Way
a, b int64
distM float64
rank int
}
var best *seg
for _, w := range ways {
rank := classRank(w.Highway)
for i := 0; i+1 < len(w.Nodes); i++ {
d := geo.DistToPolylineMeters(p, []geo.Point{{Lat: w.Nodes[i].Lat, Lon: w.Nodes[i].Lon}, {Lat: w.Nodes[i+1].Lat, Lon: w.Nodes[i+1].Lon}})
if d > classWindowM && best != nil && best.distM <= classWindowM {
continue
}
better := false
if best == nil {
better = true
} else if rank > best.rank && d <= classWindowM {
better = true
} else if rank == best.rank && d < best.distM {
better = true
} else if rank < best.rank && best.distM > classWindowM && d < best.distM {
better = true // nothing in the window; fall back to nearest
}
if better {
best = &seg{way: w, a: w.Nodes[i].ID, b: w.Nodes[i+1].ID, distM: d, rank: rank}
}
}
}
if best == nil {
return
}
return best.way, best.a, best.b, best.distM, true
}
// ExpandToRadius returns node pairs along the matched way covering ±radiusM
// of the incident point (incidents affect a stretch, not a single edge).
func ExpandToRadius(way Way, hitA, hitB int64, radiusM float64) [][2]int64 {
found := -1
for i := 0; i+1 < len(way.Nodes); i++ {
if way.Nodes[i].ID == hitA && way.Nodes[i+1].ID == hitB {
found = i
break
}
}
if found < 0 {
if hitA != 0 {
return [][2]int64{{hitA, hitB}}
}
return nil
}
start := found
back := 0.0
for start > 0 && back+segmentLen(way, start-1) <= radiusM {
back += segmentLen(way, start - 1)
start--
}
end := found
fwd := 0.0
for end+1 < len(way.Nodes)-1 && fwd+segmentLen(way, end+1) <= radiusM {
fwd += segmentLen(way, end+1)
end++
}
var pairs [][2]int64
for i := start; i <= end; i++ {
pairs = append(pairs, [2]int64{way.Nodes[i].ID, way.Nodes[i+1].ID})
}
return pairs
}
func segmentLen(w Way, i int) float64 {
return geo.Meters(geo.Point{Lat: w.Nodes[i].Lat, Lon: w.Nodes[i].Lon}, geo.Point{Lat: w.Nodes[i+1].Lat, Lon: w.Nodes[i+1].Lon})
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}