Take two rides:
| Ride | Profile | Avg power | Felt like |
|---|---|---|---|
| A | Steady 220W for 60 min | 220W | Comfortable |
| B | 60 × 1 min at 400W with 1 min recovery at 40W | 220W | Brutal |
Same average. Wildly different recovery cost. Average power, as a single-number summary, is broken.
Normalized Power is the standard fix. Here's why it works, and how to compute it from a power stream.
The intuition
Riding hard for a minute and then easy for a minute does not feel like riding moderately for two minutes. The body responds non-linearly to intensity:
- Lactate clearance lags effort.
- Cardiovascular drift compounds with surges.
- Glycogen burn rate scales steeper than linearly with intensity.
So a useful "average" should weight high-intensity samples more than proportionally to their wattage. NP does this by raising every sample (after smoothing) to the 4th power, averaging, then taking the 4th root.
The algorithm
- Take the 30-second rolling mean of the power stream.
- Raise every smoothed sample to the 4th power.
- Take the arithmetic mean.
- Take the 4th root.
The 30-second smoothing reflects the body's response time — sub-30s spikes don't fatigue you the way sustained 30s+ efforts do. The 4th-power weighting was reverse-engineered from physiological data: it's the exponent that best correlates with blood lactate response across the range of cycling intensities.
In code:
export function normalizedPower(watts: (number | null)[]): number {
const valid = watts.map(w => w ?? 0);
const window = 30;
const smoothed: number[] = [];
let sum = 0;
for (let i = 0; i < valid.length; i++) {
sum += valid[i];
if (i >= window) sum -= valid[i - window];
if (i >= window - 1) smoothed.push(sum / window);
}
if (smoothed.length === 0) return 0;
const fourthMean =
smoothed.reduce((acc, w) => acc + w ** 4, 0) / smoothed.length;
return fourthMean ** 0.25;
} ~12 lines. Source.
What it actually does to the two rides above
For ride A (steady 220W, 60 min): NP ≈ 220W. By construction — a constant stream, smoothed and weighted, returns the same number.
For ride B (1-on/1-off, 400/40, 60 min): NP ≈ 290W.
The 70-watt gap is the punishment for variability. Two rides with identical average power, two very different NPs, two very different TSS scores, two very different recovery requirements.
Variability Index
VI = NP / Avg. A useful diagnostic on its own:
- VI 1.00–1.05 — steady-state ride (TT, smooth tempo).
- VI 1.05–1.15 — typical road ride with traffic + climbs.
- VI 1.15–1.30 — group ride with surges, crit, mountain bike.
- VI > 1.30 — interval workout or race with attacks.
If your VI on a "tempo" ride is 1.20, you weren't riding tempo — you were doing intervals. The number doesn't lie.
When NP breaks down
NP isn't perfect. Known failure modes:
- Very short rides (< 20 min) — the 30s smoothing eats too large a fraction of the data; results get noisy.
- Coasting-heavy rides — long stretches of zero watts (descents) drag the mean down. xPower (Skiba's variant) handles this slightly better by ignoring zeros.
- Sprint efforts — 5–15s neuromuscular efforts get smoothed into oblivion. NP underweights them.
- Steady efforts above threshold — the formula was tuned around sub-threshold variability.
For 95% of rides, NP is the right number. For the other 5%, look at peak power curve and time-in-zone separately.
Why this isn't on Strava
It is — for paying users. The function is 12 lines. The data is yours (in your FIT file, on your computer). Strava charges for the rendering, the rolling history, the comparison to your peers. The math has been public domain since 2003.
pacelore computes NP on every ride, shows the VI, and lets you compare against your own history. Source is in packages/metrics, computed in <50ms per ride.
Try it
Live demo — see NP, VI, IF, TSS on a real ride.
GitHub — fork it, deploy it, audit the math.
Further reading
- Coggan, A. Training and Racing with a Power Meter, Ch. 7.
- Skiba, P. "Calculation of xPower." — the ignore-zeros variant.
- What is TSS, really? — the next number up.
- The Performance Manager Chart, explained.