From 9d6ca541514f2d13d1ce3407a36dd40edd1b38d0 Mon Sep 17 00:00:00 2001 From: SATHVIK SVS Date: Mon, 10 Aug 2026 21:25:10 +0530 Subject: [PATCH 1/7] Keytel publishes two models: use the one that reads VO2max The active-EE estimator has always run Keytel 2005's age/mass/sex model. The same paper publishes a second one that adds a VO2max term, and it is the more accurate of the pair: fitness is what decides how much energy a given heart rate represents, because a higher VO2max means a greater stroke volume, so the same beat moves more oxygen. Without that term the model has to substitute the derivation cohort's mean fitness for everybody, which reads high for untrained people and low for athletes. Each sex's coefficient block gains the fitness-adjusted constants, and activeKcalPerS picks the model per call. dailyEnergy and estimateBoutCalories both take an optional vo2max and thread it down. OPTIONAL EVERYWHERE. Omit it and every caller gets exactly the numbers it got before, byte for byte, which is what the two backward-compatibility tests pin. A non-positive or non-finite value is treated as absent rather than fed to the regression: VO2max is strictly positive, and 0 is this package's "not measured" shape, not a reading. VO2max belongs to the ACTIVE term only. Below the bout gate, and for the Mifflin/Harris-Benedict floors, there is no fitness term at all. Expected values in the tests are computed by hand from the published equations so they pin the arithmetic rather than the implementation. --- lib/src/onehz/workout/calories.dart | 73 ++++++++++-- test/onehz/calories_vo2max_test.dart | 160 +++++++++++++++++++++++++++ 2 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 test/onehz/calories_vo2max_test.dart diff --git a/lib/src/onehz/workout/calories.dart b/lib/src/onehz/workout/calories.dart index dbf4a1c..ca07943 100644 --- a/lib/src/onehz/workout/calories.dart +++ b/lib/src/onehz/workout/calories.dart @@ -39,6 +39,22 @@ class CalorieCoeffs { final double workoutWeight; final double workoutAge; final double workoutAlpha; + + // Keytel's SECOND published active model, which adds a VO2max term. It is + // the more accurate of the pair: fitness is what decides how much energy a + // given heart rate represents, because a higher VO2max means a greater + // stroke volume, so the same beat moves more oxygen. The age/mass/sex-only + // model above has to bake in the derivation cohort's mean fitness instead, + // which is why it reads high for untrained people and low for athletes. + // + // Used only when the caller supplies a VO2max; every entry point keeps the + // original model as its fallback so an absent fitness anchor changes nothing. + final double fitAlpha; + final double fitAge; + final double fitWeight; + final double fitVo2max; + final double fitHR; + const CalorieCoeffs({ required this.restingAlpha, required this.restingWeight, @@ -48,11 +64,18 @@ class CalorieCoeffs { required this.workoutWeight, required this.workoutAge, required this.workoutAlpha, + required this.fitAlpha, + required this.fitAge, + required this.fitWeight, + required this.fitVo2max, + required this.fitHR, }); } /// HR-based calorie estimation (Keytel 2005 active + revised Harris–Benedict BMR). class Calories { + // fitAlpha folds Keytel's shared -59.3954 intercept together with the + // male-only -36.3781 term, so each block carries one flat intercept. static const CalorieCoeffs male = CalorieCoeffs( restingAlpha: 88.362, restingWeight: 13.397, @@ -62,6 +85,11 @@ class Calories { workoutWeight: 0.1988, workoutAge: 0.2017, workoutAlpha: -55.0969, + fitAlpha: -95.7735, // -59.3954 - 36.3781 + fitAge: 0.271, + fitWeight: 0.394, + fitVo2max: 0.404, + fitHR: 0.634, ); static const CalorieCoeffs female = CalorieCoeffs( restingAlpha: 447.593, @@ -72,6 +100,11 @@ class Calories { workoutWeight: -0.1263, workoutAge: 0.0740, workoutAlpha: -20.4022, + fitAlpha: -59.3954, + fitAge: 0.274, + fitWeight: 0.103, + fitVo2max: 0.380, + fitHR: 0.450, ); static const CalorieCoeffs nonbinary = CalorieCoeffs( restingAlpha: 267.9775, @@ -82,6 +115,11 @@ class Calories { workoutWeight: 0.03625, workoutAge: 0.13785, workoutAlpha: -37.74955, + fitAlpha: -77.58445, + fitAge: 0.2725, + fitWeight: 0.2485, + fitVo2max: 0.392, + fitHR: 0.542, ); /// Bout active gate: a sample burns the Keytel active rate above @@ -116,12 +154,29 @@ class Calories { } /// Active EE rate (kcal/s) — Keytel 2005 kJ/min ÷ workoutDivisor. + /// + /// With a [vo2max], uses Keytel's fitness-adjusted model (see + /// [CalorieCoeffs.fitAlpha]); without one, the age/mass/sex model, unchanged. + /// A non-positive [vo2max] is treated as absent rather than fed to the + /// regression — VO2max is a strictly positive quantity, and 0 is this + /// package's "not measured" shape, not a real reading. static double activeKcalPerS( - CalorieCoeffs c, double hr, double hrmax, double weightKg, double age) { - final eeKjMin = c.workoutHR * math.min(hr, hrmax) + - c.workoutWeight * weightKg + - c.workoutAge * age + - c.workoutAlpha; + CalorieCoeffs c, double hr, double hrmax, double weightKg, double age, + {double? vo2max}) { + final cappedHr = math.min(hr, hrmax); + final double eeKjMin; + if (vo2max != null && vo2max.isFinite && vo2max > 0) { + eeKjMin = c.fitAlpha + + c.fitAge * age + + c.fitWeight * weightKg + + c.fitVo2max * vo2max + + c.fitHR * cappedHr; + } else { + eeKjMin = c.workoutHR * cappedHr + + c.workoutWeight * weightKg + + c.workoutAge * age + + c.workoutAlpha; + } return math.max(0.0, eeKjMin) / workoutDivisor; } @@ -172,6 +227,7 @@ class Calories { double? hrmax, double activeFraction = 0.50, int dayMinutes = 1440, + double? vo2max, }) { // the 220-age hrmax fallback used to just silently apply with nothing // telling the caller it wasnt a real anchor. usedDefaultHrmax lets the @@ -196,7 +252,8 @@ class Calories { for (final hr in hrPerMin) { if (hr < flexHr) continue; // below flex point → basal only final activePerMin = - activeKcalPerS(coeffs, hr, effHRmax, weightKg, age) * 60.0; + activeKcalPerS(coeffs, hr, effHRmax, weightKg, age, vo2max: vo2max) * + 60.0; final surplus = activePerMin - basalPerMin; if (surplus > 0) active += surplus; } @@ -224,6 +281,7 @@ class Calories { double? hrmax, double? restingHr, double mergeGapCapS = 150.0, + double? vo2max, }) { final weightKg = profile.weightKg > 0 ? profile.weightKg : 70.0; final heightCm = profile.heightCm > 0 ? profile.heightCm : 170.0; @@ -262,7 +320,8 @@ class Calories { totalKcal += restingRate * dur; } else { totalKcal += - activeKcalPerS(coeffs, b, effHRmax, weightKg, age) * dur; + activeKcalPerS(coeffs, b, effHRmax, weightKg, age, vo2max: vo2max) * + dur; } } return ( diff --git a/test/onehz/calories_vo2max_test.dart b/test/onehz/calories_vo2max_test.dart new file mode 100644 index 0000000..171b2fa --- /dev/null +++ b/test/onehz/calories_vo2max_test.dart @@ -0,0 +1,160 @@ +// Keytel 2005 publishes TWO active-EE models. The one this package shipped +// reads age, body mass and sex; the other adds VO2max and is the more accurate +// of the pair, because fitness is what decides how much energy a given heart +// rate actually represents — a trained athlete at 140 bpm is moving far more +// oxygen than an untrained person at 140 bpm. +// +// The fitness-adjusted model (as published, in kJ/min): +// male: -59.3954 - 36.3781 + 0.271*age + 0.394*wt + 0.404*VO2max + 0.634*HR +// female: -59.3954 + 0.274*age + 0.103*wt + 0.380*VO2max + 0.450*HR +// +// VO2max is OPTIONAL everywhere. Absent, every caller gets exactly the numbers +// it got before — this must not silently move calories for anyone whose +// fitness anchor cannot be estimated. +// +// Expected values below are computed by hand from those two equations so the +// test pins the published arithmetic rather than the implementation. + +import 'package:openstrap_analytics/onehz.dart'; +import 'package:test/test.dart'; + +const _male = WorkoutUserProfile( + weightKg: 72, + heightCm: 178, + age: 34, + sex: 'male', +); +const _female = WorkoutUserProfile( + weightKg: 72, + heightCm: 178, + age: 34, + sex: 'female', +); +const _nonbinary = WorkoutUserProfile( + weightKg: 72, + heightCm: 178, + age: 34, + sex: 'nonbinary', +); + +// Tanaka HRmax for 34 y; bout gate = 55 + 0.30*(184.2-55) = 93.76, so the whole +// stream below is active. +const _hrMax = 184.2; +const _restingHr = 55.0; + +final _ts = [for (var t = 0; t < 600; t++) t]; +final _bpm = [for (var t = 0; t < 600; t++) 140.0]; + +double _bout(WorkoutUserProfile p, {double? vo2max}) => + Calories.estimateBoutCalories( + _ts, + _bpm, + profile: p, + hrmax: _hrMax, + restingHr: _restingHr, + vo2max: vo2max, + ).kcal; + +void main() { + group('Keytel fitness-adjusted active EE', () { + test('without a VO2max the published age/mass/sex model is unchanged', () { + // -55.0969 + 0.6309*140 + 0.1988*72 + 0.2017*34 = 54.4005 kJ/min + // 54.4005 / 251.04 * 600 s = 130.02 kcal + expect(_bout(_male), closeTo(130.02, 0.05)); + }); + + test('a male bout with a VO2max uses the fitness-adjusted coefficients', () { + // -95.7735 + 0.271*34 + 0.394*72 + 0.404*50 + 0.634*140 = 50.7685 kJ/min + // 50.7685 / 251.04 * 600 s = 121.34 kcal + expect(_bout(_male, vo2max: 50), closeTo(121.34, 0.05)); + }); + + test('a female bout with a VO2max uses its own coefficient block', () { + // -59.3954 + 0.274*34 + 0.103*72 + 0.380*50 + 0.450*140 = 39.3366 kJ/min + // 39.3366 / 251.04 * 600 s = 94.02 kcal + expect(_bout(_female, vo2max: 50), closeTo(94.02, 0.05)); + }); + + test('a fitter athlete burns MORE at the same heart rate', () { + // The whole reason the term is worth threading. Higher VO2max means a + // greater stroke volume, so the same heart rate moves more oxygen. + final unfit = _bout(_male, vo2max: 35); + final mid = _bout(_male, vo2max: 50); + final fit = _bout(_male, vo2max: 70); + + expect(unfit, lessThan(mid)); + expect(mid, lessThan(fit)); + // -95.7735 + 9.214 + 28.368 + 0.404*70 + 88.76 = 58.8485 kJ/min -> 140.65 + expect(fit, closeTo(140.65, 0.05)); + }); + + test('nonbinary stays the mean of the two published blocks', () { + // Matches how this package already resolves the age/mass/sex model, and + // the model is linear in its coefficients, so the mean block and the mean + // of the two results are the same number. + expect( + _bout(_nonbinary, vo2max: 50), + closeTo((_bout(_male, vo2max: 50) + _bout(_female, vo2max: 50)) / 2, 0.01), + ); + }); + + test('resting samples still take the BMR floor, VO2max or not', () { + // VO2max belongs to the ACTIVE term only. Below the gate the bout bills + // Harris-Benedict, which has no fitness term at all. + final easy = [for (var t = 0; t < 300; t++) 65.0]; + final withVo2 = Calories.estimateBoutCalories( + [for (var t = 0; t < 300; t++) t], + easy, + profile: _male, + hrmax: _hrMax, + restingHr: _restingHr, + vo2max: 50, + ); + final without = Calories.estimateBoutCalories( + [for (var t = 0; t < 300; t++) t], + easy, + profile: _male, + hrmax: _hrMax, + restingHr: _restingHr, + ); + expect(withVo2.kcal, closeTo(without.kcal, 1e-9)); + }); + }); + + group('dailyEnergy threads the fitness term', () { + // 60 min at 140 bpm (above the 0.50*184.2 = 92.1 flex point) + a quiet rest. + final day = [ + for (var i = 0; i < 60; i++) 140.0, + for (var i = 0; i < 1380; i++) 55.0, + ]; + + test('active energy moves with VO2max, basal does not', () { + final base = Calories.dailyEnergy(day, profile: _male, hrmax: _hrMax); + final fit = Calories.dailyEnergy( + day, + profile: _male, + hrmax: _hrMax, + vo2max: 70, + ); + + expect(fit.active, greaterThan(base.active)); + expect( + fit.basal, + closeTo(base.basal, 1e-9), + reason: 'Mifflin has no fitness term — only the active surplus moves', + ); + }); + + test('omitting VO2max leaves the daily figures byte-for-byte unchanged', () { + final a = Calories.dailyEnergy(day, profile: _male, hrmax: _hrMax); + final b = Calories.dailyEnergy( + day, + profile: _male, + hrmax: _hrMax, + vo2max: null, + ); + expect(a.total, b.total); + expect(a.active, b.active); + }); + }); +} From 8e46518c3619af131234fde1e114c2c56f57c8bd Mon Sep 17 00:00:00 2001 From: SATHVIK SVS Date: Mon, 10 Aug 2026 21:31:04 +0530 Subject: [PATCH 2/7] review: cover every branch of the vo2max guard, not just null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard rejects on three separate conditions — null, non-finite, and non-positive — and all three have to land on the same fallback. Only null was covered. Zero and negatives are "not measured" rather than readings, since VO2max is strictly positive. A non-finite value matters more: it would otherwise propagate straight through the linear term and persist a NaN/Infinity daily total, not just spoil one bout. Asserted with exact equality rather than closeTo, because a correct fallback runs the identical code path — anything short of a bit-identical answer means the value got through. Verified the cases have teeth by weakening the guard to `vo2max != null` and confirming both tests fail. Covers both entry points; the daily one also asserts the total stays finite. --- test/onehz/calories_vo2max_test.dart | 51 ++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/test/onehz/calories_vo2max_test.dart b/test/onehz/calories_vo2max_test.dart index 171b2fa..fc6f8fb 100644 --- a/test/onehz/calories_vo2max_test.dart +++ b/test/onehz/calories_vo2max_test.dart @@ -63,6 +63,34 @@ void main() { expect(_bout(_male), closeTo(130.02, 0.05)); }); + test('an unusable VO2max falls back rather than entering the regression', + () { + // The guard rejects on three separate conditions and every one of them + // has to land on the SAME fallback. VO2max is a strictly positive + // quantity, so 0 and negatives are "not measured" rather than readings, + // and a non-finite value would otherwise propagate straight through the + // linear term and poison the result as NaN/Infinity kcal. + // + // Exact equality, not closeTo: a correct fallback runs the identical code + // path, so anything other than a bit-identical answer means the guard let + // the value through. + final baseline = _bout(_male); + for (final vo2max in [ + 0, + -1, + -62.5, + double.nan, + double.infinity, + double.negativeInfinity, + ]) { + expect( + _bout(_male, vo2max: vo2max), + baseline, + reason: 'vo2max $vo2max must fall back to the age/mass/sex model', + ); + } + }); + test('a male bout with a VO2max uses the fitness-adjusted coefficients', () { // -95.7735 + 0.271*34 + 0.394*72 + 0.404*50 + 0.634*140 = 50.7685 kJ/min // 50.7685 / 251.04 * 600 s = 121.34 kcal @@ -156,5 +184,28 @@ void main() { expect(a.total, b.total); expect(a.active, b.active); }); + + test('an unusable VO2max leaves the daily figures unchanged too', () { + // Same guard, second entry point. A NaN reaching the active term here + // would poison a persisted daily total, not just one bout. + final base = Calories.dailyEnergy(day, profile: _male, hrmax: _hrMax); + for (final vo2max in [ + 0, + -1, + double.nan, + double.infinity, + double.negativeInfinity, + ]) { + final e = Calories.dailyEnergy( + day, + profile: _male, + hrmax: _hrMax, + vo2max: vo2max, + ); + expect(e.total, base.total, reason: 'vo2max $vo2max'); + expect(e.active, base.active, reason: 'vo2max $vo2max'); + expect(e.total.isFinite, isTrue); + } + }); }); } From dbb7f5e08e43e8c8bb8b11ca2809acfb26bf1115 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 10 Aug 2026 22:17:35 +0530 Subject: [PATCH 3/7] bound the VO2max the fitness model will accept, and reach the detector with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard checked the sign and finiteness and nothing else, so the fitness term was a bare multiplication with no ceiling: a million through a ten-minute bout came back as 965,000 kcal. The value arriving there is not measured. It is usually a resting-HR ratio estimate, so it carries every artifact of the resting HR it divides by, and one bad night at 30 bpm against a 200 bpm HRmax reads as 102 mL/kg/min, which prices a bout 32% high and looks perfectly ordinary doing it. Unit confusion fails the same way at the other end. A value outside the range a human can occupy is rejected rather than clamped: it says nothing about this person's fitness, so the age/mass/sex model is the honest answer. Values inside the range but outside Keytel's cohort are still used — extrapolating a published linear model is not the same thing as a broken input. Both results now carry which of the two models priced them, for the reason usedDefaultHrmax already exists: a rejected anchor is otherwise indistinguishable from one that was never supplied. WorkoutDetect never got the parameter, so a detected bout and a hand-logged one over the same trace would have been scored on different models — about 30% apart, same app, same day. The nonbinary mean is only an identity while both blocks stay above the zero floor. The floor is applied after the mean block is evaluated, so a profile extreme enough to drive one sex negative breaks it. Pinned rather than reworded, since the alternative reading — averaging two clamped results — is not what the mean coefficient block means. --- lib/src/onehz/workout/calories.dart | 61 ++++++++++-- lib/src/onehz/workout/workout_detect.dart | 7 ++ test/onehz/calories_vo2max_test.dart | 113 +++++++++++++++++++++- 3 files changed, 170 insertions(+), 11 deletions(-) diff --git a/lib/src/onehz/workout/calories.dart b/lib/src/onehz/workout/calories.dart index ca07943..e19b40d 100644 --- a/lib/src/onehz/workout/calories.dart +++ b/lib/src/onehz/workout/calories.dart @@ -126,6 +126,35 @@ class Calories { /// resting + this fraction of HRR, else the resting BMR rate. static const double activeHRRFraction = 0.30; + /// Plausibility bounds on a supplied VO2max, in mL/kg/min. + /// + /// The fitness term is a bare multiplication, so nothing in the regression + /// stops an absurd input from producing an absurd answer: 1e6 through a + /// 10-minute bout yields ~965,000 kcal. The value that reaches here is not + /// measured — the usual source is a resting-HR ratio estimate, which inherits + /// every artifact in the resting HR it divides by. A single bad night at + /// 30 bpm against a 200 bpm HRmax reads as ~102, and unit confusion (L/min + /// rather than mL/kg/min) fails the same way in the other direction. + /// + /// [minVo2max] is below the lowest value seen in severely deconditioned + /// adults and [maxVo2max] above the highest recorded in elite endurance + /// athletes, so anything outside is a data error rather than a person. + /// Such a value is REJECTED, not clamped: it carries no information about + /// this user's fitness, and the age/mass/sex model is the honest answer when + /// the fitness anchor is unusable. Values inside the range but outside + /// Keytel's derivation cohort (~25-65) are still used — that is ordinary + /// extrapolation of a published linear model, not a broken input. + static const double minVo2max = 10.0; + static const double maxVo2max = 95.0; + + /// Whether [vo2max] is usable as the fitness term. Absent, non-finite, or + /// outside [minVo2max]..[maxVo2max] ⇒ the age/mass/sex model runs instead. + static bool usableVo2max(double? vo2max) => + vo2max != null && + vo2max.isFinite && + vo2max >= minVo2max && + vo2max <= maxVo2max; + /// 60 s/min × 4.184 kJ/kcal. static const double workoutDivisor = 251.04; @@ -155,21 +184,21 @@ class Calories { /// Active EE rate (kcal/s) — Keytel 2005 kJ/min ÷ workoutDivisor. /// - /// With a [vo2max], uses Keytel's fitness-adjusted model (see + /// With a usable [vo2max], uses Keytel's fitness-adjusted model (see /// [CalorieCoeffs.fitAlpha]); without one, the age/mass/sex model, unchanged. - /// A non-positive [vo2max] is treated as absent rather than fed to the - /// regression — VO2max is a strictly positive quantity, and 0 is this + /// "Usable" is [usableVo2max]: absent, non-finite, or physiologically + /// impossible values fall back rather than entering the regression. 0 is this /// package's "not measured" shape, not a real reading. static double activeKcalPerS( CalorieCoeffs c, double hr, double hrmax, double weightKg, double age, {double? vo2max}) { final cappedHr = math.min(hr, hrmax); final double eeKjMin; - if (vo2max != null && vo2max.isFinite && vo2max > 0) { + if (usableVo2max(vo2max)) { eeKjMin = c.fitAlpha + c.fitAge * age + c.fitWeight * weightKg + - c.fitVo2max * vo2max + + c.fitVo2max * vo2max! + c.fitHR * cappedHr; } else { eeKjMin = c.workoutHR * cappedHr + @@ -220,8 +249,13 @@ class Calories { /// matching the edge pipeline): minutes below it burn BMR only, so a quiet day /// reads ≈ basal and Keytel's low-HR over-estimate can't inflate "active". /// [dayMinutes] lets a partial day pro-rate basal (default 1440 = full day). - static ({double total, double active, double basal, bool usedDefaultHrmax}) - dailyEnergy( + static ({ + double total, + double active, + double basal, + bool usedDefaultHrmax, + bool usedFitnessModel, + }) dailyEnergy( List hrPerMin, { required WorkoutUserProfile profile, double? hrmax, @@ -263,6 +297,11 @@ class Calories { active: active, basal: basal, usedDefaultHrmax: hrmax == null, + // Which of Keytel's two published models priced the active term. Same + // reason usedDefaultHrmax exists: a caller comparing two days, or a user + // asking why a number moved, cannot otherwise tell that the fitness + // anchor was supplied and then silently rejected as implausible. + usedFitnessModel: usableVo2max(vo2max), ); } @@ -274,7 +313,12 @@ class Calories { /// length). [hrmax]/[restingHr] anchors (null → 220 / 60 fallback, flagged /// via [usedDefaultAnchors] on the result so a fabricated-anchor calorie /// number can be caveated instead of shown as if it were real). - static ({double kcal, double kj, bool usedDefaultAnchors}) estimateBoutCalories( + static ({ + double kcal, + double kj, + bool usedDefaultAnchors, + bool usedFitnessModel, + }) estimateBoutCalories( List hrTsSec, List hrBpm, { required WorkoutUserProfile profile, @@ -328,6 +372,7 @@ class Calories { kcal: totalKcal, kj: totalKcal * 4.184, usedDefaultAnchors: usedDefaultAnchors, + usedFitnessModel: usableVo2max(vo2max), ); } } diff --git a/lib/src/onehz/workout/workout_detect.dart b/lib/src/onehz/workout/workout_detect.dart index 79f4556..3fd7172 100644 --- a/lib/src/onehz/workout/workout_detect.dart +++ b/lib/src/onehz/workout/workout_detect.dart @@ -293,6 +293,12 @@ class WorkoutDetector { double? maxHR, double? age, WorkoutUserProfile? profile, + // Keytel's fitness-adjusted active model reads this. It has to reach here + // as well as the manual-logging path: a detected bout and a hand-logged one + // over the SAME heart-rate trace are the same workout, and pricing one on + // each of the two published models puts them ~30% apart in the same app on + // the same day. Null ⇒ the age/mass/sex model, as before. + double? vo2max, List savedSpans = const [], SportClassifier classify = defaultSportClassifier, }) { @@ -415,6 +421,7 @@ class WorkoutDetector { hrmax: effMaxHR, restingHr: restHR, mergeGapCapS: mergeGapS, + vo2max: vo2max, ); kcal = cal.kcal; kj = cal.kj; diff --git a/test/onehz/calories_vo2max_test.dart b/test/onehz/calories_vo2max_test.dart index fc6f8fb..3383ed3 100644 --- a/test/onehz/calories_vo2max_test.dart +++ b/test/onehz/calories_vo2max_test.dart @@ -117,15 +117,122 @@ void main() { }); test('nonbinary stays the mean of the two published blocks', () { - // Matches how this package already resolves the age/mass/sex model, and - // the model is linear in its coefficients, so the mean block and the mean - // of the two results are the same number. + // Matches how this package already resolves the age/mass/sex model. + // + // The equality holds because the model is linear in its coefficients AND + // both sexes' raw kJ/min are positive here, so no clamp fires. It is NOT + // a general identity: `max(0.0, eeKjMin)` is applied after the mean block + // is evaluated, so on a profile extreme enough to drive exactly one sex + // negative the mean block and the mean of the two results diverge. See + // the clamp-asymmetry case below. expect( _bout(_nonbinary, vo2max: 50), closeTo((_bout(_male, vo2max: 50) + _bout(_female, vo2max: 50)) / 2, 0.01), ); }); + test('the nonbinary mean is NOT an identity once a clamp fires', () { + // Pinned so nobody restates the linearity claim as unconditional. The + // zero floor is applied to each block's own kJ/min, so a profile that + // drives one sex negative and not the other breaks the equality: the mean + // block lands under the floor and reads 0 while the mean of the two + // results does not. Reachable only at a genuinely extreme profile, which + // is why it is pinned rather than fixed — the alternative is averaging + // two clamped results, which is not what "the mean coefficient block" + // means. + const old = WorkoutUserProfile( + weightKg: 35, + heightCm: 150, + age: 80, + sex: 'male', + ); + const oldF = WorkoutUserProfile( + weightKg: 35, + heightCm: 150, + age: 80, + sex: 'female', + ); + const oldN = WorkoutUserProfile( + weightKg: 35, + heightCm: 150, + age: 80, + sex: 'nonbinary', + ); + final day = [for (var i = 0; i < 600; i++) 76.0]; + double active(WorkoutUserProfile p) => + Calories.dailyEnergy(day, profile: p, hrmax: 152.0, vo2max: 15) + .active; + + expect(active(old), 0.0, reason: 'male block clamps to the floor'); + expect(active(oldF), greaterThan(0.0)); + expect(active(oldN), 0.0, reason: 'the MEAN block also clamps'); + expect( + active(oldN), + isNot(closeTo((active(old) + active(oldF)) / 2, 1.0)), + ); + }); + + test('a physiologically impossible VO2max falls back, never scales', () { + // The guard used to test only the sign and finiteness, so the fitness + // term was a bare multiplication with no upper bound: 1e6 through this + // bout returned ~965,000 kcal. The value arriving here is not measured — + // it is usually a resting-HR ratio estimate, so it inherits every + // artifact in the resting HR it divides by, and unit confusion (L/min for + // mL/kg/min) fails the same way at the bottom. + final baseline = _bout(_male); + for (final vo2max in [ + 1e6, + 1000, + 95.01, + 9.99, + 1e-12, + ]) { + expect( + _bout(_male, vo2max: vo2max), + baseline, + reason: 'vo2max $vo2max is not a human and must not price a bout', + ); + } + // The bounds themselves are inclusive and DO price the bout. + expect(_bout(_male, vo2max: Calories.minVo2max), isNot(baseline)); + expect(_bout(_male, vo2max: Calories.maxVo2max), isNot(baseline)); + }); + + test('the result says which of the two models priced it', () { + // Same contract as usedDefaultHrmax/usedDefaultAnchors: a silently + // rejected fitness anchor is indistinguishable from one that was never + // supplied unless the result says so. + double? none; + expect( + Calories.estimateBoutCalories(_ts, _bpm, + profile: _male, + hrmax: _hrMax, + restingHr: _restingHr, + vo2max: none) + .usedFitnessModel, + isFalse, + ); + expect( + Calories.estimateBoutCalories(_ts, _bpm, + profile: _male, + hrmax: _hrMax, + restingHr: _restingHr, + vo2max: 1e6) + .usedFitnessModel, + isFalse, + reason: 'rejected as implausible — the caller has to be able to tell', + ); + expect( + Calories.estimateBoutCalories(_ts, _bpm, + profile: _male, + hrmax: _hrMax, + restingHr: _restingHr, + vo2max: 50) + .usedFitnessModel, + isTrue, + ); + }); + test('resting samples still take the BMR floor, VO2max or not', () { // VO2max belongs to the ACTIVE term only. Below the gate the bout bills // Harris-Benedict, which has no fitness term at all. From d04e5a2e3e6f43147eb73aaf6ac4d379f9e03959 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Tue, 11 Aug 2026 19:52:35 +0530 Subject: [PATCH 4/7] say the fitness model ran only when it actually priced something usedFitnessModel reported whether a usable VO2max arrived, not whether the regression that reads it evaluated. A bout spent entirely under the gate, or a day entirely under the flex point, is Harris-Benedict or Mifflin from end to end and has no fitness term in it, so the flag was crediting a calculation that never ran. The detector dropped the flag on the floor the way it used to drop usedDefaultAnchors, so a bout came back with no way to tell which of the two models had priced it. ExerciseSession carries it now. The gap cap is a named constant rather than a default-argument literal. A caller scoring a bout live has to stop standing in for a missing sample at the same point the re-score does, and a default argument cannot be referenced from outside, so the second copy of 150.0 was guaranteed to drift. --- lib/src/onehz/workout/calories.dart | 29 ++++++++++++++++++++--- lib/src/onehz/workout/workout_detect.dart | 13 ++++++++++ test/onehz/calories_vo2max_test.dart | 28 ++++++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/lib/src/onehz/workout/calories.dart b/lib/src/onehz/workout/calories.dart index e19b40d..dbf8da8 100644 --- a/lib/src/onehz/workout/calories.dart +++ b/lib/src/onehz/workout/calories.dart @@ -158,6 +158,16 @@ class Calories { /// 60 s/min × 4.184 kJ/kcal. static const double workoutDivisor = 251.04; + /// How long one HR sample may stand in for when the next one is late. + /// + /// Named rather than left as a default-parameter literal because it is not + /// only this package's business: a caller scoring a bout live, sample by + /// sample, has to give up at the same point as the re-score of the same + /// stream, or the two disagree by whatever a dropout ran over. A default + /// argument cannot be referenced from outside, so the two copies of `150.0` + /// drifted apart by construction. + static const double defaultMergeGapCapS = 150.0; + static CalorieCoeffs resolveCoeffs(String sex) { switch (sex.toLowerCase()) { case 'male': @@ -283,8 +293,15 @@ class Calories { final basalPerMin = bmrDay / 1440.0; var active = 0.0; + final fitUsable = usableVo2max(vo2max); + // Whether the fitness-adjusted regression actually PRICED anything, not + // merely whether a usable anchor was handed in. A day spent entirely below + // the flex point is all Mifflin, and reporting the fitness model for it + // would credit a fitness term that never ran. + var pricedByFitness = false; for (final hr in hrPerMin) { if (hr < flexHr) continue; // below flex point → basal only + if (fitUsable) pricedByFitness = true; final activePerMin = activeKcalPerS(coeffs, hr, effHRmax, weightKg, age, vo2max: vo2max) * 60.0; @@ -301,7 +318,7 @@ class Calories { // reason usedDefaultHrmax exists: a caller comparing two days, or a user // asking why a number moved, cannot otherwise tell that the fitness // anchor was supplied and then silently rejected as implausible. - usedFitnessModel: usableVo2max(vo2max), + usedFitnessModel: pricedByFitness, ); } @@ -324,7 +341,7 @@ class Calories { required WorkoutUserProfile profile, double? hrmax, double? restingHr, - double mergeGapCapS = 150.0, + double mergeGapCapS = defaultMergeGapCapS, double? vo2max, }) { final weightKg = profile.weightKg > 0 ? profile.weightKg : 70.0; @@ -350,6 +367,11 @@ class Calories { final ts = [for (final i in idx) hrTsSec[i]]; final bpm = [for (final i in idx) hrBpm[i]]; + final fitUsable = usableVo2max(vo2max); + // See dailyEnergy: the flag reports that the fitness regression PRICED a + // sample, not that an anchor was available. A bout spent entirely under the + // gate is all Harris-Benedict and has no fitness term in it. + var pricedByFitness = false; var totalKcal = 0.0; for (var i = 0; i < ts.length; i++) { final b = bpm[i]; @@ -363,6 +385,7 @@ class Calories { if (b < activeThreshold) { totalKcal += restingRate * dur; } else { + if (fitUsable) pricedByFitness = true; totalKcal += activeKcalPerS(coeffs, b, effHRmax, weightKg, age, vo2max: vo2max) * dur; @@ -372,7 +395,7 @@ class Calories { kcal: totalKcal, kj: totalKcal * 4.184, usedDefaultAnchors: usedDefaultAnchors, - usedFitnessModel: usableVo2max(vo2max), + usedFitnessModel: pricedByFitness, ); } } diff --git a/lib/src/onehz/workout/workout_detect.dart b/lib/src/onehz/workout/workout_detect.dart index 3fd7172..2a1d166 100644 --- a/lib/src/onehz/workout/workout_detect.dart +++ b/lib/src/onehz/workout/workout_detect.dart @@ -54,6 +54,14 @@ class ExerciseSession { /// computed at all. final bool caloriesUsedDefaultAnchors; + /// Which of Keytel's two published active models priced [caloriesKcal]: true + /// for the fitness-adjusted one, false for age/mass/sex. Carried for the same + /// reason as [caloriesUsedDefaultAnchors] — two bouts scored on different + /// models are not comparable, and a VO2max that was supplied and then + /// rejected as implausible is otherwise indistinguishable from one that was + /// never supplied. False when no calories were computed at all. + final bool caloriesUsedFitnessModel; + /// Sport label from the classifier seam ("detected" by default). final String sport; @@ -71,6 +79,7 @@ class ExerciseSession { required this.caloriesKcal, required this.caloriesKJ, this.caloriesUsedDefaultAnchors = false, + this.caloriesUsedFitnessModel = false, this.sport = defaultSportLabel, }); @@ -88,6 +97,7 @@ class ExerciseSession { 'calories_kcal': caloriesKcal == null ? null : round6(caloriesKcal!), 'calories_kj': caloriesKJ == null ? null : round6(caloriesKJ!), 'calories_used_default_anchors': caloriesUsedDefaultAnchors, + 'calories_used_fitness_model': caloriesUsedFitnessModel, 'sport': sport, }; } @@ -412,6 +422,7 @@ class WorkoutDetector { // `hrmax ?? 220` / `restingHr ?? 60` fallback can be caveated, and it used // to be computed and dropped on the floor here. var calUsedDefaultAnchors = false; + var calUsedFitnessModel = false; if (profile != null) { final winBpmInt = [for (final b in winBpm) b]; final cal = Calories.estimateBoutCalories( @@ -426,6 +437,7 @@ class WorkoutDetector { kcal = cal.kcal; kj = cal.kj; calUsedDefaultAnchors = cal.usedDefaultAnchors; + calUsedFitnessModel = cal.usedFitnessModel; } final avg = winBpm.reduce((a, b) => a + b) / winBpm.length; @@ -484,6 +496,7 @@ class WorkoutDetector { caloriesKcal: kcal, caloriesKJ: kj, caloriesUsedDefaultAnchors: calUsedDefaultAnchors, + caloriesUsedFitnessModel: calUsedFitnessModel, sport: sport, )); } diff --git a/test/onehz/calories_vo2max_test.dart b/test/onehz/calories_vo2max_test.dart index 3383ed3..448db39 100644 --- a/test/onehz/calories_vo2max_test.dart +++ b/test/onehz/calories_vo2max_test.dart @@ -233,6 +233,34 @@ void main() { ); }); + test('a bout spent entirely under the gate did not use the fitness model', + () { + // The flag reports that the fitness regression PRICED something, not that + // an anchor was available. Everything below the gate is Harris-Benedict, + // which has no fitness term in it, so claiming the fitness model for a + // resting bout credits a calculation that never ran. + final easy = [for (var t = 0; t < 300; t++) 65.0]; + final bout = Calories.estimateBoutCalories( + [for (var t = 0; t < 300; t++) t], + easy, + profile: _male, + hrmax: _hrMax, + restingHr: _restingHr, + vo2max: 50, + ); + expect(bout.kcal, greaterThan(0), reason: 'it was still costed'); + expect(bout.usedFitnessModel, isFalse); + }); + + test('a day spent entirely under the flex point reports no fitness model', + () { + final quiet = [for (var i = 0; i < 1440; i++) 55.0]; + final e = + Calories.dailyEnergy(quiet, profile: _male, hrmax: _hrMax, vo2max: 50); + expect(e.active, 0.0); + expect(e.usedFitnessModel, isFalse); + }); + test('resting samples still take the BMR floor, VO2max or not', () { // VO2max belongs to the ACTIVE term only. Below the gate the bout bills // Harris-Benedict, which has no fitness term at all. From 317403a18e977646f45fbd39f25de3597223e45d Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Tue, 11 Aug 2026 20:30:06 +0530 Subject: [PATCH 5/7] set the fitness flag where the energy lands, and cover the detector wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A minute can clear the flex gate and still contribute nothing: at a low VO2max the regression comes out negative, clamps to zero and loses to the basal minute. Setting the flag on entering the branch reported a fitness-priced day whose active total was 0.0, which is the same over-claim one level down from the one the last change removed. The detector's copy of the flag had no test at all — hardcoding it to false left the suite green, which is how the flag it replaces came to be dropped on the floor in the first place. Both are pinned now, through detect() rather than a hand-built session. The detector also passed its own split threshold as the sample cap. Same number, different decision, and restating one as the other means moving the cap rescores auto-detected bouts against a value nothing else uses. --- lib/src/onehz/workout/calories.dart | 12 +++++- lib/src/onehz/workout/workout_detect.dart | 8 +++- test/onehz/calories_vo2max_test.dart | 28 ++++++++++++ test/onehz/workout_test.dart | 52 +++++++++++++++++++++++ 4 files changed, 97 insertions(+), 3 deletions(-) diff --git a/lib/src/onehz/workout/calories.dart b/lib/src/onehz/workout/calories.dart index dbf8da8..9343372 100644 --- a/lib/src/onehz/workout/calories.dart +++ b/lib/src/onehz/workout/calories.dart @@ -298,15 +298,23 @@ class Calories { // merely whether a usable anchor was handed in. A day spent entirely below // the flex point is all Mifflin, and reporting the fitness model for it // would credit a fitness term that never ran. + // + // Set where the energy LANDS, not where the branch is taken. A minute can + // clear the flex gate and still contribute nothing: at a low VO2max the + // regression can come out negative, clamp to zero, and lose to the basal + // minute. Setting the flag on entry reported a fitness-priced day whose + // active total was 0.0 — the same over-report one level down. var pricedByFitness = false; for (final hr in hrPerMin) { if (hr < flexHr) continue; // below flex point → basal only - if (fitUsable) pricedByFitness = true; final activePerMin = activeKcalPerS(coeffs, hr, effHRmax, weightKg, age, vo2max: vo2max) * 60.0; final surplus = activePerMin - basalPerMin; - if (surplus > 0) active += surplus; + if (surplus > 0) { + active += surplus; + if (fitUsable) pricedByFitness = true; + } } final basal = basalPerMin * dayMinutes; return ( diff --git a/lib/src/onehz/workout/workout_detect.dart b/lib/src/onehz/workout/workout_detect.dart index 2a1d166..310e777 100644 --- a/lib/src/onehz/workout/workout_detect.dart +++ b/lib/src/onehz/workout/workout_detect.dart @@ -431,7 +431,13 @@ class WorkoutDetector { profile: profile, hrmax: effMaxHR, restingHr: restHR, - mergeGapCapS: mergeGapS, + // The published cap, not this class's split threshold. They are the + // same number today and the cap cannot bind inside a detected bout + // anyway (a gap that long would have ended it), but restating one as + // the other means moving the cap silently rescores auto-detected + // bouts against a value the live gauge and manual_session no longer + // use. + mergeGapCapS: Calories.defaultMergeGapCapS, vo2max: vo2max, ); kcal = cal.kcal; diff --git a/test/onehz/calories_vo2max_test.dart b/test/onehz/calories_vo2max_test.dart index 448db39..240678d 100644 --- a/test/onehz/calories_vo2max_test.dart +++ b/test/onehz/calories_vo2max_test.dart @@ -252,6 +252,34 @@ void main() { expect(bout.usedFitnessModel, isFalse); }); + test('a clamped minute above the gate is not a fitness-priced day', () { + // A minute can clear the flex gate and still contribute nothing. At a low + // VO2max the regression comes out NEGATIVE, `max(0.0, eeKjMin)` clamps it + // to zero, and zero loses to the basal minute — so `active` stays 0.0 + // while the fit branch was entered. Reporting the fitness model there is + // the same over-claim as reporting it for a day that never left basal. + // + // Female, 20 y, 45 kg, HRmax 194 -> flex 97 bpm. At VO2max 10 (the floor + // the guard admits) and HR 97: + // -59.3954 + 0.274*20 + 0.103*45 + 0.380*10 + 0.450*97 = -1.83 kJ/min + const small = WorkoutUserProfile( + weightKg: 45, + heightCm: 160, + age: 20, + sex: 'female', + ); + final atGate = [for (var i = 0; i < 120; i++) 97.0]; + final e = Calories.dailyEnergy( + atGate, + profile: small, + hrmax: 194.0, + vo2max: Calories.minVo2max, + ); + + expect(e.active, 0.0, reason: 'the clamped rate loses to the basal minute'); + expect(e.usedFitnessModel, isFalse); + }); + test('a day spent entirely under the flex point reports no fitness model', () { final quiet = [for (var i = 0; i < 1440; i++) 55.0]; diff --git a/test/onehz/workout_test.dart b/test/onehz/workout_test.dart index 5bb45d9..57f7fd5 100644 --- a/test/onehz/workout_test.dart +++ b/test/onehz/workout_test.dart @@ -309,6 +309,58 @@ void main() { expect(out, isEmpty); }); + test('the detected bout carries which calorie model priced it', () { + // Both flags used to be computed inside detect() and dropped before the + // ExerciseSession was built, so a bout came back with no way to tell + // whether its calories were personal or built on a 220/60 stand-in, and + // later no way to tell which of Keytel's two models produced them. Two + // bouts scored on different models are not comparable. + final d = buildDay(); + const profile = WorkoutUserProfile( + weightKg: 72, + heightCm: 178, + age: 34, + sex: 'male', + ); + + final plain = WorkoutDetector.detect( + hrTs: d.hrTs, + hrBpm: d.hrBpm, + gravTs: d.gTs, + gx: d.gx, + gy: d.gy, + gz: d.gz, + maxHR: 190, + restingHR: 60, + profile: profile, + ); + final fit = WorkoutDetector.detect( + hrTs: d.hrTs, + hrBpm: d.hrBpm, + gravTs: d.gTs, + gx: d.gx, + gy: d.gy, + gz: d.gz, + maxHR: 190, + restingHR: 60, + profile: profile, + vo2max: 50, + ); + + expect(plain, isNotEmpty); + expect(fit, isNotEmpty); + expect(plain.first.caloriesUsedFitnessModel, isFalse); + expect(fit.first.caloriesUsedFitnessModel, isTrue); + expect(plain.first.toJson()['calories_used_fitness_model'], isFalse); + expect(fit.first.toJson()['calories_used_fitness_model'], isTrue); + // The anchors here are real, so the other flag must stay false — it is + // the same plumbing and was equally untested. + expect(fit.first.caloriesUsedDefaultAnchors, isFalse); + // And the term has to actually move the number, or the flag is decoration. + expect(fit.first.caloriesKcal, isNotNull); + expect(fit.first.caloriesKcal, isNot(closeTo(plain.first.caloriesKcal!, 0.5))); + }); + test('injected sport classifier types the detected bout', () { final d = buildDay(); String classify(WorkoutBout b, MotionFeatures? f) { From 5f473ea2d56378f58bd1150b7648658ef182c21e Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Tue, 11 Aug 2026 22:14:43 +0530 Subject: [PATCH 6/7] drop the fitness-adjusted calorie model, keep the shared gap cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the only VO2max this app can produce is the Uth estimate, 15.3 * HRmax/HRrest with a Tanaka HRmax. substituted into keytel's fitness-adjusted regression it collapses to (1285.7 - 4.3268*age)/RHR — no VO2 term survives, just age and resting HR wearing a fitness label. the estimate's error is wider than the spread of the thing it estimates, so using it at full weight moves the number without adding information, and it made a trait metric jitter by tens of kcal a day on ordinary overnight resting-HR drift. so the fit* coefficients, the vo2max parameters, the plausibility bounds and the usedFitnessModel flags all come back out. calorie output is unchanged from before the model landed. what stays is Calories.defaultMergeGapCapS. the cap is not only this package's business — edge's live scorer has to stop standing in for a missing HR sample at the same instant the re-score does, and a default-argument literal cannot be read from another package, so the second copy of 150.0 was going to drift. the detector's note about it was also wrong: the cap CAN bind inside a detected bout, because the bridge window is twice the merge gap and _bridgeRuns stitches across an HR-free dropout of up to 300 s. a bout straddling a ~252 s dropout comes out 324.62 kcal capped against 349.44 uncapped. --- lib/src/onehz/workout/calories.dart | 152 +-------- lib/src/onehz/workout/workout_detect.dart | 34 +- test/onehz/calories_vo2max_test.dart | 374 ---------------------- test/onehz/workout_test.dart | 52 --- 4 files changed, 23 insertions(+), 589 deletions(-) delete mode 100644 test/onehz/calories_vo2max_test.dart diff --git a/lib/src/onehz/workout/calories.dart b/lib/src/onehz/workout/calories.dart index 9343372..b5855b7 100644 --- a/lib/src/onehz/workout/calories.dart +++ b/lib/src/onehz/workout/calories.dart @@ -39,22 +39,6 @@ class CalorieCoeffs { final double workoutWeight; final double workoutAge; final double workoutAlpha; - - // Keytel's SECOND published active model, which adds a VO2max term. It is - // the more accurate of the pair: fitness is what decides how much energy a - // given heart rate represents, because a higher VO2max means a greater - // stroke volume, so the same beat moves more oxygen. The age/mass/sex-only - // model above has to bake in the derivation cohort's mean fitness instead, - // which is why it reads high for untrained people and low for athletes. - // - // Used only when the caller supplies a VO2max; every entry point keeps the - // original model as its fallback so an absent fitness anchor changes nothing. - final double fitAlpha; - final double fitAge; - final double fitWeight; - final double fitVo2max; - final double fitHR; - const CalorieCoeffs({ required this.restingAlpha, required this.restingWeight, @@ -64,18 +48,11 @@ class CalorieCoeffs { required this.workoutWeight, required this.workoutAge, required this.workoutAlpha, - required this.fitAlpha, - required this.fitAge, - required this.fitWeight, - required this.fitVo2max, - required this.fitHR, }); } /// HR-based calorie estimation (Keytel 2005 active + revised Harris–Benedict BMR). class Calories { - // fitAlpha folds Keytel's shared -59.3954 intercept together with the - // male-only -36.3781 term, so each block carries one flat intercept. static const CalorieCoeffs male = CalorieCoeffs( restingAlpha: 88.362, restingWeight: 13.397, @@ -85,11 +62,6 @@ class Calories { workoutWeight: 0.1988, workoutAge: 0.2017, workoutAlpha: -55.0969, - fitAlpha: -95.7735, // -59.3954 - 36.3781 - fitAge: 0.271, - fitWeight: 0.394, - fitVo2max: 0.404, - fitHR: 0.634, ); static const CalorieCoeffs female = CalorieCoeffs( restingAlpha: 447.593, @@ -100,11 +72,6 @@ class Calories { workoutWeight: -0.1263, workoutAge: 0.0740, workoutAlpha: -20.4022, - fitAlpha: -59.3954, - fitAge: 0.274, - fitWeight: 0.103, - fitVo2max: 0.380, - fitHR: 0.450, ); static const CalorieCoeffs nonbinary = CalorieCoeffs( restingAlpha: 267.9775, @@ -115,46 +82,12 @@ class Calories { workoutWeight: 0.03625, workoutAge: 0.13785, workoutAlpha: -37.74955, - fitAlpha: -77.58445, - fitAge: 0.2725, - fitWeight: 0.2485, - fitVo2max: 0.392, - fitHR: 0.542, ); /// Bout active gate: a sample burns the Keytel active rate above /// resting + this fraction of HRR, else the resting BMR rate. static const double activeHRRFraction = 0.30; - /// Plausibility bounds on a supplied VO2max, in mL/kg/min. - /// - /// The fitness term is a bare multiplication, so nothing in the regression - /// stops an absurd input from producing an absurd answer: 1e6 through a - /// 10-minute bout yields ~965,000 kcal. The value that reaches here is not - /// measured — the usual source is a resting-HR ratio estimate, which inherits - /// every artifact in the resting HR it divides by. A single bad night at - /// 30 bpm against a 200 bpm HRmax reads as ~102, and unit confusion (L/min - /// rather than mL/kg/min) fails the same way in the other direction. - /// - /// [minVo2max] is below the lowest value seen in severely deconditioned - /// adults and [maxVo2max] above the highest recorded in elite endurance - /// athletes, so anything outside is a data error rather than a person. - /// Such a value is REJECTED, not clamped: it carries no information about - /// this user's fitness, and the age/mass/sex model is the honest answer when - /// the fitness anchor is unusable. Values inside the range but outside - /// Keytel's derivation cohort (~25-65) are still used — that is ordinary - /// extrapolation of a published linear model, not a broken input. - static const double minVo2max = 10.0; - static const double maxVo2max = 95.0; - - /// Whether [vo2max] is usable as the fitness term. Absent, non-finite, or - /// outside [minVo2max]..[maxVo2max] ⇒ the age/mass/sex model runs instead. - static bool usableVo2max(double? vo2max) => - vo2max != null && - vo2max.isFinite && - vo2max >= minVo2max && - vo2max <= maxVo2max; - /// 60 s/min × 4.184 kJ/kcal. static const double workoutDivisor = 251.04; @@ -193,29 +126,12 @@ class Calories { } /// Active EE rate (kcal/s) — Keytel 2005 kJ/min ÷ workoutDivisor. - /// - /// With a usable [vo2max], uses Keytel's fitness-adjusted model (see - /// [CalorieCoeffs.fitAlpha]); without one, the age/mass/sex model, unchanged. - /// "Usable" is [usableVo2max]: absent, non-finite, or physiologically - /// impossible values fall back rather than entering the regression. 0 is this - /// package's "not measured" shape, not a real reading. static double activeKcalPerS( - CalorieCoeffs c, double hr, double hrmax, double weightKg, double age, - {double? vo2max}) { - final cappedHr = math.min(hr, hrmax); - final double eeKjMin; - if (usableVo2max(vo2max)) { - eeKjMin = c.fitAlpha + - c.fitAge * age + - c.fitWeight * weightKg + - c.fitVo2max * vo2max! + - c.fitHR * cappedHr; - } else { - eeKjMin = c.workoutHR * cappedHr + - c.workoutWeight * weightKg + - c.workoutAge * age + - c.workoutAlpha; - } + CalorieCoeffs c, double hr, double hrmax, double weightKg, double age) { + final eeKjMin = c.workoutHR * math.min(hr, hrmax) + + c.workoutWeight * weightKg + + c.workoutAge * age + + c.workoutAlpha; return math.max(0.0, eeKjMin) / workoutDivisor; } @@ -259,19 +175,13 @@ class Calories { /// matching the edge pipeline): minutes below it burn BMR only, so a quiet day /// reads ≈ basal and Keytel's low-HR over-estimate can't inflate "active". /// [dayMinutes] lets a partial day pro-rate basal (default 1440 = full day). - static ({ - double total, - double active, - double basal, - bool usedDefaultHrmax, - bool usedFitnessModel, - }) dailyEnergy( + static ({double total, double active, double basal, bool usedDefaultHrmax}) + dailyEnergy( List hrPerMin, { required WorkoutUserProfile profile, double? hrmax, double activeFraction = 0.50, int dayMinutes = 1440, - double? vo2max, }) { // the 220-age hrmax fallback used to just silently apply with nothing // telling the caller it wasnt a real anchor. usedDefaultHrmax lets the @@ -293,28 +203,12 @@ class Calories { final basalPerMin = bmrDay / 1440.0; var active = 0.0; - final fitUsable = usableVo2max(vo2max); - // Whether the fitness-adjusted regression actually PRICED anything, not - // merely whether a usable anchor was handed in. A day spent entirely below - // the flex point is all Mifflin, and reporting the fitness model for it - // would credit a fitness term that never ran. - // - // Set where the energy LANDS, not where the branch is taken. A minute can - // clear the flex gate and still contribute nothing: at a low VO2max the - // regression can come out negative, clamp to zero, and lose to the basal - // minute. Setting the flag on entry reported a fitness-priced day whose - // active total was 0.0 — the same over-report one level down. - var pricedByFitness = false; for (final hr in hrPerMin) { if (hr < flexHr) continue; // below flex point → basal only final activePerMin = - activeKcalPerS(coeffs, hr, effHRmax, weightKg, age, vo2max: vo2max) * - 60.0; + activeKcalPerS(coeffs, hr, effHRmax, weightKg, age) * 60.0; final surplus = activePerMin - basalPerMin; - if (surplus > 0) { - active += surplus; - if (fitUsable) pricedByFitness = true; - } + if (surplus > 0) active += surplus; } final basal = basalPerMin * dayMinutes; return ( @@ -322,35 +216,25 @@ class Calories { active: active, basal: basal, usedDefaultHrmax: hrmax == null, - // Which of Keytel's two published models priced the active term. Same - // reason usedDefaultHrmax exists: a caller comparing two days, or a user - // asking why a number moved, cannot otherwise tell that the fitness - // anchor was supplied and then silently rejected as implausible. - usedFitnessModel: pricedByFitness, ); } /// Estimate (kcal, kJ) for a workout bout. Each sample is weighted by the - /// ELAPSED time to the next sample (capped at [mergeGapCapS] = mergeGapS, 150 s), - /// so a sparse stream is counted over real seconds. + /// ELAPSED time to the next sample (capped at [mergeGapCapS], which defaults + /// to [defaultMergeGapCapS] = 150 s), so a sparse stream is counted over real + /// seconds. /// /// [hrTsSec]/[hrBpm] are the bout's HR samples (timestamps in SECONDS, same /// length). [hrmax]/[restingHr] anchors (null → 220 / 60 fallback, flagged /// via [usedDefaultAnchors] on the result so a fabricated-anchor calorie /// number can be caveated instead of shown as if it were real). - static ({ - double kcal, - double kj, - bool usedDefaultAnchors, - bool usedFitnessModel, - }) estimateBoutCalories( + static ({double kcal, double kj, bool usedDefaultAnchors}) estimateBoutCalories( List hrTsSec, List hrBpm, { required WorkoutUserProfile profile, double? hrmax, double? restingHr, double mergeGapCapS = defaultMergeGapCapS, - double? vo2max, }) { final weightKg = profile.weightKg > 0 ? profile.weightKg : 70.0; final heightCm = profile.heightCm > 0 ? profile.heightCm : 170.0; @@ -375,11 +259,6 @@ class Calories { final ts = [for (final i in idx) hrTsSec[i]]; final bpm = [for (final i in idx) hrBpm[i]]; - final fitUsable = usableVo2max(vo2max); - // See dailyEnergy: the flag reports that the fitness regression PRICED a - // sample, not that an anchor was available. A bout spent entirely under the - // gate is all Harris-Benedict and has no fitness term in it. - var pricedByFitness = false; var totalKcal = 0.0; for (var i = 0; i < ts.length; i++) { final b = bpm[i]; @@ -393,17 +272,14 @@ class Calories { if (b < activeThreshold) { totalKcal += restingRate * dur; } else { - if (fitUsable) pricedByFitness = true; totalKcal += - activeKcalPerS(coeffs, b, effHRmax, weightKg, age, vo2max: vo2max) * - dur; + activeKcalPerS(coeffs, b, effHRmax, weightKg, age) * dur; } } return ( kcal: totalKcal, kj: totalKcal * 4.184, usedDefaultAnchors: usedDefaultAnchors, - usedFitnessModel: pricedByFitness, ); } } diff --git a/lib/src/onehz/workout/workout_detect.dart b/lib/src/onehz/workout/workout_detect.dart index 310e777..86e969a 100644 --- a/lib/src/onehz/workout/workout_detect.dart +++ b/lib/src/onehz/workout/workout_detect.dart @@ -54,14 +54,6 @@ class ExerciseSession { /// computed at all. final bool caloriesUsedDefaultAnchors; - /// Which of Keytel's two published active models priced [caloriesKcal]: true - /// for the fitness-adjusted one, false for age/mass/sex. Carried for the same - /// reason as [caloriesUsedDefaultAnchors] — two bouts scored on different - /// models are not comparable, and a VO2max that was supplied and then - /// rejected as implausible is otherwise indistinguishable from one that was - /// never supplied. False when no calories were computed at all. - final bool caloriesUsedFitnessModel; - /// Sport label from the classifier seam ("detected" by default). final String sport; @@ -79,7 +71,6 @@ class ExerciseSession { required this.caloriesKcal, required this.caloriesKJ, this.caloriesUsedDefaultAnchors = false, - this.caloriesUsedFitnessModel = false, this.sport = defaultSportLabel, }); @@ -97,7 +88,6 @@ class ExerciseSession { 'calories_kcal': caloriesKcal == null ? null : round6(caloriesKcal!), 'calories_kj': caloriesKJ == null ? null : round6(caloriesKJ!), 'calories_used_default_anchors': caloriesUsedDefaultAnchors, - 'calories_used_fitness_model': caloriesUsedFitnessModel, 'sport': sport, }; } @@ -303,12 +293,6 @@ class WorkoutDetector { double? maxHR, double? age, WorkoutUserProfile? profile, - // Keytel's fitness-adjusted active model reads this. It has to reach here - // as well as the manual-logging path: a detected bout and a hand-logged one - // over the SAME heart-rate trace are the same workout, and pricing one on - // each of the two published models puts them ~30% apart in the same app on - // the same day. Null ⇒ the age/mass/sex model, as before. - double? vo2max, List savedSpans = const [], SportClassifier classify = defaultSportClassifier, }) { @@ -422,7 +406,6 @@ class WorkoutDetector { // `hrmax ?? 220` / `restingHr ?? 60` fallback can be caveated, and it used // to be computed and dropped on the floor here. var calUsedDefaultAnchors = false; - var calUsedFitnessModel = false; if (profile != null) { final winBpmInt = [for (final b in winBpm) b]; final cal = Calories.estimateBoutCalories( @@ -432,18 +415,20 @@ class WorkoutDetector { hrmax: effMaxHR, restingHr: restHR, // The published cap, not this class's split threshold. They are the - // same number today and the cap cannot bind inside a detected bout - // anyway (a gap that long would have ended it), but restating one as - // the other means moving the cap silently rescores auto-detected - // bouts against a value the live gauge and manual_session no longer - // use. + // same number today, but they are not the same quantity, and the cap + // really can bind inside a detected bout: [bridgeGapS] is twice + // [mergeGapS], so _bridgeRuns stitches an HR-free dropout of up to + // 300 s into one bout. A bout straddling a ~252 s dropout measures + // 324.62 kcal capped against 349.44 uncapped — 7.1%. Restating one + // constant as the other would mean a later move of the cap silently + // rescored auto-detected bouts against a value the live gauge and + // manual_session no longer use, which is the whole reason both sides + // read the same constant. mergeGapCapS: Calories.defaultMergeGapCapS, - vo2max: vo2max, ); kcal = cal.kcal; kj = cal.kj; calUsedDefaultAnchors = cal.usedDefaultAnchors; - calUsedFitnessModel = cal.usedFitnessModel; } final avg = winBpm.reduce((a, b) => a + b) / winBpm.length; @@ -502,7 +487,6 @@ class WorkoutDetector { caloriesKcal: kcal, caloriesKJ: kj, caloriesUsedDefaultAnchors: calUsedDefaultAnchors, - caloriesUsedFitnessModel: calUsedFitnessModel, sport: sport, )); } diff --git a/test/onehz/calories_vo2max_test.dart b/test/onehz/calories_vo2max_test.dart deleted file mode 100644 index 240678d..0000000 --- a/test/onehz/calories_vo2max_test.dart +++ /dev/null @@ -1,374 +0,0 @@ -// Keytel 2005 publishes TWO active-EE models. The one this package shipped -// reads age, body mass and sex; the other adds VO2max and is the more accurate -// of the pair, because fitness is what decides how much energy a given heart -// rate actually represents — a trained athlete at 140 bpm is moving far more -// oxygen than an untrained person at 140 bpm. -// -// The fitness-adjusted model (as published, in kJ/min): -// male: -59.3954 - 36.3781 + 0.271*age + 0.394*wt + 0.404*VO2max + 0.634*HR -// female: -59.3954 + 0.274*age + 0.103*wt + 0.380*VO2max + 0.450*HR -// -// VO2max is OPTIONAL everywhere. Absent, every caller gets exactly the numbers -// it got before — this must not silently move calories for anyone whose -// fitness anchor cannot be estimated. -// -// Expected values below are computed by hand from those two equations so the -// test pins the published arithmetic rather than the implementation. - -import 'package:openstrap_analytics/onehz.dart'; -import 'package:test/test.dart'; - -const _male = WorkoutUserProfile( - weightKg: 72, - heightCm: 178, - age: 34, - sex: 'male', -); -const _female = WorkoutUserProfile( - weightKg: 72, - heightCm: 178, - age: 34, - sex: 'female', -); -const _nonbinary = WorkoutUserProfile( - weightKg: 72, - heightCm: 178, - age: 34, - sex: 'nonbinary', -); - -// Tanaka HRmax for 34 y; bout gate = 55 + 0.30*(184.2-55) = 93.76, so the whole -// stream below is active. -const _hrMax = 184.2; -const _restingHr = 55.0; - -final _ts = [for (var t = 0; t < 600; t++) t]; -final _bpm = [for (var t = 0; t < 600; t++) 140.0]; - -double _bout(WorkoutUserProfile p, {double? vo2max}) => - Calories.estimateBoutCalories( - _ts, - _bpm, - profile: p, - hrmax: _hrMax, - restingHr: _restingHr, - vo2max: vo2max, - ).kcal; - -void main() { - group('Keytel fitness-adjusted active EE', () { - test('without a VO2max the published age/mass/sex model is unchanged', () { - // -55.0969 + 0.6309*140 + 0.1988*72 + 0.2017*34 = 54.4005 kJ/min - // 54.4005 / 251.04 * 600 s = 130.02 kcal - expect(_bout(_male), closeTo(130.02, 0.05)); - }); - - test('an unusable VO2max falls back rather than entering the regression', - () { - // The guard rejects on three separate conditions and every one of them - // has to land on the SAME fallback. VO2max is a strictly positive - // quantity, so 0 and negatives are "not measured" rather than readings, - // and a non-finite value would otherwise propagate straight through the - // linear term and poison the result as NaN/Infinity kcal. - // - // Exact equality, not closeTo: a correct fallback runs the identical code - // path, so anything other than a bit-identical answer means the guard let - // the value through. - final baseline = _bout(_male); - for (final vo2max in [ - 0, - -1, - -62.5, - double.nan, - double.infinity, - double.negativeInfinity, - ]) { - expect( - _bout(_male, vo2max: vo2max), - baseline, - reason: 'vo2max $vo2max must fall back to the age/mass/sex model', - ); - } - }); - - test('a male bout with a VO2max uses the fitness-adjusted coefficients', () { - // -95.7735 + 0.271*34 + 0.394*72 + 0.404*50 + 0.634*140 = 50.7685 kJ/min - // 50.7685 / 251.04 * 600 s = 121.34 kcal - expect(_bout(_male, vo2max: 50), closeTo(121.34, 0.05)); - }); - - test('a female bout with a VO2max uses its own coefficient block', () { - // -59.3954 + 0.274*34 + 0.103*72 + 0.380*50 + 0.450*140 = 39.3366 kJ/min - // 39.3366 / 251.04 * 600 s = 94.02 kcal - expect(_bout(_female, vo2max: 50), closeTo(94.02, 0.05)); - }); - - test('a fitter athlete burns MORE at the same heart rate', () { - // The whole reason the term is worth threading. Higher VO2max means a - // greater stroke volume, so the same heart rate moves more oxygen. - final unfit = _bout(_male, vo2max: 35); - final mid = _bout(_male, vo2max: 50); - final fit = _bout(_male, vo2max: 70); - - expect(unfit, lessThan(mid)); - expect(mid, lessThan(fit)); - // -95.7735 + 9.214 + 28.368 + 0.404*70 + 88.76 = 58.8485 kJ/min -> 140.65 - expect(fit, closeTo(140.65, 0.05)); - }); - - test('nonbinary stays the mean of the two published blocks', () { - // Matches how this package already resolves the age/mass/sex model. - // - // The equality holds because the model is linear in its coefficients AND - // both sexes' raw kJ/min are positive here, so no clamp fires. It is NOT - // a general identity: `max(0.0, eeKjMin)` is applied after the mean block - // is evaluated, so on a profile extreme enough to drive exactly one sex - // negative the mean block and the mean of the two results diverge. See - // the clamp-asymmetry case below. - expect( - _bout(_nonbinary, vo2max: 50), - closeTo((_bout(_male, vo2max: 50) + _bout(_female, vo2max: 50)) / 2, 0.01), - ); - }); - - test('the nonbinary mean is NOT an identity once a clamp fires', () { - // Pinned so nobody restates the linearity claim as unconditional. The - // zero floor is applied to each block's own kJ/min, so a profile that - // drives one sex negative and not the other breaks the equality: the mean - // block lands under the floor and reads 0 while the mean of the two - // results does not. Reachable only at a genuinely extreme profile, which - // is why it is pinned rather than fixed — the alternative is averaging - // two clamped results, which is not what "the mean coefficient block" - // means. - const old = WorkoutUserProfile( - weightKg: 35, - heightCm: 150, - age: 80, - sex: 'male', - ); - const oldF = WorkoutUserProfile( - weightKg: 35, - heightCm: 150, - age: 80, - sex: 'female', - ); - const oldN = WorkoutUserProfile( - weightKg: 35, - heightCm: 150, - age: 80, - sex: 'nonbinary', - ); - final day = [for (var i = 0; i < 600; i++) 76.0]; - double active(WorkoutUserProfile p) => - Calories.dailyEnergy(day, profile: p, hrmax: 152.0, vo2max: 15) - .active; - - expect(active(old), 0.0, reason: 'male block clamps to the floor'); - expect(active(oldF), greaterThan(0.0)); - expect(active(oldN), 0.0, reason: 'the MEAN block also clamps'); - expect( - active(oldN), - isNot(closeTo((active(old) + active(oldF)) / 2, 1.0)), - ); - }); - - test('a physiologically impossible VO2max falls back, never scales', () { - // The guard used to test only the sign and finiteness, so the fitness - // term was a bare multiplication with no upper bound: 1e6 through this - // bout returned ~965,000 kcal. The value arriving here is not measured — - // it is usually a resting-HR ratio estimate, so it inherits every - // artifact in the resting HR it divides by, and unit confusion (L/min for - // mL/kg/min) fails the same way at the bottom. - final baseline = _bout(_male); - for (final vo2max in [ - 1e6, - 1000, - 95.01, - 9.99, - 1e-12, - ]) { - expect( - _bout(_male, vo2max: vo2max), - baseline, - reason: 'vo2max $vo2max is not a human and must not price a bout', - ); - } - // The bounds themselves are inclusive and DO price the bout. - expect(_bout(_male, vo2max: Calories.minVo2max), isNot(baseline)); - expect(_bout(_male, vo2max: Calories.maxVo2max), isNot(baseline)); - }); - - test('the result says which of the two models priced it', () { - // Same contract as usedDefaultHrmax/usedDefaultAnchors: a silently - // rejected fitness anchor is indistinguishable from one that was never - // supplied unless the result says so. - double? none; - expect( - Calories.estimateBoutCalories(_ts, _bpm, - profile: _male, - hrmax: _hrMax, - restingHr: _restingHr, - vo2max: none) - .usedFitnessModel, - isFalse, - ); - expect( - Calories.estimateBoutCalories(_ts, _bpm, - profile: _male, - hrmax: _hrMax, - restingHr: _restingHr, - vo2max: 1e6) - .usedFitnessModel, - isFalse, - reason: 'rejected as implausible — the caller has to be able to tell', - ); - expect( - Calories.estimateBoutCalories(_ts, _bpm, - profile: _male, - hrmax: _hrMax, - restingHr: _restingHr, - vo2max: 50) - .usedFitnessModel, - isTrue, - ); - }); - - test('a bout spent entirely under the gate did not use the fitness model', - () { - // The flag reports that the fitness regression PRICED something, not that - // an anchor was available. Everything below the gate is Harris-Benedict, - // which has no fitness term in it, so claiming the fitness model for a - // resting bout credits a calculation that never ran. - final easy = [for (var t = 0; t < 300; t++) 65.0]; - final bout = Calories.estimateBoutCalories( - [for (var t = 0; t < 300; t++) t], - easy, - profile: _male, - hrmax: _hrMax, - restingHr: _restingHr, - vo2max: 50, - ); - expect(bout.kcal, greaterThan(0), reason: 'it was still costed'); - expect(bout.usedFitnessModel, isFalse); - }); - - test('a clamped minute above the gate is not a fitness-priced day', () { - // A minute can clear the flex gate and still contribute nothing. At a low - // VO2max the regression comes out NEGATIVE, `max(0.0, eeKjMin)` clamps it - // to zero, and zero loses to the basal minute — so `active` stays 0.0 - // while the fit branch was entered. Reporting the fitness model there is - // the same over-claim as reporting it for a day that never left basal. - // - // Female, 20 y, 45 kg, HRmax 194 -> flex 97 bpm. At VO2max 10 (the floor - // the guard admits) and HR 97: - // -59.3954 + 0.274*20 + 0.103*45 + 0.380*10 + 0.450*97 = -1.83 kJ/min - const small = WorkoutUserProfile( - weightKg: 45, - heightCm: 160, - age: 20, - sex: 'female', - ); - final atGate = [for (var i = 0; i < 120; i++) 97.0]; - final e = Calories.dailyEnergy( - atGate, - profile: small, - hrmax: 194.0, - vo2max: Calories.minVo2max, - ); - - expect(e.active, 0.0, reason: 'the clamped rate loses to the basal minute'); - expect(e.usedFitnessModel, isFalse); - }); - - test('a day spent entirely under the flex point reports no fitness model', - () { - final quiet = [for (var i = 0; i < 1440; i++) 55.0]; - final e = - Calories.dailyEnergy(quiet, profile: _male, hrmax: _hrMax, vo2max: 50); - expect(e.active, 0.0); - expect(e.usedFitnessModel, isFalse); - }); - - test('resting samples still take the BMR floor, VO2max or not', () { - // VO2max belongs to the ACTIVE term only. Below the gate the bout bills - // Harris-Benedict, which has no fitness term at all. - final easy = [for (var t = 0; t < 300; t++) 65.0]; - final withVo2 = Calories.estimateBoutCalories( - [for (var t = 0; t < 300; t++) t], - easy, - profile: _male, - hrmax: _hrMax, - restingHr: _restingHr, - vo2max: 50, - ); - final without = Calories.estimateBoutCalories( - [for (var t = 0; t < 300; t++) t], - easy, - profile: _male, - hrmax: _hrMax, - restingHr: _restingHr, - ); - expect(withVo2.kcal, closeTo(without.kcal, 1e-9)); - }); - }); - - group('dailyEnergy threads the fitness term', () { - // 60 min at 140 bpm (above the 0.50*184.2 = 92.1 flex point) + a quiet rest. - final day = [ - for (var i = 0; i < 60; i++) 140.0, - for (var i = 0; i < 1380; i++) 55.0, - ]; - - test('active energy moves with VO2max, basal does not', () { - final base = Calories.dailyEnergy(day, profile: _male, hrmax: _hrMax); - final fit = Calories.dailyEnergy( - day, - profile: _male, - hrmax: _hrMax, - vo2max: 70, - ); - - expect(fit.active, greaterThan(base.active)); - expect( - fit.basal, - closeTo(base.basal, 1e-9), - reason: 'Mifflin has no fitness term — only the active surplus moves', - ); - }); - - test('omitting VO2max leaves the daily figures byte-for-byte unchanged', () { - final a = Calories.dailyEnergy(day, profile: _male, hrmax: _hrMax); - final b = Calories.dailyEnergy( - day, - profile: _male, - hrmax: _hrMax, - vo2max: null, - ); - expect(a.total, b.total); - expect(a.active, b.active); - }); - - test('an unusable VO2max leaves the daily figures unchanged too', () { - // Same guard, second entry point. A NaN reaching the active term here - // would poison a persisted daily total, not just one bout. - final base = Calories.dailyEnergy(day, profile: _male, hrmax: _hrMax); - for (final vo2max in [ - 0, - -1, - double.nan, - double.infinity, - double.negativeInfinity, - ]) { - final e = Calories.dailyEnergy( - day, - profile: _male, - hrmax: _hrMax, - vo2max: vo2max, - ); - expect(e.total, base.total, reason: 'vo2max $vo2max'); - expect(e.active, base.active, reason: 'vo2max $vo2max'); - expect(e.total.isFinite, isTrue); - } - }); - }); -} diff --git a/test/onehz/workout_test.dart b/test/onehz/workout_test.dart index 57f7fd5..5bb45d9 100644 --- a/test/onehz/workout_test.dart +++ b/test/onehz/workout_test.dart @@ -309,58 +309,6 @@ void main() { expect(out, isEmpty); }); - test('the detected bout carries which calorie model priced it', () { - // Both flags used to be computed inside detect() and dropped before the - // ExerciseSession was built, so a bout came back with no way to tell - // whether its calories were personal or built on a 220/60 stand-in, and - // later no way to tell which of Keytel's two models produced them. Two - // bouts scored on different models are not comparable. - final d = buildDay(); - const profile = WorkoutUserProfile( - weightKg: 72, - heightCm: 178, - age: 34, - sex: 'male', - ); - - final plain = WorkoutDetector.detect( - hrTs: d.hrTs, - hrBpm: d.hrBpm, - gravTs: d.gTs, - gx: d.gx, - gy: d.gy, - gz: d.gz, - maxHR: 190, - restingHR: 60, - profile: profile, - ); - final fit = WorkoutDetector.detect( - hrTs: d.hrTs, - hrBpm: d.hrBpm, - gravTs: d.gTs, - gx: d.gx, - gy: d.gy, - gz: d.gz, - maxHR: 190, - restingHR: 60, - profile: profile, - vo2max: 50, - ); - - expect(plain, isNotEmpty); - expect(fit, isNotEmpty); - expect(plain.first.caloriesUsedFitnessModel, isFalse); - expect(fit.first.caloriesUsedFitnessModel, isTrue); - expect(plain.first.toJson()['calories_used_fitness_model'], isFalse); - expect(fit.first.toJson()['calories_used_fitness_model'], isTrue); - // The anchors here are real, so the other flag must stay false — it is - // the same plumbing and was equally untested. - expect(fit.first.caloriesUsedDefaultAnchors, isFalse); - // And the term has to actually move the number, or the flag is decoration. - expect(fit.first.caloriesKcal, isNotNull); - expect(fit.first.caloriesKcal, isNot(closeTo(plain.first.caloriesKcal!, 0.5))); - }); - test('injected sport classifier types the detected bout', () { final d = buildDay(); String classify(WorkoutBout b, MotionFeatures? f) { From 31658d227d5f87cfd4eeed8daab21d25bf27d7ae Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Tue, 11 Aug 2026 22:36:22 +0530 Subject: [PATCH 7/7] pin the cap through the detector, not just the estimator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other case for the gap cap calls estimateBoutCalories directly, which cannot observe whether the detector hands it the right constant. A dropout of 252 s is longer than the cap and shorter than the bridge window, so it lands inside one bout and the cap binds — about 24 kcal on this fixture. --- test/onehz/workout_test.dart | 68 ++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/test/onehz/workout_test.dart b/test/onehz/workout_test.dart index 5bb45d9..7fc683b 100644 --- a/test/onehz/workout_test.dart +++ b/test/onehz/workout_test.dart @@ -352,6 +352,74 @@ void main() { }); }); + test('a bridged dropout is billed at the published cap, through detect()', () { + // The reason detect() reads Calories.defaultMergeGapCapS rather than its own + // mergeGapS: bridgeGapS is TWICE mergeGapS, so _bridgeRuns stitches an + // HR-free dropout of up to 300 s into a single bout, and the cap really does + // bind inside one. Everything else about the cap is tested against + // estimateBoutCalories directly, which cannot observe that the detector + // passes the right constant. + const gapS = 252; // > the 150 s cap, < the 300 s bridge window + final hrTs = []; + final hrBpm = []; + for (var t = 0; t < 600; t++) { + hrTs.add(t); + hrBpm.add(150); + } + for (var t = 600 + gapS; t < 1200 + gapS; t++) { + hrTs.add(t); + hrBpm.add(150); + } + // Motion has to stay above the gate across the gap or the runs never merge. + final gTs = []; + final gx = [], gy = [], gz = []; + for (var t = 0; t < 1200 + gapS; t++) { + gTs.add(t); + gx.add(t.isEven ? 0.0 : 0.6); + gy.add(0); + gz.add(1); + } + + const profile = + WorkoutUserProfile(weightKg: 75, heightCm: 178, age: 30, sex: 'male'); + final out = WorkoutDetector.detect( + hrTs: hrTs, + hrBpm: hrBpm, + gravTs: gTs, + gx: gx, + gy: gy, + gz: gz, + maxHR: 190, + restingHR: 60, + profile: profile, + ); + + expect(out, hasLength(1), reason: 'the dropout must be bridged, not split'); + final session = out.first; + + // Score the same samples both ways. The detector must match the capped one. + double score(double cap) => Calories.estimateBoutCalories( + hrTs, + hrBpm, + profile: profile, + hrmax: 190, + restingHr: 60, + mergeGapCapS: cap, + ).kcal; + + final capped = score(Calories.defaultMergeGapCapS); + final uncapped = score(gapS.toDouble() + 1); + + // Not exact: detect() prices its own bout window, which the run boundaries + // trim by a sample or two against the raw stream scored here — worth a few + // tenths of a kcal. The capped and uncapped figures are ~24 kcal apart, so + // a 2 kcal tolerance still tells them apart by an order of magnitude. + expect(uncapped - capped, greaterThan(20.0), + reason: 'if these converge the assertions below prove nothing'); + expect((session.caloriesKcal! - capped).abs(), lessThan(2.0)); + expect((session.caloriesKcal! - uncapped).abs(), greaterThan(20.0)); + }); + group('Calories (Keytel + Harris–Benedict)', () { test('male/female coefficients differ; active > resting', () { // 10 min @ 150 bpm, 1 Hz.