Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 79 additions & 28 deletions lib/src/onehz/sleep/nap.dart
Original file line number Diff line number Diff line change
Expand Up @@ -227,49 +227,60 @@ Metric<List<NapWindow>> 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<bool>.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<bool>.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 = <double>[];
// Indices, not values: each candidate has to subtract ITSELF from this pool.
final awakeIdx = <int>[];
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<List<NapWindow>>.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<List<NapWindow>>.absent(
tier: Tier.estimate,
inputs_used: inputs,
note: 'no usable awake daytime HR baseline',
);
}

final naps = <NapWindow>[];
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];
Expand Down Expand Up @@ -308,9 +319,6 @@ Metric<List<NapWindow>> 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 = <double>[];
for (var k = start; k < end; k++) {
if (hr[k] > 0) boutHr.add(hr[k]);
Expand All @@ -321,6 +329,32 @@ Metric<List<NapWindow>> 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 = <double>[];
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;
}

Comment on lines +332 to +357

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add a regression test for the per-candidate baseline shortage path.

test/onehz/nap_test.dart Lines 322-336 leave the day-wide awakeIdx pool below minAwakeHrSamples. That test returns at Line 268. It does not execute the new check at Line 342.

Add a fixture where awakeIdx.length >= minAwakeHrSamples, but removing the candidate leaves fewer samples. Assert that detectNaps returns Metric.absent when no other nap is emitted.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/src/onehz/sleep/nap.dart` around lines 332 - 357, Add a regression
fixture in the nap tests that gives detectNaps an awakeIdx pool meeting
minAwakeHrSamples but leaves fewer than that threshold after removing the
candidate interval. Assert that detectNaps returns Metric.absent when no other
nap is detected, ensuring the per-candidate baseline shortage branch is
exercised.

if (medHr > baseline * napRestingHrMult) {
awakeStill++;
continue;
Expand Down Expand Up @@ -366,8 +400,25 @@ Metric<List<NapWindow>> 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<List<NapWindow>>.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 = <String>[
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%)',
Expand Down
71 changes: 71 additions & 0 deletions test/onehz/nap_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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');
});
Comment on lines +425 to +450

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Separate the 50-minute nap from adjacent sedentary bouts.

The preceding still(25) and following still(25) are contiguous with still(50). The detector therefore evaluates one approximately 100-minute immobility bout. hasLength(1) passes even if it reports the combined bout instead of the intended 50-minute nap.

Insert an active interval longer than the bout-chain bridge before the nap. Assert that tstSec is close to 50 * 60.

Proposed test correction
       for (var b = 0; b < 4; b++) {
         d
           ..active(6, bpm: 96)
           ..still(25, bpm: 72);
       }
+      d.active(6, bpm: 96);
       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);
       }
@@
       expect(m.value, hasLength(1),
           reason: 'the deep-dip block is a nap even though the day around it '
               'is sedentary');
+      expect(m.value!.single.tstSec, closeTo(50 * 60, 90));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/onehz/nap_test.dart` around lines 425 - 450, Update the test “a real nap
is still detected on a SEDENTARY day” to insert an active interval longer than
the bout-chain bridge immediately before the 50-minute nap, separating it from
the surrounding 25-minute sedentary bouts. Keep the existing single-nap
assertion and additionally assert that the detected nap’s tstSec is
approximately 50 * 60.

});

group('detectNaps — recording holes are not stillness', () {
Expand Down
Loading