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
+330
View File
@@ -0,0 +1,330 @@
//! The route, as the ride screen needs to draw it.
//!
//! `crates/core` owns profile *semantics* — `Profile::sample`,
//! `Profile::preview`, `Profile::total_extent`. This module owns the *view
//! model*: the elevation trace, the block breakdown, and the geometry needed to
//! place the current-position marker and answer "how much climbing is left".
//!
//! The route is the hero element of the ride screen, so this is the payload
//! that matters most.
use bikecontrol_core::profile::{
Block, Channel, Extent, Position, Profile, Waveform,
};
use serde::Serialize;
/// Which axis the profile is drawn against.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum XUnit {
#[default]
Seconds,
Metres,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BlockSummary {
pub index: usize,
/// `constant` | `ramp` | `wave` | `segments` | `terrain`
pub kind: &'static str,
pub channel: Channel,
pub label: String,
pub start_x: f64,
pub end_x: f64,
pub unit: XUnit,
}
/// Everything the UI needs to draw a profile (FR-6.7, FR-9.7).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileView {
pub name: String,
pub description: Option<String>,
pub looping: bool,
/// Where it came from: a file path, a sample name, or `"editor"`.
pub source: String,
/// The channel the value series plots.
pub channel: Channel,
pub x_unit: XUnit,
pub total_x: f64,
/// Total ride duration, if the profile is measured in time.
pub total_seconds: Option<f64>,
/// Total ride distance, if the profile is measured in distance.
pub total_metres: Option<f64>,
/// `[x, value]` along the axis — gradient %, watts or resistance level.
pub series: Vec<[f64; 2]>,
/// `[distance_m, elevation_m]`. Real elevation for GPX-derived terrain,
/// integrated from gradient otherwise. This is the hero chart.
pub elevation: Option<Vec<[f64; 2]>>,
pub elevation_min_m: Option<f32>,
pub elevation_max_m: Option<f32>,
pub total_ascent_m: Option<f32>,
pub blocks: Vec<BlockSummary>,
/// The profile as YAML, for the in-app editor.
pub yaml: String,
}
/// Precomputed geometry kept Rust-side so per-tick lookups are cheap. Never
/// serialised — the frontend gets answers, not arrays to search.
#[derive(Debug, Clone, Default)]
pub struct ProfileGeometry {
pub xs: Vec<f64>,
pub elevation: Vec<f32>,
/// Cumulative ascent at each sample, so "climbing remaining" is a
/// subtraction rather than a scan.
pub cum_ascent: Vec<f32>,
pub total_x: f64,
pub x_unit: XUnit,
pub looping: bool,
pub total_seconds: Option<f64>,
pub total_metres: Option<f64>,
}
impl ProfileGeometry {
/// Elevation at a position on the axis, linearly interpolated.
pub fn elevation_at(&self, x: f64) -> Option<f32> {
interp(&self.xs, &self.elevation, x)
}
/// Metres of climbing still to come from `x` to the end.
pub fn ascent_remaining(&self, x: f64) -> Option<f32> {
let total = *self.cum_ascent.last()?;
let done = interp(&self.xs, &self.cum_ascent, x)?;
Some((total - done).max(0.0))
}
pub fn total_ascent(&self) -> Option<f32> {
self.cum_ascent.last().copied()
}
}
fn interp(xs: &[f64], ys: &[f32], x: f64) -> Option<f32> {
if xs.is_empty() || xs.len() != ys.len() {
return None;
}
if x <= xs[0] {
return Some(ys[0]);
}
let last = xs.len() - 1;
if x >= xs[last] {
return Some(ys[last]);
}
let i = xs.partition_point(|v| *v <= x).clamp(1, last);
let (x0, x1) = (xs[i - 1], xs[i]);
let (y0, y1) = (ys[i - 1], ys[i]);
let span = x1 - x0;
Some(if span.abs() < f64::EPSILON { y1 } else { y0 + (y1 - y0) * ((x - x0) / span) as f32 })
}
const PREVIEW_SAMPLES: usize = 1400;
fn extent_parts(extent: Extent) -> (f64, XUnit) {
match extent {
Extent::Seconds(s) => (s.max(0.0), XUnit::Seconds),
Extent::Metres(m) => (m.max(0.0), XUnit::Metres),
}
}
/// Build the view model and the geometry that goes with it.
pub fn build(profile: &Profile, source: impl Into<String>) -> (ProfileView, ProfileGeometry) {
let extent = profile.total_extent();
let x_unit = match (extent.metres, extent.seconds) {
(Some(m), Some(s)) => {
if m >= s {
XUnit::Metres
} else {
XUnit::Seconds
}
}
(Some(_), None) => XUnit::Metres,
_ => XUnit::Seconds,
};
let preview = profile.preview(PREVIEW_SAMPLES);
let series: Vec<[f64; 2]> = preview.iter().map(|(x, v)| [*x, *v as f64]).collect();
let total_x = series.last().map(|p| p[0]).unwrap_or(0.0);
let channel = profile.blocks.first().map(|b| b.channel()).unwrap_or(Channel::Gradient);
// Elevation. Prefer the real thing: a GPX import lands as a `Terrain`
// block that already carries surveyed elevation. Otherwise integrate the
// gradient, which is what a hand-authored segment profile implies anyway.
let mut geom = ProfileGeometry {
total_x,
x_unit,
looping: profile.looping,
total_seconds: extent.seconds,
total_metres: extent.metres,
..Default::default()
};
let elevation: Option<Vec<[f64; 2]>> = if channel == Channel::Gradient {
let surveyed = surveyed_elevation(profile);
let pairs = match surveyed {
Some(points) => points,
None if x_unit == XUnit::Metres => integrate_gradient(&series),
None => Vec::new(),
};
if pairs.len() < 2 {
None
} else {
geom.xs = pairs.iter().map(|p| p[0]).collect();
geom.elevation = pairs.iter().map(|p| p[1] as f32).collect();
let mut cum = Vec::with_capacity(geom.elevation.len());
let mut acc = 0.0f32;
let mut prev = geom.elevation[0];
for e in &geom.elevation {
acc += (e - prev).max(0.0);
prev = *e;
cum.push(acc);
}
geom.cum_ascent = cum;
Some(pairs)
}
} else {
None
};
let (elevation_min_m, elevation_max_m) = match &geom.elevation {
e if e.is_empty() => (None, None),
e => (
Some(e.iter().copied().fold(f32::INFINITY, f32::min)),
Some(e.iter().copied().fold(f32::NEG_INFINITY, f32::max)),
),
};
let mut blocks = Vec::with_capacity(profile.blocks.len());
let mut cursor = 0.0f64;
for (index, block) in profile.blocks.iter().enumerate() {
let (span, unit) = extent_parts(block.extent());
blocks.push(BlockSummary {
index,
kind: block_kind(block),
channel: block.channel(),
label: block_label(block),
start_x: cursor,
end_x: cursor + span,
unit,
});
cursor += span;
}
let view = ProfileView {
name: profile.name.clone(),
description: profile.description.clone(),
looping: profile.looping,
source: source.into(),
channel,
x_unit,
total_x,
total_seconds: extent.seconds,
total_metres: extent.metres,
series,
elevation,
elevation_min_m,
elevation_max_m,
total_ascent_m: geom.total_ascent(),
blocks,
yaml: serde_yaml_ng::to_string(profile).unwrap_or_default(),
};
(view, geom)
}
/// Elevation straight out of `Terrain` blocks, offset so consecutive blocks
/// join up rather than each restarting at zero distance.
fn surveyed_elevation(profile: &Profile) -> Option<Vec<[f64; 2]>> {
let mut out: Vec<[f64; 2]> = Vec::new();
let mut offset = 0.0f64;
let mut any = false;
for block in &profile.blocks {
let (span, _) = extent_parts(block.extent());
if let Block::Terrain { points } = block {
any = true;
let base = points.first().map(|p| p.distance_m).unwrap_or(0.0);
for p in points {
out.push([offset + (p.distance_m - base), p.elevation_m as f64]);
}
}
offset += span;
}
any.then_some(out)
}
/// Integrate gradient over distance to get a relative elevation trace.
fn integrate_gradient(series: &[[f64; 2]]) -> Vec<[f64; 2]> {
let mut elev = 0.0f64;
let mut prev_x = series.first().map(|p| p[0]).unwrap_or(0.0);
series
.iter()
.map(|[x, grade]| {
elev += (x - prev_x).max(0.0) * (grade / 100.0);
prev_x = *x;
[*x, elev]
})
.collect()
}
/// Where the rider is on the preview axis right now.
pub fn position_x(geom: &ProfileGeometry, elapsed_s: f64, distance_m: f64) -> f64 {
let raw = match geom.x_unit {
XUnit::Seconds => elapsed_s,
XUnit::Metres => distance_m,
};
if geom.looping && geom.total_x > 0.0 {
raw.rem_euclid(geom.total_x)
} else {
raw.clamp(0.0, geom.total_x.max(0.0))
}
}
/// Convenience wrapper so callers do not have to build a `Position`.
pub fn position(elapsed_s: f64, distance_m: f64) -> Position {
Position { elapsed_s, distance_m }
}
fn block_kind(block: &Block) -> &'static str {
match block {
Block::Constant { .. } => "constant",
Block::Ramp { .. } => "ramp",
Block::Wave { .. } => "wave",
Block::Segments { .. } => "segments",
Block::Terrain { .. } => "terrain",
}
}
fn unit_suffix(channel: Channel) -> &'static str {
match channel {
Channel::Gradient => "%",
Channel::Resistance => "",
Channel::Power => " W",
}
}
fn block_label(block: &Block) -> String {
let u = unit_suffix(block.channel());
match block {
Block::Constant { value, .. } => format!("hold {value:.0}{u}"),
Block::Ramp { from, to, .. } => format!("ramp {from:.0}{u}{to:.0}{u}"),
Block::Wave { shape, midpoint, amplitude, repeats, .. } => format!(
"{} {:.0}{u} ±{:.0}{u} ×{:.0}",
match shape {
Waveform::Sine => "sine",
Waveform::Square => "square",
Waveform::Triangle => "triangle",
Waveform::Sawtooth => "sawtooth",
},
midpoint,
amplitude,
repeats
),
Block::Segments { segments } => {
let d: f64 = segments.iter().map(|s| s.distance_m).sum();
format!("{} segments · {:.1} km", segments.len(), d / 1000.0)
}
Block::Terrain { points } => {
let d = points.last().map(|p| p.distance_m).unwrap_or(0.0);
format!("terrain · {:.1} km", d / 1000.0)
}
}
}