diff --git a/bincio/extract/metrics.py b/bincio/extract/metrics.py index 364cba1..d464321 100644 --- a/bincio/extract/metrics.py +++ b/bincio/extract/metrics.py @@ -14,6 +14,11 @@ 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] +# VAM curve durations — start at 60 s (shorter windows are too noisy for elevation data). +VAM_DURATIONS_S = [60, 120, 180, 300, 600, 1200, 1800, 3600] +_VAM_SPORTS = frozenset({"cycling", "running", "hiking", "walking"}) +_MIN_CLIMB_GAIN_M = 10.0 # minimum net gain in a window for VAM to be meaningful + # 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], @@ -62,6 +67,8 @@ class ComputedMetrics: # [[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) + climbing_vam_mh: Optional[int] # VAM on ascending segments only (m/h) + vam_curve: Optional[list[list[int]]] # [[duration_s, vam_mh], ...] def compute(activity: ParsedActivity) -> ComputedMetrics: @@ -81,6 +88,7 @@ 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, vam_curve = compute_vam(pts, activity.started_at, activity.sport) return ComputedMetrics( distance_m=distance_m, @@ -102,6 +110,8 @@ def compute(activity: ParsedActivity) -> ComputedMetrics: mmp=mmp, best_efforts=best_efforts, best_climb_m=best_climb_m, + climbing_vam_mh=climbing_vam_mh, + vam_curve=vam_curve, ) @@ -161,6 +171,114 @@ def compute_mmp(pts: list[DataPoint], started_at: datetime) -> Optional[list[lis return results if results else None +# ── VAM (Velocità Ascensionale Media) ──────────────────────────────────────── + +def _rolling_mean_ele(data: list[float], win: int) -> list[float]: + """O(n) rolling mean via prefix sums.""" + n = len(data) + prefix = [0.0] * (n + 1) + for i, v in enumerate(data): + prefix[i + 1] = prefix[i] + v + half = win // 2 + result = [] + for i in range(n): + lo = max(0, i - half) + hi = min(n, i + half + 1) + result.append((prefix[hi] - prefix[lo]) / (hi - lo)) + return result + + +def compute_vam( + pts: list[DataPoint], + started_at: datetime, + sport: str, +) -> tuple[Optional[int], Optional[list[list[int]]]]: + """Compute climbing VAM and VAM duration curve. + + Returns (climbing_vam_mh, vam_curve). + climbing_vam_mh: VAM on ascending segments only (m/h), or None. + vam_curve: [[duration_s, vam_mh], ...] best VAM per standard duration, or None. + Only computed for cycling, running, hiking, walking. + """ + if sport not in _VAM_SPORTS: + return None, None + + # Build dense 1 Hz elevation array, forward-filling gaps + sparse: dict[int, 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 + sparse[t] = p.elevation_m + last_t = t + + if not sparse: + return None, None + + t_min = min(sparse) + t_max = max(sparse) + if t_max - t_min > 7 * 24 * 3600: + return None, None + + ele_raw: list[Optional[float]] = [] + last_known: Optional[float] = None + for t in range(t_min, t_max + 1): + v = sparse.get(t) + if v is not None: + last_known = v + ele_raw.append(last_known) + + if sum(1 for e in ele_raw if e is not None) < 60: + return None, None + + first_valid = next((e for e in ele_raw if e is not None), None) + if first_valid is None: + return None, None + ele_1hz: list[float] = [e if e is not None else first_valid for e in ele_raw] + + n = len(ele_1hz) + ele_smooth = _rolling_mean_ele(ele_1hz, 30) + + # VAM curve: sliding window per duration, only windows with net gain above threshold + vam_results: list[list[int]] = [] + for d in VAM_DURATIONS_S: + if d >= n: + break + best_vam: Optional[float] = None + for i in range(n - d): + net_gain = ele_smooth[i + d] - ele_smooth[i] + if net_gain < _MIN_CLIMB_GAIN_M: + continue + vam = net_gain * 3600.0 / d + if best_vam is None or vam > best_vam: + best_vam = vam + if best_vam is not None: + vam_results.append([d, round(best_vam)]) + vam_curve: Optional[list[list[int]]] = vam_results if vam_results else None + + # Climbing VAM: accumulate gain and time only on ascending seconds. + # A second is climbing if the 30 s forward elevation gain exceeds 2 m + # (roughly 1 % gradient at 7 km/h). + _LOOK = 30 + _THRESH = 2.0 + climbing_gain = 0.0 + climbing_time = 0 + for i in range(n - 1): + look = min(i + _LOOK, n - 1) + if ele_smooth[look] - ele_smooth[i] >= _THRESH: + inst = ele_smooth[i + 1] - ele_smooth[i] + if inst > 0: + climbing_gain += inst + climbing_time += 1 + + climbing_vam_mh: Optional[int] = None + if climbing_time >= 60 and climbing_gain >= 5.0: + climbing_vam_mh = round(climbing_gain * 3600.0 / climbing_time) + + return climbing_vam_mh, vam_curve + + # ── best efforts & best climb ───────────────────────────────────────────────── def compute_best_efforts( @@ -524,4 +642,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, vam_curve=None, ) diff --git a/bincio/extract/writer.py b/bincio/extract/writer.py index 3a71092..568c7e2 100644 --- a/bincio/extract/writer.py +++ b/bincio/extract/writer.py @@ -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 diff --git a/bincio/render/cli.py b/bincio/render/cli.py index 0b4f41a..a4b75d9 100644 --- a/bincio/render/cli.py +++ b/bincio/render/cli.py @@ -116,7 +116,7 @@ def _rebuild_athlete_json(data: Path, handle: str | None = None) -> None: from bincio.render.merge import parse_sidecar, _apply_sidecar_summary targets = [data / handle] if handle else _user_dirs(data) - _COMPUTED = {"bas_version", "generated_at", "power_curve", "records", "best_climbs"} + _COMPUTED = {"bas_version", "generated_at", "power_curve", "vam_curve", "records", "best_climbs"} for user_dir in targets: index_path = user_dir / "index.json" if not index_path.exists(): diff --git a/bincio/render/merge.py b/bincio/render/merge.py index e4c0b52..ca046f4 100644 --- a/bincio/render/merge.py +++ b/bincio/render/merge.py @@ -421,7 +421,7 @@ FEED_PAGE_SIZE = 50 # Extra fields stripped from the combined feed — preview_coords is the biggest # contributor (~24% of shard size) but the feed cards need it for thumbnails, # so we keep it. mmp is never displayed in feed cards. -_COMBINED_FEED_STRIP = _FEED_STRIP | {"mmp"} +_COMBINED_FEED_STRIP = _FEED_STRIP | {"mmp", "vam_curve"} def write_combined_feed(data_dir: Path) -> int: diff --git a/site/src/components/ActivityDetail.svelte b/site/src/components/ActivityDetail.svelte index 472ff07..e29bb86 100644 --- a/site/src/components/ActivityDetail.svelte +++ b/site/src/components/ActivityDetail.svelte @@ -183,6 +183,9 @@ stat('Distance', formatDistance(activity.distance_m)), stat('Moving time', formatDuration(activity.moving_time_s ?? activity.duration_s)), stat('Elevation ↑', formatElevation(activity.elevation_gain_m), 'elevation'), + ...(detail?.climbing_vam_mh != null ? [ + stat('Climbing VAM', `${detail.climbing_vam_mh.toLocaleString()} m/h`, 'elevation'), + ] : []), stat('Avg speed', formatSpeed(activity.avg_speed_kmh), 'speed'), stat('Max speed', formatSpeed(activity.max_speed_kmh), 'speed'), stat('Avg HR', activity.avg_hr_bpm ? `${activity.avg_hr_bpm} bpm` : '—', 'heart_rate'), diff --git a/site/src/components/AthleteView.svelte b/site/src/components/AthleteView.svelte index 0bcb8bf..4b96755 100644 --- a/site/src/components/AthleteView.svelte +++ b/site/src/components/AthleteView.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import type { AthleteJson, BASIndex, ActivitySummary } from '../lib/types'; import MmpChart from './MmpChart.svelte'; + import VamChart from './VamChart.svelte'; import RecordsView from './RecordsView.svelte'; import AthleteDrawer from './AthleteDrawer.svelte'; import Explore from './Explore.svelte'; @@ -19,12 +20,13 @@ let athlete: AthleteJson | null = null; let activities: ActivitySummary[] = []; + let vamActivities: ActivitySummary[] = []; let allActivities: ActivitySummary[] = []; let loading = true; let error: string | null = null; let drawerOpen = false; - type Tab = 'power' | 'records' | 'segments' | 'profile' | 'explore' | 'nerd'; + type Tab = 'power' | 'vam' | 'records' | 'segments' | 'profile' | 'explore' | 'nerd'; let activeTab: Tab = 'power'; let mounted = false; let isOwner = false; @@ -94,7 +96,7 @@ isOwner = (e as CustomEvent).detail === handle; }, { once: true }); } - const TABS: Tab[] = ['power', 'records', 'segments', 'profile', 'explore', 'nerd']; + const TABS: Tab[] = ['power', 'vam', 'records', 'segments', 'profile', 'explore', 'nerd']; const rawTab = new URLSearchParams(window.location.search).get('tab'); const resolved = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : 'power'; activeTab = (resolved === 'explore' && !isOwner) ? 'power' : resolved; @@ -131,6 +133,7 @@ athlete = resolvedAthlete; allActivities = index.activities.filter(a => !isUnlisted(a.privacy)); activities = allActivities.filter(a => a.mmp); + vamActivities = allActivities.filter(a => a.vam_curve); } catch (e: any) { error = e.message; } finally { @@ -156,15 +159,19 @@ return hi >= 900 ? `${lo}+ bpm` : `${lo}–${hi} bpm`; } - const ALL_TABS: { key: Tab; label: string; ownerOnly?: boolean }[] = [ + const ALL_TABS: { key: Tab; label: string; ownerOnly?: boolean; requiresVam?: boolean }[] = [ { key: 'power', label: 'Power Curve' }, + { key: 'vam', label: 'VAM Curve', requiresVam: true }, { key: 'records', label: 'Records' }, { key: 'segments', label: 'Segments' }, { key: 'profile', label: 'Profile' }, { key: 'explore', label: 'Explore', ownerOnly: true }, { key: 'nerd', label: 'Nerd Corner', ownerOnly: true }, ]; - $: TABS = ALL_TABS.filter(t => !t.ownerOnly || isOwner); + $: TABS = ALL_TABS.filter(t => + (!t.ownerOnly || isOwner) && + (!t.requiresVam || athlete?.vam_curve?.all_time != null) + ); {#if loading} @@ -216,6 +223,16 @@

