From 65c4765bc462074b8e28a825b1b0a4fc3389c860 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:16:33 +0530 Subject: [PATCH 1/8] rmssd gate: don't refuse breathing near nyquist as jitter --- lib/src/onehz/clinical/hrv_time.dart | 89 ++++++++++++++++++++++++++-- test/onehz/clinical_test.dart | 59 ++++++++++++++++++ 2 files changed, 142 insertions(+), 6 deletions(-) diff --git a/lib/src/onehz/clinical/hrv_time.dart b/lib/src/onehz/clinical/hrv_time.dart index 1127832..33e4ab1 100644 --- a/lib/src/onehz/clinical/hrv_time.dart +++ b/lib/src/onehz/clinical/hrv_time.dart @@ -72,6 +72,83 @@ double? nnDiffAcf1(List> diffRuns) { return varSum > 0 ? cov / varSum : null; } +/// White-noise share of the mean squared successive difference above which a +/// night that failed [kNnDiffAcf1Floor] stays refused. Same line as the floor: +/// with acf-neutral physiology, ACF1 ≈ −0.5 × share, so −0.35 ↔ 0.7. +const double kNnDiffNoiseShareCeiling = 0.7; + +/// Share of the mean squared successive difference that a FLAT (white) RR noise +/// floor accounts for: 2σ² / mean(d²), with σ² read as the median of the +/// beat-indexed Welch spectrum (64-beat Hann, 50 % overlap, Welch 1967) over +/// 0.15–0.5 cycles/beat. ~1 for white or differenced-white timing jitter; well +/// below 1 when the high band is a respiratory line on a low floor. +/// +/// Why ACF1 alone is not enough: RSA is a line at (breaths/min ÷ HR) cycles per +/// beat, and differencing a line at f gives ACF1 = cos 2πf. At a resting HR +/// in the 40s and 18–20 breaths/min that is ~0.4 cycles/beat, ACF1 ≈ −0.8 — +/// below the floor on a clean night, so the floor locked out slow hearts. +/// Ceiling: a line AT Nyquist (breathing at exactly half the heart rate) is an +/// alternation, indistinguishable from detector alternation, and stays refused. +/// +/// Null (no verdict) on fewer than 20 segments or a peak at Nyquist. +double? nnDiffNoiseShare(List> diffRuns) { + const n = 64, kLo = 10, kHi = n ~/ 2; // kLo/n ≈ 0.15 cycles/beat + final w = [ + for (var i = 0; i < n; i++) 0.5 - 0.5 * math.cos(2 * math.pi * i / (n - 1)) + ]; + var w2 = 0.0; + for (final x in w) { + w2 += x * x; + } + final psd = List.filled(kHi + 1, 0.0); + var segs = 0, nd = 0; + var ssd = 0.0; + for (final r in diffRuns) { + // Integrate the run back to RR levels (up to a constant the mean removes). + final x = List.filled(r.length + 1, 0.0); + for (var i = 0; i < r.length; i++) { + x[i + 1] = x[i] + r[i]; + ssd += r[i] * r[i]; + nd++; + } + for (var s = 0; s + n <= x.length; s += n ~/ 2) { + var m = 0.0; + for (var i = 0; i < n; i++) { + m += x[s + i]; + } + m /= n; + for (var k = kLo; k <= kHi; k++) { + var re = 0.0, im = 0.0; + for (var i = 0; i < n; i++) { + final v = (x[s + i] - m) * w[i]; + final a = 2 * math.pi * k * i / n; + re += v * math.cos(a); + im -= v * math.sin(a); + } + psd[k] += re * re + im * im; + } + segs++; + } + } + if (segs < 20 || ssd == 0) return null; + final band = psd.sublist(kLo); + var peak = 0; + for (var k = 1; k < band.length; k++) { + if (band[k] > band[peak]) peak = k; + } + if (peak + kLo >= kHi - 1) return null; // Nyquist alternation, see above + final floor = median(band)! / segs / w2; + return 2 * floor / (ssd / nd); +} + +/// The one RMSSD jitter verdict every path shares: ACF1 below the floor AND +/// the spectrum does not show a respiratory line on a low white floor. +bool _jitterRefused(double? acf1, List> runs) { + if (acf1 == null || acf1 >= kNnDiffAcf1Floor) return false; + final share = nnDiffNoiseShare(runs); + return share == null || share >= kNnDiffNoiseShareCeiling; +} + /// Confidence multiplier for a measured [acf1]: 1.0 on a smooth tachogram, /// falling linearly to 0 at [kNnDiffAcf1Floor] so confidence bottoms out /// exactly where RMSSD is refused. 1.0 when ACF1 could not be measured. @@ -177,7 +254,7 @@ Metric hrvTime( // the audit corpus) — they keep publishing, which is what the header has // always advised. final acf1 = nnDiffAcf1(runs); - final jittery = acf1 != null && acf1 < kNnDiffAcf1Floor; + final jittery = _jitterRefused(acf1, runs); final rmssd = (pairs > 0 && !jittery) ? math.sqrt(ssd / pairs) : null; final pnn50 = (pairs > 0 && !jittery) ? 100.0 * nn50 / pairs : null; final sdnn = stddev(nnMs); @@ -219,7 +296,7 @@ Metric hrvTime( tier: Tier.high, inputs_used: inputs, note: jittery - ? '${_jitterNote(acf1)}. SDNN/SDANN survive it and are the lead here. ' + ? '${_jitterNote(acf1!)}. SDNN/SDANN survive it and are the lead here. ' 'PRV not ECG-HRV.' : 'PRV not ECG-HRV; RMSSD/pNN50 are quantization-sensitive at 1 Hz ' '— lead with SDNN/SDANN', @@ -321,11 +398,11 @@ Metric nocturnalRmssd( rmssds.add(math.sqrt(ssd / nd)); } final acf1 = nnDiffAcf1(runs); - if (acf1 != null && acf1 < kNnDiffAcf1Floor) { + if (_jitterRefused(acf1, runs)) { return Metric.absent( tier: Tier.high, inputs_used: inputs, - note: _jitterNote(acf1), + note: _jitterNote(acf1!), ); } if (rmssds.isEmpty) { @@ -435,11 +512,11 @@ Metric sleepSessionWindowedRmssd( // are noise, the honest output is no headline, not a plausible one — the // readiness composite already treats a null HRV driver as absent. final acf1 = nnDiffAcf1(runs); - if (acf1 != null && acf1 < kNnDiffAcf1Floor) { + if (_jitterRefused(acf1, runs)) { return Metric.absent( tier: Tier.high, inputs_used: inputs, - note: _jitterNote(acf1), + note: _jitterNote(acf1!), ); } if (rmssds.isEmpty) { diff --git a/test/onehz/clinical_test.dart b/test/onehz/clinical_test.dart index aaa5d0e..4ef0541 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -52,6 +52,65 @@ void main() { expect(clean.value!.pnn50, isNotNull); }); + test('HRV-02: slow-heart RSA near Nyquist is not jitter', () { + // HR 45, breathing every 2.5 beats (18 br/min) — diff-ACF1 ≈ cos(0.8π) + // ≈ −0.8, under the floor, yet the high band is one respiratory line. + final rnd = math.Random(3); + final rr = [ + for (var i = 0; i < 4800; i++) + 1333 + 30 * math.sin(2 * math.pi * i / 2.5) + (rnd.nextDouble() - 0.5) * 10 + ]; + final ts = []; + var t = 0.0; + for (final v in rr) { + t += v; + ts.add(t); + } + final h = hrvTime(rr, nnTimesMs: ts); + expect(h.value!.diffAcf1!, lessThan(kNnDiffAcf1Floor)); + expect(h.value!.rmssd, isNotNull); + expect(nocturnalRmssd(rr, ts).present, isTrue); + final ss = sleepSessionWindowedRmssd(rr, ts, + startSec: 1, endSec: (t / 1000).floor()); + expect(ss.present, isTrue); + expect(ss.value, closeTo(40.3, 2), reason: '√2·30·sin(0.4π)'); + }); + + test('HRV-02: long jitter stays refused through the spectral check', () { + final rnd = math.Random(5); + final white = [ + for (var i = 0; i < 4800; i++) 1000 + (rnd.nextDouble() - 0.5) * 120 + ]; + // Beat-TIME jitter: RR = base + e[i] − e[i−1], ACF1 ≈ −2/3, high-band + // power rising to Nyquist rather than flat. + final e = [for (var i = 0; i <= 4800; i++) (rnd.nextDouble() - 0.5) * 60]; + final timing = [for (var i = 1; i <= 4800; i++) 1333 + e[i] - e[i - 1]]; + // Strict alternation is a line AT Nyquist — refused, not called RSA. + final alt = [for (var i = 0; i < 4800; i++) 1333.0 + (i.isEven ? 30 : -30)]; + for (final s in [white, timing, alt]) { + final m = hrvTime(s); + expect(m.value!.diffAcf1!, lessThan(kNnDiffAcf1Floor)); + expect(m.value!.rmssd, isNull); + expect(m.note, contains('rmssd_refused:acf1=')); + } + expect(nnDiffNoiseShare([ + [for (var i = 1; i < white.length; i++) white[i] - white[i - 1]] + ])!, greaterThan(kNnDiffNoiseShareCeiling)); + }); + + test('HRV-02: normal-HR RSA is untouched by the jitter gate', () { + // HR 60, 15 br/min = 4 beats/breath: ACF1 ≈ 0, never reaches the + // spectral check. + final rnd = math.Random(9); + final rr = [ + for (var i = 0; i < 2400; i++) + 1000 + 30 * math.sin(2 * math.pi * i / 4) + (rnd.nextDouble() - 0.5) * 10 + ]; + final m = hrvTime(rr); + expect(m.value!.diffAcf1!, greaterThan(kNnDiffAcf1Floor)); + expect(m.value!.rmssd, closeTo(30, 2), reason: '√2·30·sin(π/4)'); + }); + test('HRV-02: confidence carries jitter and artifact, not beat count alone', () { // It used to be clamp(n/250, .3, .95), which published 0.95 on all 13 From 5c81ff52107fc5b881d1f601216d70398ab8f208 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:08:45 +0530 Subject: [PATCH 2/8] rmssd gate: noise share only counts diffs the spectrum sees, document the top-band guard --- lib/src/onehz/clinical/hrv_time.dart | 15 +++++++++++--- test/onehz/clinical_test.dart | 29 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/lib/src/onehz/clinical/hrv_time.dart b/lib/src/onehz/clinical/hrv_time.dart index 33e4ab1..1b04d82 100644 --- a/lib/src/onehz/clinical/hrv_time.dart +++ b/lib/src/onehz/clinical/hrv_time.dart @@ -87,10 +87,13 @@ const double kNnDiffNoiseShareCeiling = 0.7; /// beat, and differencing a line at f gives ACF1 = cos 2πf. At a resting HR /// in the 40s and 18–20 breaths/min that is ~0.4 cycles/beat, ACF1 ≈ −0.8 — /// below the floor on a clean night, so the floor locked out slow hearts. -/// Ceiling: a line AT Nyquist (breathing at exactly half the heart rate) is an +/// Ceiling: a line at Nyquist (breathing at half the heart rate) is an /// alternation, indistinguishable from detector alternation, and stays refused. +/// The guard is deliberately the top two bins, so a peak anywhere in +/// ~0.477–0.5 cycles/beat is refused too (HR ~40 at 20 br/min lands there): +/// at 64 beats a Hann line that close puts its main lobe on the Nyquist bin. /// -/// Null (no verdict) on fewer than 20 segments or a peak at Nyquist. +/// Null (no verdict) on fewer than 20 segments or a peak in that top band. double? nnDiffNoiseShare(List> diffRuns) { const n = 64, kLo = 10, kHi = n ~/ 2; // kLo/n ≈ 0.15 cycles/beat final w = [ @@ -108,6 +111,12 @@ double? nnDiffNoiseShare(List> diffRuns) { final x = List.filled(r.length + 1, 0.0); for (var i = 0; i < r.length; i++) { x[i + 1] = x[i] + r[i]; + } + // mean(d²) over the same diffs the segments span, so short runs and run + // tails (which never reach the spectrum) can't dilute the share. + final covered = + x.length < n ? 0 : (x.length - n) ~/ (n ~/ 2) * (n ~/ 2) + n - 1; + for (var i = 0; i < covered; i++) { ssd += r[i] * r[i]; nd++; } @@ -136,7 +145,7 @@ double? nnDiffNoiseShare(List> diffRuns) { for (var k = 1; k < band.length; k++) { if (band[k] > band[peak]) peak = k; } - if (peak + kLo >= kHi - 1) return null; // Nyquist alternation, see above + if (peak + kLo >= kHi - 1) return null; // ~0.477–0.5 band, see above final floor = median(band)! / segs / w2; return 2 * floor / (ssd / nd); } diff --git a/test/onehz/clinical_test.dart b/test/onehz/clinical_test.dart index 4ef0541..8413bd9 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -98,6 +98,35 @@ void main() { ])!, greaterThan(kNnDiffNoiseShareCeiling)); }); + test('HRV-02: noisier short runs cannot dilute the noise share', () { + // Pure white noise, but fragmented: a few long runs at ±10 ms and many + // 40-beat runs (too short for the spectrum) at 3x the amplitude. The + // share must come from the same diffs the spectrum sees. + final rnd = math.Random(11); + final rr = []; + final ts = []; + var t = 0.0; + void run(int len, double amp) { + t += 5000; // sensor hole ends the run + for (var i = 0; i < len; i++) { + final v = 1000 + (rnd.nextDouble() - 0.5) * 2 * amp; + t += v; + rr.add(v); + ts.add(t); + } + } + for (var i = 0; i < 12; i++) { + run(400, 10); + } + for (var i = 0; i < 150; i++) { + run(40, 30); + } + final h = hrvTime(rr, nnTimesMs: ts); + expect(h.value!.diffAcf1!, lessThan(kNnDiffAcf1Floor)); + expect(h.value!.rmssd, isNull); + expect(nocturnalRmssd(rr, ts).present, isFalse); + }); + test('HRV-02: normal-HR RSA is untouched by the jitter gate', () { // HR 60, 15 br/min = 4 beats/breath: ACF1 ≈ 0, never reaches the // spectral check. From 764d39cf94a7dfb3b07a6dc9148dfd45dd9c2083 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:17:30 +0530 Subject: [PATCH 3/8] rmssd gate: weight noise share by hann coverage so run-edge spikes can't pass as rsa, trig table --- lib/src/onehz/clinical/hrv_time.dart | 50 +++++++++++++++++++--------- test/onehz/clinical_test.dart | 26 +++++++++++++++ 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/lib/src/onehz/clinical/hrv_time.dart b/lib/src/onehz/clinical/hrv_time.dart index 1b04d82..77a0ae1 100644 --- a/lib/src/onehz/clinical/hrv_time.dart +++ b/lib/src/onehz/clinical/hrv_time.dart @@ -93,6 +93,13 @@ const double kNnDiffNoiseShareCeiling = 0.7; /// ~0.477–0.5 cycles/beat is refused too (HR ~40 at 20 br/min lands there): /// at 64 beats a Hann line that close puts its main lobe on the Nyquist bin. /// +/// Diffs are weighted by the same Hann² coverage the spectrum gives them: a +/// diff in the first or last few beats of a segment barely reaches the PSD, so +/// counting it at full weight against the floor hid run-edge artifacts (sensor +/// holes, dropped runs, window seams are exactly where re-lock residuals sit). +/// Energy the spectrum never vets (edges, tails, too-short runs) above what the +/// weighted mean carries counts as noise, so it can only push toward refusal. +/// /// Null (no verdict) on fewer than 20 segments or a peak in that top band. double? nnDiffNoiseShare(List> diffRuns) { const n = 64, kLo = 10, kHi = n ~/ 2; // kLo/n ≈ 0.15 cycles/beat @@ -103,43 +110,54 @@ double? nnDiffNoiseShare(List> diffRuns) { for (final x in w) { w2 += x * x; } + final cosT = [ + for (var k = kLo; k <= kHi; k++) + [for (var i = 0; i < n; i++) math.cos(2 * math.pi * k * i / n)] + ]; + final sinT = [ + for (var k = kLo; k <= kHi; k++) + [for (var i = 0; i < n; i++) math.sin(2 * math.pi * k * i / n)] + ]; final psd = List.filled(kHi + 1, 0.0); var segs = 0, nd = 0; - var ssd = 0.0; + var ssd = 0.0, wsd = 0.0, wsum = 0.0; for (final r in diffRuns) { + for (final d in r) { + ssd += d * d; + nd++; + } // Integrate the run back to RR levels (up to a constant the mean removes). final x = List.filled(r.length + 1, 0.0); for (var i = 0; i < r.length; i++) { x[i + 1] = x[i] + r[i]; } - // mean(d²) over the same diffs the segments span, so short runs and run - // tails (which never reach the spectrum) can't dilute the share. - final covered = - x.length < n ? 0 : (x.length - n) ~/ (n ~/ 2) * (n ~/ 2) + n - 1; - for (var i = 0; i < covered; i++) { - ssd += r[i] * r[i]; - nd++; - } + final cov = List.filled(x.length, 0.0); // Σ w² per sample for (var s = 0; s + n <= x.length; s += n ~/ 2) { var m = 0.0; for (var i = 0; i < n; i++) { m += x[s + i]; + cov[s + i] += w[i] * w[i]; } m /= n; + final v = [for (var i = 0; i < n; i++) (x[s + i] - m) * w[i]]; for (var k = kLo; k <= kHi; k++) { + final c = cosT[k - kLo], sn = sinT[k - kLo]; var re = 0.0, im = 0.0; for (var i = 0; i < n; i++) { - final v = (x[s + i] - m) * w[i]; - final a = 2 * math.pi * k * i / n; - re += v * math.cos(a); - im -= v * math.sin(a); + re += v[i] * c[i]; + im -= v[i] * sn[i]; } psd[k] += re * re + im * im; } segs++; } + for (var i = 0; i < r.length; i++) { + final c = (cov[i] + cov[i + 1]) / 2; + wsd += c * r[i] * r[i]; + wsum += c; + } } - if (segs < 20 || ssd == 0) return null; + if (segs < 20 || ssd == 0 || wsd == 0) return null; final band = psd.sublist(kLo); var peak = 0; for (var k = 1; k < band.length; k++) { @@ -147,7 +165,9 @@ double? nnDiffNoiseShare(List> diffRuns) { } if (peak + kLo >= kHi - 1) return null; // ~0.477–0.5 band, see above final floor = median(band)! / segs / w2; - return 2 * floor / (ssd / nd); + final vetted = wsd / wsum, all = ssd / nd; + // 1 − (structured share of RMSSD²); equals 2σ²/mean(d²) when stationary. + return 1 - (vetted - 2 * floor) / math.max(vetted, all); } /// The one RMSSD jitter verdict every path shares: ACF1 below the floor AND diff --git a/test/onehz/clinical_test.dart b/test/onehz/clinical_test.dart index 8413bd9..3c39576 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -127,6 +127,32 @@ void main() { expect(nocturnalRmssd(rr, ts).present, isFalse); }); + test('HRV-02: run-edge artifacts the Hann taper hides stay refused', () { + // White ±5 ms, but beat 1 of every run is +120 ms: the taper barely sees + // it, so counting it at full weight against the floor read as RSA. + final rnd = math.Random(13); + final rr = []; + final ts = []; + var t = 0.0; + for (var r = 0; r < 40; r++) { + t += 5000; + for (var i = 0; i < 100; i++) { + final v = 1000 + (rnd.nextDouble() - 0.5) * 10 + (i == 1 ? 120 : 0); + t += v; + rr.add(v); + ts.add(t); + } + } + final h = hrvTime(rr, nnTimesMs: ts); + expect(h.value!.diffAcf1!, lessThan(kNnDiffAcf1Floor)); + expect(h.value!.rmssd, isNull); + expect(nocturnalRmssd(rr, ts).present, isFalse); + final ss = sleepSessionWindowedRmssd(rr, ts, + startSec: 1, endSec: (t / 1000).floor()); + expect(ss.present, isFalse); + expect(ss.note, contains('rmssd_refused:acf1=')); + }); + test('HRV-02: normal-HR RSA is untouched by the jitter gate', () { // HR 60, 15 br/min = 4 beats/breath: ACF1 ≈ 0, never reaches the // spectral check. From 5b23c412387cbd85a9e8d998b4d5eb7740ca92cf Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:24:52 +0530 Subject: [PATCH 4/8] rmssd gate: rsa exemption needs a peak clear of the floor, short jitter was slipping through --- lib/src/onehz/clinical/hrv_time.dart | 13 +++++++++++-- test/onehz/clinical_test.dart | 18 +++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/lib/src/onehz/clinical/hrv_time.dart b/lib/src/onehz/clinical/hrv_time.dart index 77a0ae1..288025a 100644 --- a/lib/src/onehz/clinical/hrv_time.dart +++ b/lib/src/onehz/clinical/hrv_time.dart @@ -100,7 +100,14 @@ const double kNnDiffNoiseShareCeiling = 0.7; /// Energy the spectrum never vets (edges, tails, too-short runs) above what the /// weighted mean carries counts as noise, so it can only push toward refusal. /// -/// Null (no verdict) on fewer than 20 segments or a peak in that top band. +/// A respiratory line has to stand out: the peak bin must reach 4.5x the band +/// median. With only ~20-30 segments the median is noisy enough that pure +/// beat-time jitter sometimes reads under the ceiling; its peak stays near +/// 3.4x the median at worst, while an RSA line that clears the ceiling sits at +/// 5x or more. +/// +/// Null (no verdict) on fewer than 20 segments, a peak in that top band, or +/// no peak standing clear of the floor. double? nnDiffNoiseShare(List> diffRuns) { const n = 64, kLo = 10, kHi = n ~/ 2; // kLo/n ≈ 0.15 cycles/beat final w = [ @@ -164,7 +171,9 @@ double? nnDiffNoiseShare(List> diffRuns) { if (band[k] > band[peak]) peak = k; } if (peak + kLo >= kHi - 1) return null; // ~0.477–0.5 band, see above - final floor = median(band)! / segs / w2; + final med = median(band)!; + if (band[peak] < 4.5 * med) return null; // no line, no exemption + final floor = med / segs / w2; final vetted = wsd / wsum, all = ssd / nd; // 1 − (structured share of RMSSD²); equals 2σ²/mean(d²) when stationary. return 1 - (vetted - 2 * floor) / math.max(vetted, all); diff --git a/test/onehz/clinical_test.dart b/test/onehz/clinical_test.dart index 3c39576..aabec13 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -93,9 +93,25 @@ void main() { expect(m.value!.rmssd, isNull); expect(m.note, contains('rmssd_refused:acf1=')); } + // Flat band: no line stands out, so no spectral verdict at all. expect(nnDiffNoiseShare([ [for (var i = 1; i < white.length; i++) white[i] - white[i - 1]] - ])!, greaterThan(kNnDiffNoiseShareCeiling)); + ]), isNull); + }); + + test('HRV-02: short beat-time jitter cannot pass as RSA', () { + // ~680 beats is just over 20 Welch segments: the band median is noisy + // enough that differenced jitter used to read under the ceiling. + for (var seed = 0; seed < 400; seed++) { + final rnd = math.Random(seed); + double g() => + math.sqrt(-2 * math.log(1 - rnd.nextDouble())) * + math.cos(2 * math.pi * rnd.nextDouble()); + final e = [for (var i = 0; i <= 680; i++) 20 * g()]; + final rr = [for (var i = 1; i <= 680; i++) 1333 + e[i] - e[i - 1]]; + final h = hrvTime(rr); + expect(h.value!.rmssd, isNull, reason: 'seed $seed'); + } }); test('HRV-02: noisier short runs cannot dilute the noise share', () { From 3b409cf80e20b300c97865cab435d519652c478e Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:55:24 +0530 Subject: [PATCH 5/8] rmssd gate: refuse a nyquist hump too, phase-slipping alternation was passing as rsa --- lib/src/onehz/clinical/hrv_time.dart | 9 ++++++-- test/onehz/clinical_test.dart | 32 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/lib/src/onehz/clinical/hrv_time.dart b/lib/src/onehz/clinical/hrv_time.dart index 288025a..18f380c 100644 --- a/lib/src/onehz/clinical/hrv_time.dart +++ b/lib/src/onehz/clinical/hrv_time.dart @@ -92,6 +92,10 @@ const double kNnDiffNoiseShareCeiling = 0.7; /// The guard is deliberately the top two bins, so a peak anywhere in /// ~0.477–0.5 cycles/beat is refused too (HR ~40 at 20 br/min lands there): /// at 64 beats a Hann line that close puts its main lobe on the Nyquist bin. +/// Alternation that slips phase now and then is not a line but a hump centred +/// on Nyquist, a few bins wide, and Welch scatter can put its peak lower. A +/// real line at <= ~0.47 cycles/beat leaves the Nyquist bin on the Hann null, +/// holding only the floor, so a Nyquist bin above 0.2x the peak is refused too. /// /// Diffs are weighted by the same Hann² coverage the spectrum gives them: a /// diff in the first or last few beats of a segment barely reaches the PSD, so @@ -106,8 +110,8 @@ const double kNnDiffNoiseShareCeiling = 0.7; /// 3.4x the median at worst, while an RSA line that clears the ceiling sits at /// 5x or more. /// -/// Null (no verdict) on fewer than 20 segments, a peak in that top band, or -/// no peak standing clear of the floor. +/// Null (no verdict) on fewer than 20 segments, a peak in that top band, a +/// Nyquist bin that rivals the peak, or no peak standing clear of the floor. double? nnDiffNoiseShare(List> diffRuns) { const n = 64, kLo = 10, kHi = n ~/ 2; // kLo/n ≈ 0.15 cycles/beat final w = [ @@ -171,6 +175,7 @@ double? nnDiffNoiseShare(List> diffRuns) { if (band[k] > band[peak]) peak = k; } if (peak + kLo >= kHi - 1) return null; // ~0.477–0.5 band, see above + if (band.last > 0.2 * band[peak]) return null; // hump on Nyquist, see above final med = median(band)!; if (band[peak] < 4.5 * med) return null; // no line, no exemption final floor = med / segs / w2; diff --git a/test/onehz/clinical_test.dart b/test/onehz/clinical_test.dart index aabec13..e441775 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -114,6 +114,38 @@ void main() { } }); + test('HRV-02: alternation that slips phase cannot pass as RSA', () { + // ±30 ms alternation that holds sign on 20% of beats: a hump centred on + // Nyquist rather than a line at it, so its Welch peak often lands below + // the top-two-bin guard. + for (var seed = 0; seed < 100; seed++) { + final rnd = math.Random(seed); + double g() => + math.sqrt(-2 * math.log(1 - rnd.nextDouble())) * + math.cos(2 * math.pi * rnd.nextDouble()); + var s = 1.0; + final rr = []; + final ts = []; + var t = 0.0; + for (var i = 0; i < 1000; i++) { + if (rnd.nextDouble() >= 0.2) s = -s; + final v = 1333 + s * 30 + 5 * g(); + t += v; + rr.add(v); + ts.add(t); + } + expect(hrvTime(rr, nnTimesMs: ts).value!.rmssd, isNull, + reason: 'seed $seed'); + expect(nocturnalRmssd(rr, ts).present, isFalse, reason: 'seed $seed'); + expect( + sleepSessionWindowedRmssd(rr, ts, + startSec: 1, endSec: (t / 1000).floor()) + .present, + isFalse, + reason: 'seed $seed'); + } + }); + test('HRV-02: noisier short runs cannot dilute the noise share', () { // Pure white noise, but fragmented: a few long runs at ±10 ms and many // 40-beat runs (too short for the spectrum) at 3x the amplitude. The From c76945c81610ecfd642f25618b4975c910871fbe Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:06:04 +0530 Subject: [PATCH 6/8] rmssd gate: count welch segments by power, one loud burst was passing as rsa --- lib/src/onehz/clinical/hrv_time.dart | 18 +++++++++++++--- test/onehz/clinical_test.dart | 32 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/lib/src/onehz/clinical/hrv_time.dart b/lib/src/onehz/clinical/hrv_time.dart index 18f380c..d9b897d 100644 --- a/lib/src/onehz/clinical/hrv_time.dart +++ b/lib/src/onehz/clinical/hrv_time.dart @@ -110,8 +110,14 @@ const double kNnDiffNoiseShareCeiling = 0.7; /// 3.4x the median at worst, while an RSA line that clears the ceiling sits at /// 5x or more. /// -/// Null (no verdict) on fewer than 20 segments, a peak in that top band, a -/// Nyquist bin that rivals the peak, or no peak standing clear of the floor. +/// A short loud burst can carry most of the band power, leaving the average +/// effectively one or two segments wide, and the median of that undershoots +/// the floor. So the segment count that matters is the power-weighted one, +/// (Σ P_s)² / Σ P_s², which must reach 15. +/// +/// Null (no verdict) on fewer than 20 segments (or 15 effective), a peak in +/// that top band, a Nyquist bin that rivals the peak, or no peak standing +/// clear of the floor. double? nnDiffNoiseShare(List> diffRuns) { const n = 64, kLo = 10, kHi = n ~/ 2; // kLo/n ≈ 0.15 cycles/beat final w = [ @@ -131,7 +137,7 @@ double? nnDiffNoiseShare(List> diffRuns) { ]; final psd = List.filled(kHi + 1, 0.0); var segs = 0, nd = 0; - var ssd = 0.0, wsd = 0.0, wsum = 0.0; + var ssd = 0.0, wsd = 0.0, wsum = 0.0, pSum = 0.0, p2Sum = 0.0; for (final r in diffRuns) { for (final d in r) { ssd += d * d; @@ -151,6 +157,7 @@ double? nnDiffNoiseShare(List> diffRuns) { } m /= n; final v = [for (var i = 0; i < n; i++) (x[s + i] - m) * w[i]]; + var p = 0.0; for (var k = kLo; k <= kHi; k++) { final c = cosT[k - kLo], sn = sinT[k - kLo]; var re = 0.0, im = 0.0; @@ -159,7 +166,10 @@ double? nnDiffNoiseShare(List> diffRuns) { im -= v[i] * sn[i]; } psd[k] += re * re + im * im; + p += re * re + im * im; } + pSum += p; + p2Sum += p * p; segs++; } for (var i = 0; i < r.length; i++) { @@ -169,6 +179,8 @@ double? nnDiffNoiseShare(List> diffRuns) { } } if (segs < 20 || ssd == 0 || wsd == 0) return null; + // One loud stretch dominates the average: count segments by power, not number. + if (pSum * pSum / p2Sum < 15) return null; final band = psd.sublist(kLo); var peak = 0; for (var k = 1; k < band.length; k++) { diff --git a/test/onehz/clinical_test.dart b/test/onehz/clinical_test.dart index e441775..702ea3d 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -175,6 +175,38 @@ void main() { expect(nocturnalRmssd(rr, ts).present, isFalse); }); + test('HRV-02: one loud jitter burst cannot pass as RSA', () { + // Beat-time jitter at 5 ms with a 64-beat stretch at 20x: the burst + // carries most of the band power, so the averaged spectrum is in effect + // one or two segments and its median undershoots the floor. + var gated = 0; + for (var seed = 0; seed < 200; seed++) { + final rnd = math.Random(seed); + double g() => + math.sqrt(-2 * math.log(1 - rnd.nextDouble())) * + math.cos(2 * math.pi * rnd.nextDouble()); + final e = [ + for (var i = 0; i <= 3000; i++) + 5 * g() * (i >= 1500 && i < 1564 ? 20 : 1) + ]; + final rr = []; + final ts = []; + var t = 0.0; + for (var i = 1; i <= 3000; i++) { + final v = 1333 + e[i] - e[i - 1]; + t += v; + rr.add(v); + ts.add(t); + } + final h = hrvTime(rr, nnTimesMs: ts); + if (h.value!.diffAcf1! >= kNnDiffAcf1Floor) continue; + gated++; + expect(h.value!.rmssd, isNull, reason: 'seed $seed'); + expect(nocturnalRmssd(rr, ts).present, isFalse, reason: 'seed $seed'); + } + expect(gated, greaterThan(150)); + }); + test('HRV-02: run-edge artifacts the Hann taper hides stay refused', () { // White ±5 ms, but beat 1 of every run is +120 ms: the taper barely sees // it, so counting it at full weight against the floor read as RSA. From d4e28ac980f360729b2509e94b4211310473cedd Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:18:41 +0530 Subject: [PATCH 7/8] rmssd gate: beat times on a coarse grid read as a clean line, refuse when rmssd is under the grid step --- lib/src/onehz/clinical/hrv_time.dart | 30 ++++++++++++++++++++- test/onehz/clinical_test.dart | 39 ++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/lib/src/onehz/clinical/hrv_time.dart b/lib/src/onehz/clinical/hrv_time.dart index d9b897d..a48d0e6 100644 --- a/lib/src/onehz/clinical/hrv_time.dart +++ b/lib/src/onehz/clinical/hrv_time.dart @@ -117,7 +117,8 @@ const double kNnDiffNoiseShareCeiling = 0.7; /// /// Null (no verdict) on fewer than 20 segments (or 15 effective), a peak in /// that top band, a Nyquist bin that rivals the peak, or no peak standing -/// clear of the floor. +/// clear of the floor, or diffs on a beat-time grid coarse against their size +/// (see [_onCoarseLattice]). double? nnDiffNoiseShare(List> diffRuns) { const n = 64, kLo = 10, kHi = n ~/ 2; // kLo/n ≈ 0.15 cycles/beat final w = [ @@ -179,6 +180,7 @@ double? nnDiffNoiseShare(List> diffRuns) { } } if (segs < 20 || ssd == 0 || wsd == 0) return null; + if (_onCoarseLattice(diffRuns, ssd / nd)) return null; // One loud stretch dominates the average: count segments by power, not number. if (pSum * pSum / p2Sum < 15) return null; final band = psd.sublist(kLo); @@ -196,6 +198,32 @@ double? nnDiffNoiseShare(List> diffRuns) { return 1 - (vetted - 2 * floor) / math.max(vetted, all); } +/// True when every successive difference is a whole multiple of one step q and +/// the mean squared difference is under 2q². Beat times rounded to a grid q +/// make RR a two-level sequence on a near-constant heart: its differences are +/// 0 or ±q, a deterministic sawtooth at frac(RR/q) cycles/beat, so it reads as +/// one clean line and the white-floor test cannot see it. Rounding alone puts +/// mean(d²) at 2·min(f, 1−f)·q² ≤ q², so under 2q² the grid, not the heart, +/// sets RMSSD. +// ponytail: exact lattice only; a grid re-rounded to whole ms (7.8 ms → 7/8) +// breaks the common step and is not caught. +bool _onCoarseLattice(List> diffRuns, double msd) { + var q = double.infinity; + for (final r in diffRuns) { + for (final d in r) { + if (d.abs() > 1e-6 && d.abs() < q) q = d.abs(); + } + } + if (q.isInfinite || msd >= 2 * q * q) return false; + for (final r in diffRuns) { + for (final d in r) { + final k = d.abs() / q; + if ((k - k.roundToDouble()).abs() > 1e-3) return false; + } + } + return true; +} + /// The one RMSSD jitter verdict every path shares: ACF1 below the floor AND /// the spectrum does not show a respiratory line on a low white floor. bool _jitterRefused(double? acf1, List> runs) { diff --git a/test/onehz/clinical_test.dart b/test/onehz/clinical_test.dart index 702ea3d..161e798 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -233,6 +233,45 @@ void main() { expect(ss.note, contains('rmssd_refused:acf1=')); }); + test('HRV-02: beat times on a coarse grid cannot pass as RSA', () { + // True RR ≈ 1013 ± 2 ms (RMSSD ~3), beat times rounded to 40 ms: RR + // flips between 1000 and 1040, a sawtooth line at 0.325 cycles/beat. + for (var seed = 0; seed < 6; seed++) { + final rnd = math.Random(seed); + double g() => + math.sqrt(-2 * math.log(1 - rnd.nextDouble())) * + math.cos(2 * math.pi * rnd.nextDouble()); + var t = 0.0; + final tq = []; + for (var i = 0; i <= 2400; i++) { + t += 1013 + 2 * g(); + tq.add((t / 40).roundToDouble() * 40); + } + final rr = [for (var i = 1; i < tq.length; i++) tq[i] - tq[i - 1]]; + final ts = tq.sublist(1); + final h = hrvTime(rr, nnTimesMs: ts); + expect(h.value!.diffAcf1!, lessThan(kNnDiffAcf1Floor)); + expect(h.value!.rmssd, isNull, reason: 'seed $seed'); + expect(nocturnalRmssd(rr, ts).present, isFalse, reason: 'seed $seed'); + expect( + sleepSessionWindowedRmssd(rr, ts, + startSec: 1, endSec: (ts.last / 1000).floor()) + .present, + isFalse, + reason: 'seed $seed'); + } + // Whole-ms RR is a lattice too, but far finer than the RSA it carries. + final rnd = math.Random(3); + final rr = [ + for (var i = 0; i < 4800; i++) + (1333 + + 30 * math.sin(2 * math.pi * i / 2.5) + + (rnd.nextDouble() - 0.5) * 10) + .roundToDouble() + ]; + expect(hrvTime(rr).value!.rmssd, isNotNull); + }); + test('HRV-02: normal-HR RSA is untouched by the jitter gate', () { // HR 60, 15 br/min = 4 beats/breath: ACF1 ≈ 0, never reaches the // spectral check. From 8c3812b29d47511ed9d69ac51b114ed1d73105fc Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:29:10 +0530 Subject: [PATCH 8/8] rmssd gate: on the exemption path every window feeds the headline only if it clears the gate itself --- lib/src/onehz/clinical/hrv_time.dart | 66 +++++++++++++++++++++++----- test/onehz/clinical_test.dart | 26 +++++++++++ 2 files changed, 81 insertions(+), 11 deletions(-) diff --git a/lib/src/onehz/clinical/hrv_time.dart b/lib/src/onehz/clinical/hrv_time.dart index a48d0e6..3f7e745 100644 --- a/lib/src/onehz/clinical/hrv_time.dart +++ b/lib/src/onehz/clinical/hrv_time.dart @@ -115,11 +115,12 @@ const double kNnDiffNoiseShareCeiling = 0.7; /// the floor. So the segment count that matters is the power-weighted one, /// (Σ P_s)² / Σ P_s², which must reach 15. /// -/// Null (no verdict) on fewer than 20 segments (or 15 effective), a peak in +/// Null (no verdict) on fewer than [minSegments] segments (or 3/4 of that +/// effective; 20/15 for a night, see [_windowClears] for one window), a peak in /// that top band, a Nyquist bin that rivals the peak, or no peak standing /// clear of the floor, or diffs on a beat-time grid coarse against their size /// (see [_onCoarseLattice]). -double? nnDiffNoiseShare(List> diffRuns) { +double? nnDiffNoiseShare(List> diffRuns, {int minSegments = 20}) { const n = 64, kLo = 10, kHi = n ~/ 2; // kLo/n ≈ 0.15 cycles/beat final w = [ for (var i = 0; i < n; i++) 0.5 - 0.5 * math.cos(2 * math.pi * i / (n - 1)) @@ -179,10 +180,10 @@ double? nnDiffNoiseShare(List> diffRuns) { wsum += c; } } - if (segs < 20 || ssd == 0 || wsd == 0) return null; + if (segs < minSegments || ssd == 0 || wsd == 0) return null; if (_onCoarseLattice(diffRuns, ssd / nd)) return null; // One loud stretch dominates the average: count segments by power, not number. - if (pSum * pSum / p2Sum < 15) return null; + if (pSum * pSum / p2Sum < 0.75 * minSegments) return null; final band = psd.sublist(kLo); var peak = 0; for (var k = 1; k < band.length; k++) { @@ -226,12 +227,35 @@ bool _onCoarseLattice(List> diffRuns, double msd) { /// The one RMSSD jitter verdict every path shares: ACF1 below the floor AND /// the spectrum does not show a respiratory line on a low white floor. -bool _jitterRefused(double? acf1, List> runs) { +bool _jitterRefused(double? acf1, List> runs, + {int minSegments = 20}) { if (acf1 == null || acf1 >= kNnDiffAcf1Floor) return false; - final share = nnDiffNoiseShare(runs); + final share = nnDiffNoiseShare(runs, minSegments: minSegments); return share == null || share >= kNnDiffNoiseShareCeiling; } +/// One 5-min window judged on its own: a measured ACF1 at or above the floor, +/// or its own spectral line (4 segments is what a window at HR ~40 holds). +bool _windowClears(List> runs) { + final a = nnDiffAcf1(runs); + return a != null && !_jitterRefused(a, runs, minSegments: 4); +} + +/// The window RMSSDs a headline may use. A night that cleared the floor +/// outright keeps them all. One that only passed through the spectral +/// exemption proved a line somewhere in the pooled power, not in every window: +/// quiet jitter-only windows can win the median or dilute the mean while a few +/// loud breathing windows carry the pooled share. There every window has to +/// clear the gate itself. +List _clearedWindows(double? nightAcf1, List rmssds, + List>> winRuns) { + if (nightAcf1 == null || nightAcf1 >= kNnDiffAcf1Floor) return rmssds; + return [ + for (var i = 0; i < rmssds.length; i++) + if (_windowClears(winRuns[i])) rmssds[i] + ]; +} + /// Confidence multiplier for a measured [acf1]: 1.0 on a smooth tachogram, /// falling linearly to 0 at [kNnDiffAcf1Floor] so confidence bottoms out /// exactly where RMSSD is refused. 1.0 when ACF1 could not be measured. @@ -440,6 +464,7 @@ Metric nocturnalRmssd( // passed by luck — measured, that let WHOOP 5 publish 109-116 ms from its // calmest-looking windows while the night pooled to −0.43/−0.51. final runs = >[]; + final perWindow = >>[]; final indices = buckets.keys.toList()..sort(); for (final idx in indices) { if (stageMaskPerSec != null) { @@ -478,6 +503,7 @@ Metric nocturnalRmssd( } if (nd < minBeatsPerWindow) continue; runs.addAll(winRuns); + perWindow.add(winRuns); rmssds.add(math.sqrt(ssd / nd)); } final acf1 = nnDiffAcf1(runs); @@ -495,11 +521,19 @@ Metric nocturnalRmssd( note: 'no usable 5-min windows for nocturnal RMSSD', ); } - final robust = median(rmssds)!; + final kept = _clearedWindows(acf1, rmssds, perWindow); + if (kept.isEmpty) { + return Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: '${_jitterNote(acf1!)}; no 5-min window clears it on its own', + ); + } + final robust = median(kept)!; // Confidence scales with how many windows we could median over, and with the // measured jitter level (see [kNnDiffAcf1Floor]). final conf = - ((rmssds.length / 12.0).clamp(0.0, 1.0) * _acf1Quality(acf1)).clamp( + ((kept.length / 12.0).clamp(0.0, 1.0) * _acf1Quality(acf1)).clamp( // 12 ≈ 1 h 0.3, 0.95); @@ -508,7 +542,7 @@ Metric nocturnalRmssd( confidence: conf, tier: Tier.high, inputs_used: inputs, - note: 'robust nocturnal RMSSD = MEDIAN of ${rmssds.length} consecutive ' + note: 'robust nocturnal RMSSD = MEDIAN of ${kept.length} consecutive ' '5-min-window RMSSDs (REM/arousal-robust). PRV not ECG-HRV; ' 'RMSSD is quantization-sensitive at 1 Hz.', ); @@ -572,6 +606,7 @@ Metric sleepSessionWindowedRmssd( final rmssds = []; final runs = >[]; // pooled jitter floor — see [nocturnalRmssd] + final perWindow = >>[]; final indices = buckets.keys.toList()..sort(); for (final idx in indices) { final diffRuns = [ @@ -588,6 +623,7 @@ Metric sleepSessionWindowedRmssd( } if (nd == 0) continue; runs.addAll(diffRuns); + perWindow.add(diffRuns); rmssds.add(math.sqrt(ssd / nd)); } @@ -609,9 +645,17 @@ Metric sleepSessionWindowedRmssd( note: 'no valid 5-min windows for sleep-session RMSSD', ); } + final kept = _clearedWindows(acf1, rmssds, perWindow); + if (kept.isEmpty) { + return Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: '${_jitterNote(acf1!)}; no 5-min window clears it on its own', + ); + } - final meanRmssd = mean(rmssds)!; - final conf = ((rmssds.length / 12.0).clamp(0.0, 1.0) * _acf1Quality(acf1)) + final meanRmssd = mean(kept)!; + final conf = ((kept.length / 12.0).clamp(0.0, 1.0) * _acf1Quality(acf1)) .clamp(0.3, 0.95); return Metric( value: meanRmssd, diff --git a/test/onehz/clinical_test.dart b/test/onehz/clinical_test.dart index 161e798..2170850 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -76,6 +76,32 @@ void main() { expect(ss.value, closeTo(40.3, 2), reason: '√2·30·sin(0.4π)'); }); + test('HRV-02: jitter-only windows never supply the exempted headline', () { + // 20 loud breathing windows carry the pooled line; 30 quiet windows are + // pure jitter. Pooled, the night clears the exemption, but the median + // (and most of the mean) would be the jitter windows. + final rnd = math.Random(11); + final rr = [], ts = []; + var t = 0.0; + for (var w = 0; w < 50; w++) { + final rsa = w % 5 < 2; + for (var i = 0; i < 225; i++) { + final v = rsa + ? 1333 + 60 * math.sin(2 * math.pi * i / 2.5) + + (rnd.nextDouble() - 0.5) * 10 + : 1333 + (rnd.nextDouble() - 0.5) * 20; + t += v; + rr.add(v); + ts.add(t); + } + } + final n = nocturnalRmssd(rr, ts); + expect(n.value, greaterThan(70), reason: 'breathing windows only'); + final ss = sleepSessionWindowedRmssd(rr, ts, + startSec: 1, endSec: (t / 1000).floor() + 1); + expect(ss.value, greaterThan(70)); + }); + test('HRV-02: long jitter stays refused through the spectral check', () { final rnd = math.Random(5); final white = [