AI Разбираем «под капотом» кастомную фитнес-метрику: от идеи до реализации на Python

AI

Редактор
Регистрация
23 Август 2023
Сообщения
2 822
Лучшие ответы
0
Реакции
0
Баллы
51
Offline
#1
Всем привет! Я, как и многие здесь, не только разработчик, но и человек, увлеченный циклическими видами спорта. Я обожаю копаться в данных своих тренировок из Strava: анализировать мощность, пульсовые зоны, темп. Но мне всегда не хватало одной вещи — единой, понятной и, главное, прозрачной метрики, которая бы отвечала на простой вопрос: "А насколько я сейчас в хорошей форме?".

Конечно, есть VO2max, есть Fitness & Freshness от Strava, есть GFR у Garmin. Но мне всегда хотелось создать что-то свое. Метрику, которую я бы понимал от и до, от первой строчки кода до финальной цифры на экране.

Так в рамках моего pet-проекта The Peakline это платформы для аутдор-энтузиастов родилась идея PeakLine Score (PLS). Это моя попытка создать комплексную оценку производительности, которая учитывает не только твою скорость, но и сложность маршрута, по которому ты ехал.

В этой статье я расскажу, как устроен этот механизм "под капотом". Мы погрузимся в логику на Python, посмотрим, как она интегрируется в общий анализатор активностей и как результат подается пользователю в простом и понятном виде.

Важный дисклеймер: Весь проект, от идеи до кода, я делаю один в свободное от основной работы время. Он далек от идеала, и я буду очень благодарен за конструктивную критику и свежий взгляд.

Приглашаю вас изучить сам проект:


А теперь — к техническим деталям.


Общий вид страницы PeakLine Score
Архитектура: Python-мозг и HTML-лицо


Когда ты делаешь проект в одиночку, простота и четкое разделение ответственности — ключ к выживанию. Я не стал усложнять и построил архитектуру по классической схеме: бэкенд на Python (Flask) отвечает за всю логику, а фронтенд — это легковесный HTML, отрисованный с помощью серверного шаблонизатора Jinja2.

Чтобы не утонуть в коде, я разделил логику PLS на три четких, независимых компонента:

/folder1/
├── activity_analyzer.py # "Интегратор"
└── peakline_score.py # "Мозг" - вся математика здесь

/folder1/
└── peakline_score.html # "Лицо" - представление данных

  1. peakline_score.py (Мозг): Чистый Python-модуль, ядро всей системы. Он ничего не знает о Strava, веб-серверах или базах данных. Его задача — принять на вход числовые данные об активности и вернуть числовой балл. Максимально изолированный и тестируемый.


  2. activity_analyzer.py (Интегратор): Этот модуль — дирижер оркестра. Он забирает "сырые" данные из Strava, проводит их через разные анализаторы (расчет зон мощности, пульса) и, в том числе, передает их в peakline_score.py для расчета PLS. Его задача — элегантно встроить новую фичу в существующий конвейер.


  3. peakline_score.html (Лицо): Jinja2-шаблон. Он получает с бэкенда готовый словарь с данными PLS и отвечает только за то, чтобы красиво и понятно их показать пользователю.

Такой подход позволяет мне легко дорабатывать каждый компонент по отдельности.

peakline_score.py: Математика "супер-атлета"


Это сердце всей системы. Как объективно оценить результат? Проехать 100 км по плоской трассе за 3 часа — это одно, а проехать 70 км с набором высоты 2000 метров за то же время — совсем другое.

Идея проста: а что, если сравнить время пользователя со временем, которое показал бы на этом же маршруте гипотетический "идеальный" спортсмен?

Сначала я определил параметры этого "супер-атлета" — константный объект с показателями атлета мирового уровня.

# /utils/peakline_score.py
class PeakLineScoreCalculator:
def __init__(self):
self.SUPER_ATHLETE_PARAMS = {
'ftp': 400,
'max_speed_flat': 55,
'climbing_power': 6.5,
'weight': 70,
# ... и другие параметры
}

Затем я написал функцию calculateideal_time, которая оценивает, за сколько бы этот "супер-атлет" проехал маршрут. Логика учитывает два ключевых фактора: время на равнине и "штраф" за набор высоты.

# /utils/peakline_score.py (упрощенно)
def _calculate_ideal_time(self, distance_km: float, elevation_gain: float, activity_type: str):
base_speed_kmh = self.SUPER_ATHLETE_PARAMS['max_speed_flat']
climbing_penalty = 0.3 # минут на 100м набора высоты для велосипеда

