Virtual gearing, trainer-speed blend, and cadence decode
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>
This commit is contained in:
+79
-48
@@ -52,8 +52,8 @@ pub struct FitSummary {
|
||||
pub avg_power_w: Option<u16>,
|
||||
/// Peak power. `None` if no sample reported power.
|
||||
pub max_power_w: Option<u16>,
|
||||
/// Energy in kilocalories, from the trainer if it reports it and otherwise
|
||||
/// derived from mechanical work.
|
||||
/// Estimated rider energy expenditure in kilocalories, derived from
|
||||
/// measured mechanical work — see [`Aggregates::calories`].
|
||||
pub total_calories: Option<u16>,
|
||||
/// Number of BLE dropouts spanned (FR-8.5).
|
||||
pub gaps: usize,
|
||||
@@ -89,12 +89,9 @@ struct Aggregates {
|
||||
descent_m: f64,
|
||||
grade_sum: f64,
|
||||
grade_n: u32,
|
||||
/// Mechanical work, joules, integrated from power. The basis for calories
|
||||
/// when the trainer does not report energy directly.
|
||||
/// Mechanical work, joules, integrated from power. The sole basis for the
|
||||
/// calorie figure — see [`Aggregates::calories`].
|
||||
work_j: f64,
|
||||
/// Trainer-reported cumulative energy at the first and last sample.
|
||||
energy_start: Option<u16>,
|
||||
energy_end: Option<u16>,
|
||||
records: usize,
|
||||
}
|
||||
|
||||
@@ -125,19 +122,23 @@ impl Aggregates {
|
||||
self.total_distance_m() / (self.total_timer_ms as f64 / 1000.0)
|
||||
}
|
||||
|
||||
/// Calories.
|
||||
/// Calories, from measured work via [`bikecontrol_core::energy`] — the same
|
||||
/// model the live readout uses, so the file agrees with what the rider
|
||||
/// watched during the ride.
|
||||
///
|
||||
/// Prefers the trainer's own cumulative figure. Otherwise it uses the
|
||||
/// cycling convention that kilojoules of mechanical work and dietary
|
||||
/// kilocalories are numerically near-equal — human efficiency of roughly
|
||||
/// 24% and the 4.184 kJ/kcal conversion very nearly cancel. This is the
|
||||
/// same approximation Strava and Garmin apply to a power-meter ride.
|
||||
fn calories(&self) -> Option<u16> {
|
||||
match (self.energy_start, self.energy_end) {
|
||||
(Some(a), Some(b)) if b >= a && b > 0 => return Some(b - a),
|
||||
_ => {}
|
||||
}
|
||||
let kcal = clamp_u16(self.work_j / 1000.0);
|
||||
/// The trainer's own cumulative energy field is deliberately *not* used,
|
||||
/// even when present. FTMS specifies "Total Energy" in kilocalories but
|
||||
/// says nothing about whether it means mechanical or metabolic energy, and
|
||||
/// implementations disagree by a factor of four: some report kJ/4.184 (the
|
||||
/// rider as a perfect engine), others apply an efficiency factor, others
|
||||
/// report a figure with no documented basis at all. Measured power is the
|
||||
/// one input we can reason about, so it is the only one used.
|
||||
fn calories(&self, rider_kg: f32) -> Option<u16> {
|
||||
let kcal = clamp_u16(bikecontrol_core::energy::kcal(
|
||||
self.work_j,
|
||||
rider_kg,
|
||||
self.total_timer_ms as f64 / 1000.0,
|
||||
));
|
||||
(kcal > 0).then_some(kcal)
|
||||
}
|
||||
|
||||
@@ -215,7 +216,7 @@ pub fn encode_activity(log: &RawLog) -> Result<(Vec<u8>, FitSummary), FitError>
|
||||
total_ascent_m: clamp_u16(session_agg.ascent_m),
|
||||
avg_power_w: session_agg.avg_power(),
|
||||
max_power_w: session_agg.max_power,
|
||||
total_calories: session_agg.calories(),
|
||||
total_calories: session_agg.calories(log.start.rider_kg),
|
||||
gaps: log.gaps(end_ms).len(),
|
||||
recovered_from_crash: !log.clean_shutdown,
|
||||
skipped_log_lines: log.skipped_lines,
|
||||
@@ -358,12 +359,8 @@ fn aggregate(
|
||||
let s = &r.sample;
|
||||
if i == 0 {
|
||||
agg.start_distance_m = s.distance_m;
|
||||
agg.energy_start = s.energy_kcal;
|
||||
}
|
||||
agg.end_distance_m = s.distance_m;
|
||||
if s.energy_kcal.is_some() {
|
||||
agg.energy_end = s.energy_kcal;
|
||||
}
|
||||
|
||||
// Sample interval, for work integration. Clamped so that a long BLE
|
||||
// dropout does not silently attribute minutes of work to one sample.
|
||||
@@ -504,7 +501,7 @@ fn assemble(
|
||||
enc.write_message(
|
||||
local::LAP,
|
||||
mesg::LAP,
|
||||
&lap_message(lap_index as u16, agg, log.start.sub_sport, is_last),
|
||||
&lap_message(lap_index as u16, agg, log.start.sub_sport, log.start.rider_kg, is_last),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -513,7 +510,12 @@ fn assemble(
|
||||
enc.write_message(
|
||||
local::SESSION,
|
||||
mesg::SESSION,
|
||||
&session_message(session_agg, log.start.sub_sport, lap_aggs.len() as u16),
|
||||
&session_message(
|
||||
session_agg,
|
||||
log.start.sub_sport,
|
||||
log.start.rider_kg,
|
||||
lap_aggs.len() as u16,
|
||||
),
|
||||
);
|
||||
|
||||
let mut m = Message::new();
|
||||
@@ -641,7 +643,13 @@ fn record_message(r: &Resolved, mask: &FieldMask) -> Message {
|
||||
m
|
||||
}
|
||||
|
||||
fn lap_message(index: u16, agg: &Aggregates, sub_sport: u8, is_last: bool) -> Message {
|
||||
fn lap_message(
|
||||
index: u16,
|
||||
agg: &Aggregates,
|
||||
sub_sport: u8,
|
||||
rider_kg: f32,
|
||||
is_last: bool,
|
||||
) -> Message {
|
||||
let mut m = Message::new();
|
||||
m.set(lap::MESSAGE_INDEX, Value::Uint16(index));
|
||||
m.set(lap::TIMESTAMP, Value::Uint32(agg.end_fit));
|
||||
@@ -660,7 +668,7 @@ fn lap_message(index: u16, agg: &Aggregates, sub_sport: u8, is_last: bool) -> Me
|
||||
lap::TOTAL_DISTANCE,
|
||||
Value::Uint32(clamp_u32(agg.total_distance_m() * 100.0)),
|
||||
);
|
||||
m.set_opt(lap::TOTAL_CALORIES, agg.calories().map(Value::Uint16));
|
||||
m.set_opt(lap::TOTAL_CALORIES, agg.calories(rider_kg).map(Value::Uint16));
|
||||
m.set(
|
||||
lap::AVG_SPEED,
|
||||
Value::Uint16(clamp_u16(agg.avg_speed_mps() * 1000.0)),
|
||||
@@ -697,7 +705,7 @@ fn lap_message(index: u16, agg: &Aggregates, sub_sport: u8, is_last: bool) -> Me
|
||||
m
|
||||
}
|
||||
|
||||
fn session_message(agg: &Aggregates, sub_sport: u8, num_laps: u16) -> Message {
|
||||
fn session_message(agg: &Aggregates, sub_sport: u8, rider_kg: f32, num_laps: u16) -> Message {
|
||||
let mut m = Message::new();
|
||||
m.set(session::MESSAGE_INDEX, Value::Uint16(0));
|
||||
m.set(session::TIMESTAMP, Value::Uint32(agg.end_fit));
|
||||
@@ -718,7 +726,7 @@ fn session_message(agg: &Aggregates, sub_sport: u8, num_laps: u16) -> Message {
|
||||
session::TOTAL_DISTANCE,
|
||||
Value::Uint32(clamp_u32(agg.total_distance_m() * 100.0)),
|
||||
);
|
||||
m.set_opt(session::TOTAL_CALORIES, agg.calories().map(Value::Uint16));
|
||||
m.set_opt(session::TOTAL_CALORIES, agg.calories(rider_kg).map(Value::Uint16));
|
||||
m.set(
|
||||
session::AVG_SPEED,
|
||||
Value::Uint16(clamp_u16(agg.avg_speed_mps() * 1000.0)),
|
||||
@@ -905,7 +913,8 @@ mod tests {
|
||||
assert_eq!(summary.total_distance_m, 100.0);
|
||||
assert_eq!(summary.avg_power_w, Some(200));
|
||||
assert_eq!(summary.max_power_w, Some(200));
|
||||
// 200 W for ten one-second intervals = 2000 J = 2 kJ ~ 2 kcal.
|
||||
// 200 W for ten one-second intervals = 2000 J = 2 kJ ~ 2 kcal. The
|
||||
// fixture log carries no rider mass, so there is no resting term.
|
||||
assert_eq!(summary.total_calories, Some(2));
|
||||
}
|
||||
|
||||
@@ -1182,25 +1191,47 @@ mod tests {
|
||||
assert_eq!(session.total_distance_m(), 600.0);
|
||||
}
|
||||
|
||||
/// A trainer's own energy field is ignored, however confidently it is
|
||||
/// reported: implementations disagree by a factor of four about what it
|
||||
/// means, and measured power does not. 200 W for a minute is 12 kJ of
|
||||
/// work — about 12 kcal — not the 200 the trainer claims.
|
||||
#[test]
|
||||
fn trainer_reported_energy_is_preferred_for_calories() {
|
||||
let entries = vec![
|
||||
LogEntry::Sample(Sample {
|
||||
elapsed_ms: 0,
|
||||
power_w: Some(200),
|
||||
energy_kcal: Some(10),
|
||||
..Default::default()
|
||||
}),
|
||||
LogEntry::Sample(Sample {
|
||||
elapsed_ms: 60_000,
|
||||
power_w: Some(200),
|
||||
energy_kcal: Some(210),
|
||||
..Default::default()
|
||||
}),
|
||||
LogEntry::End { at_ms: 60_000 },
|
||||
];
|
||||
fn trainer_reported_energy_is_ignored_in_favour_of_measured_work() {
|
||||
let mut entries: Vec<LogEntry> = (0..=60)
|
||||
.map(|i| {
|
||||
LogEntry::Sample(Sample {
|
||||
elapsed_ms: i * 1000,
|
||||
power_w: Some(200),
|
||||
// A trainer insisting the rider burned 200 kcal in a minute.
|
||||
energy_kcal: Some(10 + (i * 200 / 60) as u16),
|
||||
..Default::default()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
entries.push(LogEntry::End { at_ms: 60_000 });
|
||||
let (_, summary) = encode_activity(&log_with(entries)).unwrap();
|
||||
assert_eq!(summary.total_calories, Some(200));
|
||||
assert_eq!(summary.total_calories, Some(11));
|
||||
}
|
||||
|
||||
/// With a rider mass recorded, the hour spent riding costs something even
|
||||
/// beyond the pedalling: 1 MET of resting metabolism on top of the work.
|
||||
#[test]
|
||||
fn a_recorded_rider_mass_adds_the_resting_burn() {
|
||||
let mut entries: Vec<LogEntry> = (0..=60)
|
||||
.map(|i| {
|
||||
LogEntry::Sample(Sample {
|
||||
elapsed_ms: i * 1000,
|
||||
power_w: Some(200),
|
||||
..Default::default()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
entries.push(LogEntry::End { at_ms: 60_000 });
|
||||
let mut log = log_with(entries);
|
||||
log.start.rider_kg = 75.0;
|
||||
let (_, summary) = encode_activity(&log).unwrap();
|
||||
// ~11.5 kcal of work plus 75 kcal/h for one minute.
|
||||
assert_eq!(summary.total_calories, Some(13));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -107,6 +107,11 @@ pub struct SessionStart {
|
||||
/// Device serial. Zero means "unset" (the FIT base type is `uint32z`).
|
||||
#[serde(default)]
|
||||
pub serial_number: u32,
|
||||
/// Rider mass in kilograms, for the resting half of the calorie estimate
|
||||
/// (`bikecontrol_core::energy`). Zero means unknown — as it will be in any
|
||||
/// journal written before this field existed — and simply drops that term.
|
||||
#[serde(default)]
|
||||
pub rider_kg: f32,
|
||||
/// Format version of this log.
|
||||
#[serde(default)]
|
||||
pub log_format: u16,
|
||||
@@ -138,6 +143,7 @@ impl Default for SessionStart {
|
||||
product_name: default_product_name(),
|
||||
software_version: default_software_version(),
|
||||
serial_number: 0,
|
||||
rider_kg: 0.0,
|
||||
log_format: LOG_FORMAT_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ pub struct RecorderOptions {
|
||||
pub software_version: u16,
|
||||
/// Device serial number. Zero means unset.
|
||||
pub serial_number: u32,
|
||||
/// Rider mass in kilograms, recorded so the calorie estimate in the
|
||||
/// finished activity can include the resting term. Zero means unknown.
|
||||
pub rider_kg: f32,
|
||||
}
|
||||
|
||||
impl Default for RecorderOptions {
|
||||
@@ -54,6 +57,7 @@ impl Default for RecorderOptions {
|
||||
product_name: "BikeControl".to_string(),
|
||||
software_version: 100,
|
||||
serial_number: 0,
|
||||
rider_kg: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,6 +138,7 @@ impl Recorder {
|
||||
product_name: opts.product_name.clone(),
|
||||
software_version: opts.software_version,
|
||||
serial_number: opts.serial_number,
|
||||
rider_kg: opts.rider_kg,
|
||||
log_format: LOG_FORMAT_VERSION,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user