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

346 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The route, as the ride screen needs to draw it.
//!
//! `crates/core` owns profile *semantics* — `Profile::sample`,
//! `Profile::preview`, `Profile::total_extent`. This module owns the *view
//! model*: the elevation trace, the block breakdown, and the geometry needed to
//! place the current-position marker and answer "how much climbing is left".
//!
//! The route is the hero element of the ride screen, so this is the payload
//! that matters most.
use bikecontrol_core::profile::{Block, Channel, Extent, Position, Profile, Waveform};
use serde::Serialize;
/// Which axis the profile is drawn against.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum XUnit {
#[default]
Seconds,
Metres,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BlockSummary {
pub index: usize,
/// `constant` | `ramp` | `wave` | `segments` | `terrain`
pub kind: &'static str,
pub channel: Channel,
pub label: String,
pub start_x: f64,
pub end_x: f64,
pub unit: XUnit,
}
/// Everything the UI needs to draw a profile (FR-6.7, FR-9.7).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileView {
pub name: String,
pub description: Option<String>,
pub looping: bool,
/// Where it came from: a file path, a sample name, or `"editor"`.
pub source: String,
/// The channel the value series plots.
pub channel: Channel,
pub x_unit: XUnit,
pub total_x: f64,
/// Total ride duration, if the profile is measured in time.
pub total_seconds: Option<f64>,
/// Total ride distance, if the profile is measured in distance.
pub total_metres: Option<f64>,
/// `[x, value]` along the axis — gradient %, watts or resistance level.
pub series: Vec<[f64; 2]>,
/// `[distance_m, elevation_m]`. Real elevation for GPX-derived terrain,
/// integrated from gradient otherwise. This is the hero chart.
pub elevation: Option<Vec<[f64; 2]>>,
pub elevation_min_m: Option<f32>,
pub elevation_max_m: Option<f32>,
pub total_ascent_m: Option<f32>,
pub blocks: Vec<BlockSummary>,
/// The profile as YAML, for the in-app editor.
pub yaml: String,
}
/// Precomputed geometry kept Rust-side so per-tick lookups are cheap. Never
/// serialised — the frontend gets answers, not arrays to search.
#[derive(Debug, Clone, Default)]
pub struct ProfileGeometry {
pub xs: Vec<f64>,
pub elevation: Vec<f32>,
/// Cumulative ascent at each sample, so "climbing remaining" is a
/// subtraction rather than a scan.
pub cum_ascent: Vec<f32>,
pub total_x: f64,
pub x_unit: XUnit,
pub looping: bool,
pub total_seconds: Option<f64>,
pub total_metres: Option<f64>,
}
impl ProfileGeometry {
/// Elevation at a position on the axis, linearly interpolated.
pub fn elevation_at(&self, x: f64) -> Option<f32> {
interp(&self.xs, &self.elevation, x)
}
/// Metres of climbing still to come from `x` to the end.
pub fn ascent_remaining(&self, x: f64) -> Option<f32> {
let total = *self.cum_ascent.last()?;
let done = interp(&self.xs, &self.cum_ascent, x)?;
Some((total - done).max(0.0))
}
pub fn total_ascent(&self) -> Option<f32> {
self.cum_ascent.last().copied()
}
}
fn interp(xs: &[f64], ys: &[f32], x: f64) -> Option<f32> {
if xs.is_empty() || xs.len() != ys.len() {
return None;
}
if x <= xs[0] {
return Some(ys[0]);
}
let last = xs.len() - 1;
if x >= xs[last] {
return Some(ys[last]);
}
let i = xs.partition_point(|v| *v <= x).clamp(1, last);
let (x0, x1) = (xs[i - 1], xs[i]);
let (y0, y1) = (ys[i - 1], ys[i]);
let span = x1 - x0;
Some(if span.abs() < f64::EPSILON {
y1
} else {
y0 + (y1 - y0) * ((x - x0) / span) as f32
})
}
const PREVIEW_SAMPLES: usize = 1400;
fn extent_parts(extent: Extent) -> (f64, XUnit) {
match extent {
Extent::Seconds(s) => (s.max(0.0), XUnit::Seconds),
Extent::Metres(m) => (m.max(0.0), XUnit::Metres),
}
}
/// Build the view model and the geometry that goes with it.
pub fn build(profile: &Profile, source: impl Into<String>) -> (ProfileView, ProfileGeometry) {
let extent = profile.total_extent();
let x_unit = match (extent.metres, extent.seconds) {
(Some(m), Some(s)) => {
if m >= s {
XUnit::Metres
} else {
XUnit::Seconds
}
}
(Some(_), None) => XUnit::Metres,
_ => XUnit::Seconds,
};
let preview = profile.preview(PREVIEW_SAMPLES);
let series: Vec<[f64; 2]> = preview.iter().map(|(x, v)| [*x, *v as f64]).collect();
let total_x = series.last().map(|p| p[0]).unwrap_or(0.0);
let channel = profile
.blocks
.first()
.map(|b| b.channel())
.unwrap_or(Channel::Gradient);
// Elevation. Prefer the real thing: a GPX import lands as a `Terrain`
// block that already carries surveyed elevation. Otherwise integrate the
// gradient, which is what a hand-authored segment profile implies anyway.
let mut geom = ProfileGeometry {
total_x,
x_unit,
looping: profile.looping,
total_seconds: extent.seconds,
total_metres: extent.metres,
..Default::default()
};
let elevation: Option<Vec<[f64; 2]>> = if channel == Channel::Gradient {
let surveyed = surveyed_elevation(profile);
let pairs = match surveyed {
Some(points) => points,
None if x_unit == XUnit::Metres => integrate_gradient(&series),
None => Vec::new(),
};
if pairs.len() < 2 {
None
} else {
geom.xs = pairs.iter().map(|p| p[0]).collect();
geom.elevation = pairs.iter().map(|p| p[1] as f32).collect();
let mut cum = Vec::with_capacity(geom.elevation.len());
let mut acc = 0.0f32;
let mut prev = geom.elevation[0];
for e in &geom.elevation {
acc += (e - prev).max(0.0);
prev = *e;
cum.push(acc);
}
geom.cum_ascent = cum;
Some(pairs)
}
} else {
None
};
let (elevation_min_m, elevation_max_m) = match &geom.elevation {
e if e.is_empty() => (None, None),
e => (
Some(e.iter().copied().fold(f32::INFINITY, f32::min)),
Some(e.iter().copied().fold(f32::NEG_INFINITY, f32::max)),
),
};
let mut blocks = Vec::with_capacity(profile.blocks.len());
let mut cursor = 0.0f64;
for (index, block) in profile.blocks.iter().enumerate() {
let (span, unit) = extent_parts(block.extent());
blocks.push(BlockSummary {
index,
kind: block_kind(block),
channel: block.channel(),
label: block_label(block),
start_x: cursor,
end_x: cursor + span,
unit,
});
cursor += span;
}
let view = ProfileView {
name: profile.name.clone(),
description: profile.description.clone(),
looping: profile.looping,
source: source.into(),
channel,
x_unit,
total_x,
total_seconds: extent.seconds,
total_metres: extent.metres,
series,
elevation,
elevation_min_m,
elevation_max_m,
total_ascent_m: geom.total_ascent(),
blocks,
yaml: serde_yaml_ng::to_string(profile).unwrap_or_default(),
};
(view, geom)
}
/// Elevation straight out of `Terrain` blocks, offset so consecutive blocks
/// join up rather than each restarting at zero distance.
fn surveyed_elevation(profile: &Profile) -> Option<Vec<[f64; 2]>> {
let mut out: Vec<[f64; 2]> = Vec::new();
let mut offset = 0.0f64;
let mut any = false;
for block in &profile.blocks {
let (span, _) = extent_parts(block.extent());
if let Block::Terrain { points } = block {
any = true;
let base = points.first().map(|p| p.distance_m).unwrap_or(0.0);
for p in points {
out.push([offset + (p.distance_m - base), p.elevation_m as f64]);
}
}
offset += span;
}
any.then_some(out)
}
/// Integrate gradient over distance to get a relative elevation trace.
fn integrate_gradient(series: &[[f64; 2]]) -> Vec<[f64; 2]> {
let mut elev = 0.0f64;
let mut prev_x = series.first().map(|p| p[0]).unwrap_or(0.0);
series
.iter()
.map(|[x, grade]| {
elev += (x - prev_x).max(0.0) * (grade / 100.0);
prev_x = *x;
[*x, elev]
})
.collect()
}
/// Where the rider is on the preview axis right now.
pub fn position_x(geom: &ProfileGeometry, elapsed_s: f64, distance_m: f64) -> f64 {
let raw = match geom.x_unit {
XUnit::Seconds => elapsed_s,
XUnit::Metres => distance_m,
};
if geom.looping && geom.total_x > 0.0 {
raw.rem_euclid(geom.total_x)
} else {
raw.clamp(0.0, geom.total_x.max(0.0))
}
}
/// Convenience wrapper so callers do not have to build a `Position`.
pub fn position(elapsed_s: f64, distance_m: f64) -> Position {
Position {
elapsed_s,
distance_m,
}
}
fn block_kind(block: &Block) -> &'static str {
match block {
Block::Constant { .. } => "constant",
Block::Ramp { .. } => "ramp",
Block::Wave { .. } => "wave",
Block::Segments { .. } => "segments",
Block::Terrain { .. } => "terrain",
}
}
fn unit_suffix(channel: Channel) -> &'static str {
match channel {
Channel::Gradient => "%",
Channel::Resistance => "",
Channel::Power => " W",
}
}
fn block_label(block: &Block) -> String {
let u = unit_suffix(block.channel());
match block {
Block::Constant { value, .. } => format!("hold {value:.0}{u}"),
Block::Ramp { from, to, .. } => format!("ramp {from:.0}{u}{to:.0}{u}"),
Block::Wave {
shape,
midpoint,
amplitude,
repeats,
..
} => format!(
"{} {:.0}{u} ±{:.0}{u} ×{:.0}",
match shape {
Waveform::Sine => "sine",
Waveform::Square => "square",
Waveform::Triangle => "triangle",
Waveform::Sawtooth => "sawtooth",
},
midpoint,
amplitude,
repeats
),
Block::Segments { segments } => {
let d: f64 = segments.iter().map(|s| s.distance_m).sum();
format!("{} segments · {:.1} km", segments.len(), d / 1000.0)
}
Block::Terrain { points } => {
let d = points.last().map(|p| p.distance_m).unwrap_or(0.0);
format!("terrain · {:.1} km", d / 1000.0)
}
}
}