---
title: Rebuilding Garmin's proprietary Endurance Score from Intervals.icu data, within 2%
date: 2026-05-25
tags: [garmin, intervals-icu, endurance-score, sports-science, reverse-engineering, triathlon]
type: success
status: n/a
reproducible: true
---

# Rebuilding Garmin's proprietary Endurance Score from Intervals.icu data, within 2%

Garmin watches show one headline number — the Endurance Score (roughly
1–8600) — that blends all your sports into "how is my endurance overall".
Athletes check it before anything else. The formula is closed: Firstbeat
Analytics doesn't publish it. My AI agent and I rebuilt the *structure* from
open sports-science formulas, calibrated it on my own training history, and
landed within −2% of the Garmin reading.

The shape that worked: a VO₂max anchor (≈3500–5500 points for an age-grouper)
plus five capped bonuses for actually using that engine — long-term load,
ramp rate, long sessions, consistency, aerobic stability. Per-sport VO₂max
comes from published estimators (Storer for cycling FTP, Daniels/ACSM for
running threshold pace), blended by each sport's share of training load.

What we rejected or got wrong on the way:

1. Multiplicative form `VO₂max × (1 + training)` — a sedentary person with
   good genetics would score high without training. Additive + caps fixed it.
2. ATL (acute load) as a component — that's fatigue, not endurance. An
   overtrained athlete would get a bonus. Replaced with capped ramp rate.
3. Curve-fitting weights to the Garmin anchor — four free variables against
   one observation is infinitely many solutions. Weights stay literature-fixed.
4. A 12-week consistency window straddled training-phase changes (peak week +
   injury layoff in one window → CV 0.8, "consistency" became "did you have a
   long break"). Shrunk to 8 weeks, empty weeks skipped.
5. Manual FTP in settings silently poisoned historical trend points — mine
   still said 225 W from a December peak while actual eFTP was 208. Fixed by
   reading date-specific eFTP per trend point.

Validation across five phases of one athlete-year: true peak scored ~6900,
detraining bottom ~4400, base-without-long-sessions ~4600 — all
distinguishable, and the single Garmin anchor (5773) came out as 5660
predicted. Post-ship honesty: two structural defects surfaced later (the
score barely leaves your zone week-to-week; "Detrained" is unreachable
because the VO₂max anchor never decays) — a decay factor and
personal-relative zones are the follow-up.

## Reproduce this

Prerequisites: 6+ months of training data in [Intervals.icu](https://intervals.icu)
(activities with training load, per-sport fitness/CTL in wellness
`sportInfo`); your FTP and/or running threshold pace, weight, and age. A
Garmin Endurance Score reading is optional but makes Verify stronger. API
access per the [Intervals.icu API thread](https://forum.intervals.icu/t/api-access-to-intervals-icu/609)
— no special skills or plugins required, any agent with HTTP access works.

Your agent will need from you: your Intervals.icu athlete id and API key
(Settings → Developer), weight (kg), age, and — if you have one — the
Endurance Score number your Garmin currently shows.

Steps:

1. Per-sport VO₂max (ml/kg/min):
   - Bike, from Storer et al. 1990: `(10.51·ftp_w + 6.35·weight_kg − 10.49·age + 519.3) / weight_kg`.
     Prefer a date-specific eFTP over a manually-set FTP.
   - Run, from Daniels (threshold ≈ 86% of vVO₂max) + the ACSM running
     equation: `v_threshold_kmh / 0.86` → `0.2 · v_m_per_min + 3.5`.
   - Swim: proxy with the run value (no accepted swim-pace estimator exists).
2. Blend by sport share of total fitness: `vo2max_composite = Σ share_sport · vo2max_sport`,
   where `share_sport = ctl_sport / ctl_total` from wellness `sportInfo`.
3. Score = `100 · vo2max_composite` plus five capped bonuses:
   - LongTerm: `min(ctl_avg_8w / 80, 1) · 1000`
   - Recent: `clamp(ramp_rate / 8, 0, 1) · 200` (negative ramp → 0, not a penalty)
   - Duration: share of 28-day load from long sessions (run ≥90 min,
     ride ≥120, swim ≥60, ≥70% of time in Z2+), `clamp(share, 0, 0.5) · 800`
   - Consistency: `clamp(1 − CV(weekly_load_8w), 0, 1) · 200`, population
     stdev, skip empty weeks, need ≥4 non-empty
   - Recovery: share of sessions ≥45–60 min with mean DFA-α1 ≥ 0.75, `share · 200`
     (needs an HRV strap; 0 if fewer than 3 valid sessions)
4. Compute the score for today and for a few contrasting past dates (a peak
   block, a layoff, a base block).

Verify: contrasting phases must separate by ≥1500 points (peak highest,
layoff lowest — if they don't, your inputs are off). If you have a Garmin
Endurance Score, the computed number should land within ±15% of it; ours came
within 2% on the only anchor we had. Don't tune the weights to match your
single anchor — that's the trap (step-3 weights are the deliverable).
