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)
144 lines
4.2 KiB
Go
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
|
|
}
|