Audit issue #22 speed-pipeline test coverage, fill the real GPX-replay distance gap #103

Merged
robert merged 1 commit from area/speed-pipeline-coverage-audit into main 2026-09-04 20:30:49 +02:00
Owner

Closes #22.

Audit result: most of #22 was already satisfied tonight

Read #22's acceptance criteria against what #100/#101/#102 (issues #69/#17/#19) and #99 (#18) actually shipped before writing anything. Five of six criteria are already real, passing coverage — no duplicate tests added for any of them:

  • Speed smoothing and the stop clamp tested against synthetic fix sequences — SpeedPipelineTest.kt: "onGpsFix falls back to a position-delta (distance/dt) speed when the fix carries no platform speed, smoothed over the ~3 s window", "onGpsFix's reported speed clamps to exactly zero once StopDetector confirms a stop", and the synthetic stop-and-go traffic-light scenario test.
  • CSC 16-bit event-time and 32-bit revolution wraparound tested at the boundaries — WheelSpeedTrackerTest.kt: "a revolution-count rollover across two samples still yields a correct, positive delta", "an event-time rollover across two samples still yields the correct elapsed time", "both counters rolling over on the same pair of samples still yields a correct delta", plus property-based checkAll fuzzing of uint32Delta/uint16Delta over their full ranges. Confirmed real, not assumed.
  • Bad-accuracy fix rejection tested — SpeedPipelineTest.kt: "onGpsFix rejects a fix past the accuracy threshold..." and "onGpsFix rejects a fix with unknown (NaN) accuracy too...".
  • Source arbitration hand-over tested, including the no-source case — SpeedPipelineTest.kt: the wheel-live/GPS-dropped tests, "a disconnect immediately hands speed sourcing back to GPS...", and the no-source case via "currentReport is NoSignal before any sample has ever arrived" / "currentReport flips to NoSignal once neither source has reported for SIGNAL_TIMEOUT_NANOS...". Confirmed real, not assumed.
  • All tests run on the JVM with no device or network — true of every test in companion/core/src/test/ including the new one below; ./gradlew :companion:core:test confirms it.

The one real gap this PR fills

"Distance accumulation tested against a known GPX with a hand-checked total" had a real gap: RealWorldFixtureTest.kt (from #38/#6, PR #95) tests the ~119 km komoot fixture through GpxImporter/computeRouteMetrics — the import-time route-metrics path. That is a different code path from SpeedPipeline.onGpsFix's live-ride, one-fix-at-a-time distance accumulation this issue is actually about (the accuracy gate, has-speed-vs-fallback, and smoothing this issue names).

New: companion/core/src/test/kotlin/de/butzei/pedalpebble/core/location/SpeedPipelineGpxReplayTest.kt. It:

  • Parses the fixture's raw <trkpt> sequence directly (2795 points), deliberately bypassing GpxImporter's RDP simplification so it replays the exact same raw-point basis RealWorldFixtureTest's ~119,020 m ground truth was independently computed over (plain haversine over the raw file).
  • Feeds each point through SpeedPipeline.onGpsFix one at a time, using the point's own real recorded <time> (not a synthetic fixed cadence — komoot's actual interval varies ~0.1-160 s) and a fixed 6.0 m accuracy (mid of a realistic 5-8 m phone GPS accuracy; the file carries no accuracy field).
  • Asserts movingDistanceMeters lands within 50 m of ~119,020 m, and that rejectedFixCount == 0.

Measured result: 119,020.28649817174 m vs. the ~119,020.29 m ground truth — a difference of about 3.5 mm. The 50 m tolerance is deliberately tight and is justified, not padded: a standalone Python re-implementation of the exact SpeedPipeline/StopDetector algorithm over this fixture found zero confirmed-stop events anywhere in the ride (no two consecutive raw points are ever slower than the 0.8 m/s stop threshold apart), so StopDetector never leaves its initial MOVING state and every position delta counts toward moving distance — for this specific fixture, "moving distance" and "raw total distance" are the same quantity, not merely close ones. A fixture with real recorded stops would need a substantially wider tolerance; this one doesn't, and the test's own KDoc says so.

Test run

./gradlew :companion:core:test :companion:pebble:test    # BUILD SUCCESSFUL, all tests pass
./gradlew :companion:core:koverVerify                     # BUILD SUCCESSFUL, coverage floor holds

Real Android SDK/JDK 21 toolchain, not hand-reviewed.

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt

