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:
2026-08-05 13:11:32 +02:00
co-authored by Claude Opus 5
commit eb216dd9e6
21 changed files with 2910 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "bikecontrol-core"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde = { workspace = true }
serde_yaml_ng = { workspace = true }
thiserror = { workspace = true }
roxmltree = { workspace = true }
[dev-dependencies]
approx = "0.5"
+80
View File
@@ -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")
}
+17
View File
@@ -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,
};
+63
View File
@@ -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")
}
+235
View File
@@ -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(()),
}
}
}
+117
View File
@@ -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")
}
}
+170
View File
@@ -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.01.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 },
}