Auto-pause: one definition of stopped, in Phase 2 (#69) #100

Merged
robert merged 1 commit from area/stop-detector into main 2026-09-04 19:54:57 +02:00
Owner

Closes #69.

Implements D35's one shared definition of "stopped": StopDetector (companion/core, core.ride package) plus SpeedPipeline (companion/core, core.location package), the wheel-vs-GPS arbitration that feeds it.

Speed threshold: 0.8 m/s, the same number as #17's display clamp, deliberately — two numbers for "stopped" is exactly the class of bug D35 exists to prevent (the display reading 0 km/h while the ride timer still counted the same GPS jitter as motion, or vice versa).

Dwell: 3 s before a stop registers — long enough that one noisy low sample (a bad GPS fix, a tight slow corner) can never trigger a stop on its own, short enough not to visibly lag a real stop.

Resume: 1.5 m/s held for 1 s — hysteresis: the 1.5 m/s threshold sits comfortably above brisk walking pace (~1.4 m/s) so a rider shuffling forward at a light does not flap the detector; the 1 s confirm rejects a single spurious high reading (multipath GPS spike, sensor glitch) without meaningfully delaying a real departure.

Wheel sensor preferred over GPS when live. SpeedPipeline.onWheelSpeedSample treats WheelSpeedSample.NoNewRevolution as a genuine, noise-free 0.0 m/s — the wheel confirming it isn't turning, not a missing sample — and GPS fixes arriving while the wheel is live (reported within the last 5 s) are dropped outright, not merely deprioritised. The wheel gets no separate threshold or dwell-skip; it feeds the same StopDetector a cleaner signal, which is what "the wheel knows immediately" actually buys.

Auto-pause on/off (#44). autoPauseRideState() is the only place that setting gates anything — StopDetector itself runs unconditionally (fed by SpeedPipeline regardless of the setting), so moving-time accumulation keeps working correctly even with auto-pause off, exactly as the issue asks.

Manual pause always beats an automatic resume. autoPauseRideState() checks manualPauseActive first and unconditionally, before consulting MovementState at all. On the watch side this composes with, rather than competes against, state.c's existing D43 semantics: docs/PROTOCOL.md §2.1/§3.1 already forbids a RIDE_STATE push from overwriting a watch command the phone hasn't yet acknowledged via STATE_ACK, and the watch drains its queued Select/long-Select commands before accepting any push at all. This issue adds the phone-side half of the same principle for whichever future issue wires an actual manualPauseActive source (none exists yet — no phone-side pause UI, no watch-command receive path; that's downstream of #4/Spike A). StopDetector/autoPauseRideState() are the decision; wiring the source is out of this issue's scope.

Module placement. Both files land in :companion:core rather than the issue's literal .../ride/StopDetector.kt and .../location/SpeedPipeline.kt paths taken as Gradle module paths — :companion:ride depends on :companion:location, so a StopDetector in :companion:ride could never be consumed by a SpeedPipeline in :companion:location (a dependency cycle). Both paths' .../ prefixes are read as elliptical package names instead, following the precedent this codebase already set with core.ride.RideSetupState (pure decision logic in :companion:core, Android-facing adapter in :companion:location's RidePermissions.kt). Full reasoning in SpeedPipeline.kt's own KDoc.

No Android SDK surface needed. Both files have zero android.* imports — confirmed, not assumed — and are entirely host-JVM-testable. ./gradlew :companion:core:test passes for real: 18 new tests (12 StopDetectorTest, 6 SpeedPipelineTest), all green, no Android build invoked because nothing here has anything to prove against one.

Tests. StopDetectorTest.kt covers the dwell/hysteresis state machine directly (brief dips absorbed, confirmed stop/resume timing, dwell-clock restart, autoPauseRideState's truth table). SpeedPipelineTest.kt covers wheel-live-vs-GPS-fallback arbitration, NoNewRevolution driving a real stop, disconnect handling, exact wheel-distance accumulation — and a synthetic 1 Hz stop-and-go "traffic light" scenario (cruise 0–10 s, decelerate 11–16 s, stopped at the light 17–25 s, pull away 26–27 s, cruise 28–35 s) standing in for #23's not-yet-built GPX replay harness, asserting 25.0 s of moving time out of a 35.0 s synthetic ride (confirmed stop at t=18, confirmed resume at t=28) against hand-checked arithmetic documented in the test's own KDoc.

Docs. docs/DECISIONS.md D35 updated with the concrete numbers this PR chose, per D48 (a checked fact — derived and verified by the tests in this PR, not recalled).

https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt

Closes #69. Implements D35's one shared definition of "stopped": `StopDetector` (`companion/core`, `core.ride` package) plus `SpeedPipeline` (`companion/core`, `core.location` package), the wheel-vs-GPS arbitration that feeds it. **Speed threshold: 0.8 m/s, the same number as #17's display clamp**, deliberately — two numbers for "stopped" is exactly the class of bug D35 exists to prevent (the display reading 0 km/h while the ride timer still counted the same GPS jitter as motion, or vice versa). **Dwell: 3 s** before a stop registers — long enough that one noisy low sample (a bad GPS fix, a tight slow corner) can never trigger a stop on its own, short enough not to visibly lag a real stop. **Resume: 1.5 m/s held for 1 s** — hysteresis: the 1.5 m/s threshold sits comfortably above brisk walking pace (~1.4 m/s) so a rider shuffling forward at a light does not flap the detector; the 1 s confirm rejects a single spurious high reading (multipath GPS spike, sensor glitch) without meaningfully delaying a real departure. **Wheel sensor preferred over GPS when live.** `SpeedPipeline.onWheelSpeedSample` treats `WheelSpeedSample.NoNewRevolution` as a genuine, noise-free 0.0 m/s — the wheel confirming it isn't turning, not a missing sample — and GPS fixes arriving while the wheel is live (reported within the last 5 s) are dropped outright, not merely deprioritised. The wheel gets no separate threshold or dwell-skip; it feeds the *same* `StopDetector` a cleaner signal, which is what "the wheel knows immediately" actually buys. **Auto-pause on/off (#44).** `autoPauseRideState()` is the only place that setting gates anything — `StopDetector` itself runs unconditionally (fed by `SpeedPipeline` regardless of the setting), so moving-time accumulation keeps working correctly even with auto-pause off, exactly as the issue asks. **Manual pause always beats an automatic resume.** `autoPauseRideState()` checks `manualPauseActive` first and unconditionally, before consulting `MovementState` at all. On the watch side this composes with, rather than competes against, `state.c`'s existing D43 semantics: `docs/PROTOCOL.md` §2.1/§3.1 already forbids a `RIDE_STATE` push from overwriting a watch command the phone hasn't yet acknowledged via `STATE_ACK`, and the watch drains its queued Select/long-Select commands before accepting any push at all. This issue adds the phone-side half of the same principle for whichever future issue wires an actual `manualPauseActive` source (none exists yet — no phone-side pause UI, no watch-command receive path; that's downstream of #4/Spike A). `StopDetector`/`autoPauseRideState()` are the decision; wiring the source is out of this issue's scope. **Module placement.** Both files land in `:companion:core` rather than the issue's literal `.../ride/StopDetector.kt` and `.../location/SpeedPipeline.kt` paths taken as Gradle module paths — `:companion:ride` depends on `:companion:location`, so a `StopDetector` in `:companion:ride` could never be consumed by a `SpeedPipeline` in `:companion:location` (a dependency cycle). Both paths' `.../` prefixes are read as elliptical package names instead, following the precedent this codebase already set with `core.ride.RideSetupState` (pure decision logic in `:companion:core`, Android-facing adapter in `:companion:location`'s `RidePermissions.kt`). Full reasoning in `SpeedPipeline.kt`'s own KDoc. **No Android SDK surface needed.** Both files have zero `android.*` imports — confirmed, not assumed — and are entirely host-JVM-testable. `./gradlew :companion:core:test` passes for real: 18 new tests (12 `StopDetectorTest`, 6 `SpeedPipelineTest`), all green, no Android build invoked because nothing here has anything to prove against one. **Tests.** `StopDetectorTest.kt` covers the dwell/hysteresis state machine directly (brief dips absorbed, confirmed stop/resume timing, dwell-clock restart, `autoPauseRideState`'s truth table). `SpeedPipelineTest.kt` covers wheel-live-vs-GPS-fallback arbitration, `NoNewRevolution` driving a real stop, disconnect handling, exact wheel-distance accumulation — and a synthetic 1 Hz stop-and-go "traffic light" scenario (cruise 0–10 s, decelerate 11–16 s, stopped at the light 17–25 s, pull away 26–27 s, cruise 28–35 s) standing in for #23's not-yet-built GPX replay harness, asserting **25.0 s of moving time out of a 35.0 s synthetic ride** (confirmed stop at t=18, confirmed resume at t=28) against hand-checked arithmetic documented in the test's own KDoc. **Docs.** `docs/DECISIONS.md` D35 updated with the concrete numbers this PR chose, per D48 (a checked fact — derived and verified by the tests in this PR, not recalled). https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
Implement StopDetector and SpeedPipeline: one definition of stopped (#69)
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
4bc72c3511
D35 deferred the speed threshold, dwell and resume condition to this issue.
StopDetector (companion/core, core.ride package) is now that single decision
point: 0.8 m/s stop threshold (same number as #17's display clamp,
deliberately - two numbers for "stopped" is exactly the bug D35 exists to
prevent), 3 s dwell before a stop registers, 1.5 m/s resume threshold held
for 1 s (hysteresis above brisk walking pace). autoPauseRideState() composes
that with #44's auto-pause setting and manual-pause precedence into the
RIDE_STATE value auto-pause alone would push, reconciling with state.c's
existing D43 manual-pause-wins semantics rather than competing with it.

SpeedPipeline (companion/core, core.location package) arbitrates wheel vs.
GPS speed - wheel preferred whenever live (including NoNewRevolution, an
exact 0.0 m/s the instant the wheel stops turning), GPS as fallback only,
never default - and feeds the single StopDetector, accumulating moving time
and moving distance from its MOVING/STOPPED verdict.

Both files land in :companion:core (pure JVM, no android.* import) rather
than the issue's literal :companion:ride/:companion:location paths, which
would have created a Gradle dependency cycle; see SpeedPipeline.kt's own
KDoc for the reasoning, following the RideSetupState/RidePermissions
precedent already established in this module.

Tests (companion/core/src/test/kotlin/.../ride/StopDetectorTest.kt,
.../location/SpeedPipelineTest.kt) cover dwell/hysteresis in isolation, the
autoPauseRideState composition, wheel-live-vs-GPS-fallback arbitration, and
a synthetic 1 Hz stop-and-go "traffic light" scenario (cruise, decelerate,
dwell stopped, pull away, cruise) standing in for #23's not-yet-built GPX
replay harness, asserting moving time against a hand-checked 25.0 s out of
a 35.0 s synthetic ride.

Entirely host-JVM-testable; ./gradlew :companion:core:test passes for real
(18 new tests, all green). No Android SDK surface needed by this change.

D35 updated with the concrete numbers chosen, per D48.

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
robert merged commit 4fdc8624ef into main 2026-09-04 19:54:57 +02:00
Sign in to join this conversation.
No description provided.