Skip to content
Open
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
3 changes: 2 additions & 1 deletion lib/src/onehz/sleep/advanced_stager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
91 changes: 91 additions & 0 deletions lib/src/onehz/sleep/band_offset.dart
Original file line number Diff line number Diff line change
@@ -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<int> (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<int> tsSec,
required List<int> 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;
}
39 changes: 38 additions & 1 deletion lib/src/onehz/sleep/segment.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -362,6 +369,7 @@ class SleepSegmentation {

Map<String, dynamic> toJson() => {
'window': window?.toJson(),
if (bandOffsetTrimSec != null) 'band_offset_trim_sec': bandOffsetTrimSec,
'tst_sec': tstSec,
'waso_sec': wasoSec,
'in_bed_sec': inBedSec,
Expand Down Expand Up @@ -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<int>? bandSleepState,
}) {
final n = math.min(accel.length, hr1hz.length);
// A forced window (manual entry / user confirmation, Approach 1) is asserted
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
);
}
}
final onset = _lowerBoundInt(tsSec, chosen.start);
final offset = _lowerBoundInt(tsSec, chosen.end);
final inBed = chosen.end - chosen.start;
Expand Down Expand Up @@ -717,6 +753,7 @@ SleepSegmentation segmentSleep(
sustainedAwakenings: sustainedAwakenings,
longestSleepRunSec: longestSleepRun,
confidence: conf,
bandOffsetTrimSec: bandOffsetTrimSec,
);
}

Expand Down
1 change: 1 addition & 0 deletions lib/src/onehz/sleep/sleep.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
133 changes: 133 additions & 0 deletions test/onehz/band_offset_test.dart
Original file line number Diff line number Diff line change
@@ -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<int> ts, List<int> band, int end}) _night(List<(int?, int)> runs) {
final ts = <int>[], band = <int>[];
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<int> ts, List<int> 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<int> ts, List<int> band, int end}) _dupTail(
int tailSec, bool Function(int i) present) {
final ts = <int>[], band = <int>[];
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);
});
}
Loading
Loading