Ride the drivetrain, command the load in watts

Speed now comes from the drivetrain and the load from the road, which is
the way round a bike actually works.

Speed is cadence x development, filtered lightly. Power, not cadence,
decides whether the rider is driving it: on a direct-drive trainer the
flywheel keeps the cranks turning after they stop, so cadence alone reads
a healthy 80 rpm for someone doing nothing. Below 15 W the speed runs
down to whatever the gradient sustains on no power - zero uphill, a real
freewheeling speed on a descent. Stopping on a 3.5% climb used to settle
at 22 km/h and stay there, because the model wanted to decelerate and a
blend toward the flywheel speed outvoted it; that blend is gone.

The D100 sends no cadence over FTMS - it is a rebadged Magene T110 with
cadence disabled in firmware (qdomyos-zwift#3282) - so it is inferred
from wheel speed, which one sprocket and no freewheel make exact. Its
Zwift channel does carry cadence, and is now greeted with RideOn and
subscribed on every notifying characteristic, so a measured value is used
where one arrives.

The load is commanded as power, not gradient. The trainer declares
50-600 W in 1 W steps against 0-6% inclination in 0.1% steps refusing
negatives, and whether it acts on 0x11 at all is still unconfirmed. Its
power target is a ceiling rather than a setpoint, which is very nearly
what a road is: exceed it and the surplus becomes speed. Gravity travels
on the same channel as watts, so nothing is lost by leaving 0x11 alone.
LoadChannel keeps the gradient path selectable and tested.

Virtual shifting reaches the trainer for the first time. The physics
load model was written but never called, and a paddle press both shifted
a gear in Rust and nudged the gradient in the webview - the shift
silently, the tilt visibly, so the paddles looked like a gradient trim.

Also: a fixed 12 W drivetrain loss, held as a power because that is how
it presents; crank length, so a gear can be reported as the force it puts
under the foot; gear and pedal force on the ride screen; a drag-race
profile for testing gearing on the flat.

Two readout bugs fixed on the way. The rolling windows were trimmed by
timestamp but fed on a fixed timer, so every second spent on the ride
screen before starting pushed samples at t=0 that could never expire -
speed read a fraction of the truth for the first 45 s. And the headline
speed was a 45 s mean, which took most of a minute to show a gear change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 18:21:08 +02:00
co-authored by Claude Opus 5
parent f2c4cb2120
commit 7b511db3dc
44 changed files with 6636 additions and 950 deletions
+174 -6
View File
@@ -30,17 +30,35 @@
//! wrong: it would let a rider spin up a 15% wall at 45 km/h without ever
//! producing the watts that requires, and gradient would become decoration.
//!
//! The loop closes on cadence. For a given road speed, the selected gear
//! # What commands the trainer
//!
//! [`Gearing::load_gradient_pct`] — a direct computation, not a feedback loop.
//! A gear changes the leverage between crank and wheel, so the load it implies
//! is the road force scaled by the gear's development relative to the bike's
//! real one. Shifting therefore lands on the pedals the moment it happens.
//!
//! An earlier design servoed the load toward a cadence target instead. It was
//! abandoned because a servo can only find the right load by first being wrong:
//! at a gentle enough gain not to oscillate against the rider's own cadence
//! variation, a shift took seconds to be felt, which is not what a shift is.
//!
//! # What the cadence loop is still for
//!
//! The servo ([`Gearing::update`], [`Gearing::correction_pct`]) survives as a
//! *readout*, not a control input. For a given road speed the selected gear
//! implies a cadence:
//!
//! ```text
//! target_cadence = road_speed × 60 / development
//! ```
//!
//! If the rider is turning faster than that they are spinning out, so add load;
//! slower, and they are grinding, so shed it. The trainer's own cadence reading
//! closes the loop, which is why this had to wait for the Zwift-channel decode
//! (§2.1.1) — FTMS on this trainer reports no cadence at all.
//! and the signed distance between that and the rider's actual cadence says
//! whether they are spinning out or grinding — worth showing them, and worth
//! recording, but no longer added to what the trainer is asked for. Nothing
//! reads it into the control path; see `RideSession::tick`.
//!
//! Either way this had to wait for the Zwift-channel decode (§2.1.1) — FTMS on
//! this trainer reports no cadence at all.
use serde::{Deserialize, Serialize};
@@ -48,6 +66,14 @@ use serde::{Deserialize, Serialize};
/// enough to recover a spun-out descent, bounded so a runaway loop cannot ask
/// for a cliff.
const MAX_CORRECTION_PCT: f32 = 8.0;
/// Widest load the gear model may command, in gradient percent.
///
/// Separate from the servo's bound, and deliberately so: this one caps what the
/// rider is *asked to push against*, not how far a feedback loop may wander. A
/// top gear on a steep climb legitimately reaches well past the servo's range,
/// and 16% is about the steepest thing worth reproducing under a rider before
/// it stops being training and starts being a wall.
const MAX_LOAD_PCT: f32 = 16.0;
/// Gradient percent applied per rpm of cadence error, per second. Deliberately
/// gentle: shifting should settle over a second or two, not snap, and an
/// aggressive gain oscillates against the rider's own cadence variation.
@@ -174,6 +200,12 @@ impl Gearing {
self.correction_pct += error * GAIN_PCT_PER_RPM_S * dt;
self.correction_pct =
self.correction_pct.clamp(-MAX_CORRECTION_PCT, MAX_CORRECTION_PCT);
} else {
// Inside the deadband, bleed off. Holding the last value
// instead would freeze whatever transient the rider passed
// through on the way to riding the gear correctly, and
// leave the readout claiming an error that is over.
self.decay(dt);
}
}
None => self.decay(dt),
@@ -365,13 +397,149 @@ impl Gearing {
let scaled = f * (self.development_m() / physical);
let pct = scaled / (mass * crate::physics::GRAVITY) * 100.0;
if pct.is_finite() {
pct.clamp(-MAX_CORRECTION_PCT * 2.0, MAX_CORRECTION_PCT * 2.0)
pct.clamp(-MAX_LOAD_PCT, MAX_LOAD_PCT)
} else {
0.0
}
}
}
impl Gearing {
/// The watts the road is asking for at this speed — what to command when
/// the load is expressed as power rather than slope.
///
/// Simply `F × v`, because that is what power is. Note there is no gear
/// term: at a *given speed* the power required is the same in every gear,
/// which is not a flaw in the model but the definition of a gear. The gear
/// enters through the speed, since speed is cadence × development — so
/// shifting up at the same cadence raises the speed and with it the demand,
/// while what changes at the pedal is the force (see
/// [`Gearing::pedal_force_n`]) and the cadence needed to hold it.
///
/// On a trainer whose power target is a ceiling rather than a setpoint,
/// commanding this makes the ride self-correcting: the rider accelerates
/// when they exceed it and slows when they fall short, and next tick it is
/// recomputed at the new speed.
pub fn load_power_w(&self, resistive_n: f32, speed_mps: f32) -> f32 {
let f = if resistive_n.is_finite() { resistive_n } else { 0.0 };
let v = if speed_mps.is_finite() { speed_mps.max(0.0) } else { 0.0 };
let w = f * v;
// A descent asks for negative power, which no brake can supply — the
// honest floor is zero, and the safety limits raise it to whatever the
// trainer's minimum really is.
if w.is_finite() { w.max(0.0) } else { 0.0 }
}
/// Force the rider's leg feels at the pedal, newtons.
///
/// Work is force × distance on both sides of the crank. Over one crank
/// revolution the pedal travels `2πr` and the bike travels `development`,
/// and the work done is the same quantity seen twice:
///
/// ```text
/// F_pedal × 2πr = F_road × development
/// ```
///
/// Note which development appears. The trainer is asked for
/// `F_road × (virtual / physical)` at the wheel, and the rider's crank
/// turns through the *physical* gear, so the two physical terms cancel and
/// what the leg feels is `F_road × virtual / 2πr` — precisely what a real
/// bike in that gear would feel. That the virtual gear lands on the pedal
/// exactly as a real one would is the check that the whole scheme is
/// mechanically honest, not merely plausible.
///
/// Purely a readout: nothing downstream of the wheel changes what is
/// commanded. What it is *for* is judging a gear ladder — 40 N is freewheel
/// -light and 600 N is a gear nobody can turn, and neither is visible from
/// a gradient.
pub fn pedal_force_n(&self, resistive_n: f32, crank_length_m: f32) -> f32 {
let crank = if crank_length_m.is_finite() && crank_length_m > 0.01 {
crank_length_m
} else {
0.1725
};
let f = if resistive_n.is_finite() { resistive_n } else { 0.0 };
let pedal = f * self.development_m() / (std::f32::consts::TAU * crank);
if pedal.is_finite() {
pedal
} else {
0.0
}
}
}
#[cfg(test)]
mod pedal_tests {
use super::*;
use crate::physics::resistive_force_n;
use crate::types::RiderConfig;
#[test]
fn a_longer_gear_is_heavier_at_the_pedal() {
let c = RiderConfig::default();
let f = resistive_force_n(8.0, 3.0, &c);
let mut g = Gearing::default();
g.set_gear(1);
let easy = g.pedal_force_n(f, c.crank_length_m);
g.set_gear(12);
let hard = g.pedal_force_n(f, c.crank_length_m);
assert!(hard > easy * 3.0, "top gear must be far heavier: {hard} vs {easy}");
}
#[test]
fn longer_cranks_lighten_the_pedal() {
// More leverage, less force, same work — the whole reason crank length
// is a number worth knowing.
let c = RiderConfig::default();
let f = resistive_force_n(8.0, 3.0, &c);
let g = Gearing::default();
let short = g.pedal_force_n(f, 0.165);
let long = g.pedal_force_n(f, 0.175);
assert!(long < short, "longer cranks must feel lighter: {long} vs {short}");
// Inverse in the radius, so the ratio is exact.
assert!((short / long - 0.175 / 0.165).abs() < 0.001);
}
#[test]
fn the_work_balance_holds_across_the_crank() {
// The identity the function encodes: over one crank revolution the
// rider does the same work whichever side of the crank you measure.
let c = RiderConfig::default();
let mut g = Gearing::default();
g.set_gear(8);
let road_n = resistive_force_n(8.0, 4.0, &c);
let pedal_n = g.pedal_force_n(road_n, c.crank_length_m);
let at_the_pedal = pedal_n * std::f32::consts::TAU * c.crank_length_m;
let at_the_road = road_n * g.development_m();
assert!(
(at_the_pedal - at_the_road).abs() < at_the_road * 1e-4,
"{at_the_pedal} J at the pedal vs {at_the_road} J at the road"
);
}
#[test]
fn a_flat_road_in_a_middling_gear_is_a_force_a_person_can_produce() {
// Sanity on the absolute scale, which is the only thing that makes this
// readout worth showing: easy flat riding should be tens of newtons.
let c = RiderConfig::default();
let g = Gearing::default();
let pedal = g.pedal_force_n(resistive_force_n(8.0, 0.0, &c), c.crank_length_m);
assert!(
(20.0..250.0).contains(&pedal),
"flat at 29 km/h should be light but not nothing: {pedal} N"
);
}
#[test]
fn absurd_cranks_do_not_produce_absurd_forces() {
let g = Gearing::default();
assert!(g.pedal_force_n(100.0, 0.0).is_finite());
assert!(g.pedal_force_n(100.0, f32::NAN).is_finite());
assert_eq!(g.pedal_force_n(f32::NAN, 0.1725), 0.0);
}
}
#[cfg(test)]
mod load_tests {
use super::*;
+88 -28
View File
@@ -11,7 +11,16 @@
//! F_gravity = m × g × sin(atan(gradient))
//! F_rolling = m × g × Crr × cos(atan(gradient))
//! F_aero = ½ × ρ × CdA × v²
//! a = (F_propulsive F_gravity F_rolling F_aero) / m
//! F_loss = rolling_loss_w / max(v, v_min)
//! a = (F_propulsive F_gravity F_rolling F_aero F_loss) / m
//! ```
//!
//! `F_loss` is the trainer's own fixed drag, held as a power because that is
//! how it presents. Dividing by speed makes it small when moving fast and large
//! when slowing, which is what brings a coast to a halt instead of an
//! asymptote.
//!
//! ```text
//! v += a × Δt (clamped at ≥ 0)
//! ```
@@ -66,9 +75,15 @@ pub struct PhysicsState {
impl PhysicsState {
/// Advance the simulation by `dt` seconds under `power_w` at `gradient_pct`.
///
/// Must model inertia (FR-7.3) — speed accelerates toward equilibrium
/// rather than snapping to it — and must never produce negative speed,
/// NaN, or unbounded values for any finite input.
/// **Not on the ride path.** The ride takes its speed from the drivetrain
/// (`RideSession::tick`), which has no inertia because a chain has none.
/// This integrator is retained as the reference implementation of the force
/// balance: it is what [`equilibrium_speed_mps`] — the coasting target — is
/// cross-validated against, and its tests are the coverage for [`Forces`].
/// Do not reintroduce it as a speed source without saying why.
///
/// Never produces negative speed, NaN, or unbounded values for any finite
/// input.
pub fn step(&mut self, power_w: f32, gradient_pct: f32, cfg: &RiderConfig, dt: f32) {
let dt = sanitise(dt, 0.0).clamp(0.0, MAX_DT_S);
if dt <= 0.0 {
@@ -116,32 +131,64 @@ impl PhysicsState {
}
}
/// Pull the modelled speed toward one the trainer actually measured.
/// Advance the ride at a speed the drivetrain dictates, rather than one
/// integrated from the force balance.
///
/// 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.
/// On a bike the wheel is locked to the cranks: road speed *is* cadence ×
/// development, and no force balance gets a say in it. When the trainer is
/// reporting cadence this is the honest speed, and the force balance moves
/// to the other side of the loop — it decides how *hard* that cadence is to
/// hold (see [`crate::gearing::Gearing::load_gradient_pct`]), not how fast
/// it carries the rider.
///
/// `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 {
/// `tau_s` is the time constant of a first-order approach to `target_mps`.
/// Cadence arrives a few times a second and varies within a single pedal
/// stroke; stepping the speed straight onto it would make the readout jump
/// and the distance integral gritty. Small enough that a real change of
/// pace still lands promptly.
pub fn advance_at(&mut self, target_mps: f32, gradient_pct: f32, tau_s: f32, dt: f32) {
let dt = sanitise(dt, 0.0).clamp(0.0, MAX_DT_S);
if 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);
let target = sanitise(target_mps, 0.0).clamp(0.0, MAX_SPEED_MPS);
let tau = sanitise(tau_s, 0.0).max(0.0);
let v0 = self.speed_mps.clamp(0.0, MAX_SPEED_MPS);
let v1 = if tau <= 0.0 {
target
} else {
// Exact solution of the first-order lag over the step, so the
// result does not depend on tick rate.
let alpha = 1.0 - (-dt / tau).exp();
v0 + (target - v0) * alpha
};
let v1 = if v1.is_finite() { v1.clamp(0.0, MAX_SPEED_MPS) } else { 0.0 };
// An exponential approach to zero never arrives, and a ride left
// reporting 6e-45 m/s is stopped in every sense except the one the
// readout uses. Below a millimetre a second, call it stopped.
self.speed_mps = if target <= 0.0 && v1 < 0.001 { 0.0 } else { v1 };
let gradient =
sanitise(gradient_pct, 0.0).clamp(-MAX_ABS_GRADIENT_PCT, MAX_ABS_GRADIENT_PCT);
let sin_theta = (gradient / 100.0).atan().sin();
let ds = (0.5 * (v0 + self.speed_mps) * dt) as f64;
self.distance_m += ds;
let climb = ds as f32 * sin_theta;
if climb > 0.0 {
self.elevation_gain_m += climb;
}
}
// There was a `correct_toward` here, blending the modelled speed toward the
// trainer's own. It is gone on purpose. A flywheel keeps turning long after
// the rider stops, so its speed is not evidence of road speed, and blending
// toward it held the ride at 22 km/h up a 3.5% climb on zero watts —
// the model wanted to decelerate and the flywheel outvoted it. Speed now
// comes from the drivetrain, or from the road when the rider is coasting;
// see `RideSession::tick`.
pub fn speed_kph(&self) -> f32 {
self.speed_mps * 3.6
}
@@ -160,6 +207,8 @@ struct Forces {
resistive_n: f32,
/// `½ρ·CdA`; multiplied by v² to give drag.
drag_k: f32,
/// Fixed power loss, watts. Becomes a force by dividing by speed.
loss_w: f32,
mass_kg: f32,
}
@@ -184,6 +233,7 @@ impl Forces {
Self {
wheel_power_w: power * efficiency,
loss_w: sanitise(cfg.rolling_loss_w, 0.0).max(0.0),
sin_theta: theta.sin(),
resistive_n: mass * GRAVITY * (theta.sin() + crr * theta.cos()),
drag_k: 0.5 * rho * cda,
@@ -191,12 +241,18 @@ impl Forces {
}
}
/// The fixed loss as a force at this speed. Divided by the same floor the
/// propulsive term uses, so neither blows up at a standstill.
fn loss_n(&self, v: f32) -> f32 {
self.loss_w / v.max(MIN_SPEED_MPS)
}
fn acceleration(&self, v: f32) -> f32 {
let propulsive = self.wheel_power_w / v.max(MIN_SPEED_MPS);
// Rolling resistance and gravity are folded together, so at a
// standstill on the flat the net is a small negative that the ≥0 clamp
// absorbs — the rider does not roll backwards.
let net = propulsive - self.resistive_n - self.drag_k * v * v;
let net = propulsive - self.resistive_n - self.drag_k * v * v - self.loss_n(v);
let a = net / self.mass_kg;
if a.is_finite() {
a
@@ -263,7 +319,7 @@ pub fn resistive_force_n(speed_mps: f32, gradient_pct: f32, cfg: &RiderConfig) -
} else {
0.0
};
let f = forces.resistive_n + forces.drag_k * v * v;
let f = forces.resistive_n + forces.drag_k * v * v + forces.loss_n(v);
if f.is_finite() {
f
} else {
@@ -305,14 +361,18 @@ mod tests {
#[test]
fn equilibrium_matches_hand_computed_flat_case() {
// 250 W on the flat with the default rider: solve P·η = F_roll·v + k·v³.
// 250 W on the flat with the default rider. This is a *power* balance
// — the force balance multiplied through by v — so the residual is in
// watts, and the fixed loss enters it as itself rather than divided by
// speed: P·η = loss + F_roll·v + k·v³.
let c = cfg();
let v = equilibrium_speed_mps(250.0, 0.0, &c);
let m = c.total_mass_kg();
let f_roll = m * GRAVITY * c.crr;
let drag = 0.5 * c.air_density * c.cda;
let balance = 250.0 * c.drivetrain_efficiency - (f_roll * v + drag * v * v * v);
assert!(balance.abs() < 0.5, "residual force {balance} N at v={v}");
let balance =
250.0 * c.drivetrain_efficiency - (c.rolling_loss_w + f_roll * v + drag * v * v * v);
assert!(balance.abs() < 0.5, "residual power {balance} W at v={v}");
// Sanity: a 75 kg rider at 250 W on the flat sits around 40 km/h.
assert!((35.0..45.0).contains(&(v * 3.6)), "{} km/h", v * 3.6);
}
+797 -55
View File
@@ -8,7 +8,8 @@ use crate::gearing::Gearing;
use crate::physics::PhysicsState;
use crate::profile::{Position, Profile};
use crate::types::{
ControlMode, ControlTarget, RideSnapshot, RiderConfig, SafetyLimits, Telemetry,
ControlMode, ControlTarget, LoadChannel, RideSnapshot, RiderConfig, SafetyLimits, SpeedSource,
Telemetry,
};
/// Something the session wants the outside world to do or know about.
@@ -38,6 +39,40 @@ pub enum RideStatus {
/// inside that without a timer, and avoids churning the trainer with values it
/// cannot resolve anyway.
const GRADIENT_EPSILON_PCT: f32 = 0.05;
/// Grid the *computed* road load is snapped to, watts.
///
/// The road power goes as v³, so an unquantised target would change on nearly
/// every tick as the speed wanders and spend the whole write budget on
/// differences no rider can feel. Snapping to five watts rate-limits the writes
/// by construction, without an epsilon in `changed_meaningfully` — which would
/// also have coarsened the deliberate wattages an ERG profile asks for, and
/// those must arrive exactly as written.
const LOAD_POWER_STEP_W: f32 = 5.0;
/// Time constant for the speed following cadence × gear, seconds.
///
/// Cadence arrives a few times a second and genuinely varies within one pedal
/// stroke, so the speed is filtered rather than stepped. Short enough that a
/// shift or a surge shows up almost at once, long enough that the readout does
/// not flicker at the rate the pedals go round.
const DRIVETRAIN_TAU_S: f32 = 0.8;
/// Above this the rider is driving the bike; at or below it they are coasting
/// and the flywheel is merely spinning down. Low enough that soft pedalling
/// still counts as riding, high enough that a trainer reporting a few stray
/// watts at rest does not.
///
/// This is the only reliable way to tell the two apart on this hardware:
/// cadence cannot, because the flywheel keeps the cranks turning for a rider
/// who has stopped.
const COASTING_POWER_W: f32 = 15.0;
/// Time constant for the speed settling to what a gradient sustains on no
/// power, seconds. Longer than the drivetrain's, because a rider easing off
/// should feel the bike run down rather than hit a wall — but nothing like the
/// minutes real momentum would give them, which on a climb is the difference
/// between stopping and freewheeling uphill for a quarter of a kilometre.
const COAST_TAU_S: f32 = 1.5;
pub struct RideSession {
pub config: RiderConfig,
@@ -55,6 +90,8 @@ pub struct RideSession {
/// Virtual gears (FR-4.1): changes how hard the pedals feel, not how
/// fast the rider travels for a given power.
pub gearing: Gearing,
/// Which rule decided the speed on the last tick. Diagnostic only.
speed_source: SpeedSource,
elapsed_ms: u64,
last_target: Option<ControlTarget>,
}
@@ -72,6 +109,7 @@ impl RideSession {
manual_resistance: 0,
erg_watts: 150,
gearing: Gearing::default(),
speed_source: SpeedSource::Stopped,
elapsed_ms: 0,
last_target: None,
}
@@ -154,6 +192,49 @@ impl RideSession {
self.last_target
}
/// Cadence: measured if the trainer reports it, inferred from wheel speed
/// if not.
///
/// The inference is exact, not a fudge. With a Zwift Cog there is one
/// sprocket and no freewheel between the cranks and the flywheel, so cadence
/// and wheel speed are locked by the bike's physical development:
///
/// ```text
/// cadence = wheel_speed × 60 / physical_development
/// ```
///
/// It is the same rigid drivetrain the virtual gears are already built on,
/// read in the other direction — and the trainer's own speed is the one
/// signal this hardware reports reliably on every Indoor Bike Data packet.
/// Depending on it instead of the vendor Zwift channel takes the whole ride
/// off an undocumented protocol and puts it on a standard FTMS field.
///
/// This is not a workaround for a bug we might later fix. The D100 is a
/// rebadged Magene T110 with cadence disabled in firmware, and the absence
/// is confirmed by others rather than only observed here:
/// <https://github.com/cagnulein/qdomyos-zwift/issues/3282> — "Cadence is
/// not broadcasted, at least not in the 0.106 firmware version". Inference
/// is the only source of cadence this trainer will ever have.
///
/// Note what this makes the virtual speed: `trainer_speed × virtual /
/// physical`. Shifting up covers more ground per flywheel revolution and
/// costs proportionally more to turn — which is exactly what a gear is.
///
/// A measured cadence still wins where one exists. It is the same number on
/// this drivetrain, and on a bike with a freewheel it would be the only
/// honest one.
fn effective_cadence(&self, telemetry: &Telemetry) -> Option<f32> {
if let Some(rpm) = telemetry.cadence_rpm.filter(|c| c.is_finite() && *c >= 0.0) {
return Some(rpm);
}
let speed_kph = telemetry.speed_kph.filter(|s| s.is_finite() && *s >= 0.0)?;
let development = self.config.physical_development_m;
if !development.is_finite() || development <= 0.1 {
return None;
}
Some((speed_kph / 3.6 * 60.0 / development).clamp(0.0, 250.0))
}
/// Advance the ride by one tick.
///
/// Feeds telemetry into the physics model, advances the profile, and
@@ -180,25 +261,71 @@ impl RideSession {
// A trainer that reports no power is a trainer the rider is not
// pushing; nothing here may panic on a partial FTMS packet.
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.
// Keep the cadence servo running purely as a readout of how far the
// rider is from the cadence their gear implies; the load itself is
// computed directly below rather than servoed toward it.
self.gearing
.update(telemetry.cadence_rpm, self.physics.speed_mps, dt);
let gradient = self.simulated_gradient_pct();
// Correct toward the trainer's own measured speed — NOT toward
// cadence x virtual gear. That would be circular: the servo's target
// cadence is derived from speed, so making speed follow cadence
// leaves it nothing to correct and the gears stop doing anything.
// Physics owns the speed; cadence is the servo's feedback signal.
if let Some(kph) = telemetry.speed_kph {
self.physics
.correct_toward(kph / 3.6, self.config.trainer_speed_weight, dt);
}
// The drivetrain decides the speed; the road decides how hard it is.
//
// A bike's wheel is locked to its cranks, so while the rider is
// driving it, road speed is cadence × development and nothing else.
// The force balance is not bypassed — it moves to the other end of
// the loop, setting the load the trainer applies (below). Too big a
// gear on a climb therefore does what it does outdoors: the load
// becomes unholdable, cadence falls, and the speed falls with it.
//
// Power, not cadence, is what says the rider is driving. On a
// direct-drive trainer the flywheel keeps the cranks turning after
// the rider stops, so cadence alone cannot tell riding from
// freewheeling — it reads a healthy 80 rpm for someone doing
// nothing at all.
let driving = power_w > COASTING_POWER_W;
let cadence = self.effective_cadence(&telemetry);
let (source, target_mps, tau) = match (cadence, driving) {
// Riding: the drivetrain owns the speed outright.
(Some(rpm), true) => (
SpeedSource::Drivetrain,
rpm / 60.0 * self.gearing.development_m(),
DRIVETRAIN_TAU_S,
),
// Coasting: the rider has stopped contributing, so the road
// decides alone. The target is the speed this gradient sustains
// on no power — zero on anything uphill, a real freewheeling
// speed on a descent.
//
// Approached with a lag rather than integrated as momentum, to
// stay consistent with the riding case: that has no momentum
// either (speed is cadence × gear, instantly), and a model that
// is momentum-free while pedalling but momentum-rich while
// coasting is two models, not one. It is also what the rider is
// actually experiencing — they are not moving, and stopping on
// a climb should feel like stopping.
(Some(_), false) => (
SpeedSource::Coasting,
crate::physics::equilibrium_speed_mps(0.0, gradient, &self.config),
COAST_TAU_S,
),
// Neither a measured cadence nor a wheel speed to infer one
// from: no drivetrain, no ride.
//
// There is deliberately no fallback to integrating power. That
// is a different model — one where gears change the load and not
// the speed — and substituting it silently means a dead
// telemetry feed presents as a ride that merely feels a bit off,
// for as long as it takes someone to notice. Stopping says it
// outright. This now requires the trainer to be reporting
// nothing at all, which is a genuine fault.
(None, _) => (SpeedSource::NoCadence, 0.0, COAST_TAU_S),
};
self.speed_source = source;
self.physics.advance_at(target_mps, gradient, tau, dt);
// Kept as a readout only, and fed the same cadence the speed was
// built from — passing the raw telemetry here would have it servo
// against a cadence the ride is not using. With a rigid drivetrain
// it is a tautology by construction, which is exactly what a rigid
// drivetrain means.
self.gearing
.update(cadence, self.physics.speed_mps, dt);
}
// Only a running ride commands the trainer. When paused or finished the
@@ -209,13 +336,47 @@ impl RideSession {
// 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.correction_pct())
.max(self.config.descent_load_floor_pct),
},
// Gearing applies to what the TRAINER is asked for, not to
// what the physics simulated: shifting changes effort, not
// the speed the terrain implies.
//
// This *replaces* the route gradient rather than adding to
// it. `resistive_force_n` is given that same gradient, so
// gravity is already inside the force being scaled; adding
// `percent` back on top would charge the rider for the hill
// twice.
ControlTarget::Gradient { percent } => {
let resistive_n = crate::physics::resistive_force_n(
self.physics.speed_mps,
percent,
&self.config,
);
// Same load, two ways of saying it. Which one the
// trainer can actually act on is a property of the
// hardware, not of the ride — see `LoadChannel`.
match self.config.load_channel {
LoadChannel::Gradient => {
let load_pct = self.gearing.load_gradient_pct(
resistive_n,
self.config.total_mass_kg(),
self.config.physical_development_m,
);
ControlTarget::Gradient {
percent: load_pct.max(self.config.descent_load_floor_pct),
}
}
LoadChannel::Power => {
let watts = self
.gearing
.load_power_w(resistive_n, self.physics.speed_mps);
let snapped = (watts / LOAD_POWER_STEP_W).round()
* LOAD_POWER_STEP_W;
ControlTarget::Power {
watts: snapped.clamp(0.0, u16::MAX as f32) as u16,
}
}
}
}
other => other,
};
let clamped = self.limits.clamp(target);
@@ -251,6 +412,32 @@ impl RideSession {
virtual_distance_m: self.physics.distance_m,
gradient_pct: self.simulated_gradient_pct(),
elevation_gain_m: self.physics.elevation_gain_m,
gear: self.gearing.gear(),
gear_count: self.gearing.gear_count(),
development_m: self.gearing.development_m(),
// A stationary rider has no implied cadence, and reporting the one
// their gear would imply at a speed they are not doing would be a
// number the screen cannot justify.
target_cadence_rpm: match self.status {
RideStatus::Running => self.gearing.target_cadence_rpm(self.physics.speed_mps),
_ => 0.0,
},
speed_source: match self.status {
RideStatus::Running => self.speed_source,
_ => SpeedSource::Stopped,
},
// A rider who is not riding is pushing against nothing.
pedal_force_n: match self.status {
RideStatus::Running => self.gearing.pedal_force_n(
crate::physics::resistive_force_n(
self.physics.speed_mps,
self.simulated_gradient_pct(),
&self.config,
),
self.config.crank_length_m,
),
_ => 0.0,
},
mode: self.mode,
target: self.last_target,
profile_progress: self.profile_progress(),
@@ -338,13 +525,34 @@ mod tests {
use super::*;
use crate::profile::{Block, Channel, Extent, Segment, Waveform};
/// A session on the **gradient** channel.
///
/// Most of this suite predates the power channel and tests the slope
/// arithmetic, which is still exactly right for `LoadChannel::Gradient`.
/// Named plainly rather than silently defaulted, so that a test asserting a
/// gradient target is visibly asking for one.
fn session() -> RideSession {
let config = RiderConfig {
load_channel: LoadChannel::Gradient,
..RiderConfig::default()
};
RideSession::new(config, SafetyLimits::default())
}
/// A session on the **power** channel — what actually ships.
fn power_session() -> RideSession {
RideSession::new(RiderConfig::default(), SafetyLimits::default())
}
/// A rider putting `watts` in and turning the cranks at a plausible rate.
///
/// Cadence is not optional garnish here: speed comes from the drivetrain,
/// so a sample with power but no cadence is a bike that is not moving. Any
/// test that wants a moving rider needs both.
fn powered(watts: i16) -> Telemetry {
Telemetry {
power_w: Some(watts),
cadence_rpm: Some(if watts > 0 { 85.0 } else { 0.0 }),
..Default::default()
}
}
@@ -505,19 +713,27 @@ mod tests {
// ---- gradient offset -------------------------------------------------
#[test]
fn manual_grade_commands_the_trim_directly() {
fn manual_grade_sets_the_road_and_the_load_follows_it() {
// The trim states what the road is doing. What the trainer is *asked*
// for is the load that road implies through the selected gear, which is
// not the same number — see `crate::gearing`. The two must not be
// conflated: the snapshot reports the road, the command reports the
// load, and only the first is the rider's trim verbatim.
let mut s = session();
s.start();
assert_eq!(gradient_of(commands(&s.tick(powered(0), 1.0))[0]), 0.0);
let flat = gradient_of(commands(&s.tick(powered(0), 1.0))[0]);
s.nudge_gradient(0.5);
s.nudge_gradient(0.5);
let events = s.tick(powered(0), 1.0);
assert_eq!(gradient_of(commands(&events)[0]), 1.0);
assert_eq!(snapshot_of(&events).gradient_pct, 1.0);
let climbing = gradient_of(commands(&events)[0]);
assert_eq!(snapshot_of(&events).gradient_pct, 1.0, "the road is the trim");
assert!(climbing > flat, "a 1% road must load more than a flat: {climbing} vs {flat}");
s.reset_gradient_offset();
assert_eq!(gradient_of(commands(&s.tick(powered(0), 1.0))[0]), 0.0);
let events = s.tick(powered(0), 1.0);
assert_eq!(snapshot_of(&events).gradient_pct, 0.0);
assert!(gradient_of(commands(&events)[0]) < climbing, "resetting must shed the load");
}
#[test]
@@ -534,13 +750,18 @@ mod tests {
}],
});
s.start();
assert_eq!(gradient_of(commands(&s.tick(powered(200), 1.0))[0]), 4.0);
let steep = gradient_of(commands(&s.tick(powered(200), 1.0))[0]);
s.nudge_gradient(-1.5);
let events = s.tick(powered(200), 1.0);
assert_eq!(gradient_of(commands(&events)[0]), 2.5);
// And the physics see the trimmed gradient too, not the raw profile.
// The road is the profile plus the trim, exactly. The load commanded
// for it is a separate quantity (see `crate::gearing`) — all that is
// owed here is that trimming the road down eases the pedals.
assert_eq!(snapshot_of(&events).gradient_pct, 2.5);
assert!(
gradient_of(commands(&events)[0]) < steep,
"trimming 1.5% off the route must shed load"
);
}
#[test]
@@ -657,8 +878,13 @@ mod tests {
#[test]
fn custom_limits_are_honoured() {
// Gradient limits, so a gradient channel — the power channel has its
// own bounds and is covered separately.
let mut s = RideSession::new(
RiderConfig::default(),
RiderConfig {
load_channel: LoadChannel::Gradient,
..RiderConfig::default()
},
SafetyLimits {
min_gradient_pct: -2.0,
max_gradient_pct: 3.0,
@@ -676,11 +902,17 @@ mod tests {
fn an_unchanged_target_is_not_resent() {
let mut s = session();
s.start();
assert_eq!(commands(&s.tick(powered(200), 1.0)).len(), 1);
// Ride up to terminal speed first. The commanded load carries the
// rider's own drag, which rises with v², so while they are still
// accelerating the target is genuinely changing and writing it is
// correct. What FR-2.8 forbids is churn once nothing is moving.
for _ in 0..600 {
s.tick(powered(200), 1.0);
}
for _ in 0..50 {
assert!(
commands(&s.tick(powered(200), 1.0)).is_empty(),
"a steady target must not be re-sent (FR-2.8)"
"a settled ride must not be re-sent (FR-2.8)"
);
}
s.nudge_gradient(1.0);
@@ -721,8 +953,11 @@ mod tests {
for _ in 0..6000 {
writes += commands(&s.tick(powered(200), 0.1)).len();
}
// 6 % of gradient at a 0.05 % threshold is ~120 writes over 600 s.
assert!(writes <= 130, "{writes} writes in 600 s");
// The ramp itself is ~120 writes at a 0.05 % threshold; the rider's
// drag changing as they speed up on it accounts for the rest. Still one
// write every four seconds against a 4 Hz cap — two orders of magnitude
// of headroom, which is what this test is actually guarding.
assert!(writes <= 200, "{writes} writes in 600 s");
assert!(writes > 100);
}
@@ -981,33 +1216,540 @@ mod tests {
);
}
/// The gradient the session most recently asked the trainer for.
fn commanded_gradient(s: &RideSession) -> f32 {
match s.last_target() {
Some(ControlTarget::Gradient { percent }) => percent,
other => panic!("expected a gradient target, got {other:?}"),
}
}
#[test]
fn the_gear_servo_finds_load_when_the_rider_spins_out() {
// The descent failure that started this: steep downhill, rider spinning
// far faster than the gear implies. The commanded gradient must come
// back up so there is something to push against.
fn a_shift_changes_the_load_on_the_very_next_tick() {
// The point of the direct model. A rider who shifts and feels nothing
// for three seconds has not shifted, they have waited.
let mut s = session();
s.start();
s.config.descent_load_floor_pct = f32::NEG_INFINITY;
// A descent is ridden in a BIG gear — as on a real bike. In a short
// gear the bike simply outruns the rider's legs and the honest answer
// is that they are freewheeling, not that the trainer owes them load.
while s.gearing.shift_up() {}
s.nudge_gradient(-6.0);
s.gearing.set_gear(6);
let t = Telemetry {
power_w: Some(200),
cadence_rpm: Some(85.0),
..Default::default()
};
for _ in 0..40 {
s.tick(t, 0.25);
}
let before = commanded_gradient(&s);
let spun_out = Telemetry {
s.gearing.shift_up();
s.tick(t, 0.25);
let after = commanded_gradient(&s);
assert!(
after > before + 0.05,
"one tick after shifting up the load must be higher: {before} -> {after}"
);
}
#[test]
fn the_gear_the_rider_selects_is_what_reaches_the_trainer() {
// Bottom gear and top gear on the same road must not command the same
// load, or the shifter is decoration.
let mut s = session();
s.start();
let t = Telemetry {
power_w: Some(200),
cadence_rpm: Some(85.0),
..Default::default()
};
s.gearing.set_gear(1);
for _ in 0..40 {
s.tick(t, 0.25);
}
let bottom = commanded_gradient(&s);
s.gearing.set_gear(s.gearing.gear_count());
s.tick(t, 0.25);
let top = commanded_gradient(&s);
assert!(top > bottom, "top gear must load more: {top} vs {bottom}");
}
#[test]
fn the_route_gradient_is_not_charged_twice() {
// The commanded load already contains gravity, because the force it
// scales was computed at the route's gradient. A 6% climb in a gear
// close to the bike's real one should command something in the
// neighbourhood of 6% — not 12%.
let mut s = session();
s.start();
s.config.physical_development_m = s.gearing.development_m();
s.nudge_gradient(6.0);
let t = Telemetry {
power_w: Some(200),
cadence_rpm: Some(85.0),
..Default::default()
};
for _ in 0..40 {
s.tick(t, 0.25);
}
let commanded = commanded_gradient(&s);
assert!(
(commanded - 6.0).abs() < 2.0,
"6% road in a 1:1 gear should command about 6%, got {commanded}"
);
}
/// Settle a ride at a fixed cadence and gear, and report its speed.
fn ride_at(cadence: f32, gear: usize) -> RideSession {
let mut s = session();
s.start();
s.gearing.set_gear(gear);
let t = Telemetry {
power_w: Some(200),
cadence_rpm: Some(cadence),
..Default::default()
};
for _ in 0..80 {
s.tick(t, 0.25);
}
s
}
/// Ride up to speed on `gradient`, then stop pedalling for `seconds` while
/// the flywheel keeps the cranks turning. Returns (riding, coasting) km/h.
fn coast_after_riding(gradient: f32, seconds: f32) -> (f32, f32) {
let mut s = session();
s.start();
s.nudge_gradient(gradient);
let riding = Telemetry {
power_w: Some(250),
cadence_rpm: Some(85.0),
speed_kph: Some(28.0),
..Default::default()
};
for _ in 0..200 {
s.tick(riding, 0.25);
}
let moving = s.physics().speed_kph();
let coasting = Telemetry {
power_w: Some(0),
// The flywheel drives the cranks: cadence stays healthy for a
// rider who has stopped doing anything.
cadence_rpm: Some(80.0),
speed_kph: Some(26.0),
..Default::default()
};
for _ in 0..((seconds / 0.25) as u32) {
s.tick(coasting, 0.25);
}
(moving, s.physics().speed_kph())
}
#[test]
fn stopping_pedalling_on_a_climb_stops_the_bike() {
// The flywheel reports 80 rpm throughout, so anything keyed on cadence
// alone would have the rider still climbing at speed. Power is what
// says they have stopped.
let (moving, coasting) = coast_after_riding(3.5, 5.0);
assert!(moving > 20.0, "should have been riding: {moving}");
assert!(
coasting < 1.5,
"five seconds after stopping on a 3.5% climb, expected a halt, got {coasting} kph"
);
}
#[test]
fn coasting_a_descent_keeps_rolling() {
// The mirror image, and the reason coasting is not simply "stop": a
// rider who stops pedalling downhill speeds up, and must not be brought
// to a halt by a rule written for climbs.
let (_, coasting) = coast_after_riding(-5.0, 5.0);
assert!(
coasting > 15.0,
"freewheeling down a 5% descent should carry on, got {coasting} kph"
);
}
#[test]
fn speed_is_never_negative() {
// Whatever the road, the telemetry or the gear, a readout that says the
// rider is travelling backwards is never right.
for gradient in [-25.0, -8.0, 0.0, 8.0, 25.0] {
for (power, cadence) in
[(0u16, None), (0, Some(0.0)), (0, Some(95.0)), (400, Some(95.0))]
{
let mut s = session();
s.start();
s.nudge_gradient(gradient);
let t = Telemetry {
power_w: Some(power as i16),
cadence_rpm: cadence,
speed_kph: Some(0.0),
..Default::default()
};
for _ in 0..200 {
let snap = snapshot_of(&s.tick(t, 0.25));
assert!(
snap.virtual_speed_kph >= 0.0,
"negative speed {} at {gradient}% / {power} W / {cadence:?}",
snap.virtual_speed_kph
);
assert!(snap.virtual_distance_m >= 0.0, "distance went backwards");
}
}
}
}
/// The watts commanded after settling at a given flywheel speed and gear.
fn commanded_watts(gear: usize, trainer_kph: f32) -> u16 {
let mut s = power_session();
s.start();
s.gearing.set_gear(gear);
let t = Telemetry {
power_w: Some(180),
cadence_rpm: None,
speed_kph: Some(trainer_kph),
..Default::default()
};
for _ in 0..80 {
s.tick(t, 0.25);
}
match s.last_target() {
Some(ControlTarget::Power { watts }) => watts,
other => panic!("expected a power target, got {other:?}"),
}
}
#[test]
fn the_road_gradient_reaches_the_trainer_as_watts() {
// The D100 will honour the power channel or the gradient channel, not
// both, so gravity has to travel on whichever one we chose. It does:
// the climb is in the commanded watts, which is the whole reason
// dropping the gradient channel costs nothing.
let watts = |grade: f32| {
let mut s = power_session();
s.start();
s.gearing.set_gear(6);
s.nudge_gradient(grade);
let t = Telemetry {
power_w: Some(180),
cadence_rpm: None,
speed_kph: Some(20.0),
..Default::default()
};
for _ in 0..80 {
s.tick(t, 0.25);
}
match s.last_target() {
Some(ControlTarget::Power { watts }) => watts,
other => panic!("expected a power target, got {other:?}"),
}
};
let flat = watts(0.0);
let climb = watts(3.0);
let descent = watts(-5.0);
assert!(climb > flat * 2, "a 3% climb must cost far more: {climb} vs {flat}");
assert!(descent < flat, "a descent must cost less: {descent} vs {flat}");
assert!(
descent >= power_session().limits.min_power_w,
"never below what the trainer can hold"
);
}
#[test]
fn the_power_channel_is_what_ships() {
// The D100 declares 50-600 W in 1 W steps and 0-6% inclination in 0.1%
// steps, and only the former has room for gearing to show up in.
assert_eq!(RiderConfig::default().load_channel, LoadChannel::Power);
}
#[test]
fn a_longer_gear_demands_more_watts() {
// Not because power depends on the gear — at a given speed it does not
// — but because a longer gear turns the same cadence into more speed,
// and the road charges for speed.
let bottom = commanded_watts(1, 20.0);
let top = commanded_watts(12, 20.0);
assert!(top > bottom * 3, "top gear must cost far more: {top} vs {bottom}");
}
#[test]
fn the_commanded_watts_are_a_plausible_road_load() {
// Sanity on the absolute scale. This is the number the trainer will
// hold as a ceiling, so if it is wrong the ride is wrong.
let watts = commanded_watts(6, 20.0);
assert!(
(40..=250).contains(&watts),
"a middling gear on the flat should be ordinary riding: {watts} W"
);
}
#[test]
fn the_commanded_load_is_quantised_so_it_does_not_churn() {
// The write budget depends on this: road power goes as v cubed, and an
// unquantised target would change on nearly every tick.
for gear in [2usize, 6, 10] {
for kph in [12.0f32, 18.0, 25.0] {
let watts = commanded_watts(gear, kph);
assert_eq!(
watts % LOAD_POWER_STEP_W as u16,
0,
"{watts} W is off the {LOAD_POWER_STEP_W} W grid"
);
}
}
}
#[test]
fn a_steady_ride_on_the_power_channel_stays_inside_the_write_budget() {
// FR-2.8 caps writes at 4 Hz. Once the speed has settled the demand
// should stop moving entirely.
let mut s = power_session();
s.start();
let t = Telemetry {
power_w: Some(180),
cadence_rpm: None,
speed_kph: Some(20.0),
..Default::default()
};
for _ in 0..200 {
s.tick(t, 0.25);
}
let mut writes = 0;
for _ in 0..200 {
writes += commands(&s.tick(t, 0.25)).len();
}
assert!(writes <= 2, "a settled ride rewrote the target {writes} times");
}
#[test]
fn a_descent_never_asks_the_brake_for_negative_watts() {
// No brake can push. The floor is zero, and the safety limits raise it
// to whatever the trainer's real minimum is.
let mut s = power_session();
s.start();
s.nudge_gradient(-12.0);
let t = Telemetry {
power_w: Some(0),
cadence_rpm: Some(80.0),
speed_kph: Some(35.0),
..Default::default()
};
for _ in 0..80 {
s.tick(t, 0.25);
}
match s.last_target() {
Some(ControlTarget::Power { watts }) => {
assert!(watts <= s.limits.max_power_w);
}
other => panic!("expected a power target, got {other:?}"),
}
}
#[test]
fn speed_is_cadence_times_the_gear() {
// The drivetrain constraint, exactly as on a bike: the wheel is locked
// to the cranks, so this is arithmetic, not a force balance.
let s = ride_at(90.0, 6);
let expected = 90.0 / 60.0 * s.gearing.development_m();
let actual = s.physics().speed_mps;
assert!(
(actual - expected).abs() < 0.05,
"90 rpm in a {:.1} m gear is {expected:.2} m/s, got {actual:.2}",
s.gearing.development_m()
);
}
#[test]
fn a_bigger_gear_at_the_same_cadence_goes_faster() {
let small = ride_at(90.0, 2).physics().speed_mps;
let big = ride_at(90.0, 11).physics().speed_mps;
assert!(big > small * 1.5, "a longer gear must travel further: {big} vs {small}");
}
#[test]
fn a_higher_cadence_in_the_same_gear_goes_faster() {
let slow = ride_at(60.0, 6).physics().speed_mps;
let fast = ride_at(100.0, 6).physics().speed_mps;
assert!(fast > slow, "spinning faster must go faster: {fast} vs {slow}");
}
#[test]
fn a_rigid_drivetrain_leaves_the_cadence_servo_nothing_to_say() {
// With speed taken from cadence, the cadence the gear implies IS the
// cadence the rider is turning. The servo is a tautology here, which is
// what a rigid drivetrain means — and why it drives nothing.
let s = ride_at(90.0, 6);
assert!(
s.gearing.correction_pct().abs() < 0.01,
"expected no correction, got {}",
s.gearing.correction_pct()
);
}
#[test]
fn wheel_speed_infers_cadence_on_a_single_cog_drivetrain() {
// The trainer reports no cadence over FTMS but always reports speed,
// and with one sprocket the two are locked. 20 km/h through a 5.1 m
// development is 5.56 m/s / 5.1 m = 1.09 rev/s = 65 rpm.
let mut s = session();
s.config.physical_development_m = 5.1;
let t = Telemetry {
power_w: Some(150),
cadence_rpm: None,
speed_kph: Some(20.0),
..Default::default()
};
let rpm = s.effective_cadence(&t).expect("speed alone must yield a cadence");
assert!((rpm - 65.4).abs() < 0.5, "expected ~65 rpm, got {rpm}");
// And it drives the ride, rather than the ride standing still.
s.start();
for _ in 0..80 {
s.tick(t, 0.25);
}
assert!(s.physics().speed_mps > 1.0, "inferred cadence must move the bike");
assert_eq!(
snapshot_of(&s.tick(t, 0.25)).speed_source,
SpeedSource::Drivetrain
);
}
#[test]
fn a_measured_cadence_beats_an_inferred_one() {
// On this drivetrain they agree; on a bike with a freewheel only the
// measured one is honest, so it must win where it exists.
let s = session();
let t = Telemetry {
cadence_rpm: Some(95.0),
speed_kph: Some(20.0),
..Default::default()
};
assert_eq!(s.effective_cadence(&t), Some(95.0));
}
#[test]
fn the_gear_scales_the_trainers_own_speed() {
// What inference makes the model: virtual speed is the trainer's speed
// times the gear ratio. Top gear covers more ground per flywheel
// revolution than bottom, for the same flywheel.
let ride = |gear: usize| {
let mut s = session();
s.start();
s.gearing.set_gear(gear);
let t = Telemetry {
power_w: Some(200),
cadence_rpm: None,
speed_kph: Some(25.0),
..Default::default()
};
for _ in 0..80 {
s.tick(t, 0.25);
}
s.physics().speed_kph()
};
let bottom = ride(1);
let top = ride(12);
assert!(top > bottom * 2.0, "top gear must cover more ground: {top} vs {bottom}");
}
#[test]
fn a_silent_trainer_still_stops_the_ride() {
// The inference needs a wheel speed. With neither cadence nor speed
// there is genuinely nothing to ride on, and that must still be loud.
let mut s = session();
s.start();
let t = Telemetry {
power_w: Some(250),
cadence_rpm: None,
speed_kph: None,
..Default::default()
};
let mut snap = None;
for _ in 0..40 {
snap = Some(snapshot_of(&s.tick(t, 0.25)));
}
let snap = snap.unwrap();
assert_eq!(snap.virtual_speed_kph, 0.0);
assert_eq!(snap.speed_source, SpeedSource::NoCadence);
}
#[test]
fn without_cadence_the_ride_does_not_move_and_says_why() {
// Deliberate. Speed comes from the drivetrain, and with no cadence
// there is no drivetrain to read. Inventing a speed from power instead
// would let a broken cadence feed pass for a working ride that merely
// felt a little wrong — for however long it took someone to notice.
let mut s = session();
s.start();
let t = Telemetry { power_w: Some(250), cadence_rpm: None, ..Default::default() };
let mut snap = None;
for _ in 0..80 {
snap = Some(snapshot_of(&s.tick(t, 0.25)));
}
let snap = snap.unwrap();
assert_eq!(snap.virtual_speed_kph, 0.0);
assert_eq!(
snap.speed_source,
SpeedSource::NoCadence,
"the snapshot must name the fault, not just report a stopped bike"
);
}
#[test]
fn the_snapshot_names_which_rule_set_the_speed() {
let mut s = session();
s.start();
s.tick(powered(200), 0.25);
assert_eq!(
snapshot_of(&s.tick(powered(200), 0.25)).speed_source,
SpeedSource::Drivetrain
);
let coasting = Telemetry {
power_w: Some(0),
cadence_rpm: Some(80.0),
..Default::default()
};
assert_eq!(
snapshot_of(&s.tick(coasting, 0.25)).speed_source,
SpeedSource::Coasting
);
s.pause();
assert_eq!(
snapshot_of(&s.tick(powered(200), 0.25)).speed_source,
SpeedSource::Stopped
);
}
#[test]
fn a_descent_in_a_big_gear_carries_the_rider() {
// Replaces `the_gear_servo_finds_load_when_the_rider_spins_out`, whose
// premise a rigid drivetrain removes: you cannot spin out of a gear
// that is locked to the wheel. Turning 120 rpm in top gear now simply
// *is* going fast, which is the outcome that test was reaching for by
// a much longer route.
let mut s = session();
s.start();
s.nudge_gradient(-6.0);
while s.gearing.shift_up() {}
let spinning = Telemetry {
power_w: Some(40),
cadence_rpm: Some(120.0),
..Default::default()
};
for _ in 0..200 {
s.tick(spun_out, 0.25);
s.tick(spinning, 0.25);
}
let expected = 120.0 / 60.0 * s.gearing.development_m();
assert!(
s.gearing.correction_pct() > 0.5,
"servo should have added load, got {}",
s.gearing.correction_pct()
(s.physics().speed_mps - expected).abs() < 0.05,
"120 rpm in top gear is {expected:.1} m/s, got {:.1}",
s.physics().speed_mps
);
}
}
+129 -19
View File
@@ -83,9 +83,11 @@ impl SafetyLimits {
/// Where the base target comes from (§5.4). Note that in the full design
/// gearing and gradient are simultaneously active; mode selects the *source* of
/// the base gradient, not whether shifting works.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ControlMode {
/// Rider sets gradient directly; no profile running.
/// Rider sets gradient directly; no profile running. The default: it is the
/// mode a session with nothing loaded is already in.
#[default]
ManualGrade,
/// Rider sets raw resistance; physics ignored.
Resistance,
@@ -102,6 +104,20 @@ pub struct RiderConfig {
pub bike_kg: f32,
/// Coefficient of rolling resistance.
pub crr: f32,
/// A fixed power loss that never goes away, watts.
///
/// Everything a trainer costs you that does not scale the way road forces
/// do: belt and bearing drag, the chain, the flywheel's own bearings. On
/// this hardware that is about 12 W.
///
/// Held as a *power* rather than a force because that is how it presents —
/// a constant tax on what you put in. Converted where it is used by
/// dividing by speed, so it is a small force when you are moving fast and a
/// large one as you slow down. That asymmetry is the point: it barely
/// touches a fast descent and it is what brings a coast to an actual halt
/// rather than a long asymptotic drift.
#[serde(default = "default_rolling_loss_w")]
pub rolling_loss_w: f32,
/// Drag coefficient × frontal area, m².
pub cda: f32,
/// Fraction of measured power reaching the wheel.
@@ -109,17 +125,24 @@ 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.
/// Crank length — the radius of the circle the pedal travels, metres.
///
/// `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 last lever in the chain. Work is force × distance either side of it:
/// per crank revolution the pedal travels `2πr` while the bike travels
/// `development`, so the force the rider's leg feels is
///
/// ```text
/// F_pedal = F_road × development / (2πr)
/// ```
///
/// It changes nothing about what is commanded — the trainer is asked for a
/// force at the wheel, and leverage past the wheel is the rider's own
/// business — but it is the only way to say what a gear will actually feel
/// like, which is what makes a gear ladder sane or absurd.
///
/// Road bikes are 170175 mm; nothing sold is near 300 mm.
#[serde(default = "default_crank_length")]
pub crank_length_m: f32,
/// The steepest descent the trainer is ever *asked* to simulate.
///
/// On a real descent a trainer unloads almost completely, and on a
@@ -138,18 +161,25 @@ pub struct RiderConfig {
/// A 34T chainring on a 14T cog with a 2.1 m wheel is 5.1 m.
#[serde(default = "default_physical_development")]
pub physical_development_m: f32,
/// Which FTMS channel the computed load is sent on. See [`LoadChannel`].
#[serde(default)]
pub load_channel: LoadChannel,
}
fn default_physical_development() -> f32 {
5.1
}
fn default_descent_load_floor() -> f32 {
-1.0
fn default_rolling_loss_w() -> f32 {
12.0
}
fn default_trainer_speed_weight() -> f32 {
0.3
fn default_crank_length() -> f32 {
0.1725
}
fn default_descent_load_floor() -> f32 {
-1.0
}
impl Default for RiderConfig {
@@ -158,13 +188,15 @@ impl Default for RiderConfig {
rider_kg: 105.0,
bike_kg: 8.0,
crr: 0.004,
rolling_loss_w: default_rolling_loss_w(),
cda: 0.32,
drivetrain_efficiency: 0.97,
air_density: 1.225,
wheel_circumference_m: 2.1,
trainer_speed_weight: default_trainer_speed_weight(),
crank_length_m: default_crank_length(),
descent_load_floor_pct: default_descent_load_floor(),
physical_development_m: default_physical_development(),
load_channel: LoadChannel::default(),
}
}
}
@@ -175,10 +207,69 @@ impl RiderConfig {
}
}
/// How the computed road load is expressed to the trainer.
///
/// The load itself is the same physics either way; this only chooses the FTMS
/// channel it is sent on, and the right answer is whichever one the hardware
/// actually acts on.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum LoadChannel {
/// `SetIndoorBikeSimulationParameters` (0x11) — tell the trainer the slope
/// and let its own model produce the resistance.
Gradient,
/// `SetTargetPower` (0x05) — command the watts the road demands at the
/// current speed, recomputed every tick.
///
/// The default, because on the D100 it is the channel with room to work.
/// Its declared inclination range is 06% in 0.1% steps and refuses
/// negatives outright, so a gearing difference that should be dramatic
/// arrives as a fraction of a percent; its power range is 50600 W in 1 W
/// steps. Whether 0x11 does anything at all on this trainer is still
/// unconfirmed (TASK-2), whereas power is declared in its feature bits and
/// is what the physics computes natively.
///
/// This is not ERG. On the D100 the target is a **ceiling**, not a
/// setpoint: the brake absorbs up to that many watts and no more, so
/// anything the rider produces beyond it becomes speed instead of being
/// resisted away.
///
/// That is very nearly what a road is. Command the watts the road demands
/// at the current speed and the rest follows on its own — push harder than
/// the road asks and you accelerate, ease off and you slow, and because the
/// demand is recomputed from the new speed each tick the whole thing
/// settles where the physics says it should. A true ERG setpoint would
/// fight the rider instead, pressing harder the slower they went.
#[default]
Power,
}
/// Which rule decided the virtual speed on a given tick.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum SpeedSource {
/// The rider is driving: speed is cadence × the selected gear.
Drivetrain,
/// The rider has stopped putting power in: speed runs down to whatever the
/// gradient sustains on none.
Coasting,
/// No cadence reaching the engine, so there is no drivetrain to read and
/// the ride is held at a stop. A fault, not a mode: cadence comes from the
/// trainer's Zwift channel, and this says it is not arriving.
NoCadence,
/// The ride is not running. The default, because a snapshot that has not
/// been through a tick describes a bike nobody is on.
#[default]
Stopped,
}
/// A snapshot of the ride, pushed to the UI each tick. This is what the
/// frontend renders; it should contain everything the ride screen needs and
/// nothing it does not.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
/// `Default` is the zeroed ride — nothing elapsed, nothing moving, no gear
/// engaged. It exists for test fixtures and for the first frame before a tick
/// has run, so that adding a field here does not force every construction site
/// to be edited. Note that it is NOT a valid ride state: `gear_count` of zero
/// means no cassette, which the UI renders as a dash rather than "gear 0 of 0".
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct RideSnapshot {
pub elapsed_ms: u64,
pub telemetry: Telemetry,
@@ -186,10 +277,29 @@ pub struct RideSnapshot {
pub virtual_speed_kph: f32,
/// Virtual distance travelled, metres.
pub virtual_distance_m: f64,
/// Gradient currently commanded, percent.
/// The gradient of the *road*, percent — the profile's slope plus the
/// rider's trim. Not what the trainer was asked for: gearing sits between
/// the two, so see `target` for that.
pub gradient_pct: f32,
/// Cumulative elevation gained, metres.
pub elevation_gain_m: f32,
/// Selected virtual gear, one-based (FR-4.1).
pub gear: usize,
/// How many gears there are to choose from.
pub gear_count: usize,
/// Metres travelled per crank revolution in the selected gear.
pub development_m: f32,
/// Cadence the selected gear implies at the current speed, rpm — what the
/// rider would be turning if they were riding this gear honestly.
pub target_cadence_rpm: f32,
/// Force the rider's leg is pushing against at the pedal, newtons. The
/// commanded gradient in the units the legs actually work in — see
/// [`crate::gearing::Gearing::pedal_force_n`].
pub pedal_force_n: f32,
/// Which rule produced `virtual_speed_kph` this tick. Diagnostic: the three
/// behave very differently, and "the speed is wrong" is not answerable
/// without knowing which one was in charge. See `RideSession::tick`.
pub speed_source: SpeedSource,
pub mode: ControlMode,
/// The target most recently sent to the trainer, post-clamp.
pub target: Option<ControlTarget>,