Repository navigation
HRV: default the nightly window to deep sleep, once deep staging holds up against PSG #1008
Description
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→ defaulttrue. 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→ defaultDEEP_SLEEPfor 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
HRV: default Overnight only ON for fresh installs (#1008, minimum hal…
merge: upstream 9 commits (post-9.3.0) into main
ryanbr commentedon Aug 1, 2026
Status: half of this shipped in #1019; the other half should stay put, and the blocker now has a number.
Done —
continuousHrvOvernightdefaults totrue. 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_SLEEPwindow default.The blocker above was "only defensible once the benchmark says staging holds".
Tools/SleepPSGhas since scored the shipped recipe against PSG truth over 31 subjects / 26 773 epochs, and it says the opposite: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:
AnalyticsEnginealready notes WHOOP-4.0 staging can be sparse, andchargeDeepWindowGapexists 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.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.[-]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[/+]ryanbr commentedon Aug 1, 2026
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.
avgHRVDailyunder the Deep window isdeep.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 nightSummaryalready logsnDeep, 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, andSleepStageVocabularyalready handles it.HRV: record the measured deep-staging bias where the window is defined (
pilleuspulcher-blip commentedon Aug 3, 2026
@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. 🤔
pilleuspulcher-blip commentedon Aug 4, 2026
@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
hrv: pin densest-second sample parity edge cases (#1008)
hrv: sample the densest second on an over-count night (#1008) (#1117)
ryanbr commentedon Aug 7, 2026
@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.
artemc commentedon Aug 8, 2026
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=2holdout), but it supports keeping RR cleanup and sleep-stage/window validation as distinct gates rather than expecting one to resolve the other.fix: thread the registry's active strap id into every resolvedSeries …
test: regression coverage for ryanbr#175 (resolvedSeries active strap…
feat(hrv): persist a per-night R-R over-count flag for the HRV caveat (…
9 remaining items
diag(rr): pre-storage R-R census + same-second tolerance sweep (#1008/#…
fix(hrv): align rolling rMSSD semantics across platforms (#1433)
fix(hrv): make the windowed rMSSD gap-aware, like the nightly one alr…
chore(staging): testing build 10.1.1 (343 / iOS 225)
diag(rr): print each sampled beat's ord, the last field that can loca…
chore(staging): testing build 10.1.1 (345 / iOS 227)
ryanbr commentedon Aug 20, 2026
Root-cause candidate, with numbers: the strap clock drifts ~8 s/hour and each offload re-times against it
Following the
ordresult (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: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=1787183246pair. #1427 pins the correlation to its capture wall correctly. Theoffset 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.they are mis-timed relative to each other by up to ~65 s.
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-
ordpattern.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.
dupBeatsstayed at 140-264 of 50,000 for the same reason — exact duplicates are rare because the stampsdiffer.
Ruled out by this log
52473→52562, 52563) with no range re-sent. The
4294967295entry is a0xFFFFFFFFsentinel, not acursor.
ratioRep1.01 across 3,745 intervals.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:
construction. Schema change, but it makes the row identity drift-proof.
offload, which would fix the ~65 s intra-night skew.
Not proposing a patch yet: (1) is a migration and (2) needs to know whether
GET_DATA_RANGEgives enoughto 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.
Add per-chunk historical clock + R-R packing diagnostic (#1008)
Count deliveries per stored second across a whole night (#1331/#1008)
fix(rr): tag R-R by transport, and let history own the beats it repla…
Measure how a duplicated second's two copies compare (#1510)
hrv: multiMs overflows a 32-bit Int, so the R-R delivery census repor…
hrv: state the real pct overflow bound, and pin that the wrap could l…
Read WHOOP physiology across all registered straps (#1730)
WHOOP 4 R-R sources: repin the ingress test, pair extractStreams