Files
BikeControl/src-tauri/src/derive.rs
T
dtourolleandClaude Opus 5 7b511db3dc Ride the drivetrain, command the load in watts
Speed now comes from the drivetrain and the load from the road, which is
the way round a bike actually works.

Speed is cadence x development, filtered lightly. Power, not cadence,
decides whether the rider is driving it: on a direct-drive trainer the
flywheel keeps the cranks turning after they stop, so cadence alone reads
a healthy 80 rpm for someone doing nothing. Below 15 W the speed runs
down to whatever the gradient sustains on no power - zero uphill, a real
freewheeling speed on a descent. Stopping on a 3.5% climb used to settle
at 22 km/h and stay there, because the model wanted to decelerate and a
blend toward the flywheel speed outvoted it; that blend is gone.

The D100 sends no cadence over FTMS - it is a rebadged Magene T110 with
cadence disabled in firmware (qdomyos-zwift#3282) - so it is inferred
from wheel speed, which one sprocket and no freewheel make exact. Its
Zwift channel does carry cadence, and is now greeted with RideOn and
subscribed on every notifying characteristic, so a measured value is used
where one arrives.

The load is commanded as power, not gradient. The trainer declares
50-600 W in 1 W steps against 0-6% inclination in 0.1% steps refusing
negatives, and whether it acts on 0x11 at all is still unconfirmed. Its
power target is a ceiling rather than a setpoint, which is very nearly
what a road is: exceed it and the surplus becomes speed. Gravity travels
on the same channel as watts, so nothing is lost by leaving 0x11 alone.
LoadChannel keeps the gradient path selectable and tested.

Virtual shifting reaches the trainer for the first time. The physics
load model was written but never called, and a paddle press both shifted
a gear in Rust and nudged the gradient in the webview - the shift
silently, the tilt visibly, so the paddles looked like a gradient trim.

Also: a fixed 12 W drivetrain loss, held as a power because that is how
it presents; crank length, so a gear can be reported as the force it puts
under the foot; gear and pedal force on the ride screen; a drag-race
profile for testing gearing on the flat.

Two readout bugs fixed on the way. The rolling windows were trimmed by
timestamp but fed on a fixed timer, so every second spent on the ride
screen before starting pushed samples at t=0 that could never expire -
speed read a fraction of the truth for the first 45 s. And the headline
speed was a 45 s mean, which took most of a minute to show a gear change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:21:08 +02:00

651 lines
24 KiB
Rust

//! 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::energy;
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 speed the rider *reads*.
///
/// Deliberately far shorter than the ETA's. An ETA wants a pace, and averaging
/// three quarters of a minute is right for that. A speedometer wants to answer
/// "what did that do?" — and with virtual gearing a shift changes the speed
/// immediately, so a 45 s mean takes most of a minute to show a change that
/// already happened. Shifting through the whole cassette inside one window
/// averages the lot and reads as the gears doing nothing at all.
const DISPLAY_SPEED_WINDOW_S: f64 = 3.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 coasting to a halt rather than riding, so the ETA
/// stops tracking and holds. Set well above walking pace: an ETA computed from
/// 1 km/h is arithmetically valid and completely useless.
const MIN_ETA_SPEED_KPH: f32 = 5.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,
/// The speed to put on screen: lightly smoothed, so a shift is visible at
/// once. `smoothed_speed_kph` is the ETA's much longer mean and would take
/// most of a minute to show the same change.
pub display_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,
/// Estimated metabolic cost of the ride so far, kcal. See
/// [`bikecontrol_core::energy`] — this is the rider's burn, not the
/// mechanical work in `energy_kj`.
pub calories_kcal: f32,
}
/// Rolling windows. One instance lives in the app state for the whole ride.
pub struct Deriver {
speed: VecDeque<(f64, f32)>,
/// Short window behind the speed on screen; `speed` is the ETA's.
display_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,
/// Seconds the ride has actually been running. Drives the resting-burn
/// half of the calorie estimate, so a paused ride does not accrue.
active_s: f64,
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(),
display_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,
active_s: 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.
///
/// `rider_kg` is the configured rider mass; it only feeds the calorie
/// estimate, and zero simply means the resting term is skipped.
pub fn update(
&mut self,
snapshot: &RideSnapshot,
running: bool,
rider_kg: f32,
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);
// Only fold a sample in when the ride clock actually moved.
//
// These windows are trimmed by timestamp, so a sample taken while the
// clock is frozen can never expire: `t - t0 > span` is `0 > span`. The
// tick loop runs at a fixed 4 Hz whether or not the ride is running,
// and `elapsed_ms` only advances while it is — so every second spent
// sitting on the ride screen before pressing start used to push four
// more zero-speed samples at t = 0 that nothing would ever evict.
//
// The rider then set off and watched the speed read a fraction of their
// real pace, because the mean was still mostly those zeros, and it only
// came right 45 seconds in when the frozen samples finally aged out.
// Rolling power, normalised power and the ETA all had it too.
if dt > 0.0 {
push_window(
&mut self.speed,
t,
snapshot.virtual_speed_kph,
SPEED_WINDOW_S,
);
push_window(
&mut self.display_speed,
t,
snapshot.virtual_speed_kph,
DISPLAY_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;
self.active_s += dt;
// 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);
let display_speed_kph = mean(&self.display_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);
// A paused ride holds too — the clock is not running,
// so neither should the estimate.
if running && 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,
display_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,
calories_kcal: energy::kcal(f64::from(self.energy_kj) * 1000.0, rider_kg, self.active_s)
as f32,
}
}
}
/// 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,
}
#[cfg(test)]
mod tests {
use super::*;
use bikecontrol_core::profile::{Block, Channel, Extent, Segment};
use bikecontrol_core::types::Telemetry;
use crate::profile_view;
/// The default rider mass, so the calorie term is exercised everywhere.
const RIDER_KG: f32 = 75.0;
fn snapshot(elapsed_s: f64, distance_m: f64, speed_kph: f32) -> RideSnapshot {
RideSnapshot {
elapsed_ms: (elapsed_s * 1000.0) as u64,
telemetry: Telemetry {
power_w: Some(200),
..Telemetry::default()
},
virtual_speed_kph: speed_kph,
virtual_distance_m: distance_m,
gradient_pct: 0.0,
elevation_gain_m: 0.0,
gear: 6,
gear_count: 12,
development_m: 5.7,
target_cadence_rpm: 0.0,
speed_source: bikecontrol_core::types::SpeedSource::Drivetrain,
pedal_force_n: 120.0,
mode: bikecontrol_core::types::ControlMode::Profile,
target: None,
profile_progress: None,
}
}
fn timed_profile() -> Profile {
Profile {
name: "timed".into(),
description: None,
looping: false,
blocks: vec![Block::Constant {
channel: Channel::Power,
value: 200.0,
extent: Extent::Seconds(600.0),
}],
}
}
fn distance_profile(looping: bool) -> Profile {
Profile {
name: "distance".into(),
description: None,
looping,
blocks: vec![Block::Segments {
segments: vec![
Segment {
distance_m: 1000.0,
gradient_pct: 4.0,
},
Segment {
distance_m: 1000.0,
gradient_pct: -2.0,
},
],
}],
}
}
/// A time-based profile knows exactly how long is left. No estimation, no
/// dependence on speed.
#[test]
fn time_based_eta_is_exact() {
let profile = timed_profile();
let (_, geom) = profile_view::build(&profile, "test");
let mut d = Deriver::default();
let out = d.update(
&snapshot(120.0, 0.0, 0.0),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
assert_eq!(out.eta_kind, EtaKind::Exact);
assert!((out.time_remaining_s.unwrap() - 480.0).abs() < 1e-6);
}
/// Distance-based ETA uses the smoothed speed, not the instantaneous one.
#[test]
fn distance_based_eta_smooths_speed() {
let profile = distance_profile(false);
let (_, geom) = profile_view::build(&profile, "test");
let mut d = Deriver::default();
// Ride at 36 km/h (10 m/s) until the speed window is full.
let mut t = 0.0;
for i in 1..=(SPEED_WINDOW_S / 0.25) as u32 {
t = i as f64 * 0.25;
d.update(
&snapshot(t, t * 10.0, 36.0),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
}
t += 0.25;
let steady = d.update(
&snapshot(t, t * 10.0, 36.0),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
t += 0.25;
// One absurd sample: 90 km/h, two and a half times reality.
let spike = d.update(
&snapshot(t, t * 10.0, 90.0),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
assert_eq!(spike.eta_kind, EtaKind::Estimated);
let base = steady.time_remaining_s.unwrap();
let drift = (spike.time_remaining_s.unwrap() - base).abs();
assert!(
drift / base < 0.02,
"one noisy sample moved the ETA by {drift:.1}s ({:.1}%)",
drift / base * 100.0
);
}
/// Stopping must hold the last estimate, not diverge to infinity.
#[test]
fn stopping_holds_the_last_eta() {
let profile = distance_profile(false);
let (_, geom) = profile_view::build(&profile, "test");
let mut d = Deriver::default();
for i in 1..=200 {
let t = i as f64 * 0.25;
d.update(
&snapshot(t, t * 8.0, 28.8),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
}
let moving = d.update(
&snapshot(50.25, 402.0, 28.8),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
assert_eq!(moving.eta_kind, EtaKind::Estimated);
// Now stop dead for long enough to flush the whole speed window.
let mut prev = moving;
let mut stopped = moving;
for i in 1..=400 {
let t = 50.25 + i as f64 * 0.25;
prev = stopped;
stopped = d.update(
&snapshot(t, 402.0, 0.0),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
}
// The contract: finite, flagged as held, and no longer changing.
assert_eq!(stopped.eta_kind, EtaKind::Held);
let eta = stopped
.time_remaining_s
.expect("held ETA must still be a number");
assert!(eta.is_finite(), "ETA diverged when the rider stopped");
assert_eq!(
prev.time_remaining_s, stopped.time_remaining_s,
"held ETA still drifting"
);
}
/// Pausing freezes the estimate rather than letting it creep.
#[test]
fn pausing_holds_the_eta() {
let profile = distance_profile(false);
let (_, geom) = profile_view::build(&profile, "test");
let mut d = Deriver::default();
for i in 1..=200 {
let t = i as f64 * 0.25;
d.update(
&snapshot(t, t * 8.0, 28.8),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
}
let paused = d.update(
&snapshot(50.25, 402.0, 28.8),
false,
RIDER_KG,
Some(&profile),
Some(&geom),
);
assert_eq!(paused.eta_kind, EtaKind::Held);
assert!(paused.time_remaining_s.unwrap().is_finite());
}
/// A looping profile has no finish, so it reports a lap, never an ETA.
#[test]
fn looping_profile_reports_lap_not_eta() {
let profile = distance_profile(true);
let (_, geom) = profile_view::build(&profile, "test");
let mut d = Deriver::default();
let out = d.update(
&snapshot(300.0, 4500.0, 30.0),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
assert_eq!(out.eta_kind, EtaKind::Looping);
assert_eq!(out.time_remaining_s, None);
assert_eq!(out.loop_index, Some(3));
// Position wraps into the profile rather than running off the end.
assert!(out.position_x < geom.total_x);
}
/// No profile means no invented numbers.
#[test]
fn no_profile_means_unavailable() {
let mut d = Deriver::default();
let out = d.update(&snapshot(60.0, 500.0, 30.0), true, RIDER_KG, None, None);
assert_eq!(out.eta_kind, EtaKind::Unavailable);
assert_eq!(out.time_remaining_s, None);
}
/// Rolling power must lag a step change — that is the entire point of it
/// (FR-9.11).
#[test]
fn rolling_power_smooths_a_step() {
let profile = timed_profile();
let (_, geom) = profile_view::build(&profile, "test");
let mut d = Deriver::default();
let mut snap = snapshot(0.0, 0.0, 30.0);
for i in 1..=40 {
snap.elapsed_ms = (i * 250) as u64;
snap.telemetry.power_w = Some(100);
d.update(&snap, true, RIDER_KG, Some(&profile), Some(&geom));
}
snap.elapsed_ms = 10_250;
snap.telemetry.power_w = Some(600);
let out = d.update(&snap, true, RIDER_KG, Some(&profile), Some(&geom));
assert!(
out.rolling_power_w < 250.0,
"rolling power tracked the spike too closely"
);
}
/// An hour at 200 W: the work term dominates, the resting term is the
/// smaller correction on top, and the total is in the range a rider would
/// recognise from a head unit.
#[test]
fn calories_track_work_plus_a_resting_correction() {
let mut d = Deriver::default();
let mut out = None;
for i in 1..=3600 {
out = Some(d.update(&snapshot(i as f64, 0.0, 30.0), true, RIDER_KG, None, None));
}
let out = out.unwrap();
assert!(
(out.energy_kj - 720.0).abs() < 1.0,
"work {} kJ",
out.energy_kj
);
// 720 kJ of work plus 75 kcal of being alive for an hour.
assert!(
(out.calories_kcal - 763.0).abs() < 5.0,
"burn {} kcal",
out.calories_kcal
);
}
/// A paused ride burns nothing this ride can claim: neither pedalling work
/// nor the resting term accrues while the clock is stopped.
#[test]
fn a_paused_ride_accrues_no_calories() {
let mut d = Deriver::default();
let running = d.update(&snapshot(60.0, 0.0, 30.0), true, RIDER_KG, None, None);
let paused = d.update(&snapshot(3600.0, 0.0, 0.0), false, RIDER_KG, None, None);
assert!(running.calories_kcal > 0.0);
assert_eq!(running.calories_kcal, paused.calories_kcal);
}
}