Files
BikeControl/src-tauri/src/samples.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

196 lines
6.0 KiB
Rust

//! Profiles shipped with the app, so there is always something to ride and the
//! YAML schema (`crates/core/src/profile.rs`) has worked examples.
use crate::commands::SampleProfile;
const OVER_UNDERS: &str = r#"name: Over-unders
description: Ten minutes up to threshold, then eight over-under cycles, then easy.
looping: false
blocks:
- type: ramp
channel: power
from: 110
to: 210
extent: { seconds: 600 }
- type: wave
channel: power
shape: sine
midpoint: 245
amplitude: 45
period: { seconds: 120 }
repeats: 8
- type: constant
channel: power
value: 120
extent: { seconds: 300 }
"#;
const HILL_REPEATS: &str = r#"name: Hill repeats
description: Four kilometres of rolling terrain, looped. Gradient by distance.
looping: true
blocks:
- type: segments
segments:
- { distance_m: 600, gradient_pct: 1.0 }
- { distance_m: 900, gradient_pct: 6.5 }
- { distance_m: 300, gradient_pct: 9.0 }
- { distance_m: 500, gradient_pct: -3.0 }
- { distance_m: 700, gradient_pct: 4.0 }
- { distance_m: 1000, gradient_pct: -2.0 }
"#;
const SAWTOOTH_GRADE: &str = r#"name: Sawtooth grade
description: A gradient sawtooth for shakedown testing — every 400 m ramps 0 to 8%.
looping: true
blocks:
- type: wave
channel: gradient
shape: sawtooth
midpoint: 4.0
amplitude: 4.0
period: { metres: 400 }
repeats: 20
"#;
const STEADY_ENDURANCE: &str = r#"name: Steady endurance
description: Ninety minutes at a fixed grade, with a gentle triangular trim.
looping: false
blocks:
- type: constant
channel: gradient
value: 2.0
extent: { seconds: 900 }
- type: wave
channel: gradient
shape: triangle
midpoint: 3.0
amplitude: 2.5
period: { seconds: 600 }
repeats: 7
- type: constant
channel: gradient
value: 0.0
extent: { seconds: 600 }
"#;
/// The gearing bench test, shipped rather than kept in a scratch file because
/// it is the fastest way to answer "do the gears and the resistance work?" on
/// real hardware. Flat on purpose: on a slope, gravity swamps everything and a
/// broken gear ratio still feels like a hill.
const DRAG_RACE: &str = r#"name: Drag race
description: >-
A standing-start kilometre on a dead-flat road, for testing that the gears and
the resistance actually do something. Start in bottom gear from a stop and
wind it up: every shift should land under the pedals at once, and holding one
gear should get harder as you speed up, because on the flat drag is the only
thing resisting you and it grows with the square of speed. If shifting feels
like nothing, the control writes are not reaching the trainer. Loops, so you
can go again in a different gear and compare the time.
looping: true
blocks:
- type: segments
segments:
- distance_m: 1000.0
gradient_pct: 0.0
"#;
/// A real GPX, bundled so the route view has something to draw on first run.
const SAMPLE_CLIMB_GPX: &str = include_str!("../../testdata/sample-climb.gpx");
pub fn all() -> Vec<SampleProfile> {
// Drag race first among the written profiles: it is the bench test, and the
// thing most likely to be wanted in a hurry when the gearing feels wrong.
let mut out: Vec<SampleProfile> = [
DRAG_RACE,
OVER_UNDERS,
HILL_REPEATS,
SAWTOOTH_GRADE,
STEADY_ENDURANCE,
]
.iter()
.map(|yaml| {
let (name, summary) = header(yaml);
SampleProfile {
name,
summary,
text: (*yaml).to_string(),
is_gpx: false,
}
})
.collect();
out.insert(
0,
SampleProfile {
name: "Sample climb".into(),
summary: "3 km GPX with real GPS elevation noise — smoothed on import.".into(),
text: SAMPLE_CLIMB_GPX.to_string(),
is_gpx: true,
},
);
out
}
fn header(yaml: &str) -> (String, String) {
let mut name = String::from("Profile");
let mut summary = String::new();
for line in yaml.lines() {
if let Some(rest) = line.strip_prefix("name: ") {
name = rest.trim().to_string();
} else if let Some(rest) = line.strip_prefix("description: ") {
summary = rest.trim().to_string();
}
}
(name, summary)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_shipped_profile_parses() {
// These are only ever exercised when a rider clicks one, so a typo in a
// heredoc ships and stays shipped. Parsing them here is the difference
// between finding that at compile time and finding it mid-warm-up.
for sample in all() {
if sample.is_gpx {
continue;
}
bikecontrol_core::profile::Profile::from_yaml(&sample.text)
.unwrap_or_else(|e| panic!("sample profile {:?} does not parse: {e}", sample.name));
}
}
#[test]
fn the_drag_race_is_offered_and_is_the_flat_kilometre_it_claims() {
// Flat is the whole point: on a slope gravity swamps the gearing and a
// broken ratio still feels like a hill.
let sample = all()
.into_iter()
.find(|s| s.name == "Drag race")
.expect("the drag race must reach the picker — it was defined but unlisted once");
let profile = bikecontrol_core::profile::Profile::from_yaml(&sample.text).unwrap();
assert!(
profile.looping,
"you must be able to go again without reloading"
);
let extent = profile.total_extent();
let metres = extent
.metres
.expect("a drag race is measured in distance, not time");
assert!(
(metres - 1000.0).abs() < 1.0,
"expected a kilometre, got {metres} m"
);
}
#[test]
fn a_name_and_summary_are_extracted_for_every_sample() {
for sample in all() {
assert!(!sample.name.is_empty(), "a nameless entry in the picker");
assert_ne!(sample.name, "Profile", "fell back to the placeholder name");
}
}
}