Calculer la charge d'entraînement (CTL/ATL/TSB)
strava_training_loadCalculate your chronic training load (CTL), acute fatigue (ATL), and training stress balance (TSB) from Strava data to assess fitness, fatigue, and freshness for optimized training.
Instructions
Calcule les métriques de forme, fatigue et fraîcheur via des moyennes exponentielles. CTL (fitness chronique, 42j) / ATL (fatigue aiguë, 7j) / TSB = CTL - ATL (fraîcheur). TSB > +10 = frais pour une course ; TSB < -20 = surcharge.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| days | No | Jours d'historique (min 42 pour que CTL soit significatif) | |
| hr_rest | No | Fréquence cardiaque au repos (bpm) | |
| hr_max | No | Fréquence cardiaque maximale (bpm) | |
| show_daily | No | Afficher les données jour par jour (sinon juste la synthèse) |
Implementation Reference
- src/analytics/analysisTools.ts:94-123 (handler)Handler function for the 'strava_training_load' tool. It fetches Strava activities, computes training load points via computeTrainingLoad, and returns CTL/ATL/TSB metrics with an optional daily breakdown.
async ({ days, hr_rest, hr_max, show_daily }) => { const afterEpoch = Math.floor(Date.now() / 1000) - days * 86400 - 86400; const activities = await listAllActivities(afterEpoch); const loadPoints = computeTrainingLoad(activities, days, hr_rest, hr_max); const latest = loadPoints[loadPoints.length - 1]; const summary = { date: latest.date, CTL_fitness: latest.ctl, ATL_fatigue: latest.atl, TSB_fraicheur: latest.tsb, interpretation: interpretTSB(latest.tsb), evolution_7j: { CTL_avant: loadPoints[loadPoints.length - 8]?.ctl ?? null, CTL_maintenant: latest.ctl, tendance: latest.ctl > (loadPoints[loadPoints.length - 8]?.ctl ?? 0) ? "↑ progression" : "↓ récupération", }, }; const result: Record<string, unknown> = { synthese: summary }; if (show_daily) { // Only last 30 days for readability result.detail_30j = loadPoints.slice(-30); } return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; } - src/analytics/analysisTools.ts:60-92 (schema)Schema definition and registration of the 'strava_training_load' tool. Defines input parameters: days (42-365, default 90), hr_rest (30-80, default 50), hr_max (150-220, default 190), and show_daily (boolean, default false).
{ title: "Calculer la charge d'entraînement (CTL/ATL/TSB)", description: "Calcule les métriques de forme, fatigue et fraîcheur via des moyennes exponentielles. " + "CTL (fitness chronique, 42j) / ATL (fatigue aiguë, 7j) / TSB = CTL - ATL (fraîcheur). " + "TSB > +10 = frais pour une course ; TSB < -20 = surcharge.", inputSchema: z.object({ days: z .number() .int() .min(42) .max(365) .default(90) .describe("Jours d'historique (min 42 pour que CTL soit significatif)"), hr_rest: z .number() .int() .min(30) .max(80) .default(50) .describe("Fréquence cardiaque au repos (bpm)"), hr_max: z .number() .int() .min(150) .max(220) .default(190) .describe("Fréquence cardiaque maximale (bpm)"), show_daily: z .boolean() .default(false) .describe("Afficher les données jour par jour (sinon juste la synthèse)"), }), - src/analytics/analysisTools.ts:58-124 (registration)Registration of the 'strava_training_load' tool via server.registerTool() inside registerAnalysisTools(), which is called from src/index.ts line 16.
server.registerTool( "strava_training_load", { title: "Calculer la charge d'entraînement (CTL/ATL/TSB)", description: "Calcule les métriques de forme, fatigue et fraîcheur via des moyennes exponentielles. " + "CTL (fitness chronique, 42j) / ATL (fatigue aiguë, 7j) / TSB = CTL - ATL (fraîcheur). " + "TSB > +10 = frais pour une course ; TSB < -20 = surcharge.", inputSchema: z.object({ days: z .number() .int() .min(42) .max(365) .default(90) .describe("Jours d'historique (min 42 pour que CTL soit significatif)"), hr_rest: z .number() .int() .min(30) .max(80) .default(50) .describe("Fréquence cardiaque au repos (bpm)"), hr_max: z .number() .int() .min(150) .max(220) .default(190) .describe("Fréquence cardiaque maximale (bpm)"), show_daily: z .boolean() .default(false) .describe("Afficher les données jour par jour (sinon juste la synthèse)"), }), }, async ({ days, hr_rest, hr_max, show_daily }) => { const afterEpoch = Math.floor(Date.now() / 1000) - days * 86400 - 86400; const activities = await listAllActivities(afterEpoch); const loadPoints = computeTrainingLoad(activities, days, hr_rest, hr_max); const latest = loadPoints[loadPoints.length - 1]; const summary = { date: latest.date, CTL_fitness: latest.ctl, ATL_fatigue: latest.atl, TSB_fraicheur: latest.tsb, interpretation: interpretTSB(latest.tsb), evolution_7j: { CTL_avant: loadPoints[loadPoints.length - 8]?.ctl ?? null, CTL_maintenant: latest.ctl, tendance: latest.ctl > (loadPoints[loadPoints.length - 8]?.ctl ?? 0) ? "↑ progression" : "↓ récupération", }, }; const result: Record<string, unknown> = { synthese: summary }; if (show_daily) { // Only last 30 days for readability result.detail_30j = loadPoints.slice(-30); } return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; } ); - src/analytics/trainingLoad.ts:1-80 (helper)Core helper module: computeTRIMP calculates training impulse from heart rate data; computeTrainingLoad builds daily TRIMP map and applies exponential moving averages for CTL (42-day), ATL (7-day), and TSB; interpretTSB provides French-language text interpretation of the TSB score.
import type { StravaActivity, TrainingLoadPoint } from "../types.js"; const LAMBDA_CTL = 2 / 43; // 42-day EMA const LAMBDA_ATL = 2 / 8; // 7-day EMA export function computeTRIMP( activity: StravaActivity, hrRest = 50, hrMax = 190 ): number { const durationMin = activity.moving_time / 60; if (activity.average_heartrate && activity.average_heartrate > hrRest) { const hrRatio = (activity.average_heartrate - hrRest) / (hrMax - hrRest); const trimp = durationMin * hrRatio * 0.64 * Math.exp(1.92 * hrRatio); return Math.max(0, trimp); } // Fallback: distance-based proxy const distKm = activity.distance / 1000; return distKm * 5; } export function computeTrainingLoad( activities: StravaActivity[], days: number, hrRest = 50, hrMax = 190 ): TrainingLoadPoint[] { // Build a day → TRIMP map const trimpByDay = new Map<string, number>(); const RUN_TYPES = new Set(["Run", "VirtualRun", "TrailRun"]); const runs = activities.filter((a) => RUN_TYPES.has(a.type) && !a.trainer); for (const activity of runs) { const date = activity.start_date.slice(0, 10); const trimp = computeTRIMP(activity, hrRest, hrMax); trimpByDay.set(date, (trimpByDay.get(date) ?? 0) + trimp); } // Generate date range and apply EMA const result: TrainingLoadPoint[] = []; const endDate = new Date(); const startDate = new Date(); startDate.setDate(endDate.getDate() - days + 1); let ctl = 0; let atl = 0; const cursor = new Date(startDate); while (cursor <= endDate) { const dateStr = cursor.toISOString().slice(0, 10); const trimp = trimpByDay.get(dateStr) ?? 0; ctl = trimp * LAMBDA_CTL + (1 - LAMBDA_CTL) * ctl; atl = trimp * LAMBDA_ATL + (1 - LAMBDA_ATL) * atl; result.push({ date: dateStr, trimp: Math.round(trimp * 10) / 10, ctl: Math.round(ctl * 10) / 10, atl: Math.round(atl * 10) / 10, tsb: Math.round((ctl - atl) * 10) / 10, }); cursor.setDate(cursor.getDate() + 1); } return result; } export function interpretTSB(tsb: number): string { if (tsb > 25) return "Très frais (risque de désentraînement)"; if (tsb > 10) return "Frais et reposé — idéal pour une course"; if (tsb > 0) return "Légèrement fatigué — bonne forme d'entraînement"; if (tsb > -10) return "Fatigué — charge d'entraînement normale"; if (tsb > -20) return "Significativement fatigué — surveille la récupération"; if (tsb > -30) return "Très fatigué — risque de surentraînement"; return "Surcharge — repos nécessaire"; }