flat_time_hours = distance_km / base_speed_kmh
elevation_penalty_hours = (elevation_gain / 100) * climbing_penalty / 60

terrain_coefficient = self._get_terrain_coefficient(distance_km, elevation_gain)

ideal_time = (flat_time_hours + elevation_penalty_hours) * terrain_coefficient
return ideal_time

Функция getterrain_coefficient дополнительно классифицирует маршрут (flat, rolling, hilly, mountain) и вводит небольшой повышающий коэффициент для более сложного рельефа.

Теперь, имея actual_time и ideal_time, формула расчета балла становится элементарной:

pls_points = (ideal_time / actual_time) * 1000

Если проехал как "супер-атлет" — получаешь 1000 баллов. Вдвое медленнее — 500. Просто и прозрачно.

activity_analyzer.py: Интеграция без боли


Новая фича не должна ломать старую логику. У меня уже был большой модуль activity_analyzer.py, который выполнял полный анализ тренировки: запрашивал данные из Strava, считал зоны мощности, пульса, получал погоду. Задача — встроить расчет PLS в этот процесс, не создавая хаоса.

Я решил эту задачу с помощью небольшой вспомогательной функции add_pls_to_activity_analysis. Она работает как последний шаг в конвейере анализа.

# /utils/activity_analyzer.py
from .peakline_score import add_pls_to_activity_analysis

async def analyze_activity(activity_id: str, strava_user_id: int):
# ... здесь происходит весь основной анализ ...
# ... получение данных из Strava, расчет зон и т.д. ...

# В конце, когда все данные собраны в analysis_results:
set_cached_analysis(int(activity_id), strava_user_id, analysis_results)

# Добавляем PeakLine Score к анализу
analysis_results = add_pls_to_activity_analysis(analysis_results)

logger.info(f"Analysis for activity {activity_id} completed successfully.")
return analysis_results

Сама функция-обертка add_pls_to_activity_analysis просто извлекает уже посчитанные данные из общего объекта анализа, передает их в наш калькулятор PeakLineScoreCalculator и добавляет результат в новый ключ peakline_score.

# /utils/peakline_score.py
def add_pls_to_activity_analysis(analysis_data: Dict[str, Any]) -> Dict[str, Any]:
# ... проверка, что данные существуют ...

details = analysis_data['details']
activity_data = {
'distance': details.get('distance', 0),
'moving_time': details.get('moving_time', 0),
'total_elevation_gain': details.get('total_elevation_gain', 0),
# ... и другие необходимые поля
}

pls_data = calculate_peakline_score_for_activity(activity_data)

if pls_data:
analysis_data['peakline_score'] = pls_data

return analysis_data

Такой подход "декоратора" позволил добавить новую сложную логику, практически не изменяя основной код анализатора.

peakline_score.html: От цифр к эмоциям


Сухие цифры на бэкенде — это лишь полдела. Важно было подать их пользователю так, чтобы это мотивировало, а не расстраивало. Здесь в игру вступает шаблонизатор Jinja2.

Бэкенд передает в шаблон один большой объект pls_data. А дальше магия происходит прямо в HTML. Например, главный балл выводится одной строкой:

<div class="pls-score-display">{{ pls_data.overall_pls_score }}</div>
<div class="pls-level">{{ pls_data.performance_level }}</div>

Таблица с лучшими результатами генерируется в цикле, что делает код чистым и лаконичным:

{% for score in pls_data.top_scores %}


<a class="activity-link" href="/activity/{{ score.activity_id }}">
{{ score.activity_name }}
</a>

{{ score.date[:10] }}

{% if score.terrain_type == 'hilly' %}
<span class="terrain-icon">⛰️</span>{{ T.pls_terrain_hilly }}
{% else %}
...
{% endif %}

<strong>{{ score.pls_points }}</strong>

{% endfor %}

А блок с рекомендациями использует простую if/elif/else логику, чтобы давать разные советы в зависимости от уровня пользователя. Это делает страницу "живой" и персонализированной.

<div class="improvement-tip">
<h3>{{ T.pls_recommendations_title }}</h3>
{% if pls_data.overall_pls_score &lt; 600 %}
<p><strong>{{ T.pls_recommendations_tips_title }}</strong></p>
<ul>...</ul>
{% elif pls_data.overall_pls_score &lt; 800 %}
<p><strong>{{ T.pls_recommendations_excellent_title }}</strong></p>
<ul>...</ul>
{% else %}
<p><strong>{{ T.pls_recommendations_great_job }}</strong></p>
{% endif %}
</div>
Под капотом "Общего рейтинга": Как из хаоса тренировок рождается единый балл


