Code Header

Build Your Own Performance Management Chart in Python


In this article, I will show you how to evaluate your current training level in Python based on a PMC (Performance Management Chart). The PMC shows your fatigue (short-term trainings load), fitness (long-term trainings load) and form over a period of time. It is a great tool to monitor your daily training progress and plan your performance peaks ahead.

TSS, IF and Normalized Power

The training load is defined as TSS (Training Stress Score). It's a function of duration, intensity, and normalized power of your workout/race and your personal Functional Threshold Power (FTP). For example, a workout which lasts for one hour at your FTP would result in a TSS of 100. You can calculate it in Python like this:

tss = (moving_time * norm_power * intensity) / (ftp * 3600.0) * 100.0

In my previous blog article I've shown you how to calculate the Normalized Power and the Intensity Factor (IF). But I will walk you again through all the steps to generate your PMC. You can also download the complete PMC Script here.

Install Dependencies

To start analyzing your data, you need to install the following Python packages:

pip install pandas numpy fitparse matplotlib tqdm

Open a text file and import the packages using this code:

import os
import datetime
from fitparse import FitFile
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from tqdm import tqdm

lthr = 171.0
my_ftp = 311
start_date =, 9, 27)
end_date =
directory = 'fitfiles'

Also, you need to add some variables, such as your FTP and your threshold heart rate.

Loading your Fitfiles

In a first step, copy all your fitfiles that you've collected from your bike computer in a "fitfiles" folder. This folder should be within the same directory as the Python script that you are going to use to create your PMC. The next step is to loop through this folder and load each fitfile via Python. The following code snippet does this:

date_range = pd.date_range(start_date, end_date).date
df = pd.DataFrame(index=date_range)
df['date'] = df.index
df['TSS'] = 0
for filename in tqdm(os.listdir(directory)):
%t%if filename.endswith('.fit'):
%t%%t%workout = load_workout((os.path.join(directory, filename)))
%t%%t%date = get_date(workout)
%t%%t%if date not in df.index:
%t%%t%if 'power' in workout:
%t%%t%%t%df.loc[date, 'TSS'] += get_tss(workout)
%t%%t%elif 'heart_rate' in workout:
%t%%t%%t%df.loc[date, 'TSS'] += get_hr_tss(workout)

The load_workout function loads the fitfile, fills all missing values and converts it into a Pandas DataFrame.

def load_workout(workout_file):
%t%Load fitfile and transforms
%t%it into a pandas Dataframe.
%t%Nan Values are replaced.
%t%:param workout_file:
%t%:return dataframe:
%t%fitfile = FitFile(workout_file)
%t%while True:
%t%%t%except KeyError:
%t%%t%workout = []
%t%%t%for record in fitfile.get_messages('record'):
%t%%t%%t%r = {}
%t%%t%%t%for record_data in record:
%t%%t%%t%%t%r[] = record_data.value
%t%%t%workout_df = pd.DataFrame(workout)
%t%%t%workout_df.fillna(method='ffill', inplace=True)
%t%%t%workout_df.fillna(method='backfill', inplace=True)
%t%%t%return workout_df

Checking the date of the workout is essential in limiting your PMC to a certain date range.

def get_date(workout_df):
%t%Gets the workout date.
%t%:param workout_df:
%t%:return date:
%t%workout_date = workout_df['timestamp'][0].date()
%t%return workout_date

Calculate TSS and hrTSS

Once the workout is loaded, we are ready to calculate the TSS. The get_tss function calculates the Normalized Power (rolling mean over 30 sec), Intensity and the Training Stress Score.

def get_tss(workout_df):
%t%Calculates the TSS based on Power.
%t%:param workout_df:
%t%:return tss:
%t%norm_power = np.sqrt(np.sqrt(np.mean(workout_df['power'].rolling(30).mean() ** 4)))
%t%intensity = norm_power / my_ftp
%t%moving_time = int((workout_df['timestamp'].values[-1] - workout_df['timestamp'].values[0]) / 1000000000)
%t%workout_tss = (moving_time * norm_power * intensity) / (my_ftp * 3600.0) * 100.0
%t%return workout_tss

