Core ride logic, FTMS client, FIT encoder and probe CLI

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>
This commit is contained in:
2026-08-05 13:34:27 +02:00
co-authored by Claude Opus 5
parent 3e106de2c5
commit 7c17ca6158
61 changed files with 20933 additions and 55 deletions
+752 -5
View File
@@ -32,6 +32,12 @@ pub enum RideStatus {
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,
@@ -41,6 +47,10 @@ pub struct RideSession {
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>,
}
@@ -55,6 +65,8 @@ impl RideSession {
physics: PhysicsState::default(),
profile: None,
gradient_offset_pct: 0.0,
manual_resistance: 0,
erg_watts: 150,
elapsed_ms: 0,
last_target: None,
}
@@ -93,6 +105,50 @@ impl RideSession {
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
@@ -100,18 +156,709 @@ impl RideSession {
/// 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 _ = (telemetry, dt_s);
todo!("implemented in crates/core/src/session.rs — see AGENT task A")
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 {
let _ = telemetry;
todo!("implemented in crates/core/src/session.rs — see AGENT task A")
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> {
todo!("implemented in crates/core/src/session.rs — see AGENT task A")
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);
}
}