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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions lib/src/onehz/sleep/advanced_stager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -552,8 +552,13 @@ class AdvancedSleepStager {
}

static bool _hrSleepBandAcross(int a, int b, List<HrTs> hr, double? baseline) {
if (baseline == null) return false;
final seg = [for (final h in hr) if (h.ts > a && h.ts <= b) h.bpm];
if (baseline == null || b <= a || b - a > sparseBridgeGapMin * 60) {
return false;
}
// No interior-coverage requirement: segmentSleep only has HR on accel rows,
// so a real hole has HR at its two edges and nothing inside. segmentSleep
// stamps those seconds 'unobserved'; the 90-min cap above bounds the bridge.
final seg = [for (final h in hr) if (h.ts >= a && h.ts <= b) h.bpm];
if (seg.isEmpty) return false;
final meanHr = seg.reduce((x, y) => x + y) / seg.length;
return meanHr <= baseline * hrSleepBandMult;
Expand Down Expand Up @@ -628,6 +633,7 @@ class AdvancedSleepStager {

static List<_Period> _mergePeriods(List<_Period> periods, int mergeMinutes) {
final thresholdS = mergeMinutes * 60;
const maxObservedGapS = maxGapMin * 60;
final pending = [...periods];
final merged = <_Period>[];
var i = 0;
Expand All @@ -639,10 +645,13 @@ class AdvancedSleepStager {
i += 1;
continue;
}
final hasPrev = i > 0 && merged.isNotEmpty;
final hasNext = i + 1 < pending.length;
// never smooth across a recording gap _buildRuns split on.
final hasPrev = merged.isNotEmpty &&
current.start - merged.last.end <= maxObservedGapS;
final hasNext = i + 1 < pending.length &&
pending[i + 1].start - current.end <= maxObservedGapS;
final bridgesSame =
hasPrev && hasNext && pending[i - 1].stage == pending[i + 1].stage;
hasPrev && hasNext && merged.last.stage == pending[i + 1].stage;
if (bridgesSame) {
final prev = merged.removeLast();
merged.add(_Period(prev.stage, prev.start, pending[i + 1].end));
Expand All @@ -656,6 +665,7 @@ class AdvancedSleepStager {
merged.add(_Period(prev.stage, prev.start, current.end));
i += 1;
} else {
merged.add(current);
i += 1;
}
}
Expand Down
34 changes: 34 additions & 0 deletions test/onehz/advanced_stager_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ void main() {
return (grav: grav, hr: hr, rr: rr, nightStart: nightStart, nightEnd: nightEnd);
}

// Still wrist at a sleeping HR over each (start, secs) block, nothing
// recorded between blocks.
({List<GravTs> grav, List<HrTs> hr}) stillBlocks(List<(int, int)> blocks) {
final grav = <GravTs>[];
final hr = <HrTs>[];
for (final (start, secs) in blocks) {
for (var i = 0; i < secs; i++) {
grav.add(GravTs(start + i, 0.001 * math.sin(i * 0.01), 0, 1));
hr.add(HrTs(start + i, 52));
}
}
return (grav: grav, hr: hr);
}

group('AdvancedSleepStager detection + 4-class staging (default: cardio)', () {
test('still low-HR night → one session with a 4-class hypnogram', () {
final d = build(2, 7, 1);
Expand Down Expand Up @@ -97,6 +111,26 @@ void main() {
// The single 17h run exceeds maxMainSleepSpanS and is dropped (not truncated).
expect(sessions, isEmpty);
});

test('a recording gap cannot merge a short rest into the next night', () {
const nextNight = 18 * 3600;
final d = stillBlocks([(0, 10 * 60), (nextNight, 6 * 3600)]);
final sessions = AdvancedSleepStager.detectSleep(d.grav, d.hr);
expect(sessions, hasLength(1));
expect(sessions.single.start, greaterThanOrEqualTo(nextNight));
expect(sessions.single.end - sessions.single.start,
inInclusiveRange(5 * 3600, 7 * 3600));
});

// segmentSleep only has HR on accel rows, so a hole carries edge HR only.
// Under the 90-min cap it bridges; segmentSleep marks it unobserved.
test('a sub-90-min hole with sleep-band edge HR stays one session', () {
final d = stillBlocks([(0, 2 * 3600), (2 * 3600 + 45 * 60, 6 * 3600)]);
final sessions = AdvancedSleepStager.detectSleep(d.grav, d.hr);
expect(sessions, hasLength(1));
expect(sessions.single.start, lessThan(60));
expect(sessions.single.end, greaterThan(8 * 3600 + 40 * 60));
});
});

group('Realistic cycled fixture — deep/REM regression (2026-07)', () {
Expand Down
40 changes: 38 additions & 2 deletions test/onehz/sleep_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -684,14 +684,50 @@ void main() {
hr.add(72 + (i % 5).toDouble());
t += 1000.0;
}
final s =
segmentSleep(accel, hr, hrBaseline: List<double>.filled(60, 72));
final s = segmentSleep(accel, hr,
hrBaseline: List<double>.filled(60, 72), tzOffsetSec: 0);
expect(s.present, isTrue);
// The 30-min block (1800 s) must be counted as wake/WASO, not bridged.
// Allow epoch/HR-dip edge trimming but require most of the 30-min block
// (1800 s) to remain wake — proving sustained arousals are NOT bridged.
expect(s.wasoSec!, greaterThan(18 * 60));
});

test('a 25-min recording hole keeps the sleep before it, stamped unobserved',
() {
// segmentSleep only has HR on the accel rows, so a hole has HR at its two
// edges and nothing inside. It must still bridge (<= 90 min, sleep-band
// HR either side) and mark the hole unobserved, not drop the 45 min of
// observed sleep before it.
final accel = <AccelSample>[];
final hr = <double>[];
var t = 0.0;
void active(int secs) {
for (var i = 0; i < secs; i++) {
final p = math.sin(i * 0.5);
accel.add(AccelSample(t, 0.3 * p, 0.3, 0.9 * (1 - 0.2 * p)));
hr.add(75);
t += 1000.0;
}
}
void still(int secs) {
for (var i = 0; i < secs; i++) {
accel.add(AccelSample(t, 0.02, 0.02, 1.0));
hr.add(52);
t += 1000.0;
}
}
t = 21 * 3600 * 1000.0;
active(3600);
still(45 * 60);
t += 25 * 60 * 1000.0;
still(6 * 3600);
active(3600);
final s = segmentSleep(accel, hr,
hrBaseline: List<double>.filled(60, 60), tzOffsetSec: 0);
expect(s.present, isTrue);
expect(s.unobservedSec!, greaterThan(20 * 60));
});
});

// ---------------------------------------------------------------------- CPC
Expand Down