{"spec_id":"line-training-load-pmc","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nline-training-load-pmc: Training Load Performance Management Chart\nLibrary: bokeh 3.9.1 | Python 3.13.13\nQuality: 90/100 | Created: 2026-06-13\n\"\"\"\n\nimport os\nimport sys\n\n\n# Prevent self-import: this file is named bokeh.py, which shadows the installed\n# bokeh package when its directory sits at the front of sys.path.\n_this_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p or \".\") != _this_dir]\n\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\nfrom bokeh.io import output_file, save\nfrom bokeh.models import BoxAnnotation, ColumnDataSource, Label, LinearAxis, Range1d\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\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 — semantic mapping for PMC chart\nTSB_FRESH = \"#009E73\"  # brand green — positive form (fresh)\nCTL_COLOR = \"#4467A3\"  # blue — fitness / chronic load\nATL_COLOR = \"#C475FD\"  # lavender — fatigue / acute load\nTSB_TIRED = \"#AE3030\"  # matte red — negative form (fatigued)\n\n# Data: 180-day cycling training block\nnp.random.seed(42)\nn_days = 180\ndates = pd.date_range(start=\"2025-01-06\", periods=n_days, freq=\"D\")\n\n# Generate realistic TSS: 3-week build + 1-week recovery cycles, taper at end\ntss = np.zeros(n_days)\nfor i in range(n_days):\n    week = i // 7\n    day_in_week = i % 7\n    week_in_block = week % 4\n    load_factor = [0.70, 0.85, 1.00, 0.45][week_in_block]\n\n    if day_in_week == 6:\n        base = np.random.uniform(0, 20)\n    elif day_in_week in [1, 4]:\n        base = np.random.uniform(90, 150) * load_factor\n    else:\n        base = np.random.uniform(35, 75) * load_factor\n\n    tss[i] = max(0, base + np.random.normal(0, 8))\n\n# Three-week taper before target race\ntss[n_days - 21 : n_days - 10] *= 0.55\ntss[n_days - 10 :] *= 0.25\n\n# CTL (42-day EWMA) and ATL (7-day EWMA)\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)\nctl[0] = tss[0] * ctl_alpha\natl[0] = tss[0] * atl_alpha\n\nfor i in range(1, n_days):\n    ctl[i] = (1 - ctl_alpha) * ctl[i - 1] + ctl_alpha * tss[i]\n    atl[i] = (1 - atl_alpha) * atl[i - 1] + atl_alpha * tss[i]\n\n# TSB = previous-day CTL minus previous-day ATL\ntsb = np.zeros(n_days)\nfor i in range(1, n_days):\n    tsb[i] = ctl[i - 1] - atl[i - 1]\n\n# Split TSB for two-toned fill\ntsb_pos = np.where(tsb >= 0, tsb, 0.0)\ntsb_neg = np.where(tsb < 0, tsb, 0.0)\nzeros = np.zeros(n_days)\n\n# Title — length-scaled font (baseline 50pt for ~67 chars)\ntitle = \"Cycling Season PMC · line-training-load-pmc · python · bokeh · anyplot.ai\"\nn = len(title)\nratio = 67 / n if n > 67 else 1.0\ntitle_size = f\"{max(34, round(50 * ratio))}pt\"\n\n# Figure: primary y for CTL/ATL/TSS, secondary y for TSB\np = figure(\n    width=3200,\n    height=1800,\n    x_axis_type=\"datetime\",\n    y_range=Range1d(start=0, end=165),\n    toolbar_location=None,\n    min_border_bottom=160,\n    min_border_left=200,\n    min_border_top=110,\n    min_border_right=210,\n)\n\n# Secondary y-axis for TSB (right side)\np.extra_y_ranges = {\"tsb\": Range1d(start=-80, end=80)}\ntsb_axis = LinearAxis(\n    y_range_name=\"tsb\",\n    axis_label=\"Form (TSB)\",\n    axis_label_text_font_size=\"42pt\",\n    axis_label_text_color=INK,\n    major_label_text_font_size=\"34pt\",\n    major_label_text_color=INK_SOFT,\n    axis_line_color=None,\n    major_tick_line_color=INK_SOFT,\n    minor_tick_line_color=None,\n)\np.add_layout(tsb_axis, \"right\")\n\n# ColumnDataSource\nsource = ColumnDataSource(\n    data={\"dates\": dates, \"tss\": tss, \"ctl\": ctl, \"atl\": atl, \"tsb_pos\": tsb_pos, \"tsb_neg\": tsb_neg, \"zeros\": zeros}\n)\n\n# Draw order: TSB areas → zero reference → TSS bars → CTL line → ATL line\n# TSB positive area (fresh / green) — first series, always Imprint position 1\np.varea(\n    x=\"dates\",\n    y1=\"zeros\",\n    y2=\"tsb_pos\",\n    source=source,\n    y_range_name=\"tsb\",\n    fill_color=TSB_FRESH,\n    fill_alpha=0.35,\n    legend_label=\"Form TSB+ (Fresh)\",\n)\n\n# TSB negative area (fatigued / red)\np.varea(\n    x=\"dates\",\n    y1=\"tsb_neg\",\n    y2=\"zeros\",\n    source=source,\n    y_range_name=\"tsb\",\n    fill_color=TSB_TIRED,\n    fill_alpha=0.35,\n    legend_label=\"Form TSB− (Tired)\",\n)\n\n# TSB = 0 reference line (no legend entry — structural chrome)\np.line(x=\"dates\", y=\"zeros\", source=source, y_range_name=\"tsb\", line_color=INK_SOFT, line_width=1.5, line_dash=\"dashed\")\n\n# Daily TSS bars — subordinate raw input\nday_ms = 86_400_000\np.vbar(\n    x=\"dates\",\n    top=\"tss\",\n    bottom=0,\n    width=day_ms * 0.60,\n    source=source,\n    fill_color=INK_MUTED,\n    fill_alpha=0.28,\n    line_color=None,\n    legend_label=\"Daily TSS\",\n)\n\n# CTL line — fitness / chronic load\np.line(x=\"dates\", y=\"ctl\", source=source, line_color=CTL_COLOR, line_width=5.0, legend_label=\"Fitness (CTL)\")\n\n# ATL line — fatigue / acute load\np.line(x=\"dates\", y=\"atl\", source=source, line_color=ATL_COLOR, line_width=5.0, legend_label=\"Fatigue (ATL)\")\n\n# Taper region annotation — final 21 days where load was progressively reduced\n# Draws attention to the race-preparation narrative (DE-03 storytelling)\ntaper_start_ms = float(dates[n_days - 21].value) / 1_000_000  # ns → ms (bokeh datetime axis unit)\ntaper_end_ms = float(dates[-1].value) / 1_000_000 + 86_400_000  # include last day\ntaper_box = BoxAnnotation(\n    left=taper_start_ms, right=taper_end_ms, fill_color=INK_MUTED, fill_alpha=0.07, line_color=None\n)\np.add_layout(taper_box)\ntaper_label = Label(\n    x=taper_start_ms,\n    y=152,\n    text=\"  Taper\",\n    text_color=INK_SOFT,\n    text_font_size=\"28pt\",\n    text_alpha=0.65,\n    x_units=\"data\",\n    y_units=\"data\",\n)\np.add_layout(taper_label)\n\n# Theme chrome\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = None\n\np.title.text = title\np.title.text_font_size = title_size\np.title.text_color = INK\np.title.text_font_style = \"bold\"\n\np.xaxis.axis_label = \"Date\"\np.xaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.axis_label_text_color = INK\np.xaxis.major_label_text_font_size = \"34pt\"\np.xaxis.major_label_text_color = INK_SOFT\np.xaxis.axis_line_color = INK_SOFT\np.xaxis.major_tick_line_color = INK_SOFT\np.xaxis.minor_tick_line_color = None\n\np.yaxis.axis_label = \"Load (CTL / ATL / TSS)\"\np.yaxis.axis_label_text_font_size = \"42pt\"\np.yaxis.axis_label_text_color = INK\np.yaxis.major_label_text_font_size = \"34pt\"\np.yaxis.major_label_text_color = INK_SOFT\np.yaxis.axis_line_color = INK_SOFT\np.yaxis.major_tick_line_color = INK_SOFT\np.yaxis.minor_tick_line_color = None\n\np.xgrid.grid_line_color = None\np.ygrid.grid_line_color = INK\np.ygrid.grid_line_alpha = 0.12\n\n# Legend\np.legend.location = \"top_left\"\np.legend.background_fill_color = ELEVATED_BG\np.legend.border_line_color = None\np.legend.label_text_color = INK_SOFT\np.legend.label_text_font_size = \"34pt\"\np.legend.glyph_width = 50\np.legend.glyph_height = 50\np.legend.spacing = 10\np.legend.padding = 16\np.legend.click_policy = \"hide\"\n\n# Save HTML (interactive catalog artifact)\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot with headless Selenium (Bokeh export_png not available in CI)\nW, H = 3200, 1800\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\n# CDP override forces an exact W×H viewport regardless of outer window chrome\ndriver.execute_cdp_cmd(\n    \"Emulation.setDeviceMetricsOverride\", {\"width\": W, \"height\": H, \"deviceScaleFactor\": 1, \"mobile\": False}\n)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}