diff --git a/lib/src/onehz/sleep/nap.dart b/lib/src/onehz/sleep/nap.dart index 4e72fbc..3437ef0 100644 --- a/lib/src/onehz/sleep/nap.dart +++ b/lib/src/onehz/sleep/nap.dart @@ -227,49 +227,60 @@ Metric> detectNaps( } } - // The AWAKE HR baseline: seconds that are neither the main sleep nor ANY - // detected sleep bout. Excluding only `mainSleep` was not enough — it left - // the candidate bout's own low-HR seconds in the median it is then judged - // against, and on this device the nap window deliberately extends hours past - // midnight, so the first hours of tonight's sleep were dragging the bar down - // too. Both make the gate self-suppressing: the quieter the sleep, the lower - // the threshold it has to beat. - final inBout = List.filled(n, false); - for (final b in bouts) { - for (var k = b[0]; k < b[1]; k++) { - inBout[k] = true; + // The AWAKE HR baseline pool: every second that is not the main sleep and not + // a bout we have already DEFERRED as unfinished. + // + // SEDENTARY WAKE STAYS IN THIS POOL, and that is the whole point. Excluding + // every detected bout — which is what "neither the main sleep nor ANY bout" + // did — removes all of the day's still time, so what survives is the + // AMBULATORY HR median, not an awake baseline. The gate `medHr > baseline * + // napRestingHrMult` is then cleared by any motionless awake stretch whose HR + // sits more than 5% below walking HR: desk work, reading, driving, a sofa. + // Measured, not argued: a synthetic day of 8 x (6 min walking @ 96 bpm, 25 + // min motionless @ 72 bpm) reported EIGHT naps totalling 199 minutes, each at + // confidence 0.85 — the cap — where nobody had napped. A 10% contrast (76 vs + // 84 bpm) did the same. Keeping the still seconds puts the median at 72, and + // 0.95 x 72 = 68.4 < 72 rejects all eight. The exclusion WAS the bug. + // + // Two exclusions are still right, and they are the two the original change + // was actually reaching for: + // * the CANDIDATE's own low-HR seconds, or the gate grades a bout against a + // median it is itself dragging down — self-suppressing, and the quieter + // the sleep the lower the bar it has to beat. That is per-candidate, so + // it is done inside the loop below, not here. + // * any UNFINISHED bout. The nap window deliberately runs hours past + // midnight, so the first hours of tonight's sleep sit in this record; + // they are sleep, not sedentary wake, and they belong in no baseline. + final deferredSec = List.filled(n, false); + for (var b = 0; b < bouts.length; b++) { + if (!unfinished[b]) continue; + for (var k = bouts[b][0]; k < bouts[b][1]; k++) { + deferredSec[k] = true; } } - final awake = []; + // Indices, not values: each candidate has to subtract ITSELF from this pool. + final awakeIdx = []; for (var k = 0; k < n; k++) { - if (hr[k] <= 0 || inBout[k]) continue; + if (hr[k] <= 0 || deferredSec[k]) continue; if (mainSleep != null && k >= mainSleep.start && k < mainSleep.end) continue; - awake.add(hr[k]); + awakeIdx.add(k); } - if (awake.length < minAwakeHrSamples) { + if (awakeIdx.length < minAwakeHrSamples) { return Metric>.absent( tier: Tier.estimate, inputs_used: inputs, note: 'not enough awake daytime HR to set a baseline ' - '(${awake.length}s, need ${minAwakeHrSamples}s) — ' + '(${awakeIdx.length}s, need ${minAwakeHrSamples}s) — ' 'cannot corroborate stillness as sleep', ); } - final baseline = median(awake)!; - if (baseline <= 0) { - return const Metric>.absent( - tier: Tier.estimate, - inputs_used: inputs, - note: 'no usable awake daytime HR baseline', - ); - } final naps = []; var deferred = 0, unverifiable = 0, offWrist = 0, awakeStill = 0; // Every rejection path increments one of these and reports it in `skipped`. // A silent `continue` turns "your 7-hour still block is too long to be a nap" // into a bare "no qualifying nap", which tells the caller nothing about why. - var outOfRange = 0, inMainSleep = 0; + var outOfRange = 0, inMainSleep = 0, noBaseline = 0; for (var bi = 0; bi < bouts.length; bi++) { final start = bouts[bi][0], end = bouts[bi][1]; @@ -308,9 +319,6 @@ Metric> detectNaps( continue; } - // NOT `inBout` — that name belongs to the whole-day boolean baseline mask - // above, and shadowing it here would silently hand the HR list to any later - // edit that reaches for the mask inside this loop. final boutHr = []; for (var k = start; k < end; k++) { if (hr[k] > 0) boutHr.add(hr[k]); @@ -321,6 +329,32 @@ Metric> detectNaps( continue; } final medHr = median(boutHr)!; + + // PER-CANDIDATE baseline: the day's awake pool minus THIS bout. A bout must + // not be graded against a median it is itself pulling down — see the pool + // construction above for why only the candidate and the deferred bouts come + // out, and not every still block in the day. + final awakeHr = []; + for (final k in awakeIdx) { + if (k >= start && k < end) continue; + awakeHr.add(hr[k]); + } + if (awakeHr.length < minAwakeHrSamples) { + // The day had enough awake HR, but not once this candidate is removed — + // so THIS bout cannot be corroborated, while others still may be. Abstain + // for it rather than judging it against a median built from a handful of + // seconds. Counted separately from `unverifiable` because it is the one + // rejection that makes the DAY's verdict incomplete: see the check after + // the loop. + noBaseline++; + continue; + } + final baseline = median(awakeHr)!; + if (baseline <= 0) { + noBaseline++; + continue; + } + if (medHr > baseline * napRestingHrMult) { awakeStill++; continue; @@ -366,8 +400,25 @@ Metric> detectNaps( )); } + // A still block we could not RULE OUT is not the same as a day with no nap. + // If nothing was emitted and at least one candidate went unjudged for want of + // an awake baseline, the day's verdict is unknown — say so, rather than + // returning an empty list that every caller reads as "judged, none". This is + // the day-level abstain that used to fall out of the whole-day baseline check + // before it became per-candidate. + if (naps.isEmpty && noBaseline > 0) { + return Metric>.absent( + tier: Tier.estimate, + inputs_used: inputs, + note: 'not enough awake daytime HR to set a baseline for ' + '$noBaseline still block(s) (need ${minAwakeHrSamples}s outside the ' + 'block itself) — cannot corroborate stillness as sleep', + ); + } + final skipped = [ if (deferred > 0) '$deferred deferred (record ends mid-bout)', + if (noBaseline > 0) '$noBaseline without an awake HR baseline', if (outOfRange > 0) '$outOfRange outside 15 min–6 h', if (inMainSleep > 0) '$inMainSleep inside the main sleep window', if (unverifiable > 0) '$unverifiable unverifiable (HR coverage <50%)', diff --git a/test/onehz/nap_test.dart b/test/onehz/nap_test.dart index 082a9a8..2220e24 100644 --- a/test/onehz/nap_test.dart +++ b/test/onehz/nap_test.dart @@ -147,6 +147,50 @@ void main() { reason: 'the rejection reason must be visible, not silent'); }); + test('a SEDENTARY DAY is not eight naps — sedentary wake stays in the ' + 'awake baseline', () { + // THE REGRESSION. The baseline once excluded every detected bout, which + // removed all of the day's still time and left the AMBULATORY HR median + // in its place. Every motionless block then cleared `medHr > baseline * + // napRestingHrMult` on nothing but the ordinary sit/walk HR difference: + // this exact fixture returned EIGHT naps totalling 199 minutes, each at + // confidence 0.85 — the cap — for a day nobody napped on. + // + // The test above cannot catch it: it uses bpm 80 for BOTH its active and + // still segments, so active and sedentary HR are identical and the + // baseline cannot be inflated. The contrast IS the bug, so it has to be + // in the fixture. + final d = _Day(); + for (var b = 0; b < 8; b++) { + d + ..active(6, bpm: 96) // walking + ..still(25, bpm: 72); // at the desk — awake, just not moving + } + d.active(60, bpm: 96); + + final m = detectNaps(d.accel, d.hr); + + expect(m.value, isEmpty, + reason: 'sitting still at 72 bpm on a day you walk at 96 is not a ' + 'nap; keeping the still seconds puts the median at 72, and ' + '0.95 x 72 = 68.4 < 72 rejects every block'); + expect(m.note, contains('no HR dip')); + }); + + test('the same day at a 10% HR contrast is also not a nap', () { + // The failure did not need a dramatic difference — 84 vs 76 bpm, which is + // an unremarkable sit-versus-walk gap, produced all eight at 0.84. + final d = _Day(); + for (var b = 0; b < 8; b++) { + d + ..active(6, bpm: 84) + ..still(25, bpm: 76); + } + d.active(60, bpm: 84); + + expect(detectNaps(d.accel, d.hr).value, isEmpty); + }); + test('an off-wrist span is rejected even though it is perfectly still', () { final d = _Day() ..active(90) @@ -377,6 +421,33 @@ void main() { reason: 'the awake baseline is ~78 bpm; a 60 bpm nap clears it'); expect(m.value!.single.tstSec, closeTo(30 * 60, 90)); }); + + test('a real nap is still detected on a SEDENTARY day', () { + // The other side of the same coin, and the reason the fix keeps sedentary + // wake in the pool instead of dropping every bout: a day that is mostly + // sitting still must still be able to report the one block that was + // actually sleep. Baseline lands at desk HR (~72), and 56 clears + // 0.95 x 72 = 68.4 comfortably. + final d = _Day(); + for (var b = 0; b < 4; b++) { + d + ..active(6, bpm: 96) + ..still(25, bpm: 72); + } + d.still(50, bpm: 56); // the genuine nap, a real autonomic dip + for (var b = 0; b < 4; b++) { + d + ..active(6, bpm: 96) + ..still(25, bpm: 72); + } + d.active(60, bpm: 96); // end awake: nothing to defer at the record end + + final m = detectNaps(d.accel, d.hr); + + expect(m.value, hasLength(1), + reason: 'the deep-dip block is a nap even though the day around it ' + 'is sedentary'); + }); }); group('detectNaps — recording holes are not stillness', () {