Navigation engine: windowed snapping to the route polyline #31

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

Goal

Locate the rider along the route cheaply and without jumping.

Acceptance criteria

  • Position snapped to the nearest point on the polyline
  • Search restricted to a window around the last match, not the whole route
  • Out-and-back and lollipop routes do not jump to the return leg - covered by tests
  • Window widens on re-acquisition after an off-route excursion or a GPS gap
  • Distance along route maintained monotonically while on route
  • Cost stays negligible at 1 Hz on a long route

Files

  • companion/.../route/nav/RouteSnapper.kt

Update — 2026-09-01: monotonicity and re-routing interact

  • The monotonic distance-along-route invariant is stated as a property the code enforces, and is
    asserted in tests (#39), not merely intended
  • Behaviour on a spliced route defined: a re-route (#57) produces a new route object with
    its own monotonic distance axis
    , swapped in once at the moment the detour is accepted —
    rather than mutating the route this engine is mid-way through reading

See D30. The rejoin point is the part everyone thinks of; the splice is the part that breaks
invariants.

Update — 2026-09-02: monotonicity is now free

D42 removes mid-ride re-routing from Phase 3, so the route object never changes during a ride.
Distance-along-route is therefore monotonic by construction rather than by rule.

  • Drop the splice-related wording: there is no route swap to survive in Phase 3
  • The windowed snapping requirement is unchanged, and still must not jump to the return leg of an
    out-and-back

The Phase 5 re-routing issue reintroduces a route swap, and carries the monotonic-axis requirement
itself.

## Goal Locate the rider along the route cheaply and without jumping. ## Acceptance criteria - [ ] Position snapped to the nearest point on the polyline - [ ] Search restricted to a window around the last match, not the whole route - [ ] **Out-and-back and lollipop routes do not jump to the return leg** - covered by tests - [ ] Window widens on re-acquisition after an off-route excursion or a GPS gap - [ ] Distance along route maintained monotonically while on route - [ ] Cost stays negligible at 1 Hz on a long route ## Files - `companion/.../route/nav/RouteSnapper.kt` ## Update — 2026-09-01: monotonicity and re-routing interact - [ ] The monotonic distance-along-route invariant is stated as a property the code enforces, and is **asserted in tests** (#39), not merely intended - [ ] Behaviour on a **spliced route** defined: a re-route (#57) produces a **new route object with its own monotonic distance axis**, swapped in once at the moment the detour is accepted — rather than mutating the route this engine is mid-way through reading See D30. The rejoin point is the part everyone thinks of; the splice is the part that breaks invariants. ## Update — 2026-09-02: monotonicity is now free D42 removes mid-ride re-routing from Phase 3, so **the route object never changes during a ride**. Distance-along-route is therefore monotonic by construction rather than by rule. - [ ] Drop the splice-related wording: there is no route swap to survive in Phase 3 - [ ] The windowed snapping requirement is unchanged, and still must not jump to the return leg of an out-and-back The Phase 5 re-routing issue reintroduces a route swap, and carries the monotonic-axis requirement itself.
Owner

Closed by PR #114 (area/navigation-snapping).

New RouteSnapper/RouteSnap in companion/core/.../route/nav/RouteSnapper.kt, reusing RouteGeodesy.locateAlongPolyline (#26) directly rather than reimplementing point-to-segment projection — the only change needed was calling it with maxOffsetMeters = Double.MAX_VALUE, since a live snapper wants the honest (possibly large) offset reported, not discarded the way tier-0 cue matching wants it discarded.

Windowing: forward-only search cursor persisting across snap() calls, 150 m normal horizon (NFR-B2's ~1 Hz fix rate at plausible speed), widening to elapsedSeconds x 20 m/s after a >5 s GPS gap, and to a flat 500 m after the previous fix was >50 m off-route (a re-acquisition case, not #32's own off-route determination — that threshold is purely internal to this class's own window sizing).

Output type RouteSnap(segmentStartIndex, distanceAlongRouteMeters, offsetMeters) carries offsetMeters unfiltered specifically for #32's future hysteresis logic to consume.

Real test coverage (6 tests, ./gradlew :companion:core:test, genuinely re-run not cached): a real ~119km komoot GPX fixture confirming monotonic distance-along-route across a long real polyline; an out-and-back route proving the forward-only cursor doesn't jump to the geometric twin ~3000m away on the return leg; a genuinely off-route case (two geometrically-close "lanes" far apart in route order) proving a large honest offset is reported instead of a wrong nearest-order match; and two widening-specific tests (GPS gap, off-route excursion) with tight distance tolerances that would catch a regression to the normal 150m horizon.

This unblocks #39 and #40.

Closed by PR #114 (`area/navigation-snapping`). New `RouteSnapper`/`RouteSnap` in `companion/core/.../route/nav/RouteSnapper.kt`, reusing `RouteGeodesy.locateAlongPolyline` (#26) directly rather than reimplementing point-to-segment projection — the only change needed was calling it with `maxOffsetMeters = Double.MAX_VALUE`, since a live snapper wants the honest (possibly large) offset reported, not discarded the way tier-0 cue matching wants it discarded. Windowing: forward-only search cursor persisting across `snap()` calls, 150 m normal horizon (NFR-B2's ~1 Hz fix rate at plausible speed), widening to `elapsedSeconds x 20 m/s` after a >5 s GPS gap, and to a flat 500 m after the previous fix was >50 m off-route (a re-acquisition case, not #32's own off-route determination — that threshold is purely internal to this class's own window sizing). Output type `RouteSnap(segmentStartIndex, distanceAlongRouteMeters, offsetMeters)` carries `offsetMeters` unfiltered specifically for #32's future hysteresis logic to consume. Real test coverage (6 tests, `./gradlew :companion:core:test`, genuinely re-run not cached): a real ~119km komoot GPX fixture confirming monotonic distance-along-route across a long real polyline; an out-and-back route proving the forward-only cursor doesn't jump to the geometric twin ~3000m away on the return leg; a genuinely off-route case (two geometrically-close "lanes" far apart in route order) proving a large honest offset is reported instead of a wrong nearest-order match; and two widening-specific tests (GPS gap, off-route excursion) with tight distance tolerances that would catch a regression to the normal 150m horizon. This unblocks #39 and #40.
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#31
No description provided.