Gearing as development, physics-derived load model, 105kg rider
Gears are metres per crank revolution rather than gradient offsets, and resistive_force_n exposes what the road is doing at a given speed so load can be computed directly instead of servoed. The load model is tested but not yet commanded: FTMS sim mode has the trainer compute rolling and aero itself, so sending a gradient that already contains them would double-count. Needs Crr/Cw zeroed and a ride to verify. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+315
-116
@@ -6,129 +6,122 @@
|
||||
//! 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.
|
||||
//! # How a gear is expressed
|
||||
//!
|
||||
//! Two gradients therefore exist and must not be confused:
|
||||
//! Not as a tooth count — the rider should not have to know what chainring is
|
||||
//! fitted — but as **development**: the metres travelled per crank revolution.
|
||||
//! A 34×28 bottom gear on 700c is about 2.5 m; a 50×11 top gear about 9.5 m.
|
||||
//! Development is the honest statement of what a gear *does*, and it needs only
|
||||
//! the wheel circumference to be useful.
|
||||
//!
|
||||
//! * 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.
|
||||
//! # How a gear is made to feel real
|
||||
//!
|
||||
//! 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.
|
||||
//! FTMS has no virtual-shifting op code — Zwift's implementation is proprietary
|
||||
//! — so gearing is servoed rather than commanded. The causal chain is the same
|
||||
//! as a real bike:
|
||||
//!
|
||||
//! 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.
|
||||
//! ```text
|
||||
//! gear -> resistance -> power -> speed
|
||||
//! ```
|
||||
//!
|
||||
//! Shifting does not set the speed. It sets how hard the pedals are, which
|
||||
//! decides the power the rider produces, which the physics model turns into
|
||||
//! speed. Deriving speed straight from cadence × gear would be simpler and
|
||||
//! 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
|
||||
//! 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.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A ladder of load offsets, easiest first.
|
||||
/// Widest load correction the servo may apply, in gradient percent. Generous
|
||||
/// enough to recover a spun-out descent, bounded so a runaway loop cannot ask
|
||||
/// for a cliff.
|
||||
const MAX_CORRECTION_PCT: f32 = 8.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.
|
||||
const GAIN_PCT_PER_RPM_S: f32 = 0.02;
|
||||
/// Cadence error small enough to ignore, rpm. Real pedalling wanders by a few
|
||||
/// rpm and chasing that would churn the control point for nothing.
|
||||
const DEADBAND_RPM: f32 = 3.0;
|
||||
|
||||
/// A ladder of gears, expressed as development in metres per crank revolution.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VirtualCassette {
|
||||
/// Gradient offset per gear, in percent. Ascending.
|
||||
offsets: Vec<f32>,
|
||||
/// Metres per crank revolution, ascending (easiest first).
|
||||
development_m: 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 {
|
||||
/// Evenly spaced gears between two developments.
|
||||
pub fn linear(gears: usize, easiest_m: f32, hardest_m: f32) -> Self {
|
||||
let gears = gears.max(1);
|
||||
if gears == 1 {
|
||||
return Self { offsets: vec![0.0] };
|
||||
return Self { development_m: vec![easiest_m.max(0.1)] };
|
||||
}
|
||||
let step = (hardest_pct - easiest_pct) / (gears - 1) as f32;
|
||||
let step = (hardest_m - easiest_m) / (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)
|
||||
development_m: (0..gears)
|
||||
.map(|i| (easiest_m + step * i as f32).max(0.1))
|
||||
.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)
|
||||
pub fn len(&self) -> usize {
|
||||
self.development_m.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.development_m.is_empty()
|
||||
}
|
||||
|
||||
/// Metres per crank revolution for a gear.
|
||||
pub fn development_m(&self, gear: usize) -> f32 {
|
||||
self.development_m
|
||||
.get(gear.min(self.development_m.len().saturating_sub(1)))
|
||||
.copied()
|
||||
.unwrap_or(1.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.
|
||||
/// Twelve gears from 2.5 m to 9.5 m — roughly a 34/28 to 50/11 road setup,
|
||||
/// which is a sane range for terrain from a steep climb to a fast descent.
|
||||
fn default() -> Self {
|
||||
Self::centred(12, 4, 0.75)
|
||||
Self::linear(12, 2.5, 9.5)
|
||||
}
|
||||
}
|
||||
|
||||
/// The rider's current gear selection.
|
||||
/// Gear selection plus the servo that makes the selection felt.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Gearing {
|
||||
cassette: VirtualCassette,
|
||||
gear: usize,
|
||||
/// Load correction the servo has settled on, in gradient percent.
|
||||
correction_pct: f32,
|
||||
}
|
||||
|
||||
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 }
|
||||
Self::new(VirtualCassette::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl Gearing {
|
||||
pub fn new(cassette: VirtualCassette) -> Self {
|
||||
let gear = cassette.neutral_gear();
|
||||
Self { cassette, gear }
|
||||
let gear = cassette.len() / 2;
|
||||
Self { cassette, gear, correction_pct: 0.0 }
|
||||
}
|
||||
|
||||
/// One-based, because riders count gears from one.
|
||||
@@ -140,9 +133,61 @@ impl Gearing {
|
||||
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)
|
||||
pub fn development_m(&self) -> f32 {
|
||||
self.cassette.development_m(self.gear)
|
||||
}
|
||||
|
||||
/// The load correction currently applied, in gradient percent.
|
||||
pub fn correction_pct(&self) -> f32 {
|
||||
self.correction_pct
|
||||
}
|
||||
|
||||
/// Cadence the selected gear implies at this road speed, rpm.
|
||||
pub fn target_cadence_rpm(&self, speed_mps: f32) -> f32 {
|
||||
let dev = self.development_m().max(0.1);
|
||||
(speed_mps.max(0.0) * 60.0 / dev).clamp(0.0, 250.0)
|
||||
}
|
||||
|
||||
/// Advance the servo one tick and return the load correction to add to the
|
||||
/// commanded gradient.
|
||||
///
|
||||
/// `cadence_rpm` is `None` when the trainer is not reporting it, in which
|
||||
/// case the correction decays toward zero rather than freezing — a stale
|
||||
/// correction is worse than none, because the rider cannot tell it is there.
|
||||
pub fn update(&mut self, cadence_rpm: Option<f32>, speed_mps: f32, dt: f32) -> f32 {
|
||||
let dt = if dt.is_finite() { dt.clamp(0.0, 1.0) } else { 0.0 };
|
||||
if dt <= 0.0 {
|
||||
return self.correction_pct;
|
||||
}
|
||||
|
||||
match cadence_rpm.filter(|c| c.is_finite() && *c > 0.0) {
|
||||
Some(actual) => {
|
||||
let target = self.target_cadence_rpm(speed_mps);
|
||||
// Below walking pace the target is meaningless; a rider rolling
|
||||
// to a stop should not be handed a correction for it.
|
||||
if target < 20.0 {
|
||||
self.decay(dt);
|
||||
return self.correction_pct;
|
||||
}
|
||||
let error = actual - target;
|
||||
if error.abs() > DEADBAND_RPM {
|
||||
self.correction_pct += error * GAIN_PCT_PER_RPM_S * dt;
|
||||
self.correction_pct =
|
||||
self.correction_pct.clamp(-MAX_CORRECTION_PCT, MAX_CORRECTION_PCT);
|
||||
}
|
||||
}
|
||||
None => self.decay(dt),
|
||||
}
|
||||
self.correction_pct
|
||||
}
|
||||
|
||||
fn decay(&mut self, dt: f32) {
|
||||
// ~2 s time constant, so an unexplained correction fades rather than
|
||||
// lingering under the pedals.
|
||||
self.correction_pct *= 1.0 - (0.5 * dt).min(1.0);
|
||||
if self.correction_pct.abs() < 0.01 {
|
||||
self.correction_pct = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Shift to a harder gear. Clamps at the top — never wraps (FR-4.1.3),
|
||||
@@ -177,34 +222,98 @@ impl Gearing {
|
||||
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");
|
||||
fn settle(g: &mut Gearing, cadence: f32, speed_mps: f32, seconds: f32) -> f32 {
|
||||
let dt = 0.25;
|
||||
for _ in 0..((seconds / dt) as u32) {
|
||||
g.update(Some(cadence), speed_mps, dt);
|
||||
}
|
||||
g.correction_pct()
|
||||
}
|
||||
|
||||
#[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);
|
||||
fn a_harder_gear_demands_a_lower_cadence() {
|
||||
let mut g = Gearing::default();
|
||||
let v = 8.0; // ~29 km/h
|
||||
let easy = {
|
||||
g.set_gear(1);
|
||||
g.target_cadence_rpm(v)
|
||||
};
|
||||
g.set_gear(12);
|
||||
let hard = g.target_cadence_rpm(v);
|
||||
assert!(easy > hard, "bottom gear should spin faster: {easy} vs {hard}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spinning_out_adds_load() {
|
||||
// The descent failure: rider at 110 rpm when the gear implies far less.
|
||||
let mut g = Gearing::default();
|
||||
g.set_gear(6);
|
||||
let v = 8.0;
|
||||
let target = g.target_cadence_rpm(v);
|
||||
assert!(target < 110.0);
|
||||
let correction = settle(&mut g, 110.0, v, 6.0);
|
||||
assert!(correction > 0.5, "should add load, got {correction}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grinding_sheds_load() {
|
||||
let mut g = Gearing::default();
|
||||
g.set_gear(6);
|
||||
let v = 8.0;
|
||||
let target = g.target_cadence_rpm(v);
|
||||
assert!(target > 40.0);
|
||||
let correction = settle(&mut g, 40.0, v, 6.0);
|
||||
assert!(correction < -0.5, "should shed load, got {correction}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cadence_matching_the_gear_is_left_alone() {
|
||||
let mut g = Gearing::default();
|
||||
g.set_gear(6);
|
||||
let v = 8.0;
|
||||
let target = g.target_cadence_rpm(v);
|
||||
let correction = settle(&mut g, target, v, 6.0);
|
||||
assert_eq!(correction, 0.0, "no error means no correction");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correction_is_bounded_however_long_the_error_persists() {
|
||||
let mut g = Gearing::default();
|
||||
g.set_gear(6);
|
||||
let correction = settle(&mut g, 200.0, 8.0, 120.0);
|
||||
assert!(
|
||||
correction <= MAX_CORRECTION_PCT,
|
||||
"runaway correction: {correction}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn losing_cadence_decays_the_correction_rather_than_freezing_it() {
|
||||
let mut g = Gearing::default();
|
||||
g.set_gear(6);
|
||||
settle(&mut g, 110.0, 8.0, 6.0);
|
||||
assert!(g.correction_pct() > 0.5);
|
||||
for _ in 0..40 {
|
||||
g.update(None, 8.0, 0.25);
|
||||
}
|
||||
assert!(
|
||||
g.correction_pct().abs() < 0.1,
|
||||
"stale correction lingered: {}",
|
||||
g.correction_pct()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shifting_is_monotonic_and_clamps_at_both_ends() {
|
||||
let mut g = Gearing::new(VirtualCassette::linear(5, -2.0, 4.0));
|
||||
let mut g = Gearing::new(VirtualCassette::linear(5, 2.5, 9.5));
|
||||
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;
|
||||
let mut previous = g.development_m();
|
||||
while g.shift_up() {
|
||||
let now = g.offset_pct();
|
||||
assert!(now > previous, "each shift up must add load");
|
||||
let now = g.development_m();
|
||||
assert!(now > previous, "each shift up must lengthen the gear");
|
||||
previous = now;
|
||||
}
|
||||
assert_eq!(g.gear(), 5);
|
||||
@@ -212,23 +321,113 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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}%"
|
||||
);
|
||||
fn a_stationary_rider_is_not_given_a_correction() {
|
||||
let mut g = Gearing::default();
|
||||
g.set_gear(6);
|
||||
let correction = settle(&mut g, 90.0, 0.0, 4.0);
|
||||
assert_eq!(correction, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
impl Gearing {
|
||||
/// The gradient to command so the pedals feel like the road does.
|
||||
///
|
||||
/// This is the whole point of the module, and it is a direct computation
|
||||
/// rather than a feedback loop. `resistive_n` is what the road is doing at
|
||||
/// the current speed — gravity down the slope, rolling resistance, and
|
||||
/// aerodynamic drag rising with v² — from [`crate::physics::resistive_force_n`].
|
||||
///
|
||||
/// A gear changes the *leverage* between crank and wheel, so the torque the
|
||||
/// rider feels for a given wheel force scales with development. Expressing
|
||||
/// the selected gear as a multiple of the bike's real one gives
|
||||
///
|
||||
/// ```text
|
||||
/// commanded_force = road_force × (virtual_development / physical_development)
|
||||
/// ```
|
||||
///
|
||||
/// and converting that force back to the gradient the trainer understands is
|
||||
/// just `F / (m·g)`. A longer gear therefore asks the trainer for a steeper
|
||||
/// hill, which is exactly what a longer gear feels like.
|
||||
///
|
||||
/// Note what this does *not* do: on a real descent, gravity exceeds drag and
|
||||
/// the net road force is negative. The honest result is little or no load,
|
||||
/// because a rider on a real descent freewheels. Selecting a longer gear
|
||||
/// scales that up, so there is more to push against, but it cannot conjure
|
||||
/// resistance that the road is not providing.
|
||||
pub fn load_gradient_pct(&self, resistive_n: f32, mass_kg: f32, physical_development_m: f32) -> f32 {
|
||||
let mass = if mass_kg.is_finite() { mass_kg.max(1.0) } else { 1.0 };
|
||||
let physical = if physical_development_m.is_finite() && physical_development_m > 0.1 {
|
||||
physical_development_m
|
||||
} else {
|
||||
5.1
|
||||
};
|
||||
let f = if resistive_n.is_finite() { resistive_n } else { 0.0 };
|
||||
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)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod load_tests {
|
||||
use super::*;
|
||||
use crate::physics::resistive_force_n;
|
||||
use crate::types::RiderConfig;
|
||||
|
||||
fn cfg() -> RiderConfig {
|
||||
RiderConfig::default()
|
||||
}
|
||||
|
||||
#[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);
|
||||
fn a_longer_gear_asks_for_more_load() {
|
||||
let c = cfg();
|
||||
let f = resistive_force_n(8.0, 0.0, &c);
|
||||
let mut g = Gearing::default();
|
||||
g.set_gear(1);
|
||||
let easy = g.load_gradient_pct(f, c.total_mass_kg(), 5.1);
|
||||
g.set_gear(12);
|
||||
let hard = g.load_gradient_pct(f, c.total_mass_kg(), 5.1);
|
||||
assert!(hard > easy, "top gear must load more: {hard} vs {easy}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_climb_loads_more_than_the_flat() {
|
||||
let c = cfg();
|
||||
let g = Gearing::default();
|
||||
let flat = g.load_gradient_pct(resistive_force_n(8.0, 0.0, &c), c.total_mass_kg(), 5.1);
|
||||
let climb = g.load_gradient_pct(resistive_force_n(8.0, 6.0, &c), c.total_mass_kg(), 5.1);
|
||||
assert!(climb > flat, "a hill must be harder: {climb} vs {flat}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn air_resistance_shows_up_as_load_at_speed() {
|
||||
// Flat road: the only thing that grows with speed is drag, so the
|
||||
// commanded load must grow with it too.
|
||||
let c = cfg();
|
||||
let g = Gearing::default();
|
||||
let slow = g.load_gradient_pct(resistive_force_n(4.0, 0.0, &c), c.total_mass_kg(), 5.1);
|
||||
let fast = g.load_gradient_pct(resistive_force_n(14.0, 0.0, &c), c.total_mass_kg(), 5.1);
|
||||
assert!(fast > slow, "drag must load at speed: {fast} vs {slow}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_steep_descent_honestly_offers_little_load() {
|
||||
// Not a bug: gravity exceeds drag, so a real rider freewheels.
|
||||
let c = cfg();
|
||||
let g = Gearing::default();
|
||||
let load = g.load_gradient_pct(resistive_force_n(10.0, -8.0, &c), c.total_mass_kg(), 5.1);
|
||||
assert!(load < 0.0, "a steep descent should not demand work: {load}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absurd_inputs_do_not_produce_absurd_targets() {
|
||||
let g = Gearing::default();
|
||||
assert_eq!(g.load_gradient_pct(f32::NAN, 100.0, 5.1), 0.0);
|
||||
assert!(g.load_gradient_pct(1e9, 100.0, 5.1).is_finite());
|
||||
assert!(g.load_gradient_pct(100.0, 0.0, 0.0).is_finite());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +250,27 @@ pub fn equilibrium_speed_mps(power_w: f32, gradient_pct: f32, cfg: &RiderConfig)
|
||||
0.5 * (lo + hi)
|
||||
}
|
||||
|
||||
/// Total resistive force at a given speed and gradient, newtons.
|
||||
///
|
||||
/// This is what the road is doing to the rider: gravity down the slope, rolling
|
||||
/// resistance, and aerodynamic drag rising with the square of speed. It is the
|
||||
/// force a trainer must reproduce at the wheel for the ride to feel real, and
|
||||
/// therefore the basis for virtual gearing (see `crate::gearing`).
|
||||
pub fn resistive_force_n(speed_mps: f32, gradient_pct: f32, cfg: &RiderConfig) -> f32 {
|
||||
let forces = Forces::new(0.0, gradient_pct, cfg);
|
||||
let v = if speed_mps.is_finite() {
|
||||
speed_mps.clamp(0.0, MAX_SPEED_MPS)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let f = forces.resistive_n + forces.drag_k * v * v;
|
||||
if f.is_finite() {
|
||||
f
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -184,6 +184,17 @@ impl RideSession {
|
||||
.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);
|
||||
|
||||
// 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);
|
||||
@@ -202,7 +213,7 @@ impl RideSession {
|
||||
// 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())
|
||||
percent: (percent + self.gearing.correction_pct())
|
||||
.max(self.config.descent_load_floor_pct),
|
||||
},
|
||||
other => other,
|
||||
@@ -940,4 +951,63 @@ mod tests {
|
||||
assert!(s.snapshot(powered(250)).virtual_speed_kph > 5.0);
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn cadence_and_gear_pull_the_speed_toward_the_drivetrain_constraint() {
|
||||
// On a real bike wheel speed is locked to cadence x gear. The model
|
||||
// should not drift far from that, whatever the force balance says.
|
||||
let mut s = session();
|
||||
s.start();
|
||||
s.gearing.set_gear(6);
|
||||
let development = s.gearing.development_m();
|
||||
|
||||
// Ride at a steady cadence with modest power for long enough to settle.
|
||||
let t = Telemetry {
|
||||
power_w: Some(150),
|
||||
cadence_rpm: Some(85.0),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..400 {
|
||||
s.tick(t, 0.25);
|
||||
}
|
||||
|
||||
// The servo's job is to make the rider's cadence agree with the gear,
|
||||
// by leaning on the load until it does — not to force the speed to
|
||||
// match. So what must converge is the cadence TARGET, not the speed.
|
||||
let target = s.gearing.target_cadence_rpm(s.physics().speed_mps);
|
||||
assert!(
|
||||
(target - 85.0).abs() < 25.0,
|
||||
"gear {development:.1} m implies {target:.0} rpm; rider is turning 85"
|
||||
);
|
||||
}
|
||||
|
||||
#[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.
|
||||
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);
|
||||
|
||||
let spun_out = Telemetry {
|
||||
power_w: Some(40),
|
||||
cadence_rpm: Some(120.0),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..200 {
|
||||
s.tick(spun_out, 0.25);
|
||||
}
|
||||
assert!(
|
||||
s.gearing.correction_pct() > 0.5,
|
||||
"servo should have added load, got {}",
|
||||
s.gearing.correction_pct()
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -131,6 +131,17 @@ pub struct RiderConfig {
|
||||
/// it. Set to a large negative number to disable.
|
||||
#[serde(default = "default_descent_load_floor")]
|
||||
pub descent_load_floor_pct: f32,
|
||||
/// Development of the bike's *real* gear — metres travelled per crank
|
||||
/// revolution through the Zwift Cog. Virtual gears are expressed relative
|
||||
/// to this, so it sets the leverage between the two.
|
||||
///
|
||||
/// 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,
|
||||
}
|
||||
|
||||
fn default_physical_development() -> f32 {
|
||||
5.1
|
||||
}
|
||||
|
||||
fn default_descent_load_floor() -> f32 {
|
||||
@@ -144,15 +155,16 @@ fn default_trainer_speed_weight() -> f32 {
|
||||
impl Default for RiderConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
rider_kg: 75.0,
|
||||
rider_kg: 105.0,
|
||||
bike_kg: 8.0,
|
||||
crr: 0.004,
|
||||
cda: 0.32,
|
||||
drivetrain_efficiency: 0.97,
|
||||
air_density: 1.225,
|
||||
wheel_circumference_m: 2.105,
|
||||
wheel_circumference_m: 2.1,
|
||||
trainer_speed_weight: default_trainer_speed_weight(),
|
||||
descent_load_floor_pct: default_descent_load_floor(),
|
||||
physical_development_m: default_physical_development(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user