trips/router/internal/route/osrm.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

148 lines
3.9 KiB
Go

package route
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"maps/router/internal/geo"
)
// OSRM is a client for a local OSRM backend (osrm-routed), e.g.
// http://localhost:5000. Returns full route geometry.
type OSRM struct {
BaseURL string
HTTP *http.Client
profile string
name string
}
func NewOSRM(baseURL string) *OSRM {
return &OSRM{
BaseURL: baseURL,
HTTP: &http.Client{Timeout: 60 * time.Second},
profile: "driving",
name: "osrm(" + baseURL + ")",
}
}
func (o *OSRM) Name() string { return o.name }
func (o *OSRM) osrmProfile(p Profile) string {
if p == ProfileWalk {
return "walking"
}
return "driving"
}
type osrmRoute struct {
Code string `json:"code"`
Routes []struct {
Distance float64 `json:"distance"`
Duration float64 `json:"duration"`
Legs []struct {
Distance float64 `json:"distance"`
Duration float64 `json:"duration"`
} `json:"legs"`
Geometry string `json:"geometry"`
} `json:"routes"`
}
func (o *OSRM) Route(ctx context.Context, profile Profile, pts []geo.Point) (*Route, error) {
if len(pts) < 2 {
return nil, fmt.Errorf("osrm: need >= 2 points, got %d", len(pts))
}
var coords []string
for _, p := range pts {
coords = append(coords, fmt.Sprintf("%.6f,%.6f", p.Lon, p.Lat))
}
// overview=full keeps the (encoded-polyline) geometry in the response;
// the client trims it after. alternatives is left off: one route is what
// stop_cost needs.
u := fmt.Sprintf("%s/route/v1/%s/%s?alternatives=false&overview=full",
o.BaseURL, o.osrmProfile(profile), strings.Join(coords, ";"))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
resp, err := o.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var or osrmRoute
if err := json.NewDecoder(resp.Body).Decode(&or); err != nil {
return nil, fmt.Errorf("osrm: bad response: %w", err)
}
if or.Code != "Ok" || len(or.Routes) == 0 {
return nil, fmt.Errorf("osrm: %s", or.Code)
}
r0 := or.Routes[0]
out := &Route{Duration: r0.Duration, Distance: r0.Distance}
for _, l := range r0.Legs {
out.Legs = append(out.Legs, Leg{Duration: l.Duration, Distance: l.Distance})
}
// Default OSRM responses carry the geometry as an encoded polyline;
// with ?geometries=geojson it is a GeoJSON LineString. Handle both.
if r0.Geometry != "" {
var gj struct {
Geometry struct {
Coords [][]float64 `json:"coordinates"`
} `json:"geometry"`
}
if json.Unmarshal([]byte(r0.Geometry), &gj) == nil && len(gj.Geometry.Coords) > 0 {
for _, c := range gj.Geometry.Coords {
out.Geometry = append(out.Geometry, geo.Point{Lat: c[1], Lon: c[0]})
}
} else {
out.Geometry = decodePolyline(r0.Geometry)
}
}
return out, nil
}
// OSRMTable builds a matrix via the OSRM table service (one call) when
// available; the generic BuildMatrix (pairwise) works on any backend.
func (o *OSRM) Table(ctx context.Context, profile Profile, pts []geo.Point) (Matrix, error) {
var coords []string
for _, p := range pts {
coords = append(coords, fmt.Sprintf("%.6f,%.6f", p.Lon, p.Lat))
}
u := fmt.Sprintf("%s/table/v1/%s/%s?annotations=duration",
o.BaseURL, o.osrmProfile(profile), strings.Join(coords, ";"))
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
resp, err := o.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var tr struct {
Code string `json:"code"`
Durations [][]float64 `json:"durations"`
}
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
return nil, err
}
if tr.Code != "Ok" {
return nil, fmt.Errorf("osrm table: %s", tr.Code)
}
m := make(Matrix, len(pts))
for i := range m {
m[i] = make([]float64, len(pts))
for j := range m[i] {
if tr.Durations[i][j] > 0 {
m[i][j] = tr.Durations[i][j]
} else {
m[i][j] = -1
}
}
}
return m, nil
}
var _ = url.Values{} // keep net/url import for future query building