Add Svelte GUI, FIT encoder and README
Standalone binary embeds the frontend, avoiding the dev-server dependency that made the window fail to load. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+181
-3
@@ -30,8 +30,10 @@ const SPEED_WINDOW_S: f64 = 45.0;
|
||||
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;
|
||||
/// 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")]
|
||||
@@ -227,7 +229,9 @@ impl Deriver {
|
||||
(_, 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 {
|
||||
// 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;
|
||||
@@ -289,3 +293,177 @@ 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;
|
||||
|
||||
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,
|
||||
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, 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, Some(&profile), Some(&geom));
|
||||
}
|
||||
t += 0.25;
|
||||
let steady = d.update(&snapshot(t, t * 10.0, 36.0), true, 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, 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, Some(&profile), Some(&geom));
|
||||
}
|
||||
let moving = d.update(&snapshot(50.25, 402.0, 28.8), true, 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, 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, Some(&profile), Some(&geom));
|
||||
}
|
||||
let paused = d.update(&snapshot(50.25, 402.0, 28.8), false, 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, 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, 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, Some(&profile), Some(&geom));
|
||||
}
|
||||
snap.elapsed_ms = 10_250;
|
||||
snap.telemetry.power_w = Some(600);
|
||||
let out = d.update(&snap, true, Some(&profile), Some(&geom));
|
||||
assert!(out.rolling_power_w < 250.0, "rolling power tracked the spike too closely");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,12 @@ pub fn run() {
|
||||
handle.state::<AppState>().lock().devices.start_scan();
|
||||
state::spawn_ride_loop(handle.clone());
|
||||
state::spawn_device_loop(handle.clone());
|
||||
// `BIKECONTROL_DEMO=1` opens straight onto a running ride with the
|
||||
// bundled GPX loaded. Purely a development convenience — it makes
|
||||
// the ride screen reviewable without clicking through first.
|
||||
if std::env::var("BIKECONTROL_DEMO").is_ok() {
|
||||
state::start_demo(&handle);
|
||||
}
|
||||
state::emit_devices(&handle);
|
||||
state::emit_ride_state(&handle);
|
||||
Ok(())
|
||||
|
||||
@@ -236,6 +236,30 @@ fn transmit(_app: &AppHandle, target: ControlTarget) {
|
||||
tracing::debug!(?target, "control target (no trainer attached — mock backend)");
|
||||
}
|
||||
|
||||
/// Load the bundled GPX and start riding it. Development only — see the
|
||||
/// `BIKECONTROL_DEMO` check in `lib.rs`.
|
||||
pub fn start_demo(app: &AppHandle) {
|
||||
let Some(sample) = crate::samples::all().into_iter().find(|s| s.is_gpx) else {
|
||||
return;
|
||||
};
|
||||
let profile = match bikecontrol_core::gpx::import(
|
||||
&sample.text,
|
||||
&sample.name,
|
||||
&bikecontrol_core::gpx::SmoothingConfig::default(),
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!(%e, "demo profile failed to import");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (view, geom) = crate::profile_view::build(&profile, "demo");
|
||||
let state = app.state::<AppState>();
|
||||
let mut inner = state.lock();
|
||||
inner.set_profile(profile, view, geom);
|
||||
inner.inputs.status = RideStatus::Running;
|
||||
}
|
||||
|
||||
/// SAF-2: never leave the trainer loaded. Called on ride end and on app exit.
|
||||
pub fn release_trainer(app: &AppHandle) {
|
||||
let state = app.state::<AppState>();
|
||||
|
||||
Reference in New Issue
Block a user