In case the fitfile does not contain power values, we are still able to calculate a TSS based on the heart rate. This is done by adding up the seconds you have spent in different heart rate zones. I have set the heart rate zones as follows:

Zone 1 low Less than 73% of LTHR | 20 TSS/hr
Zone 1 73% to 77% of LTHR | 30 TSS/hr
Zone 1 high 77% to 81% of LTHR | 40 TSS/hr
Zone 2 low 81% to 85% of LTHR | 50 TSS/hr
Zone 2 high 85% to 89% of LTHR | 60 TSS/hr
Zone 3 89% to 93% of LTHR | 70 TSS/hr
Zone 4 93% to 100% of LTHR | 80 TSS/hr
Zone 5a 100% to 103% of LTHR | 100 TSS/hr
Zone 5b 103% to 106% of LTHR | 120 TSS/hr
Zone 5c More than 106% of LTHR | 140 TSS/hr

With these zones, we'll get our hrTSS with the following function (get_hr_tss):

def get_hr_tss(workout_df):
%t%Calculates the TSS based on Heart Rate.
%t%:param workout_df:
%t%:return hr_tss:
%t%hr_zones = (pd.Series([0, 0.73, 0.77, 0.81, 0.85, 0.89, 0.93, 0.99, 1.03, 1.06, 2]) * lthr).to_list()
%t%workout_df['hrZone'] = pd.cut(workout_df['heart_rate'], hr_zones, labels=['Z1 low', 'Z1', 'Z1 high','Z2 low',
%t%%t%%t%%t%%t%%t%%t%%t%%t%%t%%t%%t%%t%%t%%t%%t%%t%%t%'Z2 high', 'Z3', 'Z4', 'Z5a', 'Z5b',
%t%workout_df['hrTSS'] = pd.cut(workout_df['heart_rate'], hr_zones,
%t%labels=[20, 30, 40, 50, 60, 70, 80, 100, 120, 140])
%t%workout_df['hrTSS'] = workout_df['hrTSS'].astype(int)
%t%hr_tss = np.sum(workout_df['hrTSS'].values) / 3600
%t%return hr_tss

Generate Your PMC

The loop through all the files calculates the TSS of each workout and appends the results to a Pandas DataFrame. With this dataframe, we are now able to generate our Performance Management Chart.

fig, ax = plt.subplots()
df['CTL'] = df['TSS'].rolling(42, min_periods=1).mean()
df['ATL'] = df['TSS'].rolling(7, min_periods=1).mean()
df['TSB'] = df['CTL'] - df['ATL']
df[['CTL', 'ATL', 'TSB']].plot(ax=ax, title='Performance Management Chart')
plt.ylabel('CTL / ATL / TSB')
ax2 = ax.twinx()
df[['TSS']].plot(ax=ax2, style='o')
ax.grid(which='major', linestyle='-', linewidth='0.5', color='black')
ax.grid(which='minor', linestyle=':', linewidth='0.5', color='black')
plt.ylim(5, max(df['TSS'] + 5))

You can download the complete script here. Before you generate the PMC, adjust your "Lactate Threshold Heart Rate Value" (your aerobe/anaerobe HR Threshold) and your FTP (your Power Threshold; which you know from doing an FTP test).

Then you can simply run python in your terminal (or command prompt). You'll get a status bar like this:

70%|███████ | 82/117 [04:37<02:09, 3.70s/it]

Afterwards you will see your PMC which should look similar to this one:


A little explanation:

CTL = Chronic Training Load = Fitness
ATL = Acute Training Load = Fatigue
TSB = Training Stress Balance = Form

You are in "Form" where the TSB curve peaks. You should try to adjust your training over the year, so that the TSB is high when you have important competitions. Also, you should avoid "overtraining" - this happens if your ATL (fatigue) is very high.

Summing It Up

We've created a pythonic way to analyze your cycling data over time and generate a PMC. The PMC is important to know your fitness status and plan your season ahead.

In the very near future, I will create a web based tool that automatically generates your PMC based on your Strava data or workout files. I will keep you updated once this tool is launched.

blog comments powered by Disqus