Cycling run through the numbers: 95 percent basal metabolism and a route with 0 km of new road

It started with a duplicate point in a chart. My Health Dashboard showed today twice, because an end marker got appended even when a data point for today already existed. The fix turned into a rebuild, and the rebuild turned into the question that kept me busy for two weeks: how do I actually optimise my cycling?

[[img:1]]

The data is 23 GPX tracks from 60 days, pushed second by second through a physics model instead of Apple's heart-rate guess.

Training is not the lever

My reflex was to tweak the training. The numbers say otherwise: over 60 days, 130,500 kcal came from basal metabolism and 6,595 from all training combined. 95 to 5. A double cheeseburger at 1,273 kcal equals 165 minutes on the bike.

So cycling is not a calorie lever, it protects muscle and trains the cardiovascular system. The balance is decided at the dinner table. Within a ride there are still clear differences:

uphill above 2 percent: 10.4 kcal/min, but only 14 percent of ride time

flat: 5.2 kcal/min, 78 percent of the time

downhill: 0.5 kcal/min

For 22 percent of the ride time I coast below 25 watts, which is basically just basal metabolism. One metre of climbing costs 1.31 kcal at 134 kg system weight, regardless of speed. Climbing is the strongest single lever, riding faster the weakest: ride a fixed loop faster and you are simply home earlier. I plan in minutes now, not kilometres.

Heart rate is a symptom, not a control. The correlation between heart rate and burn is 0.82; take speed out of the equation and 0.54 is left. The rest is heat, fatigue and hydration.

Speaking of drinking: I first had 1.0 to 1.5 l/h of sweat in the plan, taken from endurance-athlete literature. Wrong transfer, those numbers come from road racers at 250 to 300 watts. Only what shows up as heat can evaporate, and a litre of sweat removes 2,430 kJ. At my 120 to 180 watts it is really 0.5 to 0.8 l/h. The headache lever is not water anyway, it is sodium: roughly 800 mg is lost per litre of sweat, and topping that up with plain water dilutes your blood sodium even further.

The idea I dropped: letting water run downhill

If climbing is the lever, I need a loop with as much of it as possible inside the same time budget. So I built a route planner: OpenStreetMap for the paths, an elevation model for the heights, the power model for the speed.

My first approach was a water simulation. Start at the highest rideable point and let water run downhill, always taking the steepest descending edge. These flow paths are by construction the steepest continuous lines in the network, so ridden backwards they are the climbs with the highest metres per kilometre. Count how many sources pass over each edge and you get flow accumulation, the same thing hydrologists use to find streams. Just for ramps instead.

It does not work as route planning, and that became obvious quickly. A flow path runs top to bottom and stops at the bottom. It knows nothing about a time budget, nothing about getting back, and certainly nothing about a loop. What I wanted was an evening ride that ends where it started. So the simulation went out again and the optimiser now works directly on the road graph.

What is left of it is a nice map showing where the ramps actually are.

[[img:2]]

A route with 0.0 km of new road

The first usable run won with 259 metres of climbing. I asked where exactly I would be riding. Answer: out to Hopferbach, the same ramp twice, same way back. 0.0 km ridden only once.

The optimiser was right. Maximising climbing alone inevitably leads to repetition, because every new stretch costs time on flat connecting road while riding the same ramp again costs nothing. I was not missing compute, I was missing a constraint. --min-new now sets the minimum share of road ridden only once.

Another question had already exposed a real bug before that. I only wanted to know how to get home from Fünfhäuser, and got 9.8 km for a connection 1.4 km as the crow flies. That was not a detour, that was a severed graph.

The most expensive mistake was resolution

The gradient filter dropped every edge steeper than the rideable limit, and it checked edges 20 to 50 metres long as well. On an elevation grid with 300 metre cells, their height difference comes from interpolation noise, not from real gradient. Flat roads fell out of the network. The rule is now fixed in the code: only apply the gradient filter to edges longer than half the cell size. After the fix every route variant gained 18 to 42 metres of climbing.

The actual fix was getting rid of the coarse grid. Baden-Württemberg publishes its official DGM1 elevation model for free, 1 metre cells, 2x2 km tiles. The URL scheme is documented nowhere, but it is right there in the source of the official QGIS plugin:

https://opengeodata.lgl-bw.de/data/dgm/dgm1_32_{easting}_{northing}_2_bw.zip

Tile grid in UTM32, easting odd, northing even, both in kilometres. Without that, all you get is 404s.

Computing at 1 metre would be nonsense: my 9 km radius would be over 10 GB of elevation points, and what matters is the gradient over edge lengths of 20 to 200 metres. So I aggregate down to 10 metres with gdalwarp -r average. 12 GB of raw data become a 9 MB GeoTIFF, thirty times finer than before, and it fits entirely in RAM. The aggregation is not a compromise, it is the whole point: it removes exactly the noise that sabotaged the gradient filter earlier.

It runs in a container on ghcr.io/osgeo/gdal, because I do not want a GDAL toolchain on my Mac:

docker run --rm -v "$PWD:/work" healthgeo \
python3 scripts/geodata/build_dem.py --radius 9 --target-res 10 --out var/route-cache/dem.tif

One detour I saved myself: pyosmium refused to install without a build toolchain. Turns out GDAL ships the OSM PBF driver anyway, and ogr2ogr reads the Geofabrik extract directly.

[[img:3]]

What came out of it

[[img:4]]

231 metres of climbing, 17.9 km, 59 minutes, 98 percent new road. My previous loops were 82 metres in 41 minutes. That is +149 metres of climbing and roughly 250 kcal for 18 minutes more time.

The exact route is obviously not part of this post. Map and elevation profile are trimmed by 2.5 kilometres at each end and the start marker is gone, otherwise I would be publishing where I live along with it. If you want the same thing, take the planner and your own starting point.

The route is not provably optimal. Maximum climbing on a loop within a time budget is an orienteering problem and therefore NP-hard. It is deterministic though: same input, bit-identical route.

What I take away from those two weeks: both real bugs came out of the sentence "I don't understand this", not out of tests. And the most expensive mistake was not in the physics, it was in a grid that was too coarse.