Skip to content
Open
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
4 changes: 2 additions & 2 deletions docs/ALGORITHM_CATALOG_1HZ.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ Synthesized from 6 independent literature reviews (HRV, cardiac, sleep/circadian
- **van Hees / GGIR angle sleep-window** — 2015/2018. Count-FREE, gravity-orientation @1 Hz is ample. **THE sleep/wake spine** (sidesteps the Cole-Kripke count-calibration trap). `24/7 · HIGH`
- **True Phillips SRI** — epoch-by-epoch 24-h concordance (Phillips 2017), NOT SD-of-midsleep. `24/7 · HIGH`
- **Cardiopulmonary Coupling (CPC)** — Thomas 2005; RR + RSA/RIIV respiration surrogate (substitute for EDR). Sleep-stability spectrogram + apnea-risk; plays to continuous RR. `24/7 · MED-HIGH`
- **3-class autonomic stager (wake/NREM/REM)** + HR-dip onset, REM gated by 1 Hz immobility. Never claim N1/N2/N3. `24/7 · MED (honesty-bounded)`
- **Sleep accounting** — onset/offset, WASO, TST, efficiency, NREM-REM cycles (~90 min from CPC/autonomic). `24/7 · HIGH`
- **3-class cardio stager (wake/NREM/REM)** — `cardioStager`, Webster/Cole-Kripke wake + HRV; replaces the deleted HR-only autonomic stager. HR-dip onset, REM gated by 1 Hz immobility. Never claim N1/N2/N3. `24/7 · MED (honesty-bounded)`
- **Sleep accounting** — onset/offset, WASO, TST, efficiency, NREM-REM cycles (~90 min from CPC/cardio stager). `24/7 · HIGH`

### Respiration & SpO₂ (PPG + RR)
- **RSA respiratory rate from RR** — Lomb-Scargle HF-peak; Pimentel 2017 AR-order robustness. *Primary 24/7 respiration source.* `24/7 · HIGH`
Expand Down
33 changes: 18 additions & 15 deletions lib/src/onehz/clinical/load_trimp.dart
Original file line number Diff line number Diff line change
Expand Up @@ -460,21 +460,15 @@ class StrainScorer {

// ── TRIMP accumulation ──────────────────────────────────────────────────────

/// Median inter-sample interval (seconds) of a time-ordered stream, ignoring
/// non-positive steps and pathological (>[maxPlausibleGapSec]) ones. Floored
/// at [fallbackSampleMin] minutes' worth. Mirrors the convention already used
/// by `HeartRateZones.timeInZone`.
static double medianIntervalSeconds(List<double> tsSec,
{double maxPlausibleGapSec = 300.0}) {
final gaps = <double>[];
for (var i = 1; i < tsSec.length; i++) {
final g = tsSec[i] - tsSec[i - 1];
if (g > 0 && g <= maxPlausibleGapSec) gaps.add(g);
}
if (gaps.isEmpty) return fallbackSampleMin * 60.0;
gaps.sort();
return math.max(gaps[gaps.length ~/ 2], fallbackSampleMin * 60.0);
}
/// Measured cadence (seconds) of a time-ordered stream, or NULL.
///
/// Now a thin alias for [sampleCadenceSeconds], which is the single helper
/// for all three of the old near-duplicates. Kept because it is public API;
/// the old `maxPlausibleGapSec` parameter is gone — the ceiling lives on
/// [maxSupportedCadenceSec] and the old `fallbackSampleMin` floor was the
/// fabricated 1 s that made a 301 s stream look like a 1 Hz one.
static double? medianIntervalSeconds(List<double> tsSec) =>
sampleCadenceSeconds(tsSec);

/// PER-SAMPLE effort durations (minutes) from the ACTUAL timestamps.
///
Expand All @@ -488,11 +482,16 @@ class StrainScorer {
/// on exactly the sparse/irregular streams [minSparseReadings] admits: 21
/// samples over 20 min with the first two 1 s apart scored strain 8.08
/// instead of ~47, and a 1 Hz stream with a 5-min leading gap scored 104.
/// EMPTY when the stream's cadence cannot be measured — see
/// [sampleCadenceSeconds]. Every duration here is a multiple of that cadence,
/// so an unmeasurable cadence is an unmeasurable effort, and [strain] turns
/// it into an absent metric rather than a small confident one.
static List<double> sampleDurationsMinutes(List<double> tsSec) {
final n = tsSec.length;
if (n == 0) return const [];
if (n == 1) return [fallbackSampleMin];
final capSec = medianIntervalSeconds(tsSec);
if (capSec == null) return const [];
final out = List<double>.filled(n, capSec / 60.0);
for (var i = 0; i < n - 1; i++) {
final g = tsSec[i + 1] - tsSec[i];
Expand Down Expand Up @@ -573,6 +572,10 @@ class StrainScorer {
if (!enoughData || effMax <= restingHR) return null;

final durations = sampleDurationsMinutes(tsSec);
// No measurable cadence ⇒ no durations ⇒ no effort. Must NOT fall through:
// `banisterTRIMP` credits `fallbackSampleMin` per sample when handed an
// empty list, which is the fabricated 1 s this abstention exists to stop.
if (durations.isEmpty) return null;
final trimp = banisterTRIMP(bpm, restingHR, effMax - restingHR, durations,
female: female);
return trimpToStrain(trimp, denominator: denominator);
Expand Down
116 changes: 81 additions & 35 deletions lib/src/onehz/clinical/nocturnal.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,57 +22,99 @@ class NocturnalRhr {
};
}

/// Nocturnal resting HR from a night of 1 Hz HR samples.
/// Nocturnal resting HR from a night of HR samples.
///
/// [hr] 1 Hz HR samples (bpm; 0 = off-skin, excluded). [windowSamples] rolling
/// window length in SAMPLE POSITIONS (default 1800 = 30 min at 1 Hz).
/// [minCoverage] fraction of a window's positions that must carry a valid
/// on-skin sample for the window to count.
/// [hr] HR samples (bpm; 0 = off-skin, excluded). [window] the rolling trough
/// window as a DURATION (default 30 min). [tsSec] their sample times (seconds);
/// null keeps the historical contract — one sample per second, so a position IS
/// a wall-clock second, which is what every WHOOP caller feeds. [minCoverage]
/// fraction of a window's expected samples that must carry a valid on-skin
/// reading for the window to count.
///
/// The window slides over WALL-CLOCK POSITIONS, not over the compacted valid
/// stream: an off-skin gap must not be closed up, or the "30-min" window can
/// silently span the whole night. A night with no window meeting [minCoverage]
/// yields an ABSENT metric — we never relabel the whole-night mean as a
/// lowest-30-min trough.
/// The window slides over WALL-CLOCK TIME, not over sample positions. It used
/// to be a fixed 1800 POSITIONS, which is 30 min only at 1 Hz: on a 15 s band
/// the same 1800 positions span 7.5 h, so "the lowest 30-min mean" quietly
/// became "the whole-night mean" — measured on a real night, 59.7 bpm published
/// as 66.4. With [tsSec] the window length is derived from the stream's own
/// measured cadence ([sampleCadenceSeconds]), which ABSTAINS rather than
/// guessing, so an unmeasurable cadence yields an absent metric.
///
/// Off-skin gaps are still never compacted away: coverage is checked against
/// the samples a full window SHOULD hold, so a window that is mostly hole stays
/// ineligible. A night with no window meeting [minCoverage] yields an ABSENT
/// metric — we never relabel the whole-night mean as a lowest-30-min trough.
Metric<NocturnalRhr> nocturnalRhr(List<double> hr,
{int windowSamples = 1800, double minCoverage = 0.9}) {
{List<double>? tsSec,
Duration window = const Duration(minutes: 30),
double minCoverage = 0.9}) {
const inputs = ['hr_1hz'];
final valid = hr.where((h) => h > 0).toList();
if (windowSamples < 1 || hr.length < windowSamples) {
if (tsSec != null && tsSec.length != hr.length) {
return const Metric<NocturnalRhr>.absent(
tier: Tier.high,
inputs_used: inputs,
note: 'HR and timestamps disagree in length',
);
}
final ts = tsSec ?? [for (var i = 0; i < hr.length; i++) i.toDouble()];
// The null path is 1 Hz BY CONTRACT, not by measurement — pinning it here
// keeps WHOOP output bit-identical instead of routing it through a helper
// that can abstain on a night the app already scores today.
final cadence = tsSec == null ? 1.0 : sampleCadenceSeconds(ts);
final winSec = window.inSeconds.toDouble();
if (cadence == null) {
return const Metric<NocturnalRhr>.absent(
tier: Tier.high,
inputs_used: inputs,
note: 'no measurable sampling cadence — a 30-min trough cannot be '
'located without knowing how much time one sample covers',
);
}
// Samples a fully-covered window holds at this cadence: 1800 at 1 Hz, 120 at
// 15 s, 30 at 60 s. This is the number the old fixed `windowSamples = 1800`
// got wrong for every stream that is not 1 Hz.
final perWindow = winSec / cadence;
if (perWindow < 1 || hr.length < perWindow) {
return const Metric<NocturnalRhr>.absent(
tier: Tier.high,
inputs_used: inputs,
note: 'insufficient valid (on-skin) HR for nocturnal RHR',
);
}
// Lowest rolling mean over CONTIGUOUS wall-clock windows. A window is only
// eligible when at least [minCoverage] of its positions are on-skin; its
// mean is taken over the valid samples inside it.
final needValid = (minCoverage * windowSamples).ceil();
// eligible when at least [minCoverage] of the samples it should hold are
// on-skin; its mean is taken over the valid samples inside it.
final needValid = (minCoverage * perWindow).ceil();
// A window ending before this has not had a full [window] of stream behind
// it — the same rule the positional loop expressed as "start at index 1800".
final firstFullEnd = ts.first + winSec - cadence;
var sum = 0.0;
var count = 0;
for (var i = 0; i < windowSamples; i++) {
if (hr[i] > 0) {
sum += hr[i];
count++;
}
}
var lo = 0;
double? best;
if (count >= needValid) best = sum / count;
for (var i = windowSamples; i < hr.length; i++) {
if (hr[i] > 0) {
sum += hr[i];
for (var hi = 0; hi < hr.length; hi++) {
if (hr[hi] > 0) {
sum += hr[hi];
count++;
}
final out = hr[i - windowSamples];
if (out > 0) {
sum -= out;
count--;
}
if (count >= needValid) {
final m = sum / count;
if (best == null || m < best) best = m;
while (ts[hi] - ts[lo] >= winSec) {
if (hr[lo] > 0) {
sum -= hr[lo];
count--;
}
lo++;
}
// `count == 0` is its own guard, not just a stricter `needValid`: a caller
// that passes `minCoverage: 0` makes `needValid` 0 too, and `count <
// needValid` is then never true for a non-negative count — so a window
// with NO on-skin sample would otherwise reach `sum / count` as `0.0 / 0`,
// which is NaN in Dart. NaN LATCHES here (`m < best` is false for a NaN
// `m`, and false again once a NaN `best` is compared against anything
// later), so one such window would silently poison the whole night's
// trough instead of being skipped.
if (ts[hi] < firstFullEnd || count < needValid || count == 0) continue;
final m = sum / count;
if (best == null || m < best) best = m;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if (best == null) {
return const Metric<NocturnalRhr>.absent(
Expand All @@ -83,13 +125,17 @@ Metric<NocturnalRhr> nocturnalRhr(List<double> hr,
);
}
final p1 = percentile(valid, 1)!;
final conf = clamp(valid.length / 7200.0, 0.4, 0.95); // ~2 h coverage => high
// Confidence is COVERAGE IN SECONDS, not a sample count — 480 samples of a
// 60 s band is the same 8 h of night as 28,800 samples at 1 Hz.
final conf =
clamp(valid.length * cadence / 7200.0, 0.4, 0.95); // ~2 h => high
return Metric<NocturnalRhr>(
value: NocturnalRhr(best, p1, valid.length),
confidence: conf,
tier: Tier.high,
inputs_used: inputs,
note: 'lowest-30-min mean + 1st-percentile; HR=0 excluded as off-skin',
note: 'lowest-${window.inMinutes}-min mean + 1st-percentile; HR=0 excluded '
'as off-skin',
);
}

Expand Down
41 changes: 15 additions & 26 deletions lib/src/onehz/device.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
//
// HOW A METRIC USES IT — the whole mechanism, no framework:
//
// const _ceiling = {DeviceFamily.gen4: 230, DeviceFamily.gen5: 210};
// const _ceiling = {'gen4': 230, 'gen5': 210};
//
// Metric<double> foo(..., {String? deviceFamily}) {
// final hi = calibrationFor(_ceiling, deviceFamily);
Expand All @@ -36,33 +36,22 @@
// no registry, no plugin table and no central constants file: a shared table
// is how one family's number quietly becomes another's.

/// The sensor packages we have calibrated algorithms for.
///
/// `gen5` covers the whole fd4b family (WHOOP 5, MG) — they share one sensor
/// package. Add a value here only alongside the calibration work; an enum entry
/// with no constants behind it just moves the refusal one step later.
enum DeviceFamily { gen4, gen5 }

/// Parse the ingest-stamped family id (`decoded_onehz.device_family`).
///
/// Returns null for NULL, for empty, and for any id this build does not know —
/// all three mean "unknown provenance", which is its own case, not gen4.
DeviceFamily? deviceFamilyOf(String? id) => switch (id) {
'gen4' => DeviceFamily.gen4,
'gen5' => DeviceFamily.gen5,
_ => null,
};

/// The id a [DeviceFamily] is stamped as. Inverse of [deviceFamilyOf].
String deviceFamilyId(DeviceFamily f) => f.name;

/// This metric's own constants for [family], or null when it must REFUSE —
/// either the family is unknown or this metric has nothing calibrated for it.
/// either the family is unstamped or this metric has nothing calibrated for it.
/// Both are refusals: never substitute another family's constants.
T? calibrationFor<T>(Map<DeviceFamily, T> byFamily, String? family) {
final f = deviceFamilyOf(family);
return f == null ? null : byFamily[f];
}
///
/// THE SET OF FAMILIES IS OPEN, and it is each map's own business who is in it.
/// There used to be a closed `DeviceFamily` enum here, which made "a strap this
/// build has never heard of" and "a strap this METRIC has no numbers for" the
/// same answer arrived at in two places — and meant a new band could not be
/// calibrated for one metric at a time. A key is in a map or it is not.
///
/// Null and empty are not keys: both mean NO STAMP, which no map may answer
/// for. (An id is never trimmed or case-folded on the way in — the stamp is
/// written by ingest, and quietly matching a near-miss is how one family's
/// constants get applied to another's counts.)
T? calibrationFor<T>(Map<String, T> byFamily, String? family) =>
(family == null || family.isEmpty) ? null : byFamily[family];

/// MACHINE-READABLE refusal note, same convention as `need_baseline:`.
///
Expand Down
30 changes: 27 additions & 3 deletions lib/src/onehz/respiration/resp_rate.dart
Original file line number Diff line number Diff line change
Expand Up @@ -322,9 +322,11 @@ Metric<RespEstimate> rsaRespRate(
///
/// Respiratory-Induced Intensity Variation: a 0.1–0.5 Hz band-pass on the green
/// ADC, then the dominant spectral peak in the respiratory band => breaths/min.
/// [adc] the 1 Hz green ADC samples, [tsSec] their times (seconds). Uneven
/// times are fine — we use Lomb-Scargle, no resampling. [validFraction] of the
/// window that passed the contact/SQI gate drives confidence.
/// [adc] the green ADC samples, [tsSec] their times (seconds). Uneven times are
/// fine — we use Lomb-Scargle, no resampling — but the stream must sample at
/// 1 Hz OR FASTER, because the band is fixed and anything slower aliases into
/// it (see the Nyquist gate below). [validFraction] of the window that passed
/// the contact/SQI gate drives confidence.
Metric<RespEstimate> riivRespRate(
List<double> adc,
List<double> tsSec, {
Expand All @@ -347,6 +349,28 @@ Metric<RespEstimate> riivRespRate(
note: 'degenerate timestamps',
);
}
// NYQUIST. Unlike RSA (which derives its ceiling per window, `_beatNyquist`),
// this band was FIXED at 0.1–0.5 Hz with no reference to the sampling rate at
// all. A stream slower than 1 Hz cannot represent it: at 5 s sampling every
// real breath sits above Nyquist and folds back INSIDE the band as an alias,
// so the peak is real, in range, and about a rate nobody is breathing —
// measured on a real night, 21.2 br/min was published as 10.8. Narrowing the
// search grid is no defence, because an alias is in-band by construction.
// The only honest output is absence.
final cadenceSec = sampleCadenceSeconds(tsSec);
if (cadenceSec == null || 1.0 / (2 * cadenceSec) < respHiHz) {
return Metric<RespEstimate>.absent(
tier: Tier.relative,
inputs_used: inputs,
note: cadenceSec == null
? 'no measurable sampling cadence — cannot rule out a respiratory '
'alias, so RIIV is withheld'
: 'sampling at ${round6(cadenceSec)}s (Nyquist '
'${round6(1.0 / (2 * cadenceSec))} Hz) cannot represent the '
'${respLoHz}–${respHiHz} Hz respiratory band — every rate in it '
'would alias into the band; withheld',
);
}
// Detrend (remove DC/slow baseline wander) via a robust-ish linear fit; the
// band-pass character comes from restricting the Lomb-Scargle grid to the
// respiratory band, which rejects both DC (<0.1 Hz) and HR/cardiac (>0.5 Hz).
Expand Down
Loading
Loading