В предыдущей статье я рассказал о том, как получить данные о персональных тренировках из набора FIT-файлов, которые создаются при использовании носимых устройств (фитнес-браслеты, часы, смартфоны, велокомпьютеры).
При дальнейшем анализе моих активностей я решил сфокусироваться на виртуальных велотренировках по нескольким причинам:
наличие более сотни записей с этого вида тренировок, совершенных за последние два года на одном виде станка с использованием одних и тех же сенсоров (мощемер, датчик каденса и пульсометр) и одного и того же приложения
тренировки проводились в зимний сезон в помещении при относительно одинаковых условиях, то есть исключается фактор разных метеоусловий (температура воздуха, сила ветра, влажность)
тренировки проводились регулярно приблизительно в одно и то же время суток и дни недели
Относительная сопоставимость условий проведения тренировок позволяет сравнивать между собой имеющиеся данные.
Стоит отметить, что уровень точности сенсоров их стабильности все же не претендует на абсолютную значимость результатов анализа, и сам анализ интересен исключительно с любительской точки зрения.
Что такое виртуальная велотренировка
Виртуальная велотренировка является заменой или дополнением к велотренировкам в помещении, которые проходят на велотренажерах.
Минимальный набор оборудования для начала виртуальных велотренировок включает в себя сам велосипед и станок, пульсометр, датчик каденса, мощемер и приложение (на компьютере, смартфоне или смарт-приставке телевизора), которое транслирует структурированную тренировку и записывает данные со всех датчиков. Виртуальность может дополняться прорисованным в приложении маршрутом, соревнованиями с другими велосипедистами.
Детально о том как проходит виртуальная велотренировка можно посмотреть здесь.
К ключевым показателям виртуальной велотренировки относятся:
мощность (power, watts) – прилагаемая на педали сила, умноженная на скорость. Измеряется в ваттах и демонстрирует эффективность той работы, которую вы выполняете, крутя педали
каденс (cadence, rpm) – частота педалирования или по-другому количество оборотов педалей велосипедиста, сделанных за одну минуту
пульс (heart rate, bpm) – частота сердечных сокращений или по-другому количество ударов сердца в минуту.
Данные со всех датчиков регистрируются в момент времени (каждую секунду). Если рассматривать FIT-файл, то упомянутые данные для каждой тренировки хранятся в сообщении Record, обощенные данные - в Session.
Как построить график тренировки
Для извлечения данных о конкретной тренировке из развернутой мною ранее базы данных, я использую запрос с указанием уникального номера тренировки (activity_id =124863703316) в таблицу Record:
select * from record
where activity_id =124863703316 order by timestamp asc
Добавлю, что другие показатели - координаты, отметка высоты, скорость, расстояние - в структурированных виртуальных велотренировках являются производными от ключевых (мощность, каденс и пульс), поэтому исключены из дальнейшего анализа.
Для построения привычного графика показателей тренировки я использовал подключение к базе данных PostgreSQL через модуль psycopg2, pandas для работы с датасетом и matplotlib для визуализации.
В первом шаге подключаемся к данным:
import psycopg2
import pandas as pd
activity_id = 124863703316
conn = psycopg2.connect(host="localhost", database="garmin_data", user="postgres", password="afande")
df = pd.read_sql_query("""select timestamp, heart_rate, cadence, power from record
where activity_id ={} order by timestamp asc""".format(activity_id), conn)
Для каждой записи мы имеем указание на конкретный момент времени в виде 2022-02-15 16:18:16+00:00, что не очень удобно для общего графика тренировки. Обозначим старт тренировки как нулевую секунду, все последующие записи будут пересчитаны относительно старта. Добавим новую колонку sec к датасету:
df['sec'] = (df['timestamp']-min(df['timestamp'])).dt.total_seconds()/60
На графике предполагается отображение пульса, мощности и каденса одновременно, то есть необходимо иметь три шкалы показателей. Я нашел пример построения графика с тремя осями Y на одном графике здесь.
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
fig.subplots_adjust(right=0.75)
plt.title(str(min(df['timestamp']).date()) + " / Activity - " +str(activity_id))
twin1 = ax.twinx()
twin2 = ax.twinx()
twin2.spines.right.set_position(("axes", 1.1))
p1, = ax.plot(df.sec, df.heart_rate,"r-", label="HR")
p2, = twin1.plot(df.sec, df.power, "b-", label="Power")
p3, = twin2.plot(df.sec, df.cadence, "g-", label="Cadence")
ax.set_xlim(0, 90)
ax.set_ylim(0, 200)
twin1.set_ylim(0, 400)
twin2.set_ylim(0, 120)
ax.set_xlabel("Time, min")
ax.set_ylabel("HR, bpm")
twin1.set_ylabel("Power, watts")
twin2.set_ylabel("Cadence, bpm")
ax.yaxis.label.set_color(p1.get_color())
twin1.yaxis.label.set_color(p2.get_color())
twin2.yaxis.label.set_color(p3.get_color())
tkw = dict(size=4, width=1.5)
ax.tick_params(axis='y', colors=p1.get_color(), **tkw)
twin1.tick_params(axis='y', colors=p2.get_color(), **tkw)
twin2.tick_params(axis='y', colors=p3.get_color(), **tkw)
ax.tick_params(axis='x', **tkw)
ax.legend(handles=[p1, p2, p3])
plt.rcParams['figure.figsize'] = [10, 5]
plt.show()
В итоге мы получаем следующий график:
На этом графике хорошо видно, что показатели могут довольно резко изменяться (секундные падения и взлеты), что может быть обусловлено потерей сигнала сенсора или остановкой педалирования.
Для визуально приятного отображения графика тренировки можно добавить сглаживание линий через функцию сплайна. Способ описан здесь.
Я добавил в код функцию spline с тремя параметрами – значения по оси X, значения по оси Y и параметр n, отвечающий за уровень сглаживания конечной линии (чем он меньше, тем более сглаженной получится линия):
from scipy.interpolate import make_interp_spline
import numpy as np
def spline(x, y, n):
do_spline = make_interp_spline(x,y)
x_ = np.linspace(x.min(), x.max(), n)
y_ = do_spline(x_)
return x_, y_
Заменим код для линий на графике на следующий:
p1, = ax.plot(spline(df.sec, df.heart_rate, 150)[0], spline(df.sec, df.heart_rate, 150)[1],"r-", label="HR")
p2, = twin1.plot(spline(df.sec, df.power, 150)[0], spline(df.sec, df.power, 150)[1], "b-", label="Power")
p3, = twin2.plot(spline(df.sec, df.cadence, 150)[0], spline(df.sec, df.cadence, 150)[1], "g-", label="Cadence")
На рисунке ниже показано сопотавление двух вариантов:
Какие бывают велотренировки
Регулярное планирование велотренировок довольно сложный процесс и может быть нацелено на достижения различных целей: конкретный старт, повышение выносливости на длинных дистанциях, общей максимизации эффективности тренировочного времени и прочее.
Наиболее популярный метод планирования построен на комбинировании различных зон интенсивности в рамках одной структурированной тренировки и/или в пределах недельного/месячного плана тренировок в зависимости от целей.
Разделение на зоны интенсивности чаще всего происходит на основе функциональной пороговой мощности (Functional Threshold Power, FTP) – средняя максимальная мощность при езде на велосипеде в течение часа. Персональное значение FTP велосипедиста можно определить с помощью FTP-теста.
Одна из наиболее распространенных моделей предполагает деление на семь зон интенсивности в соответствии с физиологическим ответом организма спортсмена:
Подробное описание каждой из семи зон приведено здесь.
Фактически отдельная тренировка может сочетать в себе несколько типов, и отнести тренировку к одному типу можно только условно.
Как определить похожие тренировки
Я попытался разделить весь набор завершенных тренировок на несколько типов, учитывая продолжительность тренировки, соотношение проведенного времени в зонах интенсивности, соотношение проведенного времени при низком и среднем каденсе. Дальше я расскажу как я получил каждый из показателей.
Продолжительность тренировки
Продолжительность тренировки можно получить напрямую из таблицы Session – это колонка total_timer_time, где время тренировки записано в секундах.
При дальнейшем сопоставлении времени тренировок с показателями в других единицах измерения целесообразно нормализовать эти данные. Для готового датафрейма нормализация может быть сделана следующим образом, где time_n – колонка с нормализованными данными, time – исходные данные:
data['time_n'] = data.apply(lambda row: round((row['time']-min(data['time']))/(max(data['time'])-min(data['time']))*100.00)
Соотношение проведенного времени в зонах интенсивности
Выставленные зоны интенсивности в процессе тренировок меняются и зависят от последнего результата FTP-теста. В сезоне 2020-2021 я провел три аналогичных теста, в сезоне 2021-2022 – два. Результаты тестов я занес в отдельную таблицу Test в своей базе данных.
Далее для каждой тренировки из таблицы Session (здесь хранятся общие сведения о тренировке) я добавил значение последнего FTP-теста, на основе которого выставляются зоны интенсивности для всех последующих тренировок. Также я исключил все тренировки до первого теста в сезоне, так как использовать значение из прошлого сезона не правдоподобно (за лето форма могла измениться). В запросе я использовал cross join и rank() over:
select * from (select session.activity_id, session.timestamp, session.avg_heart_rate, session.avg_power,
session.total_timer_time/3600 as time,
test.power_threshold, test.timestamp as test_timestamp,
rank() over (partition by session.activity_id order by session.activity_id, test.timestamp desc) as ftp_rank
from session cross join test
where sub_sport = 'virtual_activity' and avg_heart_rate > 90
and session.timestamp > '2020-12-26' and session.timestamp >= test.timestamp
and record.activity_id not in (109983788203, 110771005101, 111376595537, 111494782478)
order by session.activity_id) a
where a.ftp_rank = 1
Для детальных данных тренировок я сделал запрос к таблице Record (к ней же мы делали запрос для построения графика одной тренировки ранее), исключив тренировки в каждом сезоне до первого теста:
select record.record_id, record.activity_id, record.timestamp, record.heart_rate,
record.cadence, record.power
from record join session on record.activity_id = session.activity_id
where record.timestamp >= '2020-12-26' and session.sub_sport = 'virtual_activity' and record.power > 0
and record.activity_id not in (109983788203, 110771005101, 111376595537, 111494782478)
order by timestamp asc
При объединении таблиц я получил детальные данные для каждой тренировки вместе с пороговой мощностью. Теперь можно рассчитать соотношение времени, проведенного в разных зонах интенсивности.
Добавим новую колонку в объединенную таблицу, где будет рассчитан процент от пороговой мощности для каждой записи (каждой секунды):
record_data['percent_power'] = round(record_data['power']/record_data['power_threshold']*100.00)
Для дальнейших расчетов я добавил следующую функцию на основе приведенной ранее таблицы семи зон интенсивности:
def zone(row):
if row['percent_power'] <= 55:
val = 1
elif row['percent_power'] > 55 and row['percent_power'] <=75:
val = 2
elif row['percent_power'] > 75 and row['percent_power'] <=90:
val = 3
elif row['percent_power'] > 90 and row['percent_power'] <=105:
val = 4
elif row['percent_power'] > 105 and row['percent_power'] <=120:
val = 5
elif row['percent_power'] > 120 and row['percent_power'] <=130:
val = 6
elif row['percent_power'] > 130:
val = 7
else:
val = 0
return val
Расчет номера зоны для каждой строчки в датафрейме упрощается до вида:
record_data['zone'] = record_data.apply(zone, axis=1)
Далее агрегируем данные для того, чтобы получить соотношение времени потраченного в каждой зоне интенсивности для отдельной тренировки:
record_pivot = pd.pivot_table(record_data, index = ['activity_id', 'zone'], values = ['percent_power'], aggfunc='count')
record_pivot = record_pivot.reset_index()
record_pivot['record_count'] = record_pivot.groupby('activity_id')['percent_power'].transform('sum')
record_pivot['percent_zone'] = round(record_pivot.percent_power/record_pivot.record_count*100.00)
record_pivot = record_pivot[['activity_id', 'zone', 'percent_zone']]
record_pivot['zone_desc'] = record_pivot.apply(lambda row: 'zone_'+str(int(row['zone'])), axis=1)
training_zone = pd.pivot_table(record_pivot, index=['activity_id'], values='percent_zone', columns='zone_desc')
training_zone = training_zone.reset_index()
Итоговый датафрейм имеет следующий вид:
Соотношение проведенного времени при низком и высоком каденсе
Используя метод расчета для зон интенсивности, можно сделать аналогичный расчет для каденса. Я использовал следующую функцию для расчета трех условных зон каденса (низкий, обычный или средний и высокий):
def cadence(row):
if row['cadence'] >= 45 and row['cadence'] < 65:
val = 1
elif row['cadence'] >=85 and row['cadence'] <= 95:
val = 2
elif row['cadence'] > 95:
val = 3
else:
val = 0
return val
Аналогично рассчитаем зоны каденса для каждой секунды тренировок:
cadence_pivot = pd.pivot_table(record_data, index = ['activity_id', 'cadence_zone'], values = ['cadence'], aggfunc='count')
cadence_pivot = cadence_pivot.reset_index()
cadence_pivot['record_count'] = cadence_pivot.groupby('activity_id')['cadence'].transform('sum')
cadence_pivot['cadence'] = round(cadence_pivot.cadence/cadence_pivot.record_count*100.00)
cadence_pivot = cadence_pivot[['activity_id', 'cadence_zone', 'cadence']]
cadence_pivot['cadence_zone_desc'] = cadence_pivot.apply(lambda row: 'cadence_zone_'+str(int(row['cadence_zone'])), axis=1)
cadence_zone = pd.pivot_table(cadence_pivot, index=['activity_id'], values='cadence', columns='cadence_zone_desc')
cadence_zone = cadence_zone.reset_index()
При объединении всех данных в одну таблицу мы получаем датафрейм следующего вида:
Использование контролируемой классификации
В итоговой таблице я получил список из 102 тренировок за два последние сезона (2020-2021 и 2021-2022). Учитывая размер выборки можно проклассифицировать тренировки вручную, но я решил попробовать метод контролируемой классификации.
Сразу скажу, что рациональность этого действия сводится к минимуму, но мне было интересно как этот метод сможет сработать в данном случае.
Метод контролируемой классификации (или обучение с учителем) использует заранее подготовленные образцы или эталоны для классификации данных или точного прогнозирования результатов. Подробнее о методах обучения здесь.
Подготовка тренировок-эталонов
Для 37 из 102 тренировок я проставил наиболее близкий тип тренировки, сопоставляя время и зоны интенсивности для каждой из них. Примеры всех типов тренировок-эталонов и их описания приведены в таблице:
Код эталона |
Название |
Продолжительность |
Зоны интенсивности |
Зоны каденса |
3 |
Условно пороговая тренировка / FTP-тест |
короткая (меньше часа) |
значительная часть времени (50% и более) проведено в зонах высокой интенсивности |
большая часть времени проведена на среднем и высоком каденсе |
2 |
Условно тренировка темпо |
средняя (1,5-2 часа) |
большая часть времени (более 70%) проведена в зонах низкой и средней интенсивности |
большая часть времени проведена на среднем каденсе или на низком и среднем каденсе |
1 |
Условно тренировка на выносливость |
длительная (больше 1,5 часов) |
большая часть времени (более 70%) проведена в зонах низкой интенсивности |
большая часть времени проведена на среднем каденсе |
0 |
Условно восстановительная тренировка |
короткая (до 1,5 часов) |
Большая часть времени (более 70%) проведена в зонах низкой интенсивности |
большая часть времени проведена на среднем каденсе |
Random Forests
Я использовал наиболее распространенный и простой в исполнении алгоритм Random Forests, который включен в модуль sklearn. Понятная инструкция для его применения доступна здесь.
Для построения модели в качестве входных данных я использовал различные комбинации колонок из подготовленной ранее таблицы. Наиболее удачная комбинация на мой взгляд для моего набора данных выглядит следующим образом:
X=df_train[['time_n', 'zone_1_2', 'zone_3', 'zone_4_6', 'cadence_zone_1']]
Я суммировал значения первой и второй зон интенсивности, так как они почти всегда соответствуют низкому уровню, аналогично для четвертой, пятой и шестой – так как они соответствуют высокому уровню. Седьмую зону я исключил, так как она единично представлена для некоторых тренировок (1 и меньше процента). Для каденса я взял только зону с низкими значениями, так как значение средней и высокой зоны будет зависимо напрямую.
В качестве выходных данных я использовал 37 значений, соотносящихся с типом тренировок, расставленных ранее.
Тестирование модели показало ее высокую точность (Accuracy: 1.0), но скорее всего она меньше, исходя из маленького набора данных.
Применив эту модель, я проклассифицировал все остальные тренировки на четыре типа. В результате классификации я получил 5 тренировок на пороговой мощности, 39 темпо, 14 на выносливость, 44 на восстановление.
Сравнение похожих тренировок
Для похожих тренировок я попробовал совместить на одном графике их профили мощности, поскольку они отображают исходное структурированное задание. В теории я должен был увидеть схожие профили для одного типа тренировок.
Для визуализации нескольких профилей одновременно я использовал опцию subplot из библиотеки matplotlib.
# import libraries
import psycopg2
import pandas as pd
import matplotlib.pyplot as plt
from datetime import datetime
# connect to the dataset
df = pd.read_csv('power_zones_data_rf_comparison.csv', index_col=0)
df = df[['activity_id', 'training_type']]
training_3 = df.loc[df['training_type']==3]
list_3 = training_3['activity_id'].to_list()
conn = psycopg2.connect(host="localhost", database="garmin_data", user="postgres", password="afande")
# generate a chart for each training from the list_3
for i in range(5):
activity_id = list_3[i]
df = pd.read_sql_query("""select timestamp, heart_rate, cadence, power from record
where activity_id ={} order by timestamp asc""".format(activity_id), conn)
df['sec'] = (df['timestamp']-min(df['timestamp'])).dt.total_seconds()/60
plt.subplot(2,3,i+1)
plt.plot(df.sec, df.power)
plt.title(min(df['timestamp']).date())
plt.xlim(0, 60)
plt.ylim(0,450)
plt.xlabel('time, min')
plt.ylabel('power, watts')
plt.rcParams['figure.figsize'] = [20, 10]
plt.show()
Наиболее точно были определены пороговые тренировки (или FTP-тесты). Три из них были определены вручную как эталонные, модель правильно определила оставшиеся две.
Относительно точно были определены тренировки других типов. Сопоставление тренировок в рамках выделенных типов будет продолжено в последующем анализе.
Итоги
Это моя первая попытка проанализировать данные, полученные из FIT-файлов. По сути я смог воспроизвести отображение базового набора показателей, которые могут в дальшейшем быть использованы для расчетов эффективности тренировок. В данном виде это конечно ничего не добавляет к существующей функциональности фитнес-приложений (такие как Garmin Connect или Strava), но это первый шаг к независимости от их интерфейса.
Я попробовал произвести классификацию завершенных тренировок для дальнейшего сопоставления показателей похожих тренировок, а именно для сравнения ответа организма на похожую нагрузку через пульс. Надеюсь поделиться этим анализом в следующих статьях.
iridiumhawk
А ведь существует уже программа для анализа: https://www.goldencheetah.org/
Import all popular file formats
TrainingPeaks (WKO, PWX)
PowerTap (RAW, CSV)
Garmin / ANT+ (FIT, FIT 2.0)
SportTracks (FITLOG)
Ambit (SML)
Sigma (SLF, SMF)
Ergomo (CSV)
Google Earth (KML)
Garmin (TCX, GPX)
Polar (HRM)
SRMWin (SRM)
Computrainer (TXT)
iBike (CSV)
MotoACTV (CSV)
Hrv4Training and EliteHRV (HRV)
Row Perfect (RP3)
Forensic Ride Analysis
Multiple charts to examine and analyse ride and interval data including:
Critical Power Modelling
Embedded Python and R
Performance Plot
Stress Plot
Pedal Force/Velocity
Histogram
2d and 3d scatter
HR v Power
Map (Google / OSM)
Aerolab
Edit, Search and Export Data
Advanced data editor with undo, redo
Automatic anomaly detection
Fix tools for GPS, Spikes, Torque
Advanced interval find, free-text search, data filters and search for values
Export data to other applications and formats including; PWX, CSV, KML, TCX and JSON