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:
2026-08-05 15:33:28 +02:00
co-authored by Claude Opus 5
parent 3a2a787b7d
commit 57eb5e809b
48 changed files with 57737 additions and 431 deletions
+93
View File
@@ -0,0 +1,93 @@
//! 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 2025%; [`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);
}
}
+234
View File
@@ -0,0 +1,234 @@
//! Virtual gears for a single-cog drivetrain (§5.4, FR-4.1).
//!
//! With a Zwift Cog there is one 14T sprocket and no way to shift, so the rider
//! has exactly one gear. That is tolerable on the flat and useless everywhere
//! else: on a climb they grind, and on a descent the trainer unloads, they spin
//! out against nothing, and their effort stops contributing at precisely the
//! moment they can see the speed rising.
//!
//! FTMS has no virtual-shifting op code — Zwift's own implementation is
//! proprietary — so gearing has to be synthesised from what the trainer does
//! expose. The D100 accepts `SetIndoorBikeSimulationParameters`, so a gear is
//! expressed as an **offset to the gradient the trainer is asked to simulate**:
//! a harder gear asks for a steeper hill and therefore more load.
//!
//! Two gradients therefore exist and must not be confused:
//!
//! * the **route** gradient, which the physics model uses, so speed still
//! reflects the terrain;
//! * the **commanded** gradient — route plus gear offset — which only decides
//! how hard the pedals feel.
//!
//! Shifting consequently changes effort, not speed, exactly as on a real bike.
//! Speed changes only as a *result*: a harder gear at the same cadence produces
//! more watts, and more watts produce more speed through the physics.
//!
//! The percent-per-gear mapping is a pragmatic stand-in for a proper torque
//! model and **wants calibrating against the real resistance curve** (TASK-3 in
//! REQUIREMENTS.md, still outstanding). The defaults are a starting point, not
//! a measured result.
use serde::{Deserialize, Serialize};
/// A ladder of load offsets, easiest first.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VirtualCassette {
/// Gradient offset per gear, in percent. Ascending.
offsets: Vec<f32>,
}
impl VirtualCassette {
/// Evenly spaced gears between two offsets.
///
/// `easiest` is normally negative — it *removes* load, so the rider can
/// still turn the pedals on a steep climb. `hardest` is positive, which is
/// what makes a descent rideable rather than a spin-out.
pub fn linear(gears: usize, easiest_pct: f32, hardest_pct: f32) -> Self {
let gears = gears.max(1);
if gears == 1 {
return Self { offsets: vec![0.0] };
}
let step = (hardest_pct - easiest_pct) / (gears - 1) as f32;
Self {
offsets: (0..gears).map(|i| easiest_pct + step * i as f32).collect(),
}
}
pub fn len(&self) -> usize {
self.offsets.len()
}
pub fn is_empty(&self) -> bool {
self.offsets.is_empty()
}
pub fn offset_pct(&self, gear: usize) -> f32 {
self.offsets
.get(gear.min(self.offsets.len().saturating_sub(1)))
.copied()
.unwrap_or(0.0)
}
}
impl VirtualCassette {
/// A ladder with an exact **zero** rung at `neutral`, stepping by `step`
/// either side.
///
/// The zero matters: it is the gear in which the trainer is asked for
/// precisely the route's gradient and nothing else, so a rider who never
/// shifts gets exactly the behaviour they had before gears existed.
pub fn centred(gears: usize, neutral: usize, step: f32) -> Self {
let gears = gears.max(1);
let neutral = neutral.min(gears - 1);
Self {
offsets: (0..gears)
.map(|i| (i as f32 - neutral as f32) * step)
.collect(),
}
}
/// Index of the gear whose offset is nearest neutral.
pub fn neutral_gear(&self) -> usize {
self.offsets
.iter()
.enumerate()
.min_by(|a, b| a.1.abs().total_cmp(&b.1.abs()))
.map(|(i, _)| i)
.unwrap_or(0)
}
}
impl Default for VirtualCassette {
/// Twelve gears in 0.75% steps, neutral at gear 5, spanning 3% to +5.25%.
/// The asymmetry is deliberate: shedding load on a climb matters less than
/// being able to *find* load on a descent, which is the failure this module
/// exists to fix.
fn default() -> Self {
Self::centred(12, 4, 0.75)
}
}
/// The rider's current gear selection.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Gearing {
cassette: VirtualCassette,
gear: usize,
}
impl Default for Gearing {
fn default() -> Self {
let cassette = VirtualCassette::default();
// Start in the neutral gear so an un-shifted ride behaves exactly as it
// did before gears existed — no silent change to the commanded gradient.
let gear = cassette.neutral_gear();
Self { cassette, gear }
}
}
impl Gearing {
pub fn new(cassette: VirtualCassette) -> Self {
let gear = cassette.neutral_gear();
Self { cassette, gear }
}
/// One-based, because riders count gears from one.
pub fn gear(&self) -> usize {
self.gear + 1
}
pub fn gear_count(&self) -> usize {
self.cassette.len()
}
/// Load offset in simulated-gradient percent for the selected gear.
pub fn offset_pct(&self) -> f32 {
self.cassette.offset_pct(self.gear)
}
/// Shift to a harder gear. Clamps at the top — never wraps (FR-4.1.3),
/// because wrapping from hardest to easiest mid-climb would be violent.
pub fn shift_up(&mut self) -> bool {
if self.gear + 1 < self.cassette.len() {
self.gear += 1;
true
} else {
false
}
}
/// Shift to an easier gear. Clamps at the bottom.
pub fn shift_down(&mut self) -> bool {
if self.gear > 0 {
self.gear -= 1;
true
} else {
false
}
}
/// Select a gear directly, one-based. Out-of-range values clamp.
pub fn set_gear(&mut self, one_based: usize) {
let idx = one_based.saturating_sub(1);
self.gear = idx.min(self.cassette.len().saturating_sub(1));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_default_cassette_spans_easier_and_harder_than_neutral() {
let g = Gearing::default();
assert_eq!(g.gear_count(), 12);
let c = &g.cassette;
assert!(c.offset_pct(0) < 0.0, "bottom gear must shed load");
assert!(c.offset_pct(11) > 0.0, "top gear must add load");
}
#[test]
fn an_unshifted_ride_commands_exactly_the_route_gradient() {
// Gears must not silently alter the ride for someone who never shifts.
let g = Gearing::default();
assert_eq!(g.offset_pct(), 0.0);
}
#[test]
fn shifting_is_monotonic_and_clamps_at_both_ends() {
let mut g = Gearing::new(VirtualCassette::linear(5, -2.0, 4.0));
while g.shift_down() {}
assert_eq!(g.gear(), 1);
assert!(!g.shift_down(), "must not wrap past the bottom");
let bottom = g.offset_pct();
let mut previous = bottom;
while g.shift_up() {
let now = g.offset_pct();
assert!(now > previous, "each shift up must add load");
previous = now;
}
assert_eq!(g.gear(), 5);
assert!(!g.shift_up(), "must not wrap past the top");
}
#[test]
fn a_hard_gear_finds_load_on_a_descent() {
// The failure this module exists to fix: on a -6% descent the trainer
// unloads and the rider spins out. Selecting a hard gear must bring the
// commanded gradient back to something they can push against.
let mut g = Gearing::new(VirtualCassette::default());
while g.shift_up() {}
let commanded = -6.0 + g.offset_pct();
assert!(
commanded > -1.0,
"top gear should recover load on a descent, got {commanded}%"
);
}
#[test]
fn a_single_speed_cassette_is_neutral() {
let g = Gearing::new(VirtualCassette::linear(1, -3.0, 6.0));
assert_eq!(g.gear_count(), 1);
assert_eq!(g.offset_pct(), 0.0);
}
}
+220
View File
@@ -191,6 +191,14 @@ pub fn haversine_m(a: TrackPoint, b: TrackPoint) -> f64 {
/// 2. Resample elevation onto an even `resample_m` grid. Uneven GPS spacing
/// otherwise weights a stationary cluster of fixes as heavily as a fast
/// descent.
/// 2b. Reject *outliers* — single fixes metres away from their neighbours —
/// before any averaging. A moving average does not remove an outlier, it
/// smears it across the whole window, and the differentiation in step 4
/// then reads that smear as a sustained gradient. The commonest instance is
/// the first fix of a recorded activity, taken before the receiver has
/// settled: on a real 22.6 km file the opening fix sat 9 m above the road
/// and produced a phantom 9.5% descent that the trainer was then asked to
/// reproduce.
/// 3. Smooth elevation with two cascaded centred moving averages `window_m`
/// wide, over a reflected extension so the window never truncates at the
/// ends. Consumer GPS elevation carries metres of noise; differentiating it
@@ -263,6 +271,9 @@ pub fn to_terrain(
grid_ele.push(y0 + (y1 - y0) * f);
}
// 2b. Reject outliers before averaging anything. See `despike`.
despike(&mut grid_ele, spacing);
// 3. Smooth, over a reflected extension of the series so that the window
// stays full width at the ends. Truncating the window instead leaves
// the first and last samples barely smoothed, and since step 4 reads
@@ -348,6 +359,100 @@ fn gradient_bounds(cfg: &SmoothingConfig) -> (f32, f32) {
}
}
/// Width of the outlier-rejection window, metres. Wide enough that the spread
/// estimate over it is stable — a window of a handful of samples produces a
/// noisy σ, and a noisy σ makes the filter fire on ordinary noise — and narrow
/// enough that a road's curvature across it stays below [`DESPIKE_FLOOR_M`].
const DESPIKE_WINDOW_M: f64 = 150.0;
/// How many robust standard deviations from the local trend counts as an
/// outlier.
const DESPIKE_K: f32 = 4.0;
/// A sample is never rejected for deviating less than this, in metres.
///
/// Two jobs. It stops a genuinely smooth stretch — where the estimated spread
/// is near zero — from having every millimetre of wobble called an outlier.
/// And it keeps the filter clear of ordinary consumer-GPS elevation noise,
/// which runs to ±1.53 m: that is the smoothing window's problem to solve,
/// not this one's. Set below the errors that actually matter, which are the
/// 510 m variety.
const DESPIKE_FLOOR_M: f32 = 4.0;
/// Lower-median of a slice, sorted in place. NaN-safe via `total_cmp`.
fn median(values: &mut [f32]) -> f32 {
if values.is_empty() {
return 0.0;
}
values.sort_by(|a, b| a.total_cmp(b));
values[values.len() / 2]
}
/// Replace elevation samples that are not on the road with the road.
///
/// This is a Hampel filter with one addition that matters here: the local
/// trend is removed before the test. A plain median filter is already immune
/// to a *linear* trend in the middle of a series, because the median of a
/// symmetric window through a ramp is its centre value — but not at the ends,
/// where the window can only look one way. Since the single most common
/// outlier in a real GPX is the *first* fix of the recording, taken before the
/// receiver has settled, the ends are exactly where this has to work.
///
/// So each window's slope is estimated robustly (the median of its consecutive
/// differences, which one bad sample cannot move), every sample in the window
/// is projected along that slope to the position under test, and the median of
/// those projections is what the sample is compared against. A sample further
/// than `max(K·σ, floor)` from it is not terrain and is replaced.
///
/// Why this cannot be left to the smoothing that follows: averaging does not
/// remove an outlier, it spreads it over the whole window, and differentiating
/// that smear yields a gradient that is sustained rather than transient. A 9 m
/// first-fix error produced a 9.5% opening descent on a road that was flat,
/// and that gradient was commanded to the trainer.
fn despike(grid: &mut [f32], spacing: f64) {
let n = grid.len();
let radius = ((DESPIKE_WINDOW_M / spacing.max(f64::MIN_POSITIVE)) * 0.5).round();
let radius = (radius.max(3.0) as usize).min(n);
let width = 2 * radius + 1;
if n < width {
// Too short to tell an outlier from the shape of the road.
return;
}
let src = grid.to_vec();
let mut diffs = Vec::with_capacity(width - 1);
let mut projected = Vec::with_capacity(width);
let mut deviations = Vec::with_capacity(width);
for (i, out) in grid.iter_mut().enumerate() {
// A full-width window of the nearest samples: centred in the interior,
// slid inward at the ends so the estimate never runs short of data.
let start = i.saturating_sub(radius).min(n - width);
let window = &src[start..start + width];
diffs.clear();
diffs.extend(window.windows(2).map(|w| w[1] - w[0]));
let slope = median(&mut diffs);
projected.clear();
projected.extend(
window
.iter()
.enumerate()
.map(|(k, v)| v + slope * (i as f32 - (start + k) as f32)),
);
let predicted = median(&mut projected);
deviations.clear();
deviations.extend(projected.iter().map(|v| (v - predicted).abs()));
// 1.4826·MAD estimates σ for normally distributed noise.
let sigma = 1.4826 * median(&mut deviations);
let threshold = (DESPIKE_K * sigma).max(DESPIKE_FLOOR_M);
if (src[i] - predicted).abs() > threshold {
*out = predicted;
}
}
}
/// Centred moving average over `2·half + 1` samples, with the window truncated
/// symmetrically at the ends so the series is not phase-shifted. Prefix sums
/// in f64 keep it O(n) without losing precision on long tracks.
@@ -911,6 +1016,121 @@ mod tests {
assert!(terrain.iter().all(|p| p.gradient_pct.is_finite()));
}
// ---- outlier rejection ------------------------------------------------
/// The shape of the shipped fixture, asserted end to end: flat opening,
/// then a real climb, then a real descent.
///
/// This is the test that catches a reversed distance axis, an off-by-one
/// in the terrain lookup, or a sign error in the differentiation — any of
/// which would put the climb where the descent is, or invert both.
#[test]
fn the_sample_climb_reads_flat_then_up_then_down_in_that_order() {
let terrain = to_terrain(&parse(SAMPLE_CLIMB).unwrap(), &SmoothingConfig::default())
.expect("fixture imports");
let opening = mean_gradient(&terrain, 0.0, 400.0);
let climb = mean_gradient(&terrain, 600.0, 2_200.0);
let descent = mean_gradient(&terrain, 2_500.0, 2_900.0);
assert!(opening.abs() < 1.0, "opening should be flat, got {opening}%");
assert!(
(4.0..9.0).contains(&climb),
"climb should be 49%, got {climb}%"
);
assert!(
(-6.0..-3.0).contains(&descent),
"descent should be about -4.5%, got {descent}%"
);
// And the same read through the profile the app actually rides, so a
// fault in the block indexing cannot hide behind a correct terrain
// series.
let profile = import(SAMPLE_CLIMB, "sample", &SmoothingConfig::default()).unwrap();
let at = |d: f64| {
profile
.sample_channel(crate::profile::Position {
elapsed_s: 0.0,
distance_m: d,
})
.expect("in range")
.1
};
assert!(at(200.0).abs() < 1.5, "flat at 200 m: {}", at(200.0));
assert!(at(1_000.0) > 3.0, "climbing at 1 km: {}", at(1_000.0));
assert!(at(2_700.0) < -2.0, "descending at 2.7 km: {}", at(2_700.0));
}
/// The first fix of a recorded activity is routinely metres out, because
/// the receiver has not settled. Averaging spreads that error over the
/// whole smoothing window and the differentiation then reads it as a
/// sustained gradient — on a real 22.6 km file, a 9 m first fix produced a
/// 9.5% descent at the start of a flat road, which was commanded to the
/// trainer.
#[test]
fn a_bad_first_fix_does_not_become_an_opening_descent() {
let mut elevations = vec![100.0f32; 200];
let clean = to_terrain(&synthetic_track(&elevations, 10.0), &SmoothingConfig::default())
.unwrap();
assert!(clean[0].gradient_pct.abs() < 0.2, "control: {clean:?}");
// One bad sample, at the worst possible place.
elevations[0] = 109.0;
let spiked =
to_terrain(&synthetic_track(&elevations, 10.0), &SmoothingConfig::default()).unwrap();
assert!(
spiked[0].gradient_pct.abs() < 1.0,
"a 9 m first-fix error became a {}% gradient",
spiked[0].gradient_pct
);
// And it must not have shifted the road it sits on either.
assert!(
(spiked[0].elevation_m - 100.0).abs() < 1.0,
"elevation dragged to {} m",
spiked[0].elevation_m
);
}
/// The same treatment must leave a genuine gradient alone — including one
/// that starts at the very first sample, where the rejection window can
/// only look forwards.
#[test]
fn a_real_climb_is_not_mistaken_for_an_outlier() {
// A constant 8% from the first metre.
let elevations: Vec<f32> = (0..300).map(|i| 100.0 + i as f32 * 0.8).collect();
let terrain =
to_terrain(&synthetic_track(&elevations, 10.0), &SmoothingConfig::default()).unwrap();
for p in terrain.iter().take(50) {
assert!(
(7.0..9.0).contains(&p.gradient_pct),
"real 8% climb reported as {}% at {} m",
p.gradient_pct,
p.distance_m
);
}
}
/// A spike in the middle of a ride — a dropped fix, a tunnel — is rejected
/// on the same terms, and does not leave a gradient step behind.
#[test]
fn a_mid_ride_elevation_spike_is_rejected() {
let mut elevations: Vec<f32> = (0..400).map(|i| 100.0 + i as f32 * 0.2).collect();
let baseline =
to_terrain(&synthetic_track(&elevations, 10.0), &SmoothingConfig::default()).unwrap();
elevations[200] += 12.0;
let spiked =
to_terrain(&synthetic_track(&elevations, 10.0), &SmoothingConfig::default()).unwrap();
let worst = spiked
.iter()
.zip(&baseline)
.map(|(a, b)| (a.gradient_pct - b.gradient_pct).abs())
.fold(0.0f32, f32::max);
assert!(worst < 1.0, "a 12 m spike moved the gradient by {worst}%");
}
#[test]
fn import_propagates_parse_errors() {
assert!(matches!(
+3
View File
@@ -4,6 +4,8 @@
//! is unit-testable with synthetic telemetry, and it must stay that way — BLE
//! lives in `bikecontrol-ble`, file writing in `bikecontrol-fit`.
pub mod energy;
pub mod gearing;
pub mod gpx;
pub mod physics;
pub mod profile;
@@ -11,6 +13,7 @@ pub mod session;
pub mod types;
pub use profile::{Block, Channel, Extent, Profile, Segment, Waveform};
pub use gearing::{Gearing, VirtualCassette};
pub use session::{RideSession, SessionEvent};
pub use types::{
ConnectionState, ControlMode, ControlTarget, RideSnapshot, RiderConfig, SafetyLimits, Telemetry,
+125 -1
View File
@@ -28,6 +28,17 @@ pub const MIN_SPEED_MPS: f32 = 0.5;
/// absurd configuration (CdA of zero, a 90% descent) still cannot run away.
pub const MAX_SPEED_MPS: f32 = 40.0;
/// Ceiling on *measured* power fed to the model. FTMS Instantaneous Power is a
/// sint16, so a glitched packet can legitimately decode to 32767 W — which the
/// force balance faithfully turns into a 144 km/h ride. No human produces more
/// than ~2500 W even for a single track-sprint pedal stroke, so anything above
/// this is a bad reading, not a rider.
///
/// This is deliberately *not* [`crate::types::SafetyLimits::max_power_w`]: that
/// one bounds the ERG target we *command*, this one bounds the power we
/// *believe*.
pub const MAX_MEASURED_POWER_W: f32 = 2500.0;
/// Longest tick the integrator will honour. A caller that stalls for a minute
/// must not be allowed to teleport the rider down a mountain.
const MAX_DT_S: f32 = 10.0;
@@ -105,6 +116,32 @@ impl PhysicsState {
}
}
/// Pull the modelled speed toward one the trainer actually measured.
///
/// The model knows what a bike *would* do for a given power and gradient;
/// the trainer knows how fast its flywheel is really turning. Neither alone
/// is right on a single-cog drivetrain: pure physics lets the rider "coast"
/// downhill at 39 km/h while spinning out against no resistance, and pure
/// trainer speed would cap descents at whatever cadence the one gear allows.
///
/// `weight` is the fraction of the gap closed **per second**, so the result
/// does not depend on tick rate — a 4 Hz and a 10 Hz loop converge the same.
pub fn correct_toward(&mut self, measured_mps: f32, weight: f32, dt: f32) {
if !measured_mps.is_finite() || measured_mps < 0.0 || !dt.is_finite() || dt <= 0.0 {
return;
}
let w = weight.clamp(0.0, 1.0);
if w == 0.0 {
return;
}
// Fraction of the gap to close this tick, from the per-second rate.
let alpha = 1.0 - (1.0 - w).powf(dt.min(MAX_DT_S));
let corrected = self.speed_mps + (measured_mps - self.speed_mps) * alpha;
if corrected.is_finite() {
self.speed_mps = corrected.clamp(0.0, MAX_SPEED_MPS);
}
}
pub fn speed_kph(&self) -> f32 {
self.speed_mps * 3.6
}
@@ -129,7 +166,10 @@ struct Forces {
impl Forces {
fn new(power_w: f32, gradient_pct: f32, cfg: &RiderConfig) -> Self {
// Braking is not modelled, so negative power is treated as coasting.
let power = sanitise(power_w, 0.0).max(0.0);
// The upper clamp is what keeps a glitched FTMS sample from driving the
// ride at 144 km/h; the integrator is stable and drift-free on its own,
// but it cannot tell an implausible input from a real one.
let power = sanitise(power_w, 0.0).clamp(0.0, MAX_MEASURED_POWER_W);
let gradient =
sanitise(gradient_pct, 0.0).clamp(-MAX_ABS_GRADIENT_PCT, MAX_ABS_GRADIENT_PCT);
let theta = (gradient / 100.0).atan();
@@ -477,6 +517,90 @@ mod tests {
assert_eq!(s, before);
}
/// The integrator must not creep. Forward Euler's discrete fixed point is
/// exactly the root of `a(v)`, i.e. the continuous equilibrium, so a steady
/// effort held for hours must not accumulate its way to a higher speed. A
/// higher-order scheme would not improve this — it shares the same fixed
/// point — so this test, not the integration order, is the guarantee.
#[test]
fn a_long_steady_ride_does_not_drift_upwards() {
let c = cfg();
let target = equilibrium_speed_mps(250.0, 0.0, &c);
let mut s = PhysicsState::default();
// Settle first, then hold for six hours of ride time.
for _ in 0..2_400 {
s.step(250.0, 0.0, &c, 0.25);
}
let after_settling = s.speed_mps;
for _ in 0..86_400 {
s.step(250.0, 0.0, &c, 0.25);
}
assert!(
(s.speed_mps - after_settling).abs() < 1.0e-3,
"speed crept from {after_settling} to {} over six hours",
s.speed_mps
);
assert!(
s.speed_mps <= target + 1.0e-3,
"settled {} above equilibrium {target}",
s.speed_mps
);
}
/// Equilibrium is a fixed point *exactly*, not approximately: stepping from
/// it must not move. This is the property that makes drift impossible.
#[test]
fn stepping_from_equilibrium_does_not_move() {
let c = cfg();
for (power, gradient) in [(200.0, 0.0), (300.0, 5.0), (150.0, -2.0)] {
let v = equilibrium_speed_mps(power, gradient, &c);
let mut s = PhysicsState {
speed_mps: v,
..Default::default()
};
s.step(power, gradient, &c, 1.0);
assert!(
(s.speed_mps - v).abs() < 1.0e-4,
"P={power} g={gradient}: {v} -> {}",
s.speed_mps
);
}
}
/// A glitched FTMS sample is a sint16, so it can decode to 32767 W. That
/// must not become a 144 km/h ride.
#[test]
fn implausible_power_cannot_drive_an_implausible_speed() {
let c = cfg();
let sane = settle(MAX_MEASURED_POWER_W, 0.0, 300.0).speed_mps;
for absurd in [3_000.0, 10_000.0, 32_767.0] {
let s = settle(absurd, 0.0, 300.0);
assert!(
(s.speed_mps - sane).abs() < 1.0e-3,
"{absurd} W settled at {} m/s, above the {sane} m/s ceiling",
s.speed_mps
);
assert!(
s.speed_mps < MAX_SPEED_MPS,
"{absurd} W pinned the speed at the absolute clamp"
);
}
// Real efforts, including a hard sprint, must be untouched by the clamp.
for real in [250.0, 600.0, 1_200.0, 2_000.0] {
let s = settle(real, 0.0, 300.0);
let expected = equilibrium_speed_mps(real, 0.0, &c);
assert!(
(s.speed_mps - expected).abs() < 0.05,
"{real} W was clamped: {} vs {expected}",
s.speed_mps
);
}
}
#[test]
fn speed_kph_conversion() {
let s = PhysicsState {
+80 -1
View File
@@ -4,6 +4,7 @@
//! This is the piece the Tauri layer drives. It takes telemetry in, produces
//! snapshots and control targets out, and knows nothing about BLE or the UI.
use crate::gearing::Gearing;
use crate::physics::PhysicsState;
use crate::profile::{Position, Profile};
use crate::types::{
@@ -51,6 +52,9 @@ pub struct RideSession {
manual_resistance: i16,
/// Wattage held in [`ControlMode::Erg`] (FR-4.6).
erg_watts: u16,
/// Virtual gears (FR-4.1): changes how hard the pedals feel, not how
/// fast the rider travels for a given power.
pub gearing: Gearing,
elapsed_ms: u64,
last_target: Option<ControlTarget>,
}
@@ -67,6 +71,7 @@ impl RideSession {
gradient_offset_pct: 0.0,
manual_resistance: 0,
erg_watts: 150,
gearing: Gearing::default(),
elapsed_ms: 0,
last_target: None,
}
@@ -177,12 +182,31 @@ impl RideSession {
let power_w = f32::from(telemetry.power_w.unwrap_or(0)).max(0.0);
self.physics
.step(power_w, self.simulated_gradient_pct(), &self.config, dt);
// Pull the model back toward what the flywheel is really doing.
// Pure physics lets a spun-out rider "coast" downhill at 39 km/h.
if let Some(kph) = telemetry.speed_kph {
self.physics
.correct_toward(kph / 3.6, self.config.trainer_speed_weight, dt);
}
}
// Only a running ride commands the trainer. When paused or finished the
// last target simply stands (SAF-1) rather than being re-sent or reset.
if running {
if let Some(target) = desired {
// Keep load under the pedals on descents so the rider's effort
// still counts; the physics above already used the true route
// gradient, so the descent stays as fast as the terrain says.
let target = match target {
// Gear offset applies to what the TRAINER is asked for, not
// to what the physics simulated: shifting changes effort,
// not the speed the terrain implies.
ControlTarget::Gradient { percent } => ControlTarget::Gradient {
percent: (percent + self.gearing.offset_pct())
.max(self.config.descent_load_floor_pct),
},
other => other,
};
let clamped = self.limits.clamp(target);
if changed_meaningfully(self.last_target, clamped) {
self.last_target = Some(clamped);
@@ -205,7 +229,14 @@ impl RideSession {
RideSnapshot {
elapsed_ms: self.elapsed_ms,
telemetry,
virtual_speed_kph: self.physics.speed_kph(),
// A paused or finished ride is a rider who is not moving. Holding
// the last *target* when input is lost is correct (SAF-1); holding
// the last *speed* is not — it tells a stationary rider they are
// doing 39 km/h. Distance is retained, because it happened.
virtual_speed_kph: match self.status {
RideStatus::Running => self.physics.speed_kph(),
_ => 0.0,
},
virtual_distance_m: self.physics.distance_m,
gradient_pct: self.simulated_gradient_pct(),
elevation_gain_m: self.physics.elevation_gain_m,
@@ -532,6 +563,9 @@ mod tests {
let target = commands(&s.tick(powered(0), 1.0))[0];
assert_eq!(gradient_of(target), s.limits.max_gradient_pct);
// The descent load floor normally bites first, so disable it here to
// prove the *safety* clamp still holds on its own.
s.config.descent_load_floor_pct = f32::NEG_INFINITY;
s.reset_gradient_offset();
s.nudge_gradient(-90.0);
// Two ticks: the first re-emits after the reset.
@@ -543,6 +577,26 @@ mod tests {
);
}
#[test]
fn descents_keep_load_under_the_pedals() {
// A steep descent commands almost no resistance, so on a single-cog
// drivetrain the rider spins out and their effort stops counting. The
// floor keeps something to push against.
let mut s = session();
s.start();
s.config.descent_load_floor_pct = -1.0;
s.nudge_gradient(-8.0);
let commanded = gradient_of(commands(&s.tick(powered(0), 1.0))[0]);
assert_eq!(commanded, -1.0, "descent should be floored for load");
// But the *simulated* gradient stays true to the terrain, so the rider
// still descends at the speed the route implies.
assert!(
s.snapshot(powered(0)).gradient_pct < -7.0,
"physics must still see the real descent"
);
}
#[test]
fn an_absurd_profile_cannot_command_an_unsafe_target() {
// SAF-6: parameter errors must be caught by SAF-3, not by the profile.
@@ -861,4 +915,29 @@ mod tests {
assert!((snap.elevation_gain_m - expected).abs() < expected * 0.02);
assert!(snap.elevation_gain_m > 50.0);
}
#[test]
fn a_paused_ride_reports_zero_speed_not_the_last_reading() {
let mut s = session();
s.start();
// Build up real speed under power.
for _ in 0..40 {
s.tick(powered(250), 0.25);
}
let moving = s.snapshot(powered(250)).virtual_speed_kph;
assert!(moving > 5.0, "expected to be moving, got {moving} kph");
// Pause. The rider is stationary — reporting the last speed would tell
// them they are still doing 30-odd kph while stood still.
s.pause();
let paused = s.snapshot(powered(0));
assert_eq!(paused.virtual_speed_kph, 0.0);
// Distance is retained: it happened.
assert!(paused.virtual_distance_m > 0.0);
// Resuming picks the speed back up rather than restarting from rest.
s.start();
assert!(s.snapshot(powered(250)).virtual_speed_kph > 5.0);
}
}
+32
View File
@@ -109,6 +109,36 @@ pub struct RiderConfig {
/// Air density, kg/m³.
pub air_density: f32,
pub wheel_circumference_m: f32,
/// How strongly the trainer's own reported speed pulls the modelled speed
/// back toward it, as a fraction of the gap closed per second.
///
/// `0.0` is pure physics: correct for a real bike, but on a single-cog
/// drivetrain the rider spins out against no resistance on a descent while
/// the model happily reports 39 km/h. `1.0` would track the flywheel
/// exactly, capping descents at whatever the one gear allows. The default
/// keeps physics in charge while refusing to drift far from what the
/// hardware measures.
#[serde(default = "default_trainer_speed_weight")]
pub trainer_speed_weight: f32,
/// The steepest descent the trainer is ever *asked* to simulate.
///
/// On a real descent a trainer unloads almost completely, and on a
/// single-cog drivetrain the rider then spins out against nothing and can
/// produce no watts at all — so their effort stops mattering exactly when
/// they can see the speed climbing. Flooring the *commanded* gradient keeps
/// some load under the pedals while the *simulated* gradient stays true to
/// the route, so the descent is still fast but the rider can contribute to
/// it. Set to a large negative number to disable.
#[serde(default = "default_descent_load_floor")]
pub descent_load_floor_pct: f32,
}
fn default_descent_load_floor() -> f32 {
-1.0
}
fn default_trainer_speed_weight() -> f32 {
0.3
}
impl Default for RiderConfig {
@@ -121,6 +151,8 @@ impl Default for RiderConfig {
drivetrain_efficiency: 0.97,
air_density: 1.225,
wheel_circumference_m: 2.105,
trainer_speed_weight: default_trainer_speed_weight(),
descent_load_floor_pct: default_descent_load_floor(),
}
}
}