Skip to content
17 changes: 14 additions & 3 deletions lib/src/onehz/workout/calories.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down
12 changes: 11 additions & 1 deletion lib/src/onehz/workout/workout_detect.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
kcal = cal.kcal;
kj = cal.kj;
Expand Down
68 changes: 68 additions & 0 deletions test/onehz/workout_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <int>[];
final hrBpm = <double>[];
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 = <int>[];
final gx = <double>[], gy = <double>[], gz = <double>[];
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.
Expand Down
Loading