Repository navigation
HRV reports nil on nights whose own trace prints a whole-night and deep-only mean, so Charge never establishes #2128
Description
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
- Android, 11.6.0
- The cited nights are WHOOP 4.0 with full gravity and staging
- Live Bluetooth, not an import
- Related: WHOOP 5 MG banks no gravity, skin temp or respiration while HR and R-R flow normally #2127 (the WHOOP 5 gravity gap, independent), WHOOP 4.0 R-R over-count contaminates HRV (not reproducible since 2026-08-25, cause unidentified) #1118 (4.0 R-R over-count, present on these nights as
rrIntegrity=crossSecondOverCount)
Activity
ryanbr commentedon Sep 12, 2026
Correcting this: the nil is intended behaviour, and the premise of this issue is wrong.
SleepStager.sessionAvgHRVcarries an explicit #1118 gate:successiveDiffIsTrustworthyis false for exactly the two over-count verdicts. The nights cited here reportcoverage=1.35andcoverage=1.34withrrIntegrity=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
wholeNightanddeepOnlybeside 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
nilread as loss, and I did not check thehrv diagrow directly above, which carriesrrIntegrityon 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.
rrIntegrityon thehrv diagrow 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 readsrrIntegrity=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:
A WHOOP 4.0 whose nights are consistently over-counted yields almost no HRV, so the baseline never reaches
nValid >= 4and 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 loggedwinsor=90.0..165.0 clamped=yes to=90.0. The resulting mean was still 21.28, so it may be harmless atnValid=0. Worth confirming separately rather than assuming either way.Retitling would be reasonable; the remaining content is a diagnostic gap, not a dropped value.
hrv: say why the nightly summary reported nothing (#2129)
ryanbr commentedon Sep 12, 2026
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=overCountbeside the nil. Worth keeping rather than shrugging at, because therrIntegrityon the adjacenthrv diagrow 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:
nValid >= 4and Charge can never establish. That is WHOOP 4.0 R-R over-count contaminates HRV (not reproducible since 2026-08-25, cause unidentified) #1118 working as designed at a large, quiet price, and whether that price stays acceptable is WHOOP 4.0 R-R over-count contaminates HRV (not reproducible since 2026-08-25, cause unidentified) #1118's call.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 loggedwinsor=90.0..165.0 clamped=yes to=90.0. The resulting mean was still 21.28, so it may well be harmless atnValid=0. Worth a separate look rather than an assumption in either direction.