Repository navigation
[hrv] When the main night's HRV is refused as over-counted, the day's HRV and the baseline fall back to naps alone #2425
Description
NOOP version
11.8.0
Platform
iOS
OS version
26.6.2
WHOOP strap / Oura ring model
Oura Ring 3 (Gen3)
Where the data is coming from
Live Bluetooth (Oura ring connected)
Test Centre profile
Not a test-mode bug
What happens
Summary
When #1118's gate refuses a session's HRV (sessionAvgHRV returns nil on an over-counted session), the
day's HRV is still produced from whichever other sessions of that day passed. Those are naps. So a
night refused as untrustworthy is replaced by the mean of one or two short naps, and that value goes
into the HRV baseline as the day's HRV.
Worked example (one ring, 2026-09-20)
Sessions ending on 09-20 (local), with each one's own sessionAvgHRV:
| Session | Length | Coverage | avgHRV |
|---|---|---|---|
| Main night | 5 h 37 m | 1.31 | nil (refused) |
| Nap | 26 m | — | nil |
| Nap | 26 m | — | 69.5 ms |
| Nap | 26 m | — | 27.9 ms |
| (two more short sessions) | 16–72 m | — | nil |
avgHRVDaily is the in-bed-weighted mean over sessions with a non-nil avgHRV
(AnalyticsEngine.swift, avgHRVDaily; Kotlin AnalyticsEngine.kt, same name). Two equal 26-minute
naps give (69.51 + 27.90) / 2 = 48.7 ms. The log shows
hrv day=2026-09-20 window=whole avgHrv=48.7, and the baseline folds it as a full night:
baseline hrv night=folded value=48.71 … mean=35.61 nValid=2. That fold moved the baseline mean from
32.2 to 35.6 on the second of the four nights it needs to seed.
The comment above physiologySessions justifies day-wide physiology with "the main overnight dominates
these anyway (it is far longer than any nap and HRV is in-bed-weighted by duration)". That holds only
while the main night has a value. Once the gate makes it nil, it contributes zero weight, and the naps
become 100 % of the figure.
Secondary: the hrv diag verdict disagrees with the gate
The always-on hrv diag line classifies coverage over all of the day's sessions pooled
(IntelligenceEngine.swift, sleepRrRows = rr.filter { cachedSleep.contains … }). rrCoverage then
divides by first-beat-to-last-beat, which includes the hours between sessions. For the same day the
line printed:
hrv diag day=2026-09-20 … coverage=0.48 … rrIntegrity=underCovered
while the gate had refused the main night at 1.31. 09-19 reads the same way (main night 1.09 alone;
pooled with a nap 7.5 h later: 0.56, underCovered). The log therefore says "under-covered, not an
over-count" about a day whose main night was refused for an over-count. That is two readouts of one
fact disagreeing, and it sent triage of this report the wrong way first.
Proposal (for discussion)
- If a main-night-group session's HRV was refused by the coverage gate, the day's
avgHRVshould be
nil (skip-and-hold). Refilling it from naps is the wrong fallback. A day with no main night can keep
using its naps as today. Scoping this to the refusal case keeps [hrv] An HR-only night computes a usable HRV and then discards it, leaving Charge unscoreable #1884's "fill what is missing"
behaviour for nights that simply had no R-R. - Compute the
hrv diagcoverage per session, or over the main-night group only, so the verdict it
prints is the one the gate acted on. Alternatively, print the per-session verdicts next to it.
Both need the Swift/Kotlin twins changed together, with a test pinning the refused-main-night + passing
nap case on each side.
What you expected instead
see above
Steps to reproduce
- see above
Strap log
see above
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
log(hrv): describe the main night the gate judged in the hrv diag line
hrv: do not replace a refused main night's HRV with the day's naps (#…
log(hrv): describe the main night the gate judged in the hrv diag line
log(hrv): describe the main night the gate judged in the hrv diag line (
ryanbr commentedon Sep 24, 2026
Both halves are on
mainnow, @pipiche38:2e3d1a263for the fallback andf412fa084for the diagnostic.The report did the hard part. "The main overnight dominates these anyway" is true right up until the gate makes it nil, and zero weight is not dominance: your worked example, two 26-minute naps averaging to 48.7 ms and folding into the baseline as a night, is the whole bug in one table. Working out that the day's HRV and the line describing it were disagreeing about the SAME night, and saying so before proposing anything, is what made this reviewable.
Two things in the fix I want to name, because they are the reason it merged as fast as it did.
Extracting
sessionHrvOverCountedrather than restating the gate. "Was this night refused?" and "what is this night's HRV?" are now one definition asked two ways, so they cannot drift, andtestTheHelperAgreesWithTheValueGatemakes that assertable instead of assumed. A second copy of the coverage rule would have passed review today and rotted quietly later.Pinning the boundary, not just the bug. Refused excludes the naps, never-measured still fills in from them, clean pools as before, on both platforms. The boundary is where a rule like this goes wrong, and it is the case a later reader will need the test for.
Merge notes: I rebased #2427 onto the squashed #2426, which is why its branch moved. I could not run StrandAnalytics locally, GRDB fails to link against this host's sqlite3, so those assertions are covered by CI's macOS job and your own 2068/0 rather than by me.
The coverage caveat you wrote into
hrvDiagnosticRowsis worth keeping in view: a bridged multi-fragment night still spans its bridge gap and reads lower than the gate's own window. It errs safe, and you said so in the doc rather than leaving it to be discovered.