В предыдущей статье я рассказал о том, как получить данные о персональных тренировках из набора 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
cтруктура таблицы Record с данными одной тренировки
cтруктура таблицы Record с данными одной тренировки

Добавлю, что другие показатели - координаты, отметка высоты, скорость, расстояние - в структурированных виртуальных велотренировках являются производными от ключевых (мощность, каденс и пульс), поэтому исключены из дальнейшего анализа.

Для построения привычного графика показателей тренировки я использовал подключение к базе данных 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-теста.

Одна из наиболее распространенных моделей предполагает деление на семь зон интенсивности в соответствии с физиологическим ответом организма спортсмена:

Семь зон интенсивности велотренировок. Источник: https://www.highnorth.co.uk/articles/cycling-training-zones
Семь зон интенсивности велотренировок. Источник: https://www.highnorth.co.uk/articles/cycling-training-zones

Подробное описание каждой из семи зон приведено здесь.

Фактически отдельная тренировка может сочетать в себе несколько типов, и отнести тренировку к одному типу можно только условно.

Как определить похожие тренировки

Я попытался разделить весь набор завершенных тренировок на несколько типов, учитывая продолжительность тренировки, соотношение проведенного времени в зонах интенсивности, соотношение проведенного времени при низком и среднем каденсе. Дальше я расскажу как я получил каждый из показателей.

Продолжительность тренировки

Продолжительность тренировки можно получить напрямую из таблицы 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 в своей базе данных.

Таблица Test со значениями пороговой мощности для каждого FTP-теста
Таблица Test со значениями пороговой мощности для каждого FTP-теста

Далее для каждой тренировки из таблицы 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()

Итоговый датафрейм имеет следующий вид:

Тренировки с рассчитанным соотношением затраченного времени по зонам интенсивности (zone_1 - zone_7)
Тренировки с рассчитанным соотношением затраченного времени по зонам интенсивности (zone_1 - zone_7)

Соотношение проведенного времени при низком и высоком каденсе

Используя метод расчета для зон интенсивности, можно сделать аналогичный расчет для каденса. Я использовал следующую функцию для расчета трех условных зон каденса (низкий, обычный или средний и высокий):

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()

При объединении всех данных в одну таблицу мы получаем датафрейм следующего вида:

Тренировки с рассчитанным соотношением затраченного времени по зонам интенсивности (zone_1 - zone_7), зонам каденса (cadence_zone_0-cadence_zone_3) и ее продолжительностью в нормализованном виде (time_n)
Тренировки с рассчитанным соотношением затраченного времени по зонам интенсивности (zone_1 - zone_7), зонам каденса (cadence_zone_0-cadence_zone_3) и ее продолжительностью в нормализованном виде (time_n)

Использование контролируемой классификации

В итоговой таблице я получил список из 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-тесты). Три из них были определены вручную как эталонные, модель правильно определила оставшиеся две.

Сопоставление профилей мощности тренировок с кодом 3: верхний ряд - тренировки-эталоны, нижний - определены с помощью модели контролируемой классификации
Сопоставление профилей мощности тренировок с кодом 3: верхний ряд - тренировки-эталоны, нижний - определены с помощью модели контролируемой классификации

Относительно точно были определены тренировки других типов. Сопоставление тренировок в рамках выделенных типов будет продолжено в последующем анализе.

Сопоставление профилей мощности тренировок с кодом 2: верхний ряд - тренировки-эталоны, нижний - определены с помощью модели контролируемой классификации
Сопоставление профилей мощности тренировок с кодом 2: верхний ряд - тренировки-эталоны, нижний - определены с помощью модели контролируемой классификации

Итоги

Это моя первая попытка проанализировать данные, полученные из FIT-файлов. По сути я смог воспроизвести отображение базового набора показателей, которые могут в дальшейшем быть использованы для расчетов эффективности тренировок. В данном виде это конечно ничего не добавляет к существующей функциональности фитнес-приложений (такие как Garmin Connect или Strava), но это первый шаг к независимости от их интерфейса.

Я попробовал произвести классификацию завершенных тренировок для дальнейшего сопоставления показателей похожих тренировок, а именно для сравнения ответа организма на похожую нагрузку через пульс. Надеюсь поделиться этим анализом в следующих статьях.

Ссылка на github.

Комментарии (1)


  1. iridiumhawk
    16.04.2022 13:56

    А ведь существует уже программа для анализа: 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