Skip to content
Open
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
4 changes: 3 additions & 1 deletion ALGORITHMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
71 changes: 63 additions & 8 deletions lib/src/onehz/workout/calories.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<double> hrPerMin, {
required WorkoutUserProfile profile,
required double hrmax,
required double restingHr,
int dayMinutes = 1440,
List<double?>? 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
Expand All @@ -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
Expand Down
112 changes: 112 additions & 0 deletions test/onehz/steps_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<double>.filled(60, 95.0);
final cad = List<double?>.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<double>.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<double?>.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<double>.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<double>.filled(30, 0.0),
profile: profile,
hrmax: hrmax,
restingHr: rhr,
cadenceSpmPerMin: List<double?>.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<double>.filled(10, 95.0),
profile: profile,
hrmax: hrmax,
restingHr: rhr,
cadenceSpmPerMin: List<double?>.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<double>.filled(60, 95.0),
profile: profile,
hrmax: double.nan,
restingHr: rhr,
cadenceSpmPerMin: List<double?>.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');
Expand Down