Rejoin-point selection for off-route guidance (issue #57) #124

Merged
robert merged 1 commit from area/rejoin-point-selection into main 2026-09-06 13:12:03 +02:00
Owner

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.kt
  • companion/core/src/test/kotlin/de/butzei/pedalpebble/core/route/nav/RejoinPointSelectorTest.kt

The algorithm

RejoinPointSelector.select(lastOnRouteSnap: RouteSnap, offRouteFix: GpsFix, headingDegreesTrue: Double) reuses RouteGeodesy.locateAlongPolyline directly (no reimplementation of point-to-segment projection): it projects the rider's current off-route GPS fix onto the route polyline, searching forward only from lastOnRouteSnap.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

  • Out-and-back: outbound (0,50,100,150,200 m east) then the same road retraced back. The rider's last on-route position is on the return leg (index 6, 100 m east, 300 m into the ride). An off-route fix landing exactly on (100 m east, 0) has two exact, zero-offset matches on the whole route: index 2 (outbound, 100 m in — behind this rider) and index 6 itself (return, 300 m in). A naive "first minimum wins" whole-route scan reports index 2, sending the rider 200 m backward along a route they're actually most of the way back along. The forward-only search, starting at index 6, correctly can't even see index 2.
  • Route that passes close to itself: a stem east from the origin, then a loop that climbs away and dips back to within ~4 m of the stem's own start before continuing to a different finish — close, not coincident. The rider's last on-route position is 100 m along the stem; they go off-route to a point ~1.4 m from the stem's start (behind them) but ~4.0 m from the loop's later near-repeat (genuinely ahead, ~595 m into the route). Forward-only correctly picks the farther-but-ahead point.

Both tests assert the algorithm lands on the forward answer and never the (closer, but behind) naive one.

Bearing convention

docs/PROTOCOL.md's NAV_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_M is 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

GpsFix carries no heading of its own (checked — it's position/accuracy/speed only), and neither does SpeedPipelineSample. There is no heading-of-travel signal anywhere in the ride pipeline yet, so select() takes the rider's current true-north heading as an explicit parameter, the same way MapSliceBuilder already takes headingDegreesTrue for MAP_HEADING rather than deriving one itself. Obtaining it (platform Location.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 established RouteSnap/MapSlice/NavUpdate pattern. #4's real PebbleKit transport hasn't landed, so wire-encoding this into an actual NAV_REJOIN_BEARING/NAV_REJOIN_M AppMessage is left for that later layer.

Reused vs. new

  • Reused as-is: RouteGeodesy.locateAlongPolyline, haversineMeters, bearingDegrees, cumulativeDistancesMeters; RouteSnap as an input type, unchanged.
  • New: RejoinPointSelector/RejoinPoint, and one small private interpolatedPoint helper (mirrors pointAtDistanceMeters's interpolation but reuses the segment index locateAlongPolyline already found, rather than re-scanning from the route start).

Naming

The issue's Files section named Rerouter.kt. Renamed to RejoinPointSelector.kt/RejoinPointSelector because D42 deleted the re-routing/splicing half of the original issue outright — this class never re-routes, it only selects a rejoin point, matching how RouteSnapper/NavEngine are named for what they actually do in this codebase. Checked: no other doc or issue's Files section references the literal Rerouter.kt filename, so nothing else needs reconciling.

Tests

Real ./gradlew :companion:core:test --rerun-tasks run, full :companion:core suite green, including the 5 new RejoinPointSelectorTest cases (empty-route/end-of-route edge cases, the two required fixtures, and a bearing-convention check).

https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt

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.kt` - `companion/core/src/test/kotlin/de/butzei/pedalpebble/core/route/nav/RejoinPointSelectorTest.kt` ### The algorithm `RejoinPointSelector.select(lastOnRouteSnap: RouteSnap, offRouteFix: GpsFix, headingDegreesTrue: Double)` reuses `RouteGeodesy.locateAlongPolyline` directly (no reimplementation of point-to-segment projection): it projects the rider's current off-route GPS fix onto the route polyline, searching **forward only** from `lastOnRouteSnap.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 - **Out-and-back**: outbound (0,50,100,150,200 m east) then the same road retraced back. The rider's last on-route position is on the return leg (index 6, 100 m east, 300 m into the ride). An off-route fix landing exactly on (100 m east, 0) has two *exact*, zero-offset matches on the whole route: index 2 (outbound, 100 m in — **behind** this rider) and index 6 itself (return, 300 m in). A naive "first minimum wins" whole-route scan reports index 2, sending the rider 200 m backward along a route they're actually most of the way back along. The forward-only search, starting at index 6, correctly can't even see index 2. - **Route that passes close to itself**: a stem east from the origin, then a loop that climbs away and dips back to within ~4 m of the stem's own start before continuing to a different finish — close, not coincident. The rider's last on-route position is 100 m along the stem; they go off-route to a point ~1.4 m from the stem's start (**behind** them) but ~4.0 m from the loop's later near-repeat (genuinely **ahead**, ~595 m into the route). Forward-only correctly picks the farther-but-ahead point. Both tests assert the algorithm lands on the forward answer and never the (closer, but behind) naive one. ### Bearing convention `docs/PROTOCOL.md`'s `NAV_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_M` is 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 `GpsFix` carries no heading of its own (checked — it's position/accuracy/speed only), and neither does `SpeedPipelineSample`. There is no heading-of-travel signal anywhere in the ride pipeline yet, so `select()` takes the rider's current true-north heading as an explicit parameter, the same way `MapSliceBuilder` already takes `headingDegreesTrue` for `MAP_HEADING` rather than deriving one itself. Obtaining it (platform `Location.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 established `RouteSnap`/`MapSlice`/`NavUpdate` pattern. #4's real PebbleKit transport hasn't landed, so wire-encoding this into an actual `NAV_REJOIN_BEARING`/`NAV_REJOIN_M` AppMessage is left for that later layer. ### Reused vs. new - Reused as-is: `RouteGeodesy.locateAlongPolyline`, `haversineMeters`, `bearingDegrees`, `cumulativeDistancesMeters`; `RouteSnap` as an input type, unchanged. - New: `RejoinPointSelector`/`RejoinPoint`, and one small private `interpolatedPoint` helper (mirrors `pointAtDistanceMeters`'s interpolation but reuses the segment index `locateAlongPolyline` already found, rather than re-scanning from the route start). ### Naming The issue's `Files` section named `Rerouter.kt`. Renamed to `RejoinPointSelector.kt`/`RejoinPointSelector` because D42 deleted the re-routing/splicing half of the original issue outright — this class never re-routes, it only selects a rejoin point, matching how `RouteSnapper`/`NavEngine` are named for what they actually do in this codebase. Checked: no other doc or issue's `Files` section references the literal `Rerouter.kt` filename, so nothing else needs reconciling. ### Tests Real `./gradlew :companion:core:test --rerun-tasks` run, full `:companion:core` suite green, including the 5 new `RejoinPointSelectorTest` cases (empty-route/end-of-route edge cases, the two required fixtures, and a bearing-convention check). https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
Rejoin-point selection for off-route guidance (issue #57, D42 scope)
Some checks failed
dev-artifact / build-pbw (push) Failing after 0s
dev-artifact / build-apk (push) Failing after 0s
dev-artifact / publish (push) Has been skipped
fast-lane / host-c-tests (push) Failing after 0s
fast-lane / jvm-tests (push) Failing after 0s
fast-lane / pebble-build (push) Failing after 0s
fast-lane / lint-and-secrets (push) Failing after 0s
fast-lane / meta-declares-required-jobs (push) Failing after 0s
fast-lane / host-c-tests (pull_request) Failing after 0s
fast-lane / jvm-tests (pull_request) Failing after 0s
fast-lane / pebble-build (pull_request) Failing after 0s
fast-lane / lint-and-secrets (pull_request) Failing after 0s
fast-lane / meta-declares-required-jobs (pull_request) Failing after 0s
d800199bb1
Implements the final, 2026-09-02 scope of #57: given the rider is
off-route right now, choose a sensible rejoin point on the course and
report the bearing (relative to the rider's heading) and distance to
it. Does not detect off-route-ness (#32's job) and does not enrich or
splice a detour (D42 moved that to a future Phase 5 issue) - this is
a pure function ready for #32 to call once it exists.

RejoinPointSelector.select() reuses RouteGeodesy's
locateAlongPolyline directly: given the rider's last on-route RouteSnap
and their current off-route GpsFix, it searches the route polyline
forward-only from the RouteSnap's own segmentStartIndex (never
before it), unbounded to the route's end. That is the operational
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, which is exactly
what can send a rider backward on an out-and-back or a route that
passes close to itself without repeating it. Two tests exercise that
divergence directly: an out-and-back where the naive nearest match
is an exact-coordinate twin 200 m behind, and a route with a loop
that dips within a few meters of its own earlier stem, where the
naive nearest match is a near-miss behind the rider rather than the
correct, slightly farther one ahead.

GpsFix carries no heading of its own, and neither does
SpeedPipelineSample - there is no heading-of-travel signal anywhere
in the ride pipeline yet, so select() takes the rider's current
true-north heading as an explicit parameter, matching how
MapSliceBuilder already takes headingDegreesTrue for MAP_HEADING
rather than deriving one itself.

Output is a plain in-memory RejoinPoint type (bearingDegreesRelative,
distanceMeters, distanceAlongRouteMeters), not a wire encoding - #4's
real PebbleKit transport hasn't landed, matching the established
RouteSnap/MapSlice/NavUpdate pattern.

Named RejoinPointSelector.kt rather than the issue's original
Rerouter.kt: D42 deleted the re-routing/splicing half of this issue
(moved to a future Phase 5 issue), so the class this issue now needs
only selects a rejoin point - it never re-routes. No other doc or
issue's Files section references the literal Rerouter.kt filename.

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
robert merged commit 299ac4460d into main 2026-09-06 13:12:03 +02:00
Sign in to join this conversation.
No description provided.