From 977198f753ad95fe229c69efd847293f9baff4bc Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:37:25 +0530 Subject: [PATCH] fix hr ceiling corroboration + doc drift from round 2 burst corroboration only looked at the current trailing window so a burst early in a hold got forgotten once the window swept past it. now tracks the best trailing average seen over the whole span. added tests for start-edge and mid-window bursts. also cleaned up ALGORITHMS.md/catalog docs that still cited deleted functions (edwards trimp, dailyStepEstimate, sleepRegularityIndex, vo2maxEstimate/physiologicalAge), linked the 1hz catalog from ALGORITHMS.md, fixed the stale test count in README, and swapped the hand-rolled mean/stddev in advanced_stager's zfun for the shared helpers. --- ALGORITHMS.md | 10 ++++--- README.md | 2 +- docs/ALGORITHM_CATALOG_1HZ.md | 2 +- lib/src/onehz/sleep/advanced_stager.dart | 8 ++---- lib/src/onehz/workout/observed_max_hr.dart | 14 +++++++-- test/onehz/observed_max_hr_test.dart | 33 ++++++++++++++++++++++ 6 files changed, 54 insertions(+), 15 deletions(-) diff --git a/ALGORITHMS.md b/ALGORITHMS.md index d487503..4f90a46 100644 --- a/ALGORITHMS.md +++ b/ALGORITHMS.md @@ -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` envelope: ```dart @@ -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 | @@ -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 | @@ -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 | — | --- diff --git a/README.md b/README.md index c7b6cf2..b5b8757 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/ALGORITHM_CATALOG_1HZ.md b/docs/ALGORITHM_CATALOG_1HZ.md index 8ffda11..a22f937 100644 --- a/docs/ALGORITHM_CATALOG_1HZ.md +++ b/docs/ALGORITHM_CATALOG_1HZ.md @@ -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` diff --git a/lib/src/onehz/sleep/advanced_stager.dart b/lib/src/onehz/sleep/advanced_stager.dart index dde5f4b..070e973 100644 --- a/lib/src/onehz/sleep/advanced_stager.dart +++ b/lib/src/onehz/sleep/advanced_stager.dart @@ -1835,12 +1835,8 @@ class AdvancedSleepStager { double Function(double?) zfun(List 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; } diff --git a/lib/src/onehz/workout/observed_max_hr.dart b/lib/src/onehz/workout/observed_max_hr.dart index bfa0dff..ddb9efc 100644 --- a/lib/src/onehz/workout/observed_max_hr.dart +++ b/lib/src/onehz/workout/observed_max_hr.dart @@ -166,6 +166,12 @@ Metric 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); @@ -178,14 +184,16 @@ Metric 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, diff --git a/test/onehz/observed_max_hr_test.dart b/test/onehz/observed_max_hr_test.dart index d9609ea..626724f 100644 --- a/test/onehz/observed_max_hr_test.dart +++ b/test/onehz/observed_max_hr_test.dart @@ -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 = []; + final accel = []; + 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 = []; + final accel = []; + 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