trips/router/internal/traffic/policy.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

105 lines
3.0 KiB
Go

package traffic
import (
"fmt"
"os"
"sort"
"strings"
)
// Policy maps a feed event type to the speed override + affected stretch.
// Speeds are in km/h, applied to every graph edge on the matched stretch.
// These are initial values — they should be tuned against measured traffic,
// not asserted as fact (R4: provenance "assumed (policy)").
type Policy struct {
// SpeedKmh is the speed applied to the affected stretch.
SpeedKmh int
// RadiusM is how far along the road the effect extends from the point.
RadiusM float64
// Weight: when multiple incidents hit the same edge, the highest-weight
// (most severe) policy wins.
Weight int
}
// Policies covers the 511NY event types we expect. Unknown types are
// ignored (no override) rather than guessed.
var Policies = map[string]Policy{
"road closed": {5, 1500, 100},
"roadclosure": {5, 1500, 100},
"lane closure": {45, 800, 60},
"laneclosure": {45, 800, 60},
"accident": {25, 1200, 90},
"collision": {25, 1200, 90},
"road work": {50, 1000, 40},
"roadwork": {50, 1000, 40},
"construction": {50, 1000, 40},
"congestion": {30, 1500, 30},
"slow traffic": {30, 1500, 30},
"traffic": {30, 1500, 30},
"vehicle on road": {30, 800, 50},
"disabled vehicle": {25, 800, 70},
"incident": {25, 1000, 50},
}
// PolicyFor normalizes a feed event type and looks up its policy.
func PolicyFor(eventType string) (Policy, bool) {
norm := strings.ToLower(strings.TrimSpace(eventType))
if p, ok := Policies[norm]; ok {
return p, true
}
// substring fallback: "Accident - Multi Vehicle" etc.
for key, p := range Policies {
if strings.Contains(norm, key) {
return p, true
}
}
return Policy{}, false
}
// Assignment is one incident's contribution: a stretch of node pairs, a
// speed, and a severity weight.
type Assignment struct {
Pair [2]int64
SpeedKmh int
Weight int
}
// BuildCSV resolves assignments into OSRM segment-speed CSV rows. For each
// unordered node pair, the highest-weight (most severe) speed wins; the OSRM
// updater itself keys on (nodeA,nodeB), so conflicts must resolve here.
func BuildCSV(assignments []Assignment) string {
type key struct{ a, b int64 }
type row struct{ pair key; speed, weight int }
best := map[key]*row{}
for _, as := range assignments {
k := key{as.Pair[0], as.Pair[1]}
if k.a > k.b {
k.a, k.b = k.b, k.a
}
cur, ok := best[k]
if !ok || as.Weight > cur.weight {
best[k] = &row{pair: k, speed: as.SpeedKmh, weight: as.Weight}
}
}
rows := make([]*row, 0, len(best))
for _, r := range best {
rows = append(rows, r)
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].pair.a != rows[j].pair.a {
return rows[i].pair.a < rows[j].pair.a
}
return rows[i].pair.b < rows[j].pair.b
})
var b strings.Builder
for _, r := range rows {
fmt.Fprintf(&b, "%d,%d,%d\n", r.pair.a, r.pair.b, r.speed)
}
return b.String()
}
// WriteSpeedFile writes the CSV to disk.
func WriteSpeedFile(path, csv string) error {
return os.WriteFile(path, []byte(csv), 0o644)
}