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:
@@ -57,7 +57,14 @@ pub enum ClickEvent {
|
|||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
},
|
},
|
||||||
/// The link dropped. Any held button has already been reported as released.
|
/// The link dropped. Any held button has already been reported as released.
|
||||||
|
/// The actor is retrying — this is not terminal.
|
||||||
Disconnected,
|
Disconnected,
|
||||||
|
/// Reconnecting ran out of attempts and the actor has stopped (FR-1.11).
|
||||||
|
///
|
||||||
|
/// Terminal, and distinct from [`ClickEvent::Disconnected`] for exactly that
|
||||||
|
/// reason: one means "hold on", the other means "go and look at the pod".
|
||||||
|
/// Nothing further arrives on this stream.
|
||||||
|
GaveUp { attempts: u32 },
|
||||||
/// A press or release edge. Repeats while held are filtered out here, not
|
/// A press or release edge. Repeats while held are filtered out here, not
|
||||||
/// by the consumer (see [`ButtonTracker`]).
|
/// by the consumer (see [`ButtonTracker`]).
|
||||||
Button { button: Button, pressed: bool },
|
Button { button: Button, pressed: bool },
|
||||||
|
|||||||
+315
-116
@@ -6,129 +6,122 @@
|
|||||||
//! out against nothing, and their effort stops contributing at precisely the
|
//! out against nothing, and their effort stops contributing at precisely the
|
||||||
//! moment they can see the speed rising.
|
//! moment they can see the speed rising.
|
||||||
//!
|
//!
|
||||||
//! FTMS has no virtual-shifting op code — Zwift's own implementation is
|
//! # How a gear is expressed
|
||||||
//! proprietary — so gearing has to be synthesised from what the trainer does
|
|
||||||
//! expose. The D100 accepts `SetIndoorBikeSimulationParameters`, so a gear is
|
|
||||||
//! expressed as an **offset to the gradient the trainer is asked to simulate**:
|
|
||||||
//! a harder gear asks for a steeper hill and therefore more load.
|
|
||||||
//!
|
//!
|
||||||
//! Two gradients therefore exist and must not be confused:
|
//! 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
|
//! # How a gear is made to feel real
|
||||||
//! reflects the terrain;
|
|
||||||
//! * the **commanded** gradient — route plus gear offset — which only decides
|
|
||||||
//! how hard the pedals feel.
|
|
||||||
//!
|
//!
|
||||||
//! Shifting consequently changes effort, not speed, exactly as on a real bike.
|
//! FTMS has no virtual-shifting op code — Zwift's implementation is proprietary
|
||||||
//! Speed changes only as a *result*: a harder gear at the same cadence produces
|
//! — so gearing is servoed rather than commanded. The causal chain is the same
|
||||||
//! more watts, and more watts produce more speed through the physics.
|
//! as a real bike:
|
||||||
//!
|
//!
|
||||||
//! The percent-per-gear mapping is a pragmatic stand-in for a proper torque
|
//! ```text
|
||||||
//! model and **wants calibrating against the real resistance curve** (TASK-3 in
|
//! gear -> resistance -> power -> speed
|
||||||
//! REQUIREMENTS.md, still outstanding). The defaults are a starting point, not
|
//! ```
|
||||||
//! a measured result.
|
//!
|
||||||
|
//! 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};
|
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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct VirtualCassette {
|
pub struct VirtualCassette {
|
||||||
/// Gradient offset per gear, in percent. Ascending.
|
/// Metres per crank revolution, ascending (easiest first).
|
||||||
offsets: Vec<f32>,
|
development_m: Vec<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VirtualCassette {
|
impl VirtualCassette {
|
||||||
/// Evenly spaced gears between two offsets.
|
/// Evenly spaced gears between two developments.
|
||||||
///
|
pub fn linear(gears: usize, easiest_m: f32, hardest_m: f32) -> Self {
|
||||||
/// `easiest` is normally negative — it *removes* load, so the rider can
|
|
||||||
/// still turn the pedals on a steep climb. `hardest` is positive, which is
|
|
||||||
/// what makes a descent rideable rather than a spin-out.
|
|
||||||
pub fn linear(gears: usize, easiest_pct: f32, hardest_pct: f32) -> Self {
|
|
||||||
let gears = gears.max(1);
|
let gears = gears.max(1);
|
||||||
if gears == 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 {
|
Self {
|
||||||
offsets: (0..gears).map(|i| easiest_pct + step * i as f32).collect(),
|
development_m: (0..gears)
|
||||||
}
|
.map(|i| (easiest_m + step * i as f32).max(0.1))
|
||||||
}
|
|
||||||
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
self.offsets.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.offsets.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn offset_pct(&self, gear: usize) -> f32 {
|
|
||||||
self.offsets
|
|
||||||
.get(gear.min(self.offsets.len().saturating_sub(1)))
|
|
||||||
.copied()
|
|
||||||
.unwrap_or(0.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VirtualCassette {
|
|
||||||
/// A ladder with an exact **zero** rung at `neutral`, stepping by `step`
|
|
||||||
/// either side.
|
|
||||||
///
|
|
||||||
/// The zero matters: it is the gear in which the trainer is asked for
|
|
||||||
/// precisely the route's gradient and nothing else, so a rider who never
|
|
||||||
/// shifts gets exactly the behaviour they had before gears existed.
|
|
||||||
pub fn centred(gears: usize, neutral: usize, step: f32) -> Self {
|
|
||||||
let gears = gears.max(1);
|
|
||||||
let neutral = neutral.min(gears - 1);
|
|
||||||
Self {
|
|
||||||
offsets: (0..gears)
|
|
||||||
.map(|i| (i as f32 - neutral as f32) * step)
|
|
||||||
.collect(),
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Index of the gear whose offset is nearest neutral.
|
pub fn len(&self) -> usize {
|
||||||
pub fn neutral_gear(&self) -> usize {
|
self.development_m.len()
|
||||||
self.offsets
|
}
|
||||||
.iter()
|
|
||||||
.enumerate()
|
pub fn is_empty(&self) -> bool {
|
||||||
.min_by(|a, b| a.1.abs().total_cmp(&b.1.abs()))
|
self.development_m.is_empty()
|
||||||
.map(|(i, _)| i)
|
}
|
||||||
.unwrap_or(0)
|
|
||||||
|
/// 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 {
|
impl Default for VirtualCassette {
|
||||||
/// Twelve gears in 0.75% steps, neutral at gear 5, spanning −3% to +5.25%.
|
/// Twelve gears from 2.5 m to 9.5 m — roughly a 34/28 to 50/11 road setup,
|
||||||
/// The asymmetry is deliberate: shedding load on a climb matters less than
|
/// which is a sane range for terrain from a steep climb to a fast descent.
|
||||||
/// being able to *find* load on a descent, which is the failure this module
|
|
||||||
/// exists to fix.
|
|
||||||
fn default() -> Self {
|
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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct Gearing {
|
pub struct Gearing {
|
||||||
cassette: VirtualCassette,
|
cassette: VirtualCassette,
|
||||||
gear: usize,
|
gear: usize,
|
||||||
|
/// Load correction the servo has settled on, in gradient percent.
|
||||||
|
correction_pct: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Gearing {
|
impl Default for Gearing {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let cassette = VirtualCassette::default();
|
Self::new(VirtualCassette::default())
|
||||||
// Start in the neutral gear so an un-shifted ride behaves exactly as it
|
|
||||||
// did before gears existed — no silent change to the commanded gradient.
|
|
||||||
let gear = cassette.neutral_gear();
|
|
||||||
Self { cassette, gear }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Gearing {
|
impl Gearing {
|
||||||
pub fn new(cassette: VirtualCassette) -> Self {
|
pub fn new(cassette: VirtualCassette) -> Self {
|
||||||
let gear = cassette.neutral_gear();
|
let gear = cassette.len() / 2;
|
||||||
Self { cassette, gear }
|
Self { cassette, gear, correction_pct: 0.0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One-based, because riders count gears from one.
|
/// One-based, because riders count gears from one.
|
||||||
@@ -140,9 +133,61 @@ impl Gearing {
|
|||||||
self.cassette.len()
|
self.cassette.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load offset in simulated-gradient percent for the selected gear.
|
pub fn development_m(&self) -> f32 {
|
||||||
pub fn offset_pct(&self) -> f32 {
|
self.cassette.development_m(self.gear)
|
||||||
self.cassette.offset_pct(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),
|
/// Shift to a harder gear. Clamps at the top — never wraps (FR-4.1.3),
|
||||||
@@ -177,34 +222,98 @@ impl Gearing {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
fn settle(g: &mut Gearing, cadence: f32, speed_mps: f32, seconds: f32) -> f32 {
|
||||||
fn a_default_cassette_spans_easier_and_harder_than_neutral() {
|
let dt = 0.25;
|
||||||
let g = Gearing::default();
|
for _ in 0..((seconds / dt) as u32) {
|
||||||
assert_eq!(g.gear_count(), 12);
|
g.update(Some(cadence), speed_mps, dt);
|
||||||
let c = &g.cassette;
|
}
|
||||||
assert!(c.offset_pct(0) < 0.0, "bottom gear must shed load");
|
g.correction_pct()
|
||||||
assert!(c.offset_pct(11) > 0.0, "top gear must add load");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unshifted_ride_commands_exactly_the_route_gradient() {
|
fn a_harder_gear_demands_a_lower_cadence() {
|
||||||
// Gears must not silently alter the ride for someone who never shifts.
|
let mut g = Gearing::default();
|
||||||
let g = Gearing::default();
|
let v = 8.0; // ~29 km/h
|
||||||
assert_eq!(g.offset_pct(), 0.0);
|
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]
|
#[test]
|
||||||
fn shifting_is_monotonic_and_clamps_at_both_ends() {
|
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() {}
|
while g.shift_down() {}
|
||||||
assert_eq!(g.gear(), 1);
|
assert_eq!(g.gear(), 1);
|
||||||
assert!(!g.shift_down(), "must not wrap past the bottom");
|
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() {
|
while g.shift_up() {
|
||||||
let now = g.offset_pct();
|
let now = g.development_m();
|
||||||
assert!(now > previous, "each shift up must add load");
|
assert!(now > previous, "each shift up must lengthen the gear");
|
||||||
previous = now;
|
previous = now;
|
||||||
}
|
}
|
||||||
assert_eq!(g.gear(), 5);
|
assert_eq!(g.gear(), 5);
|
||||||
@@ -212,23 +321,113 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_hard_gear_finds_load_on_a_descent() {
|
fn a_stationary_rider_is_not_given_a_correction() {
|
||||||
// The failure this module exists to fix: on a -6% descent the trainer
|
let mut g = Gearing::default();
|
||||||
// unloads and the rider spins out. Selecting a hard gear must bring the
|
g.set_gear(6);
|
||||||
// commanded gradient back to something they can push against.
|
let correction = settle(&mut g, 90.0, 0.0, 4.0);
|
||||||
let mut g = Gearing::new(VirtualCassette::default());
|
assert_eq!(correction, 0.0);
|
||||||
while g.shift_up() {}
|
}
|
||||||
let commanded = -6.0 + g.offset_pct();
|
}
|
||||||
assert!(
|
|
||||||
commanded > -1.0,
|
impl Gearing {
|
||||||
"top gear should recover load on a descent, got {commanded}%"
|
/// 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]
|
#[test]
|
||||||
fn a_single_speed_cassette_is_neutral() {
|
fn a_longer_gear_asks_for_more_load() {
|
||||||
let g = Gearing::new(VirtualCassette::linear(1, -3.0, 6.0));
|
let c = cfg();
|
||||||
assert_eq!(g.gear_count(), 1);
|
let f = resistive_force_n(8.0, 0.0, &c);
|
||||||
assert_eq!(g.offset_pct(), 0.0);
|
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)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -184,6 +184,17 @@ impl RideSession {
|
|||||||
.step(power_w, self.simulated_gradient_pct(), &self.config, dt);
|
.step(power_w, self.simulated_gradient_pct(), &self.config, dt);
|
||||||
// Pull the model back toward what the flywheel is really doing.
|
// Pull the model back toward what the flywheel is really doing.
|
||||||
// Pure physics lets a spun-out rider "coast" downhill at 39 km/h.
|
// 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 {
|
if let Some(kph) = telemetry.speed_kph {
|
||||||
self.physics
|
self.physics
|
||||||
.correct_toward(kph / 3.6, self.config.trainer_speed_weight, dt);
|
.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,
|
// to what the physics simulated: shifting changes effort,
|
||||||
// not the speed the terrain implies.
|
// not the speed the terrain implies.
|
||||||
ControlTarget::Gradient { percent } => ControlTarget::Gradient {
|
ControlTarget::Gradient { percent } => ControlTarget::Gradient {
|
||||||
percent: (percent + self.gearing.offset_pct())
|
percent: (percent + self.gearing.correction_pct())
|
||||||
.max(self.config.descent_load_floor_pct),
|
.max(self.config.descent_load_floor_pct),
|
||||||
},
|
},
|
||||||
other => other,
|
other => other,
|
||||||
@@ -940,4 +951,63 @@ mod tests {
|
|||||||
assert!(s.snapshot(powered(250)).virtual_speed_kph > 5.0);
|
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.
|
/// it. Set to a large negative number to disable.
|
||||||
#[serde(default = "default_descent_load_floor")]
|
#[serde(default = "default_descent_load_floor")]
|
||||||
pub descent_load_floor_pct: f32,
|
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 {
|
fn default_descent_load_floor() -> f32 {
|
||||||
@@ -144,15 +155,16 @@ fn default_trainer_speed_weight() -> f32 {
|
|||||||
impl Default for RiderConfig {
|
impl Default for RiderConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
rider_kg: 75.0,
|
rider_kg: 105.0,
|
||||||
bike_kg: 8.0,
|
bike_kg: 8.0,
|
||||||
crr: 0.004,
|
crr: 0.004,
|
||||||
cda: 0.32,
|
cda: 0.32,
|
||||||
drivetrain_efficiency: 0.97,
|
drivetrain_efficiency: 0.97,
|
||||||
air_density: 1.225,
|
air_density: 1.225,
|
||||||
wheel_circumference_m: 2.105,
|
wheel_circumference_m: 2.1,
|
||||||
trainer_speed_weight: default_trainer_speed_weight(),
|
trainer_speed_weight: default_trainer_speed_weight(),
|
||||||
descent_load_floor_pct: default_descent_load_floor(),
|
descent_load_floor_pct: default_descent_load_floor(),
|
||||||
|
physical_development_m: default_physical_development(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user