//! 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, pub distance_total_m: Option, pub distance_remaining_m: Option, /// Current altitude on the route, metres. pub elevation_m: Option, pub ascent_remaining_m: Option, /// 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, // --- 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, 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, } 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::() 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); } }