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>
944 lines
32 KiB
Rust
944 lines
32 KiB
Rust
//! The ride state machine: ties telemetry, physics and the active profile
|
|
//! together and decides what to command the trainer.
|
|
//!
|
|
//! 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::{
|
|
ControlMode, ControlTarget, RideSnapshot, RiderConfig, SafetyLimits, Telemetry,
|
|
};
|
|
|
|
/// Something the session wants the outside world to do or know about.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum SessionEvent {
|
|
/// Send this target to the trainer. Already clamped (SAF-3).
|
|
Command(ControlTarget),
|
|
/// A new snapshot is available for the UI.
|
|
Snapshot(RideSnapshot),
|
|
/// A non-looping profile reached its end.
|
|
ProfileFinished,
|
|
/// The rider crossed into a new lap.
|
|
Lap { index: u32 },
|
|
}
|
|
|
|
/// Ride lifecycle.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum RideStatus {
|
|
Idle,
|
|
Running,
|
|
Paused,
|
|
Finished,
|
|
}
|
|
|
|
/// Smallest change worth spending a control-point write on. FR-2.8 caps writes
|
|
/// at 4 Hz; suppressing no-op targets keeps a 10 Hz tick loop comfortably
|
|
/// inside that without a timer, and avoids churning the trainer with values it
|
|
/// cannot resolve anyway.
|
|
const GRADIENT_EPSILON_PCT: f32 = 0.05;
|
|
|
|
pub struct RideSession {
|
|
pub config: RiderConfig,
|
|
pub limits: SafetyLimits,
|
|
pub mode: ControlMode,
|
|
pub status: RideStatus,
|
|
physics: PhysicsState,
|
|
profile: Option<Profile>,
|
|
/// Manual gradient trim applied on top of the profile's gradient.
|
|
gradient_offset_pct: f32,
|
|
/// Level held in [`ControlMode::Resistance`] (FR-4.3).
|
|
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>,
|
|
}
|
|
|
|
impl RideSession {
|
|
pub fn new(config: RiderConfig, limits: SafetyLimits) -> Self {
|
|
Self {
|
|
config,
|
|
limits,
|
|
mode: ControlMode::ManualGrade,
|
|
status: RideStatus::Idle,
|
|
physics: PhysicsState::default(),
|
|
profile: None,
|
|
gradient_offset_pct: 0.0,
|
|
manual_resistance: 0,
|
|
erg_watts: 150,
|
|
gearing: Gearing::default(),
|
|
elapsed_ms: 0,
|
|
last_target: None,
|
|
}
|
|
}
|
|
|
|
pub fn load_profile(&mut self, profile: Profile) {
|
|
self.profile = Some(profile);
|
|
self.mode = ControlMode::Profile;
|
|
}
|
|
|
|
pub fn profile(&self) -> Option<&Profile> {
|
|
self.profile.as_ref()
|
|
}
|
|
|
|
pub fn position(&self) -> Position {
|
|
Position {
|
|
elapsed_s: self.elapsed_ms as f64 / 1000.0,
|
|
distance_m: self.physics.distance_m,
|
|
}
|
|
}
|
|
|
|
pub fn start(&mut self) {
|
|
self.status = RideStatus::Running;
|
|
}
|
|
|
|
pub fn pause(&mut self) {
|
|
self.status = RideStatus::Paused;
|
|
}
|
|
|
|
/// Adjust the manual gradient trim by `delta` percent (FR-4.2).
|
|
pub fn nudge_gradient(&mut self, delta_pct: f32) {
|
|
self.gradient_offset_pct += delta_pct;
|
|
}
|
|
|
|
pub fn reset_gradient_offset(&mut self) {
|
|
self.gradient_offset_pct = 0.0;
|
|
}
|
|
|
|
pub fn gradient_offset_pct(&self) -> f32 {
|
|
self.gradient_offset_pct
|
|
}
|
|
|
|
/// Set the resistance level held in [`ControlMode::Resistance`] (FR-4.3).
|
|
///
|
|
/// Stored unclamped; `SafetyLimits` still has the final say at
|
|
/// transmission, so the rider's setting is never silently rewritten here.
|
|
pub fn set_resistance(&mut self, level: i16) {
|
|
self.manual_resistance = level;
|
|
}
|
|
|
|
pub fn nudge_resistance(&mut self, delta: i16) {
|
|
self.manual_resistance = self.manual_resistance.saturating_add(delta);
|
|
}
|
|
|
|
pub fn resistance_level(&self) -> i16 {
|
|
self.manual_resistance
|
|
}
|
|
|
|
/// Set the wattage held in [`ControlMode::Erg`] (FR-4.6).
|
|
pub fn set_erg_power(&mut self, watts: u16) {
|
|
self.erg_watts = watts;
|
|
}
|
|
|
|
pub fn nudge_erg_power(&mut self, delta: i16) {
|
|
self.erg_watts = self.erg_watts.saturating_add_signed(delta);
|
|
}
|
|
|
|
pub fn erg_power_w(&self) -> u16 {
|
|
self.erg_watts
|
|
}
|
|
|
|
/// Read-only view of the physics model, for diagnostics and recording.
|
|
pub fn physics(&self) -> &PhysicsState {
|
|
&self.physics
|
|
}
|
|
|
|
/// The target most recently sent to the trainer, post-clamp (SAF-1: this
|
|
/// is what should be held when input is lost).
|
|
pub fn last_target(&self) -> Option<ControlTarget> {
|
|
self.last_target
|
|
}
|
|
|
|
/// Advance the ride by one tick.
|
|
///
|
|
/// Feeds telemetry into the physics model, advances the profile, and
|
|
/// returns whatever the outside world needs to act on. Must be safe to call
|
|
/// when paused (no distance accrues) and when telemetry is missing power
|
|
/// (treat as zero rather than panicking).
|
|
pub fn tick(&mut self, telemetry: Telemetry, dt_s: f32) -> Vec<SessionEvent> {
|
|
let mut events = Vec::new();
|
|
let dt = if dt_s.is_finite() { dt_s.max(0.0) } else { 0.0 };
|
|
let running = self.status == RideStatus::Running;
|
|
|
|
if running {
|
|
self.elapsed_ms = self
|
|
.elapsed_ms
|
|
.saturating_add((dt as f64 * 1000.0).round() as u64);
|
|
}
|
|
|
|
// Resolve the target *before* stepping, so the physics see the same
|
|
// gradient the trainer is being asked for this tick.
|
|
let desired = self.desired_target();
|
|
let exhausted = self.profile.is_some() && desired.is_none();
|
|
|
|
if running {
|
|
// A trainer that reports no power is a trainer the rider is not
|
|
// pushing; nothing here may panic on a partial FTMS packet.
|
|
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);
|
|
events.push(SessionEvent::Command(clamped));
|
|
}
|
|
}
|
|
}
|
|
|
|
if exhausted && running {
|
|
self.status = RideStatus::Finished;
|
|
events.push(SessionEvent::ProfileFinished);
|
|
}
|
|
|
|
events.push(SessionEvent::Snapshot(self.snapshot(telemetry)));
|
|
events
|
|
}
|
|
|
|
/// Build the snapshot the UI renders.
|
|
pub fn snapshot(&self, telemetry: Telemetry) -> RideSnapshot {
|
|
RideSnapshot {
|
|
elapsed_ms: self.elapsed_ms,
|
|
telemetry,
|
|
// 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,
|
|
mode: self.mode,
|
|
target: self.last_target,
|
|
profile_progress: self.profile_progress(),
|
|
}
|
|
}
|
|
|
|
/// Fractional progress through the loaded profile (FR-9.7). `None` for a
|
|
/// looping profile, which never ends, or when nothing is loaded.
|
|
pub fn profile_progress(&self) -> Option<f32> {
|
|
let profile = self.profile.as_ref()?;
|
|
if profile.looping {
|
|
return None;
|
|
}
|
|
profile.total_extent().progress(self.position())
|
|
}
|
|
|
|
/// The target that should be in force right now, before clamping.
|
|
fn desired_target(&self) -> Option<ControlTarget> {
|
|
match self.mode {
|
|
// No profile involved: the trim *is* the gradient.
|
|
ControlMode::ManualGrade => Some(ControlTarget::Gradient {
|
|
percent: self.gradient_offset_pct,
|
|
}),
|
|
ControlMode::Profile => {
|
|
let sampled = self.profile.as_ref()?.sample(self.position())?;
|
|
Some(match sampled {
|
|
// The D-pad trim rides on top of the route (FR-4.2 and
|
|
// FR-4.4 are simultaneously active, §5.4).
|
|
ControlTarget::Gradient { percent } => ControlTarget::Gradient {
|
|
percent: percent + self.gradient_offset_pct,
|
|
},
|
|
other => other,
|
|
})
|
|
}
|
|
// These modes hold a value the rider set directly and ignore any
|
|
// loaded profile — selecting the mode *is* the statement that the
|
|
// rider is driving the trainer, not the route.
|
|
ControlMode::Resistance => Some(ControlTarget::Resistance {
|
|
level: self.manual_resistance,
|
|
}),
|
|
ControlMode::Erg => Some(ControlTarget::Power {
|
|
watts: self.erg_watts,
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// The gradient the physics model should simulate this tick: the profile's
|
|
/// gradient, if it is driving one, plus the manual trim. A profile driving
|
|
/// power or resistance contributes no slope, so the rider is on the flat
|
|
/// plus whatever trim they have dialled in.
|
|
fn simulated_gradient_pct(&self) -> f32 {
|
|
let base = match self.mode {
|
|
ControlMode::Profile => match self
|
|
.profile
|
|
.as_ref()
|
|
.and_then(|p| p.sample(self.position()))
|
|
{
|
|
Some(ControlTarget::Gradient { percent }) => percent,
|
|
_ => 0.0,
|
|
},
|
|
_ => 0.0,
|
|
};
|
|
base + self.gradient_offset_pct
|
|
}
|
|
}
|
|
|
|
/// Whether a new target differs enough from the last one to be worth sending.
|
|
/// A change of channel always counts.
|
|
fn changed_meaningfully(previous: Option<ControlTarget>, next: ControlTarget) -> bool {
|
|
match (previous, next) {
|
|
(None, _) => true,
|
|
(Some(ControlTarget::Gradient { percent: a }), ControlTarget::Gradient { percent: b }) => {
|
|
(a - b).abs() >= GRADIENT_EPSILON_PCT
|
|
}
|
|
(Some(ControlTarget::Resistance { level: a }), ControlTarget::Resistance { level: b }) => {
|
|
a != b
|
|
}
|
|
(Some(ControlTarget::Power { watts: a }), ControlTarget::Power { watts: b }) => a != b,
|
|
_ => true,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::profile::{Block, Channel, Extent, Segment, Waveform};
|
|
|
|
fn session() -> RideSession {
|
|
RideSession::new(RiderConfig::default(), SafetyLimits::default())
|
|
}
|
|
|
|
fn powered(watts: i16) -> Telemetry {
|
|
Telemetry {
|
|
power_w: Some(watts),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn commands(events: &[SessionEvent]) -> Vec<ControlTarget> {
|
|
events
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
SessionEvent::Command(t) => Some(*t),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn snapshot_of(events: &[SessionEvent]) -> RideSnapshot {
|
|
events
|
|
.iter()
|
|
.find_map(|e| match e {
|
|
SessionEvent::Snapshot(s) => Some(*s),
|
|
_ => None,
|
|
})
|
|
.expect("every tick emits a snapshot")
|
|
}
|
|
|
|
fn gradient_of(target: ControlTarget) -> f32 {
|
|
match target {
|
|
ControlTarget::Gradient { percent } => percent,
|
|
other => panic!("expected a gradient target, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
// ---- basic loop ------------------------------------------------------
|
|
|
|
#[test]
|
|
fn every_tick_emits_exactly_one_snapshot() {
|
|
let mut s = session();
|
|
s.start();
|
|
for _ in 0..10 {
|
|
let events = s.tick(powered(200), 1.0);
|
|
let snapshots = events
|
|
.iter()
|
|
.filter(|e| matches!(e, SessionEvent::Snapshot(_)))
|
|
.count();
|
|
assert_eq!(snapshots, 1);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn running_accrues_time_distance_and_speed() {
|
|
let mut s = session();
|
|
s.start();
|
|
for _ in 0..60 {
|
|
s.tick(powered(250), 1.0);
|
|
}
|
|
let snap = snapshot_of(&s.tick(powered(250), 1.0));
|
|
assert_eq!(snap.elapsed_ms, 61_000);
|
|
assert!(snap.virtual_distance_m > 300.0);
|
|
assert!(snap.virtual_speed_kph > 20.0);
|
|
}
|
|
|
|
// ---- pause -----------------------------------------------------------
|
|
|
|
#[test]
|
|
fn pausing_accrues_neither_time_nor_distance() {
|
|
let mut s = session();
|
|
s.start();
|
|
for _ in 0..30 {
|
|
s.tick(powered(250), 1.0);
|
|
}
|
|
let before = snapshot_of(&s.tick(powered(250), 1.0));
|
|
|
|
s.pause();
|
|
for _ in 0..100 {
|
|
let events = s.tick(powered(250), 1.0);
|
|
// Paused: nothing new is commanded, the last target stands (SAF-1).
|
|
assert!(commands(&events).is_empty());
|
|
}
|
|
let after = snapshot_of(&s.tick(powered(250), 1.0));
|
|
assert_eq!(after.virtual_distance_m, before.virtual_distance_m);
|
|
assert_eq!(after.elapsed_ms, before.elapsed_ms);
|
|
assert_eq!(after.elevation_gain_m, before.elevation_gain_m);
|
|
assert_eq!(after.target, before.target);
|
|
}
|
|
|
|
#[test]
|
|
fn an_idle_session_never_commands_the_trainer() {
|
|
let mut s = session();
|
|
for _ in 0..5 {
|
|
assert!(commands(&s.tick(powered(300), 1.0)).is_empty());
|
|
}
|
|
assert_eq!(s.last_target(), None);
|
|
assert_eq!(
|
|
snapshot_of(&s.tick(powered(300), 1.0)).virtual_distance_m,
|
|
0.0
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resuming_after_a_pause_continues_from_where_it_stopped() {
|
|
let mut s = session();
|
|
s.start();
|
|
for _ in 0..30 {
|
|
s.tick(powered(250), 1.0);
|
|
}
|
|
let mid = snapshot_of(&s.tick(powered(250), 1.0)).virtual_distance_m;
|
|
s.pause();
|
|
s.tick(powered(250), 1.0);
|
|
s.start();
|
|
for _ in 0..10 {
|
|
s.tick(powered(250), 1.0);
|
|
}
|
|
assert!(snapshot_of(&s.tick(powered(250), 1.0)).virtual_distance_m > mid);
|
|
}
|
|
|
|
// ---- missing / hostile telemetry ------------------------------------
|
|
|
|
#[test]
|
|
fn missing_power_is_treated_as_zero() {
|
|
let mut s = session();
|
|
s.start();
|
|
for _ in 0..30 {
|
|
s.tick(powered(300), 1.0);
|
|
}
|
|
let moving = snapshot_of(&s.tick(powered(300), 1.0)).virtual_speed_kph;
|
|
assert!(moving > 10.0);
|
|
|
|
// Empty packets — a real FTMS possibility, not a hypothetical.
|
|
for _ in 0..300 {
|
|
s.tick(Telemetry::default(), 1.0);
|
|
}
|
|
let snap = snapshot_of(&s.tick(Telemetry::default(), 1.0));
|
|
assert!(snap.virtual_speed_kph < moving);
|
|
assert_eq!(snap.virtual_speed_kph, 0.0, "should coast to a stop");
|
|
}
|
|
|
|
#[test]
|
|
fn negative_power_does_not_drive_the_rider_backwards() {
|
|
let mut s = session();
|
|
s.start();
|
|
for _ in 0..50 {
|
|
let snap = snapshot_of(&s.tick(powered(-500), 1.0));
|
|
assert!(snap.virtual_speed_kph >= 0.0);
|
|
assert_eq!(snap.virtual_distance_m, 0.0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_dt_does_not_corrupt_the_ride() {
|
|
let mut s = session();
|
|
s.start();
|
|
for dt in [f32::NAN, f32::INFINITY, -1.0, 0.0, 1e20] {
|
|
let snap = snapshot_of(&s.tick(powered(200), dt));
|
|
assert!(snap.virtual_speed_kph.is_finite() && snap.virtual_speed_kph >= 0.0);
|
|
assert!(snap.virtual_distance_m.is_finite() && snap.virtual_distance_m >= 0.0);
|
|
}
|
|
}
|
|
|
|
// ---- gradient offset -------------------------------------------------
|
|
|
|
#[test]
|
|
fn manual_grade_commands_the_trim_directly() {
|
|
let mut s = session();
|
|
s.start();
|
|
assert_eq!(gradient_of(commands(&s.tick(powered(0), 1.0))[0]), 0.0);
|
|
|
|
s.nudge_gradient(0.5);
|
|
s.nudge_gradient(0.5);
|
|
let events = s.tick(powered(0), 1.0);
|
|
assert_eq!(gradient_of(commands(&events)[0]), 1.0);
|
|
assert_eq!(snapshot_of(&events).gradient_pct, 1.0);
|
|
|
|
s.reset_gradient_offset();
|
|
assert_eq!(gradient_of(commands(&s.tick(powered(0), 1.0))[0]), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn the_trim_adds_on_top_of_the_profile_gradient() {
|
|
let mut s = session();
|
|
s.load_profile(Profile {
|
|
name: "flat-then-hill".into(),
|
|
description: None,
|
|
looping: false,
|
|
blocks: vec![Block::Constant {
|
|
channel: Channel::Gradient,
|
|
value: 4.0,
|
|
extent: Extent::Seconds(600.0),
|
|
}],
|
|
});
|
|
s.start();
|
|
assert_eq!(gradient_of(commands(&s.tick(powered(200), 1.0))[0]), 4.0);
|
|
|
|
s.nudge_gradient(-1.5);
|
|
let events = s.tick(powered(200), 1.0);
|
|
assert_eq!(gradient_of(commands(&events)[0]), 2.5);
|
|
// And the physics see the trimmed gradient too, not the raw profile.
|
|
assert_eq!(snapshot_of(&events).gradient_pct, 2.5);
|
|
}
|
|
|
|
#[test]
|
|
fn the_trim_does_not_disturb_a_power_profile_target() {
|
|
let mut s = session();
|
|
s.load_profile(Profile {
|
|
name: "erg".into(),
|
|
description: None,
|
|
looping: false,
|
|
blocks: vec![Block::Constant {
|
|
channel: Channel::Power,
|
|
value: 220.0,
|
|
extent: Extent::Seconds(600.0),
|
|
}],
|
|
});
|
|
s.start();
|
|
s.nudge_gradient(3.0);
|
|
let events = s.tick(powered(220), 1.0);
|
|
assert_eq!(commands(&events)[0], ControlTarget::Power { watts: 220 });
|
|
// The trim still tilts the virtual road, which is what drives speed.
|
|
assert_eq!(snapshot_of(&events).gradient_pct, 3.0);
|
|
}
|
|
|
|
// ---- safety clamping -------------------------------------------------
|
|
|
|
#[test]
|
|
fn out_of_range_gradients_are_clamped_before_transmission() {
|
|
let mut s = session();
|
|
s.start();
|
|
s.nudge_gradient(90.0);
|
|
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.
|
|
s.tick(powered(0), 1.0);
|
|
assert!(gradient_of(s.last_target().unwrap()) >= s.limits.min_gradient_pct);
|
|
assert_eq!(
|
|
gradient_of(s.last_target().unwrap()),
|
|
s.limits.min_gradient_pct
|
|
);
|
|
}
|
|
|
|
#[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.
|
|
let mut s = session();
|
|
s.load_profile(Profile {
|
|
name: "runaway".into(),
|
|
description: None,
|
|
looping: false,
|
|
blocks: vec![
|
|
Block::Constant {
|
|
channel: Channel::Power,
|
|
value: 5000.0,
|
|
extent: Extent::Seconds(10.0),
|
|
},
|
|
Block::Constant {
|
|
channel: Channel::Gradient,
|
|
value: -400.0,
|
|
extent: Extent::Seconds(10.0),
|
|
},
|
|
Block::Constant {
|
|
channel: Channel::Resistance,
|
|
value: 9000.0,
|
|
extent: Extent::Seconds(10.0),
|
|
},
|
|
],
|
|
});
|
|
s.start();
|
|
let mut seen = Vec::new();
|
|
for _ in 0..29 {
|
|
seen.extend(commands(&s.tick(powered(200), 1.0)));
|
|
}
|
|
assert!(!seen.is_empty());
|
|
for target in seen {
|
|
match target {
|
|
ControlTarget::Power { watts } => {
|
|
assert!((s.limits.min_power_w..=s.limits.max_power_w).contains(&watts))
|
|
}
|
|
ControlTarget::Gradient { percent } => assert!((s.limits.min_gradient_pct
|
|
..=s.limits.max_gradient_pct)
|
|
.contains(&percent)),
|
|
ControlTarget::Resistance { level } => {
|
|
assert!((s.limits.min_resistance..=s.limits.max_resistance).contains(&level))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn custom_limits_are_honoured() {
|
|
let mut s = RideSession::new(
|
|
RiderConfig::default(),
|
|
SafetyLimits {
|
|
min_gradient_pct: -2.0,
|
|
max_gradient_pct: 3.0,
|
|
..Default::default()
|
|
},
|
|
);
|
|
s.start();
|
|
s.nudge_gradient(10.0);
|
|
assert_eq!(gradient_of(commands(&s.tick(powered(0), 1.0))[0]), 3.0);
|
|
}
|
|
|
|
// ---- rate limiting ---------------------------------------------------
|
|
|
|
#[test]
|
|
fn an_unchanged_target_is_not_resent() {
|
|
let mut s = session();
|
|
s.start();
|
|
assert_eq!(commands(&s.tick(powered(200), 1.0)).len(), 1);
|
|
for _ in 0..50 {
|
|
assert!(
|
|
commands(&s.tick(powered(200), 1.0)).is_empty(),
|
|
"a steady target must not be re-sent (FR-2.8)"
|
|
);
|
|
}
|
|
s.nudge_gradient(1.0);
|
|
assert_eq!(commands(&s.tick(powered(200), 1.0)).len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn sub_threshold_gradient_drift_is_suppressed() {
|
|
let mut s = session();
|
|
s.start();
|
|
s.tick(powered(0), 1.0);
|
|
s.nudge_gradient(0.01);
|
|
assert!(commands(&s.tick(powered(0), 1.0)).is_empty());
|
|
for _ in 0..10 {
|
|
s.nudge_gradient(0.01);
|
|
}
|
|
assert_eq!(commands(&s.tick(powered(0), 1.0)).len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn a_continuously_varying_profile_stays_well_inside_the_write_budget() {
|
|
// A 10 Hz tick loop over a gradient ramp must not produce 10 writes a
|
|
// second; FR-2.8 caps them at four.
|
|
let mut s = session();
|
|
s.load_profile(Profile {
|
|
name: "ramp".into(),
|
|
description: None,
|
|
looping: false,
|
|
blocks: vec![Block::Ramp {
|
|
channel: Channel::Gradient,
|
|
from: 0.0,
|
|
to: 6.0,
|
|
extent: Extent::Seconds(600.0),
|
|
}],
|
|
});
|
|
s.start();
|
|
let mut writes = 0;
|
|
for _ in 0..6000 {
|
|
writes += commands(&s.tick(powered(200), 0.1)).len();
|
|
}
|
|
// 6 % of gradient at a 0.05 % threshold is ~120 writes over 600 s.
|
|
assert!(writes <= 130, "{writes} writes in 600 s");
|
|
assert!(writes > 100);
|
|
}
|
|
|
|
// ---- profile lifecycle ----------------------------------------------
|
|
|
|
#[test]
|
|
fn a_non_looping_profile_finishes_once() {
|
|
let mut s = session();
|
|
s.load_profile(Profile {
|
|
name: "short".into(),
|
|
description: None,
|
|
looping: false,
|
|
blocks: vec![Block::Constant {
|
|
channel: Channel::Gradient,
|
|
value: 2.0,
|
|
extent: Extent::Seconds(5.0),
|
|
}],
|
|
});
|
|
s.start();
|
|
let mut finishes = 0;
|
|
for _ in 0..20 {
|
|
finishes += s
|
|
.tick(powered(200), 1.0)
|
|
.iter()
|
|
.filter(|e| matches!(e, SessionEvent::ProfileFinished))
|
|
.count();
|
|
}
|
|
assert_eq!(finishes, 1);
|
|
assert_eq!(s.status, RideStatus::Finished);
|
|
}
|
|
|
|
#[test]
|
|
fn a_looping_profile_never_finishes() {
|
|
let mut s = session();
|
|
s.load_profile(Profile {
|
|
name: "loop".into(),
|
|
description: None,
|
|
looping: true,
|
|
blocks: vec![Block::Segments {
|
|
segments: vec![
|
|
Segment {
|
|
distance_m: 400.0,
|
|
gradient_pct: 0.0,
|
|
},
|
|
Segment {
|
|
distance_m: 400.0,
|
|
gradient_pct: 5.0,
|
|
},
|
|
],
|
|
}],
|
|
});
|
|
s.start();
|
|
for _ in 0..1200 {
|
|
let events = s.tick(powered(250), 1.0);
|
|
assert!(!events
|
|
.iter()
|
|
.any(|e| matches!(e, SessionEvent::ProfileFinished)));
|
|
}
|
|
assert_eq!(s.status, RideStatus::Running);
|
|
assert!(s.physics().distance_m > 2000.0, "should have lapped");
|
|
assert_eq!(s.profile_progress(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn progress_advances_from_zero_to_one() {
|
|
let mut s = session();
|
|
s.load_profile(Profile {
|
|
name: "p".into(),
|
|
description: None,
|
|
looping: false,
|
|
blocks: vec![Block::Constant {
|
|
channel: Channel::Gradient,
|
|
value: 0.0,
|
|
extent: Extent::Seconds(100.0),
|
|
}],
|
|
});
|
|
s.start();
|
|
assert_eq!(
|
|
snapshot_of(&s.tick(powered(200), 0.0)).profile_progress,
|
|
Some(0.0)
|
|
);
|
|
for _ in 0..50 {
|
|
s.tick(powered(200), 1.0);
|
|
}
|
|
let mid = snapshot_of(&s.tick(powered(200), 0.0))
|
|
.profile_progress
|
|
.unwrap();
|
|
assert!((mid - 0.5).abs() < 0.02, "{mid}");
|
|
for _ in 0..60 {
|
|
s.tick(powered(200), 1.0);
|
|
}
|
|
assert_eq!(
|
|
snapshot_of(&s.tick(powered(200), 0.0)).profile_progress,
|
|
Some(1.0)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_distance_profile_advances_only_as_the_rider_rides() {
|
|
let mut s = session();
|
|
s.load_profile(Profile {
|
|
name: "hill".into(),
|
|
description: None,
|
|
looping: false,
|
|
blocks: vec![Block::Segments {
|
|
segments: vec![
|
|
Segment {
|
|
distance_m: 200.0,
|
|
gradient_pct: 0.0,
|
|
},
|
|
Segment {
|
|
distance_m: 200.0,
|
|
gradient_pct: 8.0,
|
|
},
|
|
],
|
|
}],
|
|
});
|
|
s.start();
|
|
// No power, so no distance, so no progress no matter how long it runs.
|
|
for _ in 0..600 {
|
|
s.tick(Telemetry::default(), 1.0);
|
|
}
|
|
assert_eq!(s.profile_progress(), Some(0.0));
|
|
assert!(s.status == RideStatus::Running);
|
|
|
|
for _ in 0..600 {
|
|
s.tick(powered(250), 1.0);
|
|
}
|
|
assert_eq!(s.status, RideStatus::Finished);
|
|
}
|
|
|
|
#[test]
|
|
fn a_wave_profile_drives_the_power_channel() {
|
|
let mut s = session();
|
|
s.load_profile(Profile {
|
|
name: "over-unders".into(),
|
|
description: None,
|
|
looping: false,
|
|
blocks: vec![Block::Wave {
|
|
channel: Channel::Power,
|
|
shape: Waveform::Sine,
|
|
midpoint: 240.0,
|
|
amplitude: 40.0,
|
|
period: Extent::Seconds(120.0),
|
|
repeats: 2.0,
|
|
phase: 0.0,
|
|
}],
|
|
});
|
|
s.start();
|
|
let mut watts = Vec::new();
|
|
for _ in 0..240 {
|
|
for target in commands(&s.tick(powered(240), 1.0)) {
|
|
match target {
|
|
ControlTarget::Power { watts: w } => watts.push(w),
|
|
other => panic!("unexpected {other:?}"),
|
|
}
|
|
}
|
|
}
|
|
assert!(watts.contains(&280), "peak never reached: {watts:?}");
|
|
assert!(watts.contains(&200), "trough never reached");
|
|
assert!(watts.iter().all(|w| (200..=280).contains(w)));
|
|
}
|
|
|
|
#[test]
|
|
fn loading_a_profile_switches_into_profile_mode() {
|
|
let mut s = session();
|
|
assert_eq!(s.mode, ControlMode::ManualGrade);
|
|
s.load_profile(Profile {
|
|
name: "p".into(),
|
|
description: None,
|
|
looping: false,
|
|
blocks: vec![Block::Constant {
|
|
channel: Channel::Gradient,
|
|
value: 1.0,
|
|
extent: Extent::Seconds(10.0),
|
|
}],
|
|
});
|
|
assert_eq!(s.mode, ControlMode::Profile);
|
|
assert!(s.profile().is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn a_gradient_profile_accumulates_elevation() {
|
|
let mut s = session();
|
|
s.load_profile(Profile {
|
|
name: "climb".into(),
|
|
description: None,
|
|
looping: false,
|
|
blocks: vec![Block::Constant {
|
|
channel: Channel::Gradient,
|
|
value: 6.0,
|
|
extent: Extent::Seconds(1200.0),
|
|
}],
|
|
});
|
|
s.start();
|
|
for _ in 0..600 {
|
|
s.tick(powered(250), 1.0);
|
|
}
|
|
let snap = snapshot_of(&s.tick(powered(250), 0.0));
|
|
let expected = snap.virtual_distance_m as f32 * (0.06f32.atan()).sin();
|
|
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);
|
|
}
|
|
|
|
}
|