Skip to content

Readiness suggests wrong baseline #2315

Open

Description

@bartmuskala

NOOP version

11.7.0

Platform

Android

OS version

16

WHOOP strap / Oura ring model

WHOOP 5.0

Where the data is coming from

Live Bluetooth (WHOOP strap connected)

Test Centre profile

Not a test-mode bug

What happens

The baseline on top of the charge page says 57. At the bottom it is 48. This is rather my HRV. Could be a coincidence?

Image Image

What you expected instead

Same value.

Steps to reproduce

Go to charge screen

Strap log


Before you submit

  • I attached my exported NOOP .zip (not a screenshot of the Live screen). If my bug is a clearly reproducible UI issue I described exactly how to see it instead.
  • I attached or pasted a strap log above — or my bug is a clearly reproducible UI issue that doesn't need one (e.g. a typo, a button that does nothing) and I've described exactly how to see it.
  • I checked the Troubleshooting guide — especially "history syncs but no data" (most often the strap isn't banking history to flash; charge it to 100% and reconnect).
  • I checked the FAQ and — if I'm on a WHOOP 5.0 / MG — the 5.0/MG status page, or — if I'm on an Oura ring — the Oura ring support table, and this isn't an already-known limit (e.g. SpO₂, recovery/sleep on the 5/MG, or no Recovery / HRV on a ring-only day).
  • I've left out anything personal (strap serial, account email, raw export).

Activity

  1. bartmuskala commented on Sep 18, 2026

    @bartmuskala
    Author

    Coach says yet another value. 52 Ms.

    Image
  2. ryanbr commented on Sep 18, 2026

    @ryanbr
    Owner

    Thanks @bartmuskala, and the second screenshot is what makes this diagnosable. Three
    numbers on three surfaces is not a coincidence: there are four separate HRV baseline
    computations in the app, and you are seeing three of them.

    The four

    # Where How it is computed
    1 The engine, which scores Charge Baselines.foldHistory WITH the recalibration epoch. Winsorized at 3x spread, hard-rejects at 5x, gated on 4 seed / 14 trust nights
    2 "What shaped it" driver rows The same fold WITHOUT the epoch (TodayScoring.kt:70, ChargeBreakdownWiring.swift:38)
    3 Coach A plain mean of the trailing 30 days excluding today, no winsorization and no gating (CoachSuggestions.kt:94, CoachSuggestions.swift:97)
    4 The Today "X% over baseline" insight A plain mean of whatever priors the caller passes (TodayView.swift:3212)

    Only the first is the baseline the Charge score actually rides.

    Why the two numbers on one page differ

    The headline reads #1 and the rows read #2, and the only difference between them is one
    argument:

    TodayScoring.kt:41   foldHistory(..., Baselines.hrvCfg, hrvBaselineEpoch)   // headline path
    TodayScoring.kt:70   foldHistory(ordered.map { it.avgHrv }, Baselines.hrvCfg) // driver rows

    The comment above the second one already states the condition:

    byte-identical to the engine's whole-history fold when no manual Recalibrate epoch is
    set, the common case

    noop.hrvBaselineEpoch is written by Settings, Recalibrate HRV baseline. Once it is set,
    the engine discards every night dated before it while the driver rows keep folding the
    whole history, so the two numbers separate and stay separated. Apple has the identical
    split, so this is not Android-only.

    The one thing that would confirm it: have you ever used Recalibrate HRV baseline in
    Settings?
    No log needed, just yes or no. If yes, that is 57 against 48 explained. If no,
    then #1 and #2 should agree and the interesting number is the pair that does not, which
    would point somewhere else and I would want the export after all.

    Coach's 52 ms needs no recalibration to differ from either. A plain 30-day mean with no
    winsorization will sit apart from a winsorized, outlier-rejected baseline for anyone with
    one unusual night, which is most people.

    Proposed fix

    1. Thread the epoch into the driver folds. TodayScoring.kt:70 and
    ChargeBreakdownWiring.swift:38 take hrvBaselineEpoch exactly as the headline path does.
    That makes the two numbers on the Charge page the same number by construction rather than
    by coincidence, and it is a small change on both platforms with an existing twin test shape
    to follow.

    2. Give Coach the real baseline. Route CoachSuggestions through Baselines instead of
    its own mean. A user cannot reconcile advice against a number nothing else in the app
    agrees with. If Coach genuinely needs a different window, then it should not be called a
    baseline in the copy.

    3. One definition, with the others named. The deeper problem is that "baseline" is four
    things wearing one word. hrvBaselineDeltaPct already says so:

    Proper fix = tag HRV provenance/method per row and isolate baselines (separate follow-up)

    Worth doing after 1 and 2, since those two remove the visible contradiction while this one
    is a refactor with its own risk.

    I would not start on any of it before the Recalibrate answer, because it decides whether
    step 1 is the fix for what you are seeing or just a correctness cleanup that leaves your
    actual complaint untouched.

  3. bartmuskala commented on Sep 19, 2026

    @bartmuskala
    Author

    No. I never recalibrated. Thanks for looking into your suggested solutions.

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

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions