A local-first cycling analytics tool for Strava/Garmin FIT exports: a browser dashboard for interactive analysis and a CLI for one-shot HTML reports. Normalized power, training load (CTL/ATL/TSB), power curve, durability, climbs and time in zones — computed entirely on your machine, nothing uploaded.
The local web dashboard: drop in your FIT files, steer every panel with one global date filter. Rendered from real ride data.
Strava tightened its API terms in 2026: API data may not be fed into AI models, and third-party apps may no longer display other users' data. Instead of building on an API that restricts what you can do with your own numbers, this tool reads the FIT files from a regular Strava bulk export. Everything runs offline. No OAuth, no tokens, no cloud, and your training data never leaves your disk.
The dashboard is the fastest way in — no command per analysis, just drop your files in the browser:
ride-analytics serve # then open http://localhost:8000On macOS you can skip the terminal entirely: double-click run_dashboard.command in Finder and it starts the server and opens the browser for you. Upload single .fit files or a whole Strava export .zip (drag-and-drop or file picker), then steer every panel — metric tiles, PMC, power curve, durability, zones, efficiency & decoupling, interval analysis, training distribution, rides and climbs — with one global date filter, and narrow it further to specific ride types. Export everything in view as a CSV bundle with one click.
Climb comparison. Pick a recurring climb from the dropdown above the climbs table to see every ascent of that hill: a header with its length, gradient and gain, a table of all efforts with your personal best highlighted, and a trend chart of time-per-ascent that shows how your form on that exact climb developed. Climbs are auto-named from start place to end place (e.g. Rittenweier -> Oberflockenbach) by offline reverse geocoding — no network. You can still rename any climb inline (a pencil next to the name); those names live only for the session.
One recurring climb: every ascent, the personal best marked, and time-per-ascent over the season. Rendered from real ride data.
Your data stays on your machine. Uploaded files are parsed in a temporary directory and deleted immediately; only the computed metrics stay in memory, tied to your browser session. Nothing is written to disk, there is no database, and stopping the server discards everything. The server binds to localhost only.
Why FastAPI with a plain HTML/CSS/JavaScript frontend and no framework: it keeps the repo small and buildless (no npm, no bundler, no build step), works fully offline (Plotly is served locally, never from a CDN), and the browser only ever talks to a small JSON API on your own machine. Charts are generated as Plotly JSON server-side and rendered client-side.
- Local web dashboard (FastAPI): upload in the browser, one global date filter, live charts — or the CLI for a one-shot self-contained HTML report
- Reads a single FIT file or a whole export folder; skips non-cycling activities with a log note
- Tolerates missing sensors: rides without power still get distance, time, HR stats and an HR-based TSS estimate
- Single-ride metrics: Normalized Power, Intensity Factor, TSS, Variability Index, work in kJ, moving vs. elapsed time
- Mean-maximal power curve (5 s to 60 min) per ride and across the whole history, with an FTP estimate from the best 20-min effort
- Performance Management Chart: CTL, ATL and TSB as a day-continuous series
- Time in Coggan power zones (7) and heart-rate zones (5)
- Durability analysis: power curves split by accumulated work (0–1000 / 1000–2000 / 2000–3000 / 3000+ kJ) with a durability index per window — how much power you lose once fatigue sets in; the report shows kJ or kcal, switchable with one click
- Automatic climb detection from elevation data, no Strava segments needed: length, gain, gradients, VAM, W/kg, pacing quarters, and matching of repeated climbs with personal bests
- Climb comparison in the dashboard: every ascent of a recurring climb, personal best highlighted, and a time-per-ascent trend chart; climbs auto-named place-to-place by offline reverse geocoding, still renamable inline (session-only names)
- Aerobic decoupling & efficiency factor: NP per heartbeat over time with a 4-week trend, plus the within-ride drift that tells you whether your aerobic base is deep enough for the duration
- Interval detection: finds structured efforts in a ride, groups repetitions into sets, and reports per-interval and per-set metrics (power fade, rest quality, pacing consistency) — pick an interval session and see the power trace with efforts highlighted
- Ride-type classification: every ride is sorted into a training type (endurance, threshold, intervals, race, …) with a confidence level; filter every panel by type, and correct a type by hand when the rules miss
- Training distribution: how the selected period splits across ride types, so you can see at a glance whether the training is polarized or muddled
- Period comparison (
compare): any two date ranges or--preset last-two-seasons, with per-week normalization when the periods differ in length - CSV export of every computed metric — as CLI files (
--export-csv) or a one-click ZIP download from the dashboard (respecting the active date filter), for further analysis in Excel/Sheets - One self-contained HTML report with interactive Plotly charts (works without internet), plus an optional terminal summary
Requires Python 3.11+.
git clone https://github.com/BeFuchs/strava-analytics.git
cd strava-analytics
python3 -m venv .venv && source .venv/bin/activate
pip install -e .- Get your FIT files. Strava: Settings → My Account → Download or Delete Your Account → Request your archive. The ZIP contains an
activities/folder with your original FIT files. Garmin devices store FIT files directly on the device (GARMIN/Activity/). - Set your athlete profile. Copy
config.example.yamltoconfig.yamland enter your FTP, threshold heart rate, weight and max HR. TSS and zone boundaries depend on these values. - Run the analysis:
ride-analytics analyze path/to/activities --report report.html --summary --export-csv csv/analyze accepts a single .fit file or a folder. --summary prints a per-ride table to the terminal, --export-csv writes all metrics as CSV files; the HTML report is written either way.
The CLI's self-contained HTML report. Rendered from synthetic demo data.
To compare two seasons or arbitrary date ranges:
ride-analytics compare path/to/activities --preset last-two-seasons --report compare.html
ride-analytics compare path/to/activities --period-a 2025-01-01:2025-06-30 --period-b 2026-01-01:2026-06-30Normalized Power (NP) weights power spikes the way your body feels them: a 30-second rolling average is raised to the fourth power, averaged, and rooted again. A ride with surges gets a higher NP than its plain average, which is why NP is the better basis for training stress.
Intensity Factor (IF) is NP divided by your FTP. An IF of 1.0 means you rode at threshold for the whole ride; 0.7 is a typical endurance ride.
Training Stress Score (TSS) combines duration and intensity into one load number. One hour at FTP equals 100 TSS. Rides without power get an estimate from heart rate relative to your threshold HR, marked as estimated in the report.
CTL (Chronic Training Load) is a 42-day weighted average of daily TSS, a proxy for fitness. It rises slowly when you train consistently and decays slowly when you stop.
ATL (Acute Training Load) is the same average over 7 days, a proxy for fatigue. It reacts fast in both directions.
TSB (Training Stress Balance) is yesterday's CTL minus yesterday's ATL, a proxy for form. Negative values mean you are carrying fatigue; positive values mean you are fresh, at the cost of losing fitness if it stays positive too long.
Variability Index (VI) is NP divided by average power. A steady time trial sits near 1.0; a criterium or group ride sits well above.
Durability is how much power you still produce after work has piled up. Each ride is split by accumulated work into kJ buckets, and the best efforts are computed inside each bucket separately, so your fresh 20-min best and your 20-min best after 2,000 kJ become two different numbers. The durability index compares each bucket to the fresh one: 0.85 means 15 % of your power is gone at that depth of fatigue — a dimension Strava doesn't show at all. The report displays the buckets in kJ or kcal (toggle in the header); in cycling the two are numerically almost identical, because the ~24 % gross efficiency cancels the 4.184 conversion factor — which is why your head unit's calorie number tracks the kJ of work so closely.
Durability section of the report: one power curve per kJ bucket; the gap between the curves is fatigue resistance. Rendered from synthetic demo data.
VAM (Vertical Ascent Metres per hour) is climbing speed measured vertically: elevation gain divided by climbing time. It makes climbs of different length and gradient directly comparable — a steady club rider climbs at 700–900 m/h, pro race pace on a mountain pass is 1,500+.
Efficiency Factor (EF) is Normalized Power divided by average heart rate — watts per heartbeat. Tracked over time at a similar intensity, a rising EF is the clearest single sign of improving aerobic fitness: the same power costs fewer beats. Because it depends on the kind of riding, EF is only compared within steady endurance and tempo rides; on interval and race days it is still computed but flagged as not comparable.
Aerobic decoupling (Pw:Hr) asks whether that efficiency holds up over a long ride. The ride is split into two halves by moving time and the EF of each half is compared; if power drifts down relative to heart rate in the second half, decoupling is positive. Under about 5 %, the aerobic base is deep enough for that duration; above it, it isn't yet. Decoupling is only meaningful on steady rides of at least 60 minutes with continuous power and heart rate — on short, surgy or stop-and-go rides the number is noise, so the tool refuses to compute it there and says why. A cardiac-drift figure (heart-rate slope within a narrow power band) is reported alongside as a steadier companion metric.
Interval analysis: the ride's power trace with detected efforts highlighted, then per-set and per-interval tables. Rendered from real ride data.
ModuleNotFoundError: No module named 'ride_analytics' after pip install -e .
This happens when .venv lives inside a cloud-synced folder (iCloud Drive's
Desktop & Documents sync, Dropbox, OneDrive, Google Drive). These services
often fail to sync the Python symlinks inside .venv/bin correctly, leaving
a broken virtual environment even though installation reports no errors.
Fix — recreate the virtual environment:
rm -rf .venv
python3 -m venv .venv
source .venv/bin/activate
pip install -e .To avoid this permanently, keep the project outside any cloud-synced directory.
Climbs are detected from the smoothed barometric altitude alone — no Strava segments needed. A stretch counts as a climb when it averages at least 3 % gradient, gains at least 30 m and runs at least 500 m. These thresholds are deliberate: 3 % is where climbing starts to dominate the power demand, 30 m filters out highway ramps and railway bridges, and 500 m keeps every short kicker from flooding the list. Short flat or downhill pieces inside a climb (under 200 m or 30 s) don't end it — a hairpin road with flat corners is one climb, not twenty. Repeated climbs are matched by start location (haversine) and similar length and gain, which yields personal bests and a time trend per climb.
The same hill, ridden many times, is grouped into one stable cluster. Two ascents join the same cluster only when all three hold: start points within 200 m (haversine), length within ±15 %, and elevation gain within ±15 % — each relative tolerance backed by an absolute floor (20 m of gain, 250 m of length) so that on a small climb, barometric noise or a shifted detection boundary doesn't split one hill into two. A cluster's representative length, gain and start coordinate is the median across its ascents, not the mean: a single GPS-drifted outlier then can't drag the cluster off the hill the way an average would. Cluster IDs are derived deterministically from the rounded coordinate and length, so re-uploading the same export reproduces the same clusters. The dashboard's climb comparison view is built on these clusters — pick one from the dropdown to compare every ascent of that hill.
Climbs are named from place to place. Each cluster's representative start and end coordinates are reverse-geocoded against a small OpenStreetMap gazetteer bundled with the app — settlements (villages, hamlets, suburbs) across the riding region — producing a name like Rittenweier -> Oberflockenbach. The lookup is a pure nearest-neighbour search over local data: no network, no external API, in keeping with the local-first design. When no settlement lies within range of an endpoint (2 km) the climb keeps its coordinate label, and a manual rename always wins over the auto-name. Regenerate or widen the gazetteer with python scripts/build_gazetteer.py.
Intervals are found from power alone — heart rate lags far too much to delimit an effort. Power is smoothed over 10 s and stretches at or above 88 % of FTP (the bottom of Zone 4) become candidates. Cleanup then removes the noise: efforts shorter than 30 s are dropped (they are sprints and ramps, not intervals), and gaps under 20 s are bridged so a single gear change or corner doesn't shatter a 5-minute interval into pieces. Repetitions of similar duration (±20 %) and power (±10 %) are grouped into a set, and each set reports the things you actually want after a hard session: power fade from the first rep to the last, rest quality between reps, and how consistent the pacing was.
Every ride is sorted into a training type by transparent, hand-set rules, checked in order — first match wins. The thresholds are deliberately set, not trained: there is no machine learning here on purpose, so the reasoning stays readable. Each ride also carries a confidence level, and any type can be corrected by hand in the dashboard (session-only, like everything else).
| Type | Rule |
|---|---|
| Race | IF ≥ 0.85 and ≥ 60 min and VI ≥ 1.15 |
| Intervals | a set of ≥ 3 repetitions and ≤ 150 min and ≥ 8 min above the interval threshold |
| Threshold / sweetspot | ≥ 20 min continuous in the 84–105 % FTP band, with no set structure |
| Long endurance | ≥ 150 min and ≥ 60 % of time in Zone 1–2 and VI ≤ 1.20 |
| Endurance | ≥ 60 % of time in Zone 1–2 and VI ≤ 1.25 |
| Commute | ≤ 60 min and ≤ 25 km and VI ≥ 1.20 (stop-and-go) |
| Recovery | ≤ 90 min and IF ≤ 0.60 |
| Other | fallback when nothing fits |
The interval and endurance thresholds were tuned against real rides in hilly terrain, where long climbs push time into Zone 3 and lift variability — the textbook values (a ≥ 2-rep set, 70 % Zone 1–2, VI ≤ 1.15) mislabeled most long rides. Without power, rides are classified from heart-rate zones instead, with confidence capped at medium.
src/ride_analytics/
├── cli.py # click entry point, wiring only
├── config.py # YAML athlete profile -> typed AthleteConfig
├── ingest.py # FIT files -> normalized per-ride DataFrames
├── metrics/
│ ├── single_ride.py # NP, IF, TSS, VI, kJ, moving/elapsed time
│ ├── power_curve.py # mean-maximal power + FTP estimate
│ ├── durability.py # power curves per kJ bucket + durability index
│ ├── climbs.py # climb detection, VAM, repeated-climb matching
│ ├── decoupling.py # efficiency factor, aerobic decoupling, cardiac drift
│ ├── intervals.py # interval detection + set grouping
│ ├── classification.py # rule-based ride-type classification
│ ├── comparison.py # two-period aggregation and deltas
│ ├── pmc.py # CTL / ATL / TSB time series
│ └── zones.py # power & HR zone distributions
├── clustering/
│ └── climb_clusters.py # stable clusters for repeated climbs
├── geocode/
│ ├── gazetteer.py # offline reverse geocoding (nearest place)
│ └── data/places.csv # bundled OSM settlements (built by scripts/)
├── export/
│ └── csv_export.py # all computed metrics as CSV files
├── report/
│ ├── builder.py # data model + Plotly figures + template rendering
│ └── templates/ # self-contained HTML report
└── web/
├── app.py # FastAPI routes — serialization only, no math
├── session.py # in-memory session store (no persistence)
├── charts.py # Plotly figures as JSON for the frontend
└── static/ # index.html, CSS, app.js (vanilla, no build step)
Each metric is a pure function (DataFrame, AthleteConfig) -> result: ingest knows nothing about metrics, metrics know nothing about HTTP or HTML, and both the report layer and the web layer only orchestrate and serialize — they do no math. The test suite verifies every formula against synthetic data with known results (constant 200 W for an hour at FTP 200 must yield IF 1.0 and TSS 100), drives the API through FastAPI's TestClient, and feeds the ingest layer with FIT files generated by a minimal binary encoder in tests/conftest.py.
pip install -e ".[dev]"
pytest && ruff check- Training plan suggestions derived from the PMC and the ride-type mix
- Optional persistence for climb names and manual ride-type corrections (session-only today, gone on server restart)
- Multi-sport support beyond cycling
- W' balance and other advanced power models
- Route map rendering (would need external map tiles; the elevation profile stays the default)
- Optional internet hosting — deliberately out of scope: GPS and heart-rate data are health data under Art. 9 GDPR, so a public deployment brings obligations (privacy policy, data-processing agreement, deletion concept) that a local-first tool avoids by never leaving your machine
A personal learning project, in active development, built to learn Python properly on a problem I care about as a cyclist. Not affiliated with Strava or Garmin. MIT licensed.




