Kcals: The Training Metric That Works Across Every Sport
18/04/2026

The whole argument starts with one simple idea: calories are a useful common unit of work.
The problem with tracking multi-sport training
How do you track progress in endurance sports? If you were a single-sport athlete, this would be simpler. As a runner, you can just track your weekly mileage year on year and that's a solid indication of progression. Volume is a great predictor of running performance, and by the law of specificity, you get the best bang for your buck training for running with running.
But for people like me who like to do a lot of things, it gets messy fast.
If I only tracked hours, that wouldn't account for the fact that swimming is non-weight-bearing, and cycling is less weight-bearing than running. That's why you see cyclists pump out 20 to 30 hour weeks while runners go 10 to 15. And even then, hours alone don't capture intensity.

Illustrative only. Equal time does not mean equal energy demand or equal weight-bearing load.
Kcals as a unified currency
Listening to Alan Couzens and Inaki de la Parra made me realise that monitoring work capacity using kcals is the best way to solve this: Getting real about the work. It takes both intensity and duration into consideration, and it gives you a single number you can compare across sports.
The bigger point is that kcals are not just a neat accounting trick. They are a practical proxy for energy capacity.
Endurance is not only about how fast you can move for one session. It is also about how much total work your body can absorb, fuel, recover from, and repeat. That is the piece that gets blurred when everything is reduced to pace, watts, FTP, TSS, or CTL. Those are useful metrics, but they are mostly work-rate metrics. They tell you how hard the work was relative to some threshold. They do not always make the total energy demand obvious.
That distinction matters because many endurance goals are limited by energy throughput as much as speed. Can you keep moving for a long time? Can you eat enough while doing it? Can your legs tolerate the accumulated load? Can you back up a big day with another day instead of needing a week to recover? Those are energy-capacity questions.
This is why low-intensity volume matters so much. A hard session can generate a big-looking training score quickly, but it is usually not repeatable day after day. Easy riding, walking, hiking, jogging, swimming, and other low-intensity work let you accumulate a lot of energy expenditure without turning every session into a recovery debt. That is how the ceiling moves up over years: not by constantly chasing a higher work rate, but by gradually expanding the amount of work you can handle.
Tracking kcals gives me a way to see that directly. If my weekly or yearly kcal total is rising in a controlled way, I am probably building the ability to process more work. If the number is flat, I might be maintaining. If it jumps too fast, I know I am taking on more total energy demand than I may be ready to absorb. That is much more useful for long-term planning than asking whether this year's mix of running, cycling, swimming, walking, rowing, and strength work has the same number of hours as last year's mix.

The mix can shift a lot between running and cycling from year to year, which is exactly why I prefer comparing kcal instead of raw distance or hours from a single sport.
So maybe by now you've bought into the stacking kcals game. The next question is: how do you actually calculate them?
"Just use what your device gives you"
Well, you don't have to calculate anything, right? Your device already spits out calories.
Yeah, but here's where it gets messy.
Every provider is different, and it's all proprietary. There's no way for us to know exactly how the number is obtained.
- Old Garmin reported total kcal (including BMR). New Garmin shows active kcal in Garmin Connect.
- COROS is better: it uses active kcal and explicitly says so in their docs. The downside is no public API, though you can pull the data from the Strava API. The bigger issue for me: COROS didn't exist before 2018, and I was already using Garmin before that. So I have to reconcile kcals from different sources using different proprietary formulas.
- Strava takes whatever kcal your device spits out and doesn't care. It also spits out estimated power.
- Strava app (recording with your phone) has poor GPS data. Online research suggests they use active kcals, but it's still proprietary and not transparent.
- Wahoo is cycling-specific. When power is recorded, it roughly uses 1 kJ = 1 kcal, which is a decent estimate of active kcals. When no power is recorded, it falls back to a proprietary formula, which is still an issue.

Different devices and platforms attach different meanings to the same calorie label.
My first attempt: normalise to active kcals
My first thought was simple: just turn everything into active kcals.
- Subtract my BMR from the Garmin activities that only reported total kcals.
- Keep whatever COROS, Wahoo, and the Strava app gave me, since they already use active kcals by default.
The problem: COROS's "active kcal" has its own secret sauce too.
I tested this by walking the same route under the same conditions, once recorded with Garmin and once with COROS. The Garmin walk reported lower calories even though it included BMR. The COROS walk reported higher calories even though it was active-only. That breaks the whole exercise. If I'm subtracting BMR from Garmin activities to make them comparable, but COROS is systematically inflated relative to Garmin, I'm not actually tracking real year-on-year changes in activity level. I'm tracking noise between devices.

Same athlete, same route, similar duration, different calorie number.
The solution: own the formula
The fix is to download all the raw data and apply my own formulas. That way, regardless of which device recorded the activity, the same formula is used to calculate active kcals across the board. One consistent method, one consistent number, real year-on-year comparisons.

The whole point is to turn provider-specific raw data into one consistent number downstream.
The formulas I actually use
The important part is not that the formula is perfect. It is that the same formula is applied to the same kind of activity every time.
In my dashboard, I keep two calorie numbers:
calories_rawis whatever the source gave me.caloriesis my normalised active kcal number.
That way I can still audit the Garmin/COROS/Wahoo/Strava number later, but the dashboard, yearly totals, and charts all use my normalised number.
The normaliser uses my body weight at the time of the activity where possible. If there is a weight history entry before that date, it uses the latest one. If there is no earlier entry but there is a later one, it uses the earliest future weight as the best available estimate. If weight history is empty, it falls back to my current weight setting. If that is missing too, it uses a default weight; my weight has been fairly stable over many years.
The rule hierarchy looks like this.

