From 11e00e9f1f9735ec49b27d4c965b0f0ff16c5bfe Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Tue, 25 Aug 2026 19:39:55 +0200 Subject: [PATCH] feat(calories): price sub-gate walking minutes from measured cadence (CADENCE-Adults) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MOT-02 knowingly traded walking away: the HR-flex gate bills nothing below the ACSM moderate floor because Keytel has no fitted data there, so a walk at 95 bpm added ZERO active kcal for its entire duration (edge report: "Walking calories are not counted" — the workout and its steps record fine, Active energy never moves). MT-05 established the 1 Hz accel cannot fill that gap. Measured cadence can: it is the one signal on this platform that actually resolves gait (the 100 Hz pedometer), and CADENCE-Adults (Tudor-Locke et al. 2019, IJBNPA 16:8) publishes the cadence↔MET line for exactly this region — heuristic thresholds 100/110/120/130 steps/min for 3/4/5/6 METs. dailyEnergy gains an optional cadenceSpmPerMin series, index-aligned with hrPerMin (null = nobody measured that minute — which is most minutes; the pedometer only runs while the phone holds the live link): - A minute the HR gate accepts bills by HR alone, exactly as before. HR sees intensity cadence cannot, and a minute is never billed twice. - A minute the HR gate refuses (below flex, or no HR at all — measured gait stands on its own), whose measured cadence is at/above the study's own moderate floor (100 spm), bills (MET − 1) basal-minutes of surplus via the new metFromCadenceSpm — linear between the published anchors, clamped at both ends of the fitted range (under 100 spm the study does not price it; over 130 spm is running, which drives HR over the gate and bills there). - The result record gains a `walking` component (already included in `active`) so callers can disclose how much of the day's active energy came from measured walking rather than HR. - No cadence series → byte-identical behaviour to before. - Misaligned series throws; unusable anchors still abstain, cadence or not. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015TxisrChNFxtVMXFFNRGtV --- ALGORITHMS.md | 4 +- lib/src/onehz/workout/calories.dart | 71 ++++++++++++++++-- test/onehz/steps_test.dart | 112 ++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 9 deletions(-) diff --git a/ALGORITHMS.md b/ALGORITHMS.md index 6534cd1..14db222 100644 --- a/ALGORITHMS.md +++ b/ALGORITHMS.md @@ -108,6 +108,7 @@ Grouped by family (subdirectory under `lib/src/onehz/`). File paths are relative | `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 | +| `Calories.metFromCadenceSpm` | `workout/calories.dart` | walking METs from measured cadence, for sub-flex-gate minutes (100/110/120/130 spm ↔ 3/4/5/6 METs, linear between, clamped) | Tudor-Locke et al. 2019 (CADENCE-Adults) | ### `wellness/` | Function | File | Method | Citation | @@ -149,6 +150,7 @@ Lipponen & Tarvainen 2019 · Laguna, Moody & Mark 1998 · Bigger 1992 · Bauer e Alavi et al. 2022 · Mishra et al. 2020 · Plews et al. 2013 · Halberg & Nelson 1979 · Banister 1991 · Edwards 1993 · Baevsky & Berseneva 2008 · McCraty & Zayas 2014 · McCraty, Atkinson, Tomasino & Bradley 2009 · van Hees et al. · Rosenblum et al. 2024 · Phillips et -al. 2017 · Pimentel et al. · Brage et al. 2004 · Keytel et al. 2005 · Hopkins 2004 · +al. 2017 · Pimentel et al. · Brage et al. 2004 · Keytel et al. 2005 · Tudor-Locke et al. +2019 · Hopkins 2004 · Mahalanobis 1936 · Killick et al. 2012 · Wittmann & Roenneberg 2006 · Pietilä et al. 2018 · Malik et al. diff --git a/lib/src/onehz/workout/calories.dart b/lib/src/onehz/workout/calories.dart index 03e6ea3..2edf051 100644 --- a/lib/src/onehz/workout/calories.dart +++ b/lib/src/onehz/workout/calories.dart @@ -281,13 +281,51 @@ class Calories { /// case and for the same reason: no gate, no honest energy figure. It is /// never the zeros, because a zero here reads downstream as a measured day /// with nothing in it. - static ({double total, double active, double basal})? dailyEnergy( + /// METs for a measured walking cadence, or null when the cadence carries no + /// honest MET. + /// + /// Tudor-Locke et al. 2019 (CADENCE-Adults, Int J Behav Nutr Phys Act 16:8): + /// heuristic cadence thresholds of 100, 110, 120 and 130 steps/min + /// correspond to 3, 4, 5 and 6 METs in adults. Linear between the anchors; + /// CLAMPED at both ends of the fitted range rather than extrapolated — + /// below 100 spm is under the study's own moderate floor (the same boundary + /// [activeHRRFraction] holds on the HR side, ACSM moderate), and above + /// 130 spm is running, which drives HR over the flex gate and bills there. + static double? metFromCadenceSpm(double cadenceSpm) { + if (!cadenceSpm.isFinite || cadenceSpm < 100.0) return null; + final met = 3.0 + (cadenceSpm - 100.0) * 0.1; + return met > 6.0 ? 6.0 : met; + } + + /// [cadenceSpmPerMin], when given, must be index-aligned with [hrPerMin] + /// (one entry per minute; null = no measured cadence that minute — which is + /// most minutes: the pedometer that can resolve gait only runs while the + /// phone holds the live link). It fills the exact gap MOT-02 knowingly + /// opened: the HR-flex gate refuses everything below the ACSM moderate + /// floor because Keytel has no fitted data there, so a walk at 95 bpm + /// added ZERO active kcal for its whole duration. Cadence is the one signal + /// here that measures walking (MT-05 showed the 1 Hz accel cannot), and + /// CADENCE-Adults prices it: a minute the HR gate refuses, whose cadence is + /// at/above the study's own moderate floor, bills (MET − 1) basal-minutes + /// of surplus via [metFromCadenceSpm]. A minute the HR gate accepts bills + /// by HR alone — HR sees intensity cadence cannot, and a minute is never + /// billed twice. The `walking` component of the result is that surplus, + /// already included in `active`. + static ({double total, double active, double basal, double walking})? + dailyEnergy( List hrPerMin, { required WorkoutUserProfile profile, required double hrmax, required double restingHr, int dayMinutes = 1440, + List? cadenceSpmPerMin, }) { + if (cadenceSpmPerMin != null && + cadenceSpmPerMin.length != hrPerMin.length) { + throw ArgumentError( + 'cadenceSpmPerMin (${cadenceSpmPerMin.length}) must align with ' + 'hrPerMin (${hrPerMin.length}): one entry per wake minute'); + } // (weight/height/age still can't be told apart from their defaults here: // WorkoutUserProfile's own constructor bakes 70/170/30 in at construction, // so by the time a profile object arrives there is no way left to tell "the @@ -304,17 +342,34 @@ class Calories { final basalPerMin = bmrDay / 1440.0; var active = 0.0; - for (final hr in hrPerMin) { + var walking = 0.0; + for (var i = 0; i < hrPerMin.length; i++) { + final hr = hrPerMin[i]; // `hr < flexHr` is false for NaN, so an unfiltered non-finite minute // would bill active and carry its NaN into the day total. - if (!hr.isFinite || hr < flexHr) continue; // below flex → basal only - final activePerMin = - activeKcalPerS(coeffs, hr, hrmax, weightKg, age) * 60.0; - final surplus = activePerMin - basalPerMin; - if (surplus > 0) active += surplus; + if (hr.isFinite && hr >= flexHr) { + final activePerMin = + activeKcalPerS(coeffs, hr, hrmax, weightKg, age) * 60.0; + final surplus = activePerMin - basalPerMin; + if (surplus > 0) active += surplus; + continue; // HR billed the minute — cadence never doubles it. + } + // Below flex (or no HR at all — measured gait stands on its own): + // a measured walking cadence prices the minute the HR gate refused. + final cad = cadenceSpmPerMin?[i]; + if (cad == null) continue; + final met = metFromCadenceSpm(cad); + if (met == null) continue; + walking += (met - 1.0) * basalPerMin; } + active += walking; final basal = basalPerMin * dayMinutes; - return (total: basal + active, active: active, basal: basal); + return ( + total: basal + active, + active: active, + basal: basal, + walking: walking, + ); } /// Estimate (kcal, kJ) for a workout bout. Each sample is weighted by the diff --git a/test/onehz/steps_test.dart b/test/onehz/steps_test.dart index e69a9b4..d479318 100644 --- a/test/onehz/steps_test.dart +++ b/test/onehz/steps_test.dart @@ -904,6 +904,118 @@ void main() { reason: 'ACSM moderate floor: 40 % HRR ≡ 64 % HRmax'); }); + group('walking cadence term (CADENCE-Adults)', () { + // MOT-02 knowingly traded walking away: HR-flex bills nothing below the + // ACSM moderate floor because Keytel has no fitted data there, and a + // walk at 95 bpm added ZERO active kcal for its entire duration (edge + // report: "Walking calories are not counted"). MT-05 established the + // 1 Hz accel cannot fill that gap. MEASURED CADENCE can: it is the one + // gait signal the platform actually has (the 100 Hz pedometer), and + // CADENCE-Adults publishes the cadence↔MET line for exactly this + // region. Minutes the HR gate refuses are billed from cadence instead — + // never both, and never from a cadence nobody measured. + const profile = WorkoutUserProfile( + weightKg: 80, heightCm: 180, age: 30, sex: 'male'); + const hrmax = 190.0, rhr = 60.0; + final basalPerMin = + Calories.mifflinBmrKcalDay(80, 180, 30, 'male') / 1440.0; + + test('metFromCadenceSpm follows the published anchors', () { + // Tudor-Locke 2019: heuristic thresholds 100/110/120/130 steps/min + // for 3/4/5/6 METs. Linear between anchors, clamped at the ends of + // the fitted range — 140 spm is running, and running bills by HR. + expect(Calories.metFromCadenceSpm(100), closeTo(3.0, 1e-9)); + expect(Calories.metFromCadenceSpm(110), closeTo(4.0, 1e-9)); + expect(Calories.metFromCadenceSpm(120), closeTo(5.0, 1e-9)); + expect(Calories.metFromCadenceSpm(130), closeTo(6.0, 1e-9)); + expect(Calories.metFromCadenceSpm(140), closeTo(6.0, 1e-9)); + expect(Calories.metFromCadenceSpm(99.9), isNull, + reason: 'below the moderate floor the study does not price it'); + expect(Calories.metFromCadenceSpm(double.nan), isNull); + }); + + test('a below-gate walk with measured cadence finally bills', () { + // One hour at 95 bpm, 110 spm — the reported walk. HR-only: 0 kcal. + final hr = List.filled(60, 95.0); + final cad = List.filled(60, 110.0); + final without = Calories.dailyEnergy(hr, + profile: profile, hrmax: hrmax, restingHr: rhr)!; + expect(without.active, 0.0, + reason: 'the HR gate alone still refuses — unchanged'); + final with_ = Calories.dailyEnergy(hr, + profile: profile, + hrmax: hrmax, + restingHr: rhr, + cadenceSpmPerMin: cad)!; + // 4 METs → surplus (4−1)·basal per minute, 60 minutes. + expect(with_.walking, closeTo(60 * 3 * basalPerMin, 0.5)); + expect(with_.active, closeTo(with_.walking, 1e-9), + reason: 'no HR-billed minutes in this hour'); + expect(with_.total, closeTo(with_.basal + with_.active, 1e-6)); + }); + + test('a minute the HR gate bills is never ALSO billed from cadence', () { + final hr = List.filled(60, 150.0); // above gate: HR bills + final byHr = Calories.dailyEnergy(hr, + profile: profile, hrmax: hrmax, restingHr: rhr)!; + final both = Calories.dailyEnergy(hr, + profile: profile, + hrmax: hrmax, + restingHr: rhr, + cadenceSpmPerMin: List.filled(60, 120.0))!; + expect(both.active, closeTo(byHr.active, 1e-9), + reason: 'HR sees intensity cadence cannot; it wins the minute'); + expect(both.walking, 0.0); + }); + + test('an unmeasured or ambling minute stays basal', () { + final hr = List.filled(3, 95.0); + final e = Calories.dailyEnergy(hr, + profile: profile, + hrmax: hrmax, + restingHr: rhr, + cadenceSpmPerMin: [null, 85.0, double.infinity])!; + expect(e.walking, 0.0, + reason: 'null = nobody measured; 85 spm = below the moderate ' + 'floor; non-finite = not a measurement'); + expect(e.active, 0.0); + }); + + test('measured gait on an off-skin-HR minute still bills', () { + // HR 0 (poor contact) while the pedometer counts a real walk: the + // cadence measurement stands on its own. The HR branch already skips + // the minute; the walking branch must not require an HR to exist. + final e = Calories.dailyEnergy(List.filled(30, 0.0), + profile: profile, + hrmax: hrmax, + restingHr: rhr, + cadenceSpmPerMin: List.filled(30, 105.0))!; + expect(e.walking, closeTo(30 * 2.5 * basalPerMin, 0.5)); // 3.5 METs + }); + + test('a misaligned cadence series is a caller bug, said out loud', () { + expect( + () => Calories.dailyEnergy(List.filled(10, 95.0), + profile: profile, + hrmax: hrmax, + restingHr: rhr, + cadenceSpmPerMin: List.filled(9, 110.0)), + throwsArgumentError); + }); + + test('unusable anchors still abstain, cadence or not', () { + // The contract is unchanged: no gate, no energy figure. Walking kcal + // published alone would be a partial day wearing a whole day's key. + expect( + Calories.dailyEnergy(List.filled(60, 95.0), + profile: profile, + hrmax: double.nan, + restingHr: rhr, + cadenceSpmPerMin: List.filled(60, 110.0)), + isNull); + }); + }); + test('an exercise block adds active calories on top of basal', () { final profile = const WorkoutUserProfile( weightKg: 80, heightCm: 180, age: 30, sex: 'male');