diff --git a/lib/src/onehz/sleep/advanced_stager.dart b/lib/src/onehz/sleep/advanced_stager.dart index 2b44e45..62a0da0 100644 --- a/lib/src/onehz/sleep/advanced_stager.dart +++ b/lib/src/onehz/sleep/advanced_stager.dart @@ -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, @@ -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); @@ -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). @@ -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 _stageSessionCardio(int start, int end, List grav, List hr, List rr) { final span = end - start; if (span <= 0) return const []; 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 = { @@ -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.filled(span, 'wake'); + // Everything not staged below stays 'unobserved' — no stage, no wake. + final perSec = List.filled(span, 'unobserved'); var i = 0; while (i < span) { if (!usable[i]) { diff --git a/lib/src/onehz/sleep/hr_fallback.dart b/lib/src/onehz/sleep/hr_fallback.dart index 9592571..f8ea5f0 100644 --- a/lib/src/onehz/sleep/hr_fallback.dart +++ b/lib/src/onehz/sleep/hr_fallback.dart @@ -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; diff --git a/lib/src/onehz/sleep/segment.dart b/lib/src/onehz/sleep/segment.dart index bff96eb..f72cf46 100644 --- a/lib/src/onehz/sleep/segment.dart +++ b/lib/src/onehz/sleep/segment.dart @@ -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++; } diff --git a/test/onehz/hr_fallback_test.dart b/test/onehz/hr_fallback_test.dart index 2161b5d..4694d73 100644 --- a/test/onehz/hr_fallback_test.dart +++ b/test/onehz/hr_fallback_test.dart @@ -65,5 +65,30 @@ void main() { hrBaseline: List.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 = []; + final ts = []; + 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.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'); + }); }); } diff --git a/test/onehz/sleep_honesty_test.dart b/test/onehz/sleep_honesty_test.dart index 5bcf000..b307b2d 100644 --- a/test/onehz/sleep_honesty_test.dart +++ b/test/onehz/sleep_honesty_test.dart @@ -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. // @@ -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 = []; + final hr = []; + 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)); + }); }); // ═══════════════════════════════════════════════════════════════════════════