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:
2026-08-05 13:50:01 +02:00
co-authored by Claude Opus 5
parent 7c17ca6158
commit 3a2a787b7d
23 changed files with 3297 additions and 46 deletions
+181 -3
View File
@@ -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");
}
}
+6
View File
@@ -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(())
+24
View File
@@ -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>();