diff --git a/lib/src/onehz/clinical/hrv_freq.dart b/lib/src/onehz/clinical/hrv_freq.dart index 70dcb6e..d759248 100644 --- a/lib/src/onehz/clinical/hrv_freq.dart +++ b/lib/src/onehz/clinical/hrv_freq.dart @@ -231,7 +231,20 @@ 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. 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 af03c6e..aaa5d0e 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -114,6 +114,77 @@ 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( + '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