diff --git a/lib/src/onehz/sleep/advanced_stager.dart b/lib/src/onehz/sleep/advanced_stager.dart index 070e973..2b44e45 100644 --- a/lib/src/onehz/sleep/advanced_stager.dart +++ b/lib/src/onehz/sleep/advanced_stager.dart @@ -41,6 +41,7 @@ import '../util.dart'; import '../clinical/hrv_time.dart'; import 'cardio_stager.dart' show cardioStager; import 'accounting.dart' show SleepStage; +import 'band_offset.dart'; /// Which per-session staging engine [AdvancedSleepStager] runs. See the file /// header for the 2026-07 comparison behind this choice. Do not switch the @@ -214,7 +215,7 @@ class AdvancedSleepStager { // Morning-stillness (#531) static const int morningStillnessWindowMin = 180; static const double morningReonsetRestingHRMult = 0.90; - static const int bandStateAsleep = 2; + static const int bandStateAsleep = kBandStateSleep; static const double morningReonsetBandAsleepFrac = 0.6; // Off-wrist (#500) diff --git a/lib/src/onehz/sleep/band_offset.dart b/lib/src/onehz/sleep/band_offset.dart new file mode 100644 index 0000000..3869a13 --- /dev/null +++ b/lib/src/onehz/sleep/band_offset.dart @@ -0,0 +1,91 @@ +// BAND-STATE NIGHT END — corroboration, never a source. +// +// Gen5/MG R18 body 60 bits 4-5 carry the band's own coarse envelope: +// 0 wake, 1 still, 2 sleep, 3 up ("up" only after sleep, "still" only between +// wake and sleep). No stages, lags onset, so it never creates or stages a night. +// One use: when the band reports SLEEP for the LAST time inside our window and +// then stays awake (UP/WAKE), continuously observed, for the rest of it, the +// remainder was a lie-in. Never "first UP": mid-night UP runs of 16-28 min that +// return to SLEEP are normal. STILL in the tail means re-settling — refuse. +// +// Two shapes exist for the same signal in this package: this POSITIONAL +// List (1:1 with the 1 Hz arrays, -1 absent) and +// AdvancedSleepStager.detectSleep's [ts, state] pairs. Same codes. + +const int kBandStateSleep = 2; +const int kBandStateStill = 1; +const int kMinBandStateCoveragePct = 80; +const int kMinBandTailCoveragePct = 95; +const int kMaxBandTailGapSec = 300; +const int kMaxBandTailStillSec = 60; +const int kMinBandOffsetTrimSec = 600; + +/// The night's new end (same convention as the chosen group's `end`), or null. +/// +/// [tsSec] ascending, 1:1 with [bandState]; a state outside 0..3 is ABSENT. +int? bandTrimmedOffsetSec({ + required int startSec, + required int endSec, + required List tsSec, + required List bandState, + int minNightSec = 3 * 3600, +}) { + if (endSec <= startSec || tsSec.length != bandState.length) return null; + bool known(int s) => s >= 0 && s <= 3; + + var lo = 0, hi = tsSec.length; + while (lo < hi) { + final mid = (lo + hi) >> 1; + if (tsSec[mid] < startSec) { + lo = mid + 1; + } else { + hi = mid; + } + } + final first = lo; + + var knownSec = 0; + int? lastSleepSec; + int? lastCounted; // tsSec is ascending: count each distinct second once + for (var k = first; k < tsSec.length && tsSec[k] < endSec; k++) { + if (!known(bandState[k])) continue; + if (tsSec[k] != lastCounted) { + knownSec++; + lastCounted = tsSec[k]; + } + if (bandState[k] == kBandStateSleep) lastSleepSec = tsSec[k]; + } + if (lastSleepSec == null) return null; + if (knownSec * 100 < (endSec - startSec) * kMinBandStateCoveragePct) return null; + + final newEnd = lastSleepSec + 1; + final tailSec = endSec - newEnd; + if (tailSec < kMinBandOffsetTrimSec) return null; + if (newEnd - startSec < minNightSec) return null; + + // The tail must be continuously observed awake: coverage, longest hole + // (leading, internal and trailing all count) and no re-settling. + var tailKnown = 0, stillSec = 0, maxGap = 0; + var prevKnownTs = lastSleepSec; // the last SLEEP second itself was observed + int? lastTailCounted, lastStillCounted; + for (var k = first; k < tsSec.length && tsSec[k] < endSec; k++) { + final t = tsSec[k]; + if (t < newEnd || !known(bandState[k])) continue; + if (bandState[k] == kBandStateStill && t != lastStillCounted) { + stillSec++; + lastStillCounted = t; + } + if (t == lastTailCounted) continue; + lastTailCounted = t; + tailKnown++; + final gap = t - prevKnownTs - 1; + if (gap > maxGap) maxGap = gap; + prevKnownTs = t; + } + final trailingGap = endSec - 1 - prevKnownTs; + if (trailingGap > maxGap) maxGap = trailingGap; + if (tailKnown * 100 < tailSec * kMinBandTailCoveragePct) return null; + if (maxGap > kMaxBandTailGapSec) return null; + if (stillSec > kMaxBandTailStillSec) return null; + return newEnd; +} diff --git a/lib/src/onehz/sleep/segment.dart b/lib/src/onehz/sleep/segment.dart index 543f107..bff96eb 100644 --- a/lib/src/onehz/sleep/segment.dart +++ b/lib/src/onehz/sleep/segment.dart @@ -44,6 +44,7 @@ import '../util.dart'; import 'van_hees.dart'; import 'accounting.dart' show SleepStage; import 'advanced_stager.dart'; +import 'band_offset.dart'; /// Minimum in-bed duration to qualify as the main sleep (ARCHITECTURE_V2: ~3 h). const int _minQualifyingSleepSec = 3 * 3600; @@ -279,6 +280,11 @@ class SleepSegmentation { /// 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; + /// null when the rule did not apply. Untrimmed end = + /// `window.offsetMs ~/ 1000 + bandOffsetTrimSec`. + final int? bandOffsetTrimSec; + const SleepSegmentation({ required this.window, required this.stages, @@ -297,6 +303,7 @@ class SleepSegmentation { required this.longestSleepRunSec, required this.confidence, this.absenceReason, + this.bandOffsetTrimSec, }); /// Honest "no qualifying sleep" result — all figures null, confidence 0. @@ -362,6 +369,7 @@ class SleepSegmentation { Map toJson() => { 'window': window?.toJson(), + if (bandOffsetTrimSec != null) 'band_offset_trim_sec': bandOffsetTrimSec, 'tst_sec': tstSec, 'waso_sec': wasoSec, 'in_bed_sec': inBedSec, @@ -416,6 +424,13 @@ SleepSegmentation segmentSleep( ({int onsetSec, int offsetSec})? forcedWindow, int? tzOffsetSec, int Function(int tsSec)? tzOffsetResolver, + + /// Optional Gen5/MG band envelope, POSITIONAL 1:1 with [accel] (0 wake, + /// 1 still, 2 sleep, 3 up; anything else absent). AUTO path only: may END the + /// chosen night at the band's last SLEEP (see band_offset.dart). Never + /// creates, extends or stages a night. (AdvancedSleepStager.detectSleep takes + /// the same signal as [ts,state] pairs; not forwarded here.) + List? bandSleepState, }) { final n = math.min(accel.length, hr1hz.length); // A forced window (manual entry / user confirmation, Approach 1) is asserted @@ -476,7 +491,7 @@ SleepSegmentation segmentSleep( RrTs((rrTsMs[i] / 1000.0).round(), rrMs[i]) ]; - final _SleepGroup? chosen; + _SleepGroup? chosen; if (forcedWindow != null) { final onsetSec = forcedWindow.onsetSec; final offsetSec = forcedWindow.offsetSec; @@ -508,6 +523,27 @@ SleepSegmentation segmentSleep( if (chosen == null) return SleepSegmentation.absent; final tsSec = [for (final a in trimmedAccel) a.tsMs ~/ 1000]; + int? bandOffsetTrimSec; + if (forcedWindow == null && + bandSleepState != null && + bandSleepState.length >= n) { + final trimmedEnd = bandTrimmedOffsetSec( + startSec: chosen.start, + endSec: chosen.end, + tsSec: tsSec, + bandState: bandSleepState.sublist(0, n), + minNightSec: _minQualifyingSleepSec, + ); + if (trimmedEnd != null) { + bandOffsetTrimSec = chosen.end - trimmedEnd; + chosen = _SleepGroup( + sessions: chosen.sessions, + start: chosen.start, + end: trimmedEnd, + asleepMin: chosen.asleepMin, + ); + } + } final onset = _lowerBoundInt(tsSec, chosen.start); final offset = _lowerBoundInt(tsSec, chosen.end); final inBed = chosen.end - chosen.start; @@ -717,6 +753,7 @@ SleepSegmentation segmentSleep( sustainedAwakenings: sustainedAwakenings, longestSleepRunSec: longestSleepRun, confidence: conf, + bandOffsetTrimSec: bandOffsetTrimSec, ); } diff --git a/lib/src/onehz/sleep/sleep.dart b/lib/src/onehz/sleep/sleep.dart index 1789b40..2e2bd2f 100644 --- a/lib/src/onehz/sleep/sleep.dart +++ b/lib/src/onehz/sleep/sleep.dart @@ -27,6 +27,7 @@ export 'nap.dart'; export 'segment.dart'; export 'hr_fallback.dart'; export 'advanced_stager.dart'; +export 'band_offset.dart'; export 'sri.dart'; export 'accounting.dart'; // stager.dart provides StagerResult + the shared Webster/consolidation diff --git a/test/onehz/band_offset_test.dart b/test/onehz/band_offset_test.dart new file mode 100644 index 0000000..5db5bdd --- /dev/null +++ b/test/onehz/band_offset_test.dart @@ -0,0 +1,133 @@ +import 'package:test/test.dart'; +import 'package:openstrap_analytics/onehz.dart'; + +const int _t0 = 1700000000; + +/// Positional ts/band arrays from (state, seconds) runs. state -1 = row present +/// but band state absent; state null = no row at all (a hole in tsSec). +({List ts, List band, int end}) _night(List<(int?, int)> runs) { + final ts = [], band = []; + var t = _t0; + for (final (s, n) in runs) { + for (var i = 0; i < n; i++, t++) { + if (s == null) continue; + ts.add(t); + band.add(s); + } + } + return (ts: ts, band: band, end: t); +} + +int? _trim(({List ts, List band, int end}) n) => bandTrimmedOffsetSec( + startSec: _t0, endSec: n.end, tsSec: n.ts, bandState: n.band); + +void main() { + test('lie-in after waking: end moves to last SLEEP second + 1', () { + final n = _night([(2, 8 * 3600), (3, 70 * 60)]); // lie-in after waking + expect(_trim(n), _t0 + 8 * 3600); + }); + + test('UP then WAKE tail also trims', () { + final n = _night([(2, 8 * 3600), (3, 30 * 60), (0, 20 * 60)]); + expect(_trim(n), _t0 + 8 * 3600); + }); + + test('mid-night UP then SLEEP again: only the trailing UP is trimmed', () { + final n = _night([(2, 4 * 3600), (3, 19 * 60), (2, 3 * 3600), (3, 48 * 60)]); + expect(_trim(n), _t0 + 4 * 3600 + 19 * 60 + 3 * 3600); + }); + + test('mid-night UP then SLEEP to the end: no trim', () { + expect(_trim(_night([(2, 4 * 3600), (3, 28 * 60), (2, 3 * 3600)])), isNull); + }); + + test('tail shorter than 10 min: no trim', () { + expect(_trim(_night([(2, 7 * 3600), (3, 9 * 60)])), isNull); + }); + + test('band never SLEEP in the window: no trim (no veto in this plan)', () { + expect(_trim(_night([(0, 2 * 3600), (1, 3600), (3, 5 * 3600)])), isNull); + }); + + // Each gap case below has EXACTLY 95 % tail coverage (114 of 120 min), so it + // passes the coverage gate and fails only on the 6-min (> 5 min) hole. + test('trailing gap > 5 min: no trim (could hide a return to SLEEP)', () { + expect(_trim(_night([(2, 7 * 3600), (3, 114 * 60), (null, 6 * 60)])), isNull); + }); + + test('leading gap > 5 min right after last SLEEP: no trim', () { + expect(_trim(_night([(2, 7 * 3600), (null, 6 * 60), (3, 114 * 60)])), isNull); + }); + + test('internal gap > 5 min: no trim', () { + expect(_trim(_night([(2, 7 * 3600), (3, 60 * 60), (-1, 6 * 60), (3, 54 * 60)])), + isNull); + }); + + test('control: same 2 h tail without the hole trims', () { + expect(_trim(_night([(2, 7 * 3600), (3, 120 * 60)])), _t0 + 7 * 3600); + }); + + test('short gaps (<= 5 min) at >= 95 % tail coverage still trim', () { + final n = _night([(2, 7 * 3600), (3, 30 * 60), (null, 2 * 60), (3, 38 * 60)]); + expect(_trim(n), _t0 + 7 * 3600); + }); + + test('STILL in the tail (re-settling) > 60 s: no trim', () { + expect(_trim(_night([(2, 7 * 3600), (3, 30 * 60), (1, 10 * 60), (0, 5 * 60)])), + isNull); + }); + + test('whole-window coverage below 80 %: no trim', () { + expect(_trim(_night([(-1, 6 * 3600), (2, 2 * 3600), (3, 60 * 60)])), isNull); + }); + + test('trim would drop below 3 h: no trim', () { + expect(_trim(_night([(2, 2 * 3600 + 50 * 60), (3, 60 * 60)])), isNull); + }); + + test('all absent (-1, gen4): no trim', () { + expect(_trim(_night([(-1, 9 * 3600)])), isNull); + }); + + test('length mismatch or empty window: no trim', () { + expect(bandTrimmedOffsetSec( + startSec: _t0, endSec: _t0 + 10, tsSec: const [1, 2], bandState: const [2]), + isNull); + expect(bandTrimmedOffsetSec( + startSec: _t0, endSec: _t0, tsSec: const [], bandState: const []), + isNull); + }); + + // Duplicate seconds (callers derive tsSec with tsMs ~/ 1000) must count once. + ({List ts, List band, int end}) _dupTail( + int tailSec, bool Function(int i) present) { + final ts = [], band = []; + for (var i = 0; i < 7 * 3600; i++) { + ts.add(_t0 + i); + band.add(2); + } + for (var i = 0; i < tailSec; i++) { + if (!present(i)) continue; + for (var r = 0; r < 2; r++) { + ts.add(_t0 + 7 * 3600 + i); + band.add(3); + } + } + return (ts: ts, band: band, end: _t0 + 7 * 3600 + tailSec); + } + + test('duplicate rows cannot fake tail coverage', () { + final n = _dupTail(120 * 60, (i) => i.isEven); // 50 % distinct coverage + expect(_trim(n), isNull); + }); + + test('duplicate rows in an otherwise complete tail still trim', () { + final n = _dupTail(60 * 60, (_) => true); + expect(_trim(n), _t0 + 7 * 3600); + }); + + test('stager and rule share one "sleep" code', () { + expect(AdvancedSleepStager.bandStateAsleep, kBandStateSleep); + }); +} diff --git a/test/onehz/sleep_band_trim_test.dart b/test/onehz/sleep_band_trim_test.dart new file mode 100644 index 0000000..9e0bec4 --- /dev/null +++ b/test/onehz/sleep_band_trim_test.dart @@ -0,0 +1,79 @@ +// test/onehz/sleep_band_trim_test.dart +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); +final int _wake = _midnight + 7 * 3600 + 10 * 60; // band's last SLEEP + 1 + +/// 22:00 → 11:00 UTC. Asleep (still, HR 55) 23:00 → 07:10; a short walk +/// 07:10-07:15; lie-in (still, HR 57 — near RHR, like the real nights) +/// 07:15 → 08:30; then real activity. +({List accel, List hr, List band}) _lieInNight() { + final accel = [], hr = [], band = []; + final rnd = math.Random(7); + for (var t = _midnight - 2 * 3600; t < _midnight + 11 * 3600; t++) { + final asleep = t >= _midnight - 3600 && t < _wake; + final walk = t >= _wake && t < _wake + 5 * 60; + final lieIn = t >= _wake + 5 * 60 && t < _midnight + 8 * 3600 + 30 * 60; + final moving = !asleep && !lieIn; + accel.add(AccelSample(t * 1000.0, + moving ? 0.4 * math.cos(t / 2) : 0.01 * rnd.nextDouble(), 0.0, + moving ? 1.0 + 0.3 * math.sin(t / 3) : 1.0)); + hr.add(asleep ? 55 : (lieIn ? 57 : (walk ? 80 : 85))); + band.add(asleep ? 2 : (lieIn || walk ? 3 : 0)); + } + return (accel: accel, hr: hr, band: band); +} + +void main() { + test('fixture reproduces the bug: without band the night runs past 07:20', () { + final n = _lieInNight(); + final base = segmentSleep(n.accel, n.hr, tzOffsetSec: 0); + expect(base.present, isTrue); + expect(base.window!.offsetMs! ~/ 1000, greaterThan(_wake + 10 * 60)); + expect(base.bandOffsetTrimSec, isNull); + }); + + test('band last SLEEP 07:10, awake after: night ends at 07:10', () { + final n = _lieInNight(); + final base = segmentSleep(n.accel, n.hr, tzOffsetSec: 0); + final s = segmentSleep(n.accel, n.hr, tzOffsetSec: 0, bandSleepState: n.band); + expect(s.present, isTrue); + expect(s.window!.offsetMs, _wake * 1000.0); + expect(s.bandOffsetTrimSec, (base.window!.offsetMs! ~/ 1000) - _wake); + expect(s.window!.offsetMs! ~/ 1000 + s.bandOffsetTrimSec!, + base.window!.offsetMs! ~/ 1000); // untrimmed end recoverable + expect(s.inBedSec, lessThan(base.inBedSec!)); + expect(s.tstSec, lessThan(base.tstSec!)); + expect(s.toJson()['band_offset_trim_sec'], s.bandOffsetTrimSec); + }); + + test('all-absent band (-1) is byte-identical to no input', () { + final n = _lieInNight(); + expect( + segmentSleep(n.accel, n.hr, + tzOffsetSec: 0, + bandSleepState: List.filled(n.band.length, -1)) + .toJson(), + segmentSleep(n.accel, n.hr, tzOffsetSec: 0).toJson()); + }); + + test('forced window ignores the band entirely', () { + final n = _lieInNight(); + final w = (onsetSec: _midnight - 3600, offsetSec: _midnight + 8 * 3600 + 30 * 60); + expect( + segmentSleep(n.accel, n.hr, tzOffsetSec: 0, forcedWindow: w, bandSleepState: n.band) + .toJson(), + segmentSleep(n.accel, n.hr, tzOffsetSec: 0, forcedWindow: w).toJson()); + }); + + test('band shorter than accel: ignored (positional contract broken)', () { + final n = _lieInNight(); + expect( + segmentSleep(n.accel, n.hr, tzOffsetSec: 0, bandSleepState: n.band.sublist(0, 100)) + .toJson(), + segmentSleep(n.accel, n.hr, tzOffsetSec: 0).toJson()); + }); +}