diff --git a/bincio/extract/metrics.py b/bincio/extract/metrics.py index 280b8ab..505ebb3 100644 --- a/bincio/extract/metrics.py +++ b/bincio/extract/metrics.py @@ -14,10 +14,7 @@ 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]] = { @@ -67,8 +64,7 @@ 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], ...] + climbing_vam_mh: Optional[int] # average VAM on ascending segments only (m/h) def compute(activity: ParsedActivity) -> ComputedMetrics: @@ -88,7 +84,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) + climbing_vam_mh = compute_vam(pts, activity.started_at, activity.sport) return ComputedMetrics( distance_m=distance_m, @@ -111,7 +107,6 @@ def compute(activity: ParsedActivity) -> ComputedMetrics: best_efforts=best_efforts, best_climb_m=best_climb_m, climbing_vam_mh=climbing_vam_mh, - vam_curve=vam_curve, ) @@ -188,33 +183,18 @@ def _rolling_mean_ele(data: list[float], win: int) -> list[float]: return result -def _vam_from_ele_1hz(ele_1hz: list[float]) -> tuple[Optional[int], Optional[list[list[int]]]]: - """Core VAM computation from a dense 1 Hz elevation array.""" +def _vam_from_ele_1hz(ele_1hz: list[float]) -> Optional[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. + """ n = len(ele_1hz) if n < 60: - return None, None + return None ele_smooth = _rolling_mean_ele(ele_1hz, 30) - # VAM curve: best VAM per standard duration, windows with net gain ≥ threshold only - 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). climbing_gain = 0.0 climbing_time = 0 for i in range(n - 1): @@ -225,11 +205,9 @@ def _vam_from_ele_1hz(ele_1hz: list[float]) -> tuple[Optional[int], Optional[lis 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 + return round(climbing_gain * 3600.0 / climbing_time) + return None def _build_ele_1hz(sparse: dict[int, Optional[float]]) -> Optional[list[float]]: @@ -255,18 +233,14 @@ 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, -) -> tuple[Optional[int], Optional[list[list[int]]]]: - """Compute climbing VAM and VAM duration curve from DataPoints. +def compute_vam(pts: list[DataPoint], started_at: datetime, sport: str) -> Optional[int]: + """Compute average climbing VAM (m/h) from DataPoints. - Returns (climbing_vam_mh, vam_curve). Only computed for cycling, running, hiking, walking. + Returns None when the activity has insufficient climbing data. """ if sport not in _VAM_SPORTS: - return None, None + return None sparse: dict[int, Optional[float]] = {} last_t = -1 for p in pts: @@ -277,22 +251,7 @@ def compute_vam( last_t = t ele_1hz = _build_ele_1hz(sparse) if ele_1hz is None: - return None, None - return _vam_from_ele_1hz(ele_1hz) - - -def compute_vam_from_timeseries(ts: dict, sport: str) -> tuple[Optional[int], Optional[list[list[int]]]]: - """Compute VAM from a stored timeseries dict (used for backfill without re-parsing files).""" - if sport not in _VAM_SPORTS: - return None, None - t_vals = ts.get("t") or [] - ele_vals = ts.get("elevation_m") or [] - if not t_vals or not ele_vals: - return None, None - sparse: dict[int, Optional[float]] = {int(t): e for t, e in zip(t_vals, ele_vals)} - ele_1hz = _build_ele_1hz(sparse) - if ele_1hz is None: - return None, None + return None return _vam_from_ele_1hz(ele_1hz) @@ -659,5 +618,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, + climbing_vam_mh=None, ) diff --git a/bincio/extract/writer.py b/bincio/extract/writer.py index 568c7e2..b12f238 100644 --- a/bincio/extract/writer.py +++ b/bincio/extract/writer.py @@ -102,7 +102,6 @@ def write_activity( "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, @@ -259,7 +258,7 @@ def build_summary( "mmp": metrics.mmp, "best_efforts": metrics.best_efforts, "best_climb_m": metrics.best_climb_m, - "vam_curve": metrics.vam_curve, + "climbing_vam_mh": metrics.climbing_vam_mh, "source": _infer_source(activity), "privacy": privacy, "detail_url": f"activities/{activity_id}.json", @@ -303,25 +302,6 @@ 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} @@ -390,11 +370,6 @@ 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 c1d8be0..00e27c7 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", "vam_curve", "records", "best_climbs"} + _COMPUTED = {"bas_version", "generated_at", "power_curve", "records", "best_climbs"} for user_dir in targets: index_path = user_dir / "index.json" if not index_path.exists(): @@ -377,10 +377,12 @@ def _link_data(site: Path, data: Path) -> None: console.print(f"Linked data: [cyan]{target}[/cyan] → [cyan]{public_data}[/cyan]") -def _recompute_vam(data: Path, handle: str | None = None) -> None: - """Recompute climbing_vam_mh and vam_curve for all activities from stored timeseries.""" +def _backfill_vam_summary(data: Path, handle: str | None = None) -> None: + """Copy climbing_vam_mh from detail JSONs into index.json summaries. + + Needed once after the vam_curve→climbing_vam_mh-in-summary migration. + """ import json - from bincio.extract.metrics import compute_vam_from_timeseries targets = [data / handle] if handle else _user_dirs(data) for user_dir in targets: @@ -394,31 +396,18 @@ def _recompute_vam(data: Path, handle: str | None = None) -> None: continue updated = 0 - for act_path in acts_dir.glob("*.json"): - if act_path.stem.endswith((".timeseries", ".geojson")): - continue - ts_path = acts_dir / f"{act_path.stem}.timeseries.json" - if not ts_path.exists(): + for s in index_data.get("activities", []): + if "climbing_vam_mh" in s: + continue # already backfilled + act_path = acts_dir / f"{s['id']}.json" + if not act_path.exists(): continue try: detail = json.loads(act_path.read_text(encoding="utf-8")) - sport = detail.get("sport", "other") - ts = json.loads(ts_path.read_text(encoding="utf-8")) - new_vam, new_curve = compute_vam_from_timeseries(ts, sport) - if (new_vam == detail.get("climbing_vam_mh") - and new_curve == detail.get("vam_curve")): - continue - detail["climbing_vam_mh"] = new_vam - detail["vam_curve"] = new_curve - act_path.write_text( - json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8" - ) - act_id = act_path.stem - for s in index_data.get("activities", []): - if s.get("id") == act_id: - s["vam_curve"] = new_curve - break - updated += 1 + vam = detail.get("climbing_vam_mh") + if vam is not None: + s["climbing_vam_mh"] = vam + updated += 1 except Exception: pass @@ -426,7 +415,7 @@ def _recompute_vam(data: Path, handle: str | None = None) -> None: index_path.write_text( json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8" ) - console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} activity(ies) updated") + console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} summary(ies) updated") @click.command() @@ -452,9 +441,9 @@ def _recompute_vam(data: Path, handle: str | None = None) -> None: @click.option("--recompute-elevation", "recompute_elevation", is_flag=True, help="Recompute elevation_gain_m/loss_m for all activities from stored timeseries " "(run once after upgrading the dropout-skip fix).") -@click.option("--recompute-vam", "recompute_vam", is_flag=True, - help="Recompute climbing_vam_mh and vam_curve for all activities from stored " - "timeseries (run once after adding VAM support).") +@click.option("--backfill-vam-summary", "backfill_vam_summary", is_flag=True, + help="Copy climbing_vam_mh from detail JSONs into index.json summaries " + "(run once after the VAM curve → summary migration).") def render( config_path: Optional[str], data_dir: Optional[str], @@ -466,7 +455,7 @@ def render( no_build: bool, recompute_climbs: bool, recompute_elevation: bool, - recompute_vam: bool, + backfill_vam_summary: bool, ) -> None: """Build (or serve) the BincioActivity static site from a BAS data store.""" @@ -484,9 +473,9 @@ def render( console.print("Recomputing elevation gain/loss from timeseries…") _recompute_elevation(data, handle=handle) - if recompute_vam: - console.print("Recomputing VAM from timeseries…") - _recompute_vam(data, handle=handle) + if backfill_vam_summary: + console.print("Backfilling climbing_vam_mh into summaries…") + _backfill_vam_summary(data, handle=handle) _merge_edits(data, handle=handle) _rebuild_athlete_json(data, handle=handle) diff --git a/bincio/render/merge.py b/bincio/render/merge.py index ca046f4..e4c0b52 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", "vam_curve"} +_COMBINED_FEED_STRIP = _FEED_STRIP | {"mmp"} def write_combined_feed(data_dir: Path) -> int: diff --git a/site/src/components/AthleteView.svelte b/site/src/components/AthleteView.svelte index 4b96755..fbca99c 100644 --- a/site/src/components/AthleteView.svelte +++ b/site/src/components/AthleteView.svelte @@ -2,7 +2,6 @@ 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'; @@ -20,13 +19,12 @@ 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' | 'vam' | 'records' | 'segments' | 'profile' | 'explore' | 'nerd'; + type Tab = 'power' | 'records' | 'segments' | 'profile' | 'explore' | 'nerd'; let activeTab: Tab = 'power'; let mounted = false; let isOwner = false; @@ -96,7 +94,7 @@ isOwner = (e as CustomEvent).detail === handle; }, { once: true }); } - const TABS: Tab[] = ['power', 'vam', 'records', 'segments', 'profile', 'explore', 'nerd']; + const TABS: Tab[] = ['power', '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; @@ -133,7 +131,6 @@ 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 { @@ -159,19 +156,15 @@ return hi >= 900 ? `${lo}+ bpm` : `${lo}–${hi} bpm`; } - const ALL_TABS: { key: Tab; label: string; ownerOnly?: boolean; requiresVam?: boolean }[] = [ + const ALL_TABS: { key: Tab; label: string; ownerOnly?: 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 }, + { key: 'explore', label: 'Explore', ownerOnly: true }, + { key: 'nerd', label: 'Nerd Corner', ownerOnly: true }, ]; - $: TABS = ALL_TABS.filter(t => - (!t.ownerOnly || isOwner) && - (!t.requiresVam || athlete?.vam_curve?.all_time != null) - ); + $: TABS = ALL_TABS.filter(t => !t.ownerOnly || isOwner); {#if loading} @@ -223,16 +216,6 @@

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/NerdCorner.svelte b/site/src/components/NerdCorner.svelte index 0b28a31..0c436d2 100644 --- a/site/src/components/NerdCorner.svelte +++ b/site/src/components/NerdCorner.svelte @@ -5,7 +5,7 @@ export let activities: ActivitySummary[] = []; - type Metric = 'distance' | 'elevation' | 'time'; + type Metric = 'distance' | 'elevation' | 'time' | 'vam'; type Granularity = 'week' | 'month'; let metric: Metric = 'distance'; @@ -15,11 +15,13 @@ distance: 'Distance (km)', elevation: 'Elevation gain (m)', time: 'Moving time (h)', + vam: 'Avg climbing VAM (m/h)', }; const METRIC_FMT: Record string> = { distance: v => `${Math.round(v)} km`, elevation: v => `${Math.round(v)} m`, time: v => `${v.toFixed(1)} h`, + vam: v => `${Math.round(v)} m/h`, }; // Cool→warm ramp for past years; current year is always blue-400 @@ -57,18 +59,34 @@ 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 for (const act of acts) { if (!act.started_at) continue; + if (m === 'vam' && act.climbing_vam_mh == null) continue; const d = new Date(act.started_at); const yr = d.getFullYear(); const per = g === 'week' ? weekOfYear(d) : d.getMonth() + 1; const val = m === 'distance' ? (act.distance_m ?? 0) / 1000 : m === 'elevation' ? (act.elevation_gain_m ?? 0) + : m === 'vam' ? (act.climbing_vam_mh ?? 0) : (act.moving_time_s ?? 0) / 3600; - if (!byYear.has(yr)) byYear.set(yr, new Map()); - const ym = byYear.get(yr)!; - ym.set(per, (ym.get(per) ?? 0) + val); + if (!byYear.has(yr)) byYear.set(yr, new Map()); + if (!byYearCnt.has(yr)) byYearCnt.set(yr, new Map()); + const ym = byYear.get(yr)!; + const ymc = byYearCnt.get(yr)!; + ym.set(per, (ym.get(per) ?? 0) + val); + ymc.set(per, (ymc.get(per) ?? 0) + 1); + } + + // VAM: convert sums to averages + if (m === 'vam') { + for (const [yr, ym] of byYear) { + const ymc = byYearCnt.get(yr)!; + for (const [per, sum] of ym) { + ym.set(per, sum / (ymc.get(per) ?? 1)); + } + } } const years = [...byYear.keys()].sort(); @@ -241,6 +259,7 @@ +
@@ -250,8 +269,10 @@
- -
+{#if metric !== 'vam'} + +
+{/if} {#if !rows.length}

No activity data to display.

diff --git a/site/src/components/VamChart.svelte b/site/src/components/VamChart.svelte deleted file mode 100644 index 468b6a4..0000000 --- a/site/src/components/VamChart.svelte +++ /dev/null @@ -1,188 +0,0 @@ - - - - -
- {#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 bf82172..9f1f421 100644 --- a/site/src/lib/types.ts +++ b/site/src/lib/types.ts @@ -36,17 +36,10 @@ 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; @@ -73,7 +66,7 @@ export interface ActivitySummary { avg_cadence_rpm: number | null; avg_power_w: number | null; mmp: MmpCurve | null; - vam_curve?: MmpCurve | null; + climbing_vam_mh?: number | null; source: string | null; privacy: Privacy; detail_url: string | null; @@ -130,7 +123,6 @@ export interface ActivityDetail extends Omit