diff --git a/lib/src/onehz/clinical/irregular_rhythm.dart b/lib/src/onehz/clinical/irregular_rhythm.dart index 5d91e75..2b0faf8 100644 --- a/lib/src/onehz/clinical/irregular_rhythm.dart +++ b/lib/src/onehz/clinical/irregular_rhythm.dart @@ -94,15 +94,33 @@ Metric irregularBeatScreen( // artifact beat the same way the aggregate diffs already do (see [keep] // note above) instead of just diffing consecutive elements of the // compacted array. - final nnAdjacent = []; - var prevKeptOrigIdx = -1; - for (var i = 0; i < rrMs.length; i++) { - if (keep[i]) { - nnAdjacent.add(prevKeptOrigIdx == i - 1); - prevKeptOrigIdx = i; - } - } - if (nn.length < minBeats) { + // + // Successive also means successive in TIME: callers pass `correctRr(...).nn`, + // which already dropped multi-beat artifact runs and re-anchored its clock + // across sensor dropouts, so two beats adjacent in the input can still sit + // either side of a hole. Same seam test as hrv_time.dart: a pair is + // contiguous iff the elapsed time between the two beat times is the interval. + final hasTimes = nnTimesMs != null && nnTimesMs.length == rrMs.length; + final successive = [ + for (var i = 0; i < rrMs.length; i++) + i > 0 && + keep[i] && + keep[i - 1] && + (!hasTimes || nnTimesMs[i] - nnTimesMs[i - 1] <= rrMs[i] + 0.5) + ]; + final nnAdjacent = [ + for (var i = 0; i < rrMs.length; i++) + if (keep[i]) successive[i] + ]; + // Poincaré descriptors — successive beats only (see [keep]). + final diffs = [ + for (var i = 1; i < rrMs.length; i++) + if (successive[i]) rrMs[i] - rrMs[i - 1] + ]; + // Gate on the contiguous pairs actually averaged, not the beat count: a + // series fragmented by holes can hold minBeats beats yet only a handful of + // successive pairs, and SD1/pNNx would then come from those few diffs. + if (diffs.length < minBeats - 1) { return const Metric.absent( tier: Tier.estimate, inputs_used: inputs, @@ -118,11 +136,6 @@ Metric irregularBeatScreen( ); } - // Poincaré descriptors — successive beats only (see [keep]). - final diffs = [ - for (var i = 1; i < rrMs.length; i++) - if (keep[i] && keep[i - 1]) rrMs[i] - rrMs[i - 1] - ]; final sdsd = stddev(diffs); final sdnn = stddev(nn); if (sdsd == null || sdnn == null) { @@ -171,7 +184,6 @@ Metric irregularBeatScreen( // [keep] mask), not the raw input — an artifact beat the aggregate // correctly excludes must not be allowed back in here to inflate one // window's own ratio/pNN into a spurious per-window flag. - final hasTimes = nnTimesMs != null && nnTimesMs.length == rrMs.length; final nnTimes = hasTimes ? [ for (var i = 0; i < rrMs.length; i++) @@ -222,7 +234,8 @@ bool _sustainedAcrossWindows( List rrMs, List timesMs, // Aligned to [rrMs]: whether each beat was truly adjacent (no dropped - // artifact beat in between) to the previous one in the ORIGINAL series. + // artifact beat in between, no time hole) to the previous one in the + // ORIGINAL series. List adjacent, { required double sd1sd2Flag, required double pnnThresholdMs, @@ -253,15 +266,16 @@ bool _sustainedAcrossWindows( var bucket = []; var bucketAdjacent = []; void flush() { - if (bucket.length >= minWindowBeats) { + // Mirror the aggregate's `keep[i] && keep[i-1]` guard: never diff + // across a beat that was dropped as an artifact in the original series, + // even though it's now a consecutive pair in this compacted bucket. + final diffs = [ + for (var i = 1; i < bucket.length; i++) + if (bucketAdjacent[i]) bucket[i] - bucket[i - 1] + ]; + // Valid on contiguous pairs, not beats (same reason as the aggregate). + if (diffs.length >= minWindowBeats - 1) { validWindows++; - // Mirror the aggregate's `keep[i] && keep[i-1]` guard: never diff - // across a beat that was dropped as an artifact in the original series, - // even though it's now a consecutive pair in this compacted bucket. - final diffs = [ - for (var i = 1; i < bucket.length; i++) - if (bucketAdjacent[i]) bucket[i] - bucket[i - 1] - ]; final sdsd = stddev(diffs); final sdnn = stddev(bucket); if (sdsd != null && sdnn != null) { diff --git a/lib/src/onehz/sleep/cardio_stager.dart b/lib/src/onehz/sleep/cardio_stager.dart index bf4ec34..6738c3a 100644 --- a/lib/src/onehz/sleep/cardio_stager.dart +++ b/lib/src/onehz/sleep/cardio_stager.dart @@ -1007,14 +1007,21 @@ CardioStagerResult _abstain(int epochSec) => CardioStagerResult( /// window centred on epoch [s,t). Returns NaN when too few clean beats. double _windowRmssd(List rrMs, List rrTsMs, List accel, int s, int t, int epochSec) { - final beats = _cleanBeatsInWindow(rrMs, rrTsMs, accel, s, t).beats; + final win = _cleanBeatsInWindow(rrMs, rrTsMs, accel, s, t); + final beats = win.beats; if (beats.length < 5) return double.nan; var ss = 0.0; + var pairs = 0; for (var i = 1; i < beats.length; i++) { + if (win.seam[i]) continue; final d = beats[i] - beats[i - 1]; ss += d * d; + pairs++; } - return math.sqrt(ss / (beats.length - 1)); + // Gate on the successive pairs actually averaged, not the beat count: a + // window fragmented by holes/rejected beats can hold 5+ beats yet only one + // contiguous pair. 4 pairs = what the 5-beat gate meant before seams. + return pairs >= 4 ? math.sqrt(ss / pairs) : double.nan; } /// SDNN (ms) of cleaned RR beats over the SAME ±2.5-min window as @@ -1045,7 +1052,13 @@ double _windowSdnn(List rrMs, List rrTsMs, /// [_windowRemFeatures] passes a DIFFERENT [halfWinMs] on purpose (±90 s, not /// ±2.5 min): its LF/HF grid and its `beats.length` abstain gate are specified /// against that shorter window. Do not hand it the default list. -({List beats, List tsSec}) _cleanBeatsInWindow( +/// +/// `seam[k]` is true when beat k is NOT the true successor of beat k-1: a beat +/// was rejected between them, or the RR stream has a hole there. Anything that +/// differences successive beats must skip seam pairs, or each rejected beat and +/// each dropout manufactures one big jump (same defect hrv_time.dart's runs +/// fix). Tolerance is `v + 1000` because [rrTsMs] is whole-second quantized. +({List beats, List tsSec, List seam}) _cleanBeatsInWindow( List rrMs, List rrTsMs, List accel, @@ -1053,7 +1066,7 @@ double _windowSdnn(List rrMs, List rrTsMs, int t, { int halfWinMs = 150 * 1000, }) { - const empty = (beats: [], tsSec: []); + const empty = (beats: [], tsSec: [], seam: []); if (rrMs.isEmpty || rrTsMs.length != rrMs.length) return empty; final mid = (s + t) ~/ 2; if (mid >= accel.length) return empty; @@ -1077,20 +1090,27 @@ double _windowSdnn(List rrMs, List rrTsMs, } final beats = []; final tsSec = []; + final seam = []; double? prev; + var broken = true; for (var i = a; i < rrMs.length; i++) { final ts = rrTsMs[i]; if (ts > hi) break; final v = rrMs[i]; + if (i > a && ts - rrTsMs[i - 1] > v + 1000) broken = true; if (v < _rrMin || v > _rrMax) { prev = null; + broken = true; continue; } if (prev != null && (v - prev).abs() > _rrMaxStep) { prev = v; + broken = true; continue; } beats.add(v); + seam.add(broken); + broken = false; // Rebase to the window start (lo), NOT absolute epoch ms. Lomb–Scargle is // time-shift invariant (the τ phase reference cancels any offset), so the // LF/HF output is unchanged — but this keeps beat times in [0, 180] s @@ -1102,7 +1122,7 @@ double _windowSdnn(List rrMs, List rrTsMs, tsSec.add((ts - lo) / 1000.0); prev = v; } - return (beats: beats, tsSec: tsSec); + return (beats: beats, tsSec: tsSec, seam: seam); } /// [_cleanBeatsInWindow]'s beats, exposed so the window-edge regression test @@ -1118,6 +1138,31 @@ List cleanBeatsInWindowForTest( }) => _cleanBeatsInWindow(rrMs, rrTsMs, accel, s, t, halfWinMs: halfWinMs).beats; +/// The successive differences (ms) [_windowRmssd] and R(k) actually take over +/// [_cleanBeatsInWindow]'s beats, seams skipped. Test hook only. +List cleanBeatDiffsInWindowForTest( + List rrMs, + List rrTsMs, + List accel, + int s, + int t, { + int halfWinMs = 150 * 1000, +}) { + final w = _cleanBeatsInWindow(rrMs, rrTsMs, accel, s, t, halfWinMs: halfWinMs); + return [ + for (var k = 1; k < w.beats.length; k++) + if (!w.seam[k]) w.beats[k] - w.beats[k - 1] + ]; +} + +/// [_windowRmssd] and the R(k) half of [_windowRemFeatures]. Test hook only. +({double rmssd, double? rk}) windowRmssdRkForTest(List rrMs, + List rrTsMs, List accel, int s, int t) => + ( + rmssd: _windowRmssd(rrMs, rrTsMs, accel, s, t, _epochSec), + rk: _windowRemFeatures(rrMs, rrTsMs, accel, s, t, _epochSec).rk, + ); + /// Webster sleep-continuity rescore: brief wake bouts flanked by enough sleep /// are re-labelled sleep (NREM). This is the published actigraphy step that /// prevents normal in-sleep repositioning from inflating WASO. @@ -1229,15 +1274,16 @@ void _websterRescore(List sm, int epochSec) { var rkSum = 0.0; var rkCnt = 0; double? prevIhr; - for (final v in beats) { - final ihr = 60000.0 / v; - if (prevIhr != null) { + for (var k = 0; k < beats.length; k++) { + final ihr = 60000.0 / beats[k]; + if (prevIhr != null && !win.seam[k]) { rkSum += (ihr - prevIhr).abs(); rkCnt++; } prevIhr = ihr; } - final rk = rkCnt > 0 ? rkSum / rkCnt : null; + // Same floor the 16-beat gate implied before seams were skipped: 15 pairs. + final rk = rkCnt >= 15 ? rkSum / rkCnt : null; // LF/HF via Lomb–Scargle on native beat times. double? lfhf; final spanSec = beatTsSec.last - beatTsSec.first; diff --git a/test/onehz/irregular_rhythm_test.dart b/test/onehz/irregular_rhythm_test.dart index 3ed57ff..cf2c38a 100644 --- a/test/onehz/irregular_rhythm_test.dart +++ b/test/onehz/irregular_rhythm_test.dart @@ -75,8 +75,11 @@ void main() { final times = []; var windowStart = 0.0; for (final b in blocks) { + // Never step further than the beat itself, or every organised pair + // reads as a time hole and its window has no contiguous diffs. + final step = math.min(b.reduce(math.min), windowMs * 0.999 / b.length); for (var i = 0; i < b.length; i++) { - times.add(windowStart + (i / b.length) * windowMs * 0.999); + times.add(windowStart + i * step); } windowStart += windowMs; } @@ -112,6 +115,86 @@ void main() { expect(m.value!.flag, isFalse); }); + test('beats either side of a time hole are not differenced', () { + // correctRr hands over a compacted series whose clock skips dropped runs + // and dropouts. Steady 1000 / 600 ms stretches split by 60 s holes: the + // only big jumps are across the holes, so SD1 and pNN70 must stay tiny. + final rr = []; + final times = []; + var t = 0.0; + for (var s = 0; s < 40; s++) { + final base = s.isEven ? 1000.0 : 600.0; + t += 60000; + for (var i = 0; i < 60; i++) { + final v = base + 5 * math.sin(i / 3); + t += v; + rr.add(v); + times.add(t); + } + } + final m = irregularBeatScreen(rr, nnTimesMs: times); + expect(m.present, isTrue); + expect(m.value!.pnnPct, 0); + expect(m.value!.sd1, lessThan(10)); + expect(m.value!.flag, isFalse); + }); + + // A 5-min slot of 42 in-range beats where only two pairs are contiguous + // (+75 / -75); every other beat sits behind a 3 s hole. 42 beats used to + // pass the 40-beat window gate with SD1/pNN70 built from those 2 diffs. + void fragmented(List rr, List times, double start) { + var t = start; + for (var i = 0; i < 42; i++) { + final v = i == 1 + ? 975.0 + : (i == 0 || i == 2 ? 900.0 : 840.0 + (i * 37) % 196); + t += i <= 2 ? v : v + 3000; + rr.add(v); + times.add(t); + } + } + + test('beats split by holes are not a window: gate counts contiguous ' + 'pairs, not beats', () { + final rr = []; + final times = []; + for (var w = 0; w < 15; w++) { + fragmented(rr, times, w * 300000.0); + } + final m = irregularBeatScreen(rr, nnTimesMs: times); + expect(m.present, isFalse); + }); + + test('fragmented windows do not vote toward a sustained flag', () { + final rnd = math.Random(3); + final rr = []; + final times = []; + var slot = 0; + void contiguous(double Function() next) { + var t = slot++ * 300000.0; + while (t < (slot * 300000.0) - 2000) { + final v = next(); + t += v; + rr.add(v); + times.add(t); + } + } + for (var w = 0; w < 3; w++) { + contiguous(() => 500.0 + rnd.nextInt(600)); + } + for (var w = 0; w < 4; w++) { + contiguous(() => 800.0 + rnd.nextInt(10)); + } + for (var w = 0; w < 6; w++) { + fragmented(rr, times, slot++ * 300000.0); + } + // 3 of 7 real windows irregular: not sustained. The 6 fragmented + // slots used to count as 6 more flagged windows (9 of 13). + final m = irregularBeatScreen(rr, nnTimesMs: times); + expect(m.present, isTrue); + expect(m.value!.flag, isFalse); + }); + test('mismatched nnTimesMs length falls back to the whole-span verdict ' 'instead of crashing', () { final rnd = math.Random(7); diff --git a/test/onehz/real_night_cardio_stager_test.dart b/test/onehz/real_night_cardio_stager_test.dart index 7ac34cf..b779d92 100644 --- a/test/onehz/real_night_cardio_stager_test.dart +++ b/test/onehz/real_night_cardio_stager_test.dart @@ -46,6 +46,7 @@ import 'package:openstrap_analytics/onehz.dart'; void main() { _windowEdgeTies(); + _windowSeams(); test('cardioStager on the real 2026-07 overnight capture matches Apple ' 'Watch ground truth within a wide band (regression: was wake=294min ' @@ -171,3 +172,59 @@ void _windowEdgeTies() { expect(got, [901, 902, 903, 904, 905, 906, 907]); }); } + +// A beat rejected by the step gate, or a hole in the RR stream, must end the +// run: the beats either side of it are not successive, so RMSSD and R(k) must +// never difference them. +void _windowSeams() { + final accel = [ + for (var i = 0; i < 300; i++) AccelSample(i * 1000.0, 0, 0, 1) + ]; + List clock(List rr, {int holeAt = -1}) { + final ts = []; + var t = 0.0; + for (var i = 0; i < rr.length; i++) { + t += rr[i] + (i == holeAt ? 20000 : 0); + ts.add((t / 1000).floorToDouble() * 1000); + } + return ts; + } + + test('step-rejected beat does not pair its neighbours', () { + final rr = [ + for (var i = 0; i < 40; i++) 800, + 1001, + for (var i = 0; i < 40; i++) 1200, + ]; + final d = cleanBeatDiffsInWindowForTest(rr, clock(rr), accel, 140, 170); + expect(d, isNotEmpty); + expect(d.every((x) => x.abs() <= 200), isTrue); + }); + + test('beats either side of an RR hole are not differenced', () { + final rr = [ + for (var i = 0; i < 40; i++) 800, + for (var i = 0; i < 40; i++) 950, + ]; + final d = cleanBeatDiffsInWindowForTest( + rr, clock(rr, holeAt: 40), accel, 140, 170); + expect(d, isNotEmpty); + expect(d.every((x) => x == 0), isTrue); + }); + + test('a window fragmented into one contiguous pair abstains', () { + // 20 clean beats, every gap a hole except one: plenty of beats, but only + // a single successive pair. RMSSD and R(k) must not publish from it. + final rr = [for (var i = 0; i < 20; i++) i == 1 ? 820 : 800]; + final ts = []; + var t = 140000.0; + for (var i = 0; i < rr.length; i++) { + t += i == 1 ? 1000 : 5000; + ts.add(t); + } + expect(cleanBeatDiffsInWindowForTest(rr, ts, accel, 140, 170), [20]); + final w = windowRmssdRkForTest(rr, ts, accel, 140, 170); + expect(w.rmssd.isNaN, isTrue); + expect(w.rk, isNull); + }); +}