diff --git a/crates/ble/src/click.rs b/crates/ble/src/click.rs index af622a0..2b1b440 100644 --- a/crates/ble/src/click.rs +++ b/crates/ble/src/click.rs @@ -57,7 +57,14 @@ pub enum ClickEvent { name: Option, }, /// 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 }, diff --git a/crates/core/src/gearing.rs b/crates/core/src/gearing.rs index 9b064fb..423e67a 100644 --- a/crates/core/src/gearing.rs +++ b/crates/core/src/gearing.rs @@ -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, + /// Metres per crank revolution, ascending (easiest first). + development_m: Vec, } 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, 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()); } } diff --git a/crates/core/src/physics.rs b/crates/core/src/physics.rs index 5d4950f..9192872 100644 --- a/crates/core/src/physics.rs +++ b/crates/core/src/physics.rs @@ -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::*; diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index ff35ae1..ddb7ba7 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -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() + ); + } + } diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index fb8c3f1..f72b05a 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -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(), } } }