Mean-Maximal Power Duration Curve — ggplot2

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 ggplot2

R source (ggplot2)

#' anyplot.ai
#' curve-power-duration: Mean-Maximal Power Duration Curve
#' Library: ggplot2 3.5.1 | R 4.4.1
#' Quality: 88/100 | Created: 2026-06-13

library(ggplot2)
library(ragg)

set.seed(42)

# Theme tokens (Imprint palette — see prompts/default-style-guide.md)
THEME       <- Sys.getenv("ANYPLOT_THEME", "light")
PAGE_BG     <- if (THEME == "light") "#FAF8F1" else "#1A1A17"
ELEVATED_BG <- if (THEME == "light") "#FFFDF6" else "#242420"
INK         <- if (THEME == "light") "#1A1A17" else "#F0EFE8"
INK_SOFT    <- if (THEME == "light") "#4A4A44" else "#B8B7B0"
INK_MUTED   <- if (THEME == "light") "#6B6A63" else "#A8A79F"

IMPRINT_PALETTE <- c(
    "#009E73",  # 1 - brand green  (empirical MMP curve)
    "#C475FD",  # 2 - lavender     (CP model overlay)
    "#4467A3",  # 3 - blue
    "#BD8233",  # 4 - ochre
    "#AE3030",  # 5 - matte red
    "#2ABCCD",  # 6 - cyan
    "#954477",  # 7 - rose
    "#99B314"   # 8 - lime
)

# Grid color: INK with alpha embedded for subtlety (ggplot2 has no alpha on element_line)
GRID_COLOR <- grDevices::adjustcolor(INK, alpha.f = 0.12)

# --- Data -------------------------------------------------------------------

# Well-trained cyclist parameters
CP   <- 280    # Critical Power (watts) — aerobic asymptote
Wp   <- 20000  # W' anaerobic work capacity (joules)
Pmax <- 1100   # Neuromuscular peak power at 1 s (watts)

# Empirical mean-maximal power: 45 log-spaced durations from 1 s to 5 h
n_emp   <- 45
dur_emp <- exp(seq(log(1), log(18000), length.out = n_emp))

# Power-law decay: P(t) = CP + (Pmax - CP) / sqrt(t)
# Gives realistic MMP shape: 1100 W at 1 s, converging to ~286 W at 5 h
W_eff     <- Pmax - CP   # 820 W effective surplus above CP at t = 1 s
emp_power <- CP + W_eff / sqrt(dur_emp)

# Add ~2% noise and enforce monotonicity (MMP is always non-increasing)
noise      <- rnorm(n_emp, 0, sd = 5)
emp_final  <- cummin(emp_power + noise)

df_emp <- data.frame(
    duration_s = dur_emp,
    power_w    = emp_final,
    series     = "Mean-Maximal Power"
)

# CP model P(t) = CP + W'/t — shown from 2 min onward where it is physically valid
# (at < 2 min the model overestimates vs. neuromuscular-bounded empirical efforts)
dur_mod   <- exp(seq(log(120), log(18000), length.out = 200))
mod_power <- CP + Wp / dur_mod

df_mod <- data.frame(
    duration_s = dur_mod,
    power_w    = mod_power,
    series     = "Critical Power Model"
)

# Reference durations for vertical annotation guides
ref_dur    <- c(5, 60, 300, 1200)
ref_labels <- c("5 s", "1 min", "5 min", "20 min")

# Title (46 chars < 67 baseline — no fontsize scaling needed)
plot_title <- "curve-power-duration · r · ggplot2 · anyplot.ai"

# --- Plot -------------------------------------------------------------------

