//! 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); } }