athlete page first draft

This commit is contained in:
Davide Scaini
2026-03-30 09:05:18 +02:00
parent 2a1493a3e5
commit ec6175b143
8 changed files with 594 additions and 3 deletions
+55
View File
@@ -6,10 +6,14 @@ Uses inline haversine rather than geopy.geodesic to keep the hot path fast.
import math
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from bincio.extract.models import DataPoint, ParsedActivity
# Standard MMP durations (seconds). Log-spaced so the curve looks good on a log-x axis.
MMP_DURATIONS_S = [1, 2, 5, 10, 15, 20, 30, 60, 120, 180, 300, 600, 1200, 1800, 3600]
# Speed below which we consider the athlete stopped (km/h)
_STOPPED_THRESHOLD_KMH = 1.0
_EARTH_R = 6_371_000.0 # metres
@@ -42,6 +46,7 @@ class ComputedMetrics:
bbox: Optional[tuple[float, float, float, float]] # min_lon, min_lat, max_lon, max_lat
start_latlng: Optional[tuple[float, float]]
end_latlng: Optional[tuple[float, float]]
mmp: Optional[list[list[int]]] # [[duration_s, avg_watts], ...] — None if no power data
def compute(activity: ParsedActivity) -> ComputedMetrics:
@@ -58,6 +63,7 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
max_pow = _max_nonnull([p.power_w for p in pts])
bbox = _bbox(pts)
start_ll, end_ll = _endpoints(pts)
mmp = compute_mmp(pts, activity.started_at)
return ComputedMetrics(
distance_m=distance_m,
@@ -75,9 +81,57 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
bbox=bbox,
start_latlng=start_ll,
end_latlng=end_ll,
mmp=mmp,
)
# ── mean maximal power ────────────────────────────────────────────────────────
def compute_mmp(pts: list[DataPoint], started_at: datetime) -> Optional[list[list[int]]]:
"""Compute Mean Maximal Power curve at the standard MMP_DURATIONS_S.
Builds a 1 Hz power series (same downsampling as timeseries.py), then uses
a O(n) sliding-window sum for each duration. Returns a list of
[duration_s, avg_watts] pairs (integers), or None when the activity has no
power data. Only durations shorter than the total activity are included.
"""
# 1 Hz downsample: at most one sample per second, skip sub-second duplicates.
# Seconds without a recorded sample are omitted (not zero-filled) so that
# paused-recording gaps don't silently lower power averages.
power_1hz: list[int] = []
last_t = -1
for p in pts:
t = int((p.timestamp - started_at).total_seconds())
if t < 0 or t == last_t:
continue
last_t = t
if p.power_w is not None:
power_1hz.append(p.power_w)
if len(power_1hz) < 2:
return None
n = len(power_1hz)
results: list[list[int]] = []
for d in MMP_DURATIONS_S:
if d > n:
break # activity shorter than this duration — stop (durations are sorted)
# Sliding window of exactly d samples = d seconds at 1 Hz.
window_sum = sum(power_1hz[:d])
best = window_sum
for i in range(1, n - d + 1):
window_sum += power_1hz[i + d - 1] - power_1hz[i - 1]
if window_sum > best:
best = window_sum
results.append([d, round(best / d)])
return results if results else None
# ── single-pass GPS stats ──────────────────────────────────────────────────────
# distance, moving time, avg speed, and max speed are all derived from the same
# per-segment loop, so we compute them in one pass instead of four.
@@ -209,4 +263,5 @@ def _empty() -> ComputedMetrics:
avg_hr_bpm=None, max_hr_bpm=None,
avg_cadence_rpm=None, avg_power_w=None, max_power_w=None,
bbox=None, start_latlng=None, end_latlng=None,
mmp=None,
)