Adds backing state for Resistance and Erg control modes, which had no value to hold and so could never satisfy FR-4.3/FR-4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
865 lines
28 KiB
Rust
865 lines
28 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::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,
|
|
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,
|
|
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);
|
|
}
|
|
|
|
// 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<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);
|
|
|
|
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);
|
|
}
|
|
}
|