{"spec_id":"line-training-load-pmc","library":"plotly","language":"python","code":"\"\"\" anyplot.ai\nline-training-load-pmc: Training Load Performance Management Chart\nLibrary: plotly 6.8.0 | Python 3.13.13\nQuality: 86/100 | Created: 2026-06-13\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nimport plotly.graph_objects as go\nfrom plotly.subplots import make_subplots\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\"\nGRID = \"rgba(26,26,23,0.15)\" if THEME == \"light\" else \"rgba(240,239,232,0.15)\"\n\n# Imprint palette — semantic mapping for training metrics\nFITNESS_GREEN = \"#009E73\"  # CTL / positive form — Imprint position 1 (growth/fitness)\nFATIGUE_RED = \"#AE3030\"  # ATL / negative form — Imprint position 5 (stress/fatigue)\nTSS_BLUE = \"#4467A3\"  # Daily TSS bars — Imprint position 3 (neutral input)\n\n# Data — 180-day periodized training block with build/recovery cycles and end taper\nnp.random.seed(42)\nn_days = 180\ndates = pd.date_range(\"2025-01-01\", periods=n_days, freq=\"D\").strftime(\"%Y-%m-%d\").tolist()\n\ntss = np.zeros(n_days)\nfor i in range(n_days):\n    day_of_week = i % 7\n    week_phase = (i // 7) % 4  # 0,1 = base build; 2 = peak build; 3 = recovery\n    if day_of_week == 6:  # Sunday rest\n        tss[i] = 0.0\n    elif i >= 160:  # Taper for peak event at day 180\n        tss[i] = np.random.uniform(20, 60) * max(0.3, 1 - (i - 160) / 25)\n    elif week_phase == 3:  # Recovery week\n        tss[i] = np.random.uniform(25, 60)\n    elif week_phase == 2:  # Peak build week\n        tss[i] = np.random.uniform(90, 155)\n    else:  # Base build\n        tss[i] = np.random.uniform(55, 115)\n\n# Standard PMC formulas: EWMA with 42-day (CTL) and 7-day (ATL) time constants\nctl_alpha = 1 - np.exp(-1 / 42)\natl_alpha = 1 - np.exp(-1 / 7)\n\nctl = np.zeros(n_days)\natl = np.zeros(n_days)\ntsb = np.zeros(n_days)\nctl[0] = tss[0] * 0.5\natl[0] = tss[0] * 0.7\n\nfor i in range(1, n_days):\n    tsb[i] = ctl[i - 1] - atl[i - 1]  # Form = yesterday's fitness − yesterday's fatigue\n    ctl[i] = ctl[i - 1] + (tss[i] - ctl[i - 1]) * ctl_alpha\n    atl[i] = atl[i - 1] + (tss[i] - atl[i - 1]) * atl_alpha\n\n# Plot — secondary y-axis keeps TSB (±50) readable against CTL/ATL (0–150)\nfig = make_subplots(specs=[[{\"secondary_y\": True}]])\n\n# Daily TSS bars (primary axis — background layer, raw workout stress)\nfig.add_trace(\n    go.Bar(\n        x=dates,\n        y=tss,\n        name=\"Daily TSS\",\n        width=86400000,  # one day in ms — fills each slot without gaps\n        marker={\"color\": TSS_BLUE, \"opacity\": 0.28, \"line\": {\"width\": 0}},\n        hovertemplate=\"%{x|%b %d}: TSS %{y:.0f}<extra></extra>\",\n    ),\n    secondary_y=False,\n)\n\n# TSB positive fill — fresh / \"in form\" (secondary axis)\nfig.add_trace(\n    go.Scatter(\n        x=dates,\n        y=np.where(tsb >= 0, tsb, 0.0),\n        fill=\"tozeroy\",\n        fillcolor=\"rgba(0,158,115,0.18)\",\n        line={\"width\": 0},\n        mode=\"lines\",\n        showlegend=False,\n        hoverinfo=\"skip\",\n    ),\n    secondary_y=True,\n)\n\n# TSB negative fill — fatigued (secondary axis)\nfig.add_trace(\n    go.Scatter(\n        x=dates,\n        y=np.where(tsb <= 0, tsb, 0.0),\n        fill=\"tozeroy\",\n        fillcolor=\"rgba(174,48,48,0.18)\",\n        line={\"width\": 0},\n        mode=\"lines\",\n        showlegend=False,\n        hoverinfo=\"skip\",\n    ),\n    secondary_y=True,\n)\n\n# TSB = 0 reference — separates \"fresh\" from \"fatigued\" zones (secondary axis)\nfig.add_trace(\n    go.Scatter(\n        x=[dates[0], dates[-1]],\n        y=[0, 0],\n        mode=\"lines\",\n        line={\"color\": INK_SOFT, \"width\": 1.0, \"dash\": \"dot\"},\n        showlegend=False,\n        hoverinfo=\"skip\",\n    ),\n    secondary_y=True,\n)\n\n# ATL — Acute Training Load / Fatigue (primary axis, dashed for color-blind accessibility)\nfig.add_trace(\n    go.Scatter(\n        x=dates,\n        y=atl,\n        name=\"Fatigue (ATL)\",\n        line={\"color\": FATIGUE_RED, \"width\": 2.5, \"dash\": \"dot\"},\n        mode=\"lines\",\n        hovertemplate=\"%{x|%b %d}: ATL %{y:.1f}<extra></extra>\",\n    ),\n    secondary_y=False,\n)\n\n# CTL — Chronic Training Load / Fitness (primary axis, drawn on top)\nfig.add_trace(\n    go.Scatter(\n        x=dates,\n        y=ctl,\n        name=\"Fitness (CTL)\",\n        line={\"color\": FITNESS_GREEN, \"width\": 3.0},\n        mode=\"lines\",\n        hovertemplate=\"%{x|%b %d}: CTL %{y:.1f}<extra></extra>\",\n    ),\n    secondary_y=False,\n)\n\n# TSB — Form line with legend entry (secondary axis, 2px for better visibility)\nfig.add_trace(\n    go.Scatter(\n        x=dates,\n        y=tsb,\n        name=\"Form (TSB)\",\n        line={\"color\": INK_SOFT, \"width\": 2.0},\n        mode=\"lines\",\n        hovertemplate=\"%{x|%b %d}: TSB %{y:.1f}<extra></extra>\",\n    ),\n    secondary_y=True,\n)\n\n# Title with scaled font for the long string (73 chars → ratio 67/73)\ntitle = \"Training Load PMC · line-training-load-pmc · python · plotly · anyplot.ai\"\nn = len(title)\ntitle_fontsize = max(11, round(16 * 67 / n)) if n > 67 else 16\n\nfig.update_layout(\n    autosize=False,\n    paper_bgcolor=PAGE_BG,\n    plot_bgcolor=PAGE_BG,\n    font={\"color\": INK},\n    title={\"text\": title, \"font\": {\"size\": title_fontsize, \"color\": INK}, \"x\": 0.02, \"xanchor\": \"left\"},\n    margin={\"l\": 80, \"r\": 80, \"t\": 80, \"b\": 90},\n    legend={\n        \"bgcolor\": ELEVATED_BG,\n        \"bordercolor\": INK_SOFT,\n        \"borderwidth\": 1,\n        \"font\": {\"color\": INK_SOFT, \"size\": 10},\n        \"x\": 0.99,\n        \"y\": 0.97,\n        \"xanchor\": \"right\",\n        \"yanchor\": \"top\",\n    },\n    barmode=\"overlay\",\n)\n\n# Primary y-axis: Training Load (TSS bars, CTL, ATL — all non-negative)\nfig.update_yaxes(\n    title={\"text\": \"Training Load (TSS · CTL · ATL)\", \"font\": {\"color\": INK, \"size\": 12}},\n    tickfont={\"color\": INK_SOFT, \"size\": 10},\n    gridcolor=GRID,\n    showline=True,\n    linecolor=INK_SOFT,\n    mirror=False,\n    zeroline=False,\n    rangemode=\"tozero\",\n    secondary_y=False,\n)\n\n# Secondary y-axis: Form / TSB (oscillates around 0 — no grid to avoid doubling)\nfig.update_yaxes(\n    title={\"text\": \"Form (TSB)\", \"font\": {\"color\": INK, \"size\": 12}},\n    tickfont={\"color\": INK_SOFT, \"size\": 10},\n    showgrid=False,\n    zeroline=False,\n    showline=True,\n    linecolor=INK_SOFT,\n    mirror=False,\n    secondary_y=True,\n)\n\n# x-axis: shared date axis with monthly ticks\nfig.update_xaxes(\n    tickfont={\"color\": INK_SOFT, \"size\": 10},\n    showline=True,\n    linecolor=INK_SOFT,\n    mirror=False,\n    showgrid=False,\n    dtick=\"M1\",\n    tickformat=\"%b '%y\",\n)\n\n# Save\nfig.write_image(f\"plot-{THEME}.png\", width=800, height=450, scale=4)\nfig.write_html(f\"plot-{THEME}.html\", include_plotlyjs=\"cdn\")\n"}