From d6ba41cd1d3a5a463a051b4b872bbaf5a3c00543 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:32:06 +0530 Subject: [PATCH 1/3] cadence: stop counting array positions as time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nocturnal rhr, van hees and both stagers measured their windows in samples, which only equals seconds on whoop. on a 5s band van hees reported a sleep span 85% short and nocturnal rhr ran +11% high, both at tier high. cardio stager was worse — at 300s it reported zero wake for a night it couldn't read at all. they take timestamps now and abstain when the cadence can't support the window. one median-interval helper instead of three, and it abstains rather than falling back to 1.0s (which credited a whole day's readings one second each above 300s). the g/s threshold needed a ceiling — |Δg| between unit gravity vectors saturates at 2, so a linear rate goes vacuous past ~200s and everything reads still. also in here: accel validity was dropped going into GravTs, so 8h of undecoded accel published efficiency 100%. readiness let three quantized nights be a baseline, so [58,58,59] plus a 52 scored 99.9. autonomicStager deleted. device family enum opened — it was gen4/gen5 only and a closed enum can't take a third band. --- lib/src/onehz/clinical/load_trimp.dart | 33 +-- lib/src/onehz/clinical/nocturnal.dart | 108 ++++++---- lib/src/onehz/device.dart | 41 ++-- lib/src/onehz/respiration/resp_rate.dart | 30 ++- lib/src/onehz/sleep/advanced_stager.dart | 176 ++++++++++++---- lib/src/onehz/sleep/cardio_stager.dart | 37 +++- lib/src/onehz/sleep/segment.dart | 10 +- lib/src/onehz/sleep/sleep.dart | 9 +- lib/src/onehz/sleep/stager.dart | 196 +----------------- lib/src/onehz/sleep/van_hees.dart | 116 +++++++++-- lib/src/onehz/util.dart | 73 +++++++ .../onehz/wellness/readiness_composite.dart | 47 ++++- lib/src/onehz/wellness/temp_circadian.dart | 6 +- lib/src/onehz/workout/hr_zones.dart | 27 +-- lib/src/onehz/workout/observed_max_hr.dart | 13 +- test/onehz/cadence_group_c_test.dart | 134 ++++++++++++ test/onehz/clinical_test.dart | 4 +- test/onehz/device_test.dart | 30 ++- test/onehz/real_capture_test.dart | 2 +- test/onehz/sleep_cadence_test.dart | 140 +++++++++++++ test/onehz/sleep_honesty_test.dart | 35 ++++ test/onehz/sleep_test.dart | 75 ------- test/onehz/util_test.dart | 143 +++++++++++++ test/onehz/wellness_test.dart | 78 +++++-- 24 files changed, 1087 insertions(+), 476 deletions(-) create mode 100644 test/onehz/cadence_group_c_test.dart create mode 100644 test/onehz/sleep_cadence_test.dart diff --git a/lib/src/onehz/clinical/load_trimp.dart b/lib/src/onehz/clinical/load_trimp.dart index e2daa95..38a5f47 100644 --- a/lib/src/onehz/clinical/load_trimp.dart +++ b/lib/src/onehz/clinical/load_trimp.dart @@ -460,21 +460,15 @@ class StrainScorer { // ── TRIMP accumulation ────────────────────────────────────────────────────── - /// Median inter-sample interval (seconds) of a time-ordered stream, ignoring - /// non-positive steps and pathological (>[maxPlausibleGapSec]) ones. Floored - /// at [fallbackSampleMin] minutes' worth. Mirrors the convention already used - /// by `HeartRateZones.timeInZone`. - static double medianIntervalSeconds(List tsSec, - {double maxPlausibleGapSec = 300.0}) { - final gaps = []; - for (var i = 1; i < tsSec.length; i++) { - final g = tsSec[i] - tsSec[i - 1]; - if (g > 0 && g <= maxPlausibleGapSec) gaps.add(g); - } - if (gaps.isEmpty) return fallbackSampleMin * 60.0; - gaps.sort(); - return math.max(gaps[gaps.length ~/ 2], fallbackSampleMin * 60.0); - } + /// Measured cadence (seconds) of a time-ordered stream, or NULL. + /// + /// Now a thin alias for [sampleCadenceSeconds], which is the single helper + /// for all three of the old near-duplicates. Kept because it is public API; + /// the old `maxPlausibleGapSec` parameter is gone — the ceiling lives on + /// [maxSupportedCadenceSec] and the old `fallbackSampleMin` floor was the + /// fabricated 1 s that made a 301 s stream look like a 1 Hz one. + static double? medianIntervalSeconds(List tsSec) => + sampleCadenceSeconds(tsSec); /// PER-SAMPLE effort durations (minutes) from the ACTUAL timestamps. /// @@ -488,11 +482,16 @@ class StrainScorer { /// on exactly the sparse/irregular streams [minSparseReadings] admits: 21 /// samples over 20 min with the first two 1 s apart scored strain 8.08 /// instead of ~47, and a 1 Hz stream with a 5-min leading gap scored 104. + /// EMPTY when the stream's cadence cannot be measured — see + /// [sampleCadenceSeconds]. Every duration here is a multiple of that cadence, + /// so an unmeasurable cadence is an unmeasurable effort, and [strain] turns + /// it into an absent metric rather than a small confident one. static List sampleDurationsMinutes(List tsSec) { final n = tsSec.length; if (n == 0) return const []; if (n == 1) return [fallbackSampleMin]; final capSec = medianIntervalSeconds(tsSec); + if (capSec == null) return const []; final out = List.filled(n, capSec / 60.0); for (var i = 0; i < n - 1; i++) { final g = tsSec[i + 1] - tsSec[i]; @@ -573,6 +572,10 @@ class StrainScorer { if (!enoughData || effMax <= restingHR) return null; final durations = sampleDurationsMinutes(tsSec); + // No measurable cadence ⇒ no durations ⇒ no effort. Must NOT fall through: + // `banisterTRIMP` credits `fallbackSampleMin` per sample when handed an + // empty list, which is the fabricated 1 s this abstention exists to stop. + if (durations.isEmpty) return null; final trimp = banisterTRIMP(bpm, restingHR, effMax - restingHR, durations, female: female); return trimpToStrain(trimp, denominator: denominator); diff --git a/lib/src/onehz/clinical/nocturnal.dart b/lib/src/onehz/clinical/nocturnal.dart index 9cf46ec..5f85bcf 100644 --- a/lib/src/onehz/clinical/nocturnal.dart +++ b/lib/src/onehz/clinical/nocturnal.dart @@ -22,23 +22,59 @@ class NocturnalRhr { }; } -/// Nocturnal resting HR from a night of 1 Hz HR samples. +/// Nocturnal resting HR from a night of HR samples. /// -/// [hr] 1 Hz HR samples (bpm; 0 = off-skin, excluded). [windowSamples] rolling -/// window length in SAMPLE POSITIONS (default 1800 = 30 min at 1 Hz). -/// [minCoverage] fraction of a window's positions that must carry a valid -/// on-skin sample for the window to count. +/// [hr] HR samples (bpm; 0 = off-skin, excluded). [window] the rolling trough +/// window as a DURATION (default 30 min). [tsSec] their sample times (seconds); +/// null keeps the historical contract — one sample per second, so a position IS +/// a wall-clock second, which is what every WHOOP caller feeds. [minCoverage] +/// fraction of a window's expected samples that must carry a valid on-skin +/// reading for the window to count. /// -/// The window slides over WALL-CLOCK POSITIONS, not over the compacted valid -/// stream: an off-skin gap must not be closed up, or the "30-min" window can -/// silently span the whole night. A night with no window meeting [minCoverage] -/// yields an ABSENT metric — we never relabel the whole-night mean as a -/// lowest-30-min trough. +/// The window slides over WALL-CLOCK TIME, not over sample positions. It used +/// to be a fixed 1800 POSITIONS, which is 30 min only at 1 Hz: on a 15 s band +/// the same 1800 positions span 7.5 h, so "the lowest 30-min mean" quietly +/// became "the whole-night mean" — measured on a real night, 59.7 bpm published +/// as 66.4. With [tsSec] the window length is derived from the stream's own +/// measured cadence ([sampleCadenceSeconds]), which ABSTAINS rather than +/// guessing, so an unmeasurable cadence yields an absent metric. +/// +/// Off-skin gaps are still never compacted away: coverage is checked against +/// the samples a full window SHOULD hold, so a window that is mostly hole stays +/// ineligible. A night with no window meeting [minCoverage] yields an ABSENT +/// metric — we never relabel the whole-night mean as a lowest-30-min trough. Metric nocturnalRhr(List hr, - {int windowSamples = 1800, double minCoverage = 0.9}) { + {List? tsSec, + Duration window = const Duration(minutes: 30), + double minCoverage = 0.9}) { const inputs = ['hr_1hz']; final valid = hr.where((h) => h > 0).toList(); - if (windowSamples < 1 || hr.length < windowSamples) { + if (tsSec != null && tsSec.length != hr.length) { + return const Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'HR and timestamps disagree in length', + ); + } + final ts = tsSec ?? [for (var i = 0; i < hr.length; i++) i.toDouble()]; + // The null path is 1 Hz BY CONTRACT, not by measurement — pinning it here + // keeps WHOOP output bit-identical instead of routing it through a helper + // that can abstain on a night the app already scores today. + final cadence = tsSec == null ? 1.0 : sampleCadenceSeconds(ts); + final winSec = window.inSeconds.toDouble(); + if (cadence == null) { + return const Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'no measurable sampling cadence — a 30-min trough cannot be ' + 'located without knowing how much time one sample covers', + ); + } + // Samples a fully-covered window holds at this cadence: 1800 at 1 Hz, 120 at + // 15 s, 30 at 60 s. This is the number the old fixed `windowSamples = 1800` + // got wrong for every stream that is not 1 Hz. + final perWindow = winSec / cadence; + if (perWindow < 1 || hr.length < perWindow) { return const Metric.absent( tier: Tier.high, inputs_used: inputs, @@ -46,33 +82,31 @@ Metric nocturnalRhr(List hr, ); } // Lowest rolling mean over CONTIGUOUS wall-clock windows. A window is only - // eligible when at least [minCoverage] of its positions are on-skin; its - // mean is taken over the valid samples inside it. - final needValid = (minCoverage * windowSamples).ceil(); + // eligible when at least [minCoverage] of the samples it should hold are + // on-skin; its mean is taken over the valid samples inside it. + final needValid = (minCoverage * perWindow).ceil(); + // A window ending before this has not had a full [window] of stream behind + // it — the same rule the positional loop expressed as "start at index 1800". + final firstFullEnd = ts.first + winSec - cadence; var sum = 0.0; var count = 0; - for (var i = 0; i < windowSamples; i++) { - if (hr[i] > 0) { - sum += hr[i]; - count++; - } - } + var lo = 0; double? best; - if (count >= needValid) best = sum / count; - for (var i = windowSamples; i < hr.length; i++) { - if (hr[i] > 0) { - sum += hr[i]; + for (var hi = 0; hi < hr.length; hi++) { + if (hr[hi] > 0) { + sum += hr[hi]; count++; } - final out = hr[i - windowSamples]; - if (out > 0) { - sum -= out; - count--; - } - if (count >= needValid) { - final m = sum / count; - if (best == null || m < best) best = m; + while (ts[hi] - ts[lo] >= winSec) { + if (hr[lo] > 0) { + sum -= hr[lo]; + count--; + } + lo++; } + if (ts[hi] < firstFullEnd || count < needValid) continue; + final m = sum / count; + if (best == null || m < best) best = m; } if (best == null) { return const Metric.absent( @@ -83,13 +117,17 @@ Metric nocturnalRhr(List hr, ); } final p1 = percentile(valid, 1)!; - final conf = clamp(valid.length / 7200.0, 0.4, 0.95); // ~2 h coverage => high + // Confidence is COVERAGE IN SECONDS, not a sample count — 480 samples of a + // 60 s band is the same 8 h of night as 28,800 samples at 1 Hz. + final conf = + clamp(valid.length * cadence / 7200.0, 0.4, 0.95); // ~2 h => high return Metric( value: NocturnalRhr(best, p1, valid.length), confidence: conf, tier: Tier.high, inputs_used: inputs, - note: 'lowest-30-min mean + 1st-percentile; HR=0 excluded as off-skin', + note: 'lowest-${window.inMinutes}-min mean + 1st-percentile; HR=0 excluded ' + 'as off-skin', ); } diff --git a/lib/src/onehz/device.dart b/lib/src/onehz/device.dart index 5173a3f..c6fac0a 100644 --- a/lib/src/onehz/device.dart +++ b/lib/src/onehz/device.dart @@ -18,7 +18,7 @@ // // HOW A METRIC USES IT — the whole mechanism, no framework: // -// const _ceiling = {DeviceFamily.gen4: 230, DeviceFamily.gen5: 210}; +// const _ceiling = {'gen4': 230, 'gen5': 210}; // // Metric foo(..., {String? deviceFamily}) { // final hi = calibrationFor(_ceiling, deviceFamily); @@ -36,33 +36,22 @@ // no registry, no plugin table and no central constants file: a shared table // is how one family's number quietly becomes another's. -/// The sensor packages we have calibrated algorithms for. -/// -/// `gen5` covers the whole fd4b family (WHOOP 5, MG) — they share one sensor -/// package. Add a value here only alongside the calibration work; an enum entry -/// with no constants behind it just moves the refusal one step later. -enum DeviceFamily { gen4, gen5 } - -/// Parse the ingest-stamped family id (`decoded_onehz.device_family`). -/// -/// Returns null for NULL, for empty, and for any id this build does not know — -/// all three mean "unknown provenance", which is its own case, not gen4. -DeviceFamily? deviceFamilyOf(String? id) => switch (id) { - 'gen4' => DeviceFamily.gen4, - 'gen5' => DeviceFamily.gen5, - _ => null, - }; - -/// The id a [DeviceFamily] is stamped as. Inverse of [deviceFamilyOf]. -String deviceFamilyId(DeviceFamily f) => f.name; - /// This metric's own constants for [family], or null when it must REFUSE — -/// either the family is unknown or this metric has nothing calibrated for it. +/// either the family is unstamped or this metric has nothing calibrated for it. /// Both are refusals: never substitute another family's constants. -T? calibrationFor(Map byFamily, String? family) { - final f = deviceFamilyOf(family); - return f == null ? null : byFamily[f]; -} +/// +/// THE SET OF FAMILIES IS OPEN, and it is each map's own business who is in it. +/// There used to be a closed `DeviceFamily` enum here, which made "a strap this +/// build has never heard of" and "a strap this METRIC has no numbers for" the +/// same answer arrived at in two places — and meant a new band could not be +/// calibrated for one metric at a time. A key is in a map or it is not. +/// +/// Null and empty are not keys: both mean NO STAMP, which no map may answer +/// for. (An id is never trimmed or case-folded on the way in — the stamp is +/// written by ingest, and quietly matching a near-miss is how one family's +/// constants get applied to another's counts.) +T? calibrationFor(Map byFamily, String? family) => + (family == null || family.isEmpty) ? null : byFamily[family]; /// MACHINE-READABLE refusal note, same convention as `need_baseline:`. /// diff --git a/lib/src/onehz/respiration/resp_rate.dart b/lib/src/onehz/respiration/resp_rate.dart index 50a8a1d..9b683ed 100644 --- a/lib/src/onehz/respiration/resp_rate.dart +++ b/lib/src/onehz/respiration/resp_rate.dart @@ -322,9 +322,11 @@ Metric rsaRespRate( /// /// Respiratory-Induced Intensity Variation: a 0.1–0.5 Hz band-pass on the green /// ADC, then the dominant spectral peak in the respiratory band => breaths/min. -/// [adc] the 1 Hz green ADC samples, [tsSec] their times (seconds). Uneven -/// times are fine — we use Lomb-Scargle, no resampling. [validFraction] of the -/// window that passed the contact/SQI gate drives confidence. +/// [adc] the green ADC samples, [tsSec] their times (seconds). Uneven times are +/// fine — we use Lomb-Scargle, no resampling — but the stream must sample at +/// 1 Hz OR FASTER, because the band is fixed and anything slower aliases into +/// it (see the Nyquist gate below). [validFraction] of the window that passed +/// the contact/SQI gate drives confidence. Metric riivRespRate( List adc, List tsSec, { @@ -347,6 +349,28 @@ Metric riivRespRate( note: 'degenerate timestamps', ); } + // NYQUIST. Unlike RSA (which derives its ceiling per window, `_beatNyquist`), + // this band was FIXED at 0.1–0.5 Hz with no reference to the sampling rate at + // all. A stream slower than 1 Hz cannot represent it: at 5 s sampling every + // real breath sits above Nyquist and folds back INSIDE the band as an alias, + // so the peak is real, in range, and about a rate nobody is breathing — + // measured on a real night, 21.2 br/min was published as 10.8. Narrowing the + // search grid is no defence, because an alias is in-band by construction. + // The only honest output is absence. + final cadenceSec = sampleCadenceSeconds(tsSec); + if (cadenceSec == null || 1.0 / (2 * cadenceSec) < respHiHz) { + return Metric.absent( + tier: Tier.relative, + inputs_used: inputs, + note: cadenceSec == null + ? 'no measurable sampling cadence — cannot rule out a respiratory ' + 'alias, so RIIV is withheld' + : 'sampling at ${round6(cadenceSec)}s (Nyquist ' + '${round6(1.0 / (2 * cadenceSec))} Hz) cannot represent the ' + '${respLoHz}–${respHiHz} Hz respiratory band — every rate in it ' + 'would alias into the band; withheld', + ); + } // Detrend (remove DC/slow baseline wander) via a robust-ish linear fit; the // band-pass character comes from restricting the Lomb-Scargle grid to the // respiratory band, which rejects both DC (<0.1 Hz) and HR/cardiac (>0.5 Hz). diff --git a/lib/src/onehz/sleep/advanced_stager.dart b/lib/src/onehz/sleep/advanced_stager.dart index fa2358a..b54e138 100644 --- a/lib/src/onehz/sleep/advanced_stager.dart +++ b/lib/src/onehz/sleep/advanced_stager.dart @@ -37,6 +37,7 @@ import 'dart:math' as math; import '../types.dart'; +import '../util.dart'; import '../clinical/hrv_time.dart'; import 'cardio_stager.dart' show cardioStager; import 'accounting.dart' show SleepStage; @@ -66,12 +67,21 @@ class HrTs { } /// Gravity vector sample (g): [ts] unix seconds. +/// +/// [valid] mirrors [AccelSample.valid]: false means the vector was never +/// decoded and is handed over as exact (0,0,0). That is NOT a measurement of +/// anything — a run of them is a perfectly constant vector, i.e. a wrist held +/// perfectly still, which is how 8 h of undecoded accel published a 100 %- +/// efficiency night. Every consumer below asks about [valid] explicitly rather +/// than comparing (0,0,0) against a threshold (same rule van_hees.dart states +/// for its z-angle). class GravTs { final int ts; final double x; final double y; final double z; - const GravTs(this.ts, this.x, this.y, this.z); + final bool valid; + const GravTs(this.ts, this.x, this.y, this.z, {this.valid = true}); } /// RR interval: [ts] unix seconds, [rrMs]. @@ -171,13 +181,20 @@ class HypnogramMetrics { /// Advanced sleep stager — constants + the full V1/V2 algorithm. class AdvancedSleepStager { // ── Stage-0 constants ─────────────────────────────────────────────────────── - static const double gravityStillThresholdG = 0.01; + /// Stillness threshold on the gravity vector, in **g per second**. + /// + /// It is applied to a CONSECUTIVE-SAMPLE delta, so as a bare `g` figure it + /// silently meant "0.01 g between whatever two samples this device happened + /// to send". A faster sensor moves less between samples and therefore read as + /// motionless everywhere — a 50 Hz IMU staged an awake day as 16 h of sleep. + /// The number is unchanged at 1 Hz, where g and g/s are the same value; every + /// comparison now scales it by the measured cadence. + static const double gravityStillThresholdGPerS = 0.01; static const int stillWindowMin = 15; static const double stillFraction = 0.70; static const int maxGapMin = 20; static const int mergeMin = 15; static const int minSleepMin = 60; - static const double defaultIntervalS = 60.0; static const int secondsPerDay = 86400; static const int minWindowSamples = 3; static const double hrSleepBaselineMult = 1.05; @@ -215,7 +232,25 @@ class AdvancedSleepStager { static const double featureWindowS = 5 * 60.0; static const double ckCountDivisor = 100.0; static const double ckCountClip = 300.0; - static const double moveDeltaThresholdG = 0.01; + /// Movement threshold on the gravity vector, in **g per second** — same + /// consecutive-sample-delta trap as [gravityStillThresholdGPerS], same fix. + static const double moveDeltaThresholdGPerS = 0.01; + + /// Coarsest cadence the gravity-delta thresholds above will score (s/sample). + /// + /// They are RATES, so the per-sample cut grows with the sampling interval — + /// while |Δg| between two unit gravity vectors saturates at 2 g however long + /// you wait. At 30 s the cut is 0.3 g, already a ~17° orientation change; by + /// ~200 s it exceeds anything the sensor can produce and EVERY sample reads + /// still, which would publish a coarse band's whole day as one unbroken sleep + /// session. The rate model only holds while the interval is short against a + /// postural change, and 30 s is also this file's own staging epoch ([epochS]) + /// — a delta spanning more than one epoch cannot inform a 30 s epoch grid. + /// + /// ponytail: one ceiling for both thresholds. Splitting them, or replacing + /// the linear rate with a saturating angle model, needs data at those + /// cadences that we do not have. + static const double maxStillCadenceSec = epochS; static const double hrDogSigma1S = 120.0; static const double hrDogSigma2S = 600.0; @@ -248,9 +283,11 @@ class AdvancedSleepStager { // (per-epoch HR/HRV/RR/resp features -> percentile classifier -> median // smoothing -> `_reimposePhysiology`), which overrides the raw CK call, // and physiology is reimposed after. - // 3. RELATIVE, NOT ABSOLUTE. `_rescaleCounts` divides by `ckCountDivisor` - // and clips, so the spine reacts to WITHIN-NIGHT relative motion, not to - // an absolute count threshold the surrogate cannot honor. + // 3. RELATIVE, NOT ABSOLUTE. `_rescaleCounts` normalises by the epoch's + // sample count (via the measured cadence) and divides by + // `ckCountDivisor` and clips, so the spine reacts to WITHIN-NIGHT + // relative motion, not to an absolute count threshold the surrogate + // cannot honor — and not to how fast the device happens to sample. // The van Hees angle window remains the primary in-bed detector (the catalog's // sanctioned method); CK is a secondary within-window continuity spine. Do NOT // read CK output as a validated sleep/wake score. The catalog DO-NOT-SHIP line @@ -456,10 +493,19 @@ class AdvancedSleepStager { // ── Stage-0 helpers ───────────────────────────────────────────────────────── + /// |Δ gravity| vs the previous sample (index 0 is 0 by definition). NaN when + /// EITHER endpoint is invalid — the arm may have moved across the gap and the + /// record cannot say. Every comparison against NaN is false, so + /// [_classifyStill]'s `deltas[i] < threshold` degrades to "not still", never + /// to "perfectly still" (van Hees' `deltaDeg` contract, same reasoning). static List _gravityDeltas(List g) { final n = g.length; final out = List.filled(n, 0); for (var i = 1; i < n; i++) { + if (!g[i].valid || !g[i - 1].valid) { + out[i] = double.nan; + continue; + } final dx = g[i - 1].x - g[i].x; final dy = g[i - 1].y - g[i].y; final dz = g[i - 1].z - g[i].z; @@ -468,22 +514,12 @@ class AdvancedSleepStager { return out; } - static double _medianIntervalS(List times) { - if (times.length < 2) return defaultIntervalS; - final gaps = []; - for (var i = 0; i < times.length - 1; i++) { - final g = times[i + 1] - times[i]; - if (g > 0 && g < 300) gaps.add(g); - } - if (gaps.isEmpty) return 60; - gaps.sort(); - return math.max(gaps[gaps.length ~/ 2].toDouble(), 1.0); - } - - static int _windowSize(List times) { - final interval = _medianIntervalS(times); - return math.max(minWindowSamples, ((stillWindowMin * 60) / interval).toInt()); - } + /// Still-window length in SAMPLES for a measured cadence [interval] (s). + /// The cadence itself comes from [sampleCadenceSeconds], which ABSTAINS + /// rather than falling back — this used to fall back to 60 s, which turned a + /// device we cannot read into a confident 5-sample window. + static int _windowSize(double interval) => + math.max(minWindowSamples, ((stillWindowMin * 60) / interval).toInt()); static double _largestGapS(List times) { if (times.length < 2) return 0; @@ -495,6 +531,12 @@ class AdvancedSleepStager { return m.toDouble(); } + // ponytail: invalid samples still count toward gravity span/gaps here, so a + // record padded with undecoded rows reads as DENSE and the HR-only sparse + // bridging below stays off. That errs toward under-reporting sleep (the safe + // side, and the reason it is left alone) but can split a genuinely sparse + // night that also holds undecoded rows. Filter `valid` here too if that shows + // up on real records. static bool _isGravitySparse(List grav, List hr) { if (grav.length < 2 || hr.length < 2) return false; final hrSpan = hr.last.ts - hr.first.ts; @@ -520,11 +562,22 @@ class AdvancedSleepStager { static List _classifyStill(List grav, List deltas) { final n = grav.length; if (n < 2) return List.filled(n, false); - final half = _windowSize([for (final g in grav) g.ts]) ~/ 2; + // No measurable cadence — or one past [maxStillCadenceSec], where the g/s + // cut stops discriminating — ⇒ nothing is asserted still ⇒ no runs ⇒ no + // sessions ⇒ `mainSleep` is absent. The honest chain already exists; this + // just enters it instead of staging on a 60 s guess. + final cadence = + sampleCadenceSeconds([for (final g in grav) g.ts.toDouble()]); + if (cadence == null || cadence > maxStillCadenceSec) { + return List.filled(n, false); + } + final window = _windowSize(cadence); + final half = window ~/ 2; + // g/s × the seconds this delta actually spans. Identical at 1 Hz. + final stillCut = gravityStillThresholdGPerS * cadence; final stillPrefix = List.filled(n + 1, 0); for (var i = 0; i < n; i++) { - stillPrefix[i + 1] = - stillPrefix[i] + (deltas[i] < gravityStillThresholdG ? 1 : 0); + stillPrefix[i + 1] = stillPrefix[i] + (deltas[i] < stillCut ? 1 : 0); } final flags = List.filled(n, false); for (var i = 0; i < n; i++) { @@ -804,14 +857,26 @@ class AdvancedSleepStager { final rrBuckets = List>.generate(nEpochs, (_) => []); final respBuckets = List>.generate(nEpochs, (_) => []); - // gravity deltas over the segment. - final gDeltas = _gravityDeltas(gSeg); - for (var k = 0; k < gSeg.length; k++) { - final i = idx(gSeg[k].ts); - if (i == null) continue; - counts[i] += gDeltas[k]; - gravN[i] += 1; - if (gDeltas[k] >= moveDeltaThresholdG) moveN[i] += 1; + // gravity deltas over the segment. Both the per-sample move test and the + // Cole-Kripke count need the CADENCE: a delta spans one sampling interval, + // and `counts` is a SUM of them per epoch, so its magnitude is pure cadence + // (30 terms per epoch at 1 Hz, 6 at 5 s) before it means anything about + // movement. Absent a measurable cadence there is no motion evidence at all + // — leave `counts` at 0, `gravN` at 0 (⇒ `moveFrac` 1.0, "moving", which is + // this grid's existing conservative default) and hand Cole-Kripke nothing. + final cadence = + sampleCadenceSeconds([for (final g in gSeg) g.ts.toDouble()]); + final cadenceOk = cadence != null && cadence <= maxStillCadenceSec; + if (cadenceOk) { + final moveCut = moveDeltaThresholdGPerS * cadence; + final gDeltas = _gravityDeltas(gSeg); + for (var k = 0; k < gSeg.length; k++) { + final i = idx(gSeg[k].ts); + if (i == null) continue; + counts[i] += gDeltas[k]; + gravN[i] += 1; + if (gDeltas[k] >= moveCut) moveN[i] += 1; + } } for (final h in hSeg) { final i = idx(h.ts); @@ -836,12 +901,27 @@ class AdvancedSleepStager { if (hrCnt[i] > 0) hr[i] = hrSum[i] / hrCnt[i]; moveFrac[i] = gravN[i] > 0 ? moveN[i] / gravN[i] : 1.0; } - return _EpochGrid(edges, nEpochs, counts, hr, moveFrac, rrBuckets, respBuckets, - _coleKripke(_rescaleCounts(counts))); + // No cadence ⇒ no motion evidence ⇒ no sleep asserted. `counts` would be + // all-zero here, and an all-zero Cole-Kripke score is `si < 1` on every + // epoch, i.e. SLEEP everywhere — the exact fabrication the abstain exists + // to avoid, so it is spelled out rather than left to the arithmetic. + final ckFlags = !cadenceOk + ? List.filled(nEpochs, false) + : _coleKripke(_rescaleCounts(counts, cadence)); + return _EpochGrid(edges, nEpochs, counts, hr, moveFrac, rrBuckets, + respBuckets, ckFlags); } - static List _rescaleCounts(List counts) => - [for (final c in counts) math.min(c / ckCountDivisor, ckCountClip)]; + /// Per-epoch gravity-delta SUM → the Cole-Kripke count surrogate. + /// + /// The sum has one term per SAMPLE in the epoch, so at 5 s it carries a fifth + /// of the terms a 1 Hz epoch does for the same movement. Scaling by the + /// cadence normalises it to the per-epoch sample count [ckCountDivisor] was + /// calibrated against (30 samples per 30 s epoch); `× 1.0` at 1 Hz. + static List _rescaleCounts(List counts, double cadenceSec) => [ + for (final c in counts) + math.min(c * cadenceSec / ckCountDivisor, ckCountClip) + ]; static List _coleKripke(List rescaled) { final n = rescaled.length; @@ -1349,7 +1429,15 @@ class AdvancedSleepStager { final minStageableSec = 3 * epSec; if (span < minStageableSec) return [StageSegment(start, end, 'wake')]; - final gByTs = {for (final g in grav) if (g.ts >= start && g.ts < end) g.ts: g}; + // Invalid samples are NOT samples: they must not seed `usable`, and they + // must not become the source of a carry-forward. A second with only an + // undecoded vector falls through to the same unstaged→WAKE path as a + // second with no row at all (cardioStager itself never reads + // [AccelSample.valid], so it can only be protected here). + final gByTs = { + for (final g in grav) + if (g.valid && g.ts >= start && g.ts < end) g.ts: g + }; final hByTs = {for (final h in hr) if (h.ts >= start && h.ts < end) h.ts: h}; final accel = List.filled( span, AccelSample(start * 1000.0, 0, 0, 1.0)); @@ -1437,7 +1525,10 @@ class AdvancedSleepStager { static List _stageSession(int start, int end, List grav, List hr, List rr, List resp) { - final gSeg = [for (final g in grav) if (g.ts >= start && g.ts <= end) g]; + final gSeg = [ + for (final g in grav) + if (g.valid && g.ts >= start && g.ts <= end) g + ]; if (gSeg.length < 2) return [StageSegment(start, end, 'light')]; final hSeg = _rowsBetween(hr, start, end); final rSeg = [for (final r in rr) if (r.ts >= start && r.ts <= end) r]; @@ -1517,7 +1608,10 @@ class AdvancedSleepStager { static List _stageSessionV2( int start, int end, List grav, List hr, List rr) { final lo = start - _v2PadLo, hi = end + _v2PadHi; - final gravW = [for (final g in grav) if (g.ts >= lo && g.ts < hi) g]; + final gravW = [ + for (final g in grav) + if (g.valid && g.ts >= lo && g.ts < hi) g + ]; final hrW = [for (final h in hr) if (h.ts >= lo && h.ts < hi) h]; final rrW = [for (final r in rr) if (r.ts >= lo && r.ts < hi) r]; final feats = _v2Features(start, end, gravW, hrW, rrW); diff --git a/lib/src/onehz/sleep/cardio_stager.dart b/lib/src/onehz/sleep/cardio_stager.dart index d15d0bb..9d89d44 100644 --- a/lib/src/onehz/sleep/cardio_stager.dart +++ b/lib/src/onehz/sleep/cardio_stager.dart @@ -363,8 +363,12 @@ void resetCardioObservations() => _cardioObservations.clear(); /// Transparent cardiorespiratory stager. /// -/// [hr1hz] per-second HR (bpm; 0 = off-skin) over the in-bed window. -/// [accel] per-second gravity vectors, SAME length/time base as [hr1hz]. +/// [hr1hz] HR (bpm; 0 = off-skin) over the in-bed window, one entry per accel +/// sample. [accel] gravity vectors carrying their ABSOLUTE times, SAME +/// length/time base as [hr1hz]. The sampling cadence is MEASURED from +/// `accel[i].tsMs` ([sampleCadenceSeconds]) and the [epochSec] grid is laid +/// out in real seconds on top of it; a stream with no measurable cadence, or +/// one coarser than a single epoch, ABSTAINS (see [_abstain]). /// [rrMs] / [rrTsMs] beat-to-beat RR (ms) and their ABSOLUTE times (ms), same /// clock as `accel[i].tsMs`, ASCENDING in [rrTsMs] (the per-epoch window /// gather lower-bounds into it). Sparse/empty is fine — REM/deep just lean @@ -396,7 +400,24 @@ CardioStagerResult cardioStager( return true; }(), 'rrTsMs must be non-decreasing — the beat window is binary-searched'); final n = math.min(hr1hz.length, accel.length); - final nEpoch = n ~/ epochSec; + // ── REAL-TIME epoch grid ────────────────────────────────────────────────── + // `n ~/ epochSec` conflated ARRAY POSITIONS with SECONDS. At 1 Hz they are + // the same number and everything below is unchanged; off 1 Hz they are not, + // and the failure was not an accuracy one — a 300 s band gave + // `96 ~/ 30 = 3` "epochs" of 2.5 REAL HOURS each, which the 30-s-tuned rules + // labelled NREM end to end and published as `wakePct = 0.0`: a night this + // stager could not read, reported as zero wake. No new parameter is needed + // for the fix — `accel[i].tsMs` is already the clock `_cleanBeatsInWindow` + // binary-searches, so the cadence is measurable from the input we have. + final cadenceSec = + sampleCadenceSeconds([for (var i = 0; i < n; i++) accel[i].tsMs / 1000.0]); + // Coarser than one sample per epoch ⇒ there is no 30-s epoch to score, and + // the Webster/Cole-Kripke continuity rules below are specified at this epoch + // length. Stretching `epochSec` to fit the device would make a number appear + // where the evidence for it does not; abstain instead. + if (cadenceSec == null || cadenceSec > epochSec) return _abstain(epochSec); + final perEpoch = math.max(1, (epochSec / cadenceSec).round()); + final nEpoch = n ~/ perEpoch; if (nEpoch < 3) return _abstain(epochSec); // ── per-second ENMO (motion) against a LOCALLY-ADAPTIVE 1 g reference ────── @@ -424,11 +445,13 @@ CardioStagerResult cardioStager( mag[i] = math.sqrt(a.x * a.x + a.y * a.y + a.z * a.z); } const int _gRefWinSec = 300; // 5 min window, centered per epoch + // …in SAMPLES at the measured cadence. 300 at 1 Hz, unchanged. + final gRefHalf = math.max(1, (_gRefWinSec / cadenceSec).round()) ~/ 2; final gRefByEpoch = List.filled(nEpoch, 1.0); for (var e = 0; e < nEpoch; e++) { - final es = e * epochSec; - final lo = math.max(0, es - _gRefWinSec ~/ 2); - final hi = math.min(n, es + epochSec + _gRefWinSec ~/ 2); + final es = e * perEpoch; + final lo = math.max(0, es - gRefHalf); + final hi = math.min(n, es + perEpoch + gRefHalf); gRefByEpoch[e] = median(mag.sublist(lo, hi)) ?? 1.0; } @@ -448,7 +471,7 @@ CardioStagerResult cardioStager( final rk = List.filled(nEpoch, double.nan); for (var e = 0; e < nEpoch; e++) { - final s = e * epochSec, t = math.min(s + epochSec, n); + final s = e * perEpoch, t = math.min(s + perEpoch, n); // motion = mean ENMO over the epoch, against THIS epoch's local reference. final gRefE = gRefByEpoch[e]; var ms = 0.0; diff --git a/lib/src/onehz/sleep/segment.dart b/lib/src/onehz/sleep/segment.dart index 6905d62..0ecb324 100644 --- a/lib/src/onehz/sleep/segment.dart +++ b/lib/src/onehz/sleep/segment.dart @@ -459,6 +459,11 @@ SleepSegmentation segmentSleep( trimmedAccel[i].x, trimmedAccel[i].y, trimmedAccel[i].z, + // Absence is not a measurement — an undecoded vector arrives as exact + // (0,0,0), which scores a 0.0 g delta and reads as PERFECTLY still. + // Dropping this flag here published 8 h of undecoded accel as + // "TST 28619 s, efficiency 100.0 %, unobserved 0 s". See [GravTs.valid]. + valid: trimmedAccel[i].valid, ), ]; final hr = [ @@ -531,7 +536,10 @@ SleepSegmentation segmentSleep( for (var k = onset; k < tsSec.length && tsSec[k] < chosen.end; k++) { final off = tsSec[k] - chosen.start; if (off < 0 || off >= inBed) continue; - sampled[off] = true; + // A row whose accel never decoded is a row, not a measurement — the same + // reason its gravity vector is refused above. HR evidence below is a + // SEPARATE channel and is still collected from such a row. + if (trimmedAccel[k].valid) sampled[off] = true; if (trimmedHr[k] <= 0) continue; final lo = math.max(0, off - _hrEvidenceHalfWinSec); final hi = math.min(inBed - 1, off + _hrEvidenceHalfWinSec); diff --git a/lib/src/onehz/sleep/sleep.dart b/lib/src/onehz/sleep/sleep.dart index e18982d..7655fc8 100644 --- a/lib/src/onehz/sleep/sleep.dart +++ b/lib/src/onehz/sleep/sleep.dart @@ -26,11 +26,10 @@ export 'hr_fallback.dart'; export 'advanced_stager.dart'; export 'sri.dart'; export 'accounting.dart'; -// stager.dart still provides StagerResult + consolidateSleepStages (both used -// by cardio_stager and tests), but its `autonomicStager` is a DEPRECATED -// duplicate of `cardioStager` and is NOT re-exported from the barrel — deep- -// import 'src/onehz/sleep/stager.dart' if you still need the legacy estimator. -export 'stager.dart' hide autonomicStager; +// stager.dart provides StagerResult + the shared Webster/consolidation +// post-processing (both used by cardio_stager and tests). Its deprecated +// `autonomicStager` is DELETED — `cardioStager` is the stager. +export 'stager.dart'; export 'cardio_stager.dart'; export 'cpc.dart'; export 'circadian_np.dart'; diff --git a/lib/src/onehz/sleep/stager.dart b/lib/src/onehz/sleep/stager.dart index 0117a9f..54f8a90 100644 --- a/lib/src/onehz/sleep/stager.dart +++ b/lib/src/onehz/sleep/stager.dart @@ -1,25 +1,18 @@ -// SLEEP/CIRCADIAN — 3-class autonomic sleep stager (wake / NREM / REM). +// SLEEP/CIRCADIAN — the shared staging POST-PROCESSING (Webster rescore + +// stage-architecture consolidation) and the [StagerResult] shape both stagers +// return. +// +// The 3-class `autonomicStager` that used to live here is GONE (2026-08): it +// was deprecated, hidden from the barrel and reachable only from its own test, +// and it conflated epoch count with sample count (`n ~/ epochSec` assumes a +// 1 Hz stream, so any other cadence silently rescaled every epoch). The live +// stager is `cardioStager` (cardio_stager.dart), which reuses everything below. // // HONESTY CEILING (catalog rule 5): wrist staging is at best a 3-class // AUTONOMIC ESTIMATE, never a PSG 4-stage hypnogram. We NEVER emit N1/N2/N3. // This is tier ESTIMATE. -// -// Physiological basis (deterministic, no ML): -// - NREM (esp. deep): parasympathetic dominance → HR low & stable, HRV high, -// near-total immobility. -// - REM: autonomic activation → HR rises toward wake levels and becomes more -// variable (irregular), while skeletal muscle is ATONIC → still immobile. -// The "moving but immobile + HR up + HRV variable" pattern is REM's tell. -// - Wake: movement present OR HR clearly elevated with body motion. -// -// We classify per epoch (default 30 s) using: -// * immobility from the van Hees mask (motion → wake unless deep in window) -// * epoch mean HR relative to the night's sleep HR floor (low = NREM) -// * short-window HR variability (SDNN of per-epoch HR, or RR-RMSSD if given) -// REM is gated by immobility (atonia) AND elevated/variable HR. import 'dart:math' as math; -import '../types.dart'; import '../util.dart'; import 'accounting.dart' show SleepStage; @@ -45,177 +38,6 @@ class StagerResult { }; } -/// 3-class autonomic stager. -/// -/// DEPRECATED: superseded by [cardioStager] (transparent motion+HR+RMSSD rule -/// stager), which `segmentSleep` now uses. This hand-rolled deterministic stager -/// is retained only for its proven Webster-rescore + [consolidateSleepStages] -/// post-processing (which the cardio stager reuses); it is no longer the -/// segmentation engine. Prefer [cardioStager] for new code. -/// -/// [hr] per-second HR (bpm; 0 = off-skin). [immobile] per-second van Hees -/// immobility mask (same length). [epochSec] epoch granularity (default 30 s). -/// All inputs are within the in-bed window. RR is optional — when absent we use -/// per-epoch HR dispersion as the variability proxy (honest, coarser). -@Deprecated('Use cardioStager (motion+HR+RMSSD rule stager); kept for back-compat.') -Metric autonomicStager( - List hr, - List immobile, { - int epochSec = 30, -}) { - const inputs = ['hr_1hz', 'immobility_mask']; - final n = math.min(hr.length, immobile.length); - if (n < epochSec * 4) { - return const Metric.absent( - tier: Tier.estimate, - inputs_used: inputs, - note: 'too short for 3-class staging', - ); - } - final nEpoch = n ~/ epochSec; - if (nEpoch < 3) { - return const Metric.absent( - tier: Tier.estimate, - inputs_used: inputs, - note: 'too few epochs for 3-class staging', - ); - } - - // Per-epoch features. - final epHr = List.filled(nEpoch, double.nan); - final epVar = List.filled(nEpoch, 0); // HR SDNN within epoch - final epImmobile = List.filled(nEpoch, false); - for (var e = 0; e < nEpoch; e++) { - final lo = e * epochSec; - final hi = lo + epochSec; - final vals = []; - var immobCount = 0; - for (var i = lo; i < hi; i++) { - if (hr[i] > 0) vals.add(hr[i]); - if (immobile[i]) immobCount++; - } - if (vals.isNotEmpty) epHr[e] = mean(vals)!; - epVar[e] = vals.length >= 2 ? (stddev(vals) ?? 0) : 0; - epImmobile[e] = immobCount > epochSec / 2; - } - - // Sleep HR floor: 10th percentile of valid epoch HR (the deep-NREM bottom). - final validHr = [for (final h in epHr) if (!h.isNaN) h]; - if (validHr.length < 3) { - return const Metric.absent( - tier: Tier.estimate, - inputs_used: inputs, - note: 'insufficient valid HR for staging', - ); - } - final floor = percentile(validHr, 10)!; - final hrMedian = median(validHr)!; - // Variability scale: median epoch SDNN (used as REM threshold reference). - final varVals = [for (final v in epVar) if (v > 0) v]; - final varMed = varVals.isNotEmpty ? median(varVals)! : 0.0; - - // --- Wake-HR threshold ----------------------------------------------------- - // A genuine arousal/awakening shows BOTH motion AND a cardiac rise toward the - // waking level. The van Hees mask alone over-flags wake: its 5-min forward - // window smears a single reposition (a few seconds of >5° change) across the - // whole window, so a normal turn becomes ~5 min of "mobile". During such a - // reposition the heart rate stays in the sleep range, so requiring an HR rise - // before declaring WAKE removes that artifact while keeping true arousals. - // - // wakeHr = floor + 0.55·(median − floor): roughly mid-way between the deep - // sleep HR floor and the night-median, the cardiac signature of being awake. - final wakeHr = floor + 0.55 * (hrMedian - floor); - - final stages = List.filled(nEpoch, SleepStage.wake); - for (var e = 0; e < nEpoch; e++) { - final h = epHr[e]; - if (h.isNaN) { - stages[e] = SleepStage.wake; // off-skin → can't claim sleep - continue; - } - if (!epImmobile[e] && h > wakeHr) { - // Motion AND elevated HR → a real arousal/awakening (atonia broken with - // a cardiac rise). Motion WITHOUT an HR rise is treated as a reposition - // (or mask smear) and falls through to sleep classification below. - stages[e] = SleepStage.wake; - continue; - } - // Asleep (immobile, or moving without a cardiac arousal). - // Distinguish NREM vs REM by HR level + variability. - // NREM: HR near the floor, low variability (parasympathetic). - // REM: HR elevated toward median/wake + higher variability, still immobile. - final elevated = h > floor + 0.4 * (hrMedian - floor); - final variable = epVar[e] > 1.15 * varMed && varMed > 0; - if (elevated && variable) { - stages[e] = SleepStage.rem; - } else { - stages[e] = SleepStage.nrem; - } - } - - // Smooth singleton epochs (median-of-3) to suppress thrash. - final sm = List.from(stages); - for (var e = 1; e < nEpoch - 1; e++) { - if (stages[e - 1] == stages[e + 1] && stages[e] != stages[e - 1]) { - sm[e] = stages[e - 1]; - } - } - - // --- Webster / Cole-Kripke sleep-continuity rescoring ----------------------- - // Standard actigraphy post-processing: once sleep is established, brief WAKE - // bouts bracketed by sustained sleep are arousals, not real WASO, and are - // rescored to SLEEP. Real WASO requires a SUSTAINED arousal. We apply the - // classic Webster rules (durations in minutes, converted to epochs): - // after ≥ 4 min sleep, ≤ 1 min wake → sleep - // after ≥10 min sleep, ≤ 3 min wake → sleep - // after ≥15 min sleep, ≤ 5 min wake → sleep (Webster says 4; the extra - // minute is deliberate here — see the rules table for why) - // (Symmetric in both directions: "surrounded by" sleep on either side.) - _websterRescore(sm, epochSec); - - // --- Stage-architecture consolidation --------------------------------------- - // The raw per-epoch labels flip-flop nrem↔rem because the REM gate (elevated - // HR + above-median variability) is met intermittently within a single REM - // episode and during NREM micro-fluctuations. Real sleep architecture is a - // few SUSTAINED NREM/REM bouts within ~90-min cycles, not per-epoch jitter. - // Enforce: (a) REM only survives as EPISODES ≥ minRemMin, gap-bridged so a - // brief NREM intrusion inside an otherwise-REM run doesn't split it; (b) any - // remaining stage bout shorter than minBoutMin is merged into its neighbour. - // WAKE bouts are preserved (Webster already governs WASO); only the asleep - // NREM/REM micro-structure is consolidated. - _consolidateStages(sm, epochSec); - - var w = 0, nr = 0, r = 0; - for (final s in sm) { - switch (s) { - case SleepStage.wake: - w++; - break; - case SleepStage.nrem: - nr++; - break; - case SleepStage.rem: - r++; - break; - } - } - final tot = sm.length.toDouble(); - return Metric( - value: StagerResult( - stages: sm, - epochSec: epochSec, - wakePct: 100 * w / tot, - nremPct: 100 * nr / tot, - remPct: 100 * r / tot, - ), - confidence: 0.5, // honesty-bounded: a 3-class estimate, not PSG - tier: Tier.estimate, - inputs_used: inputs, - note: 'wrist 3-class autonomic ESTIMATE (wake/NREM/REM); ' - 'REM gated by atonia+HR; never N1/N2/N3, not PSG', - ); -} - /// Test seam for [_websterRescore] — exposed (non-private) so the regression /// test can drive the continuity rule directly. Not part of the public barrel. void websterRescoreAutonomic(List sm, int epochSec) => diff --git a/lib/src/onehz/sleep/van_hees.dart b/lib/src/onehz/sleep/van_hees.dart index 7c2c1b6..f21d81a 100644 --- a/lib/src/onehz/sleep/van_hees.dart +++ b/lib/src/onehz/sleep/van_hees.dart @@ -63,6 +63,11 @@ class SleepWindow { /// Duration of the detected sleep period (seconds). final int sptSec; + /// Seconds per entry of [immobile] / [immobileUnknown] / [zAngleDeg] — the + /// measured cadence of the accel stream, 1.0 for the 1 Hz substrate. The + /// masks are per-SAMPLE, so this is what turns a mask count into seconds. + final double cadenceSec; + const SleepWindow({ required this.onsetIdx, required this.offsetIdx, @@ -72,6 +77,7 @@ class SleepWindow { required this.zAngleDeg, required this.sptSec, this.immobileUnknown = const [], + this.cadenceSec = 1.0, }); /// Seconds whose immobility is genuinely undecidable — an unresolved record @@ -81,7 +87,7 @@ class SleepWindow { for (final u in immobileUnknown) { if (u) c++; } - return c; + return (c * cadenceSec).round(); } Map toJson() => { @@ -136,6 +142,17 @@ class ImmobilityMask { /// The angle-change threshold actually used, in degrees. final double thresholdDeg; + /// Measured sampling cadence of [zAngleDeg] (seconds per sample), or NULL + /// when the stream has no measurable cadence or is coarser than + /// [vanHeesMaxCadenceSec]. Null means NOTHING is asserted immobile: every + /// second is undecidable, because a 5°-between-successive-samples rule cannot + /// see an arm that moved and came back inside one sampling gap. + final double? cadenceSec; + + /// [sustainedSec] expressed in SAMPLES at [cadenceSec] — the length the + /// forward-window scan actually walks. 300 at 1 Hz. + final int sustainedSamples; + const ImmobilityMask({ required this.immobile, required this.immobileUnknown, @@ -143,9 +160,25 @@ class ImmobilityMask { required this.deltaDeg, required this.sustainedSec, required this.thresholdDeg, + required this.cadenceSec, + required this.sustainedSamples, }); } +/// Coarsest cadence the van Hees rule is published at (seconds per sample). +/// +/// van Hees 2015/2018 and GGIR specify the rule on a **5-second** rolling +/// median of the z-angle: the 5°/5-min test is a statement about how much the +/// wrist angle changes between successive 5 s values. Sampled slower than that, +/// the successive difference stops bounding the movement it is meant to bound — +/// an arm can lift and return inside one gap — and the rule over-calls rest, +/// which is the unsafe direction for a sleep window. +/// +/// ponytail: hard ceiling at the published epoch, so a 15 s band gets an ABSENT +/// sleep window rather than a generous one. Raising it is one edit here plus +/// validation data at that cadence — not a judgement call. +const double vanHeesMaxCadenceSec = 5; + /// 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. @@ -165,9 +198,33 @@ ImmobilityMask immobilityMask( deltaDeg: const [], sustainedSec: win, thresholdDeg: angleThresholdDeg, + cadenceSec: null, + sustainedSamples: win, ); } + // Every window below (the 5 min sustained scan, the 5 s smoother) was written + // in ARRAY POSITIONS, which is only seconds at 1 Hz. `AccelSample.tsMs` + // already carries the clock, so measure the cadence instead of assuming one. + final cadence = + sampleCadenceSeconds([for (final a in accel) a.tsMs / 1000.0]); + if (cadence == null || cadence > vanHeesMaxCadenceSec) { + // Nothing asserted, everything undecidable — the same honest shape the + // truncated record tail already uses, not a claim of movement either. + return ImmobilityMask( + immobile: List.filled(n, false), + immobileUnknown: List.filled(n, true), + zAngleDeg: List.filled(n, double.nan), + deltaDeg: List.filled(n, double.nan), + sustainedSec: win, + thresholdDeg: angleThresholdDeg, + cadenceSec: null, + sustainedSamples: win, + ); + } + final winSamples = math.max(1, (win / cadence).round()); + final smoothSamples = math.max(1, (smoothSec / cadence).round()); + // 1–2. z-angle + rolling-median smoothing. // // A second whose gravity vector was never decoded ([AccelSample.valid] false) @@ -181,7 +238,7 @@ ImmobilityMask immobilityMask( (i) => accel[i].valid ? zAngle(accel[i].x, accel[i].y, accel[i].z) : double.nan); - final ang = _rollingMedian(raw, smoothSec); + final ang = _rollingMedian(raw, smoothSamples); // 3. per-second immobility: |Δ z-angle| < threshold sustained for ≥ window. final dAng = List.filled(n, 0); @@ -212,7 +269,7 @@ ImmobilityMask immobilityMask( // failed on the data in hand), otherwise the arm could have moved where we // were not looking ⇒ undecidable, never asserted rest. for (var i = 0; i < n; i++) { - final hi = math.min(n, i + win); + final hi = math.min(n, i + winSamples); var maxd = 0.0; var gap = !accel[i].valid; for (var k = i + 1; k < hi; k++) { @@ -226,7 +283,7 @@ ImmobilityMask immobilityMask( } } final still = maxd < angleThresholdDeg; - final fullWindow = hi - i >= win; + final fullWindow = hi - i >= winSamples; immobile[i] = still && fullWindow && !gap; immobileUnknown[i] = still && (!fullWindow || gap); } @@ -238,13 +295,18 @@ ImmobilityMask immobilityMask( deltaDeg: dAng, sustainedSec: win, thresholdDeg: angleThresholdDeg, + cadenceSec: cadence, + sustainedSamples: winSamples, ); } -/// Detect the nocturnal sleep window from a sequence of 1 Hz accel vectors. +/// Detect the nocturnal sleep window from a sequence of accel vectors. /// -/// [accel] one gravity vector per second (assumed ~1 Hz, contiguous). [tsMs] -/// optional matching wall-clock times. Parameters follow GGIR defaults. +/// [accel] gravity vectors carrying their own absolute times. The cadence is +/// MEASURED from `tsMs` ([sampleCadenceSeconds]) — there was never a `tsMs` +/// parameter, and the every-window-is-a-position assumption that stood in for +/// one only held at 1 Hz. Coarser than [vanHeesMaxCadenceSec] ⇒ absent. +/// Parameters follow GGIR defaults. Metric vanHeesSleepWindow( List accel, { double angleThresholdDeg = 5, @@ -259,13 +321,6 @@ Metric vanHeesSleepWindow( }) { 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. @@ -275,8 +330,25 @@ Metric vanHeesSleepWindow( sustainedMin: sustainedMin, smoothSec: smoothSec, ); + final cadence = mask.cadenceSec; + if (cadence == null) { + return const Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'accel cadence not measurable, or coarser than the published ' + 'van Hees epoch (see vanHeesMaxCadenceSec) — the successive-angle ' + 'rule cannot bound movement inside a sampling gap', + ); + } + final win = mask.sustainedSamples; + if (n < win) { + return const Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'too few accel samples for a sustained-inactivity block', + ); + } final ang = mask.zAngleDeg; - final win = mask.sustainedSec; final immobile = mask.immobile; final immobileUnknown = mask.immobileUnknown; @@ -284,7 +356,7 @@ Metric vanHeesSleepWindow( // 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 // tail is left out rather than annexed on the assumption it stayed still. - final bridge = bridgeGapMin * 60; + final bridge = math.max(1, ((bridgeGapMin * 60) / cadence).round()); var bestStart = -1, bestEnd = -1, bestLen = 0; var i = 0; while (i < n) { @@ -336,8 +408,13 @@ Metric vanHeesSleepWindow( if (u) unresolved++; } + // `bestLen` is a SAMPLE count; the published SPT is seconds. Identical at + // 1 Hz, and the only place the two units were ever silently the same number. + final sptSec = (bestLen * cadence).round(); + final undecidableSec = (unresolved * cadence).round(); + // Confidence grows with the detected SPT length up to a typical night. - final conf = clamp(bestLen / (7 * 3600), 0.3, 0.95); + final conf = clamp(sptSec / (7 * 3600), 0.3, 0.95); return Metric( value: SleepWindow( onsetIdx: bestStart, @@ -347,14 +424,15 @@ Metric vanHeesSleepWindow( immobile: immobile, immobileUnknown: immobileUnknown, zAngleDeg: ang, - sptSec: bestLen, + sptSec: sptSec, + cadenceSec: cadence, ), confidence: conf, tier: Tier.high, inputs_used: inputs, note: 'van Hees angle-based REST window (5°/${sustainedMin}min); ' 'a rest period, not PSG sleep' - '${unresolved > 0 ? '; ${unresolved}s are undecidable (forward window ' + '${unresolved > 0 ? '; ${undecidableSec}s are undecidable (forward window ' 'truncated by the record end, or unmeasured gravity) and are ' 'excluded' : ''}', ); diff --git a/lib/src/onehz/util.dart b/lib/src/onehz/util.dart index 5b73896..24e93d5 100644 --- a/lib/src/onehz/util.dart +++ b/lib/src/onehz/util.dart @@ -75,6 +75,79 @@ double? mad(List xs, {bool scaled = true}) { return scaled ? raw * 1.4826 : raw; } +/// The fastest cadence [sampleCadenceSeconds] will vouch for (seconds). +/// +/// Above this the metrics that consume a cadence — time-in-zone credit, TRIMP +/// per-sample duration, the still-window sample count — have no calibration and +/// no validation data, so the helper abstains rather than picking a number. +/// It is a CEILING ON WHAT WE HAVE MEASURED, not a physical limit: raising it +/// is one edit here plus evidence for the metrics downstream. +const double maxSupportedCadenceSec = 300; + +/// Gaps within this factor of the median count as belonging to the same mode. +/// 2× because every downstream use of a cadence — sample counts for a window, +/// one sample's worth of tail credit — degrades gracefully at a 2× error and +/// catastrophically at a 300× one, which is the failure this exists to stop. +const double _cadenceModeTolerance = 2.0; + +/// The median must describe at least this share of the gaps. Below it the +/// stream has no single cadence (two interleaved sources, a burst-mode band) +/// and the median is the midpoint between two regimes rather than either of +/// them — a number that is wrong for both halves of the record. +const double _cadenceMinModeFraction = 0.5; + +/// Measured sampling cadence (seconds) of a time-ordered stream — or NULL. +/// +/// ONE helper for what used to be three near-duplicates with three signatures +/// (`StrainScorer.medianIntervalSeconds`, `HeartRateZones._medianIntervalSeconds`, +/// `AdvancedSleepStager._medianIntervalS`). Callers pass seconds; a stream held +/// in ms or ints maps at the call site. +/// +/// IT ABSTAINS INSTEAD OF FALLING BACK. The three originals returned 1.0, 1.0 +/// and 60 respectively when they could not measure a cadence — and the case +/// that reached those fallbacks was not "no data", it was "every gap exceeded +/// the plausibility filter", i.e. a device SLOWER than the filter. A 301 s band +/// therefore had every reading credited with one second: roughly a 300× +/// undercount, published at full confidence. An absent cadence has to become an +/// absent metric, which is this project's contract; a wrong one becomes a +/// wrong number nobody can see. +/// +/// Returns null when: +/// * fewer than two samples, or no positive gap — nothing to measure; +/// * the median gap exceeds [maxSupportedCadenceSec] — a device we cannot +/// yet score (this is the 301 s case, and it is now absent, not 1.0); +/// * the median describes under [_cadenceMinModeFraction] of the gaps — see +/// that constant. Ordinary dropouts do NOT trip this: a night of 1 Hz with a +/// 16 h hole still has essentially every gap at 1 s, and mild jitter (1 s +/// alternating with 2 s) stays inside [_cadenceModeTolerance]. +/// +/// There is deliberately NO floor at 1 s. All three originals had one; on a +/// 1 Hz substrate it never bound, and on a faster source it would have been a +/// fabrication in the small direction. +double? sampleCadenceSeconds(List tsSec) { + if (tsSec.length < 2) return null; + final gaps = []; + for (var i = 1; i < tsSec.length; i++) { + final g = tsSec[i] - tsSec[i - 1]; + // Non-positive gaps are duplicate or unsorted timestamps, not a cadence. + // Pathological LONG gaps are deliberately kept: a dropout is a minority of + // the gaps and cannot move a median, whereas filtering them out first is + // exactly how a genuinely-slow device ended up indistinguishable from a + // stream with no usable gaps at all. + if (g > 0) gaps.add(g); + } + final m = median(gaps); + if (m == null || m > maxSupportedCadenceSec) return null; + var inMode = 0; + for (final g in gaps) { + if (g <= m * _cadenceModeTolerance && g * _cadenceModeTolerance >= m) { + inMode++; + } + } + if (inMode < gaps.length * _cadenceMinModeFraction) return null; + return m; +} + /// Iglewicz–Hoaglin modified z-score of [x] against a sample, using /// median + MAD. Returns null if MAD is 0 (degenerate / fully-quantized) so /// the caller can fall back to a coarser test rather than divide by zero. diff --git a/lib/src/onehz/wellness/readiness_composite.dart b/lib/src/onehz/wellness/readiness_composite.dart index 72ba7eb..435bc54 100644 --- a/lib/src/onehz/wellness/readiness_composite.dart +++ b/lib/src/onehz/wellness/readiness_composite.dart @@ -52,6 +52,15 @@ class ReadinessInput { /// drops out and the weights renormalise — but the reason is named in the /// composite's note instead of vanishing. final String? refusal; + + /// The input's own QUANTIZATION STEP, in the units of [value]/[baseline] + /// (whole-bpm RHR → 1, integer skin-temp ADC → 1). 0 = continuous, no step. + /// + /// Read only on the MAD-collapse fallback path: a baseline whose dispersion + /// is below one step has no resolvable dispersion at all, so an SD computed + /// from it is quantization noise, not physiology. See the guard in + /// [readinessComposite]. + final double quantum; const ReadinessInput( this.label, this.value, @@ -59,6 +68,7 @@ class ReadinessInput { this.goodSign, this.weight, { this.refusal, + this.quantum = 0, }); } @@ -67,7 +77,7 @@ class ReadinessInput { ReadinessInput hrvInput(double? v, List base) => ReadinessInput('HRV', v, base, 1, 0.40); ReadinessInput rhrInput(double? v, List base) => - ReadinessInput('RHR', v, base, -1, 0.30); + ReadinessInput('RHR', v, base, -1, 0.30, quantum: 1); // whole bpm ReadinessInput respInput(double? v, List base) => ReadinessInput('RR', v, base, -1, 0.20); @@ -110,7 +120,7 @@ ReadinessInput tempInput( refusal: 'temp: unsettled_skin_temp:settled=' '${round6(settledFraction)},need=${round6(minSettledFraction)}'); } - return ReadinessInput('temp', v, base, -1, 0.10); + return ReadinessInput('temp', v, base, -1, 0.10, quantum: 1); // 1 ADC count } class Readiness { @@ -128,7 +138,17 @@ class Readiness { /// Each present input with a usable robust baseline contributes a sign-oriented /// robust z. Weights are renormalized over present inputs. /// Required minimum baseline points (per input) before readiness can compute. -const int readinessCompositeMinBaseline = 3; +/// +/// 14, not 3. Three nights are not a personal baseline, they are three numbers: +/// on the real corpus `[58, 58, 59]` bpm of RHR plus a 52 bpm night produced +/// z = −10.97 and a published score of **99.949** at confidence 0.60 — maximal +/// recovery manufactured out of a 6 bpm change. Two weeks is the shortest +/// window in which a median+MAD has anything to be robust ABOUT (it is also the +/// floor `overreaching_conjunction` and `session_cost` already use). +/// +/// Deliberately NOT paired with an abs(z) clamp: a bounded-but-wrong score +/// published at confidence 0.60 is harder to catch than an absurd one. +const int readinessCompositeMinBaseline = 14; /// Minimum number of inputs, and minimum surviving weight, before a composite /// is a composite at all. @@ -178,6 +198,27 @@ Metric readinessComposite( // mean/SD z so a usable input still contributes; only skip when SD is ALSO // zero (a truly constant baseline with no dispersion to normalize against). final rz = robustZ(v, base); + if (rz == null && inp.quantum > 0) { + // MAD collapsed, so more than half this baseline sits exactly on its own + // median. For a QUANTIZED input that is the signature of a baseline with + // no resolvable dispersion — and the mean/SD fallback below then divides + // by what is left, which is quantization noise: `[58,58,59]` has SD 0.577 + // bpm, so a 6 bpm night scores z = −10.97 and readiness 99.949. Refuse + // the input by name (weights renormalise over the rest, and if too few + // survive the composite comes back absent) rather than scale against a + // dispersion the instrument cannot resolve. + // + // NOT a synthetic floor and NOT a clamp: nothing is substituted for the + // missing dispersion. Scoped to the fallback on purpose — MAD > 0 on a + // quantized series already means ≥ 1 step of spread. + final sd = stddev(base); + if (sd == null || sd < inp.quantum) { + refusals.add('${inp.label}: baseline_dispersion_below_quantum:' + 'sd=${sd == null ? 'null' : round6(sd)},' + 'quantum=${round6(inp.quantum)},n=${base.length}'); + continue; + } + } final zr = rz ?? z(v, base); if (zr == null) continue; final oriented = inp.goodSign * zr; // + = good for readiness diff --git a/lib/src/onehz/wellness/temp_circadian.dart b/lib/src/onehz/wellness/temp_circadian.dart index 2393e52..e7f15a8 100644 --- a/lib/src/onehz/wellness/temp_circadian.dart +++ b/lib/src/onehz/wellness/temp_circadian.dart @@ -92,20 +92,20 @@ class _TempCal { const _TempCal(this.unit, this.motionGate, this.settleBandLow); } -const Map _tempCal = { +const Map _tempCal = { // gen4's 40 counts: on the 8 real gen4 nights in whoop-4.db the seven clean // nights keep 94.4–100.0 % of their sleep-window samples above // (night median − 40), while the one night carrying a two-hour cold segment // keeps 78.7 %. The band is ~6× the 6.47-count between-night SD and ~1.4× the // 29.3-count corpus-wide circadian range, so ordinary rhythm survives it. - DeviceFamily.gen4: _TempCal('adc_counts', 0.10, 40.0), + 'gen4': _TempCal('adc_counts', 0.10, 40.0), // gen5 has NO measured band. The exports carry 106 (W5) and 933 (MG) non-zero // skin-temp rows in total — not one night clears the 60-sample floor — so // there is nothing to calibrate against. Scaling gen4's 40 counts by a // counts-per-°C guess would be gen4's number wearing a gen5 badge, which is // exactly what device.dart's contract forbids. Fill this in from gen5 nights, // not from arithmetic. - DeviceFamily.gen5: _TempCal('centi_c', 0.04, null), + 'gen5': _TempCal('centi_c', 0.04, null), }; /// A nightly skin-temp mean that knows how much of the night it is made of. diff --git a/lib/src/onehz/workout/hr_zones.dart b/lib/src/onehz/workout/hr_zones.dart index b6beb78..61fb9b2 100644 --- a/lib/src/onehz/workout/hr_zones.dart +++ b/lib/src/onehz/workout/hr_zones.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import '../types.dart'; +import '../util.dart'; /// One display heart-rate zone defined by a bpm interval. class HeartRateZone { @@ -172,18 +173,23 @@ class HeartRateZones { /// Each sample is credited with the duration until the next sample. The tail /// sample gets the median plausible interval so a regular stream is fully /// accounted for without letting one pathological gap dominate a zone. - static TimeInHeartRateZone timeInZone( + /// + /// NULL when [sampleCadenceSeconds] cannot vouch for the stream's cadence — + /// see it for the rule. Every duration in here is a multiple of that cadence, + /// so without it there is no time-in-zone, only a number shaped like one: a + /// 301 s stream used to credit each reading with a single second and publish + /// a ~300× undercount as minutes. + static TimeInHeartRateZone? timeInZone( List hr, HeartRateZoneSet zoneSet, ) { final sorted = [...hr]..sort((a, b) => a.tsMs.compareTo(b.tsMs)); final zoneSeconds = List.filled(5, 0); var below = 0.0; - if (sorted.isEmpty) { - return TimeInHeartRateZone(seconds: zoneSeconds, belowZone1: 0); - } - final tailSeconds = _medianIntervalSeconds(sorted); + final tailSeconds = + sampleCadenceSeconds([for (final s in sorted) s.tsMs / 1000.0]); + if (tailSeconds == null) return null; for (var i = 0; i < sorted.length; i++) { final sample = sorted[i]; if (!sample.valid) continue; @@ -207,15 +213,4 @@ class HeartRateZones { : fallbackSeconds; } - static double _medianIntervalSeconds(List sorted) { - if (sorted.length < 2) return 1.0; - final gaps = []; - for (var i = 1; i < sorted.length; i++) { - final gapSeconds = (sorted[i].tsMs - sorted[i - 1].tsMs) / 1000.0; - if (gapSeconds > 0 && gapSeconds <= 300) gaps.add(gapSeconds); - } - if (gaps.isEmpty) return 1.0; - gaps.sort(); - return math.max(gaps[gaps.length ~/ 2], 1.0); - } } diff --git a/lib/src/onehz/workout/observed_max_hr.dart b/lib/src/onehz/workout/observed_max_hr.dart index 3d7a997..79a08e8 100644 --- a/lib/src/onehz/workout/observed_max_hr.dart +++ b/lib/src/onehz/workout/observed_max_hr.dart @@ -76,9 +76,14 @@ class HrCeiling { /// captures (see the header). The gate sits above each family's own p90 and /// well below its p99, so ordinary wear does not corroborate and real movement /// does. -const Map _motionGateG = { - DeviceFamily.gen4: 0.10, - DeviceFamily.gen5: 0.04, +/// +/// PUBLIC because a caller that wants to explain the refusal has to ask the +/// same question this metric asks — "is there a gate for this stamp" — and +/// there is no longer a global list of known families to ask instead. Read it +/// through [calibrationFor]; it is this metric's table and nothing else's. +const Map hrCeilingMotionGateG = { + 'gen4': 0.10, + 'gen5': 0.04, }; /// Above this, a HELD heart rate is a sensor fault rather than a heart — no @@ -107,7 +112,7 @@ Metric sessionHrCeiling( double maxGapSeconds = 2.0, }) { const inputs = ['hr_1hz', 'accel_1hz', 'device_family']; - final gate = calibrationFor(_motionGateG, deviceFamily); + final gate = calibrationFor(hrCeilingMotionGateG, deviceFamily); if (gate == null) { return Metric.absent( tier: Tier.high, diff --git a/test/onehz/cadence_group_c_test.dart b/test/onehz/cadence_group_c_test.dart new file mode 100644 index 0000000..210bec1 --- /dev/null +++ b/test/onehz/cadence_group_c_test.dart @@ -0,0 +1,134 @@ +// GROUP C — cadence-awareness of the metrics that carry a WALL-CLOCK window +// inside them: `nocturnalRhr` (C1) and `riivRespRate` (C6). +// +// Own file so it cannot collide with the concurrent group-C work. The gate that +// matters in both directions: WHOOP behaviour (1 Hz, no timestamps) must be +// bit-identical, and a slower stream must produce the SAME answer or NO answer +// — never a plausible wrong one. + +import 'dart:math' as math; + +import 'package:openstrap_analytics/onehz.dart'; +import 'package:test/test.dart'; + +/// A night: [hours] of HR at [baseline], with a [troughMin]-minute block of +/// [trough] starting at [troughAtMin]. One sample per second. +List _night({ + int hours = 8, + double baseline = 70, + double trough = 50, + int troughAtMin = 300, + int troughMin = 30, +}) => + [ + for (var s = 0; s < hours * 3600; s++) + (s >= troughAtMin * 60 && s < (troughAtMin + troughMin) * 60) + ? trough + : baseline + ]; + +/// Keep every Nth sample, and hand back the times it kept. +(List, List) _decimate(List hr, int everyN) => ( + [for (var i = 0; i < hr.length; i += everyN) hr[i]], + [for (var i = 0; i < hr.length; i += everyN) i.toDouble()], + ); + +void main() { + group('C1 nocturnalRhr — the 30-minute window is 30 MINUTES', () { + test('no timestamps: 1 Hz behaviour is unchanged', () { + final hr = _night(); + final m = nocturnalRhr(hr); + expect(m.value!.low30Mean, closeTo(50, 1e-9)); + // The 1 Hz contract also holds when the clock is supplied explicitly. + final withTs = nocturnalRhr(hr, + tsSec: [for (var i = 0; i < hr.length; i++) i.toDouble()]); + expect(withTs.value!.low30Mean, m.value!.low30Mean); + expect(withTs.confidence, m.confidence); + expect(withTs.note, m.note); + }); + + test('REGRESSION: 1800 POSITIONS is 7.5 h at 15 s — the wrong-number band', + () { + final (hr, ts) = _decimate(_night(), 15); + expect(hr.length, greaterThan(1800)); // the old path does produce a number + // Old behaviour, reachable by simply not passing the clock: 1800 + // positions span 7.5 h, so the "lowest 30-min mean" is the night's mean. + expect(nocturnalRhr(hr).value!.low30Mean, greaterThan(60)); + // With the clock it is the trough, to the same value 1 Hz reports. + expect(nocturnalRhr(hr, tsSec: ts).value!.low30Mean, closeTo(50, 1e-9)); + }); + + test('the whole 2–16 s band converges on the 1 Hz answer', () { + final truth = nocturnalRhr(_night()).value!.low30Mean; + for (final n in [2, 5, 10, 15, 60]) { + final (hr, ts) = _decimate(_night(), n); + final m = nocturnalRhr(hr, tsSec: ts); + expect(m.present, isTrue, reason: '${n}s: ${m.note}'); + expect(m.value!.low30Mean, closeTo(truth, 1e-9), reason: '${n}s'); + } + }); + + test('a cadence nothing can vouch for is ABSENT, not a guess', () { + final (hr, _) = _decimate(_night(), 301); + final ts = [for (var i = 0; i < hr.length; i++) i * 301.0]; + // 301 s is past what `sampleCadenceSeconds` will vouch for. + expect(nocturnalRhr(hr, tsSec: ts).present, isFalse); + // Mismatched lengths are refused rather than zipped short. + expect(nocturnalRhr(hr, tsSec: const [0, 1, 2]).present, isFalse); + }); + + test('minCoverage still bites at a slow cadence', () { + // 15 s stream, but only one sample in eight is on-skin: no window holds + // 90% of the 120 samples 30 min should contain. + final (hr, ts) = _decimate(_night(), 15); + final holed = [ + for (var i = 0; i < hr.length; i++) (i % 8 == 0) ? hr[i] : 0.0 + ]; + expect(nocturnalRhr(holed, tsSec: ts).present, isFalse); + }); + + test('window is honoured as a duration at any cadence', () { + // A 10-min trough is invisible to a 30-min window and found by a 10-min + // one — at 15 s just as at 1 Hz. + final hr = _night(troughMin: 10); + final (d, ts) = _decimate(hr, 15); + expect(nocturnalRhr(d, tsSec: ts).value!.low30Mean, greaterThan(60)); + expect( + nocturnalRhr(d, tsSec: ts, window: const Duration(minutes: 10)) + .value! + .low30Mean, + closeTo(50, 1e-9), + ); + }); + }); + + group('C6 riivRespRate — Nyquist', () { + // 0.2 Hz (12 br/min) intensity variation on a DC pedestal. + (List, List) adcAt(int everyN, {int seconds = 600}) => ( + [ + for (var s = 0; s < seconds; s += everyN) + 10000 + 200 * math.sin(2 * math.pi * 0.2 * s) + ], + [for (var s = 0; s < seconds; s += everyN) s.toDouble()], + ); + + test('1 Hz resolves the band it claims to', () { + final (adc, ts) = adcAt(1); + final m = riivRespRate(adc, ts); + expect(m.present, isTrue, reason: m.note); + expect(m.value!.brpm!, closeTo(12, 1.0)); + }); + + test('REGRESSION: 5 s cannot represent 0.1–0.5 Hz, so it ABSTAINS', () { + // Before: the same night published 10.8 br/min for a true 21.2 — an + // alias of a rate the stream cannot see, at full RELATIVE confidence. + for (final n in [2, 5, 15, 60]) { + final (adc, ts) = adcAt(n, seconds: 3600); + final m = riivRespRate(adc, ts); + expect(m.present, isFalse, reason: '${n}s published ${m.value?.brpm}'); + expect(m.confidence, 0); + expect(m.note, contains('alias')); + } + }); + }); +} diff --git a/test/onehz/clinical_test.dart b/test/onehz/clinical_test.dart index f69a179..f001732 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -623,7 +623,7 @@ void main() { const HrSample(120000, 150), // z3 for 60 s const HrSample(180000, 170), // z4 for 60 s const HrSample(240000, 190), // z5 for tail median 60 s - ], zoneSet); + ], zoneSet)!; expect(time.secondsInZone(1), closeTo(60, 1e-9)); expect(time.secondsInZone(2), closeTo(60, 1e-9)); expect(time.secondsInZone(3), closeTo(60, 1e-9)); @@ -640,7 +640,7 @@ void main() { const HrSample(1000, 150), // z3 const HrSample(2000, 190), // z5, next gap huge const HrSample(700000, 190), // huge gap capped to 1 s - ], zoneSet); + ], zoneSet)!; expect(time.secondsInZone(2), closeTo(1, 1e-9)); expect(time.secondsInZone(3), closeTo(1, 1e-9)); expect(time.secondsInZone(5), closeTo(2, 1e-9)); diff --git a/test/onehz/device_test.dart b/test/onehz/device_test.dart index 1dfd7f9..055d06d 100644 --- a/test/onehz/device_test.dart +++ b/test/onehz/device_test.dart @@ -3,28 +3,26 @@ import 'package:test/test.dart'; void main() { group('device family dispatch seam', () { - test('known ids parse, everything else is unknown', () { - expect(deviceFamilyOf('gen4'), DeviceFamily.gen4); - expect(deviceFamilyOf('gen5'), DeviceFamily.gen5); - for (final id in [null, '', 'GEN4', 'gen6', 'whoop4', 'imported']) { - expect(deviceFamilyOf(id), isNull, reason: 'id=$id must be unknown'); - } - }); - - test('id round-trips', () { - for (final f in DeviceFamily.values) { - expect(deviceFamilyOf(deviceFamilyId(f)), f); - } - }); - test('unknown family gets NO constants — never gen4 as a fallback', () { - const k = {DeviceFamily.gen4: 230, DeviceFamily.gen5: 210}; + const k = {'gen4': 230, 'gen5': 210}; expect(calibrationFor(k, 'gen4'), 230); expect(calibrationFor(k, 'gen5'), 210); expect(calibrationFor(k, null), isNull); + expect(calibrationFor(k, ''), isNull); expect(calibrationFor(k, 'gen6'), isNull); + // No trimming, no case folding: a near-miss stamp is a refusal, not a + // hint. Matching one would apply gen4's counts to another band's. + expect(calibrationFor(k, 'GEN4'), isNull); + expect(calibrationFor(k, ' gen4'), isNull); // a family the metric itself has not calibrated is also a refusal - expect(calibrationFor(const {DeviceFamily.gen5: 1}, 'gen4'), isNull); + expect(calibrationFor(const {'gen5': 1}, 'gen4'), isNull); + }); + + test('the set of families is open — a new id needs no enum entry', () { + // The whole point of deleting `DeviceFamily`: one metric can be + // calibrated for a band before any other is. + expect(calibrationFor(const {'gen6': 42}, 'gen6'), 42); + expect(calibrationFor(const {'gen6': 42}, 'gen4'), isNull); }); test('refusal note is machine-readable', () { diff --git a/test/onehz/real_capture_test.dart b/test/onehz/real_capture_test.dart index 96470c6..04ddebb 100644 --- a/test/onehz/real_capture_test.dart +++ b/test/onehz/real_capture_test.dart @@ -101,7 +101,7 @@ void main() { } // --- nocturnal RHR runs without crashing; if enough data, plausible --- - final rhr = nocturnalRhr(hr, windowSamples: 60); // 60 s window for a short read + final rhr = nocturnalRhr(hr, window: const Duration(seconds: 60)); // short read if (rhr.present) { // ignore: avoid_print print('REAL RHR(60s): low=${rhr.value!.low30Mean.toStringAsFixed(1)} ' diff --git a/test/onehz/sleep_cadence_test.dart b/test/onehz/sleep_cadence_test.dart new file mode 100644 index 0000000..938b0f7 --- /dev/null +++ b/test/onehz/sleep_cadence_test.dart @@ -0,0 +1,140 @@ +// SLEEP — cadence independence (group C, phase 3). +// +// Every window in the sleep family was written in ARRAY POSITIONS, which is +// only seconds at 1 Hz. These are the smallest checks that fail if any of them +// slips back to counting positions — plus the one that is not an accuracy check +// at all: `cardioStager` used to publish `wakePct = 0.0` for a night it could +// not read, and that is a confident wrong number about someone's sleep, not a +// tolerance. +// +// Own file on purpose (concurrent agents), and every case is synthetic and +// deterministic — no fixture, no database. + +import 'dart:math' as math; + +import 'package:openstrap_analytics/onehz.dart'; +import 'package:test/test.dart'; + +/// A perfectly static wrist, sampled every [cadence] s over [spanSec]. +List _stillAccel(int cadence, int spanSec) => [ + for (var t = 0; t < spanSec; t += cadence) + AccelSample(t * 1000.0, 0, 0, 1.0) + ]; + +List _flatHr(int cadence, int spanSec, [double bpm = 55]) => + [for (var t = 0; t < spanSec; t += cadence) bpm]; + +/// A wrist rotating at a constant 0.004 rad/s — chosen so |Δg| between +/// SUCCESSIVE SAMPLES is ~0.004 g at 1 Hz and ~0.020 g at 5 s. The same +/// physical motion therefore sits below the 0.01 threshold at 1 Hz and above it +/// at 5 s, which is exactly the trap a bare-`g` threshold falls into. +List _driftGrav(int cadence, int spanSec) => [ + for (var t = 0; t < spanSec; t += cadence) + GravTs(t, math.sin(0.004 * t), 0, math.cos(0.004 * t)) + ]; + +List _stillGrav(int cadence, int spanSec) => + [for (var t = 0; t < spanSec; t += cadence) GravTs(t, 0, 0, 1.0)]; + +List _hrTs(int cadence, int spanSec, [double bpm = 52]) => + [for (var t = 0; t < spanSec; t += cadence) HrTs(t, bpm)]; + +void main() { + const night = 8 * 3600; + + group('cardioStager — real-time epochs, and abstention over zero', () { + test('a cadence coarser than one epoch ABSTAINS, it does not report 0% wake', + () { + // 300 s over 8 h = 96 samples. `n ~/ epochSec` made that 3 "epochs" of + // 2.5 REAL HOURS, which the 30-s rules labelled NREM end to end and + // published as wakePct 0.000 — a night with no readable evidence in it, + // reported as zero wake at up to 0.6 confidence. + final r = cardioStager(_flatHr(300, night), _stillAccel(300, night)); + expect(r.base.stages, isEmpty, reason: 'no epochs ⇒ nothing staged'); + expect(r.confidence, 0); + // And the caller's own absence test (the rig's, and + // `_stageSessionCardio`'s) sees it. + expect(r.base.wakePct, 0, reason: 'meaningless without stages — the ' + 'empty `stages` list is the abstention signal, not this number'); + }); + + test('the epoch grid is real seconds, not array positions', () { + const span = 3 * 3600; + final at1 = cardioStager(_flatHr(1, span), _stillAccel(1, span)); + final at5 = cardioStager(_flatHr(5, span), _stillAccel(5, span)); + expect(at1.base.stages.length, 360, reason: '3 h of 30 s epochs'); + // Pre-fix this was 2160 ~/ 30 = 72 epochs, i.e. a fifth of the night. + expect(at5.base.stages.length, at1.base.stages.length); + expect(at5.base.epochSec, 30); + }); + + test('1 Hz is untouched', () { + const span = 2 * 3600; + final r = cardioStager(_flatHr(1, span), _stillAccel(1, span)); + expect(r.base.stages.length, span ~/ 30); + }); + }); + + group('vanHeesSleepWindow — sptSec is SECONDS', () { + test('a 5 s stream reports the same rest period as the same night at 1 Hz', + () { + const span = 3 * 3600; + final a = vanHeesSleepWindow(_stillAccel(1, span)); + final b = vanHeesSleepWindow(_stillAccel(5, span)); + expect(a.present, isTrue); + expect(b.present, isTrue); + // Pre-fix the 5 s answer was a SAMPLE COUNT published as seconds: 2100 + // against 10500, an 80% undercount at tier HIGH. + // Within ONE SAMPLE: the 5 s block boundary can only land on the 5 s + // grid. That is quantisation, and it is the whole remaining error. + expect(b.value!.sptSec, closeTo(a.value!.sptSec, 5)); + expect(a.value!.sptSec, greaterThan(2 * 3600)); + }); + + test('coarser than the published van Hees epoch ⇒ absent, with a reason', + () { + final m = vanHeesSleepWindow(_stillAccel(15, 3 * 3600)); + expect(m.present, isFalse); + expect(m.note, contains('cadence')); + }); + + test('the undecidable tail is reported in seconds too', () { + const span = 3 * 3600; + final a = vanHeesSleepWindow(_stillAccel(1, span)).value!; + final b = vanHeesSleepWindow(_stillAccel(5, span)).value!; + expect(b.undecidableSec, closeTo(a.undecidableSec, 5)); + }); + }); + + group('AdvancedSleepStager — gravity thresholds are g/s', () { + test('the same physical drift is staged the same at 1 Hz and 5 s', () { + final one = AdvancedSleepStager.detectSleep( + _driftGrav(1, night), _hrTs(1, night)); + final five = AdvancedSleepStager.detectSleep( + _driftGrav(5, night), _hrTs(5, night)); + expect(one, isNotEmpty); + // Pre-fix: EMPTY. 0.020 g per 5 s sample cleared a threshold that meant + // "0.01 g between whatever two samples this device happened to send", so + // an identically-still wrist read as moving purely for sampling faster. + expect(five, isNotEmpty); + final spanOne = one.first.end - one.first.start; + final spanFive = five.first.end - five.first.start; + expect((spanFive - spanOne).abs(), lessThan(spanOne * 0.05)); + }); + + test('past the ceiling the rate threshold is vacuous, so it abstains', () { + // |Δg| between two unit gravity vectors can never exceed 2, and the g/s + // cut passes 2 at ~200 s — every sample would read still, unconditionally. + // A perfectly static stream is the case that WOULD be detected on the + // arithmetic alone, so it is the one that proves the ceiling. + expect(AdvancedSleepStager.maxStillCadenceSec, 30.0); + expect(AdvancedSleepStager.detectSleep(_stillGrav(60, night), + _hrTs(60, night)), isEmpty); + expect(AdvancedSleepStager.detectSleep(_stillGrav(300, night), + _hrTs(300, night)), isEmpty); + // …and at the ceiling it still works. + expect(AdvancedSleepStager.detectSleep(_stillGrav(30, night), + _hrTs(30, night)), isNotEmpty); + }); + }); +} diff --git a/test/onehz/sleep_honesty_test.dart b/test/onehz/sleep_honesty_test.dart index 4884745..5bcf000 100644 --- a/test/onehz/sleep_honesty_test.dart +++ b/test/onehz/sleep_honesty_test.dart @@ -105,6 +105,41 @@ void main() { }); }); + // ═══════════════════════════════════════════════════════════════════════════ + // (3b) An UNDECODED accel row is not a still one. `AccelSample.valid` was + // dropped when the sample became a `GravTs`, so absent gravity (exact 0,0,0) + // scored a 0.0 g delta and read as a wrist held perfectly still. + // ═══════════════════════════════════════════════════════════════════════════ + group('honesty — undecoded accel is not stillness', () { + List night({required bool valid}) => [ + for (var k = 0; k < 8 * 3600; k++) + AccelSample((_t0 + k) * 1000.0, 0, 0, 0, valid: valid) + ]; + final hr = List.filled(8 * 3600, 52); + + test( + '8 h of valid:false accel + valid HR is ABSENT ' + '(was: inBed 28800 s, TST 28800 s, efficiency 100.0%, unobserved 0 s)', + () { + final s = segmentSleep(night(valid: false), hr, + forcedWindow: (onsetSec: _t0, offsetSec: _t0 + 8 * 3600)); + expect(s.present, isFalse); + expect(s.tstSec, isNull); + expect(s.efficiencyPct, isNull); + expect(s.absenceReason, contains('observed'), + reason: 'not one second of gravity was measured'); + }); + + test('the SAME vectors marked valid still stage — the flag is the only ' + 'difference', () { + // Control: proves the abstention above comes from the validity flag and + // not from the (0,0,0) coordinates or the window shape. + final s = segmentSleep(night(valid: true), hr, + forcedWindow: (onsetSec: _t0, offsetSec: _t0 + 8 * 3600)); + expect(s.present, isTrue); + }); + }); + // ═══════════════════════════════════════════════════════════════════════════ // (3) An accelerometer dropout must not be carried forward into "stillness". // ═══════════════════════════════════════════════════════════════════════════ diff --git a/test/onehz/sleep_test.dart b/test/onehz/sleep_test.dart index b72740e..19b8fe2 100644 --- a/test/onehz/sleep_test.dart +++ b/test/onehz/sleep_test.dart @@ -1,9 +1,3 @@ -// This file deliberately exercises `autonomicStager`, which is deprecated but -// still exported for back-compat. Testing shipped-but-deprecated API is the -// point, so the deprecation notice is suppressed for the whole file. Older Dart -// analyzers report `deprecated_member_use_from_same_package` where newer ones -// don't, and CI runs `--fatal-infos` on both, so this has to be file-level. -// ignore_for_file: deprecated_member_use_from_same_package // SLEEP & CIRCADIAN family — synthetic known-answer + real-capture plausibility. // // No TS oracle exists for the 1 Hz sleep/circadian methods, so every method is @@ -21,10 +15,6 @@ import 'dart:io'; import 'dart:math' as math; import 'package:test/test.dart'; import 'package:openstrap_analytics/onehz.dart'; -// autonomicStager is DEPRECATED and no longer re-exported from the barrel -// (superseded by cardioStager); deep-import it here for the legacy coverage. -import 'package:openstrap_analytics/src/onehz/sleep/stager.dart' - show autonomicStager; import 'package:openstrap_protocol/openstrap_protocol.dart'; void main() { @@ -625,46 +615,6 @@ void main() { }); }); - // ------------------------------------------------------------------- stager - group('3-class autonomic stager', () { - test('constructed NREM/REM/wake epochs classify correctly', () { - // Build 90 min: 30 min deep NREM (low stable HR, immobile), 30 min REM - // (elevated variable HR, immobile/atonic), 30 min wake (moving). - final hr = []; - final immobile = []; - final rnd = math.Random(1); - // NREM: HR ~48 ± 0.5, immobile. - for (var i = 0; i < 30 * 60; i++) { - hr.add(48 + (rnd.nextDouble() - 0.5)); - immobile.add(true); - } - // REM: HR ~62 ± 6 (variable), immobile (atonia). - for (var i = 0; i < 30 * 60; i++) { - hr.add(62 + (rnd.nextDouble() - 0.5) * 12); - immobile.add(true); - } - // Wake: HR ~70, MOVING. - for (var i = 0; i < 30 * 60; i++) { - hr.add(70 + (rnd.nextDouble() - 0.5) * 4); - immobile.add(false); - } - final m = autonomicStager(hr, immobile, epochSec: 30); - expect(m.present, isTrue); - final s = m.value!; - // Each phase has 60 epochs of 30s. Check the dominant label per third. - final third = s.stages.length ~/ 3; - final nremPart = s.stages.sublist(0, third); - final remPart = s.stages.sublist(third, 2 * third); - final wakePart = s.stages.sublist(2 * third); - expect(_dominant(nremPart), SleepStage.nrem); - expect(_dominant(remPart), SleepStage.rem); - expect(_dominant(wakePart), SleepStage.wake); - // Honesty: tier is ESTIMATE, confidence bounded. - expect(m.tier, Tier.estimate); - expect(m.confidence, lessThanOrEqualTo(0.6)); - }); - }); - // ---------------------------------------------------------------------- CPC group('Cardiopulmonary Coupling (WITHDRAWN)', () { test('abstains — no respiration channel independent of the beat times', () { @@ -730,7 +680,6 @@ void main() { histFile.readAsLinesSync().where((l) => l.trim().isNotEmpty).toList(); final accel = []; final hr = []; - final immobileFromRest = []; final rrMs = []; var t = 0.0; for (final line in lines) { @@ -771,14 +720,6 @@ void main() { expect(cpc.value!.hfc, greaterThanOrEqualTo(0)); } } - - // Stager on the real HR + a built immobility mask (all immobile here as a - // smoke test): must run and return an ESTIMATE-tier result or absent. - for (var i = 0; i < hr.length; i++) { - immobileFromRest.add(true); - } - final st = autonomicStager(hr, immobileFromRest, epochSec: 30); - expect(st.tier, Tier.estimate); }); }); @@ -905,22 +846,6 @@ void main() { }); } -SleepStage _dominant(List xs) { - final counts = {}; - for (final s in xs) { - counts[s] = (counts[s] ?? 0) + 1; - } - var best = SleepStage.wake; - var bestN = -1; - counts.forEach((k, v) { - if (v > bestN) { - bestN = v; - best = k; - } - }); - return best; -} - // --------------------------------------------------------------------------- // SLP-13 — stage minutes are INTERVALS, sized by the night's own confidence. // --------------------------------------------------------------------------- diff --git a/test/onehz/util_test.dart b/test/onehz/util_test.dart index 49d6e55..ff9e49f 100644 --- a/test/onehz/util_test.dart +++ b/test/onehz/util_test.dart @@ -189,4 +189,147 @@ void main() { expect(rr.beatTimesMs(0), [1000.0, 1800.0, 2600.0]); expect(rr.length, 3); }); + + // ── C9: the ONE median-interval helper ──────────────────────────────────── + // Three near-duplicates used to live in load_trimp / hr_zones / + // advanced_stager with three signatures and three numeric fallbacks (1.0, + // 1.0 via `fallbackSampleMin * 60`, and 60). The fallbacks fired for a + // device SLOWER than their 300 s gap filter, not for a device with no data — + // so a 301 s band had every reading credited with one second. + // + // Every case here is at 301 s or 600 s, never 300: a 300 s device passed the + // old `<= 300` filters cleanly and was never the bug. + group('sampleCadenceSeconds', () { + List at(double cadence, int n, {double t0 = 0}) => + [for (var i = 0; i < n; i++) t0 + i * cadence]; + + test('measures the cadence of a regular stream', () { + expect(sampleCadenceSeconds(at(1, 100)), closeTo(1.0, 1e-12)); + expect(sampleCadenceSeconds(at(60, 100)), closeTo(60.0, 1e-12)); + // 300 s is SUPPORTED. It always was; the cliff is above it. + expect(sampleCadenceSeconds(at(300, 100)), closeTo(300.0, 1e-12)); + }); + + test('301 s and 600 s ABSTAIN instead of falling back to 1.0 / 60', () { + expect(sampleCadenceSeconds(at(301, 100)), isNull); + expect(sampleCadenceSeconds(at(600, 100)), isNull); + }); + + test('nothing to measure → null, never a number', () { + expect(sampleCadenceSeconds(const []), isNull); + expect(sampleCadenceSeconds(const [5.0]), isNull); + // Duplicate timestamps are not a cadence. + expect(sampleCadenceSeconds(const [5.0, 5.0, 5.0]), isNull); + }); + + test('dropouts do NOT abstain — a hole is a hole, not a cadence', () { + // A sleep-only source: 8 h of 1 Hz then a 16 h gap. The median is still + // 1 s and it is still right; the caller caps the hole at that cadence. + final night = [...at(1, 28800), 28800 + 57600.0]; + expect(sampleCadenceSeconds(night), closeTo(1.0, 1e-12)); + // Mild jitter (1 s alternating with 2 s) stays inside the 2x mode + // tolerance — a jittering device is still a device we can read. + final jitter = [0]; + for (var i = 0; i < 200; i++) { + jitter.add(jitter.last + (i.isEven ? 1 : 2)); + } + expect(sampleCadenceSeconds(jitter), isNotNull); + }); + + test('a stream with no dominant mode has no cadence → null', () { + // THREE regimes in equal thirds — 1 s, 60 s, 600 s, which is what one + // series fed by two devices at different rates looks like. The median + // (60 s) is a real value but describes only a third of the record, so + // there is no cadence to hand a caller. + // + // This check is DELIBERATELY NARROW. A two-mode stream (dense sampling + // plus long holes) passes on purpose: there the median IS the cadence + // and the long gaps are dropouts, which every caller already caps at the + // median — "a hole in the stream is not elapsed effort". Tightening this + // enough to catch that case would abstain on ordinary dropout-heavy + // nights, which is a worse failure than the one it would prevent. + final mixed = [0]; + for (var i = 0; i < 99; i++) { + mixed.add(mixed.last + (i % 3 == 0 ? 1 : (i % 3 == 1 ? 60 : 600))); + } + expect(sampleCadenceSeconds(mixed), isNull); + }); + }); + + group('C9 callers abstain at 301 s and 600 s', () { + test('HeartRateZones.timeInZone: 300 s scores, 301 s and 600 s are null', + () { + final zoneSet = HeartRateZones.zonesFromMaxHr(200); + List stream(double cadence, int n) => [ + for (var i = 0; i < n; i++) HrSample(i * cadence * 1000.0, 150) + ]; + // 300 s: 20 readings, each credited its own 300 s → 6000 s in z3. + expect(HeartRateZones.timeInZone(stream(300, 20), zoneSet)!.total, + closeTo(6000, 1e-9)); + // 301 s used to return 20 x 1.0 s = 20 s — a ~300x undercount published + // as minutes. It is now absent. + expect(HeartRateZones.timeInZone(stream(301, 20), zoneSet), isNull); + expect(HeartRateZones.timeInZone(stream(600, 20), zoneSet), isNull); + }); + + test('StrainScorer: 301 s and 600 s produce no durations and no strain', + () { + final bpm = List.filled(40, 150.0); + List ts(double cadence) => + [for (var i = 0; i < 40; i++) i * cadence]; + expect(StrainScorer.medianIntervalSeconds(ts(300)), closeTo(300, 1e-12)); + expect(StrainScorer.medianIntervalSeconds(ts(301)), isNull); + expect(StrainScorer.medianIntervalSeconds(ts(600)), isNull); + expect(StrainScorer.sampleDurationsMinutes(ts(301)), isEmpty); + expect(StrainScorer.sampleDurationsMinutes(ts(600)), isEmpty); + // The abstention has to reach the published number, not stop at the + // helper: `banisterTRIMP` credits `fallbackSampleMin` per sample when + // handed an empty list, which is the fabricated 1 s all over again. + expect(StrainScorer.strain(bpm, ts(301), maxHR: 190, restingHR: 50), + isNull); + expect(StrainScorer.strain(bpm, ts(600), maxHR: 190, restingHR: 50), + isNull); + expect(StrainScorer.strain(bpm, ts(300), maxHR: 190, restingHR: 50), + isNotNull); + }); + + test('AdvancedSleepStager stages nothing past its own cadence ceiling', () { + // A perfectly still, sleep-shaped night. It used to be staged on a 60 s + // guess at ANY cadence; `sampleCadenceSeconds` stopped that above 300 s. + // + // The 300 s case moved in phase 3 (C4) and is now ABSENT too, which is a + // stricter ceiling than this helper's, not a contradiction of it: + // `gravityStillThresholdGPerS` is a RATE, so the per-sample cut is + // `0.01 x cadence` — and |Δg| between two unit gravity vectors saturates + // at 2, so at 300 s the cut is 3.0 g and EVERY sample reads still whatever + // the wrist did. This synthetic (all deltas exactly 0) cannot see that; + // a real 300 s day would have come out as one unbroken 8 h sleep session. + // See `AdvancedSleepStager.maxStillCadenceSec` and + // `test/onehz/sleep_cadence_test.dart`. + List grav(int cadence) => [ + for (var t = 0; t < 8 * 3600; t += cadence) GravTs(t, 0, 0, 1.0) + ]; + List hr(int cadence) => [ + for (var t = 0; t < 8 * 3600; t += cadence) HrTs(t, 52) + ]; + expect(AdvancedSleepStager.detectSleep(grav(30), hr(30)), isNotEmpty); + expect(AdvancedSleepStager.detectSleep(grav(300), hr(300)), isEmpty); + expect(AdvancedSleepStager.detectSleep(grav(301), hr(301)), isEmpty); + expect(AdvancedSleepStager.detectSleep(grav(600), hr(600)), isEmpty); + }); + + test('1 Hz is untouched — the refactor moves nothing on a WHOOP stream', + () { + final zoneSet = HeartRateZones.zonesFromMaxHr(200); + final oneHz = [for (var i = 0; i < 3600; i++) HrSample(i * 1000.0, 150)]; + // 3600 samples, each 1 s, tail gets the 1 s median. + expect(HeartRateZones.timeInZone(oneHz, zoneSet)!.total, + closeTo(3600, 1e-9)); + final ts = [for (var i = 0; i < 3600; i++) i.toDouble()]; + expect(StrainScorer.medianIntervalSeconds(ts), closeTo(1.0, 1e-12)); + final durs = StrainScorer.sampleDurationsMinutes(ts); + expect(durs.length, 3600); + expect(durs.every((d) => (d - 1 / 60.0).abs() < 1e-12), isTrue); + }); + }); } diff --git a/test/onehz/wellness_test.dart b/test/onehz/wellness_test.dart index f631964..8d5c0c9 100644 --- a/test/onehz/wellness_test.dart +++ b/test/onehz/wellness_test.dart @@ -520,29 +520,66 @@ void main() { test( 'degenerate-MAD baseline is rescued by mean/SD z (no intermittent "—")', () { - // A quantized RHR whose recent baseline clusters tight enough that the - // median-absolute-deviation collapses to 0 (deviations from the median 52 - // are [0,0,0,0,0,0,1] => MAD 0), but SD > 0. robustZ can't score this, so - // before the fallback the WHOLE composite blanked to "—" here — the - // intermittent "readiness sometimes disappears (with sleep present)" bug. - final tightBase = [52, 52, 52, 52, 52, 52, 53]; + // A quantized RHR whose baseline clusters tight enough that the + // median-absolute-deviation collapses to 0 (most nights sit exactly on + // the median 52) but which still has more than one whole bpm of spread. + // robustZ can't score this, so before the fallback the WHOLE composite + // blanked to "—" here — the intermittent "readiness sometimes disappears + // (with sleep present)" bug. The fallback stays. + // + // FIXTURE RE-PINNED 2026-08 (A4): this used to be + // `[52,52,52,52,52,52,53]` — seven nights with an SD of 0.36 bpm, i.e. + // BELOW the 1 bpm the instrument can resolve. That shape is the one A4 + // refuses (it is how `[58,58,59]` + 52 became a published 99.949), and it + // is asserted as a refusal in its own test below. The rescue itself is + // unchanged; only a baseline with resolvable dispersion reaches it. + final tightBase = [ + 52, 52, 52, 52, 52, 52, 52, 52, 52, 52, 52, 49, 55, 53 + ]; // minInputs:1 — the subject here is the MAD==0 rescue, not the RD-04 // minimum-inputs gate (which has its own test below). final m = readinessComposite([rhrInput(56.0, tightBase)], minInputs: 1, minWeightSum: 0.0); expect(m.present, isTrue, - reason: 'mean/SD z should rescue a MAD==0 (but SD>0) baseline'); + reason: 'mean/SD z should rescue a MAD==0 (but SD>=1 bpm) baseline'); expect(m.inputs_used, ['RHR']); // RHR well above a tight baseline => bad for readiness => below 50. expect(m.value!.score, lessThan(50)); // A TRULY constant baseline (SD == 0 too) still honestly abstains — we // only rescue degenerate MAD, never fabricate against zero dispersion. - final flat = readinessComposite([ - rhrInput(56.0, [52, 52, 52, 52]) - ]); + final flat = + readinessComposite([rhrInput(56.0, List.filled(14, 52.0))]); expect(flat.present, isFalse); }); + + test('A4 — three quantized nights are not a baseline (was: score 99.949)', + () { + // The measured production failure: [58,58,59] bpm + a 52 bpm night => + // MAD 0 => mean/SD fallback on an SD of 0.577 bpm => z = -10.97 => + // readiness 99.949 at confidence 0.60. Maximal recovery out of 6 bpm. + final m = readinessComposite([rhrInput(52.0, [58, 58, 59])], + minInputs: 1, minWeightSum: 0.0); + expect(m.present, isFalse, reason: 'was: 99.949 at confidence 0.60'); + expect(m.note, contains('need_baseline:have=3,need=14')); + }); + + test('A4 — a 14-night baseline with sub-bpm dispersion is REFUSED by name', + () { + // Long enough now, but MAD 0 and SD 0.267 bpm — below the 1 bpm the + // instrument resolves, so the fallback would be dividing a 6 bpm change + // by quantization noise. Refuse, with a machine-readable reason; do NOT + // clamp (a bounded-but-wrong score at confidence 0.60 is harder to catch + // than an absurd one). + final base = [ + 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 59 + ]; + final m = readinessComposite([rhrInput(52.0, base)], + minInputs: 1, minWeightSum: 0.0); + expect(m.present, isFalse); + expect(m.note, contains('RHR: baseline_dispersion_below_quantum:')); + expect(m.note, contains('quantum=1')); + }); }); // ------------------------------------------------------------------------- @@ -611,12 +648,12 @@ void main() { expect(m.confidence, 0); expect( m.note, 'need_baseline:have=1,need=$readinessCompositeMinBaseline'); - // With >= minBaseline points it computes. + // With >= minBaseline points it computes (14 nights, not 5 — A4). final ok = readinessComposite([ - hrvInput(60.0, [48.0, 49.0, 50.0, 51.0, 52.0]), - rhrInput(55.0, [54.0, 55.0, 56.0, 55.0, 54.0]), + hrvInput(60.0, [for (var i = 0; i < 14; i++) 48.0 + i % 5]), + rhrInput(55.0, [for (var i = 0; i < 14; i++) 54.0 + i % 3]), ]); - expect(ok.present, isTrue); + expect(ok.present, isTrue, reason: ok.note); }); test('multivariateAnomaly: short baseline night carries need note', () { @@ -708,7 +745,12 @@ void main() { // Whole-bpm RHR pinned at 55 for most of the window: MAD collapses to 0, // robustZ abstains and the deliberate `?? z(v, base)` fallback (#26) // produced the contribution. PRE-FIX the detail still said "robust-z". - final base = [55, 55, 55, 55, 55, 55, 55, 58]; + // Twelve nights pinned at 55 (MAD 0) plus two genuinely different ones, + // so the baseline still has more than 1 bpm of SD — below that the input + // is refused outright rather than scored (A4). + final base = [ + 55, 55, 55, 55, 55, 55, 55, 55, 55, 55, 55, 55, 58, 51 + ]; final m = readinessComposite([rhrInput(60, base)], minInputs: 1, minWeightSum: 0.0); expect(m.present, isTrue, reason: m.note); @@ -718,7 +760,9 @@ void main() { }); test('a dispersed baseline still discloses robust-z (median+MAD)', () { - final base = [50, 52, 54, 56, 58, 60, 62]; + final base = [ + 50, 52, 54, 56, 58, 60, 62, 51, 53, 55, 57, 59, 61, 63 + ]; final m = readinessComposite([rhrInput(70, base)], minInputs: 1, minWeightSum: 0.0); expect(m.present, isTrue, reason: m.note); @@ -728,7 +772,7 @@ void main() { test('a fully constant baseline (MAD=0 AND SD=0) still abstains', () { // The fallback is NOT a licence to score against zero dispersion. final m = - readinessComposite([rhrInput(60, List.filled(8, 55.0))]); + readinessComposite([rhrInput(60, List.filled(14, 55.0))]); expect(m.present, isFalse); expect(m.toJson()['value'], '—'); }); From 353c6bc30f56b936c450e25a3759e5572b164bd8 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:37:26 +0530 Subject: [PATCH 2/3] review fixes: NaN trough, cadence double-scaled, epoch drift, quantum guard gap nocturnalRhr(minCoverage: 0) could reach a zero-valid-sample window and land sum/count as NaN, which then latches as the night's best trough instead of being skipped. _rescaleCounts multiplied an already cadence-invariant gravity-delta sum by cadence again -- the sum is 30 terms of size d at 1 Hz and 6 terms of size 5d at 5 s, both already equal 30d, so the extra factor inflated slow-cadence bands' cole-kripke score up to 5x and shifted the sleep/wake call. dropped it. cardio_stager's epoch grid silently drifted from its own reported length whenever the measured cadence didn't divide epochSec evenly -- abstains now instead of publishing a grid that doesn't match its own label. readiness's sub-quantum dispersion guard only ran when robustZ came back null, so a baseline with nonzero MAD but still-quantized SD (58/59 alternating, MAD 0.5, SD ~0.52) slipped through unrefused. runs for every quantized input now, regardless of which z path succeeds. plus stale autonomicStager mentions in the sleep barrel doc and the algorithm catalog -- it's cardioStager now, was already correct in the code itself. --- docs/ALGORITHM_CATALOG_1HZ.md | 4 ++-- lib/src/onehz/clinical/nocturnal.dart | 10 +++++++- lib/src/onehz/sleep/advanced_stager.dart | 22 ++++++++++------- lib/src/onehz/sleep/cardio_stager.dart | 10 ++++++++ lib/src/onehz/sleep/sleep.dart | 5 +++- .../onehz/wellness/readiness_composite.dart | 8 ++++++- test/onehz/clinical_test.dart | 16 +++++++++++++ test/onehz/wellness_test.dart | 24 ++++++++++++++++++- 8 files changed, 85 insertions(+), 14 deletions(-) diff --git a/docs/ALGORITHM_CATALOG_1HZ.md b/docs/ALGORITHM_CATALOG_1HZ.md index 07767b0..8ffda11 100644 --- a/docs/ALGORITHM_CATALOG_1HZ.md +++ b/docs/ALGORITHM_CATALOG_1HZ.md @@ -46,8 +46,8 @@ Synthesized from 6 independent literature reviews (HRV, cardiac, sleep/circadian - **van Hees / GGIR angle sleep-window** — 2015/2018. Count-FREE, gravity-orientation @1 Hz is ample. **THE sleep/wake spine** (sidesteps the Cole-Kripke count-calibration trap). `24/7 · HIGH` - **True Phillips SRI** — epoch-by-epoch 24-h concordance (Phillips 2017), NOT SD-of-midsleep. `24/7 · HIGH` - **Cardiopulmonary Coupling (CPC)** — Thomas 2005; RR + RSA/RIIV respiration surrogate (substitute for EDR). Sleep-stability spectrogram + apnea-risk; plays to continuous RR. `24/7 · MED-HIGH` -- **3-class autonomic stager (wake/NREM/REM)** + HR-dip onset, REM gated by 1 Hz immobility. Never claim N1/N2/N3. `24/7 · MED (honesty-bounded)` -- **Sleep accounting** — onset/offset, WASO, TST, efficiency, NREM-REM cycles (~90 min from CPC/autonomic). `24/7 · HIGH` +- **3-class cardio stager (wake/NREM/REM)** — `cardioStager`, Webster/Cole-Kripke wake + HRV; replaces the deleted HR-only autonomic stager. HR-dip onset, REM gated by 1 Hz immobility. Never claim N1/N2/N3. `24/7 · MED (honesty-bounded)` +- **Sleep accounting** — onset/offset, WASO, TST, efficiency, NREM-REM cycles (~90 min from CPC/cardio stager). `24/7 · HIGH` ### Respiration & SpO₂ (PPG + RR) - **RSA respiratory rate from RR** — Lomb-Scargle HF-peak; Pimentel 2017 AR-order robustness. *Primary 24/7 respiration source.* `24/7 · HIGH` diff --git a/lib/src/onehz/clinical/nocturnal.dart b/lib/src/onehz/clinical/nocturnal.dart index 5f85bcf..7bb85be 100644 --- a/lib/src/onehz/clinical/nocturnal.dart +++ b/lib/src/onehz/clinical/nocturnal.dart @@ -104,7 +104,15 @@ Metric nocturnalRhr(List hr, } lo++; } - if (ts[hi] < firstFullEnd || count < needValid) continue; + // `count == 0` is its own guard, not just a stricter `needValid`: a caller + // that passes `minCoverage: 0` makes `needValid` 0 too, and `count < + // needValid` is then never true for a non-negative count — so a window + // with NO on-skin sample would otherwise reach `sum / count` as `0.0 / 0`, + // which is NaN in Dart. NaN LATCHES here (`m < best` is false for a NaN + // `m`, and false again once a NaN `best` is compared against anything + // later), so one such window would silently poison the whole night's + // trough instead of being skipped. + if (ts[hi] < firstFullEnd || count < needValid || count == 0) continue; final m = sum / count; if (best == null || m < best) best = m; } diff --git a/lib/src/onehz/sleep/advanced_stager.dart b/lib/src/onehz/sleep/advanced_stager.dart index b54e138..272d062 100644 --- a/lib/src/onehz/sleep/advanced_stager.dart +++ b/lib/src/onehz/sleep/advanced_stager.dart @@ -907,20 +907,26 @@ class AdvancedSleepStager { // to avoid, so it is spelled out rather than left to the arithmetic. final ckFlags = !cadenceOk ? List.filled(nEpochs, false) - : _coleKripke(_rescaleCounts(counts, cadence)); + : _coleKripke(_rescaleCounts(counts)); return _EpochGrid(edges, nEpochs, counts, hr, moveFrac, rrBuckets, respBuckets, ckFlags); } /// Per-epoch gravity-delta SUM → the Cole-Kripke count surrogate. /// - /// The sum has one term per SAMPLE in the epoch, so at 5 s it carries a fifth - /// of the terms a 1 Hz epoch does for the same movement. Scaling by the - /// cadence normalises it to the per-epoch sample count [ckCountDivisor] was - /// calibrated against (30 samples per 30 s epoch); `× 1.0` at 1 Hz. - static List _rescaleCounts(List counts, double cadenceSec) => [ - for (final c in counts) - math.min(c * cadenceSec / ckCountDivisor, ckCountClip) + /// NO CADENCE FACTOR, on purpose — the sum is already cadence-invariant + /// under this file's own rate model (`gravityStillThresholdGPerS`, + /// `moveDeltaThresholdGPerS`: a per-sample delta grows linearly with the + /// sampling interval for the same physical movement). A 30 s epoch holds 30 + /// terms of size `d` at 1 Hz and 6 terms of size `5d` at 5 s cadence — both + /// sums equal `30d`. Multiplying by `cadenceSec` here used to inflate the + /// 5 s-cadence count 5x relative to 1 Hz, which shifted `_coleKripke`'s + /// `si < 1.0` decision (and therefore `_onsetAndFinalWake`) on exactly the + /// non-WHOOP bands this rate model exists to support. The 1 Hz path is + /// unaffected either way (`cadenceSec == 1`), which is why the defect was + /// invisible until now. + static List _rescaleCounts(List counts) => [ + for (final c in counts) math.min(c / ckCountDivisor, ckCountClip) ]; static List _coleKripke(List rescaled) { diff --git a/lib/src/onehz/sleep/cardio_stager.dart b/lib/src/onehz/sleep/cardio_stager.dart index 9d89d44..1c57d24 100644 --- a/lib/src/onehz/sleep/cardio_stager.dart +++ b/lib/src/onehz/sleep/cardio_stager.dart @@ -417,6 +417,16 @@ CardioStagerResult cardioStager( // where the evidence for it does not; abstain instead. if (cadenceSec == null || cadenceSec > epochSec) return _abstain(epochSec); final perEpoch = math.max(1, (epochSec / cadenceSec).round()); + // `perEpoch` samples at this cadence must actually SPAN `epochSec` — a + // cadence that does not divide it evenly (4 s against a 30 s epoch rounds + // `perEpoch` to 8, an 8 * 4 = 32 s epoch) drifts the real grid against the + // reported one. `epochSec` is not just a label: it is what the result + // reports (`CardioStagerResult.epochSec`) and what `consolidateSleepStages` + // / `_websterRescore` / `_mergeShortDeep` derive the Webster and bout + // thresholds from below, and the drift accumulates over the whole night. + // Abstain rather than publish a grid whose real spacing does not match its + // own label. + if ((perEpoch * cadenceSec - epochSec).abs() > 1e-6) return _abstain(epochSec); final nEpoch = n ~/ perEpoch; if (nEpoch < 3) return _abstain(epochSec); diff --git a/lib/src/onehz/sleep/sleep.dart b/lib/src/onehz/sleep/sleep.dart index 7655fc8..1789b40 100644 --- a/lib/src/onehz/sleep/sleep.dart +++ b/lib/src/onehz/sleep/sleep.dart @@ -11,7 +11,10 @@ // 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 +// - 3-class cardio stager (wake/NREM/REM) (cardio_stager.dart) — +// honesty-bounded; stager.dart holds only the shared post-processing +// (StagerResult, Webster rescore, consolidation) it reuses. The +// autonomic-HR-only stager that used to live in stager.dart is DELETED. // - Cardiopulmonary Coupling (CPC) (cpc.dart) // - Nonparametric circadian IS/IV/RA/L5/M10 (circadian_np.dart) // diff --git a/lib/src/onehz/wellness/readiness_composite.dart b/lib/src/onehz/wellness/readiness_composite.dart index 435bc54..2e77765 100644 --- a/lib/src/onehz/wellness/readiness_composite.dart +++ b/lib/src/onehz/wellness/readiness_composite.dart @@ -198,7 +198,13 @@ Metric readinessComposite( // mean/SD z so a usable input still contributes; only skip when SD is ALSO // zero (a truly constant baseline with no dispersion to normalize against). final rz = robustZ(v, base); - if (rz == null && inp.quantum > 0) { + // Checked for EVERY quantized input, not only when robustZ came back null. + // `robustZ` can still return a score on a baseline whose SD sits below the + // quantum — a 14-night whole-bpm baseline alternating 58/59 has MAD 0.5 + // (nonzero, so robustZ succeeds) but SD ~0.52, which is exactly the + // unresolvable-dispersion case this guard exists to catch. Gating it on + // `rz == null` let that baseline's z through unrefused. + if (inp.quantum > 0) { // MAD collapsed, so more than half this baseline sits exactly on its own // median. For a QUANTIZED input that is the signature of a baseline with // no resolvable dispersion — and the mean/SD fallback below then divides diff --git a/test/onehz/clinical_test.dart b/test/onehz/clinical_test.dart index f001732..034787b 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -339,6 +339,22 @@ void main() { expect(ok.value!.low30Mean, closeTo(60, 1e-9)); }); + test( + 'REGRESSION: minCoverage: 0 does not admit an all-off-skin window ' + 'as a trough', () { + // needValid = (minCoverage * perWindow).ceil() is 0 when minCoverage is + // 0, and `count < needValid` is then never true for a non-negative + // count — so a window with ZERO on-skin samples used to reach `sum / + // count` as 0.0 / 0, i.e. NaN, which then LATCHES as `best` (`m < best` + // is false for a NaN on either side) and comes back as a PRESENT metric + // whose low30Mean is NaN. An explicit `count == 0` guard is what stops + // it, independent of whatever minCoverage the caller passed. + final allOffSkin = List.filled(2000, 0.0); + final m = nocturnalRhr(allOffSkin, minCoverage: 0.0); + expect(m.present, isFalse); + expect(m.value, isNull); + }); + test('REGRESSION: hrDip refuses a 1-sample day and a 1-sample night', () { final m = hrDip([70], [60]); expect(m.present, isFalse); diff --git a/test/onehz/wellness_test.dart b/test/onehz/wellness_test.dart index 8d5c0c9..f0578d2 100644 --- a/test/onehz/wellness_test.dart +++ b/test/onehz/wellness_test.dart @@ -580,6 +580,24 @@ void main() { expect(m.note, contains('RHR: baseline_dispersion_below_quantum:')); expect(m.note, contains('quantum=1')); }); + + test( + 'A4 — an alternating baseline with nonzero MAD is STILL refused ' + 'below quantum', () { + // 58/59/58/59/... has a nonzero MAD (0.5), so robustZ succeeds and used + // to skip the quantum guard entirely — but its SD (~0.52) is still below + // the 1 bpm quantum. The guard must run for every quantized input, not + // only when robustZ came back null, or exactly this baseline lets a + // score through on quantization noise. + final base = [ + for (var i = 0; i < 14; i++) i.isEven ? 58.0 : 59.0 + ]; + final m = readinessComposite([rhrInput(52.0, base)], + minInputs: 1, minWeightSum: 0.0); + expect(m.present, isFalse); + expect(m.note, contains('RHR: baseline_dispersion_below_quantum:')); + expect(m.note, contains('quantum=1')); + }); }); // ------------------------------------------------------------------------- @@ -649,9 +667,13 @@ void main() { expect( m.note, 'need_baseline:have=1,need=$readinessCompositeMinBaseline'); // With >= minBaseline points it computes (14 nights, not 5 — A4). + // RHR baseline spread widened to i % 5 (sd ~1.4, above the 1-bpm + // quantum): i % 3 (sd ~0.83) is exactly the sub-quantum-dispersion case + // readinessComposite now refuses regardless of whether robustZ's MAD + // happened to be nonzero — see the quantum guard's own test below. final ok = readinessComposite([ hrvInput(60.0, [for (var i = 0; i < 14; i++) 48.0 + i % 5]), - rhrInput(55.0, [for (var i = 0; i < 14; i++) 54.0 + i % 3]), + rhrInput(55.0, [for (var i = 0; i < 14; i++) 54.0 + i % 5]), ]); expect(ok.present, isTrue, reason: ok.note); }); From f822748f3ba2b4cce10ea62f7cd97077c011e4de Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:19:09 +0530 Subject: [PATCH 3/3] test: ignore intentional deprecated cardiopulmonaryCoupling calls these two calls deliberately exercise the withdrawn function to assert it abstains; the deprecation info fires as fatal on the pinned 3.5.0 CI matrix leg (not on stable), failing analyze --fatal-infos --- test/onehz/sleep_test.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/onehz/sleep_test.dart b/test/onehz/sleep_test.dart index 19b8fe2..418f6b6 100644 --- a/test/onehz/sleep_test.dart +++ b/test/onehz/sleep_test.dart @@ -630,6 +630,7 @@ void main() { nn.add(rr); times.add(t); } + // ignore: deprecated_member_use_from_same_package final m = cardiopulmonaryCoupling(nn, times); expect(m.present, isFalse); expect(m.note, contains('respiration channel')); @@ -714,6 +715,7 @@ void main() { // dominant frequency in the physiological respiratory range (or absent). final corr = correctRr(rrMs); if (corr.nn.length >= 60) { + // ignore: deprecated_member_use_from_same_package final cpc = cardiopulmonaryCoupling(corr.nn, corr.nnTimesMs); if (cpc.present) { expect(cpc.value!.dominantHz, inInclusiveRange(0.0, 0.45));