Navigation engine: windowed snapping to the route polyline (issue #31) #114

Merged
robert merged 1 commit from area/navigation-snapping into main 2026-09-05 17:47:21 +02:00
Owner

Closes #31.

What this adds

RouteSnapper (companion/core/.../route/nav/RouteSnapper.kt), which locates a live GPS fix along an imported GpxRoute's polyline.

Reuse, not reimplementation

This reuses route.enrich.locateAlongPolyline (#26/PR #79) directly - no changes to that function. It is already a forward-only, bounded-horizon point-to-segment projection with a monotonically advancing search cursor, built for #63's tier-0 cue placement, and its own KDoc already documents the exact out-and-back/lollipop disambiguation problem this issue is about.

The one behavioural difference a live snapper needs versus a cue-matcher - reporting a genuinely large offset instead of discarding a bad match - comes for free by calling it with maxOffsetMeters = Double.MAX_VALUE. locateAlongPolyline only rejects a match past that threshold; a live snapper wants the opposite (the large offset is the useful signal for #32), so nothing in that function needed to change.

RouteSnapper supplies the two things a live-GPS caller has that a one-shot cue-import pass doesn't:

  • A search cursor that persists fix-to-fix (searchCursorIndex), starting at the route's own start and advancing to wherever the previous fix matched.
  • A search horizon that widens dynamically instead of being one fixed value:
    • NORMAL_SEARCH_HORIZON_METERS (150 m) at ordinary 1 Hz cadence.
    • Widens to elapsed seconds x MAX_PLAUSIBLE_SPEED_METERS_PER_SECOND (20 m/s) after a GPS gap longer than GAP_THRESHOLD_SECONDS (5 s).
    • Widens to a fixed OFF_ROUTE_REACQUISITION_HORIZON_METERS (500 m) when the previous fix's offset exceeded REACQUISITION_OFFSET_THRESHOLD_METERS (50 m) - this internal threshold is purely a window-sizing decision, not #32's off-route determination.

RouteSnap (the output type) carries segmentStartIndex, distanceAlongRouteMeters, and offsetMeters - the last one deliberately unfiltered and unclamped, so #32's off-route detection has a real number to run its own hysteresis against. Deciding "is the rider off-route" is explicitly out of scope here.

Monotonicity (per the issue's 2026-09-02 update)

D42 removed mid-ride re-routing from Phase 3, so there is no route swap this engine has to survive. Distance-along-route is non-decreasing across on-route fixes by construction: the search cursor never looks behind its own previous result. RouteSnapperTest asserts this directly over a sampled real GPX route, per the issue's "asserted in tests, not merely intended" wording.

Tests (./gradlew :companion:core:test, all passing, 287 tests total in the module)

  • A real komoot export (real-komoot-havelchaussee-glienicker-bruecke.gpx), sampled every 20th point, confirming exact matches and non-decreasing distance-along-route.
  • An out-and-back route that does not jump from the outbound leg to its geometrically coincident return leg (the outbound leg is deliberately much longer than the search horizon, so exclusion is by distance, not by tie-breaking luck), and correctly transitions onto the return leg once actually there.
  • A genuinely off-route fix that reports a large offset instead of snapping onto a spatially closer segment that is far away in route order (two parallel "lanes" of a big loop, connected by a long filler stretch).
  • Window widening after a GPS gap, and after an off-route excursion - each asserted with a tight distance tolerance so the test actually catches a regression back to the un-widened horizon, not just "some progress was made".

https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt

Closes #31. ## What this adds `RouteSnapper` (`companion/core/.../route/nav/RouteSnapper.kt`), which locates a live GPS fix along an imported `GpxRoute`'s polyline. ## Reuse, not reimplementation This reuses `route.enrich.locateAlongPolyline` (#26/PR #79) directly - no changes to that function. It is already a forward-only, bounded-horizon point-to-segment projection with a monotonically advancing search cursor, built for #63's tier-0 cue placement, and its own KDoc already documents the exact out-and-back/lollipop disambiguation problem this issue is about. The one behavioural difference a live snapper needs versus a cue-matcher - reporting a genuinely large offset instead of discarding a bad match - comes for free by calling it with `maxOffsetMeters = Double.MAX_VALUE`. `locateAlongPolyline` only rejects a match past that threshold; a live snapper wants the opposite (the large offset **is** the useful signal for #32), so nothing in that function needed to change. `RouteSnapper` supplies the two things a live-GPS caller has that a one-shot cue-import pass doesn't: - **A search cursor that persists fix-to-fix** (`searchCursorIndex`), starting at the route's own start and advancing to wherever the previous fix matched. - **A search horizon that widens dynamically** instead of being one fixed value: - `NORMAL_SEARCH_HORIZON_METERS` (150 m) at ordinary 1 Hz cadence. - Widens to `elapsed seconds x MAX_PLAUSIBLE_SPEED_METERS_PER_SECOND` (20 m/s) after a GPS gap longer than `GAP_THRESHOLD_SECONDS` (5 s). - Widens to a fixed `OFF_ROUTE_REACQUISITION_HORIZON_METERS` (500 m) when the previous fix's offset exceeded `REACQUISITION_OFFSET_THRESHOLD_METERS` (50 m) - this internal threshold is purely a window-sizing decision, **not** #32's off-route determination. `RouteSnap` (the output type) carries `segmentStartIndex`, `distanceAlongRouteMeters`, and `offsetMeters` - the last one deliberately unfiltered and unclamped, so #32's off-route detection has a real number to run its own hysteresis against. Deciding "is the rider off-route" is explicitly out of scope here. ## Monotonicity (per the issue's 2026-09-02 update) D42 removed mid-ride re-routing from Phase 3, so there is no route swap this engine has to survive. Distance-along-route is non-decreasing across on-route fixes *by construction*: the search cursor never looks behind its own previous result. `RouteSnapperTest` asserts this directly over a sampled real GPX route, per the issue's "asserted in tests, not merely intended" wording. ## Tests (`./gradlew :companion:core:test`, all passing, 287 tests total in the module) - A real komoot export (`real-komoot-havelchaussee-glienicker-bruecke.gpx`), sampled every 20th point, confirming exact matches and non-decreasing distance-along-route. - An out-and-back route that does **not** jump from the outbound leg to its geometrically coincident return leg (the outbound leg is deliberately much longer than the search horizon, so exclusion is by distance, not by tie-breaking luck), and correctly transitions onto the return leg once actually there. - A genuinely off-route fix that reports a large offset instead of snapping onto a spatially closer segment that is far away in route order (two parallel "lanes" of a big loop, connected by a long filler stretch). - Window widening after a GPS gap, and after an off-route excursion - each asserted with a tight distance tolerance so the test actually catches a regression back to the un-widened horizon, not just "some progress was made". https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
Navigation engine: windowed snapping to the route polyline (issue #31)
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
191131fd99
Adds RouteSnapper, which locates a live GPS fix along an imported
GpxRoute's polyline for turn-by-turn navigation.

Reuses route.enrich.locateAlongPolyline directly rather than
reimplementing point-to-segment projection - that function is already
a forward-only, bounded-horizon projection with a monotonically
advancing search cursor, built for #63's tier-0 cue placement but
documented as solving exactly this out-and-back/lollipop disambiguation
problem. The one behavioural difference a live snapper needs -
reporting a genuinely large offset instead of discarding the match -
comes for free by calling it with maxOffsetMeters = Double.MAX_VALUE;
no changes to that function were needed.

RouteSnapper adds the two things a live GPS caller needs on top:
a search cursor that persists fix-to-fix (searchCursorIndex), and a
search horizon that widens on GPS gaps (elapsed time x a generous
plausible-speed bound) or after an off-route excursion (a large fixed
re-acquisition window), rather than a single fixed horizon.

RouteSnap carries segmentStartIndex, distanceAlongRouteMeters and
offsetMeters - the last one deliberately unfiltered, so #32's
off-route detection has the real, honest offset to run its own
hysteresis against; that decision is explicitly out of scope here.

Tests (./gradlew :companion:core:test, all passing):
- a real komoot export, sampled and monotonicity-checked
- an out-and-back route that does not jump from the outbound leg to
  its geometrically coincident return leg, and correctly transitions
  onto the return leg once actually there
- a genuinely off-route fix reporting a large offset rather than
  snapping onto a spatially closer segment far away in route order
- window widening after a GPS gap and after an off-route excursion,
  each with a tight distance tolerance so the test actually catches a
  regression to the un-widened horizon

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
robert merged commit ceb255b3da into main 2026-09-05 17:47:21 +02:00
Sign in to join this conversation.
No description provided.