//! 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::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, /// 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, elapsed_ms: u64, last_target: Option, } 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, 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 { 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 { 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); } // 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 { 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, virtual_speed_kph: self.physics.speed_kph(), 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 { 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 { 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, 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 { 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); 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 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); } }