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
32 changes: 25 additions & 7 deletions lib/src/onehz/human/event_detection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -135,12 +141,21 @@ Metric<EventState> 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;
Expand Down Expand Up @@ -217,11 +232,14 @@ Metric<EventState> 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<EventState>(
value: EventState(
Expand Down
29 changes: 27 additions & 2 deletions test/onehz/human_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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)', () {
Expand Down
Loading