trips/router/cmd/routectl/main.go
Greg Pomerantz efde2cc71b maps project: design, survey, mock app, and route-aware planning backend
- DESIGN.md: full design incl. driving-trip requirements (R1-R4),
  stays model, focus mode, mobile, provenance rules
- SURVEY.md: open-source landscape
- mock/: interaction mock (Florence itinerary, focus mode, stays,
  region stops, mobile layout)
- router/: Go module (stdlib-only) with Router interface
  (Valhalla + OSRM backends), stop_cost, optimize_stops, corridor,
  routectl CLI, bench (5 real NE-corridor tasks, 26 checks passing),
  integration tests, and setup-osrm.sh for the self-hosted router
- osm/: NH+MA+CT+NY PBFs (gitignored) + setup artifacts
2026-09-06 00:05:17 -04:00

219 lines
5.8 KiB
Go

// routectl is the manual/CI front end for the router + plan primitives.
//
// Usage:
//
// routectl route --backend valhalla --from 44.054,-71.650 --to 40.729,-73.966
// routectl stopcost --backend valhalla --from A --to C --stop 42.277,-71.806:60:CascadeFalls
// routectl optimize --backend valhalla --from A --to C --k 2 \
// --stop 42.277,-71.806:60:CascadeFalls --stop 43.507,-71.548:50:Ladd
//
// stop format: lat,lon[:dwellMin[:name]]
package main
import (
"context"
"flag"
"fmt"
"os"
"strconv"
"strings"
"time"
"maps/router/internal/geo"
"maps/router/internal/plan"
"maps/router/internal/route"
)
func main() {
if len(os.Args) < 2 {
usage()
}
var err error
switch os.Args[1] {
case "route":
err = cmdRoute(os.Args[2:])
case "stopcost":
err = cmdStopCost(os.Args[2:])
case "optimize":
err = cmdOptimize(os.Args[2:])
default:
usage()
}
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: routectl {route|stopcost|optimize} [flags]")
os.Exit(2)
}
type common struct {
backend string
from, to string
}
func (c *common) parse(fs *flag.FlagSet) {
fs.StringVar(&c.backend, "backend", "valhalla", "valhalla|osrm")
fs.StringVar(&c.from, "from", "", "lat,lon origin")
fs.StringVar(&c.to, "to", "", "lat,lon destination")
}
func (c *common) router() route.Router {
switch c.backend {
case "valhalla":
return route.NewValhalla("https://valhalla1.openstreetmap.de")
case "osrm":
return route.NewOSRM("http://localhost:5000")
default:
panic("bad backend " + c.backend)
}
}
func parsePt(s string) (lat, lon float64) {
parts := strings.Split(s, ",")
lat, _ = strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
lon, _ = strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
return
}
func point(s string) geo.Point {
lat, lon := parsePt(s)
return geo.Point{Lat: lat, Lon: lon}
}
func parseStops(args []string) []plan.Stop {
var stops []plan.Stop
for _, a := range args {
parts := strings.Split(a, ":")
lat, lon := parsePt(parts[0])
s := plan.Stop{At: geo.Point{Lat: lat, Lon: lon}, ID: parts[0]}
if len(parts) > 1 && parts[1] != "" {
s.DwellMin, _ = strconv.Atoi(parts[1])
}
if len(parts) > 2 && parts[2] != "" {
s.Name = parts[2]
s.ID = parts[2]
}
stops = append(stops, s)
}
return stops
}
func fmtDur(sec float64) string {
m := int(0.5 + sec/60)
return fmt.Sprintf("%dh%02dm", m/60, m%60)
}
// ---- commands -------------------------------------------------------
func cmdRoute(args []string) error {
fs := flag.NewFlagSet("route", flag.ExitOnError)
var c common
c.parse(fs)
var walk bool
fs.BoolVar(&walk, "walk", false, "walking profile")
fs.Parse(args)
if c.from == "" || c.to == "" {
return fmt.Errorf("--from and --to required")
}
r, err := c.router().Route(context.Background(), prof(walk), []geo.Point{point(c.from), point(c.to)})
if err != nil {
return err
}
fmt.Printf("backend: %s\n", c.router().Name())
fmt.Printf("time: %s (%.0f s)\n", fmtDur(r.Duration), r.Duration)
fmt.Printf("distance: %.1f km\n", r.Distance/1000)
if len(r.Geometry) > 0 {
fmt.Printf("geometry: %d points (exact corridor available)\n", len(r.Geometry))
} else {
fmt.Printf("geometry: none (chord-corridor fallback, low confidence)\n")
}
return nil
}
func cmdStopCost(args []string) error {
fs := flag.NewFlagSet("stopcost", flag.ExitOnError)
var c common
c.parse(fs)
var stopArgs []string
fs.Func("stop", "stop lat,lon[:dwellMin[:name]]", func(v string) error {
stopArgs = append(stopArgs, v)
return nil
})
fs.Parse(args)
if c.from == "" || c.to == "" || len(stopArgs) == 0 {
return fmt.Errorf("--from, --to, and --stop required")
}
stops := parseStops(stopArgs)
m, err := buildMatrix(c.router(), c.from, c.to, stops)
if err != nil {
return err
}
for i, s := range stops {
cost := plan.StopCost(m, i, s)
fmt.Printf("%-18s detour=%3d min dwell=%3d min overhead=%2d min total=%3d min (direct %d min)\n",
s.ID, cost.DetourMin, cost.DwellMin, cost.OverheadMin, cost.TotalMin, cost.DirectMin)
}
return nil
}
func cmdOptimize(args []string) error {
fs := flag.NewFlagSet("optimize", flag.ExitOnError)
var c common
c.parse(fs)
var stopArgs []string
var k, budget int
fs.Func("stop", "stop lat,lon[:dwellMin[:name]]", func(v string) error {
stopArgs = append(stopArgs, v)
return nil
})
fs.IntVar(&k, "k", 2, "number of stops")
fs.IntVar(&budget, "budget", 0, "max total minutes (0 = none)")
fs.Parse(args)
if c.from == "" || c.to == "" || len(stopArgs) == 0 {
return fmt.Errorf("--from, --to, and --stop required")
}
stops := parseStops(stopArgs)
m, err := buildMatrix(c.router(), c.from, c.to, stops)
if err != nil {
return err
}
res := plan.OptimizeStops(m, stops, k, budget)
names := make([]string, len(res.Order))
for i, si := range res.Order {
names[i] = stops[si].ID
}
fmt.Printf("order: %s\n", strings.Join(names, " -> "))
fmt.Printf("detour: %d min\n", res.DetourMin)
fmt.Printf("total: %d min (budget %d, feasible=%v, relaxed=%v, exhaustive=%v)\n",
res.TotalMin, budget, res.Feasible, res.Relaxed, res.Exhaustive)
return nil
}
// ---- helpers --------------------------------------------------------
func prof(walk bool) route.Profile {
if walk {
return route.ProfileWalk
}
return route.ProfileDrive
}
// buildMatrix builds the (n+2)-layout matrix: stops, then A, then C.
func buildMatrix(r route.Router, fromS, toS string, stops []plan.Stop) (route.Matrix, error) {
pts := make([]geo.Point, 0, len(stops)+2)
for _, s := range stops {
pts = append(pts, s.At)
}
pts = append(pts, point(fromS), point(toS))
// Politeness delay for hosted backends.
delay := 150 * time.Millisecond
if _, ok := r.(*route.OSRM); ok {
delay = 0
}
return route.BuildMatrix(context.Background(), r, route.ProfileDrive, pts, delay)
}