Build notes
A heart-rate-clamped Zone 2 controller for smart trainers, written up in the open. None of the method here is novel; the value was in the execution and the honesty. When I started I couldn’t find a plain account of how to build one of these — so here is mine, with the research, the parameters, an honest negative result, and real sample data.
Single-author side project. The shipping overview is current for Zone2 1.1; sections marked as research archives describe development experiments that are not exposed in the current App Store release. This is not medical advice.
A normal ERG workout holds power constant. Your body doesn’t cooperate: as you warm up, dehydrate, and fatigue, your heart rate drifts upward at the same watts — cardiac drift — and an “easy” ride quietly becomes a tempo ride. Zone2 inverts the control variable. It uses your heart rate as feedback and lets the trainer’s power move underneath it.
Normal ERG locks the watts and lets heart rate drift. Zone2 steers from heart rate and lets the watts move.
This isn’t a compromise — for the Zone 2 goal specifically it’s the more honest signal. When HR and power disagree, heart rate is the better proxy for physiological state, and staying under the first lactate threshold matters more than holding a watts number. Iñigo San Millán, the coach behind the Zone 2 revival, uses HR as the primary metric and accepts declining power; TrainerDay’s HR+ mode implements the same inversion. The Friel-school “power leads, HR informs” reconciles cleanly: for fat-oxidation base work, clamp HR and let power fall. (High North on San Millán, TrainerDay HR modes)
Before building the controller I had to pin down what it should produce. The short version of the research (full notes):
The shipping behavior-v17 loop is continuous and bounded — a calm hand on the dial, not a sequence of fixed jumps. Once per second it reads smoothed heart rate, updates an adaptive position-form regulator, and proposes the trainer power that best fits the centre of the saved band. The shared actuator then moves progressively toward that request:
| Knob | Value | Why |
|---|---|---|
| Input cadence | 1 Hz | Each valid sample updates the same regulator; there is no 30-second decision timer. |
| HR smoothing | 15 s | Control uses the displayed smoothed response rather than letting one high beat yank the trainer. |
| Nominal law | adaptive PI | An evolving watt estimate plus bounded band error produces one desired target every tick. |
| Trainer delivery | up to 2 W/s | Normal changes are slew-limited in either direction so a new request is delivered progressively. |
| Above-band authority | downward only | A separate governor can withhold or lower the nominal request when displayed HR is above the band; it never raises power there. |
This replaced the early prototype’s 30-second +4/−8 W step scheme. The current app does not issue one fixed-size “ease” command; it continually revises a desired target while the delivery and safety layers keep the result bounded.
This app actuates hardware attached to a person who is working hard. So the safety rules don’t live in the UI where a refactor can lose them — they live in the portable core, each pinned by a named regression test. The governing principle for handled control paths: safety actions never request harder trainer power. A process crash or OS suspension cannot issue a new FTMS command, so those system-level failures are outside this guarantee.
| Invariant | Behavior |
|---|---|
| HR signal lost | The target decays toward the ramp-in floor (~1 W/s after a short grace) — it never escalates power to “chase” a heart rate it can no longer see. |
| Implausible HR | Raw HR outside 30–220 bpm is dropped at intake; stale packets are rejected by timestamp. (A valid constant BPM is not treated as stuck — steady HR is real.) |
| Lost skin contact | The strap’s contact bit, debounced ~5 s to ignore jersey flutter, routes into the same ease-down path — earlier and more specific than the stale-signal backstop. |
| Power ceiling | Every target passes an internal command ceiling. It is deliberately not a rider setting; a corrupt configuration that could invert the clamp is rejected on load and falls back to safe defaults. |
| HR ceiling hard-stop | A hard ceiling above your band bypasses the nominal loop and latches. It auto-tracks the band (band-top + a fixed margin) so it can’t be accidentally left below the target. |
The clock is injected into the core, so all of this is tested in virtual time — a mid-ride disconnect, a half-open link, a suspend/resume — without waiting for real seconds. That’s what let me trust it before pointing it at a trainer with my own legs on it.
The shipping app offers three simple inputs: an observed maximum heart rate from Apple Health, an age-based maximum estimate, or a maximum heart rate you already know. You review and confirm the resulting band before Zone2 saves it. The current public release does not expose threshold-HR entry or a DFA-a1 calibration test.
The experiment used DFA‑a1 — the short-scale detrended-fluctuation exponent of beat-to-beat (R-R) intervals — to estimate the top of an easy aerobic band with a short step test.
As a rider crosses the first ventilatory threshold (VT1, the top of Zone 2), heart-rate variability loses its fractal correlation and a1 falls through ~0.75. The step test walks power up in rungs, holding ~90 rpm, and reads the HR at which a1 crosses 0.75.
nolds implementation to
~1e‑15 on public PhysioNet data. I built an implementation, not a discovery.
The one subtlety worth stating plainly, because it burned me: there are two different percentages in this pipeline and they are not the same knob.
Rejected beats are interpolated to the local reference rather than dropped, so the DFA profile has no discontinuity. On a real Polar H10 this “auto + interpolate” pipeline is what stopped spurious a1 crashes; the plain fixed-20%-drop default gave false threshold crossings.
The experimental UI stated its limits plainly: the box math is validated against a reference on public data; the VT1 read reports its HR with a confidence interval (the a1 uncertainty propagated through the local HR-vs-a1 slope, floored at ±2 bpm). It is biased low — it tends to place VT1 a touch under the true threshold — which is the conservative direction for this experimental estimate, though it can also leave a rider below their intended training intensity. What is not claimed: that a single step test is a lab test, or that a “confirmed” read is anything more than a good-quality a1 read. (the deeper calibration writeup)
Cadence can affect heart rate at matched power, but the direction and size vary by rider. The development notes are retained in full (cadence research); the shipping guidance is intentionally modest:
For comparisons across rides, keeping cadence reasonably similar removes one source of noise from watts-at-a-given-HR trends.
The Polar H10 can stream raw single-lead ECG (130 Hz) and chest accelerometer (200 Hz). I spent real effort asking whether a second, independent breathing-based signal could cross-check the a1 crossing. The result is a clean, recorded negative — kept so nobody (including future me) re-runs it from scratch (full study + figures):
Chest-motion breathing rate was too contaminated by pedaling and posture on the trainer to yield a reliable ventilatory breakpoint.
R-peak-amplitude respiration did show a ventilatory breakpoint that matched the a1 VT1 on a maximal ramp — genuinely promising.
Why I dropped it anyway. A breakpoint cross-check needs data ~2 rungs (roughly +40 W, into Zone 3) above VT1 to see both arms of the “V”; the a1 crossing only needs to reach VT1. Requiring a Zone 2 calibration to overshoot into Zone 3 drags the rider into exactly the zone the whole product exists to keep them out of — to buy marginal precision on a band that’s already biased safe. Add that raw PMD needs a specific strap, exclusive Bluetooth access, and ~16 MB/hour of capture, versus R-R intervals available from compatible straps such as the Polar H10, and the trade is lopsided. Trust the a1. The toolkit and the finding are kept so this can be revisited with evidence, not from a blank page.
When I started, the thing I most wanted and couldn’t find was real ride data with R-R intervals to develop against. So here’s a small set:
R-R here is stored as an integer millisecond list per sample — load it with any HRV library. The calibration/fusion tooling that produced the a1 reports lives with the application code.