From 0b2c182d86625a19160b6b803ba1816f6dd90c9d Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:05:23 +0530 Subject: [PATCH] fix: relativeOdi odi/hr and burden% denominators use monitored time, not raw span same bug shape as the IS fix (#67). a contact-loss dropout (arm tucked, band shifted) turns part of the ratio-of-ratios NaN, but odiPerHour and burdenPct still divided by the full wall-clock span, diluting the rate instead of reporting it over the time that actually had usable samples. now scales the denominator by trustedCoverage, same idea cvhr_apnea.dart already uses. --- lib/src/onehz/respiration/relative_odi.dart | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/lib/src/onehz/respiration/relative_odi.dart b/lib/src/onehz/respiration/relative_odi.dart index 1207bfd..25d3b6a 100644 --- a/lib/src/onehz/respiration/relative_odi.dart +++ b/lib/src/onehz/respiration/relative_odi.dart @@ -200,7 +200,17 @@ Metric relativeOdi( } } - final odiPerHour = analyzedHours > 0 ? dipCount / analyzedHours : 0.0; + // Denominator must be time that actually produced a usable ratio-of-ratios + // sample, not the raw wall-clock span: a contact-loss dropout (arm tucked, + // band shifted) turns part of relR NaN via the dcRed/dcIr/rIr guards above, + // and charging that gap to analyzedHours dilutes odiPerHour/burdenPct + // downward instead of correctly reporting the rate over the monitored time + // (same fix as cvhr_apnea.dart's analyzedHours). + final nanCount = relR.where((v) => v.isNaN).length; + final trustedCoverage = n > 0 ? (n - nanCount) / n : 0.0; + final validSpanSec = spanSec * trustedCoverage; + final validHours = validSpanSec / 3600.0; + final odiPerHour = validHours > 0 ? dipCount / validHours : 0.0; // Severity buckets by RELATIVE drop magnitude (% rise in R vs baseline). var mild = 0, moderate = 0, severe = 0; for (final m in dipMags) { @@ -212,20 +222,19 @@ Metric relativeOdi( mild++; } } - final nanCount = relR.where((v) => v.isNaN).length; final conf = (0.5 * validFraction).clamp(0.1, 0.5); return Metric( value: RelativeOdiResult( meanRelR: meanRelR, dipCount: dipCount, odiPerHour: odiPerHour, - analyzedHours: analyzedHours, + analyzedHours: validHours, 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, + burdenPct: validSpanSec > 0 ? 100.0 * totalDipSec / validSpanSec : 0.0, signalCoverage: validFraction.clamp(0.0, 1.0), - trustedCoverage: n > 0 ? (n - nanCount) / n : 0.0, + trustedCoverage: trustedCoverage, rejectCounts: {'low_signal': nanCount}, severityCounts: {'mild': mild, 'moderate': moderate, 'severe': severe}, ),