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:
2026-08-05 15:46:07 +02:00
co-authored by Claude Opus 5
parent 57eb5e809b
commit f2c4cb2120
5 changed files with 428 additions and 119 deletions
+7
View File
@@ -57,7 +57,14 @@ pub enum ClickEvent {
name: Option<String>,
},
/// The link dropped. Any held button has already been reported as released.
/// The actor is retrying — this is not terminal.
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
/// by the consumer (see [`ButtonTracker`]).
Button { button: Button, pressed: bool },
+315 -116
View File
@@ -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());
}
}
+21
View File
@@ -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::*;
+71 -1
View File
@@ -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()
);
}
}
+14 -2
View File
@@ -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(),
}
}
}