Всем привет! Я, как и многие здесь, не только разработчик, но и человек, увлеченный циклическими видами спорта. Я обожаю копаться в данных своих тренировок из 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 в целом. Нужны ли такие нишевые инструменты для спортсменов-любителей?
Спасибо, что дочитали эту длинную статью. Буду рад любому фидбэку в комментариях!
Комментарии (3)
cyberscoper Автор
31.08.2025 09:37Идея сравнения с супер-атлетом это не попытка демотивировать а попытка создать объективный, неподвижный эталон (по аналогии с нулем на шкале Цельсия).
Сравнение со своими прошлыми результатами это отличная вещь, но у нее есть недостаток: это "плавающий" ориентир (Если бы условно заболеете, то Форма упадет и сравнивать с той тренировкой, где вы условно после болезни куда сложнее.)
Коэффициенты действительно грубые и негибкие. Это было сознательное решение для первой версии (напомню что это mvp и этот компонент, как и весь проект, делается в соло)Борьба с нестабильностью GPX не моя Я живу в тех краях, где в с этим нет проблем. Из чего вытекает, что я не могу На должном уровне реализовать - фиксер https://habr.com/ru/articles/920402/ тем временм он имеется
Создавать свой трекер и всю эту инфраструктуру с нуля не брав данные для тренировок через удобные api strava усложнит мне жизнь колоссально - Опять же я не делаю заменитель стравы, а дополнение.
В дополнение. Статья была отправлена в раздел бэкенда не аналитики данных)
EmCreatore
31.08.2025 09:37Идея сравнения с супер-атлетом это не попытка демотивировать а попытка создать объективный, неподвижный эталон (по аналогии с нулем на шкале Цельсия).
Это как если бы высоту отмеряли не от уровня моря, а от вершины Эвереста. Но вершина Эвереста заполнена мусором.И эта гора мусора на ней растет.
Уровень моря здесь - собственное состояние. Но его не так просто понять если не прибегать к стрессовым нагрузкам, которые сами по себе могут ухудшить состояние.
Тут лишний раз доказываетя сомнительность нормального фитнес-анализатора без доступа к сырым данным и всему комплексу сенсоров.
EmCreatore
Много написано про код, но мало про смысл.
Какой смысл сравнивать свои результаты с результами генно-модифицированных атлетов, условно говоря?
Со своими результатами, да, интересно.
Но где-то в предыдущих статьях писали, что пользователям нужно больше аналитики, а сейчас вдруг всплывает один бал, и рассчитан как-то уж очень топорно, на грубых, не гибких коэффициентах.
Потом борьба со сбоями GPS как-то нераскрыта.
А ведь еще хуже сбои сенсоров пульса. А это постоянно происходит, и чаще чем с GPS.
А где инерциальная навигация?
А где учет питания? Водного баланса? Где качество воздуха? Где магнитные бури?
В таком приложении интересна была бы предсказательная функция. Но как увеличить точность предсказания без этих факторов?
И тут опять всплывает мотивация. Мотивация не от скругленных окошек и надписей. Мотивация от ощущения, что у тебя все под контролем и все достижимо. Достижимость доказывается оправдыванием прогнозов. Т.е. сбывшиеся прогнозы - самый мощный мотиватор.
И зачем сюда притягивать эту коммерческую Strava?