Мне стало интересно проанализировать данные о своих тренировках за последние несколько лет, и я понял, что обычного функционала приложений типа Garmin Connect или бесплатной версии Strava будет недостаточно. В этой статье я расскажу как получить свои персональные данные о тренировках из устройств Garmin и разместить их в реляционной базе данных с помощью библиотек python.
Что такое FIT файл и что он может содержать
Если вы используете носимые устройства (фитнес-браслеты, часы, смартфоны, велокомпьютеры) для записи своих активностей или тренировок, то скорее всего информация будет сохранена в FIT файле активности – наиболее распространенный формат, используемый в фитнес-приложениях для обмена детальными данными о совершенных тренировках.
Для каждой отдельной активности файл включает в себя набор обязательных и дополнительных сообщений. Я разобрался с обязательными: они включают в себя записи о дате и времени тренировки, ее типе спорта, данные о кругах, GPS-трек, данные с сенсоров (может быть датчик пульса, датчик каденса/скорости, мощемер и прочее). Подробнее о структуре каждого сообщения ниже в таблице.
Название сообщения |
Описание и содержание сообщения |
File Id |
Содержит данные о производителе устройства сбора данных и название устройства |
Activity |
Включает данные о дате и времени активности, общее время, количестве сессий |
Session |
Обобщенная информация об активности (средняя и максимальная скорость, общее время, общее расстояние, общий набор высоты, вид и подвид спорта, средняя и максимальная частота пульса и другие) |
Lap |
Отображает обобщенные сведения о кругах или интервалах в рамках одной активности (номер круга, общее расстояние круга, максимальная скорость круга и другие). Может быть несколько кругов для одной активности |
Record |
Содержит данные со всех сенсоров в момент времени (координаты, номер круга, абсолютная высота на местности, дата и время, значение пульса, каденса, скорости, мощности, температуры воздуха и другие) |
Как получить доступ к данным
Все персональные данные о тренировках, собранные с помощью устройств Garmin (в моем случае это несколько версий часов и велокомпьютер), можно получить по запросу с официального сайта Garmin. После запроса в течение 48 часов вам на почту должна быть прислана ссылка на загрузку пакета данных.
В скачанном архиве наиболее ценные файлы с точки зрения данных об активностях будут находиться в папке DI_CONNECT\DI-Connect-Fitness-Uploaded-Files\UploadedFiles_0-_Part1. Каждый файл содержит информацию об одной совершенной активности.
Структура данных
На базе PostgreSQL я создал пять новых таблиц, которые будут соответствовать сообщениям из FIT файла - File Id, Activity, Session, Lap, Record. Таблица Activity будет содержать в себе ключ (activity_id) к каждой уникальной активности, все остальные таблицы будут содержать этот ключ как foreign key. При парсинге файла заполнение таблиц будет начинаться именно с сообщения Activity. Сгенерированная в PostgreSQL ERD схема базы данных представлена ниже.
Описание кода
Для декодирования, чтения и загрузки и проверке данных об активностях из FIT файлов в таблицы PostgreSQL я использовал Jupiter Notebook и несколько библиотек python, в том числе os, pandas, psycopg2, fitdecode и matplotlib.
Код для каждого файла из указанной выше папки проходит следующие шаги:
Извлечение уникального номера активности и идентификатора пользователя из названия FIT файла
Чтение, декодирование и запись данных в датафрейм для каждого из пяти сообщений (File Id, Activity, Session, Lap, Record)
Загрузка датафреймов в соответствующую таблицу PostgreSQL
Я подготовил схему данных для каждой из таблиц, чтобы код искал только те поля данных, которые мне будут необходимы из каждого отдельного сообщения. Например, из сообщения Activity мне нужны следующие данные:
activity = ['timestamp', 'total_timer_time', 'local_timestamp', 'num_sessions', 'type', 'event', 'event_type', 'event_group']
Извлечение кода активности и идентификатора пользователя
Названия файлов могут варьироваться по содержанию, но всегда содержат идентификатор пользователя и номер активности как два первых параметра, разделенных символом '_'. Для извлечения этих параметров использовал простую функцию:
def get_user_activity_details(file):
filename = os.path.basename(file)
user_id, activity_id = filename.split('_')[0], filename.split('_')[1]
if '.' in activity_id:
activity_id = activity_id.split('.')[0]
return user_id, activity_id
Чтение, декодирование и запись данных в датафрейм
Для парсинга FIT файла была использована специальная библиотека fitdecode. Здесь можно найти исходную документацию. Установка библиотеки через PyPI:
pip install fitdecode
Библиотека позволяет создать объект FitReader, который считывает FIT файл и получает доступ к каждому сообщению (или фрейму) в этом файле. Каждый фрейм включает в себя FitHeader, FitDefinitionMessage, FitDataMessage, FitCRC. Нас будет интересовать только FitDataMessage, так как он содержит в себе данные об активности. В этой статье описано подробнее.
Пример извлечения данных из сообщения Activity в датафрейм ниже:
def get_fit_other_data(col, frame: fitdecode.records.FitDataMessage) -> Optional[Dict[str, Union[float, int, str, datetime]]]:
data: Dict[str, Union[float, int, str, datetime]] = {}
for field in col:
if frame.has_field(field):
data[field] = frame.get_value(field)
return data
def get_dataframes(fname: str) -> Tuple[pd.DataFrame]:
activity_data = []
with fitdecode.FitReader(fname) as fit_file:
for frame in fit_file:
if isinstance(frame, fitdecode.records.FitDataMessage):
if frame.name == 'activity':
activity_data.append(get_fit_other_data(activity, frame))
activity_df = pd.DataFrame(activity_data, columns = activity)
df['activity_id'] = activity_id
if activity_df.empty:
activity_df = activity_df.append({'activity_id':activity_id}, ignore_index=True)
return activity_df
Загрузка датафрейма в таблицу базы данных
Для подключения и загрузки данных в PostgreSQL я использовал стандартную библиотеку psycopg2. Пример загрузки данных из датафрейма Activity в соответствующую таблицу ниже:
def load_dataframe_to_postgres(df, tabl):
if not df.empty:
df = df.fillna(0)
cursor = conn.cursor()
if tabl == 'activity':
df = df.astype({'activity_id': 'int64','timestamp': 'datetime64[ns, UTC]', 'total_timer_time': 'float64', 'local_timestamp': 'datetime64[ns]', 'num_sessions': 'int64', 'type': 'object', 'event': 'object', 'event_type': 'object', 'event_group': 'object'})
for index, row in df.iterrows():
cursor.execute("""insert into activity(activity_id, timestamp, total_timer_time, local_timestamp, num_sessions, type, event, event_type, event_group)
values (%s, %s, %s, %s, %s, %s, %s, %s, %s)""", [row.activity_id, row.timestamp, row.total_timer_time, row.local_timestamp, row.num_sessions, row.type, row.event, row.event_type, row.event_group])
conn.commit()
cursor.close()
Проверка полученных данных
Для проверки адекватности полученных данных я попробовал визуализировать количество минут, потраченных на вид активности по месяцам за весь доступный период времени (с апреля 2014 по февраль 2022).
Мне было достаточно обобщенных данных из таблицы Session – я использовал поля с данными о дате активности, ее продолжительности и виде (timestamp, total_timer_time, sport). Итоговые данные сгруппированы по месяцам и виду активности.
# get session data summary with sport split
conn = psycopg2.connect(host="localhost", database="garmin_data", user="postgres", password="afande")
df = pd.read_sql_query("""select to_char(timestamp, 'YYYY-MM') as stamp, sum(total_timer_time / 60) as minutes_spent, sport
from session
group by to_char(timestamp, 'YYYY-MM'), sport
having sum(total_timer_time / 60) > 0
order by to_char(timestamp, 'YYYY-MM') desc""", conn)
Дополнительно был создан датафрейм со всеми месяцами для их отображения на графике даже при нулевом значении (когда не было ни одной завершенной активности). Для этого были определены самый ранний и самый поздний месяцы, которые есть в таблице. После создан range с шагом в месяц и этими месяцами как граничными значениями.
# get min and max dates from the dataframe
min_date = datetime.strptime(min(df.stamp), '%Y-%m')
max_date = datetime.strptime(max(df.stamp), '%Y-%m')
n_max_date = max_date + pd.DateOffset(months=1)
# create a table with all months from min to max date
data = pd.DataFrame()
data['Dates'] = pd.date_range(start=min_date, end=n_max_date, freq='M')
data['Dates'] = data['Dates'].dt.strftime('%Y-%m')
# merge datasets
df_main = pd.merge(data, df, left_on='Dates', right_on='stamp', how='left', indicator=True)
df_main = df_main[['Dates', 'minutes_spent','sport']]
df_main = df_main.fillna(0)
Сводная таблица была использована для построения графика stacked bar из библиотеки matplotlib. На график дополнительно добавлена разграфка по годам в виде вертикальных разделительных линий.
# pivot table
df_pivot = pd.pivot_table(df_main, index='Dates', columns='sport', values='minutes_spent').reset_index()
df_pivot = df_pivot.fillna(0)
df_pivot = df_pivot[['Dates', 'cross_country_skiing', 'cycling', 'running', 'swimming', 'walking']]
# create stacked bar chart for monthly sports
df_pivot.plot(x='Dates', kind='bar', stacked=True, color=['r', 'y', 'g', 'b', 'k'])
# labels for x & y axis
plt.xlabel('Months', fontsize=20)
plt.ylabel('Minutes Spent', fontsize=20)
plt.legend(loc='upper left', fontsize=20)
for num in [69, 57, 45, 33, 21, 9]:
plt.axvline(linewidth=2, x=num, linestyle=':', color = 'grey')
# title of plot
plt.title('Minutes spent by Sport', fontsize=20)
plt.rcParams['figure.figsize'] = [24, 10]
Итоговый график правдоподобно отображает количество минут потраченных на виды активностей – например максимум минут на велосипеде в апреле-мае 2019 года соответствует велотуру по Скандинавии, отсутствие велотренировок в августе-сентябре 2021 года из-за травмы и их замены на занятия по плаванию и пешими прогулками, появление лыжных тренировок с декабря 2021 года соответствует обкатке новой пары лыж.
Итоги
Всего я получил доступ к 5,300 FIT файлам, среди которых только один оказался битым и не выдавал доступ к данным. Полный код для загрузки данных из всех главных сообщений FIT файла в таблицы PostgreSQL DB можно найти здесь.
Данный метод позволяет получить доступ ко всем историческим данным одновременно и проводить дальнейший анализ в удобной среде.
Стоит отметить, что существует способ получения данных через Activity API, он больше ориентирован для разработчиков приложений, которым необходим постоянный доступ к обновляемым данным.
ne555
На мой взгляд, не справились с задачей, брошенный анализ в самом его начале.
Неудачный авто-шаг меток по "Y" не дает предположить и примерно: "сколько наплавали осенью 16г." например.
Можно было бы наложить/визуализировать все свои тренировки на карту; проверить: стремится ли активность весна-осень под нормальное распределение или нет; где медиана времени по периодам? мода по видам в облаке тегов?
"Цифры обманчивы, особенно когда я сам ими занимаюсь", но ничего этого почти нет.
Тема интересная, но увы статистика не раскрыта, поэтому анализ спорт.сети дает картинку нагляднее чем ваша.
estet
Это все задел на будущее. Успехов автору с начинанием!
denis_afanasyev Автор
спасибо за комментарий! В этой статье я хотел рассказать только о том, как получить данные и подготовить их к дальнейшему анализу в удобных мне интерфейсах. Первое предложение только раскрывает для чего я все это начиная делать.
Собственно сам анализ и методы я постараюсь описать в следующих статьях. Та часть с графиком была исключительно для понимания адекватно ли выглядят подготовленные данные.
В любом случае спасибо за идеи и предложения, я возьму их на заметку