athlete page first draft
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user