You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[P1] N-02: Whole-day calories count samples, not elapsed time (~30x undercount at 30 s cadence) #7
Severity: P1 — whole-day energy can be off by orders of magnitude; result depends on sampling cadence.
From an external audit of 46cf3e5; every claim re-verified against current main (f2271cd). Not fixed by any commit since.
Problem
estimateDayCalories claims a whole-day total but adds exactly one second of resting or active energy per HR row, ignoring timestamps entirely. At the ~30 s live cadence the repo itself documents for WHOOP 5/MG, resting energy is undercounted ~30×; off-wrist/missing time contributes zero BMR. The result is persisted as activeKcalEst even though it includes resting metabolism.
Evidence (current main)
Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift:556–561, 586–596: "Each HR sample = ONE second of data (1 Hz strap), counted flat" — the loop adds restingRate or max(restingRate, active) once per sample. Kotlin twin: android/app/src/main/java/com/noop/analytics/WorkoutDetector.kt:587–635.
The behavior is enshrined in tests on both platforms: Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayCaloriesTests.swift:111–124 and android/app/src/test/java/com/noop/analytics/DayCaloriesTest.kt:76–95 ("Two active samples an hour apart therefore burn the same as two adjacent active seconds").
Contradiction inside the repo: Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift:33–41 documents "the WHOOP 5/MG sends live standard HR only ~every 30 s".
Mislabeled persistence: AnalyticsEngine.swift:784–819 / AnalyticsEngine.kt:713–759 store the active+resting total in activeKcalEst (MetricsCache.swift:409–428, Entities.kt:311–314).
Note: the comment at WorkoutDetector.swift:556 shows this was a deliberate conservative choice (to avoid crediting up to ~150 s of active burn to one isolated elevated sample on gappy days). That reasoning is sound for the active increment — but applying it to basal energy is what produces the huge undercount. The two concerns need to be split, not share one rule.
Fix
Integrate BMR over the actual local calendar interval (completed day = true day length; today = midnight→now) — basal energy does not depend on sample presence.
Integrate only the active increment above resting (max(activeRate − restingRate, 0)) over bounded continuous spans / confirmed bouts; keep the no-gap-extension rule there.
Separate resting vs active energy in schema/UI (or rename the field); store coverage/confidence.
Add cadence-invariance tests: same physiology at 1 Hz, 5 s, 30 s must yield ~the same day total; plus long gaps and 23/25-hour days.
Upstream context:ryanbr#113 (open) is the feature request that motivates a native whole-day energy estimate. Related but not a duplicate — this issue is about the existing estimator's sample-counting integration, which any implementation of ryanbr#113-style scope would need fixed first.
Status update. The cadence half is fixed upstream by ryanbr#1692 (merged): estimateDayEnergy now integrates real observed seconds from sample gaps instead of counting
samples, splits resting from active kcal, and caps the active carry at the inferred cadence, with
the Kotlin twin and day-calorie tests on both platforms.
Remainder, keeping this open:AnalyticsEngine still stores the resting + active total in
the field named activeKcalEst, so the stored and displayed "active" number is not active
energy. Fixing that is a schema and UI change — a rename or a second column, the matching Room and
GRDB migrations, the .noopbak key, and the surfaces that read it — not a one-liner, so it wants
its own PR.
Activity
bhelm commentedon Aug 17, 2026
Upstream context: ryanbr#113 (open) is the feature request that motivates a native whole-day energy estimate. Related but not a duplicate — this issue is about the existing estimator's sample-counting integration, which any implementation of ryanbr#113-style scope would need fixed first.
bhelm commentedon Sep 7, 2026
Status update. The cadence half is fixed upstream by ryanbr#1692 (merged):
estimateDayEnergynow integrates real observed seconds from sample gaps instead of countingsamples, splits resting from active kcal, and caps the active carry at the inferred cadence, with
the Kotlin twin and day-calorie tests on both platforms.
Remainder, keeping this open:
AnalyticsEnginestill stores the resting + active total inthe field named
activeKcalEst, so the stored and displayed "active" number is not activeenergy. Fixing that is a schema and UI change — a rename or a second column, the matching Room and
GRDB migrations, the
.noopbakkey, and the surfaces that read it — not a one-liner, so it wantsits own PR.