Nav engine: next/then-turn, remaining distance and ETA (#33) #119

Merged
robert merged 1 commit from area/nav-engine into main 2026-09-05 23:44:44 +02:00
Owner

Closes #33.

What NavEngine consumes/produces

NavEngine(route: GpxRoute, cues: List<Cue>), one update(snap: RouteSnap, speedSample: SpeedPipelineSample): NavUpdate call per fix. Consumes exactly two existing types — #31's RouteSnap and the route's own Cue list (#26/#40's tiers) — and the same SpeedPipelineSample shape the rest of the ride pipeline already produces, per the issue's own "shaped consistently with what the rest of the ride pipeline already produces" instruction. No new position tracking, no new distance-along-route computation.

NavUpdate is a plain in-memory output type (progressState, nextCue: NextCue?, thenTurn: Direction?, remainingDistanceMeters, etaSeconds: Long?) — deliberately not a wire encoding. There is no existing "NAV wire encoding" class in this codebase yet (checked before writing this), so this mirrors RouteSnap/MapSliceBuilder's own precedent of a pure-computation type that a later, separate issue turns into AppMessage keys.

Rolling-average-speed design

New RollingSpeedAverage class (in NavEngine.kt, same package): a 30 s, moving-time-only simple moving average of SpeedPipelineSample.speedMetersPerSecond, ArrayDeque-backed, ramping up from a partial window rather than withholding output (shape borrowed from Power3sAverage, but a genuinely new class — Power3sAverage never gates on MovementState, this one must).

  • Not AverageAccumulator (the whole-ride mean behind AVG_HR/AVG_CADENCE/AVG_POWER): gets more stable and less responsive the longer a ride runs — exactly wrong for an ETA that needs to reflect current pace, not the ride-to-date average.
  • Not SpeedPipeline's own 3-sample GPS smoothing: that removes single-fix jitter for a live speed readout where a half-second of lag is fine; a 3 s average still swings hard on ordinary riding variation (coast into a light, sprint out of it) and produces a jittery ETA.
  • 30 s chosen: long enough to absorb ordinary pace variation (coasting, wind gusts, residual post-smoothing jitter) without swinging the ETA; short enough that a genuine sustained pace change (a real climb, a tailwind) shows up within under a minute rather than the many minutes a whole-ride average would take.
  • Moving-time only, mirroring AvgHrAccumulator/AvgCadenceAccumulator: a sample while MovementState.STOPPED is excluded entirely rather than folded in as 0.0, so a red light holds the ETA steady instead of ballooning it. Tested directly (NavEngineTest, "excludes stopped time").

"Coalesced to about 1 Hz"

Scoped to the transport layer, which does not exist yet — same as #40's MapSliceBuilder caveat for MAP_* keys. shared/message_keys.json's nav group already declares period_ms: 1000/heartbeat_ms: 3000, mirroring the map group's own 0.2 Hz/5000 ms convention from #40. NavEngine itself is pure computation with no rate limiting of its own; whatever future AppMessage-transport layer wires NavUpdate onto the wire is where the actual "at most once every ~1000 ms" gate belongs, using that already-declared period_ms. Scoping a second rate limiter into this class would just duplicate that convention.

Then-turn / route-completion edge cases

  • thenTurn is null (not a fabricated Direction) whenever fewer than two cues remain ahead of the rider — plain Kotlin nullability, the same "unavailable is a sentinel, never a fabricated value" convention already used throughout this codebase (Cue.streetName, AvgHrAccumulator.averageBpm, ...), rather than inventing a new wire-level sentinel at this (pre-wire) layer.
  • Cue selection needs no search cursor of its own: because RouteSnap.distanceAlongRouteMeters only ever advances, "next cue" is just the first sorted cue still ahead of the rider — a passed cue drops out of that filter by itself. Cue counts are small, so the linear scan costs nothing at ~1 Hz.
  • Route completion: rider's distance-along-route within FINISH_TOLERANCE_METERS (15 m — comfortably above GpxImporter's own 5 m RDP simplification tolerance, #24) of the route's total length. Latched, not re-evaluated every call: once FINISHED, it is sticky for the engine's lifetime, because ordinary closest-point-projection wobble on the rider's final polyline segment can shift the reported distance-along-route a meter or two either way between calls even though the search cursor itself never moves backward — without the latch this could oscillate FINISHED↔ON_ROUTE right at the finish. Tested directly, including a fix reporting a smaller distance-along-route than the one that already triggered FINISHED.
  • NavProgressState is deliberately only ON_ROUTE/FINISHED, not the full NAV_STATE wire enum — NO_ROUTE and OFF_ROUTE (#32) are out of scope here, composed in one layer up once #32 exists, same scoping RouteSnap's own KDoc already draws.

Tests

companion/core/src/test/kotlin/de/butzei/pedalpebble/core/route/nav/NavEngineTest.kt, 5 tests, real ./gradlew :companion:core:test --rerun-tasks run (green, full suite):

  • Distance-to-next-cue and then-turn against the real (if hand-built) bikerouter-style-cues.gpx two-cue route, run through the actual GpxImporter/GpxCueEnricher pipeline.
  • NAV_REMAIN_M monotonically decreasing as the rider advances along that same real route.
  • A synthetic jittery (2/10 m/s alternating) speed sequence converging to a stable ETA (settled values within 2 s of each other) rather than swinging with each fix (contrasted explicitly against the ~400 s swing a naive instantaneous-speed ETA would show).
  • The rolling average excluding stopped time, so ETA holds exactly steady through a simulated red-light stop.
  • Route completion latching FINISHED and staying FINISHED against a later fix reporting a smaller distance-along-route (backward GPS noise near the end).

Files

  • companion/core/src/main/kotlin/de/butzei/pedalpebble/core/route/nav/NavEngine.kt (new)
  • companion/core/src/test/kotlin/de/butzei/pedalpebble/core/route/nav/NavEngineTest.kt (new)
Closes #33. ## What NavEngine consumes/produces `NavEngine(route: GpxRoute, cues: List<Cue>)`, one `update(snap: RouteSnap, speedSample: SpeedPipelineSample): NavUpdate` call per fix. Consumes exactly two existing types — #31's `RouteSnap` and the route's own `Cue` list (#26/#40's tiers) — and the same `SpeedPipelineSample` shape the rest of the ride pipeline already produces, per the issue's own "shaped consistently with what the rest of the ride pipeline already produces" instruction. No new position tracking, no new distance-along-route computation. `NavUpdate` is a plain in-memory output type (`progressState`, `nextCue: NextCue?`, `thenTurn: Direction?`, `remainingDistanceMeters`, `etaSeconds: Long?`) — deliberately not a wire encoding. There is no existing "NAV wire encoding" class in this codebase yet (checked before writing this), so this mirrors `RouteSnap`/`MapSliceBuilder`'s own precedent of a pure-computation type that a later, separate issue turns into AppMessage keys. ## Rolling-average-speed design New `RollingSpeedAverage` class (in `NavEngine.kt`, same package): a 30 s, moving-time-only simple moving average of `SpeedPipelineSample.speedMetersPerSecond`, `ArrayDeque`-backed, ramping up from a partial window rather than withholding output (shape borrowed from `Power3sAverage`, but a genuinely new class — `Power3sAverage` never gates on `MovementState`, this one must). - **Not `AverageAccumulator`** (the whole-ride mean behind `AVG_HR`/`AVG_CADENCE`/`AVG_POWER`): gets *more* stable and *less* responsive the longer a ride runs — exactly wrong for an ETA that needs to reflect current pace, not the ride-to-date average. - **Not `SpeedPipeline`'s own 3-sample GPS smoothing**: that removes single-fix jitter for a live speed readout where a half-second of lag is fine; a 3 s average still swings hard on ordinary riding variation (coast into a light, sprint out of it) and produces a jittery ETA. - **30 s chosen**: long enough to absorb ordinary pace variation (coasting, wind gusts, residual post-smoothing jitter) without swinging the ETA; short enough that a genuine sustained pace change (a real climb, a tailwind) shows up within under a minute rather than the many minutes a whole-ride average would take. - **Moving-time only**, mirroring `AvgHrAccumulator`/`AvgCadenceAccumulator`: a sample while `MovementState.STOPPED` is excluded entirely rather than folded in as `0.0`, so a red light holds the ETA steady instead of ballooning it. Tested directly (`NavEngineTest`, "excludes stopped time"). ## "Coalesced to about 1 Hz" Scoped to the transport layer, which does not exist yet — same as #40's `MapSliceBuilder` caveat for `MAP_*` keys. `shared/message_keys.json`'s `nav` group already declares `period_ms: 1000`/`heartbeat_ms: 3000`, mirroring the `map` group's own 0.2 Hz/5000 ms convention from #40. `NavEngine` itself is pure computation with no rate limiting of its own; whatever future AppMessage-transport layer wires `NavUpdate` onto the wire is where the actual "at most once every ~1000 ms" gate belongs, using that already-declared `period_ms`. Scoping a second rate limiter into this class would just duplicate that convention. ## Then-turn / route-completion edge cases - `thenTurn` is `null` (not a fabricated `Direction`) whenever fewer than two cues remain ahead of the rider — plain Kotlin nullability, the same "unavailable is a sentinel, never a fabricated value" convention already used throughout this codebase (`Cue.streetName`, `AvgHrAccumulator.averageBpm`, ...), rather than inventing a new wire-level sentinel at this (pre-wire) layer. - Cue selection needs no search cursor of its own: because `RouteSnap.distanceAlongRouteMeters` only ever advances, "next cue" is just the first sorted cue still ahead of the rider — a passed cue drops out of that filter by itself. Cue counts are small, so the linear scan costs nothing at ~1 Hz. - Route completion: rider's distance-along-route within `FINISH_TOLERANCE_METERS` (15 m — comfortably above `GpxImporter`'s own 5 m RDP simplification tolerance, #24) of the route's total length. **Latched, not re-evaluated every call**: once `FINISHED`, it is sticky for the engine's lifetime, because ordinary closest-point-projection wobble on the rider's final polyline segment can shift the reported distance-along-route a meter or two either way between calls even though the search cursor itself never moves backward — without the latch this could oscillate `FINISHED`↔`ON_ROUTE` right at the finish. Tested directly, including a fix reporting a smaller distance-along-route than the one that already triggered `FINISHED`. - `NavProgressState` is deliberately only `ON_ROUTE`/`FINISHED`, not the full `NAV_STATE` wire enum — `NO_ROUTE` and `OFF_ROUTE` (#32) are out of scope here, composed in one layer up once #32 exists, same scoping `RouteSnap`'s own KDoc already draws. ## Tests `companion/core/src/test/kotlin/de/butzei/pedalpebble/core/route/nav/NavEngineTest.kt`, 5 tests, real `./gradlew :companion:core:test --rerun-tasks` run (green, full suite): - Distance-to-next-cue and then-turn against the real (if hand-built) `bikerouter-style-cues.gpx` two-cue route, run through the actual `GpxImporter`/`GpxCueEnricher` pipeline. - `NAV_REMAIN_M` monotonically decreasing as the rider advances along that same real route. - A synthetic jittery (2/10 m/s alternating) speed sequence converging to a stable ETA (settled values within 2 s of each other) rather than swinging with each fix (contrasted explicitly against the ~400 s swing a naive instantaneous-speed ETA would show). - The rolling average excluding stopped time, so ETA holds exactly steady through a simulated red-light stop. - Route completion latching `FINISHED` and staying `FINISHED` against a later fix reporting a smaller distance-along-route (backward GPS noise near the end). ## Files - `companion/core/src/main/kotlin/de/butzei/pedalpebble/core/route/nav/NavEngine.kt` (new) - `companion/core/src/test/kotlin/de/butzei/pedalpebble/core/route/nav/NavEngineTest.kt` (new)
Add NavEngine: next/then-turn, remaining distance, ETA, route completion (#33)
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
1d6d5baa71
Pure computation consuming an existing RouteSnap (#31) and Cue list (#26/#40's
tiers), producing a NavUpdate the transport layer will later encode onto the
NAV_* AppMessage keys (docs/PROTOCOL.md ids 40-47):

- Distance to next cue is cue.distanceAlongRouteMeters minus the rider's own
  RouteSnap.distanceAlongRouteMeters -- along-route by construction, never a
  straight-line computation.
- NAV_THEN_TURN is the cue after next in the route's own cue order; null (not
  a fabricated direction) when fewer than two cues remain ahead.
- NAV_REMAIN_M is the route's total length (cumulativeDistancesMeters(...).last())
  minus the rider's distance-along-route, clamped to >= 0.
- NAV_ETA_S is remaining distance divided by a new RollingSpeedAverage: a 30 s,
  moving-time-only rolling average of SpeedPipelineSample -- long enough to
  absorb ordinary speed jitter without ever averaging over the whole ride the
  way ride/DerivedMetrics.kt's AverageAccumulator does (too slow to reflect a
  real late-ride pace change), short enough to reflect a genuine sustained pace
  change within under a minute. Moving-time-only mirrors AvgHrAccumulator/
  AvgCadenceAccumulator's own convention, so a stop at a light holds ETA
  steady rather than ballooning it.
- Route completion (rider within FINISH_TOLERANCE_METERS of the route's total
  length) latches NavProgressState.FINISHED for the engine's lifetime, so
  ordinary closest-point-projection wobble on the final segment cannot flip
  it back to ON_ROUTE.
- 'Coalesced to about 1 Hz' is scoped to the transport layer that does not
  exist yet (shared/message_keys.json's nav group already declares
  period_ms: 1000/heartbeat_ms: 3000) -- NavEngine itself is pure computation
  with no rate limiting of its own, matching #40's MapSliceBuilder precedent.

Tested against a real (if hand-built, per its own established GpxCueEnricherTest
KDoc) bikerouter-style two-cue route for next/then-turn and monotonic
NAV_REMAIN_M, plus synthetic-geometry tests isolating the rolling-average
smoothing, moving-time exclusion, and finished-state latching behaviour.

./gradlew :companion:core:test --rerun-tasks passes (5/5 new tests, full suite
green).

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
robert merged commit 357c8d492a into main 2026-09-05 23:44:44 +02:00
Sign in to join this conversation.
No description provided.