{"spec_id":"curve-power-duration","library":"plotnine","language":"python","code":"\"\"\" anyplot.ai\ncurve-power-duration: Mean-Maximal Power Duration Curve\nLibrary: plotnine 0.15.7 | Python 3.13.13\nQuality: 87/100 | Created: 2026-06-13\n\"\"\"\n\nimport os\nimport sys\n\n\nsys.path = [p for p in sys.path if os.path.abspath(p) != os.getcwd()]\n\nimport numpy as np\nimport pandas as pd\nfrom plotnine import (\n    aes,\n    annotate,\n    coord_cartesian,\n    element_blank,\n    element_line,\n    element_rect,\n    element_text,\n    geom_hline,\n    geom_line,\n    geom_vline,\n    ggplot,\n    labs,\n    scale_color_manual,\n    scale_linetype_manual,\n    scale_x_log10,\n    theme,\n)\nfrom scipy.interpolate import PchipInterpolator\n\n\n# Theme tokens\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 — position 1 always first series\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\nBRAND = IMPRINT_PALETTE[0]\n\n# Data: well-trained cyclist power-duration profile\nnp.random.seed(42)\nCP = 280  # Critical Power in watts (aerobic asymptote)\nW_PRIME = 20000  # Anaerobic work capacity in joules\n\n# Anchor points for realistic empirical mean-maximal power curve\nanchor_dur = np.array([1, 5, 15, 30, 60, 120, 300, 600, 1200, 3600, 7200, 18000])\nanchor_pwr = np.array([1050, 820, 620, 520, 430, 365, 350, 316, 298, 287, 283, 278])\n\n# 50 empirical points, log-spaced, via monotone cubic interpolation\nlog_interp = PchipInterpolator(np.log10(anchor_dur), anchor_pwr)\ndurations = np.logspace(0, np.log10(18000), 50)\nempirical_power = log_interp(np.log10(durations))\n\n# Realistic noise — stronger at short neuromuscular efforts\nnoise_scale = np.where(durations < 120, 0.025, 0.012)\nempirical_power = empirical_power + np.random.normal(0, empirical_power * noise_scale)\n\n# Enforce monotonically non-increasing (MMP is always the BEST effort at each duration)\nfor i in range(1, len(empirical_power)):\n    empirical_power[i] = min(empirical_power[i], empirical_power[i - 1])\n\n# CP model: P(t) = CP + W′/t, shown from 5 min (valid aerobic-anaerobic range)\nmodel_dur = np.logspace(np.log10(300), np.log10(18000), 200)\nmodel_pwr = CP + W_PRIME / model_dur\n\n# Build tidy DataFrames\nSERIES_EMP = \"Mean-maximal power\"\nSERIES_MOD = \"CP model  (P = CP + W′/t)\"\n\ndf_emp = pd.DataFrame({\"duration_s\": durations, \"power_w\": empirical_power, \"series\": SERIES_EMP})\ndf_mod = pd.DataFrame({\"duration_s\": model_dur, \"power_w\": model_pwr, \"series\": SERIES_MOD})\ndf_all = pd.concat([df_emp, df_mod], ignore_index=True)\n\n# Title length scaling\ntitle = \"curve-power-duration · python · plotnine · anyplot.ai\"\nn = len(title)\nratio = 67 / n if n > 67 else 1.0\ntitle_fontsize = max(8, round(12 * ratio))\n\n# Series color and linetype mappings\ncolor_map = {SERIES_EMP: BRAND, SERIES_MOD: IMPRINT_PALETTE[1]}\nlinetype_map = {SERIES_EMP: \"solid\", SERIES_MOD: \"dashed\"}\n\n# X-axis: human-readable log ticks\nx_breaks = [1, 5, 30, 60, 300, 1200, 3600, 18000]\nx_labels = [\"1 s\", \"5 s\", \"30 s\", \"1 min\", \"5 min\", \"20 min\", \"1 h\", \"5 h\"]\n\n# Theme\nanyplot_theme = theme(\n    figure_size=(8, 4.5),\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, size=0.3, alpha=0.15),\n    panel_grid_major_x=element_blank(),\n    panel_grid_minor=element_blank(),\n    panel_border=element_blank(),\n    axis_line=element_line(color=INK_SOFT, size=0.5),\n    axis_title=element_text(color=INK, size=10),\n    axis_text=element_text(color=INK_SOFT, size=8),\n    plot_title=element_text(color=INK, size=title_fontsize, weight=\"medium\"),\n    legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n    legend_text=element_text(color=INK_SOFT, size=8),\n    legend_title=element_blank(),\n    legend_key=element_rect(fill=PAGE_BG),\n    legend_position=\"top\",\n    legend_direction=\"horizontal\",\n)\n\n# Plot\nplot = (\n    ggplot(df_all, aes(\"duration_s\", \"power_w\", color=\"series\", linetype=\"series\"))\n    + geom_line(size=1.1)\n    # Reference duration markers\n    + geom_vline(xintercept=[5, 60, 300, 1200], color=INK_MUTED, linetype=\"dotted\", size=0.5)\n    # CP asymptote\n    + geom_hline(yintercept=CP, color=INK_MUTED, linetype=\"dotted\", size=0.4)\n    # Duration annotation labels\n    + annotate(\"text\", x=5, y=1000, label=\"5 s\", color=INK_MUTED, size=3.5, ha=\"center\")\n    + annotate(\"text\", x=60, y=1000, label=\"1 min\", color=INK_MUTED, size=3.5, ha=\"center\")\n    + annotate(\"text\", x=300, y=1000, label=\"5 min\", color=INK_MUTED, size=3.5, ha=\"center\")\n    + annotate(\"text\", x=1200, y=1000, label=\"20 min\\n(FTP proxy)\", color=INK_MUTED, size=3.5, ha=\"center\")\n    # CP label\n    + annotate(\"text\", x=1.5, y=CP + 16, label=f\"CP = {CP} W\", color=INK_MUTED, size=3.2, ha=\"left\")\n    # Scales\n    + scale_x_log10(breaks=x_breaks, labels=x_labels)\n    + scale_color_manual(values=color_map, name=\"\")\n    + scale_linetype_manual(values=linetype_map, name=\"\")\n    + labs(x=\"Duration\", y=\"Power (W)\", title=title)\n    + coord_cartesian(ylim=(220, 1120))\n    + anyplot_theme\n)\n\n# Save\nplot.save(f\"plot-{THEME}.png\", dpi=400, width=8, height=4.5, units=\"in\")\n"}