Virtual gearing, trainer-speed blend, and cadence decode
Gears are expressed as an offset to the commanded gradient, leaving the physics on the route's true gradient so shifting changes effort, not speed. Neutral gear commands exactly the route gradient, so an un-shifted ride is unchanged. Cadence is not in FTMS on this trainer but is on its Zwift channel, decoded against captured frames. The undeclared FTMS trailing bytes were ruled out: wheel RPM restated at a fixed 73.8x speed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+125
-1
@@ -28,6 +28,17 @@ pub const MIN_SPEED_MPS: f32 = 0.5;
|
||||
/// absurd configuration (CdA of zero, a 90% descent) still cannot run away.
|
||||
pub const MAX_SPEED_MPS: f32 = 40.0;
|
||||
|
||||
/// Ceiling on *measured* power fed to the model. FTMS Instantaneous Power is a
|
||||
/// sint16, so a glitched packet can legitimately decode to 32767 W — which the
|
||||
/// force balance faithfully turns into a 144 km/h ride. No human produces more
|
||||
/// than ~2500 W even for a single track-sprint pedal stroke, so anything above
|
||||
/// this is a bad reading, not a rider.
|
||||
///
|
||||
/// This is deliberately *not* [`crate::types::SafetyLimits::max_power_w`]: that
|
||||
/// one bounds the ERG target we *command*, this one bounds the power we
|
||||
/// *believe*.
|
||||
pub const MAX_MEASURED_POWER_W: f32 = 2500.0;
|
||||
|
||||
/// Longest tick the integrator will honour. A caller that stalls for a minute
|
||||
/// must not be allowed to teleport the rider down a mountain.
|
||||
const MAX_DT_S: f32 = 10.0;
|
||||
@@ -105,6 +116,32 @@ impl PhysicsState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the modelled speed toward one the trainer actually measured.
|
||||
///
|
||||
/// The model knows what a bike *would* do for a given power and gradient;
|
||||
/// the trainer knows how fast its flywheel is really turning. Neither alone
|
||||
/// is right on a single-cog drivetrain: pure physics lets the rider "coast"
|
||||
/// downhill at 39 km/h while spinning out against no resistance, and pure
|
||||
/// trainer speed would cap descents at whatever cadence the one gear allows.
|
||||
///
|
||||
/// `weight` is the fraction of the gap closed **per second**, so the result
|
||||
/// does not depend on tick rate — a 4 Hz and a 10 Hz loop converge the same.
|
||||
pub fn correct_toward(&mut self, measured_mps: f32, weight: f32, dt: f32) {
|
||||
if !measured_mps.is_finite() || measured_mps < 0.0 || !dt.is_finite() || dt <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let w = weight.clamp(0.0, 1.0);
|
||||
if w == 0.0 {
|
||||
return;
|
||||
}
|
||||
// Fraction of the gap to close this tick, from the per-second rate.
|
||||
let alpha = 1.0 - (1.0 - w).powf(dt.min(MAX_DT_S));
|
||||
let corrected = self.speed_mps + (measured_mps - self.speed_mps) * alpha;
|
||||
if corrected.is_finite() {
|
||||
self.speed_mps = corrected.clamp(0.0, MAX_SPEED_MPS);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn speed_kph(&self) -> f32 {
|
||||
self.speed_mps * 3.6
|
||||
}
|
||||
@@ -129,7 +166,10 @@ struct Forces {
|
||||
impl Forces {
|
||||
fn new(power_w: f32, gradient_pct: f32, cfg: &RiderConfig) -> Self {
|
||||
// Braking is not modelled, so negative power is treated as coasting.
|
||||
let power = sanitise(power_w, 0.0).max(0.0);
|
||||
// The upper clamp is what keeps a glitched FTMS sample from driving the
|
||||
// ride at 144 km/h; the integrator is stable and drift-free on its own,
|
||||
// but it cannot tell an implausible input from a real one.
|
||||
let power = sanitise(power_w, 0.0).clamp(0.0, MAX_MEASURED_POWER_W);
|
||||
let gradient =
|
||||
sanitise(gradient_pct, 0.0).clamp(-MAX_ABS_GRADIENT_PCT, MAX_ABS_GRADIENT_PCT);
|
||||
let theta = (gradient / 100.0).atan();
|
||||
@@ -477,6 +517,90 @@ mod tests {
|
||||
assert_eq!(s, before);
|
||||
}
|
||||
|
||||
/// The integrator must not creep. Forward Euler's discrete fixed point is
|
||||
/// exactly the root of `a(v)`, i.e. the continuous equilibrium, so a steady
|
||||
/// effort held for hours must not accumulate its way to a higher speed. A
|
||||
/// higher-order scheme would not improve this — it shares the same fixed
|
||||
/// point — so this test, not the integration order, is the guarantee.
|
||||
#[test]
|
||||
fn a_long_steady_ride_does_not_drift_upwards() {
|
||||
let c = cfg();
|
||||
let target = equilibrium_speed_mps(250.0, 0.0, &c);
|
||||
let mut s = PhysicsState::default();
|
||||
|
||||
// Settle first, then hold for six hours of ride time.
|
||||
for _ in 0..2_400 {
|
||||
s.step(250.0, 0.0, &c, 0.25);
|
||||
}
|
||||
let after_settling = s.speed_mps;
|
||||
for _ in 0..86_400 {
|
||||
s.step(250.0, 0.0, &c, 0.25);
|
||||
}
|
||||
|
||||
assert!(
|
||||
(s.speed_mps - after_settling).abs() < 1.0e-3,
|
||||
"speed crept from {after_settling} to {} over six hours",
|
||||
s.speed_mps
|
||||
);
|
||||
assert!(
|
||||
s.speed_mps <= target + 1.0e-3,
|
||||
"settled {} above equilibrium {target}",
|
||||
s.speed_mps
|
||||
);
|
||||
}
|
||||
|
||||
/// Equilibrium is a fixed point *exactly*, not approximately: stepping from
|
||||
/// it must not move. This is the property that makes drift impossible.
|
||||
#[test]
|
||||
fn stepping_from_equilibrium_does_not_move() {
|
||||
let c = cfg();
|
||||
for (power, gradient) in [(200.0, 0.0), (300.0, 5.0), (150.0, -2.0)] {
|
||||
let v = equilibrium_speed_mps(power, gradient, &c);
|
||||
let mut s = PhysicsState {
|
||||
speed_mps: v,
|
||||
..Default::default()
|
||||
};
|
||||
s.step(power, gradient, &c, 1.0);
|
||||
assert!(
|
||||
(s.speed_mps - v).abs() < 1.0e-4,
|
||||
"P={power} g={gradient}: {v} -> {}",
|
||||
s.speed_mps
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A glitched FTMS sample is a sint16, so it can decode to 32767 W. That
|
||||
/// must not become a 144 km/h ride.
|
||||
#[test]
|
||||
fn implausible_power_cannot_drive_an_implausible_speed() {
|
||||
let c = cfg();
|
||||
let sane = settle(MAX_MEASURED_POWER_W, 0.0, 300.0).speed_mps;
|
||||
|
||||
for absurd in [3_000.0, 10_000.0, 32_767.0] {
|
||||
let s = settle(absurd, 0.0, 300.0);
|
||||
assert!(
|
||||
(s.speed_mps - sane).abs() < 1.0e-3,
|
||||
"{absurd} W settled at {} m/s, above the {sane} m/s ceiling",
|
||||
s.speed_mps
|
||||
);
|
||||
assert!(
|
||||
s.speed_mps < MAX_SPEED_MPS,
|
||||
"{absurd} W pinned the speed at the absolute clamp"
|
||||
);
|
||||
}
|
||||
|
||||
// Real efforts, including a hard sprint, must be untouched by the clamp.
|
||||
for real in [250.0, 600.0, 1_200.0, 2_000.0] {
|
||||
let s = settle(real, 0.0, 300.0);
|
||||
let expected = equilibrium_speed_mps(real, 0.0, &c);
|
||||
assert!(
|
||||
(s.speed_mps - expected).abs() < 0.05,
|
||||
"{real} W was clamped: {} vs {expected}",
|
||||
s.speed_mps
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_kph_conversion() {
|
||||
let s = PhysicsState {
|
||||
|
||||
Reference in New Issue
Block a user