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>
This commit is contained in:
+178
-18
@@ -26,8 +26,78 @@ use tokio::sync::{broadcast, mpsc, oneshot};
|
||||
|
||||
use crate::client::{Backoff, InFlight, DISCONNECT_TIMEOUT};
|
||||
use crate::error::FtmsError;
|
||||
use crate::scan::{self, TrainerSelector};
|
||||
use crate::zwift::{self, Button, ButtonTracker};
|
||||
use crate::scan::{self, ScanKind};
|
||||
use crate::zwift::{self, Button, ButtonTracker, PodId};
|
||||
|
||||
/// How to pick a controller pod out of a scan.
|
||||
///
|
||||
/// Deliberately not [`crate::scan::TrainerSelector`]. A pair of Click pods
|
||||
/// advertise the *same* local name — both of ours are plain `Zwift Click` — so
|
||||
/// name matching cannot tell them apart and whichever answered first won. That
|
||||
/// is the whole reason the connection felt arbitrary: ask for "the Click" and
|
||||
/// you get a coin toss between the pod with the D-pad and the one without.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PodSelector {
|
||||
/// A specific pod, by address. What the app uses once a scan has seen the
|
||||
/// pair, because an address cannot be confused with its twin.
|
||||
Address(String),
|
||||
/// The pod whose manufacturer-data type byte says it is this one (§2.3.1).
|
||||
/// Used before either pod has been seen by a scan.
|
||||
Pod(PodId),
|
||||
/// Any Click pod at all. The fallback for hardware whose type byte we do
|
||||
/// not recognise — better a controller in the wrong slot than none.
|
||||
Any,
|
||||
}
|
||||
|
||||
impl PodSelector {
|
||||
/// Does this peripheral match?
|
||||
pub fn matches(&self, d: &scan::DiscoveredDevice) -> bool {
|
||||
self.matches_parts(&d.address, d.zwift_kind())
|
||||
}
|
||||
|
||||
/// The matching rule, factored out so it can be unit-tested without a
|
||||
/// `PeripheralId` (which only the platform backend can construct).
|
||||
pub(crate) fn matches_parts(&self, address: &str, kind: Option<zwift::DeviceKind>) -> bool {
|
||||
match self {
|
||||
// An address is matched without insisting on the manufacturer data:
|
||||
// a pod that is mid-connection may not be advertising it, and the
|
||||
// address alone already identifies exactly one peripheral.
|
||||
PodSelector::Address(a) => address.eq_ignore_ascii_case(a),
|
||||
PodSelector::Pod(pod) => kind.and_then(|k| k.pod_id()) == Some(*pod),
|
||||
PodSelector::Any => kind.is_some_and(|k| k.is_click()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable, for error messages the rider will actually read.
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
PodSelector::Address(a) => format!("Click pod at {a}"),
|
||||
PodSelector::Pod(p) => format!("the {} Click pod", p.symbol()),
|
||||
PodSelector::Any => "any Click pod".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Which pod this asks for, when it asks for a particular one at all.
|
||||
pub fn pod(&self) -> Option<PodId> {
|
||||
match self {
|
||||
PodSelector::Pod(p) => Some(*p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan until a pod matching `selector` appears, or `timeout` elapses.
|
||||
pub async fn find_pod(
|
||||
adapter: &Adapter,
|
||||
selector: &PodSelector,
|
||||
timeout: Duration,
|
||||
) -> Result<Peripheral, FtmsError> {
|
||||
// Unfiltered: a Click advertises Zwift's own service, never FTMS.
|
||||
scan::find_matching(adapter, ScanKind::All, timeout, &selector.describe(), |d| {
|
||||
selector.matches(d)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Tunables for [`ClickClient`].
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -55,6 +125,11 @@ pub enum ClickEvent {
|
||||
Connected {
|
||||
address: String,
|
||||
name: Option<String>,
|
||||
/// Which pod this turned out to be, from its own advertisement — not
|
||||
/// from what was asked for. Connecting by address says nothing about
|
||||
/// which paddle the pod carries, and the app would otherwise have to
|
||||
/// assume it got what it requested.
|
||||
pod: Option<PodId>,
|
||||
},
|
||||
/// The link dropped. Any held button has already been reported as released.
|
||||
/// The actor is retrying — this is not terminal.
|
||||
@@ -84,18 +159,36 @@ enum Cmd {
|
||||
pub struct ClickClient {
|
||||
cmd_tx: mpsc::Sender<Cmd>,
|
||||
events_tx: broadcast::Sender<ClickEvent>,
|
||||
address: String,
|
||||
name: Option<String>,
|
||||
pod: Option<PodId>,
|
||||
}
|
||||
|
||||
impl ClickClient {
|
||||
/// The pod's address. Carried on the handle rather than left to the event
|
||||
/// stream because [`ClickEvent::Connected`] for the *first* session is sent
|
||||
/// before the caller has had a chance to subscribe — a caller that learned
|
||||
/// its identity only from events would sit there believing it had connected
|
||||
/// to nothing.
|
||||
pub fn address(&self) -> &str {
|
||||
&self.address
|
||||
}
|
||||
|
||||
pub fn name(&self) -> Option<&str> {
|
||||
self.name.as_deref()
|
||||
}
|
||||
|
||||
/// Which pod of a pair this turned out to be, where it said so (§2.3.1).
|
||||
pub fn pod(&self) -> Option<PodId> {
|
||||
self.pod
|
||||
}
|
||||
|
||||
/// Connect to a Click and start streaming events.
|
||||
///
|
||||
/// Returns once the pod has answered the handshake, so a caller that gets
|
||||
/// an `Ok` knows the controller is genuinely talking — not merely that a
|
||||
/// BLE link exists.
|
||||
pub async fn connect(
|
||||
selector: TrainerSelector,
|
||||
config: ClickConfig,
|
||||
) -> Result<Self, FtmsError> {
|
||||
pub async fn connect(selector: PodSelector, config: ClickConfig) -> Result<Self, FtmsError> {
|
||||
let adapter = scan::default_adapter().await?;
|
||||
Self::connect_with_adapter(adapter, selector, config).await
|
||||
}
|
||||
@@ -108,7 +201,7 @@ impl ClickClient {
|
||||
/// a hang (FR-1.10). Any link the abandoned attempt had opened is closed
|
||||
/// before this returns (SAF-9).
|
||||
pub async fn connect_cancellable(
|
||||
selector: TrainerSelector,
|
||||
selector: PodSelector,
|
||||
config: ClickConfig,
|
||||
cancel: impl Future<Output = ()>,
|
||||
) -> Result<Option<Self>, FtmsError> {
|
||||
@@ -137,7 +230,7 @@ impl ClickClient {
|
||||
/// As [`ClickClient::connect`], but on a caller-supplied adapter.
|
||||
pub async fn connect_with_adapter(
|
||||
adapter: Adapter,
|
||||
selector: TrainerSelector,
|
||||
selector: PodSelector,
|
||||
config: ClickConfig,
|
||||
) -> Result<Self, FtmsError> {
|
||||
Self::connect_on(adapter, selector, config, &InFlight::default()).await
|
||||
@@ -145,7 +238,7 @@ impl ClickClient {
|
||||
|
||||
async fn connect_on(
|
||||
adapter: Adapter,
|
||||
selector: TrainerSelector,
|
||||
selector: PodSelector,
|
||||
config: ClickConfig,
|
||||
in_flight: &InFlight,
|
||||
) -> Result<Self, FtmsError> {
|
||||
@@ -153,21 +246,34 @@ impl ClickClient {
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel(8);
|
||||
|
||||
let (session, notifications) = open_session(&adapter, &selector, &config, in_flight).await?;
|
||||
let (address, name, pod) = (session.address.clone(), session.name.clone(), session.pod);
|
||||
// Sent for symmetry with a reconnect. Nobody is subscribed yet, which is
|
||||
// why the same three facts also ride out on the handle below.
|
||||
let _ = events_tx.send(ClickEvent::Connected {
|
||||
address: session.address.clone(),
|
||||
name: session.name.clone(),
|
||||
address: address.clone(),
|
||||
name: name.clone(),
|
||||
pod,
|
||||
});
|
||||
|
||||
let actor = Actor {
|
||||
adapter,
|
||||
selector,
|
||||
// Reconnect to *this* pod, not to whatever now answers the original
|
||||
// description. Chasing `the − pod` after a drop could land on the
|
||||
// twin if the type bytes are not what we think they are, and then
|
||||
// both slots would be the same peripheral. Only an address we
|
||||
// actually read is an improvement on what we were asked for.
|
||||
selector: if address.is_empty() {
|
||||
selector
|
||||
} else {
|
||||
PodSelector::Address(address.clone())
|
||||
},
|
||||
config,
|
||||
events_tx: events_tx.clone(),
|
||||
tracker: ButtonTracker::new(),
|
||||
};
|
||||
tokio::spawn(actor.run(cmd_rx, session, Box::pin(notifications)));
|
||||
|
||||
Ok(Self { cmd_tx, events_tx })
|
||||
Ok(Self { cmd_tx, events_tx, address, name, pod })
|
||||
}
|
||||
|
||||
/// Subscribe to controller events. Late subscribers see only what arrives
|
||||
@@ -204,21 +310,23 @@ struct Session {
|
||||
peripheral: Peripheral,
|
||||
address: String,
|
||||
name: Option<String>,
|
||||
/// Which pod this is, as it advertised itself.
|
||||
pod: Option<PodId>,
|
||||
subscribed: Vec<Characteristic>,
|
||||
}
|
||||
|
||||
/// Find the pod, connect, subscribe, and complete the `RideOn` handshake.
|
||||
async fn open_session(
|
||||
adapter: &Adapter,
|
||||
selector: &TrainerSelector,
|
||||
selector: &PodSelector,
|
||||
config: &ClickConfig,
|
||||
in_flight: &InFlight,
|
||||
) -> Result<(Session, Notifications), FtmsError> {
|
||||
let peripheral = scan::find_peripheral(adapter, selector, config.scan_timeout).await?;
|
||||
let peripheral = find_pod(adapter, selector, config.scan_timeout).await?;
|
||||
// Cancelling past this point would otherwise strand the link (FR-1.10).
|
||||
in_flight.hold(peripheral.clone());
|
||||
|
||||
match setup_session(peripheral.clone()).await {
|
||||
match setup_session(peripheral.clone(), selector.pod()).await {
|
||||
Ok(session) => {
|
||||
in_flight.released();
|
||||
Ok(session)
|
||||
@@ -235,7 +343,10 @@ async fn open_session(
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup_session(peripheral: Peripheral) -> Result<(Session, Notifications), FtmsError> {
|
||||
async fn setup_session(
|
||||
peripheral: Peripheral,
|
||||
asked_for: Option<PodId>,
|
||||
) -> Result<(Session, Notifications), FtmsError> {
|
||||
if !peripheral.is_connected().await.unwrap_or(false) {
|
||||
peripheral.connect().await?;
|
||||
}
|
||||
@@ -293,6 +404,12 @@ async fn setup_session(peripheral: Peripheral) -> Result<(Session, Notifications
|
||||
.as_ref()
|
||||
.map(|d| d.address.clone())
|
||||
.unwrap_or_default(),
|
||||
// A peripheral that is already connected may no longer carry its
|
||||
// advertisement, so fall back to the pod we went looking for.
|
||||
pod: described
|
||||
.as_ref()
|
||||
.and_then(|d| d.pod_id())
|
||||
.or(asked_for),
|
||||
name: described.and_then(|d| d.name),
|
||||
peripheral,
|
||||
subscribed,
|
||||
@@ -303,7 +420,7 @@ async fn setup_session(peripheral: Peripheral) -> Result<(Session, Notifications
|
||||
|
||||
struct Actor {
|
||||
adapter: Adapter,
|
||||
selector: TrainerSelector,
|
||||
selector: PodSelector,
|
||||
config: ClickConfig,
|
||||
events_tx: broadcast::Sender<ClickEvent>,
|
||||
tracker: ButtonTracker,
|
||||
@@ -357,6 +474,11 @@ impl Actor {
|
||||
loop {
|
||||
if self.config.backoff.exhausted(attempt) {
|
||||
tracing::warn!("click: giving up after {attempt} reconnect attempts");
|
||||
// Say so before going away. Leaving silently strands the
|
||||
// supervisor holding a handle whose actor is gone, with
|
||||
// nothing to tell the rider why the buttons stopped working
|
||||
// (FR-1.11).
|
||||
let _ = self.events_tx.send(ClickEvent::GaveUp { attempts: attempt });
|
||||
return;
|
||||
}
|
||||
let delay = self.config.backoff.delay(attempt);
|
||||
@@ -399,6 +521,7 @@ impl Actor {
|
||||
let _ = self.events_tx.send(ClickEvent::Connected {
|
||||
address: session.address.clone(),
|
||||
name: session.name.clone(),
|
||||
pod: session.pod,
|
||||
});
|
||||
current = session;
|
||||
notifications = stream;
|
||||
@@ -492,6 +615,43 @@ mod tests {
|
||||
assert_eq!(c.backoff.max_attempts, None);
|
||||
}
|
||||
|
||||
/// The bug this whole selector exists to kill: both pods of a pair
|
||||
/// advertise the identical local name, so "the Click" is not a thing you
|
||||
/// can ask a scan for. Which one you got was a race.
|
||||
#[test]
|
||||
fn a_pod_is_picked_by_its_type_byte_not_by_a_name_they_both_share() {
|
||||
let minus = Some(zwift::DeviceKind::from_type_byte(0x0B));
|
||||
let plus = Some(zwift::DeviceKind::from_type_byte(0x0A));
|
||||
|
||||
let want_minus = PodSelector::Pod(PodId::Minus);
|
||||
assert!(want_minus.matches_parts("f4:c4:59:03:a1:8e", minus));
|
||||
assert!(!want_minus.matches_parts("c0:4a:0e:f9:a8:78", plus));
|
||||
|
||||
let want_plus = PodSelector::Pod(PodId::Plus);
|
||||
assert!(want_plus.matches_parts("c0:4a:0e:f9:a8:78", plus));
|
||||
assert!(!want_plus.matches_parts("f4:c4:59:03:a1:8e", minus));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_identifies_a_pod_that_has_stopped_advertising() {
|
||||
// Reconnect runs against a pod that may no longer be broadcasting its
|
||||
// manufacturer data. Insisting on the type byte here would mean never
|
||||
// finding it again.
|
||||
let s = PodSelector::Address("F4:C4:59:03:A1:8E".into());
|
||||
assert!(s.matches_parts("f4:c4:59:03:a1:8e", None));
|
||||
assert!(!s.matches_parts("c0:4a:0e:f9:a8:78", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_takes_a_click_but_not_a_trainer_speaking_the_zwift_protocol() {
|
||||
// The D100 advertises Zwift's custom service with no manufacturer data.
|
||||
// Connecting to it as a controller would wedge the trainer link.
|
||||
let s = PodSelector::Any;
|
||||
assert!(s.matches_parts("aa", Some(zwift::DeviceKind::from_type_byte(0x0A))));
|
||||
assert!(!s.matches_parts("aa", None));
|
||||
assert!(!s.matches_parts("aa", Some(zwift::DeviceKind::Unknown(0x77))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_compare_by_value() {
|
||||
assert_eq!(
|
||||
|
||||
+150
-1
@@ -37,6 +37,7 @@ use crate::error::FtmsError;
|
||||
use crate::indoor_bike_data::{self, hex};
|
||||
use crate::scan::{self, TrainerSelector};
|
||||
use crate::uuids;
|
||||
use crate::zwift;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
@@ -316,6 +317,7 @@ impl FtmsClient {
|
||||
halted: false,
|
||||
ride_start: Instant::now(),
|
||||
last_health_check: Instant::now(),
|
||||
zwift_cadence: None,
|
||||
};
|
||||
|
||||
tokio::spawn(actor.run(cmd_rx, Some(connected.notifications)));
|
||||
@@ -581,9 +583,24 @@ struct Actor {
|
||||
halted: bool,
|
||||
ride_start: Instant,
|
||||
last_health_check: Instant,
|
||||
/// Latest cadence from the trainer's Zwift channel, and when it arrived.
|
||||
///
|
||||
/// This trainer declares no cadence in its Fitness Machine Feature bits and
|
||||
/// sends none in Indoor Bike Data, so FTMS alone cannot supply it — see
|
||||
/// [`crate::zwift::RidingData`]. Riding data arrives at about 1 Hz against
|
||||
/// Indoor Bike Data's ~5 Hz, so it is held here and stamped onto the faster
|
||||
/// stream rather than published as telemetry of its own.
|
||||
zwift_cadence: Option<(f32, Instant)>,
|
||||
}
|
||||
|
||||
const HEALTH_CHECK_INTERVAL: Duration = Duration::from_secs(3);
|
||||
/// How long a cadence from the Zwift channel stays believable.
|
||||
///
|
||||
/// Generous against its ~1 Hz rate, but finite: a rider who stops pedalling
|
||||
/// must read as stopped, and a channel that dies must not leave the last
|
||||
/// cadence under the ride forever — with speed taken from cadence × gear, a
|
||||
/// stale value is a ride that keeps rolling on its own.
|
||||
const ZWIFT_CADENCE_TTL: Duration = Duration::from_secs(4);
|
||||
|
||||
impl Actor {
|
||||
async fn run(
|
||||
@@ -658,7 +675,41 @@ impl Actor {
|
||||
|
||||
// -- inbound ----------------------------------------------------------
|
||||
|
||||
/// The Zwift-channel cadence, if one arrived recently enough to trust.
|
||||
fn fresh_zwift_cadence(&self) -> Option<f32> {
|
||||
self.zwift_cadence
|
||||
.filter(|(_, at)| at.elapsed() < ZWIFT_CADENCE_TTL)
|
||||
.map(|(rpm, _)| rpm)
|
||||
}
|
||||
|
||||
fn handle_notification(&mut self, n: ValueNotification) {
|
||||
if zwift::is_zwift_uuid(n.uuid) {
|
||||
// Any characteristic in the trainer's Zwift service, not just the
|
||||
// one the Click uses — which one a trainer talks on is undocumented.
|
||||
// Only riding-data frames (type 0x03) mean anything; the channel
|
||||
// carries other traffic and `decode_riding_data` rejects all of it.
|
||||
match zwift::decode_riding_data(&n.value) {
|
||||
Some(data) => {
|
||||
tracing::debug!(
|
||||
uuid = %n.uuid,
|
||||
raw = %hex(&n.value),
|
||||
cadence_rpm = data.cadence_rpm(),
|
||||
power_w = data.power_w,
|
||||
"zwift riding data"
|
||||
);
|
||||
self.zwift_cadence = Some((data.cadence_rpm(), Instant::now()));
|
||||
}
|
||||
// Logged, not swallowed. If cadence never arrives, the question
|
||||
// is whether the channel is silent or merely speaking a dialect
|
||||
// we do not decode, and only these lines can tell the two apart.
|
||||
None => tracing::debug!(
|
||||
uuid = %n.uuid,
|
||||
raw = %hex(&n.value),
|
||||
"zwift frame that is not riding data"
|
||||
),
|
||||
}
|
||||
return;
|
||||
}
|
||||
if n.uuid == uuids::INDOOR_BIKE_DATA {
|
||||
tracing::trace!(raw = %hex(&n.value), "0x2AD2 indoor bike data");
|
||||
match indoor_bike_data::decode(&n.value) {
|
||||
@@ -670,7 +721,14 @@ impl Actor {
|
||||
"indoor bike data had trailing bytes we do not understand"
|
||||
);
|
||||
}
|
||||
let telemetry = data.to_telemetry(self.elapsed_ms());
|
||||
let mut telemetry = data.to_telemetry(self.elapsed_ms());
|
||||
// FTMS carries no cadence on this trainer; the Zwift
|
||||
// channel does. Fill it in, but never overwrite a cadence
|
||||
// FTMS did report — a trainer that declares one is the
|
||||
// better authority on it.
|
||||
if telemetry.cadence_rpm.is_none() {
|
||||
telemetry.cadence_rpm = self.fresh_zwift_cadence();
|
||||
}
|
||||
let _ = self.telemetry_tx.send(telemetry);
|
||||
let _ = self.events_tx.send(FtmsEvent::Telemetry(telemetry));
|
||||
}
|
||||
@@ -1344,6 +1402,15 @@ async fn setup_session(
|
||||
}
|
||||
}
|
||||
|
||||
// The trainer's Zwift channel, which is where its cadence lives — FTMS on
|
||||
// this hardware reports none at all (see `zwift::RidingData`), and with the
|
||||
// speed taken from cadence × gear, no cadence means no ride.
|
||||
//
|
||||
// Optional in every sense: a trainer without the service simply rides
|
||||
// without cadence, and nothing here may cost us the FTMS session that is
|
||||
// already working. Hence the warnings rather than `?`.
|
||||
subscribe_zwift_channel(&peripheral, &chars).await;
|
||||
|
||||
let mut notif_rx = notif_rx;
|
||||
|
||||
// FR-2.1: control first, everything else after.
|
||||
@@ -1384,6 +1451,88 @@ async fn setup_session(
|
||||
))
|
||||
}
|
||||
|
||||
/// Subscribe to the trainer's Zwift channel and complete the `RideOn`
|
||||
/// handshake, so it starts reporting cadence.
|
||||
///
|
||||
/// The handshake is the part that is easy to miss. Subscribing alone is not
|
||||
/// enough: like the Click, the trainer's Zwift service says nothing until it
|
||||
/// has been greeted, so a client that only subscribes sits there receiving
|
||||
/// silence and concludes the device has no cadence to give. Every symptom of
|
||||
/// that is indistinguishable from a trainer that genuinely has none.
|
||||
///
|
||||
/// Entirely best-effort. A trainer without the service, or one that refuses the
|
||||
/// write, rides without cadence; none of it may disturb the FTMS session, which
|
||||
/// is the part that actually controls the resistance.
|
||||
async fn subscribe_zwift_channel(
|
||||
peripheral: &Peripheral,
|
||||
chars: &std::collections::BTreeSet<Characteristic>,
|
||||
) {
|
||||
let zwift_char = |uuid: Uuid| chars.iter().find(|c| c.uuid == uuid).cloned();
|
||||
|
||||
let zwift_chars: Vec<&Characteristic> = chars
|
||||
.iter()
|
||||
.filter(|c| zwift::is_zwift_uuid(c.uuid))
|
||||
.collect();
|
||||
if zwift_chars.is_empty() {
|
||||
tracing::info!("trainer exposes no Zwift channel; it will report no cadence");
|
||||
return;
|
||||
}
|
||||
// Listed because which characteristic a *trainer* publishes riding data on
|
||||
// is not documented — the Click's is `ASYNC`, and betting on that being
|
||||
// universal is what this list exists to disprove or confirm.
|
||||
for c in &zwift_chars {
|
||||
tracing::debug!(uuid = %c.uuid, properties = ?c.properties, "zwift characteristic");
|
||||
}
|
||||
|
||||
// Subscribe to every notifying characteristic in the service, exactly as
|
||||
// the Click path does, rather than guessing which one carries cadence. A
|
||||
// subscription we did not need costs nothing; the one we failed to make
|
||||
// costs the entire ride, because speed comes from cadence.
|
||||
let mut subscribed = 0;
|
||||
for c in &zwift_chars {
|
||||
if !c
|
||||
.properties
|
||||
.intersects(btleplug::api::CharPropFlags::NOTIFY | btleplug::api::CharPropFlags::INDICATE)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match peripheral.subscribe(c).await {
|
||||
Ok(()) => {
|
||||
subscribed += 1;
|
||||
tracing::debug!(uuid = %c.uuid, "subscribed to a Zwift characteristic");
|
||||
}
|
||||
Err(e) => tracing::warn!(uuid = %c.uuid, error = %e, "Zwift subscribe failed"),
|
||||
}
|
||||
}
|
||||
if subscribed == 0 {
|
||||
tracing::warn!("the trainer's Zwift service notifies on nothing; no cadence");
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribed above, before writing, so the reply cannot outrun us.
|
||||
let Some(sync_rx) = zwift_char(zwift::SYNC_RX) else {
|
||||
// Subscribed but ungreetable. Worth saying: if cadence never arrives,
|
||||
// this line is the reason.
|
||||
tracing::warn!("trainer has a Zwift channel but no sync RX to greet it on");
|
||||
return;
|
||||
};
|
||||
let write_type = if sync_rx
|
||||
.properties
|
||||
.contains(btleplug::api::CharPropFlags::WRITE_WITHOUT_RESPONSE)
|
||||
{
|
||||
WriteType::WithoutResponse
|
||||
} else {
|
||||
WriteType::WithResponse
|
||||
};
|
||||
match peripheral
|
||||
.write(&sync_rx, &zwift::handshake(&zwift::REQUEST_START), write_type)
|
||||
.await
|
||||
{
|
||||
Ok(()) => tracing::info!("greeted the trainer's Zwift channel; expecting cadence"),
|
||||
Err(e) => tracing::warn!(error = %e, "Zwift handshake write failed; expect no cadence"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_capabilities(
|
||||
peripheral: &Peripheral,
|
||||
find: &impl Fn(Uuid) -> Option<Characteristic>,
|
||||
|
||||
@@ -57,7 +57,7 @@ pub mod scan;
|
||||
pub mod uuids;
|
||||
pub mod zwift;
|
||||
|
||||
pub use click::{ClickClient, ClickConfig, ClickEvent};
|
||||
pub use click::{ClickClient, ClickConfig, ClickEvent, PodSelector};
|
||||
pub use capabilities::{
|
||||
FitnessMachineFeature, InclinationRange, PowerRange, ResistanceLevelRange, TrainerCapabilities,
|
||||
UnsupportedTarget,
|
||||
@@ -74,5 +74,5 @@ pub use scan::{
|
||||
pub use uuids::FITNESS_MACHINE_SERVICE;
|
||||
pub use zwift::{
|
||||
Button, ButtonBitmask, ClickButtons, DeviceKind as ZwiftDeviceKind,
|
||||
MessageType as ZwiftMessageType,
|
||||
MessageType as ZwiftMessageType, PodId,
|
||||
};
|
||||
|
||||
+40
-4
@@ -59,6 +59,19 @@ impl DiscoveredDevice {
|
||||
.and_then(|d| zwift::DeviceKind::from_manufacturer_data(d))
|
||||
}
|
||||
|
||||
/// True when this is a controller pod the app can drive — a Click, not a
|
||||
/// trainer that merely speaks the Zwift protocol. The D100 advertises the
|
||||
/// custom service with no manufacturer data, which is exactly the case this
|
||||
/// separates out.
|
||||
pub fn is_click_pod(&self) -> bool {
|
||||
self.zwift_kind().is_some_and(|k| k.is_click())
|
||||
}
|
||||
|
||||
/// Which pod of a pair this is, where the advertisement says so (FR-1.4).
|
||||
pub fn pod_id(&self) -> Option<zwift::PodId> {
|
||||
self.zwift_kind().and_then(|k| k.pod_id())
|
||||
}
|
||||
|
||||
/// Best-effort human label.
|
||||
pub fn label(&self) -> String {
|
||||
match &self.name {
|
||||
@@ -224,7 +237,29 @@ pub async fn find_peripheral(
|
||||
selector: &TrainerSelector,
|
||||
timeout: Duration,
|
||||
) -> Result<Peripheral, FtmsError> {
|
||||
let kind = selector.scan_kind();
|
||||
find_matching(
|
||||
adapter,
|
||||
selector.scan_kind(),
|
||||
timeout,
|
||||
&selector.describe(),
|
||||
|d| selector.matches(d),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Scan until a peripheral satisfying `matches` appears, or `timeout` elapses.
|
||||
///
|
||||
/// The predicate form exists because a controller is not selected the way a
|
||||
/// trainer is: a Click pod is picked out by the type byte in its manufacturer
|
||||
/// data (§2.3.1), which no `TrainerSelector` variant can express. `what`
|
||||
/// describes the search well enough to read in an error message.
|
||||
pub async fn find_matching(
|
||||
adapter: &Adapter,
|
||||
kind: ScanKind,
|
||||
timeout: Duration,
|
||||
what: &str,
|
||||
matches: impl Fn(&DiscoveredDevice) -> bool,
|
||||
) -> Result<Peripheral, FtmsError> {
|
||||
adapter.start_scan(kind.filter()).await?;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
@@ -234,12 +269,13 @@ pub async fn find_peripheral(
|
||||
'search: loop {
|
||||
for p in adapter.peripherals().await?.into_iter() {
|
||||
if let Some(d) = describe(&p).await {
|
||||
if selector.matches(&d) {
|
||||
if matches(&d) {
|
||||
tracing::info!(
|
||||
address = %d.address,
|
||||
name = d.label(),
|
||||
rssi = ?d.rssi,
|
||||
"matched trainer"
|
||||
%what,
|
||||
"matched peripheral"
|
||||
);
|
||||
found = Some(p);
|
||||
break 'search;
|
||||
@@ -256,7 +292,7 @@ pub async fn find_peripheral(
|
||||
tracing::debug!(error = %e, "stop_scan failed");
|
||||
}
|
||||
|
||||
found.ok_or_else(|| FtmsError::NotFound(selector.describe()))
|
||||
found.ok_or_else(|| FtmsError::NotFound(what.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+132
-10
@@ -103,17 +103,73 @@ pub fn is_zwift_uuid(uuid: Uuid) -> bool {
|
||||
// Device discrimination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Which of a pair of pods this is.
|
||||
///
|
||||
/// A Click v2 is **two independent peripherals** (§2.3.1, FR-1.4), each with
|
||||
/// its own address, battery and link — so the app has to track two of
|
||||
/// everything, and the rider has to be told which of the two is missing.
|
||||
///
|
||||
/// They are named for the **shift paddle** each one carries rather than for the
|
||||
/// side of the bar they clamp to. Left and right would be a guess: nothing in
|
||||
/// the advertisement says which end of the handlebar a pod is on, and a rider
|
||||
/// who mounts them the other way round makes the label a lie. The paddle is
|
||||
/// printed on the pod, so `+` and `−` are checkable by eye and by pressing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PodId {
|
||||
/// The pod carrying the `−` paddle: shift down, and the D-pad.
|
||||
Minus,
|
||||
/// The pod carrying the `+` paddle: shift up, and the lettered face buttons.
|
||||
Plus,
|
||||
}
|
||||
|
||||
impl PodId {
|
||||
pub const BOTH: [PodId; 2] = [PodId::Minus, PodId::Plus];
|
||||
|
||||
pub fn other(self) -> Self {
|
||||
match self {
|
||||
PodId::Minus => PodId::Plus,
|
||||
PodId::Plus => PodId::Minus,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowercase, stable — used as a key by the app and the webview.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
PodId::Minus => "minus",
|
||||
PodId::Plus => "plus",
|
||||
}
|
||||
}
|
||||
|
||||
/// How the pod is written on screen.
|
||||
pub fn symbol(self) -> &'static str {
|
||||
match self {
|
||||
PodId::Minus => "−",
|
||||
PodId::Plus => "+",
|
||||
}
|
||||
}
|
||||
|
||||
/// The paddle whose press proves a pod is the one we filed it under.
|
||||
pub fn paddle(self) -> Button {
|
||||
match self {
|
||||
PodId::Minus => Button::Minus,
|
||||
PodId::Plus => Button::Plus,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What kind of Zwift peripheral is advertising, from the first byte of its
|
||||
/// manufacturer data (§2.3.1).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DeviceKind {
|
||||
/// `0x09` — Click v1. Unencrypted; the easy case.
|
||||
ClickV1,
|
||||
/// `0x0A` / `0x0B` — Click v2. The target hardware (§2.3).
|
||||
ClickV2,
|
||||
/// `0x03` — Play, left pod. *Unverified.*
|
||||
/// `0x0B` / `0x0A` — Click v2. The target hardware (§2.3). **One byte per
|
||||
/// pod, not a version marker** — confirmed on our own pair, where
|
||||
/// `f4:c4:59:03:a1:8e` advertises `0x0B` and `c0:4a:0e:f9:a8:78` `0x0A`.
|
||||
ClickV2(PodId),
|
||||
/// `0x03` — Play, left-hand pod (`−` paddle). *Unverified.*
|
||||
PlayLeft,
|
||||
/// `0x02` — Play, right pod. *Unverified.*
|
||||
/// `0x02` — Play, right-hand pod (`+` paddle). *Unverified.*
|
||||
PlayRight,
|
||||
/// A Zwift device we do not have a byte for. Carries the raw value so the
|
||||
/// probe can report it rather than swallow it.
|
||||
@@ -122,10 +178,21 @@ pub enum DeviceKind {
|
||||
|
||||
impl DeviceKind {
|
||||
/// Classify from the device type byte.
|
||||
///
|
||||
/// Which v2 byte is which pod is a **starting guess**: §2.3.1 confirms one
|
||||
/// byte per pod, but no capture names them. It is read by analogy with
|
||||
/// Play, whose `+` pod is the lower byte (`0x02`) and `−` pod the higher
|
||||
/// (`0x03`), and it agrees with the one thing we did observe — the D-pad
|
||||
/// frames, which live beside the `−` paddle, came from the `0x0B` pod.
|
||||
///
|
||||
/// The guess does not have to be right. The app confirms each pod the first
|
||||
/// time a paddle is pressed on it, and swaps the pair if the pods answer to
|
||||
/// the other name.
|
||||
pub fn from_type_byte(b: u8) -> Self {
|
||||
match b {
|
||||
0x09 => DeviceKind::ClickV1,
|
||||
0x0A | 0x0B => DeviceKind::ClickV2,
|
||||
0x0B => DeviceKind::ClickV2(PodId::Minus),
|
||||
0x0A => DeviceKind::ClickV2(PodId::Plus),
|
||||
0x03 => DeviceKind::PlayLeft,
|
||||
0x02 => DeviceKind::PlayRight,
|
||||
other => DeviceKind::Unknown(other),
|
||||
@@ -140,13 +207,28 @@ impl DeviceKind {
|
||||
|
||||
/// True for a Click of either generation — the devices this app drives.
|
||||
pub fn is_click(self) -> bool {
|
||||
matches!(self, DeviceKind::ClickV1 | DeviceKind::ClickV2)
|
||||
matches!(self, DeviceKind::ClickV1 | DeviceKind::ClickV2(_))
|
||||
}
|
||||
|
||||
/// Which pod of a pair this is, where the advertisement says so.
|
||||
///
|
||||
/// `None` for a v1 Click, which is a single unit, and for anything we
|
||||
/// cannot place — a pod with no id is still connectable, it just cannot be
|
||||
/// filed under one of the two slots on the connection screen.
|
||||
pub fn pod_id(self) -> Option<PodId> {
|
||||
match self {
|
||||
DeviceKind::ClickV2(side) => Some(side),
|
||||
DeviceKind::PlayLeft => Some(PodId::Minus),
|
||||
DeviceKind::PlayRight => Some(PodId::Plus),
|
||||
DeviceKind::ClickV1 | DeviceKind::Unknown(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn describe(self) -> String {
|
||||
match self {
|
||||
DeviceKind::ClickV1 => "Zwift Click v1".into(),
|
||||
DeviceKind::ClickV2 => "Zwift Click v2".into(),
|
||||
DeviceKind::ClickV2(PodId::Minus) => "Zwift Click v2 (− pod)".into(),
|
||||
DeviceKind::ClickV2(PodId::Plus) => "Zwift Click v2 (+ pod)".into(),
|
||||
DeviceKind::PlayLeft => "Zwift Play (left)".into(),
|
||||
DeviceKind::PlayRight => "Zwift Play (right)".into(),
|
||||
DeviceKind::Unknown(b) => format!("unrecognised Zwift device (type byte 0x{b:02x})"),
|
||||
@@ -620,8 +702,17 @@ mod tests {
|
||||
#[test]
|
||||
fn device_type_bytes_classify() {
|
||||
assert_eq!(DeviceKind::from_type_byte(0x09), DeviceKind::ClickV1);
|
||||
assert_eq!(DeviceKind::from_type_byte(0x0A), DeviceKind::ClickV2);
|
||||
assert_eq!(DeviceKind::from_type_byte(0x0B), DeviceKind::ClickV2);
|
||||
// §2.3.1: one byte per pod, not a version marker. The two v2 bytes must
|
||||
// therefore land on *different* pods — a mapping that collapsed them
|
||||
// would put both pods in one slot and lose the other entirely.
|
||||
assert_eq!(
|
||||
DeviceKind::from_type_byte(0x0B),
|
||||
DeviceKind::ClickV2(PodId::Minus)
|
||||
);
|
||||
assert_eq!(
|
||||
DeviceKind::from_type_byte(0x0A),
|
||||
DeviceKind::ClickV2(PodId::Plus)
|
||||
);
|
||||
assert_eq!(DeviceKind::from_type_byte(0x02), DeviceKind::PlayRight);
|
||||
assert_eq!(DeviceKind::from_type_byte(0xFE), DeviceKind::Unknown(0xFE));
|
||||
assert!(DeviceKind::from_type_byte(0x0B).is_click());
|
||||
@@ -632,11 +723,42 @@ mod tests {
|
||||
fn manufacturer_data_needs_at_least_one_byte() {
|
||||
assert_eq!(
|
||||
DeviceKind::from_manufacturer_data(&[0x0A, 0x00]),
|
||||
Some(DeviceKind::ClickV2)
|
||||
Some(DeviceKind::ClickV2(PodId::Plus))
|
||||
);
|
||||
assert_eq!(DeviceKind::from_manufacturer_data(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pair_of_pods_covers_both_ids_and_nothing_else_claims_one() {
|
||||
let minus = DeviceKind::from_type_byte(0x0B).pod_id();
|
||||
let plus = DeviceKind::from_type_byte(0x0A).pod_id();
|
||||
assert_eq!(minus, Some(PodId::Minus));
|
||||
assert_eq!(plus, Some(PodId::Plus));
|
||||
assert_eq!(minus.map(PodId::other), plus);
|
||||
|
||||
// A v1 Click is a single unit: giving it an id would fill a slot the
|
||||
// rider has no pod for.
|
||||
assert_eq!(DeviceKind::ClickV1.pod_id(), None);
|
||||
assert_eq!(DeviceKind::Unknown(0x77).pod_id(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pod_names_are_the_keys_the_webview_switches_on() {
|
||||
assert_eq!(PodId::Minus.as_str(), "minus");
|
||||
assert_eq!(PodId::Plus.as_str(), "plus");
|
||||
assert_eq!(PodId::BOTH, [PodId::Minus, PodId::Plus]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pod_is_named_for_the_paddle_that_proves_which_one_it_is() {
|
||||
// The whole point of naming them `+` and `−` rather than left and
|
||||
// right: pressing the paddle settles the question, and no guess about
|
||||
// how the pods are mounted can make the label wrong.
|
||||
assert_eq!(PodId::Plus.paddle(), Button::Plus);
|
||||
assert_eq!(PodId::Minus.paddle(), Button::Minus);
|
||||
assert_eq!(PodId::Plus.symbol(), "+");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_is_ride_on_plus_suffix() {
|
||||
assert_eq!(
|
||||
|
||||
+174
-6
@@ -30,17 +30,35 @@
|
||||
//! wrong: it would let a rider spin up a 15% wall at 45 km/h without ever
|
||||
//! producing the watts that requires, and gradient would become decoration.
|
||||
//!
|
||||
//! The loop closes on cadence. For a given road speed, the selected gear
|
||||
//! # What commands the trainer
|
||||
//!
|
||||
//! [`Gearing::load_gradient_pct`] — a direct computation, not a feedback loop.
|
||||
//! A gear changes the leverage between crank and wheel, so the load it implies
|
||||
//! is the road force scaled by the gear's development relative to the bike's
|
||||
//! real one. Shifting therefore lands on the pedals the moment it happens.
|
||||
//!
|
||||
//! An earlier design servoed the load toward a cadence target instead. It was
|
||||
//! abandoned because a servo can only find the right load by first being wrong:
|
||||
//! at a gentle enough gain not to oscillate against the rider's own cadence
|
||||
//! variation, a shift took seconds to be felt, which is not what a shift is.
|
||||
//!
|
||||
//! # What the cadence loop is still for
|
||||
//!
|
||||
//! The servo ([`Gearing::update`], [`Gearing::correction_pct`]) survives as a
|
||||
//! *readout*, not a control input. For a given road speed the selected gear
|
||||
//! implies a cadence:
|
||||
//!
|
||||
//! ```text
|
||||
//! target_cadence = road_speed × 60 / development
|
||||
//! ```
|
||||
//!
|
||||
//! If the rider is turning faster than that they are spinning out, so add load;
|
||||
//! slower, and they are grinding, so shed it. The trainer's own cadence reading
|
||||
//! closes the loop, which is why this had to wait for the Zwift-channel decode
|
||||
//! (§2.1.1) — FTMS on this trainer reports no cadence at all.
|
||||
//! and the signed distance between that and the rider's actual cadence says
|
||||
//! whether they are spinning out or grinding — worth showing them, and worth
|
||||
//! recording, but no longer added to what the trainer is asked for. Nothing
|
||||
//! reads it into the control path; see `RideSession::tick`.
|
||||
//!
|
||||
//! Either way this had to wait for the Zwift-channel decode (§2.1.1) — FTMS on
|
||||
//! this trainer reports no cadence at all.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -48,6 +66,14 @@ use serde::{Deserialize, Serialize};
|
||||
/// enough to recover a spun-out descent, bounded so a runaway loop cannot ask
|
||||
/// for a cliff.
|
||||
const MAX_CORRECTION_PCT: f32 = 8.0;
|
||||
/// Widest load the gear model may command, in gradient percent.
|
||||
///
|
||||
/// Separate from the servo's bound, and deliberately so: this one caps what the
|
||||
/// rider is *asked to push against*, not how far a feedback loop may wander. A
|
||||
/// top gear on a steep climb legitimately reaches well past the servo's range,
|
||||
/// and 16% is about the steepest thing worth reproducing under a rider before
|
||||
/// it stops being training and starts being a wall.
|
||||
const MAX_LOAD_PCT: f32 = 16.0;
|
||||
/// Gradient percent applied per rpm of cadence error, per second. Deliberately
|
||||
/// gentle: shifting should settle over a second or two, not snap, and an
|
||||
/// aggressive gain oscillates against the rider's own cadence variation.
|
||||
@@ -174,6 +200,12 @@ impl Gearing {
|
||||
self.correction_pct += error * GAIN_PCT_PER_RPM_S * dt;
|
||||
self.correction_pct =
|
||||
self.correction_pct.clamp(-MAX_CORRECTION_PCT, MAX_CORRECTION_PCT);
|
||||
} else {
|
||||
// Inside the deadband, bleed off. Holding the last value
|
||||
// instead would freeze whatever transient the rider passed
|
||||
// through on the way to riding the gear correctly, and
|
||||
// leave the readout claiming an error that is over.
|
||||
self.decay(dt);
|
||||
}
|
||||
}
|
||||
None => self.decay(dt),
|
||||
@@ -365,13 +397,149 @@ impl Gearing {
|
||||
let scaled = f * (self.development_m() / physical);
|
||||
let pct = scaled / (mass * crate::physics::GRAVITY) * 100.0;
|
||||
if pct.is_finite() {
|
||||
pct.clamp(-MAX_CORRECTION_PCT * 2.0, MAX_CORRECTION_PCT * 2.0)
|
||||
pct.clamp(-MAX_LOAD_PCT, MAX_LOAD_PCT)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Gearing {
|
||||
/// The watts the road is asking for at this speed — what to command when
|
||||
/// the load is expressed as power rather than slope.
|
||||
///
|
||||
/// Simply `F × v`, because that is what power is. Note there is no gear
|
||||
/// term: at a *given speed* the power required is the same in every gear,
|
||||
/// which is not a flaw in the model but the definition of a gear. The gear
|
||||
/// enters through the speed, since speed is cadence × development — so
|
||||
/// shifting up at the same cadence raises the speed and with it the demand,
|
||||
/// while what changes at the pedal is the force (see
|
||||
/// [`Gearing::pedal_force_n`]) and the cadence needed to hold it.
|
||||
///
|
||||
/// On a trainer whose power target is a ceiling rather than a setpoint,
|
||||
/// commanding this makes the ride self-correcting: the rider accelerates
|
||||
/// when they exceed it and slows when they fall short, and next tick it is
|
||||
/// recomputed at the new speed.
|
||||
pub fn load_power_w(&self, resistive_n: f32, speed_mps: f32) -> f32 {
|
||||
let f = if resistive_n.is_finite() { resistive_n } else { 0.0 };
|
||||
let v = if speed_mps.is_finite() { speed_mps.max(0.0) } else { 0.0 };
|
||||
let w = f * v;
|
||||
// A descent asks for negative power, which no brake can supply — the
|
||||
// honest floor is zero, and the safety limits raise it to whatever the
|
||||
// trainer's minimum really is.
|
||||
if w.is_finite() { w.max(0.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Force the rider's leg feels at the pedal, newtons.
|
||||
///
|
||||
/// Work is force × distance on both sides of the crank. Over one crank
|
||||
/// revolution the pedal travels `2πr` and the bike travels `development`,
|
||||
/// and the work done is the same quantity seen twice:
|
||||
///
|
||||
/// ```text
|
||||
/// F_pedal × 2πr = F_road × development
|
||||
/// ```
|
||||
///
|
||||
/// Note which development appears. The trainer is asked for
|
||||
/// `F_road × (virtual / physical)` at the wheel, and the rider's crank
|
||||
/// turns through the *physical* gear, so the two physical terms cancel and
|
||||
/// what the leg feels is `F_road × virtual / 2πr` — precisely what a real
|
||||
/// bike in that gear would feel. That the virtual gear lands on the pedal
|
||||
/// exactly as a real one would is the check that the whole scheme is
|
||||
/// mechanically honest, not merely plausible.
|
||||
///
|
||||
/// Purely a readout: nothing downstream of the wheel changes what is
|
||||
/// commanded. What it is *for* is judging a gear ladder — 40 N is freewheel
|
||||
/// -light and 600 N is a gear nobody can turn, and neither is visible from
|
||||
/// a gradient.
|
||||
pub fn pedal_force_n(&self, resistive_n: f32, crank_length_m: f32) -> f32 {
|
||||
let crank = if crank_length_m.is_finite() && crank_length_m > 0.01 {
|
||||
crank_length_m
|
||||
} else {
|
||||
0.1725
|
||||
};
|
||||
let f = if resistive_n.is_finite() { resistive_n } else { 0.0 };
|
||||
let pedal = f * self.development_m() / (std::f32::consts::TAU * crank);
|
||||
if pedal.is_finite() {
|
||||
pedal
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pedal_tests {
|
||||
use super::*;
|
||||
use crate::physics::resistive_force_n;
|
||||
use crate::types::RiderConfig;
|
||||
|
||||
#[test]
|
||||
fn a_longer_gear_is_heavier_at_the_pedal() {
|
||||
let c = RiderConfig::default();
|
||||
let f = resistive_force_n(8.0, 3.0, &c);
|
||||
let mut g = Gearing::default();
|
||||
g.set_gear(1);
|
||||
let easy = g.pedal_force_n(f, c.crank_length_m);
|
||||
g.set_gear(12);
|
||||
let hard = g.pedal_force_n(f, c.crank_length_m);
|
||||
assert!(hard > easy * 3.0, "top gear must be far heavier: {hard} vs {easy}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longer_cranks_lighten_the_pedal() {
|
||||
// More leverage, less force, same work — the whole reason crank length
|
||||
// is a number worth knowing.
|
||||
let c = RiderConfig::default();
|
||||
let f = resistive_force_n(8.0, 3.0, &c);
|
||||
let g = Gearing::default();
|
||||
let short = g.pedal_force_n(f, 0.165);
|
||||
let long = g.pedal_force_n(f, 0.175);
|
||||
assert!(long < short, "longer cranks must feel lighter: {long} vs {short}");
|
||||
// Inverse in the radius, so the ratio is exact.
|
||||
assert!((short / long - 0.175 / 0.165).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_work_balance_holds_across_the_crank() {
|
||||
// The identity the function encodes: over one crank revolution the
|
||||
// rider does the same work whichever side of the crank you measure.
|
||||
let c = RiderConfig::default();
|
||||
let mut g = Gearing::default();
|
||||
g.set_gear(8);
|
||||
let road_n = resistive_force_n(8.0, 4.0, &c);
|
||||
let pedal_n = g.pedal_force_n(road_n, c.crank_length_m);
|
||||
|
||||
let at_the_pedal = pedal_n * std::f32::consts::TAU * c.crank_length_m;
|
||||
let at_the_road = road_n * g.development_m();
|
||||
assert!(
|
||||
(at_the_pedal - at_the_road).abs() < at_the_road * 1e-4,
|
||||
"{at_the_pedal} J at the pedal vs {at_the_road} J at the road"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_road_in_a_middling_gear_is_a_force_a_person_can_produce() {
|
||||
// Sanity on the absolute scale, which is the only thing that makes this
|
||||
// readout worth showing: easy flat riding should be tens of newtons.
|
||||
let c = RiderConfig::default();
|
||||
let g = Gearing::default();
|
||||
let pedal = g.pedal_force_n(resistive_force_n(8.0, 0.0, &c), c.crank_length_m);
|
||||
assert!(
|
||||
(20.0..250.0).contains(&pedal),
|
||||
"flat at 29 km/h should be light but not nothing: {pedal} N"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absurd_cranks_do_not_produce_absurd_forces() {
|
||||
let g = Gearing::default();
|
||||
assert!(g.pedal_force_n(100.0, 0.0).is_finite());
|
||||
assert!(g.pedal_force_n(100.0, f32::NAN).is_finite());
|
||||
assert_eq!(g.pedal_force_n(f32::NAN, 0.1725), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod load_tests {
|
||||
use super::*;
|
||||
|
||||
+88
-28
@@ -11,7 +11,16 @@
|
||||
//! F_gravity = m × g × sin(atan(gradient))
|
||||
//! F_rolling = m × g × Crr × cos(atan(gradient))
|
||||
//! F_aero = ½ × ρ × CdA × v²
|
||||
//! a = (F_propulsive − F_gravity − F_rolling − F_aero) / m
|
||||
//! F_loss = rolling_loss_w / max(v, v_min)
|
||||
//! a = (F_propulsive − F_gravity − F_rolling − F_aero − F_loss) / m
|
||||
//! ```
|
||||
//!
|
||||
//! `F_loss` is the trainer's own fixed drag, held as a power because that is
|
||||
//! how it presents. Dividing by speed makes it small when moving fast and large
|
||||
//! when slowing, which is what brings a coast to a halt instead of an
|
||||
//! asymptote.
|
||||
//!
|
||||
//! ```text
|
||||
//! v += a × Δt (clamped at ≥ 0)
|
||||
//! ```
|
||||
|
||||
@@ -66,9 +75,15 @@ pub struct PhysicsState {
|
||||
impl PhysicsState {
|
||||
/// Advance the simulation by `dt` seconds under `power_w` at `gradient_pct`.
|
||||
///
|
||||
/// Must model inertia (FR-7.3) — speed accelerates toward equilibrium
|
||||
/// rather than snapping to it — and must never produce negative speed,
|
||||
/// NaN, or unbounded values for any finite input.
|
||||
/// **Not on the ride path.** The ride takes its speed from the drivetrain
|
||||
/// (`RideSession::tick`), which has no inertia because a chain has none.
|
||||
/// This integrator is retained as the reference implementation of the force
|
||||
/// balance: it is what [`equilibrium_speed_mps`] — the coasting target — is
|
||||
/// cross-validated against, and its tests are the coverage for [`Forces`].
|
||||
/// Do not reintroduce it as a speed source without saying why.
|
||||
///
|
||||
/// Never produces negative speed, NaN, or unbounded values for any finite
|
||||
/// input.
|
||||
pub fn step(&mut self, power_w: f32, gradient_pct: f32, cfg: &RiderConfig, dt: f32) {
|
||||
let dt = sanitise(dt, 0.0).clamp(0.0, MAX_DT_S);
|
||||
if dt <= 0.0 {
|
||||
@@ -116,32 +131,64 @@ impl PhysicsState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the modelled speed toward one the trainer actually measured.
|
||||
/// Advance the ride at a speed the drivetrain dictates, rather than one
|
||||
/// integrated from the force balance.
|
||||
///
|
||||
/// The model knows what a bike *would* do for a given power and gradient;
|
||||
/// the trainer knows how fast its flywheel is really turning. Neither alone
|
||||
/// is right on a single-cog drivetrain: pure physics lets the rider "coast"
|
||||
/// downhill at 39 km/h while spinning out against no resistance, and pure
|
||||
/// trainer speed would cap descents at whatever cadence the one gear allows.
|
||||
/// On a bike the wheel is locked to the cranks: road speed *is* cadence ×
|
||||
/// development, and no force balance gets a say in it. When the trainer is
|
||||
/// reporting cadence this is the honest speed, and the force balance moves
|
||||
/// to the other side of the loop — it decides how *hard* that cadence is to
|
||||
/// hold (see [`crate::gearing::Gearing::load_gradient_pct`]), not how fast
|
||||
/// it carries the rider.
|
||||
///
|
||||
/// `weight` is the fraction of the gap closed **per second**, so the result
|
||||
/// does not depend on tick rate — a 4 Hz and a 10 Hz loop converge the same.
|
||||
pub fn correct_toward(&mut self, measured_mps: f32, weight: f32, dt: f32) {
|
||||
if !measured_mps.is_finite() || measured_mps < 0.0 || !dt.is_finite() || dt <= 0.0 {
|
||||
/// `tau_s` is the time constant of a first-order approach to `target_mps`.
|
||||
/// Cadence arrives a few times a second and varies within a single pedal
|
||||
/// stroke; stepping the speed straight onto it would make the readout jump
|
||||
/// and the distance integral gritty. Small enough that a real change of
|
||||
/// pace still lands promptly.
|
||||
pub fn advance_at(&mut self, target_mps: f32, gradient_pct: f32, tau_s: f32, dt: f32) {
|
||||
let dt = sanitise(dt, 0.0).clamp(0.0, MAX_DT_S);
|
||||
if dt <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let w = weight.clamp(0.0, 1.0);
|
||||
if w == 0.0 {
|
||||
return;
|
||||
}
|
||||
// Fraction of the gap to close this tick, from the per-second rate.
|
||||
let alpha = 1.0 - (1.0 - w).powf(dt.min(MAX_DT_S));
|
||||
let corrected = self.speed_mps + (measured_mps - self.speed_mps) * alpha;
|
||||
if corrected.is_finite() {
|
||||
self.speed_mps = corrected.clamp(0.0, MAX_SPEED_MPS);
|
||||
let target = sanitise(target_mps, 0.0).clamp(0.0, MAX_SPEED_MPS);
|
||||
let tau = sanitise(tau_s, 0.0).max(0.0);
|
||||
|
||||
let v0 = self.speed_mps.clamp(0.0, MAX_SPEED_MPS);
|
||||
let v1 = if tau <= 0.0 {
|
||||
target
|
||||
} else {
|
||||
// Exact solution of the first-order lag over the step, so the
|
||||
// result does not depend on tick rate.
|
||||
let alpha = 1.0 - (-dt / tau).exp();
|
||||
v0 + (target - v0) * alpha
|
||||
};
|
||||
let v1 = if v1.is_finite() { v1.clamp(0.0, MAX_SPEED_MPS) } else { 0.0 };
|
||||
// An exponential approach to zero never arrives, and a ride left
|
||||
// reporting 6e-45 m/s is stopped in every sense except the one the
|
||||
// readout uses. Below a millimetre a second, call it stopped.
|
||||
self.speed_mps = if target <= 0.0 && v1 < 0.001 { 0.0 } else { v1 };
|
||||
|
||||
let gradient =
|
||||
sanitise(gradient_pct, 0.0).clamp(-MAX_ABS_GRADIENT_PCT, MAX_ABS_GRADIENT_PCT);
|
||||
let sin_theta = (gradient / 100.0).atan().sin();
|
||||
|
||||
let ds = (0.5 * (v0 + self.speed_mps) * dt) as f64;
|
||||
self.distance_m += ds;
|
||||
let climb = ds as f32 * sin_theta;
|
||||
if climb > 0.0 {
|
||||
self.elevation_gain_m += climb;
|
||||
}
|
||||
}
|
||||
|
||||
// There was a `correct_toward` here, blending the modelled speed toward the
|
||||
// trainer's own. It is gone on purpose. A flywheel keeps turning long after
|
||||
// the rider stops, so its speed is not evidence of road speed, and blending
|
||||
// toward it held the ride at 22 km/h up a 3.5% climb on zero watts —
|
||||
// the model wanted to decelerate and the flywheel outvoted it. Speed now
|
||||
// comes from the drivetrain, or from the road when the rider is coasting;
|
||||
// see `RideSession::tick`.
|
||||
|
||||
pub fn speed_kph(&self) -> f32 {
|
||||
self.speed_mps * 3.6
|
||||
}
|
||||
@@ -160,6 +207,8 @@ struct Forces {
|
||||
resistive_n: f32,
|
||||
/// `½ρ·CdA`; multiplied by v² to give drag.
|
||||
drag_k: f32,
|
||||
/// Fixed power loss, watts. Becomes a force by dividing by speed.
|
||||
loss_w: f32,
|
||||
mass_kg: f32,
|
||||
}
|
||||
|
||||
@@ -184,6 +233,7 @@ impl Forces {
|
||||
|
||||
Self {
|
||||
wheel_power_w: power * efficiency,
|
||||
loss_w: sanitise(cfg.rolling_loss_w, 0.0).max(0.0),
|
||||
sin_theta: theta.sin(),
|
||||
resistive_n: mass * GRAVITY * (theta.sin() + crr * theta.cos()),
|
||||
drag_k: 0.5 * rho * cda,
|
||||
@@ -191,12 +241,18 @@ impl Forces {
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixed loss as a force at this speed. Divided by the same floor the
|
||||
/// propulsive term uses, so neither blows up at a standstill.
|
||||
fn loss_n(&self, v: f32) -> f32 {
|
||||
self.loss_w / v.max(MIN_SPEED_MPS)
|
||||
}
|
||||
|
||||
fn acceleration(&self, v: f32) -> f32 {
|
||||
let propulsive = self.wheel_power_w / v.max(MIN_SPEED_MPS);
|
||||
// Rolling resistance and gravity are folded together, so at a
|
||||
// standstill on the flat the net is a small negative that the ≥0 clamp
|
||||
// absorbs — the rider does not roll backwards.
|
||||
let net = propulsive - self.resistive_n - self.drag_k * v * v;
|
||||
let net = propulsive - self.resistive_n - self.drag_k * v * v - self.loss_n(v);
|
||||
let a = net / self.mass_kg;
|
||||
if a.is_finite() {
|
||||
a
|
||||
@@ -263,7 +319,7 @@ pub fn resistive_force_n(speed_mps: f32, gradient_pct: f32, cfg: &RiderConfig) -
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let f = forces.resistive_n + forces.drag_k * v * v;
|
||||
let f = forces.resistive_n + forces.drag_k * v * v + forces.loss_n(v);
|
||||
if f.is_finite() {
|
||||
f
|
||||
} else {
|
||||
@@ -305,14 +361,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn equilibrium_matches_hand_computed_flat_case() {
|
||||
// 250 W on the flat with the default rider: solve P·η = F_roll·v + k·v³.
|
||||
// 250 W on the flat with the default rider. This is a *power* balance
|
||||
// — the force balance multiplied through by v — so the residual is in
|
||||
// watts, and the fixed loss enters it as itself rather than divided by
|
||||
// speed: P·η = loss + F_roll·v + k·v³.
|
||||
let c = cfg();
|
||||
let v = equilibrium_speed_mps(250.0, 0.0, &c);
|
||||
let m = c.total_mass_kg();
|
||||
let f_roll = m * GRAVITY * c.crr;
|
||||
let drag = 0.5 * c.air_density * c.cda;
|
||||
let balance = 250.0 * c.drivetrain_efficiency - (f_roll * v + drag * v * v * v);
|
||||
assert!(balance.abs() < 0.5, "residual force {balance} N at v={v}");
|
||||
let balance =
|
||||
250.0 * c.drivetrain_efficiency - (c.rolling_loss_w + f_roll * v + drag * v * v * v);
|
||||
assert!(balance.abs() < 0.5, "residual power {balance} W at v={v}");
|
||||
// Sanity: a 75 kg rider at 250 W on the flat sits around 40 km/h.
|
||||
assert!((35.0..45.0).contains(&(v * 3.6)), "{} km/h", v * 3.6);
|
||||
}
|
||||
|
||||
+797
-55
@@ -8,7 +8,8 @@ use crate::gearing::Gearing;
|
||||
use crate::physics::PhysicsState;
|
||||
use crate::profile::{Position, Profile};
|
||||
use crate::types::{
|
||||
ControlMode, ControlTarget, RideSnapshot, RiderConfig, SafetyLimits, Telemetry,
|
||||
ControlMode, ControlTarget, LoadChannel, RideSnapshot, RiderConfig, SafetyLimits, SpeedSource,
|
||||
Telemetry,
|
||||
};
|
||||
|
||||
/// Something the session wants the outside world to do or know about.
|
||||
@@ -38,6 +39,40 @@ pub enum RideStatus {
|
||||
/// inside that without a timer, and avoids churning the trainer with values it
|
||||
/// cannot resolve anyway.
|
||||
const GRADIENT_EPSILON_PCT: f32 = 0.05;
|
||||
/// Grid the *computed* road load is snapped to, watts.
|
||||
///
|
||||
/// The road power goes as v³, so an unquantised target would change on nearly
|
||||
/// every tick as the speed wanders and spend the whole write budget on
|
||||
/// differences no rider can feel. Snapping to five watts rate-limits the writes
|
||||
/// by construction, without an epsilon in `changed_meaningfully` — which would
|
||||
/// also have coarsened the deliberate wattages an ERG profile asks for, and
|
||||
/// those must arrive exactly as written.
|
||||
const LOAD_POWER_STEP_W: f32 = 5.0;
|
||||
|
||||
/// Time constant for the speed following cadence × gear, seconds.
|
||||
///
|
||||
/// Cadence arrives a few times a second and genuinely varies within one pedal
|
||||
/// stroke, so the speed is filtered rather than stepped. Short enough that a
|
||||
/// shift or a surge shows up almost at once, long enough that the readout does
|
||||
/// not flicker at the rate the pedals go round.
|
||||
const DRIVETRAIN_TAU_S: f32 = 0.8;
|
||||
|
||||
/// Above this the rider is driving the bike; at or below it they are coasting
|
||||
/// and the flywheel is merely spinning down. Low enough that soft pedalling
|
||||
/// still counts as riding, high enough that a trainer reporting a few stray
|
||||
/// watts at rest does not.
|
||||
///
|
||||
/// This is the only reliable way to tell the two apart on this hardware:
|
||||
/// cadence cannot, because the flywheel keeps the cranks turning for a rider
|
||||
/// who has stopped.
|
||||
const COASTING_POWER_W: f32 = 15.0;
|
||||
|
||||
/// Time constant for the speed settling to what a gradient sustains on no
|
||||
/// power, seconds. Longer than the drivetrain's, because a rider easing off
|
||||
/// should feel the bike run down rather than hit a wall — but nothing like the
|
||||
/// minutes real momentum would give them, which on a climb is the difference
|
||||
/// between stopping and freewheeling uphill for a quarter of a kilometre.
|
||||
const COAST_TAU_S: f32 = 1.5;
|
||||
|
||||
pub struct RideSession {
|
||||
pub config: RiderConfig,
|
||||
@@ -55,6 +90,8 @@ pub struct RideSession {
|
||||
/// Virtual gears (FR-4.1): changes how hard the pedals feel, not how
|
||||
/// fast the rider travels for a given power.
|
||||
pub gearing: Gearing,
|
||||
/// Which rule decided the speed on the last tick. Diagnostic only.
|
||||
speed_source: SpeedSource,
|
||||
elapsed_ms: u64,
|
||||
last_target: Option<ControlTarget>,
|
||||
}
|
||||
@@ -72,6 +109,7 @@ impl RideSession {
|
||||
manual_resistance: 0,
|
||||
erg_watts: 150,
|
||||
gearing: Gearing::default(),
|
||||
speed_source: SpeedSource::Stopped,
|
||||
elapsed_ms: 0,
|
||||
last_target: None,
|
||||
}
|
||||
@@ -154,6 +192,49 @@ impl RideSession {
|
||||
self.last_target
|
||||
}
|
||||
|
||||
/// Cadence: measured if the trainer reports it, inferred from wheel speed
|
||||
/// if not.
|
||||
///
|
||||
/// The inference is exact, not a fudge. With a Zwift Cog there is one
|
||||
/// sprocket and no freewheel between the cranks and the flywheel, so cadence
|
||||
/// and wheel speed are locked by the bike's physical development:
|
||||
///
|
||||
/// ```text
|
||||
/// cadence = wheel_speed × 60 / physical_development
|
||||
/// ```
|
||||
///
|
||||
/// It is the same rigid drivetrain the virtual gears are already built on,
|
||||
/// read in the other direction — and the trainer's own speed is the one
|
||||
/// signal this hardware reports reliably on every Indoor Bike Data packet.
|
||||
/// Depending on it instead of the vendor Zwift channel takes the whole ride
|
||||
/// off an undocumented protocol and puts it on a standard FTMS field.
|
||||
///
|
||||
/// This is not a workaround for a bug we might later fix. The D100 is a
|
||||
/// rebadged Magene T110 with cadence disabled in firmware, and the absence
|
||||
/// is confirmed by others rather than only observed here:
|
||||
/// <https://github.com/cagnulein/qdomyos-zwift/issues/3282> — "Cadence is
|
||||
/// not broadcasted, at least not in the 0.106 firmware version". Inference
|
||||
/// is the only source of cadence this trainer will ever have.
|
||||
///
|
||||
/// Note what this makes the virtual speed: `trainer_speed × virtual /
|
||||
/// physical`. Shifting up covers more ground per flywheel revolution and
|
||||
/// costs proportionally more to turn — which is exactly what a gear is.
|
||||
///
|
||||
/// A measured cadence still wins where one exists. It is the same number on
|
||||
/// this drivetrain, and on a bike with a freewheel it would be the only
|
||||
/// honest one.
|
||||
fn effective_cadence(&self, telemetry: &Telemetry) -> Option<f32> {
|
||||
if let Some(rpm) = telemetry.cadence_rpm.filter(|c| c.is_finite() && *c >= 0.0) {
|
||||
return Some(rpm);
|
||||
}
|
||||
let speed_kph = telemetry.speed_kph.filter(|s| s.is_finite() && *s >= 0.0)?;
|
||||
let development = self.config.physical_development_m;
|
||||
if !development.is_finite() || development <= 0.1 {
|
||||
return None;
|
||||
}
|
||||
Some((speed_kph / 3.6 * 60.0 / development).clamp(0.0, 250.0))
|
||||
}
|
||||
|
||||
/// Advance the ride by one tick.
|
||||
///
|
||||
/// Feeds telemetry into the physics model, advances the profile, and
|
||||
@@ -180,25 +261,71 @@ impl RideSession {
|
||||
// A trainer that reports no power is a trainer the rider is not
|
||||
// pushing; nothing here may panic on a partial FTMS packet.
|
||||
let power_w = f32::from(telemetry.power_w.unwrap_or(0)).max(0.0);
|
||||
self.physics
|
||||
.step(power_w, self.simulated_gradient_pct(), &self.config, dt);
|
||||
// Pull the model back toward what the flywheel is really doing.
|
||||
// Pure physics lets a spun-out rider "coast" downhill at 39 km/h.
|
||||
// Keep the cadence servo running purely as a readout of how far the
|
||||
// rider is from the cadence their gear implies; the load itself is
|
||||
// computed directly below rather than servoed toward it.
|
||||
self.gearing
|
||||
.update(telemetry.cadence_rpm, self.physics.speed_mps, dt);
|
||||
let gradient = self.simulated_gradient_pct();
|
||||
|
||||
// Correct toward the trainer's own measured speed — NOT toward
|
||||
// cadence x virtual gear. That would be circular: the servo's target
|
||||
// cadence is derived from speed, so making speed follow cadence
|
||||
// leaves it nothing to correct and the gears stop doing anything.
|
||||
// Physics owns the speed; cadence is the servo's feedback signal.
|
||||
if let Some(kph) = telemetry.speed_kph {
|
||||
self.physics
|
||||
.correct_toward(kph / 3.6, self.config.trainer_speed_weight, dt);
|
||||
}
|
||||
// The drivetrain decides the speed; the road decides how hard it is.
|
||||
//
|
||||
// A bike's wheel is locked to its cranks, so while the rider is
|
||||
// driving it, road speed is cadence × development and nothing else.
|
||||
// The force balance is not bypassed — it moves to the other end of
|
||||
// the loop, setting the load the trainer applies (below). Too big a
|
||||
// gear on a climb therefore does what it does outdoors: the load
|
||||
// becomes unholdable, cadence falls, and the speed falls with it.
|
||||
//
|
||||
// Power, not cadence, is what says the rider is driving. On a
|
||||
// direct-drive trainer the flywheel keeps the cranks turning after
|
||||
// the rider stops, so cadence alone cannot tell riding from
|
||||
// freewheeling — it reads a healthy 80 rpm for someone doing
|
||||
// nothing at all.
|
||||
let driving = power_w > COASTING_POWER_W;
|
||||
let cadence = self.effective_cadence(&telemetry);
|
||||
|
||||
let (source, target_mps, tau) = match (cadence, driving) {
|
||||
// Riding: the drivetrain owns the speed outright.
|
||||
(Some(rpm), true) => (
|
||||
SpeedSource::Drivetrain,
|
||||
rpm / 60.0 * self.gearing.development_m(),
|
||||
DRIVETRAIN_TAU_S,
|
||||
),
|
||||
// Coasting: the rider has stopped contributing, so the road
|
||||
// decides alone. The target is the speed this gradient sustains
|
||||
// on no power — zero on anything uphill, a real freewheeling
|
||||
// speed on a descent.
|
||||
//
|
||||
// Approached with a lag rather than integrated as momentum, to
|
||||
// stay consistent with the riding case: that has no momentum
|
||||
// either (speed is cadence × gear, instantly), and a model that
|
||||
// is momentum-free while pedalling but momentum-rich while
|
||||
// coasting is two models, not one. It is also what the rider is
|
||||
// actually experiencing — they are not moving, and stopping on
|
||||
// a climb should feel like stopping.
|
||||
(Some(_), false) => (
|
||||
SpeedSource::Coasting,
|
||||
crate::physics::equilibrium_speed_mps(0.0, gradient, &self.config),
|
||||
COAST_TAU_S,
|
||||
),
|
||||
// Neither a measured cadence nor a wheel speed to infer one
|
||||
// from: no drivetrain, no ride.
|
||||
//
|
||||
// There is deliberately no fallback to integrating power. That
|
||||
// is a different model — one where gears change the load and not
|
||||
// the speed — and substituting it silently means a dead
|
||||
// telemetry feed presents as a ride that merely feels a bit off,
|
||||
// for as long as it takes someone to notice. Stopping says it
|
||||
// outright. This now requires the trainer to be reporting
|
||||
// nothing at all, which is a genuine fault.
|
||||
(None, _) => (SpeedSource::NoCadence, 0.0, COAST_TAU_S),
|
||||
};
|
||||
self.speed_source = source;
|
||||
self.physics.advance_at(target_mps, gradient, tau, dt);
|
||||
|
||||
// Kept as a readout only, and fed the same cadence the speed was
|
||||
// built from — passing the raw telemetry here would have it servo
|
||||
// against a cadence the ride is not using. With a rigid drivetrain
|
||||
// it is a tautology by construction, which is exactly what a rigid
|
||||
// drivetrain means.
|
||||
self.gearing
|
||||
.update(cadence, self.physics.speed_mps, dt);
|
||||
}
|
||||
|
||||
// Only a running ride commands the trainer. When paused or finished the
|
||||
@@ -209,13 +336,47 @@ impl RideSession {
|
||||
// still counts; the physics above already used the true route
|
||||
// gradient, so the descent stays as fast as the terrain says.
|
||||
let target = match target {
|
||||
// Gear offset applies to what the TRAINER is asked for, not
|
||||
// to what the physics simulated: shifting changes effort,
|
||||
// not the speed the terrain implies.
|
||||
ControlTarget::Gradient { percent } => ControlTarget::Gradient {
|
||||
percent: (percent + self.gearing.correction_pct())
|
||||
.max(self.config.descent_load_floor_pct),
|
||||
},
|
||||
// Gearing applies to what the TRAINER is asked for, not to
|
||||
// what the physics simulated: shifting changes effort, not
|
||||
// the speed the terrain implies.
|
||||
//
|
||||
// This *replaces* the route gradient rather than adding to
|
||||
// it. `resistive_force_n` is given that same gradient, so
|
||||
// gravity is already inside the force being scaled; adding
|
||||
// `percent` back on top would charge the rider for the hill
|
||||
// twice.
|
||||
ControlTarget::Gradient { percent } => {
|
||||
let resistive_n = crate::physics::resistive_force_n(
|
||||
self.physics.speed_mps,
|
||||
percent,
|
||||
&self.config,
|
||||
);
|
||||
// Same load, two ways of saying it. Which one the
|
||||
// trainer can actually act on is a property of the
|
||||
// hardware, not of the ride — see `LoadChannel`.
|
||||
match self.config.load_channel {
|
||||
LoadChannel::Gradient => {
|
||||
let load_pct = self.gearing.load_gradient_pct(
|
||||
resistive_n,
|
||||
self.config.total_mass_kg(),
|
||||
self.config.physical_development_m,
|
||||
);
|
||||
ControlTarget::Gradient {
|
||||
percent: load_pct.max(self.config.descent_load_floor_pct),
|
||||
}
|
||||
}
|
||||
LoadChannel::Power => {
|
||||
let watts = self
|
||||
.gearing
|
||||
.load_power_w(resistive_n, self.physics.speed_mps);
|
||||
let snapped = (watts / LOAD_POWER_STEP_W).round()
|
||||
* LOAD_POWER_STEP_W;
|
||||
ControlTarget::Power {
|
||||
watts: snapped.clamp(0.0, u16::MAX as f32) as u16,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
let clamped = self.limits.clamp(target);
|
||||
@@ -251,6 +412,32 @@ impl RideSession {
|
||||
virtual_distance_m: self.physics.distance_m,
|
||||
gradient_pct: self.simulated_gradient_pct(),
|
||||
elevation_gain_m: self.physics.elevation_gain_m,
|
||||
gear: self.gearing.gear(),
|
||||
gear_count: self.gearing.gear_count(),
|
||||
development_m: self.gearing.development_m(),
|
||||
// A stationary rider has no implied cadence, and reporting the one
|
||||
// their gear would imply at a speed they are not doing would be a
|
||||
// number the screen cannot justify.
|
||||
target_cadence_rpm: match self.status {
|
||||
RideStatus::Running => self.gearing.target_cadence_rpm(self.physics.speed_mps),
|
||||
_ => 0.0,
|
||||
},
|
||||
speed_source: match self.status {
|
||||
RideStatus::Running => self.speed_source,
|
||||
_ => SpeedSource::Stopped,
|
||||
},
|
||||
// A rider who is not riding is pushing against nothing.
|
||||
pedal_force_n: match self.status {
|
||||
RideStatus::Running => self.gearing.pedal_force_n(
|
||||
crate::physics::resistive_force_n(
|
||||
self.physics.speed_mps,
|
||||
self.simulated_gradient_pct(),
|
||||
&self.config,
|
||||
),
|
||||
self.config.crank_length_m,
|
||||
),
|
||||
_ => 0.0,
|
||||
},
|
||||
mode: self.mode,
|
||||
target: self.last_target,
|
||||
profile_progress: self.profile_progress(),
|
||||
@@ -338,13 +525,34 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::profile::{Block, Channel, Extent, Segment, Waveform};
|
||||
|
||||
/// A session on the **gradient** channel.
|
||||
///
|
||||
/// Most of this suite predates the power channel and tests the slope
|
||||
/// arithmetic, which is still exactly right for `LoadChannel::Gradient`.
|
||||
/// Named plainly rather than silently defaulted, so that a test asserting a
|
||||
/// gradient target is visibly asking for one.
|
||||
fn session() -> RideSession {
|
||||
let config = RiderConfig {
|
||||
load_channel: LoadChannel::Gradient,
|
||||
..RiderConfig::default()
|
||||
};
|
||||
RideSession::new(config, SafetyLimits::default())
|
||||
}
|
||||
|
||||
/// A session on the **power** channel — what actually ships.
|
||||
fn power_session() -> RideSession {
|
||||
RideSession::new(RiderConfig::default(), SafetyLimits::default())
|
||||
}
|
||||
|
||||
/// A rider putting `watts` in and turning the cranks at a plausible rate.
|
||||
///
|
||||
/// Cadence is not optional garnish here: speed comes from the drivetrain,
|
||||
/// so a sample with power but no cadence is a bike that is not moving. Any
|
||||
/// test that wants a moving rider needs both.
|
||||
fn powered(watts: i16) -> Telemetry {
|
||||
Telemetry {
|
||||
power_w: Some(watts),
|
||||
cadence_rpm: Some(if watts > 0 { 85.0 } else { 0.0 }),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -505,19 +713,27 @@ mod tests {
|
||||
// ---- gradient offset -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn manual_grade_commands_the_trim_directly() {
|
||||
fn manual_grade_sets_the_road_and_the_load_follows_it() {
|
||||
// The trim states what the road is doing. What the trainer is *asked*
|
||||
// for is the load that road implies through the selected gear, which is
|
||||
// not the same number — see `crate::gearing`. The two must not be
|
||||
// conflated: the snapshot reports the road, the command reports the
|
||||
// load, and only the first is the rider's trim verbatim.
|
||||
let mut s = session();
|
||||
s.start();
|
||||
assert_eq!(gradient_of(commands(&s.tick(powered(0), 1.0))[0]), 0.0);
|
||||
let flat = gradient_of(commands(&s.tick(powered(0), 1.0))[0]);
|
||||
|
||||
s.nudge_gradient(0.5);
|
||||
s.nudge_gradient(0.5);
|
||||
let events = s.tick(powered(0), 1.0);
|
||||
assert_eq!(gradient_of(commands(&events)[0]), 1.0);
|
||||
assert_eq!(snapshot_of(&events).gradient_pct, 1.0);
|
||||
let climbing = gradient_of(commands(&events)[0]);
|
||||
assert_eq!(snapshot_of(&events).gradient_pct, 1.0, "the road is the trim");
|
||||
assert!(climbing > flat, "a 1% road must load more than a flat: {climbing} vs {flat}");
|
||||
|
||||
s.reset_gradient_offset();
|
||||
assert_eq!(gradient_of(commands(&s.tick(powered(0), 1.0))[0]), 0.0);
|
||||
let events = s.tick(powered(0), 1.0);
|
||||
assert_eq!(snapshot_of(&events).gradient_pct, 0.0);
|
||||
assert!(gradient_of(commands(&events)[0]) < climbing, "resetting must shed the load");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -534,13 +750,18 @@ mod tests {
|
||||
}],
|
||||
});
|
||||
s.start();
|
||||
assert_eq!(gradient_of(commands(&s.tick(powered(200), 1.0))[0]), 4.0);
|
||||
let steep = gradient_of(commands(&s.tick(powered(200), 1.0))[0]);
|
||||
|
||||
s.nudge_gradient(-1.5);
|
||||
let events = s.tick(powered(200), 1.0);
|
||||
assert_eq!(gradient_of(commands(&events)[0]), 2.5);
|
||||
// And the physics see the trimmed gradient too, not the raw profile.
|
||||
// The road is the profile plus the trim, exactly. The load commanded
|
||||
// for it is a separate quantity (see `crate::gearing`) — all that is
|
||||
// owed here is that trimming the road down eases the pedals.
|
||||
assert_eq!(snapshot_of(&events).gradient_pct, 2.5);
|
||||
assert!(
|
||||
gradient_of(commands(&events)[0]) < steep,
|
||||
"trimming 1.5% off the route must shed load"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -657,8 +878,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn custom_limits_are_honoured() {
|
||||
// Gradient limits, so a gradient channel — the power channel has its
|
||||
// own bounds and is covered separately.
|
||||
let mut s = RideSession::new(
|
||||
RiderConfig::default(),
|
||||
RiderConfig {
|
||||
load_channel: LoadChannel::Gradient,
|
||||
..RiderConfig::default()
|
||||
},
|
||||
SafetyLimits {
|
||||
min_gradient_pct: -2.0,
|
||||
max_gradient_pct: 3.0,
|
||||
@@ -676,11 +902,17 @@ mod tests {
|
||||
fn an_unchanged_target_is_not_resent() {
|
||||
let mut s = session();
|
||||
s.start();
|
||||
assert_eq!(commands(&s.tick(powered(200), 1.0)).len(), 1);
|
||||
// Ride up to terminal speed first. The commanded load carries the
|
||||
// rider's own drag, which rises with v², so while they are still
|
||||
// accelerating the target is genuinely changing and writing it is
|
||||
// correct. What FR-2.8 forbids is churn once nothing is moving.
|
||||
for _ in 0..600 {
|
||||
s.tick(powered(200), 1.0);
|
||||
}
|
||||
for _ in 0..50 {
|
||||
assert!(
|
||||
commands(&s.tick(powered(200), 1.0)).is_empty(),
|
||||
"a steady target must not be re-sent (FR-2.8)"
|
||||
"a settled ride must not be re-sent (FR-2.8)"
|
||||
);
|
||||
}
|
||||
s.nudge_gradient(1.0);
|
||||
@@ -721,8 +953,11 @@ mod tests {
|
||||
for _ in 0..6000 {
|
||||
writes += commands(&s.tick(powered(200), 0.1)).len();
|
||||
}
|
||||
// 6 % of gradient at a 0.05 % threshold is ~120 writes over 600 s.
|
||||
assert!(writes <= 130, "{writes} writes in 600 s");
|
||||
// The ramp itself is ~120 writes at a 0.05 % threshold; the rider's
|
||||
// drag changing as they speed up on it accounts for the rest. Still one
|
||||
// write every four seconds against a 4 Hz cap — two orders of magnitude
|
||||
// of headroom, which is what this test is actually guarding.
|
||||
assert!(writes <= 200, "{writes} writes in 600 s");
|
||||
assert!(writes > 100);
|
||||
}
|
||||
|
||||
@@ -981,33 +1216,540 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The gradient the session most recently asked the trainer for.
|
||||
fn commanded_gradient(s: &RideSession) -> f32 {
|
||||
match s.last_target() {
|
||||
Some(ControlTarget::Gradient { percent }) => percent,
|
||||
other => panic!("expected a gradient target, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gear_servo_finds_load_when_the_rider_spins_out() {
|
||||
// The descent failure that started this: steep downhill, rider spinning
|
||||
// far faster than the gear implies. The commanded gradient must come
|
||||
// back up so there is something to push against.
|
||||
fn a_shift_changes_the_load_on_the_very_next_tick() {
|
||||
// The point of the direct model. A rider who shifts and feels nothing
|
||||
// for three seconds has not shifted, they have waited.
|
||||
let mut s = session();
|
||||
s.start();
|
||||
s.config.descent_load_floor_pct = f32::NEG_INFINITY;
|
||||
// A descent is ridden in a BIG gear — as on a real bike. In a short
|
||||
// gear the bike simply outruns the rider's legs and the honest answer
|
||||
// is that they are freewheeling, not that the trainer owes them load.
|
||||
while s.gearing.shift_up() {}
|
||||
s.nudge_gradient(-6.0);
|
||||
s.gearing.set_gear(6);
|
||||
let t = Telemetry {
|
||||
power_w: Some(200),
|
||||
cadence_rpm: Some(85.0),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..40 {
|
||||
s.tick(t, 0.25);
|
||||
}
|
||||
let before = commanded_gradient(&s);
|
||||
|
||||
let spun_out = Telemetry {
|
||||
s.gearing.shift_up();
|
||||
s.tick(t, 0.25);
|
||||
let after = commanded_gradient(&s);
|
||||
|
||||
assert!(
|
||||
after > before + 0.05,
|
||||
"one tick after shifting up the load must be higher: {before} -> {after}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gear_the_rider_selects_is_what_reaches_the_trainer() {
|
||||
// Bottom gear and top gear on the same road must not command the same
|
||||
// load, or the shifter is decoration.
|
||||
let mut s = session();
|
||||
s.start();
|
||||
let t = Telemetry {
|
||||
power_w: Some(200),
|
||||
cadence_rpm: Some(85.0),
|
||||
..Default::default()
|
||||
};
|
||||
s.gearing.set_gear(1);
|
||||
for _ in 0..40 {
|
||||
s.tick(t, 0.25);
|
||||
}
|
||||
let bottom = commanded_gradient(&s);
|
||||
|
||||
s.gearing.set_gear(s.gearing.gear_count());
|
||||
s.tick(t, 0.25);
|
||||
let top = commanded_gradient(&s);
|
||||
|
||||
assert!(top > bottom, "top gear must load more: {top} vs {bottom}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_route_gradient_is_not_charged_twice() {
|
||||
// The commanded load already contains gravity, because the force it
|
||||
// scales was computed at the route's gradient. A 6% climb in a gear
|
||||
// close to the bike's real one should command something in the
|
||||
// neighbourhood of 6% — not 12%.
|
||||
let mut s = session();
|
||||
s.start();
|
||||
s.config.physical_development_m = s.gearing.development_m();
|
||||
s.nudge_gradient(6.0);
|
||||
let t = Telemetry {
|
||||
power_w: Some(200),
|
||||
cadence_rpm: Some(85.0),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..40 {
|
||||
s.tick(t, 0.25);
|
||||
}
|
||||
let commanded = commanded_gradient(&s);
|
||||
assert!(
|
||||
(commanded - 6.0).abs() < 2.0,
|
||||
"6% road in a 1:1 gear should command about 6%, got {commanded}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Settle a ride at a fixed cadence and gear, and report its speed.
|
||||
fn ride_at(cadence: f32, gear: usize) -> RideSession {
|
||||
let mut s = session();
|
||||
s.start();
|
||||
s.gearing.set_gear(gear);
|
||||
let t = Telemetry {
|
||||
power_w: Some(200),
|
||||
cadence_rpm: Some(cadence),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..80 {
|
||||
s.tick(t, 0.25);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Ride up to speed on `gradient`, then stop pedalling for `seconds` while
|
||||
/// the flywheel keeps the cranks turning. Returns (riding, coasting) km/h.
|
||||
fn coast_after_riding(gradient: f32, seconds: f32) -> (f32, f32) {
|
||||
let mut s = session();
|
||||
s.start();
|
||||
s.nudge_gradient(gradient);
|
||||
let riding = Telemetry {
|
||||
power_w: Some(250),
|
||||
cadence_rpm: Some(85.0),
|
||||
speed_kph: Some(28.0),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..200 {
|
||||
s.tick(riding, 0.25);
|
||||
}
|
||||
let moving = s.physics().speed_kph();
|
||||
|
||||
let coasting = Telemetry {
|
||||
power_w: Some(0),
|
||||
// The flywheel drives the cranks: cadence stays healthy for a
|
||||
// rider who has stopped doing anything.
|
||||
cadence_rpm: Some(80.0),
|
||||
speed_kph: Some(26.0),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..((seconds / 0.25) as u32) {
|
||||
s.tick(coasting, 0.25);
|
||||
}
|
||||
(moving, s.physics().speed_kph())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopping_pedalling_on_a_climb_stops_the_bike() {
|
||||
// The flywheel reports 80 rpm throughout, so anything keyed on cadence
|
||||
// alone would have the rider still climbing at speed. Power is what
|
||||
// says they have stopped.
|
||||
let (moving, coasting) = coast_after_riding(3.5, 5.0);
|
||||
assert!(moving > 20.0, "should have been riding: {moving}");
|
||||
assert!(
|
||||
coasting < 1.5,
|
||||
"five seconds after stopping on a 3.5% climb, expected a halt, got {coasting} kph"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coasting_a_descent_keeps_rolling() {
|
||||
// The mirror image, and the reason coasting is not simply "stop": a
|
||||
// rider who stops pedalling downhill speeds up, and must not be brought
|
||||
// to a halt by a rule written for climbs.
|
||||
let (_, coasting) = coast_after_riding(-5.0, 5.0);
|
||||
assert!(
|
||||
coasting > 15.0,
|
||||
"freewheeling down a 5% descent should carry on, got {coasting} kph"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_is_never_negative() {
|
||||
// Whatever the road, the telemetry or the gear, a readout that says the
|
||||
// rider is travelling backwards is never right.
|
||||
for gradient in [-25.0, -8.0, 0.0, 8.0, 25.0] {
|
||||
for (power, cadence) in
|
||||
[(0u16, None), (0, Some(0.0)), (0, Some(95.0)), (400, Some(95.0))]
|
||||
{
|
||||
let mut s = session();
|
||||
s.start();
|
||||
s.nudge_gradient(gradient);
|
||||
let t = Telemetry {
|
||||
power_w: Some(power as i16),
|
||||
cadence_rpm: cadence,
|
||||
speed_kph: Some(0.0),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..200 {
|
||||
let snap = snapshot_of(&s.tick(t, 0.25));
|
||||
assert!(
|
||||
snap.virtual_speed_kph >= 0.0,
|
||||
"negative speed {} at {gradient}% / {power} W / {cadence:?}",
|
||||
snap.virtual_speed_kph
|
||||
);
|
||||
assert!(snap.virtual_distance_m >= 0.0, "distance went backwards");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The watts commanded after settling at a given flywheel speed and gear.
|
||||
fn commanded_watts(gear: usize, trainer_kph: f32) -> u16 {
|
||||
let mut s = power_session();
|
||||
s.start();
|
||||
s.gearing.set_gear(gear);
|
||||
let t = Telemetry {
|
||||
power_w: Some(180),
|
||||
cadence_rpm: None,
|
||||
speed_kph: Some(trainer_kph),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..80 {
|
||||
s.tick(t, 0.25);
|
||||
}
|
||||
match s.last_target() {
|
||||
Some(ControlTarget::Power { watts }) => watts,
|
||||
other => panic!("expected a power target, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_road_gradient_reaches_the_trainer_as_watts() {
|
||||
// The D100 will honour the power channel or the gradient channel, not
|
||||
// both, so gravity has to travel on whichever one we chose. It does:
|
||||
// the climb is in the commanded watts, which is the whole reason
|
||||
// dropping the gradient channel costs nothing.
|
||||
let watts = |grade: f32| {
|
||||
let mut s = power_session();
|
||||
s.start();
|
||||
s.gearing.set_gear(6);
|
||||
s.nudge_gradient(grade);
|
||||
let t = Telemetry {
|
||||
power_w: Some(180),
|
||||
cadence_rpm: None,
|
||||
speed_kph: Some(20.0),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..80 {
|
||||
s.tick(t, 0.25);
|
||||
}
|
||||
match s.last_target() {
|
||||
Some(ControlTarget::Power { watts }) => watts,
|
||||
other => panic!("expected a power target, got {other:?}"),
|
||||
}
|
||||
};
|
||||
let flat = watts(0.0);
|
||||
let climb = watts(3.0);
|
||||
let descent = watts(-5.0);
|
||||
assert!(climb > flat * 2, "a 3% climb must cost far more: {climb} vs {flat}");
|
||||
assert!(descent < flat, "a descent must cost less: {descent} vs {flat}");
|
||||
assert!(
|
||||
descent >= power_session().limits.min_power_w,
|
||||
"never below what the trainer can hold"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_power_channel_is_what_ships() {
|
||||
// The D100 declares 50-600 W in 1 W steps and 0-6% inclination in 0.1%
|
||||
// steps, and only the former has room for gearing to show up in.
|
||||
assert_eq!(RiderConfig::default().load_channel, LoadChannel::Power);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_longer_gear_demands_more_watts() {
|
||||
// Not because power depends on the gear — at a given speed it does not
|
||||
// — but because a longer gear turns the same cadence into more speed,
|
||||
// and the road charges for speed.
|
||||
let bottom = commanded_watts(1, 20.0);
|
||||
let top = commanded_watts(12, 20.0);
|
||||
assert!(top > bottom * 3, "top gear must cost far more: {top} vs {bottom}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_commanded_watts_are_a_plausible_road_load() {
|
||||
// Sanity on the absolute scale. This is the number the trainer will
|
||||
// hold as a ceiling, so if it is wrong the ride is wrong.
|
||||
let watts = commanded_watts(6, 20.0);
|
||||
assert!(
|
||||
(40..=250).contains(&watts),
|
||||
"a middling gear on the flat should be ordinary riding: {watts} W"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_commanded_load_is_quantised_so_it_does_not_churn() {
|
||||
// The write budget depends on this: road power goes as v cubed, and an
|
||||
// unquantised target would change on nearly every tick.
|
||||
for gear in [2usize, 6, 10] {
|
||||
for kph in [12.0f32, 18.0, 25.0] {
|
||||
let watts = commanded_watts(gear, kph);
|
||||
assert_eq!(
|
||||
watts % LOAD_POWER_STEP_W as u16,
|
||||
0,
|
||||
"{watts} W is off the {LOAD_POWER_STEP_W} W grid"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_steady_ride_on_the_power_channel_stays_inside_the_write_budget() {
|
||||
// FR-2.8 caps writes at 4 Hz. Once the speed has settled the demand
|
||||
// should stop moving entirely.
|
||||
let mut s = power_session();
|
||||
s.start();
|
||||
let t = Telemetry {
|
||||
power_w: Some(180),
|
||||
cadence_rpm: None,
|
||||
speed_kph: Some(20.0),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..200 {
|
||||
s.tick(t, 0.25);
|
||||
}
|
||||
let mut writes = 0;
|
||||
for _ in 0..200 {
|
||||
writes += commands(&s.tick(t, 0.25)).len();
|
||||
}
|
||||
assert!(writes <= 2, "a settled ride rewrote the target {writes} times");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_descent_never_asks_the_brake_for_negative_watts() {
|
||||
// No brake can push. The floor is zero, and the safety limits raise it
|
||||
// to whatever the trainer's real minimum is.
|
||||
let mut s = power_session();
|
||||
s.start();
|
||||
s.nudge_gradient(-12.0);
|
||||
let t = Telemetry {
|
||||
power_w: Some(0),
|
||||
cadence_rpm: Some(80.0),
|
||||
speed_kph: Some(35.0),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..80 {
|
||||
s.tick(t, 0.25);
|
||||
}
|
||||
match s.last_target() {
|
||||
Some(ControlTarget::Power { watts }) => {
|
||||
assert!(watts <= s.limits.max_power_w);
|
||||
}
|
||||
other => panic!("expected a power target, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_is_cadence_times_the_gear() {
|
||||
// The drivetrain constraint, exactly as on a bike: the wheel is locked
|
||||
// to the cranks, so this is arithmetic, not a force balance.
|
||||
let s = ride_at(90.0, 6);
|
||||
let expected = 90.0 / 60.0 * s.gearing.development_m();
|
||||
let actual = s.physics().speed_mps;
|
||||
assert!(
|
||||
(actual - expected).abs() < 0.05,
|
||||
"90 rpm in a {:.1} m gear is {expected:.2} m/s, got {actual:.2}",
|
||||
s.gearing.development_m()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bigger_gear_at_the_same_cadence_goes_faster() {
|
||||
let small = ride_at(90.0, 2).physics().speed_mps;
|
||||
let big = ride_at(90.0, 11).physics().speed_mps;
|
||||
assert!(big > small * 1.5, "a longer gear must travel further: {big} vs {small}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_higher_cadence_in_the_same_gear_goes_faster() {
|
||||
let slow = ride_at(60.0, 6).physics().speed_mps;
|
||||
let fast = ride_at(100.0, 6).physics().speed_mps;
|
||||
assert!(fast > slow, "spinning faster must go faster: {fast} vs {slow}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rigid_drivetrain_leaves_the_cadence_servo_nothing_to_say() {
|
||||
// With speed taken from cadence, the cadence the gear implies IS the
|
||||
// cadence the rider is turning. The servo is a tautology here, which is
|
||||
// what a rigid drivetrain means — and why it drives nothing.
|
||||
let s = ride_at(90.0, 6);
|
||||
assert!(
|
||||
s.gearing.correction_pct().abs() < 0.01,
|
||||
"expected no correction, got {}",
|
||||
s.gearing.correction_pct()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_speed_infers_cadence_on_a_single_cog_drivetrain() {
|
||||
// The trainer reports no cadence over FTMS but always reports speed,
|
||||
// and with one sprocket the two are locked. 20 km/h through a 5.1 m
|
||||
// development is 5.56 m/s / 5.1 m = 1.09 rev/s = 65 rpm.
|
||||
let mut s = session();
|
||||
s.config.physical_development_m = 5.1;
|
||||
let t = Telemetry {
|
||||
power_w: Some(150),
|
||||
cadence_rpm: None,
|
||||
speed_kph: Some(20.0),
|
||||
..Default::default()
|
||||
};
|
||||
let rpm = s.effective_cadence(&t).expect("speed alone must yield a cadence");
|
||||
assert!((rpm - 65.4).abs() < 0.5, "expected ~65 rpm, got {rpm}");
|
||||
|
||||
// And it drives the ride, rather than the ride standing still.
|
||||
s.start();
|
||||
for _ in 0..80 {
|
||||
s.tick(t, 0.25);
|
||||
}
|
||||
assert!(s.physics().speed_mps > 1.0, "inferred cadence must move the bike");
|
||||
assert_eq!(
|
||||
snapshot_of(&s.tick(t, 0.25)).speed_source,
|
||||
SpeedSource::Drivetrain
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_measured_cadence_beats_an_inferred_one() {
|
||||
// On this drivetrain they agree; on a bike with a freewheel only the
|
||||
// measured one is honest, so it must win where it exists.
|
||||
let s = session();
|
||||
let t = Telemetry {
|
||||
cadence_rpm: Some(95.0),
|
||||
speed_kph: Some(20.0),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(s.effective_cadence(&t), Some(95.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gear_scales_the_trainers_own_speed() {
|
||||
// What inference makes the model: virtual speed is the trainer's speed
|
||||
// times the gear ratio. Top gear covers more ground per flywheel
|
||||
// revolution than bottom, for the same flywheel.
|
||||
let ride = |gear: usize| {
|
||||
let mut s = session();
|
||||
s.start();
|
||||
s.gearing.set_gear(gear);
|
||||
let t = Telemetry {
|
||||
power_w: Some(200),
|
||||
cadence_rpm: None,
|
||||
speed_kph: Some(25.0),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..80 {
|
||||
s.tick(t, 0.25);
|
||||
}
|
||||
s.physics().speed_kph()
|
||||
};
|
||||
let bottom = ride(1);
|
||||
let top = ride(12);
|
||||
assert!(top > bottom * 2.0, "top gear must cover more ground: {top} vs {bottom}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_silent_trainer_still_stops_the_ride() {
|
||||
// The inference needs a wheel speed. With neither cadence nor speed
|
||||
// there is genuinely nothing to ride on, and that must still be loud.
|
||||
let mut s = session();
|
||||
s.start();
|
||||
let t = Telemetry {
|
||||
power_w: Some(250),
|
||||
cadence_rpm: None,
|
||||
speed_kph: None,
|
||||
..Default::default()
|
||||
};
|
||||
let mut snap = None;
|
||||
for _ in 0..40 {
|
||||
snap = Some(snapshot_of(&s.tick(t, 0.25)));
|
||||
}
|
||||
let snap = snap.unwrap();
|
||||
assert_eq!(snap.virtual_speed_kph, 0.0);
|
||||
assert_eq!(snap.speed_source, SpeedSource::NoCadence);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_cadence_the_ride_does_not_move_and_says_why() {
|
||||
// Deliberate. Speed comes from the drivetrain, and with no cadence
|
||||
// there is no drivetrain to read. Inventing a speed from power instead
|
||||
// would let a broken cadence feed pass for a working ride that merely
|
||||
// felt a little wrong — for however long it took someone to notice.
|
||||
let mut s = session();
|
||||
s.start();
|
||||
let t = Telemetry { power_w: Some(250), cadence_rpm: None, ..Default::default() };
|
||||
let mut snap = None;
|
||||
for _ in 0..80 {
|
||||
snap = Some(snapshot_of(&s.tick(t, 0.25)));
|
||||
}
|
||||
let snap = snap.unwrap();
|
||||
assert_eq!(snap.virtual_speed_kph, 0.0);
|
||||
assert_eq!(
|
||||
snap.speed_source,
|
||||
SpeedSource::NoCadence,
|
||||
"the snapshot must name the fault, not just report a stopped bike"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_snapshot_names_which_rule_set_the_speed() {
|
||||
let mut s = session();
|
||||
s.start();
|
||||
s.tick(powered(200), 0.25);
|
||||
assert_eq!(
|
||||
snapshot_of(&s.tick(powered(200), 0.25)).speed_source,
|
||||
SpeedSource::Drivetrain
|
||||
);
|
||||
|
||||
let coasting = Telemetry {
|
||||
power_w: Some(0),
|
||||
cadence_rpm: Some(80.0),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
snapshot_of(&s.tick(coasting, 0.25)).speed_source,
|
||||
SpeedSource::Coasting
|
||||
);
|
||||
|
||||
s.pause();
|
||||
assert_eq!(
|
||||
snapshot_of(&s.tick(powered(200), 0.25)).speed_source,
|
||||
SpeedSource::Stopped
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_descent_in_a_big_gear_carries_the_rider() {
|
||||
// Replaces `the_gear_servo_finds_load_when_the_rider_spins_out`, whose
|
||||
// premise a rigid drivetrain removes: you cannot spin out of a gear
|
||||
// that is locked to the wheel. Turning 120 rpm in top gear now simply
|
||||
// *is* going fast, which is the outcome that test was reaching for by
|
||||
// a much longer route.
|
||||
let mut s = session();
|
||||
s.start();
|
||||
s.nudge_gradient(-6.0);
|
||||
while s.gearing.shift_up() {}
|
||||
|
||||
let spinning = Telemetry {
|
||||
power_w: Some(40),
|
||||
cadence_rpm: Some(120.0),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..200 {
|
||||
s.tick(spun_out, 0.25);
|
||||
s.tick(spinning, 0.25);
|
||||
}
|
||||
let expected = 120.0 / 60.0 * s.gearing.development_m();
|
||||
assert!(
|
||||
s.gearing.correction_pct() > 0.5,
|
||||
"servo should have added load, got {}",
|
||||
s.gearing.correction_pct()
|
||||
(s.physics().speed_mps - expected).abs() < 0.05,
|
||||
"120 rpm in top gear is {expected:.1} m/s, got {:.1}",
|
||||
s.physics().speed_mps
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+129
-19
@@ -83,9 +83,11 @@ impl SafetyLimits {
|
||||
/// Where the base target comes from (§5.4). Note that in the full design
|
||||
/// gearing and gradient are simultaneously active; mode selects the *source* of
|
||||
/// the base gradient, not whether shifting works.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ControlMode {
|
||||
/// Rider sets gradient directly; no profile running.
|
||||
/// Rider sets gradient directly; no profile running. The default: it is the
|
||||
/// mode a session with nothing loaded is already in.
|
||||
#[default]
|
||||
ManualGrade,
|
||||
/// Rider sets raw resistance; physics ignored.
|
||||
Resistance,
|
||||
@@ -102,6 +104,20 @@ pub struct RiderConfig {
|
||||
pub bike_kg: f32,
|
||||
/// Coefficient of rolling resistance.
|
||||
pub crr: f32,
|
||||
/// A fixed power loss that never goes away, watts.
|
||||
///
|
||||
/// Everything a trainer costs you that does not scale the way road forces
|
||||
/// do: belt and bearing drag, the chain, the flywheel's own bearings. On
|
||||
/// this hardware that is about 12 W.
|
||||
///
|
||||
/// Held as a *power* rather than a force because that is how it presents —
|
||||
/// a constant tax on what you put in. Converted where it is used by
|
||||
/// dividing by speed, so it is a small force when you are moving fast and a
|
||||
/// large one as you slow down. That asymmetry is the point: it barely
|
||||
/// touches a fast descent and it is what brings a coast to an actual halt
|
||||
/// rather than a long asymptotic drift.
|
||||
#[serde(default = "default_rolling_loss_w")]
|
||||
pub rolling_loss_w: f32,
|
||||
/// Drag coefficient × frontal area, m².
|
||||
pub cda: f32,
|
||||
/// Fraction of measured power reaching the wheel.
|
||||
@@ -109,17 +125,24 @@ pub struct RiderConfig {
|
||||
/// Air density, kg/m³.
|
||||
pub air_density: f32,
|
||||
pub wheel_circumference_m: f32,
|
||||
/// How strongly the trainer's own reported speed pulls the modelled speed
|
||||
/// back toward it, as a fraction of the gap closed per second.
|
||||
/// Crank length — the radius of the circle the pedal travels, metres.
|
||||
///
|
||||
/// `0.0` is pure physics: correct for a real bike, but on a single-cog
|
||||
/// drivetrain the rider spins out against no resistance on a descent while
|
||||
/// the model happily reports 39 km/h. `1.0` would track the flywheel
|
||||
/// exactly, capping descents at whatever the one gear allows. The default
|
||||
/// keeps physics in charge while refusing to drift far from what the
|
||||
/// hardware measures.
|
||||
#[serde(default = "default_trainer_speed_weight")]
|
||||
pub trainer_speed_weight: f32,
|
||||
/// The last lever in the chain. Work is force × distance either side of it:
|
||||
/// per crank revolution the pedal travels `2πr` while the bike travels
|
||||
/// `development`, so the force the rider's leg feels is
|
||||
///
|
||||
/// ```text
|
||||
/// F_pedal = F_road × development / (2πr)
|
||||
/// ```
|
||||
///
|
||||
/// It changes nothing about what is commanded — the trainer is asked for a
|
||||
/// force at the wheel, and leverage past the wheel is the rider's own
|
||||
/// business — but it is the only way to say what a gear will actually feel
|
||||
/// like, which is what makes a gear ladder sane or absurd.
|
||||
///
|
||||
/// Road bikes are 170–175 mm; nothing sold is near 300 mm.
|
||||
#[serde(default = "default_crank_length")]
|
||||
pub crank_length_m: f32,
|
||||
/// The steepest descent the trainer is ever *asked* to simulate.
|
||||
///
|
||||
/// On a real descent a trainer unloads almost completely, and on a
|
||||
@@ -138,18 +161,25 @@ pub struct RiderConfig {
|
||||
/// A 34T chainring on a 14T cog with a 2.1 m wheel is 5.1 m.
|
||||
#[serde(default = "default_physical_development")]
|
||||
pub physical_development_m: f32,
|
||||
/// Which FTMS channel the computed load is sent on. See [`LoadChannel`].
|
||||
#[serde(default)]
|
||||
pub load_channel: LoadChannel,
|
||||
}
|
||||
|
||||
fn default_physical_development() -> f32 {
|
||||
5.1
|
||||
}
|
||||
|
||||
fn default_descent_load_floor() -> f32 {
|
||||
-1.0
|
||||
fn default_rolling_loss_w() -> f32 {
|
||||
12.0
|
||||
}
|
||||
|
||||
fn default_trainer_speed_weight() -> f32 {
|
||||
0.3
|
||||
fn default_crank_length() -> f32 {
|
||||
0.1725
|
||||
}
|
||||
|
||||
fn default_descent_load_floor() -> f32 {
|
||||
-1.0
|
||||
}
|
||||
|
||||
impl Default for RiderConfig {
|
||||
@@ -158,13 +188,15 @@ impl Default for RiderConfig {
|
||||
rider_kg: 105.0,
|
||||
bike_kg: 8.0,
|
||||
crr: 0.004,
|
||||
rolling_loss_w: default_rolling_loss_w(),
|
||||
cda: 0.32,
|
||||
drivetrain_efficiency: 0.97,
|
||||
air_density: 1.225,
|
||||
wheel_circumference_m: 2.1,
|
||||
trainer_speed_weight: default_trainer_speed_weight(),
|
||||
crank_length_m: default_crank_length(),
|
||||
descent_load_floor_pct: default_descent_load_floor(),
|
||||
physical_development_m: default_physical_development(),
|
||||
load_channel: LoadChannel::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,10 +207,69 @@ impl RiderConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// How the computed road load is expressed to the trainer.
|
||||
///
|
||||
/// The load itself is the same physics either way; this only chooses the FTMS
|
||||
/// channel it is sent on, and the right answer is whichever one the hardware
|
||||
/// actually acts on.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LoadChannel {
|
||||
/// `SetIndoorBikeSimulationParameters` (0x11) — tell the trainer the slope
|
||||
/// and let its own model produce the resistance.
|
||||
Gradient,
|
||||
/// `SetTargetPower` (0x05) — command the watts the road demands at the
|
||||
/// current speed, recomputed every tick.
|
||||
///
|
||||
/// The default, because on the D100 it is the channel with room to work.
|
||||
/// Its declared inclination range is 0–6% in 0.1% steps and refuses
|
||||
/// negatives outright, so a gearing difference that should be dramatic
|
||||
/// arrives as a fraction of a percent; its power range is 50–600 W in 1 W
|
||||
/// steps. Whether 0x11 does anything at all on this trainer is still
|
||||
/// unconfirmed (TASK-2), whereas power is declared in its feature bits and
|
||||
/// is what the physics computes natively.
|
||||
///
|
||||
/// This is not ERG. On the D100 the target is a **ceiling**, not a
|
||||
/// setpoint: the brake absorbs up to that many watts and no more, so
|
||||
/// anything the rider produces beyond it becomes speed instead of being
|
||||
/// resisted away.
|
||||
///
|
||||
/// That is very nearly what a road is. Command the watts the road demands
|
||||
/// at the current speed and the rest follows on its own — push harder than
|
||||
/// the road asks and you accelerate, ease off and you slow, and because the
|
||||
/// demand is recomputed from the new speed each tick the whole thing
|
||||
/// settles where the physics says it should. A true ERG setpoint would
|
||||
/// fight the rider instead, pressing harder the slower they went.
|
||||
#[default]
|
||||
Power,
|
||||
}
|
||||
|
||||
/// Which rule decided the virtual speed on a given tick.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SpeedSource {
|
||||
/// The rider is driving: speed is cadence × the selected gear.
|
||||
Drivetrain,
|
||||
/// The rider has stopped putting power in: speed runs down to whatever the
|
||||
/// gradient sustains on none.
|
||||
Coasting,
|
||||
/// No cadence reaching the engine, so there is no drivetrain to read and
|
||||
/// the ride is held at a stop. A fault, not a mode: cadence comes from the
|
||||
/// trainer's Zwift channel, and this says it is not arriving.
|
||||
NoCadence,
|
||||
/// The ride is not running. The default, because a snapshot that has not
|
||||
/// been through a tick describes a bike nobody is on.
|
||||
#[default]
|
||||
Stopped,
|
||||
}
|
||||
|
||||
/// A snapshot of the ride, pushed to the UI each tick. This is what the
|
||||
/// frontend renders; it should contain everything the ride screen needs and
|
||||
/// nothing it does not.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
/// `Default` is the zeroed ride — nothing elapsed, nothing moving, no gear
|
||||
/// engaged. It exists for test fixtures and for the first frame before a tick
|
||||
/// has run, so that adding a field here does not force every construction site
|
||||
/// to be edited. Note that it is NOT a valid ride state: `gear_count` of zero
|
||||
/// means no cassette, which the UI renders as a dash rather than "gear 0 of 0".
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RideSnapshot {
|
||||
pub elapsed_ms: u64,
|
||||
pub telemetry: Telemetry,
|
||||
@@ -186,10 +277,29 @@ pub struct RideSnapshot {
|
||||
pub virtual_speed_kph: f32,
|
||||
/// Virtual distance travelled, metres.
|
||||
pub virtual_distance_m: f64,
|
||||
/// Gradient currently commanded, percent.
|
||||
/// The gradient of the *road*, percent — the profile's slope plus the
|
||||
/// rider's trim. Not what the trainer was asked for: gearing sits between
|
||||
/// the two, so see `target` for that.
|
||||
pub gradient_pct: f32,
|
||||
/// Cumulative elevation gained, metres.
|
||||
pub elevation_gain_m: f32,
|
||||
/// Selected virtual gear, one-based (FR-4.1).
|
||||
pub gear: usize,
|
||||
/// How many gears there are to choose from.
|
||||
pub gear_count: usize,
|
||||
/// Metres travelled per crank revolution in the selected gear.
|
||||
pub development_m: f32,
|
||||
/// Cadence the selected gear implies at the current speed, rpm — what the
|
||||
/// rider would be turning if they were riding this gear honestly.
|
||||
pub target_cadence_rpm: f32,
|
||||
/// Force the rider's leg is pushing against at the pedal, newtons. The
|
||||
/// commanded gradient in the units the legs actually work in — see
|
||||
/// [`crate::gearing::Gearing::pedal_force_n`].
|
||||
pub pedal_force_n: f32,
|
||||
/// Which rule produced `virtual_speed_kph` this tick. Diagnostic: the three
|
||||
/// behave very differently, and "the speed is wrong" is not answerable
|
||||
/// without knowing which one was in charge. See `RideSession::tick`.
|
||||
pub speed_source: SpeedSource,
|
||||
pub mode: ControlMode,
|
||||
/// The target most recently sent to the trainer, post-clamp.
|
||||
pub target: Option<ControlTarget>,
|
||||
|
||||
@@ -52,6 +52,8 @@ pub struct FitSummary {
|
||||
pub avg_power_w: Option<u16>,
|
||||
/// Peak power. `None` if no sample reported power.
|
||||
pub max_power_w: Option<u16>,
|
||||
/// Mean cadence over samples that reported one. `None` if none did.
|
||||
pub avg_cadence_rpm: Option<u8>,
|
||||
/// Estimated rider energy expenditure in kilocalories, derived from
|
||||
/// measured mechanical work — see [`Aggregates::calories`].
|
||||
pub total_calories: Option<u16>,
|
||||
@@ -216,6 +218,7 @@ pub fn encode_activity(log: &RawLog) -> Result<(Vec<u8>, FitSummary), FitError>
|
||||
total_ascent_m: clamp_u16(session_agg.ascent_m),
|
||||
avg_power_w: session_agg.avg_power(),
|
||||
max_power_w: session_agg.max_power,
|
||||
avg_cadence_rpm: session_agg.avg_cadence(),
|
||||
total_calories: session_agg.calories(log.start.rider_kg),
|
||||
gaps: log.gaps(end_ms).len(),
|
||||
recovered_from_crash: !log.clean_shutdown,
|
||||
|
||||
@@ -602,6 +602,12 @@ mod tests {
|
||||
resistance_level: Some(7),
|
||||
..Default::default()
|
||||
},
|
||||
gear: 6,
|
||||
gear_count: 12,
|
||||
development_m: 5.7,
|
||||
target_cadence_rpm: 92.0,
|
||||
speed_source: bikecontrol_core::types::SpeedSource::Drivetrain,
|
||||
pedal_force_n: 120.0,
|
||||
virtual_speed_kph: 31.5,
|
||||
virtual_distance_m: 105.0,
|
||||
gradient_pct: 3.5,
|
||||
|
||||
@@ -387,6 +387,12 @@ mod tests {
|
||||
virtual_distance_m: elapsed_ms as f64 * 0.009,
|
||||
gradient_pct: 1.5,
|
||||
elevation_gain_m: elapsed_ms as f32 * 0.000_135,
|
||||
gear: 6,
|
||||
gear_count: 12,
|
||||
development_m: 5.7,
|
||||
target_cadence_rpm: 94.7,
|
||||
speed_source: bikecontrol_core::types::SpeedSource::Drivetrain,
|
||||
pedal_force_n: 120.0,
|
||||
mode: ControlMode::ManualGrade,
|
||||
target: None,
|
||||
profile_progress: None,
|
||||
|
||||
@@ -38,6 +38,12 @@ fn snapshot(second: u64) -> RideSnapshot {
|
||||
virtual_distance_m: second as f64 * 8.2,
|
||||
gradient_pct: ((second % 20) as f32 - 10.0) / 2.0,
|
||||
elevation_gain_m: second as f32 * 0.15,
|
||||
gear: 6,
|
||||
gear_count: 12,
|
||||
development_m: 5.7,
|
||||
target_cadence_rpm: 90.0,
|
||||
speed_source: bikecontrol_core::types::SpeedSource::Drivetrain,
|
||||
pedal_force_n: 120.0,
|
||||
mode: ControlMode::Profile,
|
||||
target: None,
|
||||
profile_progress: Some(second as f32 / 600.0),
|
||||
|
||||
Reference in New Issue
Block a user