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
12 changes: 6 additions & 6 deletions ALGORITHMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ relative-only ADC channels (skin temp, SpO2, ambient light).
This file is the index — what's implemented, where it lives, and who published it. If a
row here doesn't have a real citation next to it, that's a bug in this file, go fix it.

See `docs/ALGORITHM_CATALOG_1HZ.md` for the underlying literature review and what's
deliberately not shipped.

Almost everything returns a `Metric<T>` envelope:

```dart
Expand Down Expand Up @@ -46,7 +49,6 @@ Grouped by family (subdirectory under `lib/src/onehz/`). File paths are relative
|---|---|---|
| `correctRr` | `foundations/rr_correction.dart` | Lipponen & Tarvainen 2019 RR artifact correction (dRR/mRR/sRR beat classification, Kubios-style) |
| `Baselines` (Winsorized-EWMA) | `foundations/ewma_baselines.dart` | Winsorized exponentially-weighted moving baseline — the rolling personal reference most other metrics compare against |
| PPG signal-quality index | `foundations/ppg_sqi.dart` | Skewness-based SQI |
| inverse-variance fusion | `foundations/fusion.dart` | Standard inverse-variance weighting for combining multiple noisy estimates of the same quantity |

### `clinical/` — Tier-1 cardiac/autonomic metrics
Expand All @@ -61,7 +63,7 @@ Grouped by family (subdirectory under `lib/src/onehz/`). File paths are relative
| `illnessCusum` | `clinical/illness_cusum.dart` | Online CUSUM state machine (green/yellow/red) over RHR — "NightSignal" | Alavi et al. 2022; Mishra et al. 2020 |
| `readinessLnRmssd` | `clinical/readiness_lnrmssd.dart` | ln(RMSSD) z-scored against a rolling prior-nights baseline | Plews et al. 2013 |
| `cosinor` | `clinical/cosinor.dart` | Cosinor rhythmometry (MESOR/amplitude/acrophase) | Halberg & Nelson 1979 |
| `banisterTrimp` / `StrainScorer.edwardsTRIMP` | `clinical/load_trimp.dart` | Training impulse from HR-reserve | Banister 1991; Edwards 1993 |
| `banisterTrimp` | `clinical/load_trimp.dart` | Training impulse from HR-reserve | Banister 1991 |
| `strainScoreMetric` | `clinical/load_trimp.dart` | log-squash of TRIMP onto a 0-21 scale | — |
| `trimpStrain` | `clinical/load_trimp.dart` | TRIMP → 0-100 strain, honesty-wrapped (absent without real HRmax/RHR anchors) | — |
| `ctlAtlTsb` | `clinical/load_trimp.dart` | Fitness-Fatigue-Form: EWMA CTL (42d) / ATL (7d) / TSB = CTL-ATL | Banister impulse-response model |
Expand Down Expand Up @@ -99,12 +101,11 @@ Grouped by family (subdirectory under `lib/src/onehz/`). File paths are relative
| `relativeIntensityBands` | `motion/enmo.dart` | ENMO/MAD activity intensity bands | — |
| `staticTilt` | `motion/orientation.dart` | orientation/posture from gravity vector | — |
| `branchedEnergyFusion` | `motion/energy_fusion.dart` | HR-anchored-when-possible energy expenditure fusion | Brage et al. 2004 |
| `dailyStepEstimate` | `motion/steps.dart` | 1 Hz fallback step estimateENMO+HR gated, bout-length gated (contiguous-run requirement), only for minutes the live 100 Hz pedometer didn't cover | AN-2554-adjacent (see `livePedometer` for the real 100 Hz method) |
| `dailyActiveMinutes` | `motion/steps.dart` | minutes of sustained wrist movement from the 1 Hz substrate, no step counttrue per-step counting is impossible below gait Nyquist | internal ESTIMATE (personal dynamic-floor movement threshold, no published method; unrelated to AN-2554see `livePedometer` for the real AN-2554-derived 100 Hz method) |

### `workout/`
| Function | File | Method | Citation |
|---|---|---|---|
| `detectWorkouts` | `workout/workout_detect.dart` | explicit workout detection + zones | — |
| `autoDetectWorkouts` | `workout/auto_detect.dart` | automatic workout detection | — |
| `hrRecovery` | `workout/hr_recovery.dart` | HRR — HR drop N seconds post-peak | Cole/Lauer 1999-style HRR |
| `Calories.dailyEnergy` / `estimateBoutCalories` | `workout/calories.dart` | Keytel HR→kcal regression + Harris-Benedict/Mifflin BMR | Keytel et al. 2005 |
Expand All @@ -122,14 +123,13 @@ Grouped by family (subdirectory under `lib/src/onehz/`). File paths are relative
### `human/`
| Function | File | Method | Citation |
|---|---|---|---|
| `sleepRegularityIndex` | `human/sleep_regularity.dart` | SRI (see also `phillipsSri` above) | Phillips et al. 2017 |
| `sleepDebt` | `human/sleep_regularity.dart` | accumulated sleep debt vs. need | — |
| `socialJetlag` / `chronotype` | `human/circadian_lifestyle.dart` | MSFsc-based social jetlag + chronotype | Wittmann & Roenneberg 2006; MCTQ |
| `alcoholNightFlag` | `human/event_detection.dart` | dose-graded autonomic-stress signature — **reports state, never asserts a cause** | Pietilä et al. 2018 |
| `roughNight` | `human/event_detection.dart` | neutral fallback descriptor when the signature is ambiguous | — |
| `percentileOfYou` / `personalRecord` | `human/percentile_of_you.dart` | percentile-vs-your-own-history, miss-tolerant personal-record streaks | — |
| `glassBoxReadiness` | `human/readiness_glassbox.dart` | **deprecated** — kept only for its percentile-of-you breakdown + narrative and edge back-compat; `readinessComposite` is canonical | — |
| `vo2maxEstimate` / `physiologicalAge` / `sleepNeed` / `strainTarget` / `recommendedBedtime` / `recommendedWake` / `sleepPerformance` | `human/coaching.dart` | deterministic coaching layer over the metrics above (naps come from `sleep/nap.dart`, not here) | Uth-Sørensen-style HR-ratio VO2max estimate (still `ESTIMATE`, never a lab claim) |
| `sleepNeed` / `strainTarget` / `recommendedBedtime` / `recommendedWake` / `sleepPerformance` | `human/coaching.dart` | deterministic coaching layer over the metrics above (naps come from `sleep/nap.dart`, not here) | |
| `journalCorrelations` | `human/coaching.dart` | per-tag mean-difference correlation vs. logged outcomes, on-device, personal | — |

---
Expand Down
51 changes: 44 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,32 @@ metric that needs 7 nights of baseline and only has 3 says so directly (`note:
"need_baseline:have=3,need=7"`) instead of quietly computing something off 3 nights and
hoping you don't notice.

## Quick start

```dart
import 'package:openstrap_analytics/onehz.dart';

