Всем привет! Я, как и многие здесь, не только разработчик, но и человек, увлеченный циклическими видами спорта. Я обожаю копаться в данных своих тренировок из Strava: анализировать мощность, пульсовые зоны, темп. Но мне всегда не хватало одной вещи — единой, понятной и, главное, прозрачной метрики, которая бы отвечала на простой вопрос: «А насколько я сейчас в хорошей форме?».
Конечно, есть VO2max, есть Fitness & Freshness от Strava, есть GFR у Garmin. Но мне всегда хотелось создать что-то свое. Метрику, которую я бы понимал от и до, от первой строчки кода до финальной цифры на экране.
Так в рамках моего pet-проекта The Peakline это платформы для аутдор-энтузиастов родилась идея PeakLine Score (PLS). Это моя попытка создать комплексную оценку производительности, которая учитывает не только твою скорость, но и сложность маршрута, по которому ты ехал.
В этой статье я расскажу, как устроен этот механизм «под капотом». Мы погрузимся в логику на Python, посмотрим, как она интегрируется в общий анализатор активностей и как результат подается пользователю в простом и понятном виде.
Важный дисклеймер: Весь проект, от идеи до кода, я делаю один в свободное от основной работы время. Он далек от идеала, и я буду очень благодарен за конструктивную критику и свежий взгляд.
Приглашаю вас изучить сам проект:
-
Основной проект The Peakline: https://www.thepeakline.com/
-
Репозиторий на GitHub: https://github.com/CyberScoper/peakline-peakline-score
-
Демо страницы со Score: https://www.thepeakline.com/peakline-score (необходима авторизация в аккаунт Strava)
А теперь — к техническим деталям.
Архитектура: Python-мозг и HTML-лицо
Когда ты делаешь проект в одиночку, простота и четкое разделение ответственности — ключ к выживанию. Я не стал усложнять и построил архитектуру по классической схеме: бэкенд на Python (Flask) отвечает за всю логику, а фронтенд — это легковесный HTML, отрисованный с помощью серверного шаблонизатора Jinja2.
Чтобы не утонуть в коде, я разделил логику PLS на три четких, независимых компонента:
/folder1/ ├── activity_analyzer.py # "Интегратор" └── peakline_score.py # "Мозг" - вся математика здесь /folder1/ └── peakline_score.html # "Лицо" - представление данных
-
peakline_score.py(Мозг): Чистый Python-модуль, ядро всей системы. Он ничего не знает о Strava, веб-серверах или базах данных. Его задача — принять на вход числовые данные об активности и вернуть числовой балл. Максимально изолированный и тестируемый. -
activity_analyzer.py(Интегратор): Этот модуль — дирижер оркестра. Он забирает «сырые» данные из Strava, проводит их через разные анализаторы (расчет зон мощности, пульса) и, в том числе, передает их вpeakline_score.pyдля расчета PLS. Его задача — элегантно встроить новую фичу в существующий конвейер. -
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 < 600 %} <p><strong>{{ T.pls_recommendations_tips_title }}</strong></p> <ul>...</ul> {% elif pls_data.overall_pls_score < 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 состоит из пяти простых шагов:
-
Анализ: Скрипт перебирает все доступные тренировки пользователя.
-
Расчет: Для каждой вычисляется индивидуальный PLS-балл.
-
Сортировка: Все результаты сортируются по убыванию — от лучших к худшим.
-
Выборка: Из всего списка берутся только 6 лучших результатов. Это позволяет отсеять неудачные или восстановительные тренировки, которые не отражают пиковую форму.
-
Усреднение: Итоговый 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), }
Этот подход показался мне наиболее сбалансированным. Он отражает реальный потенциал, но при этом достаточно стабилен и не скачет от одной неудачной тренировки.
Трудности на пути соло-разработчика
Когда ты один на один с проектом, проблемы приобретают особый вкус.
-
Подбор коэффициентов. Самым сложным было найти «правильные» цифры для
SUPER_ATHLETE_PARAMSи «штрафов» за рельеф. Я потратил несколько вечеров, сравнивая свои результаты с результатами профессионалов на известных сегментах Strava. Это была настоящая исследовательская работа, чтобы добиться адекватной и правдоподобной оценки. -
«Грязные» данные из API. Не у всех активностей есть данные о наборе высоты. Иногда GPS-трек может быть неточным. Пришлось заложить в код множество проверок вроде
details.get('total_elevation_gain', 0), чтобы одна «сломанная» тренировка не обрушила весь анализ пользователя. -
Сделать фичу мотивирующей. Изначально шкала была слишком жесткой, и большинство пользователей получали бы «обидные» 300-400 баллов. Я понял, что продукт должен вдохновлять. Поэтому я доработал формулу и добавил текстовые уровни (
'Elite','Excellent','Good'), а также тот самый блок с рекомендациями, чтобы система не просто ставила оценку, а подсказывала, как стать лучше.
Что дальше? Планы развития
PeakLine Score — это только начало. У меня в планах:
-
Учет большего числа факторов: Добавить в формулу влияние погоды (ветер, температура), данные о которой я уже получаю для детального анализа активности.
-
Динамика во времени: Строить график изменения PLS, чтобы пользователь видел свой прогресс наглядно.
-
Разделение по видам спорта: Создать отдельные рейтинги для бега и велоспорта, так как сравнивать их напрямую некорректно.
Заключение и призыв к действию
Создание своей собственной аналитической метрики — это увлекательнейшее путешествие на стыке программирования и предметной области (в моем случае — спорта). PeakLine Score — это моя первая попытка сделать что-то подобное, и я уверен, что формулу еще можно и нужно улучшать.
И здесь мне очень нужна ваша помощь.
Призыв №1: Оцените идею. Как вам сама концепция? Какие факторы вы бы добавили в расчет? Может, у вас есть идеи, как сделать оценку еще точнее и полезнее?
Призыв №2: Поделитесь своим мнением. Мне очень важно услышать ваше мнение о проекте The Peakline в целом. Нужны ли такие нишевые инструменты для спортсменов-любителей?
Спасибо, что дочитали эту длинную статью. Буду рад любому фидбэку в комментариях!
ссылка на оригинал статьи https://habr.com/ru/articles/946632/
Добавить комментарий