From 54ba3c6098a8d9f96eb17b0f605b1d8bd282b2a1 Mon Sep 17 00:00:00 2001 From: SATHVIK SVS Date: Tue, 4 Aug 2026 11:16:28 +0530 Subject: [PATCH 1/2] sleep: detect naps with a nap detector, not the one built to reject them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `detectNaps` delegated to `AdvancedSleepStager.detectSleep`, whose `minSleepMin = 60` exists — in its own comment — "so daytime naps and stray still-blocks stay excluded", plus a 90-min + HR-dip guard for anything centred 11:00–20:00 local. The canonical 20–45 min afternoon nap was therefore STRUCTURALLY undetectable: the advertised 20-min floor was unreachable dead code. It failed silently, returning an empty list with the note "no qualifying naps (20 min–3 h)" — wrong about the range (the constant was 6 h) and crediting van Hees to a path that never called van_hees.dart. The same 75-min nap was detected at 10:00 and not at 14:00. Those floors are load-bearing for NIGHT accuracy (#90, and the episode where gravity-magnitude stillness produced 389 of 421 false positives), so they are not loosened. New `sleep/nap.dart` instead, run strictly on the complement of the main sleep window: - van Hees z-angle immobility, via the new `immobilityMask` extracted from van_hees.dart so night and nap share ONE primitive. An angle is orientation-invariant, so it does not inherit the ~13% spread in |accel| across static wrist postures that motivated those guards. - every bout, not just the longest, bridged across brief arousals - an HR dip against the AWAKE-DAYTIME baseline: seconds that are neither the main sleep nor any detected bout. Excluding only the main sleep left the candidate's own low-HR seconds, and the hours of tonight's sleep the nap window borrows, inside the median it is judged against — which makes the gate self-suppressing, since the quieter the sleep the lower the bar. - TST and in-bed reported SEPARATELY. Time in bed was the only figure before, and it is what fed the sleep-need credit. - a real confidence (HR coverage, dip depth, still fraction, per-bout wear corroboration), not sleep efficiency wearing the name - abstain, never guess: under 10 min of awake HR, or under 50% HR coverage in a bout, the answer is absent rather than a number No sleep-stage claim, deliberately: a 30-min nap holds no complete cycle and the daytime HR duty cycle cannot support a 4-class partition. No timezone dependence either — corroboration is physiological, not clock-based, so a nap no longer appears or vanishes with the machine's local offset. Durations are WALL CLOCK, and a run breaks at a timestamp discontinuity. The substrate is a positional array with pruning and sync holes, so measuring in sample counts let 20 minutes of evidence report a 2-hour nap. A bout the record cannot see the end of is DEFERRED, and so is any bout chained to it. Deferring only the bout touching the array end is not enough: an ordinary 6-min awakening splits an unfinished night and only the trailing half touches the end, leaving the leading multi-hour fragment to be emitted as a nap. Adds tool/nap_harness.dart, scoring the shipped detector on hand-labelled days and reporting EVENT-level sensitivity and PPV together, TST error on matched pairs, and the per-subject spread. Per-second accuracy is deliberately not printed: naps are a few percent of a day, so a detector that reports nothing scores >97%. The honest caveat is in the header — there is no PSG nap corpus, because PSG is a nocturnal protocol, so this measures agreement with an annotator on a small self-collected set and cannot support a population claim. The old nap tests are removed with the old detector: all five used blocks of 150 min or more, sized (per their own comment) to clear the very gates that made the real nap regime unreachable, so none could ever have exercised it. --- ALGORITHMS.md | 2 + lib/src/onehz/human/coaching.dart | 117 --------- lib/src/onehz/sleep/nap.dart | 394 ++++++++++++++++++++++++++++ lib/src/onehz/sleep/sleep.dart | 5 + lib/src/onehz/sleep/van_hees.dart | 122 +++++++-- test/onehz/coaching_test.dart | 90 ------- test/onehz/nap_test.dart | 411 ++++++++++++++++++++++++++++++ tool/nap_harness.dart | 274 ++++++++++++++++++++ 8 files changed, 1187 insertions(+), 228 deletions(-) create mode 100644 lib/src/onehz/sleep/nap.dart create mode 100644 test/onehz/nap_test.dart create mode 100644 tool/nap_harness.dart diff --git a/ALGORITHMS.md b/ALGORITHMS.md index eeea03d..ced7660 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 | 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..d4e94cb --- /dev/null +++ b/lib/src/onehz/sleep/nap.dart @@ -0,0 +1,394 @@ +// 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; + + 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) continue; + + if (mainSleep != null && + start < mainSleep.end && + end > mainSleep.start) { + continue; + } + + final offFrac = _overlapFraction(aStart, aEnd, wristOff); + final exFrac = _overlapFraction(aStart, aEnd, exclude); + if (offFrac >= maxNapOffWristFraction || + exFrac >= maxNapOffWristFraction) { + offWrist++; + continue; + } + + final inBout = []; + for (var k = start; k < end; k++) { + if (hr[k] > 0) inBout.add(hr[k]); + } + final coverage = inBout.length / tib; + if (coverage < minNapHrCoverage) { + unverifiable++; + continue; + } + final medHr = median(inBout)!; + 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 (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). +double _overlapFraction(int start, int end, List> spans) { + final dur = end - start; + if (dur <= 0 || spans.isEmpty) return 0; + var covered = 0; + 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) covered += hi - lo; + } + 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..b1c36ce --- /dev/null +++ b/test/onehz/nap_test.dart @@ -0,0 +1,411 @@ +// 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('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..9c5b6a2 --- /dev/null +++ b/tool/nap_harness.dart @@ -0,0 +1,274 @@ +// 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 TST. Detecting that a nap +// happened is only half the job: the duration is what reaches sleep need. +// * 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. There is no +// PSG nap corpus: polysomnography is a nocturnal protocol, so these labels are +// human-annotated from the accelerometer/HR trace and self-report, not a +// physiological gold standard. 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. +// +// 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; + 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++; + 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++; + final truthSec = truth[best][1] - truth[best][0]; + durErrMin.add((nap.tstSec - 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'); + stdout.writeln(' labelled naps : ${tp + fn}'); + stdout.writeln(' detected : ${tp + fp}'); + stdout.writeln(' abstained : $absent day(s)'); + stdout.writeln(' TP=$tp FP=$fp FN=$fn'); + stdout.writeln(' sensitivity : ${_pct(sens)} (missed naps)'); + stdout.writeln(' PPV : ${_pct(ppv)} (false naps)'); + if (durErrMin.isNotEmpty) { + final med = _median(durErrMin)!; + final mx = durErrMin.reduce(math.max); + stdout.writeln(' |TST 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(' There is no PSG nap gold standard — PSG is a nocturnal ' + 'protocol. Do not quote'); + stdout.writeln(' these as population accuracy.'); +} + +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; +} From 0e2814beee92ea19d6bf250e60bb8c56c4d947e1 Mon Sep 17 00:00:00 2001 From: SATHVIK SVS Date: Thu, 6 Aug 2026 22:02:38 +0530 Subject: [PATCH 2/2] nap review: score TIB against interval labels, and stop hiding abstentions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review on #38. The two that mattered are both in the harness — the numbers that justify the detector, not the detector itself. THE HARNESS SCORED TST AGAINST A TIB LABEL. A fixture label is a [startSec, endSec] INTERVAL, so its length is the whole episode: time in bed. It was compared against `nap.tstSec`, so every matched nap was charged its own awake time as error — a perfectly measured 2 h episode at 70% efficiency reported a 36-minute miss. That is precisely the TST/TIB conflation this PR exists to end, reappearing in the tool that validates it. Now compares `tibSec`, and the metric is renamed `|TIB error|` rather than left mislabelled. The honest consequence is stated rather than papered over: an interval label cannot score TST at all, so this corpus does not validate the field that actually feeds the sleep-need credit. Said in the header and in the output. ABSTENTIONS WERE FREE. Labelled naps on an abstained day never reached tp/fp/fn, so `labelled naps` under-reported the corpus and sensitivity/PPV were silently conditional on the days the detector agreed to judge — a detector that abstains on its hard days outscored one that tries. Abstained labels are now counted and printed, the two conditional metrics say JUDGED days only, and a new end-to-end recall charges abstentions as misses, which is what a user experiences: an abstention shows them no nap. PSG CLAIM CORRECTED. "PSG is a nocturnal protocol" is false — the MSLT stages daytime naps, and PSG nap corpora exist. The true statement is narrower: we do not HAVE a PSG-labelled nap corpus. Overclaiming that a gold standard cannot exist is still overclaiming. Fixed in the header and the printed footer. _overlapFraction summed spans instead of unioning them, so two overlapping entries counted the same second twice, the fraction could exceed 1, and a bout only half contradicted was rejected outright with `corroborated` driven to 0 — while the doc comment above it has always promised "covered by ANY of [spans]". LATENT, not live: edge's `_toggleSpans` is a correct toggle machine that cannot emit overlapping spans within a list, and wristOff/exclude are evaluated independently. Fixed as a contract guarantee for other callers, with a test verified to fail against the summing version. Two rejection paths — duration out of range, and overlap with the main sleep window — dropped their bout without incrementing a counter, while every other path reports itself in `skipped`. A day whose only candidate was a 7-hour still block therefore answered "no qualifying nap" with an empty tail, telling the caller nothing. Both counted now. The inner `inBout` HR list shadowed the whole-day boolean mask of the same name declared above the loop. Not a live bug — the mask is not read inside the loop — but the next edit that reaches for it would get a List. Renamed to `boutHr`. ALGORITHMS.md still listed `detectNaps` under `human/coaching.dart`, which this PR empties, contradicting its own new `sleep/nap.dart` row. Not taken: defensive validation of the fixture JSON before casting. It is a dev-only tool reading a hand-written file; a stack trace is adequate signal and the guard is unearned ceremony. dart analyze clean; 400 tests pass (was 399). --- ALGORITHMS.md | 2 +- lib/src/onehz/sleep/nap.dart | 52 +++++++++++++++++++++---- test/onehz/nap_test.dart | 28 ++++++++++++++ tool/nap_harness.dart | 73 +++++++++++++++++++++++++++--------- 4 files changed, 128 insertions(+), 27 deletions(-) diff --git a/ALGORITHMS.md b/ALGORITHMS.md index ced7660..afff0ff 100644 --- a/ALGORITHMS.md +++ b/ALGORITHMS.md @@ -128,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/sleep/nap.dart b/lib/src/onehz/sleep/nap.dart index d4e94cb..4e72fbc 100644 --- a/lib/src/onehz/sleep/nap.dart +++ b/lib/src/onehz/sleep/nap.dart @@ -266,6 +266,10 @@ Metric> detectNaps( 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]; @@ -284,11 +288,15 @@ Metric> detectNaps( final aStart = absAt(start); final aEnd = absAt(end - 1) + 1; final tib = aEnd - aStart; - if (tib < minNapSec || tib > maxNapSec) continue; + if (tib < minNapSec || tib > maxNapSec) { + outOfRange++; + continue; + } if (mainSleep != null && start < mainSleep.end && end > mainSleep.start) { + inMainSleep++; continue; } @@ -300,16 +308,19 @@ Metric> detectNaps( continue; } - final inBout = []; + // 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) inBout.add(hr[k]); + if (hr[k] > 0) boutHr.add(hr[k]); } - final coverage = inBout.length / tib; + final coverage = boutHr.length / tib; if (coverage < minNapHrCoverage) { unverifiable++; continue; } - final medHr = median(inBout)!; + final medHr = median(boutHr)!; if (medHr > baseline * napRestingHrMult) { awakeStill++; continue; @@ -357,6 +368,8 @@ Metric> detectNaps( 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', @@ -379,16 +392,39 @@ Metric> detectNaps( ); } -/// Fraction of [start, end) covered by any of [spans] (absolute seconds). +/// 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; - var covered = 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) covered += hi - lo; + 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/test/onehz/nap_test.dart b/test/onehz/nap_test.dart index b1c36ce..082a9a8 100644 --- a/test/onehz/nap_test.dart +++ b/test/onehz/nap_test.dart @@ -184,6 +184,34 @@ void main() { 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) diff --git a/tool/nap_harness.dart b/tool/nap_harness.dart index 9c5b6a2..45abe6c 100644 --- a/tool/nap_harness.dart +++ b/tool/nap_harness.dart @@ -13,19 +13,27 @@ // 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 TST. Detecting that a nap -// happened is only half the job: the duration is what reaches sleep need. +// * 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. There is no -// PSG nap corpus: polysomnography is a nocturnal protocol, so these labels are -// human-annotated from the accelerometer/HR trace and self-report, not a -// physiological gold standard. 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. +// 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] @@ -95,6 +103,12 @@ void main(List args) { } 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] @@ -136,6 +150,7 @@ void main(List args) { // 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}'); @@ -161,8 +176,14 @@ void main(List args) { 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.tstSec - truthSec).abs() / 60.0); + durErrMin.add((nap.tibSec - truthSec).abs() / 60.0); } else { dfp++; } @@ -188,16 +209,30 @@ void main(List args) { stdout.writeln(''); stdout.writeln('NAP DETECTOR — ${days.length} day(s), ' '${perSubject.length} subject(s), IoU ≥ $iouCut'); - stdout.writeln(' labelled naps : ${tp + fn}'); + // 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)'); + 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)'); - stdout.writeln(' PPV : ${_pct(ppv)} (false naps)'); + 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); - stdout.writeln(' |TST error| : median ${med.toStringAsFixed(1)} min, ' + // 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)'); } @@ -217,9 +252,11 @@ void main(List args) { stdout.writeln(''); stdout.writeln(' READ THIS AS: agreement with a human annotator on a small ' 'self-collected corpus.'); - stdout.writeln(' There is no PSG nap gold standard — PSG is a nocturnal ' - 'protocol. Do not quote'); - stdout.writeln(' these as population accuracy.'); + 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) => [