{"spec_id":"curve-power-duration","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\ncurve-power-duration: Mean-Maximal Power Duration Curve\nLibrary: letsplot 4.10.1 | Python 3.13.13\nQuality: 90/100 | Created: 2026-06-13\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import (\n    LetsPlot,\n    aes,\n    element_blank,\n    element_line,\n    element_rect,\n    element_text,\n    geom_hline,\n    geom_line,\n    geom_point,\n    geom_text,\n    geom_vline,\n    ggplot,\n    ggsave,\n    ggsize,\n    labs,\n    scale_color_manual,\n    scale_linetype_manual,\n    scale_x_log10,\n    theme,\n)\nfrom scipy.interpolate import PchipInterpolator\n\n\nLetsPlot.setup_html()\n\n# Theme tokens — Imprint palette, theme-adaptive chrome\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint palette (hybrid-v3 canonical order)\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\n\n# Data — synthetic well-trained cyclist: CP = 280 W, W' = 18 500 J\nnp.random.seed(42)\nCP = 280\nW_PRIME = 18500\n\n# Realistic MMP anchor points spanning 1 s → 5 h\nanchor_dur = np.array([1, 5, 15, 30, 60, 120, 300, 600, 1200, 3600, 7200, 18000])\nanchor_pwr = np.array([1100, 920, 750, 640, 545, 450, 385, 342, 316, 291, 284, 279])\n\n# PCHIP interpolation → smooth, monotone empirical curve\ninterp = PchipInterpolator(np.log10(anchor_dur), anchor_pwr)\ndurations_s = np.logspace(0, np.log10(18000), 50)\nempirical_power = interp(np.log10(durations_s))\n\n# Realistic measurement noise (higher at short sprints, fades at long durations)\nnoise = np.random.normal(0, 6 * np.exp(-durations_s / 500) + 1.5)\nempirical_power = np.maximum(empirical_power + noise, CP + 0.5)\n\n# Enforce monotonically non-increasing (core MMP invariant)\nfor i in range(1, len(empirical_power)):\n    if empirical_power[i] > empirical_power[i - 1]:\n        empirical_power[i] = empirical_power[i - 1]\n\n# CP model overlay — valid from ~45 s; overestimates at sprint durations,\n# accurate in the 2–20 min range where it is typically fitted\ndur_model = np.logspace(np.log10(45), np.log10(18000), 300)\npwr_model = CP + W_PRIME / dur_model\n\n# Series labels (used for legend key lookup)\nSERIES_MMP = \"Measured MMP\"\nSERIES_MODEL = \"CP Model  (P = CP + W′/t)\"\n\ndf_empirical = pd.DataFrame({\"duration_s\": durations_s, \"power_w\": empirical_power, \"series\": SERIES_MMP})\ndf_model_line = pd.DataFrame({\"duration_s\": dur_model, \"power_w\": pwr_model, \"series\": SERIES_MODEL})\ndf_lines = pd.concat([df_empirical, df_model_line], ignore_index=True)\n\n# Reference duration annotation labels (placed above the data)\nref_x = [5, 60, 300, 1200]\nref_lbl = [\"5 s\", \"1 min\", \"5 min\", \"20 min\"]\ndf_ref = pd.DataFrame({\"x\": ref_x, \"y\": [1170] * 4, \"label\": ref_lbl})\n\n# CP asymptote annotation (right-hand side)\ndf_cp_lbl = pd.DataFrame({\"x\": [9000], \"y\": [CP + 25], \"label\": [f\"CP = {CP} W\"]})\n\n# Log-axis ticks with human-readable time labels\nx_breaks = [1, 5, 30, 60, 300, 1200, 3600]\nx_labels = [\"1s\", \"5s\", \"30s\", \"1min\", \"5min\", \"20min\", \"1h\"]\n\ntitle = \"curve-power-duration · python · letsplot · anyplot.ai\"\n\n# Imprint theme\nanyplot_theme = theme(\n    plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n    panel_background=element_rect(fill=PAGE_BG),\n    panel_grid_major_y=element_line(color=INK_MUTED, size=0.2),\n    panel_grid_major_x=element_blank(),\n    panel_grid_minor=element_blank(),\n    panel_border=element_blank(),\n    axis_title=element_text(color=INK, size=12),\n    axis_text=element_text(color=INK_SOFT, size=10),\n    axis_line=element_line(color=INK_SOFT),\n    axis_ticks=element_line(color=INK_SOFT),\n    plot_title=element_text(color=INK, size=16),\n    legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n    legend_text=element_text(color=INK_SOFT, size=10),\n    legend_title=element_blank(),\n    legend_position=\"bottom\",\n)\n\n# Plot\nplot = (\n    ggplot(df_lines, aes(x=\"duration_s\", y=\"power_w\", color=\"series\", linetype=\"series\"))\n    # CP horizontal asymptote\n    + geom_hline(yintercept=CP, color=INK_MUTED, linetype=\"dotted\", size=0.7, alpha=0.9)\n    # Reference duration markers: 5 s, 1 min, 5 min, 20 min\n    + geom_vline(xintercept=5, color=INK_MUTED, linetype=\"dashed\", size=0.5, alpha=0.55)\n    + geom_vline(xintercept=60, color=INK_MUTED, linetype=\"dashed\", size=0.5, alpha=0.55)\n    + geom_vline(xintercept=300, color=INK_MUTED, linetype=\"dashed\", size=0.5, alpha=0.55)\n    + geom_vline(xintercept=1200, color=INK_MUTED, linetype=\"dashed\", size=0.5, alpha=0.55)\n    # Series lines (empirical solid, model dashed — driven by scale_linetype_manual)\n    + geom_line(size=1.5)\n    # Scatter markers on empirical curve only\n    + geom_point(data=df_empirical, size=2.5, alpha=0.85)\n    # Reference duration labels near top\n    + geom_text(\n        mapping=aes(x=\"x\", y=\"y\", label=\"label\"),\n        data=df_ref,\n        color=INK_SOFT,\n        size=3.2,\n        vjust=0,\n        hjust=0.5,\n        inherit_aes=False,\n    )\n    # CP value label beside the asymptote\n    + geom_text(\n        mapping=aes(x=\"x\", y=\"y\", label=\"label\"),\n        data=df_cp_lbl,\n        color=INK_MUTED,\n        size=3.2,\n        vjust=0,\n        hjust=0.5,\n        inherit_aes=False,\n    )\n    # Imprint color mapping: green = empirical, blue = model\n    + scale_color_manual(name=\"\", values=[IMPRINT_PALETTE[0], IMPRINT_PALETTE[2]], breaks=[SERIES_MMP, SERIES_MODEL])\n    + scale_linetype_manual(name=\"\", values=[\"solid\", \"dashed\"], breaks=[SERIES_MMP, SERIES_MODEL])\n    # Log x-axis with human-readable duration labels\n    + scale_x_log10(breaks=x_breaks, labels=x_labels)\n    + labs(x=\"Duration\", y=\"Mean-Maximal Power (W)\", title=title)\n    + ggsize(800, 450)\n    + anyplot_theme\n)\n\n# Save PNG (scale=4 → 3200 × 1800 px) and interactive HTML\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}