Меню

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

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

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

Звичайно, є VO2max, є Fitness & Freshness від Strava, є GFR у Garmin. Але мені завжди хотілося створити щось своє. Метрику, яку я б розумів від і до, від першої стрічки коду до фінальної цифри на екрані.

Так в рамках мого pet-проекту The Peakline цієї платформи для аутдор-ентузіастів народилася ідея PeakLine Score (PLS). Це моя спроба створити комплексну оцінку продуктивності, яка враховує не тільки твою швидкість, але й складність маршруту, по якому ти їхав.

У цій статті я розповім, як влаштований цей механізм "під капотом". Ми зануримося в логіку на Python, подивимося, як вона інтегрується в загальний аналізатор активностей і як результат подається користувачеві в простому і зрозумілому вигляді.

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

Запрошую вас вивчити сам проект:

  1. Основний проект The Peakline: https://www.thepeakline.com/
  2. Репозиторій на GitHub: https://github.com/CyberScoper/peakline-peakline-score
  3. Демо сторінки зі Score: https://www.thepeakline.com/peakline-score (необхідна авторизація в акаунт Strava)

А тепер — до технічних деталей.

Загальний вигляд сторінки 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 — це тільки початок. У мене в планах:

  1. Врахування більшої кількості факторів: Додати в формулу вплив погоди (вітер, температура), дані про яку я вже отримую для детального аналізу активності.
  2. Динаміка в часі: Будувати графік зміни PLS, щоб користувач бачив свій прогрес наочно.
  3. Розділення за видами спорту: Створити окремі рейтинги для бігу і велоспорту, так як порівнювати їх безпосередньо некоректно.

Висновок і заклик до дій

Створення своєї власної аналітичної метрики — це захоплююча подорож на стику програмування і предметної області (в моєму випадку — спорту). PeakLine Score — це моя перша спроба зробити щось подібне, і я впевнений, що формулу ще можна і потрібно покращувати.

І тут мені дуже потрібна ваша допомога.

Заклик №1: Оцініть ідею. Як вам сама концепція? Які фактори ви б додали в розрахунок? Може, у вас є ідеї, як зробити оцінку ще точнішою і кориснішою?

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

Дякую, що дочитали цю довгу статтю. Буду радий будь-якому фідбеку в коментарях!

Коментарі