From 45e90b7852bb7592c0f1dcacc812164054ed3f3d Mon Sep 17 00:00:00 2001 From: Alexander Wagner Date: Mon, 5 Oct 2026 18:39:11 +0200 Subject: [PATCH 1/2] fix(resp): the RSA rate no longer withholds a whole night on sensor gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rsaRespRate read the beat-rate Nyquist off span/(n−1). correctRr's beat times are re-anchored to the wall clock across dropouts and advance across rejected beats, so that ratio is the beat interval DIVIDED BY COVERAGE: the night was withheld whole as an "alias" whenever coverage fell below 48/HR (87 % at 55 bpm, 80 % at 60), with a note stating a heart rate the user never had. Edge's own two-device golden fixture shows it: a 60 bpm synthetic night with a 2 h hole reads "beat rate 45.08 bpm resolves only to 22.5 br/min … rate withheld", while its gap-free 30-min BRV bins all resolve 12.27 br/min. The per-sub-window ceiling had the same flaw, and the sub-window "time-complete" test measured first-to-last span, so two beats near the edges with a 3-minute hole between them passed it. The Nyquist of a beat-sampled tachogram is set by the beat rate, 0.5/NN (DeBoer, Karemaker & Strackee 1984); missing beats do not lower it. Both ceilings now use the median NN, each value one real interval between adjacent beats (rsaCeilingHz's argument is now named beatIntervalSec). Gaps belong to the completeness test, which now measures coverage: a sub-window needs Σ NN ≥ 80 % of its length, judged before the beat count so a stretch a dropout emptied is counted as gappy, with its own reason in the note. Lomb–Scargle already handles the uneven sampling (Press & Rybicki 1989). The alias guard stays: below ~48 bpm the night is still withheld, and the note now states the true sleeping heart rate and median beat interval. Because gappy nights now publish, a rate can rest on as few as three sub-windows (~12 min). Confidence therefore also scales with usable sub-windows, reaching full weight at 23: the 300 s sub-windows step by 150 s, so 23 of them are ≈ 1 h (12 would be ~33 min). A 12-minute night that read 0.9 now reads 0.2. RespEstimate reports usable_subwindows / subwindows so a reader can see how much of the night the rate rests on. Tests: a 75 %-covered 55 bpm night publishes within 1 br/min of its gap-free twin (three seeds); the alias note states the true heart rate; 12 contiguous minutes publish at ≤ 0.25 confidence (and ~33 minutes are still discounted) while 12 minutes scattered over an hour stay absent, named as gaps; a sub-window full of holes is counted as gappy, not as a low beat rate; a 25 s dropout every 150 s isolates the per-sub-window ceiling (fails if it reverts to span/(k−1)). Output changes: nights with dropouts or rejected beats now publish a respiratory rate; the alias note states the true heart rate; confidence scales with usable sub-windows; new usable_subwindows/subwindows keys. Requires kAlgoVersion bump (batched). --- ALGORITHMS.md | 2 +- lib/src/onehz/respiration/resp_rate.dart | 153 ++++++++++++++----- test/onehz/respiration_test.dart | 185 +++++++++++++++++++++++ 3 files changed, 301 insertions(+), 39 deletions(-) diff --git a/ALGORITHMS.md b/ALGORITHMS.md index f43d820..7b8b0bf 100644 --- a/ALGORITHMS.md +++ b/ALGORITHMS.md @@ -89,7 +89,7 @@ Grouped by family (subdirectory under `lib/src/onehz/`). File paths are relative ### `respiration/` | Function | File | Method | Citation | |---|---|---|---| -| `rsaRespRate` | `respiration/resp_rate.dart` | respiratory sinus arrhythmia — HF spectral peak of the RR series | — | +| `rsaRespRate` | `respiration/resp_rate.dart` | respiratory sinus arrhythmia — HF spectral peak of the RR series: Lomb–Scargle on native beat times in 300 s Welch sub-windows (≥ 80 % covered by clean beats), median across them with an agreement gate; beat-rate Nyquist from the median NN | Welch 1967; Lomb 1976; Scargle 1982; Press & Rybicki 1989; DeBoer, Karemaker & Strackee 1984 | | `riivRespRate` / `fuseRespRate` | `respiration/resp_rate.dart` | respiration-induced intensity variation, fused with the RSA estimate | Pimentel et al. (multi-grid RIIV fusion) | | `cvhrApnea` / `cvhrApneaScreen` | `respiration/cvhr_apnea.dart` | cyclic-variation-in-HR apnea screening | — | | `relativeOdi` | `respiration/relative_odi.dart` | ratio-of-ratios relative desaturation index — **never an absolute SpO2 claim** | — | diff --git a/lib/src/onehz/respiration/resp_rate.dart b/lib/src/onehz/respiration/resp_rate.dart index 3179e5f..65080bd 100644 --- a/lib/src/onehz/respiration/resp_rate.dart +++ b/lib/src/onehz/respiration/resp_rate.dart @@ -18,10 +18,12 @@ // * RIIV rides the 1 Hz green ADC, whose Nyquist caps any rate at 0.5 Hz = // 30 br/min. We refuse to report a peak at/above that ceiling (aliasing). // * RSA rides the TACHOGRAM, which is sampled once per BEAT, not once per -// second — so its ceiling is the beat-rate Nyquist (0.5/meanNN), ~37 br/min +// second — so its ceiling is the beat-rate Nyquist (0.5/NN), ~37 br/min // at HR 75 but only ~25 br/min at HR 50. [rsaRespRate] computes that -// ceiling per window, caps it at [respHiHz], and withholds the whole window -// when that ceiling falls below the classic HF band (the alias would be +// ceiling from the MEDIAN beat interval (whole input and per sub-window — +// never from a time span, which a sensor gap stretches), caps it at +// [respHiHz], and withholds the whole window when that ceiling falls below +// the classic HF band (the alias would be // indistinguishable from a genuine slower rate). A rate the window cannot // resolve is ABSENT, never a spurious in-band peak dressed up as a normal // number. @@ -57,13 +59,17 @@ const double rsaHiHz = 0.40; /// The highest respiratory frequency an RSA window can actually resolve (Hz). /// -/// The tachogram is sampled once per BEAT, so its Nyquist is `0.5 / meanNN`, -/// not 0.5 Hz. At HR 75 (NN 800 ms) that is 0.625 Hz; at HR 50 it is 0.417 Hz. -/// [respHiHz] caps it because nothing downstream of a 1 Hz record should claim -/// more than 30 br/min. -double rsaCeilingHz(double meanNnSec) { - if (!meanNnSec.isFinite || meanNnSec <= 0) return respHiHz; - final nyq = 0.5 / meanNnSec; +/// The tachogram is sampled once per BEAT, so its Nyquist is `0.5 / NN`, not +/// 0.5 Hz (DeBoer, Karemaker & Strackee 1984). At HR 75 (NN 800 ms) that is +/// 0.625 Hz; at HR 50 it is 0.417 Hz. [respHiHz] caps it because nothing +/// downstream of a 1 Hz record should claim more than 30 br/min. +/// +/// [beatIntervalSec] is the BEAT INTERVAL — the median of the NN values, each one a +/// real interval between adjacent beats. Not span/(n−1): missing beats do not +/// lower the Nyquist, they only stretch the span. +double rsaCeilingHz(double beatIntervalSec) { + if (!beatIntervalSec.isFinite || beatIntervalSec <= 0) return respHiHz; + final nyq = 0.5 / beatIntervalSec; return nyq < respHiHz ? nyq : respHiHz; } @@ -73,12 +79,20 @@ class RespEstimate { final double? peakHz; // the spectral peak (Hz) final double? power; // peak power (normalized) final String source; // 'rsa' | 'riiv' - const RespEstimate(this.brpm, this.peakHz, this.power, this.source); + + /// RSA only: the sub-windows the rate rests on, out of all it tried — how + /// much of the input was actually usable. + final int? usableSubwindows; + final int? subwindows; + const RespEstimate(this.brpm, this.peakHz, this.power, this.source, + {this.usableSubwindows, this.subwindows}); Map toJson() => { 'brpm': brpm == null ? null : round6(brpm!), if (peakHz != null) 'peak_hz': round6(peakHz!), if (power != null) 'power': round6(power!), 'source': source, + if (usableSubwindows != null) 'usable_subwindows': usableSubwindows, + if (subwindows != null) 'subwindows': subwindows, }; } @@ -99,6 +113,12 @@ class RespEstimate { /// inside it. const double rsaSegmentSec = 300; +/// Usable sub-windows at which [rsaRespRate]'s confidence stops being +/// discounted for thin evidence: ≈ 1 h of beats, since the 300 s sub-windows +/// step by 150 s — (3600 − 300) / 150 + 1 = 23. Counting them as 5-min bins +/// (12) would call ~33 minutes an hour. +const double _rsaFullWeightSubwindows = 23; + /// RSA respiratory rate from cleaned NN beat times (PRIMARY 24/7 source). /// /// [nnMs] cleaned NN intervals (ms), [nnTimesMs] their cumulative beat times @@ -133,6 +153,15 @@ const double rsaSegmentSec = 300; /// nights, real consensus 52–85%. Uniform peaks over the ~21 br/min searchable /// band would put ±2 br/min agreement at 19% by chance, which is what the /// surrogates show. 50% is ~2.5× chance and sits in the measured gap. +/// +/// GAPS. Both Nyquist ceilings (whole input and per sub-window) come from the +/// MEDIAN NN, the beat interval itself. Gaps belong to the completeness test: +/// a sub-window is used only when its kept beats COVER ≥ 80 % of it (Σ NN, not +/// first-to-last span). Lomb–Scargle handles the uneven sampling a dropout +/// leaves (Press & Rybicki 1989); the coverage test discards the stretches too +/// empty to spectrum. Confidence scales with how many sub-windows the rate +/// rests on, reaching full weight at [_rsaFullWeightSubwindows] (≈ 1 h), so a +/// rate read from a few minutes is not "confident". Metric rsaRespRate( List nnMs, List nnTimesMs, { @@ -170,8 +199,17 @@ Metric rsaRespRate( // SEARCH CEILING. The window's own beat-rate Nyquist, capped at [respHiHz]. // The search used to stop at [rsaHiHz] while the guard below tested against // [respHiHz], so the guard was unreachable and the 24 br/min cap was silent. - final meanNnSec = spanSec / (nnMs.length - 1); - final hiHz = rsaCeilingHz(meanNnSec); + // + // THE BEAT INTERVAL IS A PROPERTY OF THE BEATS, NOT OF THE CLOCK BETWEEN THE + // FIRST AND THE LAST ONE. `nnTimesMs` (correctRr) is re-anchored to the wall + // clock across sensor dropouts and advances across every rejected beat, so + // span/(n−1) is the beat interval DIVIDED BY COVERAGE: a 60 bpm night missing + // 25 % of its beats read as 45 bpm and the whole night was withheld as an + // alias. Each NN value is one real interval between adjacent beats; their + // median is the beat interval, gap-free by construction, and it is what the + // tachogram's Nyquist (0.5/NN; DeBoer et al. 1984) depends on. + final beatSec = median(nnMs)! / 1000.0; + final hiHz = rsaCeilingHz(beatSec); // The ceiling must at least cover the classic HF band. Below that, a rate // inside the band the literature defines would fold back down into the band // as an alias and be indistinguishable from a genuine slower one — measured: @@ -181,9 +219,11 @@ Metric rsaRespRate( return Metric.absent( tier: Tier.high, inputs_used: inputs, - note: 'beat rate ${round6(60 / meanNnSec)} bpm resolves only to ' - '${round6(hiHz * 60)} br/min — below the HF band, any peak could be ' - 'an alias; rate withheld', + note: 'sleeping heart rate ${round6(60 / beatSec)} bpm (median beat ' + 'interval ${round6(beatSec)} s) can resolve breathing from beat ' + 'timing only up to ${round6(hiHz * 60)} br/min, below the top of the ' + 'normal 9–24 br/min band, so any peak could be an alias; rate ' + 'withheld', ); } @@ -208,24 +248,35 @@ Metric rsaRespRate( var atCeiling = 0; var belowBand = 0; var thin = 0; + var gappy = 0; for (var s = tSec.first; s + segSec <= tSec.last + 1e-9; s += segSec / 2) { final lo = _lowerBound(tSec, s); final hi = _lowerBound(tSec, s + segSec); final k = hi - lo; - // A sub-window has to be BOTH beat-dense and time-complete: a dropout in + // A sub-window has to be BOTH time-complete and beat-dense: a dropout in // the middle leaves few beats spanning the full 5 minutes, and its // periodogram is a window function, not a spectrum. - if (k < 30 || tSec[hi - 1] - tSec[lo] < segSec * 0.8) { + final segT = tSec.sublist(lo, hi); + final segNn = nnMs.sublist(lo, hi); + // TIME-COMPLETE = the kept beats COVER ≥ 80 % of the sub-window. Σ NN, not + // first-to-last span: two beats near the edges with a 3-minute hole between + // them passed the span test and handed Lomb–Scargle a window function. + // Judged first, so a sub-window a sensor gap emptied is named a gap. + final coveredSec = segNn.fold(0, (a, b) => a + b) / 1000.0; + if (coveredSec < segSec * 0.8) { + gappy++; + continue; + } + if (k < 30) { thin++; continue; } - final segT = tSec.sublist(lo, hi); - final segNn = nnMs.sublist(lo, hi); - final segSpan = segT.last - segT.first; + final segSpan = segT.last - segT.first; // still sets the grid resolution // Per sub-window Nyquist: heart rate moves through the night, so the // resolvable ceiling does too. A sub-window whose beat rate cannot cover - // the HF band is dropped for the SAME alias reason as the whole window. - final segHi = rsaCeilingHz(segSpan / (k - 1)); + // the HF band is dropped for the SAME alias reason as the whole window — + // from its median beat interval, gap-free, like the whole-input check. + final segHi = rsaCeilingHz(median(segNn)! / 1000.0); if (segHi < rsaHiHz) { belowBand++; continue; @@ -252,19 +303,39 @@ Metric rsaRespRate( peakHz.add(pk); peakPwr.add(_powerAt(ls, pk)); } + final dropped = atCeiling + belowBand + thin + gappy; + final total = peaks.length + dropped; if (peaks.length < 3) { - final dropped = atCeiling + belowBand + thin; - final why = dropped == 0 - ? 'only ${peaks.length} usable sub-windows' - : (belowBand >= atCeiling && belowBand >= thin - ? '$belowBand of ${peaks.length + dropped} sub-windows had a beat ' - 'rate too low to cover the HF band (any peak could be an alias)' - : (atCeiling >= thin - ? '$atCeiling of ${peaks.length + dropped} sub-windows peaked ' - 'at/above the resolvable ceiling ' - '(${round6(hiHz * 60)} br/min)' - : '$thin of ${peaks.length + dropped} sub-windows were too ' - 'sparse or gappy to spectrum')); + // The largest counter is the reason; ties go to the earlier entry. + final reasons = <(int, String)>[ + ( + belowBand, + '$belowBand of $total sub-windows had a beat rate too low to cover ' + 'the HF band (any peak could be an alias)' + ), + ( + atCeiling, + '$atCeiling of $total sub-windows peaked at/above the resolvable ' + 'ceiling (${round6(hiHz * 60)} br/min)' + ), + ( + gappy, + '$gappy of $total ${round6(segSec)}s sub-windows had less than 80 % ' + 'of their time covered by clean beats (sensor gaps or rejected ' + 'beats)' + ), + ( + thin, + '$thin of $total sub-windows had fewer than 30 beats or no spectral ' + 'peak' + ), + ]; + var top = reasons.first; + for (final r in reasons.skip(1)) { + if (r.$1 > top.$1) top = r; + } + final why = + dropped == 0 ? 'only ${peaks.length} usable sub-windows' : top.$2; return Metric.absent( tier: Tier.high, inputs_used: inputs, @@ -303,10 +374,16 @@ Metric rsaRespRate( final bestPeakHz = peakHz[best]; final bestPower = peakPwr[best]; // Confidence: how much of the window agrees with itself, discounted by - // artifacts. Cap below 1 (PRV ceiling). - final conf = ((1 - artifactFraction) * consensus).clamp(0.2, 0.9); + // artifacts and by how few sub-windows it rests on. Cap below 1 (PRV + // ceiling). + final conf = ((1 - artifactFraction) * + consensus * + (peaks.length / _rsaFullWeightSubwindows).clamp(0.0, 1.0)) + .clamp(0.2, 0.9); return Metric( - value: RespEstimate(brpm, bestPeakHz, bestPower, 'rsa'), + value: RespEstimate(brpm, bestPeakHz, bestPower, 'rsa', + usableSubwindows: peaks.length, + subwindows: total), confidence: conf, tier: Tier.high, inputs_used: inputs, diff --git a/test/onehz/respiration_test.dart b/test/onehz/respiration_test.dart index 43821ab..4258d3b 100644 --- a/test/onehz/respiration_test.dart +++ b/test/onehz/respiration_test.dart @@ -62,6 +62,37 @@ import 'package:openstrap_protocol/openstrap_protocol.dart'; return (adc: adc, ts: ts); } +/// [hours] of RSA at [hrBpm], breathing drifting [resp0Brpm] → [resp1Brpm] on +/// a random-walk baseline. Beats inside [dropouts] (hours) happen but are NOT +/// recorded — the heart kept beating, the sensor did not see it. +({List rr, List t}) gappyRsaNight( + int seed, { + double hrBpm = 55, + double hours = 7, + double resp0Brpm = 16, + double resp1Brpm = 14, + List<(double, double)> dropouts = const [], +}) { + final rnd = math.Random(seed); + final rr = [], t = []; + final base = 60000.0 / hrBpm, totalSec = hours * 3600; + final f0 = resp0Brpm / 60, f1 = resp1Brpm / 60; + var tMs = 0.0, walk = 0.0; + while (tMs < totalSec * 1000) { + final sec = tMs / 1000; + final phase = + 2 * math.pi * (f0 * sec + (f1 - f0) * sec * sec / (2 * totalSec)); + walk = (walk + (rnd.nextDouble() - 0.5) * 12) * 0.98; + final v = base + walk + 40 * math.sin(phase); + tMs += v; + final h = sec / 3600; + if (dropouts.any((d) => h >= d.$1 && h < d.$2)) continue; + rr.add(v); + t.add(tMs); + } + return (rr: rr, t: t); +} + void main() { _resp01Tests(); // ------------------------------------------------------------------------- @@ -218,6 +249,160 @@ void main() { reason: '${full.value!.brpm} vs ${trimmed.value!.brpm}'); }); + // ----------------------------------------------------------------------- + // GAPS. The beat-rate Nyquist used to be read off span/(n−1). correctRr's + // beat times are re-anchored across dropouts and advance across rejected + // beats, so that is the beat interval DIVIDED BY COVERAGE: below 48/HR + // coverage (87 % at 55 bpm, 80 % at 60) a perfectly resolvable night was + // withheld whole as an "alias" at a heart rate it never had. + // ----------------------------------------------------------------------- + test('RSA: a 75 %-covered night at 55 bpm is NOT withheld as an alias', () { + for (final seed in [11, 12, 13]) { + final full = gappyRsaNight(seed); + final gap = gappyRsaNight(seed, dropouts: [(1.0, 1.85), (4.0, 4.9)]); + final cf = correctRr(full.rr, rrTsMs: full.t); + final cg = correctRr(gap.rr, rrTsMs: gap.t); + // The fixture really exercises the old pre-check: span/(n−1) > 1.25 s. + final spanSec = (cg.nnTimesMs.last - cg.nnTimesMs.first) / 1000; + expect(spanSec / (cg.nn.length - 1), greaterThan(1.25)); + final mf = rsaRespRate(cf.nn, cf.nnTimesMs, + artifactFraction: 1 - cf.cleanFraction); + final mg = rsaRespRate(cg.nn, cg.nnTimesMs, + artifactFraction: 1 - cg.cleanFraction); + expect(mf.present, isTrue, reason: mf.note); + expect(mg.present, isTrue, reason: 'seed $seed: ${mg.note}'); + expect(mg.value!.brpm!, closeTo(mf.value!.brpm!, 1.0)); + expect(mg.value!.brpm!, closeTo(15.0, 0.8)); // time-median of 16→14 + expect(mg.value!.usableSubwindows!, + lessThan(mg.value!.subwindows!), + reason: 'the holes cost sub-windows, and the value says so'); + } + }); + + test('RSA: the withheld note states the TRUE heart rate', () { + final s = gappyRsaNight(5, + hrBpm: 43, + hours: 2, + resp0Brpm: 20, + resp1Brpm: 20, + dropouts: [(0.5, 0.9)]); + final c = correctRr(s.rr, rrTsMs: s.t); + final m = + rsaRespRate(c.nn, c.nnTimesMs, artifactFraction: 1 - c.cleanFraction); + expect(m.present, isFalse, reason: 'got ${m.value?.brpm}'); + expect(m.note, contains('alias')); + final hr = RegExp(r'heart rate ([0-9.]+) bpm').firstMatch(m.note!); + expect(hr, isNotNull, reason: m.note); + expect(double.parse(hr!.group(1)!), closeTo(43, 1.0), + reason: 'the gap-blind ratio printed ~33 bpm'); + }); + + test('RSA: 12 contiguous minutes publish only at low confidence (was 0.9)', + () { + // Three full 300 s sub-windows agree, so a rate is honestly resolvable; + // what it is not is a confident NIGHT. Measured: 15.0 br/min at 0.9 + // before confidence scaled with usable sub-windows, 0.25 (3/12) now. + final s = gappyRsaNight(7, dropouts: [(0.0, 3.4), (3.6, 7.0)]); // 12 min + final c = correctRr(s.rr, rrTsMs: s.t); + final m = + rsaRespRate(c.nn, c.nnTimesMs, artifactFraction: 1 - c.cleanFraction); + expect(m.present, isTrue, reason: m.note); + expect(m.value!.brpm!, closeTo(15.0, 0.8)); + expect(m.value!.usableSubwindows, 3); + expect(m.confidence, lessThanOrEqualTo(0.25)); + }); + + test('RSA: 12 minutes scattered over an hour stay absent, and say why', () { + // Four 3-minute bursts, one every 15 minutes: no 300 s sub-window holds + // more than 60 % of its time in beats, most hold none. Unlike the next + // test (beats everywhere, holes everywhere) this is a night with too + // little signal in total, not a gappy one. + final s = gappyRsaNight(7, hours: 1, dropouts: [ + for (var k = 0; k < 4; k++) (k * 0.25 + 0.05, k * 0.25 + 0.25) + ]); + final c = correctRr(s.rr, rrTsMs: s.t); + final m = + rsaRespRate(c.nn, c.nnTimesMs, artifactFraction: 1 - c.cleanFraction); + expect(m.present, isFalse, reason: 'got ${m.value?.brpm}'); + expect(m.confidence, 0); + expect(m.note, contains('sub-windows')); + // The empty stretches are gaps, and the note says so. + expect(m.note, contains('covered by clean beats')); + expect(m.note, isNot(contains('alias'))); + }); + + test( + 'RSA: a sub-window full of holes is counted as gappy, not as a low ' + 'beat rate', () { + // 2 h at 55 bpm with a 100 s hole every 5 minutes: every 300 s + // sub-window is ~67 % covered. The old code withheld it whole as an + // alias at an apparent ~37 bpm. + final s = gappyRsaNight(9, hours: 2, dropouts: [ + for (var k = 0; k < 24; k++) + ((k * 300 + 100) / 3600.0, (k * 300 + 200) / 3600.0) + ]); + final c = correctRr(s.rr, rrTsMs: s.t); + final m = + rsaRespRate(c.nn, c.nnTimesMs, artifactFraction: 1 - c.cleanFraction); + expect(m.present, isFalse, reason: 'got ${m.value?.brpm}'); + expect(m.note, contains('covered by clean beats')); + expect(m.note, isNot(contains('alias'))); + expect(m.note, isNot(contains('beat rate too low'))); + }); + + test('RSA: a sub-window\'s own ceiling comes from its median beat interval', + () { + // 55 bpm with a 25 s dropout every 150 s: every 300 s sub-window is + // ~83 % covered — complete enough — but its span/(k−1) reads ~1.31 s + // (~46 bpm), below the HF band. Only the median-NN ceiling keeps them. + final s = gappyRsaNight(3, hours: 2, dropouts: [ + for (var k = 0; k < 48; k++) + ((k * 150 + 60) / 3600.0, (k * 150 + 85) / 3600.0) + ]); + final c = correctRr(s.rr, rrTsMs: s.t); + final m = + rsaRespRate(c.nn, c.nnTimesMs, artifactFraction: 1 - c.cleanFraction); + expect(m.present, isTrue, reason: m.note); + expect(m.value!.brpm!, closeTo(15.0, 1.0)); + expect(m.value!.subwindows, 46); + expect(m.value!.usableSubwindows, 46, + reason: 'every sub-window is ≥ 80 % covered and resolvable'); + }); + + test('RSA: confidence scales with usable sub-windows', () { + final s = rsaNight(11); + final c = correctRr(s.rr, rrTsMs: s.t); + ({List nn, List t}) firstSec(double sec) { + // through the first beat PAST [sec], so the span covers all of it + final k = c.nnTimesMs + .indexWhere((t) => t - c.nnTimesMs.first > sec * 1000) + + 1; + return (nn: c.nn.sublist(0, k), t: c.nnTimesMs.sublist(0, k)); + } + + final full = rsaRespRate(c.nn, c.nnTimesMs, artifactFraction: 0); + expect(full.confidence, greaterThan(0.85)); + expect(full.value!.usableSubwindows, full.value!.subwindows); + final hour = firstSec(3600); + final h = rsaRespRate(hour.nn, hour.t, artifactFraction: 0); + expect(h.value!.usableSubwindows, 23); + expect(h.confidence, greaterThanOrEqualTo(0.85)); + // 12 half-overlapping sub-windows are ~33 minutes, not an hour. + final half = firstSec(1950); + final m33 = rsaRespRate(half.nn, half.t, artifactFraction: 0); + expect(m33.value!.usableSubwindows, 12); + expect(m33.confidence, lessThan(0.6)); + final q = firstSec(900); + final m15 = rsaRespRate(q.nn, q.t, artifactFraction: 0); + expect(m15.present, isTrue, reason: m15.note); + expect(m15.value!.usableSubwindows, 5); + expect(m15.confidence, lessThanOrEqualTo(0.45), + reason: 'five 5-min sub-windows are not a confident night'); + final j = m15.value!.toJson(); + expect(j['usable_subwindows'], 5); + expect(j['subwindows'], 5); + }); + test('RSA: absent on too-few beats -> null + confidence 0', () { final m = rsaRespRate([800, 810, 790], [800, 1610, 2400], artifactFraction: 0); From 7f77edd65d66eb2c5904d4cf83f420ad65106877 Mon Sep 17 00:00:00 2001 From: Alexander Wagner Date: Thu, 8 Oct 2026 13:13:08 +0200 Subject: [PATCH 2/2] fix(resp): the at-ceiling note names the sub-windows' own ceilings When sub-windows were rejected for peaking at their resolvable ceiling, the absence note printed the whole input's ceiling. Each sub-window's ceiling follows its own heart rate, so across a night the number shown could match none of them. The note now reports the range of the ceilings the rejected sub-windows actually had. Tests: a night whose heart rate drops below the HF band for its last stretch now fails if the per-sub-window ceiling check is removed (the existing ceiling test still passed without it). The scattered-bursts fixture now asserts it really is four ~3-minute bursts, ~12 min of beats. --- lib/src/onehz/respiration/resp_rate.dart | 13 ++++- test/onehz/respiration_test.dart | 64 ++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/lib/src/onehz/respiration/resp_rate.dart b/lib/src/onehz/respiration/resp_rate.dart index 65080bd..7b5b7dc 100644 --- a/lib/src/onehz/respiration/resp_rate.dart +++ b/lib/src/onehz/respiration/resp_rate.dart @@ -246,6 +246,9 @@ Metric rsaRespRate( final peakHz = []; // the same peaks in Hz, index-aligned final peakPwr = []; // their spectral power, index-aligned var atCeiling = 0; + // The ceilings those sub-windows peaked at, br/min — their own, which + // move with heart rate; not the whole input's. + var ceilingLo = double.infinity, ceilingHi = 0.0; var belowBand = 0; var thin = 0; var gappy = 0; @@ -297,6 +300,8 @@ Metric rsaRespRate( // rate: reporting the edge would publish the ceiling as a measurement. if (pk >= segHi - (segHi - rsaLoHz) / (grid - 1)) { atCeiling++; + ceilingLo = math.min(ceilingLo, segHi * 60); + ceilingHi = math.max(ceilingHi, segHi * 60); continue; } peaks.add(pk * 60.0); @@ -315,8 +320,12 @@ Metric rsaRespRate( ), ( atCeiling, - '$atCeiling of $total sub-windows peaked at/above the resolvable ' - 'ceiling (${round6(hiHz * 60)} br/min)' + atCeiling == 0 + ? '' + : '$atCeiling of $total sub-windows peaked at/above their ' + 'resolvable ceiling (${round6(ceilingLo)}' + '${ceilingHi > ceilingLo ? '–${round6(ceilingHi)}' : ''} ' + 'br/min)' ), ( gappy, diff --git a/test/onehz/respiration_test.dart b/test/onehz/respiration_test.dart index 4258d3b..3151933 100644 --- a/test/onehz/respiration_test.dart +++ b/test/onehz/respiration_test.dart @@ -320,6 +320,17 @@ void main() { final s = gappyRsaNight(7, hours: 1, dropouts: [ for (var k = 0; k < 4; k++) (k * 0.25 + 0.05, k * 0.25 + 0.25) ]); + // The fixture is what it says: four bursts of ~3 min, ~12 min of beats. + final bursts = >[[s.t.first, s.t.first]]; + for (final x in s.t.skip(1)) { + if (x - bursts.last[1] > 60e3) bursts.add([x, x]); + bursts.last[1] = x; + } + expect(bursts, hasLength(4)); + for (final b in bursts) { + expect((b[1] - b[0]) / 1000, inInclusiveRange(170, 185)); + } + expect(s.rr.fold(0, (a, v) => a + v) / 60e3, closeTo(12, 0.5)); final c = correctRr(s.rr, rrTsMs: s.t); final m = rsaRespRate(c.nn, c.nnTimesMs, artifactFraction: 1 - c.cleanFraction); @@ -369,6 +380,59 @@ void main() { reason: 'every sub-window is ≥ 80 % covered and resolvable'); }); + test('RSA: sub-windows whose own heart rate is below the band are dropped', + () { + // 1.4 h at 56 bpm, then 0.6 h at 40 bpm (Nyquist 20 br/min, below the + // top of the HF band). The whole input's median clears the band; the + // slow stretch's sub-windows do not, and must not be used. + final a = gappyRsaNight(5, hrBpm: 56, hours: 1.4, resp0Brpm: 15, + resp1Brpm: 15); + final b = gappyRsaNight(6, hrBpm: 40, hours: 0.6, resp0Brpm: 15, + resp1Brpm: 15); + final rr = [...a.rr, ...b.rr]; + final t = [...a.t, for (final x in b.t) a.t.last + x]; + final c = correctRr(rr, rrTsMs: t); + final m = + rsaRespRate(c.nn, c.nnTimesMs, artifactFraction: 1 - c.cleanFraction); + expect(m.present, isTrue, reason: m.note); + expect(m.value!.brpm!, closeTo(15.0, 1.0)); + expect(m.value!.subwindows! - m.value!.usableSubwindows!, + greaterThanOrEqualTo(10), + reason: 'the 40 bpm stretch holds ~13 sub-windows'); + }); + + test('RSA: the at-ceiling note names the rejecting sub-windows\' own ' + 'ceilings', () { + // A beat-to-beat alternation sits exactly at each sub-window's own + // Nyquist, so every sub-window peaks at its ceiling: ~26 br/min in the + // 52 bpm hour, ~29 in the 58 bpm hour (a 15-min gap between them). The + // whole input's ceiling (~28) is neither. + final rnd = math.Random(4); + final nn = [], t = []; + var tMs = 0.0; + for (final hr in [52.0, 58.0]) { + if (tMs > 0) tMs += 900e3; + final end = tMs + 3600e3; + while (tMs < end) { + final v = 60000 / hr + + (nn.length.isEven ? 30 : -30) + + (rnd.nextDouble() - 0.5) * 0.5; + tMs += v; + nn.add(v); + t.add(tMs); + } + } + final m = rsaRespRate(nn, t, artifactFraction: 0); + expect(m.present, isFalse, reason: 'got ${m.value?.brpm}'); + expect(m.note, contains('at/above')); + final nums = RegExp(r'ceiling \(([0-9.]+)(?:–([0-9.]+))? br/min\)') + .firstMatch(m.note!); + expect(nums, isNotNull, reason: m.note); + // The low end comes from the 52 bpm hour, the high end from the 58. + expect(double.parse(nums!.group(1)!), inInclusiveRange(25.0, 27.0)); + expect(double.parse(nums.group(2) ?? '0'), inInclusiveRange(28.5, 30.0)); + }); + test('RSA: confidence scales with usable sub-windows', () { final s = rsaNight(11); final c = correctRr(s.rr, rrTsMs: s.t);