From 82bd686c61fd6c2affcc0fc73a3dd66d28e73611 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:21:20 +0530 Subject: [PATCH] fix sleep-session rmssd not catching timestamp gaps sleepSessionWindowedRmssd buckets rr values but drops the timestamps before handing the bucket to _cleanWindowRuns, so it can't tell a real sensor dropout (loose strap, momentary skin-contact loss) from two beats that are actually adjacent. nocturnalRmssd already does this gap check via nnTimesMs, this was the one producer that couldn't. threaded timestamps through to _cleanWindowRuns and added the same gap check, with a wider tolerance than nocturnalRmssd since the real caller quantizes beat times to whole seconds (RrTs.ts rounds), not sub-second. --- lib/src/onehz/clinical/hrv_time.dart | 35 +++++++++++++++++++++------- test/onehz/clinical_test.dart | 32 +++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/lib/src/onehz/clinical/hrv_time.dart b/lib/src/onehz/clinical/hrv_time.dart index 39e6263..1127832 100644 --- a/lib/src/onehz/clinical/hrv_time.dart +++ b/lib/src/onehz/clinical/hrv_time.dart @@ -393,11 +393,13 @@ Metric sleepSessionWindowedRmssd( } final buckets = >{}; + final bucketsTs = >{}; for (var i = 0; i < rrMs.length; i++) { final tsSec = (rrTsMs[i] / 1000.0).round(); if (tsSec < startSec || tsSec >= endSec) continue; final idx = ((tsSec - startSec) ~/ windowSec); (buckets[idx] ??= []).add(rrMs[i]); + (bucketsTs[idx] ??= []).add(rrTsMs[i]); } if (buckets.isEmpty) { @@ -413,7 +415,7 @@ Metric sleepSessionWindowedRmssd( final indices = buckets.keys.toList()..sort(); for (final idx in indices) { final diffRuns = [ - for (final r in _cleanWindowRuns(buckets[idx]!)) + for (final r in _cleanWindowRuns(buckets[idx]!, bucketsTs[idx]!)) if (r.length >= 2) [for (var i = 1; i < r.length; i++) r[i] - r[i - 1]] ]; var ssd = 0.0; @@ -488,27 +490,42 @@ List> _fiveMinSegments(List nn, List times) { /// Runs, not one compacted list: differencing straight down a compacted list /// manufactures exactly one difference per rejected beat, spanning it — the same /// defect `hrvTime` refuses at dropped runs and `irregularBeatScreen` refuses -/// with its keep-mask. This was the last producer in the file still doing it, -/// and it is the one feeding the nightly headline. MEASURED over the 13-night -/// audit corpus: it inflated the headline by 2–13 % on gen4 (57.2 → 52.3 ms at -/// worst) and by 51–102 % on MG (87.7 → 58.2, 82.9 → 40.9, 76.9 → 40.2 ms) — -/// i.e. most of the "gen5 reads 2× gen4" gap was this, not physiology. -List> _cleanWindowRuns(List rr) { +/// with its keep-mask. MEASURED over the 13-night audit corpus: it inflated +/// the headline by 2–13 % on gen4 (57.2 → 52.3 ms at worst) and by 51–102 % on +/// MG (87.7 → 58.2, 82.9 → 40.9, 76.9 → 40.2 ms) — i.e. most of the "gen5 +/// reads 2× gen4" gap was this, not physiology. +/// +/// [ts] are [rr]'s beat-end epoch times (ms), same length/order as [rr]. Also +/// breaks a run across a real sensor gap between two beats that BOTH survive +/// the range/median filter — the same seam check `nocturnalRmssd` applies via +/// `nnTimesMs`, needed here too since two beats either side of a dropout can +/// individually pass and land adjacent in the compacted survivor list. +/// +/// The real caller (`_sessionAvgHRV`) quantizes [ts] to whole seconds +/// (`RrTs.ts` is `(rrTsMs / 1000.0).round()`), so two independent roundings +/// can disagree with the true interval by up to ~1000 ms with no dropout at +/// all — the tolerance is `nn[i] + 1000.0`, not `nocturnalRmssd`'s `+ 0.5` +/// (which assumes sub-second beat times), so quantization alone never trips +/// it while an actual multi-second-or-longer dropout still does. +List> _cleanWindowRuns(List rr, List ts) { const radius = 2; const threshold = 0.20; // Range filter first, keeping each survivor's position in [rr] — BOTH filters // break a run, so neither one's compaction can manufacture a difference. final nn = []; final at = []; + final nnTs = []; for (var i = 0; i < rr.length; i++) { if (rr[i] >= 300 && rr[i] <= 2000) { nn.add(rr[i]); at.add(i); + nnTs.add(ts[i]); } } final runs = >[]; var run = []; var lastKept = -2; + var lastTs = 0.0; for (var i = 0; i < nn.length; i++) { var keep = true; if (nn.length > radius) { @@ -528,12 +545,14 @@ List> _cleanWindowRuns(List rr) { } continue; } - if (run.isNotEmpty && at[i] != lastKept + 1) { + if (run.isNotEmpty && + (at[i] != lastKept + 1 || nnTs[i] - lastTs > nn[i] + 1000.0)) { runs.add(run); run = []; } run.add(nn[i]); lastKept = at[i]; + lastTs = nnTs[i]; } if (run.isNotEmpty) runs.add(run); return runs; diff --git a/test/onehz/clinical_test.dart b/test/onehz/clinical_test.dart index eb5f57b..da093cb 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -815,6 +815,38 @@ void main() { expect(m.present, isFalse); expect(m.note, contains('rmssd_refused:acf1=')); }); + + test( + 'HRV-gap: no difference is manufactured across a real sensor dropout', + () { + // Two flat runs of beats, individually passing the range/median filter, + // sitting on EITHER SIDE of a ~2 min skin-contact gap inside the same + // 5-min bucket. Both survive `_cleanWindowRuns`'s value-only filters and + // land back-to-back in the compacted survivor list, so without the + // timestamp gap check their genuinely-adjacent-in-time-but-not seam + // would be differenced as if the beats were 1 s apart. + final rr = [900, 900, 900, 1000, 1000, 1000]; + final ts = [ + 1000, 2000, 3000, // flat run before the gap + 123000, 124000, 125000, // flat run after a ~2 min dropout + ]; + final m = sleepSessionWindowedRmssd(rr, ts, startSec: 1, endSec: 301); + expect(m.present, isTrue); + expect(m.value, closeTo(0.0, 1e-9), + reason: 'two flat runs, no cross-gap difference manufactured'); + }); + + test('HRV-gap: no gap means no change (control)', () { + // Same shape, but the second run starts right after the first (no real + // gap) — the fix must not shrink a window that has nothing to exclude. + final rr = [900, 900, 900, 1000, 1000, 1000]; + final ts = [1000, 2000, 3000, 4000, 5000, 6000]; + final m = sleepSessionWindowedRmssd(rr, ts, startSec: 1, endSec: 301); + expect(m.present, isTrue); + // One real seam difference (900 -> 1000 = 100 ms) survives; RMSSD = 100 + // over that single difference (the rest are 0). + expect(m.value, closeTo(math.sqrt(100.0 * 100.0 / 5.0), 1e-6)); + }); }); // The CALIBRATION of this scale (what a rest / active / hard / maximal day