Off-route re-routing back to the course #57

Closed
opened 2026-08-31 17:30:08 +02:00 by robert · 1 comment
robert commented 2026-08-31 17:30:08 +02:00 (Migrated from git.butzei.de)

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

  • Re-route offered after off-route is confirmed, not on the first stray metre
  • BRouter asked for a route from the current position to a rejoin point on the course
  • Rejoin point chosen sensibly - not the nearest point if that means riding backwards along the route
  • Returned leg enriched into cues and spliced into the active cue sheet
  • Watch guides along the return leg, then resumes the original route
  • Rider can decline and continue navigating manually
  • Re-routing never blocks the ride view or the recording, and runs off the main thread
  • Repeated failure to re-route degrades gracefully to plain off-route reporting
  • Behaviour when BRouter is unavailable is defined and tested

Files

  • companion/.../route/nav/Rerouter.kt

Notes

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:

  • Tier 1 only. A re-route enriches through BRouter offline, or not at all. No network call
    ever happens mid-ride
    — that is what D7's rule was really protecting
  • Detour enrichment runs under a hard time budget; if it does not complete, the watch shows
    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:

  • Detour cues merged into the existing cue sheet, with every later cue's index shifted
  • The result is a new route object with its own monotonic distance axis (#31), swapped in once
    when the detour is accepted
  • NAV_REMAIN_M and NAV_ETA_S recomputed against the new geometry, and must not jump
  • NAV_STATE = 3 (re-routing) shown while the detour is being computed, so the watch is honest
    about what it is doing
  • Declining or ignoring a re-route returns cleanly to plain off-route
  • Tests: re-route on an out-and-back, and a re-route that rejoins ahead of where the rider left

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:

  • Rejoin-point selection — D20's genuinely hard part, and now the whole of this issue. The
    nearest point on the course is often wrong, because it can mean riding backwards along the route
  • Bearing and distance to that point emitted as NAV_REJOIN_BEARING / NAV_REJOIN_M (#32)
  • Rejoin detected, and the original cue sheet resumed on its original distance axis
  • Nothing is enriched, nothing is spliced, no new route object is created
  • Tests: rejoin-point choice on an out-and-back, and on a route that passes close to itself

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_S recomputation, and NAV_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.

## 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 - [ ] Re-route offered after off-route is confirmed, not on the first stray metre - [ ] BRouter asked for a route from the current position to a rejoin point on the course - [ ] Rejoin point chosen sensibly - not the nearest point if that means riding backwards along the route - [ ] Returned leg enriched into cues and spliced into the active cue sheet - [ ] Watch guides along the return leg, then resumes the original route - [ ] Rider can decline and continue navigating manually - [ ] Re-routing never blocks the ride view or the recording, and runs off the main thread - [ ] Repeated failure to re-route degrades gracefully to plain off-route reporting - [ ] Behaviour when BRouter is unavailable is defined and tested ## Files - `companion/.../route/nav/Rerouter.kt` ## Notes 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: - [ ] **Tier 1 only.** A re-route enriches through BRouter offline, or not at all. **No network call ever happens mid-ride** — that is what D7's rule was really protecting - [ ] Detour enrichment runs under a hard time budget; if it does not complete, the watch shows 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: - [ ] Detour cues merged into the existing cue sheet, with every later cue's index shifted - [ ] The result is a **new route object with its own monotonic distance axis** (#31), swapped in once when the detour is accepted - [ ] `NAV_REMAIN_M` and `NAV_ETA_S` recomputed against the new geometry, and **must not jump** - [ ] `NAV_STATE = 3` (re-routing) shown while the detour is being computed, so the watch is honest about what it is doing - [ ] Declining or ignoring a re-route returns cleanly to plain off-route - [ ] Tests: re-route on an out-and-back, and a re-route that rejoins ahead of where the rider left ## 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:** - [ ] **Rejoin-point selection** — D20's genuinely hard part, and now the whole of this issue. The nearest point on the course is often wrong, because it can mean riding backwards along the route - [ ] Bearing and distance to that point emitted as `NAV_REJOIN_BEARING` / `NAV_REJOIN_M` (#32) - [ ] Rejoin detected, and the **original** cue sheet resumed on its **original** distance axis - [ ] Nothing is enriched, nothing is spliced, no new route object is created - [ ] Tests: rejoin-point choice on an out-and-back, and on a route that passes close to itself **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_S` recomputation, and `NAV_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.
Owner

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/RejoinPoint in companion/core/.../route/nav/RejoinPointSelector.kt (renamed from the issue's literal Rerouter.kt since 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 (unlike RouteSnapper'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_BEARING as "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). GpsFix carries no heading field (confirmed by inspection), so select() takes the rider's heading as an explicit parameter, matching MapSliceBuilder's own precedent for MAP_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-tasks full rebuild, all green, RejoinPointSelectorTest 5/5.

46 issues closed.

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`/`RejoinPoint` in `companion/core/.../route/nav/RejoinPointSelector.kt` (renamed from the issue's literal `Rerouter.kt` since 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 (unlike `RouteSnapper`'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_BEARING` as "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). `GpsFix` carries no heading field (confirmed by inspection), so `select()` takes the rider's heading as an explicit parameter, matching `MapSliceBuilder`'s own precedent for `MAP_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-tasks` full rebuild, all green, `RejoinPointSelectorTest` 5/5. 46 issues closed.
Sign in to join this conversation.
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
robert/PedalPebble#57
No description provided.