p <- ggplot() +
    # CP asymptote — horizontal guide at CP = 280 W
    geom_hline(
        yintercept = CP,
        color      = INK_SOFT,
        linewidth  = 0.5,
        linetype   = "dotted"
    ) +
    # Reference duration vertical guides (5 s sprint, 1 min, 5 min, 20 min FTP)
    geom_vline(
        xintercept = ref_dur,
        color      = INK_MUTED,
        linewidth  = 0.35,
        linetype   = "dashed",
        alpha      = 0.55
    ) +
    # CP model — dashed lavender line (from 2 min to 5 h)
    geom_line(
        data      = df_mod,
        aes(x = duration_s, y = power_w, color = series, linetype = series),
        linewidth = 1.0
    ) +
    # Empirical MMP — solid green line
    geom_line(
        data      = df_emp,
        aes(x = duration_s, y = power_w, color = series, linetype = series),
        linewidth = 1.3
    ) +
    # Empirical data points (every 3rd for clarity at this density)
    geom_point(
        data        = df_emp[seq(1, n_emp, by = 3), ],
        aes(x = duration_s, y = power_w),
        color       = IMPRINT_PALETTE[1],
        size        = 1.8,
        alpha       = 0.75,
        show.legend = FALSE
    ) +
    # Reference duration labels at top of chart
    annotate(
        "text",
        x         = ref_dur,
        y         = 1120,
        label     = ref_labels,
        color     = INK_MUTED,
        size      = 2.4,
        hjust     = 0.5,
        vjust     = 1,
        fontface  = "plain"
    ) +
    # CP label next to the asymptote line
    annotate(
        "text",
        x        = 160,
        y        = CP + 24,
        label    = "CP = 280 W",
        color    = INK_SOFT,
        size     = 2.6,
        hjust    = 0,
        fontface = "italic"
    ) +
    # Color scale — Imprint positions 1 (green) and 2 (lavender), canonical order
    # limits forces legend order: MMP first (primary), CP Model second
    scale_color_manual(
        values = c(
            "Mean-Maximal Power"   = IMPRINT_PALETTE[1],
            "Critical Power Model" = IMPRINT_PALETTE[2]
        ),
        limits = c("Mean-Maximal Power", "Critical Power Model"),
        name   = NULL
    ) +
    # Linetype scale — suppress redundant guide, handled via override in color guide
    scale_linetype_manual(
        values = c(
            "Mean-Maximal Power"   = "solid",
            "Critical Power Model" = "dashed"
        ),
        limits = c("Mean-Maximal Power", "Critical Power Model"),
        name   = NULL,
        guide  = "none"
    ) +
    # Legend icons match actual styles: MMP = solid green, CP Model = dashed lavender
    guides(color = guide_legend(
        override.aes = list(
            linetype  = c("solid", "dashed"),
            linewidth = c(1.3, 1.0)
        )
    )) +
    # Log-scale x axis: 1 s to 5 h with human-readable labels
    scale_x_log10(
        limits = c(1, 18000),
        breaks = c(1, 5, 30, 60, 300, 1200, 3600, 18000),
        labels = c("1 s", "5 s", "30 s", "1 min", "5 min", "20 min", "1 h", "5 h"),
        expand = expansion(mult = c(0.02, 0.04))
    ) +
    # Y axis: power in watts, 200–1150 W shows full curve and CP asymptote
    scale_y_continuous(
        limits = c(200, 1150),
        breaks = c(200, 400, 600, 800, 1000),
        labels = function(x) paste0(x, " W"),
        expand = expansion(mult = c(0.02, 0.08))
    ) +
    labs(
        title = plot_title,
        x     = "Duration (log scale, s)",
        y     = "Mean-Maximal Power (W)"
    ) +
    theme_minimal(base_size = 8) +
    theme(
        plot.background   = element_rect(fill = PAGE_BG, color = PAGE_BG),
        panel.background  = element_rect(fill = PAGE_BG, color = NA),
        panel.grid.major  = element_line(color = GRID_COLOR, linewidth = 0.35),
        panel.grid.minor  = element_blank(),
        panel.border      = element_blank(),
        axis.line         = element_line(color = INK_SOFT, linewidth = 0.4),
        axis.ticks        = element_line(color = INK_SOFT, linewidth = 0.3),
        axis.ticks.length = unit(3, "pt"),
        axis.title        = element_text(color = INK,      size = 10),
        axis.text         = element_text(color = INK_SOFT, size = 8),
        plot.title        = element_text(color = INK,      size = 12, face = "bold",
                                         margin = margin(b = 8)),
        legend.background = element_rect(fill = ELEVATED_BG, color = INK_SOFT,
                                         linewidth = 0.3),
        legend.text       = element_text(color = INK_SOFT, size = 8),
        legend.key.width  = unit(1.4, "cm"),
        legend.position   = "bottom",
        legend.margin     = margin(t = 4, b = 4, l = 6, r = 6),
        plot.margin       = margin(t = 12, r = 20, b = 8, l = 10)
    )

# --- Save -------------------------------------------------------------------

ggsave(
    filename = sprintf("plot-%s.png", THEME),
    plot     = p,
    device   = ragg::agg_png,
    width    = 8,
    height   = 4.5,
    units    = "in",
    dpi      = 400
)

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

Other implementations