Speed pipeline: smoothing, stop clamping, distance, moving average (#17) #101
No reviewers
Labels
No labels
area:companion
area:docs
area:shared
area:tooling
area:watchapp
blocker
kind:chore
kind:feature
kind:spike
kind:test
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
robert/PedalPebble!101
Loading…
Reference in a new issue
No description provided.
Delete branch "area/speed-pipeline"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #17.
Extends #69's
SpeedPipeline(companion/core,core.locationpackage) rather than forking it: a newGpsFixtype +onGpsFix()entry point sits in front of the existing wheel/GPS arbitration andStopDetector, adding everything #17 asks for that #69 deliberately left out ("'stopped' is defined in #69, not here").Accuracy gate. Fixes with
accuracyMeters > 30 m(orNaN, i.e.Location.hasAccuracy() == false) are rejected outright — before smoothing, before touching the position baseline. 30 m sits above typical canopy-degraded fixes (real-world GPS accuracy is roughly 3-15 m under open sky, 10-30 m under tree cover, 30-100+ m in genuine urban canyon/multipath), so ordinary riding under trees is not thrown away, while fixes bad enough to plausibly corrupt distance are. This is a tuned engineering judgement call, documented as such onSpeedPipeline.MAX_ACCEPTABLE_ACCURACY_METERS— not a platform constant, and the number to revisit once a real GPX-replay harness (#23) exists.Has-speed vs. distance/dt fallback.
Location.getSpeed()used directly when the platform supplied one; a great-circle (haversine) position delta over elapsed time otherwise — both paths implemented and covered by tests, including the no-speed-field rollover case.~3 s smoothing. A plain 3-sample simple moving average (SMA) at the 1 Hz cadence NFR-B2 assumes — chosen over an exponential/low-pass filter because GPS fixes arrive at a known, steady rate (no irregular spacing for a decay constant to handle better than a fixed window would) and a fixed window is trivially testable (sum of N known values / N) versus reasoning about an EMA's tail. Justification is on
SpeedPipeline.GPS_SMOOTHING_WINDOW_SIZE.Real position-based distance.
onGpsFixaccumulates haversine deltas between consecutive accepted fixes rather thanspeed × dt— exact regardless of whether a given fix carried a platform speed. (onGpsSpeedSample, the pre-existing scalar-only entry point, keeps itsspeed × dtapproximation since it genuinely never receives a position — unchanged, still covered by #69's own tests.)Rejected fixes are never silent.
SpeedPipeline.rejectedFixCount/.lastRejectedFixsurface every accuracy rejection;RideServicelogs each one viaLog.w. Wiring this into an actual GPS-quality UI (the watch status strip already has a placeholder icon per #59/PR #87) is later scope, per the issue's own wording.Display clamp.
SpeedPipelineSample.speedMetersPerSecondnow reports exactly0.0onceStopDetectorconfirmsSTOPPED, instead of whatever residual jitter produced that state. The 0.8 m/s number itself still lives in exactly one place —StopDetector.STOP_THRESHOLD_MPS(#69/D35) — this only decides what a caller sees once that single decision has already landed on STOPPED, per the issue's "state explicitly whether the display clamp and stop threshold are the same number, and why" follow-up.Android-SDK-bound half
companion/location/src/main/kotlin/de/butzei/pedalpebble/location/LocationFixMapping.kt: a smallLocationFix -> GpsFixadapter, following the mirrored-type pattern this codebase already uses forRideSetupState/RidePermissionsrather than growing a dependency from:companion:coreonto:companion:location.RideService(companion/ride) now constructs aSpeedPipelineand feeds everyLocationFixthroughonGpsFix, logging (not silently dropping) any rejected fix. No wheel sensor is wired into this service yet (#19's remaining scope) — the KDoc added toRideServiceflags the "nullhere currently only means accuracy-rejected" assumption that breaks once #19 lands. The notification text is left as the existing fix-count display; reflecting real distance/speed there is a UI decision this issue doesn't make unilaterally.Deviation from the literal acceptance criteria — reported, not silently overridden
The issue's acceptance criteria say
FusedLocationProviderClientat 1 s/high accuracy.AndroidLocationSource(companion/location, built in #15/PR #97, merged before this issue's remaining scope was picked up) already deliberately usesandroid.location.LocationManager's modernLocationRequest.BuilderAPI instead — a documented decision (see that file's own KDoc), not an oversight, made possible becauseminSdkis already 31 (D19), exactly where that builder API starts. It already requests 1 Hz /QUALITY_HIGH_ACCURACY. Addingplay-services-locationnow would contradict that already-shipped design for no behavioural gain. Noplay-services-locationdependency was added — verified viagit diff --statongradle/libs.versions.tomland everybuild.gradle.kts(empty).Tests
companion/core/src/test/kotlin/de/butzei/pedalpebble/core/location/SpeedPipelineTest.ktadds real JVM/Kotest coverage: the accuracy gate (including theNaN/hasAccuracy()==falseedge case caught while wiringLocationFixMapping), a fix's own platform speed taking precedence over a position-derived one, the distance/dt fallback converging through the smoothing window to the expected steady-state speed, wheel-live fixes dropping without counting as rejections, and the display clamp. Run via./gradlew :companion:core:test— all passing (167 → 173 tests)../gradlew :companion:assembleDebug(real SDK + JDK 21) — passes. No GPS hardware in this sandbox, so the live location callback itself is compiled, not exercised — same honesty caveat as every other sensor/location PR tonight.https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt