personal records tab into athlete page
This commit is contained in:
+108
-1
@@ -14,6 +14,17 @@ 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]
|
||||
|
||||
# Standard best-effort distances (km) per sport.
|
||||
BEST_EFFORT_DISTANCES: dict[str, list[float]] = {
|
||||
"running": [0.4, 1.0, 1.609, 5.0, 10.0, 21.097, 42.195],
|
||||
"cycling": [5.0, 10.0, 20.0, 50.0, 100.0],
|
||||
"swimming": [0.1, 0.2, 0.5, 1.0, 2.0],
|
||||
"hiking": [], # no sliding-window records; aggregate from summaries only
|
||||
"walking": [],
|
||||
"skiing": [],
|
||||
"other": [],
|
||||
}
|
||||
|
||||
# Speed below which we consider the athlete stopped (km/h)
|
||||
_STOPPED_THRESHOLD_KMH = 1.0
|
||||
_EARTH_R = 6_371_000.0 # metres
|
||||
@@ -47,6 +58,9 @@ class ComputedMetrics:
|
||||
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
|
||||
# [[distance_km, time_s], ...] sorted by distance — None if sport has no distance targets
|
||||
best_efforts: Optional[list[list[float]]]
|
||||
best_climb_m: Optional[float] # max net elevation gain in one contiguous window (cycling only)
|
||||
|
||||
|
||||
def compute(activity: ParsedActivity) -> ComputedMetrics:
|
||||
@@ -64,6 +78,7 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
|
||||
bbox = _bbox(pts)
|
||||
start_ll, end_ll = _endpoints(pts)
|
||||
mmp = compute_mmp(pts, activity.started_at)
|
||||
best_efforts, best_climb_m = compute_best_efforts(pts, activity.started_at, activity.sport)
|
||||
|
||||
return ComputedMetrics(
|
||||
distance_m=distance_m,
|
||||
@@ -82,6 +97,8 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
|
||||
start_latlng=start_ll,
|
||||
end_latlng=end_ll,
|
||||
mmp=mmp,
|
||||
best_efforts=best_efforts,
|
||||
best_climb_m=best_climb_m,
|
||||
)
|
||||
|
||||
|
||||
@@ -132,6 +149,96 @@ def compute_mmp(pts: list[DataPoint], started_at: datetime) -> Optional[list[lis
|
||||
return results if results else None
|
||||
|
||||
|
||||
# ── best efforts & best climb ─────────────────────────────────────────────────
|
||||
|
||||
def compute_best_efforts(
|
||||
pts: list[DataPoint],
|
||||
started_at: datetime,
|
||||
sport: str,
|
||||
) -> tuple[Optional[list[list[float]]], Optional[float]]:
|
||||
"""Return (best_efforts, best_climb_m) for this activity.
|
||||
|
||||
best_efforts: [[distance_km, time_s], ...] — one entry per target distance
|
||||
where the activity was long enough to contain that effort.
|
||||
best_climb_m: maximum net elevation gain over any contiguous window (cycling).
|
||||
|
||||
Both use the same 1 Hz downsampled series as the timeseries writer.
|
||||
"""
|
||||
targets = BEST_EFFORT_DISTANCES.get(sport, [])
|
||||
|
||||
# Build 1 Hz speed (km/h) and elevation (m) arrays — same downsampling as timeseries.py
|
||||
speed_1hz: list[float] = []
|
||||
ele_1hz: list[Optional[float]] = []
|
||||
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
|
||||
speed_1hz.append(p.speed_kmh if p.speed_kmh is not None else 0.0)
|
||||
ele_1hz.append(p.elevation_m)
|
||||
|
||||
best_efforts: Optional[list[list[float]]] = None
|
||||
if targets and speed_1hz:
|
||||
results = []
|
||||
for d_km in targets:
|
||||
t_s = _fastest_time_for_distance(speed_1hz, d_km)
|
||||
if t_s is not None:
|
||||
results.append([d_km, t_s])
|
||||
best_efforts = results if results else None
|
||||
|
||||
best_climb_m: Optional[float] = None
|
||||
if sport == "cycling":
|
||||
best_climb_m = _best_climb(ele_1hz)
|
||||
|
||||
return best_efforts, best_climb_m
|
||||
|
||||
|
||||
def _fastest_time_for_distance(speed_1hz: list[float], target_km: float) -> Optional[int]:
|
||||
"""Minimum number of seconds to cover target_km using a two-pointer sliding window.
|
||||
|
||||
Each sample contributes speed_kmh / 3600 km (one second at that speed).
|
||||
Nulls/zeros extend the window without adding distance — naturally deprioritised.
|
||||
"""
|
||||
n = len(speed_1hz)
|
||||
left = 0
|
||||
window_dist = 0.0
|
||||
best_s: Optional[int] = None
|
||||
|
||||
for right in range(n):
|
||||
window_dist += speed_1hz[right] / 3600.0
|
||||
|
||||
# Shrink from the left while we still cover the target
|
||||
while window_dist >= target_km and left <= right:
|
||||
window_s = right - left + 1
|
||||
if best_s is None or window_s < best_s:
|
||||
best_s = window_s
|
||||
window_dist -= speed_1hz[left] / 3600.0
|
||||
left += 1
|
||||
|
||||
return best_s
|
||||
|
||||
|
||||
def _best_climb(ele_1hz: list[Optional[float]]) -> Optional[float]:
|
||||
"""Maximum net elevation gain over any contiguous window (Kadane's on deltas).
|
||||
|
||||
Ignores samples where elevation is None. Returns None if fewer than two
|
||||
valid elevation samples exist.
|
||||
"""
|
||||
valid = [e for e in ele_1hz if e is not None]
|
||||
if len(valid) < 2:
|
||||
return None
|
||||
|
||||
max_gain = 0.0
|
||||
current = 0.0
|
||||
for a, b in zip(valid, valid[1:]):
|
||||
current = max(0.0, current + (b - a))
|
||||
if current > max_gain:
|
||||
max_gain = current
|
||||
|
||||
return round(max_gain, 1) if max_gain > 0 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.
|
||||
@@ -263,5 +370,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,
|
||||
mmp=None, best_efforts=None, best_climb_m=None,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user