Skip to content
Merged
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
29 changes: 17 additions & 12 deletions lib/src/onehz/sleep/advanced_stager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -425,9 +425,11 @@ class AdvancedSleepStager {
/// whether it is sleep — we only label the stages within it. Staging itself
/// runs through the SAME per-[method] code the auto path uses (see
/// [StagingMethod]), so the single-source invariant holds (only the WINDOW
/// boundary is forced, never the staging math). Seconds with no data inside
/// [startSec, endSec) simply stay unstaged (wake) — honest about gaps,
/// never fabricated.
/// boundary is forced, never the staging math). With [StagingMethod.cardio]
/// (the default), seconds with no data inside [startSec, endSec) simply stay
/// unstaged ('unobserved') — honest about gaps, never fabricated. The legacy
/// v1/v2 methods do not make that guarantee (a no-data window comes back as
/// 'light').
static SleepSession stageWindow(
int startSec,
int endSec,
Expand Down Expand Up @@ -795,7 +797,8 @@ class AdvancedSleepStager {
if (inBed <= 0) return 0;
var wake = 0;
for (final s in stages) {
if (s.stage == 'wake') wake += s.end - s.start;
// Unstaged seconds are not sleep either; they stay in the denominator.
if (s.stage == 'wake' || s.stage == 'unobserved') wake += s.end - s.start;
}
final asleep = math.max(0, inBed - wake);
return math.min(1.0, asleep / inBed);
Expand Down Expand Up @@ -1408,7 +1411,7 @@ class AdvancedSleepStager {
/// claims to separate sleep from wake. A carry-forward bounded by it can
/// therefore never manufacture a scorable sleep bout on its own. Seconds past
/// the bound are UNSTAGED: they are left out of staging entirely and reported
/// as wake.
/// as unobserved.
static const int maxAccelCarryForwardSec = 60;

/// DEFAULT staging path — delegates to `cardioStager` (cardio_stager.dart).
Expand All @@ -1425,20 +1428,22 @@ class AdvancedSleepStager {
/// 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
/// `cardioStager` itself abstains all come back as WAKE — 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 length.
/// `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
/// length, and not as 'wake' either: an abstained run can still carry HR, so
/// `segmentSleep` would see it as observed and publish it as measured WASO.
static List<StageSegment> _stageSessionCardio(int start, int end,
List<GravTs> grav, List<HrTs> hr, List<RrTs> rr) {
final span = end - start;
if (span <= 0) return const <StageSegment>[];
final epSec = epochS.round();
final minStageableSec = 3 * epSec;
if (span < minStageableSec) return [StageSegment(start, end, 'wake')];
if (span < minStageableSec) return [StageSegment(start, end, 'unobserved')];

// Invalid samples are NOT samples: they must not seed `usable`, and they
// must not become the source of a carry-forward. A second with only an
// undecoded vector falls through to the same unstaged→WAKE path as a
// undecoded vector falls through to the same unstaged→unobserved path as a
// second with no row at all (cardioStager itself never reads
// [AccelSample.valid], so it can only be protected here).
final gByTs = <int, GravTs>{
Expand Down Expand Up @@ -1475,8 +1480,8 @@ class AdvancedSleepStager {
final rrMs = [for (final r in rSeg) r.rrMs];
final rrTsMs = [for (final r in rSeg) r.ts * 1000.0];

// Everything not staged below stays 'wake' — the honest default.
final perSec = List<String>.filled(span, 'wake');
// Everything not staged below stays 'unobserved' — no stage, no wake.
final perSec = List<String>.filled(span, 'unobserved');
var i = 0;
while (i < span) {
if (!usable[i]) {
Expand Down
7 changes: 6 additions & 1 deletion lib/src/onehz/sleep/hr_fallback.dart
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,12 @@ HrLedWindow? hrLedSleepWindow(
(k == 0 || tsSec[k] - tsSec[k - 1] <= maxSampleGapSec)) {
k++;
}
if (k < n && low(k) && (tsSec[k] - tsSec[j]) < bridgeGapSec) {
// A look-ahead that stopped on a hole bridged nothing: taking k there
// would end the run on the awake stretch just before the hole.
if (k < n &&
low(k) &&
(tsSec[k] - tsSec[j]) < bridgeGapSec &&
tsSec[k] - tsSec[k - 1] <= maxSampleGapSec) {
j = k;
} else {
break;
Expand Down
6 changes: 4 additions & 2 deletions lib/src/onehz/sleep/segment.dart
Original file line number Diff line number Diff line change
Expand Up @@ -597,10 +597,12 @@ SleepSegmentation segmentSleep(
}
}
// Stamped LAST: an unobserved second has no stage, whatever a staging segment
// spanning the hole happened to claim.
// spanning the hole happened to claim. A second the stager left unstaged
// ('unobserved', e.g. a run it abstained on) is unobserved too, HR or not.
var unobservedSec = 0;
for (var i = 0; i < inBed; i++) {
if (observed[i]) continue;
if (observed[i] && stages4[i] != 'unobserved') continue;
observed[i] = false;
stages4[i] = 'unobserved';
unobservedSec++;
}
Expand Down
25 changes: 25 additions & 0 deletions test/onehz/hr_fallback_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,30 @@ void main() {
hrBaseline: List<double>.filled(120, 72));
expect(w, isNull);
});

test('an awake stretch ending at a recording hole is not bridged into', () {
// 3 h @50, 20 min @80, 6 min hole, 1 h @50. Pre-fix the look-ahead
// stopped on the hole, accepted the low sample after it, and the run
// ended on the last awake sample: offset t0+12000 instead of ~t0+10800.
final hr = <double>[];
final ts = <int>[];
const t0 = 1700000000;
var t = t0;
void seg(int secs, double bpm) {
for (var i = 0; i < secs; i++) {
hr.add(bpm);
ts.add(t++);
}
}
seg(3 * 3600, 50);
seg(20 * 60, 80);
t += 6 * 60;
seg(3600, 50);
final w = hrLedSleepWindow(hr, ts,
hrBaseline: List<double>.filled(120, 75));
expect(w, isNotNull);
expect(w!.offsetSec, lessThanOrEqualTo(t0 + 3 * 3600 + 300),
reason: 'pre-fix: t0+12000, the end of the awake stretch');
});
});
}
27 changes: 26 additions & 1 deletion test/onehz/sleep_honesty_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//
// The core contract: absent input yields null / unstaged, NEVER a fabricated
// value. `AdvancedSleepStager.stageWindow`'s own docstring promises "Seconds
// with no data ... simply stay unstaged (wake) — honest about gaps, never
// with no data ... simply stay unstaged — honest about gaps, never
// fabricated". Every test here pins a place where the sleep code broke that
// promise and reported a perfect night out of an empty or fragmented signal.
//
Expand Down Expand Up @@ -637,6 +637,31 @@ void main() {
reason: 'pre-fix: 21600 — the dropouts were credited as Light');
expect(s.stages4[90 * 60], 'unobserved');
});

test('a run the stager abstains on is unobserved, not measured WASO', () {
// Forced 8 h, still, HR 55. Two 120 s accel gaps (> the 60 s
// carry-forward) split it into 4 h / 2 h / 2 h runs, and the middle run's
// HR is on 5 min / off 6 min, under cardioStager's 50 % coverage floor,
// so it abstains there. Pre-fix the abstained run came back 'wake' and
// its HR-covered seconds were published as WASO: waso 3615, tst 21600.
final accel = <AccelSample>[];
final hr = <double>[];
const gapA = 4 * 3600, gapB = 6 * 3600;
for (var k = 0; k < 8 * 3600; k++) {
final inGap =
(k >= gapA && k < gapA + 120) || (k >= gapB && k < gapB + 120);
if (inGap) continue;
accel.add(AccelSample((_t0 + k) * 1000.0, 0.005, 0.0, 1.0));
final mid = k >= gapA + 120 && k < gapB;
hr.add(mid && (k - gapA - 120) % 660 >= 300 ? 0.0 : 55.0);
}
final s = segmentSleep(accel, hr,
forcedWindow: (onsetSec: _t0, offsetSec: _t0 + 8 * 3600));
expect(s.present, isTrue);
expect(s.wasoSec, 0, reason: 'pre-fix: 3615 s of abstained run as WASO');
expect(s.stages4[5 * 3600], 'unobserved');
expect(s.unobservedSec!, greaterThan(7000));
});
});

// ═══════════════════════════════════════════════════════════════════════════
Expand Down
Loading