From 0c1f73a5620672d65b1fe0eed21c135c2a5e6f1a Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:02:34 +0530 Subject: [PATCH] fix hr ceiling missing uneven-motion holds, clean up stale readme/algo docs sessionHrCeiling bailed on the first duration-qualifying window even when its motion average failed the gate, so a real held ceiling with motion concentrated at the edges (quiet middle) never got found. now it keeps extending the window (capped at 4x holdSeconds) until motion actually corroborates before giving up on a start index. also fixed a few stale doc claims: test count was hardcoded at 290 (actual is 624 passed / 6 skipped), the workout family still advertised explicit/ retroactive detection that was deleted, and both README and ALGORITHMS.md still pointed at foundations/ppg_sqi.dart which doesn't exist on main. --- ALGORITHMS.md | 2 -- README.md | 9 +++--- lib/src/onehz/workout/observed_max_hr.dart | 37 ++++++++++++++-------- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/ALGORITHMS.md b/ALGORITHMS.md index 14db222..d487503 100644 --- a/ALGORITHMS.md +++ b/ALGORITHMS.md @@ -46,7 +46,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 @@ -104,7 +103,6 @@ Grouped by family (subdirectory under `lib/src/onehz/`). File paths are relative ### `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 | diff --git a/README.md b/README.md index afd6d50..f9be7a6 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Eight families, each its own subdirectory with its own sub-barrel, built on two 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 @@ -86,8 +86,8 @@ foundation layers: - **`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. +- **`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 @@ -110,7 +110,8 @@ 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. +624 passed / 6 skipped (630 total), nothing mocked — pure functions, fixture in, +assertion out. ## If you want to add a metric diff --git a/lib/src/onehz/workout/observed_max_hr.dart b/lib/src/onehz/workout/observed_max_hr.dart index 79a08e8..7e208a6 100644 --- a/lib/src/onehz/workout/observed_max_hr.dart +++ b/lib/src/onehz/workout/observed_max_hr.dart @@ -143,10 +143,16 @@ Metric sessionHrCeiling( final holdMs = holdSeconds * 1000.0; final gapMs = maxGapSeconds * 1000.0; + // A real held effort's corroborating motion is not necessarily even across + // the hold (e.g. a couple of seconds of arm swing at each end of a quiet + // middle), so a start that fails the motion gate on the MINIMAL qualifying + // window still gets to extend further before giving up — capped, so one + // quiet start can't turn this into an O(n²) scan of a whole day. + final maxSpanMs = holdMs * 4; HrCeiling? best; - // ponytail: O(n · holdSeconds) — one session at 1 Hz, so ~15 passes over a - // few thousand samples. A monotonic-deque sliding minimum if it ever runs - // over a whole day. + // ponytail: O(n · holdSeconds) — one session at 1 Hz, so a bounded number of + // passes over a few thousand samples. A monotonic-deque sliding minimum if + // it ever runs over a whole day. for (var i = 0; i < rows.length; i++) { var lo = rows[i].hr; var motionSum = 0.0; @@ -158,19 +164,22 @@ Metric sessionHrCeiling( count++; final span = rows[j].ts - rows[i].ts; if (span < holdMs) continue; - // The window qualifies on duration. `lo` is the bpm sustained across all - // of it; extending further can only lower it, so this is the best this - // start can do and we stop. + 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 the motion actually corroborates — that's + // the earliest point extending further can only lower `lo` for no gain. final motion = motionSum / count; - if (motion >= gate && (best == null || lo > best.bpm)) { - best = HrCeiling( - bpm: lo, - tsMs: rows[i].ts, - heldSeconds: (span / 1000).round(), - motionG: motion, - ); + if (motion >= gate) { + if (best == null || lo > best.bpm) { + best = HrCeiling( + bpm: lo, + tsMs: rows[i].ts, + heldSeconds: (span / 1000).round(), + motionG: motion, + ); + } + break; } - break; } }