I built a tool to prove my AI coach wrong about my Zone 2 rides. It sided with him. A developer built z2analyzer, an open-source Python web app (Apache 2.0, 51 tests) that attributes minutes spent above Zone 2 heart rate to three causes: road gradient, heat, and rider effort. Running locally, it ingests FIT files after a ride or GPX routes with Météo-France temperature forecasts before one, reporting that Zone 2 was possible on 88% of the kilometres but only 72% of the time on a sample ride. The tool found that on an 11% climb at the rider's slowest sustainable pace, the required power exceeded the Zone 2 ceiling, confirming the AI coach's claim that such minutes were imposed by the road rather than the rider. This is a submission for the Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady https://dev.to/challenges/hacktoberfest-week3-2026-10-19 On 4 October my AI coach, an agent I run in OpenClaw, had planned a long ride in Zone 2: heart rate under 140, even on the climbs. A bit more than two hours in, the climb to Canareccia turned up to 11 % for 900 metres. I was in 34×34, the easiest gear on the bike, my legs turning at 57 rpm. My heart was at 170. After my long rides, the coach keeps saying the same thing: my Zone 2 rides drift into tempo. Its notes even have a theory: I chase personal records on Strava segments. That Sunday I beat my best time up Canareccia by one second, so I won't argue with that one. What I wanted to know was something the curves don't say: on that 11 % stretch, at the slowest pace I can actually ride, was Zone 2 even possible? Zone 2 is the one workout where holding back is the assignment: ride slowly, for a long time. I keep failing it, and working out why means a long look at the curves after each ride. z2analyzer does that chore: a small web app I run on my own machine, which gives the answer in two lines and keeps every analysis. After a ride , I start a new analysis and drop the FIT file from my bike computer. A few seconds later, the page splits the minutes I spent above Zone 2 between three causes: For the Canareccia ride, in two lines: 119 min above Zone 2: 45 imposed by the road, 5 by the heat, 70 mine. Zone 2 was possible on 88 % of the kilometres, 72 % of the time. Before a ride , I drop a GPX and pick a start time. It takes the temperature from the Météo-France forecast and tells me how much of the route I can ride in Zone 2, in kilometres and in time, and where it can't be done. The same loop, for Saturday 24 October at 8:30: Zone 2 is possible on 89 % of the kilometres, but 70 % of the time. 11 passages where it is not: 6.8 km, 1 h 02 even at your slowest pace. The two numbers differ because climbs are slow: they weigh more in time than in distance, and my coach counts in time. I don't even have to drop the file: pointed at the folder where my FIT files land, z2analyzer judges each new ride on its own, and the verdict waits in the list. It is not a route planner. It suggests no route and gives no training advice: it looks at a route I give it, or a ride I did, and says what the road allowed. The video runs both on my own rides, from a new analysis to its result page. It shows minutes and percentages only, and no map or profile of the first and last 2 km of a ride. How much of a ride can really stay in Zone 2? On some slopes, even in your smallest gear and at the slowest cadence you can actually hold, the power the road asks for is above your Zone 2 ceiling. Those minutes are the road's, not yours. z2analyzer sorts out which minutes are which, before and after the ride, so you don't have to read the curves. It is a small web app you run on your own machine: open it, start a new analysis, drop a file, and the result page comes a few seconds later. Every analysis stays in the list. Python, Apache 2.0, 51 tests. z2analyzer serve is the web app: it runs on your machine, keeps every analysis, and its pages load nothing from the internet. prepare , validate and learn build your limits from your own FIT files; plan and review give the same verdicts from the command line. The tables in this post come out of z2analyzer validate , tools/experiments.py and tools/proof.py , and docs/results.md https://github.com/dsiacci/z2analyzer/blob/main/docs/results.md has them all, with what failed. The power needed to hold a speed on a slope is textbook physics: gravity, rolling resistance and air, divided by the drivetrain's efficiency. On a climb at walking pace, gravity is almost all of it. My bike has 50/34 chainrings and an 11-34 cassette, so the easiest gear, 34×34, moves the bike 2.1 m per pedal turn. Times the slowest cadence I really hold, that is the slowest speed I can ride. Where the power needed at that speed is above my Zone 2 ceiling, Zone 2 is impossible. The grade doesn't come from the GPS or the barometer. It comes from the IGN's terrain model of Corsica RGE ALTI, 5 m cells, open licence , read from files on disk, so no point of my rides is sent anywhere. Bridges and tunnels come from OpenStreetMap and are drawn straight; otherwise the profile dives into the river under the bridge. Checked against my bike computer's barometer, the total climbing agrees within 4 % on nine rides out of ten, and 8 % on the tenth. Three things in that calculation are personal and change with the conditions: I gave all three to TabPFN v2. It takes your rows as context and predicts new ones in a single forward pass, with a full distribution instead of one number. It runs on a CPU. My data: ten outdoor rides with power-meter pedals since mid-August, 24.7 hours cut into 30-second windows. I scored it the only way that matters for a tool like this: each ride predicted by models that never saw it, against a linear regression on the same inputs and a constant. | On rides it never saw | TabPFN v2 | Linear regression | Constant | |---|---|---|---| | Heart rate, mean error | 8.1 bpm | 6.5 bpm | 16.2 bpm | | Lowest cadence, loss at the 10 % quantile | 1.82 | 1.59 | 1.60 | | Speed, mean error | 0.70 m/s | 1.20 m/s | 2.29 m/s | TabPFN won the speed by a wide margin: speed against slope and power is curved physics, with descents where I brake. It lost the cadence, where my lowest cadence turned out to be almost a constant. And it lost the heart rate, for a reason I hadn't seen coming. At the same power, my heart doesn't sit at the same level from one day to the next. On 16 August it ran 13 bpm below its usual level; on 19 August, a long hot day, 7 above. Sleep, fatigue, water: none of it is in a FIT file. Each of my rides also had its own range of temperatures, so TabPFN could all but recognise a ride by its temperature and copy that day's level onto the ride it was predicting. A straight line can't do that. When I took each day's level out of the training rides, TabPFN caught up: 4.85 bpm against 4.68 for the line, on the second half of each ride. It still didn't pass it. So here is what z2analyzer does: validate scores both models on your rides, and learn --models keeps the winner of each table. On mine: the line for the heart rate and the cadence, TabPFN for the speed. On yours it may come out the other way. The FIT file has no gear channel, but speed divided by cadence gives the gear away: on my bike the two easiest gears are 13 % apart, so the tool can tell when 34×34 was already in. That gives a test that depends on no model at all. Over my ten rides, where z2analyzer says Zone 2 was possible and my heart was above it anyway, an easier gear was still there 91 % of the time. The web app is Python's standard library and nothing else: a small HTTP server, one folder per analysis the file dropped, its verdict, its page , and one background thread that runs the analyses in turn, each in a few seconds. It listens on the machine it runs on, and nothing in its pages comes from the internet. Each of my ten rides, judged with limits learned from the nine others: | Ride | Air | Above Zone 2 | The road | The heat | Me | |---|---|---|---|---|---| | 14 Aug | 22 °C | 27 min | 0 | 0 | 27 | | 16 Aug | 24 °C | 4 min | 0 | 0 | 4 | | 19 Aug | 29 °C | 201 min | 27 | 8 | 166 | | 24 Aug | 25 °C | 18 min | 0 | 0 | 18 | | 29 Aug | 25 °C | 47 min | 25 | 3 | 19 | | 31 Aug | 23 °C | 16 min | 0 | 0 | 16 | | 13 Sep | 21 °C | 34 min | 0 | 0 | 34 | | 20 Sep | 22 °C | 84 min | 1 | 1 | 82 | | 27 Sep | 19 °C | 86 min | 1 | 0 | 85 | | 4 Oct | 24 °C | 119 min | 45 | 5 | 70 | | All | | 637 min | 98 | 17 | 522 | Out of 637 minutes above Zone 2, the road made 98. I made 522. My AI coach was right four times out of five. And it isn't the model talking: with TabPFN for every table, the road gets 88 minutes and I get 516. The road does make Zone 2 impossible on my steep rides: on Canareccia, my slowest possible pace needed 1.5 times my ceiling, and I was in 34×34 almost all the way. On the 58 km loop I rode three times in September, though, Zone 2 was possible on 98 to 100 % of the kilometres, and on 27 September, the day the coach wrote "not Zone 2", 85 of my 86 minutes above it were mine. Even 14 August, where I spent three quarters of those minutes in 34×34, goes to me: I was turning at 54 rpm on grades where I hold 46 when I have to. The heat barely counts, because I ride at dawn and a hot day's extra heartbeats partly show up in that day's level instead. Two things the tool doesn't say. It counts minutes, not how hard: on Canareccia the road put me above Zone 2, it didn't make me beat my record. And it doesn't tell me to ride flatter roads; it just stops me blaming the island for the rest. A recorded ride is my heart rate second by second, my power and exactly where I ride, and I want to keep control of that. z2analyzer looks like a web service, but it's one I run myself: the files I drop stay on machines I run, and TabPFN's weights are downloaded once and run on a plain CPU, with no account and no cost per analysis. The terrain, the bridges and the weather are open data, and the only thing about a ride that goes out is one weather point, rounded to about 11 km. The open weights did something else for me. Because I could run TabPFN on my own rides as often as I liked, I could test it against a straight line, find out where it lost, and keep the line there. Behind an API, every one of those runs would have sent my rides out. And nothing in it is mine except the numbers. Your mass, your gears, the top of your Zone 2 and your own FIT files replace mine; without a power meter, z2analyzer fixed builds the limits from three numbers. Nobody does this analysis by hand. Going through every 30 seconds of a three-hour ride, working out the slowest pace the slope allowed and comparing it with a ceiling that moves with the heat and the hours, is too long and too tedious to do objectively, so I never did it: I argued with the curves instead. Now it's a new analysis, a dropped file and a few seconds, or nothing at all when the ride lands in the watched folder. This week I ran it on my ten rides since August. Best Use of TabPFN. TabPFN v2 turns kilometres into time: it predicts my speed riding easy from the slope and the power, with 40 % less error than a linear regression on rides it never saw, and time is what my coach counts. It was also tested on my heart rate and my lowest cadence, where it lost to a straight line, and z2analyzer picks the model of each table from that test, on your own rides. Built with PriorLabs-TabPFN. Wind isn't counted. The Zone 2 ceiling is the top of my coach's Zone 2, 145 bpm, a little above the 140 it asks for on long rides. The numbers hold for one rider and one bike, mine, and the models only know the conditions of my rides: below 19 °C, for instance, the tool counts the air as 19 °C, and it says so. It's a finding about a route or a ride, not training advice and not medical advice. If you ride in the mountains: how do you keep your Zone 2 on climbs like these? Do you pick flatter roads, get off and walk, or accept the minutes?