From 1132e7bbeb3c549eab4e1af6502966a614140e73 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:07:30 +0530 Subject: [PATCH] fix: relativeOdi didn't segment at recording gaps like cvhr_apnea does same bug cvhr_apnea already fixed once: analyzedHours used the raw tsSec.last-first span, so a charging/off-wrist gap in the middle dilutes odiPerHour and burdenPct. the rolling AC/DC/baseline windows were also plain index windows with no gap awareness, so they could blend pre-gap and post-gap samples across a hole (perfusion jump scored as a fake dip at the boundary). ported the same fix: split at gaps > maxGapSec, window/score each segment on its own, sum only observed spans into analyzedHours, fix burdenPct's denominator the same way. no callers yet so no wiring/ kAlgoVersion change. --- lib/src/onehz/respiration/relative_odi.dart | 199 +++++++++++--------- test/onehz/respiration_test.dart | 122 ++++++++++++ 2 files changed, 233 insertions(+), 88 deletions(-) diff --git a/lib/src/onehz/respiration/relative_odi.dart b/lib/src/onehz/respiration/relative_odi.dart index 1207bfd..2064132 100644 --- a/lib/src/onehz/respiration/relative_odi.dart +++ b/lib/src/onehz/respiration/relative_odi.dart @@ -75,6 +75,11 @@ class RelativeOdiResult { /// [acWindowSec] rolling window for the AC (variation) / DC (mean) estimate. /// [baselineSec] rolling baseline for the dip test (Hayano-style 120 s). /// [dipPct] relative drop threshold for a desaturation event (default 3%). +/// [maxGapSec] splits the night at recording gaps (off-wrist/charging), same +/// as `cvhr_apnea.dart`: each gap-free segment is windowed and scored on its +/// own, so the AC/DC and baseline windows never blend samples across a hole, +/// and `analyzedHours` sums only the segments' own OBSERVED spans instead of +/// the raw first-to-last span (which lets a charging break dilute the index). Metric relativeOdi( List red, List ir, @@ -83,6 +88,7 @@ Metric relativeOdi( int acWindowSec = 8, int baselineSec = 120, double dipPct = 3.0, + double maxGapSec = 30, }) { const inputs = ['spo2_red_raw', 'spo2_ir_raw', 'ts']; final n = red.length; @@ -93,55 +99,118 @@ Metric relativeOdi( note: 'too few red/IR samples for a relative-ODI screen (need ≥60 s)', ); } - final spanSec = tsSec.last - tsSec.first; - final analyzedHours = spanSec / 3600.0; - if (analyzedHours <= 0) { - return const Metric.absent( - tier: Tier.relative, - inputs_used: inputs, - note: 'degenerate timestamps', - ); + + // SEGMENT AT GAPS. A stretch more than maxGapSec apart is a separate + // recording, not a straight line to interpolate across or window through. + final segStart = [0]; + for (var i = 1; i < tsSec.length; i++) { + if (tsSec[i] - tsSec[i - 1] > maxGapSec) segStart.add(i); } - // Rolling AC (stddev) / DC (mean) per channel over acWindowSec. - final acRed = _rollingStd(red, acWindowSec); - final dcRed = _rollingMean(red, acWindowSec); - final acIr = _rollingStd(ir, acWindowSec); - final dcIr = _rollingMean(ir, acWindowSec); + final relR = List.filled(n, double.nan); + var analyzedHours = 0.0; + var dipCount = 0; + final dipMags = []; + var totalDipSec = 0; // sum of qualifying excursion seconds (for burden) + var longestDipSec = 0; // longest single excursion + + for (var s = 0; s < segStart.length; s++) { + final lo = segStart[s]; + final hi = (s + 1 < segStart.length ? segStart[s + 1] : n) - 1; + final segRed = red.sublist(lo, hi + 1); + final segIr = ir.sublist(lo, hi + 1); + final segTs = tsSec.sublist(lo, hi + 1); + final segSpanSec = segTs.last - segTs.first; + if (segTs.length < 2 || segSpanSec <= 0) continue; // nothing observable + analyzedHours += segSpanSec / 3600.0; - // Ratio-of-ratios R = (AC_red/DC_red)/(AC_ir/DC_ir). Higher R ⇒ lower SpO₂ - // (well-established direction), but we keep it UNITLESS / relative. - final relR = []; - for (var i = 0; i < n; i++) { - // A zero DC on EITHER channel means every raw sample in this window was - // literally zero — a contact-loss/dropout signature, not a real reading. - // That must become NaN immediately, same as the IR-side guard below, and - // never a fabricated 0.0 that flows into meanRelR/baseR as a real ratio. - if (dcRed[i] == 0 || dcIr[i] == 0) { - relR.add(double.nan); - continue; + // Rolling AC (stddev) / DC (mean) per channel, scoped to this segment. + final acRed = _rollingStd(segRed, acWindowSec); + final dcRed = _rollingMean(segRed, acWindowSec); + final acIr = _rollingStd(segIr, acWindowSec); + final dcIr = _rollingMean(segIr, acWindowSec); + + // Ratio-of-ratios R = (AC_red/DC_red)/(AC_ir/DC_ir). Higher R ⇒ lower + // SpO₂ (well-established direction), but we keep it UNITLESS / relative. + final segRelR = []; + for (var i = 0; i < segRed.length; i++) { + // A zero DC on EITHER channel means every raw sample in this window was + // literally zero — a contact-loss/dropout signature, not a real + // reading. That must become NaN immediately, same as the IR-side guard + // below, and never a fabricated 0.0 flowing into meanRelR/baseR. + if (dcRed[i] == 0 || dcIr[i] == 0) { + segRelR.add(double.nan); + continue; + } + final rRed = acRed[i] / dcRed[i]; + final rIr = acIr[i] / dcIr[i]; + segRelR.add(rIr <= 0 ? double.nan : rRed / rIr); } - final rRed = acRed[i] / dcRed[i]; - final rIr = acIr[i] / dcIr[i]; - if (rIr <= 0) { - relR.add(double.nan); - } else { - relR.add(rRed / rIr); + for (var i = 0; i < segRelR.length; i++) { + relR[lo + i] = segRelR[i]; } + + // Rolling baseline of R over baselineSec, scoped to this segment; a + // desaturation event = R rises ≥ dipPct above it for ≥minDipSec. + final baseR = _rollingMean(segRelR, baselineSec, skipNan: true); + final segLen = segRelR.length; + var i = 0; + const minDipSec = 8; + const refractorySec = 10; // min separation between distinct events + var lastEnd = -refractorySec - 1; + while (i < segLen) { + final b = baseR[i]; + if (segRelR[i].isNaN || b <= 0) { + i++; + continue; + } + final risePct = 100.0 * (segRelR[i] - b) / b; + if (risePct < dipPct) { + i++; + continue; + } + final start = i; + var peakPct = 0.0; + while (i < segLen && + !segRelR[i].isNaN && + baseR[i] > 0 && + 100.0 * (segRelR[i] - baseR[i]) / baseR[i] >= dipPct) { + final p = 100.0 * (segRelR[i] - baseR[i]) / baseR[i]; + if (p > peakPct) peakPct = p; + i++; + } + final widthSec = i - start; + if (widthSec >= minDipSec) { + totalDipSec += widthSec; + if (widthSec > longestDipSec) longestDipSec = widthSec; + // Refractory gate: merge events that start within refractorySec of + // the previous one's end (one physiological desaturation, not two). + if (start - lastEnd <= refractorySec && dipMags.isNotEmpty) { + if (peakPct > dipMags.last) dipMags[dipMags.length - 1] = peakPct; + } else { + dipCount++; + dipMags.add(peakPct); + } + lastEnd = i; + } + } + } + + if (analyzedHours <= 0) { + return const Metric.absent( + tier: Tier.relative, + inputs_used: inputs, + note: 'no gap-free stretch long enough for a relative-ODI screen', + ); } - // Proxy oxygenation index: oxygenation falls as R rises, so use the IR DC- - // normalized perfusion ratio's inverse mapping. A 1 Hz-honest self- - // referential surrogate: oxy ∝ -R. We track dips as RISES in R relative to a - // rolling baseline (equiv. to drops in oxygenation), thresholded at dipPct. + // HONEST-BY-TYPE: if EVERY ratio sample is NaN (no channel passed the + // DC/IR guards) there is no self-referential trend to report — a + // fabricated 0.0 would read as a real (and impossibly stable) relative-R. final validR = [ for (final v in relR) if (!v.isNaN) v ]; - // HONEST-BY-TYPE: if EVERY ratio sample is NaN (no channel passed the DC/IR - // guards) there is no self-referential trend to report — a fabricated 0.0 - // would read as a real (and impossibly stable) relative-R. Return absent - // rather than a manufactured zero. if (validR.isEmpty) { return const Metric.absent( tier: Tier.relative, @@ -152,55 +221,7 @@ Metric relativeOdi( } final meanRelR = mean(validR)!; - // Rolling baseline of R over baselineSec; a desaturation event = R rises - // ≥ dipPct above its rolling baseline for a sustained (≥10 s) excursion. - final baseR = _rollingMean(relR, baselineSec, skipNan: true); - var dipCount = 0; - final dipMags = []; - var totalDipSec = 0; // sum of qualifying excursion seconds (for burden) - var longestDipSec = 0; // longest single excursion - var i = 0; - const minDipSec = 8; - const refractorySec = 10; // min separation between distinct events - var lastEnd = -refractorySec - 1; - while (i < n) { - final b = baseR[i]; - if (relR[i].isNaN || b <= 0) { - i++; - continue; - } - final risePct = 100.0 * (relR[i] - b) / b; - if (risePct < dipPct) { - i++; - continue; - } - final start = i; - var peakPct = 0.0; - while (i < n && - !relR[i].isNaN && - baseR[i] > 0 && - 100.0 * (relR[i] - baseR[i]) / baseR[i] >= dipPct) { - final p = 100.0 * (relR[i] - baseR[i]) / baseR[i]; - if (p > peakPct) peakPct = p; - i++; - } - final widthSec = i - start; - if (widthSec >= minDipSec) { - totalDipSec += widthSec; - if (widthSec > longestDipSec) longestDipSec = widthSec; - // Refractory gate: merge events that start within refractorySec of the - // previous one's end (one physiological desaturation, not two). - if (start - lastEnd <= refractorySec && dipMags.isNotEmpty) { - if (peakPct > dipMags.last) dipMags[dipMags.length - 1] = peakPct; - } else { - dipCount++; - dipMags.add(peakPct); - } - lastEnd = i; - } - } - - final odiPerHour = analyzedHours > 0 ? dipCount / analyzedHours : 0.0; + final odiPerHour = dipCount / analyzedHours; // Severity buckets by RELATIVE drop magnitude (% rise in R vs baseline). var mild = 0, moderate = 0, severe = 0; for (final m in dipMags) { @@ -223,7 +244,9 @@ Metric relativeOdi( meanDipPct: dipMags.isEmpty ? 0 : mean(dipMags)!, maxDipPct: dipMags.isEmpty ? 0 : dipMags.reduce((a, b) => a > b ? a : b), longestDipSec: longestDipSec, - burdenPct: spanSec > 0 ? 100.0 * totalDipSec / spanSec : 0.0, + // OBSERVED-time denominator (analyzedHours), not the raw span — same + // fix as cvhr_apnea.dart's burden accounting. + burdenPct: 100.0 * totalDipSec / (analyzedHours * 3600.0), signalCoverage: validFraction.clamp(0.0, 1.0), trustedCoverage: n > 0 ? (n - nanCount) / n : 0.0, rejectCounts: {'low_signal': nanCount}, diff --git a/test/onehz/respiration_test.dart b/test/onehz/respiration_test.dart index 0d138d1..43821ab 100644 --- a/test/onehz/respiration_test.dart +++ b/test/onehz/respiration_test.dart @@ -479,6 +479,128 @@ void main() { expect(v.trustedCoverage, greaterThan(0.7)); }); + test( + 'relativeOdi: a mid-night gap (charging break) does not dilute ' + 'odiPerHour/burdenPct — observed-hours denominator, not raw span', + () { + // Two clean 300 s segments with 5 dips each, separated by a 2 h gap + // (charging break) in ts. Same shape as cvhr_apnea's regression case. + const pulsHz = 0.3; + List buildSegment(int startSec, math.Random rnd, + List events, List red, List ir, + List ts) { + for (var s = 0; s < 300; s++) { + final t = (startSec + s).toDouble(); + final inEvent = events.any((e) => s >= e && s < e + 20); + final irPuls = + 50 * math.sin(2 * math.pi * pulsHz * s) + rnd.nextDouble() * 5; + ir.add(20000 + irPuls); + final redAmp = inEvent ? 400.0 : 60.0; + final redPuls = redAmp * math.sin(2 * math.pi * pulsHz * s) + + rnd.nextDouble() * 5; + red.add(18000 + redPuls); + ts.add(t); + } + return ts; + } + + final events = [80, 200]; + const gapSec = 2 * 3600; // 2 h charging break + + // No-gap reference: same two segments back-to-back in TIME too, so the + // per-segment dip pattern is identical and only the gap differs. + final redNoGap = [], irNoGap = [], tsNoGap = []; + buildSegment(0, math.Random(1), events, redNoGap, irNoGap, tsNoGap); + buildSegment(300, math.Random(2), events, redNoGap, irNoGap, tsNoGap); + final noGap = relativeOdi(redNoGap, irNoGap, tsNoGap, dipPct: 3.0); + expect(noGap.present, isTrue, reason: noGap.note); + + final redGap = [], irGap = [], tsGap = []; + buildSegment(0, math.Random(1), events, redGap, irGap, tsGap); + buildSegment(300 + gapSec, math.Random(2), events, redGap, irGap, tsGap); + final gapped = relativeOdi(redGap, irGap, tsGap, dipPct: 3.0); + expect(gapped.present, isTrue, reason: gapped.note); + + // Observed hours must equal the summed segment spans (≈ 2 * 300 s), + // NOT the raw first-to-last span (≈ 2h + 600s). + expect(gapped.value!.analyzedHours, + closeTo(noGap.value!.analyzedHours, 0.01), + reason: 'gap must not inflate the observed-hours denominator'); + + // Same dip pattern per segment ⇒ the same dip count and (within + // rounding) the same rate — a diluted denominator would read LOWER. + expect(gapped.value!.dipCount, noGap.value!.dipCount); + expect(gapped.value!.odiPerHour, + closeTo(noGap.value!.odiPerHour, noGap.value!.odiPerHour * 0.15)); + expect(gapped.value!.burdenPct, + closeTo(noGap.value!.burdenPct, noGap.value!.burdenPct * 0.15)); + }); + + test( + 'relativeOdi: a DC/perfusion-baseline jump across a gap does not ' + 'fabricate a spurious dip at the boundary', () { + // Two flat, dip-free segments at DIFFERENT DC baselines (a perfusion + // shift after re-donning the band), separated by a gap. If the rolling + // AC/DC or baseline window ever blended across the gap, the jump would + // be scored as a desaturation right at the boundary. + const pulsHz = 0.3; + final red = [], ir = [], ts = []; + // No noise here on purpose: this isolates the boundary-blending bug + // from ordinary sample noise, which can itself cross a tight 3% + // threshold and would make the assertion flaky for the wrong reason. + for (var s = 0; s < 300; s++) { + final irPuls = 50 * math.sin(2 * math.pi * pulsHz * s); + ir.add(20000 + irPuls); + final redPuls = 60 * math.sin(2 * math.pi * pulsHz * s); + red.add(18000 + redPuls); // low DC baseline + ts.add(s.toDouble()); + } + const gapSec = 3600; + for (var s = 0; s < 300; s++) { + final t = (300 + gapSec + s).toDouble(); + final irPuls = 50 * math.sin(2 * math.pi * pulsHz * s); + ir.add(20000 + irPuls); + final redPuls = 60 * math.sin(2 * math.pi * pulsHz * s); + red.add(30000 + redPuls); // DC baseline jumps ~67% higher + ts.add(t); + } + final m = relativeOdi(red, ir, ts, dipPct: 3.0); + expect(m.present, isTrue, reason: m.note); + expect(m.value!.dipCount, 0, + reason: 'a DC baseline jump across a gap must never be scored as ' + 'a desaturation at the segment boundary'); + }); + + test('relativeOdi: default maxGapSec is a no-op on gap-free input ' + '(regression guard)', () { + const totalSec = 600; + final red = []; + final ir = []; + final ts = []; + final rnd = math.Random(42); + final events = [80, 200, 320, 440, 540]; + const pulsHz = 0.3; + for (var s = 0; s < totalSec; s++) { + final inEvent = events.any((e) => s >= e && s < e + 20); + final irPuls = + 50 * math.sin(2 * math.pi * pulsHz * s) + rnd.nextDouble() * 5; + ir.add(20000 + irPuls); + final redAmp = inEvent ? 400.0 : 60.0; + final redPuls = + redAmp * math.sin(2 * math.pi * pulsHz * s) + rnd.nextDouble() * 5; + red.add(18000 + redPuls); + ts.add(s.toDouble()); + } + final m = relativeOdi(red, ir, ts, dipPct: 3.0); + expect(m.present, isTrue, reason: m.note); + expect(m.value!.dipCount, inInclusiveRange(3, 7)); + // span is (totalSec-1) seconds (ts runs 0..totalSec-1 inclusive) — + // same first-to-last-span semantics as before this fix, just now + // computed as a single observed segment instead of the raw ts delta. + expect(m.value!.analyzedHours, + closeTo((totalSec - 1) / 3600.0, 1e-9)); + }); + test('BRV: variable breathing rates -> CV>0 + Theil-Sen slope', () { final brpm = [14.0, 15.0, 13.0, 16.0, 12.0, 17.0, 11.0]; final m = breathingRateVariability(brpm);