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
30 changes: 26 additions & 4 deletions lib/src/onehz/clinical/cosinor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,20 @@ class CosinorFit {
final double acrophaseRad; // phase of the peak (rad), in [-π, π]
final double acrophaseHours; // acrophase expressed as clock-hours of [period]
final double periodHours;
final double r2; // goodness of fit (0..1)
final double r2; // raw goodness of fit (0..1)

/// R² adjusted for the 3 fitted parameters (M, β, γ):
/// R²adj = 1 − (1−R²)·(n−1)/(n−3). This is the honest fit quality — the raw
/// R² of a 3-parameter fit is upward-biased and approaches 1 as n → 3.
final double r2Adj;
const CosinorFit({
required this.mesor,
required this.amplitude,
required this.acrophaseRad,
required this.acrophaseHours,
required this.periodHours,
required this.r2,
required this.r2Adj,
});
Map<String, dynamic> toJson() => {
'mesor': round6(mesor),
Expand All @@ -36,21 +42,31 @@ class CosinorFit {
'acrophase_hours': round6(acrophaseHours),
'period_hours': round6(periodHours),
'r2': round6(r2),
'r2_adj': round6(r2Adj),
};
}

/// Minimum samples for a cosinor fit. The model has 3 free parameters
/// (M, β, γ); with n = 4 there is a single residual degree of freedom, so R²
/// is essentially an interpolation score (4 RANDOM points routinely fit at
/// r² 0.76–0.99). Halberg's zero-amplitude test needs real residual dof, so we
/// require ≥8 samples over the period and report the ADJUSTED R².
const int cosinorMinPoints = 8;

/// Single-component cosinor fit.
///
/// [tHours] sample times in hours (any origin). [y] sample values. [periodHours]
/// the rhythm period (default 24). Needs ≥4 points and non-degenerate design.
/// the rhythm period (default 24). Needs ≥[minPoints] points and a
/// non-degenerate design.
Metric<CosinorFit> cosinor(
List<double> tHours,
List<double> y, {
double periodHours = 24,
int minPoints = cosinorMinPoints,
}) {
const inputs = ['signal_timeseries'];
final n = y.length;
if (n < 4 || tHours.length != n || periodHours <= 0) {
if (n < math.max(4, minPoints) || tHours.length != n || periodHours <= 0) {
return const Metric<CosinorFit>.absent(
tier: Tier.high,
inputs_used: inputs,
Expand Down Expand Up @@ -109,8 +125,13 @@ Metric<CosinorFit> cosinor(
ssRes += (y[i] - fit) * (y[i] - fit);
}
final r2 = ssTot == 0 ? 0.0 : clamp(1 - ssRes / ssTot, 0, 1);
// Adjusted for the 3 fitted parameters (M, β, γ). Confidence MUST come from
// the adjusted value: the raw R² of a 3-parameter fit is upward-biased
// (E[R²] = 2/(n−1) under the null), so a handful of noise points used to
// score confidence 0.95 at tier HIGH.
final r2Adj = clamp(1 - (1 - r2) * (n - 1) / (n - 3), 0, 1);

final conf = clamp(r2, 0.1, 0.95);
final conf = clamp(r2Adj, 0.1, 0.95);
return Metric<CosinorFit>(
value: CosinorFit(
mesor: mesor,
Expand All @@ -119,6 +140,7 @@ Metric<CosinorFit> cosinor(
acrophaseHours: phaseHours,
periodHours: periodHours,
r2: r2,
r2Adj: r2Adj,
),
confidence: conf,
tier: Tier.high,
Expand Down
11 changes: 9 additions & 2 deletions lib/src/onehz/clinical/hrv_freq.dart
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,14 @@ Metric<HrvFreq> hrvFreq(
nuLf = 100.0 * lf / (lf + hf);
nuHf = 100.0 * hf / (lf + hf);
}
final total = (ulf ?? 0) + (vlf ?? 0) + lf + hfRaw;
// TOTAL POWER is by definition the sum over ALL bands (Task Force 1996).
// When HF is suppressed we do not have a trustworthy HF term, so a "total"
// is not computable: silently summing the withheld hfRaw back in republished
// exactly the quantity the gate withheld (the gated and ungated totals came
// out bit-identical), and dropping HF from the sum would republish a
// different quantity under the same name. Either way it would be dishonest —
// so total is WITHHELD alongside HF.
final total = hfGated ? null : (ulf ?? 0) + (vlf ?? 0) + lf + hfRaw;

// Confidence: penalize artifacts heavily; low-band-only reads still HIGH-ish.
final conf = clamp((1 - artifactFraction) * (hfGated ? 0.6 : 0.9), 0.2, 0.9);
Expand All @@ -126,7 +133,7 @@ Metric<HrvFreq> hrvFreq(
inputs_used: inputs,
note: hfGated
? 'HF suppressed: artifact fraction ${round6(artifactFraction)} '
'> gate — LF/VLF reported, HF/LF-HF/nu withheld'
'> gate — LF/VLF reported, HF/LF-HF/nu/total withheld'
: 'PRV spectrum; HF band quantization-limited at 1 Hz',
);
}
26 changes: 22 additions & 4 deletions lib/src/onehz/clinical/illness_cusum.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ enum IllnessState { green, yellow, red }
/// Required minimum valid baseline nights before the CUSUM can flag.
const int illnessCusumMinBaseline = 7;

/// Machine-readable note attached to a night whose trailing baseline is long
/// enough but has ZERO dispersion (MAD and SD both 0 — a fully constant,
/// quantized baseline). The standardized deviation is undefined, so the night
/// is held green and NOT accumulated rather than standardized against a
/// fabricated scale.
const String degenerateBaselineNote = 'degenerate_baseline:scale=0';

class IllnessDay {
final String date;
final IllnessState state;
Expand Down Expand Up @@ -91,10 +98,21 @@ List<IllnessDay> illnessCusum(
final med = median(window)!;
var scale = mad(window) ?? 0;
if (scale <= 0) {
// Quantized/constant baseline: fall back to a small physiological floor
// (1 bpm) so we can still standardize, but flag low confidence by never
// letting tiny noise trip the alarm.
scale = math.max(1.0, (stddev(window) ?? 1.0));
// Quantized baseline (whole-bpm RHR) can collapse the MAD to 0. Fall
// back to the ordinary SD so a usable baseline still standardizes —
// the same convention as wellness/readiness_composite.dart and
// wellness/changepoint.dart.
scale = stddev(window) ?? 0;
}
if (scale <= 0 || !scale.isFinite) {
// Truly constant baseline: there is NO dispersion to standardize
// against, so z is undefined. Substituting a magic 1 bpm floor here
// turned a 5 bpm one-night bump into z = 5 and latched the alarm red —
// exactly the fabrication this package forbids. ABSTAIN instead: hold
// green, do not accumulate, and say why.
out.add(IllnessDay(dates[i], IllnessState.green, null, null,
need: degenerateBaselineNote));
continue;
}
final z = (r - med) / scale;
// One-sided upper CUSUM on elevation (RHR up = potential illness).
Expand Down
Loading
Loading