Scaffold workspace, shared types and requirements spec
Cargo workspace with core/ble/fit/probe crates. crates/core/src/types.rs is the fixed contract between the BLE layer, ride engine and UI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
//! GPX import: turn a recorded ride into a gradient profile (§5.5).
|
||||
//!
|
||||
//! The hard part is not parsing — it is that **raw GPS elevation is far too
|
||||
//! noisy to differentiate directly** (FR-5.2). Differentiating unsmoothed
|
||||
//! elevation produces wild gradient spikes that would make the trainer lurch.
|
||||
//! Elevation must be smoothed before gradients are derived, and the result
|
||||
//! clamped (FR-5.3).
|
||||
|
||||
use crate::profile::{Profile, TerrainPoint};
|
||||
|
||||
/// A single trackpoint read from a GPX file.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct TrackPoint {
|
||||
pub lat_deg: f64,
|
||||
pub lon_deg: f64,
|
||||
pub elevation_m: f32,
|
||||
}
|
||||
|
||||
/// Tuning for elevation smoothing and gradient derivation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct SmoothingConfig {
|
||||
/// Resample the track to this spacing before differentiating, in metres.
|
||||
/// Larger values give smoother, less twitchy gradients.
|
||||
pub resample_m: f64,
|
||||
/// Width of the smoothing window, in metres.
|
||||
pub window_m: f64,
|
||||
pub min_gradient_pct: f32,
|
||||
pub max_gradient_pct: f32,
|
||||
}
|
||||
|
||||
impl Default for SmoothingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
resample_m: 10.0,
|
||||
window_m: 100.0,
|
||||
min_gradient_pct: -10.0,
|
||||
max_gradient_pct: 15.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GpxError {
|
||||
#[error("malformed GPX: {0}")]
|
||||
Malformed(String),
|
||||
#[error("GPX contains no track points with elevation")]
|
||||
NoElevation,
|
||||
#[error("GPX track is too short to derive a gradient profile")]
|
||||
TooShort,
|
||||
}
|
||||
|
||||
/// Parse the track points out of a GPX document.
|
||||
///
|
||||
/// Must tolerate real-world GPX: `<trk>/<trkseg>/<trkpt>` and `<rte>/<rtept>`,
|
||||
/// missing `<ele>` on some points, multiple segments, and namespaced documents.
|
||||
pub fn parse(xml: &str) -> Result<Vec<TrackPoint>, GpxError> {
|
||||
let _ = xml;
|
||||
todo!("implemented in crates/core/src/gpx.rs — see AGENT task A")
|
||||
}
|
||||
|
||||
/// Great-circle distance between two points, in metres.
|
||||
pub fn haversine_m(a: TrackPoint, b: TrackPoint) -> f64 {
|
||||
let _ = (a, b);
|
||||
todo!("implemented in crates/core/src/gpx.rs — see AGENT task A")
|
||||
}
|
||||
|
||||
/// Turn track points into a smoothed, clamped gradient profile.
|
||||
pub fn to_terrain(
|
||||
points: &[TrackPoint],
|
||||
cfg: &SmoothingConfig,
|
||||
) -> Result<Vec<TerrainPoint>, GpxError> {
|
||||
let _ = (points, cfg);
|
||||
todo!("implemented in crates/core/src/gpx.rs — see AGENT task A")
|
||||
}
|
||||
|
||||
/// Convenience: GPX document to a ready-to-ride single-block profile.
|
||||
pub fn import(xml: &str, name: &str, cfg: &SmoothingConfig) -> Result<Profile, GpxError> {
|
||||
let _ = (xml, name, cfg);
|
||||
todo!("implemented in crates/core/src/gpx.rs — see AGENT task A")
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Pure ride logic: physics, profiles, GPX import and session state.
|
||||
//!
|
||||
//! This crate has no I/O and no platform dependencies (NFR-5). Everything here
|
||||
//! is unit-testable with synthetic telemetry, and it must stay that way — BLE
|
||||
//! lives in `bikecontrol-ble`, file writing in `bikecontrol-fit`.
|
||||
|
||||
pub mod gpx;
|
||||
pub mod physics;
|
||||
pub mod profile;
|
||||
pub mod session;
|
||||
pub mod types;
|
||||
|
||||
pub use profile::{Block, Channel, Extent, Profile, Segment, Waveform};
|
||||
pub use session::{RideSession, SessionEvent};
|
||||
pub use types::{
|
||||
ConnectionState, ControlMode, ControlTarget, RideSnapshot, RiderConfig, SafetyLimits, Telemetry,
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Virtual speed from measured power (§5.7 of REQUIREMENTS.md).
|
||||
//!
|
||||
//! The app owns the physics rather than trusting the trainer's reported speed
|
||||
//! (FR-7.1). This makes ride behaviour reproducible in tests, independent of
|
||||
//! the trainer's internal mass assumptions, and is a prerequisite for virtual
|
||||
//! gearing later.
|
||||
//!
|
||||
//! Per tick:
|
||||
//! ```text
|
||||
//! F_propulsive = (P × drivetrain_efficiency) / max(v, v_min)
|
||||
//! F_gravity = m × g × sin(atan(gradient))
|
||||
//! F_rolling = m × g × Crr × cos(atan(gradient))
|
||||
//! F_aero = ½ × ρ × CdA × v²
|
||||
//! a = (F_propulsive − F_gravity − F_rolling − F_aero) / m
|
||||
//! v += a × Δt (clamped at ≥ 0)
|
||||
//! ```
|
||||
|
||||
use crate::types::RiderConfig;
|
||||
|
||||
pub const GRAVITY: f32 = 9.80665;
|
||||
|
||||
/// Speed floor used to keep `P / v` finite at a standstill. Also the speed
|
||||
/// below which the rider is considered stopped.
|
||||
pub const MIN_SPEED_MPS: f32 = 0.5;
|
||||
|
||||
/// Evolving physical state of the virtual rider.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub struct PhysicsState {
|
||||
/// Virtual speed, metres per second.
|
||||
pub speed_mps: f32,
|
||||
/// Virtual distance travelled, metres.
|
||||
pub distance_m: f64,
|
||||
/// Cumulative elevation gained, metres.
|
||||
pub elevation_gain_m: f32,
|
||||
}
|
||||
|
||||
impl PhysicsState {
|
||||
/// Advance the simulation by `dt` seconds under `power_w` at `gradient_pct`.
|
||||
///
|
||||
/// Must model inertia (FR-7.3) — speed accelerates toward equilibrium
|
||||
/// rather than snapping to it — and must never produce negative speed,
|
||||
/// NaN, or unbounded values for any finite input.
|
||||
pub fn step(&mut self, power_w: f32, gradient_pct: f32, cfg: &RiderConfig, dt: f32) {
|
||||
let _ = (power_w, gradient_pct, cfg, dt);
|
||||
todo!("implemented in crates/core/src/physics.rs — see AGENT task A")
|
||||
}
|
||||
|
||||
pub fn speed_kph(&self) -> f32 {
|
||||
self.speed_mps * 3.6
|
||||
}
|
||||
|
||||
pub fn is_moving(&self) -> bool {
|
||||
self.speed_mps > MIN_SPEED_MPS
|
||||
}
|
||||
}
|
||||
|
||||
/// Steady-state speed for a given power and gradient — the speed at which
|
||||
/// propulsive and resistive forces balance. Useful for tests and for sanity
|
||||
/// checks on the resistance curve later.
|
||||
pub fn equilibrium_speed_mps(power_w: f32, gradient_pct: f32, cfg: &RiderConfig) -> f32 {
|
||||
let _ = (power_w, gradient_pct, cfg);
|
||||
todo!("implemented in crates/core/src/physics.rs — see AGENT task A")
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Ride profiles: terrain segments, synthetic waveforms, and the file format
|
||||
//! that carries both (§5.5, §5.6 of REQUIREMENTS.md).
|
||||
//!
|
||||
//! A profile is an ordered list of blocks. Each block drives one channel
|
||||
//! (gradient, resistance or power) for either a duration or a distance. The
|
||||
//! engine asks the profile for a target given elapsed time and distance
|
||||
//! travelled, and the profile decides which block is active and what it wants.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::ControlTarget;
|
||||
|
||||
/// Which trainer parameter a block drives (FR-6.2).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Channel {
|
||||
Gradient,
|
||||
Resistance,
|
||||
Power,
|
||||
}
|
||||
|
||||
/// Waveform shapes (FR-6.1). All are evaluated as a function of phase in
|
||||
/// `[0, 1)` and produce a value in `[-1, 1]`, which the block then scales by
|
||||
/// amplitude and offsets by midpoint.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Waveform {
|
||||
Sine,
|
||||
Square,
|
||||
Triangle,
|
||||
Sawtooth,
|
||||
}
|
||||
|
||||
impl Waveform {
|
||||
/// Evaluate at `phase` in `[0, 1)`, returning `[-1, 1]`.
|
||||
pub fn eval(self, phase: f32) -> f32 {
|
||||
let p = phase.rem_euclid(1.0);
|
||||
match self {
|
||||
Waveform::Sine => (p * std::f32::consts::TAU).sin(),
|
||||
Waveform::Square => {
|
||||
if p < 0.5 {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
}
|
||||
Waveform::Triangle => {
|
||||
// Rises 0→1 over the first quarter, falls 1→-1, returns to 0.
|
||||
4.0 * (p - (p + 0.25).floor()).abs() - 1.0
|
||||
}
|
||||
Waveform::Sawtooth => 2.0 * p - 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a block measures its own extent — by time or by distance (FR-6.4).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Extent {
|
||||
Seconds(f64),
|
||||
Metres(f64),
|
||||
}
|
||||
|
||||
/// A single terrain segment: hold a gradient for a distance (FR-5.4).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Segment {
|
||||
pub distance_m: f64,
|
||||
pub gradient_pct: f32,
|
||||
}
|
||||
|
||||
/// One block of a profile.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum Block {
|
||||
/// Hold a fixed value.
|
||||
Constant {
|
||||
channel: Channel,
|
||||
value: f32,
|
||||
extent: Extent,
|
||||
},
|
||||
/// Linear sweep between two values.
|
||||
Ramp {
|
||||
channel: Channel,
|
||||
from: f32,
|
||||
to: f32,
|
||||
extent: Extent,
|
||||
},
|
||||
/// Oscillate around a midpoint.
|
||||
Wave {
|
||||
channel: Channel,
|
||||
shape: Waveform,
|
||||
midpoint: f32,
|
||||
amplitude: f32,
|
||||
/// Length of one full cycle.
|
||||
period: Extent,
|
||||
/// Number of cycles. Total extent = period × repeats.
|
||||
repeats: f32,
|
||||
/// Phase offset in `[0, 1)`.
|
||||
#[serde(default)]
|
||||
phase: f32,
|
||||
},
|
||||
/// A terrain profile: gradient as a function of distance, interpolated
|
||||
/// between points (FR-5.5).
|
||||
Segments { segments: Vec<Segment> },
|
||||
/// A gradient/distance profile derived from a GPX import. Points are
|
||||
/// cumulative distance in metres paired with gradient in percent.
|
||||
Terrain { points: Vec<TerrainPoint> },
|
||||
}
|
||||
|
||||
/// One point of an elevation-derived gradient profile.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TerrainPoint {
|
||||
pub distance_m: f64,
|
||||
pub gradient_pct: f32,
|
||||
/// Elevation in metres, retained for display of the profile chart.
|
||||
pub elevation_m: f32,
|
||||
}
|
||||
|
||||
/// A complete ride profile.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Profile {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
pub blocks: Vec<Block>,
|
||||
/// Restart from the beginning on completion (FR-5.6).
|
||||
#[serde(default)]
|
||||
pub looping: bool,
|
||||
}
|
||||
|
||||
/// Where the rider currently is within a profile.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Position {
|
||||
pub elapsed_s: f64,
|
||||
pub distance_m: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ProfileError {
|
||||
#[error("failed to parse profile: {0}")]
|
||||
Parse(String),
|
||||
#[error("profile has no blocks")]
|
||||
Empty,
|
||||
#[error("block {index} is invalid: {reason}")]
|
||||
InvalidBlock { index: usize, reason: String },
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
/// Parse a profile from YAML.
|
||||
pub fn from_yaml(src: &str) -> Result<Self, ProfileError> {
|
||||
let profile: Profile =
|
||||
serde_yaml_ng::from_str(src).map_err(|e| ProfileError::Parse(e.to_string()))?;
|
||||
profile.validate()?;
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
/// Reject profiles that cannot be evaluated, so failures surface at load
|
||||
/// time rather than mid-ride.
|
||||
pub fn validate(&self) -> Result<(), ProfileError> {
|
||||
if self.blocks.is_empty() {
|
||||
return Err(ProfileError::Empty);
|
||||
}
|
||||
for (index, block) in self.blocks.iter().enumerate() {
|
||||
block
|
||||
.validate()
|
||||
.map_err(|reason| ProfileError::InvalidBlock { index, reason })?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The target this profile wants at `position`, or `None` if the profile
|
||||
/// has finished and is not looping.
|
||||
///
|
||||
/// Implementations must clamp nothing here — safety clamping happens once,
|
||||
/// at transmission (SAF-3).
|
||||
pub fn sample(&self, position: Position) -> Option<ControlTarget> {
|
||||
let _ = position;
|
||||
todo!("implemented in crates/core/src/profile.rs — see AGENT task A")
|
||||
}
|
||||
|
||||
/// Total extent of the profile, if finite. Used for progress display
|
||||
/// (FR-9.7) and to know when a non-looping profile has ended.
|
||||
pub fn total_extent(&self) -> ProfileExtent {
|
||||
todo!("implemented in crates/core/src/profile.rs — see AGENT task A")
|
||||
}
|
||||
|
||||
/// Sample the whole profile ahead of time for the preview chart (FR-6.7).
|
||||
/// Returns `(x, value)` pairs where `x` is seconds or metres depending on
|
||||
/// the profile's dominant extent kind.
|
||||
pub fn preview(&self, samples: usize) -> Vec<(f64, f32)> {
|
||||
let _ = samples;
|
||||
todo!("implemented in crates/core/src/profile.rs — see AGENT task A")
|
||||
}
|
||||
}
|
||||
|
||||
/// Total length of a profile, which may be measured in time, distance, both or
|
||||
/// neither (a profile of only unbounded blocks).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub struct ProfileExtent {
|
||||
pub seconds: Option<f64>,
|
||||
pub metres: Option<f64>,
|
||||
}
|
||||
|
||||
impl Block {
|
||||
pub fn channel(&self) -> Channel {
|
||||
match self {
|
||||
Block::Constant { channel, .. }
|
||||
| Block::Ramp { channel, .. }
|
||||
| Block::Wave { channel, .. } => *channel,
|
||||
Block::Segments { .. } | Block::Terrain { .. } => Channel::Gradient,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
match self {
|
||||
Block::Wave { repeats, period, .. } => {
|
||||
if *repeats <= 0.0 {
|
||||
return Err("repeats must be positive".into());
|
||||
}
|
||||
match period {
|
||||
Extent::Seconds(s) if *s <= 0.0 => Err("period must be positive".into()),
|
||||
Extent::Metres(m) if *m <= 0.0 => Err("period must be positive".into()),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
Block::Segments { segments } if segments.is_empty() => {
|
||||
Err("segments block is empty".into())
|
||||
}
|
||||
Block::Terrain { points } if points.len() < 2 => {
|
||||
Err("terrain block needs at least two points".into())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! 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,
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
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;
|
||||
}
|
||||
|
||||
/// 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 _ = (telemetry, dt_s);
|
||||
todo!("implemented in crates/core/src/session.rs — see AGENT task A")
|
||||
}
|
||||
|
||||
/// 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")
|
||||
}
|
||||
|
||||
/// 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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Shared types. This module is the contract between the BLE layer, the ride
|
||||
//! engine, the recorder and the UI. Change it deliberately.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One telemetry sample decoded from the trainer's Indoor Bike Data
|
||||
/// characteristic. Every field is optional because FTMS packets are
|
||||
/// variable-length — presence is driven by the leading flags bitfield, and a
|
||||
/// given trainer may never send some of them.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Telemetry {
|
||||
/// Milliseconds since the ride started.
|
||||
pub elapsed_ms: u64,
|
||||
pub power_w: Option<i16>,
|
||||
pub cadence_rpm: Option<f32>,
|
||||
/// Trainer-reported speed. Diagnostic only — the ride engine computes its
|
||||
/// own virtual speed from power (FR-7.1, FR-7.5).
|
||||
pub speed_kph: Option<f32>,
|
||||
pub resistance_level: Option<i16>,
|
||||
pub heart_rate_bpm: Option<u8>,
|
||||
pub total_distance_m: Option<u32>,
|
||||
pub total_energy_kcal: Option<u16>,
|
||||
}
|
||||
|
||||
/// A command to the trainer. Which variant is used depends on the active
|
||||
/// [`ControlMode`] and on what the trainer actually supports (FR-2.6).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ControlTarget {
|
||||
/// Simulated gradient, in percent. Positive is uphill.
|
||||
Gradient { percent: f32 },
|
||||
/// Raw trainer resistance level, in the trainer's own units.
|
||||
Resistance { level: i16 },
|
||||
/// Target power in watts (ERG-style).
|
||||
Power { watts: u16 },
|
||||
}
|
||||
|
||||
/// Hard limits applied at the point of transmission, regardless of where the
|
||||
/// target came from (SAF-3, SAF-6). A profile with absurd parameters must not
|
||||
/// be able to command an unsafe target.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SafetyLimits {
|
||||
pub min_gradient_pct: f32,
|
||||
pub max_gradient_pct: f32,
|
||||
pub min_resistance: i16,
|
||||
pub max_resistance: i16,
|
||||
pub min_power_w: u16,
|
||||
pub max_power_w: u16,
|
||||
}
|
||||
|
||||
impl Default for SafetyLimits {
|
||||
fn default() -> Self {
|
||||
// Gradient range per FR-5.3; power range per the D100 reference
|
||||
// implementation (§3.2 of REQUIREMENTS.md).
|
||||
Self {
|
||||
min_gradient_pct: -10.0,
|
||||
max_gradient_pct: 15.0,
|
||||
min_resistance: 0,
|
||||
max_resistance: 100,
|
||||
min_power_w: 50,
|
||||
max_power_w: 600,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SafetyLimits {
|
||||
/// Clamp a target into the safe range. Every path to the trainer must go
|
||||
/// through this.
|
||||
pub fn clamp(&self, target: ControlTarget) -> ControlTarget {
|
||||
match target {
|
||||
ControlTarget::Gradient { percent } => ControlTarget::Gradient {
|
||||
percent: percent.clamp(self.min_gradient_pct, self.max_gradient_pct),
|
||||
},
|
||||
ControlTarget::Resistance { level } => ControlTarget::Resistance {
|
||||
level: level.clamp(self.min_resistance, self.max_resistance),
|
||||
},
|
||||
ControlTarget::Power { watts } => ControlTarget::Power {
|
||||
watts: watts.clamp(self.min_power_w, self.max_power_w),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the base target comes from (§5.4). Note that in the full design
|
||||
/// gearing and gradient are simultaneously active; mode selects the *source* of
|
||||
/// the base gradient, not whether shifting works.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ControlMode {
|
||||
/// Rider sets gradient directly; no profile running.
|
||||
ManualGrade,
|
||||
/// Rider sets raw resistance; physics ignored.
|
||||
Resistance,
|
||||
/// Gradient driven by a loaded profile at the current distance/time.
|
||||
Profile,
|
||||
/// Fixed target power.
|
||||
Erg,
|
||||
}
|
||||
|
||||
/// Rider and bike parameters feeding the physics model (FR-7.4).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RiderConfig {
|
||||
pub rider_kg: f32,
|
||||
pub bike_kg: f32,
|
||||
/// Coefficient of rolling resistance.
|
||||
pub crr: f32,
|
||||
/// Drag coefficient × frontal area, m².
|
||||
pub cda: f32,
|
||||
/// Fraction of measured power reaching the wheel.
|
||||
pub drivetrain_efficiency: f32,
|
||||
/// Air density, kg/m³.
|
||||
pub air_density: f32,
|
||||
pub wheel_circumference_m: f32,
|
||||
}
|
||||
|
||||
impl Default for RiderConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
rider_kg: 75.0,
|
||||
bike_kg: 8.0,
|
||||
crr: 0.004,
|
||||
cda: 0.32,
|
||||
drivetrain_efficiency: 0.97,
|
||||
air_density: 1.225,
|
||||
wheel_circumference_m: 2.105,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RiderConfig {
|
||||
pub fn total_mass_kg(&self) -> f32 {
|
||||
self.rider_kg + self.bike_kg
|
||||
}
|
||||
}
|
||||
|
||||
/// A snapshot of the ride, pushed to the UI each tick. This is what the
|
||||
/// frontend renders; it should contain everything the ride screen needs and
|
||||
/// nothing it does not.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RideSnapshot {
|
||||
pub elapsed_ms: u64,
|
||||
pub telemetry: Telemetry,
|
||||
/// Virtual speed computed by the physics engine, km/h.
|
||||
pub virtual_speed_kph: f32,
|
||||
/// Virtual distance travelled, metres.
|
||||
pub virtual_distance_m: f64,
|
||||
/// Gradient currently commanded, percent.
|
||||
pub gradient_pct: f32,
|
||||
/// Cumulative elevation gained, metres.
|
||||
pub elevation_gain_m: f32,
|
||||
pub mode: ControlMode,
|
||||
/// The target most recently sent to the trainer, post-clamp.
|
||||
pub target: Option<ControlTarget>,
|
||||
/// Fractional progress through the loaded profile, 0.0–1.0, if one is
|
||||
/// loaded and has finite length.
|
||||
pub profile_progress: Option<f32>,
|
||||
}
|
||||
|
||||
/// Connection state for a single BLE peripheral (FR-1.7).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ConnectionState {
|
||||
Idle,
|
||||
Scanning,
|
||||
Connecting,
|
||||
/// Connected at the BLE level but control not yet acquired. For a trainer,
|
||||
/// connected ≠ controllable (FR-9.3).
|
||||
Connected,
|
||||
/// FTMS control point acquired; commands will be accepted.
|
||||
Controlling,
|
||||
Reconnecting,
|
||||
Lost { reason: String },
|
||||
}
|
||||
Reference in New Issue
Block a user