diff --git a/lib/src/onehz/human/event_detection.dart b/lib/src/onehz/human/event_detection.dart index 32bac50..abf5164 100644 --- a/lib/src/onehz/human/event_detection.dart +++ b/lib/src/onehz/human/event_detection.dart @@ -44,6 +44,12 @@ import '../types.dart'; import '../util.dart'; import '../foundations/baseline.dart'; +/// Nightly RHR is read in whole bpm; a baseline whose SD sits below this +/// quantum has no dispersion the instrument can actually resolve — see +/// [dispersionBelowQuantum]. Same convention as illness_cusum.dart and +/// readiness_composite.dart's `rhrInput` (quantum: 1). +const double _rhrQuantum = 1.0; + /// One night's nocturnal summary vs the personal baseline window. class NightSignature { final double rhr; // nocturnal RHR tonight (bpm) @@ -135,12 +141,21 @@ Metric alcoholNightFlag( final rmsBase = robustBaseline(rmssdHistory, minValid: minNights); final rhrDelta = tonight.rhr - (rhrBase.center ?? tonight.rhr); final rmssdDelta = tonight.rmssd - (rmsBase.center ?? tonight.rmssd); - final rhrZ = rhrBase.modZ(tonight.rhr); + + // Nightly RHR is read in whole bpm; a baseline whose SD sits below this + // quantum (quantized-but-nonzero, e.g. an alternating 54/55/56 baseline) + // has no dispersion the sensor can actually resolve. Standardizing against + // it fabricates an extreme z off ordinary rounding noise — the same guard + // illness_cusum.dart and readiness_composite.dart already apply to this + // exact channel. Abstain on the RHR axis only; RMSSD isn't quantized this + // way, so it keeps standardizing normally. + final rhrBelowQuantum = dispersionBelowQuantum(rhrHistory, _rhrQuantum); + final rhrZ = rhrBelowQuantum ? null : rhrBase.modZ(tonight.rhr); final rmssdZ = rmsBase.modZ(tonight.rmssd); // MDC gates: a sign only counts if it clears the metric's minimal detectable // change. When MDC is unavailable (degenerate scale) the sign cannot fire. - final rhrMdc = mdc(rhrBase); + final rhrMdc = rhrBelowQuantum ? null : mdc(rhrBase); final rmsMdc = mdc(rmsBase); final rhrUp = rhrMdc != null && rhrDelta > rhrMdc; final rmssdDown = rmsMdc != null && (-rmssdDelta) > rmsMdc; @@ -217,11 +232,14 @@ Metric alcoholNightFlag( tonight.skinTempZ != null || tonight.respRate != null; final ambiguous = state == 'autonomically_stressed' && !hasDisambiguator; - final note = state == 'normal' - ? 'no autonomic signature tonight' - : 'STATE = autonomic stress (confident). "Alcohol" is a tag-confirmable ' - 'hypothesis only — late meal / early illness / luteal / hot room ' - 'share this signature; disambiguate with temp + respiration, not HR.'; + final note = (state == 'normal' + ? 'no autonomic signature tonight' + : 'STATE = autonomic stress (confident). "Alcohol" is a tag-confirmable ' + 'hypothesis only — late meal / early illness / luteal / hot room ' + 'share this signature; disambiguate with temp + respiration, not HR.') + + (rhrBelowQuantum + ? '; rhr baseline dispersion below whole-bpm quantum — RHR axis abstained' + : ''); return Metric( value: EventState( diff --git a/test/onehz/human_test.dart b/test/onehz/human_test.dart index 617929f..e042ebd 100644 --- a/test/onehz/human_test.dart +++ b/test/onehz/human_test.dart @@ -107,8 +107,9 @@ void main() { }); group('alcohol-night flag — fires at the right band, silent when normal', () { - // Personal baseline: RHR ~55 (tight), RMSSD ~60 (tight), dip ~12%, temp z ~0. - final rhrHist = [54.0, 55.0, 56.0, 55.0, 54.0, 55.0, 56.0, 55.0]; + // Personal baseline: RHR ~55 (real dispersion, SD >= 1 whole-bpm quantum), + // RMSSD ~60 (tight), dip ~12%, temp z ~0. + final rhrHist = [52.0, 55.0, 58.0, 54.0, 56.0, 53.0, 57.0, 55.0]; final rmsHist = [60.0, 61.0, 59.0, 60.0, 62.0, 58.0, 60.0, 61.0]; final dipHist = [12.0, 11.5, 12.5, 12.0, 11.0, 13.0, 12.0, 12.0]; final tempHist = [0.0, 0.1, -0.1, 0.0, 0.2, -0.2, 0.0, 0.1]; @@ -198,6 +199,30 @@ void main() { // other baseline-gated metric in this package uses. expect(m.note, 'need_baseline:have=2,need=7'); }); + + test( + 'sub-quantum RHR baseline: ordinary +3 bpm night does not fabricate ' + 'the alcohol signature', () { + // Whole-bpm-quantized baseline (SD ~0.76, below the 1 bpm quantum) — + // the exact shape illness_cusum.dart's guard was written for. An + // ordinary +3 bpm night must not standardize against rounding noise. + final quantizedRhrHist = [54.0, 55.0, 56.0, 55.0, 54.0, 55.0, 56.0, 55.0]; + final tonight = NightSignature( + rhr: 58, // +3 bpm vs median 55 — unremarkable + rmssd: 60, // no RMSSD move at all + hrDipPct: 12, + skinTempZ: 0.0, + ); + final m = alcoholNightFlag(tonight, + rhrHistory: quantizedRhrHist, + rmssdHistory: rmsHist, + hrDipHistory: dipHist, + skinTempZHistory: tempHist); + final v = m.value!; + expect(v.alcoholHypothesisBand, isNot(anyOf('moderate', 'heavy'))); + expect(v.rhrZ, isNull); + expect(m.note, contains('rhr baseline dispersion below whole-bpm quantum')); + }); }); group('percentile-of-you + records (MDC-gated)', () {