Closes #22. ## Audit result: most of #22 was already satisfied tonight Read #22's acceptance criteria against what #100/#101/#102 (issues #69/#17/#19) and #99 (#18) actually shipped before writing anything. Five of six criteria are already real, passing coverage — no duplicate tests added for any of them: - **Speed smoothing and the stop clamp tested against synthetic fix sequences** — `SpeedPipelineTest.kt`: `"onGpsFix falls back to a position-delta (distance/dt) speed when the fix carries no platform speed, smoothed over the ~3 s window"`, `"onGpsFix's reported speed clamps to exactly zero once StopDetector confirms a stop"`, and the synthetic stop-and-go traffic-light scenario test. - **CSC 16-bit event-time and 32-bit revolution wraparound tested at the boundaries** — `WheelSpeedTrackerTest.kt`: `"a revolution-count rollover across two samples still yields a correct, positive delta"`, `"an event-time rollover across two samples still yields the correct elapsed time"`, `"both counters rolling over on the same pair of samples still yields a correct delta"`, plus property-based `checkAll` fuzzing of `uint32Delta`/`uint16Delta` over their full ranges. Confirmed real, not assumed. - **Bad-accuracy fix rejection tested** — `SpeedPipelineTest.kt`: `"onGpsFix rejects a fix past the accuracy threshold..."` and `"onGpsFix rejects a fix with unknown (NaN) accuracy too..."`. - **Source arbitration hand-over tested, including the no-source case** — `SpeedPipelineTest.kt`: the wheel-live/GPS-dropped tests, `"a disconnect immediately hands speed sourcing back to GPS..."`, and the no-source case via `"currentReport is NoSignal before any sample has ever arrived"` / `"currentReport flips to NoSignal once neither source has reported for SIGNAL_TIMEOUT_NANOS..."`. Confirmed real, not assumed. - **All tests run on the JVM with no device or network** — true of every test in `companion/core/src/test/` including the new one below; `./gradlew :companion:core:test` confirms it. ## The one real gap this PR fills **"Distance accumulation tested against a known GPX with a hand-checked total"** had a real gap: `RealWorldFixtureTest.kt` (from #38/#6, PR #95) tests the ~119 km komoot fixture through `GpxImporter`/`computeRouteMetrics` — the *import-time* route-metrics path. That is a different code path from `SpeedPipeline.onGpsFix`'s *live-ride*, one-fix-at-a-time distance accumulation this issue is actually about (the accuracy gate, has-speed-vs-fallback, and smoothing this issue names). New: `companion/core/src/test/kotlin/de/butzei/pedalpebble/core/location/SpeedPipelineGpxReplayTest.kt`. It: - Parses the fixture's raw `<trkpt>` sequence directly (2795 points), deliberately bypassing `GpxImporter`'s RDP simplification so it replays the exact same raw-point basis `RealWorldFixtureTest`'s ~119,020 m ground truth was independently computed over (plain haversine over the raw file). - Feeds each point through `SpeedPipeline.onGpsFix` one at a time, using the point's own real recorded `<time>` (not a synthetic fixed cadence — komoot's actual interval varies ~0.1-160 s) and a fixed 6.0 m accuracy (mid of a realistic 5-8 m phone GPS accuracy; the file carries no accuracy field). - Asserts `movingDistanceMeters` lands within 50 m of ~119,020 m, and that `rejectedFixCount == 0`. **Measured result: 119,020.28649817174 m vs. the ~119,020.29 m ground truth — a difference of about 3.5 mm.** The 50 m tolerance is deliberately tight and is justified, not padded: a standalone Python re-implementation of the exact `SpeedPipeline`/`StopDetector` algorithm over this fixture found **zero confirmed-stop events** anywhere in the ride (no two consecutive raw points are ever slower than the 0.8 m/s stop threshold apart), so `StopDetector` never leaves its initial `MOVING` state and every position delta counts toward moving distance — for this specific fixture, "moving distance" and "raw total distance" are the same quantity, not merely close ones. A fixture with real recorded stops would need a substantially wider tolerance; this one doesn't, and the test's own KDoc says so. ## Test run ``` ./gradlew :companion:core:test :companion:pebble:test # BUILD SUCCESSFUL, all tests pass ./gradlew :companion:core:koverVerify # BUILD SUCCESSFUL, coverage floor holds ``` Real Android SDK/JDK 21 toolchain, not hand-reviewed. Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
Add GPX-replay distance test for SpeedPipeline.onGpsFix (#22)
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
d7a24d588f
Issue #22 audited against the coverage #100/#101/#102/#18/#38 already
shipped tonight: speed smoothing/stop clamp, bad-accuracy rejection
(including NaN), CSC 16-bit/32-bit wraparound at real rollover
boundaries, and source-arbitration handover (including no-source) are
all already covered by SpeedPipelineTest.kt, CscMeasurementTest.kt,
WheelSpeedTrackerTest.kt and StopDetectorTest.kt — no duplication
added for any of those.

The one real gap: "distance accumulation tested against a known GPX
with a hand-checked total" against the *live-ride* onGpsFix path.
RealWorldFixtureTest.kt already covers the *import-time*
computeRouteMetrics path over the same real ~119 km komoot fixture,
but that is a different code path from SpeedPipeline's one-fix-at-a-
time accumulation this issue is actually about.

SpeedPipelineGpxReplayTest parses the fixture's raw 2795 <trkpt>
points directly (bypassing GpxImporter's RDP simplification, so it
replays the same raw-point basis the ~119,020 m ground truth was
computed over), feeds them through SpeedPipeline.onGpsFix one at a
time using each point's real recorded timestamp and a fixed 6 m
accuracy, and asserts the resulting movingDistanceMeters lands within
50 m of that ground truth. A companion analysis confirmed this
specific ride has no segment slower than the 0.8 m/s stop threshold,
so StopDetector never confirms a stop and the tight tolerance is
justified — not padded.

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
robert merged commit 2a4fe7f144 into main 2026-09-04 20:30:49 +02:00
Sign in to join this conversation.
No description provided.