Add VAM (climbing velocity) metric and per-duration curve
Extract pipeline now computes two VAM metrics per activity (cycling, running, hiking, walking): - climbing_vam_mh: VAM on ascending segments only, using 30 s forward lookahead to classify climbing vs. flat/descent (stored in detail JSON) - vam_curve: [[duration_s, vam_mh], ...] best VAM per standard duration (60 s – 1 h), sliding window on 30 s smoothed elevation, only windows with ≥ 10 m net gain count (stored in summary + detail) Athlete JSON aggregates vam_curve across all activities (all_time, last_365d, last_90d), same structure as power_curve. Frontend: - ActivityDetail shows "Climbing VAM" stat (grouped with elevation) - AthleteView adds a "VAM Curve" tab that appears only when the athlete has climbing data; renders VamChart (new component, mirrors MmpChart) vam_curve stripped from combined global feed; kept in user year shards for season-based on-the-fly aggregation in VamChart. Requires bincio reextract to backfill existing activities.
This commit is contained in:
@@ -101,6 +101,8 @@ def write_activity(
|
||||
"mmp": metrics.mmp,
|
||||
"best_efforts": metrics.best_efforts,
|
||||
"best_climb_m": metrics.best_climb_m,
|
||||
"climbing_vam_mh": metrics.climbing_vam_mh,
|
||||
"vam_curve": metrics.vam_curve,
|
||||
"laps": [_serialise_lap(lap) for lap in activity.laps],
|
||||
"timeseries_url": f"activities/{activity_id}.timeseries.json" if timeseries else None,
|
||||
"source": source,
|
||||
@@ -257,6 +259,7 @@ def build_summary(
|
||||
"mmp": metrics.mmp,
|
||||
"best_efforts": metrics.best_efforts,
|
||||
"best_climb_m": metrics.best_climb_m,
|
||||
"vam_curve": metrics.vam_curve,
|
||||
"source": _infer_source(activity),
|
||||
"privacy": privacy,
|
||||
"detail_url": f"activities/{activity_id}.json",
|
||||
@@ -300,6 +303,25 @@ def write_athlete_json(summaries: list[dict], output_dir: Path, athlete_config:
|
||||
mmps_365 = [s["mmp"] for s in summaries if s.get("mmp") and _is_outdoor(s) and s["started_at"] >= cutoff_365]
|
||||
mmps_90 = [s["mmp"] for s in summaries if s.get("mmp") and _is_outdoor(s) and s["started_at"] >= cutoff_90]
|
||||
|
||||
# ── VAM curve aggregation ─────────────────────────────────────────────────
|
||||
|
||||
def _merge_vam_curves(vam_lists: list[list[list[int]]]) -> list[list[int]]:
|
||||
best: dict[int, int] = {}
|
||||
for vc in vam_lists:
|
||||
for d, v in vc:
|
||||
if d not in best or v > best[d]:
|
||||
best[d] = v
|
||||
return [[d, v] for d, v in sorted(best.items())]
|
||||
|
||||
_VAM_SPORTS = {"cycling", "running", "hiking", "walking"}
|
||||
|
||||
def _has_vam(s: dict) -> bool:
|
||||
return bool(s.get("vam_curve")) and s.get("sport") in _VAM_SPORTS and _is_outdoor(s)
|
||||
|
||||
all_vams = [s["vam_curve"] for s in summaries if _has_vam(s)]
|
||||
vams_365 = [s["vam_curve"] for s in summaries if _has_vam(s) and s["started_at"] >= cutoff_365]
|
||||
vams_90 = [s["vam_curve"] for s in summaries if _has_vam(s) and s["started_at"] >= cutoff_90]
|
||||
|
||||
# ── Personal records aggregation ──────────────────────────────────────────
|
||||
# records[sport][distance_km] = {time_s, activity_id, started_at, title}
|
||||
# best_climb[activity_id] = {climb_m, started_at, title}
|
||||
@@ -368,6 +390,11 @@ def write_athlete_json(summaries: list[dict], output_dir: Path, athlete_config:
|
||||
"last_365d": _merge_mmps(mmps_365) if mmps_365 else None,
|
||||
"last_90d": _merge_mmps(mmps_90) if mmps_90 else None,
|
||||
},
|
||||
"vam_curve": {
|
||||
"all_time": _merge_vam_curves(all_vams) if all_vams else None,
|
||||
"last_365d": _merge_vam_curves(vams_365) if vams_365 else None,
|
||||
"last_90d": _merge_vam_curves(vams_90) if vams_90 else None,
|
||||
},
|
||||
"records": {
|
||||
sport: _serialise_sport_records(records[sport])
|
||||
for sport in SPORTS
|
||||
|
||||
Reference in New Issue
Block a user