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
62 changes: 38 additions & 24 deletions lib/src/onehz/clinical/irregular_rhythm.dart
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,33 @@ Metric<IrregularRhythm> 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 = <bool>[];
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 = <bool>[
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 = <bool>[
for (var i = 0; i < rrMs.length; i++)
if (keep[i]) successive[i]
];
// Poincaré descriptors — successive beats only (see [keep]).
final diffs = <double>[
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<IrregularRhythm>.absent(
tier: Tier.estimate,
inputs_used: inputs,
Expand All @@ -118,11 +136,6 @@ Metric<IrregularRhythm> irregularBeatScreen(
);
}

// Poincaré descriptors — successive beats only (see [keep]).
final diffs = <double>[
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) {
Expand Down Expand Up @@ -171,7 +184,6 @@ Metric<IrregularRhythm> 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++)
Expand Down Expand Up @@ -222,7 +234,8 @@ bool _sustainedAcrossWindows(
List<double> rrMs,
List<double> 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<bool> adjacent, {
required double sd1sd2Flag,
required double pnnThresholdMs,
Expand Down Expand Up @@ -253,15 +266,16 @@ bool _sustainedAcrossWindows(
var bucket = <double>[];
var bucketAdjacent = <bool>[];
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 = <double>[
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 = <double>[
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) {
Expand Down
64 changes: 55 additions & 9 deletions lib/src/onehz/sleep/cardio_stager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<double> rrMs, List<double> rrTsMs,
List<AccelSample> 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
Expand Down Expand Up @@ -1045,15 +1052,21 @@ double _windowSdnn(List<double> rrMs, List<double> 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<double> beats, List<double> 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<double> beats, List<double> tsSec, List<bool> seam}) _cleanBeatsInWindow(
List<double> rrMs,
List<double> rrTsMs,
List<AccelSample> accel,
int s,
int t, {
int halfWinMs = 150 * 1000,
}) {
const empty = (beats: <double>[], tsSec: <double>[]);
const empty = (beats: <double>[], tsSec: <double>[], seam: <bool>[]);
if (rrMs.isEmpty || rrTsMs.length != rrMs.length) return empty;
final mid = (s + t) ~/ 2;
if (mid >= accel.length) return empty;
Expand All @@ -1077,20 +1090,27 @@ double _windowSdnn(List<double> rrMs, List<double> rrTsMs,
}
final beats = <double>[];
final tsSec = <double>[];
final seam = <bool>[];
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
Expand All @@ -1102,7 +1122,7 @@ double _windowSdnn(List<double> rrMs, List<double> 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
Expand All @@ -1118,6 +1138,31 @@ List<double> 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<double> cleanBeatDiffsInWindowForTest(
List<double> rrMs,
List<double> rrTsMs,
List<AccelSample> 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<double> rrMs,
List<double> rrTsMs, List<AccelSample> 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.
Expand Down Expand Up @@ -1229,15 +1274,16 @@ void _websterRescore(List<SleepStage> 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;
Expand Down
85 changes: 84 additions & 1 deletion test/onehz/irregular_rhythm_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,11 @@ void main() {
final times = <double>[];
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);
Comment thread
abdulsaheel marked this conversation as resolved.
for (var i = 0; i < b.length; i++) {
times.add(windowStart + (i / b.length) * windowMs * 0.999);
times.add(windowStart + i * step);
}
windowStart += windowMs;
}
Expand Down Expand Up @@ -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 = <double>[];
final times = <double>[];
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<double> rr, List<double> 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 = <double>[];
final times = <double>[];
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 = <double>[];
final times = <double>[];
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);
Expand Down
Loading
Loading