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
+291
View File
@@ -0,0 +1,291 @@
//! Derived ride figures: ETA, distance remaining, rolling averages.
//!
//! All of this is computed in Rust, not the frontend (§4.3). `RideSnapshot` is
//! a frozen contract in `crates/core` and does not carry any of it, so it rides
//! alongside the snapshot in a [`RideFrame`].
//!
//! **ETA (FR-9.15).** The rule that matters: never derive it from
//! instantaneous speed. Trainer speed swings several km/h between samples and
//! an ETA computed from it flickers uselessly. Three cases:
//!
//! * **Time-based profile** — remaining time is *known*. No estimation.
//! * **Distance-based profile** — remaining distance over a 45-second rolling
//! mean speed. When the rider stops, the last good ETA is *held* rather than
//! diverging to infinity, and flagged as held so the UI can dim it.
//! * **Looping profile** — no finish exists. Report lap position instead of a
//! number that would be a lie.
use std::collections::VecDeque;
use bikecontrol_core::profile::Profile;
use bikecontrol_core::types::RideSnapshot;
use serde::Serialize;
use crate::profile_view::{self, ProfileGeometry, XUnit};
/// Rolling mean window for the speed that feeds ETA. Long enough to survive a
/// soft-pedal over a rise, short enough to react to a real change of pace.
const SPEED_WINDOW_S: f64 = 45.0;
/// Rolling mean window for the displayed power (FR-9.11).
pub const POWER_WINDOW_S: f64 = 10.0;
/// Window for the normalised-power rolling mean (§12 glossary).
const NP_WINDOW_S: f64 = 30.0;
/// Below this the rider is not really moving; hold the last ETA.
const MIN_ETA_SPEED_KPH: f32 = 2.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum EtaKind {
/// Time-based profile: the remaining time is exact, not estimated.
Exact,
/// Distance-based: remaining distance over smoothed speed.
Estimated,
/// Rider has stopped; showing the last good estimate.
Held,
/// Looping profile — there is no finish.
Looping,
/// No profile, or one with no finite extent.
Unavailable,
}
/// Everything the ride screen shows that is not in the frozen `RideSnapshot`.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Derived {
// --- route (the primary readouts) ---------------------------------------
pub eta_kind: EtaKind,
/// Seconds to the finish. `None` when unavailable or looping.
pub time_remaining_s: Option<f64>,
pub distance_total_m: Option<f64>,
pub distance_remaining_m: Option<f64>,
/// Current altitude on the route, metres.
pub elevation_m: Option<f32>,
pub ascent_remaining_m: Option<f32>,
/// Position on the profile's own axis (seconds or metres).
pub position_x: f64,
pub axis_unit: XUnit,
pub axis_total: f64,
/// Which lap of a looping profile, 1-based.
pub loop_index: Option<u32>,
// --- motion --------------------------------------------------------------
/// 45-second rolling mean. This is what drives ETA; it is also the honest
/// number to show a rider, because instantaneous speed is noise.
pub smoothed_speed_kph: f32,
// --- effort (secondary) ---------------------------------------------------
/// Rolling mean power over [`POWER_WINDOW_S`] (FR-9.11).
pub rolling_power_w: f32,
pub rolling_power_window_s: f64,
pub avg_power_w: f32,
pub max_power_w: i16,
pub normalised_power_w: Option<f32>,
pub avg_cadence_rpm: f32,
pub energy_kj: f32,
}
/// Rolling windows. One instance lives in the app state for the whole ride.
pub struct Deriver {
speed: VecDeque<(f64, f32)>,
power: VecDeque<(f64, f32)>,
np: VecDeque<(f64, f32)>,
np_fourth_sum: f64,
np_n: u64,
power_sum: f64,
power_n: u64,
cadence_sum: f64,
cadence_n: u64,
max_power_w: i16,
energy_kj: f32,
last_elapsed_s: f64,
/// Last ETA that was computed from real movement (FR-9.15, hold-on-stop).
last_eta_s: Option<f64>,
}
impl Default for Deriver {
fn default() -> Self {
Self {
speed: VecDeque::new(),
power: VecDeque::new(),
np: VecDeque::new(),
np_fourth_sum: 0.0,
np_n: 0,
power_sum: 0.0,
power_n: 0,
cadence_sum: 0.0,
cadence_n: 0,
max_power_w: 0,
energy_kj: 0.0,
last_elapsed_s: 0.0,
last_eta_s: None,
}
}
}
fn push_window(window: &mut VecDeque<(f64, f32)>, t: f64, v: f32, span: f64) {
window.push_back((t, v));
while let Some((t0, _)) = window.front() {
if t - t0 > span {
window.pop_front();
} else {
break;
}
}
}
fn mean(window: &VecDeque<(f64, f32)>) -> f32 {
if window.is_empty() {
return 0.0;
}
window.iter().map(|(_, v)| *v as f64).sum::<f64>() as f32 / window.len() as f32
}
impl Deriver {
pub fn reset(&mut self) {
*self = Self::default();
}
/// Fold one snapshot in and produce the derived figures.
pub fn update(
&mut self,
snapshot: &RideSnapshot,
running: bool,
profile: Option<&Profile>,
geom: Option<&ProfileGeometry>,
) -> Derived {
let t = snapshot.elapsed_ms as f64 / 1000.0;
let dt = (t - self.last_elapsed_s).max(0.0);
self.last_elapsed_s = t;
let power = snapshot.telemetry.power_w.unwrap_or(0) as f32;
let cadence = snapshot.telemetry.cadence_rpm.unwrap_or(0.0);
push_window(&mut self.speed, t, snapshot.virtual_speed_kph, SPEED_WINDOW_S);
push_window(&mut self.power, t, power, POWER_WINDOW_S);
push_window(&mut self.np, t, power, NP_WINDOW_S);
if running {
self.power_sum += power as f64;
self.power_n += 1;
self.max_power_w = self.max_power_w.max(power as i16);
if cadence > 1.0 {
self.cadence_sum += cadence as f64;
self.cadence_n += 1;
}
self.energy_kj += power * dt as f32 / 1000.0;
// Normalised power: 30 s rolling mean, raised to the fourth,
// averaged, fourth root.
let rolling = mean(&self.np) as f64;
self.np_fourth_sum += rolling.powi(4);
self.np_n += 1;
}
let smoothed_speed_kph = mean(&self.speed);
// ---- route position and ETA ----------------------------------------
let mut eta_kind = EtaKind::Unavailable;
let mut time_remaining_s = None;
let mut distance_total_m = None;
let mut distance_remaining_m = None;
let mut elevation_m = None;
let mut ascent_remaining_m = None;
let mut loop_index = None;
let mut position_x = 0.0;
let mut axis_unit = XUnit::Seconds;
let mut axis_total = 0.0;
if let (Some(profile), Some(geom)) = (profile, geom) {
let elapsed_s = t;
let distance_m = snapshot.virtual_distance_m;
position_x = profile_view::position_x(geom, elapsed_s, distance_m);
axis_unit = geom.x_unit;
axis_total = geom.total_x;
elevation_m = geom.elevation_at(position_x);
ascent_remaining_m = geom.ascent_remaining(position_x);
distance_total_m = geom.total_metres;
if profile.looping {
eta_kind = EtaKind::Looping;
if geom.total_x > 0.0 {
let laps = match geom.x_unit {
XUnit::Metres => distance_m / geom.total_x,
XUnit::Seconds => elapsed_s / geom.total_x,
};
loop_index = Some(laps.floor() as u32 + 1);
}
if let Some(total) = geom.total_metres {
distance_remaining_m = Some((total - position_x).max(0.0));
}
} else {
match (geom.total_seconds, geom.total_metres) {
// Time-based: remaining time is known exactly.
(Some(total_s), None) => {
eta_kind = EtaKind::Exact;
time_remaining_s = Some((total_s - elapsed_s).max(0.0));
}
// Distance-based (or mixed): estimate from smoothed speed.
(_, Some(total_m)) => {
let remaining = (total_m - distance_m).max(0.0);
distance_remaining_m = Some(remaining);
if smoothed_speed_kph >= MIN_ETA_SPEED_KPH {
let eta = remaining / (smoothed_speed_kph as f64 * 1000.0 / 3600.0);
self.last_eta_s = Some(eta);
eta_kind = EtaKind::Estimated;
time_remaining_s = Some(eta);
} else {
eta_kind = EtaKind::Held;
time_remaining_s = self.last_eta_s;
}
// A mixed profile also has a hard time limit; take
// whichever finishes first.
if let Some(total_s) = geom.total_seconds {
let by_time = (total_s - elapsed_s).max(0.0);
time_remaining_s =
Some(time_remaining_s.map_or(by_time, |e: f64| e.min(by_time)));
}
}
(None, None) => {}
}
}
}
Derived {
eta_kind,
time_remaining_s,
distance_total_m,
distance_remaining_m,
elevation_m,
ascent_remaining_m,
position_x,
axis_unit,
axis_total,
loop_index,
smoothed_speed_kph,
rolling_power_w: mean(&self.power),
rolling_power_window_s: POWER_WINDOW_S,
avg_power_w: if self.power_n == 0 {
0.0
} else {
(self.power_sum / self.power_n as f64) as f32
},
max_power_w: self.max_power_w,
normalised_power_w: (self.np_n > 30)
.then(|| (self.np_fourth_sum / self.np_n as f64).powf(0.25) as f32),
avg_cadence_rpm: if self.cadence_n == 0 {
0.0
} else {
(self.cadence_sum / self.cadence_n as f64) as f32
},
energy_kj: self.energy_kj,
}
}
}
/// What lands on the `ride://snapshot` channel: the frozen core snapshot plus
/// the derived view data that cannot live in it.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RideFrame {
pub snapshot: RideSnapshot,
pub derived: Derived,
}