Skip to content

HRV reports nil on nights whose own trace prints a whole-night and deep-only mean, so Charge never establishes #2128

Closed

Description

@ryanbr

Summary

avgHRVDaily, the value NOOP displays, comes back nil on nights where the very same trace line prints both a whole-night mean and a deep-only mean computed from the same windows.

The night has the number. The reported cell does not get it. Over 21 nights this produced HRV for one of them, which then starves the baseline and takes Charge down with it.

Evidence

hrv nightSummary reported=nil wholeNight=31.55ms deepOnly=28.56ms lastSWS=13.95ms nWin=87 nDeep=22
sleep day=2026-08-25 totalSleepMin=409 stages=113+123+173=409 eff=0.92 matched=2 source=computed
hrv diag day=2026-08-25 rmssd=36ms sdnn=withheld meanNN=839ms rr=42861/42740 rejected=0% coverage=1.35

Everything a reported value needs is present on that line: 87 windows carrying R-R, 22 of them deep, a pooled whole-night mean of 31.55 ms, a deep-only mean of 28.56 ms, a matched sleep night of 409 minutes with all three stages, two matched sessions, and 0% rejection. reported is still nil.

Same shape on the neighbouring nights:

hrv nightSummary reported=nil wholeNight=44.43ms deepOnly=41.26ms lastSWS=55.36ms nWin=132 nDeep=28   (2026-08-24, matched=2)
hrv nightSummary reported=nil wholeNight=46.76ms deepOnly=41.98ms lastSWS=50.14ms nWin=166 nDeep=30   (2026-08-23, matched=2)

This is not the gravity gap in #2127: these three nights are the WHOOP 4.0 ones, with roughly 190,000 gravity samples each and full staging. The two problems are independent.

Where it goes wrong

AnalyticsEngine.avgHRVDaily has two modes.

