From 74165e45254203c5784ad88a53d8ec6ab099d6dd Mon Sep 17 00:00:00 2001 From: Alexander Wagner Date: Fri, 16 Oct 2026 17:34:35 +0200 Subject: [PATCH 1/2] fix: retain interrupted main sleep across accepted continuation gaps --- lib/src/onehz/sleep/segment.dart | 6 ++- test/onehz/sleep_continuation_test.dart | 63 +++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 test/onehz/sleep_continuation_test.dart diff --git a/lib/src/onehz/sleep/segment.dart b/lib/src/onehz/sleep/segment.dart index f72cf46..6470e6e 100644 --- a/lib/src/onehz/sleep/segment.dart +++ b/lib/src/onehz/sleep/segment.dart @@ -782,7 +782,9 @@ 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; + // Keep the detector's accepted night continuations in the same main sleep. + // Gaps retain their wake/unobserved stages; only the selected span changes. + const bridgeGapSec = AdvancedSleepStager.nightContinuationGapMin * 60; final out = <_SleepGroup>[]; for (final session in sorted) { final asleepMin = AdvancedSleepStager.hypnogramMetrics(session).tstS / 60.0; @@ -799,7 +801,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_continuation_test.dart b/test/onehz/sleep_continuation_test.dart new file mode 100644 index 0000000..acd0057 --- /dev/null +++ b/test/onehz/sleep_continuation_test.dart @@ -0,0 +1,63 @@ +import 'dart:math' as math; + +import 'package:openstrap_analytics/onehz.dart'; +import 'package:test/test.dart'; + +// Synthetic night; no personal sensor data. A return to sleep after an hour +// awake must survive the final main-sleep selection, with the gap still awake. +void main() { + for (final scenario in [ + (gapMinutes: 70, observed: true), + (gapMinutes: 100, observed: true), + (gapMinutes: 70, observed: false), + ]) { + final gapMinutes = scenario.gapMinutes; + test( + 'main sleep after a $gapMinutes-minute break (observed: ${scenario.observed})', + () { + final accel = []; + final hr = []; + final start = DateTime.utc(2024, 1, 1, 21).millisecondsSinceEpoch; + var elapsedSeconds = 0; + void block(int minutes, {required bool awake, bool observed = true}) { + for (var i = 0; i < minutes * 60; i++, elapsedSeconds++) { + if (!observed) continue; + final phase = math.sin(i * 0.5); + accel.add(AccelSample( + start + elapsedSeconds * 1000.0, + awake ? 0.3 * phase : 0.02, + awake ? 0.3 : 0.02, + awake ? 0.9 * (1 - 0.2 * phase) : 1.0, + )); + hr.add(awake ? 85 : 49 + 1.5 * math.sin(i / 1800)); + } + } + + block(120, awake: true); + block(240, awake: false); // 23:00–03:00 + block(gapMinutes, awake: true, observed: scenario.observed); + block(180, awake: false); + block(120, awake: true); + + final sleep = segmentSleep(accel, hr, tzOffsetSec: 0); + expect(sleep.present, isTrue); + if (gapMinutes == 70) { + expect(sleep.inBedSec, greaterThan(8 * 3600), + reason: 'both overnight blocks belong to the main sleep'); + expect(sleep.tstSec, greaterThan(6 * 3600)); + expect(sleep.tstSec, lessThanOrEqualTo(7 * 3600), + reason: 'the awake gap must not inflate time asleep'); + if (scenario.observed) { + expect(sleep.wasoSec, greaterThanOrEqualTo(65 * 60)); + } else { + expect(sleep.unobservedSec, greaterThanOrEqualTo(70 * 60)); + expect(sleep.wasoSec, lessThan(10 * 60), + reason: 'missing measurements must not become measured wake'); + } + } else { + expect(sleep.inBedSec, lessThan(5 * 3600), + reason: 'a break beyond the continuation limit stays separate'); + } + }); + } +} From 302332bbe74d82b9bd10d08f93f8a058a3108123 Mon Sep 17 00:00:00 2001 From: Alexander Wagner Date: Fri, 16 Oct 2026 19:26:31 +0200 Subject: [PATCH 2/2] fix: cap grouped sleep and test exact continuation boundary --- lib/src/onehz/sleep/segment.dart | 6 +- test/onehz/sleep_continuation_test.dart | 113 ++++++++++++++++++------ 2 files changed, 92 insertions(+), 27 deletions(-) diff --git a/lib/src/onehz/sleep/segment.dart b/lib/src/onehz/sleep/segment.dart index 6470e6e..6a7d57c 100644 --- a/lib/src/onehz/sleep/segment.dart +++ b/lib/src/onehz/sleep/segment.dart @@ -801,7 +801,11 @@ List<_SleepGroup> _bridgeAdjacentSessions(List sessions) { } final last = out.removeLast(); final gap = session.start - last.end; - if (gap >= 0 && gap <= bridgeGapSec) { + // Individual runs already obey this cap; the combined span must too. + final combinedSpan = math.max(last.end, session.end) - last.start; + if (gap >= 0 && + gap <= bridgeGapSec && + combinedSpan <= AdvancedSleepStager.maxMainSleepSpanS) { out.add( _SleepGroup( sessions: [...last.sessions, session], diff --git a/test/onehz/sleep_continuation_test.dart b/test/onehz/sleep_continuation_test.dart index acd0057..ce8738a 100644 --- a/test/onehz/sleep_continuation_test.dart +++ b/test/onehz/sleep_continuation_test.dart @@ -3,8 +3,8 @@ import 'dart:math' as math; import 'package:openstrap_analytics/onehz.dart'; import 'package:test/test.dart'; -// Synthetic night; no personal sensor data. A return to sleep after an hour -// awake must survive the final main-sleep selection, with the gap still awake. +// Synthetic nights; no personal sensor data. A 70-minute awakening fits the +// inclusive 90-minute continuation limit; the gap remains awake or unobserved. void main() { for (final scenario in [ (gapMinutes: 70, observed: true), @@ -15,31 +15,14 @@ void main() { test( 'main sleep after a $gapMinutes-minute break (observed: ${scenario.observed})', () { - final accel = []; - final hr = []; - final start = DateTime.utc(2024, 1, 1, 21).millisecondsSinceEpoch; - var elapsedSeconds = 0; - void block(int minutes, {required bool awake, bool observed = true}) { - for (var i = 0; i < minutes * 60; i++, elapsedSeconds++) { - if (!observed) continue; - final phase = math.sin(i * 0.5); - accel.add(AccelSample( - start + elapsedSeconds * 1000.0, - awake ? 0.3 * phase : 0.02, - awake ? 0.3 : 0.02, - awake ? 0.9 * (1 - 0.2 * phase) : 1.0, - )); - hr.add(awake ? 85 : 49 + 1.5 * math.sin(i / 1800)); - } - } - - block(120, awake: true); - block(240, awake: false); // 23:00–03:00 - block(gapMinutes, awake: true, observed: scenario.observed); - block(180, awake: false); - block(120, awake: true); + final data = _Recording(); + data.block(120 * 60, awake: true); + data.block(240 * 60, awake: false); // 23:00–03:00 + data.block(gapMinutes * 60, awake: true, observed: scenario.observed); + data.block(180 * 60, awake: false); + data.block(120 * 60, awake: true); - final sleep = segmentSleep(accel, hr, tzOffsetSec: 0); + final sleep = segmentSleep(data.accel, data.hr, tzOffsetSec: 0); expect(sleep.present, isTrue); if (gapMinutes == 70) { expect(sleep.inBedSec, greaterThan(8 * 3600), @@ -60,4 +43,82 @@ void main() { } }); } + + for (final gap in [90 * 60, 90 * 60 + 1]) { + test('detected session gap of $gap seconds respects the inclusive limit', + () { + final data = _Recording(); + data.block(2 * 3600, awake: true); + data.block(4 * 3600, awake: false); + // The still-window filter and last-sample run endpoints add 342 + // seconds for this fixture. Verify the detected gap, not the raw one. + data.block(gap - 342, awake: true); + data.block(3 * 3600, awake: false); + data.block(2 * 3600, awake: true); + final sessions = data.detect(); + expect(sessions, hasLength(2)); + expect(sessions[1].start - sessions[0].end, gap); + final sleep = segmentSleep(data.accel, data.hr, tzOffsetSec: 0); + expect(sleep.present, isTrue); + expect(sleep.inBedSec, + gap == 90 * 60 ? greaterThan(8 * 3600) : lessThan(5 * 3600)); + }); + } + + for (final hours in [ + [8, 8], + [6, 5, 5] + ]) { + test('fragmented night $hours cannot bypass the 16-hour span cap', () { + final data = _Recording(startHour: 18); + data.block(2 * 3600, awake: true); + for (var i = 0; i < hours.length; i++) { + if (i > 0) data.block(70 * 60, awake: true); + data.block(hours[i] * 3600, awake: false); + } + data.block(2 * 3600, awake: true); + final sessions = data.detect(); + expect(sessions, hasLength(hours.length)); + expect(sessions.last.end - sessions.first.start, greaterThan(16 * 3600)); + final sleep = segmentSleep(data.accel, data.hr, tzOffsetSec: 0); + expect(sleep.present, isTrue); + expect(sleep.inBedSec, lessThanOrEqualTo(16 * 3600)); + expect(sleep.tstSec, greaterThan(5 * 3600), + reason: + 'keep a valid main group rather than discard the whole night'); + }); + } +} + +class _Recording { + final accel = []; + final hr = []; + final int start; + int elapsedSeconds = 0; + + _Recording({int startHour = 21}) + : start = DateTime.utc(2024, 1, 1, startHour).millisecondsSinceEpoch; + + void block(int seconds, {required bool awake, bool observed = true}) { + for (var i = 0; i < seconds; i++, elapsedSeconds++) { + if (!observed) continue; + final phase = math.sin(i * 0.5); + accel.add(AccelSample( + start + elapsedSeconds * 1000.0, + awake ? 0.3 * phase : 0.02, + awake ? 0.3 : 0.02, + awake ? 0.9 * (1 - 0.2 * phase) : 1.0, + )); + hr.add(awake ? 85 : 49 + 1.5 * math.sin(i / 1800)); + } + } + + List detect() => AdvancedSleepStager.detectSleep( + [for (final a in accel) GravTs(a.tsMs ~/ 1000, a.x, a.y, a.z)], + [ + for (var i = 0; i < accel.length; i++) + HrTs(accel[i].tsMs ~/ 1000, hr[i]) + ], + tzOffsetSec: 0, + ); }