A local command-line tool that reads the FIT files from your Garmin and answers two questions most cycling tools skip, both without a power meter.
Not raw average speed, which wind and terrain drown out, but speed per heartbeat, corrected for wind, gradient and equipment. A fast day into a tailwind stops looking like progress, and a hard slog into a headwind stops looking like decline.
Tyres, riding position, a different bike, fewer stops. Measured against your own rides rather than a generic calculator.
Within-ride A/B tests work today. The ranked what-if table is still to come.
Both answers come out of the same physics: estimate power from speed, gradient and air density, correct every ride for the wind you actually rode through, then compare like with like.
$ wattif analyse --min-km 20 Analysing 49 ride(s)... 49 ride(s) analysed, 49 with weather. quarter n raw equiv hr headwind efficiency 2024Q3 5 22.7 23.3 147 +0.06 0.1583 2024Q4 5 23.0 23.6 149 +0.01 0.1591 2025Q1 5 23.2 23.8 154 -0.01 0.1546 2025Q2 5 23.9 24.7 153 +0.04 0.1620 2025Q3 5 24.3 24.8 156 -0.04 0.1591 2025Q4 5 25.3 26.0 161 -0.05 0.1614 2026Q1 5 25.3 26.3 161 +0.01 0.1631 2026Q2 5 26.3 26.9 165 +0.04 0.1632 2026Q3 9 26.2 26.7 163 +0.04 0.1638
Whole-ride comparisons are a poor way to judge equipment. Speed per heartbeat scatters around 5% from ride to ride, so a 4% change needs roughly twenty-five rides per arm before it clears the noise, by which time the seasons have moved underneath the experiment.
wattif ab puts both arms inside the same ride instead. Set the watch to auto-lap every 5 km, switch setup at every beep and change nothing else. Weather, fitness and route are then identical on both sides of the comparison.
| to resolve | rides needed |
|---|---|
| 5% | 4 |
| 3% | 10 |
| 2% | 23 |
$ wattif ab --since 2026-09-01 \ --arms hoods,drops --no-weather 2026-09-03 08:00 8 block(s) 40.5 km 2026-09-07 09:00 8 block(s) 40.6 km 2026-09-12 10:00 8 block(s) 40.5 km 2026-09-16 11:00 8 block(s) 40.7 km 32 block(s) over 4 ride(s), 24 drift-corrected pair(s). arm equiv km/h hr efficiency hoods 25.89 161 0.1613 drops 26.90 162 0.1662 drops vs hoods: +3.88% (95% CI +1.48% to +6.27%) uncorrected difference: +3.03% per-ride scatter of the paired difference: 1.50% to resolve 2%: 5 ride(s) to resolve 3%: 2 ride(s) to resolve 5%: 1 ride(s) The difference is significant at 95%.
Sanity-check the rig before testing anything real: do a few rides with the same setup throughout, still auto-lapping every 5 km, then run an arm against itself with wattif ab --since <date of the first of those rides> --arms null_a,null_b. It should report roughly nothing, with an interval spanning zero. If it does not, no equipment answer from those blocks will be trustworthy either.
No server, no account, no telemetry. Your rides stay on your machine. The only thing that leaves it is a coarsened coordinate for a historical weather lookup, and that is cacheable and optional. Heart rate is health data, which is why this stays a local tool rather than a hosted one.
Your Garmin password is used once to obtain tokens and is never written to disk. Garmin has no personal API, so wattif sync speaks to the same private endpoints their phone app uses. That has broken before, so the official Garmin export ZIP always works as a fallback.
Python 3.11 or newer.
$ git clone https://github.com/jakubwaller/wattif.git && cd wattif $ python3 -m venv .venv && source .venv/bin/activate $ pip install -e . $ cp config/profile.example.yaml config/profile.yaml # then edit it $ wattif sync # pull recent rides from Garmin Connect $ wattif import ~/Downloads/x.zip # or import a Garmin bulk export $ wattif analyse --min-km 20 # corrected trend, quarter by quarter
More in the README.