Самая интересная часть — это не оценка одной тренировки, а вычисление общего рейтинга атлета. Ведь одна случайная супер-успешная гонка не должна определять весь его уровень. Здесь я подсмотрел идею у Garmin с их GFR Score, но реализовал ее по-своему.

Алгоритм в функции calculate_user_pls_score состоит из пяти простых шагов:


  1. Анализ: Скрипт перебирает все доступные тренировки пользователя.


  2. Расчет: Для каждой вычисляется индивидуальный PLS-балл.


  3. Сортировка: Все результаты сортируются по убыванию — от лучших к худшим.


  4. Выборка: Из всего списка берутся только 6 лучших результатов. Это позволяет отсеять неудачные или восстановительные тренировки, которые не отражают пиковую форму.


  5. Усреднение: Итоговый PeakLine Score — это простое среднее арифметическое этих шести лучших показателей.

Вот как это выглядит в коде:

# /utils/peakline_score.py
def calculate_user_pls_score(self, user_activities: List[Dict[str, Any]]):
if not user_activities:
return None

pls_scores = []
for activity in user_activities:
score_data = self.calculate_score(activity)
if score_data:
pls_scores.append({ ... }) # Собираем все результаты

if not pls_scores:
return None

# 3. Сортируем по баллам (лучшие сначала)
pls_scores.sort(key=lambda x: x['pls_points'], reverse=True)

# 4. Берем топ-6 результатов
top_scores = pls_scores[:6]

if not top_scores:
return None

# 5. Рассчитываем средний балл
average_pls = sum(score['pls_points'] for score in top_scores) / len(top_scores)

return {
'overall_pls_score': round(average_pls, 1),
'performance_level': self._get_performance_level(int(average_pls)),
'top_scores': top_scores,
'total_activities_analyzed': len(pls_scores),
}


Этот подход показался мне наиболее сбалансированным. Он отражает реальный потенциал, но при этом достаточно стабилен и не скачет от одной неудачной тренировки.


Таблица лучших результатов, основа для расчета общего балла.
Трудности на пути соло-разработчика


Когда ты один на один с проектом, проблемы приобретают особый вкус.


  1. Подбор коэффициентов. Самым сложным было найти "правильные" цифры для SUPER_ATHLETE_PARAMS и "штрафов" за рельеф. Я потратил несколько вечеров, сравнивая свои результаты с результатами профессионалов на известных сегментах Strava. Это была настоящая исследовательская работа, чтобы добиться адекватной и правдоподобной оценки.


  2. "Грязные" данные из API. Не у всех активностей есть данные о наборе высоты. Иногда GPS-трек может быть неточным. Пришлось заложить в код множество проверок вроде details.get('total_elevation_gain', 0), чтобы одна "сломанная" тренировка не обрушила весь анализ пользователя.


  3. Сделать фичу мотивирующей. Изначально шкала была слишком жесткой, и большинство пользователей получали бы "обидные" 300-400 баллов. Я понял, что продукт должен вдохновлять. Поэтому я доработал формулу и добавил текстовые уровни ('Elite', 'Excellent', 'Good'), а также тот самый блок с рекомендациями, чтобы система не просто ставила оценку, а подсказывала, как стать лучше.
Что дальше? Планы развития


PeakLine Score — это только начало. У меня в планах:


  • Учет большего числа факторов: Добавить в формулу влияние погоды (ветер, температура), данные о которой я уже получаю для детального анализа активности.


  • Динамика во времени: Строить график изменения PLS, чтобы пользователь видел свой прогресс наглядно.


  • Разделение по видам спорта: Создать отдельные рейтинги для бега и велоспорта, так как сравнивать их напрямую некорректно.
Заключение и призыв к действию


Создание своей собственной аналитической метрики — это увлекательнейшее путешествие на стыке программирования и предметной области (в моем случае — спорта). PeakLine Score — это моя первая попытка сделать что-то подобное, и я уверен, что формулу еще можно и нужно улучшать.

И здесь мне очень нужна ваша помощь.

Призыв №1: Оцените идею. Как вам сама концепция? Какие факторы вы бы добавили в расчет? Может, у вас есть идеи, как сделать оценку еще точнее и полезнее?

Призыв №2: Поделитесь своим мнением. Мне очень важно услышать ваше мнение о проекте The Peakline в целом. Нужны ли такие нишевые инструменты для спортсменов-любителей?

Спасибо, что дочитали эту длинную статью. Буду рад любому фидбэку в комментариях!
 
Сверху Снизу