Speed-source arbitration and GPS quality reporting (#19) #102
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!102
Loading…
Reference in a new issue
No description provided.
Delete branch "area/speed-source-arbitration"
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 #19.
Scope
Most of the wheel-vs-GPS arbitration was already built by #69/#17 (
SpeedPipeline). This issue'sremaining scope, per its acceptance criteria: reconcile the 3s-vs-5s wheel-live-window discrepancy,
derive
GPS_QUALITY(new logic), wireSPEED_SOURCE/GPS_QUALITYto the generatedProto.Keyconstants, close a real distance-discontinuity gap on source handover, and give "no source at all"
an honest reporting state instead of a frozen number.
The 3s-vs-5s wheel-live-window discrepancy
SpeedPipeline.WHEEL_LIVE_WINDOW_NANOSwas shipped as 5s by #69/PR #100. This issue's ownacceptance criterion says 3s. Checked
docs/DECISIONS.mdD4 (the original speed-sourcingdecision, predating #69): it already says, verbatim, "wheel sensor if it has reported within the
last 3 s, GPS otherwise." So this wasn't a case of picking between two arbitrary numbers — D4
had already settled on 3s, and #69's 5s was a default picked without D4 in view (its own KDoc
justified 5s only in the abstract, "generous relative to a sensor's normal notify rate," against no
specific requirement). Resolved: changed the constant to 3s, matching D4, updated
SpeedPipelineTest's timings, and amendeddocs/DECISIONS.mdD35's own "2026-09-04 update" notewith the correction and the reasoning (
docs/DECISIONS.md, D35 amendment).SPEED_SOURCE / GPS_QUALITY wire keys
Both already existed from #7 (
shared/message_keys.json, generated intocompanion/pebble/.../Proto.kt'sProto.Key.SPEED_SOURCE/Proto.Key.GPS_QUALITY), and thenumbering already matched this issue exactly —
message_keys.json's own note onSPEED_SOURCEalready reads "0 = none, 1 = GPS, 2 = wheel sensor," the same numbering this issue's acceptance
criterion asks for. No reconciliation needed, unlike #7's own
RideCmdnumbering fix. New:companion/pebble/src/main/kotlin/de/butzei/pedalpebble/pebble/SpeedWireEncoding.kt— a small,pure encoder (following
ProtocolHandshake.kt's existing "pure logic nearProto.kt, noPebbleKit/Android dependency" pattern in this module) turning a
SpeedReport+ aGPS_QUALITYintinto the
Map<Int, Int>ofProto.Key-> value a futureAppMessagedictionary-builder (#4/SpikeA) can merge in directly — nothing sends an
AppMessageyet,PebbleTransportis still #4'splaceholder.
GPS_QUALITY 0-3 derivation
New:
companion/core/.../location/GpsQuality.kt,deriveGpsQuality(accuracyMeters, fixAgeSeconds): Int. Grades accuracy and age independently into 4 tiers each and reports the worse of the two(not an average — a fresh-but-inaccurate fix and a stale-but-accurate fix are both real problems,
and averaging would hide which one is wrong):
MAX_ACCEPTABLE_ACCURACY_METERS'sown already-documented real-world bands (roughly 3-15m open sky, 10-30m tree cover, 30m+ rejected
outright) — tier 3 sits inside open-sky, tier 2 mid-canopy, tier 1 covers the rest of what's
actually accepted, tier 0 is what the accuracy gate already rejects.
SIGNAL_TIMEOUT_NANOS(see below) — tier 3 tolerates one missed beat, tier 2 a handful ofconsecutive misses, tier 1 runs right up to the no-signal boundary, tier 0 is what's already past it.
SpeedPipeline.currentGpsQuality(atNanos)wires this to the real pipeline state (the most recentlyaccepted fix's accuracy/age; 0 if no fix has ever been accepted). Deliberately reads the position
baseline directly rather than "whichever source is preferred" — GPS quality keeps reporting
correctly even while the wheel is the active source, feeding off the same baseline the
discontinuity guard below keeps fresh.
The distance-discontinuity guard (the real bug this issue asked me to look for)
Found a genuine bug, not just a missing test:
onGpsFixchecked the accuracy gate before thewheel-live check. So a GPS fix that failed accuracy while the wheel was live was rejected outright
and never refreshed the position baseline (
lastAcceptedGpsFix) — even though GPS wasn't the activesource and the fix was only ever going to matter as a future baseline. A sustained run of such fixes
(a bridge or urban canyon, with a wheel sensor also paired) left the baseline stuck at whatever fix
preceded the bad patch. The moment the wheel then fell quiet and GPS accuracy recovered, the very
next accepted fix computed its distance against that stale baseline — a spike bounded only by how
far the rider had actually ridden during the bad patch, not by anything the pipeline checked.
Fix: check wheel-liveness first; while the wheel is live, refresh the baseline from every
incoming fix regardless of its own accuracy (never counted as a rejection either way — dropped for
arbitration, not for quality). Bounds the worst case to a single fix's own accuracy error instead of
an unbounded, ever-growing jump.
Test proving it (
SpeedPipelineTest.kt, "onGpsFix does not spike distance when GPS accuracy waspoor throughout a long wheel-live stretch"): 9 seconds of wheel-live riding with GPS producing
50m-accuracy fixes throughout (tracking the same real 5 m/s progression), then the wheel falls
silent and a good fix arrives 5m further along. Asserts
movingDistanceMetersis ~50m (45m wheel +5m final delta) — the regression this guards against would report ~95m (the stale 45m-old baseline
producing a 50m delta in one interval instead of 5m).
No-signal reporting
New:
SpeedReportsealed interface (Live(sample)/NoSignal) andSpeedPipeline.currentReport(atNanos). Previously, a caller polling after both sources went quietwould just keep seeing whatever
SpeedPipelineSamplewas last produced, forever — the exact "stalenumber" this issue's acceptance criterion forbids, and the phone-side mirror of
docs/DECISIONS.mdD44's watch-side rule ("unavailability must be signalled explicitly, never inferred from silence").
currentReportis keyed offadvance()'s own shared bookkeeping (any source, not source-specific)against a new
SIGNAL_TIMEOUT_NANOS= 10s — deliberately not reused fromWHEEL_LIVE_WINDOW_NANOS(3s), since the two answer different questions: 3s governs which sourcewins when both could be live (tight, because GPS should hand back over promptly); 10s governs "is
there a signal at all," which has to tolerate a run of ordinary missed/rejected 1Hz GPS fixes (a
bridge, tall buildings) without flapping the display, while still surfacing a genuine loss (a
tunnel, an unpaired wheel sensor with no GPS fix either) within an order of magnitude a rider would
call "the display noticed." Full reasoning on the constant's own KDoc.
Files
companion/core/src/main/kotlin/de/butzei/pedalpebble/core/location/SpeedPipeline.kt— window fix,discontinuity-guard reorder,
SpeedReport/currentReport/currentGpsQuality.companion/core/src/main/kotlin/de/butzei/pedalpebble/core/location/GpsQuality.kt— new, purederiveGpsQuality.companion/pebble/src/main/kotlin/de/butzei/pedalpebble/pebble/SpeedWireEncoding.kt— new, wireencoding against
Proto.Key.SpeedSourceArbiter.kt(the issue's literal suggested filename): the arbitrationitself was already fully built and correct in
SpeedPipelinebefore this issue's scope was pickedup — a wrapper class re-deciding an already-decided source would be exactly the "two definitions"
problem D35 exists to prevent, one level up. What was actually still owned (no-signal query,
GPS-quality derivation, the discontinuity bug, wire encoding) landed where each piece's own
dependencies put it instead; reasoning is on
SpeedWireEncoding.kt's own KDoc.docs/DECISIONS.mdD35 amended (2026-09-04, #19) with the window correction and this PR's summary.GpsQualityTest.kt(new),SpeedWireEncodingTest.kt(new), 7 new cases inSpeedPipelineTest.kt(discontinuity regression,currentReport/SpeedReportx3,currentGpsQualitywiring x3).Verification
./gradlew :companion:core:test— real run, all green (SpeedPipelineTest 19 tests, GpsQualityTest6 tests, plus the rest of the module unaffected).
./gradlew :companion:pebble:test— real run, all green (ProtocolHandshakeTest 6,SpeedWireEncodingTest 6).
./gradlew :companion:assembleDebug— real Android SDK + JDK 21 build, succeeds.https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt