Off-route re-routing back to the course #57
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
2 participants
Notifications
Due date
No due date set.
Blocks
Depends on
#73 Off-route re-routing with cue-sheet splicing
robert/PedalPebble
#31 Navigation engine: windowed snapping to the route polyline
robert/PedalPebble
Reference
robert/PedalPebble#57
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Goal
When the rider leaves the route, compute a way back. BRouter is already a dependency and does this entirely offline, so no connectivity is needed mid-ride.
Acceptance criteria
Files
companion/.../route/nav/Rerouter.ktNotes
Pulled into Phase 3 so it is built while the navigation engine is fresh. See docs/DECISIONS.md D20.
Update — 2026-09-01: splicing is the hard half, and D7 gets one bounded exception
D7 says enrichment happens once at import and never during a ride. Re-routing necessarily enriches
a detour mid-ride. Both could not be true; D30 resolves it — re-routing is the single named exception,
and it is bounded:
ever happens mid-ride — that is what D7's rule was really protecting
off-route rather than a stale or partial cue sheet
This issue previously covered only the rejoin-point choice. The splice is the part that breaks things:
when the detour is accepted
NAV_REMAIN_MandNAV_ETA_Srecomputed against the new geometry, and must not jumpNAV_STATE = 3(re-routing) shown while the detour is being computed, so the watch is honestabout what it is doing
Update — 2026-09-02: scope cut — the bearing arrow, not the splice
D42 supersedes D30. Everything about enriching and splicing a detour moves to a new Phase 5 issue.
What ships in Phase 3 is the part an off-route rider actually needs: a direction and a distance.
With the map off by default, a rider who has taken a wrong turn is looking at a screen that says "off
route". A bearing arrow answers that. A freshly enriched cue sheet is a great deal of machinery for
the same question.
This issue now covers only:
nearest point on the course is often wrong, because it can mean riding backwards along the route
NAV_REJOIN_BEARING/NAV_REJOIN_M(#32)Removed from this issue (now the Phase 5 re-routing issue): the BRouter call for a return leg, the
tier-1-only rule, the mid-ride time budget, cue merging and index shifting, the new route object,
NAV_REMAIN_M/NAV_ETA_Srecomputation, andNAV_STATE = 3.Two consequences worth noting. D7's "enrichment happens once, at import — never during a ride" is true
again with no exception, which makes the airplane-mode acceptance test (NFR-C3) something a test
can assert absolutely. And #31's monotonic distance axis is now guaranteed by construction.
Closed by PR #124 (
area/rejoin-point-selection), D42's final scope after two rounds of scope cuts — the whole BRouter-detour-splicing half moved to a future Phase 5 issue; this is purely rejoin-point selection.New
RejoinPointSelector/RejoinPointincompanion/core/.../route/nav/RejoinPointSelector.kt(renamed from the issue's literalRerouter.ktsince D42 deleted everything that name implied — checked nothing else references that literal filename). Correctly does not decide whether the rider is off-route (that's #32's still-open hysteresis) — it's a pure function #32 will call once it exists."Not simply the nearest point": reuses
locateAlongPolyline(#26) with a forward-only search from the rider's last confirmed on-route position (RouteSnap.segmentStartIndex), unbounded to the route's end — deliberately unbounded (unlikeRouteSnapper's per-fix bounded window) since this runs once per off-route episode, not once per fix, so there's no cost to amortise and no reason to risk excluding a genuinely-better distant rejoin point. Two real fixtures precisely constructed to expose where a naive whole-route nearest-point search fails: an out-and-back where the off-route fix has two exact zero-offset matches (100m behind on the outbound leg vs. 300m ahead on the return leg — naive picks the wrong one), and a route passing close to itself where the nearer candidate (1.4m away, but behind) loses correctly to the farther-but-forward one (4.0m away, 595m ahead). Both independently verified with a standalone script before asserting, per the PR's own account.Bearing convention documented, not left ambiguous: PROTOCOL.md fixes
NAV_REJOIN_BEARINGas "relative to the rider's heading" but not which convention "relative" resolves to — this PR picks and documents compass-style clockwise-from-ahead (0=ahead, 90=right, 180=behind, 270=left), verified by two test cases I independently re-checked by hand (facing east with the rejoin point due south = 90°, i.e. to the right — correct).GpsFixcarries no heading field (confirmed by inspection), soselect()takes the rider's heading as an explicit parameter, matchingMapSliceBuilder's own precedent forMAP_HEADING.Correctly stayed out of #32's territory and #4's wire-transport layer — produces a plain, tested in-memory type for a later caller/encoder, matching
RouteSnap/MapSlice/NavUpdate's established pattern.Real verification: genuine
./gradlew :companion:core:test --rerun-tasksfull rebuild, all green,RejoinPointSelectorTest5/5.46 issues closed.