Use the best direct signal first and only fall back when stronger inputs are missing.
Cycling with a real power meter
If the activity is a ride and Strava/device metadata says the watts came from a real device, I use:
active kcal = avg watts x duration seconds / 1000
That gives mechanical work in kilojoules, and I treat kJ as roughly equivalent to active kcal. Technically, 1 kcal is 4.184 kJ, but cyclists are only roughly 20-25% efficient at turning metabolic energy into pedal work. Those two conversions almost cancel each other out. So a ride with 800 kJ of measured work becomes about 800 active kcal. It is not perfect, but with a real power meter it is the cleanest number in the whole system.
Running
For running, I use:
active kcal = 0.95 x body weight kg x distance km
The common running-cost shortcut is about 1 kcal per kg per km on flat ground. I use 0.95 because I would rather be slightly conservative than inflate years of run volume. This also avoids the weirdness of heart-rate formulas where heat, caffeine, fatigue, or a bad optical reading can make the same run look like a different metabolic event.
Walking and hiking
Walking is not just slower running, so I do not use the running formula. I use a speed-tiered kcal per kg per km rate:
- Under 4.0 km/h: 0.45
- 4.0 to 5.5 km/h: 0.50
- 5.5 to 6.5 km/h: 0.65
- 6.5 km/h and up: 0.85
Then:
active kcal = rate x body weight kg x distance km
This means an easy walk, a purposeful walk, and a borderline power walk do not get treated the same. It also stops easy walking from being over-counted just because the duration is long.
Concept2 rowing
Concept2 gets its own rule because rowing erg calories have their own model. If I have average watts, my dashboard uses:
active kcal = (4 / 1.1639) x avg watts x duration hours
If watts are missing, it falls back to the source calorie number and subtracts a baseline of 300 kcal per hour to turn it into an active estimate.
Everything else
For anything without a better distance or power model, I use a MET-style fallback:
active kcal = MET x body weight kg x duration hours
The current fallback values are deliberately simple:
- Cycling without real power uses speed tiers from 3.0 to 15.0 MET.
- Swimming uses 7.3 MET.
- Strength or weights use 4.5 MET.
- Generic workout uses 4.0 MET.
- Climbing uses 6.5 MET.
- Rowing uses 5.5 MET.
- Unknown activity uses 3.5 MET.
This is the least precise part of the system, but it is still better than mixing four proprietary calorie models and pretending the output is comparable.
What I deliberately do not do
I do not let heart rate adjust the normalised kcal number right now. Heart rate is useful for intensity analysis, but as a calorie input it brings back the exact problem I am trying to remove: device-specific noise. Wrist HR, straps, heat, dehydration, cardiac drift, and fatigue all affect heart rate. For this use case, I want a consistent work proxy, not a physiological detective story.
How the pipeline works
The pipeline is boring on purpose.
First, my dashboard syncs from the available sources. Garmin, Wahoo, and COROS are handled by Python pipelines when their scripts/tokens are present. Strava is handled inside the TypeScript app. The sync tasks run in parallel, because waiting for each provider one by one would make the app feel broken.
Second, everything lands in the same SQLite activities table. Each activity carries the normal fields you would expect: date, type, duration, distance, average heart rate, average watts, normalised power, source, external ID, and so on. The important fields for this problem are calories, calories_raw, device_watts, source_device_name, source_origin, and dedup_key.
Third, the app deduplicates cross-posted activities. A Garmin run that also appears on Strava should not count twice. The matching is based on date, activity type, distance, and duration, with tolerances for small differences between providers. Wahoo is especially annoying because it can show up as a generic Workout, so the app has special matching rules for Wahoo workout aliases too.
When there is a duplicate, I want the native device source to win over Strava. Strava is great as a social layer and useful as a backup source, but if Garmin, COROS, or Wahoo has the native file, that is the record I trust more. Strava can still fill gaps like titles, descriptions, streams, or external IDs, but it should not be the thing deciding the core training load if the native data exists.
Fourth, after sync, the dashboard runs an active-calorie maintenance pass. It makes sure the raw calorie column exists, preserves the old source value in calories_raw, calculates the normalised active kcal with the rule hierarchy above, writes that normalised value into calories, and updates daily_stats so the dashboard totals stay consistent. The same maintenance pass is also guarded by the app's data version so it can be rerun safely when data changes without pointlessly rewriting the same rows forever.
The final result is exactly what I wanted in the first place. I can record on Garmin, COROS, Wahoo, Strava, or whatever I end up using next, but the long-term chart is not at the mercy of their black boxes. A 600 kcal run in 2018 and a 600 kcal run in 2026 mean the same thing in my system because they went through the same calculation.

Once everything is normalised, year-on-year average daily active calories become comparable again.
This is not nutrition accounting
I would not use this to decide that I "earned" exactly 683 kcal of food. That is a different problem. This is for training history. The goal is not perfect measurement. The goal is a stable, transparent, repeatable metric for how much work I am actually accumulating over months and years.
Sources and formula notes
- Alan Couzens and Inaki de la Parra on work capacity: Getting real about the work
- ACSM walking/running equations, as reproduced in Medicine & Science in Sports & Exercise: paper link
- 2024 Adult Compendium of Physical Activities, MET definitions and activity values: site and PDF
- Cycling gross efficiency example study: PubMed link