The deep branch (#141) pools RMSSD over deep-stage windows and is documented to return null only "when the night has no detected deep sleep (WHOOP-4.0 staging can be sparse)". These nights have 22, 28 and 30 deep windows, so that reason does not apply.

The other branch is a duration-weighted mean over per-session avgHRV:

val pairs = physiologySessions.mapNotNull { s ->
    s.avgHRV?.let { it to (s.end - s.start).toDouble() }
}
if (pairs.isEmpty()) null else ...

So reported depends on each session carrying its own avgHRV, and returns nil when none does, even though the night's pooled windows plainly produce means. The trace prints the answer immediately beside the nil, which is what makes this visible at all.

Whichever branch is active here, a night with 87 windows, 22 deep windows and a matched, fully staged sleep should not report nothing.

What it costs downstream

re-score census: 21 night(s) 2026-08-23..2026-09-12 charge=0 effort=13 sleep=17 hrv=1 rhr=17

Seventeen nights produced sleep and seventeen produced resting HR. HRV produced one. Charge produced zero, because:

[recovery] charge day=2026-09-11 nilScore reason=hrvBaselineNotUsable hrvStatus=calibrating hrvNValid=1 (need nValid>=4)
[recovery] charge day=2026-09-12 nilScore reason=missingInput (hrv/rhr/hrvBaseline required)

One valid night cannot reach nValid >= 4, so Charge can never establish no matter how long the strap is worn. Sleep and resting HR are fine throughout; HRV alone is the blocker.

A second thing the same trace shows

While the baseline has no valid nights it sits at:

[recovery] baseline hrv night=missing skip-and-hold nightsSinceUpdate=8 -> mean=127.5 spread=5.0 nValid=0 status=calibrating

127.5 is (minVal 5.0 + maxVal 250.0) / 2, the midpoint of the HRV MetricCfg band, i.e. the seed rather than a measurement. The winsor window derived from it is far above any real reading, so the one night that did fold logged:

[recovery] baseline hrv night=folded value=21.28 young=yes effSpread=12.5 halfLifeB=3.0 winsor=90.0..165.0 clamped=yes to=90.0 spread 5.0->5.0 atFloor=yes -> mean=21.28 spread=5.0 nValid=1

A genuine 21.28 ms reading recorded clamped=yes to=90.0 against a band of 90.0 to 165.0. The resulting mean is still 21.28, so it may be harmless when nValid is 0, but a seed-derived winsor band that rejects a real reading by a factor of four is worth confirming rather than assuming. Splitting this into its own issue is fine if the first-fold path turns out to be sound.

Environment

Activity

  1. ryanbr commented on Sep 12, 2026

    @ryanbr
    OwnerAuthor

    Correcting this: the nil is intended behaviour, and the premise of this issue is wrong.

    SleepStager.sessionAvgHRV carries an explicit #1118 gate:

    val verdict = HrvAnalyzer.classifyCoverage(coverage, coverage)
    if (!HrvAnalyzer.successiveDiffIsTrustworthy(verdict)) return null

    successiveDiffIsTrustworthy is false for exactly the two over-count verdicts. The nights cited here report coverage=1.35 and coverage=1.34 with rrIntegrity=crossSecondOverCount, so the night banks more beat-time than the wall clock it spans, and RMSSD is a successive-difference statistic. The gate withholds it deliberately, and a blank is the right answer rather than a number built on duplicated beats.

    So the claim that "a night with 87 windows, 22 deep windows and a matched, fully staged sleep should not report nothing" is incorrect. It should report nothing. The trace prints wholeNight and deepOnly beside the nil precisely so the refused value is visible.

    I filed this from the trace line without following it into the gate. The window means next to a bare nil read as loss, and I did not check the hrv diag row directly above, which carries rrIntegrity on those same nights.

    What survives

    One narrower thing, which #2129 addresses: the summary line does not say why it withheld anything.

    That is not merely a reader failing to look one line up. rrIntegrity on the hrv diag row is scored over the whole day, while the gate runs per session, and they disagree exactly where it counts. 2026-08-24 in the same log reads rrIntegrity=underCovered, which is trustworthy, and still reported nothing, because a session inside it was not. On that night the adjacent line does not explain the nil, it contradicts it.

    So the line now reads reported=nil refused=crossSecondOverCount ..., with the verdict coming from the same helper the value gate uses so the two cannot drift.

    What this does not change

    The downstream cost stands and is real, but it belongs to #1118 rather than here:

    re-score census: 21 night(s) charge=0 effort=13 sleep=17 hrv=1 rhr=17
    [recovery] charge day=... nilScore reason=hrvBaselineNotUsable hrvStatus=calibrating hrvNValid=1 (need nValid>=4)
    

    A WHOOP 4.0 whose nights are consistently over-counted yields almost no HRV, so the baseline never reaches nValid >= 4 and Charge can never establish. That is #1118 working as designed and having a large, quiet cost. It is worth deciding whether that cost is acceptable indefinitely, but the decision is #1118's.

    The baseline seed observation also stands on its own: the baseline sits at mean=127.5, which is (minVal 5.0 + maxVal 250.0) / 2, and a real 21.28 ms night logged winsor=90.0..165.0 clamped=yes to=90.0. The resulting mean was still 21.28, so it may be harmless at nValid=0. Worth confirming separately rather than assuming either way.

    Retitling would be reasonable; the remaining content is a diagnostic gap, not a dropped value.

  2. ryanbr commented on Sep 12, 2026

    @ryanbr
    OwnerAuthor

    The premise of this report was wrong. The nil is the #1118 gate refusing an over-counted night on purpose, not a value being dropped. Detail in the correction above.

    The one real thing here was that the summary line never said so, and that landed in #2129: it now prints refused=overCount beside the nil. Worth keeping rather than shrugging at, because the rrIntegrity on the adjacent hrv diag row is scored over the whole DAY while the gate runs per SESSION, and they disagree exactly where it counts.

    Two things survive this and are tracked elsewhere:

    The baseline seed observation stands on its own and was not investigated: the fold sits at mean=127.5, which is (minVal 5.0 + maxVal 250.0) / 2, and a real 21.28 ms night logged winsor=90.0..165.0 clamped=yes to=90.0. The resulting mean was still 21.28, so it may well be harmless at nValid=0. Worth a separate look rather than an assumption in either direction.

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