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..7b5b7dc 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', ); } @@ -206,26 +246,40 @@ 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; 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; @@ -246,25 +300,51 @@ 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); 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 == 0 + ? '' + : '$atCeiling of $total sub-windows peaked at/above their ' + 'resolvable ceiling (${round6(ceilingLo)}' + '${ceilingHi > ceilingLo ? '–${round6(ceilingHi)}' : ''} ' + '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 +383,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..3151933 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,224 @@ 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) + ]); + // 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); + 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: 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); + ({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);