diff --git a/lib/src/onehz/workout/calories.dart b/lib/src/onehz/workout/calories.dart index dbf4a1c..b5855b7 100644 --- a/lib/src/onehz/workout/calories.dart +++ b/lib/src/onehz/workout/calories.dart @@ -91,6 +91,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': @@ -210,8 +220,9 @@ class Calories { } /// 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 @@ -223,7 +234,7 @@ class Calories { required WorkoutUserProfile profile, double? hrmax, double? restingHr, - double mergeGapCapS = 150.0, + double mergeGapCapS = defaultMergeGapCapS, }) { final weightKg = profile.weightKg > 0 ? profile.weightKg : 70.0; final heightCm = profile.heightCm > 0 ? profile.heightCm : 170.0; diff --git a/lib/src/onehz/workout/workout_detect.dart b/lib/src/onehz/workout/workout_detect.dart index 79f4556..86e969a 100644 --- a/lib/src/onehz/workout/workout_detect.dart +++ b/lib/src/onehz/workout/workout_detect.dart @@ -414,7 +414,17 @@ 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, 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, ); kcal = cal.kcal; kj = cal.kj; 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.