// nnMs: cleaned beat-to-beat RR intervals in ms (see foundations/rr_correction.dart
// for turning raw RR into this). nnTimesMs: cumulative beat timestamp in ms
// (time since the first beat, NOT the per-beat RR duration), same length.
final nnMs = <double>[800, 810, 795, 805];
final nnTimesMs = <double>[0, 810, 1605, 2410]; // nnTimesMs[i] = nnTimesMs[i-1] + nnMs[i]
final Metric<HrvTime> hrv = hrvTime(nnMs, nnTimesMs: nnTimesMs, artifactFraction: 0.04);
if (hrv.value != null) {
print('RMSSD ${hrv.value!.rmssd} ms (confidence ${hrv.confidence}, tier ${hrv.tier})');
}
```

Every metric function in the package follows this shape: plain `List<double>` (or a
small typed input class for the composite ones) in, `Metric<T>` out.

## What's actually in here

Eight families, each its own subdirectory with its own sub-barrel, built on two shared
foundation layers:

- **`foundations/`** — Lipponen-Tarvainen RR artifact correction, Winsorized-EWMA rolling
baselines, inverse-variance fusion, a PPG signal-quality index.
baselines, inverse-variance fusion.
- **`clinical/`** (Tier-1) — HRV time/frequency domain (RMSSD/SDNN/pNNx, Lomb-Scargle
LF/HF), PRSA (deceleration/acceleration capacity), nocturnal RHR/dip, an illness-risk
CUSUM state machine, Plews ln-RMSSD readiness, Baevsky stress index, Banister/Edwards
Expand All @@ -83,11 +102,12 @@ foundation layers:
(IS/IV/RA/L5/M10).
- **`respiration/`** — RSA-derived respiratory rate fused with motion-modulated RIIV,
CVHR-based apnea screening, a relative (never absolute) oxygen-desaturation ratio.
- **`motion/`** — ENMO/MAD activity metrics, a hybrid live/1 Hz step estimator (AN-2554
100 Hz pedometer preferred, a gated-and-bout-length-checked 1 Hz fallback for whatever
the live stream missed), energy-expenditure fusion.
- **`workout/`** — workout detection (both explicit and automatic), heart-rate-reserve
zones, Keytel/Harris-Benedict calorie estimation.
- **`motion/`** — ENMO/MAD activity metrics, a 100 Hz AN-2554-derived live pedometer
plus a separate 1 Hz movement-minutes estimate (no step count — true per-step
counting is impossible below gait Nyquist) for whatever the live stream missed,
energy-expenditure fusion.
- **`workout/`** — automatic workout detection (bout suggestion, never explicit/
retroactive), heart-rate-reserve zones, Keytel/Harris-Benedict calorie estimation.
- **`wellness/`** — the canonical composite readiness score, multivariate (Mahalanobis)
anomaly detection, CUSUM changepoint detection, temperature-based illness flagging.
- **`human/`** — sleep regularity index, social jetlag/chronotype, single-night event
Expand All @@ -110,7 +130,24 @@ not a feature, no matter how tempting the plausible-looking headline is.
dart test # run from the repo root — some fixtures resolve paths relative to it
```

