From 68193c8f174aa9105c6026ac77a007dc9fd5d46c Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:02:30 +0530 Subject: [PATCH 1/2] hrv_freq: reject welch segments with a mid-window gap _welchBandPower only checked beat count per segment, not time span. a dropout in the middle of a ~67s HF window still clears 16 points if beats cluster at both ends, so lomb-scargle ran on it like a full segment and published bogus LF/HF/lf_hf/nu numbers at high confidence. resp_rate.dart's identical welch loop already guards this (span < segSec*0.8), ported the same check here. --- lib/src/onehz/clinical/hrv_freq.dart | 7 ++++- test/onehz/clinical_test.dart | 43 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/lib/src/onehz/clinical/hrv_freq.dart b/lib/src/onehz/clinical/hrv_freq.dart index 70dcb6e..86430f6 100644 --- a/lib/src/onehz/clinical/hrv_freq.dart +++ b/lib/src/onehz/clinical/hrv_freq.dart @@ -231,7 +231,12 @@ double? _welchBandPower( ts.add(tSec[i]); ys.add(y[i]); } - if (ts.length < minPointsPerSegment) continue; + // A segment has to be BOTH beat-dense and time-complete: a dropout in + // the middle leaves few beats spanning the full window, and its + // periodogram is a window function, not a spectrum. + if (ts.length < minPointsPerSegment || ts.last - ts.first < segSec * 0.8) { + continue; + } final ls = lombScargle(ts, ys, grid); if (ls == null) continue; final p = ls.bandPower(loHz, hiHz); diff --git a/test/onehz/clinical_test.dart b/test/onehz/clinical_test.dart index af03c6e..f369626 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -114,6 +114,49 @@ void main() { expect(m.value!.hf!, greaterThan(0)); }); + test( + 'a mid-segment recording gap is rejected, not averaged in as a ' + 'window function', () { + // HF's segment is 10 cycles of its 0.15 Hz floor = ~66.7 s. Beats + // clustered in the first ~27 s of that window, then a 45 s gap (an + // off-wrist moment, a BLE reconnect mid-drain), still total >=16 + // points — enough to pass a beat-COUNT-only guard — but the window is + // no longer time-complete, so its periodogram is a window function, + // not a spectrum. resp_rate.dart's identical Welch loop already + // guards this with `span < segSec * 0.8`; hrv_freq's copy must too. + final rr = []; + final times = []; + var t = 0.0; + for (var i = 0; i < 28; i++) { + final v = 1000 + 40 * math.sin(2 * math.pi * 0.25 * (t / 1000)); + rr.add(v); + t += v; + times.add(t); + } + t += 45000; // the gap + for (var i = 0; i < 4; i++) { + rr.add(1000); + t += 1000; + times.add(t); + } + final gapped = hrvFreq(rr, times, artifactFraction: 0.0); + expect(gapped.value?.hf, isNull, + reason: 'the only segment spanning the record is gap-corrupted'); + + // Same span, no gap: the segment is beat-dense AND time-complete. + final rrControl = []; + final timesControl = []; + t = 0.0; + while (t < 90000) { + final v = 1000 + 40 * math.sin(2 * math.pi * 0.25 * (t / 1000)); + rrControl.add(v); + t += v; + timesControl.add(t); + } + final control = hrvFreq(rrControl, timesControl, artifactFraction: 0.0); + expect(control.value!.hf, isNotNull); + }); + test('GATES HF when artifact fraction exceeds the threshold', () { // RE-PINNED 2026-08: 400 beats, not 64. Band powers are now Welch- // averaged over segments long enough to RESOLVE the band (10 cycles of From a47358ff86660145b201c032f95124ce21193e58 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:07:34 +0530 Subject: [PATCH 2/2] hrv_freq: also reject a single internal gap eating the window endpoint span (ts.last-ts.first < segSec*0.8) misses a dropout with beats surviving near both edges of the window - span looks fine, the middle is empty. guard the single largest inter-beat gap too (>20% of segSec), per coderabbit review on #72. --- lib/src/onehz/clinical/hrv_freq.dart | 10 +++++++++- test/onehz/clinical_test.dart | 28 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/src/onehz/clinical/hrv_freq.dart b/lib/src/onehz/clinical/hrv_freq.dart index 86430f6..d759248 100644 --- a/lib/src/onehz/clinical/hrv_freq.dart +++ b/lib/src/onehz/clinical/hrv_freq.dart @@ -233,10 +233,18 @@ double? _welchBandPower( } // A segment has to be BOTH beat-dense and time-complete: a dropout in // the middle leaves few beats spanning the full window, and its - // periodogram is a window function, not a spectrum. + // periodogram is a window function, not a spectrum. Endpoint span alone + // misses an internal dropout that still leaves beats near both edges, so + // also reject on the single largest gap between consecutive beats. if (ts.length < minPointsPerSegment || ts.last - ts.first < segSec * 0.8) { continue; } + var maxGap = 0.0; + for (var i = 1; i < ts.length; i++) { + final g = ts[i] - ts[i - 1]; + if (g > maxGap) maxGap = g; + } + if (maxGap > segSec * 0.2) continue; final ls = lombScargle(ts, ys, grid); if (ls == null) continue; final p = ls.bandPower(loHz, hiHz); diff --git a/test/onehz/clinical_test.dart b/test/onehz/clinical_test.dart index f369626..aaa5d0e 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -157,6 +157,34 @@ void main() { expect(control.value!.hf, isNotNull); }); + test( + 'an INTERNAL gap with beats on both sides is rejected, not just an ' + 'endpoint-span shortfall', () { + // Beats near both edges of the ~66.7 s HF window, with a 40 s hole in + // the middle, can still clear the endpoint-span check (first-to-last + // beat still covers most of the window) while the window itself is + // half-empty. Guard on the single largest inter-beat gap too. + final rr = []; + final times = []; + var t = 0.0; + while (t < 20000) { + final v = 1000 + 40 * math.sin(2 * math.pi * 0.25 * (t / 1000)); + rr.add(v); + t += v; + times.add(t); + } + t += 40000; // the internal gap + while (t < 66000) { + final v = 1000 + 40 * math.sin(2 * math.pi * 0.25 * (t / 1000)); + rr.add(v); + t += v; + times.add(t); + } + final gapped = hrvFreq(rr, times, artifactFraction: 0.0); + expect(gapped.value?.hf, isNull, + reason: 'endpoints span the window but the middle is empty'); + }); + test('GATES HF when artifact fraction exceeds the threshold', () { // RE-PINNED 2026-08: 400 beats, not 64. Band powers are now Welch- // averaged over segments long enough to RESOLVE the band (10 cycles of