-
Notifications
You must be signed in to change notification settings - Fork 0
/
calculations.py
371 lines (273 loc) · 11.8 KB
/
calculations.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
from activity import Activity
from collections import namedtuple
from typing import List, Optional
from calculation_data import AerobicDecoupling, Fitness
from athlete import get_ftp
from collections import deque
import datetime
import itertools
import math
def calculate_transient_values(activity: Activity):
"""
Calculate the transient values for a specific activity.
Lots of this logic comes from https://medium.com/critical-powers/formulas-from-training-and-racing-with-a-power-meter-2a295c661b46.
Args:
activity: The activity to calculate the transient values for.
"""
# Simple stuff
activity.variability_index = round(((activity.normalised_power - activity.avg_power) / activity.normalised_power) * 100, 0)
activity.ftp = get_ftp(activity.start_time)
activity.intensity_factor = activity.normalised_power / activity.ftp if activity.ftp else 0
activity.duration_in_seconds = (activity.end_time - activity.start_time).seconds
activity.tss = int((activity.duration_in_seconds * activity.normalised_power * activity.intensity_factor) / (activity.ftp * 36)) if activity.ftp else 0
distance_in_meters = activity.distance
speed_in_ms = distance_in_meters / activity.duration_in_seconds
activity.speed_in_kmhr = speed_in_ms * 3600 / 1000
# Now calculate aerobic decoupling
# See https://www.trainingpeaks.com/blog/aerobic-endurance-and-decoupling.
if distance_in_meters >= 10000:
activity.aerobic_decoupling = calculate_aerobic_decoupling(activity)
activity.aerobic_efficiency = activity.normalised_power / activity.avg_hr
def calculate_progressive_fitness(activities: List[Activity]):
"""
Calculate the CTL and ATL for each day in the list of activities.
Args:
activities (List[Activity]): The list of activities to calculate for.
"""
# Let's start by setting the 'first_for_day' flag for each activity; we need this later
_determine_first_for_day(activities=activities)
# Now we'll walk through the activities and calculate the CTL and ATL for each one
for idx, activity in enumerate(activities):
# If it's not the first for the date, copy from the previous activity
if not activity.first_for_day:
activity.ctl = activities[idx - 1].ctl
activity.atl = activities[idx - 1].atl
continue
# It's the first activity for the day — let's calculate CTL and ATL for this day
# Step 1 — grab the date
activity_date = activity.start_time.date()
# Step 2 — determine the earliest date we want in the CTL and ATL
earliest_date_for_ctl = activity_date - datetime.timedelta(days=42)
earliest_date_for_atl = activity_date - datetime.timedelta(days=7)
# Step 3 — setup TSS buffers for CTL and ATL
ctl_tss_sum: int = 0
ctl_tss_count: int = 1
atl_tss_sum: int = 0
atl_tss_count: int = 1
# Step 4 — walk backward and sum TSS
for i in range(idx - 1, 0, -1):
subject = activities[i]
subject_date = subject.start_time.date()
if subject_date < earliest_date_for_ctl:
break
ctl_tss_sum += subject.tss
if subject.first_for_day:
ctl_tss_count += 1
if subject_date < earliest_date_for_atl:
continue
atl_tss_sum += subject.tss
if subject.first_for_day:
atl_tss_count += 1
# Step 5 — store the CTL and ATL for this activity
activity.ctl = int(ctl_tss_sum / 42) if ctl_tss_count > 0 else 0
activity.atl = int(atl_tss_sum / 7) if atl_tss_count > 0 else 0
def _determine_first_for_day(activities: List[Activity]):
"""
Determine which activity is the first of each day (give we have multiple activities per day)
Args:
activities (List[Activity]): The list of activities to separate by day
"""
# Make sure we got an activity list
assert activities
# Buffer for the currennt date
current_date: datetime.date = None
# Visit each activity. When the date changes from the previous activity, it's the first
# one for the date.
for activity in activities:
activity_date = activity.start_time.date()
if current_date is None or activity_date != current_date:
activity.first_for_day = True
current_date = activity_date
else:
activity.first_for_day = False
def calculate_aerobic_decoupling(activity: Activity) -> Optional[AerobicDecoupling]:
"""
Calculate the aerobic decoupling for an activity.
Args:
activity (Activity): The activity to calculate for.
Returns:
The aerobic decoupling.
"""
# Split the power and HR data in half
assert len(activity.raw_power) == len(activity.raw_hr)
half_way_point = int(len(activity.raw_power) / 2)
first_half_power = activity.raw_power[0:half_way_point]
first_half_hr = activity.raw_hr[0:half_way_point]
second_half_power = activity.raw_power[half_way_point:]
second_half_hr = activity.raw_hr[half_way_point:]
# If either is empty, we don't have enough data to calculate
if not first_half_power or not second_half_power:
return None
# Calculate the first half's power-to-hr ratio
first_half_ratio = _calculate_aerobic_ratio(power=first_half_power, hr=first_half_hr)
second_half_ratio = _calculate_aerobic_ratio(power=second_half_power, hr=second_half_hr)
if first_half_ratio is None or second_half_ratio is None:
return None
# Calculate the decoupling of the two
coupling = ((first_half_ratio - second_half_ratio) / first_half_ratio) * 100
# Done
return AerobicDecoupling(coupling=coupling, first_half_ratio=first_half_ratio, second_half_ratio=second_half_ratio)
def calculate_fitness(*, activities: List[Activity]) -> Fitness:
"""
Calculate fitness given a list of activities.
Args:
activities: The activities.
Returns:
Fitness: The fitness, including CTL, ATL, and TSB.
"""
# Make sure we've got TSS for each activity
for activity in activities:
calculate_transient_values(activity)
calculate_progressive_fitness(activities=activities)
# Calculate CTL and ATL
ctl = _calculate_training_load(activities=activities, days=42)
atl = _calculate_training_load(activities=activities, days=7)
# Done
return Fitness(ctl=ctl, atl=atl, tsb=ctl - atl)
def calculate_normalised_power(*, power: List[int]) -> int:
"""
Given a collection of power figures, calculate the normalised power.
This algorithm comes from the book ‘Training and Racing with a Power Meter’,
by Hunter and Allen via the blog post at
https://medium.com/critical-powers/formulas-from-training-and-racing-with-a-power-meter-2a295c661b46.
In essence, it's as follows:
Step 1
Calculate the rolling average with a window of 30 seconds:
Start at 30 seconds, calculate the average power of the previous
30 seconds and to the end for every second after that.
Step 2
Calculate the 4th power of the values from the previous step.
Step 3
Calculate the average of the values from the previous step.
Step 4
Take the fourth root of the average from the previous step.
This is your normalized power.
Args:
power: The power figures for each second.
Returns:
int: The normalised power.
"""
# Step 1: get our moving averages
moving_averages = get_moving_average(source=power, window=30)
if not moving_averages:
return 0
# Step 2: calculate the fourth power of each figure
fourth_powers = [pow(x, 4) for x in moving_averages]
# Step 3: Calculate the average of our fourth powers
fourth_power_average = sum(fourth_powers) / len(fourth_powers)
# Step 4: Take the fourth root of the average to yield normalised power
normalised_power = pow(fourth_power_average, 0.25)
# Done!
return int(normalised_power)
def get_moving_average(*, source: List[int], window: int) -> List[int]:
"""
Get a moving average from an iterable value.
Note: Any zero values are ignored.
Args:
source: The data to iterate over.
window: The moving average window, in seconds.
Returns:
The moving averages found in the data.
"""
# Create a copy of the source data without any zeroes.
fixed_source = [x for x in source if x != 0]
# Create an iterable object from the preprocessed source data.
it = iter(fixed_source)
d = deque(itertools.islice(it, window - 1))
# Create deque object by slicing iterable.
d.appendleft(0)
# Initialise.
avg_list = []
# Iterate over the source data, yielding the moving average.
s = sum(d)
for elem in it:
s += elem - d.popleft()
d.append(elem)
avg_list.append(int(s / window))
# Done.
return avg_list
def _calculate_training_load(*, activities: List[Activity], days: int) -> int:
"""
Given a list of activities, and a number of days, calculate the training load for the
specified number of days.
For example: given all activities in the database, and "42' as the number of days,
this will calculate the classic CTL value.
Args:
activities: The activities to consider.
days: The number of days to include in the training load calculation.
Returns:
The training load.
"""
# Determine the TSS on each day in our window
tss_list = _calculate_daily_tss(activities=activities, days=days)
# Skip the last day (that's today)
del tss_list[-1]
assert len(tss_list) == days
# Average the tss
return sum(tss_list) / len(tss_list)
def _calculate_daily_tss(*, activities: List[Activity], days: int) -> List[int]:
"""
Sum the TSS for each day in the given number of days.
Args:
activities: The list of activities.
days: The number of days to include in the sum (counting back from today).
Returns:
A list of tss values. The length of this list will match the given number
of days. The last value is today's TSS.
"""
# Group activities by date
date_grouping = itertools.groupby(activities, key=lambda x: x.start_time.date())
# Initialise our list
tss_list = []
# Initialise dates
start_date = (datetime.datetime.now() - datetime.timedelta(days=days)).date()
current_date = start_date
# Visit each date
for date, activity_list in date_grouping:
# Skip if too early
if date < start_date:
continue
# If we're not exactly one day on from the previous day, we've got a gap, so we'll
# have to add some padding
day_count = date - current_date
if day_count.days > 1:
padding = day_count.days - 1
while padding:
tss_list.append(0)
padding -= 1
# Calculate the TSS for this date
daily_tss = sum([activity.tss for activity in activity_list])
# Add into the list
tss_list.append(daily_tss)
# Advance to the next day
current_date = date
# Zero pad if we've got missing days at the end
while len(tss_list) < days:
tss_list.append(0)
# Done
return tss_list
def _calculate_aerobic_ratio(*, power: List[int], hr: List[int]) -> Optional[float]:
"""
Given a list of power and HR details, find the ratio between their averages.
Args:
power: The list of power values.
hr: The list of HR values.
Returns:
The ratio between their averages.
"""
# Calculate power and HR averages
assert len(power) == len(hr)
power_avg = calculate_normalised_power(power=power)
hr_avg = sum(hr) / len(hr)
# Determine ratio
return power_avg / hr_avg if hr_avg else None