No power data found. Make sure your activities include power meter data.

{/if} + + {:else if activeTab === 'vam'} + {#if athlete.vam_curve?.all_time} +
+ +
+ {:else} +

No climbing data found.

+ {/if} + {:else if activeTab === 'records'} diff --git a/site/src/components/VamChart.svelte b/site/src/components/VamChart.svelte new file mode 100644 index 0000000..468b6a4 --- /dev/null +++ b/site/src/components/VamChart.svelte @@ -0,0 +1,188 @@ + + + + +
+ {#each allRangeKeys as key, i} + {@const active = selectedRanges.has(key)} + {@const color = curveColor(key, i)} + + {/each} +
+ +
+ +{#if !plotData.length} +

No VAM data for the selected range.

+{/if} diff --git a/site/src/lib/types.ts b/site/src/lib/types.ts index 212e456..bf82172 100644 --- a/site/src/lib/types.ts +++ b/site/src/lib/types.ts @@ -36,10 +36,17 @@ export interface BestClimb { title: string; } +export interface AthleteVamCurve { + all_time: MmpCurve | null; + last_365d: MmpCurve | null; + last_90d: MmpCurve | null; +} + export interface AthleteJson { bas_version: string; generated_at: string; power_curve: AthletePowerCurve; + vam_curve?: AthleteVamCurve | null; records?: Record>; best_climbs?: BestClimb[]; max_hr?: number; @@ -66,6 +73,7 @@ export interface ActivitySummary { avg_cadence_rpm: number | null; avg_power_w: number | null; mmp: MmpCurve | null; + vam_curve?: MmpCurve | null; source: string | null; privacy: Privacy; detail_url: string | null; @@ -122,6 +130,7 @@ export interface ActivityDetail extends Omit