From 1e65bef1034bdec099c194b67359685d6dd1732a Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 08:19:24 +0530 Subject: [PATCH 01/29] sleep cycles no longer span RR holes a night with a contact or RR dropout paired the rmssd peak before the hole with the peak after it, so the hole itself was reported as one long cycle and the mean cycle length grew with it. a cycle is now only formed when the minutes between its peaks have no unobserved run longer than 20 min, and peak finding treats missing minutes as unknown instead of -inf, so the one-sided smoothing at a hole's edge no longer produces a free peak. --- lib/src/onehz/sleep/cycles.dart | 37 +++++++++++++++++++++++---- test/onehz/sleep_honesty_test.dart | 41 ++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/lib/src/onehz/sleep/cycles.dart b/lib/src/onehz/sleep/cycles.dart index 6ef0a72..7ebc58a 100644 --- a/lib/src/onehz/sleep/cycles.dart +++ b/lib/src/onehz/sleep/cycles.dart @@ -57,6 +57,12 @@ const int _smoothMin = 10; // ± window of the moving average const int _minPeakDist = 20; // min minutes between cycle peaks const double _minProminence = 0.9; // z-score prominence to qualify a peak +/// Longest run of unobserved minutes a cycle may span. Contact loss, charging +/// or an RR dropout leaves the per-minute series null; pairing the peak before +/// such a hole with the peak after it reported the hole itself as one long +/// "cycle" and dragged the mean cycle length up with it. +const int _maxGapMin = 20; + /// Physiologic RR gate (ms): keep 300–2000, drop |Δ|>200 (ectopy/artifact). const double _rrMin = 300, _rrMax = 2000, _rrStep = 200; @@ -126,6 +132,7 @@ SleepCyclesResult detectSleepCycles( final cycles = []; for (var i = 0; i + 1 < peaks.length; i++) { final a = peaks[i], b = peaks[i + 1]; + if (_longestNullRun(z, a, b) > _maxGapMin) continue; cycles.add(SleepCycle(a, b, b - a)); } final meanDur = cycles.isEmpty @@ -144,6 +151,19 @@ SleepCyclesResult detectSleepCycles( return SleepCyclesResult(cycles, meanDur, cycles.length, series); } +int _longestNullRun(List y, int from, int to) { + var best = 0, run = 0; + for (var k = from; k <= to; k++) { + if (y[k] == null) { + run++; + if (run > best) best = run; + } else { + run = 0; + } + } + return best; +} + double? _rmssd(List rr) { if (rr.length < 10) return null; var s = 0.0, n = 0; @@ -163,16 +183,23 @@ List _findPeaks(List y, int minDist, double minProm) { final cand = >[]; // [index, value] for (var i = 1; i < n - 1; i++) { final yi = y[i]; - if (yi == null) continue; - final a = y[i - 1] ?? double.negativeInfinity; - final b = y[i + 1] ?? double.negativeInfinity; + final a = y[i - 1], b = y[i + 1]; + // A missing neighbour is unknown, not -inf: a point beside a data hole + // (where the one-sided moving average also bends) is not a local maximum + // on that evidence alone. The prominence walk likewise stops at a hole, so + // the base on that side is only what was actually observed. + if (yi == null || a == null || b == null) continue; if (!(yi >= a && yi > b)) continue; var l = i; - while (l > 0 && (y[l - 1] ?? double.negativeInfinity) < yi) { + while (l > 0) { + final v = y[l - 1]; + if (v == null || v >= yi) break; l--; } var r = i; - while (r < n - 1 && (y[r + 1] ?? double.negativeInfinity) < yi) { + while (r < n - 1) { + final v = y[r + 1]; + if (v == null || v >= yi) break; r++; } var lmin = yi, rmin = yi; diff --git a/test/onehz/sleep_honesty_test.dart b/test/onehz/sleep_honesty_test.dart index b307b2d..4d1aa2b 100644 --- a/test/onehz/sleep_honesty_test.dart +++ b/test/onehz/sleep_honesty_test.dart @@ -466,6 +466,47 @@ void main() { 't == onsetSec'); expect(r.series.any((p) => p['t'] == onset), isFalse); }); + + test('a cycle never spans a long RR hole', () { + // 8 h with a 90-min RMSSD rhythm, then no RR at all for minutes 100-259 + // (contact loss). Pre-fix the peak before the hole and the peak after it + // became one ~186-min "cycle" and the mean jumped from ~89 to ~138 min. + const onset = _t0; + const nMin = 480; + List cyclesWith({int? holeFrom, int? holeTo}) { + final rnd = math.Random(7); + final rr = [], ts = []; + for (var s = 0; s < nMin * 60; s++) { + final m = s ~/ 60; + if (holeFrom != null && m >= holeFrom && m < holeTo!) continue; + final amp = 20 + 15 * math.sin(2 * math.pi * m / 90); + rr.add(1000 + amp * (rnd.nextBool() ? 1 : -1) * 0.7); + ts.add((onset + s) * 1000.0); + } + final r = detectSleepCycles(rr, ts, onset, onset + nMin * 60); + if (r.cycles.isNotEmpty) { + expect(r.meanDurationMin, + closeTo( + r.cycles.fold(0, (a, c) => a + c.lenMin) / + r.cycles.length, + 1e-9)); + } + return r.cycles; + } + + final full = cyclesWith(); + expect(full, isNotEmpty); + for (final c in full) { + expect(c.lenMin, inInclusiveRange(70, 110)); + } + + final holed = cyclesWith(holeFrom: 100, holeTo: 260); + for (final c in holed) { + expect(c.startMin < 260 && c.endMin > 100, isFalse, + reason: 'cycle ${c.startMin}-${c.endMin} spans the 100-260 hole'); + expect(c.lenMin, inInclusiveRange(70, 110)); + } + }); }); // ═══════════════════════════════════════════════════════════════════════════ From cfa495985677a50a6b1f8efe89f813a475dbe338 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 08:22:18 +0530 Subject: [PATCH 02/29] sleep cycles: gate holes on raw minutes, not the smoothed series MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the hole gate looked at the smoothed rmssd series, and the ±10 min moving average fills 10 min into each edge of a hole, so a 30-40 min contact loss never tripped it. dropping every peak with a missing neighbour then removed the real peak beside the hole, and the peaks on either side paired straight across it into a ~180-min cycle. the gap is now counted on raw per-minute rmssd, a peak has to sit on an observed minute, and a point beside a hole is compared against its observed side only. --- lib/src/onehz/sleep/cycles.dart | 60 +++++++++++++++++++++--------- test/onehz/sleep_honesty_test.dart | 34 ++++++++++++----- 2 files changed, 67 insertions(+), 27 deletions(-) diff --git a/lib/src/onehz/sleep/cycles.dart b/lib/src/onehz/sleep/cycles.dart index 7ebc58a..4a8a04e 100644 --- a/lib/src/onehz/sleep/cycles.dart +++ b/lib/src/onehz/sleep/cycles.dart @@ -57,10 +57,13 @@ const int _smoothMin = 10; // ± window of the moving average const int _minPeakDist = 20; // min minutes between cycle peaks const double _minProminence = 0.9; // z-score prominence to qualify a peak -/// Longest run of unobserved minutes a cycle may span. Contact loss, charging -/// or an RR dropout leaves the per-minute series null; pairing the peak before -/// such a hole with the peak after it reported the hole itself as one long -/// "cycle" and dragged the mean cycle length up with it. +/// Longest run of unobserved RAW minutes (no per-minute RMSSD) a cycle may +/// span. Contact loss, charging or an RR dropout leaves the per-minute series +/// null; pairing the peak before such a hole with the peak after it reported +/// the hole itself as one long "cycle" and dragged the mean cycle length up +/// with it. Counted on the raw minutes, not the smoothed series: the ±10 min +/// moving average paints 10 min into each edge of a hole, so a gate on the +/// smoothed series would let a 40-min hole through. const int _maxGapMin = 20; /// Physiologic RR gate (ms): keep 300–2000, drop |Δ|>200 (ectopy/artifact). @@ -123,16 +126,19 @@ SleepCyclesResult detectSleepCycles( // ── z-score ──────────────────────────────────────────────────────────────── final mean = vals.reduce((a, b) => a + b) / vals.length; final sd0 = math.sqrt( - vals.fold(0, (a, b) => a + (b - mean) * (b - mean)) / vals.length); + vals.fold(0, (a, b) => a + (b - mean) * (b - mean)) / + vals.length); final sd = sd0 == 0 ? 1.0 : sd0; final z = [for (final x in sm) x == null ? null : (x - mean) / sd]; // ── prominence-gated peaks → peak-to-peak cycles ─────────────────────────── - final peaks = _findPeaks(z, _minPeakDist, _minProminence); + // A peak must sit on an observed minute: inside a hole the smoothed value + // is extrapolated from one side only, so its position is not evidence. + final peaks = _findPeaks(z, perMin, _minPeakDist, _minProminence); final cycles = []; for (var i = 0; i + 1 < peaks.length; i++) { final a = peaks[i], b = peaks[i + 1]; - if (_longestNullRun(z, a, b) > _maxGapMin) continue; + if (_longestNullRun(perMin, a, b) > _maxGapMin) continue; cycles.add(SleepCycle(a, b, b - a)); } final meanDur = cycles.isEmpty @@ -177,31 +183,48 @@ double? _rmssd(List rr) { } /// Local maxima with z-prominence ≥ [minProm], pruned to ≥ [minDist] apart -/// (tallest-first). Returns peak minute-indices, ascending. -List _findPeaks(List y, int minDist, double minProm) { +/// (tallest-first). Only minutes observed in [raw] are candidates, filtered +/// before the distance pruning so an extrapolated point cannot suppress a +/// real neighbour. Returns peak minute-indices, ascending. +List _findPeaks( + List y, List raw, int minDist, double minProm) { final n = y.length; final cand = >[]; // [index, value] for (var i = 1; i < n - 1; i++) { final yi = y[i]; final a = y[i - 1], b = y[i + 1]; - // A missing neighbour is unknown, not -inf: a point beside a data hole - // (where the one-sided moving average also bends) is not a local maximum - // on that evidence alone. The prominence walk likewise stops at a hole, so - // the base on that side is only what was actually observed. - if (yi == null || a == null || b == null) continue; - if (!(yi >= a && yi > b)) continue; + // A missing neighbour is unknown, not -inf. A point beside a data hole is + // judged against its observed side only; with no observed neighbour at + // all it is not a local maximum on any evidence. + if (yi == null || raw[i] == null || (a == null && b == null)) continue; + if (a != null && yi < a) continue; + if (b != null && yi <= b) continue; + // Walk each side until a higher point, the array edge, or a hole. A side + // that runs into a hole has no observed base beyond it, so it does not + // contribute to the prominence; if both sides do, there is no base at all. var l = i; + var lHole = false; while (l > 0) { final v = y[l - 1]; - if (v == null || v >= yi) break; + if (v == null) { + lHole = true; + break; + } + if (v >= yi) break; l--; } var r = i; + var rHole = false; while (r < n - 1) { final v = y[r + 1]; - if (v == null || v >= yi) break; + if (v == null) { + rHole = true; + break; + } + if (v >= yi) break; r++; } + if (lHole && rHole) continue; var lmin = yi, rmin = yi; for (var k = l; k <= i; k++) { final v = y[k]; @@ -211,7 +234,8 @@ List _findPeaks(List y, int minDist, double minProm) { final v = y[k]; if (v != null && v < rmin) rmin = v; } - if (yi - math.max(lmin, rmin) >= minProm) cand.add([i.toDouble(), yi]); + final base = lHole ? rmin : (rHole ? lmin : math.max(lmin, rmin)); + if (yi - base >= minProm) cand.add([i.toDouble(), yi]); } cand.sort((p, q) => q[1].compareTo(p[1])); // tallest first final kept = []; diff --git a/test/onehz/sleep_honesty_test.dart b/test/onehz/sleep_honesty_test.dart index 4d1aa2b..6ab63aa 100644 --- a/test/onehz/sleep_honesty_test.dart +++ b/test/onehz/sleep_honesty_test.dart @@ -130,7 +130,8 @@ void main() { reason: 'not one second of gravity was measured'); }); - test('the SAME vectors marked valid still stage — the flag is the only ' + test( + 'the SAME vectors marked valid still stage — the flag is the only ' 'difference', () { // Control: proves the abstention above comes from the validity flag and // not from the (0,0,0) coordinates or the window shape. @@ -473,8 +474,8 @@ void main() { // became one ~186-min "cycle" and the mean jumped from ~89 to ~138 min. const onset = _t0; const nMin = 480; - List cyclesWith({int? holeFrom, int? holeTo}) { - final rnd = math.Random(7); + List cyclesWith({int? holeFrom, int? holeTo, int seed = 7}) { + final rnd = math.Random(seed); final rr = [], ts = []; for (var s = 0; s < nMin * 60; s++) { final m = s ~/ 60; @@ -485,7 +486,8 @@ void main() { } final r = detectSleepCycles(rr, ts, onset, onset + nMin * 60); if (r.cycles.isNotEmpty) { - expect(r.meanDurationMin, + expect( + r.meanDurationMin, closeTo( r.cycles.fold(0, (a, c) => a + c.lenMin) / r.cycles.length, @@ -500,11 +502,25 @@ void main() { expect(c.lenMin, inInclusiveRange(70, 110)); } - final holed = cyclesWith(holeFrom: 100, holeTo: 260); - for (final c in holed) { - expect(c.startMin < 260 && c.endMin > 100, isFalse, - reason: 'cycle ${c.startMin}-${c.endMin} spans the 100-260 hole'); - expect(c.lenMin, inInclusiveRange(70, 110)); + // Short contact losses matter most: the ±10 min smoothing paints into + // each edge of a hole, so a 30-40 min raw hole must still be caught. + for (final h in const [ + [100, 260, 7], + [100, 260, 11], + [150, 190, 11], + [200, 240, 7], + [200, 240, 11], + [100, 130, 11], + [100, 140, 11], + [60, 100, 11], + ]) { + final holed = cyclesWith(holeFrom: h[0], holeTo: h[1], seed: h[2]); + for (final c in holed) { + expect(c.startMin < h[1] && c.endMin > h[0], isFalse, + reason: 'cycle ${c.startMin}-${c.endMin} spans the ' + '${h[0]}-${h[1]} hole (seed ${h[2]})'); + expect(c.lenMin, inInclusiveRange(70, 110)); + } } }); }); From fe9c47d346e9e84dac18ad2059b58ae0c490a68a Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 08:25:07 +0530 Subject: [PATCH 03/29] keep the pre-dawn sleep tail when the night chain opens on an evening doze the overnight flag for a sleep chain was decided only by the chain's first session. an evening doze that started before 20:00 and ran into the real night left the whole chain marked daytime, so the short re-onset after a pre-dawn arousal fell under the 60-min floor. wake time got cut at the arousal and the tail showed up as a morning nap. the flag now turns on as soon as an overnight-onset session continues the chain. --- lib/src/onehz/sleep/advanced_stager.dart | 6 +++ test/onehz/advanced_stager_test.dart | 52 ++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/lib/src/onehz/sleep/advanced_stager.dart b/lib/src/onehz/sleep/advanced_stager.dart index 184d28a..1b0a07d 100644 --- a/lib/src/onehz/sleep/advanced_stager.dart +++ b/lib/src/onehz/sleep/advanced_stager.dart @@ -405,6 +405,12 @@ class AdvancedSleepStager { if (!continuesChain) { chainFromOvernight = _isOvernightOnset(p.start, tzAt); nightTailAccepted = false; // new chain re-arms the one-shot exemption + } else if (!chainFromOvernight && _isOvernightOnset(p.start, tzAt)) { + // A chain that opened on an evening doze (onset before 20:00) becomes + // overnight once the real night continues it. Judging only the first + // session would leave the flag false for the whole night and drop the + // short re-onset after a pre-dawn arousal at the 60-min floor. + chainFromOvernight = true; } else if (isNightTail && (p.end - p.start) <= minSleepS) { // Consume the one-shot ONLY when a SHORT tail actually relied on the floor // exemption; a >60-min continuation passes the floor unaided and must not diff --git a/test/onehz/advanced_stager_test.dart b/test/onehz/advanced_stager_test.dart index 29424f5..1a229aa 100644 --- a/test/onehz/advanced_stager_test.dart +++ b/test/onehz/advanced_stager_test.dart @@ -351,4 +351,56 @@ void main() { expect(m.efficiency, closeTo(850 / 1000, 1e-9)); }); }); + group('night-tail exemption after an evening doze', () { + // tz 0, day starts at local midnight. Active until 19:00, optional still + // doze 19:00-20:40, active to 21:30, night 21:30-05:00, 20-min arousal, + // 50-min re-onset 05:20-06:10, then active. + const day = 1699920000; // 2023-11-14 00:00 UTC + ({List grav, List hr}) night({required bool doze}) { + final grav = []; + final hr = []; + var t = day + 12 * 3600; + void active(int until) { + for (var i = 0; t < until; i++, t++) { + final phase = math.sin(i * 0.5); + grav.add(GravTs(t, 0.3 * phase, 0.3, 0.9 * (1 - 0.2 * phase))); + hr.add(HrTs(t, 72 + 2 * math.sin(i / 600.0))); + } + } + + void still(int until, double bpm) { + for (var i = 0; t < until; i++, t++) { + grav.add(GravTs(t, 0.001 * math.sin(i * 0.01), 0.001, 1.0)); + hr.add(HrTs(t, bpm)); + } + } + + const h = 3600; + active(day + 19 * h); + if (doze) { + still(day + 20 * h + 40 * 60, 58); + } else { + active(day + 20 * h + 40 * 60); + } + active(day + 21 * h + 30 * 60); + still(day + 29 * h, 55); + active(day + 29 * h + 20 * 60); + still(day + 30 * h + 10 * 60, 56); + active(day + 32 * h + 10 * 60); + return (grav: grav, hr: hr); + } + + for (final doze in [false, true]) { + test('pre-dawn tail survives (doze=$doze)', () { + final d = night(doze: doze); + final sessions = AdvancedSleepStager.detectSleep(d.grav, d.hr); + final tail = sessions.where((s) => + s.start >= day + 29 * 3600 && s.end <= day + 30 * 3600 + 15 * 60); + expect(tail, hasLength(1), + reason: 'a chain that opens on a pre-20:00 doze must still count ' + 'as overnight once the real night joins it, or the short ' + 're-onset after the pre-dawn arousal is dropped'); + }); + } + }); } From 75fc7ae3f06c5277f57a14a160282e5f88419fad Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 08:28:05 +0530 Subject: [PATCH 04/29] stop booking dropped pre-dawn night tails as morning naps the night detector drops a second short pre-dawn tail once its one-shot tail exemption is spent, so the main window ends before that tail. the nap detector only carved out the window by overlap, so the dropped tail came back as a 6 am nap and the night read short. a bout that starts within the night's continuation gap (90 min) after the main sleep is now treated as the night going on: it is not a nap and stays out of the awake hr baseline. --- lib/src/onehz/sleep/nap.dart | 43 +++++++++++++++++++++++++++++++-- test/onehz/nap_test.dart | 47 ++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/lib/src/onehz/sleep/nap.dart b/lib/src/onehz/sleep/nap.dart index 268468e..569547e 100644 --- a/lib/src/onehz/sleep/nap.dart +++ b/lib/src/onehz/sleep/nap.dart @@ -22,7 +22,8 @@ // two detectors have never been shown to agree on real nights. // 2. Enumerate EVERY immobility bout (the nocturnal path keeps only the // longest), bridging brief arousals. -// 3. Reject hard: off-wrist, charging/workout spans, the main sleep window, +// 3. Reject hard: off-wrist, charging/workout spans, the main sleep window +// and anything starting within the night's continuation gap after it, // and bouts built on seconds the band never measured (`minNapAccelCoverage`). // 4. Require an autonomic signature — median HR inside the bout at or below // `napRestingHrMult` × the DAYTIME-AWAKE HR baseline. Stillness alone is @@ -41,6 +42,7 @@ import '../types.dart'; import '../util.dart'; +import 'advanced_stager.dart' show AdvancedSleepStager; import 'van_hees.dart'; /// An index range [start, end) into the day's arrays marking the MAIN @@ -155,6 +157,18 @@ const double maxNapOffWristFraction = 0.5; /// is ending — the exact phantom the deferral exists to prevent. const int napChainGapSec = 60 * 60; +/// A bout starting within this long after the main sleep ends is the night +/// going on, not a nap. Same gap the night detector chains continuations with, +/// so the two detectors agree on where a night can still be. +/// +/// The night detector does not keep every continuation it sees: once its +/// one-shot short-tail exemption is spent, a second short pre-dawn tail falls +/// to the 60-min floor and the main window ends before it. Those dropped tails +/// are still, HR-dipped and outside the window, so an overlap test alone books +/// them as a 6 AM nap and the night reads short. +const int napNightContinuationSec = + AdvancedSleepStager.nightContinuationGapMin * 60; + /// Minimum awake HR samples needed to define a baseline. Below this the day is /// not judged at all: a threshold set by a handful of samples is not a /// baseline, and every nap decision hangs off it. @@ -324,9 +338,27 @@ Metric> detectNaps( // * 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. + // Bouts that start inside the night's continuation gap after the main sleep + // ends (see [napNightContinuationSec]). Measured on the wall clock from the + // window's last second, since index distance is not elapsed time. + final continuesNight = List.filled(bouts.length, false); + final mainEnd = mainSleep == null + ? 0 + : (mainSleep.end > n ? n : mainSleep.end); + if (mainSleep != null && mainEnd > 0 && mainEnd > mainSleep.start) { + final mainEndAbs = absAt(mainEnd - 1) + 1; + for (var b = 0; b < bouts.length; b++) { + if (bouts[b][0] < mainEnd) continue; + final gap = absAt(bouts[b][0]) - mainEndAbs; + if (gap <= napNightContinuationSec) continuesNight[b] = true; + } + } + final deferredSec = List.filled(n, false); for (var b = 0; b < bouts.length; b++) { - if (!unfinished[b]) continue; + // A night fragment is sleep, not sedentary wake, so it stays out of the + // awake baseline for the same reason an unfinished bout does. + if (!unfinished[b] && !continuesNight[b]) continue; for (var k = bouts[b][0]; k < bouts[b][1]; k++) { deferredSec[k] = true; } @@ -355,6 +387,7 @@ Metric> detectNaps( // 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, noBaseline = 0, unobserved = 0; + var nightTail = 0; for (var bi = 0; bi < bouts.length; bi++) { final start = bouts[bi][0], end = bouts[bi][1]; @@ -383,6 +416,11 @@ Metric> detectNaps( continue; } + if (continuesNight[bi]) { + nightTail++; + continue; + } + // Wall-clock accel coverage — the seconds inside the bout the band actually // measured gravity for, over the bout's elapsed span (so a recording hole // counts against it exactly as an invalid sample does). @@ -502,6 +540,7 @@ Metric> detectNaps( 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 (nightTail > 0) '$nightTail continuing the main sleep', if (unobserved > 0) '$unobserved with accel coverage <50% (unmeasured)', if (unverifiable > 0) '$unverifiable unverifiable (HR coverage <50%)', if (offWrist > 0) '$offWrist off-wrist/excluded', diff --git a/test/onehz/nap_test.dart b/test/onehz/nap_test.dart index 4defdb3..b9e2530 100644 --- a/test/onehz/nap_test.dart +++ b/test/onehz/nap_test.dart @@ -303,6 +303,53 @@ void main() { expect(m.present, isTrue); expect(m.value, isEmpty, reason: 'the only block IS the main sleep'); }); + + test('a fragment that continues the night is not booked as a nap', () { + // REGRESSION. The night detector drops a second short pre-dawn tail once + // its one-shot night-tail exemption is used, so the main window it hands + // over ends before the band shows the user was up. The index-overlap + // check let that dropped tail through, and it came back as a 6 AM "nap" + // at the 0.85 confidence cap. + for (final arousal in [12, 30, 60]) { + final d = _Day() + ..active(180, bpm: 80) + ..still(420, bpm: 55) // the night the main window covers + ..active(arousal, bpm: 72) + ..still(42, bpm: 56) // the dropped tail + ..active(600, bpm: 80); + final start = 180 * 60, end = start + 420 * 60; + + final m = detectNaps( + d.accel, + d.hr, + mainSleep: SleepWindowSpan(start, end), + ); + + expect(m.present, isTrue); + expect(m.value, isEmpty, + reason: 'a $arousal-min arousal sits inside the night\'s ' + 'continuation gap, so the bout after it is the same night'); + expect(m.note, contains('continuing the main sleep')); + } + }); + + test('a nap well clear of the night is still a nap', () { + final d = _Day() + ..active(180, bpm: 80) + ..still(420, bpm: 55) + ..active(180, bpm: 80) // three hours up — past any continuation + ..still(40, bpm: 56) + ..active(300, bpm: 80); + final start = 180 * 60, end = start + 420 * 60; + + final m = detectNaps( + d.accel, + d.hr, + mainSleep: SleepWindowSpan(start, end), + ); + + expect(m.value, hasLength(1)); + }); }); group('detectNaps — honest absence', () { From c130b259b5124087d2e3ed7286cc5fad743a5c1b Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 08:31:21 +0530 Subject: [PATCH 05/29] only treat short dipped bouts after waking as night tails the previous change called every still bout that started within 90 min of the main sleep's end part of the night and pulled it out of the awake hr baseline. a long morning desk block is exactly that kind of bout, and with it gone the baseline drifted up to walking hr, so a 40-min awake sit later in the day came back as a nap. a bout is now attributed to the night only if the night detector could have dropped it (at most its 60-min floor) and it has already passed every nap gate, and it stays in the baseline pool. a real short back-to-sleep inside the gap still counts toward neither the night nor nap minutes, which the comment now says. --- lib/src/onehz/sleep/nap.dart | 71 ++++++++++++++++++++++-------------- test/onehz/nap_test.dart | 32 ++++++++++++++++ 2 files changed, 75 insertions(+), 28 deletions(-) diff --git a/lib/src/onehz/sleep/nap.dart b/lib/src/onehz/sleep/nap.dart index 569547e..ca0ebfc 100644 --- a/lib/src/onehz/sleep/nap.dart +++ b/lib/src/onehz/sleep/nap.dart @@ -23,7 +23,7 @@ // 2. Enumerate EVERY immobility bout (the nocturnal path keeps only the // longest), bridging brief arousals. // 3. Reject hard: off-wrist, charging/workout spans, the main sleep window -// and anything starting within the night's continuation gap after it, +// and short night tails starting within the continuation gap after it, // and bouts built on seconds the band never measured (`minNapAccelCoverage`). // 4. Require an autonomic signature — median HR inside the bout at or below // `napRestingHrMult` × the DAYTIME-AWAKE HR baseline. Stillness alone is @@ -157,18 +157,34 @@ const double maxNapOffWristFraction = 0.5; /// is ending — the exact phantom the deferral exists to prevent. const int napChainGapSec = 60 * 60; -/// A bout starting within this long after the main sleep ends is the night -/// going on, not a nap. Same gap the night detector chains continuations with, -/// so the two detectors agree on where a night can still be. +/// A short sleep bout starting within this long after the main sleep ends is +/// the night going on, not a nap. Same gap the night detector chains +/// continuations with, so the two detectors agree on where a night can still +/// be. /// /// The night detector does not keep every continuation it sees: once its /// one-shot short-tail exemption is spent, a second short pre-dawn tail falls /// to the 60-min floor and the main window ends before it. Those dropped tails /// are still, HR-dipped and outside the window, so an overlap test alone books /// them as a 6 AM nap and the night reads short. +/// +/// Only a bout the night detector could actually have dropped is attributed to +/// the night: at most [napNightTailMaxSec] long, and only once it has already +/// passed every nap gate (still, measured, HR-dipped). A long still block right +/// after waking — breakfast, a commute, the start of desk work — is not a tail +/// and is judged like any other bout. +/// +/// The night window itself is not extended here, so a genuine short +/// back-to-sleep within this gap counts toward neither the night nor nap +/// minutes. That loses a few minutes of sleep; booking them as a nap invents +/// one and shortens the night. const int napNightContinuationSec = AdvancedSleepStager.nightContinuationGapMin * 60; +/// Longest bout that can be a dropped night tail: the night detector's own +/// minimum-sleep floor, which is what drops it. +const int napNightTailMaxSec = AdvancedSleepStager.minSleepMin * 60; + /// Minimum awake HR samples needed to define a baseline. Below this the day is /// not judged at all: a threshold set by a handful of samples is not a /// baseline, and every nap decision hangs off it. @@ -338,27 +354,9 @@ Metric> detectNaps( // * 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. - // Bouts that start inside the night's continuation gap after the main sleep - // ends (see [napNightContinuationSec]). Measured on the wall clock from the - // window's last second, since index distance is not elapsed time. - final continuesNight = List.filled(bouts.length, false); - final mainEnd = mainSleep == null - ? 0 - : (mainSleep.end > n ? n : mainSleep.end); - if (mainSleep != null && mainEnd > 0 && mainEnd > mainSleep.start) { - final mainEndAbs = absAt(mainEnd - 1) + 1; - for (var b = 0; b < bouts.length; b++) { - if (bouts[b][0] < mainEnd) continue; - final gap = absAt(bouts[b][0]) - mainEndAbs; - if (gap <= napNightContinuationSec) continuesNight[b] = true; - } - } - final deferredSec = List.filled(n, false); for (var b = 0; b < bouts.length; b++) { - // A night fragment is sleep, not sedentary wake, so it stays out of the - // awake baseline for the same reason an unfinished bout does. - if (!unfinished[b] && !continuesNight[b]) continue; + if (!unfinished[b]) continue; for (var k = bouts[b][0]; k < bouts[b][1]; k++) { deferredSec[k] = true; } @@ -388,6 +386,14 @@ Metric> detectNaps( // into a bare "no qualifying nap", which tells the caller nothing about why. var outOfRange = 0, inMainSleep = 0, noBaseline = 0, unobserved = 0; var nightTail = 0; + // Wall-clock end of the main sleep, measured from the window's last second + // since index distance is not elapsed time. + final mainEndIdx = + mainSleep == null ? 0 : (mainSleep.end > n ? n : mainSleep.end); + final int? mainEndAbs = + mainSleep != null && mainEndIdx > 0 && mainEndIdx > mainSleep.start + ? absAt(mainEndIdx - 1) + 1 + : null; for (var bi = 0; bi < bouts.length; bi++) { final start = bouts[bi][0], end = bouts[bi][1]; @@ -416,11 +422,6 @@ Metric> detectNaps( continue; } - if (continuesNight[bi]) { - nightTail++; - continue; - } - // Wall-clock accel coverage — the seconds inside the bout the band actually // measured gravity for, over the bout's elapsed span (so a recording hole // counts against it exactly as an invalid sample does). @@ -481,6 +482,20 @@ Metric> detectNaps( continue; } + // A short sleep bout inside the night's continuation gap is a night tail + // the night detector dropped, not a nap (see [napNightContinuationSec]). + // Checked only now, after the bout has proven itself sleep, so the note + // never calls an awake sit "continuing the main sleep". It stays in the + // awake pool on purpose: removing still, low-HR seconds can only raise the + // baseline, which is the direction that invents naps. + if (mainEndAbs != null && + tib <= napNightTailMaxSec && + aStart >= mainEndAbs && + aStart - mainEndAbs <= napNightContinuationSec) { + nightTail++; + continue; + } + // Time ASLEEP is the still seconds inside the episode; the bridged arousal // is time in bed but not time asleep. This distinction is the whole point: // the sleep-need credit must be TST, and crediting TIB systematically diff --git a/test/onehz/nap_test.dart b/test/onehz/nap_test.dart index b9e2530..11e02ca 100644 --- a/test/onehz/nap_test.dart +++ b/test/onehz/nap_test.dart @@ -333,6 +333,38 @@ void main() { } }); + test('a long still block right after waking stays in the awake baseline', + () { + // REGRESSION. Treating every still bout that starts within the night's + // continuation gap as night pulled a 5-hour morning desk block out of the + // awake-HR pool. With only walking HR left, a 40-min awake sit at 75 bpm + // cleared the dip gate and became a nap, and the note called the desk + // block "continuing the main sleep". + for (final wake in [10, 60]) { + final d = _Day() + ..active(60, bpm: 85) + ..still(420, bpm: 55) // the night the main window covers + ..active(wake, bpm: 85) + ..still(300, bpm: 77) // morning desk work, awake + ..active(90, bpm: 80) + ..still(40, bpm: 75) // an awake sit + ..active(90, bpm: 80); + final start = 60 * 60, end = start + 420 * 60; + + final m = detectNaps( + d.accel, + d.hr, + mainSleep: SleepWindowSpan(start, end), + ); + + expect(m.present, isTrue); + expect(m.value, isEmpty, + reason: 'nobody napped; the desk block anchors the awake baseline ' + '($wake min after waking)'); + expect(m.note, isNot(contains('continuing the main sleep'))); + } + }); + test('a nap well clear of the night is still a nap', () { final d = _Day() ..active(180, bpm: 80) From b8f0a423ae289cbcd1bc27af5a4b31af1ec0b703 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 08:37:20 +0530 Subject: [PATCH 06/29] stop saving 0 g motion cuts from a motion-less night into the sleep profile a night whose motion MAD is 0 already abstains from the motion axis, but its observation still recorded still/move cuts of motMed + k*0, usually exactly 0 g. folded into the rolling profile, that pulled later nights' big-move bar toward 0 and ordinary repositioning got scored as wake. the cuts are now null for such a night, so the fold leaves the profile's motion axis alone. --- lib/src/onehz/sleep/cardio_stager.dart | 8 +++++-- test/onehz/sleep_user_profile_test.dart | 32 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/lib/src/onehz/sleep/cardio_stager.dart b/lib/src/onehz/sleep/cardio_stager.dart index 6738c3a..ca94b78 100644 --- a/lib/src/onehz/sleep/cardio_stager.dart +++ b/lib/src/onehz/sleep/cardio_stager.dart @@ -940,8 +940,12 @@ CardioStagerResult classifyCardioEpochs( (hrSleepMed ?? hrMedGlobal) + math.max(6.0, stddev(sleepHr) ?? 6.0), rmssdMed: rmssdMed, rmssdMad: mad(sleepRmssd), - enmoStillCut: motMed + 1.5 * motMad, - enmoMoveCut: motMed + 5.0 * motMad, + // A MAD-0 night abstained from the motion axis above; its cuts would be + // the same degenerate ~0 g threshold, and folding that into the profile + // drags later nights' big-move bar toward 0 so ordinary repositioning + // reads as WAKE. No measurement ⇒ null ⇒ the fold leaves the axis alone. + enmoStillCut: motUsable ? motMed + 1.5 * motMad : null, + enmoMoveCut: motUsable ? motMed + 5.0 * motMad : null, lfhfMed: median(sleepLfhf), rkMed: median(sleepRk), )); diff --git a/test/onehz/sleep_user_profile_test.dart b/test/onehz/sleep_user_profile_test.dart index 561c038..e4832d6 100644 --- a/test/onehz/sleep_user_profile_test.dart +++ b/test/onehz/sleep_user_profile_test.dart @@ -82,6 +82,38 @@ void main() { expect(back.rmssdMed, isNull); }); + test('a night with no motion spread records no ENMO cuts for the profile', + () { + // Exactly flat gravity ⇒ motion MAD 0 ⇒ the stager abstains from the + // motion axis. The observation must not carry a 0 g still/move cut that + // would later be folded in and pull every night's big-move bar to 0. + const n = 4 * 3600; + final hr = [for (var i = 0; i < n; i++) 55.0 + (i ~/ 60) % 5]; + final flat = [ + for (var i = 0; i < n; i++) AccelSample(i * 1000.0, 0, 0, 1.0), + ]; + cardioRecordObservations = true; + resetCardioObservations(); + try { + cardioStager(hr, flat); + final obs = takeCardioObservations(); + expect(obs, hasLength(1)); + expect(obs.single.hrSleepMedian, isNotNull); + expect(obs.single.enmoStillCut, isNull); + expect(obs.single.enmoMoveCut, isNull); + + // Folding it leaves an established motion profile untouched. + const settled = SleepUserProfile( + nights: 14, enmoStillCut: 0.001, enmoMoveCut: 0.003); + final folded = settled.fold(obs.single); + expect(folded.enmoStillCut, 0.001); + expect(folded.enmoMoveCut, 0.003); + } finally { + cardioRecordObservations = false; + resetCardioObservations(); + } + }); + test('a profile consistent with the night does not destabilize staging', () { // Synthetic still, low-HR night (should read as overwhelmingly asleep). From 8b6b94833a20650fd9aa9a3f11e4a378a3e6ca12 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 12:15:51 +0530 Subject: [PATCH 07/29] sri no longer publishes a score from a few shared minutes phillipsSri only applied its per-pair coverage floor to the breakdown list. the headline number still went out as long as a single epoch was valid on two adjacent days, and confidence came from the length of the grid, so one overlapping minute across a week scored +100 at 0.95. it now abstains when no pair clears the floor, and confidence is sized from the pairs that do. (cherry picked from commit 91ec23a63cf0b04d8fb6973bd7e41395713a8129) --- lib/src/onehz/sleep/sri.dart | 23 ++++++++++++++++++++--- test/onehz/sleep_test.dart | 28 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/lib/src/onehz/sleep/sri.dart b/lib/src/onehz/sleep/sri.dart index 88c8083..a59c90d 100644 --- a/lib/src/onehz/sleep/sri.dart +++ b/lib/src/onehz/sleep/sri.dart @@ -80,6 +80,14 @@ class SriResult { /// and without the floor a half-unobserved weekend tops the "worst pair" list /// for having no data. The floor does NOT change the published [SriResult.sri]: /// every accepted epoch still counts toward the total, thin pair or not. +/// +/// The floor DOES gate whether a total is published at all: with no pair +/// clearing it, the record is abstained on rather than scored. Without that, a +/// handful of minutes shared between two nights (a rotating shift whose sleeps +/// barely overlap in clock time) publishes ±100, and since the only valid +/// epochs are the asleep ones, almost always +100 "perfectly regular". +/// Confidence is sized from the qualifying pairs for the same reason — the +/// length of the grid says nothing about how much of it was observed. Metric phillipsSri( List sleepWake, int epochsPerDay, { @@ -135,10 +143,19 @@ Metric phillipsSri( ); } + if (pairs.isEmpty) { + return Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'no adjacent-day pair has ≥$pairFloor epochs observed on both ' + 'days (have=$cases epochs in total)', + ); + } + final sri = 200.0 * agreement / cases - 100.0; - // Confidence: scales with the number of day-comparisons (more days, more - // stable). Saturates around a typical 7-day record. - final conf = ((days - 1) / 7.0).clamp(0.3, 0.95); + // Confidence: scales with the number of adequately-observed day-comparisons + // (more of them, more stable). Saturates around a typical 7-day record. + final conf = (pairs.length / 7.0).clamp(0.3, 0.95); return Metric( value: SriResult(sri, days, cases, pairs), confidence: conf, diff --git a/test/onehz/sleep_test.dart b/test/onehz/sleep_test.dart index 09389df..8a735d0 100644 --- a/test/onehz/sleep_test.dart +++ b/test/onehz/sleep_test.dart @@ -283,6 +283,34 @@ void main() { expect(agree, lessThanOrEqualTo(cases)); expect(cases, lessThan(m.value!.cases)); }); + + test( + 'abstains when no pair clears the coverage floor, and sizes confidence ' + 'from the pairs that do', () { + // Eight days of grid, but the mask accepts one minute on day 0 and day + // 1. Before the floor gated the total this published SRI=+100 at 0.95. + const epochsPerDay = 1440; + final vec = List.filled(8 * epochsPerDay, true); + final valid = List.filled(8 * epochsPerDay, false); + valid[100] = true; + valid[epochsPerDay + 100] = true; + final thin = phillipsSri(vec, epochsPerDay, valid: valid); + expect(thin.present, isFalse); + expect(thin.value, isNull); + + // Same eight-day grid, only days 0..2 fully observed: two qualifying + // pairs, so confidence reflects two comparisons, not seven. + final valid2 = List.generate( + 8 * epochsPerDay, (i) => i < 3 * epochsPerDay); + final two = phillipsSri(vec, epochsPerDay, valid: valid2); + expect(two.present, isTrue); + expect(two.value!.pairs, hasLength(2)); + expect(two.confidence, closeTo(0.3, 1e-9)); + + // A fully observed week still saturates. + final full = phillipsSri(vec, epochsPerDay); + expect(full.confidence, closeTo(0.95, 1e-9)); + }); }); // ----------------------------------------------- segmentSleep (SINGLE SOURCE) From 39c33f2495b8b2402c91c3a2f570bf2d2ac526c6 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 12:19:28 +0530 Subject: [PATCH 08/29] remove the withdrawn cardiopulmonary coupling api cardiopulmonaryCoupling was withdrawn because it never measured coupling, and since then it only returned absent. it was kept so callers could drop the cpc_ratio key, and none are left. the sleep barrel still exported it and CpcResult, though, and listed cpc as a sleep metric, so anyone wiring it in would get a tile that stays blank forever. i deleted cpc.dart, the export and the doc rows, and the tests that only checked it was absent. i kept the test that shows the old ratio was just the rr periodogram hf/lf. (cherry picked from commit a5212efb6eec14002f2d69da75be9de1753e53c5) --- ALGORITHMS.md | 1 - README.md | 4 +- lib/src/onehz/sleep/cpc.dart | 93 ------------------------------ lib/src/onehz/sleep/sleep.dart | 2 - test/onehz/sleep_honesty_test.dart | 25 -------- test/onehz/sleep_test.dart | 40 +------------ 6 files changed, 3 insertions(+), 162 deletions(-) delete mode 100644 lib/src/onehz/sleep/cpc.dart diff --git a/ALGORITHMS.md b/ALGORITHMS.md index 7529a12..8ad09df 100644 --- a/ALGORITHMS.md +++ b/ALGORITHMS.md @@ -87,7 +87,6 @@ Grouped by family (subdirectory under `lib/src/onehz/`). File paths are relative | `cardioStager` | `sleep/cardio_stager.dart` | Webster/Cole-Kripke actigraphy + HRV fusion — explicitly replaces Walch 2019 (documented WAKE over-call bias) | | `AdvancedSleepStager` | `sleep/advanced_stager.dart` | AASM-style 4-class staging + hypnogram metrics (TIB/TST/SOL/WASO/REM-latency); `StagingMethod.cardio` is the wired production default, v1/v2 kept for regression coverage only | | `sleepAccounting` | `sleep/accounting.dart` | TIB/TST/WASO/efficiency accounting | — | -| `cardiopulmonaryCoupling` | `sleep/cpc.dart` | cardiopulmonary coupling | — | | `sleepCyclesMetric` | `sleep/cycles.dart` | fractal ultradian sleep-cycle detection | Rosenblum et al. 2024 | | `circadianNonparametric` | `sleep/circadian_np.dart` | IS/IV/RA/L5/M10 nonparametric circadian indices | — | | `phillipsSri` | `sleep/sri.dart` | true Phillips epoch-agreement Sleep Regularity Index | Phillips et al. 2017 | diff --git a/README.md b/README.md index 5edee80..c2c0bce 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,8 @@ foundation layers: reference; the 0-100 `trimpStrain` effort score is raw TRIMP with no such subtraction), a Poincaré irregular-rhythm screen, cosinor circadian fitting, and real-time cardiac coherence for guided breathing sessions. - **`sleep/`** — van Hees z-angle segmentation feeding a cardiac/motion stager (the - single source of truth for sleep staging), AASM hypnogram metrics, cardiopulmonary - coupling, fractal sleep-cycle detection, circadian non-parametric indices + single source of truth for sleep staging), AASM hypnogram metrics, + fractal sleep-cycle detection, circadian non-parametric indices (IS/IV/RA/L5/M10). - **`respiration/`** — RSA-derived respiratory rate fused with motion-modulated RIIV, CVHR-based apnea screening, a relative (never absolute) oxygen-desaturation ratio. diff --git a/lib/src/onehz/sleep/cpc.dart b/lib/src/onehz/sleep/cpc.dart deleted file mode 100644 index 9216359..0000000 --- a/lib/src/onehz/sleep/cpc.dart +++ /dev/null @@ -1,93 +0,0 @@ -// SLEEP/CIRCADIAN — Cardiopulmonary Coupling (CPC). WITHDRAWN. -// -// IF YOU GOT HERE FROM A GREP: this function has zero callers ON PURPOSE. it is -// not an oversight, not half-wired, not waiting for someone with time. every -// sweep of this repo re-proposes it, because a tested function with no caller -// reads as a loose end from the outside and as a decision only once you open -// the file. you have opened the file. the reason is below, and edge's -// compute/onehz_pipeline.dart records the same verdict where the call would go. -// -// Thomas et al. 2005 (Sleep) derives CPC from the cross-spectral coherence of -// TWO signals: the RR tachogram, and an ECG-DERIVED RESPIRATION (EDR) surrogate -// taken from the R-wave amplitude — a separate channel that is modulated by -// breathing but is NOT a function of the beat times. -// -// This implementation had no such channel. Its "respiration surrogate" was the -// NN series itself, linearly detrended: -// -// resp = detrend([v - mean(nnMs) for v in nnMs]) -// -// which differs from the tachogram only by a straight line. The coupling -// spectrum sqrt(P_rr . P_resp) therefore collapses to P_rr, and `cpc_ratio` -// came out equal to the plain RR periodogram HF/LF ratio — measured 84.493910 -// vs 84.493190 on a 9000-beat synthetic, a ratio of 1.0000085. It was the LF/HF -// of the same tachogram we already publish as `lf_hf`, wearing the name of a -// different, published, sleep-stability measure. -// -// The honest options were "derive a real EDR" or "stop shipping it". We cannot -// derive an EDR here — this function is handed beat intervals and beat times -// and nothing else, and a respiration channel independent of the tachogram is -// by definition not recoverable from them. So the metric is WITHDRAWN: it now -// abstains, with the reason attached, and the UI element it fed should be -// DELETED rather than made to explain a dash. -// -// Wiring it back up means changing the signature to take an actual respiration -// channel (PPG AC amplitude / RIIV, cf. respiration/resp_rate.dart) and -// validating the result against something. Until then there is no number here. -// -// RSA-derived respiration DOES NOT QUALIFY as that channel. it comes out of the -// same tachogram, so it reproduces exactly the failure above with more steps. -// the input has to be independent of the beat times or the coupling spectrum -// collapses again, and shipping the collapse under a clinical-sounding name is -// republishing `lf_hf` — already charted — as a second, fake metric. - -import '../types.dart'; -import '../util.dart'; - -class CpcResult { - final double hfc; // high-frequency coupling power (stable NREM) - final double lfc; // low-frequency coupling power (unstable / apnea-rich) - final double vlfc; // very-low-frequency (wake/REM) - final double cpcRatio; // HFC / LFC — sleep-stability index - final double dominantHz; // dominant coupling frequency - const CpcResult({ - required this.hfc, - required this.lfc, - required this.vlfc, - required this.cpcRatio, - required this.dominantHz, - }); - Map toJson() => { - 'hfc': round6(hfc), - 'lfc': round6(lfc), - 'vlfc': round6(vlfc), - 'cpc_ratio': round6(cpcRatio), - 'dominant_hz': round6(dominantHz), - }; -} - -/// CPC from a cleaned NN series + its beat times. -/// -/// ALWAYS ABSENT — see the file header. There is no respiration channel in this -/// signature that is independent of [nnMs], so there is no coupling to measure; -/// the previous implementation republished the RR periodogram's own HF/LF ratio -/// under Thomas 2005's name. The signature is kept so callers keep compiling -/// while the key is removed at their end. -@Deprecated( - 'WITHDRAWN — this never measured cardiopulmonary coupling. Its "respiration ' - 'surrogate" was the NN series itself, so cpc_ratio equalled the RR ' - 'periodogram HF/LF ratio (measured agreement 1.0000085). Delete the cpc_ratio ' - 'surface; reinstate only with a real respiration channel in the signature.', -) -Metric cardiopulmonaryCoupling( - List nnMs, - List nnTimesMs, -) { - return const Metric.absent( - tier: Tier.high, - inputs_used: ['rr_corrected'], - note: 'cardiopulmonary coupling needs a respiration channel independent of ' - 'the beat times (Thomas 2005 uses an ECG-derived respiration); we have ' - 'only the tachogram, so no coupling is measured', - ); -} diff --git a/lib/src/onehz/sleep/sleep.dart b/lib/src/onehz/sleep/sleep.dart index 2e2bd2f..8ff7bca 100644 --- a/lib/src/onehz/sleep/sleep.dart +++ b/lib/src/onehz/sleep/sleep.dart @@ -15,7 +15,6 @@ // honesty-bounded; stager.dart holds only the shared post-processing // (StagerResult, Webster rescore, consolidation) it reuses. The // autonomic-HR-only stager that used to live in stager.dart is DELETED. -// - Cardiopulmonary Coupling (CPC) (cpc.dart) // - Nonparametric circadian IS/IV/RA/L5/M10 (circadian_np.dart) // // Pure Dart, built on the package foundations (RR correction, Lomb-Scargle, @@ -35,7 +34,6 @@ export 'accounting.dart'; // `autonomicStager` is DELETED — `cardioStager` is the stager. export 'stager.dart'; export 'cardio_stager.dart'; -export 'cpc.dart'; export 'circadian_np.dart'; export 'cycles.dart'; export 'night_hrv_shape.dart'; diff --git a/test/onehz/sleep_honesty_test.dart b/test/onehz/sleep_honesty_test.dart index 6ab63aa..06d3256 100644 --- a/test/onehz/sleep_honesty_test.dart +++ b/test/onehz/sleep_honesty_test.dart @@ -614,31 +614,6 @@ void main() { // never a sentinel, never a NaN dressed up as a number. // ═══════════════════════════════════════════════════════════════════════════ group('honesty — cardiopulmonary coupling is WITHDRAWN', () { - test('always absent, with the reason attached', () { - // It never measured coupling. Thomas 2005 needs a respiration channel - // INDEPENDENT of the beat times; the "surrogate" here was the NN series - // linearly detrended, so sqrt(P_rr . P_resp) collapsed to P_rr and - // cpc_ratio equalled the plain RR periodogram HF/LF ratio — measured - // 84.493910 vs 84.493190 on this shape, a ratio of 1.0000085. A healthy - // record used to "still report a real ratio"; that was the bug, not the - // control case. - final nn = []; - final ts = []; - var t = 0.0; - for (var i = 0; i < 3600; i++) { - final rr = 1000 + 40 * math.sin(2 * math.pi * 0.25 * (t / 1000.0)); - t += rr; - nn.add(rr); - ts.add(t); - } - // ignore: deprecated_member_use_from_same_package - final m = cardiopulmonaryCoupling(nn, ts); - expect(m.present, isFalse); - expect(m.value, isNull); - expect(m.confidence, 0); - expect(m.note, contains('respiration channel')); - }); - test('the ratio it used to publish was the RR periodogram HF/LF', () { // The assertion that would have failed from the first commit (T-02). final nn = []; diff --git a/test/onehz/sleep_test.dart b/test/onehz/sleep_test.dart index 8a735d0..e03a809 100644 --- a/test/onehz/sleep_test.dart +++ b/test/onehz/sleep_test.dart @@ -5,7 +5,7 @@ // expected value), then sanity-checked against the ~9-min real type-24 snippet. // // IMPORTANT: ../whoop_hist.jsonl is only ~9 min of 1 Hz records — far too short -// for a real night. So van Hees / SRI / accounting / staging / CPC / circadian +// for a real night. So van Hees / SRI / accounting / staging / circadian // get SYNTHETIC validation only; on the real capture we only assert the // pipeline runs and (where applicable) returns honest "absent" rather than a // fabricated night. That limitation is stated honestly here, not faked. @@ -758,28 +758,6 @@ void main() { }); }); - // ---------------------------------------------------------------------- CPC - group('Cardiopulmonary Coupling (WITHDRAWN)', () { - test('abstains — no respiration channel independent of the beat times', () { - // Was: "NN with injected ~0.25 Hz respiration → HF coupling band". That - // passed because the respiration surrogate WAS the NN series, so of - // course the "coupling" peak sat at the RSA frequency. See cpc.dart. - final nn = []; - final times = []; - var t = 0.0; - for (var i = 0; i < 1800; i++) { - final rr = 1000 + 40 * math.sin(2 * math.pi * 0.25 * (t / 1000.0)); - t += rr; - nn.add(rr); - times.add(t); - } - // ignore: deprecated_member_use_from_same_package - final m = cardiopulmonaryCoupling(nn, times); - expect(m.present, isFalse); - expect(m.note, contains('respiration channel')); - }); - }); - // ----------------------------------------------------------- circadian (NP) group('nonparametric circadian IS/IV/RA/L5/M10', () { test('clean 24-h cosine activity over 7 days → IS≈1, RA high', () { @@ -824,7 +802,6 @@ void main() { histFile.readAsLinesSync().where((l) => l.trim().isNotEmpty).toList(); final accel = []; final hr = []; - final rrMs = []; var t = 0.0; for (final line in lines) { final obj = jsonDecode(line) as Map; @@ -836,9 +813,6 @@ void main() { accel.add(AccelSample(t, a[0], a[1], a[2])); } hr.add(r.hr.toDouble()); - for (final rr in r.rrIntervalsMs) { - if (rr > 0) rrMs.add(rr.toDouble()); - } t += 1000.0; } expect(accel.length, greaterThan(100)); @@ -853,18 +827,6 @@ void main() { } else { expect(sw.confidence, 0); } - - // CPC on real RR via the foundation corrector: should run and produce a - // dominant frequency in the physiological respiratory range (or absent). - final corr = correctRr(rrMs); - if (corr.nn.length >= 60) { - // ignore: deprecated_member_use_from_same_package - final cpc = cardiopulmonaryCoupling(corr.nn, corr.nnTimesMs); - if (cpc.present) { - expect(cpc.value!.dominantHz, inInclusiveRange(0.0, 0.45)); - expect(cpc.value!.hfc, greaterThanOrEqualTo(0)); - } - } }); }); From 7025d08700801a5ecaad4966e4b7775c2671c070 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 12:19:27 +0530 Subject: [PATCH 09/29] van hees immobility tested 5 degrees per second, not per 5 s epoch the angle change was taken between adjacent 1 hz samples of the smoothed z-angle, so a slow wrist roll spread over a few seconds never crossed the 5 degree threshold and a restless hour in bed was marked as sustained rest, pulling sleep onset earlier and letting slow daytime movement certify naps. the change is now taken across one 5 s smoothing epoch, the sliding form of the epoch-to-epoch comparison the method publishes. (cherry picked from commit 6c53f5a628f0f8a8e8002c977e67850185c9eaa0) --- lib/src/onehz/sleep/van_hees.dart | 20 ++++++++--- test/onehz/sleep_honesty_test.dart | 4 ++- test/onehz/sleep_test.dart | 55 ++++++++++++++++++++++++++---- 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/lib/src/onehz/sleep/van_hees.dart b/lib/src/onehz/sleep/van_hees.dart index 532a52a..58b433c 100644 --- a/lib/src/onehz/sleep/van_hees.dart +++ b/lib/src/onehz/sleep/van_hees.dart @@ -129,8 +129,9 @@ class ImmobilityMask { /// well-formed 0.0° that is not a measurement of anything. final List zAngleDeg; - /// |Δ z-angle| vs the previous second (index 0 is 0 by definition). NaN when - /// either endpoint second is invalid — the arm may have moved across the gap + /// |Δ z-angle| vs the sample one smoothing epoch (`smoothSec`) earlier — + /// clamped to index 0 for the first epoch, and index 0 is 0 by definition. + /// NaN when either endpoint second is invalid — the arm may have moved across the gap /// and the record cannot say. Every comparison against NaN is false, so a /// consumer testing `deltaDeg[k] < threshold` degrades to "not still", never /// to "perfectly still". @@ -241,9 +242,17 @@ ImmobilityMask immobilityMask( final ang = _rollingMedian(raw, smoothSamples); // 3. per-second immobility: |Δ z-angle| < threshold sustained for ≥ window. + // + // van Hees 2015 / GGIR test the change between CONSECUTIVE 5 s EPOCHS against + // 5°, not the change between consecutive samples. Differencing adjacent 1 Hz + // samples made the rule "< 5° per second": a 20° roll spread over 6 s moves + // ~3°/s, never crossed the threshold, and a restless hour in bed was certified + // as sustained rest. Differencing the smoothed angle across one smoothing + // epoch is the sliding form of the epoch-to-epoch comparison, so a slow roll + // is judged by the full change it makes over 5 s. final dAng = List.filled(n, 0); for (var i = 1; i < n; i++) { - dAng[i] = (ang[i] - ang[i - 1]).abs(); + dAng[i] = (ang[i] - ang[math.max(0, i - smoothSamples)]).abs(); } final immobile = List.filled(n, false); final immobileUnknown = List.filled(n, false); @@ -273,7 +282,10 @@ ImmobilityMask immobilityMask( var maxd = 0.0; var gap = !accel[i].valid; for (var k = i + 1; k < hi; k++) { - if (!accel[k].valid) { + // NaN also when the epoch this second is compared against reaches back + // into an unmeasured second, possibly one before `i` — the arm may have + // moved there, so it is a gap, not stillness. + if (!accel[k].valid || dAng[k].isNaN) { gap = true; break; } diff --git a/test/onehz/sleep_honesty_test.dart b/test/onehz/sleep_honesty_test.dart index 06d3256..5f967e9 100644 --- a/test/onehz/sleep_honesty_test.dart +++ b/test/onehz/sleep_honesty_test.dart @@ -605,7 +605,9 @@ void main() { // The two seconds must not share one answer — that is the whole bug. expect(w.immobileUnknown[3350] == w.immobileUnknown[3500], isFalse); - expect(w.undecidableSec, 193, reason: 'pre-fix: 0 (no such state)'); + // The burst also decides the seconds whose 5 s comparison epoch still + // reaches back into it. + expect(w.undecidableSec, 189, reason: 'pre-fix: 0 (no such state)'); }); }); diff --git a/test/onehz/sleep_test.dart b/test/onehz/sleep_test.dart index e03a809..eacaf49 100644 --- a/test/onehz/sleep_test.dart +++ b/test/onehz/sleep_test.dart @@ -195,18 +195,21 @@ void main() { accel.add(AccelSample(t, 0.3 * phase, 0.3, 0.9 * (1 - 0.2 * phase))); t += 1000.0; } - // Zero out only the first still-block sample's timestamp. - accel[nightStart] = AccelSample( + // Zero out only the onset sample's timestamp. The first smoothing epoch + // of the still block is still compared against the moving epoch before + // it, so the onset lands smoothSec-1 samples into the block. + final onsetAt = nightStart + 4; + accel[onsetAt] = AccelSample( 0, - accel[nightStart].x, - accel[nightStart].y, - accel[nightStart].z, + accel[onsetAt].x, + accel[onsetAt].y, + accel[onsetAt].z, ); final m = vanHeesSleepWindow(accel); expect(m.present, isTrue); final w = m.value!; - expect(w.onsetIdx, nightStart, + expect(w.onsetIdx, onsetAt, reason: 'test setup must target the sample the code actually reads'); expect(w.onsetMs, isNull, reason: 'unstamped onset sample must not fabricate epoch 0'); @@ -214,6 +217,46 @@ void main() { }); }); + group('van Hees epoch-to-epoch angle rule', () { + // 60 min of slow wrist rolls (20 deg over 6 s, ~3.3 deg/s, every 3 min, + // alternating direction) then 90 min perfectly still. No single second + // moves 5 deg, but consecutive 5 s epochs differ by well over 5 deg — + // van Hees 2015 / GGIR count that as movement. + List slowRolls() { + final out = []; + var base = 10.0; + var dir = 1.0; + for (var i = 0; i < 150 * 60; i++) { + var deg = base; + if (i < 60 * 60) { + final p = i % 180; + if (p < 6) deg = base + dir * 20 * (p + 1) / 6; + if (p == 5) { + base += dir * 20; + dir = -dir; + } + } + final r = deg * math.pi / 180; + out.add(AccelSample(1000.0 + i * 1000.0, math.cos(r), 0, math.sin(r))); + } + return out; + } + + test('a slow roll spread over several seconds breaks immobility', () { + final accel = slowRolls(); + final mask = immobilityMask(accel); + final restless = mask.immobile.sublist(0, 55 * 60); + expect(restless.where((s) => s).length, 0, + reason: 'pre-fix: every second of the restless hour was immobile'); + expect(mask.immobile[70 * 60], isTrue); + + final m = vanHeesSleepWindow(accel); + expect(m.present, isTrue); + expect(m.value!.onsetIdx, greaterThanOrEqualTo(57 * 60), + reason: 'pre-fix: onset at 0 — the restless hour was annexed'); + }); + }); + // ---------------------------------------------------------------------- SRI group('True Phillips SRI', () { test('perfectly-regular 2-day vector → SRI = 100', () { From 1fa4081239061f5486fcbfee6eec3c236934c0e7 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 18 Oct 2026 14:36:38 +0530 Subject: [PATCH 10/29] night hrv shape: abstain when every bin is a hole a sparse night could clear the total-beat and three-bin gates while no 30-min bin reached the per-bin floor. nightHrvShape then returned a present shape at zero confidence with nothing in it, so the app treated it as a stored night and showed the wrong message. it now returns an absent metric with a note saying no bin reached the floor. (cherry picked from commit 87c682effee2d8917220ca760d13eccad92dbcbf) --- lib/src/onehz/sleep/night_hrv_shape.dart | 12 ++++++++++++ test/onehz/sleep_test.dart | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/lib/src/onehz/sleep/night_hrv_shape.dart b/lib/src/onehz/sleep/night_hrv_shape.dart index 7778fba..0e82136 100644 --- a/lib/src/onehz/sleep/night_hrv_shape.dart +++ b/lib/src/onehz/sleep/night_hrv_shape.dart @@ -193,6 +193,18 @@ Metric nightHrvShape( )); } + // Enough beats overall can still be spread so thin that no bin reaches the + // floor. A shape made only of holes measures nothing, so it is an + // abstention, not a present value at zero confidence. + if (!bins.any((b) => b.present)) { + return Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'no ${binMin.round()}-min bin reached $minBeatsPerBin beats ' + 'with a usable RMSSD', + ); + } + final third = bins.length ~/ 3; double? meanOf(Iterable xs) { final v = [ diff --git a/test/onehz/sleep_test.dart b/test/onehz/sleep_test.dart index eacaf49..18a2840 100644 --- a/test/onehz/sleep_test.dart +++ b/test/onehz/sleep_test.dart @@ -1166,6 +1166,23 @@ void _cv06Tests() { expect(m.note, contains('no shape')); }); + test('a night where every bin is a hole is absent, not an empty shape', + () { + // 400 beats over ~1.1 h: past the beat-count and three-bin gates, but no + // 30-min bin reaches the per-bin floor. + final nn = []; + final t = []; + for (var i = 0; i < 400; i++) { + nn.add(i.isEven ? 800.0 : 900.0); + t.add(i * 9900.0); + } + final m = nightHrvShape(nn, t); + expect(m.present, isFalse); + expect(m.value, isNull); + expect(m.confidence, 0.0); + expect(m.note, contains('$kMinBeatsPerHrvBin beats')); + }); + test('CV-06 does not publish a time-to-max (deliberately cut)', () { final n = _syntheticNight(hours: 8, ampMs: (_) => 20.0); final j = nightHrvShape(n.nn, n.t).value!.toJson(); From 141c7f183759f80429b39f7751aa6424a2667d8d Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 18 Oct 2026 14:42:03 +0530 Subject: [PATCH 11/29] sleep: stop calling the arousal-bridging pass webster's rescore the continuity pass in cardio_stager and stager was documented as "the published table, unmodified" from webster 1982 / cole 1992. it isn't. those rules turn sleep into wake after sustained wake and can only lower tst; this pass turns short wake bouts into nrem and can only raise it. it only borrows the minute values. the comments now say what it is: a deliberate tst-leaning smoother kept because the hr/motion wake spine over-calls brief arousals. i also wrote down why the context stays one-sided: requiring sleep on both sides raised wake on the apple-watch-labelled night from 26 to 41.5 min against a 3 min reference. behaviour is unchanged. (cherry picked from commit 1f8d75319d2b73d42d47eb28eb6b7885ff5a9acf) --- lib/src/onehz/sleep/cardio_stager.dart | 37 +++++++++++++------ lib/src/onehz/sleep/stager.dart | 15 +++++--- test/onehz/real_night_cardio_stager_test.dart | 3 +- test/onehz/sleep_honesty_test.dart | 9 +++-- 4 files changed, 42 insertions(+), 22 deletions(-) diff --git a/lib/src/onehz/sleep/cardio_stager.dart b/lib/src/onehz/sleep/cardio_stager.dart index ca94b78..881e273 100644 --- a/lib/src/onehz/sleep/cardio_stager.dart +++ b/lib/src/onehz/sleep/cardio_stager.dart @@ -61,8 +61,9 @@ // TODO: re-run the DREAMT effect size for RMSSD at ±90 s and publish both // numbers side by side. DO NOT move the shipped weights on the strength of // this — it says the measurement is untrustworthy, not which way it moves. -// Post: Webster continuity rescore (bridges brief arousals into sleep — this is -// what kills the over-call) + consolidateSleepStages (no single-epoch flicker). +// Post: arousal-bridging continuity smoother (bridges brief arousals into sleep +// — this is what kills the over-call; it is NOT Webster's rescore, see +// `_websterRescore`) + consolidateSleepStages (no single-epoch flicker). // // LOCAL vs WHOLE-NIGHT baselines (2026-07, real-data root cause): a real WHOOP-4 // capture showed BOTH the motion and HR features have night-scale @@ -1167,14 +1168,27 @@ List cleanBeatDiffsInWindowForTest( rk: _windowRemFeatures(rrMs, rrTsMs, accel, s, t, _epochSec).rk, ); -/// Webster sleep-continuity rescore: brief wake bouts flanked by enough sleep -/// are re-labelled sleep (NREM). This is the published actigraphy step that -/// prevents normal in-sleep repositioning from inflating WASO. +/// Arousal-bridging continuity smoother: brief wake bouts flanked by enough +/// sleep are re-labelled sleep (NREM), so in-sleep repositioning that the +/// HR/motion wake spine over-calls does not inflate WASO. +/// +/// This is NOT Webster's rescore and is not a published method. Webster et +/// al. 1982 / Cole et al. 1992 run the other way: after >=4/10/15 min of +/// scored WAKE the next 1/3/4 min of SLEEP become wake, and a sleep bout of +/// <=6 (<=10) min with >=10 (>=20) min of wake on both sides becomes wake. Their +/// pass only ever removes sleep; this one only ever adds it, borrowing the +/// same minute values as its bounds. It is a deliberate bias toward TST: each +/// rule row can hide a genuine awakening of up to that row's length. +/// +/// Context is the LONGER of the two flanking sleep runs, not both. Requiring +/// both sides was measured: on the Apple-Watch-labelled real night +/// (real_night_cardio_stager_test, reference wake = 3 min) it raised wake from +/// 26.0 to 41.5 min, i.e. the one-sided context is absorbing over-called wake +/// on real data, not erasing real awakenings there. /// /// The flanking-sleep CONTEXT is measured against an immutable SNAPSHOT of the -/// hypnogram taken before the pass — Webster's rule scores each wake bout -/// against the ORIGINAL surrounding sleep (Webster et al. 1982; Cole et al. -/// 1992), not against sleep this same pass just manufactured. Reading the list +/// hypnogram taken before the pass, not against sleep this same pass just +/// manufactured. Reading the list /// being mutated let every bridged bout count as context for the next one, so /// bridging CASCADED: a fragmented night of short sleep bouts separated by long /// wake bouts collapsed into one continuous sleep block (WASO 0, efficiency @@ -1215,9 +1229,10 @@ void _websterRescore(List sm, int epochSec) { return c; } - // Context (min sleep flanking) → max bridgeable wake (min). THE PUBLISHED - // TABLE, unmodified: Webster et al. 1982 / Cole et al. 1992 rescoring rules — - // 4 min of sleep bridges ≤1 min of wake, 10 bridges ≤3, 15 bridges ≤4. + // Context (min sleep flanking) → max bridgeable wake (min). The minute values + // are Webster et al. 1982 / Cole et al. 1992's; the direction is not (see the + // doc comment) — 4 min of sleep bridges ≤1 min of wake, 10 bridges ≤3, 15 + // bridges ≤4. // // This used to run [15→10], [10→5], [4→2] — 2.5x the published bridge — on // the argument that the van Hees window had already certified the span as the diff --git a/lib/src/onehz/sleep/stager.dart b/lib/src/onehz/sleep/stager.dart index 267a217..7497ed4 100644 --- a/lib/src/onehz/sleep/stager.dart +++ b/lib/src/onehz/sleep/stager.dart @@ -1,4 +1,4 @@ -// SLEEP/CIRCADIAN — the shared staging POST-PROCESSING (Webster rescore + +// SLEEP/CIRCADIAN — the shared staging POST-PROCESSING (arousal bridging + // stage-architecture consolidation) and the [StagerResult] shape both stagers // return. // @@ -43,13 +43,17 @@ class StagerResult { void websterRescoreAutonomic(List sm, int epochSec) => _websterRescore(sm, epochSec); -/// Webster/Cole-Kripke sleep-continuity rescoring (in place). +/// Arousal-bridging sleep-continuity smoother (in place). NOT Webster's +/// rescore: Webster et al. 1982 / Cole et al. 1992 turn SLEEP into WAKE after +/// sustained wake and only ever remove sleep; this turns WAKE into SLEEP and +/// only ever adds it. It borrows their minute values as bounds — see +/// `cardio_stager.dart`'s `_websterRescore` for why it is kept. /// /// After sleep onset (first sleep epoch), a contiguous run of WAKE epochs that /// is short enough relative to the SUSTAINED sleep bracketing it is rescored to /// SLEEP (NREM) — it is an arousal, not real WASO. Runs longer than the /// allowed bout, or with insufficient surrounding sleep, are LEFT as wake (a -/// genuine, sustained awakening). Rules (Webster, in epochs): +/// genuine, sustained awakening). Rules (in epochs): /// sleep-before ≥ 4 min AND wake-run ≤ 1 min → sleep /// sleep-before ≥10 min AND wake-run ≤ 3 min → sleep /// sleep-before ≥15 min AND wake-run ≤ 5 min → sleep @@ -61,9 +65,8 @@ void websterRescoreAutonomic(List sm, int epochSec) => /// so an arousal sandwiched between two long sleep bouts is bridged. /// /// The flanking-sleep CONTEXT is measured against an immutable SNAPSHOT of the -/// hypnogram taken before the pass — Webster's rule scores each wake bout -/// against the ORIGINAL surrounding sleep (Webster et al. 1982; Cole et al. -/// 1992), not against sleep this same pass just manufactured. Reading the list +/// hypnogram taken before the pass, not against sleep this same pass just +/// manufactured. Reading the list /// being mutated let every bridged bout count as context for the next one, so /// bridging CASCADED and a genuinely fragmented night collapsed into one /// continuous sleep block (WASO 0, efficiency 100%). diff --git a/test/onehz/real_night_cardio_stager_test.dart b/test/onehz/real_night_cardio_stager_test.dart index b779d92..ceb6050 100644 --- a/test/onehz/real_night_cardio_stager_test.dart +++ b/test/onehz/real_night_cardio_stager_test.dart @@ -125,7 +125,8 @@ void main() { // 516.5 → 508.0. The old table was closer to this night's Apple Watch label // (wake=3) only because it was bridging up to 10 minutes of genuine // awakening into light sleep; one wrist-reference night is not a licence to - // deviate from Webster 1982 / Cole 1992. + // widen the bridge past the Webster 1982 / Cole 1992 minute values. (The + // bridging pass itself is not Webster's rescore — see `_websterRescore`.) expect(wakeMin, lessThan(35), reason: 'WAKE over-call regression guard (pre-fix: 294 min)'); expect(remMin, greaterThan(100), diff --git a/test/onehz/sleep_honesty_test.dart b/test/onehz/sleep_honesty_test.dart index 5f967e9..1bb8a9f 100644 --- a/test/onehz/sleep_honesty_test.dart +++ b/test/onehz/sleep_honesty_test.dart @@ -270,10 +270,11 @@ void main() { test('cardio_stager: only the bout with REAL flanking sleep is bridged', () { // 15 min sleep, then 5 × (4 min wake + 1 min sleep). The rule table is - // now Webster's published one — ≥15 min of flanking sleep bridges ≤4 min - // of wake — so bout #1 legitimately bridges. Bouts #2-#5 are flanked by - // ONE minute of sleep and must survive: pre-fix each bridged bout was - // counted as context for the next, so all five collapsed and WASO hit 0. + // now bounded by Webster's minute values — ≥15 min of flanking sleep + // bridges ≤4 min of wake — so bout #1 legitimately bridges. Bouts #2-#5 + // are flanked by ONE minute of sleep and must survive: pre-fix each + // bridged bout was counted as context for the next, so all five + // collapsed and WASO hit 0. // // RE-PINNED with the rule table: this used to use 10-min bouts, which the // old non-published [15 → 10] row bridged. Under Webster a 10-min bout is From 5365b92f3f0bccf9864891f9468ea2305fa1955b Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 18 Oct 2026 15:10:08 +0530 Subject: [PATCH 12/29] sleep: drop the dead second copy of the arousal-bridging pass stager.dart still carried its own copy of the bridging pass after autonomicStager went away. nothing called it except a test seam, but the seam was exported from the barrel, and its table still let 15 min of sleep bridge 5 min of wake where the live pass in cardio_stager stops at 4. a real 5-minute awakening run through it came out as nrem. I deleted the copy and its seam, moved the test onto the live pass, and pinned the 4-minute bound. the cardio_stager test hooks are now hidden from the barrel; the tests import that file directly. (cherry picked from commit 6e1c43aa38561d18a762146d5369f5f48941b3d4) --- lib/src/onehz/sleep/sleep.dart | 14 +- lib/src/onehz/sleep/stager.dart | 124 +----------------- test/onehz/real_night_cardio_stager_test.dart | 5 + test/onehz/sleep_honesty_test.dart | 28 +++- 4 files changed, 41 insertions(+), 130 deletions(-) diff --git a/lib/src/onehz/sleep/sleep.dart b/lib/src/onehz/sleep/sleep.dart index 8ff7bca..dc21184 100644 --- a/lib/src/onehz/sleep/sleep.dart +++ b/lib/src/onehz/sleep/sleep.dart @@ -29,11 +29,17 @@ export 'advanced_stager.dart'; export 'band_offset.dart'; export 'sri.dart'; export 'accounting.dart'; -// stager.dart provides StagerResult + the shared Webster/consolidation -// post-processing (both used by cardio_stager and tests). Its deprecated -// `autonomicStager` is DELETED — `cardioStager` is the stager. +// stager.dart provides StagerResult + the shared consolidation post-step. Its +// deprecated `autonomicStager` is DELETED — `cardioStager` is the stager. export 'stager.dart'; -export 'cardio_stager.dart'; +// The test hooks drive private passes directly and are not entry points; tests +// import cardio_stager.dart itself to reach them. +export 'cardio_stager.dart' + hide + websterRescoreCardio, + cleanBeatsInWindowForTest, + cleanBeatDiffsInWindowForTest, + windowRmssdRkForTest; export 'circadian_np.dart'; export 'cycles.dart'; export 'night_hrv_shape.dart'; diff --git a/lib/src/onehz/sleep/stager.dart b/lib/src/onehz/sleep/stager.dart index 7497ed4..8bd5f73 100644 --- a/lib/src/onehz/sleep/stager.dart +++ b/lib/src/onehz/sleep/stager.dart @@ -1,18 +1,19 @@ -// SLEEP/CIRCADIAN — the shared staging POST-PROCESSING (arousal bridging + -// stage-architecture consolidation) and the [StagerResult] shape both stagers -// return. +// SLEEP/CIRCADIAN — the shared stage-architecture consolidation post-step and +// the [StagerResult] shape the stagers return. // // The 3-class `autonomicStager` that used to live here is GONE (2026-08): it // was deprecated, hidden from the barrel and reachable only from its own test, // and it conflated epoch count with sample count (`n ~/ epochSec` assumes a // 1 Hz stream, so any other cadence silently rescaled every epoch). The live // stager is `cardioStager` (cardio_stager.dart), which reuses everything below. +// Its arousal-bridging pass lives in cardio_stager.dart only: this file used to +// carry a second copy with a wider rule table (15 min of sleep bridged 5 min of +// wake, not 4) that no stager called any more but the barrel still exported. // // HONESTY CEILING (catalog rule 5): wrist staging is at best a 3-class // AUTONOMIC ESTIMATE, never a PSG 4-stage hypnogram. We NEVER emit N1/N2/N3. // This is tier ESTIMATE. -import 'dart:math' as math; import '../util.dart'; import 'accounting.dart' show SleepStage; @@ -38,121 +39,6 @@ class StagerResult { }; } -/// Test seam for [_websterRescore] — exposed (non-private) so the regression -/// test can drive the continuity rule directly. Not part of the public barrel. -void websterRescoreAutonomic(List sm, int epochSec) => - _websterRescore(sm, epochSec); - -/// Arousal-bridging sleep-continuity smoother (in place). NOT Webster's -/// rescore: Webster et al. 1982 / Cole et al. 1992 turn SLEEP into WAKE after -/// sustained wake and only ever remove sleep; this turns WAKE into SLEEP and -/// only ever adds it. It borrows their minute values as bounds — see -/// `cardio_stager.dart`'s `_websterRescore` for why it is kept. -/// -/// After sleep onset (first sleep epoch), a contiguous run of WAKE epochs that -/// is short enough relative to the SUSTAINED sleep bracketing it is rescored to -/// SLEEP (NREM) — it is an arousal, not real WASO. Runs longer than the -/// allowed bout, or with insufficient surrounding sleep, are LEFT as wake (a -/// genuine, sustained awakening). Rules (in epochs): -/// sleep-before ≥ 4 min AND wake-run ≤ 1 min → sleep -/// sleep-before ≥10 min AND wake-run ≤ 3 min → sleep -/// sleep-before ≥15 min AND wake-run ≤ 5 min → sleep -/// The top row is 5, not Webster's 4, and the widening is deliberate: it -/// absorbs the van Hees 5-min mask-smear artifact. See the rules table below. -/// `cardio_stager`'s copy runs the published 4 because van Hees does not run -/// on its forced-window paths at all. -/// "sleep-before" is satisfied by sustained sleep on EITHER bracketing side, -/// so an arousal sandwiched between two long sleep bouts is bridged. -/// -/// The flanking-sleep CONTEXT is measured against an immutable SNAPSHOT of the -/// hypnogram taken before the pass, not against sleep this same pass just -/// manufactured. Reading the list -/// being mutated let every bridged bout count as context for the next one, so -/// bridging CASCADED and a genuinely fragmented night collapsed into one -/// continuous sleep block (WASO 0, efficiency 100%). -void _websterRescore(List sm, int epochSec) { - bool isSleep(SleepStage s) => s != SleepStage.wake; - final n = sm.length; - // epochs-per-minute (rounded; epochSec=30 → 2/min). - double minToEp(double m) => m * 60.0 / epochSec; - // Immutable context snapshot — see the doc comment above. - final snap = List.of(sm); - - // Locate sleep onset & final sleep epoch — only rescore within the sleep body. - var onset = -1, lastSleep = -1; - for (var i = 0; i < n; i++) { - if (isSleep(snap[i])) { - if (onset < 0) onset = i; - lastSleep = i; - } - } - if (onset < 0) return; // never slept — nothing to rescore. - - // Sustained sleep run immediately before index `i` (epochs), within body. - int sleepRunBefore(int i) { - var c = 0; - var k = i - 1; - while (k >= onset && isSleep(snap[k])) { - c++; - k--; - } - return c; - } - - // Sustained sleep run immediately after index `i` (epochs), within body. - int sleepRunAfter(int i) { - var c = 0; - var k = i + 1; - while (k <= lastSleep && isSleep(snap[k])) { - c++; - k++; - } - return c; - } - - // (sleep-context-epochs, max-wake-run-epochs) thresholds, longest context first. - // The top rule allows up to 5 min of wake to be bridged when flanked by ≥15 - // min of consolidated sleep: this is the empirical floor for "real WASO needs - // a SUSTAINED arousal", and it absorbs the van Hees 5-min mask-smear artifact - // (a single reposition inflated to ~300 s) without bridging genuine multi- - // minute awakenings (which exceed 5 min and stay scored as wake). - final rules = >[ - [minToEp(15), minToEp(5)], - [minToEp(10), minToEp(3)], - [minToEp(4), minToEp(1)], - ]; - - var i = onset; - while (i <= lastSleep) { - if (isSleep(snap[i])) { - i++; - continue; - } - // WAKE run [i, j). - var j = i; - while (j <= lastSleep && !isSleep(snap[j])) { - j++; - } - final wakeLen = j - i; - final before = sleepRunBefore(i); - final after = sleepRunAfter(j - 1); - final context = math.max(before, after).toDouble(); - var rescore = false; - for (final r in rules) { - if (context >= r[0] && wakeLen <= r[1]) { - rescore = true; - break; - } - } - if (rescore) { - for (var k = i; k < j; k++) { - sm[k] = SleepStage.nrem; - } - } - i = j; - } -} - /// Minimum sustained REM episode (min) and minimum NREM/REM bout (min) for the /// architecture-consolidation post-step. ~5 min reflects the shortest credible /// REM episode and merges sub-5-min stage flicker into the surrounding stage. diff --git a/test/onehz/real_night_cardio_stager_test.dart b/test/onehz/real_night_cardio_stager_test.dart index ceb6050..36d7f1e 100644 --- a/test/onehz/real_night_cardio_stager_test.dart +++ b/test/onehz/real_night_cardio_stager_test.dart @@ -43,6 +43,11 @@ import 'dart:io'; import 'package:test/test.dart'; import 'package:openstrap_analytics/onehz.dart'; +import 'package:openstrap_analytics/src/onehz/sleep/cardio_stager.dart' + show + cleanBeatsInWindowForTest, + cleanBeatDiffsInWindowForTest, + windowRmssdRkForTest; void main() { _windowEdgeTies(); diff --git a/test/onehz/sleep_honesty_test.dart b/test/onehz/sleep_honesty_test.dart index 1bb8a9f..04a100f 100644 --- a/test/onehz/sleep_honesty_test.dart +++ b/test/onehz/sleep_honesty_test.dart @@ -12,6 +12,8 @@ import 'dart:math' as math; import 'package:test/test.dart'; import 'package:openstrap_analytics/onehz.dart'; +import 'package:openstrap_analytics/src/onehz/sleep/cardio_stager.dart' + show websterRescoreCardio; /// Fixed absolute epoch second so every fixture is deterministic. const int _t0 = 1700000000; @@ -285,13 +287,25 @@ void main() { reason: 'four 4-min bouts survive; pre-fix: 0 (full cascade)'); }); - test('stager: only the bout with REAL flanking sleep is bridged', () { - // Same shape, sized to the classic Webster table this file uses - // (≥15 min context bridges ≤5 min of wake). - final sm = fragmented(leadMin: 15, wakeMin: 4, reps: 5); - websterRescoreAutonomic(sm, 30); - expect(wakeInBody(sm), 4 * 8, - reason: 'four 4-min bouts survive; pre-fix: 0 (full cascade)'); + test('a 5-min awakening flanked by 15 min of sleep stays wake', () { + // The bridging table tops out at 4 min. A second copy that bridged up to + // 5 min used to sit in stager.dart behind a public seam; this pins the + // one live table so the wider bound cannot quietly come back. + final sm = [ + for (var k = 0; k < 30; k++) SleepStage.nrem, + for (var k = 0; k < 10; k++) SleepStage.wake, + for (var k = 0; k < 30; k++) SleepStage.nrem, + ]; + websterRescoreCardio(sm, 30); + expect(wakeInBody(sm), 10); + + final short = [ + for (var k = 0; k < 30; k++) SleepStage.nrem, + for (var k = 0; k < 8; k++) SleepStage.wake, + for (var k = 0; k < 30; k++) SleepStage.nrem, + ]; + websterRescoreCardio(short, 30); + expect(wakeInBody(short), 0, reason: '4 min is still an arousal'); }); test('a genuinely fragmented night keeps its WASO end-to-end', () { From 114ef5b1e8dd65993c5ac1878f3843132440aa31 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 18 Oct 2026 15:10:43 +0530 Subject: [PATCH 13/29] naps: stop requiring an exact 1 s step between samples the nap run builder treated any step other than exactly one second as a recording hole, even though the immobility mask it reads accepts cadences up to the 5 s van hees epoch. a 2 s or 5 s stream could never produce a nap, and at 1 hz a single skipped second cut the run, so the day came back as a judged 'no qualifying nap' instead of finding the nap. a step inside one 5 s epoch now counts as continuous, and the accel/hr coverage gates credit each sample with the stream's cadence instead of one second, so a fully measured 5 s stream is no longer rejected as 20% covered. (cherry picked from commit bcd452f7143514d65ccd14e6a8f0761c280f8ec0) --- lib/src/onehz/sleep/nap.dart | 29 ++++++++++++-- test/onehz/nap_test.dart | 73 ++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/lib/src/onehz/sleep/nap.dart b/lib/src/onehz/sleep/nap.dart index ca0ebfc..7e7d3d6 100644 --- a/lib/src/onehz/sleep/nap.dart +++ b/lib/src/onehz/sleep/nap.dart @@ -254,8 +254,28 @@ Metric> detectNaps( // can be made better here — the record simply does not say what happened // before it — so it is REPORTED instead, via [NapWindow.startsAtRecordEdge] // below. - bool stillAt(int k) => - mask.deltaDeg[k] < thr && (k == 0 || absAt(k) - absAt(k - 1) == 1); + // + // "Discontinuity" is judged against the published van Hees epoch, not a + // hard-coded 1 s step. The mask accepts any measured cadence up to + // [vanHeesMaxCadenceSec] and scales its windows to it, so a 2 s or 5 s stream + // failed an `== 1` test at every second and could never produce a nap, and a + // 1 Hz stream lost every run that a single skipped second interrupted — both + // then published as a judged "no nap". A step inside one published epoch is + // the resolution the rule is defined at; anything longer is a real hole. + bool continuousAt(int k) { + final stepMs = series[k].tsMs - series[k - 1].tsMs; + return stepMs > 0 && stepMs <= vanHeesMaxCadenceSec * 1000; + } + + bool stillAt(int k) => mask.deltaDeg[k] < thr && (k == 0 || continuousAt(k)); + + // Wall-clock seconds one sample stands for in the coverage gates below. + // Counting samples as seconds put a fully-measured 5 s stream at 20% + // coverage and rejected every bout as unmeasured. Floored so a sample is + // never credited with more time than the stream actually spaces them by, and + // exactly 1 at 1 Hz. + final cadenceFloor = (mask.cadenceSec ?? 1).floor(); + final secPerSample = cadenceFloor < 1 ? 1 : cadenceFloor; final runs = >[]; var i = 0; @@ -427,7 +447,7 @@ Metric> detectNaps( // counts against it exactly as an invalid sample does). var accelSec = 0; for (var k = start; k < end; k++) { - if (series[k].valid) accelSec++; + if (series[k].valid) accelSec += secPerSample; } if (accelSec / tib < minNapAccelCoverage) { unobserved++; @@ -445,7 +465,8 @@ Metric> detectNaps( for (var k = start; k < end; k++) { if (hr[k] > 0) boutHr.add(hr[k]); } - final coverage = boutHr.length / tib; + final hrSec = boutHr.length * secPerSample; + final coverage = hrSec >= tib ? 1.0 : hrSec / tib; if (coverage < minNapHrCoverage) { unverifiable++; continue; diff --git a/test/onehz/nap_test.dart b/test/onehz/nap_test.dart index 11e02ca..87ead23 100644 --- a/test/onehz/nap_test.dart +++ b/test/onehz/nap_test.dart @@ -692,4 +692,77 @@ void main() { expect(m.value!.single.tibSec, greaterThan(40 * 60)); }); }); + + group('detectNaps — cadence other than an unbroken 1 Hz', () { + // The immobility mask measures the stream's cadence and accepts anything up + // to the published 5 s van Hees epoch. The nap run builder used to demand a + // step of exactly 1 s between samples, so every one of these days came back + // as a judged "no qualifying nap" despite a clear 40-minute still block + // with an HR dip. + ({List accel, List hr}) day(int stepSec, + {int dropEvery = 0, int holeAtMin = -1, int holeMin = 0}) { + final accel = []; + final hr = []; + var t = 0; + var k = 0; + void emit(double x, double z, double bpm) { + k++; + if (holeAtMin >= 0 && t == holeAtMin * 60) t += holeMin * 60; + if (dropEvery > 0 && k % dropEvery == 0) { + t += stepSec; + return; + } + accel.add(AccelSample(t * 1000.0, x, 0, z)); + hr.add(bpm); + t += stepSec; + } + + void active(int min) { + final end = t + min * 60; + while (t < end) { + final rad = (45 + 35 * math.sin(t * 0.4)) * math.pi / 180; + emit(math.cos(rad), math.sin(rad), 82); + } + } + + void still(int min) { + final end = t + min * 60; + while (t < end) { + emit(0, 1, 54); + } + } + + active(120); + still(40); + active(120); + return (accel: accel, hr: hr); + } + + for (final step in [2, 5]) { + test('a 40-minute nap at a $step s cadence is detected', () { + final d = day(step); + final m = detectNaps(d.accel, d.hr); + expect(m.present, isTrue); + final nap = m.value!.single; + expect(nap.tibSec, closeTo(40 * 60, 6 * 60)); + }); + } + + test('a skipped second at 1 Hz does not end the nap', () { + final d = day(1, dropEvery: 120); + final m = detectNaps(d.accel, d.hr); + expect(m.value!.single.tibSec, closeTo(40 * 60, 6 * 60)); + }); + + test('a real recording hole inside the still block still breaks it', () { + // 120 min active, then 20 min still, a 30-minute hole, 20 more still: + // neither half reaches the nap minimum on its own once the unobserved + // half hour is not read as stillness. + final d = day(2, holeAtMin: 140, holeMin: 30); + final m = detectNaps(d.accel, d.hr); + for (final nap in m.value ?? const []) { + expect(nap.tibSec, lessThan(30 * 60)); + } + }); + }); } From 24adbafbca5c7912be16a38e031fc794c89eb36b Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 18 Oct 2026 15:09:58 +0530 Subject: [PATCH 14/29] naps: abstain on a day with no decoded accel outside the main sleep a day with hr on every second but gravity decoded on none (hr-only thin records, a long decode gap) never formed a still bout, so the per-bout accel coverage gate never ran and the day came back as an empty list, which callers store as a measured nap-free day. I now abstain when accel coverage outside the main sleep is below the same 50% floor the per-bout gate uses. (cherry picked from commit 4ca8b0880dac2d05da9ce816f9d73eba5d0303c2) --- lib/src/onehz/sleep/nap.dart | 26 ++++++++++++++++++++++++++ test/onehz/nap_test.dart | 28 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/lib/src/onehz/sleep/nap.dart b/lib/src/onehz/sleep/nap.dart index 7e7d3d6..280a210 100644 --- a/lib/src/onehz/sleep/nap.dart +++ b/lib/src/onehz/sleep/nap.dart @@ -220,6 +220,32 @@ Metric> detectNaps( } final series = accel.length == n ? accel : accel.sublist(0, n); + + // A day the band barely measured gravity on cannot be judged nap-free. + // Unmeasured seconds fail the still test (below), so with no decoded accel + // no bout ever forms, the per-bout coverage gate is never reached, and the + // day would fall through to an empty list that every caller reads as + // "judged, none" — e.g. an HR-only thin-record day or a long decode gap. + // Measured over the seconds outside the main sleep, since that is the only + // stretch a nap could have come from. + var outside = 0, outsideAccel = 0; + for (var k = 0; k < n; k++) { + if (mainSleep != null && k >= mainSleep.start && k < mainSleep.end) { + continue; + } + outside++; + if (series[k].valid) outsideAccel++; + } + if (outside > 0 && outsideAccel / outside < minNapAccelCoverage) { + return Metric>.absent( + tier: Tier.estimate, + inputs_used: inputs, + note: 'accel decoded on $outsideAccel of $outside s outside the main ' + 'sleep (need ${(minNapAccelCoverage * 100).round()}%) — stillness ' + 'could not be observed, so naps cannot be judged', + ); + } + final mask = immobilityMask(series); final win = mask.sustainedSec; final thr = mask.thresholdDeg; diff --git a/test/onehz/nap_test.dart b/test/onehz/nap_test.dart index 87ead23..0610255 100644 --- a/test/onehz/nap_test.dart +++ b/test/onehz/nap_test.dart @@ -691,6 +691,34 @@ void main() { final m = detectNaps(d.accel, d.hr); expect(m.value!.single.tibSec, greaterThan(40 * 60)); }); + + test('a day with no decoded accel is NOT judged nap-free', () { + // HR on every second, including a dip that would corroborate a nap, but + // gravity decoded on none — an HR-only thin-record day. No still bout can + // ever form, so the per-bout coverage gate never runs. Pre-fix this came + // back present with an empty list ("judged, none") at confidence 0.3. + final d = _Day() + ..unmeasuredAccel(8 * 60, bpm: 74) + ..unmeasuredAccel(45, bpm: 52) + ..unmeasuredAccel(7 * 60, bpm: 74); + + final m = detectNaps(d.accel, d.hr); + expect(m.present, isFalse, + reason: 'nothing about stillness was observable on this day'); + }); + + test('accel coverage is judged outside the main sleep only', () { + // The awake day is fully measured; the unmeasured stretch is the night, + // which naps are never drawn from. The day stays judged. + final d = _Day() + ..unmeasuredAccel(8 * 60, bpm: 55) + ..active(6 * 60); + + final m = detectNaps(d.accel, d.hr, + mainSleep: const SleepWindowSpan(0, 8 * 3600)); + expect(m.present, isTrue); + expect(m.value, isEmpty); + }); }); group('detectNaps — cadence other than an unbroken 1 Hz', () { From 8b94214a58fd070125ecd945220b284fcdfb7cca Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 18 Oct 2026 15:13:40 +0530 Subject: [PATCH 15/29] naps: only abstain on low accel coverage when nothing was found the day-level coverage check ran before detection, so on a day where most seconds outside the main sleep had no decoded gravity it threw away every nap, including one measured on every second and corroborated by an hr dip. that happens on mixed days where measured records sit next to long hr-only stretches. the check now runs only on the empty-result path, next to the missing-baseline abstain, so a nap that passed its own per-bout coverage gate is kept. (cherry picked from commit 03f6b27ea07a8b38b7e008cac24246c26ad9f38a) --- lib/src/onehz/sleep/nap.dart | 53 +++++++++++++++++++----------------- test/onehz/nap_test.dart | 15 ++++++++++ 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/lib/src/onehz/sleep/nap.dart b/lib/src/onehz/sleep/nap.dart index 280a210..bb06e4f 100644 --- a/lib/src/onehz/sleep/nap.dart +++ b/lib/src/onehz/sleep/nap.dart @@ -221,31 +221,6 @@ Metric> detectNaps( final series = accel.length == n ? accel : accel.sublist(0, n); - // A day the band barely measured gravity on cannot be judged nap-free. - // Unmeasured seconds fail the still test (below), so with no decoded accel - // no bout ever forms, the per-bout coverage gate is never reached, and the - // day would fall through to an empty list that every caller reads as - // "judged, none" — e.g. an HR-only thin-record day or a long decode gap. - // Measured over the seconds outside the main sleep, since that is the only - // stretch a nap could have come from. - var outside = 0, outsideAccel = 0; - for (var k = 0; k < n; k++) { - if (mainSleep != null && k >= mainSleep.start && k < mainSleep.end) { - continue; - } - outside++; - if (series[k].valid) outsideAccel++; - } - if (outside > 0 && outsideAccel / outside < minNapAccelCoverage) { - return Metric>.absent( - tier: Tier.estimate, - inputs_used: inputs, - note: 'accel decoded on $outsideAccel of $outside s outside the main ' - 'sleep (need ${(minNapAccelCoverage * 100).round()}%) — stillness ' - 'could not be observed, so naps cannot be judged', - ); - } - final mask = immobilityMask(series); final win = mask.sustainedSec; final thr = mask.thresholdDeg; @@ -581,6 +556,34 @@ Metric> detectNaps( )); } + // An empty list is only a verdict if stillness was observable. Unmeasured + // seconds fail the still test, so on a day the band barely decoded gravity + // on (an HR-only thin-record day, a long decode gap) no bout ever forms and + // the per-bout coverage gate above is never reached. Checked only when + // nothing was found: a nap that passed its own per-bout coverage gate stands + // even when the rest of the day went unmeasured. Coverage is taken over the + // seconds outside the main sleep, since that is the only stretch a nap could + // have come from. + if (naps.isEmpty) { + var outside = 0, outsideAccel = 0; + for (var k = 0; k < n; k++) { + if (mainSleep != null && k >= mainSleep.start && k < mainSleep.end) { + continue; + } + outside++; + if (series[k].valid) outsideAccel++; + } + if (outside > 0 && outsideAccel / outside < minNapAccelCoverage) { + return Metric>.absent( + tier: Tier.estimate, + inputs_used: inputs, + note: 'accel decoded on $outsideAccel of $outside s outside the main ' + 'sleep (need ${(minNapAccelCoverage * 100).round()}%) — stillness ' + 'could not be observed, so naps cannot be judged', + ); + } + } + // 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 diff --git a/test/onehz/nap_test.dart b/test/onehz/nap_test.dart index 0610255..0f14d1f 100644 --- a/test/onehz/nap_test.dart +++ b/test/onehz/nap_test.dart @@ -707,6 +707,21 @@ void main() { reason: 'nothing about stillness was observable on this day'); }); + test('a measured nap survives a day that is mostly unmeasured', () { + // A fully measured, HR-corroborated nap, then six hours of HR-only + // records with no gravity. The day as a whole is under the coverage + // floor, but the nap passed its own per-bout gate and must be kept. + final d = _Day() + ..active(120) + ..still(45) + ..active(120) + ..unmeasuredAccel(6 * 60, bpm: 74); + + final m = detectNaps(d.accel, d.hr); + expect(m.present, isTrue); + expect(m.value, hasLength(1)); + }); + test('accel coverage is judged outside the main sleep only', () { // The awake day is fully measured; the unmeasured stretch is the night, // which naps are never drawn from. The day stays judged. From 215e38dc06fcd3ae66ed1bb6d442ea0fa33e2065 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 22 Oct 2026 16:11:30 +0530 Subject: [PATCH 16/29] sleep: stop staging an awakening between two accel holes as sleep each run between accel gaps was staged on its own, so every night baseline came from that run alone. a short run holding only an awakening set its arousal cut above its own awake hr, nothing reached it, and the whole block fell through to light/deep sleep - 25 minutes of wake counted into tst for anyone without a mature sleep profile yet. the runs are now laid end to end and staged in one pass with their boundaries passed through, so the baselines come from the whole night while the local gravity reference, the previous-epoch big-move test, the mode filter, webster continuity, consolidation and the short-deep merge stay inside a run. a run too hr-sparse to stage still stays unobserved, as it did when it was staged alone. (cherry picked from commit b962a8827b5b7337b816e767cde9339ed75e081a) --- lib/src/onehz/sleep/advanced_stager.dart | 98 ++++++++++++++++++------ lib/src/onehz/sleep/cardio_stager.dart | 73 ++++++++++++++++-- test/onehz/sleep_honesty_test.dart | 43 +++++++++++ 3 files changed, 183 insertions(+), 31 deletions(-) diff --git a/lib/src/onehz/sleep/advanced_stager.dart b/lib/src/onehz/sleep/advanced_stager.dart index 1b0a07d..7d39f87 100644 --- a/lib/src/onehz/sleep/advanced_stager.dart +++ b/lib/src/onehz/sleep/advanced_stager.dart @@ -39,7 +39,7 @@ import 'dart:math' as math; import '../types.dart'; import '../util.dart'; import '../clinical/hrv_time.dart'; -import 'cardio_stager.dart' show cardioStager; +import 'cardio_stager.dart' show cardioStager, minHrCoverage; import 'accounting.dart' show SleepStage; import 'band_offset.dart'; @@ -1441,9 +1441,14 @@ class AdvancedSleepStager { /// way. /// /// HONESTY: seconds with no usable accelerometer are NOT staged. The window - /// is split at those gaps and each contiguous usable RUN is staged on its own - /// (so a dropout cannot pollute the neighbouring run's night baselines - /// either); the gap seconds, runs too short to stage, and a window where + /// is split at those gaps into contiguous usable RUNS, which are laid end to + /// end (gap seconds dropped, never filled) and staged in ONE `cardioStager` + /// call with the run boundaries passed as `runStarts`: the night baselines + /// come from every run, while continuity rules never reach across a gap. + /// Staging each run on its own judged a short run against itself, so a run + /// holding only an awakening set its arousal cut above its own awake HR and + /// came out light/deep sleep end to end. The gap seconds, runs too short or + /// too HR-sparse to stage, and a window where /// `cardioStager` itself abstains all come back as 'unobserved' — the "stay /// unstaged" contract [stageWindow] documents. They must NEVER come back as /// 'light', which is what a zero-data window used to report for its entire @@ -1498,6 +1503,10 @@ class AdvancedSleepStager { // Everything not staged below stays 'unobserved' — no stage, no wake. final perSec = List.filled(span, 'unobserved'); + // Stageable runs. All but the last are trimmed to whole epochs so every run + // boundary sits on the epoch grid; cardioStager scores no sub-epoch tail + // anyway, and the tail takes the run's last label below. + final runs = <({int from, int to, int nEp})>[]; var i = 0; while (i < span) { if (!usable[i]) { @@ -1509,33 +1518,74 @@ class AdvancedSleepStager { j++; } if ((j - i) >= minStageableSec) { - final result = cardioStager( - hr1hz.sublist(i, j), - accel.sublist(i, j), - rrMs: rrMs, - rrTsMs: rrTsMs, - ); - final nEpoch = result.base.stages.length; - for (var e = 0; e < nEpoch; e++) { - final label = switch (result.base.stages[e]) { - SleepStage.wake => 'wake', - SleepStage.rem => 'rem', - SleepStage.nrem => (e < result.deepFlag.length && result.deepFlag[e]) - ? 'deep' - : 'light', - }; + final nEp = (j - i) ~/ epSec; + // cardioStager's HR-coverage abstention, applied per run as it was + // when each run was staged alone: pooled with well-covered runs, a run + // with no HR would pass the window-wide gate and fall through to NREM. + var withHr = 0; + for (var e = 0; e < nEp; e++) { final lo = i + e * epSec; - // The last epoch absorbs the run's sub-epoch remainder, exactly as - // [_buildSegments] extends the final segment to the window end. - final hi = e == nEpoch - 1 ? j : math.min(j, lo + epSec); - for (var k = lo; k < hi; k++) { - perSec[k] = label; + for (var k = lo; k < lo + epSec; k++) { + if (hr1hz[k] > 0) { + withHr++; + break; + } } } + if (withHr >= minHrCoverage * nEp) { + runs.add((from: i, to: j, nEp: nEp)); + } } i = j; } + if (runs.isNotEmpty) { + final hrCat = []; + final accelCat = []; + final runStarts = []; + for (var ri = 0; ri < runs.length; ri++) { + final r = runs[ri]; + runStarts.add(hrCat.length); + // The last run keeps its tail (no boundary follows it), so a window + // with a single run is staged exactly as before. + final stop = ri == runs.length - 1 ? r.to : r.from + r.nEp * epSec; + hrCat.addAll(hr1hz.sublist(r.from, stop)); + accelCat.addAll(accel.sublist(r.from, stop)); + } + final result = cardioStager( + hrCat, + accelCat, + rrMs: rrMs, + rrTsMs: rrTsMs, + runStarts: runStarts, + ); + final stages = result.base.stages; + // Empty = cardioStager abstained: everything stays 'unobserved'. + if (stages.isNotEmpty) { + var g = 0; // epoch index into the concatenated result + for (final r in runs) { + for (var e = 0; e < r.nEp; e++, g++) { + if (g >= stages.length) break; + final label = switch (stages[g]) { + SleepStage.wake => 'wake', + SleepStage.rem => 'rem', + SleepStage.nrem => + (g < result.deepFlag.length && result.deepFlag[g]) + ? 'deep' + : 'light', + }; + final lo = r.from + e * epSec; + // The last epoch absorbs the run's sub-epoch remainder, exactly as + // [_buildSegments] extends the final segment to the window end. + final hi = e == r.nEp - 1 ? r.to : lo + epSec; + for (var k = lo; k < hi; k++) { + perSec[k] = label; + } + } + } + } + } + // Coalesce the per-second labels into contiguous [StageSegment]s tiling // [start, end) — same segment semantics [_buildSegments] produces. final segments = []; diff --git a/lib/src/onehz/sleep/cardio_stager.dart b/lib/src/onehz/sleep/cardio_stager.dart index 881e273..e13bf6c 100644 --- a/lib/src/onehz/sleep/cardio_stager.dart +++ b/lib/src/onehz/sleep/cardio_stager.dart @@ -374,11 +374,16 @@ void resetCardioObservations() => _cardioObservations.clear(); /// clock as `accel[i].tsMs`, ASCENDING in [rrTsMs] (the per-epoch window /// gather lower-bounds into it). Sparse/empty is fine — REM/deep just lean /// more on HR and confidence drops. +/// [runStarts] sample indices (ascending, each a multiple of one epoch's +/// worth of samples) where a new contiguous recording run begins, when the +/// input is several runs separated by unrecorded gaps and laid end to end. +/// See [classifyCardioEpochs]' `segmentStarts` for what that changes. CardioStagerResult cardioStager( List hr1hz, List accel, { List rrMs = const [], List rrTsMs = const [], + List runStarts = const [], int epochSec = _epochSec, SleepUserProfile? userProfile, double remScoreCut = _remScoreCut, @@ -431,6 +436,13 @@ CardioStagerResult cardioStager( return _abstain(epochSec); final nEpoch = n ~/ perEpoch; if (nEpoch < 3) return _abstain(epochSec); + assert(runStarts.every((i) => i % perEpoch == 0), + 'runStarts must fall on epoch boundaries'); + final segStarts = [ + for (final i in runStarts) + if (i ~/ perEpoch > 0 && i ~/ perEpoch < nEpoch) i ~/ perEpoch + ]; + final segOf = _segmentOfEpoch(segStarts, nEpoch); // ── per-second ENMO (motion) against a LOCALLY-ADAPTIVE 1 g reference ────── // A single whole-night gravity-magnitude reference (the old approach) is @@ -462,8 +474,13 @@ CardioStagerResult cardioStager( final gRefByEpoch = List.filled(nEpoch, 1.0); for (var e = 0; e < nEpoch; e++) { final es = e * perEpoch; - final lo = math.max(0, es - gRefHalf); - final hi = math.min(n, es + perEpoch + gRefHalf); + // Clipped to this epoch's own run: across a gap the wearer may have + // changed posture, and a reference straddling two postures reads the + // offset between them as motion. + final segLo = segOf.lo[e] * perEpoch; + final segHi = segOf.hi[e] == nEpoch ? n : segOf.hi[e] * perEpoch; + final lo = math.max(segLo, es - gRefHalf); + final hi = math.min(segHi, es + perEpoch + gRefHalf); gRefByEpoch[e] = median(mag.sublist(lo, hi)) ?? 1.0; } @@ -519,12 +536,32 @@ CardioStagerResult cardioStager( sdnn: sdnn, ), epochSec: epochSec, + segmentStarts: segStarts, userProfile: profile, remScoreCut: remScoreCut, deepScoreCut: deepScoreCut, ); } +/// For each epoch, the [lo, hi) epoch range of the run it belongs to. +({List lo, List hi}) _segmentOfEpoch( + List segmentStarts, int nEpoch) { + final bounds = {0, nEpoch}; + for (final s in segmentStarts) { + if (s > 0 && s < nEpoch) bounds.add(s); + } + final sorted = bounds.toList()..sort(); + final lo = List.filled(nEpoch, 0); + final hi = List.filled(nEpoch, nEpoch); + for (var b = 0; b + 1 < sorted.length; b++) { + for (var e = sorted[b]; e < sorted[b + 1]; e++) { + lo[e] = sorted[b]; + hi[e] = sorted[b + 1]; + } + } + return (lo: lo, hi: hi); +} + /// Per-epoch feature vectors — the ONLY thing the staging DECISION layer sees. /// /// Splitting these out from [cardioStager] draws the line between "turn 1 Hz @@ -591,9 +628,20 @@ class CardioEpochFeatures { /// [remScoreCut] / [deepScoreCut] default to the shipped constants and exist so /// a calibration harness can sweep them on a DEVELOPMENT split and report the /// held-out result. Production callers should not pass them. +/// +/// [segmentStarts] epoch indices where a new contiguous recording run begins. +/// The night baselines (motion median/MAD, the local HR median and arousal cut, +/// the robust-z scales) are taken across ALL runs, because a run is not a +/// night: a short run that holds only an awakening would otherwise be judged +/// against its own awake HR, never reach its own arousal cut, and fall through +/// to NREM end to end. Everything that assumes two epochs are adjacent in time +/// (the previous-epoch big-move test, the mode filter, Webster continuity, +/// consolidation, the short-deep merge) stays inside one run, so nothing is +/// bridged across time nobody recorded. CardioStagerResult classifyCardioEpochs( CardioEpochFeatures f, { int epochSec = _epochSec, + List segmentStarts = const [], SleepUserProfile? userProfile, double remScoreCut = _remScoreCut, double deepScoreCut = _deepScoreCut, @@ -624,6 +672,7 @@ CardioStagerResult classifyCardioEpochs( final lfhf = f.lfhf; final rk = f.rk; final sdnn = f.sdnn; + final segOf = _segmentOfEpoch(segmentStarts, nEpoch); // ── night baselines (from the LOW-MOTION epochs — the actual sleep) ──────── // motMed/motMad stay WHOLE-NIGHT scalars: `motion` is now computed against a @@ -811,7 +860,7 @@ CardioStagerResult classifyCardioEpochs( // within half an epoch 'unobserved' and keeps it out of TST, WASO and the // efficiency denominator. final hrUp = !hr[e].isNaN && hr[e] >= hrArousal; - final bigPrev = e > 0 && bigMove(e - 1); + final bigPrev = e > segOf.lo[e] && bigMove(e - 1); final bigMoveWake = bigMove(e) && ((!hr[e].isNaN && hr[e] >= hrMed) || bigPrev); if (hrUp || bigMoveWake) { @@ -914,9 +963,14 @@ CardioStagerResult classifyCardioEpochs( // A 3-epoch categorical median (mode) filter first, to drop isolated // single-epoch label flips (e.g. a lone REM/deep spike inside a stable run) // before the continuity/consolidation stages act on min bouts. - _modeFilterStages(stages); - _websterRescore(stages, epochSec); - final sm = consolidateSleepStages(stages, epochSec); + // Per run — see [segmentStarts]. With a single run this is the whole night. + final sm = []; + for (var lo = 0; lo < nEpoch; lo = segOf.hi[lo]) { + final part = stages.sublist(lo, segOf.hi[lo]); + _modeFilterStages(part); + _websterRescore(part, epochSec); + sm.addAll(consolidateSleepStages(part, epochSec)); + } // Keep deepFlag in lockstep with the FINAL consolidated stage: the mode filter, // Webster rescore, and consolidation can move a deep-flagged NREM epoch to REM // or Wake. Clear the flag there so no epoch is ever reported deep while its @@ -925,7 +979,12 @@ CardioStagerResult classifyCardioEpochs( for (var e = 0; e < deepFlag.length && e < sm.length; e++) { if (deepFlag[e] && sm[e] != SleepStage.nrem) deepFlag[e] = false; } - _mergeShortDeep(deepFlag, sm, epochSec); + for (var lo = 0; lo < nEpoch; lo = segOf.hi[lo]) { + final hi = segOf.hi[lo]; + final part = deepFlag.sublist(lo, hi); + _mergeShortDeep(part, sm.sublist(lo, hi), epochSec); + deepFlag.setRange(lo, hi, part); + } // ── record this night's baselines for the rolling per-user profile (P2) ───── // Only when the edge armed recording AND the staged span is a real sleep diff --git a/test/onehz/sleep_honesty_test.dart b/test/onehz/sleep_honesty_test.dart index 04a100f..3683d76 100644 --- a/test/onehz/sleep_honesty_test.dart +++ b/test/onehz/sleep_honesty_test.dart @@ -224,6 +224,49 @@ void main() { forcedWindow: (onsetSec: _t0, offsetSec: _t0 + 4 * 3600)); expect(s.tstSec!, greaterThan((0.9 * 4 * 3600).round())); }); + + test( + 'an awakening walled in by two accel holes is judged against the ' + 'night, not against itself (was: 25 min awake staged light/deep)', () { + // 8 h at ~55 bpm and still, with a 25 min awakening at ~78 bpm and more + // movement starting at 3 h, and a 2 min accel hole on each side of it. + // Each hole is past the 60 s carry-forward bound, so the awakening is a + // run of its own. Staged on its own, its HR baseline was its own awake + // HR, no epoch reached its own arousal cut, and the whole block fell + // through to NREM: {light: 1230, deep: 270} pre-fix, against + // {wake: 1500} for the same input without the holes. + final saved = cardioUserProfile; + cardioUserProfile = null; // a new user: no rolling profile to lean on + addTearDown(() => cardioUserProfile = saved); + final r = math.Random(7); + const span = 8 * 3600; + const wS = _t0 + 3 * 3600, wE = wS + 1500; + final grav = []; + final hr = []; + final rr = []; + for (var k = 0; k < span; k++) { + final ts = _t0 + k; + final awake = ts >= wS && ts < wE; + final hole = (ts >= wS - 120 && ts < wS) || (ts >= wE && ts < wE + 120); + final noise = awake ? 0.02 : 0.002; + double j() => noise * (r.nextDouble() - 0.5); + if (!hole) grav.add(GravTs(ts, 0.1 + j(), 0.2 + j(), 0.97 + j())); + final bpm = (awake ? 78.0 : 55.0) + 2 * (r.nextDouble() - 0.5); + if (hole) continue; + hr.add(HrTs(ts, bpm)); + rr.add(RrTs(ts, 60000 / bpm + 20 * (r.nextDouble() - 0.5))); + } + final s = + AdvancedSleepStager.stageWindow(_t0, _t0 + span, grav, hr, rr: rr); + final inBlock = {}; + for (final seg in s.stages) { + final a = math.max(seg.start, wS), b = math.min(seg.end, wE); + if (b > a) inBlock[seg.stage] = (inBlock[seg.stage] ?? 0) + (b - a); + } + expect(inBlock['wake'] ?? 0, greaterThanOrEqualTo(1200), + reason: 'pre-fix: 0 s of the 1500 s awakening staged wake'); + expect(inBlock['deep'] ?? 0, 0, reason: 'pre-fix: 270 s deep'); + }); }); // ═══════════════════════════════════════════════════════════════════════════ From 8d0651647fdaae20eb6a088ecf5cd549df63a49f Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:52:52 +0530 Subject: [PATCH 17/29] sleep: keep both halves of a night split by a 60-90 min awakening detectSleep chains a night across an awakening of up to 90 min, but segmentSleep only grouped sessions less than 60 min apart. a 60-90 min awakening therefore left two groups and only one was published: the other half vanished from tst, stages and the hrv/rhr windows, and a short pre-dawn tail counted nowhere at all. the grouping now uses the same continuation gap as detectSleep, so the awakening is scored inside the night as wake. (cherry picked from commit b8fc81d20e6cd1670e5f5e8d91f42863f5a546fb) --- lib/src/onehz/sleep/segment.dart | 10 +++- test/onehz/sleep_honesty_test.dart | 77 +++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/lib/src/onehz/sleep/segment.dart b/lib/src/onehz/sleep/segment.dart index f72cf46..9bc07b7 100644 --- a/lib/src/onehz/sleep/segment.dart +++ b/lib/src/onehz/sleep/segment.dart @@ -782,7 +782,13 @@ class _SleepGroup { List<_SleepGroup> _bridgeAdjacentSessions(List sessions) { if (sessions.isEmpty) return const []; final sorted = [...sessions]..sort((a, b) => a.start.compareTo(b.start)); - const bridgeGapSec = 60 * 60; + // Must be the same rule detectSleep chains a night on. With a narrower bridge + // (this was a flat `< 60 min`) a 60-90 min awakening left detectSleep + // returning both halves as one night while this split them into two groups; + // the selector then published one half and silently dropped the other from + // TST, stages and the HRV/RHR windows (or let it surface as a "nap"). The + // gap's seconds are scored below as wake where observed, unobserved where not. + const bridgeGapSec = AdvancedSleepStager.nightContinuationGapMin * 60; final out = <_SleepGroup>[]; for (final session in sorted) { final asleepMin = AdvancedSleepStager.hypnogramMetrics(session).tstS / 60.0; @@ -799,7 +805,7 @@ List<_SleepGroup> _bridgeAdjacentSessions(List sessions) { } final last = out.removeLast(); final gap = session.start - last.end; - if (gap >= 0 && gap < bridgeGapSec) { + if (gap >= 0 && gap <= bridgeGapSec) { out.add( _SleepGroup( sessions: [...last.sessions, session], diff --git a/test/onehz/sleep_honesty_test.dart b/test/onehz/sleep_honesty_test.dart index 3683d76..0ecce42 100644 --- a/test/onehz/sleep_honesty_test.dart +++ b/test/onehz/sleep_honesty_test.dart @@ -423,7 +423,7 @@ void main() { active(2 * 3600); // 16:00-18:00 sleep(6 * 3600); // 18:00-00:00 → night A - active(90 * 60, bpm: 90); // 00:00-01:30 (>60 min ⇒ A and B never bridge) + active(90 * 60, bpm: 90); // 00:00-01:30 (detected gap ~95 min > 90 ⇒ never bridged) sleep(2 * 3600); // 01:30-03:30 ┐ active(45 * 60, bpm: 90); // │ night B — three fragments bridged sleep(2 * 3600); // 04:15-06:15 │ across two <60 min gaps @@ -444,6 +444,81 @@ void main() { reason: 'pre-fix the early midpoint made night A (18:02) win'); expect(s.inBedSec!, greaterThan(7 * 3600)); }); + + // detectSleep chains a night across any awakening up to + // nightContinuationGapMin (90 min). segmentSleep used to bridge only gaps + // under 60 min, so a 60-90 min awakening split one night into two groups + // and only one half was published. + void Function(int, {double bpm}) feeder( + List accel, List hr, int start, + {required bool still}) { + return (int secs, {double bpm = 0}) { + final base = accel.length; + for (var k = 0; k < secs; k++) { + final t = (start + base + k) * 1000.0; + if (still) { + accel.add(AccelSample(t, 0.02, 0.02, 1.0)); + hr.add(bpm + 1.5 * math.sin(k / 1800.0)); + } else { + final ph = math.sin(k * 0.5); + accel.add(AccelSample(t, 0.3 * ph, 0.3, 0.9 * (1 - 0.2 * ph))); + hr.add(bpm + 2 * math.sin(k / 600.0)); + } + } + }; + } + + test('a 75-min awakening keeps both halves of the night', () { + final accel = []; + final hr = []; + final start = _midnight + 21 * 3600; // 21:00 + final active = feeder(accel, hr, start, still: false); + final sleep = feeder(accel, hr, start, still: true); + + active(2 * 3600, bpm: 76); // 21:00-23:00 + sleep(210 * 60, bpm: 50); // 23:00-02:30 + active(75 * 60, bpm: 90); // 02:30-03:45 up with the baby + sleep(225 * 60, bpm: 50); // 03:45-07:30 + active(2 * 3600, bpm: 76); // 07:30-09:30 + + final s = segmentSleep(accel, hr, + hrBaseline: List.filled(200, 76), tzOffsetSec: 0); + + expect(s.present, isTrue); + final onset = s.window!.onsetMs! ~/ 1000; + final offset = s.window!.offsetMs! ~/ 1000; + // Pre-fix: only the 03:45-07:30 half was published (~3.7 h in bed). + expect(onset, lessThan(_midnight + 86400 - 30 * 60), + reason: 'onset must be the 23:00 block, not 03:45'); + expect(offset, greaterThan(_midnight + 86400 + 7 * 3600)); + expect(s.inBedSec!, greaterThan(8 * 3600)); + // The awakening is inside the window and is counted as wake. + expect(s.wasoSec!, greaterThan(60 * 60)); + expect(s.tstSec!, greaterThan(5 * 3600)); + }); + + test('a short tail after a 70-min awakening is not lost', () { + final accel = []; + final hr = []; + final start = _midnight + 20 * 3600 + 30 * 60; // 20:30 + final active = feeder(accel, hr, start, still: false); + final sleep = feeder(accel, hr, start, still: true); + + active(2 * 3600, bpm: 76); // 20:30-22:30 + sleep(390 * 60, bpm: 50); // 22:30-05:00 + active(70 * 60, bpm: 90); // 05:00-06:10 + sleep(40 * 60, bpm: 50); // 06:10-06:50 night tail + active(2 * 3600, bpm: 76); // 06:50-08:50 + + final s = segmentSleep(accel, hr, + hrBaseline: List.filled(200, 76), tzOffsetSec: 0); + + expect(s.present, isTrue); + final offset = s.window!.offsetMs! ~/ 1000; + // Pre-fix the window ended ~05:00 and the tail counted nowhere (nap + // detection discards it as a night tail). + expect(offset, greaterThan(_midnight + 86400 + 6 * 3600 + 30 * 60)); + }); }); // ═══════════════════════════════════════════════════════════════════════════ From 76ebd3f4457018af048b9987e1aa2de1cce6ead0 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:53:17 +0530 Subject: [PATCH 18/29] sleep: defer a night still going when a day slice is cut at midnight an early bedtime was still asleep at the end of a calendar-day slice, so the detector closed the run at the last sample and segmentSleep published the first hours of tonight as a finished night for the day that was ending. the next day's slice then counted the same hours again inside the full night. segmentSleep now takes recordContinues. when the caller says data carries on past the slice, a sleep group ending within the still-classifier window of the last sample, and any group chained to it, is dropped before the main sleep is picked, the same deferral naps already get. (cherry picked from commit dc68b4dbd1c2b7a46905df9bfcf70fac6df43ff3) --- lib/src/onehz/sleep/segment.dart | 48 ++++++++++++- test/onehz/sleep_unfinished_night_test.dart | 75 +++++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 test/onehz/sleep_unfinished_night_test.dart diff --git a/lib/src/onehz/sleep/segment.dart b/lib/src/onehz/sleep/segment.dart index 9bc07b7..0820976 100644 --- a/lib/src/onehz/sleep/segment.dart +++ b/lib/src/onehz/sleep/segment.dart @@ -431,6 +431,14 @@ SleepSegmentation segmentSleep( /// creates, extends or stages a night. (AdvancedSleepStager.detectSleep takes /// the same signal as [ts,state] pairs; not forwarded here.) List? bandSleepState, + + /// True when the caller cut [accel]/[hr1hz] out of a longer record and data + /// carries on past the last sample here (e.g. a calendar-day slice ending at + /// local midnight). AUTO path only: a sleep still going at the cut has no + /// knowable end, so it is deferred to the slice that sees it finish instead + /// of being published as a night that ended at the cut. See + /// [kUnfinishedNightEdgeSec]. + bool recordContinues = false, }) { final n = math.min(accel.length, hr1hz.length); // A forced window (manual entry / user confirmation, Approach 1) is asserted @@ -514,8 +522,14 @@ SleepSegmentation segmentSleep( ); if (sessions.isEmpty) return SleepSegmentation.absent; + var groups = _bridgeAdjacentSessions(sessions); + if (recordContinues) { + final lastTs = grav.fold(grav.first.ts, (m, g) => math.max(m, g.ts)); + groups = _dropUnfinishedGroups(groups, lastTs); + if (groups.isEmpty) return SleepSegmentation.absent; + } chosen = _pickMainSleepGroup( - _bridgeAdjacentSessions(sessions), + groups, tzAt, habitualMidsleepSec: habitualMidsleepSec, ); @@ -829,6 +843,38 @@ List<_SleepGroup> _bridgeAdjacentSessions(List sessions) { return out; } +/// A sleep group ending this close to the last sample of a record that +/// continues may still be going. The still/active classifier works over a +/// [AdvancedSleepStager.stillWindowMin] window, so a wake inside the final +/// window cannot be told apart from sleep that runs on past the cut. +const int kUnfinishedNightEdgeSec = AdvancedSleepStager.stillWindowMin * 60; + +/// Drops every group that is still going at [lastTs], plus any group chained +/// to one within the detector's own continuation gap. Walking backward +/// matters: a short awakening splits tonight into two groups and only the +/// trailing one touches the cut, but the leading one is the same unfinished +/// night and would otherwise be published for the day that is ending and then +/// counted again inside the full night by the next slice. +List<_SleepGroup> _dropUnfinishedGroups(List<_SleepGroup> groups, int lastTs) { + const chainGapSec = AdvancedSleepStager.nightContinuationGapMin * 60; + final unfinished = List.filled(groups.length, false); + for (var g = groups.length - 1; g >= 0; g--) { + if (lastTs - groups[g].end <= kUnfinishedNightEdgeSec) { + unfinished[g] = true; + continue; + } + if (g + 1 < groups.length && + unfinished[g + 1] && + groups[g + 1].start - groups[g].end <= chainGapSec) { + unfinished[g] = true; + } + } + return [ + for (var g = 0; g < groups.length; g++) + if (!unfinished[g]) groups[g], + ]; +} + _SleepGroup? _pickMainSleepGroup( List<_SleepGroup> groups, int Function(int tsSec) tzAt, { diff --git a/test/onehz/sleep_unfinished_night_test.dart b/test/onehz/sleep_unfinished_night_test.dart new file mode 100644 index 0000000..7f1f5e6 --- /dev/null +++ b/test/onehz/sleep_unfinished_night_test.dart @@ -0,0 +1,75 @@ +// test/onehz/sleep_unfinished_night_test.dart +// +// A calendar-day slice ends at local midnight while the record carries on. An +// early bedtime is still asleep at that cut, so the detector closes the run at +// the last sample and the first hours of tonight looked like a finished night +// for the day that is ending — and were counted again by the next day's slice. +import 'dart:math' as math; +import 'package:test/test.dart'; +import 'package:openstrap_analytics/onehz.dart'; + +const int _t0 = 1700000000; +final int _midnight = _t0 - (_t0 % 86400); // day D 00:00 UTC + +/// Builds 1 Hz streams over [from, to) with the wearer asleep (still, HR 55) +/// inside any of [asleep] and moving (HR 85) otherwise. +({List accel, List hr}) _record( + int from, int to, List<(int, int)> asleep) { + final accel = [], hr = []; + final rnd = math.Random(11); + for (var t = from; t < to; t++) { + final still = asleep.any((s) => t >= s.$1 && t < s.$2); + accel.add(AccelSample(t * 1000.0, + still ? 0.01 * rnd.nextDouble() : 0.4 * math.cos(t / 2), 0.0, + still ? 1.0 : 1.0 + 0.3 * math.sin(t / 3))); + hr.add(still ? 55 : 85); + } + return (accel: accel, hr: hr); +} + +void main() { + // Band on the charger overnight, so day D's slice holds no sleep before + // tonight's first 3.5 h: bed at 20:30, cut at midnight. + final evening = _record(_midnight + 12 * 3600, _midnight + 86400, [ + (_midnight + 20 * 3600 + 30 * 60, _midnight + 86400), + ]); + + test('fixture reproduces the bug: the cut-off evening is published', () { + final s = segmentSleep(evening.accel, evening.hr, tzOffsetSec: 0); + expect(s.present, isTrue); + expect(s.window!.offsetMs! ~/ 1000, + greaterThan(_midnight + 86400 - 5 * 60)); // "woke" at the cut + }); + + test('record continues past the cut: the unfinished night is deferred', () { + final s = segmentSleep(evening.accel, evening.hr, + tzOffsetSec: 0, recordContinues: true); + expect(s.present, isFalse); + expect(s.tstSec, isNull); + expect(s.inBedSec, isNull); + }); + + test('a finished night earlier in the slice is still the main sleep', () { + final night = (_midnight - 3600, _midnight + 7 * 3600); // 23:00 → 07:00 + final r = _record(_midnight - 4 * 3600, _midnight + 86400, [ + night, + (_midnight + 20 * 3600 + 30 * 60, _midnight + 86400), + ]); + final s = segmentSleep(r.accel, r.hr, tzOffsetSec: 0, recordContinues: true); + expect(s.present, isTrue); + final off = s.window!.offsetMs! ~/ 1000; + expect(off, lessThan(_midnight + 8 * 3600)); + expect(off, greaterThan(_midnight + 6 * 3600)); + }); + + test('a night that ended well before the cut is not deferred', () { + // Asleep 23:00 → 07:00, awake and moving until the slice ends at 12:00. + final r = _record(_midnight - 4 * 3600, _midnight + 12 * 3600, [ + (_midnight - 3600, _midnight + 7 * 3600), + ]); + expect( + segmentSleep(r.accel, r.hr, tzOffsetSec: 0, recordContinues: true) + .toJson(), + segmentSleep(r.accel, r.hr, tzOffsetSec: 0).toJson()); + }); +} From 8da48e367826761a91edec3a263a897a33352c0f Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 15:10:00 +0530 Subject: [PATCH 19/29] sleep: keep the hr-led fallback from republishing a deferred night deferring tonight's still-going night returned the same absent value as an empty slice, and the edge reads any absent result as a miss and runs the hr-led fallback over the same slice. that picked the longest hr dip, which was the same cut-off evening ending at midnight, so the night was published anyway under auto_fallback and the double count stayed. segmentSleep now reports the deferral as its own result (unfinished, absence reason night_unfinished) instead of plain absent, and hrLedSleepWindow takes recordContinues too: a dip ending within the same edge window of the last sample, or chained to one, is never proposed. a finished dip earlier in the slice is still picked. one gap is left on purpose: a night that really ends within the last 15 minutes before midnight is deferred by that day and has already ended before the next day's slice starts owning it, so neither day claims it. (cherry picked from commit 1c4ac0f1370bcf293b898d9ec87ad84b8c43c9dd) --- lib/src/onehz/sleep/hr_fallback.dart | 47 ++++++++++++++--- lib/src/onehz/sleep/segment.dart | 27 ++++++++-- test/onehz/sleep_unfinished_night_test.dart | 56 +++++++++++++++++++++ 3 files changed, 120 insertions(+), 10 deletions(-) diff --git a/lib/src/onehz/sleep/hr_fallback.dart b/lib/src/onehz/sleep/hr_fallback.dart index f8ea5f0..6bd340f 100644 --- a/lib/src/onehz/sleep/hr_fallback.dart +++ b/lib/src/onehz/sleep/hr_fallback.dart @@ -16,6 +16,8 @@ // "is this right?" prompt — never as a confirmed auto detection. import '../util.dart'; +import 'advanced_stager.dart' show AdvancedSleepStager; +import 'segment.dart' show kUnfinishedNightEdgeSec; /// A proposed in-bed window (epoch seconds). Returned by [hrLedSleepWindow]. class HrLedWindow { @@ -39,6 +41,13 @@ class HrLedWindow { /// /// Returns the longest qualifying low-HR run (≥ [minDurationSec], brief /// arousals < [bridgeGapSec] bridged), or null when no convincing dip exists. +/// +/// [recordContinues]: the caller cut this slice out of a longer record (a +/// calendar day ending at local midnight). A dip still going at the last sample +/// has no knowable end, so it is never proposed — the same deferral +/// `segmentSleep(recordContinues: true)` applies, and for the same reason: a +/// fallback run over the slice the detector just deferred would otherwise +/// propose exactly that cut-off stretch as a finished night. HrLedWindow? hrLedSleepWindow( List hr1hz, List tsSec, { @@ -47,6 +56,7 @@ HrLedWindow? hrLedSleepWindow( int bridgeGapSec = 30 * 60, double smoothSec = 300, int maxSampleGapSec = 5 * 60, + bool recordContinues = false, }) { final n = hr1hz.length < tsSec.length ? hr1hz.length : tsSec.length; if (n < minDurationSec ~/ 2) return null; @@ -79,7 +89,7 @@ HrLedWindow? hrLedSleepWindow( bool low(int i) => smooth[i] > 0 && smooth[i] < threshold!; // 4. Longest low run, bridging brief arousals (gap-aware via real timestamps). - var bestStart = -1, bestEnd = -1, bestDur = 0; + final runs = <({int start, int end, int dur})>[]; var i = 0; while (i < n) { if (!low(i)) { @@ -118,14 +128,39 @@ HrLedWindow? hrLedSleepWindow( } } final dur = tsSec[(j - 1).clamp(0, n - 1)] - tsSec[i]; - if (dur > bestDur) { - bestDur = dur; - bestStart = i; - bestEnd = j; - } + runs.add((start: i, end: j, dur: dur)); i = j + 1; } + // 5. Defer a dip still going at the cut, and any dip chained to it within + // the detector's own night-continuation gap (a long arousal splits + // tonight in two; only the trailing half touches the cut). + final deferred = List.filled(runs.length, false); + if (recordContinues) { + const chainGapSec = AdvancedSleepStager.nightContinuationGapMin * 60; + final lastTs = tsSec[n - 1]; + for (var r = runs.length - 1; r >= 0; r--) { + final endTs = tsSec[(runs[r].end - 1).clamp(0, n - 1)]; + if (lastTs - endTs <= kUnfinishedNightEdgeSec) { + deferred[r] = true; + } else if (r + 1 < runs.length && + deferred[r + 1] && + tsSec[runs[r + 1].start] - endTs <= chainGapSec) { + deferred[r] = true; + } + } + } + + var bestStart = -1, bestEnd = -1, bestDur = 0; + for (var r = 0; r < runs.length; r++) { + if (deferred[r]) continue; + if (runs[r].dur > bestDur) { + bestDur = runs[r].dur; + bestStart = runs[r].start; + bestEnd = runs[r].end; + } + } + if (bestStart < 0 || bestDur < minDurationSec) return null; final onsetSec = tsSec[bestStart]; final offsetSec = tsSec[(bestEnd - 1).clamp(0, n - 1)] + 1; diff --git a/lib/src/onehz/sleep/segment.dart b/lib/src/onehz/sleep/segment.dart index 0820976..a2ae3db 100644 --- a/lib/src/onehz/sleep/segment.dart +++ b/lib/src/onehz/sleep/segment.dart @@ -275,9 +275,9 @@ class SleepSegmentation { final double confidence; /// Machine-readable cause when [present] is false and the cause is known - /// (currently: too little of the window was observed). Null when a caller - /// should say nothing more than "no qualifying sleep". Never render a bare - /// dash for an absence that carries one of these. + /// (too little of the window was observed, or [kAbsenceNightUnfinished]). + /// Null when a caller should say nothing more than "no qualifying sleep". + /// Never render a bare dash for an absence that carries one of these. final String? absenceReason; /// Seconds the band's last SLEEP removed from the END of the auto window; @@ -348,6 +348,18 @@ class SleepSegmentation { absenceReason: reason, ); + /// Absent because the only sleep in the record was still going at its last + /// sample while the caller said the record carries on (`recordContinues`). + /// Not "no sleep": the night exists and belongs to whichever slice sees it + /// end. A caller must NOT treat this as a miss and go looking for a window by + /// another route (an HR-led fallback over the same slice proposes the same + /// cut-off stretch). + static SleepSegmentation get unfinishedNight => + unobservedWindow(kAbsenceNightUnfinished); + + /// True when this result is [unfinishedNight]. + bool get unfinished => absenceReason == kAbsenceNightUnfinished; + bool get present => window != null; /// SLP-13 — tonight's stage minutes as INTERVALS, from this night's own @@ -526,7 +538,9 @@ SleepSegmentation segmentSleep( if (recordContinues) { final lastTs = grav.fold(grav.first.ts, (m, g) => math.max(m, g.ts)); groups = _dropUnfinishedGroups(groups, lastTs); - if (groups.isEmpty) return SleepSegmentation.absent; + // Every candidate was tonight's still-going night: say so, so the + // caller can tell a deferral from an empty slice. + if (groups.isEmpty) return SleepSegmentation.unfinishedNight; } chosen = _pickMainSleepGroup( groups, @@ -849,6 +863,11 @@ List<_SleepGroup> _bridgeAdjacentSessions(List sessions) { /// window cannot be told apart from sleep that runs on past the cut. const int kUnfinishedNightEdgeSec = AdvancedSleepStager.stillWindowMin * 60; +/// [SleepSegmentation.absenceReason] for a night deferred because it was still +/// going at the end of a record that continues. See +/// [SleepSegmentation.unfinishedNight]. +const String kAbsenceNightUnfinished = 'night_unfinished'; + /// Drops every group that is still going at [lastTs], plus any group chained /// to one within the detector's own continuation gap. Walking backward /// matters: a short awakening splits tonight into two groups and only the diff --git a/test/onehz/sleep_unfinished_night_test.dart b/test/onehz/sleep_unfinished_night_test.dart index 7f1f5e6..4d1517f 100644 --- a/test/onehz/sleep_unfinished_night_test.dart +++ b/test/onehz/sleep_unfinished_night_test.dart @@ -47,6 +47,62 @@ void main() { expect(s.present, isFalse); expect(s.tstSec, isNull); expect(s.inBedSec, isNull); + // A deferral, not "no sleep": the caller must be able to tell them apart + // or it goes looking for a window by the HR-led route instead. + expect(s.unfinished, isTrue); + expect(s.absenceReason, kAbsenceNightUnfinished); + }); + + test('an empty slice is plain absent, not unfinished', () { + final r = _record(_midnight + 12 * 3600, _midnight + 86400, const []); + final s = segmentSleep(r.accel, r.hr, tzOffsetSec: 0, recordContinues: true); + expect(s.present, isFalse); + expect(s.unfinished, isFalse); + }); + + // The edge runs the HR-led fallback whenever the accel path comes back + // without a night. Over the same cut-off slice it proposed the same evening + // as a finished night, so deferring in segmentSleep alone changed nothing. + group('hrLedSleepWindow over a slice cut at midnight', () { + List ts(List a) => [for (final x in a) x.tsMs ~/ 1000]; + + test('without the flag it proposes the cut-off evening (the bug)', () { + final w = hrLedSleepWindow(evening.hr, ts(evening.accel), + hrBaseline: const [85.0]); + expect(w, isNotNull); + expect(w!.offsetSec, greaterThan(_midnight + 86400 - 5 * 60)); + }); + + test('record continues: the still-going dip is not proposed', () { + final w = hrLedSleepWindow(evening.hr, ts(evening.accel), + hrBaseline: const [85.0], recordContinues: true); + expect(w, isNull); + }); + + test('record continues: a finished dip earlier in the slice still wins', () { + final r = _record(_midnight - 4 * 3600, _midnight + 86400, [ + (_midnight - 3600, _midnight + 7 * 3600), + (_midnight + 20 * 3600 + 30 * 60, _midnight + 86400), + ]); + final w = hrLedSleepWindow(r.hr, ts(r.accel), + hrBaseline: const [85.0], recordContinues: true); + expect(w, isNotNull); + expect(w!.offsetSec, lessThan(_midnight + 8 * 3600)); + expect(w.offsetSec, greaterThan(_midnight + 6 * 3600)); + }); + + test('a dip that ended well before the cut is unchanged by the flag', () { + final r = _record(_midnight - 4 * 3600, _midnight + 12 * 3600, [ + (_midnight - 3600, _midnight + 7 * 3600), + ]); + final a = hrLedSleepWindow(r.hr, ts(r.accel), hrBaseline: const [85.0]); + final b = hrLedSleepWindow(r.hr, ts(r.accel), + hrBaseline: const [85.0], recordContinues: true); + expect(a, isNotNull); + expect(b, isNotNull); + expect(b!.onsetSec, a!.onsetSec); + expect(b.offsetSec, a.offsetSec); + }); }); test('a finished night earlier in the slice is still the main sleep', () { From ce5b00cd354ee3148e716ffa26a61a8ceccdedcf Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 29 Oct 2026 00:37:44 +0530 Subject: [PATCH 20/29] naps: count time asleep and day coverage in wall-clock seconds time asleep added one per still sample while time in bed is wall-clock seconds, so a 5 s stream reported a fully still nap as a fifth asleep and docked its confidence. the day-wide accel coverage check counted rows, which never counts the rows that are missing: two good hours in an otherwise empty day read as full coverage and came back as a judged "no nap". --- lib/src/onehz/sleep/nap.dart | 23 +++++++++++++++++++---- test/onehz/nap_test.dart | 22 ++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/lib/src/onehz/sleep/nap.dart b/lib/src/onehz/sleep/nap.dart index bb06e4f..60e4f92 100644 --- a/lib/src/onehz/sleep/nap.dart +++ b/lib/src/onehz/sleep/nap.dart @@ -522,10 +522,13 @@ Metric> detectNaps( // is time in bed but not time asleep. This distinction is the whole point: // the sleep-need credit must be TST, and crediting TIB systematically // over-credits and under-recommends sleep. + // Credited in wall-clock seconds, like `tib`: counting one per still + // sample put a fully-still 5 s stream at a fifth of its real sleep time. var tst = 0; for (var k = start; k < end; k++) { - if (stillAt(k)) tst++; + if (stillAt(k)) tst += secPerSample; } + if (tst > tib) tst = tib; // Confidence, NOT efficiency. A 20% dip below the awake baseline earns // full marks on that axis; the rest rewards evidence, not sleep quality. @@ -564,15 +567,27 @@ Metric> detectNaps( // even when the rest of the day went unmeasured. Coverage is taken over the // seconds outside the main sleep, since that is the only stretch a nap could // have come from. + // + // Both sides are wall-clock seconds over the slice's span. Counting rows + // never counted the rows that are missing, so two good hours inside an + // otherwise empty day read as full coverage and returned a judged "no nap". if (naps.isEmpty) { - var outside = 0, outsideAccel = 0; + final spanSec = absAt(n - 1) + 1 - baseSec; + var mainSec = 0; + if (mainSleep != null) { + final ms = mainSleep.start < 0 ? 0 : mainSleep.start; + final me = mainSleep.end > n ? n : mainSleep.end; + if (me > ms) mainSec = absAt(me - 1) + 1 - absAt(ms); + } + final outside = spanSec - mainSec; + var outsideAccel = 0; for (var k = 0; k < n; k++) { if (mainSleep != null && k >= mainSleep.start && k < mainSleep.end) { continue; } - outside++; - if (series[k].valid) outsideAccel++; + if (series[k].valid) outsideAccel += secPerSample; } + if (outsideAccel > outside) outsideAccel = outside; if (outside > 0 && outsideAccel / outside < minNapAccelCoverage) { return Metric>.absent( tier: Tier.estimate, diff --git a/test/onehz/nap_test.dart b/test/onehz/nap_test.dart index 0f14d1f..61b9c5f 100644 --- a/test/onehz/nap_test.dart +++ b/test/onehz/nap_test.dart @@ -722,6 +722,19 @@ void main() { expect(m.value, hasLength(1)); }); + test('a long recording hole counts against day-wide coverage', () { + // Every row that exists is measured, but they cover four hours of a + // 24 h span. Counting rows read this as full coverage and returned a + // judged "no nap" for twenty hours nobody observed. + final d = _Day() + ..active(120) + ..gap(20 * 60) + ..active(120); + + final m = detectNaps(d.accel, d.hr); + expect(m.present, isFalse); + }); + test('accel coverage is judged outside the main sleep only', () { // The awake day is fully measured; the unmeasured stretch is the night, // which naps are never drawn from. The day stays judged. @@ -791,6 +804,15 @@ void main() { }); } + test('time asleep is wall-clock seconds at a 5 s cadence, like tib', () { + // One still sample stands for five seconds here. Counting one per + // sample reported a fully-still nap as a fifth asleep. + final d = day(5); + final nap = detectNaps(d.accel, d.hr).value!.single; + expect(nap.tstSec, greaterThan(nap.tibSec * 0.8)); + expect(nap.tstSec, lessThanOrEqualTo(nap.tibSec)); + }); + test('a skipped second at 1 Hz does not end the nap', () { final d = day(1, dropEvery: 120); final m = detectNaps(d.accel, d.hr); From f46ac2c2065c1080d77eaa1e5e9247aa6cb5d085 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 29 Oct 2026 00:37:44 +0530 Subject: [PATCH 21/29] sleep cycles: keep the tallest peak of an island between two rr holes a peak whose walk hit a hole on both sides was skipped before any prominence test, so every stretch of data between two contact losses lost its tallest peak and the cycles either side of it. the island's own minima are the base now, as they are at the ends of the night. --- lib/src/onehz/sleep/cycles.dart | 10 +++++++--- test/onehz/sleep_honesty_test.dart | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/lib/src/onehz/sleep/cycles.dart b/lib/src/onehz/sleep/cycles.dart index 4a8a04e..2be8ef7 100644 --- a/lib/src/onehz/sleep/cycles.dart +++ b/lib/src/onehz/sleep/cycles.dart @@ -201,7 +201,10 @@ List _findPeaks( if (b != null && yi <= b) continue; // Walk each side until a higher point, the array edge, or a hole. A side // that runs into a hole has no observed base beyond it, so it does not - // contribute to the prominence; if both sides do, there is no base at all. + // contribute to the prominence. When BOTH sides do, the peak is the + // tallest point of an island between two holes; skipping it dropped that + // peak and every cycle beside it, so the island's own minima stand in + // for the base, as they would at the array edges. var l = i; var lHole = false; while (l > 0) { @@ -224,7 +227,6 @@ List _findPeaks( if (v >= yi) break; r++; } - if (lHole && rHole) continue; var lmin = yi, rmin = yi; for (var k = l; k <= i; k++) { final v = y[k]; @@ -234,7 +236,9 @@ List _findPeaks( final v = y[k]; if (v != null && v < rmin) rmin = v; } - final base = lHole ? rmin : (rHole ? lmin : math.max(lmin, rmin)); + final base = lHole == rHole + ? math.max(lmin, rmin) + : (lHole ? rmin : lmin); if (yi - base >= minProm) cand.add([i.toDouble(), yi]); } cand.sort((p, q) => q[1].compareTo(p[1])); // tallest first diff --git a/test/onehz/sleep_honesty_test.dart b/test/onehz/sleep_honesty_test.dart index 0ecce42..2b0f278 100644 --- a/test/onehz/sleep_honesty_test.dart +++ b/test/onehz/sleep_honesty_test.dart @@ -601,6 +601,31 @@ void main() { expect(r.series.any((p) => p['t'] == onset), isFalse); }); + test('the cycle inside an island between two RR holes is kept', () { + // A 90-min RMSSD rhythm with contact lost for minutes 60-89 and 250-279. + // The island between them holds two peaks (~112 and ~202 min). The + // taller one walks into a hole on both sides; it used to be skipped + // outright, which left the island one peak and no cycle at all. + const onset = _t0; + const nMin = 480; + for (var seed = 1; seed <= 6; seed++) { + final rnd = math.Random(seed); + final rr = [], ts = []; + for (var s = 0; s < nMin * 60; s++) { + final m = s ~/ 60; + if ((m >= 60 && m < 90) || (m >= 250 && m < 280)) continue; + final amp = 20 + 15 * math.sin(2 * math.pi * (m - 90) / 90); + rr.add(1000 + amp * (rnd.nextBool() ? 1 : -1) * 0.7); + ts.add((onset + s) * 1000.0); + } + final r = detectSleepCycles(rr, ts, onset, onset + nMin * 60); + final inIsland = + r.cycles.where((c) => c.startMin >= 90 && c.endMin < 250).toList(); + expect(inIsland, hasLength(1), reason: 'seed $seed'); + expect(inIsland.single.lenMin, inInclusiveRange(70, 110)); + } + }); + test('a cycle never spans a long RR hole', () { // 8 h with a 90-min RMSSD rhythm, then no RR at all for minutes 100-259 // (contact loss). Pre-fix the peak before the hole and the peak after it From e1f245477ea858a44a340f3e7ff5c1fc129d330a Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 29 Oct 2026 00:37:44 +0530 Subject: [PATCH 22/29] van hees: an unmeasured second inside the compared span is not stillness the rolling median only carries NaN at the unmeasured second itself, so the 5 s angle change across one was the finite difference of its two measured ends. any unmeasured second in the span makes the change NaN now. --- lib/src/onehz/sleep/van_hees.dart | 14 +++++++++++++- test/onehz/sleep_test.dart | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/lib/src/onehz/sleep/van_hees.dart b/lib/src/onehz/sleep/van_hees.dart index 58b433c..c1b68e4 100644 --- a/lib/src/onehz/sleep/van_hees.dart +++ b/lib/src/onehz/sleep/van_hees.dart @@ -250,9 +250,21 @@ ImmobilityMask immobilityMask( // as sustained rest. Differencing the smoothed angle across one smoothing // epoch is the sliding form of the epoch-to-epoch comparison, so a slow roll // is judged by the full change it makes over 5 s. + // + // The rolling median carries NaN only at the unmeasured second itself, so an + // unmeasured second strictly INSIDE the compared span left the difference of + // its two measured endpoints finite: the arm could have moved and come back + // there unobserved. Any unmeasured second in the span makes the change NaN. + final invalidBefore = List.filled(n + 1, 0); + for (var i = 0; i < n; i++) { + invalidBefore[i + 1] = invalidBefore[i] + (accel[i].valid ? 0 : 1); + } final dAng = List.filled(n, 0); for (var i = 1; i < n; i++) { - dAng[i] = (ang[i] - ang[math.max(0, i - smoothSamples)]).abs(); + final from = math.max(0, i - smoothSamples); + dAng[i] = invalidBefore[i + 1] - invalidBefore[from] > 0 + ? double.nan + : (ang[i] - ang[from]).abs(); } final immobile = List.filled(n, false); final immobileUnknown = List.filled(n, false); diff --git a/test/onehz/sleep_test.dart b/test/onehz/sleep_test.dart index 18a2840..ad03731 100644 --- a/test/onehz/sleep_test.dart +++ b/test/onehz/sleep_test.dart @@ -255,6 +255,24 @@ void main() { expect(m.value!.onsetIdx, greaterThanOrEqualTo(57 * 60), reason: 'pre-fix: onset at 0 — the restless hour was annexed'); }); + + test('an unmeasured second inside the compared span is not stillness', () { + // One undecoded second at index 100 between measured seconds at the same + // orientation. The rolling median NaNs only index 100, so the change + // from 97 to 102 used to read as a finite 0° across a second nobody saw. + final accel = [ + for (var i = 0; i < 600; i++) + i == 100 + ? AccelSample(i * 1000.0, 0, 0, 0, valid: false) + : AccelSample(i * 1000.0, 0, 0, 1) + ]; + final mask = immobilityMask(accel); + for (var i = 101; i <= 105; i++) { + expect(mask.deltaDeg[i].isNaN, isTrue, reason: 'second $i'); + } + expect(mask.deltaDeg[99].isNaN, isFalse); + expect(mask.deltaDeg[106].isNaN, isFalse); + }); }); // ---------------------------------------------------------------------- SRI From de48a81a67ebeaebe05093c0f719794c19338ef3 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 29 Oct 2026 04:23:19 +0530 Subject: [PATCH 23/29] naps: use the measured cadence unfloored when converting samples to seconds --- lib/src/onehz/sleep/nap.dart | 28 +++++++++-------- test/onehz/nap_test.dart | 58 ++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/lib/src/onehz/sleep/nap.dart b/lib/src/onehz/sleep/nap.dart index 60e4f92..fed4183 100644 --- a/lib/src/onehz/sleep/nap.dart +++ b/lib/src/onehz/sleep/nap.dart @@ -272,11 +272,12 @@ Metric> detectNaps( // Wall-clock seconds one sample stands for in the coverage gates below. // Counting samples as seconds put a fully-measured 5 s stream at 20% - // coverage and rejected every bout as unmeasured. Floored so a sample is - // never credited with more time than the stream actually spaces them by, and - // exactly 1 at 1 Hz. - final cadenceFloor = (mask.cadenceSec ?? 1).floor(); - final secPerSample = cadenceFloor < 1 ? 1 : cadenceFloor; + // coverage and rejected every bout as unmeasured. The measured cadence is + // used as is and the total rounded once: flooring it to whole seconds + // credited a 0.5 s sample with a full second (doubling coverage on a 2 Hz + // stream) and a 2.5 s sample with two. + final secPerSample = mask.cadenceSec ?? 1.0; + int samplesToSec(int count) => (count * secPerSample).round(); final runs = >[]; var i = 0; @@ -446,10 +447,11 @@ Metric> detectNaps( // Wall-clock accel coverage — the seconds inside the bout the band actually // measured gravity for, over the bout's elapsed span (so a recording hole // counts against it exactly as an invalid sample does). - var accelSec = 0; + var accelSamples = 0; for (var k = start; k < end; k++) { - if (series[k].valid) accelSec += secPerSample; + if (series[k].valid) accelSamples++; } + final accelSec = samplesToSec(accelSamples); if (accelSec / tib < minNapAccelCoverage) { unobserved++; continue; @@ -466,7 +468,7 @@ Metric> detectNaps( for (var k = start; k < end; k++) { if (hr[k] > 0) boutHr.add(hr[k]); } - final hrSec = boutHr.length * secPerSample; + final hrSec = samplesToSec(boutHr.length); final coverage = hrSec >= tib ? 1.0 : hrSec / tib; if (coverage < minNapHrCoverage) { unverifiable++; @@ -524,10 +526,11 @@ Metric> detectNaps( // over-credits and under-recommends sleep. // Credited in wall-clock seconds, like `tib`: counting one per still // sample put a fully-still 5 s stream at a fifth of its real sleep time. - var tst = 0; + var stillSamples = 0; for (var k = start; k < end; k++) { - if (stillAt(k)) tst += secPerSample; + if (stillAt(k)) stillSamples++; } + var tst = samplesToSec(stillSamples); if (tst > tib) tst = tib; // Confidence, NOT efficiency. A 20% dip below the awake baseline earns @@ -580,13 +583,14 @@ Metric> detectNaps( if (me > ms) mainSec = absAt(me - 1) + 1 - absAt(ms); } final outside = spanSec - mainSec; - var outsideAccel = 0; + var outsideSamples = 0; for (var k = 0; k < n; k++) { if (mainSleep != null && k >= mainSleep.start && k < mainSleep.end) { continue; } - if (series[k].valid) outsideAccel += secPerSample; + if (series[k].valid) outsideSamples++; } + var outsideAccel = samplesToSec(outsideSamples); if (outsideAccel > outside) outsideAccel = outside; if (outside > 0 && outsideAccel / outside < minNapAccelCoverage) { return Metric>.absent( diff --git a/test/onehz/nap_test.dart b/test/onehz/nap_test.dart index 61b9c5f..53a310a 100644 --- a/test/onehz/nap_test.dart +++ b/test/onehz/nap_test.dart @@ -813,6 +813,64 @@ void main() { expect(nap.tstSec, lessThanOrEqualTo(nap.tibSec)); }); + // Fractional cadences. Flooring the measured cadence to whole seconds + // credited a 2.5 s sample with 2 s and a 0.5 s sample with a full second. + ({List accel, List hr}) dayMs(int stepMs, + {bool Function(int k)? invalid, bool nap = true}) { + final accel = []; + final hr = []; + var tMs = 0; + var k = 0; + void emit(double x, double z, double bpm) { + final bad = invalid?.call(k) ?? false; + accel.add(bad + ? AccelSample(tMs.toDouble(), 0, 0, 0, valid: false) + : AccelSample(tMs.toDouble(), x, 0, z)); + hr.add(bpm); + tMs += stepMs; + k++; + } + + void active(int min) { + final end = tMs + min * 60000; + while (tMs < end) { + final rad = (45 + 35 * math.sin(tMs * 0.0004)) * math.pi / 180; + emit(math.cos(rad), math.sin(rad), 82); + } + } + + void still(int min) { + final end = tMs + min * 60000; + while (tMs < end) { + emit(0, 1, 54); + } + } + + active(120); + if (nap) { + still(40); + active(120); + } + return (accel: accel, hr: hr); + } + + test('time asleep is not floored at a 2.5 s cadence', () { + final d = dayMs(2500); + final nap = detectNaps(d.accel, d.hr).value!.single; + expect(nap.tstSec, greaterThan(nap.tibSec * 0.9), + reason: 'flooring 2.5 s to 2 s put a fully-still nap at 80% asleep'); + expect(nap.tstSec, lessThanOrEqualTo(nap.tibSec)); + }); + + test('day accel coverage is not doubled at a 0.5 s cadence', () { + // Gravity decoded on 2 of every 5 samples: 40% of the day, under the + // 50% gate. Crediting each 0.5 s sample a full second read it as 80% + // and published a judged "no nap". + final d = dayMs(500, invalid: (k) => k % 5 >= 2, nap: false); + final m = detectNaps(d.accel, d.hr); + expect(m.present, isFalse, reason: m.note); + }); + test('a skipped second at 1 Hz does not end the nap', () { final d = day(1, dropEvery: 120); final m = detectNaps(d.accel, d.hr); From a430627e6bf13641df80d9b03c90cb37b576ed0e Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 29 Oct 2026 04:23:19 +0530 Subject: [PATCH 24/29] van hees: keep scanning past a gap so later movement in the window decides it --- lib/src/onehz/sleep/van_hees.dart | 10 ++++++---- test/onehz/sleep_test.dart | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/lib/src/onehz/sleep/van_hees.dart b/lib/src/onehz/sleep/van_hees.dart index c1b68e4..30f3840 100644 --- a/lib/src/onehz/sleep/van_hees.dart +++ b/lib/src/onehz/sleep/van_hees.dart @@ -286,9 +286,11 @@ ImmobilityMask immobilityMask( // record it in `immobileUnknown` rather than guessing either way. // // A window containing an UNMEASURED second lands in the same two cases, for - // the same reason: movement already seen decides it (the sustained rule has - // failed on the data in hand), otherwise the arm could have moved where we - // were not looking ⇒ undecidable, never asserted rest. + // the same reason: movement seen anywhere in the window decides it (the + // sustained rule has failed on the data in hand), otherwise the arm could + // have moved where we were not looking ⇒ undecidable, never asserted rest. + // The scan therefore keeps going past a gap: stopping there missed measured + // movement later in the same window and reported it as undecidable. for (var i = 0; i < n; i++) { final hi = math.min(n, i + winSamples); var maxd = 0.0; @@ -299,7 +301,7 @@ ImmobilityMask immobilityMask( // moved there, so it is a gap, not stillness. if (!accel[k].valid || dAng[k].isNaN) { gap = true; - break; + continue; } if (dAng[k] > maxd) { maxd = dAng[k]; diff --git a/test/onehz/sleep_test.dart b/test/onehz/sleep_test.dart index ad03731..c1f72c5 100644 --- a/test/onehz/sleep_test.dart +++ b/test/onehz/sleep_test.dart @@ -272,6 +272,29 @@ void main() { } expect(mask.deltaDeg[99].isNaN, isFalse); expect(mask.deltaDeg[106].isNaN, isFalse); + for (var i = 101; i <= 104; i++) { + expect(mask.immobile[i], isFalse, reason: 'second $i'); + expect(mask.immobileUnknown[i], isTrue, reason: 'second $i'); + } + }); + + test('movement after an unmeasured second in the window is decided', () { + // Still until an undecoded second at 100, then a 90° turn at 110. The + // window starting at 50 contains both: the turn already fails the + // sustained rule on measured data, so that second is not immobile and + // not undecidable either. + final accel = [ + for (var i = 0; i < 1200; i++) + i == 100 + ? AccelSample(i * 1000.0, 0, 0, 0, valid: false) + : i < 110 + ? AccelSample(i * 1000.0, 0, 0, 1) + : AccelSample(i * 1000.0, 1, 0, 0) + ]; + final mask = immobilityMask(accel); + expect(mask.immobile[50], isFalse); + expect(mask.immobileUnknown[50], isFalse, + reason: 'the scan stopped at the gap and never saw the turn'); }); }); From 8e70fc507a98238dbd115ae4e45f5d4755a677e5 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 29 Oct 2026 08:53:14 +0530 Subject: [PATCH 25/29] sleep: only widen the session bridge to 90 min for an overnight chain detectSleep chains sessions up to 90 min apart, but that only changes what it returns for an overnight chain. two daytime sleeps 60-90 min apart are separate sessions there, yet segmentSleep bridged them into one window and counted the time up and about in between as wake. daytime sessions go back to the plain under-60-min bridge; the wider gap applies once any session in the chain starts outside the daytime band, same test detectSleep uses. --- lib/src/onehz/sleep/segment.dart | 28 ++++++++++++++++++++++++---- test/onehz/sleep_honesty_test.dart | 23 +++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/lib/src/onehz/sleep/segment.dart b/lib/src/onehz/sleep/segment.dart index a2ae3db..9d1f547 100644 --- a/lib/src/onehz/sleep/segment.dart +++ b/lib/src/onehz/sleep/segment.dart @@ -534,7 +534,7 @@ SleepSegmentation segmentSleep( ); if (sessions.isEmpty) return SleepSegmentation.absent; - var groups = _bridgeAdjacentSessions(sessions); + var groups = _bridgeAdjacentSessions(sessions, tzAt); if (recordContinues) { final lastTs = grav.fold(grav.first.ts, (m, g) => math.max(m, g.ts)); groups = _dropUnfinishedGroups(groups, lastTs); @@ -807,7 +807,8 @@ class _SleepGroup { }); } -List<_SleepGroup> _bridgeAdjacentSessions(List sessions) { +List<_SleepGroup> _bridgeAdjacentSessions( + List sessions, int Function(int tsSec) tzAt) { if (sessions.isEmpty) return const []; final sorted = [...sessions]..sort((a, b) => a.start.compareTo(b.start)); // Must be the same rule detectSleep chains a night on. With a narrower bridge @@ -816,7 +817,23 @@ List<_SleepGroup> _bridgeAdjacentSessions(List sessions) { // the selector then published one half and silently dropped the other from // TST, stages and the HRV/RHR windows (or let it surface as a "nap"). The // gap's seconds are scored below as wake where observed, unobserved where not. - const bridgeGapSec = AdvancedSleepStager.nightContinuationGapMin * 60; + // + // Only an OVERNIGHT chain gets the wider gap. detectSleep chains any two + // sessions up to 90 min apart, but the chain only changes what it returns + // when it is overnight (the night-tail exemption); two daytime sessions 75 + // min apart come back as two unrelated sessions. Bridging those would publish + // one window with the time spent up and about in between counted as wake. + // Same overnight test as detectSleep: some session in the chain started + // outside the daytime band. + const bridgeGapSec = 60 * 60; + const nightBridgeGapSec = AdvancedSleepStager.nightContinuationGapMin * 60; + bool overnightOnset(int start) { + final local = start + tzAt(start); + final hour = (((local % 86400) + 86400) % 86400) ~/ 3600; + return hour < AdvancedSleepStager.daytimeBandStartHour || + hour >= AdvancedSleepStager.daytimeBandEndHour; + } + final out = <_SleepGroup>[]; for (final session in sorted) { final asleepMin = AdvancedSleepStager.hypnogramMetrics(session).tstS / 60.0; @@ -833,7 +850,10 @@ List<_SleepGroup> _bridgeAdjacentSessions(List sessions) { } final last = out.removeLast(); final gap = session.start - last.end; - if (gap >= 0 && gap <= bridgeGapSec) { + final overnight = overnightOnset(session.start) || + last.sessions.any((x) => overnightOnset(x.start)); + if (gap >= 0 && + (gap < bridgeGapSec || (overnight && gap <= nightBridgeGapSec))) { out.add( _SleepGroup( sessions: [...last.sessions, session], diff --git a/test/onehz/sleep_honesty_test.dart b/test/onehz/sleep_honesty_test.dart index 2b0f278..2ac024c 100644 --- a/test/onehz/sleep_honesty_test.dart +++ b/test/onehz/sleep_honesty_test.dart @@ -519,6 +519,29 @@ void main() { // detection discards it as a night tail). expect(offset, greaterThan(_midnight + 86400 + 6 * 3600 + 30 * 60)); }); + + test('two daytime sleeps 75 min apart are not bridged into one window', () { + final accel = []; + final hr = []; + final start = _midnight + 7 * 3600; // 07:00 + final active = feeder(accel, hr, start, still: false); + final sleep = feeder(accel, hr, start, still: true); + + active(5 * 3600, bpm: 76); // 07:00-12:00 + sleep(200 * 60, bpm: 50); // 12:00-15:20 + active(75 * 60, bpm: 90); // 15:20-16:35 up and about + sleep(200 * 60, bpm: 50); // 16:35-19:55 + active(2 * 3600, bpm: 76); // 19:55-21:55 + + final s = segmentSleep(accel, hr, + hrBaseline: List.filled(200, 76), tzOffsetSec: 0); + + expect(s.present, isTrue); + // Bridged, this was one ~8 h window with the time up and about counted + // as wake. The wider gap is for an overnight chain only. + expect(s.inBedSec!, lessThan(4 * 3600)); + expect(s.wasoSec!, lessThan(30 * 60)); + }); }); // ═══════════════════════════════════════════════════════════════════════════ From f86491dc2341f01512ee26eb239e00cc1b9e3acf Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 29 Oct 2026 10:23:58 +0530 Subject: [PATCH 26/29] sleep: keep a finished daytime sleep when deferring the one still going the unfinished-night deferral still chained groups on a flat 90 min gap, so a finished daytime sleep that ended 60-90 min before a sleep running into the cut was deferred with it and the slice came back night_unfinished. both the bridge and the deferral now use one chain rule, the overnight-aware one. --- lib/src/onehz/sleep/segment.dart | 72 ++++++++++++--------- test/onehz/sleep_unfinished_night_test.dart | 14 ++++ 2 files changed, 56 insertions(+), 30 deletions(-) diff --git a/lib/src/onehz/sleep/segment.dart b/lib/src/onehz/sleep/segment.dart index 9d1f547..2a9c7b6 100644 --- a/lib/src/onehz/sleep/segment.dart +++ b/lib/src/onehz/sleep/segment.dart @@ -537,7 +537,7 @@ SleepSegmentation segmentSleep( var groups = _bridgeAdjacentSessions(sessions, tzAt); if (recordContinues) { final lastTs = grav.fold(grav.first.ts, (m, g) => math.max(m, g.ts)); - groups = _dropUnfinishedGroups(groups, lastTs); + groups = _dropUnfinishedGroups(groups, lastTs, tzAt); // Every candidate was tonight's still-going night: say so, so the // caller can tell a deferral from an empty slice. if (groups.isEmpty) return SleepSegmentation.unfinishedNight; @@ -807,6 +807,36 @@ class _SleepGroup { }); } +/// Whether [next] continues the chain [before] (ending at [beforeEnd]) — the +/// one rule both the bridge and the unfinished-night deferral use, so they +/// cannot disagree about what belongs to one night. +/// +/// Only an OVERNIGHT chain gets the wider gap. detectSleep chains any two +/// sessions up to 90 min apart, but the chain only changes what it returns +/// when it is overnight (the night-tail exemption); two daytime sessions 75 +/// min apart come back as two unrelated sessions. Bridging those would publish +/// one window with the time spent up and about in between counted as wake. +/// Same overnight test as detectSleep: some session in the chain started +/// outside the daytime band. +bool _continuesChain(List before, int beforeEnd, + SleepSession next, int Function(int tsSec) tzAt) { + const bridgeGapSec = 60 * 60; + const nightBridgeGapSec = AdvancedSleepStager.nightContinuationGapMin * 60; + bool overnightOnset(int start) { + final local = start + tzAt(start); + final hour = (((local % 86400) + 86400) % 86400) ~/ 3600; + return hour < AdvancedSleepStager.daytimeBandStartHour || + hour >= AdvancedSleepStager.daytimeBandEndHour; + } + + final gap = next.start - beforeEnd; + if (gap < 0) return false; + if (gap < bridgeGapSec) return true; + return gap <= nightBridgeGapSec && + (overnightOnset(next.start) || + before.any((x) => overnightOnset(x.start))); +} + List<_SleepGroup> _bridgeAdjacentSessions( List sessions, int Function(int tsSec) tzAt) { if (sessions.isEmpty) return const []; @@ -818,22 +848,7 @@ List<_SleepGroup> _bridgeAdjacentSessions( // TST, stages and the HRV/RHR windows (or let it surface as a "nap"). The // gap's seconds are scored below as wake where observed, unobserved where not. // - // Only an OVERNIGHT chain gets the wider gap. detectSleep chains any two - // sessions up to 90 min apart, but the chain only changes what it returns - // when it is overnight (the night-tail exemption); two daytime sessions 75 - // min apart come back as two unrelated sessions. Bridging those would publish - // one window with the time spent up and about in between counted as wake. - // Same overnight test as detectSleep: some session in the chain started - // outside the daytime band. - const bridgeGapSec = 60 * 60; - const nightBridgeGapSec = AdvancedSleepStager.nightContinuationGapMin * 60; - bool overnightOnset(int start) { - final local = start + tzAt(start); - final hour = (((local % 86400) + 86400) % 86400) ~/ 3600; - return hour < AdvancedSleepStager.daytimeBandStartHour || - hour >= AdvancedSleepStager.daytimeBandEndHour; - } - + // Only an overnight chain gets the full gap; see [_continuesChain]. final out = <_SleepGroup>[]; for (final session in sorted) { final asleepMin = AdvancedSleepStager.hypnogramMetrics(session).tstS / 60.0; @@ -849,11 +864,7 @@ List<_SleepGroup> _bridgeAdjacentSessions( continue; } final last = out.removeLast(); - final gap = session.start - last.end; - final overnight = overnightOnset(session.start) || - last.sessions.any((x) => overnightOnset(x.start)); - if (gap >= 0 && - (gap < bridgeGapSec || (overnight && gap <= nightBridgeGapSec))) { + if (_continuesChain(last.sessions, last.end, session, tzAt)) { out.add( _SleepGroup( sessions: [...last.sessions, session], @@ -889,13 +900,13 @@ const int kUnfinishedNightEdgeSec = AdvancedSleepStager.stillWindowMin * 60; const String kAbsenceNightUnfinished = 'night_unfinished'; /// Drops every group that is still going at [lastTs], plus any group chained -/// to one within the detector's own continuation gap. Walking backward -/// matters: a short awakening splits tonight into two groups and only the -/// trailing one touches the cut, but the leading one is the same unfinished -/// night and would otherwise be published for the day that is ending and then -/// counted again inside the full night by the next slice. -List<_SleepGroup> _dropUnfinishedGroups(List<_SleepGroup> groups, int lastTs) { - const chainGapSec = AdvancedSleepStager.nightContinuationGapMin * 60; +/// to one by [_continuesChain]. Walking backward matters: a group chained to +/// the one touching the cut is the same unfinished night and would otherwise +/// be published for the day that is ending and then counted again inside the +/// full night by the next slice. A completed daytime sleep that merely ended +/// within 90 min of it is not part of that night and is kept. +List<_SleepGroup> _dropUnfinishedGroups( + List<_SleepGroup> groups, int lastTs, int Function(int tsSec) tzAt) { final unfinished = List.filled(groups.length, false); for (var g = groups.length - 1; g >= 0; g--) { if (lastTs - groups[g].end <= kUnfinishedNightEdgeSec) { @@ -904,7 +915,8 @@ List<_SleepGroup> _dropUnfinishedGroups(List<_SleepGroup> groups, int lastTs) { } if (g + 1 < groups.length && unfinished[g + 1] && - groups[g + 1].start - groups[g].end <= chainGapSec) { + _continuesChain(groups[g].sessions, groups[g].end, + groups[g + 1].sessions.first, tzAt)) { unfinished[g] = true; } } diff --git a/test/onehz/sleep_unfinished_night_test.dart b/test/onehz/sleep_unfinished_night_test.dart index 4d1517f..42984de 100644 --- a/test/onehz/sleep_unfinished_night_test.dart +++ b/test/onehz/sleep_unfinished_night_test.dart @@ -118,6 +118,20 @@ void main() { expect(off, greaterThan(_midnight + 6 * 3600)); }); + test('a finished daytime sleep 75 min before a still-going one is kept', () { + // Asleep 12:00 → 15:20, up for 75 min, asleep again from 16:35 to the cut + // at 20:00. The second sleep is deferred; the first is finished and is not + // part of it, so it must not be deferred along with it. + final r = _record(_midnight + 6 * 3600, _midnight + 20 * 3600, [ + (_midnight + 12 * 3600, _midnight + 15 * 3600 + 20 * 60), + (_midnight + 16 * 3600 + 35 * 60, _midnight + 20 * 3600), + ]); + final s = segmentSleep(r.accel, r.hr, tzOffsetSec: 0, recordContinues: true); + expect(s.unfinished, isFalse); + expect(s.present, isTrue); + expect(s.window!.offsetMs! ~/ 1000, lessThan(_midnight + 16 * 3600)); + }); + test('a night that ended well before the cut is not deferred', () { // Asleep 23:00 → 07:00, awake and moving until the slice ends at 12:00. final r = _record(_midnight - 4 * 3600, _midnight + 12 * 3600, [ From ddbe0fbe8e98ddb6e39d6362262558eb57a27847 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 29 Oct 2026 13:22:42 +0530 Subject: [PATCH 27/29] sleep fallback: defer only the chain segmentSleep would defer The HR-led fallback deferred any dip ending within 90 min of the one still going at the cut. segmentSleep only stretches that to 90 min for an overnight chain, so a finished afternoon sleep 75 min before a still-going one was kept by one path and thrown away by the other. --- lib/src/onehz/sleep/hr_fallback.dart | 61 ++++++++++++++++----- test/onehz/sleep_unfinished_night_test.dart | 33 +++++++++++ 2 files changed, 79 insertions(+), 15 deletions(-) diff --git a/lib/src/onehz/sleep/hr_fallback.dart b/lib/src/onehz/sleep/hr_fallback.dart index 6bd340f..15ebbab 100644 --- a/lib/src/onehz/sleep/hr_fallback.dart +++ b/lib/src/onehz/sleep/hr_fallback.dart @@ -47,7 +47,11 @@ class HrLedWindow { /// has no knowable end, so it is never proposed — the same deferral /// `segmentSleep(recordContinues: true)` applies, and for the same reason: a /// fallback run over the slice the detector just deferred would otherwise -/// propose exactly that cut-off stretch as a finished night. +/// propose exactly that cut-off stretch as a finished night. Runs chained to +/// that dip are deferred with it on the same rule segmentSleep uses: under +/// 60 min apart always, up to 90 min only when the chain is overnight (some run +/// in it starts outside the daytime band, read in local time via +/// [tzOffsetResolver], default the machine's offset at that instant). HrLedWindow? hrLedSleepWindow( List hr1hz, List tsSec, { @@ -57,6 +61,7 @@ HrLedWindow? hrLedSleepWindow( double smoothSec = 300, int maxSampleGapSec = 5 * 60, bool recordContinues = false, + int Function(int tsSec)? tzOffsetResolver, }) { final n = hr1hz.length < tsSec.length ? hr1hz.length : tsSec.length; if (n < minDurationSec ~/ 2) return null; @@ -132,22 +137,48 @@ HrLedWindow? hrLedSleepWindow( i = j + 1; } - // 5. Defer a dip still going at the cut, and any dip chained to it within - // the detector's own night-continuation gap (a long arousal splits - // tonight in two; only the trailing half touches the cut). + // 5. Defer a dip still going at the cut, and every dip chained to it the + // way segmentSleep chains a night (a long arousal splits tonight in two; + // only the trailing half touches the cut). A finished daytime sleep that + // ended 60-90 min before it is not part of that night and stays eligible. final deferred = List.filled(runs.length, false); - if (recordContinues) { - const chainGapSec = AdvancedSleepStager.nightContinuationGapMin * 60; - final lastTs = tsSec[n - 1]; - for (var r = runs.length - 1; r >= 0; r--) { - final endTs = tsSec[(runs[r].end - 1).clamp(0, n - 1)]; - if (lastTs - endTs <= kUnfinishedNightEdgeSec) { - deferred[r] = true; - } else if (r + 1 < runs.length && - deferred[r + 1] && - tsSec[runs[r + 1].start] - endTs <= chainGapSec) { - deferred[r] = true; + if (recordContinues && runs.isNotEmpty) { + final int Function(int tsSec) tzAt = tzOffsetResolver ?? + (int t) => DateTime.fromMillisecondsSinceEpoch(t * 1000, isUtc: false) + .timeZoneOffset + .inSeconds; + bool overnightOnset(int start) { + final local = start + tzAt(start); + final hour = (((local % 86400) + 86400) % 86400) ~/ 3600; + return hour < AdvancedSleepStager.daytimeBandStartHour || + hour >= AdvancedSleepStager.daytimeBandEndHour; + } + + int endTs(int r) => tsSec[(runs[r].end - 1).clamp(0, n - 1)]; + // Forward pass: which earlier run each run's chain starts at. + final chainStart = List.filled(runs.length, 0); + for (var r = 1; r < runs.length; r++) { + final gap = tsSec[runs[r].start] - endTs(r - 1); + final from = chainStart[r - 1]; + var chained = false; + if (gap >= 0 && gap < 60 * 60) { + chained = true; + } else if (gap >= 0 && + gap <= AdvancedSleepStager.nightContinuationGapMin * 60) { + chained = overnightOnset(tsSec[runs[r].start]); + for (var q = from; !chained && q < r; q++) { + chained = overnightOnset(tsSec[runs[q].start]); + } } + chainStart[r] = chained ? from : r; + } + final lastTs = tsSec[n - 1]; + final unfinishedChains = { + for (var r = 0; r < runs.length; r++) + if (lastTs - endTs(r) <= kUnfinishedNightEdgeSec) chainStart[r], + }; + for (var r = 0; r < runs.length; r++) { + deferred[r] = unfinishedChains.contains(chainStart[r]); } } diff --git a/test/onehz/sleep_unfinished_night_test.dart b/test/onehz/sleep_unfinished_night_test.dart index 42984de..9cf407b 100644 --- a/test/onehz/sleep_unfinished_night_test.dart +++ b/test/onehz/sleep_unfinished_night_test.dart @@ -103,6 +103,39 @@ void main() { expect(b!.onsetSec, a!.onsetSec); expect(b.offsetSec, a.offsetSec); }); + + test('record continues: a finished daytime dip 75 min before it is kept', + () { + // Same day shape as the segmentSleep case below: asleep 12:00 → 15:20, + // up for 75 min, asleep again from 16:35 to the cut at 20:00. Neither + // run starts overnight, so the first is not part of the deferred one. + final r = _record(_midnight + 6 * 3600, _midnight + 20 * 3600, [ + (_midnight + 12 * 3600, _midnight + 15 * 3600 + 20 * 60), + (_midnight + 16 * 3600 + 35 * 60, _midnight + 20 * 3600), + ]); + final w = hrLedSleepWindow(r.hr, ts(r.accel), + hrBaseline: const [85.0], + recordContinues: true, + tzOffsetResolver: (_) => 0); + expect(w, isNotNull); + expect(w!.offsetSec, lessThan(_midnight + 16 * 3600)); + }); + + test('record continues: an overnight dip 75 min before it is deferred too', + () { + // Asleep 21:00 → 23:00, up for 75 min, asleep again from 00:15 to the + // cut at 03:00: one night split by a long arousal, so neither half is + // a finished night yet. + final r = _record(_midnight + 18 * 3600, _midnight + 27 * 3600, [ + (_midnight + 21 * 3600, _midnight + 23 * 3600), + (_midnight + 24 * 3600 + 15 * 60, _midnight + 27 * 3600), + ]); + final w = hrLedSleepWindow(r.hr, ts(r.accel), + hrBaseline: const [85.0], + recordContinues: true, + tzOffsetResolver: (_) => 0); + expect(w, isNull); + }); }); test('a finished night earlier in the slice is still the main sleep', () { From 0b8b3b5f5aad64b2e9a3d662ad233c074bdaaef5 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 29 Oct 2026 17:45:05 +0530 Subject: [PATCH 28/29] sleep fallback tests: pin the overnight chain and the tz resolver the overnight case used a first dip too short to be proposed on its own, so it passed with chaining switched off, and every chain test ran at offset 0. --- test/onehz/sleep_unfinished_night_test.dart | 23 ++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/test/onehz/sleep_unfinished_night_test.dart b/test/onehz/sleep_unfinished_night_test.dart index 9cf407b..a24d46a 100644 --- a/test/onehz/sleep_unfinished_night_test.dart +++ b/test/onehz/sleep_unfinished_night_test.dart @@ -121,13 +121,30 @@ void main() { expect(w!.offsetSec, lessThan(_midnight + 16 * 3600)); }); + test('record continues: the same day read 8 h ahead is one deferred night', + () { + // The record above with a +8 h offset: the runs start at 20:00 and + // 00:35 local, both overnight, so the 75-min gap chains them and + // neither is a finished night. + final r = _record(_midnight + 6 * 3600, _midnight + 20 * 3600, [ + (_midnight + 12 * 3600, _midnight + 15 * 3600 + 20 * 60), + (_midnight + 16 * 3600 + 35 * 60, _midnight + 20 * 3600), + ]); + final w = hrLedSleepWindow(r.hr, ts(r.accel), + hrBaseline: const [85.0], + recordContinues: true, + tzOffsetResolver: (_) => 8 * 3600); + expect(w, isNull); + }); + test('record continues: an overnight dip 75 min before it is deferred too', () { - // Asleep 21:00 → 23:00, up for 75 min, asleep again from 00:15 to the + // Asleep 20:30 → 23:00, up for 75 min, asleep again from 00:15 to the // cut at 03:00: one night split by a long arousal, so neither half is - // a finished night yet. + // a finished night yet. The first half is long enough to be proposed on + // its own, so this fails if the chain is lost. final r = _record(_midnight + 18 * 3600, _midnight + 27 * 3600, [ - (_midnight + 21 * 3600, _midnight + 23 * 3600), + (_midnight + 20 * 3600 + 30 * 60, _midnight + 23 * 3600), (_midnight + 24 * 3600 + 15 * 60, _midnight + 27 * 3600), ]); final w = hrLedSleepWindow(r.hr, ts(r.accel), From 8e4895703703f1f9cd8a45d2e0ab11029fbf43af Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 29 Oct 2026 18:44:19 +0530 Subject: [PATCH 29/29] sleep fallback tests: a finished overnight dip past the chain gap is kept --- test/onehz/sleep_unfinished_night_test.dart | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/onehz/sleep_unfinished_night_test.dart b/test/onehz/sleep_unfinished_night_test.dart index a24d46a..0cf027e 100644 --- a/test/onehz/sleep_unfinished_night_test.dart +++ b/test/onehz/sleep_unfinished_night_test.dart @@ -137,6 +137,24 @@ void main() { expect(w, isNull); }); + test('record continues: a finished overnight dip 105 min before it is kept', + () { + // Read 8 h ahead: asleep 23:00 → 03:00 local, up for 105 min, asleep + // again from 04:45 local to the cut at 07:00. Both start overnight, but + // the gap is past the 90-min chain, so the first is a finished night. + final r = _record(_midnight + 12 * 3600, _midnight + 23 * 3600, [ + (_midnight + 15 * 3600, _midnight + 19 * 3600), + (_midnight + 20 * 3600 + 45 * 60, _midnight + 23 * 3600), + ]); + final w = hrLedSleepWindow(r.hr, ts(r.accel), + hrBaseline: const [85.0], + recordContinues: true, + tzOffsetResolver: (_) => 8 * 3600); + expect(w, isNotNull); + expect(w!.offsetSec, lessThan(_midnight + 20 * 3600)); + expect(w.offsetSec, greaterThan(_midnight + 18 * 3600)); + }); + test('record continues: an overnight dip 75 min before it is deferred too', () { // Asleep 20:30 → 23:00, up for 75 min, asleep again from 00:15 to the