Skip to content

HRV: default the nightly window to deep sleep, once deep staging holds up against PSG #1008

Closed

Description

@ryanbr

What

NOOP already has a WHOOP-comparable HRV setting, and does not default to it. Both HRV defaults currently sit on the less WHOOP-like, more expensive side.

The two defaults

1. HRV window — WHOLE_NIGHT, where WHOOP uses slow-wave sleep.

HrvWindow (Units.kt) already carries both, and the enum's own doc comments say it plainly:

  • WHOLE_NIGHT("whole") — "RMSSD averaged over every 5-min window of the night (NOOP's long-standing value)."
  • DEEP_SLEEP("deep") — "RMSSD over DEEP (slow-wave) sleep windows only — comparable to WHOOP's reading."

An unset value resolves to WHOLE_NIGHT, so every install reads on the axis that does not line up with the number people compare against. The Settings copy already tells the user Deep sleep is the one "matching WHOOP", which makes the default the odd choice rather than the safe one.

2. continuousHrvOvernight defaults to false, so continuous capture runs 24/7.

Turning on "Continuous HRV capture" gives an all-day beat-to-beat stream unless the user separately finds and enables "Overnight only". WHOOP publishes no daytime HRV figure at all — its reading is an overnight one — so a 24/7 stream has no official-app analogue, and the setting's own copy says overnight-only "roughly halves the battery cost". The expensive option is the one you get by default.

(continuousHrv itself defaults to false, which is right and should stay.)

Proposed

Keep both configurable; move the defaults to the WHOOP-like side.

  • continuousHrvOvernight → default true. Low risk: it only narrows a stream the user has explicitly opted into, matches what WHOOP actually reports, and halves the cost of the opt-in. Existing users who already enabled continuous capture should keep whatever they have set rather than silently narrowing.
  • HrvWindow → default DEEP_SLEEP for new installs only. See below.

The part that needs care

Changing the HRV window is not cosmetic: setHrvWindow's own comment notes it "re-scores + re-baselines (the value itself moves)". Flipping the default under existing users would move every HRV number and every baseline built on it, in a direction they did not ask for — the same retroactive-score problem #963 and #983 caused, which is the kind of change that costs trust even when it is more correct.

So: persist the current value explicitly for existing installs on upgrade, and let only fresh installs pick up the new default. That means "default matches WHOOP" applies going forward rather than retroactively. Anyone who wants to move can, and the Settings copy already explains what changes.

Blocker to check first: DEEP_SLEEP pools RMSSD over deep-sleep windows, so it is only as trustworthy as deep-sleep detection. Staging changed in #987, and 5/MG staging is still being worked out. Defaulting to a window that depends on staging quality is only defensible once the benchmark says that staging holds — Tools/SleepPSG scoring the shipped stager against PSG truth is the check that would say so. If deep-sleep detection is not solid on a given strap, whole-night is the more honest default for that strap and the default may need to be family-aware rather than global.

Not in scope here

This does not address the underlying battery cost, which is #1007 — history offload spends ~2.4 h of radio a day regardless of any HRV setting. Narrowing continuous capture to overnight is worth doing on its own merits, but it is not the fix for #1005.

