Skip to content
Closed
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
10 changes: 6 additions & 4 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 @@ -60,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 @@ -98,7 +101,7 @@ 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 estimate — ENMO+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 count — true per-step counting is impossible below gait Nyquist | AN-2554-adjacent (see `livePedometer` for the real 100 Hz method) |

### `workout/`
| Function | File | Method | Citation |
Expand All @@ -120,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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ 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
```

624 passed / 6 skipped (630 total), nothing mocked — pure functions, fixture in,
All pass (a handful skipped), nothing mocked — pure functions, fixture in,
assertion out.

## Validation
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: 2 additions & 6 deletions lib/src/onehz/sleep/advanced_stager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1835,12 +1835,8 @@ class AdvancedSleepStager {
double Function(double?) zfun(List<double?> vals) {
final present = [for (final v in vals) if (v != null) v];
if (present.isEmpty) return (_) => 0;
final m = present.reduce((a, b) => a + b) / present.length;
var sd0 = 0.0;
for (final v in present) {
sd0 += (v - m) * (v - m);
}
sd0 = math.sqrt(sd0 / present.length);
final m = mean(present)!;
final sd0 = stddevPop(present)!;
final sd = sd0 == 0 ? 1.0 : sd0;
return (v) => v == null ? 0 : (v - m) / sd;
}
Expand Down
14 changes: 11 additions & 3 deletions lib/src/onehz/workout/observed_max_hr.dart
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ Metric<HrCeiling> sessionHrCeiling(
var trailStart = i;
var trailSum = 0.0;
var trailCount = 0;
// Running max of the trailing sub-window average seen so far as j sweeps
// forward. trailStart only ever advances, so once j moves past a burst
// the trailing sum itself forgets it — bestTrail is what keeps a burst
// near the start of a long hold (or anywhere before a quiet tail) from
// being lost once the window slides past it.
var bestTrail = 0.0;
for (var j = i; j < rows.length; j++) {
if (j > i && rows[j].ts - rows[j - 1].ts > gapMs) break; // stream broke
lo = math.min(lo, rows[j].hr);
Expand All @@ -178,14 +184,16 @@ Metric<HrCeiling> sessionHrCeiling(
trailCount--;
trailStart++;
}
final trailAvg = trailSum / trailCount;
if (trailAvg > bestTrail) bestTrail = trailAvg;
final span = rows[j].ts - rows[i].ts;
if (span < holdMs) continue;
if (span > maxSpanMs) break; // gave this start its fair shot
// The window qualifies on duration. `lo` is the bpm sustained across
// all of it. Only stop once a short burst of real motion actually
// corroborates it, checked against the trailing few seconds rather
// than the whole hold's average.
if (trailSum / trailCount >= gate) {
// corroborated it ANYWHERE in the span so far (bestTrail), not just in
// whatever the trailing window currently covers.
if (bestTrail >= gate) {
if (best == null || lo > best.bpm) {
best = HrCeiling(
bpm: lo,
Expand Down
33 changes: 33 additions & 0 deletions test/onehz/observed_max_hr_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,39 @@ void main() {
expect(m.value!.bpm, 170);
});

test('a burst at the very start of the hold still corroborates', () {
// 40s at 170 bpm: arm-swing burst only in seconds 0-2, quiet after.
// The trailing 3s window sweeps past the burst almost immediately, so
// corroboration has to remember it, not just see it at the current j.
final hr = <HrSample>[];
final accel = <AccelSample>[];
for (var i = 0; i < 40; i++) {
final t = i * 1000.0;
final burst = i < 3;
hr.add(HrSample(t, 170));
accel.add(AccelSample(t, 0, 0, 1.0 + (burst ? 0.25 : 0.005)));
}
final m = sessionHrCeiling(hr, accel, deviceFamily: 'gen4');
expect(m.present, isTrue);
expect(m.value!.bpm, 170);
});

test('a mid-window burst followed by a long quiet tail still corroborates',
() {
// 40s at 170 bpm: burst at seconds 10-12, quiet for the remaining 27s.
final hr = <HrSample>[];
final accel = <AccelSample>[];
for (var i = 0; i < 40; i++) {
final t = i * 1000.0;
final burst = i >= 10 && i < 13;
hr.add(HrSample(t, 170));
accel.add(AccelSample(t, 0, 0, 1.0 + (burst ? 0.25 : 0.005)));
}
final m = sessionHrCeiling(hr, accel, deviceFamily: 'gen4');
expect(m.present, isTrue);
expect(m.value!.bpm, 170);
});

test('a gap in the stream breaks the hold', () {
final a = _run(0, 10, 180, 0.20);
final b = _run(60000, 10, 180, 0.20); // 50 s later
Expand Down
Loading