Skip to content

[P1] N-02: Whole-day calories count samples, not elapsed time (~30x undercount at 30 s cadence) #7

Open

Description

@bhelm

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.

Both platforms in one PR (parity contract).

Activity

  1. bhelm commented on Aug 17, 2026

    @bhelm
    OwnerAuthor

    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.

  2. bhelm commented on Sep 7, 2026

    @bhelm
    OwnerAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions