/** * Computes Normalized Power (NP) over a slice of a workout, matching the * canonical Strava/TrainingPeaks algorithm and tolerating uneven sample dt. * * Exponential 4th-power smoothing with a 30 s time constant; points with * null power or non-finite timestamps are skipped, as are samples with * non-positive dt. Returns null when fewer than 2 valid points exist. * * @param times timestamps (ISO strings or ms numbers), 1 Hz * @param powers power in watts, null where the sensor has no data * @param startIdx inclusive start index * @param endIdx exclusive end index */ export function normalizedPower( times: Array, powers: Array, startIdx: number, endIdx: number, ): number | null { const tau = 30; const end = Math.min(endIdx, times.length, powers.length); const start = Math.max(0, startIdx); // Collect valid points: finite timestamp + non-null power. const t: number[] = []; const p: number[] = []; for (let i = start; i < end; i++) { const raw = times[i]; const ms = typeof raw === "number" && Number.isFinite(raw) ? raw : Number(new Date(raw as string)); const w = powers[i]; if (!Number.isFinite(ms) || w === null || !Number.isFinite(w)) { continue; } t.push(ms); p.push(w); } const n = p.length; if (n < 2) { return null; } // Exponential smoothing of p^4; the last sample has no forward dt, so its // 4th power is added to the sum as its own smoothing value. let sm = Math.pow(p[0], 4); let sum = sm; let count = 1; for (let i = 1; i < n; i++) { const dt = (t[i] - t[i - 1]) / 1000; if (dt <= 0) { continue; } sm = sm + (Math.pow(p[i], 4) - sm) * (1 - Math.exp(-dt / tau)); sum += sm; count += 1; } return Math.pow(sum / count, 0.25); }