290 tests, nothing mocked — pure functions, fixture in, assertion out.
All pass (a handful skipped), nothing mocked — pure functions, fixture in,
assertion out.

## Validation

`tool/` has four harnesses that score shipped detectors against labelled corpora, not
synthetic fixtures — run one before touching the logic it covers:

- `dart run tool/oxwalk_validate.dart <path-to-OxWalk_Dec2022>` — the pedometer against
OxWalk (Oxford, CC BY): 39 subjects, camera-annotated heel strikes.
- `dart run tool/stager_harness.dart <fixture.json>` — the sleep-staging decision layer
against a PSG-labelled corpus (e.g. DREAMT), reporting Cohen's kappa.
- `dart run tool/nap_harness.dart <fixture.json>` — the nap detector against hand-labelled
days.
- `dart run tool/whoop_proportions.dart <dir-of-night-json>` — sweeps sleep-stage cutoffs
against normative stage proportions on real device captures.

Each file's header comment has the full usage, flags, and fixture schema.

## If you want to add a metric

Expand Down
2 changes: 1 addition & 1 deletion docs/ALGORITHM_CATALOG_1HZ.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Synthesized from 6 independent literature reviews (HRV, cardiac, sleep/circadian
- **24-h SDNN + SDANN/SDNN-index** — jitter-immune (operate on 5-min means). `24/7 · HIGH`
- **Nocturnal HR dip %** / night-day ratio (dipper/non-dipper/riser) — CV-risk + acute-strain signal. `24/7 · HIGH`
- **NightSignal FSM + CUSUM/EWMA on RHR** — Alavi 2022 / Mishra 2020 (28-day baseline, designed ARL, yellow→red persistence). Best wearable-validated deterministic illness alarm. `24/7 · HIGH`
- **TRIMP (Edwards + Banister) + CTL/ATL/TSB** — Edwards 1993 / Banister; Morton 1990. Needs measured HRmax + RHR. Guard non-wear gaps. `24/7 · HIGH`
- **TRIMP (Banister) + CTL/ATL/TSB** — Banister; Morton 1990. Needs measured HRmax + RHR. Guard non-wear gaps. `24/7 · HIGH`

### Sleep & circadian (accel + HR + RR + temp + light)
- **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`
Expand Down
8 changes: 4 additions & 4 deletions lib/onehz.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/// openstrap_analytics — 1 Hz-native physiological analytics family.
///
/// Independent of the minute-resolution library in
/// `lib/openstrap_analytics.dart`. Operates on the always-on 1 Hz substrate
/// (beat-to-beat RR, 1 Hz HR, 1 Hz tri-axial accel, relative-ADC channels) per
/// docs/ALGORITHM_CATALOG_1HZ.md.
/// The old minute-resolution library that used to live alongside this one has
/// been removed; this is the only analytics library in the package now.
/// Operates on the always-on 1 Hz substrate (beat-to-beat RR, 1 Hz HR, 1 Hz
/// tri-axial accel, relative-ADC channels) per docs/ALGORITHM_CATALOG_1HZ.md.
///
/// Honesty ceilings are enforced in code: PRV not ECG-HRV; relative signals
/// carry no absolute %/°C; absent input => null + confidence 0 (never a
Expand Down
10 changes: 4 additions & 6 deletions lib/src/onehz/clinical/cardiac_coherence.dart
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ Metric<CardiacCoherence> cardiacCoherence(
// honesty guard rather than pretending a short session sees down to 0.0033 Hz.
final totalLoHz = (1.0 / spanSec).clamp(0.0033, searchLoHz);

final ls = lombScargle(tSec, nnMs, freqGrid(totalLoHz, totalHiHz, gridPoints));
final ls =
lombScargle(tSec, nnMs, freqGrid(totalLoHz, totalHiHz, gridPoints));
if (ls == null) {
return const Metric<CardiacCoherence>.absent(
tier: Tier.estimate,
Expand Down Expand Up @@ -128,11 +129,8 @@ Metric<CardiacCoherence> cardiacCoherence(
// this span actually covers (floored/ceilinged), lightly penalized when the
// found peak isn't near the guided pace (could still be real RSA, just less
// clearly the paced-breathing entrainment this feature is meant to reward).
final conf = clamp(
(spanSec / 180.0).clamp(0.3, 1.0) * (onPace ? 1.0 : 0.85),
0.2,
0.9,
);
final conf = ((spanSec / 180.0).clamp(0.3, 1.0) * (onPace ? 1.0 : 0.85))
.clamp(0.2, 0.9);
Comment on lines +132 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The replacements call Dart's num.clamp, whose static return type is num, instead of the deleted helper's double return type. These values are assigned to double variables, returned from double functions, or passed to Metric.confidence (which requires double), so the package fails static analysis/compilation at these sites and throughout the same replacement pattern.

Suggested fix: Convert each result with .clamp(...).toDouble() or retain a shared helper/extension that returns double.


return Metric<CardiacCoherence>(
value: CardiacCoherence(ratio: ratio, score: score, peakHz: peakHz),
Expand Down
6 changes: 3 additions & 3 deletions lib/src/onehz/clinical/cosinor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,14 @@ Metric<CosinorFit> cosinor(
ssTot += (y[i] - yMean) * (y[i] - yMean);
ssRes += (y[i] - fit) * (y[i] - fit);
}
final r2 = ssTot == 0 ? 0.0 : clamp(1 - ssRes / ssTot, 0, 1);
final double r2 = ssTot == 0 ? 0.0 : (1 - ssRes / ssTot).clamp(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 double r2Adj = (1 - (1 - r2) * (n - 1) / (n - 3)).clamp(0, 1);

final conf = clamp(r2Adj, 0.1, 0.95);
final conf = r2Adj.clamp(0.1, 0.95);
return Metric<CosinorFit>(
value: CosinorFit(
mesor: mesor,
Expand Down
2 changes: 1 addition & 1 deletion lib/src/onehz/clinical/hrv_freq.dart
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ Metric<HrvFreq> hrvFreq(
: [for (final b in resolved) bands[b]!].reduce((a, b) => a + b);

// 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);
final conf = ((1 - artifactFraction) * (hfGated ? 0.6 : 0.9)).clamp(0.2, 0.9);
return Metric<HrvFreq>(
value: HrvFreq(
ulf: ulf,
Expand Down
32 changes: 13 additions & 19 deletions lib/src/onehz/clinical/hrv_time.dart
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ double? nnDiffAcf1(List<List<double>> diffRuns) {
/// falling linearly to 0 at [kNnDiffAcf1Floor] so confidence bottoms out
/// exactly where RMSSD is refused. 1.0 when ACF1 could not be measured.
double _acf1Quality(double? acf1) =>
acf1 == null ? 1.0 : clamp(1 - acf1 / kNnDiffAcf1Floor, 0.0, 1.0);
acf1 == null ? 1.0 : (1 - acf1 / kNnDiffAcf1Floor).clamp(0.0, 1.0);

String _jitterNote(double acf1) =>
'rmssd_refused:acf1=${acf1.toStringAsFixed(3)} — the NN successive '
Expand Down Expand Up @@ -200,14 +200,11 @@ Metric<HrvTime> hrvTime(
// were ~pure noise. The beat-count term is capped BEFORE the quality terms
// multiply it; multiplying first let an all-night beat count (n/250 ≈ 100)
// swallow any penalty and re-clamp to 0.95 regardless.
final conf = clamp(
clamp(nnMs.length / 250.0, 0.0, 1.0) // ~250 beats ≈ 5 min
*
_acf1Quality(acf1) *
(1 - artifactFraction),
0.3,
0.95,
);
final conf = ((nnMs.length / 250.0).clamp(0.0, 1.0) // ~250 beats ≈ 5 min
*
_acf1Quality(acf1) *
(1 - artifactFraction))
.clamp(0.3, 0.95);
return Metric<HrvTime>(
value: HrvTime(
rmssd: rmssd,
Expand Down Expand Up @@ -341,11 +338,11 @@ Metric<double> nocturnalRmssd(
final robust = median(rmssds)!;
// Confidence scales with how many windows we could median over, and with the
// measured jitter level (see [kNnDiffAcf1Floor]).
final conf = clamp(
clamp(rmssds.length / 12.0, 0.0, 1.0) * _acf1Quality(acf1), // 12 ≈ 1 h
0.3,
0.95,
);
final conf =
((rmssds.length / 12.0).clamp(0.0, 1.0) * _acf1Quality(acf1)).clamp(
// 12 ≈ 1 h
0.3,
0.95);
return Metric<double>(
value: robust,
confidence: conf,
Expand Down Expand Up @@ -452,11 +449,8 @@ Metric<double> sleepSessionWindowedRmssd(
}

final meanRmssd = mean(rmssds)!;
final conf = clamp(
clamp(rmssds.length / 12.0, 0.0, 1.0) * _acf1Quality(acf1),
0.3,
0.95,
);
final conf = ((rmssds.length / 12.0).clamp(0.0, 1.0) * _acf1Quality(acf1))
.clamp(0.3, 0.95);
return Metric<double>(
value: meanRmssd,
confidence: conf,
Expand Down
Loading
Loading