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:
@@ -65,6 +65,7 @@ class ComputedMetrics:
|
|||||||
best_efforts: Optional[list[list[float]]]
|
best_efforts: Optional[list[list[float]]]
|
||||||
best_climb_m: Optional[float] # max net elevation gain in one contiguous window (cycling only)
|
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_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:
|
def compute(activity: ParsedActivity) -> ComputedMetrics:
|
||||||
@@ -84,7 +85,8 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
|
|||||||
start_ll, end_ll = _endpoints(pts)
|
start_ll, end_ll = _endpoints(pts)
|
||||||
mmp = compute_mmp(pts, activity.started_at)
|
mmp = compute_mmp(pts, activity.started_at)
|
||||||
best_efforts, best_climb_m = compute_best_efforts(pts, activity.started_at, activity.sport)
|
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(
|
return ComputedMetrics(
|
||||||
distance_m=distance_m,
|
distance_m=distance_m,
|
||||||
@@ -107,6 +109,7 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
|
|||||||
best_efforts=best_efforts,
|
best_efforts=best_efforts,
|
||||||
best_climb_m=best_climb_m,
|
best_climb_m=best_climb_m,
|
||||||
climbing_vam_mh=climbing_vam_mh,
|
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
|
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.
|
"""Climbing VAM from a dense 1 Hz elevation array.
|
||||||
|
|
||||||
Accumulates gain and time only on ascending seconds, identified by a 30 s
|
Accumulates gain and time only on ascending seconds, identified by a 30 s
|
||||||
forward-lookahead on the smoothed elevation signal.
|
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)
|
n = len(ele_1hz)
|
||||||
if n < 60:
|
if n < 60:
|
||||||
@@ -206,7 +210,7 @@ def _vam_from_ele_1hz(ele_1hz: list[float]) -> Optional[int]:
|
|||||||
climbing_time += 1
|
climbing_time += 1
|
||||||
|
|
||||||
if climbing_time >= 60 and climbing_gain >= 5.0:
|
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
|
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]
|
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.
|
"""Compute average climbing VAM (m/h) from DataPoints.
|
||||||
|
|
||||||
Only computed for cycling, running, hiking, walking.
|
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:
|
if sport not in _VAM_SPORTS:
|
||||||
return None
|
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,
|
avg_cadence_rpm=None, avg_power_w=None, np_power_w=None, max_power_w=None,
|
||||||
bbox=None, start_latlng=None, end_latlng=None,
|
bbox=None, start_latlng=None, end_latlng=None,
|
||||||
mmp=None, best_efforts=None, best_climb_m=None,
|
mmp=None, best_efforts=None, best_climb_m=None,
|
||||||
climbing_vam_mh=None,
|
climbing_vam_mh=None, climbing_time_s=None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ def write_activity(
|
|||||||
"best_efforts": metrics.best_efforts,
|
"best_efforts": metrics.best_efforts,
|
||||||
"best_climb_m": metrics.best_climb_m,
|
"best_climb_m": metrics.best_climb_m,
|
||||||
"climbing_vam_mh": metrics.climbing_vam_mh,
|
"climbing_vam_mh": metrics.climbing_vam_mh,
|
||||||
|
"climbing_time_s": metrics.climbing_time_s,
|
||||||
"laps": [_serialise_lap(lap) for lap in activity.laps],
|
"laps": [_serialise_lap(lap) for lap in activity.laps],
|
||||||
"timeseries_url": f"activities/{activity_id}.timeseries.json" if timeseries else None,
|
"timeseries_url": f"activities/{activity_id}.timeseries.json" if timeseries else None,
|
||||||
"source": source,
|
"source": source,
|
||||||
@@ -259,6 +260,7 @@ def build_summary(
|
|||||||
"best_efforts": metrics.best_efforts,
|
"best_efforts": metrics.best_efforts,
|
||||||
"best_climb_m": metrics.best_climb_m,
|
"best_climb_m": metrics.best_climb_m,
|
||||||
"climbing_vam_mh": metrics.climbing_vam_mh,
|
"climbing_vam_mh": metrics.climbing_vam_mh,
|
||||||
|
"climbing_time_s": metrics.climbing_time_s,
|
||||||
"source": _infer_source(activity),
|
"source": _infer_source(activity),
|
||||||
"privacy": privacy,
|
"privacy": privacy,
|
||||||
"detail_url": f"activities/{activity_id}.json",
|
"detail_url": f"activities/{activity_id}.json",
|
||||||
|
|||||||
@@ -56,14 +56,30 @@
|
|||||||
const _now = new Date();
|
const _now = new Date();
|
||||||
const _currentYear = _now.getFullYear();
|
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) {
|
function buildData(acts: ActivitySummary[], m: Metric, g: Granularity) {
|
||||||
const curPeriod = g === 'week' ? weekOfYear(_now) : _now.getMonth() + 1;
|
const curPeriod = g === 'week' ? weekOfYear(_now) : _now.getMonth() + 1;
|
||||||
const byYear = new Map<number, Map<number, number>>();
|
const byYear = new Map<number, Map<number, number>>();
|
||||||
const byYearCnt = new Map<number, Map<number, number>>(); // for VAM averaging
|
const byYearCnt = new Map<number, Map<number, number>>();
|
||||||
|
const byYearClimbTime = new Map<number, Map<number, number>>();
|
||||||
|
|
||||||
for (const act of acts) {
|
for (const act of acts) {
|
||||||
if (!act.started_at) continue;
|
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 d = new Date(act.started_at);
|
||||||
const yr = d.getFullYear();
|
const yr = d.getFullYear();
|
||||||
const per = g === 'week' ? weekOfYear(d) : d.getMonth() + 1;
|
const per = g === 'week' ? weekOfYear(d) : d.getMonth() + 1;
|
||||||
@@ -77,6 +93,11 @@
|
|||||||
const ymc = byYearCnt.get(yr)!;
|
const ymc = byYearCnt.get(yr)!;
|
||||||
ym.set(per, (ym.get(per) ?? 0) + val);
|
ym.set(per, (ym.get(per) ?? 0) + val);
|
||||||
ymc.set(per, (ymc.get(per) ?? 0) + 1);
|
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
|
// VAM: convert sums to averages
|
||||||
@@ -91,13 +112,17 @@
|
|||||||
|
|
||||||
const years = [...byYear.keys()].sort();
|
const years = [...byYear.keys()].sort();
|
||||||
const maxPer = g === 'week' ? 52 : 12;
|
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) {
|
for (const yr of years) {
|
||||||
const pm = byYear.get(yr)!;
|
const pm = byYear.get(yr)!;
|
||||||
|
const ct = byYearClimbTime.get(yr);
|
||||||
const limit = yr === _currentYear ? curPeriod : maxPer;
|
const limit = yr === _currentYear ? curPeriod : maxPer;
|
||||||
for (let p = 1; p <= limit; p++) {
|
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 },
|
y: { label: yLabel, grid: true, zero: true },
|
||||||
color: { domain: colorDomain, range: colorRange, legend: !cumulative },
|
color: { domain: colorDomain, range: colorRange, legend: !cumulative },
|
||||||
marks: [
|
marks: [
|
||||||
...(pastRows.length ? [
|
...(m === 'vam' ? (() => {
|
||||||
Plot.line(pastRows, {
|
// VAM: dots only, no lines — opacity encodes total climbing time in period.
|
||||||
x: 'period', y: 'value', stroke: 'year',
|
const vamRows = [...pastRows, ...curRows].filter((r: any) => r.value > 0);
|
||||||
strokeWidth: 1.5, curve: 'monotone-x',
|
return vamRows.length ? [
|
||||||
}),
|
Plot.dot(vamRows, {
|
||||||
Plot.dot(pastRows, {
|
x: 'period', y: 'value', fill: 'year',
|
||||||
x: 'period', y: 'value', fill: 'year', r: 2, fillOpacity: 0,
|
r: 5,
|
||||||
tip: true,
|
fillOpacity: (d: any) => vamOpacity(d.climbTime),
|
||||||
title: (d: any) => `${d.year} · ${xLabel} ${d.period}\n${fmt(d.value)}`,
|
tip: true,
|
||||||
}),
|
title: (d: any) => {
|
||||||
] : []),
|
const mins = d.climbTime ? `${Math.round(d.climbTime / 60)} min climbing` : '';
|
||||||
...(curRows.length ? [
|
return `${d.year} · ${xLabel} ${d.period}\n${fmt(d.value)}${mins ? '\n' + mins : ''}`;
|
||||||
Plot.line(curRows, {
|
},
|
||||||
x: 'period', y: 'value', stroke: 'year',
|
}),
|
||||||
strokeWidth: 2.5, curve: 'monotone-x',
|
] : [];
|
||||||
}),
|
})() : [
|
||||||
Plot.dot(curRows, {
|
...(pastRows.length ? [
|
||||||
x: 'period', y: 'value', fill: 'year', r: 2, fillOpacity: 0,
|
Plot.line(pastRows, {
|
||||||
tip: true,
|
x: 'period', y: 'value', stroke: 'year',
|
||||||
title: (d: any) => `${d.year} · ${xLabel} ${d.period}\n${fmt(d.value)}`,
|
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);
|
el.appendChild(chart);
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export interface ActivitySummary {
|
|||||||
avg_power_w: number | null;
|
avg_power_w: number | null;
|
||||||
mmp: MmpCurve | null;
|
mmp: MmpCurve | null;
|
||||||
climbing_vam_mh?: number | null;
|
climbing_vam_mh?: number | null;
|
||||||
|
climbing_time_s?: number | null;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
privacy: Privacy;
|
privacy: Privacy;
|
||||||
detail_url: string | null;
|
detail_url: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user