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
+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);
}