Gears are expressed as an offset to the commanded gradient, leaving the physics on the route's true gradient so shifting changes effort, not speed. Neutral gear commands exactly the route gradient, so an un-shifted ride is unchanged. Cadence is not in FTMS on this trainer but is on its Zwift channel, decoded against captured frames. The undeclared FTMS trailing bytes were ruled out: wheel RPM restated at a fixed 73.8x speed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
94 lines
4.0 KiB
Rust
94 lines
4.0 KiB
Rust
//! Metabolic energy expenditure — mechanical work in, kilocalories out.
|
||
//!
|
||
//! One model, used in two places: the live readout on the ride screen
|
||
//! (`src-tauri/src/derive.rs`) and the `total_calories` written into the FIT
|
||
//! activity (`bikecontrol-fit`). They must agree, so the arithmetic lives here
|
||
//! rather than being written twice.
|
||
//!
|
||
//! **The model.** A rider burns metabolic energy in two ways during a ride:
|
||
//!
|
||
//! * *Work.* Mechanical work measured at the pedals, divided by the rider's
|
||
//! efficiency at converting food energy into it. Cycling net efficiency sits
|
||
//! around 20–25%; [`NET_EFFICIENCY`] takes the top of that range because the
|
||
//! figure most riders compare against (Strava, Garmin) is effectively the
|
||
//! 1 kJ ≈ 1 kcal convention, which corresponds to ~24%.
|
||
//! * *Rest.* Being alive costs roughly one kilocalorie per kilogram per hour —
|
||
//! the definition of 1 MET. Over a two-hour ride that is another ~150 kcal
|
||
//! for an 80 kg rider, so it is worth counting rather than rounding away.
|
||
//!
|
||
//! Splitting the two is why this uses *net* efficiency (work above baseline)
|
||
//! and not *gross* efficiency (which already has the resting cost folded in) —
|
||
//! using gross efficiency and then adding rest back would count it twice.
|
||
//!
|
||
//! **What this is not.** It is an estimate, not a measurement. Real efficiency
|
||
//! varies by rider, cadence and intensity, and no power meter can see the
|
||
//! difference. Treat a figure from here as ±10%.
|
||
|
||
/// Joules in one dietary kilocalorie.
|
||
pub const JOULES_PER_KCAL: f64 = 4184.0;
|
||
|
||
/// Fraction of the metabolic energy spent *above resting* that reaches the
|
||
/// pedals as mechanical work.
|
||
pub const NET_EFFICIENCY: f64 = 0.25;
|
||
|
||
/// Resting metabolic rate, kcal per kilogram of body mass per hour. This is
|
||
/// 1 MET, the standard baseline.
|
||
pub const RESTING_KCAL_PER_KG_HOUR: f64 = 1.0;
|
||
|
||
/// Kilocalories burned by `work_j` joules of pedalling spread over `active_s`
|
||
/// seconds, by a rider of `rider_kg`.
|
||
///
|
||
/// `active_s` should be time the rider was actually riding — a paused ride
|
||
/// still burns calories, but they are not this ride's to claim. Passing a
|
||
/// `rider_kg` of zero (an unknown rider) drops the resting term and leaves the
|
||
/// work term intact, which is the right degradation: an underestimate rather
|
||
/// than a fabricated one.
|
||
pub fn kcal(work_j: f64, rider_kg: f32, active_s: f64) -> f64 {
|
||
let from_work = work_j.max(0.0) / JOULES_PER_KCAL / NET_EFFICIENCY;
|
||
let from_rest =
|
||
f64::from(rider_kg.max(0.0)) * RESTING_KCAL_PER_KG_HOUR * active_s.max(0.0) / 3600.0;
|
||
from_work + from_rest
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// The sanity check every cyclist knows: an hour at 250 W — 900 kJ of work
|
||
/// — costs somewhere close to a thousand kilocalories. Anything far from
|
||
/// that means the constants are wrong, whatever the arithmetic says.
|
||
#[test]
|
||
fn an_hour_at_250_w_is_about_a_thousand_kcal() {
|
||
let work_j = 250.0 * 3600.0;
|
||
let out = kcal(work_j, 75.0, 3600.0);
|
||
assert!((900.0..=1000.0).contains(&out), "got {out} kcal");
|
||
}
|
||
|
||
/// The work term alone must stay near the 1 kJ ≈ 1 kcal convention, so the
|
||
/// number is recognisable next to the kJ readout beside it.
|
||
#[test]
|
||
fn work_alone_tracks_the_kilojoule_convention() {
|
||
let ratio = kcal(1_000_000.0, 0.0, 0.0) / 1000.0;
|
||
assert!((0.9..=1.1).contains(&ratio), "kcal/kJ ratio {ratio}");
|
||
}
|
||
|
||
/// Resting metabolism accrues with time, not with work.
|
||
#[test]
|
||
fn resting_burn_accrues_without_any_work() {
|
||
let out = kcal(0.0, 80.0, 3600.0);
|
||
assert!((out - 80.0).abs() < 1e-9, "got {out} kcal");
|
||
}
|
||
|
||
/// An unknown rider mass must not invent a resting burn.
|
||
#[test]
|
||
fn unknown_rider_mass_drops_the_resting_term() {
|
||
assert_eq!(kcal(100_000.0, 0.0, 3600.0), kcal(100_000.0, 0.0, 0.0));
|
||
}
|
||
|
||
/// Garbage in must not produce a negative calorie count.
|
||
#[test]
|
||
fn negative_inputs_clamp_rather_than_subtract() {
|
||
assert_eq!(kcal(-500.0, -80.0, -60.0), 0.0);
|
||
}
|
||
}
|