diff --git a/ALGORITHMS.md b/ALGORITHMS.md index eeea03d..afff0ff 100644 --- a/ALGORITHMS.md +++ b/ALGORITHMS.md @@ -73,6 +73,8 @@ Grouped by family (subdirectory under `lib/src/onehz/`). File paths are relative | Function | File | Method | Citation | |---|---|---|---| | `vanHeesSleepWindow` | `sleep/van_hees.dart` | z-angle sleep/wake window detection | van Hees et al. | +| `immobilityMask` | `sleep/van_hees.dart` | the shared per-second z-angle immobility primitive (steps 1–3, no window selection) — used by both the nocturnal spine and naps | van Hees et al. | +| `detectNaps` | `sleep/nap.dart` | **the only nap source.** z-angle immobility bouts on the complement of the main sleep window, corroborated by an HR dip vs the user's *awake daytime* baseline. Reports TST and TIB separately; makes **no stage claim**. Deliberately NOT `AdvancedSleepStager`, whose `minSleepMin=60` + 90-min daytime guard exist to reject naps | van Hees et al. | | `segmentSleep` (`SleepSegmentation`) | `sleep/segment.dart` | **single source of truth** for sleep windowing; delegates staging to `cardioStager` | | `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 | @@ -126,7 +128,7 @@ Grouped by family (subdirectory under `lib/src/onehz/`). File paths are relative | `roughNight` | `human/event_detection.dart` | neutral fallback descriptor when the signature is ambiguous | — | | `percentileOfYou` / `personalRecord` | `human/percentile_of_you.dart` | percentile-vs-your-own-history, miss-tolerant personal-record streaks | — | | `glassBoxReadiness` | `human/readiness_glassbox.dart` | **deprecated** — kept only for its percentile-of-you breakdown + narrative and edge back-compat; `readinessComposite` is canonical | — | -| `vo2maxEstimate` / `physiologicalAge` / `sleepNeed` / `strainTarget` / `recommendedBedtime` / `recommendedWake` / `sleepPerformance` / `detectNaps` | `human/coaching.dart` | deterministic coaching layer over the metrics above | Uth-Sørensen-style HR-ratio VO2max estimate (still `ESTIMATE`, never a lab claim) | +| `vo2maxEstimate` / `physiologicalAge` / `sleepNeed` / `strainTarget` / `recommendedBedtime` / `recommendedWake` / `sleepPerformance` | `human/coaching.dart` | deterministic coaching layer over the metrics above (naps come from `sleep/nap.dart`, not here) | Uth-Sørensen-style HR-ratio VO2max estimate (still `ESTIMATE`, never a lab claim) | | `journalCorrelations` | `human/coaching.dart` | per-tag mean-difference correlation vs. logged outcomes, on-device, personal | — | --- diff --git a/lib/src/onehz/human/coaching.dart b/lib/src/onehz/human/coaching.dart index 9cef3b2..9ad55ca 100644 --- a/lib/src/onehz/human/coaching.dart +++ b/lib/src/onehz/human/coaching.dart @@ -1,124 +1,7 @@ import 'dart:math' as math; import '../types.dart'; -import '../sleep/advanced_stager.dart'; -/// An index range [start, end) into the day's accel/hr arrays marking the MAIN -/// nocturnal sleep, so [detectNaps] can carve it (and its session) out. -class SleepWindowSpan { - final int start; - final int end; - const SleepWindowSpan(this.start, this.end); -} - -class NapWindow { - final int startSec; - final int endSec; - final int durationSec; - final double confidence; - const NapWindow({ - required this.startSec, - required this.endSec, - required this.durationSec, - required this.confidence, - }); -} - -/// Daytime naps as qualifying NON-MAIN sleep sessions from the single-source -/// [AdvancedSleepStager.detectSleep] pipeline. Reuses the exact same van Hees + -/// HR autonomic machinery the main sleep uses (no second detector): every -/// detected sleep session in [20 min, 3 h] that does NOT overlap [mainSleep] is -/// reported as a nap. HONEST: the same ESTIMATE ceiling as staging (wrist -/// autonomic, never PSG); returns an EMPTY list (present, low confidence) when -/// the detector finds no qualifying nap, and [Metric.absent] only when there is -/// too little data to run at all. -/// -/// [accel]/[hr] 1 Hz gravity + HR for the whole day (same length/time base). -/// [mainSleep] index range of the main nocturnal sleep in those arrays, so it -/// (and any session overlapping it) is excluded. NapWindow start/end are seconds -/// RELATIVE to the first sample. -Metric> detectNaps( - List accel, - List hr, { - SleepWindowSpan? mainSleep, -}) { - const inputs = ['accel_1hz', 'hr_1hz']; - const minNapSec = 20 * 60; - // Was 3h, which silently dropped a real second sleep block (biphasic/ - // split sleep, shift work) — it's too long to be a nap but also loses to - // the main sleep pick, so it just vanished from every output with no - // signal it ever existed. 6h still excludes anything long enough to be - // arguably its own main sleep, while catching genuine secondary sleep. - const maxNapSec = 6 * 3600; - final n = math.min(accel.length, hr.length); - if (n < minNapSec) { - return const Metric>.absent( - tier: Tier.estimate, - inputs_used: inputs, - note: 'too little data for nap detection (need ≥20 min)', - ); - } - - final baseSec = accel.first.tsMs ~/ 1000; - final grav = [ - for (var i = 0; i < n; i++) - GravTs(accel[i].tsMs ~/ 1000, accel[i].x, accel[i].y, accel[i].z), - ]; - final hrTs = [ - for (var i = 0; i < n; i++) - if (hr[i] > 0) HrTs(accel[i].tsMs ~/ 1000, hr[i]), - ]; - - // Per-timestamp local offset (DST-correct) for the stager's daytime guard. - int tzAt(int ts) => - DateTime.fromMillisecondsSinceEpoch(ts * 1000, isUtc: false) - .timeZoneOffset - .inSeconds; - - final sessions = - AdvancedSleepStager.detectSleep(grav, hrTs, tzOffsetResolver: tzAt); - - // Absolute-second bounds of the main sleep window (for overlap exclusion). - int? mainStartSec, mainEndSec; - if (mainSleep != null && mainSleep.end > mainSleep.start) { - final lo = mainSleep.start.clamp(0, n - 1); - final hi = (mainSleep.end - 1).clamp(0, n - 1); - mainStartSec = accel[lo].tsMs ~/ 1000; - mainEndSec = accel[hi].tsMs ~/ 1000; - } - - final naps = []; - for (final s in sessions) { - final dur = s.end - s.start; - if (dur < minNapSec || dur > maxNapSec) continue; - // Exclude the main nocturnal sleep: any session overlapping its window. - if (mainStartSec != null && - mainEndSec != null && - s.start < mainEndSec && - s.end > mainStartSec) { - continue; - } - // Require the nap actually hold ≥20 min of asleep time (not just in-bed). - if (AdvancedSleepStager.hypnogramMetrics(s).tstS < minNapSec) continue; - naps.add(NapWindow( - startSec: s.start - baseSec, - endSec: s.end - baseSec, - durationSec: dur, - confidence: s.efficiency.clamp(0.0, 1.0), - )); - } - - return Metric>( - value: naps, - confidence: naps.isEmpty ? 0.3 : 0.4, - tier: Tier.estimate, - inputs_used: inputs, - note: naps.isEmpty - ? 'no qualifying naps (20 min–3 h) outside the main sleep window' - : '${naps.length} nap(s) via van Hees + HR autonomic ESTIMATE ' - '(20 min–3 h, main sleep excluded); wrist estimate, not PSG', - ); -} class SleepNeed { final double needSec; diff --git a/lib/src/onehz/sleep/nap.dart b/lib/src/onehz/sleep/nap.dart new file mode 100644 index 0000000..4e72fbc --- /dev/null +++ b/lib/src/onehz/sleep/nap.dart @@ -0,0 +1,430 @@ +// SLEEP — daytime nap detection. +// +// WHY THIS IS NOT `AdvancedSleepStager.detectSleep` +// ------------------------------------------------- +// The nocturnal detector rejects naps ON PURPOSE. `minSleepMin = 60` exists so +// "daytime naps and stray still-blocks stay excluded" (advanced_stager.dart), +// and any period centred 11:00–20:00 local must additionally clear +// `daytimeMinSleepMin = 90` plus a resting-HR dip. Those gates are load-bearing +// for NIGHT accuracy, so they are not loosened here. Naps get their own +// detector instead, run strictly on the complement of the main sleep window. +// +// METHOD +// 1. van Hees z-angle immobility (`immobilityMask`) — the SAME primitive the +// nocturnal spine uses. An angle is orientation-invariant, so it does not +// inherit the ~13% spread in |accel| across static wrist postures that +// makes magnitude-based stillness false-positive on a merely resting arm. +// 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. +// 4. Require an autonomic signature — median HR inside the bout at or below +// `napRestingHrMult` × the DAYTIME-AWAKE HR baseline. Stillness alone is +// also desk work, reading and a car passenger seat. +// 5. Abstain rather than guess when HR coverage inside a bout is too thin. +// +// HONESTY CEILING. This is an ESTIMATE from a 1 Hz wrist gravity vector plus +// opportunistic HR, never PSG. It reports WHETHER and HOW LONG, and makes no +// sleep-stage claim: a 30-minute nap contains no complete sleep cycle, and the +// daytime HR duty cycle will not support a 4-class partition. Time asleep and +// time in bed are reported separately and are never conflated. +// +// NO TIMEZONE DEPENDENCE, by construction. Corroboration is physiological (an +// HR dip against the user's own awake baseline), not clock-based, so a nap does +// not appear or vanish with the machine's local offset. + +import '../types.dart'; +import '../util.dart'; +import 'van_hees.dart'; + +/// An index range [start, end) into the day's arrays marking the MAIN +/// nocturnal sleep, so [detectNaps] can carve it out. +class SleepWindowSpan { + final int start; + final int end; + const SleepWindowSpan(this.start, this.end); +} + +/// One detected daytime sleep episode. [startSec]/[endSec] are seconds +/// RELATIVE to the first supplied sample. +class NapWindow { + final int startSec; + final int endSec; + + /// Time in bed — the full span of the bout, brief arousals included. + final int tibSec; + + /// Time asleep — seconds within the bout showing no wrist movement. + /// Always <= [tibSec]. This, never [tibSec], is the sleep-need credit. + final int tstSec; + + /// How sure we are this was sleep, in [0.2, 0.85]. Composed from HR + /// coverage, the depth of the HR dip, the still fraction, and whether + /// wrist-on telemetry corroborated it. NOT sleep efficiency. + final double confidence; + + const NapWindow({ + required this.startSec, + required this.endSec, + required this.tibSec, + required this.tstSec, + required this.confidence, + }); + + /// Fraction of the bout actually spent asleep, in [0, 1]. + double get efficiency => tibSec <= 0 ? 0 : tstSec / tibSec; + + Map toJson() => { + 'start_sec': startSec, + 'end_sec': endSec, + 'tib_sec': tibSec, + 'tst_sec': tstSec, + 'efficiency': round6(efficiency), + 'confidence': round6(confidence), + }; +} + +/// Shortest episode reported as a nap. Below this it is rest, not sleep. +const int minNapSec = 15 * 60; + +/// Longest episode still called a nap. Wide enough to keep genuine biphasic / +/// split / shift-work second sleep, which is too long to be a nap but also +/// loses to the main-sleep pick and would otherwise vanish from every output. +const int maxNapSec = 6 * 3600; + +/// Brief arousals bridged inside one nap. Far shorter than the nocturnal +/// 30-minute bridge, so two genuinely separate naps do not merge into one. +const int napBridgeSec = 5 * 60; + +/// Median HR inside a nap must sit at or below this multiple of the user's +/// daytime-awake baseline. Matches the nocturnal daytime guard's constant. +const double napRestingHrMult = 0.95; + +/// A bout needs HR on at least this fraction of its seconds to be judged at +/// all. Below it we abstain — daytime HR is opportunistic on this device. +const double minNapHrCoverage = 0.5; + +/// A bout overlapping off-wrist or excluded (charging / workout) spans by at +/// least this fraction is discarded. A charging band is perfectly still. +const double maxNapOffWristFraction = 0.5; + +/// Two sleep bouts closer together than this belong to ONE sleep episode with +/// an awakening in it, not two naps — the nocturnal detector uses the same idea +/// (`nightContinuationGapMin`). Only used to propagate DEFERRAL backwards: if +/// the record ends mid-sleep, every bout chained to that unfinished one is also +/// unfinished. Without it, a five-minute 01:50 awakening splits tonight's sleep +/// and the leading fragment gets emitted as a multi-hour "nap" for the day that +/// is ending — the exact phantom the deferral exists to prevent. +const int napChainGapSec = 60 * 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. +const int minAwakeHrSamples = 10 * 60; + +/// Detect daytime naps in a 1 Hz day. +/// +/// [accel] gravity vectors and [hr] heart rate, same time base and length. +/// [mainSleep] index range of the nocturnal sleep to carve out. [wristOff] and +/// [exclude] are `[startSec, endSec]` spans in ABSOLUTE epoch seconds +/// (matching `AdvancedSleepStager`'s convention) for off-wrist and for +/// charging/workout periods respectively. +/// +/// Returns [Metric.absent] only when the day cannot be judged at all (too +/// little data, or no awake HR to build a baseline from). A day that WAS +/// judged and held no nap returns an empty list — those two are different +/// answers and must not be collapsed. +Metric> detectNaps( + List accel, + List hr, { + SleepWindowSpan? mainSleep, + List> wristOff = const [], + List> exclude = const [], +}) { + const inputs = ['accel_1hz', 'hr_1hz']; + final n = accel.length < hr.length ? accel.length : hr.length; + if (n < minNapSec) { + return const Metric>.absent( + tier: Tier.estimate, + inputs_used: inputs, + note: 'too little data for nap detection (need ≥15 min)', + ); + } + + final series = accel.length == n ? accel : accel.sublist(0, n); + final mask = immobilityMask(series); + final win = mask.sustainedSec; + final thr = mask.thresholdDeg; + + final baseSec = series.first.tsMs ~/ 1000; + int absAt(int idx) => series[idx].tsMs ~/ 1000; + + // Work from the per-second STILL predicate, not from `mask.immobile`. + // + // `immobile` marks the second that STARTS a sustained-still window, so its + // run ends a full `sustainedSec` before the block physically does. Building + // bouts on those marks both under-reports every nap by that margin AND + // inflates the apparent gap between two halves of one nap by the same + // amount, so a 2-minute arousal reads as a 7-minute one and splits the nap. + // Enumerating real still runs and applying the sustained-inactivity rule to + // each run's LENGTH is the same van Hees criterion without the artifact. + // + // A run also BREAKS at a recording discontinuity. The substrate is a + // positional array, not a uniform grid: pruning and sync gaps leave holes, so + // two samples an hour apart can be adjacent indices. Joining them would read + // an unobserved hour as unbroken stillness and count it as sleep. + bool stillAt(int k) => + mask.deltaDeg[k] < thr && (k == 0 || absAt(k) - absAt(k - 1) == 1); + + final runs = >[]; + var i = 0; + while (i < n) { + if (!stillAt(i)) { + i++; + continue; + } + var j = i; + while (j < n && stillAt(j)) { + j++; + } + // van Hees sustained-inactivity, measured in ELAPSED SECONDS rather than + // sample count so a gappy run cannot qualify on fewer real seconds. + if (absAt(j - 1) + 1 - absAt(i) >= win) runs.add([i, j]); + i = j; + } + + // Bridge brief arousals between qualifying runs into a single episode — on + // the wall clock, again because index distance is not elapsed time. + final bouts = >[]; + for (final r in runs) { + if (bouts.isNotEmpty && + absAt(r[0]) - (absAt(bouts.last[1] - 1) + 1) < napBridgeSec) { + bouts.last[1] = r[1]; + } else { + bouts.add([r[0], r[1]]); + } + } + + // Which bouts are unfinished, walking BACKWARD from the record end. + // + // A bout running past the last sample has no knowable end. Crucially, so does + // any bout CHAINED to it: a five-minute awakening at 01:50 splits tonight's + // sleep into two bouts, and only the trailing one touches the array end. A + // rule that checked just `end >= n` would leave the leading multi-hour + // fragment to be emitted as today's "nap" and then counted a second time as + // tomorrow's main sleep, which is precisely the double-count the deferral is + // here to stop. + final unfinished = List.filled(bouts.length, false); + for (var b = bouts.length - 1; b >= 0; b--) { + if (bouts[b][1] >= n) { + unfinished[b] = true; + continue; + } + if (b + 1 < bouts.length && + unfinished[b + 1] && + absAt(bouts[b + 1][0]) - (absAt(bouts[b][1] - 1) + 1) < + napChainGapSec) { + unfinished[b] = true; + } + } + + // The AWAKE HR baseline: seconds that are neither the main sleep nor ANY + // detected sleep bout. Excluding only `mainSleep` was not enough — it left + // the candidate bout's own low-HR seconds in the median it is then judged + // against, and on this device the nap window deliberately extends hours past + // midnight, so the first hours of tonight's sleep were dragging the bar down + // too. Both make the gate self-suppressing: the quieter the sleep, the lower + // the threshold it has to beat. + final inBout = List.filled(n, false); + for (final b in bouts) { + for (var k = b[0]; k < b[1]; k++) { + inBout[k] = true; + } + } + final awake = []; + for (var k = 0; k < n; k++) { + if (hr[k] <= 0 || inBout[k]) continue; + if (mainSleep != null && k >= mainSleep.start && k < mainSleep.end) continue; + awake.add(hr[k]); + } + if (awake.length < minAwakeHrSamples) { + return Metric>.absent( + tier: Tier.estimate, + inputs_used: inputs, + note: 'not enough awake daytime HR to set a baseline ' + '(${awake.length}s, need ${minAwakeHrSamples}s) — ' + 'cannot corroborate stillness as sleep', + ); + } + final baseline = median(awake)!; + if (baseline <= 0) { + return const Metric>.absent( + tier: Tier.estimate, + inputs_used: inputs, + note: 'no usable awake daytime HR baseline', + ); + } + + final naps = []; + var deferred = 0, unverifiable = 0, offWrist = 0, awakeStill = 0; + // Every rejection path increments one of these and reports it in `skipped`. + // 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; + + for (var bi = 0; bi < bouts.length; bi++) { + final start = bouts[bi][0], end = bouts[bi][1]; + + // Never finalize an unfinished bout (see `unfinished` above): the record + // cannot say when it ended, so emitting it risks writing the first hours of + // tonight's sleep as today's nap and counting the same minutes twice. + if (unfinished[bi]) { + deferred++; + continue; + } + + // In bed is WALL-CLOCK elapsed time, not sample count. Across a recording + // hole those differ, and the reported start/end are wall-clock — a + // sample-count duration would silently disagree with its own bounds. + final aStart = absAt(start); + final aEnd = absAt(end - 1) + 1; + final tib = aEnd - aStart; + if (tib < minNapSec || tib > maxNapSec) { + outOfRange++; + continue; + } + + if (mainSleep != null && + start < mainSleep.end && + end > mainSleep.start) { + inMainSleep++; + continue; + } + + final offFrac = _overlapFraction(aStart, aEnd, wristOff); + final exFrac = _overlapFraction(aStart, aEnd, exclude); + if (offFrac >= maxNapOffWristFraction || + exFrac >= maxNapOffWristFraction) { + offWrist++; + continue; + } + + // NOT `inBout` — that name belongs to the whole-day boolean baseline mask + // above, and shadowing it here would silently hand the HR list to any later + // edit that reaches for the mask inside this loop. + final boutHr = []; + for (var k = start; k < end; k++) { + if (hr[k] > 0) boutHr.add(hr[k]); + } + final coverage = boutHr.length / tib; + if (coverage < minNapHrCoverage) { + unverifiable++; + continue; + } + final medHr = median(boutHr)!; + if (medHr > baseline * napRestingHrMult) { + awakeStill++; + 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; + for (var k = start; k < end; k++) { + if (stillAt(k)) tst++; + } + + // Confidence, NOT efficiency. A 20% dip below the awake baseline earns + // full marks on that axis; the rest rewards evidence, not sleep quality. + // Capped at 0.85 — this is a wrist estimate and never becomes a fact. + final dipScore = clamp((baseline - medHr) / (baseline * 0.20), 0, 1); + final stillScore = tib <= 0 ? 0.0 : tst / tib; + // Wear corroboration for THIS bout, not for the day. A day-global + // `wristOff.isNotEmpty` flag rewarded every nap on a day the band happened + // to come off at some unrelated hour, and gave nothing to a clean nap on a + // day it never came off — backwards on both counts. This scores how much of + // THIS bout is contradicted by an off-body span: none → full marks. + final worstOff = offFrac > exFrac ? offFrac : exFrac; + final corroborated = clamp(1 - worstOff / maxNapOffWristFraction, 0, 1); + final conf = clamp( + 0.20 + + 0.30 * dipScore + + 0.25 * coverage + + 0.15 * stillScore + + 0.10 * corroborated, + 0.2, + 0.85, + ); + + naps.add(NapWindow( + startSec: aStart - baseSec, + endSec: aEnd - baseSec, + tibSec: tib, + tstSec: tst, + confidence: conf, + )); + } + + final skipped = [ + if (deferred > 0) '$deferred deferred (record ends mid-bout)', + if (outOfRange > 0) '$outOfRange outside 15 min–6 h', + if (inMainSleep > 0) '$inMainSleep inside the main sleep window', + if (unverifiable > 0) '$unverifiable unverifiable (HR coverage <50%)', + if (offWrist > 0) '$offWrist off-wrist/excluded', + if (awakeStill > 0) '$awakeStill still but no HR dip', + ]; + final tail = skipped.isEmpty ? '' : '; skipped: ${skipped.join(', ')}'; + + return Metric>( + value: naps, + confidence: naps.isEmpty + ? 0.3 + : naps.map((x) => x.confidence).reduce((a, b) => a + b) / naps.length, + tier: Tier.estimate, + inputs_used: inputs, + note: naps.isEmpty + ? 'no qualifying nap (15 min–6 h, HR-corroborated) outside the main ' + 'sleep window$tail' + : '${naps.length} nap(s) via van Hees z-angle immobility + an HR dip ' + 'vs the awake daytime baseline; wrist ESTIMATE, not PSG, and no ' + 'sleep-stage claim$tail', + ); +} + +/// Fraction of [start, end) covered by ANY of [spans] (absolute seconds). +/// +/// The union, not the sum. Adding each span's clipped length independently +/// double-counts a second that two spans both cover, which can push the result +/// past 1.0 and reject a bout that is only half contradicted. The band's own +/// toggle events do not currently produce overlapping spans, so this is a +/// contract guarantee for every caller rather than a fix for a live symptom — +/// but the doc above has always promised a union and the arithmetic did not. +double _overlapFraction(int start, int end, List> spans) { + final dur = end - start; + if (dur <= 0 || spans.isEmpty) return 0; + final clipped = >[]; + for (final s in spans) { + if (s.length < 2) continue; + final lo = s[0] > start ? s[0] : start; + final hi = s[1] < end ? s[1] : end; + if (hi > lo) clipped.add([lo, hi]); + } + if (clipped.isEmpty) return 0; + clipped.sort((a, b) => a[0].compareTo(b[0])); + var covered = 0; + var runLo = clipped.first[0], runHi = clipped.first[1]; + for (var i = 1; i < clipped.length; i++) { + final s = clipped[i]; + if (s[0] <= runHi) { + // Overlapping or adjacent — extend the open run instead of counting twice. + if (s[1] > runHi) runHi = s[1]; + } else { + covered += runHi - runLo; + runLo = s[0]; + runHi = s[1]; + } + } + covered += runHi - runLo; + return covered / dur; +} diff --git a/lib/src/onehz/sleep/sleep.dart b/lib/src/onehz/sleep/sleep.dart index 367df43..d0b5f3b 100644 --- a/lib/src/onehz/sleep/sleep.dart +++ b/lib/src/onehz/sleep/sleep.dart @@ -5,6 +5,10 @@ // - van Hees / GGIR angle-based sleep window (van_hees.dart) — the spine. // - segmentSleep SINGLE-SOURCE entry point (segment.dart) — THE source: // window + per-second stages + TST/WASO/eff all from one staging. +// - Daytime nap detection (nap.dart) — the ONLY nap +// source. Separate from the nocturnal detector on purpose: that one +// rejects naps by design (minSleepMin=60, plus a 90-min daytime guard), +// and those gates are load-bearing for night accuracy. // - True Phillips Sleep Regularity Index (sri.dart) // - Sleep accounting (onset/offset/WASO/TST/eff/cycles) (accounting.dart) // - 3-class autonomic stager (wake/NREM/REM) (stager.dart) — honesty-bounded @@ -16,6 +20,7 @@ // plausibility. export 'van_hees.dart'; +export 'nap.dart'; export 'segment.dart'; export 'hr_fallback.dart'; export 'advanced_stager.dart'; diff --git a/lib/src/onehz/sleep/van_hees.dart b/lib/src/onehz/sleep/van_hees.dart index 36ba7a6..3455e8a 100644 --- a/lib/src/onehz/sleep/van_hees.dart +++ b/lib/src/onehz/sleep/van_hees.dart @@ -99,46 +99,77 @@ double zAngle(double x, double y, double z) { return math.atan2(z, denom) * 180 / math.pi; } -/// Detect the nocturnal sleep window from a sequence of 1 Hz accel vectors. +/// Per-second immobility evidence from the 1 Hz gravity vector — steps 1–3 of +/// the van Hees rule, with no window SELECTION applied. /// -/// [accel] one gravity vector per second (assumed ~1 Hz, contiguous). [tsMs] -/// optional matching wall-clock times. Parameters follow GGIR defaults. -Metric vanHeesSleepWindow( +/// Extracted so the nocturnal window ([vanHeesSleepWindow], which takes the +/// single longest block) and daytime naps (`sleep/nap.dart`, which enumerates +/// every block) run the SAME immobility primitive rather than two lookalike +/// copies. Being an ANGLE, this is orientation-invariant: it does not inherit +/// the ~13% spread in |accel| across static wrist postures that makes a +/// magnitude-based stillness test false-positive on a resting arm. +class ImmobilityMask { + /// ASSERTED immobile (see [SleepWindow.immobile]). + final List immobile; + + /// Undecidable — forward window truncated by the record end (see + /// [SleepWindow.immobileUnknown]). + final List immobileUnknown; + + /// Smoothed per-second z-angle (deg). + final List zAngleDeg; + + /// |Δ z-angle| vs the previous second (index 0 is 0 by definition). + final List deltaDeg; + + /// The sustained-inactivity window actually used, in seconds. + final int sustainedSec; + + /// The angle-change threshold actually used, in degrees. + final double thresholdDeg; + + const ImmobilityMask({ + required this.immobile, + required this.immobileUnknown, + required this.zAngleDeg, + required this.deltaDeg, + required this.sustainedSec, + required this.thresholdDeg, + }); +} + +/// Compute [ImmobilityMask] for a 1 Hz accel series. Safe on any length — +/// a series shorter than the sustained window yields an all-undecidable mask +/// rather than an assertion of rest. +ImmobilityMask immobilityMask( List accel, { double angleThresholdDeg = 5, int sustainedMin = 5, - // Bridge brief intra-sleep interruptions (position changes / awakenings) when - // joining sustained-inactivity blocks into one sleep period. The published - // van Hees/GGIR HDCZA bridges ~30–60 min; a too-small value (was 1 min) - // fragments a real night at every reposition and keeps only the longest sliver. - // Validated on real 1 Hz data: 1 min → 28-min sliver; 30 min → full ~6.9 h night. - int bridgeGapMin = 30, int smoothSec = 5, }) { - const inputs = ['accel_1hz']; final n = accel.length; - if (n < sustainedMin * 60) { - return const Metric.absent( - tier: Tier.high, - inputs_used: inputs, - note: 'too few accel samples for a sustained-inactivity block', + final win = sustainedMin * 60; + if (n == 0) { + return ImmobilityMask( + immobile: const [], + immobileUnknown: const [], + zAngleDeg: const [], + deltaDeg: const [], + sustainedSec: win, + thresholdDeg: angleThresholdDeg, ); } - // 1–2. z-angle + 5 s rolling-median smoothing. + // 1–2. z-angle + rolling-median smoothing. final raw = List.generate( n, (i) => zAngle(accel[i].x, accel[i].y, accel[i].z)); final ang = _rollingMedian(raw, smoothSec); // 3. per-second immobility: |Δ z-angle| < threshold sustained for ≥ window. - // First mark seconds whose change vs the previous second is small, then - // require the change to STAY small across the sustained window (GGIR uses - // a rolling 5-min check of the absolute angle change). final dAng = List.filled(n, 0); for (var i = 1; i < n; i++) { dAng[i] = (ang[i] - ang[i - 1]).abs(); } - final win = sustainedMin * 60; final immobile = List.filled(n, false); final immobileUnknown = List.filled(n, false); // A second is "no movement" if the MAX absolute angle change over the `win` @@ -172,6 +203,55 @@ Metric vanHeesSleepWindow( immobileUnknown[i] = still && !fullWindow; } + return ImmobilityMask( + immobile: immobile, + immobileUnknown: immobileUnknown, + zAngleDeg: ang, + deltaDeg: dAng, + sustainedSec: win, + thresholdDeg: angleThresholdDeg, + ); +} + +/// Detect the nocturnal sleep window from a sequence of 1 Hz accel vectors. +/// +/// [accel] one gravity vector per second (assumed ~1 Hz, contiguous). [tsMs] +/// optional matching wall-clock times. Parameters follow GGIR defaults. +Metric vanHeesSleepWindow( + List accel, { + double angleThresholdDeg = 5, + int sustainedMin = 5, + // Bridge brief intra-sleep interruptions (position changes / awakenings) when + // joining sustained-inactivity blocks into one sleep period. The published + // van Hees/GGIR HDCZA bridges ~30–60 min; a too-small value (was 1 min) + // fragments a real night at every reposition and keeps only the longest sliver. + // Validated on real 1 Hz data: 1 min → 28-min sliver; 30 min → full ~6.9 h night. + int bridgeGapMin = 30, + int smoothSec = 5, +}) { + const inputs = ['accel_1hz']; + final n = accel.length; + if (n < sustainedMin * 60) { + return const Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'too few accel samples for a sustained-inactivity block', + ); + } + + // 1–3. z-angle, smoothing and the per-second sustained-inactivity rule — + // the shared primitive, also used by daytime nap detection. + final mask = immobilityMask( + accel, + angleThresholdDeg: angleThresholdDeg, + sustainedMin: sustainedMin, + smoothSec: smoothSec, + ); + final ang = mask.zAngleDeg; + final win = mask.sustainedSec; + final immobile = mask.immobile; + final immobileUnknown = mask.immobileUnknown; + // 4. longest immobile block, bridging brief gaps. Only ASSERTED immobile // seconds extend a block, so a night still running when the record ends is // reported up to the last second we can actually certify — the undecidable diff --git a/test/onehz/coaching_test.dart b/test/onehz/coaching_test.dart index dce00f2..a0375f7 100644 --- a/test/onehz/coaching_test.dart +++ b/test/onehz/coaching_test.dart @@ -396,96 +396,6 @@ void main() { }); }); - group('detectNaps (real — non-main sleep sessions)', () { - // Build a synthetic day: active → still low-HR block → active. The still - // block clears the stager's gates on BOTH the night and daytime paths - // (>60 min, resting HR ≤ 0.95·baseline), so it is detected regardless of the - // machine timezone. Active flanks establish the higher HR baseline. - ({List accel, List hr}) buildDay({ - required int activeMin, - required int napMin, - }) { - final accel = []; - final hr = []; - var i = 0; - void active(int mins) { - for (var s = 0; s < mins * 60; s++, i++) { - // Oscillating orientation → gravity deltas well above the still floor. - final x = (i.isEven) ? 0.0 : 0.25; - accel.add(AccelSample(i * 1000.0, x, 0, 0.97)); - hr.add(80.0); - } - } - - void still(int mins) { - for (var s = 0; s < mins * 60; s++, i++) { - accel.add(AccelSample(i * 1000.0, 0, 0, 1)); // constant → immobile - hr.add(50.0); - } - } - - active(activeMin); - still(napMin); - active(activeMin); - return (accel: accel, hr: hr); - } - - test('too little data → honest absent (null value), never a fake empty', () { - final m = detectNaps(const [], const []); - expect(m.present, isFalse); - expect(m.value, isNull); - expect(m.confidence, 0); - expect(m.tier, Tier.estimate); - expect(m.note, contains('too little data')); - }); - - test('detects a qualifying non-main sleep block as a nap', () { - final day = buildDay(activeMin: 90, napMin: 150); - final m = detectNaps(day.accel, day.hr); // no main window → not excluded - expect(m.present, isTrue); - expect(m.tier, Tier.estimate); - expect(m.value, isNotEmpty); - for (final nap in m.value!) { - // Every reported nap honors the 20 min – 6 h envelope. - expect(nap.durationSec, greaterThanOrEqualTo(20 * 60)); - expect(nap.durationSec, lessThanOrEqualTo(6 * 3600)); - expect(nap.endSec, greaterThan(nap.startSec)); - expect(nap.confidence, inInclusiveRange(0.0, 1.0)); - } - }); - - test('a long secondary sleep block (>3h, <=6h) is now captured, not dropped', () { - final day = buildDay(activeMin: 90, napMin: 4 * 60); - final m = detectNaps(day.accel, day.hr); // no main window → not excluded - expect(m.present, isTrue); - expect(m.value, isNotEmpty); - expect(m.value!.first.durationSec, greaterThan(3 * 3600)); - expect(m.value!.first.durationSec, lessThanOrEqualTo(6 * 3600)); - }); - - test('a block longer than 6h is still rejected as a nap', () { - final day = buildDay(activeMin: 90, napMin: 7 * 60); - final m = detectNaps(day.accel, day.hr); - // 7h exceeds maxNapSec — no qualifying nap reported for that block. - expect(m.value, isEmpty); - }); - - test('the main sleep window is carved out (no nap overlaps it)', () { - final day = buildDay(activeMin: 90, napMin: 150); - // Main window = the whole still block's index range (indices into arrays). - final start = 90 * 60; - final end = start + 150 * 60; - final m = detectNaps( - day.accel, - day.hr, - mainSleep: SleepWindowSpan(start, end), - ); - expect(m.present, isTrue); - // The only sleep block IS the main sleep → excluded → empty list. - expect(m.value, isEmpty); - }); - }); - // ------------------------------------------------------------------------- // REGRESSION: physiologicalAge must ABSTAIN with no physiology, and must // report the inputs it ACTUALLY used. diff --git a/test/onehz/nap_test.dart b/test/onehz/nap_test.dart new file mode 100644 index 0000000..082a9a8 --- /dev/null +++ b/test/onehz/nap_test.dart @@ -0,0 +1,439 @@ +// Nap detector — synthetic known-answer tests. +// +// The regime under test is the one the NIGHT detector deliberately rejects: +// `AdvancedSleepStager.minSleepMin = 60` exists so "daytime naps and stray +// still-blocks stay excluded" (advanced_stager.dart:327), and anything centred +// 11:00–20:00 local additionally needs ≥90 min plus an HR dip. Reusing that +// detector for naps made the canonical 20–45 min afternoon nap structurally +// undetectable. These tests pin the new, purpose-built path. +import 'dart:math' as math; + +import 'package:test/test.dart'; +import 'package:openstrap_analytics/src/onehz/types.dart'; +import 'package:openstrap_analytics/src/onehz/sleep/nap.dart'; + +/// A synthetic day at 1 Hz. Timestamps are `i * 1000` ms, so absolute epoch +/// seconds equal the array index — which keeps the `[startSec, endSec]` span +/// arguments (off-wrist, charging) readable in tests. +class _Day { + final accel = []; + final hr = []; + int _i = 0; + + int get cursor => _i; + + /// Advance the CLOCK without emitting samples — a pruning / sync hole. The + /// substrate is a positional array, so the samples either side of this are + /// adjacent indices despite being [minutes] apart in wall time. + void gap(int minutes) => _i += minutes * 60; + + /// Wrist in motion: a SMOOTH angular sweep of up to ~14°/s. + /// + /// Deliberately smooth rather than a per-second square wave — van Hees runs + /// a 5 s rolling median, which erases an alternation between two values + /// entirely and would make "active" read as perfectly still. + void active(int minutes, {double bpm = 82}) { + for (var s = 0; s < minutes * 60; s++, _i++) { + final deg = 45 + 35 * math.sin(_i * 0.4); + final rad = deg * math.pi / 180; + accel.add(AccelSample(_i * 1000.0, math.cos(rad), 0, math.sin(rad))); + hr.add(bpm); + } + } + + /// A motionless block at one fixed orientation. + /// `bpm <= 0` writes an HR gap — the substrate's own "no reading" encoding, + /// which is what off-wrist and dropout actually look like. + void still(int minutes, {double bpm = 54}) { + for (var s = 0; s < minutes * 60; s++, _i++) { + accel.add(AccelSample(_i * 1000.0, 0, 0, 1)); + hr.add(bpm); + } + } +} + +void main() { + group('detectNaps — the 20–60 min regime the night detector rejects', () { + test('a 30-minute nap is detected', () { + final d = _Day() + ..active(120) + ..still(30) + ..active(120); + + final m = detectNaps(d.accel, d.hr); + + expect(m.present, isTrue, reason: 'ran with ample data'); + expect(m.value, hasLength(1), + reason: 'exactly one still, HR-dipped block in the day'); + final nap = m.value!.single; + // Tight on purpose. The van Hees rule marks the second that STARTS a + // sustained-still window, so the last `sustainedMin` of any still block + // is never itself marked immobile — a naive bout end under-reports every + // nap by a flat 5 min. This tolerance fails unless the tail is recovered + // from the actual angle data. + expect(nap.tstSec, closeTo(30 * 60, 90), + reason: 'a 30 min nap should report ~30 min ASLEEP, not 25'); + expect(nap.tibSec, greaterThanOrEqualTo(nap.tstSec), + reason: 'time in bed can never be less than time asleep'); + expect(nap.confidence, inInclusiveRange(0.2, 0.85)); + }); + + test('a 20-minute nap is detected', () { + final d = _Day() + ..active(90) + ..still(20) + ..active(90); + + final m = detectNaps(d.accel, d.hr); + + expect(m.value, hasLength(1)); + expect(m.value!.single.tstSec, closeTo(20 * 60, 90)); + }); + + test('a 12-minute rest is below the floor and is not a nap', () { + final d = _Day() + ..active(90) + ..still(12) + ..active(90); + + expect(detectNaps(d.accel, d.hr).value, isEmpty); + }); + + test('two separate naps are reported separately, not merged', () { + final d = _Day() + ..active(60) + ..still(20) + ..active(60) + ..still(25) + ..active(60); + + final m = detectNaps(d.accel, d.hr); + + expect(m.value, hasLength(2), reason: 'a 60 min gap is not an arousal'); + expect(m.value![0].tstSec, closeTo(20 * 60, 90)); + expect(m.value![1].tstSec, closeTo(25 * 60, 90)); + }); + + test('a brief arousal is bridged, and costs TST but not TIB', () { + final d = _Day() + ..active(90) + ..still(20) + ..active(2) // 2 min arousal — shorter than the 5 min bridge + ..still(20) + ..active(90); + + final m = detectNaps(d.accel, d.hr); + + expect(m.value, hasLength(1), reason: 'one nap with an arousal in it'); + final nap = m.value!.single; + expect(nap.tibSec, closeTo(42 * 60, 120), reason: '20 + 2 + 20 in bed'); + expect(nap.tstSec, lessThan(nap.tibSec), + reason: 'the arousal is time in bed but NOT time asleep'); + expect(nap.efficiency, lessThan(1.0)); + }); + }); + + group('detectNaps — false-positive rejection', () { + test('still but with no HR dip is desk work, not a nap', () { + final d = _Day() + ..active(90, bpm: 80) + ..still(40, bpm: 80) // motionless, but HR never drops + ..active(90, bpm: 80); + + final m = detectNaps(d.accel, d.hr); + + expect(m.value, isEmpty); + expect(m.note, contains('no HR dip'), + reason: 'the rejection reason must be visible, not silent'); + }); + + test('an off-wrist span is rejected even though it is perfectly still', () { + final d = _Day() + ..active(90) + ..still(40) // band on a table: still, and "HR" looks restful + ..active(90); + final offStart = 90 * 60, offEnd = offStart + 40 * 60; + + final m = detectNaps( + d.accel, + d.hr, + wristOff: [ + [offStart, offEnd] + ], + ); + + expect(m.value, isEmpty); + expect(m.note, contains('off-wrist')); + }); + + test('a charging/workout exclusion span is rejected', () { + final d = _Day() + ..active(90) + ..still(40) + ..active(90); + final exStart = 90 * 60, exEnd = exStart + 40 * 60; + + final m = detectNaps( + d.accel, + d.hr, + exclude: [ + [exStart, exEnd] + ], + ); + + expect(m.value, isEmpty); + }); + + test('overlapping off-wrist spans count their union, not their sum', () { + // Two spans covering the SAME 12 minutes of a 40-min nap. That is 30% of + // the bout contradicted, under the 50% rejection bar, so the nap stands. + // Summing the spans independently scores it 60% and throws the nap away — + // and drives `corroborated` to 0 for any bout that survives. The band's + // own toggle events do not overlap today, so this pins the contract for + // every other caller rather than a live symptom. + final d = _Day() + ..active(90) + ..still(40) + ..active(90); + final napStart = 90 * 60; + final dupStart = napStart + 5 * 60, dupEnd = dupStart + 12 * 60; + + final m = detectNaps( + d.accel, + d.hr, + wristOff: [ + [dupStart, dupEnd], + [dupStart, dupEnd], + ], + ); + + expect(m.present, isTrue); + expect(m.value, hasLength(1), + reason: 'the same 12 minutes listed twice is still 12 minutes'); + }); + + test('thin HR coverage abstains for that bout rather than guessing', () { + final d = _Day() + ..active(90) + ..still(40, bpm: 0) // HR gap throughout — nothing to corroborate with + ..active(90); + + final m = detectNaps(d.accel, d.hr); + + expect(m.value, isEmpty, reason: 'never assert sleep without evidence'); + expect(m.note, contains('unverifiable')); + }); + + test('a block longer than 6 h is not a nap', () { + final d = _Day() + ..active(60) + ..still(7 * 60) + ..active(60); + + expect(detectNaps(d.accel, d.hr).value, isEmpty); + }); + + test('the main sleep window is carved out', () { + final d = _Day() + ..active(60) + ..still(120) + ..active(60); + final start = 60 * 60, end = start + 120 * 60; + + final m = detectNaps( + d.accel, + d.hr, + mainSleep: SleepWindowSpan(start, end), + ); + + expect(m.present, isTrue); + expect(m.value, isEmpty, reason: 'the only block IS the main sleep'); + }); + }); + + group('detectNaps — honest absence', () { + test('too little data is absent, never an empty list', () { + final m = detectNaps(const [], const []); + + expect(m.present, isFalse); + expect(m.value, isNull, reason: 'absent and "found none" differ'); + expect(m.confidence, 0); + expect(m.tier, Tier.estimate); + expect(m.note, contains('too little data')); + }); + + test('a day with no awake HR is absent, not zero naps', () { + final d = _Day() + ..active(90, bpm: 0) + ..still(30, bpm: 0) + ..active(90, bpm: 0); + + final m = detectNaps(d.accel, d.hr); + + expect(m.present, isFalse, + reason: 'no baseline to judge against — abstain'); + expect(m.note, contains('awake daytime HR')); + }); + + test('too few awake HR samples abstains rather than setting a threshold', + () { + // Only ~2 min of awake HR in the whole day. A median over that is not a + // baseline, and every nap verdict hangs off it. + final d = _Day() + ..active(2, bpm: 80) + ..active(120, bpm: 0) + ..still(30) + ..active(120, bpm: 0); + + final m = detectNaps(d.accel, d.hr); + + expect(m.present, isFalse); + expect(m.note, contains('need')); + }); + + test('a judged day holding no nap is an EMPTY list, not absent', () { + final d = _Day()..active(240); + + final m = detectNaps(d.accel, d.hr); + + expect(m.present, isTrue, reason: 'we did judge this day'); + expect(m.value, isEmpty); + }); + }); + + group('detectNaps — boundary safety', () { + test('a bout still running at the record end is DEFERRED, not emitted', () { + // This is the phantom-nap bug: with a 3 h buffer past midnight, the + // first hours of TONIGHT'S sleep were emitted as a multi-hour "nap" for + // the day that was ending, then counted again as tomorrow's main sleep. + final d = _Day() + ..active(120) + ..still(90); // record ends mid-sleep + + final m = detectNaps(d.accel, d.hr); + + expect(m.value, isEmpty, + reason: 'cannot know when a right-censored bout ends'); + expect(m.note, contains('deferred')); + }); + + test('the same bout IS emitted once the record shows it ending', () { + final d = _Day() + ..active(120) + ..still(90) + ..active(60); // now we can see it end + + expect(detectNaps(d.accel, d.hr).value, hasLength(1)); + }); + + test('an awakening inside the unfinished night does not free a phantom nap', + () { + // REGRESSION. Deferring only the bout whose LAST second is still is not + // enough. The nap window runs 3 h past midnight, so tonight's sleep is in + // it; a perfectly ordinary 6-minute awakening at 01:50 splits that sleep + // into two bouts, and only the trailing one touches the record end. The + // leading fragment was emitted as a ~170-minute "nap" for the day that + // was ending, credited against tonight's sleep need, and then counted a + // SECOND time as tomorrow's main sleep. + final d = _Day() + ..still(360, bpm: 50) // last night's sleep — passed as mainSleep + ..active(1020, bpm: 82) // the waking day + ..still(170, bpm: 50) // tonight's sleep begins + ..active(6, bpm: 70) // a 6 min awakening — longer than the 5 min bridge + ..still(64, bpm: 50); // still asleep when the record stops + + final m = detectNaps( + d.accel, + d.hr, + mainSleep: const SleepWindowSpan(0, 360 * 60), + ); + + expect(m.present, isTrue); + expect(m.value, isEmpty, + reason: 'both halves of an unfinished night must be deferred — ' + 'neither is a nap that happened today'); + expect(m.note, contains('deferred')); + }); + }); + + group('detectNaps — the HR baseline is genuinely awake', () { + test('a real nap is still detected on a sleep-dominated window', () { + // REGRESSION. The baseline excluded only `mainSleep`, so it still + // contained the candidate nap's own low-HR seconds AND the hours of + // tonight's sleep that the nap window deliberately borrows. On a window + // where sleep outweighs wake, the median collapses toward sleeping HR and + // the dip gate becomes self-suppressing: the quieter the sleep, the lower + // the bar it has to beat. Here the contaminated median is ~48 bpm, so a + // genuine 60 bpm nap could never clear 0.95 x 48. + final d = _Day() + ..active(50, bpm: 78) + ..still(30, bpm: 60) // the genuine nap + ..active(70, bpm: 78) // > napChainGapSec, so no chained deferral + ..still(180, bpm: 48); // tonight's sleep, unfinished at the record end + + final m = detectNaps(d.accel, d.hr); + + expect(m.value, hasLength(1), + reason: 'the awake baseline is ~78 bpm; a 60 bpm nap clears it'); + expect(m.value!.single.tstSec, closeTo(30 * 60, 90)); + }); + }); + + group('detectNaps — recording holes are not stillness', () { + test('a gap inside a still block does not become one long nap', () { + // The substrate is a POSITIONAL array: pruning and sync holes leave the + // samples either side of a 2 h absence at adjacent indices. Measuring the + // bout in sample counts would read that unobserved hole as unbroken + // stillness and report a 2 h 20 m nap from 20 minutes of evidence. + final d = _Day() + ..active(90) + ..still(10) + ..gap(120) + ..still(10) + ..active(90); + + final m = detectNaps(d.accel, d.hr); + + expect(m.value, isEmpty, + reason: 'two 10-minute observed blocks, each below the 15 min floor ' + '— the unobserved 2 h between them is not sleep'); + }); + + test('a nap reported across no gap has tib matching its own bounds', () { + final d = _Day() + ..active(90) + ..still(30) + ..active(90); + + final nap = detectNaps(d.accel, d.hr).value!.single; + + expect(nap.tibSec, nap.endSec - nap.startSec, + reason: 'in-bed seconds must equal the reported span, or the card ' + 'and the clock range disagree'); + }); + }); + + group('detectNaps — determinism', () { + test('detection does not depend on wall-clock time of day', () { + // The old path routed candidates through a local-clock 11:00–20:00 guard, + // so an identical nap appeared or vanished with the machine timezone. + List shifted(List a, int hours) => [ + for (final s in a) + AccelSample(s.tsMs + hours * 3600 * 1000, s.x, s.y, s.z) + ]; + + final d = _Day() + ..active(120) + ..still(30) + ..active(120); + + final baseline = detectNaps(d.accel, d.hr); + for (final h in [3, 7, 11, 14, 19, 23]) { + final m = detectNaps(shifted(d.accel, h), d.hr); + expect(m.value!.length, baseline.value!.length, + reason: 'same nap, shifted $h h — detection must not move'); + expect(m.value!.single.tstSec, baseline.value!.single.tstSec); + } + }); + }); +} diff --git a/tool/nap_harness.dart b/tool/nap_harness.dart new file mode 100644 index 0000000..45abe6c --- /dev/null +++ b/tool/nap_harness.dart @@ -0,0 +1,311 @@ +// VALIDATION HARNESS — score the SHIPPED nap detector against hand-labelled +// days. +// +// Runs `detectNaps` (the real production entry point, not a reimplementation) +// over 1 Hz days carrying human nap labels, and reports the metrics that are +// actually informative for a rare-event detector: +// +// * EVENT-level sensitivity and PPV, not per-second accuracy. Naps occupy a +// low-single-digit percentage of a day, so a detector that reports nothing +// scores >97% per-second accuracy. Per-second accuracy is not printed at +// all, because there is no honest way to read it. +// * BOTH sensitivity and PPV, always together. The two failure modes here +// are opposite and both real: missing the 20-45 min power nap (the regime +// the nocturnal detector structurally rejected), and calling a still wrist +// — desk work, a car passenger seat, a band on a table — a nap. +// * DURATION error on matched pairs, in minutes of TIB — the only duration an +// interval label can score (see the second caveat below). Detecting that a +// nap happened is only half the job: the duration is what reaches sleep +// need, though the TST that actually reaches it is not validated here. +// * The PER-SUBJECT distribution, not just the pooled figure. Following +// Radha 2019 (PMID 31578345) on the stager: the spread is the whole story +// for "works for most, awful for a few". +// +// CAVEAT, and it is a real one, larger than the stager harness's. We have no +// PSG-labelled nap corpus. PSG can stage a daytime nap perfectly well — the +// MSLT is exactly that — so this is a statement about what THIS evaluation +// had access to, not a claim that a gold standard cannot exist. These labels +// are human-annotated from the accelerometer/HR trace and self-report. What +// this scores is agreement with an ANNOTATOR, on a small, self-collected +// corpus. Report it that way. It will not support a population precision +// claim, and it should never be quoted as one. +// +// A second limit, from the same source: a label is a [start, end] INTERVAL, so +// the only duration it can score is TIB. This corpus cannot validate `tstSec` +// at all — and TST is the field that feeds the sleep-need credit. Scoring TST +// against an interval label charges each nap its own awake time as error. +// +// Usage: +// dart run tool/nap_harness.dart [flags] +// +// --iou overlap needed to call a detection a match (default 0.5) +// --per-day print one line per day, including the detector's own note +// +// Fixture schema — `accel` and `hr` must be the same length, 1 Hz, contiguous: +// {"days": [ +// {"subject": "S1", +// "tsStartSec": 1783500000, +// "accel": [[x,y,z], ...], +// "hr": [72, 0, 74, ...], // 0 means NO READING, never a zero HR +// "mainSleep": [startIdx, endIdx], // optional +// "wristOff": [[startSec,endSec]], // optional, ABSOLUTE seconds +// "exclude": [[startSec,endSec]], // optional, ABSOLUTE seconds +// "naps": [[startSec,endSec]] // GROUND TRUTH, seconds RELATIVE +// }]} + +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:openstrap_analytics/onehz.dart'; + +void main(List args) { + if (args.isEmpty) { + stderr.writeln('usage: dart run tool/nap_harness.dart ' + '[--iou x] [--per-day]'); + exitCode = 64; + return; + } + + final Map fixture; + try { + fixture = + jsonDecode(File(args.first).readAsStringSync()) as Map; + } on FileSystemException catch (e) { + stderr.writeln('cannot read fixture: ${e.message}'); + exitCode = 66; + return; + } + + final days = (fixture['days'] as List?)?.cast>(); + if (days == null || days.isEmpty) { + stderr.writeln('fixture: no "days"'); + exitCode = 65; + return; + } + + double argOf(String name, double dflt) { + final i = args.indexOf(name); + if (i < 0 || i + 1 >= args.length) return dflt; + return double.tryParse(args[i + 1]) ?? dflt; + } + + final iouCut = argOf('--iou', 0.5); + final perDay = args.contains('--per-day'); + + final schemaErrors = _validate(days); + if (schemaErrors.isNotEmpty) { + for (final e in schemaErrors.take(10)) { + stderr.writeln('fixture: $e'); + } + exitCode = 65; + return; + } + + var tp = 0, fp = 0, fn = 0, absent = 0; + // Labelled naps on days the detector ABSTAINED on. They never reach tp/fp/fn, + // so leaving them uncounted made sensitivity and PPV silently conditional on + // the days the detector agreed to judge — and a detector that abstains on its + // hard days would score better than one that tries. Reported separately, plus + // an end-to-end recall that charges abstentions as misses. + var absentLabels = 0; + final durErrMin = []; + final perSubject = >{}; // subject -> [tp, fp, fn] + + for (final day in days) { + final subject = (day['subject'] as String?) ?? '?'; + final t0 = (day['tsStartSec'] as num).toInt(); + final rawAccel = (day['accel'] as List).cast(); + final accel = [ + for (var i = 0; i < rawAccel.length; i++) + AccelSample( + (t0 + i) * 1000.0, + (rawAccel[i][0] as num).toDouble(), + (rawAccel[i][1] as num).toDouble(), + (rawAccel[i][2] as num).toDouble(), + ), + ]; + final hr = [for (final v in day['hr'] as List) (v as num).toDouble()]; + + SleepWindowSpan? main; + if (day['mainSleep'] case final List m when m.length == 2) { + main = SleepWindowSpan((m[0] as num).toInt(), (m[1] as num).toInt()); + } + + final m = detectNaps( + accel, + hr, + mainSleep: main, + wristOff: _spans(day['wristOff']), + exclude: _spans(day['exclude']), + ); + + final truth = [ + for (final t in (day['naps'] as List? ?? const []).cast()) + [(t[0] as num).toInt(), (t[1] as num).toInt()] + ]; + + if (!m.present) { + // An abstention is NOT a miss to be scored as if the detector had made a + // wrong call — it is a refusal to judge. Counting it as a false negative + // would reward a detector that guesses over one that abstains honestly. + absent++; + absentLabels += truth.length; + if (perDay) { + stdout.writeln(' $subject: ABSTAINED (${truth.length} labelled) ' + '— ${m.note}'); + } + continue; + } + + final got = m.value!; + final matched = {}; + var dtp = 0, dfp = 0; + + for (final nap in got) { + var best = -1; + var bestIou = 0.0; + for (var k = 0; k < truth.length; k++) { + if (matched.contains(k)) continue; + final iou = _iou(nap.startSec, nap.endSec, truth[k][0], truth[k][1]); + if (iou > bestIou) { + bestIou = iou; + best = k; + } + } + if (best >= 0 && bestIou >= iouCut) { + matched.add(best); + dtp++; + // TIB, not TST. A label is a [start, end] INTERVAL, so its length is + // the whole episode — time in bed. Scoring it against `tstSec` charged + // every matched nap its own awake time as error: a perfectly measured + // 2 h episode at 70% efficiency reported a 36-min miss. That is the + // exact TST/TIB conflation this detector exists to end, reappearing in + // the tool that validates it. + final truthSec = truth[best][1] - truth[best][0]; + durErrMin.add((nap.tibSec - truthSec).abs() / 60.0); + } else { + dfp++; + } + } + final dfn = truth.length - matched.length; + + tp += dtp; + fp += dfp; + fn += dfn; + final acc = perSubject.putIfAbsent(subject, () => [0, 0, 0]); + acc[0] += dtp; + acc[1] += dfp; + acc[2] += dfn; + + if (perDay) { + stdout.writeln(' $subject: tp=$dtp fp=$dfp fn=$dfn — ${m.note}'); + } + } + + final sens = (tp + fn) == 0 ? null : tp / (tp + fn); + final ppv = (tp + fp) == 0 ? null : tp / (tp + fp); + + stdout.writeln(''); + stdout.writeln('NAP DETECTOR — ${days.length} day(s), ' + '${perSubject.length} subject(s), IoU ≥ $iouCut'); + // Recall over EVERY labelled nap, including those on abstained days. The + // sensitivity above is conditional on the days the detector judged; this one + // is what a user actually experiences, since an abstention shows them no nap. + final allLabels = tp + fn + absentLabels; + final e2e = allLabels == 0 ? null : tp / allLabels; + + stdout.writeln(' labelled naps : $allLabels ' + '(${tp + fn} on judged days, $absentLabels on abstained days)'); + stdout.writeln(' detected : ${tp + fp}'); + stdout.writeln(' abstained : $absent day(s), ' + '$absentLabels labelled nap(s) not scored below'); + stdout.writeln(' TP=$tp FP=$fp FN=$fn'); + stdout.writeln(' sensitivity : ${_pct(sens)} ' + '(missed naps, JUDGED days only)'); + stdout.writeln(' PPV : ${_pct(ppv)} ' + '(false naps, JUDGED days only)'); + stdout.writeln(' end-to-end : ${_pct(e2e)} ' + '(recall over ALL labels; abstentions count as misses)'); + if (durErrMin.isNotEmpty) { + final med = _median(durErrMin)!; + final mx = durErrMin.reduce(math.max); + // TIB, because a label is an interval. See the matching branch above — + // this corpus cannot score TST at all. + stdout.writeln(' |TIB error| : median ${med.toStringAsFixed(1)} min, ' + 'worst ${mx.toStringAsFixed(1)} min (n=${durErrMin.length} matched)'); + } + + if (perSubject.length > 1) { + stdout.writeln(''); + stdout.writeln(' per subject (the spread is the story):'); + final names = perSubject.keys.toList()..sort(); + for (final s in names) { + final a = perSubject[s]!; + final ss = (a[0] + a[2]) == 0 ? null : a[0] / (a[0] + a[2]); + final pp = (a[0] + a[1]) == 0 ? null : a[0] / (a[0] + a[1]); + stdout.writeln(' $s: sens ${_pct(ss)} PPV ${_pct(pp)} ' + '(tp=${a[0]} fp=${a[1]} fn=${a[2]})'); + } + } + + stdout.writeln(''); + stdout.writeln(' READ THIS AS: agreement with a human annotator on a small ' + 'self-collected corpus.'); + stdout.writeln(' We have no PSG-LABELLED nap corpus (PSG can stage naps — ' + 'the MSLT does; we just'); + stdout.writeln(' do not have one). Do not quote these as population ' + 'accuracy. Labels are intervals,'); + stdout.writeln(' so TIB is scored and TST is NOT validated here.'); +} + +List> _spans(Object? raw) => [ + for (final s in (raw as List? ?? const []).cast()) + [(s[0] as num).toInt(), (s[1] as num).toInt()] + ]; + +/// Intersection over union of two [start, end) second ranges. +double _iou(int aS, int aE, int bS, int bE) { + final lo = math.max(aS, bS), hi = math.min(aE, bE); + final inter = hi > lo ? hi - lo : 0; + final union = (aE - aS) + (bE - bS) - inter; + return union <= 0 ? 0 : inter / union; +} + +String _pct(double? x) => + x == null ? ' n/a' : '${(x * 100).toStringAsFixed(1)}%'; + +double? _median(List xs) { + if (xs.isEmpty) return null; + final s = [...xs]..sort(); + final mid = s.length ~/ 2; + return s.length.isOdd ? s[mid] : (s[mid - 1] + s[mid]) / 2; +} + +List _validate(List> days) { + final errs = []; + for (var i = 0; i < days.length; i++) { + final d = days[i]; + final tag = 'day[$i] (${d['subject'] ?? '?'})'; + if (d['tsStartSec'] is! num) errs.add('$tag: missing tsStartSec'); + final accel = d['accel']; + final hr = d['hr']; + if (accel is! List || accel.isEmpty) { + errs.add('$tag: missing accel'); + continue; + } + if (hr is! List) { + errs.add('$tag: missing hr'); + continue; + } + if (accel.length != hr.length) { + errs.add('$tag: accel ${accel.length} != hr ${hr.length}'); + } + for (final t in (d['naps'] as List? ?? const []).cast()) { + if (t.length != 2 || (t[1] as num) <= (t[0] as num)) { + errs.add('$tag: bad nap label $t'); + } + } + } + return errs; +}