trips/router/internal/route/valhalla.go
Greg Pomerantz 0c4a1f9f60 router: working local OSRM backend + per-backend bench goldens
- fix encoded-polyline decoder (zigzag: (n>>1) ^ -(n&1)) — caught by
  the OSRM integration tests (Boston clearance 5681km -> 38km)
- OSRM client: routes[] JSON shape, overview=full for geometry,
  polyline+geojson support
- local OSRM 26.4.1 built from source (no sudo): deps.sh (bzip2, lua
  5.2 with readline stub, oneTBB, boost 1.84 via b2 + hand-rolled
  CMake config files, osmium-tool), setup-osrm.sh (merge PBFs ->
  extract -> partition -> customize -> serve :5000); v26 flag fixes
- bench: tasks.osrm.json goldens; both backends 26/26 PASS and agree
  on all 5 optimized orders (Ladd->Cascade etc.)
- README: profile deltas (Valhalla 332min vs OSRM 410min, same route
  shape), per-backend goldens, verified self-host status
2026-09-06 01:19:37 -04:00

184 lines
4.3 KiB
Go

package route
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"maps/router/internal/geo"
)
// Valhalla is a client for a Valhalla routing API
// (e.g. the hosted https://valhalla1.openstreetmap.de or a self-hosted
// valhalla instance). Note: hosted instances strip route geometry, so
// Route.Geometry will be nil.
type Valhalla struct {
BaseURL string
HTTP *http.Client
name string
}
func NewValhalla(baseURL string) *Valhalla {
return &Valhalla{
BaseURL: baseURL,
HTTP: &http.Client{Timeout: 60 * time.Second},
name: "valhalla(" + baseURL + ")",
}
}
func (v *Valhalla) Name() string { return v.name }
func (v *Valhalla) costing(p Profile) string {
if p == ProfileWalk {
return "pedestrian"
}
return "auto"
}
type vLocation struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
type vRouteReq struct {
Locations []vLocation `json:"locations"`
Costing string `json:"costing"`
Polyline bool `json:"polyline"`
}
type vLeg struct {
Summary struct {
Time float64 `json:"time"`
Length float64 `json:"length"` // km
} `json:"summary"`
}
type vTrip struct {
Summary struct {
Time float64 `json:"time"`
Length float64 `json:"length"` // km
MinLat float64 `json:"min_lat"`
MaxLat float64 `json:"max_lat"`
MinLon float64 `json:"min_lon"`
MaxLon float64 `json:"max_lon"`
} `json:"summary"`
Legs []vLeg `json:"legs"`
}
type vResp struct {
Trip vTrip `json:"trip"`
Message string `json:"message"`
Error string `json:"error"`
}
func (v *Valhalla) Route(ctx context.Context, profile Profile, pts []geo.Point) (*Route, error) {
if len(pts) < 2 {
return nil, fmt.Errorf("route: need >= 2 points, got %d", len(pts))
}
req := vRouteReq{Costing: v.costing(profile), Polyline: true}
for _, p := range pts {
req.Locations = append(req.Locations, vLocation{p.Lat, p.Lon})
}
body, _ := json.Marshal(req)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, v.BaseURL+"/route", bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := v.HTTP.Do(httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 20<<20))
if err != nil {
return nil, err
}
var vr vResp
if err := json.Unmarshal(raw, &vr); err != nil {
return nil, fmt.Errorf("valhalla: bad response (%s): %q", resp.Status, string(raw[:min(len(raw), 200)]))
}
if vr.Message != "" {
return nil, fmt.Errorf("valhalla: %s", vr.Message)
}
if vr.Error != "" {
return nil, fmt.Errorf("valhalla: %s", vr.Error)
}
out := &Route{
Duration: vr.Trip.Summary.Time,
Distance: vr.Trip.Summary.Length * 1000,
BBox: BBox{
MinLat: vr.Trip.Summary.MinLat, MaxLat: vr.Trip.Summary.MaxLat,
MinLon: vr.Trip.Summary.MinLon, MaxLon: vr.Trip.Summary.MaxLon,
},
}
for _, l := range vr.Trip.Legs {
out.Legs = append(out.Legs, Leg{Duration: l.Summary.Time, Distance: l.Summary.Length * 1000})
}
// Geometry: decode encoded polyline if the instance returns it.
if pl, ok := decodePolylineField(raw); ok {
out.Geometry = pl
}
return out, nil
}
// decodePolylineField checks for trip.polyline (encoded polyline v0) —
// hosted instances omit it; self-hosted valhalla with "polyline":true
// returns it.
func decodePolylineField(raw []byte) ([]geo.Point, bool) {
var probe struct {
Trip struct {
Polyline string `json:"polyline"`
} `json:"trip"`
}
if err := json.Unmarshal(raw, &probe); err != nil || probe.Trip.Polyline == "" {
return nil, false
}
return decodePolyline(probe.Trip.Polyline), true
}
// decodePolyline decodes a Google encoded polyline (precision 1e-5 or 1e-6).
func decodePolyline(s string) []geo.Point {
var pts []geo.Point
lat, lon := 0, 0
i := 0
for i < len(s) {
var dlat, dlon int
shift := 0
for {
b := int(s[i]) - 63
i++
dlat |= (b & 0x1f) << shift
if b&0x20 == 0 {
break
}
shift += 5
}
shift = 0
for {
b := int(s[i]) - 63
i++
dlon |= (b & 0x1f) << shift
if b&0x20 == 0 {
break
}
shift += 5
}
lat += (dlat >> 1) ^ -(dlat & 1)
lon += (dlon >> 1) ^ -(dlon & 1)
pts = append(pts, geo.Point{Lat: float64(lat) / 1e5, Lon: float64(lon) / 1e5})
}
return pts
}
func min(a, b int) int {
if a < b {
return a
}
return b
}