diff --git a/ALGORITHMS.md b/ALGORITHMS.md index 7529a12..8ad09df 100644 --- a/ALGORITHMS.md +++ b/ALGORITHMS.md @@ -87,7 +87,6 @@ Grouped by family (subdirectory under `lib/src/onehz/`). File paths are relative | `cardioStager` | `sleep/cardio_stager.dart` | Webster/Cole-Kripke actigraphy + HRV fusion — explicitly replaces Walch 2019 (documented WAKE over-call bias) | | `AdvancedSleepStager` | `sleep/advanced_stager.dart` | AASM-style 4-class staging + hypnogram metrics (TIB/TST/SOL/WASO/REM-latency); `StagingMethod.cardio` is the wired production default, v1/v2 kept for regression coverage only | | `sleepAccounting` | `sleep/accounting.dart` | TIB/TST/WASO/efficiency accounting | — | -| `cardiopulmonaryCoupling` | `sleep/cpc.dart` | cardiopulmonary coupling | — | | `sleepCyclesMetric` | `sleep/cycles.dart` | fractal ultradian sleep-cycle detection | Rosenblum et al. 2024 | | `circadianNonparametric` | `sleep/circadian_np.dart` | IS/IV/RA/L5/M10 nonparametric circadian indices | — | | `phillipsSri` | `sleep/sri.dart` | true Phillips epoch-agreement Sleep Regularity Index | Phillips et al. 2017 | diff --git a/README.md b/README.md index 5edee80..c2c0bce 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,8 @@ foundation layers: reference; the 0-100 `trimpStrain` effort score is raw TRIMP with no such subtraction), a Poincaré irregular-rhythm screen, cosinor circadian fitting, and real-time cardiac coherence for guided breathing sessions. - **`sleep/`** — van Hees z-angle segmentation feeding a cardiac/motion stager (the - single source of truth for sleep staging), AASM hypnogram metrics, cardiopulmonary - coupling, fractal sleep-cycle detection, circadian non-parametric indices + single source of truth for sleep staging), AASM hypnogram metrics, + fractal sleep-cycle detection, circadian non-parametric indices (IS/IV/RA/L5/M10). - **`respiration/`** — RSA-derived respiratory rate fused with motion-modulated RIIV, CVHR-based apnea screening, a relative (never absolute) oxygen-desaturation ratio. diff --git a/lib/src/onehz/sleep/advanced_stager.dart b/lib/src/onehz/sleep/advanced_stager.dart index 184d28a..7d39f87 100644 --- a/lib/src/onehz/sleep/advanced_stager.dart +++ b/lib/src/onehz/sleep/advanced_stager.dart @@ -39,7 +39,7 @@ import 'dart:math' as math; import '../types.dart'; import '../util.dart'; import '../clinical/hrv_time.dart'; -import 'cardio_stager.dart' show cardioStager; +import 'cardio_stager.dart' show cardioStager, minHrCoverage; import 'accounting.dart' show SleepStage; import 'band_offset.dart'; @@ -405,6 +405,12 @@ class AdvancedSleepStager { if (!continuesChain) { chainFromOvernight = _isOvernightOnset(p.start, tzAt); nightTailAccepted = false; // new chain re-arms the one-shot exemption + } else if (!chainFromOvernight && _isOvernightOnset(p.start, tzAt)) { + // A chain that opened on an evening doze (onset before 20:00) becomes + // overnight once the real night continues it. Judging only the first + // session would leave the flag false for the whole night and drop the + // short re-onset after a pre-dawn arousal at the 60-min floor. + chainFromOvernight = true; } else if (isNightTail && (p.end - p.start) <= minSleepS) { // Consume the one-shot ONLY when a SHORT tail actually relied on the floor // exemption; a >60-min continuation passes the floor unaided and must not @@ -1435,9 +1441,14 @@ class AdvancedSleepStager { /// way. /// /// HONESTY: seconds with no usable accelerometer are NOT staged. The window - /// 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 + /// is split at those gaps into contiguous usable RUNS, which are laid end to + /// end (gap seconds dropped, never filled) and staged in ONE `cardioStager` + /// call with the run boundaries passed as `runStarts`: the night baselines + /// come from every run, while continuity rules never reach across a gap. + /// Staging each run on its own judged a short run against itself, so a run + /// holding only an awakening set its arousal cut above its own awake HR and + /// came out light/deep sleep end to end. The gap seconds, runs too short or + /// too HR-sparse to stage, and a window where /// `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 @@ -1492,6 +1503,10 @@ class AdvancedSleepStager { // Everything not staged below stays 'unobserved' — no stage, no wake. final perSec = List.filled(span, 'unobserved'); + // Stageable runs. All but the last are trimmed to whole epochs so every run + // boundary sits on the epoch grid; cardioStager scores no sub-epoch tail + // anyway, and the tail takes the run's last label below. + final runs = <({int from, int to, int nEp})>[]; var i = 0; while (i < span) { if (!usable[i]) { @@ -1503,33 +1518,74 @@ class AdvancedSleepStager { j++; } if ((j - i) >= minStageableSec) { - final result = cardioStager( - hr1hz.sublist(i, j), - accel.sublist(i, j), - rrMs: rrMs, - rrTsMs: rrTsMs, - ); - final nEpoch = result.base.stages.length; - for (var e = 0; e < nEpoch; e++) { - final label = switch (result.base.stages[e]) { - SleepStage.wake => 'wake', - SleepStage.rem => 'rem', - SleepStage.nrem => (e < result.deepFlag.length && result.deepFlag[e]) - ? 'deep' - : 'light', - }; + final nEp = (j - i) ~/ epSec; + // cardioStager's HR-coverage abstention, applied per run as it was + // when each run was staged alone: pooled with well-covered runs, a run + // with no HR would pass the window-wide gate and fall through to NREM. + var withHr = 0; + for (var e = 0; e < nEp; e++) { final lo = i + e * epSec; - // The last epoch absorbs the run's sub-epoch remainder, exactly as - // [_buildSegments] extends the final segment to the window end. - final hi = e == nEpoch - 1 ? j : math.min(j, lo + epSec); - for (var k = lo; k < hi; k++) { - perSec[k] = label; + for (var k = lo; k < lo + epSec; k++) { + if (hr1hz[k] > 0) { + withHr++; + break; + } } } + if (withHr >= minHrCoverage * nEp) { + runs.add((from: i, to: j, nEp: nEp)); + } } i = j; } + if (runs.isNotEmpty) { + final hrCat = []; + final accelCat = []; + final runStarts = []; + for (var ri = 0; ri < runs.length; ri++) { + final r = runs[ri]; + runStarts.add(hrCat.length); + // The last run keeps its tail (no boundary follows it), so a window + // with a single run is staged exactly as before. + final stop = ri == runs.length - 1 ? r.to : r.from + r.nEp * epSec; + hrCat.addAll(hr1hz.sublist(r.from, stop)); + accelCat.addAll(accel.sublist(r.from, stop)); + } + final result = cardioStager( + hrCat, + accelCat, + rrMs: rrMs, + rrTsMs: rrTsMs, + runStarts: runStarts, + ); + final stages = result.base.stages; + // Empty = cardioStager abstained: everything stays 'unobserved'. + if (stages.isNotEmpty) { + var g = 0; // epoch index into the concatenated result + for (final r in runs) { + for (var e = 0; e < r.nEp; e++, g++) { + if (g >= stages.length) break; + final label = switch (stages[g]) { + SleepStage.wake => 'wake', + SleepStage.rem => 'rem', + SleepStage.nrem => + (g < result.deepFlag.length && result.deepFlag[g]) + ? 'deep' + : 'light', + }; + final lo = r.from + e * epSec; + // The last epoch absorbs the run's sub-epoch remainder, exactly as + // [_buildSegments] extends the final segment to the window end. + final hi = e == r.nEp - 1 ? r.to : lo + epSec; + for (var k = lo; k < hi; k++) { + perSec[k] = label; + } + } + } + } + } + // Coalesce the per-second labels into contiguous [StageSegment]s tiling // [start, end) — same segment semantics [_buildSegments] produces. final segments = []; diff --git a/lib/src/onehz/sleep/cardio_stager.dart b/lib/src/onehz/sleep/cardio_stager.dart index 6738c3a..e13bf6c 100644 --- a/lib/src/onehz/sleep/cardio_stager.dart +++ b/lib/src/onehz/sleep/cardio_stager.dart @@ -61,8 +61,9 @@ // TODO: re-run the DREAMT effect size for RMSSD at ±90 s and publish both // numbers side by side. DO NOT move the shipped weights on the strength of // this — it says the measurement is untrustworthy, not which way it moves. -// Post: Webster continuity rescore (bridges brief arousals into sleep — this is -// what kills the over-call) + consolidateSleepStages (no single-epoch flicker). +// Post: arousal-bridging continuity smoother (bridges brief arousals into sleep +// — this is what kills the over-call; it is NOT Webster's rescore, see +// `_websterRescore`) + consolidateSleepStages (no single-epoch flicker). // // LOCAL vs WHOLE-NIGHT baselines (2026-07, real-data root cause): a real WHOOP-4 // capture showed BOTH the motion and HR features have night-scale @@ -373,11 +374,16 @@ void resetCardioObservations() => _cardioObservations.clear(); /// clock as `accel[i].tsMs`, ASCENDING in [rrTsMs] (the per-epoch window /// gather lower-bounds into it). Sparse/empty is fine — REM/deep just lean /// more on HR and confidence drops. +/// [runStarts] sample indices (ascending, each a multiple of one epoch's +/// worth of samples) where a new contiguous recording run begins, when the +/// input is several runs separated by unrecorded gaps and laid end to end. +/// See [classifyCardioEpochs]' `segmentStarts` for what that changes. CardioStagerResult cardioStager( List hr1hz, List accel, { List rrMs = const [], List rrTsMs = const [], + List runStarts = const [], int epochSec = _epochSec, SleepUserProfile? userProfile, double remScoreCut = _remScoreCut, @@ -430,6 +436,13 @@ CardioStagerResult cardioStager( return _abstain(epochSec); final nEpoch = n ~/ perEpoch; if (nEpoch < 3) return _abstain(epochSec); + assert(runStarts.every((i) => i % perEpoch == 0), + 'runStarts must fall on epoch boundaries'); + final segStarts = [ + for (final i in runStarts) + if (i ~/ perEpoch > 0 && i ~/ perEpoch < nEpoch) i ~/ perEpoch + ]; + final segOf = _segmentOfEpoch(segStarts, nEpoch); // ── per-second ENMO (motion) against a LOCALLY-ADAPTIVE 1 g reference ────── // A single whole-night gravity-magnitude reference (the old approach) is @@ -461,8 +474,13 @@ CardioStagerResult cardioStager( final gRefByEpoch = List.filled(nEpoch, 1.0); for (var e = 0; e < nEpoch; e++) { final es = e * perEpoch; - final lo = math.max(0, es - gRefHalf); - final hi = math.min(n, es + perEpoch + gRefHalf); + // Clipped to this epoch's own run: across a gap the wearer may have + // changed posture, and a reference straddling two postures reads the + // offset between them as motion. + final segLo = segOf.lo[e] * perEpoch; + final segHi = segOf.hi[e] == nEpoch ? n : segOf.hi[e] * perEpoch; + final lo = math.max(segLo, es - gRefHalf); + final hi = math.min(segHi, es + perEpoch + gRefHalf); gRefByEpoch[e] = median(mag.sublist(lo, hi)) ?? 1.0; } @@ -518,12 +536,32 @@ CardioStagerResult cardioStager( sdnn: sdnn, ), epochSec: epochSec, + segmentStarts: segStarts, userProfile: profile, remScoreCut: remScoreCut, deepScoreCut: deepScoreCut, ); } +/// For each epoch, the [lo, hi) epoch range of the run it belongs to. +({List lo, List hi}) _segmentOfEpoch( + List segmentStarts, int nEpoch) { + final bounds = {0, nEpoch}; + for (final s in segmentStarts) { + if (s > 0 && s < nEpoch) bounds.add(s); + } + final sorted = bounds.toList()..sort(); + final lo = List.filled(nEpoch, 0); + final hi = List.filled(nEpoch, nEpoch); + for (var b = 0; b + 1 < sorted.length; b++) { + for (var e = sorted[b]; e < sorted[b + 1]; e++) { + lo[e] = sorted[b]; + hi[e] = sorted[b + 1]; + } + } + return (lo: lo, hi: hi); +} + /// Per-epoch feature vectors — the ONLY thing the staging DECISION layer sees. /// /// Splitting these out from [cardioStager] draws the line between "turn 1 Hz @@ -590,9 +628,20 @@ class CardioEpochFeatures { /// [remScoreCut] / [deepScoreCut] default to the shipped constants and exist so /// a calibration harness can sweep them on a DEVELOPMENT split and report the /// held-out result. Production callers should not pass them. +/// +/// [segmentStarts] epoch indices where a new contiguous recording run begins. +/// The night baselines (motion median/MAD, the local HR median and arousal cut, +/// the robust-z scales) are taken across ALL runs, because a run is not a +/// night: a short run that holds only an awakening would otherwise be judged +/// against its own awake HR, never reach its own arousal cut, and fall through +/// to NREM end to end. Everything that assumes two epochs are adjacent in time +/// (the previous-epoch big-move test, the mode filter, Webster continuity, +/// consolidation, the short-deep merge) stays inside one run, so nothing is +/// bridged across time nobody recorded. CardioStagerResult classifyCardioEpochs( CardioEpochFeatures f, { int epochSec = _epochSec, + List segmentStarts = const [], SleepUserProfile? userProfile, double remScoreCut = _remScoreCut, double deepScoreCut = _deepScoreCut, @@ -623,6 +672,7 @@ CardioStagerResult classifyCardioEpochs( final lfhf = f.lfhf; final rk = f.rk; final sdnn = f.sdnn; + final segOf = _segmentOfEpoch(segmentStarts, nEpoch); // ── night baselines (from the LOW-MOTION epochs — the actual sleep) ──────── // motMed/motMad stay WHOLE-NIGHT scalars: `motion` is now computed against a @@ -810,7 +860,7 @@ CardioStagerResult classifyCardioEpochs( // within half an epoch 'unobserved' and keeps it out of TST, WASO and the // efficiency denominator. final hrUp = !hr[e].isNaN && hr[e] >= hrArousal; - final bigPrev = e > 0 && bigMove(e - 1); + final bigPrev = e > segOf.lo[e] && bigMove(e - 1); final bigMoveWake = bigMove(e) && ((!hr[e].isNaN && hr[e] >= hrMed) || bigPrev); if (hrUp || bigMoveWake) { @@ -913,9 +963,14 @@ CardioStagerResult classifyCardioEpochs( // A 3-epoch categorical median (mode) filter first, to drop isolated // single-epoch label flips (e.g. a lone REM/deep spike inside a stable run) // before the continuity/consolidation stages act on min bouts. - _modeFilterStages(stages); - _websterRescore(stages, epochSec); - final sm = consolidateSleepStages(stages, epochSec); + // Per run — see [segmentStarts]. With a single run this is the whole night. + final sm = []; + for (var lo = 0; lo < nEpoch; lo = segOf.hi[lo]) { + final part = stages.sublist(lo, segOf.hi[lo]); + _modeFilterStages(part); + _websterRescore(part, epochSec); + sm.addAll(consolidateSleepStages(part, epochSec)); + } // Keep deepFlag in lockstep with the FINAL consolidated stage: the mode filter, // Webster rescore, and consolidation can move a deep-flagged NREM epoch to REM // or Wake. Clear the flag there so no epoch is ever reported deep while its @@ -924,7 +979,12 @@ CardioStagerResult classifyCardioEpochs( for (var e = 0; e < deepFlag.length && e < sm.length; e++) { if (deepFlag[e] && sm[e] != SleepStage.nrem) deepFlag[e] = false; } - _mergeShortDeep(deepFlag, sm, epochSec); + for (var lo = 0; lo < nEpoch; lo = segOf.hi[lo]) { + final hi = segOf.hi[lo]; + final part = deepFlag.sublist(lo, hi); + _mergeShortDeep(part, sm.sublist(lo, hi), epochSec); + deepFlag.setRange(lo, hi, part); + } // ── record this night's baselines for the rolling per-user profile (P2) ───── // Only when the edge armed recording AND the staged span is a real sleep @@ -940,8 +1000,12 @@ CardioStagerResult classifyCardioEpochs( (hrSleepMed ?? hrMedGlobal) + math.max(6.0, stddev(sleepHr) ?? 6.0), rmssdMed: rmssdMed, rmssdMad: mad(sleepRmssd), - enmoStillCut: motMed + 1.5 * motMad, - enmoMoveCut: motMed + 5.0 * motMad, + // A MAD-0 night abstained from the motion axis above; its cuts would be + // the same degenerate ~0 g threshold, and folding that into the profile + // drags later nights' big-move bar toward 0 so ordinary repositioning + // reads as WAKE. No measurement ⇒ null ⇒ the fold leaves the axis alone. + enmoStillCut: motUsable ? motMed + 1.5 * motMad : null, + enmoMoveCut: motUsable ? motMed + 5.0 * motMad : null, lfhfMed: median(sleepLfhf), rkMed: median(sleepRk), )); @@ -1163,14 +1227,27 @@ List cleanBeatDiffsInWindowForTest( rk: _windowRemFeatures(rrMs, rrTsMs, accel, s, t, _epochSec).rk, ); -/// Webster sleep-continuity rescore: brief wake bouts flanked by enough sleep -/// are re-labelled sleep (NREM). This is the published actigraphy step that -/// prevents normal in-sleep repositioning from inflating WASO. +/// Arousal-bridging continuity smoother: brief wake bouts flanked by enough +/// sleep are re-labelled sleep (NREM), so in-sleep repositioning that the +/// HR/motion wake spine over-calls does not inflate WASO. +/// +/// This is NOT Webster's rescore and is not a published method. Webster et +/// al. 1982 / Cole et al. 1992 run the other way: after >=4/10/15 min of +/// scored WAKE the next 1/3/4 min of SLEEP become wake, and a sleep bout of +/// <=6 (<=10) min with >=10 (>=20) min of wake on both sides becomes wake. Their +/// pass only ever removes sleep; this one only ever adds it, borrowing the +/// same minute values as its bounds. It is a deliberate bias toward TST: each +/// rule row can hide a genuine awakening of up to that row's length. +/// +/// Context is the LONGER of the two flanking sleep runs, not both. Requiring +/// both sides was measured: on the Apple-Watch-labelled real night +/// (real_night_cardio_stager_test, reference wake = 3 min) it raised wake from +/// 26.0 to 41.5 min, i.e. the one-sided context is absorbing over-called wake +/// on real data, not erasing real awakenings there. /// /// The flanking-sleep CONTEXT is measured against an immutable SNAPSHOT of the -/// hypnogram taken before the pass — Webster's rule scores each wake bout -/// against the ORIGINAL surrounding sleep (Webster et al. 1982; Cole et al. -/// 1992), not against sleep this same pass just manufactured. Reading the list +/// hypnogram taken before the pass, not against sleep this same pass just +/// manufactured. Reading the list /// being mutated let every bridged bout count as context for the next one, so /// bridging CASCADED: a fragmented night of short sleep bouts separated by long /// wake bouts collapsed into one continuous sleep block (WASO 0, efficiency @@ -1211,9 +1288,10 @@ void _websterRescore(List sm, int epochSec) { return c; } - // Context (min sleep flanking) → max bridgeable wake (min). THE PUBLISHED - // TABLE, unmodified: Webster et al. 1982 / Cole et al. 1992 rescoring rules — - // 4 min of sleep bridges ≤1 min of wake, 10 bridges ≤3, 15 bridges ≤4. + // Context (min sleep flanking) → max bridgeable wake (min). The minute values + // are Webster et al. 1982 / Cole et al. 1992's; the direction is not (see the + // doc comment) — 4 min of sleep bridges ≤1 min of wake, 10 bridges ≤3, 15 + // bridges ≤4. // // This used to run [15→10], [10→5], [4→2] — 2.5x the published bridge — on // the argument that the van Hees window had already certified the span as the diff --git a/lib/src/onehz/sleep/cpc.dart b/lib/src/onehz/sleep/cpc.dart deleted file mode 100644 index 9216359..0000000 --- a/lib/src/onehz/sleep/cpc.dart +++ /dev/null @@ -1,93 +0,0 @@ -// SLEEP/CIRCADIAN — Cardiopulmonary Coupling (CPC). WITHDRAWN. -// -// IF YOU GOT HERE FROM A GREP: this function has zero callers ON PURPOSE. it is -// not an oversight, not half-wired, not waiting for someone with time. every -// sweep of this repo re-proposes it, because a tested function with no caller -// reads as a loose end from the outside and as a decision only once you open -// the file. you have opened the file. the reason is below, and edge's -// compute/onehz_pipeline.dart records the same verdict where the call would go. -// -// Thomas et al. 2005 (Sleep) derives CPC from the cross-spectral coherence of -// TWO signals: the RR tachogram, and an ECG-DERIVED RESPIRATION (EDR) surrogate -// taken from the R-wave amplitude — a separate channel that is modulated by -// breathing but is NOT a function of the beat times. -// -// This implementation had no such channel. Its "respiration surrogate" was the -// NN series itself, linearly detrended: -// -// resp = detrend([v - mean(nnMs) for v in nnMs]) -// -// which differs from the tachogram only by a straight line. The coupling -// spectrum sqrt(P_rr . P_resp) therefore collapses to P_rr, and `cpc_ratio` -// came out equal to the plain RR periodogram HF/LF ratio — measured 84.493910 -// vs 84.493190 on a 9000-beat synthetic, a ratio of 1.0000085. It was the LF/HF -// of the same tachogram we already publish as `lf_hf`, wearing the name of a -// different, published, sleep-stability measure. -// -// The honest options were "derive a real EDR" or "stop shipping it". We cannot -// derive an EDR here — this function is handed beat intervals and beat times -// and nothing else, and a respiration channel independent of the tachogram is -// by definition not recoverable from them. So the metric is WITHDRAWN: it now -// abstains, with the reason attached, and the UI element it fed should be -// DELETED rather than made to explain a dash. -// -// Wiring it back up means changing the signature to take an actual respiration -// channel (PPG AC amplitude / RIIV, cf. respiration/resp_rate.dart) and -// validating the result against something. Until then there is no number here. -// -// RSA-derived respiration DOES NOT QUALIFY as that channel. it comes out of the -// same tachogram, so it reproduces exactly the failure above with more steps. -// the input has to be independent of the beat times or the coupling spectrum -// collapses again, and shipping the collapse under a clinical-sounding name is -// republishing `lf_hf` — already charted — as a second, fake metric. - -import '../types.dart'; -import '../util.dart'; - -class CpcResult { - final double hfc; // high-frequency coupling power (stable NREM) - final double lfc; // low-frequency coupling power (unstable / apnea-rich) - final double vlfc; // very-low-frequency (wake/REM) - final double cpcRatio; // HFC / LFC — sleep-stability index - final double dominantHz; // dominant coupling frequency - const CpcResult({ - required this.hfc, - required this.lfc, - required this.vlfc, - required this.cpcRatio, - required this.dominantHz, - }); - Map toJson() => { - 'hfc': round6(hfc), - 'lfc': round6(lfc), - 'vlfc': round6(vlfc), - 'cpc_ratio': round6(cpcRatio), - 'dominant_hz': round6(dominantHz), - }; -} - -/// CPC from a cleaned NN series + its beat times. -/// -/// ALWAYS ABSENT — see the file header. There is no respiration channel in this -/// signature that is independent of [nnMs], so there is no coupling to measure; -/// the previous implementation republished the RR periodogram's own HF/LF ratio -/// under Thomas 2005's name. The signature is kept so callers keep compiling -/// while the key is removed at their end. -@Deprecated( - 'WITHDRAWN — this never measured cardiopulmonary coupling. Its "respiration ' - 'surrogate" was the NN series itself, so cpc_ratio equalled the RR ' - 'periodogram HF/LF ratio (measured agreement 1.0000085). Delete the cpc_ratio ' - 'surface; reinstate only with a real respiration channel in the signature.', -) -Metric cardiopulmonaryCoupling( - List nnMs, - List nnTimesMs, -) { - return const Metric.absent( - tier: Tier.high, - inputs_used: ['rr_corrected'], - note: 'cardiopulmonary coupling needs a respiration channel independent of ' - 'the beat times (Thomas 2005 uses an ECG-derived respiration); we have ' - 'only the tachogram, so no coupling is measured', - ); -} diff --git a/lib/src/onehz/sleep/cycles.dart b/lib/src/onehz/sleep/cycles.dart index 6ef0a72..2be8ef7 100644 --- a/lib/src/onehz/sleep/cycles.dart +++ b/lib/src/onehz/sleep/cycles.dart @@ -57,6 +57,15 @@ const int _smoothMin = 10; // ± window of the moving average const int _minPeakDist = 20; // min minutes between cycle peaks const double _minProminence = 0.9; // z-score prominence to qualify a peak +/// Longest run of unobserved RAW minutes (no per-minute RMSSD) a cycle may +/// span. Contact loss, charging or an RR dropout leaves the per-minute series +/// null; pairing the peak before such a hole with the peak after it reported +/// the hole itself as one long "cycle" and dragged the mean cycle length up +/// with it. Counted on the raw minutes, not the smoothed series: the ±10 min +/// moving average paints 10 min into each edge of a hole, so a gate on the +/// smoothed series would let a 40-min hole through. +const int _maxGapMin = 20; + /// Physiologic RR gate (ms): keep 300–2000, drop |Δ|>200 (ectopy/artifact). const double _rrMin = 300, _rrMax = 2000, _rrStep = 200; @@ -117,15 +126,19 @@ SleepCyclesResult detectSleepCycles( // ── z-score ──────────────────────────────────────────────────────────────── final mean = vals.reduce((a, b) => a + b) / vals.length; final sd0 = math.sqrt( - vals.fold(0, (a, b) => a + (b - mean) * (b - mean)) / vals.length); + vals.fold(0, (a, b) => a + (b - mean) * (b - mean)) / + vals.length); final sd = sd0 == 0 ? 1.0 : sd0; final z = [for (final x in sm) x == null ? null : (x - mean) / sd]; // ── prominence-gated peaks → peak-to-peak cycles ─────────────────────────── - final peaks = _findPeaks(z, _minPeakDist, _minProminence); + // A peak must sit on an observed minute: inside a hole the smoothed value + // is extrapolated from one side only, so its position is not evidence. + final peaks = _findPeaks(z, perMin, _minPeakDist, _minProminence); final cycles = []; for (var i = 0; i + 1 < peaks.length; i++) { final a = peaks[i], b = peaks[i + 1]; + if (_longestNullRun(perMin, a, b) > _maxGapMin) continue; cycles.add(SleepCycle(a, b, b - a)); } final meanDur = cycles.isEmpty @@ -144,6 +157,19 @@ SleepCyclesResult detectSleepCycles( return SleepCyclesResult(cycles, meanDur, cycles.length, series); } +int _longestNullRun(List y, int from, int to) { + var best = 0, run = 0; + for (var k = from; k <= to; k++) { + if (y[k] == null) { + run++; + if (run > best) best = run; + } else { + run = 0; + } + } + return best; +} + double? _rmssd(List rr) { if (rr.length < 10) return null; var s = 0.0, n = 0; @@ -157,22 +183,48 @@ double? _rmssd(List rr) { } /// Local maxima with z-prominence ≥ [minProm], pruned to ≥ [minDist] apart -/// (tallest-first). Returns peak minute-indices, ascending. -List _findPeaks(List y, int minDist, double minProm) { +/// (tallest-first). Only minutes observed in [raw] are candidates, filtered +/// before the distance pruning so an extrapolated point cannot suppress a +/// real neighbour. Returns peak minute-indices, ascending. +List _findPeaks( + List y, List raw, int minDist, double minProm) { final n = y.length; final cand = >[]; // [index, value] for (var i = 1; i < n - 1; i++) { final yi = y[i]; - if (yi == null) continue; - final a = y[i - 1] ?? double.negativeInfinity; - final b = y[i + 1] ?? double.negativeInfinity; - if (!(yi >= a && yi > b)) continue; + final a = y[i - 1], b = y[i + 1]; + // A missing neighbour is unknown, not -inf. A point beside a data hole is + // judged against its observed side only; with no observed neighbour at + // all it is not a local maximum on any evidence. + if (yi == null || raw[i] == null || (a == null && b == null)) continue; + if (a != null && yi < a) continue; + if (b != null && yi <= b) continue; + // Walk each side until a higher point, the array edge, or a hole. A side + // that runs into a hole has no observed base beyond it, so it does not + // contribute to the prominence. When BOTH sides do, the peak is the + // tallest point of an island between two holes; skipping it dropped that + // peak and every cycle beside it, so the island's own minima stand in + // for the base, as they would at the array edges. var l = i; - while (l > 0 && (y[l - 1] ?? double.negativeInfinity) < yi) { + var lHole = false; + while (l > 0) { + final v = y[l - 1]; + if (v == null) { + lHole = true; + break; + } + if (v >= yi) break; l--; } var r = i; - while (r < n - 1 && (y[r + 1] ?? double.negativeInfinity) < yi) { + var rHole = false; + while (r < n - 1) { + final v = y[r + 1]; + if (v == null) { + rHole = true; + break; + } + if (v >= yi) break; r++; } var lmin = yi, rmin = yi; @@ -184,7 +236,10 @@ List _findPeaks(List y, int minDist, double minProm) { final v = y[k]; if (v != null && v < rmin) rmin = v; } - if (yi - math.max(lmin, rmin) >= minProm) cand.add([i.toDouble(), yi]); + final base = lHole == rHole + ? math.max(lmin, rmin) + : (lHole ? rmin : lmin); + if (yi - base >= minProm) cand.add([i.toDouble(), yi]); } cand.sort((p, q) => q[1].compareTo(p[1])); // tallest first final kept = []; diff --git a/lib/src/onehz/sleep/hr_fallback.dart b/lib/src/onehz/sleep/hr_fallback.dart index f8ea5f0..15ebbab 100644 --- a/lib/src/onehz/sleep/hr_fallback.dart +++ b/lib/src/onehz/sleep/hr_fallback.dart @@ -16,6 +16,8 @@ // "is this right?" prompt — never as a confirmed auto detection. import '../util.dart'; +import 'advanced_stager.dart' show AdvancedSleepStager; +import 'segment.dart' show kUnfinishedNightEdgeSec; /// A proposed in-bed window (epoch seconds). Returned by [hrLedSleepWindow]. class HrLedWindow { @@ -39,6 +41,17 @@ class HrLedWindow { /// /// Returns the longest qualifying low-HR run (≥ [minDurationSec], brief /// arousals < [bridgeGapSec] bridged), or null when no convincing dip exists. +/// +/// [recordContinues]: the caller cut this slice out of a longer record (a +/// calendar day ending at local midnight). A dip still going at the last sample +/// has no knowable end, so it is never proposed — the same deferral +/// `segmentSleep(recordContinues: true)` applies, and for the same reason: a +/// fallback run over the slice the detector just deferred would otherwise +/// propose exactly that cut-off stretch as a finished night. Runs chained to +/// that dip are deferred with it on the same rule segmentSleep uses: under +/// 60 min apart always, up to 90 min only when the chain is overnight (some run +/// in it starts outside the daytime band, read in local time via +/// [tzOffsetResolver], default the machine's offset at that instant). HrLedWindow? hrLedSleepWindow( List hr1hz, List tsSec, { @@ -47,6 +60,8 @@ HrLedWindow? hrLedSleepWindow( int bridgeGapSec = 30 * 60, double smoothSec = 300, int maxSampleGapSec = 5 * 60, + bool recordContinues = false, + int Function(int tsSec)? tzOffsetResolver, }) { final n = hr1hz.length < tsSec.length ? hr1hz.length : tsSec.length; if (n < minDurationSec ~/ 2) return null; @@ -79,7 +94,7 @@ HrLedWindow? hrLedSleepWindow( bool low(int i) => smooth[i] > 0 && smooth[i] < threshold!; // 4. Longest low run, bridging brief arousals (gap-aware via real timestamps). - var bestStart = -1, bestEnd = -1, bestDur = 0; + final runs = <({int start, int end, int dur})>[]; var i = 0; while (i < n) { if (!low(i)) { @@ -118,14 +133,65 @@ HrLedWindow? hrLedSleepWindow( } } final dur = tsSec[(j - 1).clamp(0, n - 1)] - tsSec[i]; - if (dur > bestDur) { - bestDur = dur; - bestStart = i; - bestEnd = j; - } + runs.add((start: i, end: j, dur: dur)); i = j + 1; } + // 5. Defer a dip still going at the cut, and every dip chained to it the + // way segmentSleep chains a night (a long arousal splits tonight in two; + // only the trailing half touches the cut). A finished daytime sleep that + // ended 60-90 min before it is not part of that night and stays eligible. + final deferred = List.filled(runs.length, false); + if (recordContinues && runs.isNotEmpty) { + final int Function(int tsSec) tzAt = tzOffsetResolver ?? + (int t) => DateTime.fromMillisecondsSinceEpoch(t * 1000, isUtc: false) + .timeZoneOffset + .inSeconds; + bool overnightOnset(int start) { + final local = start + tzAt(start); + final hour = (((local % 86400) + 86400) % 86400) ~/ 3600; + return hour < AdvancedSleepStager.daytimeBandStartHour || + hour >= AdvancedSleepStager.daytimeBandEndHour; + } + + int endTs(int r) => tsSec[(runs[r].end - 1).clamp(0, n - 1)]; + // Forward pass: which earlier run each run's chain starts at. + final chainStart = List.filled(runs.length, 0); + for (var r = 1; r < runs.length; r++) { + final gap = tsSec[runs[r].start] - endTs(r - 1); + final from = chainStart[r - 1]; + var chained = false; + if (gap >= 0 && gap < 60 * 60) { + chained = true; + } else if (gap >= 0 && + gap <= AdvancedSleepStager.nightContinuationGapMin * 60) { + chained = overnightOnset(tsSec[runs[r].start]); + for (var q = from; !chained && q < r; q++) { + chained = overnightOnset(tsSec[runs[q].start]); + } + } + chainStart[r] = chained ? from : r; + } + final lastTs = tsSec[n - 1]; + final unfinishedChains = { + for (var r = 0; r < runs.length; r++) + if (lastTs - endTs(r) <= kUnfinishedNightEdgeSec) chainStart[r], + }; + for (var r = 0; r < runs.length; r++) { + deferred[r] = unfinishedChains.contains(chainStart[r]); + } + } + + var bestStart = -1, bestEnd = -1, bestDur = 0; + for (var r = 0; r < runs.length; r++) { + if (deferred[r]) continue; + if (runs[r].dur > bestDur) { + bestDur = runs[r].dur; + bestStart = runs[r].start; + bestEnd = runs[r].end; + } + } + if (bestStart < 0 || bestDur < minDurationSec) return null; final onsetSec = tsSec[bestStart]; final offsetSec = tsSec[(bestEnd - 1).clamp(0, n - 1)] + 1; diff --git a/lib/src/onehz/sleep/nap.dart b/lib/src/onehz/sleep/nap.dart index 268468e..fed4183 100644 --- a/lib/src/onehz/sleep/nap.dart +++ b/lib/src/onehz/sleep/nap.dart @@ -22,7 +22,8 @@ // two detectors have never been shown to agree on real nights. // 2. Enumerate EVERY immobility bout (the nocturnal path keeps only the // longest), bridging brief arousals. -// 3. Reject hard: off-wrist, charging/workout spans, the main sleep window, +// 3. Reject hard: off-wrist, charging/workout spans, the main sleep window +// and short night tails starting within the continuation gap after it, // and bouts built on seconds the band never measured (`minNapAccelCoverage`). // 4. Require an autonomic signature — median HR inside the bout at or below // `napRestingHrMult` × the DAYTIME-AWAKE HR baseline. Stillness alone is @@ -41,6 +42,7 @@ import '../types.dart'; import '../util.dart'; +import 'advanced_stager.dart' show AdvancedSleepStager; import 'van_hees.dart'; /// An index range [start, end) into the day's arrays marking the MAIN @@ -155,6 +157,34 @@ const double maxNapOffWristFraction = 0.5; /// is ending — the exact phantom the deferral exists to prevent. const int napChainGapSec = 60 * 60; +/// A short sleep bout starting within this long after the main sleep ends is +/// the night going on, not a nap. Same gap the night detector chains +/// continuations with, so the two detectors agree on where a night can still +/// be. +/// +/// The night detector does not keep every continuation it sees: once its +/// one-shot short-tail exemption is spent, a second short pre-dawn tail falls +/// to the 60-min floor and the main window ends before it. Those dropped tails +/// are still, HR-dipped and outside the window, so an overlap test alone books +/// them as a 6 AM nap and the night reads short. +/// +/// Only a bout the night detector could actually have dropped is attributed to +/// the night: at most [napNightTailMaxSec] long, and only once it has already +/// passed every nap gate (still, measured, HR-dipped). A long still block right +/// after waking — breakfast, a commute, the start of desk work — is not a tail +/// and is judged like any other bout. +/// +/// The night window itself is not extended here, so a genuine short +/// back-to-sleep within this gap counts toward neither the night nor nap +/// minutes. That loses a few minutes of sleep; booking them as a nap invents +/// one and shortens the night. +const int napNightContinuationSec = + AdvancedSleepStager.nightContinuationGapMin * 60; + +/// Longest bout that can be a dropped night tail: the night detector's own +/// minimum-sleep floor, which is what drops it. +const int napNightTailMaxSec = AdvancedSleepStager.minSleepMin * 60; + /// Minimum awake HR samples needed to define a baseline. Below this the day is /// not judged at all: a threshold set by a handful of samples is not a /// baseline, and every nap decision hangs off it. @@ -190,6 +220,7 @@ Metric> detectNaps( } final series = accel.length == n ? accel : accel.sublist(0, n); + final mask = immobilityMask(series); final win = mask.sustainedSec; final thr = mask.thresholdDeg; @@ -224,8 +255,29 @@ Metric> detectNaps( // can be made better here — the record simply does not say what happened // before it — so it is REPORTED instead, via [NapWindow.startsAtRecordEdge] // below. - bool stillAt(int k) => - mask.deltaDeg[k] < thr && (k == 0 || absAt(k) - absAt(k - 1) == 1); + // + // "Discontinuity" is judged against the published van Hees epoch, not a + // hard-coded 1 s step. The mask accepts any measured cadence up to + // [vanHeesMaxCadenceSec] and scales its windows to it, so a 2 s or 5 s stream + // failed an `== 1` test at every second and could never produce a nap, and a + // 1 Hz stream lost every run that a single skipped second interrupted — both + // then published as a judged "no nap". A step inside one published epoch is + // the resolution the rule is defined at; anything longer is a real hole. + bool continuousAt(int k) { + final stepMs = series[k].tsMs - series[k - 1].tsMs; + return stepMs > 0 && stepMs <= vanHeesMaxCadenceSec * 1000; + } + + bool stillAt(int k) => mask.deltaDeg[k] < thr && (k == 0 || continuousAt(k)); + + // Wall-clock seconds one sample stands for in the coverage gates below. + // Counting samples as seconds put a fully-measured 5 s stream at 20% + // coverage and rejected every bout as unmeasured. The measured cadence is + // used as is and the total rounded once: flooring it to whole seconds + // credited a 0.5 s sample with a full second (doubling coverage on a 2 Hz + // stream) and a 2.5 s sample with two. + final secPerSample = mask.cadenceSec ?? 1.0; + int samplesToSec(int count) => (count * secPerSample).round(); final runs = >[]; var i = 0; @@ -355,6 +407,15 @@ Metric> detectNaps( // A silent `continue` turns "your 7-hour still block is too long to be a nap" // into a bare "no qualifying nap", which tells the caller nothing about why. var outOfRange = 0, inMainSleep = 0, noBaseline = 0, unobserved = 0; + var nightTail = 0; + // Wall-clock end of the main sleep, measured from the window's last second + // since index distance is not elapsed time. + final mainEndIdx = + mainSleep == null ? 0 : (mainSleep.end > n ? n : mainSleep.end); + final int? mainEndAbs = + mainSleep != null && mainEndIdx > 0 && mainEndIdx > mainSleep.start + ? absAt(mainEndIdx - 1) + 1 + : null; for (var bi = 0; bi < bouts.length; bi++) { final start = bouts[bi][0], end = bouts[bi][1]; @@ -386,10 +447,11 @@ Metric> detectNaps( // Wall-clock accel coverage — the seconds inside the bout the band actually // measured gravity for, over the bout's elapsed span (so a recording hole // counts against it exactly as an invalid sample does). - var accelSec = 0; + var accelSamples = 0; for (var k = start; k < end; k++) { - if (series[k].valid) accelSec++; + if (series[k].valid) accelSamples++; } + final accelSec = samplesToSec(accelSamples); if (accelSec / tib < minNapAccelCoverage) { unobserved++; continue; @@ -406,7 +468,8 @@ Metric> detectNaps( for (var k = start; k < end; k++) { if (hr[k] > 0) boutHr.add(hr[k]); } - final coverage = boutHr.length / tib; + final hrSec = samplesToSec(boutHr.length); + final coverage = hrSec >= tib ? 1.0 : hrSec / tib; if (coverage < minNapHrCoverage) { unverifiable++; continue; @@ -443,14 +506,32 @@ Metric> detectNaps( continue; } + // A short sleep bout inside the night's continuation gap is a night tail + // the night detector dropped, not a nap (see [napNightContinuationSec]). + // Checked only now, after the bout has proven itself sleep, so the note + // never calls an awake sit "continuing the main sleep". It stays in the + // awake pool on purpose: removing still, low-HR seconds can only raise the + // baseline, which is the direction that invents naps. + if (mainEndAbs != null && + tib <= napNightTailMaxSec && + aStart >= mainEndAbs && + aStart - mainEndAbs <= napNightContinuationSec) { + nightTail++; + continue; + } + // Time ASLEEP is the still seconds inside the episode; the bridged arousal // is time in bed but not time asleep. This distinction is the whole point: // the sleep-need credit must be TST, and crediting TIB systematically // over-credits and under-recommends sleep. - var tst = 0; + // Credited in wall-clock seconds, like `tib`: counting one per still + // sample put a fully-still 5 s stream at a fifth of its real sleep time. + var stillSamples = 0; for (var k = start; k < end; k++) { - if (stillAt(k)) tst++; + if (stillAt(k)) stillSamples++; } + var tst = samplesToSec(stillSamples); + if (tst > tib) tst = tib; // Confidence, NOT efficiency. A 20% dip below the awake baseline earns // full marks on that axis; the rest rewards evidence, not sleep quality. @@ -481,6 +562,47 @@ Metric> detectNaps( )); } + // An empty list is only a verdict if stillness was observable. Unmeasured + // seconds fail the still test, so on a day the band barely decoded gravity + // on (an HR-only thin-record day, a long decode gap) no bout ever forms and + // the per-bout coverage gate above is never reached. Checked only when + // nothing was found: a nap that passed its own per-bout coverage gate stands + // even when the rest of the day went unmeasured. Coverage is taken over the + // seconds outside the main sleep, since that is the only stretch a nap could + // have come from. + // + // Both sides are wall-clock seconds over the slice's span. Counting rows + // never counted the rows that are missing, so two good hours inside an + // otherwise empty day read as full coverage and returned a judged "no nap". + if (naps.isEmpty) { + final spanSec = absAt(n - 1) + 1 - baseSec; + var mainSec = 0; + if (mainSleep != null) { + final ms = mainSleep.start < 0 ? 0 : mainSleep.start; + final me = mainSleep.end > n ? n : mainSleep.end; + if (me > ms) mainSec = absAt(me - 1) + 1 - absAt(ms); + } + final outside = spanSec - mainSec; + var outsideSamples = 0; + for (var k = 0; k < n; k++) { + if (mainSleep != null && k >= mainSleep.start && k < mainSleep.end) { + continue; + } + if (series[k].valid) outsideSamples++; + } + var outsideAccel = samplesToSec(outsideSamples); + if (outsideAccel > outside) outsideAccel = outside; + if (outside > 0 && outsideAccel / outside < minNapAccelCoverage) { + return Metric>.absent( + tier: Tier.estimate, + inputs_used: inputs, + note: 'accel decoded on $outsideAccel of $outside s outside the main ' + 'sleep (need ${(minNapAccelCoverage * 100).round()}%) — stillness ' + 'could not be observed, so naps cannot be judged', + ); + } + } + // A still block we could not RULE OUT is not the same as a day with no nap. // If nothing was emitted and at least one candidate went unjudged for want of // an awake baseline, the day's verdict is unknown — say so, rather than @@ -502,6 +624,7 @@ Metric> detectNaps( if (noBaseline > 0) '$noBaseline without an awake HR baseline', if (outOfRange > 0) '$outOfRange outside 15 min–6 h', if (inMainSleep > 0) '$inMainSleep inside the main sleep window', + if (nightTail > 0) '$nightTail continuing the main sleep', if (unobserved > 0) '$unobserved with accel coverage <50% (unmeasured)', if (unverifiable > 0) '$unverifiable unverifiable (HR coverage <50%)', if (offWrist > 0) '$offWrist off-wrist/excluded', diff --git a/lib/src/onehz/sleep/night_hrv_shape.dart b/lib/src/onehz/sleep/night_hrv_shape.dart index 7778fba..0e82136 100644 --- a/lib/src/onehz/sleep/night_hrv_shape.dart +++ b/lib/src/onehz/sleep/night_hrv_shape.dart @@ -193,6 +193,18 @@ Metric nightHrvShape( )); } + // Enough beats overall can still be spread so thin that no bin reaches the + // floor. A shape made only of holes measures nothing, so it is an + // abstention, not a present value at zero confidence. + if (!bins.any((b) => b.present)) { + return Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'no ${binMin.round()}-min bin reached $minBeatsPerBin beats ' + 'with a usable RMSSD', + ); + } + final third = bins.length ~/ 3; double? meanOf(Iterable xs) { final v = [ diff --git a/lib/src/onehz/sleep/segment.dart b/lib/src/onehz/sleep/segment.dart index f72cf46..2a9c7b6 100644 --- a/lib/src/onehz/sleep/segment.dart +++ b/lib/src/onehz/sleep/segment.dart @@ -275,9 +275,9 @@ class SleepSegmentation { final double confidence; /// Machine-readable cause when [present] is false and the cause is known - /// (currently: too little of the window was observed). Null when a caller - /// should say nothing more than "no qualifying sleep". Never render a bare - /// dash for an absence that carries one of these. + /// (too little of the window was observed, or [kAbsenceNightUnfinished]). + /// Null when a caller should say nothing more than "no qualifying sleep". + /// Never render a bare 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; @@ -348,6 +348,18 @@ class SleepSegmentation { absenceReason: reason, ); + /// Absent because the only sleep in the record was still going at its last + /// sample while the caller said the record carries on (`recordContinues`). + /// Not "no sleep": the night exists and belongs to whichever slice sees it + /// end. A caller must NOT treat this as a miss and go looking for a window by + /// another route (an HR-led fallback over the same slice proposes the same + /// cut-off stretch). + static SleepSegmentation get unfinishedNight => + unobservedWindow(kAbsenceNightUnfinished); + + /// True when this result is [unfinishedNight]. + bool get unfinished => absenceReason == kAbsenceNightUnfinished; + bool get present => window != null; /// SLP-13 — tonight's stage minutes as INTERVALS, from this night's own @@ -431,6 +443,14 @@ SleepSegmentation segmentSleep( /// creates, extends or stages a night. (AdvancedSleepStager.detectSleep takes /// the same signal as [ts,state] pairs; not forwarded here.) List? bandSleepState, + + /// True when the caller cut [accel]/[hr1hz] out of a longer record and data + /// carries on past the last sample here (e.g. a calendar-day slice ending at + /// local midnight). AUTO path only: a sleep still going at the cut has no + /// knowable end, so it is deferred to the slice that sees it finish instead + /// of being published as a night that ended at the cut. See + /// [kUnfinishedNightEdgeSec]. + bool recordContinues = false, }) { final n = math.min(accel.length, hr1hz.length); // A forced window (manual entry / user confirmation, Approach 1) is asserted @@ -514,8 +534,16 @@ SleepSegmentation segmentSleep( ); if (sessions.isEmpty) return SleepSegmentation.absent; + var groups = _bridgeAdjacentSessions(sessions, tzAt); + if (recordContinues) { + final lastTs = grav.fold(grav.first.ts, (m, g) => math.max(m, g.ts)); + groups = _dropUnfinishedGroups(groups, lastTs, tzAt); + // Every candidate was tonight's still-going night: say so, so the + // caller can tell a deferral from an empty slice. + if (groups.isEmpty) return SleepSegmentation.unfinishedNight; + } chosen = _pickMainSleepGroup( - _bridgeAdjacentSessions(sessions), + groups, tzAt, habitualMidsleepSec: habitualMidsleepSec, ); @@ -779,10 +807,48 @@ class _SleepGroup { }); } -List<_SleepGroup> _bridgeAdjacentSessions(List sessions) { +/// Whether [next] continues the chain [before] (ending at [beforeEnd]) — the +/// one rule both the bridge and the unfinished-night deferral use, so they +/// cannot disagree about what belongs to one night. +/// +/// Only an OVERNIGHT chain gets the wider gap. detectSleep chains any two +/// sessions up to 90 min apart, but the chain only changes what it returns +/// when it is overnight (the night-tail exemption); two daytime sessions 75 +/// min apart come back as two unrelated sessions. Bridging those would publish +/// one window with the time spent up and about in between counted as wake. +/// Same overnight test as detectSleep: some session in the chain started +/// outside the daytime band. +bool _continuesChain(List before, int beforeEnd, + SleepSession next, int Function(int tsSec) tzAt) { + const bridgeGapSec = 60 * 60; + const nightBridgeGapSec = AdvancedSleepStager.nightContinuationGapMin * 60; + bool overnightOnset(int start) { + final local = start + tzAt(start); + final hour = (((local % 86400) + 86400) % 86400) ~/ 3600; + return hour < AdvancedSleepStager.daytimeBandStartHour || + hour >= AdvancedSleepStager.daytimeBandEndHour; + } + + final gap = next.start - beforeEnd; + if (gap < 0) return false; + if (gap < bridgeGapSec) return true; + return gap <= nightBridgeGapSec && + (overnightOnset(next.start) || + before.any((x) => overnightOnset(x.start))); +} + +List<_SleepGroup> _bridgeAdjacentSessions( + List sessions, int Function(int tsSec) tzAt) { if (sessions.isEmpty) return const []; final sorted = [...sessions]..sort((a, b) => a.start.compareTo(b.start)); - const bridgeGapSec = 60 * 60; + // Must be the same rule detectSleep chains a night on. With a narrower bridge + // (this was a flat `< 60 min`) a 60-90 min awakening left detectSleep + // returning both halves as one night while this split them into two groups; + // the selector then published one half and silently dropped the other from + // TST, stages and the HRV/RHR windows (or let it surface as a "nap"). The + // gap's seconds are scored below as wake where observed, unobserved where not. + // + // Only an overnight chain gets the full gap; see [_continuesChain]. final out = <_SleepGroup>[]; for (final session in sorted) { final asleepMin = AdvancedSleepStager.hypnogramMetrics(session).tstS / 60.0; @@ -798,8 +864,7 @@ List<_SleepGroup> _bridgeAdjacentSessions(List sessions) { continue; } final last = out.removeLast(); - final gap = session.start - last.end; - if (gap >= 0 && gap < bridgeGapSec) { + if (_continuesChain(last.sessions, last.end, session, tzAt)) { out.add( _SleepGroup( sessions: [...last.sessions, session], @@ -823,6 +888,44 @@ List<_SleepGroup> _bridgeAdjacentSessions(List sessions) { return out; } +/// A sleep group ending this close to the last sample of a record that +/// continues may still be going. The still/active classifier works over a +/// [AdvancedSleepStager.stillWindowMin] window, so a wake inside the final +/// window cannot be told apart from sleep that runs on past the cut. +const int kUnfinishedNightEdgeSec = AdvancedSleepStager.stillWindowMin * 60; + +/// [SleepSegmentation.absenceReason] for a night deferred because it was still +/// going at the end of a record that continues. See +/// [SleepSegmentation.unfinishedNight]. +const String kAbsenceNightUnfinished = 'night_unfinished'; + +/// Drops every group that is still going at [lastTs], plus any group chained +/// to one by [_continuesChain]. Walking backward matters: a group chained to +/// the one touching the cut is the same unfinished night and would otherwise +/// be published for the day that is ending and then counted again inside the +/// full night by the next slice. A completed daytime sleep that merely ended +/// within 90 min of it is not part of that night and is kept. +List<_SleepGroup> _dropUnfinishedGroups( + List<_SleepGroup> groups, int lastTs, int Function(int tsSec) tzAt) { + final unfinished = List.filled(groups.length, false); + for (var g = groups.length - 1; g >= 0; g--) { + if (lastTs - groups[g].end <= kUnfinishedNightEdgeSec) { + unfinished[g] = true; + continue; + } + if (g + 1 < groups.length && + unfinished[g + 1] && + _continuesChain(groups[g].sessions, groups[g].end, + groups[g + 1].sessions.first, tzAt)) { + unfinished[g] = true; + } + } + return [ + for (var g = 0; g < groups.length; g++) + if (!unfinished[g]) groups[g], + ]; +} + _SleepGroup? _pickMainSleepGroup( List<_SleepGroup> groups, int Function(int tsSec) tzAt, { diff --git a/lib/src/onehz/sleep/sleep.dart b/lib/src/onehz/sleep/sleep.dart index 2e2bd2f..dc21184 100644 --- a/lib/src/onehz/sleep/sleep.dart +++ b/lib/src/onehz/sleep/sleep.dart @@ -15,7 +15,6 @@ // honesty-bounded; stager.dart holds only the shared post-processing // (StagerResult, Webster rescore, consolidation) it reuses. The // autonomic-HR-only stager that used to live in stager.dart is DELETED. -// - Cardiopulmonary Coupling (CPC) (cpc.dart) // - Nonparametric circadian IS/IV/RA/L5/M10 (circadian_np.dart) // // Pure Dart, built on the package foundations (RR correction, Lomb-Scargle, @@ -30,12 +29,17 @@ export 'advanced_stager.dart'; export 'band_offset.dart'; export 'sri.dart'; export 'accounting.dart'; -// stager.dart provides StagerResult + the shared Webster/consolidation -// post-processing (both used by cardio_stager and tests). Its deprecated -// `autonomicStager` is DELETED — `cardioStager` is the stager. +// stager.dart provides StagerResult + the shared consolidation post-step. Its +// deprecated `autonomicStager` is DELETED — `cardioStager` is the stager. export 'stager.dart'; -export 'cardio_stager.dart'; -export 'cpc.dart'; +// The test hooks drive private passes directly and are not entry points; tests +// import cardio_stager.dart itself to reach them. +export 'cardio_stager.dart' + hide + websterRescoreCardio, + cleanBeatsInWindowForTest, + cleanBeatDiffsInWindowForTest, + windowRmssdRkForTest; export 'circadian_np.dart'; export 'cycles.dart'; export 'night_hrv_shape.dart'; diff --git a/lib/src/onehz/sleep/sri.dart b/lib/src/onehz/sleep/sri.dart index 88c8083..a59c90d 100644 --- a/lib/src/onehz/sleep/sri.dart +++ b/lib/src/onehz/sleep/sri.dart @@ -80,6 +80,14 @@ class SriResult { /// and without the floor a half-unobserved weekend tops the "worst pair" list /// for having no data. The floor does NOT change the published [SriResult.sri]: /// every accepted epoch still counts toward the total, thin pair or not. +/// +/// The floor DOES gate whether a total is published at all: with no pair +/// clearing it, the record is abstained on rather than scored. Without that, a +/// handful of minutes shared between two nights (a rotating shift whose sleeps +/// barely overlap in clock time) publishes ±100, and since the only valid +/// epochs are the asleep ones, almost always +100 "perfectly regular". +/// Confidence is sized from the qualifying pairs for the same reason — the +/// length of the grid says nothing about how much of it was observed. Metric phillipsSri( List sleepWake, int epochsPerDay, { @@ -135,10 +143,19 @@ Metric phillipsSri( ); } + if (pairs.isEmpty) { + return Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'no adjacent-day pair has ≥$pairFloor epochs observed on both ' + 'days (have=$cases epochs in total)', + ); + } + final sri = 200.0 * agreement / cases - 100.0; - // Confidence: scales with the number of day-comparisons (more days, more - // stable). Saturates around a typical 7-day record. - final conf = ((days - 1) / 7.0).clamp(0.3, 0.95); + // Confidence: scales with the number of adequately-observed day-comparisons + // (more of them, more stable). Saturates around a typical 7-day record. + final conf = (pairs.length / 7.0).clamp(0.3, 0.95); return Metric( value: SriResult(sri, days, cases, pairs), confidence: conf, diff --git a/lib/src/onehz/sleep/stager.dart b/lib/src/onehz/sleep/stager.dart index 267a217..8bd5f73 100644 --- a/lib/src/onehz/sleep/stager.dart +++ b/lib/src/onehz/sleep/stager.dart @@ -1,18 +1,19 @@ -// SLEEP/CIRCADIAN — the shared staging POST-PROCESSING (Webster rescore + -// stage-architecture consolidation) and the [StagerResult] shape both stagers -// return. +// SLEEP/CIRCADIAN — the shared stage-architecture consolidation post-step and +// the [StagerResult] shape the stagers return. // // The 3-class `autonomicStager` that used to live here is GONE (2026-08): it // was deprecated, hidden from the barrel and reachable only from its own test, // and it conflated epoch count with sample count (`n ~/ epochSec` assumes a // 1 Hz stream, so any other cadence silently rescaled every epoch). The live // stager is `cardioStager` (cardio_stager.dart), which reuses everything below. +// Its arousal-bridging pass lives in cardio_stager.dart only: this file used to +// carry a second copy with a wider rule table (15 min of sleep bridged 5 min of +// wake, not 4) that no stager called any more but the barrel still exported. // // HONESTY CEILING (catalog rule 5): wrist staging is at best a 3-class // AUTONOMIC ESTIMATE, never a PSG 4-stage hypnogram. We NEVER emit N1/N2/N3. // This is tier ESTIMATE. -import 'dart:math' as math; import '../util.dart'; import 'accounting.dart' show SleepStage; @@ -38,118 +39,6 @@ class StagerResult { }; } -/// Test seam for [_websterRescore] — exposed (non-private) so the regression -/// test can drive the continuity rule directly. Not part of the public barrel. -void websterRescoreAutonomic(List sm, int epochSec) => - _websterRescore(sm, epochSec); - -/// Webster/Cole-Kripke sleep-continuity rescoring (in place). -/// -/// After sleep onset (first sleep epoch), a contiguous run of WAKE epochs that -/// is short enough relative to the SUSTAINED sleep bracketing it is rescored to -/// SLEEP (NREM) — it is an arousal, not real WASO. Runs longer than the -/// allowed bout, or with insufficient surrounding sleep, are LEFT as wake (a -/// genuine, sustained awakening). Rules (Webster, in epochs): -/// sleep-before ≥ 4 min AND wake-run ≤ 1 min → sleep -/// sleep-before ≥10 min AND wake-run ≤ 3 min → sleep -/// sleep-before ≥15 min AND wake-run ≤ 5 min → sleep -/// The top row is 5, not Webster's 4, and the widening is deliberate: it -/// absorbs the van Hees 5-min mask-smear artifact. See the rules table below. -/// `cardio_stager`'s copy runs the published 4 because van Hees does not run -/// on its forced-window paths at all. -/// "sleep-before" is satisfied by sustained sleep on EITHER bracketing side, -/// so an arousal sandwiched between two long sleep bouts is bridged. -/// -/// The flanking-sleep CONTEXT is measured against an immutable SNAPSHOT of the -/// hypnogram taken before the pass — Webster's rule scores each wake bout -/// against the ORIGINAL surrounding sleep (Webster et al. 1982; Cole et al. -/// 1992), not against sleep this same pass just manufactured. Reading the list -/// being mutated let every bridged bout count as context for the next one, so -/// bridging CASCADED and a genuinely fragmented night collapsed into one -/// continuous sleep block (WASO 0, efficiency 100%). -void _websterRescore(List sm, int epochSec) { - bool isSleep(SleepStage s) => s != SleepStage.wake; - final n = sm.length; - // epochs-per-minute (rounded; epochSec=30 → 2/min). - double minToEp(double m) => m * 60.0 / epochSec; - // Immutable context snapshot — see the doc comment above. - final snap = List.of(sm); - - // Locate sleep onset & final sleep epoch — only rescore within the sleep body. - var onset = -1, lastSleep = -1; - for (var i = 0; i < n; i++) { - if (isSleep(snap[i])) { - if (onset < 0) onset = i; - lastSleep = i; - } - } - if (onset < 0) return; // never slept — nothing to rescore. - - // Sustained sleep run immediately before index `i` (epochs), within body. - int sleepRunBefore(int i) { - var c = 0; - var k = i - 1; - while (k >= onset && isSleep(snap[k])) { - c++; - k--; - } - return c; - } - - // Sustained sleep run immediately after index `i` (epochs), within body. - int sleepRunAfter(int i) { - var c = 0; - var k = i + 1; - while (k <= lastSleep && isSleep(snap[k])) { - c++; - k++; - } - return c; - } - - // (sleep-context-epochs, max-wake-run-epochs) thresholds, longest context first. - // The top rule allows up to 5 min of wake to be bridged when flanked by ≥15 - // min of consolidated sleep: this is the empirical floor for "real WASO needs - // a SUSTAINED arousal", and it absorbs the van Hees 5-min mask-smear artifact - // (a single reposition inflated to ~300 s) without bridging genuine multi- - // minute awakenings (which exceed 5 min and stay scored as wake). - final rules = >[ - [minToEp(15), minToEp(5)], - [minToEp(10), minToEp(3)], - [minToEp(4), minToEp(1)], - ]; - - var i = onset; - while (i <= lastSleep) { - if (isSleep(snap[i])) { - i++; - continue; - } - // WAKE run [i, j). - var j = i; - while (j <= lastSleep && !isSleep(snap[j])) { - j++; - } - final wakeLen = j - i; - final before = sleepRunBefore(i); - final after = sleepRunAfter(j - 1); - final context = math.max(before, after).toDouble(); - var rescore = false; - for (final r in rules) { - if (context >= r[0] && wakeLen <= r[1]) { - rescore = true; - break; - } - } - if (rescore) { - for (var k = i; k < j; k++) { - sm[k] = SleepStage.nrem; - } - } - i = j; - } -} - /// Minimum sustained REM episode (min) and minimum NREM/REM bout (min) for the /// architecture-consolidation post-step. ~5 min reflects the shortest credible /// REM episode and merges sub-5-min stage flicker into the surrounding stage. diff --git a/lib/src/onehz/sleep/van_hees.dart b/lib/src/onehz/sleep/van_hees.dart index 532a52a..30f3840 100644 --- a/lib/src/onehz/sleep/van_hees.dart +++ b/lib/src/onehz/sleep/van_hees.dart @@ -129,8 +129,9 @@ class ImmobilityMask { /// well-formed 0.0° that is not a measurement of anything. final List zAngleDeg; - /// |Δ z-angle| vs the previous second (index 0 is 0 by definition). NaN when - /// either endpoint second is invalid — the arm may have moved across the gap + /// |Δ z-angle| vs the sample one smoothing epoch (`smoothSec`) earlier — + /// clamped to index 0 for the first epoch, and index 0 is 0 by definition. + /// NaN when either endpoint second is invalid — the arm may have moved across the gap /// and the record cannot say. Every comparison against NaN is false, so a /// consumer testing `deltaDeg[k] < threshold` degrades to "not still", never /// to "perfectly still". @@ -241,9 +242,29 @@ ImmobilityMask immobilityMask( final ang = _rollingMedian(raw, smoothSamples); // 3. per-second immobility: |Δ z-angle| < threshold sustained for ≥ window. + // + // van Hees 2015 / GGIR test the change between CONSECUTIVE 5 s EPOCHS against + // 5°, not the change between consecutive samples. Differencing adjacent 1 Hz + // samples made the rule "< 5° per second": a 20° roll spread over 6 s moves + // ~3°/s, never crossed the threshold, and a restless hour in bed was certified + // as sustained rest. Differencing the smoothed angle across one smoothing + // epoch is the sliding form of the epoch-to-epoch comparison, so a slow roll + // is judged by the full change it makes over 5 s. + // + // The rolling median carries NaN only at the unmeasured second itself, so an + // unmeasured second strictly INSIDE the compared span left the difference of + // its two measured endpoints finite: the arm could have moved and come back + // there unobserved. Any unmeasured second in the span makes the change NaN. + final invalidBefore = List.filled(n + 1, 0); + for (var i = 0; i < n; i++) { + invalidBefore[i + 1] = invalidBefore[i] + (accel[i].valid ? 0 : 1); + } final dAng = List.filled(n, 0); for (var i = 1; i < n; i++) { - dAng[i] = (ang[i] - ang[i - 1]).abs(); + final from = math.max(0, i - smoothSamples); + dAng[i] = invalidBefore[i + 1] - invalidBefore[from] > 0 + ? double.nan + : (ang[i] - ang[from]).abs(); } final immobile = List.filled(n, false); final immobileUnknown = List.filled(n, false); @@ -265,17 +286,22 @@ ImmobilityMask immobilityMask( // record it in `immobileUnknown` rather than guessing either way. // // A window containing an UNMEASURED second lands in the same two cases, for - // the same reason: movement already seen decides it (the sustained rule has - // failed on the data in hand), otherwise the arm could have moved where we - // were not looking ⇒ undecidable, never asserted rest. + // the same reason: movement seen anywhere in the window decides it (the + // sustained rule has failed on the data in hand), otherwise the arm could + // have moved where we were not looking ⇒ undecidable, never asserted rest. + // The scan therefore keeps going past a gap: stopping there missed measured + // movement later in the same window and reported it as undecidable. for (var i = 0; i < n; i++) { final hi = math.min(n, i + winSamples); var maxd = 0.0; var gap = !accel[i].valid; for (var k = i + 1; k < hi; k++) { - if (!accel[k].valid) { + // NaN also when the epoch this second is compared against reaches back + // into an unmeasured second, possibly one before `i` — the arm may have + // moved there, so it is a gap, not stillness. + if (!accel[k].valid || dAng[k].isNaN) { gap = true; - break; + continue; } if (dAng[k] > maxd) { maxd = dAng[k]; diff --git a/test/onehz/advanced_stager_test.dart b/test/onehz/advanced_stager_test.dart index 29424f5..1a229aa 100644 --- a/test/onehz/advanced_stager_test.dart +++ b/test/onehz/advanced_stager_test.dart @@ -351,4 +351,56 @@ void main() { expect(m.efficiency, closeTo(850 / 1000, 1e-9)); }); }); + group('night-tail exemption after an evening doze', () { + // tz 0, day starts at local midnight. Active until 19:00, optional still + // doze 19:00-20:40, active to 21:30, night 21:30-05:00, 20-min arousal, + // 50-min re-onset 05:20-06:10, then active. + const day = 1699920000; // 2023-11-14 00:00 UTC + ({List grav, List hr}) night({required bool doze}) { + final grav = []; + final hr = []; + var t = day + 12 * 3600; + void active(int until) { + for (var i = 0; t < until; i++, t++) { + final phase = math.sin(i * 0.5); + grav.add(GravTs(t, 0.3 * phase, 0.3, 0.9 * (1 - 0.2 * phase))); + hr.add(HrTs(t, 72 + 2 * math.sin(i / 600.0))); + } + } + + void still(int until, double bpm) { + for (var i = 0; t < until; i++, t++) { + grav.add(GravTs(t, 0.001 * math.sin(i * 0.01), 0.001, 1.0)); + hr.add(HrTs(t, bpm)); + } + } + + const h = 3600; + active(day + 19 * h); + if (doze) { + still(day + 20 * h + 40 * 60, 58); + } else { + active(day + 20 * h + 40 * 60); + } + active(day + 21 * h + 30 * 60); + still(day + 29 * h, 55); + active(day + 29 * h + 20 * 60); + still(day + 30 * h + 10 * 60, 56); + active(day + 32 * h + 10 * 60); + return (grav: grav, hr: hr); + } + + for (final doze in [false, true]) { + test('pre-dawn tail survives (doze=$doze)', () { + final d = night(doze: doze); + final sessions = AdvancedSleepStager.detectSleep(d.grav, d.hr); + final tail = sessions.where((s) => + s.start >= day + 29 * 3600 && s.end <= day + 30 * 3600 + 15 * 60); + expect(tail, hasLength(1), + reason: 'a chain that opens on a pre-20:00 doze must still count ' + 'as overnight once the real night joins it, or the short ' + 're-onset after the pre-dawn arousal is dropped'); + }); + } + }); } diff --git a/test/onehz/nap_test.dart b/test/onehz/nap_test.dart index 4defdb3..53a310a 100644 --- a/test/onehz/nap_test.dart +++ b/test/onehz/nap_test.dart @@ -303,6 +303,85 @@ void main() { expect(m.present, isTrue); expect(m.value, isEmpty, reason: 'the only block IS the main sleep'); }); + + test('a fragment that continues the night is not booked as a nap', () { + // REGRESSION. The night detector drops a second short pre-dawn tail once + // its one-shot night-tail exemption is used, so the main window it hands + // over ends before the band shows the user was up. The index-overlap + // check let that dropped tail through, and it came back as a 6 AM "nap" + // at the 0.85 confidence cap. + for (final arousal in [12, 30, 60]) { + final d = _Day() + ..active(180, bpm: 80) + ..still(420, bpm: 55) // the night the main window covers + ..active(arousal, bpm: 72) + ..still(42, bpm: 56) // the dropped tail + ..active(600, bpm: 80); + final start = 180 * 60, end = start + 420 * 60; + + final m = detectNaps( + d.accel, + d.hr, + mainSleep: SleepWindowSpan(start, end), + ); + + expect(m.present, isTrue); + expect(m.value, isEmpty, + reason: 'a $arousal-min arousal sits inside the night\'s ' + 'continuation gap, so the bout after it is the same night'); + expect(m.note, contains('continuing the main sleep')); + } + }); + + test('a long still block right after waking stays in the awake baseline', + () { + // REGRESSION. Treating every still bout that starts within the night's + // continuation gap as night pulled a 5-hour morning desk block out of the + // awake-HR pool. With only walking HR left, a 40-min awake sit at 75 bpm + // cleared the dip gate and became a nap, and the note called the desk + // block "continuing the main sleep". + for (final wake in [10, 60]) { + final d = _Day() + ..active(60, bpm: 85) + ..still(420, bpm: 55) // the night the main window covers + ..active(wake, bpm: 85) + ..still(300, bpm: 77) // morning desk work, awake + ..active(90, bpm: 80) + ..still(40, bpm: 75) // an awake sit + ..active(90, bpm: 80); + final start = 60 * 60, end = start + 420 * 60; + + final m = detectNaps( + d.accel, + d.hr, + mainSleep: SleepWindowSpan(start, end), + ); + + expect(m.present, isTrue); + expect(m.value, isEmpty, + reason: 'nobody napped; the desk block anchors the awake baseline ' + '($wake min after waking)'); + expect(m.note, isNot(contains('continuing the main sleep'))); + } + }); + + test('a nap well clear of the night is still a nap', () { + final d = _Day() + ..active(180, bpm: 80) + ..still(420, bpm: 55) + ..active(180, bpm: 80) // three hours up — past any continuation + ..still(40, bpm: 56) + ..active(300, bpm: 80); + final start = 180 * 60, end = start + 420 * 60; + + final m = detectNaps( + d.accel, + d.hr, + mainSleep: SleepWindowSpan(start, end), + ); + + expect(m.value, hasLength(1)); + }); }); group('detectNaps — honest absence', () { @@ -612,5 +691,201 @@ void main() { final m = detectNaps(d.accel, d.hr); expect(m.value!.single.tibSec, greaterThan(40 * 60)); }); + + test('a day with no decoded accel is NOT judged nap-free', () { + // HR on every second, including a dip that would corroborate a nap, but + // gravity decoded on none — an HR-only thin-record day. No still bout can + // ever form, so the per-bout coverage gate never runs. Pre-fix this came + // back present with an empty list ("judged, none") at confidence 0.3. + final d = _Day() + ..unmeasuredAccel(8 * 60, bpm: 74) + ..unmeasuredAccel(45, bpm: 52) + ..unmeasuredAccel(7 * 60, bpm: 74); + + final m = detectNaps(d.accel, d.hr); + expect(m.present, isFalse, + reason: 'nothing about stillness was observable on this day'); + }); + + test('a measured nap survives a day that is mostly unmeasured', () { + // A fully measured, HR-corroborated nap, then six hours of HR-only + // records with no gravity. The day as a whole is under the coverage + // floor, but the nap passed its own per-bout gate and must be kept. + final d = _Day() + ..active(120) + ..still(45) + ..active(120) + ..unmeasuredAccel(6 * 60, bpm: 74); + + final m = detectNaps(d.accel, d.hr); + expect(m.present, isTrue); + expect(m.value, hasLength(1)); + }); + + test('a long recording hole counts against day-wide coverage', () { + // Every row that exists is measured, but they cover four hours of a + // 24 h span. Counting rows read this as full coverage and returned a + // judged "no nap" for twenty hours nobody observed. + final d = _Day() + ..active(120) + ..gap(20 * 60) + ..active(120); + + final m = detectNaps(d.accel, d.hr); + expect(m.present, isFalse); + }); + + test('accel coverage is judged outside the main sleep only', () { + // The awake day is fully measured; the unmeasured stretch is the night, + // which naps are never drawn from. The day stays judged. + final d = _Day() + ..unmeasuredAccel(8 * 60, bpm: 55) + ..active(6 * 60); + + final m = detectNaps(d.accel, d.hr, + mainSleep: const SleepWindowSpan(0, 8 * 3600)); + expect(m.present, isTrue); + expect(m.value, isEmpty); + }); + }); + + group('detectNaps — cadence other than an unbroken 1 Hz', () { + // The immobility mask measures the stream's cadence and accepts anything up + // to the published 5 s van Hees epoch. The nap run builder used to demand a + // step of exactly 1 s between samples, so every one of these days came back + // as a judged "no qualifying nap" despite a clear 40-minute still block + // with an HR dip. + ({List accel, List hr}) day(int stepSec, + {int dropEvery = 0, int holeAtMin = -1, int holeMin = 0}) { + final accel = []; + final hr = []; + var t = 0; + var k = 0; + void emit(double x, double z, double bpm) { + k++; + if (holeAtMin >= 0 && t == holeAtMin * 60) t += holeMin * 60; + if (dropEvery > 0 && k % dropEvery == 0) { + t += stepSec; + return; + } + accel.add(AccelSample(t * 1000.0, x, 0, z)); + hr.add(bpm); + t += stepSec; + } + + void active(int min) { + final end = t + min * 60; + while (t < end) { + final rad = (45 + 35 * math.sin(t * 0.4)) * math.pi / 180; + emit(math.cos(rad), math.sin(rad), 82); + } + } + + void still(int min) { + final end = t + min * 60; + while (t < end) { + emit(0, 1, 54); + } + } + + active(120); + still(40); + active(120); + return (accel: accel, hr: hr); + } + + for (final step in [2, 5]) { + test('a 40-minute nap at a $step s cadence is detected', () { + final d = day(step); + final m = detectNaps(d.accel, d.hr); + expect(m.present, isTrue); + final nap = m.value!.single; + expect(nap.tibSec, closeTo(40 * 60, 6 * 60)); + }); + } + + test('time asleep is wall-clock seconds at a 5 s cadence, like tib', () { + // One still sample stands for five seconds here. Counting one per + // sample reported a fully-still nap as a fifth asleep. + final d = day(5); + final nap = detectNaps(d.accel, d.hr).value!.single; + expect(nap.tstSec, greaterThan(nap.tibSec * 0.8)); + expect(nap.tstSec, lessThanOrEqualTo(nap.tibSec)); + }); + + // Fractional cadences. Flooring the measured cadence to whole seconds + // credited a 2.5 s sample with 2 s and a 0.5 s sample with a full second. + ({List accel, List hr}) dayMs(int stepMs, + {bool Function(int k)? invalid, bool nap = true}) { + final accel = []; + final hr = []; + var tMs = 0; + var k = 0; + void emit(double x, double z, double bpm) { + final bad = invalid?.call(k) ?? false; + accel.add(bad + ? AccelSample(tMs.toDouble(), 0, 0, 0, valid: false) + : AccelSample(tMs.toDouble(), x, 0, z)); + hr.add(bpm); + tMs += stepMs; + k++; + } + + void active(int min) { + final end = tMs + min * 60000; + while (tMs < end) { + final rad = (45 + 35 * math.sin(tMs * 0.0004)) * math.pi / 180; + emit(math.cos(rad), math.sin(rad), 82); + } + } + + void still(int min) { + final end = tMs + min * 60000; + while (tMs < end) { + emit(0, 1, 54); + } + } + + active(120); + if (nap) { + still(40); + active(120); + } + return (accel: accel, hr: hr); + } + + test('time asleep is not floored at a 2.5 s cadence', () { + final d = dayMs(2500); + final nap = detectNaps(d.accel, d.hr).value!.single; + expect(nap.tstSec, greaterThan(nap.tibSec * 0.9), + reason: 'flooring 2.5 s to 2 s put a fully-still nap at 80% asleep'); + expect(nap.tstSec, lessThanOrEqualTo(nap.tibSec)); + }); + + test('day accel coverage is not doubled at a 0.5 s cadence', () { + // Gravity decoded on 2 of every 5 samples: 40% of the day, under the + // 50% gate. Crediting each 0.5 s sample a full second read it as 80% + // and published a judged "no nap". + final d = dayMs(500, invalid: (k) => k % 5 >= 2, nap: false); + final m = detectNaps(d.accel, d.hr); + expect(m.present, isFalse, reason: m.note); + }); + + test('a skipped second at 1 Hz does not end the nap', () { + final d = day(1, dropEvery: 120); + final m = detectNaps(d.accel, d.hr); + expect(m.value!.single.tibSec, closeTo(40 * 60, 6 * 60)); + }); + + test('a real recording hole inside the still block still breaks it', () { + // 120 min active, then 20 min still, a 30-minute hole, 20 more still: + // neither half reaches the nap minimum on its own once the unobserved + // half hour is not read as stillness. + final d = day(2, holeAtMin: 140, holeMin: 30); + final m = detectNaps(d.accel, d.hr); + for (final nap in m.value ?? const []) { + expect(nap.tibSec, lessThan(30 * 60)); + } + }); }); } diff --git a/test/onehz/real_night_cardio_stager_test.dart b/test/onehz/real_night_cardio_stager_test.dart index b779d92..36d7f1e 100644 --- a/test/onehz/real_night_cardio_stager_test.dart +++ b/test/onehz/real_night_cardio_stager_test.dart @@ -43,6 +43,11 @@ import 'dart:io'; import 'package:test/test.dart'; import 'package:openstrap_analytics/onehz.dart'; +import 'package:openstrap_analytics/src/onehz/sleep/cardio_stager.dart' + show + cleanBeatsInWindowForTest, + cleanBeatDiffsInWindowForTest, + windowRmssdRkForTest; void main() { _windowEdgeTies(); @@ -125,7 +130,8 @@ void main() { // 516.5 → 508.0. The old table was closer to this night's Apple Watch label // (wake=3) only because it was bridging up to 10 minutes of genuine // awakening into light sleep; one wrist-reference night is not a licence to - // deviate from Webster 1982 / Cole 1992. + // widen the bridge past the Webster 1982 / Cole 1992 minute values. (The + // bridging pass itself is not Webster's rescore — see `_websterRescore`.) expect(wakeMin, lessThan(35), reason: 'WAKE over-call regression guard (pre-fix: 294 min)'); expect(remMin, greaterThan(100), diff --git a/test/onehz/sleep_honesty_test.dart b/test/onehz/sleep_honesty_test.dart index b307b2d..2ac024c 100644 --- a/test/onehz/sleep_honesty_test.dart +++ b/test/onehz/sleep_honesty_test.dart @@ -12,6 +12,8 @@ import 'dart:math' as math; import 'package:test/test.dart'; import 'package:openstrap_analytics/onehz.dart'; +import 'package:openstrap_analytics/src/onehz/sleep/cardio_stager.dart' + show websterRescoreCardio; /// Fixed absolute epoch second so every fixture is deterministic. const int _t0 = 1700000000; @@ -130,7 +132,8 @@ void main() { reason: 'not one second of gravity was measured'); }); - test('the SAME vectors marked valid still stage — the flag is the only ' + test( + 'the SAME vectors marked valid still stage — the flag is the only ' 'difference', () { // Control: proves the abstention above comes from the validity flag and // not from the (0,0,0) coordinates or the window shape. @@ -221,6 +224,49 @@ void main() { forcedWindow: (onsetSec: _t0, offsetSec: _t0 + 4 * 3600)); expect(s.tstSec!, greaterThan((0.9 * 4 * 3600).round())); }); + + test( + 'an awakening walled in by two accel holes is judged against the ' + 'night, not against itself (was: 25 min awake staged light/deep)', () { + // 8 h at ~55 bpm and still, with a 25 min awakening at ~78 bpm and more + // movement starting at 3 h, and a 2 min accel hole on each side of it. + // Each hole is past the 60 s carry-forward bound, so the awakening is a + // run of its own. Staged on its own, its HR baseline was its own awake + // HR, no epoch reached its own arousal cut, and the whole block fell + // through to NREM: {light: 1230, deep: 270} pre-fix, against + // {wake: 1500} for the same input without the holes. + final saved = cardioUserProfile; + cardioUserProfile = null; // a new user: no rolling profile to lean on + addTearDown(() => cardioUserProfile = saved); + final r = math.Random(7); + const span = 8 * 3600; + const wS = _t0 + 3 * 3600, wE = wS + 1500; + final grav = []; + final hr = []; + final rr = []; + for (var k = 0; k < span; k++) { + final ts = _t0 + k; + final awake = ts >= wS && ts < wE; + final hole = (ts >= wS - 120 && ts < wS) || (ts >= wE && ts < wE + 120); + final noise = awake ? 0.02 : 0.002; + double j() => noise * (r.nextDouble() - 0.5); + if (!hole) grav.add(GravTs(ts, 0.1 + j(), 0.2 + j(), 0.97 + j())); + final bpm = (awake ? 78.0 : 55.0) + 2 * (r.nextDouble() - 0.5); + if (hole) continue; + hr.add(HrTs(ts, bpm)); + rr.add(RrTs(ts, 60000 / bpm + 20 * (r.nextDouble() - 0.5))); + } + final s = + AdvancedSleepStager.stageWindow(_t0, _t0 + span, grav, hr, rr: rr); + final inBlock = {}; + for (final seg in s.stages) { + final a = math.max(seg.start, wS), b = math.min(seg.end, wE); + if (b > a) inBlock[seg.stage] = (inBlock[seg.stage] ?? 0) + (b - a); + } + expect(inBlock['wake'] ?? 0, greaterThanOrEqualTo(1200), + reason: 'pre-fix: 0 s of the 1500 s awakening staged wake'); + expect(inBlock['deep'] ?? 0, 0, reason: 'pre-fix: 270 s deep'); + }); }); // ═══════════════════════════════════════════════════════════════════════════ @@ -269,10 +315,11 @@ void main() { test('cardio_stager: only the bout with REAL flanking sleep is bridged', () { // 15 min sleep, then 5 × (4 min wake + 1 min sleep). The rule table is - // now Webster's published one — ≥15 min of flanking sleep bridges ≤4 min - // of wake — so bout #1 legitimately bridges. Bouts #2-#5 are flanked by - // ONE minute of sleep and must survive: pre-fix each bridged bout was - // counted as context for the next, so all five collapsed and WASO hit 0. + // now bounded by Webster's minute values — ≥15 min of flanking sleep + // bridges ≤4 min of wake — so bout #1 legitimately bridges. Bouts #2-#5 + // are flanked by ONE minute of sleep and must survive: pre-fix each + // bridged bout was counted as context for the next, so all five + // collapsed and WASO hit 0. // // RE-PINNED with the rule table: this used to use 10-min bouts, which the // old non-published [15 → 10] row bridged. Under Webster a 10-min bout is @@ -283,13 +330,25 @@ void main() { reason: 'four 4-min bouts survive; pre-fix: 0 (full cascade)'); }); - test('stager: only the bout with REAL flanking sleep is bridged', () { - // Same shape, sized to the classic Webster table this file uses - // (≥15 min context bridges ≤5 min of wake). - final sm = fragmented(leadMin: 15, wakeMin: 4, reps: 5); - websterRescoreAutonomic(sm, 30); - expect(wakeInBody(sm), 4 * 8, - reason: 'four 4-min bouts survive; pre-fix: 0 (full cascade)'); + test('a 5-min awakening flanked by 15 min of sleep stays wake', () { + // The bridging table tops out at 4 min. A second copy that bridged up to + // 5 min used to sit in stager.dart behind a public seam; this pins the + // one live table so the wider bound cannot quietly come back. + final sm = [ + for (var k = 0; k < 30; k++) SleepStage.nrem, + for (var k = 0; k < 10; k++) SleepStage.wake, + for (var k = 0; k < 30; k++) SleepStage.nrem, + ]; + websterRescoreCardio(sm, 30); + expect(wakeInBody(sm), 10); + + final short = [ + for (var k = 0; k < 30; k++) SleepStage.nrem, + for (var k = 0; k < 8; k++) SleepStage.wake, + for (var k = 0; k < 30; k++) SleepStage.nrem, + ]; + websterRescoreCardio(short, 30); + expect(wakeInBody(short), 0, reason: '4 min is still an arousal'); }); test('a genuinely fragmented night keeps its WASO end-to-end', () { @@ -364,7 +423,7 @@ void main() { active(2 * 3600); // 16:00-18:00 sleep(6 * 3600); // 18:00-00:00 → night A - active(90 * 60, bpm: 90); // 00:00-01:30 (>60 min ⇒ A and B never bridge) + active(90 * 60, bpm: 90); // 00:00-01:30 (detected gap ~95 min > 90 ⇒ never bridged) sleep(2 * 3600); // 01:30-03:30 ┐ active(45 * 60, bpm: 90); // │ night B — three fragments bridged sleep(2 * 3600); // 04:15-06:15 │ across two <60 min gaps @@ -385,6 +444,104 @@ void main() { reason: 'pre-fix the early midpoint made night A (18:02) win'); expect(s.inBedSec!, greaterThan(7 * 3600)); }); + + // detectSleep chains a night across any awakening up to + // nightContinuationGapMin (90 min). segmentSleep used to bridge only gaps + // under 60 min, so a 60-90 min awakening split one night into two groups + // and only one half was published. + void Function(int, {double bpm}) feeder( + List accel, List hr, int start, + {required bool still}) { + return (int secs, {double bpm = 0}) { + final base = accel.length; + for (var k = 0; k < secs; k++) { + final t = (start + base + k) * 1000.0; + if (still) { + accel.add(AccelSample(t, 0.02, 0.02, 1.0)); + hr.add(bpm + 1.5 * math.sin(k / 1800.0)); + } else { + final ph = math.sin(k * 0.5); + accel.add(AccelSample(t, 0.3 * ph, 0.3, 0.9 * (1 - 0.2 * ph))); + hr.add(bpm + 2 * math.sin(k / 600.0)); + } + } + }; + } + + test('a 75-min awakening keeps both halves of the night', () { + final accel = []; + final hr = []; + final start = _midnight + 21 * 3600; // 21:00 + final active = feeder(accel, hr, start, still: false); + final sleep = feeder(accel, hr, start, still: true); + + active(2 * 3600, bpm: 76); // 21:00-23:00 + sleep(210 * 60, bpm: 50); // 23:00-02:30 + active(75 * 60, bpm: 90); // 02:30-03:45 up with the baby + sleep(225 * 60, bpm: 50); // 03:45-07:30 + active(2 * 3600, bpm: 76); // 07:30-09:30 + + final s = segmentSleep(accel, hr, + hrBaseline: List.filled(200, 76), tzOffsetSec: 0); + + expect(s.present, isTrue); + final onset = s.window!.onsetMs! ~/ 1000; + final offset = s.window!.offsetMs! ~/ 1000; + // Pre-fix: only the 03:45-07:30 half was published (~3.7 h in bed). + expect(onset, lessThan(_midnight + 86400 - 30 * 60), + reason: 'onset must be the 23:00 block, not 03:45'); + expect(offset, greaterThan(_midnight + 86400 + 7 * 3600)); + expect(s.inBedSec!, greaterThan(8 * 3600)); + // The awakening is inside the window and is counted as wake. + expect(s.wasoSec!, greaterThan(60 * 60)); + expect(s.tstSec!, greaterThan(5 * 3600)); + }); + + test('a short tail after a 70-min awakening is not lost', () { + final accel = []; + final hr = []; + final start = _midnight + 20 * 3600 + 30 * 60; // 20:30 + final active = feeder(accel, hr, start, still: false); + final sleep = feeder(accel, hr, start, still: true); + + active(2 * 3600, bpm: 76); // 20:30-22:30 + sleep(390 * 60, bpm: 50); // 22:30-05:00 + active(70 * 60, bpm: 90); // 05:00-06:10 + sleep(40 * 60, bpm: 50); // 06:10-06:50 night tail + active(2 * 3600, bpm: 76); // 06:50-08:50 + + final s = segmentSleep(accel, hr, + hrBaseline: List.filled(200, 76), tzOffsetSec: 0); + + expect(s.present, isTrue); + final offset = s.window!.offsetMs! ~/ 1000; + // Pre-fix the window ended ~05:00 and the tail counted nowhere (nap + // detection discards it as a night tail). + expect(offset, greaterThan(_midnight + 86400 + 6 * 3600 + 30 * 60)); + }); + + test('two daytime sleeps 75 min apart are not bridged into one window', () { + final accel = []; + final hr = []; + final start = _midnight + 7 * 3600; // 07:00 + final active = feeder(accel, hr, start, still: false); + final sleep = feeder(accel, hr, start, still: true); + + active(5 * 3600, bpm: 76); // 07:00-12:00 + sleep(200 * 60, bpm: 50); // 12:00-15:20 + active(75 * 60, bpm: 90); // 15:20-16:35 up and about + sleep(200 * 60, bpm: 50); // 16:35-19:55 + active(2 * 3600, bpm: 76); // 19:55-21:55 + + final s = segmentSleep(accel, hr, + hrBaseline: List.filled(200, 76), tzOffsetSec: 0); + + expect(s.present, isTrue); + // Bridged, this was one ~8 h window with the time up and about counted + // as wake. The wider gap is for an overnight chain only. + expect(s.inBedSec!, lessThan(4 * 3600)); + expect(s.wasoSec!, lessThan(30 * 60)); + }); }); // ═══════════════════════════════════════════════════════════════════════════ @@ -466,6 +623,87 @@ void main() { 't == onsetSec'); expect(r.series.any((p) => p['t'] == onset), isFalse); }); + + test('the cycle inside an island between two RR holes is kept', () { + // A 90-min RMSSD rhythm with contact lost for minutes 60-89 and 250-279. + // The island between them holds two peaks (~112 and ~202 min). The + // taller one walks into a hole on both sides; it used to be skipped + // outright, which left the island one peak and no cycle at all. + const onset = _t0; + const nMin = 480; + for (var seed = 1; seed <= 6; seed++) { + final rnd = math.Random(seed); + final rr = [], ts = []; + for (var s = 0; s < nMin * 60; s++) { + final m = s ~/ 60; + if ((m >= 60 && m < 90) || (m >= 250 && m < 280)) continue; + final amp = 20 + 15 * math.sin(2 * math.pi * (m - 90) / 90); + rr.add(1000 + amp * (rnd.nextBool() ? 1 : -1) * 0.7); + ts.add((onset + s) * 1000.0); + } + final r = detectSleepCycles(rr, ts, onset, onset + nMin * 60); + final inIsland = + r.cycles.where((c) => c.startMin >= 90 && c.endMin < 250).toList(); + expect(inIsland, hasLength(1), reason: 'seed $seed'); + expect(inIsland.single.lenMin, inInclusiveRange(70, 110)); + } + }); + + test('a cycle never spans a long RR hole', () { + // 8 h with a 90-min RMSSD rhythm, then no RR at all for minutes 100-259 + // (contact loss). Pre-fix the peak before the hole and the peak after it + // became one ~186-min "cycle" and the mean jumped from ~89 to ~138 min. + const onset = _t0; + const nMin = 480; + List cyclesWith({int? holeFrom, int? holeTo, int seed = 7}) { + final rnd = math.Random(seed); + final rr = [], ts = []; + for (var s = 0; s < nMin * 60; s++) { + final m = s ~/ 60; + if (holeFrom != null && m >= holeFrom && m < holeTo!) continue; + final amp = 20 + 15 * math.sin(2 * math.pi * m / 90); + rr.add(1000 + amp * (rnd.nextBool() ? 1 : -1) * 0.7); + ts.add((onset + s) * 1000.0); + } + final r = detectSleepCycles(rr, ts, onset, onset + nMin * 60); + if (r.cycles.isNotEmpty) { + expect( + r.meanDurationMin, + closeTo( + r.cycles.fold(0, (a, c) => a + c.lenMin) / + r.cycles.length, + 1e-9)); + } + return r.cycles; + } + + final full = cyclesWith(); + expect(full, isNotEmpty); + for (final c in full) { + expect(c.lenMin, inInclusiveRange(70, 110)); + } + + // Short contact losses matter most: the ±10 min smoothing paints into + // each edge of a hole, so a 30-40 min raw hole must still be caught. + for (final h in const [ + [100, 260, 7], + [100, 260, 11], + [150, 190, 11], + [200, 240, 7], + [200, 240, 11], + [100, 130, 11], + [100, 140, 11], + [60, 100, 11], + ]) { + final holed = cyclesWith(holeFrom: h[0], holeTo: h[1], seed: h[2]); + for (final c in holed) { + expect(c.startMin < h[1] && c.endMin > h[0], isFalse, + reason: 'cycle ${c.startMin}-${c.endMin} spans the ' + '${h[0]}-${h[1]} hole (seed ${h[2]})'); + expect(c.lenMin, inInclusiveRange(70, 110)); + } + } + }); }); // ═══════════════════════════════════════════════════════════════════════════ @@ -548,7 +786,9 @@ void main() { // The two seconds must not share one answer — that is the whole bug. expect(w.immobileUnknown[3350] == w.immobileUnknown[3500], isFalse); - expect(w.undecidableSec, 193, reason: 'pre-fix: 0 (no such state)'); + // The burst also decides the seconds whose 5 s comparison epoch still + // reaches back into it. + expect(w.undecidableSec, 189, reason: 'pre-fix: 0 (no such state)'); }); }); @@ -557,31 +797,6 @@ void main() { // never a sentinel, never a NaN dressed up as a number. // ═══════════════════════════════════════════════════════════════════════════ group('honesty — cardiopulmonary coupling is WITHDRAWN', () { - test('always absent, with the reason attached', () { - // It never measured coupling. Thomas 2005 needs a respiration channel - // INDEPENDENT of the beat times; the "surrogate" here was the NN series - // linearly detrended, so sqrt(P_rr . P_resp) collapsed to P_rr and - // cpc_ratio equalled the plain RR periodogram HF/LF ratio — measured - // 84.493910 vs 84.493190 on this shape, a ratio of 1.0000085. A healthy - // record used to "still report a real ratio"; that was the bug, not the - // control case. - final nn = []; - final ts = []; - var t = 0.0; - for (var i = 0; i < 3600; i++) { - final rr = 1000 + 40 * math.sin(2 * math.pi * 0.25 * (t / 1000.0)); - t += rr; - nn.add(rr); - ts.add(t); - } - // ignore: deprecated_member_use_from_same_package - final m = cardiopulmonaryCoupling(nn, ts); - expect(m.present, isFalse); - expect(m.value, isNull); - expect(m.confidence, 0); - expect(m.note, contains('respiration channel')); - }); - test('the ratio it used to publish was the RR periodogram HF/LF', () { // The assertion that would have failed from the first commit (T-02). final nn = []; diff --git a/test/onehz/sleep_test.dart b/test/onehz/sleep_test.dart index 09389df..c1f72c5 100644 --- a/test/onehz/sleep_test.dart +++ b/test/onehz/sleep_test.dart @@ -5,7 +5,7 @@ // expected value), then sanity-checked against the ~9-min real type-24 snippet. // // IMPORTANT: ../whoop_hist.jsonl is only ~9 min of 1 Hz records — far too short -// for a real night. So van Hees / SRI / accounting / staging / CPC / circadian +// for a real night. So van Hees / SRI / accounting / staging / circadian // get SYNTHETIC validation only; on the real capture we only assert the // pipeline runs and (where applicable) returns honest "absent" rather than a // fabricated night. That limitation is stated honestly here, not faked. @@ -195,18 +195,21 @@ void main() { accel.add(AccelSample(t, 0.3 * phase, 0.3, 0.9 * (1 - 0.2 * phase))); t += 1000.0; } - // Zero out only the first still-block sample's timestamp. - accel[nightStart] = AccelSample( + // Zero out only the onset sample's timestamp. The first smoothing epoch + // of the still block is still compared against the moving epoch before + // it, so the onset lands smoothSec-1 samples into the block. + final onsetAt = nightStart + 4; + accel[onsetAt] = AccelSample( 0, - accel[nightStart].x, - accel[nightStart].y, - accel[nightStart].z, + accel[onsetAt].x, + accel[onsetAt].y, + accel[onsetAt].z, ); final m = vanHeesSleepWindow(accel); expect(m.present, isTrue); final w = m.value!; - expect(w.onsetIdx, nightStart, + expect(w.onsetIdx, onsetAt, reason: 'test setup must target the sample the code actually reads'); expect(w.onsetMs, isNull, reason: 'unstamped onset sample must not fabricate epoch 0'); @@ -214,6 +217,87 @@ void main() { }); }); + group('van Hees epoch-to-epoch angle rule', () { + // 60 min of slow wrist rolls (20 deg over 6 s, ~3.3 deg/s, every 3 min, + // alternating direction) then 90 min perfectly still. No single second + // moves 5 deg, but consecutive 5 s epochs differ by well over 5 deg — + // van Hees 2015 / GGIR count that as movement. + List slowRolls() { + final out = []; + var base = 10.0; + var dir = 1.0; + for (var i = 0; i < 150 * 60; i++) { + var deg = base; + if (i < 60 * 60) { + final p = i % 180; + if (p < 6) deg = base + dir * 20 * (p + 1) / 6; + if (p == 5) { + base += dir * 20; + dir = -dir; + } + } + final r = deg * math.pi / 180; + out.add(AccelSample(1000.0 + i * 1000.0, math.cos(r), 0, math.sin(r))); + } + return out; + } + + test('a slow roll spread over several seconds breaks immobility', () { + final accel = slowRolls(); + final mask = immobilityMask(accel); + final restless = mask.immobile.sublist(0, 55 * 60); + expect(restless.where((s) => s).length, 0, + reason: 'pre-fix: every second of the restless hour was immobile'); + expect(mask.immobile[70 * 60], isTrue); + + final m = vanHeesSleepWindow(accel); + expect(m.present, isTrue); + expect(m.value!.onsetIdx, greaterThanOrEqualTo(57 * 60), + reason: 'pre-fix: onset at 0 — the restless hour was annexed'); + }); + + test('an unmeasured second inside the compared span is not stillness', () { + // One undecoded second at index 100 between measured seconds at the same + // orientation. The rolling median NaNs only index 100, so the change + // from 97 to 102 used to read as a finite 0° across a second nobody saw. + final accel = [ + for (var i = 0; i < 600; i++) + i == 100 + ? AccelSample(i * 1000.0, 0, 0, 0, valid: false) + : AccelSample(i * 1000.0, 0, 0, 1) + ]; + final mask = immobilityMask(accel); + for (var i = 101; i <= 105; i++) { + expect(mask.deltaDeg[i].isNaN, isTrue, reason: 'second $i'); + } + expect(mask.deltaDeg[99].isNaN, isFalse); + expect(mask.deltaDeg[106].isNaN, isFalse); + for (var i = 101; i <= 104; i++) { + expect(mask.immobile[i], isFalse, reason: 'second $i'); + expect(mask.immobileUnknown[i], isTrue, reason: 'second $i'); + } + }); + + test('movement after an unmeasured second in the window is decided', () { + // Still until an undecoded second at 100, then a 90° turn at 110. The + // window starting at 50 contains both: the turn already fails the + // sustained rule on measured data, so that second is not immobile and + // not undecidable either. + final accel = [ + for (var i = 0; i < 1200; i++) + i == 100 + ? AccelSample(i * 1000.0, 0, 0, 0, valid: false) + : i < 110 + ? AccelSample(i * 1000.0, 0, 0, 1) + : AccelSample(i * 1000.0, 1, 0, 0) + ]; + final mask = immobilityMask(accel); + expect(mask.immobile[50], isFalse); + expect(mask.immobileUnknown[50], isFalse, + reason: 'the scan stopped at the gap and never saw the turn'); + }); + }); + // ---------------------------------------------------------------------- SRI group('True Phillips SRI', () { test('perfectly-regular 2-day vector → SRI = 100', () { @@ -283,6 +367,34 @@ void main() { expect(agree, lessThanOrEqualTo(cases)); expect(cases, lessThan(m.value!.cases)); }); + + test( + 'abstains when no pair clears the coverage floor, and sizes confidence ' + 'from the pairs that do', () { + // Eight days of grid, but the mask accepts one minute on day 0 and day + // 1. Before the floor gated the total this published SRI=+100 at 0.95. + const epochsPerDay = 1440; + final vec = List.filled(8 * epochsPerDay, true); + final valid = List.filled(8 * epochsPerDay, false); + valid[100] = true; + valid[epochsPerDay + 100] = true; + final thin = phillipsSri(vec, epochsPerDay, valid: valid); + expect(thin.present, isFalse); + expect(thin.value, isNull); + + // Same eight-day grid, only days 0..2 fully observed: two qualifying + // pairs, so confidence reflects two comparisons, not seven. + final valid2 = List.generate( + 8 * epochsPerDay, (i) => i < 3 * epochsPerDay); + final two = phillipsSri(vec, epochsPerDay, valid: valid2); + expect(two.present, isTrue); + expect(two.value!.pairs, hasLength(2)); + expect(two.confidence, closeTo(0.3, 1e-9)); + + // A fully observed week still saturates. + final full = phillipsSri(vec, epochsPerDay); + expect(full.confidence, closeTo(0.95, 1e-9)); + }); }); // ----------------------------------------------- segmentSleep (SINGLE SOURCE) @@ -730,28 +842,6 @@ void main() { }); }); - // ---------------------------------------------------------------------- CPC - group('Cardiopulmonary Coupling (WITHDRAWN)', () { - test('abstains — no respiration channel independent of the beat times', () { - // Was: "NN with injected ~0.25 Hz respiration → HF coupling band". That - // passed because the respiration surrogate WAS the NN series, so of - // course the "coupling" peak sat at the RSA frequency. See cpc.dart. - final nn = []; - final times = []; - var t = 0.0; - for (var i = 0; i < 1800; i++) { - final rr = 1000 + 40 * math.sin(2 * math.pi * 0.25 * (t / 1000.0)); - t += rr; - nn.add(rr); - times.add(t); - } - // ignore: deprecated_member_use_from_same_package - final m = cardiopulmonaryCoupling(nn, times); - expect(m.present, isFalse); - expect(m.note, contains('respiration channel')); - }); - }); - // ----------------------------------------------------------- circadian (NP) group('nonparametric circadian IS/IV/RA/L5/M10', () { test('clean 24-h cosine activity over 7 days → IS≈1, RA high', () { @@ -796,7 +886,6 @@ void main() { histFile.readAsLinesSync().where((l) => l.trim().isNotEmpty).toList(); final accel = []; final hr = []; - final rrMs = []; var t = 0.0; for (final line in lines) { final obj = jsonDecode(line) as Map; @@ -808,9 +897,6 @@ void main() { accel.add(AccelSample(t, a[0], a[1], a[2])); } hr.add(r.hr.toDouble()); - for (final rr in r.rrIntervalsMs) { - if (rr > 0) rrMs.add(rr.toDouble()); - } t += 1000.0; } expect(accel.length, greaterThan(100)); @@ -825,18 +911,6 @@ void main() { } else { expect(sw.confidence, 0); } - - // CPC on real RR via the foundation corrector: should run and produce a - // dominant frequency in the physiological respiratory range (or absent). - final corr = correctRr(rrMs); - if (corr.nn.length >= 60) { - // ignore: deprecated_member_use_from_same_package - final cpc = cardiopulmonaryCoupling(corr.nn, corr.nnTimesMs); - if (cpc.present) { - expect(cpc.value!.dominantHz, inInclusiveRange(0.0, 0.45)); - expect(cpc.value!.hfc, greaterThanOrEqualTo(0)); - } - } }); }); @@ -1133,6 +1207,23 @@ void _cv06Tests() { expect(m.note, contains('no shape')); }); + test('a night where every bin is a hole is absent, not an empty shape', + () { + // 400 beats over ~1.1 h: past the beat-count and three-bin gates, but no + // 30-min bin reaches the per-bin floor. + final nn = []; + final t = []; + for (var i = 0; i < 400; i++) { + nn.add(i.isEven ? 800.0 : 900.0); + t.add(i * 9900.0); + } + final m = nightHrvShape(nn, t); + expect(m.present, isFalse); + expect(m.value, isNull); + expect(m.confidence, 0.0); + expect(m.note, contains('$kMinBeatsPerHrvBin beats')); + }); + test('CV-06 does not publish a time-to-max (deliberately cut)', () { final n = _syntheticNight(hours: 8, ampMs: (_) => 20.0); final j = nightHrvShape(n.nn, n.t).value!.toJson(); diff --git a/test/onehz/sleep_unfinished_night_test.dart b/test/onehz/sleep_unfinished_night_test.dart new file mode 100644 index 0000000..0cf027e --- /dev/null +++ b/test/onehz/sleep_unfinished_night_test.dart @@ -0,0 +1,213 @@ +// test/onehz/sleep_unfinished_night_test.dart +// +// A calendar-day slice ends at local midnight while the record carries on. An +// early bedtime is still asleep at that cut, so the detector closes the run at +// the last sample and the first hours of tonight looked like a finished night +// for the day that is ending — and were counted again by the next day's slice. +import 'dart:math' as math; +import 'package:test/test.dart'; +import 'package:openstrap_analytics/onehz.dart'; + +const int _t0 = 1700000000; +final int _midnight = _t0 - (_t0 % 86400); // day D 00:00 UTC + +/// Builds 1 Hz streams over [from, to) with the wearer asleep (still, HR 55) +/// inside any of [asleep] and moving (HR 85) otherwise. +({List accel, List hr}) _record( + int from, int to, List<(int, int)> asleep) { + final accel = [], hr = []; + final rnd = math.Random(11); + for (var t = from; t < to; t++) { + final still = asleep.any((s) => t >= s.$1 && t < s.$2); + accel.add(AccelSample(t * 1000.0, + still ? 0.01 * rnd.nextDouble() : 0.4 * math.cos(t / 2), 0.0, + still ? 1.0 : 1.0 + 0.3 * math.sin(t / 3))); + hr.add(still ? 55 : 85); + } + return (accel: accel, hr: hr); +} + +void main() { + // Band on the charger overnight, so day D's slice holds no sleep before + // tonight's first 3.5 h: bed at 20:30, cut at midnight. + final evening = _record(_midnight + 12 * 3600, _midnight + 86400, [ + (_midnight + 20 * 3600 + 30 * 60, _midnight + 86400), + ]); + + test('fixture reproduces the bug: the cut-off evening is published', () { + final s = segmentSleep(evening.accel, evening.hr, tzOffsetSec: 0); + expect(s.present, isTrue); + expect(s.window!.offsetMs! ~/ 1000, + greaterThan(_midnight + 86400 - 5 * 60)); // "woke" at the cut + }); + + test('record continues past the cut: the unfinished night is deferred', () { + final s = segmentSleep(evening.accel, evening.hr, + tzOffsetSec: 0, recordContinues: true); + expect(s.present, isFalse); + expect(s.tstSec, isNull); + expect(s.inBedSec, isNull); + // A deferral, not "no sleep": the caller must be able to tell them apart + // or it goes looking for a window by the HR-led route instead. + expect(s.unfinished, isTrue); + expect(s.absenceReason, kAbsenceNightUnfinished); + }); + + test('an empty slice is plain absent, not unfinished', () { + final r = _record(_midnight + 12 * 3600, _midnight + 86400, const []); + final s = segmentSleep(r.accel, r.hr, tzOffsetSec: 0, recordContinues: true); + expect(s.present, isFalse); + expect(s.unfinished, isFalse); + }); + + // The edge runs the HR-led fallback whenever the accel path comes back + // without a night. Over the same cut-off slice it proposed the same evening + // as a finished night, so deferring in segmentSleep alone changed nothing. + group('hrLedSleepWindow over a slice cut at midnight', () { + List ts(List a) => [for (final x in a) x.tsMs ~/ 1000]; + + test('without the flag it proposes the cut-off evening (the bug)', () { + final w = hrLedSleepWindow(evening.hr, ts(evening.accel), + hrBaseline: const [85.0]); + expect(w, isNotNull); + expect(w!.offsetSec, greaterThan(_midnight + 86400 - 5 * 60)); + }); + + test('record continues: the still-going dip is not proposed', () { + final w = hrLedSleepWindow(evening.hr, ts(evening.accel), + hrBaseline: const [85.0], recordContinues: true); + expect(w, isNull); + }); + + test('record continues: a finished dip earlier in the slice still wins', () { + final r = _record(_midnight - 4 * 3600, _midnight + 86400, [ + (_midnight - 3600, _midnight + 7 * 3600), + (_midnight + 20 * 3600 + 30 * 60, _midnight + 86400), + ]); + final w = hrLedSleepWindow(r.hr, ts(r.accel), + hrBaseline: const [85.0], recordContinues: true); + expect(w, isNotNull); + expect(w!.offsetSec, lessThan(_midnight + 8 * 3600)); + expect(w.offsetSec, greaterThan(_midnight + 6 * 3600)); + }); + + test('a dip that ended well before the cut is unchanged by the flag', () { + final r = _record(_midnight - 4 * 3600, _midnight + 12 * 3600, [ + (_midnight - 3600, _midnight + 7 * 3600), + ]); + final a = hrLedSleepWindow(r.hr, ts(r.accel), hrBaseline: const [85.0]); + final b = hrLedSleepWindow(r.hr, ts(r.accel), + hrBaseline: const [85.0], recordContinues: true); + expect(a, isNotNull); + expect(b, isNotNull); + expect(b!.onsetSec, a!.onsetSec); + expect(b.offsetSec, a.offsetSec); + }); + + test('record continues: a finished daytime dip 75 min before it is kept', + () { + // Same day shape as the segmentSleep case below: asleep 12:00 → 15:20, + // up for 75 min, asleep again from 16:35 to the cut at 20:00. Neither + // run starts overnight, so the first is not part of the deferred one. + final r = _record(_midnight + 6 * 3600, _midnight + 20 * 3600, [ + (_midnight + 12 * 3600, _midnight + 15 * 3600 + 20 * 60), + (_midnight + 16 * 3600 + 35 * 60, _midnight + 20 * 3600), + ]); + final w = hrLedSleepWindow(r.hr, ts(r.accel), + hrBaseline: const [85.0], + recordContinues: true, + tzOffsetResolver: (_) => 0); + expect(w, isNotNull); + expect(w!.offsetSec, lessThan(_midnight + 16 * 3600)); + }); + + test('record continues: the same day read 8 h ahead is one deferred night', + () { + // The record above with a +8 h offset: the runs start at 20:00 and + // 00:35 local, both overnight, so the 75-min gap chains them and + // neither is a finished night. + final r = _record(_midnight + 6 * 3600, _midnight + 20 * 3600, [ + (_midnight + 12 * 3600, _midnight + 15 * 3600 + 20 * 60), + (_midnight + 16 * 3600 + 35 * 60, _midnight + 20 * 3600), + ]); + final w = hrLedSleepWindow(r.hr, ts(r.accel), + hrBaseline: const [85.0], + recordContinues: true, + tzOffsetResolver: (_) => 8 * 3600); + expect(w, isNull); + }); + + test('record continues: a finished overnight dip 105 min before it is kept', + () { + // Read 8 h ahead: asleep 23:00 → 03:00 local, up for 105 min, asleep + // again from 04:45 local to the cut at 07:00. Both start overnight, but + // the gap is past the 90-min chain, so the first is a finished night. + final r = _record(_midnight + 12 * 3600, _midnight + 23 * 3600, [ + (_midnight + 15 * 3600, _midnight + 19 * 3600), + (_midnight + 20 * 3600 + 45 * 60, _midnight + 23 * 3600), + ]); + final w = hrLedSleepWindow(r.hr, ts(r.accel), + hrBaseline: const [85.0], + recordContinues: true, + tzOffsetResolver: (_) => 8 * 3600); + expect(w, isNotNull); + expect(w!.offsetSec, lessThan(_midnight + 20 * 3600)); + expect(w.offsetSec, greaterThan(_midnight + 18 * 3600)); + }); + + test('record continues: an overnight dip 75 min before it is deferred too', + () { + // Asleep 20:30 → 23:00, up for 75 min, asleep again from 00:15 to the + // cut at 03:00: one night split by a long arousal, so neither half is + // a finished night yet. The first half is long enough to be proposed on + // its own, so this fails if the chain is lost. + final r = _record(_midnight + 18 * 3600, _midnight + 27 * 3600, [ + (_midnight + 20 * 3600 + 30 * 60, _midnight + 23 * 3600), + (_midnight + 24 * 3600 + 15 * 60, _midnight + 27 * 3600), + ]); + final w = hrLedSleepWindow(r.hr, ts(r.accel), + hrBaseline: const [85.0], + recordContinues: true, + tzOffsetResolver: (_) => 0); + expect(w, isNull); + }); + }); + + test('a finished night earlier in the slice is still the main sleep', () { + final night = (_midnight - 3600, _midnight + 7 * 3600); // 23:00 → 07:00 + final r = _record(_midnight - 4 * 3600, _midnight + 86400, [ + night, + (_midnight + 20 * 3600 + 30 * 60, _midnight + 86400), + ]); + final s = segmentSleep(r.accel, r.hr, tzOffsetSec: 0, recordContinues: true); + expect(s.present, isTrue); + final off = s.window!.offsetMs! ~/ 1000; + expect(off, lessThan(_midnight + 8 * 3600)); + expect(off, greaterThan(_midnight + 6 * 3600)); + }); + + test('a finished daytime sleep 75 min before a still-going one is kept', () { + // Asleep 12:00 → 15:20, up for 75 min, asleep again from 16:35 to the cut + // at 20:00. The second sleep is deferred; the first is finished and is not + // part of it, so it must not be deferred along with it. + final r = _record(_midnight + 6 * 3600, _midnight + 20 * 3600, [ + (_midnight + 12 * 3600, _midnight + 15 * 3600 + 20 * 60), + (_midnight + 16 * 3600 + 35 * 60, _midnight + 20 * 3600), + ]); + final s = segmentSleep(r.accel, r.hr, tzOffsetSec: 0, recordContinues: true); + expect(s.unfinished, isFalse); + expect(s.present, isTrue); + expect(s.window!.offsetMs! ~/ 1000, lessThan(_midnight + 16 * 3600)); + }); + + test('a night that ended well before the cut is not deferred', () { + // Asleep 23:00 → 07:00, awake and moving until the slice ends at 12:00. + final r = _record(_midnight - 4 * 3600, _midnight + 12 * 3600, [ + (_midnight - 3600, _midnight + 7 * 3600), + ]); + expect( + segmentSleep(r.accel, r.hr, tzOffsetSec: 0, recordContinues: true) + .toJson(), + segmentSleep(r.accel, r.hr, tzOffsetSec: 0).toJson()); + }); +} diff --git a/test/onehz/sleep_user_profile_test.dart b/test/onehz/sleep_user_profile_test.dart index 561c038..e4832d6 100644 --- a/test/onehz/sleep_user_profile_test.dart +++ b/test/onehz/sleep_user_profile_test.dart @@ -82,6 +82,38 @@ void main() { expect(back.rmssdMed, isNull); }); + test('a night with no motion spread records no ENMO cuts for the profile', + () { + // Exactly flat gravity ⇒ motion MAD 0 ⇒ the stager abstains from the + // motion axis. The observation must not carry a 0 g still/move cut that + // would later be folded in and pull every night's big-move bar to 0. + const n = 4 * 3600; + final hr = [for (var i = 0; i < n; i++) 55.0 + (i ~/ 60) % 5]; + final flat = [ + for (var i = 0; i < n; i++) AccelSample(i * 1000.0, 0, 0, 1.0), + ]; + cardioRecordObservations = true; + resetCardioObservations(); + try { + cardioStager(hr, flat); + final obs = takeCardioObservations(); + expect(obs, hasLength(1)); + expect(obs.single.hrSleepMedian, isNotNull); + expect(obs.single.enmoStillCut, isNull); + expect(obs.single.enmoMoveCut, isNull); + + // Folding it leaves an established motion profile untouched. + const settled = SleepUserProfile( + nights: 14, enmoStillCut: 0.001, enmoMoveCut: 0.003); + final folded = settled.fold(obs.single); + expect(folded.enmoStillCut, 0.001); + expect(folded.enmoMoveCut, 0.003); + } finally { + cardioRecordObservations = false; + resetCardioObservations(); + } + }); + test('a profile consistent with the night does not destabilize staging', () { // Synthetic still, low-HR night (should read as overwhelmingly asleep).