From c46e91d0f551560978981db6de03061080278f76 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Tue, 12 May 2026 23:51:22 +0200 Subject: [PATCH] Compute NP from timeseries in frontend for activities missing np_power_w in JSON --- site/src/components/ActivityDetail.svelte | 40 +++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/site/src/components/ActivityDetail.svelte b/site/src/components/ActivityDetail.svelte index 3540dfb..3ec9b6f 100644 --- a/site/src/components/ActivityDetail.svelte +++ b/site/src/components/ActivityDetail.svelte @@ -112,6 +112,42 @@ $: galleryImages = (detail?.custom as any)?.images as string[] ?? []; + // Coggan NP from timeseries — mirrors the Python implementation in metrics.py. + // Used as fallback for activities extracted before np_power_w was added to the JSON. + function computeNpFromTimeseries(ts: Timeseries): number | null { + const { t, power_w } = ts; + if (!power_w || !t || t.length < 30) return null; + if (!power_w.some(v => v != null)) return null; + + const sparse = new Map(); + for (let i = 0; i < t.length; i++) { + if (power_w[i] != null) sparse.set(t[i], power_w[i]!); + } + if (sparse.size < 2) return null; + + const tMin = Math.min(...sparse.keys()); + const tMax = Math.max(...sparse.keys()); + if (tMax - tMin > 7 * 24 * 3600) return null; + + const dense: number[] = []; + for (let i = 0; i <= tMax - tMin; i++) dense.push(sparse.get(tMin + i) ?? 0); + + const win = 30; + if (dense.length < win) return null; + + const half = Math.floor(win / 2); + let windowSum = dense.slice(0, win).reduce((a, b) => a + b, 0); + const fourthPowers: number[] = []; + for (let i = half; i < dense.length - half; i++) { + fourthPowers.push((windowSum / win) ** 4); + if (i + half + 1 < dense.length) windowSum += dense[i + half + 1] - dense[i - half]; + } + if (!fourthPowers.length) return null; + return Math.round((fourthPowers.reduce((a, b) => a + b, 0) / fourthPowers.length) ** 0.25); + } + + $: npPower = detail?.np_power_w ?? (timeseries ? computeNpFromTimeseries(timeseries) : null); + const stat = (label: string, value: string, key?: string) => ({ label, value, key }); $: hiddenStats = new Set((detail?.custom as any)?.hide_stats ?? []); $: stats = [ @@ -124,8 +160,8 @@ stat('Max HR', activity.max_hr_bpm ? `${activity.max_hr_bpm} bpm` : '—', 'heart_rate'), stat('Cadence', activity.avg_cadence_rpm ? `${activity.avg_cadence_rpm} rpm` : '—', 'cadence'), ...(activity.avg_power_w != null ? [ - stat('Avg power', `${activity.avg_power_w} W`, 'power'), - stat('NP', detail?.np_power_w != null ? `${detail.np_power_w} W` : '—', 'power'), + stat('Avg power', `${activity.avg_power_w} W`, 'power'), + stat('NP', npPower != null ? `${npPower} W` : '—', 'power'), ] : []), ].filter(s => !s.key || !hiddenStats.has(s.key));