Mean-Maximal Power Duration Curve — Plotly

A power duration curve plots an athlete's best mean-maximal power output (watts) against effort duration on a logarithmic time axis spanning roughly 1 second to several hours. Each point answers "what is the highest average power this rider could sustain for exactly this long?", producing a characteristic monotonically decreasing decay curve. A critical power (CP) model is fitted and overlaid, decomposing performance into an aerobic asymptote (CP) and a finite anaerobic work capacity (W′). The chart is a cornerstone of cycling and endurance sports analytics for profiling rider strengths, tracking fitness, and setting training zones.

Mean-Maximal Power Duration Curve rendered with Plotly

Python source (Plotly)

""" anyplot.ai
curve-power-duration: Mean-Maximal Power Duration Curve
Library: plotly 6.8.0 | Python 3.13.13
Quality: 89/100 | Created: 2026-06-13
"""

import os

import numpy as np
import plotly.graph_objects as go


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
GRID = "rgba(26,26,23,0.15)" if THEME == "light" else "rgba(240,239,232,0.15)"

# Imprint palette — first series always #009E73
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314"]

# Data — synthetic well-trained cyclist
np.random.seed(42)

CP = 280  # Critical Power in watts (aerobic asymptote)
W_PRIME = 22000  # Anaerobic work capacity in joules
P_1SEC = 1100  # Neuromuscular peak power at 1 s

# Log-spaced test durations: 1 s to 5 hours (50 points)
durations = np.logspace(0, np.log10(18000), 50)

# CP model: P(t) = CP + W′/t  (valid mainly for 2–60 min)
model_power = CP + W_PRIME / durations

# Empirical curve bounded by neuromuscular capacity at short durations
neuro_cap = P_1SEC * (durations**-0.15)
empirical_raw = np.minimum(model_power, neuro_cap)
empirical_raw += np.random.normal(0, 8, len(durations))
# Enforce monotonically non-increasing; keep power above CP floor
empirical_power = np.minimum.accumulate(np.maximum(empirical_raw, CP + 1))

# Smooth model display line
model_t = np.logspace(0, np.log10(18000), 300)
model_display = CP + W_PRIME / model_t

# Reference durations: data values for interpolated empirical power
references = {"5 s": 5, "1 min": 60, "5 min": 300, "20 min": 1200}

# Log10 range for the x-axis (1 s → 18000 s)
LOG_MIN = 0.0
LOG_MAX = float(np.log10(18000))  # ≈ 4.255

# Paper x-coordinate constants (accounts for l=80, r=40 margins on width=800)
_LEFT_FRAC = 80 / 800
_DATA_FRAC = 1.0 - _LEFT_FRAC - 40 / 800

# Title — 49 chars < 67-char baseline, no scaling needed
title = "curve-power-duration · python · plotly · anyplot.ai"
title_fontsize = round(16 * min(1.0, 67 / len(title)))

# X-axis ticks: 7 well-spaced values, no overlap
tick_vals = [1, 10, 60, 300, 1200, 3600, 18000]
tick_text = ["1s", "10s", "1min", "5min", "20min", "1h", "5h"]

# Plot
fig = go.Figure()

# Subtle aerobic zone fill: area between the empirical curve and the CP floor
fig.add_trace(
    go.Scatter(
        x=np.concatenate([durations, durations[::-1]]),
        y=np.concatenate([empirical_power, np.full(len(durations), CP)]),
        fill="toself",
        fillcolor="rgba(0,158,115,0.07)",
        line={"width": 0},
        showlegend=False,
        hoverinfo="skip",
    )
)

# Empirical mean-maximal power curve (primary series — Imprint position 1)
fig.add_trace(
    go.Scatter(
        x=durations,
        y=empirical_power,
        mode="lines+markers",
        name="Mean-Maximal Power",
        line={"color": IMPRINT_PALETTE[0], "width": 3},
        marker={"size": 10, "color": IMPRINT_PALETTE[0], "opacity": 0.85, "line": {"color": PAGE_BG, "width": 1.5}},
    )
)

# CP model fit — dashed (Imprint position 2)
fig.add_trace(
    go.Scatter(
        x=model_t,
        y=model_display,
        mode="lines",
        name=f"CP Model  (CP = {CP} W, W′ = {W_PRIME // 1000} kJ)",
        line={"color": IMPRINT_PALETTE[1], "width": 2.5, "dash": "dash"},
    )
)

# CP asymptote horizontal dotted line
fig.add_hline(y=CP, line={"color": INK_MUTED, "width": 1.5, "dash": "dot"})

# CP label near the right where the curve flattens (paper coords for robustness)
fig.add_annotation(
    x=0.86,
    y=CP + 25,
    xref="paper",
    yref="y",
    text=f"CP = {CP} W",
    showarrow=False,
    font={"size": 10, "color": INK_MUTED},
    bgcolor=ELEVATED_BG,
    borderpad=3,
    xanchor="left",
)

# Reference vertical dotted lines
for dur in references.values():
    fig.add_vline(x=dur, line={"color": INK_MUTED, "width": 1.2, "dash": "dot"})

# Reference annotations using paper x-coordinates to avoid log-axis positioning issues
for label, dur in references.items():
    ref_power = int(np.interp(dur, durations, empirical_power))
    x_paper = _LEFT_FRAC + (np.log10(dur) - LOG_MIN) / (LOG_MAX - LOG_MIN) * _DATA_FRAC
    fig.add_annotation(
        x=x_paper,
        y=0.97,
        xref="paper",
        yref="paper",
        text=f"<b>{label}</b><br>{ref_power} W",
        showarrow=False,
        font={"size": 11, "color": INK},
        bgcolor=ELEVATED_BG,
        bordercolor=INK_SOFT,
        borderwidth=1,
        borderpad=3,
        xanchor="center",
        yanchor="top",
    )

# Layout
fig.update_layout(
    autosize=False,
    paper_bgcolor=PAGE_BG,
    plot_bgcolor=PAGE_BG,
    title={"text": title, "font": {"size": title_fontsize, "color": INK}, "x": 0.5, "xanchor": "center"},
    xaxis={
        "type": "log",
        "range": [LOG_MIN, LOG_MAX],
        "title": {"text": "Duration", "font": {"size": 12, "color": INK}},
        "tickvals": tick_vals,
        "ticktext": tick_text,
        "tickfont": {"size": 10, "color": INK_SOFT},
        "tickangle": 0,
        "gridcolor": GRID,
        "showgrid": True,
        "showline": False,
        "zeroline": False,
    },
    yaxis={
        "range": [220, 1250],
        "title": {"text": "Power (W)", "font": {"size": 12, "color": INK}},
        "tickfont": {"size": 10, "color": INK_SOFT},
        "gridcolor": GRID,
        "showgrid": True,
        "showline": False,
        "zeroline": False,
    },
    legend={
        "bgcolor": ELEVATED_BG,
        "bordercolor": INK_SOFT,
        "borderwidth": 1,
        "font": {"size": 10, "color": INK_SOFT},
        "x": 0.98,
        "xanchor": "right",
        "y": 0.50,
        "yanchor": "top",
    },
    margin={"l": 80, "r": 40, "t": 80, "b": 60},
)

# Save — landscape 3200 × 1800 (width=800, height=450, scale=4)
fig.write_image(f"plot-{THEME}.png", width=800, height=450, scale=4)
fig.write_html(f"plot-{THEME}.html", include_plotlyjs="cdn")

Part of Mean-Maximal Power Duration Curve on anyplot.ai.

Other implementations