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)
208 lines
5.9 KiB
Go
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] + "…"
|
|
}
|