calculate.go
1package waypoint
2
3import (
4 "crypto/sha256"
5 "encoding/hex"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log/slog"
11 "sync"
12 "time"
13
14 "github.com/paulmach/orb"
15 geolib "github.com/paulmach/orb/geo"
16 "git.kilimanjaro.io/rtw/routing"
17 "git.kilimanjaro.io/rtw/routing/graphhopper"
18)
19
20// NoRouteID is the SHA256 hash of an empty string, used as sentinel for unroutable paths
21var NoRouteID = func() string {
22 hash := sha256.Sum256([]byte(""))
23 return hex.EncodeToString(hash[:])
24}()
25
26// NoRouteSentinel represents a route that could not be calculated (no path exists)
27var NoRouteSentinel = Route{
28 ID: NoRouteID,
29 Distance: 0,
30 Time: 0,
31 Bbox: []float64{0, 0, 0, 0},
32}
33
34// CalculatedRoute holds the result of a single route calculation
35type CalculatedRoute struct {
36 WaypointID string
37 RouteKey string
38 Route Route
39 Points [][]float64 // Route points in [lon, lat] format
40 Elevation []float64 // Elevation data in meters
41 Error error
42 ErrorCodes []string // Error codes to populate on the waypoint (e.g., ["RouteTooFar"])
43 SourceID string // ID of source waypoint
44 SourceOrig []float64 // Original source point [lon, lat]
45 SourceSnap []float64 // Snapped source point [lon, lat]
46 DestOrig []float64 // Original destination point [lon, lat] (the waypoint being updated)
47 DestSnap []float64 // Snapped destination point [lon, lat]
48}
49
50// CalculateMissingRoutes finds all missing routes in the waypoints and fetches them
51// from the Stadia API in parallel. It saves route data to disk and returns the
52// calculated routes for updating waypoints.
53//
54// The storage parameter can be nil if you don't want to persist routes (useful for testing).
55func CalculateMissingRoutes(waypoints []Waypoint, storage *routing.RouteStorage) ([]CalculatedRoute, error) {
56 // Get routing requests for missing routes
57 requests, err := ToRoutingRequest(waypoints...)
58 if err != nil {
59 return nil, fmt.Errorf("failed to create routing requests: %w", err)
60 }
61
62 if len(requests) == 0 {
63 // No routes to calculate
64 return nil, nil
65 }
66
67 slog.Info("calculating missing routes", "count", len(requests))
68
69 // Process requests in parallel
70 results := make([]CalculatedRoute, len(requests))
71 var wg sync.WaitGroup
72
73 for i, req := range requests {
74 wg.Add(1)
75 go func(idx int, r RoutingRequest) {
76 defer wg.Done()
77 results[idx] = executeRoutingRequest(r, storage)
78 }(i, req)
79 }
80
81 wg.Wait()
82
83 // Check for errors and log them
84 var hasErrors bool
85 for _, result := range results {
86 if result.Error != nil {
87 hasErrors = true
88 slog.Error("route calculation failed",
89 "waypoint", result.WaypointID,
90 "routeKey", result.RouteKey,
91 "error", result.Error)
92 }
93 }
94
95 if hasErrors {
96 // Return results even if some failed - caller can decide what to do
97 slog.Warn("some route calculations failed, continuing with successful ones")
98 }
99
100 return results, nil
101}
102
103// executeRoutingRequest sends a single routing request to Graphhopper API with backoff retry
104func executeRoutingRequest(req RoutingRequest, storage *routing.RouteStorage) CalculatedRoute {
105 result := CalculatedRoute{
106 WaypointID: req.WaypointID,
107 RouteKey: req.RouteKey,
108 }
109
110 slog.Debug("executeRoutingRequest: starting",
111 "waypointID", req.WaypointID,
112 "routeKey", req.RouteKey)
113
114 // Read the request body to extract locations
115 bodyBytes, err := io.ReadAll(req.Request.Body)
116 if err != nil {
117 result.Error = fmt.Errorf("failed to read request body: %w", err)
118 slog.Error("executeRoutingRequest: failed to read request body",
119 "waypointID", req.WaypointID,
120 "error", err)
121 return result
122 }
123 req.Request.Body.Close()
124
125 slog.Debug("executeRoutingRequest: request body read",
126 "waypointID", req.WaypointID,
127 "bodySize", len(bodyBytes))
128
129 // Parse the request to get points
130 var requestPayload struct {
131 Points [][]float64 `json:"points"`
132 }
133 if err := json.Unmarshal(bodyBytes, &requestPayload); err != nil {
134 result.Error = fmt.Errorf("failed to parse request body: %w", err)
135 slog.Error("executeRoutingRequest: failed to parse request body",
136 "waypointID", req.WaypointID,
137 "error", err)
138 return result
139 }
140
141 slog.Debug("executeRoutingRequest: request parsed",
142 "waypointID", req.WaypointID,
143 "numPoints", len(requestPayload.Points))
144
145 if len(requestPayload.Points) < 2 {
146 result.Error = fmt.Errorf("request must have at least 2 points, got %d", len(requestPayload.Points))
147 slog.Error("executeRoutingRequest: insufficient points",
148 "waypointID", req.WaypointID,
149 "numPoints", len(requestPayload.Points))
150 return result
151 }
152
153 // Convert points to graphhopper format
154 points := make([]graphhopper.PointLngLat, len(requestPayload.Points))
155 for i, pt := range requestPayload.Points {
156 if len(pt) < 2 {
157 result.Error = fmt.Errorf("point %d has invalid coordinates", i)
158 slog.Error("executeRoutingRequest: invalid point coordinates",
159 "waypointID", req.WaypointID,
160 "pointIndex", i,
161 "pointLen", len(pt))
162 return result
163 }
164 points[i] = graphhopper.PointLngLat{pt[0], pt[1]}
165 }
166
167 slog.Debug("executeRoutingRequest: calling graphhopper API",
168 "waypointID", req.WaypointID,
169 "numPoints", len(points),
170 "firstPoint", points[0],
171 "lastPoint", points[len(points)-1])
172
173 // Execute the routing request with backoff
174 resp, err := graphhopperRouteWithBackoff(points)
175 if err != nil {
176 result.Error = fmt.Errorf("routing API error: %w", err)
177
178 // Check for non-retryable errors (4XX client errors)
179 // All 4XX errors mean the request is invalid and won't succeed on retry
180 if graphhopper.IsNonRetryable(err) {
181 // Map specific error types to error codes
182 switch {
183 case errors.Is(err, &graphhopper.RouteTooFarError{}):
184 result.ErrorCodes = []string{string(RouteTooFarError)}
185 case errors.Is(err, &graphhopper.PointNotFoundError{}):
186 result.ErrorCodes = []string{string(PointNotFoundError)}
187 default:
188 // Generic client error
189 result.ErrorCodes = []string{string(RouteFailedError)}
190 }
191 // Store NoRouteSentinel to prevent infinite retry loops
192 // The route ID will be NoRouteID which marks it as "calculated but no path"
193 result.Route = NoRouteSentinel
194 slog.Info("stored NoRouteSentinel for non-retryable error",
195 "waypointID", req.WaypointID,
196 "routeKey", req.RouteKey,
197 "errorCodes", result.ErrorCodes)
198 }
199
200 slog.Error("executeRoutingRequest: routing API error",
201 "waypointID", req.WaypointID,
202 "error", err)
203 return result
204 }
205
206 slog.Debug("executeRoutingRequest: received response",
207 "waypointID", req.WaypointID,
208 "numPaths", len(resp.Paths))
209
210 // Parse the response
211 route, decodedPoints, elevation, err := parseGraphhopperResponse(resp)
212 if err != nil {
213 result.Error = fmt.Errorf("failed to parse route response: %w", err)
214 slog.Error("executeRoutingRequest: failed to parse response",
215 "waypointID", req.WaypointID,
216 "error", err)
217 return result
218 }
219
220 slog.Debug("executeRoutingRequest: response parsed",
221 "waypointID", req.WaypointID,
222 "routeID", route.ID,
223 "distance", route.Distance,
224 "time", route.Time,
225 "bbox", route.Bbox,
226 "numDecodedPoints", len(decodedPoints),
227 "numElevationPoints", len(elevation))
228
229 result.Route = route
230 result.Points = decodedPoints
231 result.Elevation = elevation
232
233 // Extract source ID from route key
234 sourceID, _, _ := FromRouteKey(req.RouteKey)
235 result.SourceID = sourceID
236
237 // Store original points from request
238 if len(requestPayload.Points) >= 2 {
239 result.SourceOrig = []float64{requestPayload.Points[0][0], requestPayload.Points[0][1]}
240 result.DestOrig = []float64{requestPayload.Points[1][0], requestPayload.Points[1][1]}
241 }
242
243 // Set snapped points from decoded points
244 if result.Route.ID != NoRouteID && len(decodedPoints) >= 2 {
245 result.SourceSnap = decodedPoints[0]
246 result.DestSnap = decodedPoints[len(decodedPoints)-1]
247 }
248
249 // Save to storage if provided (skip for NoRoute sentinel)
250 if storage != nil && route.ID != NoRouteID {
251 slog.Debug("executeRoutingRequest: saving to storage",
252 "waypointID", req.WaypointID,
253 "routeID", route.ID,
254 "numPoints", len(decodedPoints),
255 "bbox", route.Bbox)
256
257 if err := storage.SaveRoute(route.ID, decodedPoints, route.Distance, route.Time, route.Bbox); err != nil {
258 slog.Error("executeRoutingRequest: failed to save route to storage",
259 "waypointID", req.WaypointID,
260 "routeID", route.ID,
261 "error", err)
262 } else {
263 slog.Debug("executeRoutingRequest: route saved successfully",
264 "waypointID", req.WaypointID,
265 "routeID", route.ID)
266 }
267
268 // Also save elevation data
269 if len(elevation) > 0 {
270 if err := storage.SaveElevation(route.ID, elevation); err != nil {
271 slog.Error("executeRoutingRequest: failed to save elevation to storage",
272 "waypointID", req.WaypointID,
273 "routeID", route.ID,
274 "error", err)
275 } else {
276 slog.Debug("executeRoutingRequest: elevation saved successfully",
277 "waypointID", req.WaypointID,
278 "routeID", route.ID,
279 "numElevationPoints", len(elevation))
280 }
281 }
282 } else {
283 slog.Debug("executeRoutingRequest: skipping storage save",
284 "waypointID", req.WaypointID,
285 "hasStorage", storage != nil,
286 "routeID", route.ID,
287 "isNoRoute", route.ID == NoRouteID)
288 }
289
290 slog.Debug("executeRoutingRequest: completed",
291 "waypointID", req.WaypointID,
292 "routeID", route.ID,
293 "hasError", result.Error != nil)
294
295 return result
296}
297
298// graphhopperRouteWithBackoff retries the routing request up to four times.
299// It does NOT retry on non-retryable errors (all 4XX client errors).
300func graphhopperRouteWithBackoff(pts []graphhopper.PointLngLat) (*graphhopper.Response, error) {
301 sleepTimes := []time.Duration{0, 5 * time.Second, 10 * time.Second, 30 * time.Second}
302 var lastErr error
303 for i, sleepTime := range sleepTimes {
304 if i > 0 {
305 time.Sleep(sleepTime)
306 }
307 resp, err := graphhopper.Route(pts)
308 if err == nil {
309 return resp, nil
310 }
311 lastErr = err
312 // Don't retry on non-retryable errors (all 4XX client errors)
313 // 4XX errors indicate a bad request that won't succeed on retry
314 if graphhopper.IsNonRetryable(err) {
315 return nil, fmt.Errorf("routing error: %w", err)
316 }
317 }
318 return nil, fmt.Errorf("routing error: no response in %d tries: %w", len(sleepTimes), lastErr)
319}
320
321// parseGraphhopperResponse extracts route data from Graphhopper API response
322func parseGraphhopperResponse(resp *graphhopper.Response) (Route, [][]float64, []float64, error) {
323 slog.Debug("parseGraphhopperResponse: starting",
324 "numPaths", len(resp.Paths))
325
326 if len(resp.Paths) == 0 {
327 slog.Debug("parseGraphhopperResponse: no paths in response, returning NoRouteSentinel")
328 return NoRouteSentinel, nil, nil, nil
329 }
330
331 // Log first path details
332 if len(resp.Paths) > 0 {
333 firstPath := resp.Paths[0]
334 slog.Debug("parseGraphhopperResponse: first path details",
335 "distance", firstPath.Distance,
336 "time", firstPath.Time,
337 "ascend", firstPath.Ascend,
338 "descend", firstPath.Descend,
339 "pointsEncoded", firstPath.PointsEncoded,
340 "pointsLen", len(firstPath.Points),
341 "bbox", firstPath.Bbox)
342 }
343
344 // Get points and elevation from response
345 points, err := resp.Points()
346 if err != nil {
347 slog.Error("parseGraphhopperResponse: failed to get points from response",
348 "error", err)
349 return Route{}, nil, nil, fmt.Errorf("failed to get points from response: %w", err)
350 }
351
352 slog.Debug("parseGraphhopperResponse: decoded points",
353 "numPoints", len(points),
354 "firstPoint", func() []float64 {
355 if len(points) > 0 {
356 return points[0]
357 }
358 return nil
359 }(),
360 "lastPoint", func() []float64 {
361 if len(points) > 0 {
362 return points[len(points)-1]
363 }
364 return nil
365 }())
366
367 elevation, err := resp.Elevation()
368 if err != nil {
369 slog.Error("parseGraphhopperResponse: failed to get elevation from response",
370 "error", err)
371 return Route{}, nil, nil, fmt.Errorf("failed to get elevation from response: %w", err)
372 }
373
374 slog.Debug("parseGraphhopperResponse: decoded elevation",
375 "numElevationPoints", len(elevation))
376
377 if len(points) == 0 {
378 slog.Warn("parseGraphhopperResponse: no points decoded, returning NoRouteSentinel")
379 return NoRouteSentinel, nil, nil, nil
380 }
381
382 // Compute ID as SHA256 hash of JSON-encoded points
383 routeID := routing.HashPoints(points)
384 slog.Debug("parseGraphhopperResponse: computed route ID",
385 "routeID", routeID)
386
387 // Get stats from response
388 stats := resp.Stats()
389 slog.Debug("parseGraphhopperResponse: response stats",
390 "distanceMeters", stats.Distance,
391 "timeSeconds", stats.Time,
392 "ascend", stats.Ascend,
393 "descend", stats.Descend)
394
395 // Build bounding box from points
396 bbox := calculateBbox(points)
397 slog.Debug("parseGraphhopperResponse: calculated bbox",
398 "bbox", bbox)
399
400 route := Route{
401 ID: routeID,
402 Distance: stats.Distance / 1000.0, // Convert meters to kilometers
403 Time: stats.Time, // Time in seconds (frontend expects seconds)
404 Bbox: bbox,
405 }
406
407 slog.Debug("parseGraphhopperResponse: completed",
408 "routeID", route.ID,
409 "distanceKm", route.Distance,
410 "timeMinutes", route.Time,
411 "bbox", route.Bbox)
412
413 return route, points, elevation, nil
414}
415
416// calculateBbox computes bounding box from points: [min_lon, min_lat, max_lon, max_lat]
417func calculateBbox(points [][]float64) []float64 {
418 slog.Debug("calculateBbox: starting",
419 "numPoints", len(points))
420
421 if len(points) == 0 {
422 slog.Warn("calculateBbox: no points provided, returning zero bbox")
423 return []float64{0, 0, 0, 0}
424 }
425
426 minLon, minLat := points[0][0], points[0][1]
427 maxLon, maxLat := points[0][0], points[0][1]
428
429 slog.Debug("calculateBbox: initialized with first point",
430 "firstPoint", points[0],
431 "initialMinLon", minLon,
432 "initialMinLat", minLat,
433 "initialMaxLon", maxLon,
434 "initialMaxLat", maxLat)
435
436 for i, pt := range points[1:] {
437 if len(pt) < 2 {
438 slog.Warn("calculateBbox: skipping point with insufficient coordinates",
439 "pointIndex", i+1,
440 "pointLen", len(pt))
441 continue
442 }
443 if pt[0] < minLon {
444 minLon = pt[0]
445 }
446 if pt[0] > maxLon {
447 maxLon = pt[0]
448 }
449 if pt[1] < minLat {
450 minLat = pt[1]
451 }
452 if pt[1] > maxLat {
453 maxLat = pt[1]
454 }
455 }
456
457 bbox := []float64{minLon, minLat, maxLon, maxLat}
458 slog.Debug("calculateBbox: completed",
459 "numPointsProcessed", len(points),
460 "bbox", bbox)
461
462 return bbox
463}
464
465// MaxSnapDistance is the maximum distance in meters for waypoint snapping
466const MaxSnapDistance = 1000.0
467
468// shouldSnapPoint determines if a waypoint should be snapped to a route point.
469// Returns true if the distance between original and snapped points is <= 1000m.
470func shouldSnapPoint(original, snapped []float64) bool {
471 if len(original) < 2 || len(snapped) < 2 {
472 return false
473 }
474
475 // geo.Distance expects orb.Point which is [lon, lat]
476 origPoint := orb.Point{original[0], original[1]}
477 snapPoint := orb.Point{snapped[0], snapped[1]}
478
479 distance := geolib.Distance(origPoint, snapPoint)
480 return distance <= MaxSnapDistance
481}
482
483// ToRouteUpdates converts calculated routes to route updates with point snapping applied.
484// It includes error codes for routes with errors and applies the 1000m distance limit for snapping.
485func ToRouteUpdates(routes []CalculatedRoute) []RouteUpdate {
486 updates := make([]RouteUpdate, 0, len(routes))
487
488 for _, r := range routes {
489 update := RouteUpdate{
490 WaypointID: r.WaypointID,
491 RouteKey: r.RouteKey,
492 Route: r.Route,
493 ErrorCodes: r.ErrorCodes,
494 }
495
496 // Only apply point updates for successful routes
497 if r.Error == nil && r.Route.ID != NoRouteID {
498 if len(r.DestSnap) >= 2 && len(r.DestOrig) >= 2 {
499 if shouldSnapPoint(r.DestOrig, r.DestSnap) {
500 update.NewPoint = r.DestSnap
501 }
502 }
503 }
504
505 updates = append(updates, update)
506 }
507
508 return updates
509}
510
511// ToSourcePointUpdates creates point updates for source waypoints.
512// This should be called separately to update source waypoint locations.
513func ToSourcePointUpdates(routes []CalculatedRoute) map[string][]float64 {
514 updates := make(map[string][]float64)
515
516 for _, r := range routes {
517 if r.Error != nil || r.Route.ID == NoRouteID {
518 continue
519 }
520
521 // Check if source point should be snapped
522 if len(r.SourceSnap) >= 2 && len(r.SourceOrig) >= 2 {
523 if shouldSnapPoint(r.SourceOrig, r.SourceSnap) {
524 updates[r.SourceID] = r.SourceSnap
525 }
526 }
527 }
528
529 return updates
530}