diff --git a/lib/src/onehz/clinical/stress_si.dart b/lib/src/onehz/clinical/stress_si.dart index 6d80394..a0fabc1 100644 --- a/lib/src/onehz/clinical/stress_si.dart +++ b/lib/src/onehz/clinical/stress_si.dart @@ -52,10 +52,30 @@ class StressIndex { /// QUANTIZATION, not physiology, and 1/MxDMn then explodes (300 beats /// alternating 1000/1001 ms → SI ≈ 48 780, reported as 'high'). Windows below /// the guard are dropped; if no window survives, the metric is ABSENT. -Metric baevskyStressIndex(List nnMs, - {double minRangeMs = 20.0}) { +/// +/// [nnTimesMs] (optional, cumulative beat times) lets a caller pass one +/// unsegmented block (e.g. a whole sleep window) even when it contains a +/// charging/off-wrist hole: beats more than [maxGapSec] apart are treated as +/// two recordings, exactly like `cvhrApneaScreen`, so a sliding window never +/// straddles the gap and reads the discontinuity as within-segment MxDMn. +/// Without [nnTimesMs] the whole array is one implicit segment (unchanged +/// behavior). +Metric baevskyStressIndex( + List nnMs, { + List? nnTimesMs, + double minRangeMs = 20.0, + double maxGapSec = 30, +}) { const inputs = ['rr_cleaned']; - final nn = nnMs.where((v) => v >= 300 && v <= 2000).toList(); + final useTimes = nnTimesMs != null && nnTimesMs.length == nnMs.length; + final nn = []; + final nnT = []; + for (var i = 0; i < nnMs.length; i++) { + final v = nnMs[i]; + if (v < 300 || v > 2000) continue; + nn.add(v); + if (useTimes) nnT.add(nnTimesMs[i]); + } if (nn.length < 30) { return const Metric.absent( tier: Tier.estimate, @@ -64,21 +84,37 @@ Metric baevskyStressIndex(List nnMs, ); } + // Segment at gaps (same rule as cvhrApneaScreen) so a charging/off-wrist + // hole spliced into one `nn` array never lets a window's MxDMn pick up the + // jump between pre-gap and post-gap RR levels as if it were within-segment + // variability. No times supplied → one implicit segment, unchanged output. + final segStart = [0]; + if (useTimes) { + for (var i = 1; i < nnT.length; i++) { + if ((nnT[i] - nnT[i - 1]) / 1000.0 > maxGapSec) segStart.add(i); + } + } + // Window ~5 min of beats (256), 50% overlap; compute SI per window. const win = 256, step = 128; final sis = []; final modes = [], amos = [], ranges = []; - for (var start = 0; start + 30 <= nn.length; start += step) { - final seg = nn.sublist(start, math.min(start + win, nn.length)); - if (seg.length < 30) break; - final r = _siOfSegment(seg, minRangeMs); - if (r != null) { - sis.add(r[0]); - modes.add(r[1]); - amos.add(r[2]); - ranges.add(r[3]); + for (var s = 0; s < segStart.length; s++) { + final lo = segStart[s]; + final hi = (s + 1 < segStart.length ? segStart[s + 1] : nn.length); + final seg = nn.sublist(lo, hi); + for (var start = 0; start + 30 <= seg.length; start += step) { + final segWin = seg.sublist(start, math.min(start + win, seg.length)); + if (segWin.length < 30) break; + final r = _siOfSegment(segWin, minRangeMs); + if (r != null) { + sis.add(r[0]); + modes.add(r[1]); + amos.add(r[2]); + ranges.add(r[3]); + } + if (start + win >= seg.length) break; } - if (start + win >= nn.length) break; } if (sis.isEmpty) { return const Metric.absent( diff --git a/test/onehz/clinical_test.dart b/test/onehz/clinical_test.dart index eb5f57b..9747ce1 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -1181,6 +1181,49 @@ void main() { ]); expect(ok.present, isTrue); }); + + test( + 'nnTimesMs segments at gaps — a charging hole spliced into one array ' + 'no longer straddles the sliding window', () { + // Two clean, near-stationary 300-beat segments at DIFFERENT RR levels + // (mirrors two different autonomic states either side of a real + // off-wrist/charging hole), spliced into one nn array with a >30s + // gap in nnTimesMs between them. + List segment(double baseMs) => + [for (var i = 0; i < 300; i++) baseMs + 15.0 * math.sin(i.toDouble())]; + final seg1 = segment(800.0); + final seg2 = segment(1100.0); + final nn = [...seg1, ...seg2]; + + var t = 0.0; + final times1 = [for (final v in seg1) t += v]; + t += 120000; // 2-minute gap, far past maxGapSec + final times2 = [for (final v in seg2) t += v]; + final nnTimes = [...times1, ...times2]; + + final gapAware = baevskyStressIndex(nn, nnTimesMs: nnTimes); + final untimed = baevskyStressIndex(nn); + final seg1Only = baevskyStressIndex(seg1); + final seg2Only = baevskyStressIndex(seg2); + + expect(gapAware.present, isTrue); + expect(seg1Only.present, isTrue); + expect(seg2Only.present, isTrue); + + // Gap-aware SI is the median across each segment's OWN windows, so it + // must land within the range spanned by the two segments' own SI — + // never distorted by a straddling window's inflated MxDMn. + final lo = math.min(seg1Only.value!.si, seg2Only.value!.si); + final hi = math.max(seg1Only.value!.si, seg2Only.value!.si); + expect(gapAware.value!.si, greaterThanOrEqualTo(lo - 1e-6)); + expect(gapAware.value!.si, lessThanOrEqualTo(hi + 1e-6)); + + // The old no-times call path is untouched (regression guard) and, on + // this fixture, differs from the gap-aware result because it lets + // straddling windows see the cross-segment jump as MxDMn. + expect(untimed.present, isTrue); + expect(untimed.value!.si, isNot(closeTo(gapAware.value!.si, 1e-6))); + }); }); group('cardiac coherence (McCraty & Zayas 2014)', () {