A watt is a fact; a zone is what it costs you. The biggest number on the
ride screen was the same shade of white at 90 W and at 400 W, which is a
thing no training app has done in fifteen years.
Zones, with two rules:
- **No reference, no zone.** An unset FTP draws the plain number. A zone
measured against a guessed threshold would paint every ride with a
confident lie.
- **Colour never carries it alone.** "Z4" renders beside the swatch, so
the meaning survives a colour-blind rider, a phone in direct sun and a
black-and-white screenshot.
Read off the rolling average, not the instantaneous watts: at 4 Hz the
raw figure crosses two boundaries every pedal stroke, and a colour that
strobes is worse than no colour. Not pedalling is not zone 1.
Units are a display preference applied at the last step before the
glass. Everything computed, stored and recorded stays SI, so a FIT file
never depends on what the screen was set to. `format.ts` takes the unit
system as an argument rather than reading a module-level setting — pure
functions are what let every readout redraw the moment it changes. The
`km` helper is gone rather than left beside `dist`, so there is no
second way to format a distance that ignores the preference.
Rust's block labels lose their baked-in kilometres. The block already
carries start_x and end_x and the frontend renders that span in the
rider's units; a kilometre in the text sat inside a sentence saying
miles everywhere else.
Also on the ride screen:
- The gradient gets a wedge beside the number. A signed decimal has to
be read; a slope is seen. Exaggerated and clamped, because a true-scale
6% is indistinguishable from 3% at 40 px wide.
- What is coming, from the profile's own block list — "2.1 km at 12% in
460 m". The chart says where the rider is; what is about to happen is
what decides whether to shift now. The data was already computed
Rust-side and thrown away here. Close in, the small unit reads better
than a fraction of the big one.
- Mode and target merge into one chip. They are a single fact, and
splitting them spent a chip of header width repeating the word
"target".
- The pod chip no longer reports a missing `+` pod while the `−` pod is
connected. The `−` pod relays its twin, so that is the intended
configuration — the ride screen was calling it a fault, contradicting
the device screen two keystrokes away.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Adds backing state for Resistance and Erg control modes, which had no
value to hold and so could never satisfy FR-4.3/FR-4.6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>