Skip to content

[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

Closed

Description

@pipiche38

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)

  1. If a main-night-group session's HRV was refused by the coverage gate, the day's avgHRV should 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.
  2. Compute the hrv diag coverage 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.

Refs #1118, #1884, #525

What you expected instead

see above

Steps to reproduce

  1. 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

  1. ryanbr commented on Sep 24, 2026

    @ryanbr
    Owner

    Both halves are on main now, @pipiche38: 2e3d1a263 for the fallback and f412fa084 for 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 sessionHrvOverCounted rather 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, and testTheHelperAgreesWithTheValueGate makes 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 hrvDiagnosticRows is 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.

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