AI Cycling
002 Analysing Ride Data With LLMs 1,729 words · 8 min

Getting Your Ride Data Out: FIT, TCX And GPX Without Losing Fidelity

Most AI ride analysis is wrong before the model sees a single watt. Not because the model is bad at physiology, and not because your prompt was sloppy. It’s because you clicked the wrong download button, handed over a file that never contained the channels you were asking about, and the model politely answered anyway.

I’ve watched this happen enough times to be blunt about it. Someone uploads a GPX to Claude or ChatGPT, asks why heart rate climbed through a threshold session, and gets three paragraphs of confident reasoning about pacing and cardiac drift. The file had no temperature data. It had no lap markers. Depending on which parser the tool used, it may not have had power at all. The model inferred an answer from position, elevation and time, which is a perfectly reasonable thing to do with what it was given, and a completely useless answer for a rider deciding what to do on Thursday.

What each format can physically hold

Forget marketing and think about schemas. Three formats, three very different ceilings.

FIT is Garmin’s binary format, built on the ANT+ spec, and it’s a container rather than a fixed table. A record message can carry timestamp, position, altitude (barometric, not GPS-derived), distance, speed, power, cadence, heart rate, temperature, left/right balance, left and right torque effectiveness, pedal smoothness, platform centre offset, respiration rate and accumulated power. Alongside those it stores lap messages with per-interval averages, event messages including Di2 and AXS gear changes, hrv messages with raw RR intervals, device_info for every connected sensor, and developer fields where Xert writes MPA, Moxy writes SmO2 and THb, and CORE writes body temperature.

TCX is Garmin’s older XML format, and it’s surprisingly decent. Each Trackpoint holds time, position, altitude, distance, heart rate and cadence, with power tucked into a TPX extension as <Watts>. Laps survive, because TCX is structurally lap-first: every Activity contains Lap elements with TriggerMethod and Intensity. What dies: temperature, balance, torque effectiveness, pedal smoothness, gear data, HRV, developer fields.

GPX was designed to describe where you walked. Its base schema is latitude, longitude, elevation, time. Everything else arrives through extensions, and the commonly supported one (gpxtpx:TrackPointExtension) defines heart rate, cadence, air temperature and water temperature. Power is not in it. Strava works around this by writing a bare <power> element as a direct child of <extensions>:

<trkpt lat="51.4501230" lon="-2.5872340">
  <ele>112.4</ele>
  <time>2026-09-14T08:12:37Z</time>
  <extensions>
    <power>287</power>
    <gpxtpx:TrackPointExtension>
      <gpxtpx:atemp>14</gpxtpx:atemp>
      <gpxtpx:hr>148</gpxtpx:hr>
      <gpxtpx:cad>91</gpxtpx:cad>
    </gpxtpx:TrackPointExtension>
  </extensions>
</trkpt>

Notice where <power> sits. It is outside the namespaced extension, which means a tool built on gpxpy gets it only if someone wrote code to walk the raw extension tree looking for it. Plenty haven’t. That’s the mechanism behind the single most common failure mode in AI ride analysis: the file contained power, the parser dropped it, nobody was told, and the model reasoned from speed and gradient instead.

ChannelFIT (original)TCXStrava GPXStrava API streams
1s powerYesYesNon-standard tagYes (watts)
CadenceYesYesYesYes
Heart rateYesYesYesYes
TemperatureYesNoYes (atemp)Yes (temp)
Barometric altitudeYesAltitude onlyAltitude onlyAltitude only
Lap / interval markersYesYesNoNo
L/R balanceYesNoNoNo
Torque effectivenessYesNoNoNo
Gear changes (Di2/AXS)YesNoNoNo
RR intervalsYesNoNoNo
Developer fieldsYesNoNoNo

The worked example

Take a 3×12 at threshold on a rolling loop near Bath, September, starting at 07:30 when it was 11°C and finishing at 19°C. Here’s what a model can tell you from the original FIT:

Lap  Time      Avg P   NP    Avg Cad  Avg HR  Temp   L/R
2    12:00     291 W   293   92       164     11°C   51/49
4    12:00     288 W   290   90       171     15°C   52/48
6    12:00     279 W   286   86       174     19°C   54/46

Three real findings fall out of that. Cadence dropped 6 rpm across the set while balance shifted 3 points to the left, which is a fatigue signature rather than a pacing error. Heart rate rose 10 bpm at 12 W lower output, and 8°C of that is ambient. The third interval’s NP-to-average gap widened from 2 W to 7 W, so the rider was surging to hold the number.

From the GPX of the same ride, the model has a flat stream of points with no lap boundaries. It has to guess where the efforts were by hunting for sustained power above some threshold it also has to guess, and in my testing across a dozen files it typically lands interval edges 8 to 25 seconds off. That shifts the 12-minute averages by 3 to 9 W. Balance and torque effectiveness aren’t there, so the fatigue observation is unavailable. It will still write you an answer. It will sound good.

