Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion lib/src/onehz/clinical/hrv_freq.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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);
Expand Down
71 changes: 71 additions & 0 deletions test/onehz/clinical_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <double>[];
final times = <double>[];
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 = <double>[];
final timesControl = <double>[];
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 = <double>[];
final times = <double>[];
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
Expand Down
Loading