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:
@@ -4,6 +4,7 @@
|
||||
//! This is the piece the Tauri layer drives. It takes telemetry in, produces
|
||||
//! snapshots and control targets out, and knows nothing about BLE or the UI.
|
||||
|
||||
use crate::gearing::Gearing;
|
||||
use crate::physics::PhysicsState;
|
||||
use crate::profile::{Position, Profile};
|
||||
use crate::types::{
|
||||
@@ -51,6 +52,9 @@ pub struct RideSession {
|
||||
manual_resistance: i16,
|
||||
/// Wattage held in [`ControlMode::Erg`] (FR-4.6).
|
||||
erg_watts: u16,
|
||||
/// Virtual gears (FR-4.1): changes how hard the pedals feel, not how
|
||||
/// fast the rider travels for a given power.
|
||||
pub gearing: Gearing,
|
||||
elapsed_ms: u64,
|
||||
last_target: Option<ControlTarget>,
|
||||
}
|
||||
@@ -67,6 +71,7 @@ impl RideSession {
|
||||
gradient_offset_pct: 0.0,
|
||||
manual_resistance: 0,
|
||||
erg_watts: 150,
|
||||
gearing: Gearing::default(),
|
||||
elapsed_ms: 0,
|
||||
last_target: None,
|
||||
}
|
||||
@@ -177,12 +182,31 @@ impl RideSession {
|
||||
let power_w = f32::from(telemetry.power_w.unwrap_or(0)).max(0.0);
|
||||
self.physics
|
||||
.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.
|
||||
if let Some(kph) = telemetry.speed_kph {
|
||||
self.physics
|
||||
.correct_toward(kph / 3.6, self.config.trainer_speed_weight, dt);
|
||||
}
|
||||
}
|
||||
|
||||
// Only a running ride commands the trainer. When paused or finished the
|
||||
// last target simply stands (SAF-1) rather than being re-sent or reset.
|
||||
if running {
|
||||
if let Some(target) = desired {
|
||||
// Keep load under the pedals on descents so the rider's effort
|
||||
// still counts; the physics above already used the true route
|
||||
// gradient, so the descent stays as fast as the terrain says.
|
||||
let target = match target {
|
||||
// Gear offset applies to what the TRAINER is asked for, not
|
||||
// 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())
|
||||
.max(self.config.descent_load_floor_pct),
|
||||
},
|
||||
other => other,
|
||||
};
|
||||
let clamped = self.limits.clamp(target);
|
||||
if changed_meaningfully(self.last_target, clamped) {
|
||||
self.last_target = Some(clamped);
|
||||
@@ -205,7 +229,14 @@ impl RideSession {
|
||||
RideSnapshot {
|
||||
elapsed_ms: self.elapsed_ms,
|
||||
telemetry,
|
||||
virtual_speed_kph: self.physics.speed_kph(),
|
||||
// A paused or finished ride is a rider who is not moving. Holding
|
||||
// the last *target* when input is lost is correct (SAF-1); holding
|
||||
// the last *speed* is not — it tells a stationary rider they are
|
||||
// doing 39 km/h. Distance is retained, because it happened.
|
||||
virtual_speed_kph: match self.status {
|
||||
RideStatus::Running => self.physics.speed_kph(),
|
||||
_ => 0.0,
|
||||
},
|
||||
virtual_distance_m: self.physics.distance_m,
|
||||
gradient_pct: self.simulated_gradient_pct(),
|
||||
elevation_gain_m: self.physics.elevation_gain_m,
|
||||
@@ -532,6 +563,9 @@ mod tests {
|
||||
let target = commands(&s.tick(powered(0), 1.0))[0];
|
||||
assert_eq!(gradient_of(target), s.limits.max_gradient_pct);
|
||||
|
||||
// The descent load floor normally bites first, so disable it here to
|
||||
// prove the *safety* clamp still holds on its own.
|
||||
s.config.descent_load_floor_pct = f32::NEG_INFINITY;
|
||||
s.reset_gradient_offset();
|
||||
s.nudge_gradient(-90.0);
|
||||
// Two ticks: the first re-emits after the reset.
|
||||
@@ -543,6 +577,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descents_keep_load_under_the_pedals() {
|
||||
// A steep descent commands almost no resistance, so on a single-cog
|
||||
// drivetrain the rider spins out and their effort stops counting. The
|
||||
// floor keeps something to push against.
|
||||
let mut s = session();
|
||||
s.start();
|
||||
s.config.descent_load_floor_pct = -1.0;
|
||||
s.nudge_gradient(-8.0);
|
||||
let commanded = gradient_of(commands(&s.tick(powered(0), 1.0))[0]);
|
||||
assert_eq!(commanded, -1.0, "descent should be floored for load");
|
||||
|
||||
// But the *simulated* gradient stays true to the terrain, so the rider
|
||||
// still descends at the speed the route implies.
|
||||
assert!(
|
||||
s.snapshot(powered(0)).gradient_pct < -7.0,
|
||||
"physics must still see the real descent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absurd_profile_cannot_command_an_unsafe_target() {
|
||||
// SAF-6: parameter errors must be caught by SAF-3, not by the profile.
|
||||
@@ -861,4 +915,29 @@ mod tests {
|
||||
assert!((snap.elevation_gain_m - expected).abs() < expected * 0.02);
|
||||
assert!(snap.elevation_gain_m > 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paused_ride_reports_zero_speed_not_the_last_reading() {
|
||||
let mut s = session();
|
||||
s.start();
|
||||
// Build up real speed under power.
|
||||
for _ in 0..40 {
|
||||
s.tick(powered(250), 0.25);
|
||||
}
|
||||
let moving = s.snapshot(powered(250)).virtual_speed_kph;
|
||||
assert!(moving > 5.0, "expected to be moving, got {moving} kph");
|
||||
|
||||
// Pause. The rider is stationary — reporting the last speed would tell
|
||||
// them they are still doing 30-odd kph while stood still.
|
||||
s.pause();
|
||||
let paused = s.snapshot(powered(0));
|
||||
assert_eq!(paused.virtual_speed_kph, 0.0);
|
||||
// Distance is retained: it happened.
|
||||
assert!(paused.virtual_distance_m > 0.0);
|
||||
|
||||
// Resuming picks the speed back up rather than restarting from rest.
|
||||
s.start();
|
||||
assert!(s.snapshot(powered(250)).virtual_speed_kph > 5.0);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user