Activity

  1. added a commit that references this issue on Aug 1, 2026
  2. ryanbr commented on Aug 1, 2026

    @ryanbr
    OwnerAuthor

    Status: half of this shipped in #1019; the other half should stay put, and the blocker now has a number.

    Done — continuousHrvOvernight defaults to true. Wired at all three launch sites (NoopApplication.onCreate, StrandApp.init, StrandiOSApp.init) with the upgrade migration pinning existing opted-in users to their current value, plus twin tests on both platforms. Nothing outstanding there.

    Not done, and shouldn't be yet — the DEEP_SLEEP window default.

    The blocker above was "only defensible once the benchmark says staging holds". Tools/SleepPSG has since scored the shipped recipe against PSG truth over 31 subjects / 26 773 epochs, and it says the opposite:

    predicted truth bias
    deep, pooled over all scored epochs 18.94 % 13.76 % +5.18 pp
    deep, mean of per-subject percentages 19.24 % 14.76 % +4.48 pp

    Four-class kappa is 0.356. The stager calls roughly 38 % more deep-sleep epochs than exist (pooled).

    That matters here specifically, because the Deep window pools RMSSD over deep-stage 5-minute windows taken from NOOP's own staging (AnalyticsEngine.analyzeDay, deepHrvWindow), not from anything the strap reports. So the bias lands directly on the value: a window that is ~38 % over-inclusive pulls the "deep" reading toward the whole-night mean, which is the one thing the setting exists not to be. Defaulting to it would ship a number labelled WHOOP-comparable that is measurably less deep-specific than the label claims.

    Two further points the benchmark reinforces rather than settles:

    • The per-strap concern in the original text still stands. AnalyticsEngine already notes WHOOP-4.0 staging can be sparse, and chargeDeepWindowGap exists precisely for a night that banks under ~5 minutes of deep sleep and therefore scores no Charge at all. A global default would hand that failure mode to users who never chose the window.
    • The over-call cuts against the empty-Charge risk (more predicted deep means fewer nights fall under the 5-minute floor), so the failure this would cause is a quietly wrong number rather than a visible blank. That is the worse of the two.

    Leaving the window default as WHOLE_NIGHT. Revisit when the deep bias is inside a few points and kappa is materially better than fair — the benchmark is the gate, and it is now cheap to re-run.

  3. changed the title [-]HRV: default to the WHOOP-comparable settings (deep-sleep window, overnight-only capture), keep both configurable[/-] [+]HRV: default the nightly window to deep sleep, once deep staging holds up against PSG[/+] on Aug 1, 2026
  4. ryanbr commented on Aug 1, 2026

    @ryanbr
    OwnerAuthor

    One more thing found while checking whether the deep window needs a code fix rather than a doc one.

    There is no minimum deep-window count. avgHRVDaily under the Deep window is deep.isEmpty ? nil : mean(deep) on both platforms, so a night with a single 5-minute deep window produces a full nightly HRV — against roughly 90 windows for whole-night. Combined with the +5.18 pp over-call above, one spurious deep window is enough to publish a confident-looking number built from five minutes of R-R.

    Not changing it here: any threshold picked now is arbitrary, and raising it re-scores history for everyone already on the Deep window — the same retroactive-score problem this issue already flags. hrv nightSummary already logs nDeep, so the order is measure the distribution first, then set a floor. Worth settling before the default moves, not after.

    Also checked and clean, so they don't need re-checking later: the Swift and Kotlin deep-window computations are structurally identical with the same iteration and summation order (no float drift), and the stage == "deep" filter is unambiguous — the stager and both importers agree on that spelling. Only wake has the two-spelling problem, and SleepStageVocabulary already handles it.

  5. pilleuspulcher-blip commented on Aug 3, 2026

    @pilleuspulcher-blip

    @ryanbr

    Does it mean that Noop now has equal values to Whoop? Up to now (including the last night with the prerelease of 9.3.1) Noop doesn't have values comparable to Whoop or other apps (Polar, Suunto, Huawei) which use the SWS reading. 🤔

  6. pilleuspulcher-blip commented on Aug 4, 2026

    @pilleuspulcher-blip

    @ryanbr

    9.3.1, Whoop 4, Android 17

    The last two nights HRV and RHR differ from the Whoop data (I imported my Whoop history to have the correct personal range of SWS reading) in the way it was from beginning since I started to use Noop.

    Deep sleep window and night only are the settings in Noop.

    noop-strap-log-260804-0833.txt

    Image
  7. ryanbr commented on Aug 7, 2026

    @ryanbr
    OwnerAuthor

    @pilleuspulcher-blip thanks — your strap log actually pinned this down, and the short version is: your settings are fine. Deep-sleep + Overnight-only are working as intended; the mismatch isn't coming from the window choice.

    The real cause is in your WHOOP 4.0's R-R (heartbeat-interval) data: the log shows roughly twice as many intervals as there are seconds in the night on every night, including the ones NOOP captured live. HRV is calculated from the beat-to-beat differences, so those extra/duplicated beats throw the HRV number off — while resting HR stays correct (which matches what you're seeing). That's why it doesn't line up with WHOOP/Polar/Suunto.

    I've split this into its own tracking issue (#1118) and shipped a diagnostic (#1117) that will make the exact cause visible in the next strap log — once we know the shape, the fix is either de-duplicating those beats or labelling the extra channel, after which HRV should track much closer.

    So nothing to change on your end for now. If you can grab a fresh strap log after the next build lands, that'll confirm the mechanism. Appreciate the detailed report — it's what made this findable.

  8. artemc commented on Aug 8, 2026

    @artemc

    I added an independent reference-backed RR-integrity comparison to #1118. The result also helps separate the two blockers discussed here: on two previously held-out nights without obvious nightly RR over-count, whole-night RMSSD had MAE of about 2 ms against both official WHOOP HRV and simultaneous Polar H10 RMSSD, while NOOP-detected deep-only RMSSD had MAE of about 12.0 ms and 10.1 ms, respectively. This is only one participant (n=2 holdout), but it supports keeping RR cleanup and sleep-stage/window validation as distinct gates rather than expecting one to resolve the other.

  9. 9 remaining items

  10. ryanbr commented on Aug 20, 2026

    @ryanbr
    OwnerAuthor

    Root-cause candidate, with numbers: the strap clock drifts ~8 s/hour and each offload re-times against it

    Following the ord result (multi-delivery seconds on every examined day, #1118),
    the same capture explains why the same real moment ends up stored at more than one wall second.

    The measurement

    Every offload seeds its correlation from GET_DATA_RANGE, and the log prints both clocks:

    when device wall offset
    19:21 (prev day) 1787090366 1787090381 15 s
    21:47 (prev day) 1787126776 1787126824 48 s
    11:43 (today) 1787182794 1787182994 200 s
    11:47 (today) 1787183022 1787183246 224 s

    Over 25.8 h the offset grew 209 s — about 8.1 s/hour, monotonically. The strap's RTC is running slow
    against the phone. Within a single 8-hour night that is roughly 65 s of drift.

    This is not a regression, and #1427 is working

    Within a session the correlation is stable and reused — 11:43 / 11:47 / 12:04 all reuse the same
    device=1787183022 wall=1787183246 pair. #1427 pins the correlation to its capture wall correctly. The
    offset changing between sessions is the strap's clock genuinely drifting, not a bug in the fix. My
    earlier note that the offset was "pinned at 17 s" described one log; across a day it is not constant, and
    it is not supposed to be.

    Why that produces the over-count

    Beats are stamped device + offset, with one offset per offload, seeded at offload time.

    • Within a night: beats banked at 23:00 and at 07:00 are stamped with the same correlation, so
      they are mis-timed relative to each other by up to ~65 s.
    • Across offloads: a moment banked before a drift step and re-covered after one is stamped at two
      different wall seconds. Σ(rr) grows while the span does not, which is coverage 1.7-2.2 exactly as
      observed — and records ~200 s apart in device time can collide on the same wall second under two
      different offsets, which is the repeated-ord pattern.

    It also explains why nothing value-based ever worked: the duplicate rows are not similar-looking beats to
    be collapsed by tolerance, they are the same beats carrying different, legitimately-derived timestamps.
    dupBeats stayed at 140-264 of 50,000 for the same reason — exact duplicates are rare because the stamps
    differ.

    Ruled out by this log

    • Not re-delivery. Trim advances cleanly across all five sessions (52058→52469, 52470→52471,
      52473→52562, 52563) with no range re-sent. The 4294967295 entry is a 0xFFFFFFFF sentinel, not a
      cursor.
    • Not the decoder. Fresh emission is ratioRep 1.01 across 3,745 intervals.
    • Not the ingest key. It absorbs ~0.6% (3,745 offered → 3,723 inserted).

    Direction

    The fix is about time basis, not de-duplication: a beat's identity should not depend on which
    offload happened to observe it. Options worth weighing, in rough order of appeal:

    1. Store device time alongside wall time and key/de-dup on the device clock, which is stable by
      construction. Schema change, but it makes the row identity drift-proof.
    2. Interpolate the correlation across the covered window rather than applying one offset to a whole
      offload, which would fix the ~65 s intra-night skew.
    3. Refuse to re-stamp wall-time already covered — narrower, and it treats the symptom.

    Not proposing a patch yet: (1) is a migration and (2) needs to know whether GET_DATA_RANGE gives enough
    to interpolate between. Both are answerable from a capture that logs the correlation per chunk rather than
    per session, which is a small diagnostic step and the natural next one.

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