{"spec_id":"curve-power-duration","library":"altair","language":"python","code":"\"\"\" anyplot.ai\ncurve-power-duration: Mean-Maximal Power Duration Curve\nLibrary: altair 6.2.1 | Python 3.13.13\nQuality: 89/100 | Created: 2026-06-13\n\"\"\"\n\nimport sys\n\n\n# This file is named altair.py; pop its directory so `import altair` finds\n# the installed package rather than this script.\nif sys.path and sys.path[0]:\n    sys.path.pop(0)\n\nimport os\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\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 — first series always #009E73, second always #C475FD (lavender)\nBRAND = \"#009E73\"  # empirical MMP curve\nMODEL_COLOR = \"#C475FD\"  # CP model fit — palette position 2\n\n# Data — synthetic well-trained cyclist: CP = 280 W, W' = 20,000 J, Pmax = 1,100 W\n# The simple CP model P=CP+W'/t diverges to ~20 kW at t=1 s (outside its valid range).\n# Empirical MMP is bounded by neuromuscular ceiling (Pmax); model is shown from 1 min\n# onward where it applies.\nnp.random.seed(42)\nCP = 280\nW_PRIME = 20000\nP_MAX = 1100\n\ndurations = np.logspace(0, np.log10(18000), 50)  # 1 s → 5 h, log-spaced\n\n# Empirical MMP: Pmax ceiling for short efforts, CP+W'/t decay for longer ones\nempirical_power = np.minimum(P_MAX, CP + W_PRIME / durations)\nnoise = np.abs(np.random.normal(0, np.maximum(3, 0.02 * empirical_power)))\nempirical_power = empirical_power + noise\nfor i in range(1, len(empirical_power)):\n    empirical_power[i] = min(empirical_power[i], empirical_power[i - 1])\n\n# CP model shown from 60 s (1 min) onward — the range where it is valid\nmodel_mask = durations >= 60\nmodel_durations = durations[model_mask]\nmodel_power = CP + W_PRIME / model_durations\n\ndf = pd.concat(\n    [\n        pd.DataFrame({\"duration_s\": durations, \"power_w\": empirical_power, \"series\": \"MMP Curve\"}),\n        pd.DataFrame({\"duration_s\": model_durations, \"power_w\": model_power, \"series\": \"CP Model  (P = CP + W′/t)\"}),\n    ],\n    ignore_index=True,\n)\n\n# Reference vertical markers\nref_df = pd.DataFrame({\"duration_s\": [5, 60, 300, 1200], \"label\": [\"5 s\", \"1 min\", \"5 min\", \"20 min\"]})\n\ncp_df = pd.DataFrame({\"cp\": [CP]})\ncp_label_df = pd.DataFrame({\"x\": [14000], \"y\": [CP + 20], \"text\": [f\"CP = {CP} W\"]})\n\n# X-axis tick configuration\ntick_vals = [1, 5, 30, 60, 300, 1200, 3600, 18000]\nlabel_expr = (\n    \"datum.value === 1 ? '1 s' : \"\n    \"datum.value === 5 ? '5 s' : \"\n    \"datum.value === 30 ? '30 s' : \"\n    \"datum.value === 60 ? '1 min' : \"\n    \"datum.value === 300 ? '5 min' : \"\n    \"datum.value === 1200 ? '20 min' : \"\n    \"datum.value === 3600 ? '1 h' : '5 h'\"\n)\n\n# Base encoding shared by line layers\nbase = alt.Chart(df).encode(\n    x=alt.X(\n        \"duration_s:Q\",\n        scale=alt.Scale(type=\"log\", domain=[1, 18000]),\n        axis=alt.Axis(\n            values=tick_vals, labelExpr=label_expr, title=\"Duration\", titleFontSize=12, labelFontSize=10, grid=False\n        ),\n    ),\n    y=alt.Y(\n        \"power_w:Q\",\n        scale=alt.Scale(domain=[200, 1200], clamp=True),\n        axis=alt.Axis(title=\"Power (W)\", titleFontSize=12, labelFontSize=10, grid=True),\n    ),\n    color=alt.Color(\n        \"series:N\",\n        scale=alt.Scale(domain=[\"MMP Curve\", \"CP Model  (P = CP + W′/t)\"], range=[BRAND, MODEL_COLOR]),\n        legend=alt.Legend(title=None, labelFontSize=10, symbolSize=100, orient=\"top-right\"),\n    ),\n    strokeDash=alt.StrokeDash(\n        \"series:N\",\n        scale=alt.Scale(domain=[\"MMP Curve\", \"CP Model  (P = CP + W′/t)\"], range=[[1, 0], [12, 4]]),\n        legend=None,\n    ),\n    tooltip=[\n        alt.Tooltip(\"duration_s:Q\", title=\"Duration (s)\", format=\".0f\"),\n        alt.Tooltip(\"power_w:Q\", title=\"Power (W)\", format=\".0f\"),\n        alt.Tooltip(\"series:N\", title=\"Series\"),\n    ],\n)\n\nlines = base.mark_line(strokeWidth=3)\npoints_mmp = base.transform_filter('datum.series == \"MMP Curve\"').mark_point(size=40, filled=True)\n\n# CP asymptote (dashed horizontal rule)\ncp_rule = alt.Chart(cp_df).mark_rule(color=INK_SOFT, strokeWidth=1, strokeDash=[5, 5]).encode(y=\"cp:Q\")\n\n# CP label\ncp_label = (\n    alt.Chart(cp_label_df)\n    .mark_text(align=\"right\", baseline=\"bottom\", fontSize=10, color=INK_MUTED)\n    .encode(x=\"x:Q\", y=\"y:Q\", text=\"text:N\")\n)\n\n# Reference vertical rules\nref_rules = alt.Chart(ref_df).mark_rule(color=INK_MUTED, strokeWidth=1, strokeDash=[3, 3]).encode(x=\"duration_s:Q\")\n\n# Reference labels at top of chart area (pixel y=6 from chart top)\nref_labels_chart = (\n    alt.Chart(ref_df)\n    .mark_text(baseline=\"top\", align=\"center\", fontSize=10, color=INK_MUTED, dy=0)\n    .encode(x=\"duration_s:Q\", text=\"label:N\", y=alt.value(6))\n)\n\ntitle_text = \"curve-power-duration · python · altair · anyplot.ai\"\n\nchart = (\n    alt.layer(lines, points_mmp, cp_rule, cp_label, ref_rules, ref_labels_chart)\n    .properties(width=620, height=320, background=PAGE_BG, title=alt.Title(title_text, fontSize=16, color=INK))\n    .configure_view(fill=PAGE_BG, stroke=None)\n    .configure_axis(\n        domainColor=INK_SOFT,\n        domainWidth=0,\n        tickColor=INK_SOFT,\n        gridColor=INK,\n        gridOpacity=0.12,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n    )\n    .configure_title(color=INK, fontSize=16)\n    .configure_legend(fillColor=ELEVATED_BG, strokeColor=PAGE_BG, labelColor=INK_SOFT, titleColor=INK, labelFontSize=10)\n)\n\n# Save PNG then pad to exact target canvas\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\nTW, TH = 3200, 1800\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\nchart.save(f\"plot-{THEME}.html\")\n"}