Всем привет! Я, как и многие здесь, не только разработчик, но и человек, увлеченный циклическими видами спорта. Я обожаю копаться в данных своих тренировок из 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 в целом. Нужны ли такие нишевые инструменты для спортсменов-любителей?

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

Комментарии (3)


  1. EmCreatore
    31.08.2025 09:37

    Много написано про код, но мало про смысл.
    Какой смысл сравнивать свои результаты с результами генно-модифицированных атлетов, условно говоря?
    Со своими результатами, да, интересно.
    Но где-то в предыдущих статьях писали, что пользователям нужно больше аналитики, а сейчас вдруг всплывает один бал, и рассчитан как-то уж очень топорно, на грубых, не гибких коэффициентах.
    Потом борьба со сбоями GPS как-то нераскрыта.
    А ведь еще хуже сбои сенсоров пульса. А это постоянно происходит, и чаще чем с GPS.
    А где инерциальная навигация?
    А где учет питания? Водного баланса? Где качество воздуха? Где магнитные бури?
    В таком приложении интересна была бы предсказательная функция. Но как увеличить точность предсказания без этих факторов?
    И тут опять всплывает мотивация. Мотивация не от скругленных окошек и надписей. Мотивация от ощущения, что у тебя все под контролем и все достижимо. Достижимость доказывается оправдыванием прогнозов. Т.е. сбывшиеся прогнозы - самый мощный мотиватор.

    И зачем сюда притягивать эту коммерческую Strava?



  1. cyberscoper Автор
    31.08.2025 09:37

    Идея сравнения с супер-атлетом это не попытка демотивировать а попытка создать объективный, неподвижный эталон (по аналогии с нулем на шкале Цельсия).

    Сравнение со своими прошлыми результатами это отличная вещь, но у нее есть недостаток: это "плавающий" ориентир (Если бы условно заболеете, то Форма упадет и сравнивать с той тренировкой, где вы условно после болезни куда сложнее.)
    Коэффициенты действительно грубые и негибкие. Это было сознательное решение для первой версии (напомню что это mvp и этот компонент, как и весь проект, делается в соло)

    Борьба с нестабильностью GPX не моя Я живу в тех краях, где в с этим нет проблем. Из чего вытекает, что я не могу На должном уровне реализовать - фиксер https://habr.com/ru/articles/920402/ тем временм он имеется

    Создавать свой трекер и всю эту инфраструктуру с нуля не брав данные для тренировок через удобные api strava усложнит мне жизнь колоссально - Опять же я не делаю заменитель стравы, а дополнение.


    В дополнение. Статья была отправлена в раздел бэкенда не аналитики данных)


  1. EmCreatore
    31.08.2025 09:37

    Идея сравнения с супер-атлетом это не попытка демотивировать а попытка создать объективный, неподвижный эталон (по аналогии с нулем на шкале Цельсия).

    Это как если бы высоту отмеряли не от уровня моря, а от вершины Эвереста. Но вершина Эвереста заполнена мусором.И эта гора мусора на ней растет.
    Уровень моря здесь - собственное состояние. Но его не так просто понять если не прибегать к стрессовым нагрузкам, которые сами по себе могут ухудшить состояние.

    Тут лишний раз доказываетя сомнительность нормального фитнес-анализатора без доступа к сырым данным и всему комплексу сенсоров.