diff --git a/bincio/extract/metrics.py b/bincio/extract/metrics.py index 505ebb3..96f0487 100644 --- a/bincio/extract/metrics.py +++ b/bincio/extract/metrics.py @@ -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, ) diff --git a/bincio/extract/writer.py b/bincio/extract/writer.py index b12f238..592df8c 100644 --- a/bincio/extract/writer.py +++ b/bincio/extract/writer.py @@ -102,6 +102,7 @@ def write_activity( "best_efforts": metrics.best_efforts, "best_climb_m": metrics.best_climb_m, "climbing_vam_mh": metrics.climbing_vam_mh, + "climbing_time_s": metrics.climbing_time_s, "laps": [_serialise_lap(lap) for lap in activity.laps], "timeseries_url": f"activities/{activity_id}.timeseries.json" if timeseries else None, "source": source, @@ -259,6 +260,7 @@ def build_summary( "best_efforts": metrics.best_efforts, "best_climb_m": metrics.best_climb_m, "climbing_vam_mh": metrics.climbing_vam_mh, + "climbing_time_s": metrics.climbing_time_s, "source": _infer_source(activity), "privacy": privacy, "detail_url": f"activities/{activity_id}.json", diff --git a/site/src/components/NerdCorner.svelte b/site/src/components/NerdCorner.svelte index 0c436d2..5033e2b 100644 --- a/site/src/components/NerdCorner.svelte +++ b/site/src/components/NerdCorner.svelte @@ -56,14 +56,30 @@ const _now = new Date(); const _currentYear = _now.getFullYear(); + // Minimum climbing time per activity to count in the VAM chart (10 min). + const VAM_MIN_CLIMB_S = 600; + // Climbing time range for full confidence opacity (10 min → 1 h). + const VAM_OPACITY_MIN_S = 600; + const VAM_OPACITY_MAX_S = 3600; + + function vamOpacity(climbTime: number | undefined): number { + if (!climbTime) return 0.25; + const t = Math.min(1, Math.max(0, (climbTime - VAM_OPACITY_MIN_S) / (VAM_OPACITY_MAX_S - VAM_OPACITY_MIN_S))); + return 0.25 + t * 0.75; + } + function buildData(acts: ActivitySummary[], m: Metric, g: Granularity) { const curPeriod = g === 'week' ? weekOfYear(_now) : _now.getMonth() + 1; - const byYear = new Map>(); - const byYearCnt = new Map>(); // for VAM averaging + const byYear = new Map>(); + const byYearCnt = new Map>(); + const byYearClimbTime = new Map>(); for (const act of acts) { if (!act.started_at) continue; - if (m === 'vam' && act.climbing_vam_mh == null) continue; + if (m === 'vam') { + if (act.climbing_vam_mh == null) continue; + if ((act.climbing_time_s ?? 0) < VAM_MIN_CLIMB_S) continue; + } const d = new Date(act.started_at); const yr = d.getFullYear(); const per = g === 'week' ? weekOfYear(d) : d.getMonth() + 1; @@ -77,6 +93,11 @@ const ymc = byYearCnt.get(yr)!; ym.set(per, (ym.get(per) ?? 0) + val); ymc.set(per, (ymc.get(per) ?? 0) + 1); + if (m === 'vam') { + if (!byYearClimbTime.has(yr)) byYearClimbTime.set(yr, new Map()); + const yct = byYearClimbTime.get(yr)!; + yct.set(per, (yct.get(per) ?? 0) + (act.climbing_time_s ?? 0)); + } } // VAM: convert sums to averages @@ -91,13 +112,17 @@ const years = [...byYear.keys()].sort(); const maxPer = g === 'week' ? 52 : 12; - const rows: { year: string; period: number; value: number }[] = []; + const rows: { year: string; period: number; value: number; climbTime?: number }[] = []; for (const yr of years) { const pm = byYear.get(yr)!; + const ct = byYearClimbTime.get(yr); const limit = yr === _currentYear ? curPeriod : maxPer; for (let p = 1; p <= limit; p++) { - rows.push({ year: String(yr), period: p, value: pm.get(p) ?? 0 }); + const row: { year: string; period: number; value: number; climbTime?: number } = + { year: String(yr), period: p, value: pm.get(p) ?? 0 }; + if (ct?.has(p)) row.climbTime = ct.get(p); + rows.push(row); } } @@ -173,28 +198,45 @@ y: { label: yLabel, grid: true, zero: true }, color: { domain: colorDomain, range: colorRange, legend: !cumulative }, marks: [ - ...(pastRows.length ? [ - Plot.line(pastRows, { - x: 'period', y: 'value', stroke: 'year', - strokeWidth: 1.5, curve: 'monotone-x', - }), - Plot.dot(pastRows, { - x: 'period', y: 'value', fill: 'year', r: 2, fillOpacity: 0, - tip: true, - title: (d: any) => `${d.year} · ${xLabel} ${d.period}\n${fmt(d.value)}`, - }), - ] : []), - ...(curRows.length ? [ - Plot.line(curRows, { - x: 'period', y: 'value', stroke: 'year', - strokeWidth: 2.5, curve: 'monotone-x', - }), - Plot.dot(curRows, { - x: 'period', y: 'value', fill: 'year', r: 2, fillOpacity: 0, - tip: true, - title: (d: any) => `${d.year} · ${xLabel} ${d.period}\n${fmt(d.value)}`, - }), - ] : []), + ...(m === 'vam' ? (() => { + // VAM: dots only, no lines — opacity encodes total climbing time in period. + const vamRows = [...pastRows, ...curRows].filter((r: any) => r.value > 0); + return vamRows.length ? [ + Plot.dot(vamRows, { + x: 'period', y: 'value', fill: 'year', + r: 5, + fillOpacity: (d: any) => vamOpacity(d.climbTime), + tip: true, + title: (d: any) => { + const mins = d.climbTime ? `${Math.round(d.climbTime / 60)} min climbing` : ''; + return `${d.year} · ${xLabel} ${d.period}\n${fmt(d.value)}${mins ? '\n' + mins : ''}`; + }, + }), + ] : []; + })() : [ + ...(pastRows.length ? [ + Plot.line(pastRows, { + x: 'period', y: 'value', stroke: 'year', + strokeWidth: 1.5, curve: 'monotone-x', + }), + Plot.dot(pastRows, { + x: 'period', y: 'value', fill: 'year', r: 2, fillOpacity: 0, + tip: true, + title: (d: any) => `${d.year} · ${xLabel} ${d.period}\n${fmt(d.value)}`, + }), + ] : []), + ...(curRows.length ? [ + Plot.line(curRows, { + x: 'period', y: 'value', stroke: 'year', + strokeWidth: 2.5, curve: 'monotone-x', + }), + Plot.dot(curRows, { + x: 'period', y: 'value', fill: 'year', r: 2, fillOpacity: 0, + tip: true, + title: (d: any) => `${d.year} · ${xLabel} ${d.period}\n${fmt(d.value)}`, + }), + ] : []), + ]), ], }); el.appendChild(chart); diff --git a/site/src/lib/types.ts b/site/src/lib/types.ts index 9f1f421..2dc1c32 100644 --- a/site/src/lib/types.ts +++ b/site/src/lib/types.ts @@ -67,6 +67,7 @@ export interface ActivitySummary { avg_power_w: number | null; mmp: MmpCurve | null; climbing_vam_mh?: number | null; + climbing_time_s?: number | null; source: string | null; privacy: Privacy; detail_url: string | null;