NerdCorner VAM: filter short climbs, opacity-encode confidence, add climbing time to tooltip

- Exclude per-activity VAM contributions where climbing_time_s < 10 min; short
  punchy efforts don't represent aerobic fitness and were skewing monthly averages
- Store climbing_time_s alongside climbing_vam_mh in metrics, detail JSON, and
  summary JSON so the frontend has the data to reason about confidence
- Accumulate total climbing time per period; opacity scales from 0.25 (10 min,
  minimum threshold) to 1.0 (≥ 1 h) so thin-evidence months read as faint dots
- Render VAM as dots only (no lines) since each period is an independent average,
  not a cumulative — lines implied continuity that isn't there
- Tooltip now shows "1060 m/h · 38 min climbing"
This commit is contained in:
Davide Scaini
2026-05-17 10:13:39 +02:00
parent 7a44cbbef0
commit 766da0320b
4 changed files with 84 additions and 34 deletions
+12 -7
View File
@@ -65,6 +65,7 @@ class ComputedMetrics:
best_efforts: Optional[list[list[float]]]
best_climb_m: Optional[float] # max net elevation gain in one contiguous window (cycling only)
climbing_vam_mh: Optional[int] # average VAM on ascending segments only (m/h)
climbing_time_s: Optional[int] # total ascending seconds used to compute VAM
def compute(activity: ParsedActivity) -> ComputedMetrics:
@@ -84,7 +85,8 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
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)
climbing_vam_mh = compute_vam(pts, activity.started_at, activity.sport)
_vam = compute_vam(pts, activity.started_at, activity.sport)
climbing_vam_mh, climbing_time_s = _vam if _vam else (None, None)
return ComputedMetrics(
distance_m=distance_m,
@@ -107,6 +109,7 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
best_efforts=best_efforts,
best_climb_m=best_climb_m,
climbing_vam_mh=climbing_vam_mh,
climbing_time_s=climbing_time_s,
)
@@ -183,12 +186,13 @@ def _rolling_mean_ele(data: list[float], win: int) -> list[float]:
return result
def _vam_from_ele_1hz(ele_1hz: list[float]) -> Optional[int]:
def _vam_from_ele_1hz(ele_1hz: list[float]) -> Optional[tuple[int, int]]:
"""Climbing VAM from a dense 1 Hz elevation array.
Accumulates gain and time only on ascending seconds, identified by a 30 s
forward-lookahead on the smoothed elevation signal.
Returns climbing_vam_mh (m/h), or None when there is too little climbing data.
Returns (climbing_vam_mh, climbing_time_s), or None when there is too little
climbing data.
"""
n = len(ele_1hz)
if n < 60:
@@ -206,7 +210,7 @@ def _vam_from_ele_1hz(ele_1hz: list[float]) -> Optional[int]:
climbing_time += 1
if climbing_time >= 60 and climbing_gain >= 5.0:
return round(climbing_gain * 3600.0 / climbing_time)
return round(climbing_gain * 3600.0 / climbing_time), climbing_time
return None
@@ -233,11 +237,12 @@ def _build_ele_1hz(sparse: dict[int, Optional[float]]) -> Optional[list[float]]:
return [e if e is not None else first_valid for e in ele_raw]
def compute_vam(pts: list[DataPoint], started_at: datetime, sport: str) -> Optional[int]:
def compute_vam(pts: list[DataPoint], started_at: datetime, sport: str) -> Optional[tuple[int, int]]:
"""Compute average climbing VAM (m/h) from DataPoints.
Only computed for cycling, running, hiking, walking.
Returns None when the activity has insufficient climbing data.
Returns (climbing_vam_mh, climbing_time_s), or None when there is insufficient
climbing data.
"""
if sport not in _VAM_SPORTS:
return None
@@ -618,5 +623,5 @@ def _empty() -> ComputedMetrics:
avg_cadence_rpm=None, avg_power_w=None, np_power_w=None, max_power_w=None,
bbox=None, start_latlng=None, end_latlng=None,
mmp=None, best_efforts=None, best_climb_m=None,
climbing_vam_mh=None,
climbing_vam_mh=None, climbing_time_s=None,
)