How to actually get the good file

The short version of how to export Strava data as FIT files: use Export Original, never Export GPX. On any activity page the URL is strava.com/activities/<id>/export_original, and it hands back whatever was uploaded. If it came off a Garmin Edge, a Wahoo ELEMNT or Zwift, that’s a .fit and every channel above is intact. The Export GPX button, by contrast, regenerates a file from Strava’s processed stream store. Two consequences worth knowing: the regenerated file reflects Strava’s smoothing and elevation correction rather than your head unit’s barometer, and for indoor rides with no GPS the button simply isn’t offered.

For your whole history, go to Settings → My Account → Download or Delete Your Account → Request Your Archive. The ZIP that arrives contains an activities/ folder plus activities.csv, which has a Filename column telling you exactly what you got. Audit it before you trust it:

Import-Csv activities.csv |
  Group-Object { [IO.Path]::GetExtension($_.Filename) } |
  Select-Object Name, Count | Sort-Object Count -Descending
Name   Count
----   -----
.gz     1284
.gpx     211

Those 211 are activities that reached Strava through an API integration with no file attached, and no amount of clever prompting will recover channels that were never uploaded. The 1,284 .fit.gz files decompress with 7-Zip or gzip -d and are your real dataset.

Other routes worth having: Garmin Connect’s activity menu has Export Original which gives you the on-device FIT. Zwift writes its own FIT to Documents\Zwift\Activities\ on Windows, named by timestamp, and ignore the inProgressActivity.fit sitting next to them. intervals.icu keeps the source file and offers Download original file on every activity, which makes it the most convenient archive of the lot if you’ve been syncing there for a while.

The Strava API’s /activities/{id}/streams endpoint is a reasonable middle path, returning time, latlng, distance, altitude, velocity_smooth, heartrate, cadence, watts, temp, moving and grade_smooth. There’s no balance, no torque effectiveness, no laps. Default rate limits for new applications are 100 requests per 15 minutes and 1,000 per day, so backfilling 1,200 rides takes three days of polite scheduling. Read the API agreement too: since the November 2024 revision it prohibits using Strava data to train machine learning or AI models. Feeding a file into an LLM for analysis is not training, but building a dataset to fine-tune on is a different thing, and the terms are explicit.

Turning a FIT into something a model can read

Don’t paste a FIT into a chat window. It’s binary, and even the text formats are the wrong shape: a 3-hour ride at 1 Hz is roughly 10,800 records, which is about 340 KB as FIT, 2.4 MB as GPX and 4.3 MB as TCX. That TCX is somewhere near 1.1 million tokens of XML boilerplate. You’re paying to send the model ten thousand copies of the word Trackpoint.

Convert to CSV first. Garmin’s own FIT SDK includes a Java tool that does it in one line:

java -jar FitCSVTool.jar ride.fit

For control over which channels you keep, fitparse is 20 lines:

from fitparse import FitFile
import csv

FIELDS = ["timestamp", "power", "cadence", "heart_rate", "temperature",
          "altitude", "distance", "speed", "left_right_balance",
          "left_torque_effectiveness", "right_torque_effectiveness"]

fit = FitFile("2026-09-14-073000.fit")
with open("ride.csv", "w", newline="") as f:
    w = csv.DictWriter(f, fieldnames=FIELDS, extrasaction="ignore")
    w.writeheader()
    for rec in fit.get_messages("record"):
        w.writerow({d.name: d.value for d in rec})

for i, lap in enumerate(fit.get_messages("lap"), 1):
    d = {fld.name: fld.value for fld in lap}
    print(i, d["total_elapsed_time"], d.get("avg_power"), d.get("normalized_power"))

One encoding trap to know about, because models get it wrong and so will you: left_right_balance is not a percentage. Bit 7 flags which leg the number refers to, and the low seven bits carry the value. A raw 179 is 128 + 51, meaning right leg 51%, so left is 49%. Ask an LLM to interpret raw balance bytes without telling it this and you’ll get a rider who is apparently 179% right-legged.

That CSV, downsampled to 5-second resolution, comes to about 2,160 rows and 27,000 tokens, which fits comfortably in context alongside a lap summary table and your actual question. For what to do with it once it’s there, including the prompt structures that hold up and the ones that quietly hallucinate, the deeper treatment is in Analysing Ride Data With LLMs.

Check your recording interval tonight

None of this rescues a file that was never captured properly. Garmin Edge units ship with Data Recording set to Smart, which logs at variable intervals and can cut a 3-hour ride from 10,800 records down to around 6,500 by dropping points it considers uninteresting. Threshold intervals contain exactly the kind of steady data Smart Recording loves to thin out. Go to Settings → System → Data Recording → Recording Interval and set it to 1 Second. Wahoo ELEMNT units already record at 1 Hz, as does Zwift.

Then pull one ride you’ve already analysed, get the original FIT alongside the GPX you used the first time, and run the same question against both. The gap between the two answers is the size of the mistake you’ve been making.