{"spec_id":"line-training-load-pmc","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nline-training-load-pmc: Training Load Performance Management Chart\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 85/100 | Created: 2026-06-13\n\"\"\"\n\nimport os\nimport sys\n\n\n# Script filename shadows the installed `pygal` package; remove script dir from path.\nsys.path.pop(0)\n\nimport numpy as np\nimport pandas as pd\nimport pygal\nfrom pygal.style import Style\n\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Palette in series-add order: ref, CTL, ATL, TSB+ (fresh), TSB- (fatigued), TSS, race-day\nCHART_COLORS = (\n    INK_MUTED,  # 0: TSB = 0 reference line (dashed)\n    \"#009E73\",  # 1: Fitness (CTL)  — Imprint pos 1\n    \"#C475FD\",  # 2: Fatigue (ATL)  — Imprint pos 2\n    \"#4467A3\",  # 3: Form TSB (fresh / positive) — Imprint pos 3\n    \"#AE3030\",  # 4: Form TSB (fatigued / negative) — Imprint pos 5\n    \"#BD8233\",  # 5: Daily TSS — Imprint pos 4\n    \"#DDCC77\",  # 6: Race Day marker — ANYPLOT_AMBER\n    \"#2ABCCD\",\n    \"#954477\",\n)\n\n# Data — 180-day training block (Jan–Jun 2024)\nnp.random.seed(42)\nn_days = 180\ndates = pd.date_range(\"2024-01-01\", periods=n_days, freq=\"D\")\n\n# Daily TSS: 3-week build + 1-week recovery pattern\ntss = np.zeros(n_days)\nfor i in range(n_days):\n    dow = dates[i].dayofweek  # 0=Mon, 6=Sun\n    cycle = (i // 7) % 4  # 0–2 = build, 3 = recovery\n\n    if cycle < 3:\n        base = 55 + cycle * 12  # 55 → 67 → 79 TSS across build weeks\n    else:\n        base = 35  # recovery week\n\n    if dow == 0:  # Monday: rest / very easy\n        tss[i] = max(0.0, np.random.normal(12, 6))\n    elif dow == 5:  # Saturday: long session\n        tss[i] = max(0.0, np.random.normal(base * 1.6, 14))\n    elif dow == 6:  # Sunday: medium effort\n        tss[i] = max(0.0, np.random.normal(base * 0.9, 10))\n    else:  # Tue–Fri: quality / tempo\n        tss[i] = max(0.0, np.random.normal(base * 0.75, 12))\n\n# Two-week taper into target race on day 155\nfor i in range(140, 156):\n    tss[i] *= max(0.12, 1.0 - (i - 140) / 18.0)\ntss[155] = 0.0  # race day\n\n# EWMA: CTL tau=42 days (fitness), ATL tau=7 days (fatigue)\na_ctl = 1.0 - np.exp(-1.0 / 42)\na_atl = 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] = atl[0] = tss[0]\n\nfor i in range(1, n_days):\n    ctl[i] = ctl[i - 1] + a_ctl * (tss[i] - ctl[i - 1])\n    atl[i] = atl[i - 1] + a_atl * (tss[i] - atl[i - 1])\n    tsb[i] = ctl[i - 1] - atl[i - 1]  # previous-day CTL minus previous-day ATL\n\n# Two-toned TSB: split into positive (fresh) and negative (fatigued) fill areas\ntsb_list = tsb.tolist()\ntsb_pos = [v if v >= 0 else None for v in tsb_list]\ntsb_neg = [v if v < 0 else None for v in tsb_list]\n\n# Race day marker: single dot at day 155 (taper complete, form peaks)\nrace_day_marker = [None] * n_days\nrace_day_marker[155] = float(ctl[155])\n\n# X-axis: only month-start labels (6 entries) — avoids rendering artefact\nx_labels = [d.strftime(\"%b %Y\") for d in dates if d.day == 1]\n\n# Title — 52 chars → ratio 1.0 → title_font_size 66\ntitle = \"line-training-load-pmc · python · pygal · anyplot.ai\"\n\n# Style\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=CHART_COLORS,\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    stroke_width=3.0,\n)\n\n# Chart — y-range capped at 80 so CTL/ATL dominate; TSS dots scatter in the upper zone\nchart = pygal.Line(\n    width=3200,\n    height=1800,\n    style=custom_style,\n    title=title,\n    x_title=\"Date (2024)\",\n    y_title=\"Training Load (TSS pts)\",\n    show_x_guides=False,\n    show_y_guides=True,\n    show_dots=False,\n    fill=False,\n    legend_at_bottom=True,  # horizontal legend gives full label width — no truncation\n    range=[-35, 80],  # clips extreme TSS spikes; ATL/CTL (≤65) fully visible\n)\n\nchart.x_labels = x_labels\n\n# Add series in color-assignment order\nchart.add(\"TSB = 0\", [0] * n_days, stroke_style={\"width\": 1.2, \"dasharray\": \"6 4\"})\nchart.add(\"Fitness (CTL)\", ctl.tolist(), stroke_style={\"width\": 5.5})\nchart.add(\"Fatigue (ATL)\", atl.tolist(), stroke_style={\"width\": 3.5})\n# TSB as two-toned filled area: blue=fresh (positive form), red=fatigued (negative form)\nchart.add(\"Form TSB (fresh)\", tsb_pos, allow_interruptions=True, fill=True, stroke_style={\"width\": 1.5})\nchart.add(\"Form TSB (fatigued)\", tsb_neg, allow_interruptions=True, fill=True, stroke_style={\"width\": 1.5})\n# TSS as scatter dots — stroke=False so only dots render without dominating spike-lines\nchart.add(\"Daily TSS\", tss.tolist(), stroke=False, show_dots=True, dots_size=3)\n# Race-day marker: single amber dot at day 155 anchors the taper narrative\nchart.add(\"Race Day ★\", race_day_marker, allow_interruptions=True, stroke=False, show_dots=True, dots_size=8)\n\n# Save PNG and interactive HTML\nchart.render_to_png(f\"plot-{THEME}.png\")\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(chart.render())\n"}