Derived metric producers: MAX_SPEED, AVG_HR, HR_ZONE, AVG_CADENCE, NORM_POWER (#65) #104

Merged
robert merged 1 commit from area/derived-metrics into main 2026-09-04 20:43:07 +02:00
Owner

Closes #65.

What this adds

companion/core/src/main/kotlin/de/butzei/pedalpebble/core/ride/DerivedMetrics.kt — a producer per FieldId D28 flagged as missing a computation: MaxSpeedTracker, AverageAccumulator (shared time-weighted-mean engine behind AvgHrAccumulator/AvgCadenceAccumulator), deriveHrZone, NormalizedPowerCalculator. Pure Kotlin, no android.*, following this module's existing SpeedPipeline/StopDetector pattern.

companion/pebble/src/main/kotlin/de/butzei/pedalpebble/pebble/DerivedMetricsWireEncoding.kt — the wire-encoding half, mirroring #19's SpeedWireEncoding.kt: converts each producer's output into the docs/PROTOCOL.md §2.2 key/value pairs, sentinel-for-null per §1 rule 3, plus a generic changedKeysOnly diff for §4's policy. Nothing sends an AppMessage yet — #4/Spike A is still a placeholder, same caveat SpeedWireEncoding already documents.

Real input today vs. honestly un-fed

Metric Real input today? Blocking issue
MAX_SPEED Yes — fed from SpeedPipeline's real arbitrated speed stream (#17/#19/#69, all merged). "Ignoring accuracy-gate-rejected fixes" holds by construction (a rejected fix never produces a SpeedPipelineSample), proven with an integration test against a real SpeedPipeline, not just argued. —
AVG_HR / HR_ZONE No live bpm anywhere in companion/ yet — heart rate is watch-local (D3) and only reaches the phone as batched HR_SAMPLES. #66 (blocked on #16/#4, hardware)
AVG_CADENCE No crank-to-RPM producer exists — CscMeasurement.kt (#18) decodes raw crank-revolution fields but nothing turns them into an instantaneous RPM. #49
NORM_POWER No BLE Cycling Power (0x1818) client exists anywhere in companion/. #50

Aggregation math and interfaces for all five are real, working, and independently tested — checked against the actual codebase (grep for HR_BPM/AVG_HR/heart.?rate/HR_SAMPLES, and for a crank-cadence tracker) before writing anything, not assumed.

AvgCadenceAccumulator also resolves the acceptance criterion's own "both conventions exist" flag: zero-cadence (coasting) samples are excluded from the average, matching the criterion's own "over moving time, excluding coasting" wording — documented and tested (AvgCadenceAccumulatorTest).

Normalised power

Standard method, verified against two independent canonical sources (checked 2026-09-04, not recalled):

Both agree: 30 s rolling average power (recalculated every second), raised to the 4th power, averaged, 4th-rooted.

Hand-checked worked example (NormalizedPowerCalculatorTest, small injected window for tractable arithmetic — same "inject a smaller constant for a fast test" precedent StopDetectorTest/SpeedPipelineTest already use):

rollingWindowSeconds = 3, power samples: 100, 100, 100, 400, 100, 100 W
Rolling 3s averages produced: 100, 200, 200, 200
mean(avg^4) = (100^4 + 3*200^4) / 4 = 4,900,000,000 / 4 = 1,225,000,000
NP = 1,225,000,000^0.25 = sqrt(35,000) = 100*sqrt(3.5) ≈ 187.0829 W

Asserted against the exact 100.0 * kotlin.math.sqrt(3.5) value and the decimal expansion, both within 1e-9/1e-6. A separate test proves the real 30 s ROLLING_WINDOW_SECONDS constant on a constant-power identity case (NP == the constant exactly). No live power meter exists to run it against yet (#50) — documented explicitly in the class KDoc, not faked.

Also documented explicitly (with citation reasoning): NP deliberately does not exclude MovementState.STOPPED time itself, unlike AvgHrAccumulator/AvgCadenceAccumulator — neither cited source describes excluding stopped/zero-power spans from the published algorithm, so silently adding that exclusion would be an undocumented deviation from the cited method. Whether a future power-meter consumer (#50) gates its own calls on MovementState is that issue's decision.

Test runs (real, not hand-reviewed)

./gradlew :companion:core:test    -> BUILD SUCCESSFUL, 27 new tests across 6 classes, all pass
./gradlew :companion:pebble:test  -> BUILD SUCCESSFUL, 15 new tests, all pass
./gradlew test                    -> BUILD SUCCESSFUL, full project
./gradlew :companion:assembleDebug -> BUILD SUCCESSFUL

Test result XMLs confirmed individually (tests="N" failures="0" errors="0" per new class) — not just a green top-level task.

https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt

Closes #65. ## What this adds `companion/core/src/main/kotlin/de/butzei/pedalpebble/core/ride/DerivedMetrics.kt` — a producer per `FieldId` D28 flagged as missing a computation: `MaxSpeedTracker`, `AverageAccumulator` (shared time-weighted-mean engine behind `AvgHrAccumulator`/`AvgCadenceAccumulator`), `deriveHrZone`, `NormalizedPowerCalculator`. Pure Kotlin, no `android.*`, following this module's existing `SpeedPipeline`/`StopDetector` pattern. `companion/pebble/src/main/kotlin/de/butzei/pedalpebble/pebble/DerivedMetricsWireEncoding.kt` — the wire-encoding half, mirroring #19's `SpeedWireEncoding.kt`: converts each producer's output into the `docs/PROTOCOL.md` §2.2 key/value pairs, sentinel-for-null per §1 rule 3, plus a generic `changedKeysOnly` diff for §4's policy. Nothing sends an `AppMessage` yet — #4/Spike A is still a placeholder, same caveat `SpeedWireEncoding` already documents. ## Real input today vs. honestly un-fed | Metric | Real input today? | Blocking issue | |---|---|---| | `MAX_SPEED` | **Yes** — fed from `SpeedPipeline`'s real arbitrated speed stream (#17/#19/#69, all merged). "Ignoring accuracy-gate-rejected fixes" holds by construction (a rejected fix never produces a `SpeedPipelineSample`), proven with an integration test against a real `SpeedPipeline`, not just argued. | — | | `AVG_HR` / `HR_ZONE` | No live bpm anywhere in `companion/` yet — heart rate is watch-local (D3) and only reaches the phone as batched `HR_SAMPLES`. | #66 (blocked on #16/#4, hardware) | | `AVG_CADENCE` | No crank-to-RPM producer exists — `CscMeasurement.kt` (#18) decodes raw crank-revolution fields but nothing turns them into an instantaneous RPM. | #49 | | `NORM_POWER` | No BLE Cycling Power (`0x1818`) client exists anywhere in `companion/`. | #50 | Aggregation math and interfaces for all five are real, working, and independently tested — checked against the actual codebase (grep for `HR_BPM`/`AVG_HR`/`heart.?rate`/`HR_SAMPLES`, and for a crank-cadence tracker) before writing anything, not assumed. `AvgCadenceAccumulator` also resolves the acceptance criterion's own "both conventions exist" flag: zero-cadence (coasting) samples are excluded from the average, matching the criterion's own "over moving time, excluding coasting" wording — documented and tested (`AvgCadenceAccumulatorTest`). ## Normalised power Standard method, verified against two independent canonical sources (checked 2026-09-04, not recalled): - TrainerRoad — ["Normalized Power®: What It Is and How to Use It"](https://www.trainerroad.com/blog/normalized-power-what-it-is-and-how-to-use-it/) - TrainingPeaks coach blog — ["Understanding Normalized Power Calculations to Coach Cyclists"](https://www.trainingpeaks.com/coach-blog/normalized-power-how-coaches-use/) Both agree: 30 s rolling average power (recalculated every second), raised to the 4th power, averaged, 4th-rooted. Hand-checked worked example (`NormalizedPowerCalculatorTest`, small injected window for tractable arithmetic — same "inject a smaller constant for a fast test" precedent `StopDetectorTest`/`SpeedPipelineTest` already use): ``` rollingWindowSeconds = 3, power samples: 100, 100, 100, 400, 100, 100 W Rolling 3s averages produced: 100, 200, 200, 200 mean(avg^4) = (100^4 + 3*200^4) / 4 = 4,900,000,000 / 4 = 1,225,000,000 NP = 1,225,000,000^0.25 = sqrt(35,000) = 100*sqrt(3.5) ≈ 187.0829 W ``` Asserted against the exact `100.0 * kotlin.math.sqrt(3.5)` value and the decimal expansion, both within 1e-9/1e-6. A separate test proves the real 30 s `ROLLING_WINDOW_SECONDS` constant on a constant-power identity case (NP == the constant exactly). No live power meter exists to run it against yet (#50) — documented explicitly in the class KDoc, not faked. Also documented explicitly (with citation reasoning): NP deliberately does **not** exclude `MovementState.STOPPED` time itself, unlike `AvgHrAccumulator`/`AvgCadenceAccumulator` — neither cited source describes excluding stopped/zero-power spans from the published algorithm, so silently adding that exclusion would be an undocumented deviation from the cited method. Whether a future power-meter consumer (#50) gates its own calls on `MovementState` is that issue's decision. ## Test runs (real, not hand-reviewed) ``` ./gradlew :companion:core:test -> BUILD SUCCESSFUL, 27 new tests across 6 classes, all pass ./gradlew :companion:pebble:test -> BUILD SUCCESSFUL, 15 new tests, all pass ./gradlew test -> BUILD SUCCESSFUL, full project ./gradlew :companion:assembleDebug -> BUILD SUCCESSFUL ``` Test result XMLs confirmed individually (`tests="N" failures="0" errors="0"` per new class) — not just a green top-level task. https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
Add derived-metric producers: MAX_SPEED, AVG_HR, HR_ZONE, AVG_CADENCE, NORM_POWER (#65)
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
590eefc220
companion/core/.../ride/DerivedMetrics.kt gives every FieldId D28 flagged as having no
producer a real, pure-Kotlin implementation:

- MaxSpeedTracker — real input today, fed from SpeedPipeline's arbitrated
  SpeedPipelineSample stream (#17/#19/#69). Ignoring accuracy-gate-rejected fixes falls
  out by construction (SpeedPipeline.onGpsFix never produces a sample for a rejected
  fix), proven with an integration test against a real SpeedPipeline, not just argued.
- AverageAccumulator — the shared time-weighted-mean engine behind AvgHrAccumulator and
  AvgCadenceAccumulator, both real, tested aggregation math with no live input yet:
  heart rate is watch-local (D3) and only reaches the phone via HR_SAMPLES (#66, not
  built); cadence has no crank-to-RPM producer yet (#49, not built). AvgCadenceAccumulator
  documents and tests the "excludes coasting" choice the issue's own acceptance criteria
  flagged as ambiguous (zero-cadence samples excluded, matching "over moving time,
  excluding coasting" literally).
- deriveHrZone — pure function reusing #44's existing HrZoneBoundaries; null (renders
  --) when zones are unconfigured, never a guessed default.
- NormalizedPowerCalculator — the standard 30s-rolling-average / 4th-power / mean /
  4th-root algorithm, verified against two independent canonical sources (TrainerRoad,
  TrainingPeaks coach blog) rather than recalled, with a hand-checked worked example
  proving the arithmetic (100,100,100,400,100,100 W -> NP = 100*sqrt(3.5) ≈ 187.083 W).
  No live power meter exists yet (#50), documented explicitly rather than faked.

companion/pebble/.../DerivedMetricsWireEncoding.kt is the wire-encoding half, mirroring
#19's SpeedWireEncoding.kt precedent: converts each producer's nullable output into the
docs/PROTOCOL.md §2.2 key/value pairs (MAX_SPEED_MMS, AVG_HR_BPM, HR_ZONE,
AVG_CADENCE_RPM, NORM_POWER_W), using each type's sentinel for "no value yet" per §1
rule 3, plus a small generic changedKeysOnly diff implementing §4's "only changed
fields are sent" policy. Nothing sends an AppMessage yet (#4/Spike A is still a
placeholder) — same caveat SpeedWireEncoding already carries.

42 new tests, all real and passing: ./gradlew :companion:core:test and
:companion:pebble:test, plus the full `test` and :companion:assembleDebug for the whole
app.

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