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:
2026-08-05 18:21:08 +02:00
co-authored by Claude Opus 5
parent f2c4cb2120
commit 7b511db3dc
44 changed files with 6636 additions and 950 deletions
+96 -50
View File
@@ -35,9 +35,6 @@
* zeros without being told why, so every non-controlling state gets words.
*/
const trainerChip = $derived.by(() => {
if (ride?.source === 'mock') {
return { tone: 'tone-warn', label: 'Simulated — no trainer' };
}
const t = ride?.trainer;
if (!t) return null;
const state = t.state;
@@ -64,6 +61,71 @@
}
});
/**
* The Click pods, mid-ride (FR-1.4).
*
* Silent when both are connected — the chip row is for things that need
* attention, and two working pods do not. A pod that has dropped or was
* never connected is named individually, because they do different jobs:
* lose the `` pod and the D-pad and shift-down go with it.
*/
const podChip = $derived.by(() => {
const c = app.controller;
if (!c) return null;
const missing = [c.minus, c.plus].filter((p) => p.state !== 'connected');
if (missing.length === 0) return null;
const names = missing.map((p) => `${p.symbol} pod`).join(' and ');
const searching = missing.some((p) => p.state === 'searching' || p.state === 'reconnecting');
return {
tone: searching ? 'tone-warn' : 'tone-idle',
label: searching ? `Looking for the ${names}…` : `No ${names}`,
// The keyboard is always the fallback, which is what keeps a missing pod
// an annoyance rather than the end of the ride.
title: 'Open Devices to connect, or use the keyboard — press ? for the list',
};
});
/**
* The selected gear (FR-4.1).
*
* Read from the snapshot rather than from `ride`, so what is shown is the
* gear the engine actually rode this tick, not the intent the shell recorded.
* Development is the subtitle because "8 of 12" alone says nothing about how
* hard the pedals will be, whereas 6.5 m per crank turn does.
*/
const gear = $derived.by(() => {
const count = snap?.gear_count ?? 0;
if (!snap || count === 0) return { value: '—', sub: null, dim: true };
return {
value: `${snap.gear}`,
// Development says how far the gear carries you; pedal force says what it
// costs to turn it. The second is the one that tells you, without
// pedalling, whether the gear you just selected is rideable.
sub: `of ${count} · ${num(snap.development_m, 1)} m · ${num(snap.pedal_force_n, 0)} N`,
dim: false,
};
});
/**
* A speed the engine could not compute is not a slow ride, it is a broken
* one, and saying "0.0 km/h" without saying why would be the readout lying by
* omission. Cadence arrives on the trainer's Zwift channel, not over FTMS.
*/
const speedFault = $derived(
snap?.speed_source === 'NoCadence' ? 'No cadence from the trainer — speed unavailable' : null,
);
/**
* Cadence, with what the selected gear is asking for. The difference is the
* whole feedback the rider gets on whether they are in the right gear:
* turning well above it is spinning out, well below it is grinding.
*/
const cadenceSub = $derived.by(() => {
const target = snap?.target_cadence_rpm ?? 0;
if (!target || target < 20) return null;
return `gear wants ${num(target, 0)}`;
});
const gradient = $derived(snap?.gradient_pct ?? 0);
const gradeColour = $derived(
gradient > 0.4 ? 'var(--climb)' : gradient < -0.4 ? 'var(--route)' : 'var(--ink)',
@@ -118,9 +180,11 @@
* mismatch instead of silently absent.
*/
const speedSub = $derived.by(() => {
if (speedFault) return speedFault;
const now = `now ${num(snap?.virtual_speed_kph ?? 0, 1)}`;
const trainer = snap?.telemetry.speed_kph;
return trainer != null ? `${now} · trainer ${num(trainer, 1)}` : now;
const coasting = snap?.speed_source === 'Coasting' ? ' · coasting' : '';
return (trainer != null ? `${now} · trainer ${num(trainer, 1)}` : now) + coasting;
});
const statusChip = $derived.by(() => {
@@ -143,15 +207,6 @@
*/
const preRide = $derived(ride?.status !== 'running' && ride?.status !== 'paused');
/**
* The rider is looking at invented data. This is never inferred from a
* missing trainer — the app shows zeros for that — it is only ever true
* because someone asked for it with `BIKECONTROL_DEMO`/`BIKECONTROL_MOCK`.
* It still gets a banner, because a session you cannot tell from a real one
* is worse than no session at all.
*/
const simulated = $derived(ride?.source === 'mock');
/** Route length and climbing, said plainly, so "what is loaded" is obvious. */
const routeSummary = $derived.by(() => {
if (!profile) return null;
@@ -166,16 +221,7 @@
const openRoutes = () => (app.showProfiles = true);
</script>
<div class="ride" class:simulated>
{#if simulated}
<!-- Not a chip, not a toast: a rider must not be able to finish a session
and only then find out none of it was real. -->
<div class="sim-banner">
<strong>Simulated ride</strong>
<span>Power, speed and distance are fabricated. No trainer is being read.</span>
</div>
{/if}
<div class="ride">
<!-- Header: what is loaded, what mode, what target (FR-9.8). -->
<header>
<div class="who">
@@ -192,6 +238,16 @@
<span class="chip {trainerChip.tone}"><span class="dot"></span>{trainerChip.label}</span>
{/if}
<span class="chip {statusChip.tone}"><span class="dot"></span>{statusChip.label}</span>
<!--
Both pods, mid-ride, in one chip. A Click that has quietly dropped is
indistinguishable from a Click nobody has touched, and the first press
that does nothing is a bad moment to find out (FR-1.4, FR-9.2).
-->
{#if podChip}
<span class="chip {podChip.tone}" title={podChip.title}>
<span class="dot"></span>{podChip.label}
</span>
{/if}
<span class="chip mode">{MODE_LABEL[ride?.mode ?? 'ManualGrade']}</span>
<span class="chip target">Target {targetText(ride?.target ?? null)}</span>
<button class="btn" onclick={openRoutes}>Route <span class="kbd">P</span></button>
@@ -251,7 +307,7 @@
/>
<Readout
label="Speed"
value={num(d?.smoothedSpeedKph ?? 0, 1)}
value={num(d?.displaySpeedKph ?? 0, 1)}
unit="km/h"
size="big"
sub={speedSub}
@@ -264,6 +320,14 @@
colour={gradeColour}
sub={ride?.gradientOffsetPct ? `trim ${signed(ride.gradientOffsetPct, 1)}%` : null}
/>
<Readout
label="Gear"
value={gear.value}
size="big"
colour="var(--power)"
sub={gear.sub}
dim={gear.dim}
/>
</section>
<!-- Route detail. -->
@@ -294,7 +358,13 @@
colour="var(--power)"
sub={`now ${num(snap?.telemetry.power_w ?? 0, 0)} W`}
/>
<Readout label="Cadence" value={num(snap?.telemetry.cadence_rpm ?? 0, 0)} unit="rpm" size="mid" />
<Readout
label="Cadence"
value={num(snap?.telemetry.cadence_rpm ?? 0, 0)}
unit="rpm"
size="mid"
sub={cadenceSub}
/>
<Readout
label="Heart rate"
value={snap?.telemetry.heart_rate_bpm != null ? num(snap.telemetry.heart_rate_bpm, 0) : '—'}
@@ -368,30 +438,6 @@
flex: none;
}
.sim-banner {
display: flex;
align-items: baseline;
gap: 0.7rem;
flex-wrap: wrap;
padding: 0.55rem var(--edge);
background: var(--warn);
color: #1a1400;
font-size: 0.92rem;
}
.sim-banner strong {
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 0.8rem;
}
/* A hairline of the same warning colour all the way round the ride, so the
state is legible from the corner of the eye at any scroll position. */
.ride.simulated {
box-shadow: inset 0 0 0 2px var(--warn);
}
header {
display: flex;
align-items: flex-start;
@@ -518,7 +564,7 @@
.primary {
display: grid;
grid-template-columns: 1.15fr 1fr 1fr 1fr;
grid-template-columns: 1.15fr 1fr 1fr 1fr 0.8fr;
gap: var(--gap);
padding: 1.1rem var(--edge) 0.9rem;
align-items: end;