{"spec_id":"line-training-load-pmc","library":"plotnine","language":"python","code":"\"\"\" anyplot.ai\nline-training-load-pmc: Training Load Performance Management Chart\nLibrary: plotnine 0.15.7 | Python 3.13.13\nQuality: 88/100 | Created: 2026-06-13\n\"\"\"\n\nimport os\nimport sys\n\n\n# Remove this script's directory from sys.path so 'plotnine' resolves to the installed package\n_here = os.path.dirname(os.path.abspath(__file__))\nif _here in sys.path:\n    sys.path.remove(_here)\n\nimport numpy as np\nimport pandas as pd\nfrom plotnine import (\n    aes,\n    element_blank,\n    element_line,\n    element_rect,\n    element_text,\n    geom_col,\n    geom_hline,\n    geom_label,\n    geom_line,\n    geom_ribbon,\n    ggplot,\n    labs,\n    scale_color_manual,\n    scale_fill_manual,\n    scale_x_date,\n    theme,\n    theme_minimal,\n)\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\nCOLOR_CTL = \"#009E73\"  # Imprint position 1 — CTL/Fitness line\nCOLOR_ATL = \"#C475FD\"  # Imprint position 2 — ATL/Fatigue line\nCOLOR_FRESH = \"#4467A3\"  # Imprint position 3 — positive TSB (fresh, blue)\nCOLOR_FATIGUED = \"#AE3030\"  # Imprint semantic red — negative TSB (fatigued)\n\n# Data: 180-day training block (Jan–Jun 2024, one athlete)\nnp.random.seed(42)\nn_days = 180\ndates = pd.date_range(\"2024-01-01\", periods=n_days, freq=\"D\")\n\n# Weekly training cycles with recovery weeks every 4th week\ntss_values = np.zeros(n_days)\nfor i in range(n_days):\n    dow = i % 7\n    week = i // 7\n    if dow == 0:\n        tss_values[i] = max(0.0, np.random.normal(15, 10))\n    elif week % 4 == 3:\n        tss_values[i] = max(0.0, np.random.normal(45, 15))\n    elif dow in (2, 5):\n        tss_values[i] = max(0.0, np.random.normal(130, 25))\n    elif dow in (1, 3, 4):\n        tss_values[i] = max(0.0, np.random.normal(80, 20))\n    else:\n        tss_values[i] = max(0.0, np.random.normal(40, 12))\n\ntss_values = np.clip(tss_values, 0.0, 200.0)\n\n# Standard PMC EWMA (tau=42 for CTL, tau=7 for ATL)\nctl_alpha = 1.0 - np.exp(-1.0 / 42)\natl_alpha = 1.0 - np.exp(-1.0 / 7)\n\nctl = np.zeros(n_days)\natl = np.zeros(n_days)\ntsb = np.zeros(n_days)\nctl[0] = tss_values[0] * ctl_alpha\natl[0] = tss_values[0] * atl_alpha\n\nfor i in range(1, n_days):\n    ctl[i] = ctl[i - 1] + ctl_alpha * (tss_values[i] - ctl[i - 1])\n    atl[i] = atl[i - 1] + atl_alpha * (tss_values[i] - atl[i - 1])\n    tsb[i] = ctl[i - 1] - atl[i - 1]\n\n# Cap TSS bars at 35 units so they stay near the baseline without dominating CTL/ATL lines\ntss_capped = np.minimum(tss_values, 35)\n\n# Base dataframe (TSS bars — capped to sit near the bottom)\ndf_tss = pd.DataFrame({\"date\": dates, \"tss\": tss_capped})\n\n# Long-format CTL/ATL lines (ordered: CTL first in legend)\ndf_lines = pd.concat(\n    [\n        pd.DataFrame({\"date\": dates, \"value\": ctl, \"metric\": \"CTL (Fitness)\"}),\n        pd.DataFrame({\"date\": dates, \"value\": atl, \"metric\": \"ATL (Fatigue)\"}),\n    ],\n    ignore_index=True,\n)\ndf_lines[\"metric\"] = pd.Categorical(df_lines[\"metric\"], categories=[\"CTL (Fitness)\", \"ATL (Fatigue)\"], ordered=True)\n\n# TSB ribbon data — split into positive (fresh) and negative (fatigued) portions\ndf_tsb = pd.concat(\n    [\n        pd.DataFrame({\"date\": dates, \"tsb_ymin\": 0.0, \"tsb_ymax\": np.maximum(tsb, 0.0), \"form\": \"TSB+ (Fresh)\"}),\n        pd.DataFrame({\"date\": dates, \"tsb_ymin\": np.minimum(tsb, 0.0), \"tsb_ymax\": 0.0, \"form\": \"TSB− (Fatigued)\"}),\n    ],\n    ignore_index=True,\n)\ndf_tsb[\"form\"] = pd.Categorical(df_tsb[\"form\"], categories=[\"TSB+ (Fresh)\", \"TSB− (Fatigued)\"], ordered=True)\n\n# Key training events for annotation\npeak_atl_idx = int(np.argmax(atl))\nmin_tsb_idx = int(np.argmin(tsb))\npeak_ctl_idx = int(np.argmax(ctl))\n\ndf_events = pd.DataFrame(\n    {\n        \"date\": [dates[peak_atl_idx], dates[min_tsb_idx], dates[peak_ctl_idx]],\n        \"y\": [atl[peak_atl_idx] + 7, tsb[min_tsb_idx] - 10, ctl[peak_ctl_idx] + 7],\n        \"label\": [\"Peak Fatigue\", \"Deepest Fatigue\", \"Peak Fitness\"],\n    }\n)\n\n# Title — 56 chars, under 67-char baseline, no font scaling needed\ntitle = \"line-training-load-pmc · python · plotnine · anyplot.ai\"\ntitle_n = len(title)\ntitle_fontsize = max(8, round(12 * 67 / title_n)) if title_n > 67 else 12\n\n# Plot\nplot = (\n    ggplot(df_tss, aes(x=\"date\"))\n    # Daily TSS as light muted bars near the bottom (capped at 35 TSS units)\n    + geom_col(aes(y=\"tss\"), fill=INK_MUTED, alpha=0.25, width=1.0)\n    # TSB ribbon: blue above zero (fresh), red below zero (fatigued)\n    + geom_ribbon(aes(x=\"date\", ymin=\"tsb_ymin\", ymax=\"tsb_ymax\", fill=\"form\"), data=df_tsb, alpha=0.45)\n    # Zero reference line separating fresh from fatigued form\n    + geom_hline(yintercept=0, color=INK_SOFT, size=0.7, linetype=\"dashed\")\n    # CTL and ATL smooth trend lines\n    + geom_line(aes(x=\"date\", y=\"value\", color=\"metric\"), data=df_lines, size=1.2)\n    # Key event callout labels\n    + geom_label(\n        aes(x=\"date\", y=\"y\", label=\"label\"),\n        data=df_events,\n        size=2.2,\n        color=INK_SOFT,\n        fill=ELEVATED_BG,\n        label_padding=0.15,\n        label_size=0.3,\n        ha=\"center\",\n    )\n    + scale_color_manual(values={\"CTL (Fitness)\": COLOR_CTL, \"ATL (Fatigue)\": COLOR_ATL}, name=\"\")\n    + scale_fill_manual(values={\"TSB+ (Fresh)\": COLOR_FRESH, \"TSB− (Fatigued)\": COLOR_FATIGUED}, name=\"\")\n    + scale_x_date(date_labels=\"%b %Y\", date_breaks=\"1 month\")\n    + labs(title=title, x=\"Date\", y=\"Training Stress Score\")\n    + theme_minimal()\n    + theme(\n        figure_size=(8, 4.5),\n        text=element_text(size=7, color=INK_SOFT),\n        plot_title=element_text(size=title_fontsize, color=INK),\n        axis_title=element_text(size=10, color=INK),\n        axis_text=element_text(size=8, color=INK_SOFT),\n        legend_text=element_text(size=8, color=INK_SOFT),\n        legend_title=element_text(size=8, color=INK),\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\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.12),\n        panel_grid_major_x=element_blank(),\n        panel_grid_minor=element_blank(),\n        axis_line=element_line(color=INK_SOFT, size=0.5),\n        panel_border=element_blank(),\n    )\n)\n\n# Save\nplot.save(f\"plot-{THEME}.png\", dpi=400, width=8, height=4.5, units=\"in\")\n"}