Rejoin-point selection for off-route guidance (issue #57) #124
No reviewers
Labels
No labels
area:companion
area:docs
area:shared
area:tooling
area:watchapp
blocker
kind:chore
kind:feature
kind:spike
kind:test
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
robert/PedalPebble!124
Loading…
Reference in a new issue
No description provided.
Delete branch "area/rejoin-point-selection"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #57.
Implements the final, 2026-09-02 scope of #57 (D42) after two scope cuts: given the rider is off-route right now, choose a sensible point back on the course and report the bearing/distance to it. Does not own off-route detection (#32, unbuilt, still owns hysteresis/enter-exit), and does not enrich or splice a detour (D42 moved that whole half of the original issue to a future Phase 5 re-routing issue, #73). This is a pure function ready for #32 to call once it exists.
Files
companion/core/src/main/kotlin/de/butzei/pedalpebble/core/route/nav/RejoinPointSelector.ktcompanion/core/src/test/kotlin/de/butzei/pedalpebble/core/route/nav/RejoinPointSelectorTest.ktThe algorithm
RejoinPointSelector.select(lastOnRouteSnap: RouteSnap, offRouteFix: GpsFix, headingDegreesTrue: Double)reusesRouteGeodesy.locateAlongPolylinedirectly (no reimplementation of point-to-segment projection): it projects the rider's current off-route GPS fix onto the route polyline, searching forward only fromlastOnRouteSnap.segmentStartIndex— never before it — unbounded to the route's end.That is the operational, testable definition of "sensible" this issue calls for: the nearest point on the route, restricted to route order at or after where the rider left it — never the nearest point over the whole route. "Nearest over the whole route" is exactly what can send a rider backward: an out-and-back's return leg is geometrically identical to its outbound leg, and any route that passes close to itself (a loop, a lollipop) can have a spatially-closer point sitting behind the rider's last confirmed position.
Unlike
RouteSnapper's own per-fix bounded window (which must stay cheap across an entire ride, re-run every GPS fix), this search is unbounded:select()runs once per off-route episode, not once per fix, so there's no per-call cost to amortise, and an arbitrary horizon risks excluding a genuinely-better rejoin point farther down the route for no correctness benefit.The two required fixtures, and how each exercises the naive-vs-forward divergence
Both tests assert the algorithm lands on the forward answer and never the (closer, but behind) naive one.
Bearing convention
docs/PROTOCOL.md'sNAV_REJOIN_BEARING(id 48, uint16) is "degrees to the rejoin point, relative to the rider's heading" but doesn't itself pin down which relative convention. This PR documents and implements:[0, 360), compass-style, clockwise from "ahead" — 0 = straight ahead, 90 = directly right, 180 = directly behind, 270 = directly left.NAV_REJOIN_Mis the straight-line (haversine) distance from the rider's current off-route position to the rejoin point (not a distance-along-route figure — the rider is off the route, there is no route-distance path to measure).Heading input
GpsFixcarries no heading of its own (checked — it's position/accuracy/speed only), and neither doesSpeedPipelineSample. There is no heading-of-travel signal anywhere in the ride pipeline yet, soselect()takes the rider's current true-north heading as an explicit parameter, the same wayMapSliceBuilderalready takesheadingDegreesTrueforMAP_HEADINGrather than deriving one itself. Obtaining it (platformLocation.getBearing(), or a bearing derived from recent fixes) is left to whichever caller wires this in — plausibly #32.Output type
RejoinPoint(bearingDegreesRelative, distanceMeters, distanceAlongRouteMeters)— a plain in-memory type, not a wire encoding, matching the establishedRouteSnap/MapSlice/NavUpdatepattern. #4's real PebbleKit transport hasn't landed, so wire-encoding this into an actualNAV_REJOIN_BEARING/NAV_REJOIN_MAppMessage is left for that later layer.Reused vs. new
RouteGeodesy.locateAlongPolyline,haversineMeters,bearingDegrees,cumulativeDistancesMeters;RouteSnapas an input type, unchanged.RejoinPointSelector/RejoinPoint, and one small privateinterpolatedPointhelper (mirrorspointAtDistanceMeters's interpolation but reuses the segment indexlocateAlongPolylinealready found, rather than re-scanning from the route start).Naming
The issue's
Filessection namedRerouter.kt. Renamed toRejoinPointSelector.kt/RejoinPointSelectorbecause D42 deleted the re-routing/splicing half of the original issue outright — this class never re-routes, it only selects a rejoin point, matching howRouteSnapper/NavEngineare named for what they actually do in this codebase. Checked: no other doc or issue'sFilessection references the literalRerouter.ktfilename, so nothing else needs reconciling.Tests
Real
./gradlew :companion:core:test --rerun-tasksrun, full:companion:coresuite green, including the 5 newRejoinPointSelectorTestcases (empty-route/end-of-route edge cases, the two required fixtures, and a bearing-convention check).https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt