A self-hosted cycling data platform — automatic ride ingestion from Strava, Grafana dashboards for analytics, and intelligent route planning. No Strava Premium required — all metrics (fitness, power zones, training load, TRIMP) are computed locally from raw data.
Inspired by TeslaMate. Works with any device that syncs to Strava.
- Polls Strava every 10 minutes for new cycling rides (non-cycling activities filtered)
- Stores full per-second telemetry (HR, power, cadence, speed, altitude, GPS)
- Calculates CTL/ATL/TSB fitness metrics locally (no Strava Premium needed)
- TRIMP (Training Impulse) computed from HR stream data (no Strava Premium needed)
- Normalized Power (NP), Efficiency Factor (EF), and Work (kJ) pre-calculated per activity from stream data
- FTP auto-estimated from rolling 90-day best 20-minute power, or configured manually
- Daily fitness recalculation at 00:05 (rest days show CTL/ATL decay)
- Smart deduplication when multiple devices record the same ride
Three dashboards with 98 panels across 12 visualization types.
Overview (34 panels) — your training hub
- 12 stat cards with period comparison + sport type filter
- 8 delta comparison cards (vs previous period)
- Fitness section: CTL/ATL/TSB with fill-between shading, FTP, TSB gauge, weekly streak, 6-week fitness delta
- 10 daily charts split by ride type (Outdoor/Zwift/E-Bike/Indoor)
- Ride type donut, ride frequency bar chart
- Outdoor records table (period best vs all-time best)
- Activities table with drill-down to Activity Details
- Lifetime ride heatmap
- Manual annotations for marking events (races, FTP tests, injuries)
Activity Details (32 panels) — per-ride deep dive
- 12 summary stat cards + 7 advanced metrics (NP, IF, VI, EF, Work, TRIMP, aerobic decoupling)
- GPS route map with speed/HR/power color overlay
- HR and power zones by kilometer (stacked bar charts)
- HR and power zone distribution (Coggan model, zone-colored)
- Power vs HR scatter plot (cardiac drift detection)
- Power zone bands on HR & Power telemetry
- Speed & elevation / HR & power / cadence & grade telemetry (distance-based x-axis)
- Per-km splits table with best/worst markers
- Power duration curve
All Time Progression (32 panels) — long-term trends
- 6 stat cards: total distance, elevation, rides, hours, current FTP, peak CTL
- 6 progression scatter plots with 10-ride rolling averages and regression lines (speed, power, NP, EF, HR, distance)
- FTP progression (monthly estimated from stream data)
- Best efforts (1min/5min/20min peak power per ride)
- Weekly power range (candlestick — week-over-week comparison)
- Training zone polarization (monthly power + HR zone stacked bars)
- CTL/ATL/TSB fitness history with fill-between shading
- 6 cumulative totals (distance, elevation, duration, rides, TSS, calories)
- Monthly trends stacked by ride type
- Year-over-year distance comparison
- Annual totals table
- Personal records with drill-down links
- All-time ride map
- Generates real road-following GPX loops via Valhalla (free, OpenStreetMap-based)
- Generates GPX files with interactive browser preview (map + route stats + download/share buttons)
- On iOS/Android, the share button opens the native share sheet to send the GPX directly to any bike computer app
--output DIRsaves the preview HTML to a directory for headless/server use- Smart waypoint selection from 10 data sources (see below)
- Weather-aware: best ride time, UV warnings, wind direction analysis
- Safety control:
--safetyflag adjusts preference for bike lanes vs main roads - Configurable avoid zones for roads/areas you don't want to ride
When planning a route, VeloMate selects waypoints and enriches the output using:
| # | Source | Data | API |
|---|---|---|---|
| 1 | OpenStreetMap POIs | Viewpoints, cafes, peaks, water fountains, bike shops | Overpass (free) |
| 2 | Strava segments | Popular cycling roads near you | Strava API |
| 3 | Komoot highlights | Community-curated cycling POIs | Vector tiles (free, no auth) |
| 4 | Your ride history | 30-day GPS density grid — variety or comfort mode | Local DB |
| 5 | OSM surface tags | Road surface verification (asphalt, gravel, etc.) | Overpass (free) |
| 6 | OSM cycling infrastructure | Bike lanes, speed limits, traffic calming → safety score | Overpass (free) |
| 7 | Open-Meteo weather | Temperature, wind, UV, rain + hourly forecast | Open-Meteo (free) |
| 8 | Open-Meteo air quality | European AQI, PM2.5, PM10 | Open-Meteo (free) |
| 9 | Open Topo Data | Elevation profile, climb/descent, max gradient | Open Topo Data (free) |
| 10 | Sunrise/Sunset | Daylight safety, golden hour | sunrise-sunset.org (free) |
Additionally, the route planner detects waymarked cycling trails (EuroVelo, national routes) along the generated path.
When multiple devices record the same ride (e.g., a bike computer and a watch both syncing to Strava), VeloMate keeps the record with the richest data:
| Field | Score |
|---|---|
| Power data | +3 |
| Heart rate | +2 |
| Distance > 0 | +1 |
| Cadence | +1 |
| Calories | +1 |
| Elevation > 0 | +1 |
The record with the higher total score wins. Missing fields from the losing record (e.g., HR from a watch when a bike computer wins on power) are merged into the winner. This works with any device brand — no hardcoded priorities.
Any device → Strava → [Ingestor] → PostgreSQL → Grafana dashboards
↑
VeloMate CLI (route planning + recommendations)
↓
Valhalla → GPX file
Three Docker Compose services:
| Service | Image | Port |
|---|---|---|
| PostgreSQL | postgres:15 |
5423 |
| Ingestor | custom Python 3.11 | — |
| Grafana | grafana/grafana:12.4 |
3021 |
The CLI runs locally and connects to the database over the network.
git clone https://github.com/elduty/velomate.git
cd velomate
cp .env.example .env
# Edit .env with your Strava API credentials and passwords# Open in browser (replace YOUR_CLIENT_ID):
# https://www.strava.com/oauth/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://localhost&approval_prompt=force&scope=activity:read_all
# After authorizing, exchange the code:
curl -X POST https://www.strava.com/oauth/token \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET \
-d code=CODE_FROM_REDIRECT \
-d grant_type=authorization_code
# Use the refresh_token from the responsedocker compose up -dOn first run, the ingestor backfills the last 12 months of Strava activities.
pip install -r requirements.txt
cp config.example.yaml ~/.config/velomate/config.yaml
# Edit with your home coordinates, DB host, and Strava credentialsCredentials support three methods: direct values, environment variables, or shell commands (password_cmd) for secret managers like Keychain, 1Password, or Vault.
# Weekly ride recommendation (fitness + weather + past routes)
python3 -m velomate.cli
# Plan a route
python3 -m velomate.cli plan --duration 2h
python3 -m velomate.cli plan --distance 50km --surface gravel
python3 -m velomate.cli plan --duration 3h --waypoints "Sintra,Cascais"
python3 -m velomate.cli plan --duration 1h --surface mtb --safety 1.0
python3 -m velomate.cli plan --distance 30 --preference comfort| Flag | Default | Description |
|---|---|---|
--duration |
* | Ride time (2h, 1h30m, 90min) |
--distance |
* | Target distance (30, 50km) |
--surface |
road |
road, gravel, or mtb |
--safety |
0.5 |
0.0 = fastest, 1.0 = safest (prefers bike lanes) |
--preference |
variety |
variety (new roads) or comfort (familiar) |
--waypoints |
— | Comma-separated place names |
--date |
tomorrow |
When to ride (today, saturday, 2026-03-20) |
--time |
— | Start time (14:00, 2pm, 9am) |
--start |
from config | Override start as lat,lng |
--loop |
true | Round-trip route |
--output DIR |
— | Save preview HTML to directory (instead of opening browser) |
* Provide either --duration or --distance (one required, mutually exclusive).
🗺 *VeloMate 2h00m Road via Miradouro de Porto Salvo, Cotão, Viewpoint*
📏 24 km
📅 2026-03-16 at 09:00
🛤 Surface: asphalt 53%, unknown 44%, paving_stones 2%
⛰ Climb: +260m / -284m (max gradient 10.2%)
🌿 Scenic: wood (25), water (10), park (4) (86/100)
🛡 Safety: bike lanes 22% (11/100)
🌤 Mainly clear, 10-21°C, wind 12 km/h
🕐 Best time: 09:00 (14°C, wind 10 km/h, UV 2)
🌅 Sunrise 06:45, sunset 18:46
💪 neutral (TSB -4)
💾 GPX: /tmp/velomate_route_road_29km.gpx
Power TSS = (duration_s × avg_power × IF) / (FTP × 3600) × 100 (preferred)
HR TSS = (duration_h) × (avg_hr / threshold_hr)² × 100 (fallback)
CTL = 42-day EMA of daily TSS (chronic training load / fitness)
ATL = 7-day EMA of daily TSS (acute training load / fatigue)
TSB = CTL − ATL (training stress balance / form)
- FTP: auto-estimated from rolling 90-day best 20-minute power × 0.95, or configured via
VELOMATE_FTP/config.yaml - NP: Normalized Power — 30s rolling average, 4th power, mean, 4th root. Pre-calculated from stream data per activity
- EF: Efficiency Factor = NP / avg HR. Rising EF indicates improving aerobic fitness
- Work: Total energy output in kJ = sum of per-second power from stream data
- Threshold HR: 95th percentile of your max HRs, or configured via
VELOMATE_MAX_HR/config.yaml - TSB interpretation: > +10 fresh · -10 to +10 neutral · < -10 fatigued
| Table | Contents |
|---|---|
activities |
Every ride — distance, duration, HR, power, cadence, elevation, calories, TSS, NP, EF, Work (kJ), sport type, device |
activity_streams |
Per-second telemetry — HR, power, cadence, speed, altitude, lat/lng |
athlete_stats |
Daily fitness metrics — CTL, ATL, TSB, weekly volume |
routes |
Legacy — created by schema but not actively written to |
sync_state |
Ingestor bookmarks (last synced timestamps) |
Schema is managed in code (ingestor/db.py:create_schema()) using IF NOT EXISTS / ADD COLUMN IF NOT EXISTS. No migration tool.
Configured via .env file:
| Variable | Required | Description |
|---|---|---|
POSTGRES_PASSWORD |
Yes | Database password |
STRAVA_CLIENT_ID |
Yes | From strava.com/settings/api |
STRAVA_CLIENT_SECRET |
Yes | From Strava API settings |
STRAVA_REFRESH_TOKEN |
Yes | OAuth refresh token |
GRAFANA_PASSWORD |
Yes | Grafana admin password |
VELOMATE_MAX_HR |
No | Your max heart rate (0 = auto-estimate) |
VELOMATE_FTP |
No | Your FTP in watts (0 = auto-estimate) |
Configured via ~/.config/velomate/config.yaml (see config.example.yaml):
- Home coordinates (required for route planning)
- Database connection
- Strava credentials (optional — enables popular segment data in route intelligence)
- Avoid zones (lat/lng areas to exclude from routes)
- Docker + Docker Compose (for ingestor, PostgreSQL, Grafana)
- Python 3.10+ (for CLI)
- A Strava account with API access
MIT



