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

от автора

Всем привет! Я, как и многие здесь, не только разработчик, но и человек, увлеченный циклическими видами спорта. Я обожаю копаться в данных своих тренировок из Strava: анализировать мощность, пульсовые зоны, темп. Но мне всегда не хватало одной вещи — единой, понятной и, главное, прозрачной метрики, которая бы отвечала на простой вопрос: «А насколько я сейчас в хорошей форме?».

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

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

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

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

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

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

Общий вид страницы PeakLine Score

Общий вид страницы 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 в целом. Нужны ли такие нишевые инструменты для спортсменов-любителей?

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


ссылка на оригинал статьи https://habr.com/ru/articles/946632/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *