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

144 lines
4.2 KiB
Go

package traffic
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Event is a normalized traffic incident from a feed.
type Event struct {
Raw json.RawMessage `json:"raw"`
Type string `json:"type"` // feed-native event type, e.g. "Road Closed"
Desc string `json:"desc"` // short description
City string `json:"city"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
const fiftyOneNYBase = "https://511ny.org/api"
// FetchEvents pulls all current incidents from the 511NY REST API.
// The service is ASP.NET OData v3, so the JSON payload is {"d":[...]};
// we accept that plus plain arrays for forward compatibility.
// The raw response body is returned so callers can archive/inspect it —
// field names are decoded leniently because we could not inspect a live
// response before the first keyed call.
func FetchEvents(key string) ([]Event, []byte, error) {
client := &http.Client{Timeout: 30 * time.Second}
u := fiftyOneNYBase + "/GetEvents?" + url.Values{"key": {key}, "format": {"json"}}.Encode()
resp, err := client.Get(u)
if err != nil {
return nil, nil, fmt.Errorf("511ny: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
return nil, nil, fmt.Errorf("511ny read: %w", err)
}
if resp.StatusCode != 200 {
return nil, body, fmt.Errorf("511ny: status %d: %s", resp.StatusCode, truncate(string(body), 200))
}
var events []Event
var err2 error
if events, err2 = decodeEvents(body); err2 != nil {
return nil, body, fmt.Errorf("511ny decode: %w", err2)
}
return events, body, nil
}
// decodeEvents accepts {"d":[...]} (OData v3), a plain array, or
// {"results":[...]}.
func decodeEvents(body []byte) ([]Event, error) {
var pick func(v any) []json.RawMessage
switch {
case strings.HasPrefix(strings.TrimSpace(string(body)), "{"):
var obj map[string]json.RawMessage
if err := json.Unmarshal(body, &obj); err != nil {
return nil, err
}
for _, k := range []string{"d", "results", "value"} {
if raw, ok := obj[k]; ok {
var arr []json.RawMessage
if err := json.Unmarshal(raw, &arr); err == nil {
pick = func(any) []json.RawMessage { return arr }
break
}
}
}
if pick == nil {
return nil, fmt.Errorf("no event array in object (keys: %s)", keys(obj))
}
default:
var arr []json.RawMessage
if err := json.Unmarshal(body, &arr); err != nil {
return nil, err
}
pick = func(any) []json.RawMessage { return arr }
}
var events []Event
for _, raw := range pick(nil) {
events = append(events, decodeEvent(raw))
}
return events, nil
}
func keys(m map[string]json.RawMessage) string {
var ks []string
for k := range m {
ks = append(ks, k)
}
return strings.Join(ks, ",")
}
// decodeEvent extracts location + type leniently. 511NY field names vary
// between vintages; we try the documented shapes:
//
// {"EventType": "...", "Location": {"Latitude": .., "Longitude": ..}, ...}
// {"type": "...", "lat": .., "lon": .., ...}
func decodeEvent(raw json.RawMessage) Event {
e := Event{Raw: raw}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return e
}
e.Type = firstString(m, "EventType", "eventType", "type", "Event", "category")
e.Desc = firstString(m, "Description", "description", "EventDescription", "summary")
e.City = firstString(m, "City", "city", "LocationDescription")
// location: nested object first, then flat fields
if loc, ok := m["Location"].(map[string]any); ok {
e.Lat = firstFloat(loc, "Latitude", "latitude", "lat")
e.Lon = firstFloat(loc, "Longitude", "longitude", "lon", "lng")
}
if !e.Valid() {
e.Lat = firstFloat(m, "Latitude", "latitude", "lat")
e.Lon = firstFloat(m, "Longitude", "longitude", "lon", "lng")
}
return e
}
// Valid reports whether the event carries a usable location.
func (e Event) Valid() bool { return e.Lat != 0 && e.Lon != 0 }
func firstString(m map[string]any, keys ...string) string {
for _, k := range keys {
if v, ok := m[k].(string); ok && v != "" {
return v
}
}
return ""
}
func firstFloat(m map[string]any, keys ...string) float64 {
for _, k := range keys {
if v, ok := m[k].(float64); ok && v != 0 {
return v
}
}
return 0
}