{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# 06 \u2014 Race plan & tracker\n",
    "\n",
    "**Three-race progression in 17 weeks:**\n",
    "\n",
    "| race          | date         | week | role                                        |\n",
    "|---------------|--------------|------|---------------------------------------------|\n",
    "| **30K**       | Sat 2026-06-13 | wk 4  | hard long run; race-day fuel/kit rehearsal  |\n",
    "| **50K**       | Sat 2026-07-25 | wk 10 | peak-fitness tune-up; race-pace calibration |\n",
    "| **50 mile**   | Sat 2026-09-12 | wk 17 | A-race                                      |\n",
    "\n",
    "Plan philosophy: the two earlier races *are* the tune-ups \u2014 no separate dress rehearsals needed. The 50K does double-duty as the biggest pre-race effort, leaving 7 weeks for one final back-to-back peak, taper, and race.\n",
    "\n",
    "Built on Ethan's proven 2023\u20132025 50K formula (~22 km/wk mean, ~29 km longest training run) scaled up for the 50-mile. Recorded Garmin volume **does not** include hikes, strength, or unrecorded efforts \u2014 adherence numbers below are a floor, not a ceiling."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import pandas as pd\n",
    "import matplotlib.pyplot as plt\n",
    "from openrun import open_conn, load_activities\n",
    "\n",
    "PLAN_START = pd.Timestamp('2026-05-18')\n",
    "RACES = {\n",
    "    'wk 4 \u2014 30K':       pd.Timestamp('2026-06-13'),\n",
    "    'wk 10 \u2014 50K':      pd.Timestamp('2026-07-25'),\n",
    "    'wk 17 \u2014 50 MILE':  pd.Timestamp('2026-09-12'),\n",
    "}\n",
    "RACE_DATE = RACES['wk 17 \u2014 50 MILE']\n",
    "TODAY     = pd.Timestamp.today().normalize()\n",
    "\n",
    "_rows = [\n",
    "    # phase,           kind,            long_km, week_km, notes\n",
    "    ('P1: base',       'build',           22,  30, 'rebuild frequency; long-run HR \u2264 145'),\n",
    "    ('P1: base',       'build',           26,  38, 'add 1 trail/vert run; practice fueling'),\n",
    "    ('P1: base',       'pre-race ease',   22,  35, 'short shakeouts late-week, legs fresh for Sat'),\n",
    "    ('P1: 30K',        '30K RACE',        30,  40, 'aerobic effort, race-day fuel + kit rehearsal'),\n",
    "    ('P2: build',      'recovery',        18,  28, 'one week easy; trail/strength fine, no hard runs'),\n",
    "    ('P2: build',      'build',           28,  45, ''),\n",
    "    ('P2: build',      'build',           32,  55, 'fueling: 60\u201390 g carb/hr non-negotiable on long'),\n",
    "    ('P2: build',      'peak before 50K', 35,  60, 'optional B2B: 28 Sat + 15 Sun'),\n",
    "    ('P2: build',      'pre-race taper',  22,  42, 'cut volume ~30%, keep frequency'),\n",
    "    ('P3: 50K',        '50K RACE',        52,  60, 'controlled effort; this is the calibration run'),\n",
    "    ('P4: recover',    'deep recovery',   15,  30, 'no hard efforts; walk-jog week'),\n",
    "    ('P4: recover',    'easy rebuild',    25,  50, 'all runs by feel, HR < 150'),\n",
    "    ('P5: peak',       'build',           35,  70, ''),\n",
    "    ('P5: peak',       'B2B peak',        40,  80, 'BACK-TO-BACK: 40 km Sat + 22 km Sun, full race kit. The single most important week.'),\n",
    "    ('P6: taper',      'taper',           28,  55, ''),\n",
    "    ('P6: taper',      'deep taper',      18,  35, 'last meaningful long run'),\n",
    "    ('P6: 50 MILE',    '50 MILE RACE',    80,  90, 'shakeouts 5\u20136 km early-week; RACE Sat'),\n",
    "]\n",
    "PLAN = pd.DataFrame(_rows, columns=['phase','kind','long_run_km','weekly_km','notes'])\n",
    "PLAN.index = pd.date_range(PLAN_START, periods=len(PLAN), freq='W-MON')\n",
    "PLAN.index.name = 'week_start'\n",
    "PLAN['week_num'] = range(1, len(PLAN) + 1)\n",
    "PLAN['date_range'] = [f\"{d.strftime('%b %d')}\u2013{(d + pd.Timedelta(days=6)).strftime('%b %d')}\" for d in PLAN.index]\n",
    "PLAN[['week_num','date_range','phase','kind','long_run_km','weekly_km','notes']]"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Plan vs actual (tracker)\n",
    "\n",
    "Coloured `status` column scores each elapsed week against planned km:\n",
    "\n",
    "- **on track**  \u2014 85\u2013115% of plan\n",
    "- **over**      \u2014 > 115%  *(usually fine; watch fatigue if two in a row)*\n",
    "- **under**     \u2014 50\u201385%  *(recoverable)*\n",
    "- **missed**    \u2014 < 50%   *(adjust the plan; don't try to make it up next week)*\n",
    "- **\u2014**         \u2014 future week, not scored\n",
    "\n",
    "Race weeks (4 / 10 / 17) are bolded."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "conn = open_conn()\n",
    "acts = load_activities(conn)\n",
    "runs = acts[acts['activity_type'].isin(['running','trail_running'])].copy()\n",
    "runs['week_start'] = runs['start_time_local'].dt.to_period('W-MON').dt.start_time\n",
    "\n",
    "weekly_actual = runs.groupby('week_start').agg(\n",
    "    actual_km=('distance_km', 'sum'),\n",
    "    longest_run_km=('distance_km', 'max'),\n",
    "    n_runs=('activity_id', 'size'),\n",
    ").round(1)\n",
    "\n",
    "tracker = PLAN.join(weekly_actual, how='left')\n",
    "tracker['actual_km'] = tracker['actual_km'].fillna(0)\n",
    "tracker['longest_run_km'] = tracker['longest_run_km'].fillna(0)\n",
    "tracker['n_runs'] = tracker['n_runs'].fillna(0).astype(int)\n",
    "tracker['weekly_delta_km'] = (tracker['actual_km'] - tracker['weekly_km']).round(1)\n",
    "tracker['long_run_delta_km'] = (tracker['longest_run_km'] - tracker['long_run_km']).round(1)\n",
    "\n",
    "elapsed_mask = tracker.index <= TODAY\n",
    "ratio = tracker['actual_km'] / tracker['weekly_km']\n",
    "status = pd.Series('\u2014', index=tracker.index)\n",
    "status[elapsed_mask & (ratio >= 0.85) & (ratio < 1.15)] = 'on track'\n",
    "status[elapsed_mask & (ratio >= 1.15)] = 'over'\n",
    "status[elapsed_mask & (ratio >= 0.50) & (ratio < 0.85)] = 'under'\n",
    "status[elapsed_mask & (ratio < 0.50)] = 'missed'\n",
    "tracker['status'] = status\n",
    "tracker['is_race'] = tracker['kind'].str.contains('RACE', na=False)\n",
    "\n",
    "view = tracker[['week_num', 'date_range', 'phase', 'kind',\n",
    "                'long_run_km', 'longest_run_km', 'long_run_delta_km',\n",
    "                'weekly_km', 'actual_km', 'weekly_delta_km',\n",
    "                'n_runs', 'status', 'notes']]\n",
    "\n",
    "_status_colors = {\n",
    "    'on track': 'background-color:#a8dadc',\n",
    "    'over':     'background-color:#fff3b0',\n",
    "    'under':    'background-color:#f4a261',\n",
    "    'missed':   'background-color:#e76f51;color:white',\n",
    "    '\u2014':        'color:#bbb',\n",
    "}\n",
    "\n",
    "def _color_status(v):\n",
    "    return _status_colors.get(v, '')\n",
    "\n",
    "def _bold_race(row):\n",
    "    return ['font-weight:bold' if tracker.loc[row.name, 'is_race'] else ''] * len(row)\n",
    "\n",
    "(view.style\n",
    "      .map(_color_status, subset=['status'])\n",
    "      .apply(_bold_race, axis=1))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Where are we?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "elapsed = tracker[tracker.index <= TODAY]\n",
    "weeks_done = len(elapsed)\n",
    "weeks_left = len(tracker) - weeks_done\n",
    "\n",
    "print(f'Today: {TODAY.date()}   Race day: {RACE_DATE.date()}   ({(RACE_DATE - TODAY).days} days / ~{(RACE_DATE - TODAY).days // 7} weeks to A-race)')\n",
    "print(f'Plan progress: {weeks_done}/{len(tracker)} weeks elapsed, {weeks_left} remaining')\n",
    "print()\n",
    "print('Upcoming races:')\n",
    "for label, d in RACES.items():\n",
    "    days = (d - TODAY).days\n",
    "    marker = '\u2713 done' if days < 0 else f'in {days} d'\n",
    "    print(f'  {label:25s} {d.date()}   ({marker})')\n",
    "\n",
    "print()\n",
    "if weeks_done == 0:\n",
    "    nxt = tracker.iloc[0]\n",
    "    print(f'Plan starts {tracker.index[0].date()} \u2014 first week:')\n",
    "    print(f'  wk 1 ({nxt.date_range}): {nxt.phase} \u2014 {nxt.kind}')\n",
    "    print(f'  target {nxt.weekly_km} km, long run {nxt.long_run_km} km')\n",
    "    if nxt.notes: print(f'  note: {nxt.notes}')\n",
    "else:\n",
    "    cur = elapsed.iloc[-1]\n",
    "    print(f'Current week (wk {int(cur.week_num)}, {cur.date_range}):')\n",
    "    print(f'  phase:   {cur.phase} \u2014 {cur.kind}')\n",
    "    print(f'  target:  {cur.weekly_km} km, long run {cur.long_run_km} km')\n",
    "    print(f'  actual:  {cur.actual_km} km, longest {cur.longest_run_km} km ({cur.n_runs} runs)')\n",
    "    print(f'  status:  {cur.status}')\n",
    "    if cur.notes: print(f'  note:    {cur.notes}')\n",
    "    if weeks_left > 0:\n",
    "        nxt = tracker.iloc[weeks_done]\n",
    "        print()\n",
    "        print(f'Next up (wk {int(nxt.week_num)}, {nxt.date_range}):')\n",
    "        print(f'  {nxt.phase} \u2014 {nxt.kind}: {nxt.weekly_km} km, long {nxt.long_run_km} km')\n",
    "        if nxt.notes: print(f'  note: {nxt.notes}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Weekly volume \u2014 planned vs actual"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "fig, ax = plt.subplots(figsize=(15, 5.5))\n",
    "x = np.arange(len(tracker))\n",
    "w = 0.4\n",
    "ax.bar(x - w/2, tracker['weekly_km'], width=w, color='#264653', label='planned', alpha=0.9)\n",
    "\n",
    "_bar_colors = {\n",
    "    'on track': '#2a9d8f',\n",
    "    'over':     '#e9c46a',\n",
    "    'under':    '#f4a261',\n",
    "    'missed':   '#9b2226',\n",
    "    '\u2014':        '#dcdcdc',\n",
    "}\n",
    "actual_colors = [_bar_colors.get(s, '#dcdcdc') for s in tracker['status']]\n",
    "ax.bar(x + w/2, tracker['actual_km'], width=w, color=actual_colors, label='actual', alpha=0.95)\n",
    "\n",
    "max_y = max(tracker['weekly_km'].max(), tracker['actual_km'].max()) * 1.22\n",
    "ax.set_ylim(0, max_y)\n",
    "\n",
    "# Phase shading + labels\n",
    "phase_groups = tracker.reset_index().groupby('phase', sort=False)\n",
    "for p, g in phase_groups:\n",
    "    i0, i1 = int(g.index.min()), int(g.index.max())\n",
    "    ax.axvspan(i0 - 0.5, i1 + 0.5, color='gray', alpha=0.05)\n",
    "    ax.text((i0 + i1) / 2, max_y * 0.96, p, ha='center', fontsize=8.5, color='#444')\n",
    "\n",
    "# Race markers \u2014 star above the actual bar\n",
    "race_x = np.where(tracker['is_race'].to_numpy())[0]\n",
    "for rx in race_x:\n",
    "    ax.scatter([rx], [max_y * 0.88], marker='*', s=220, color='#d62828', zorder=10)\n",
    "    ax.text(rx, max_y * 0.82, tracker['kind'].iloc[rx].replace(' RACE',''),\n",
    "            ha='center', fontsize=8.5, color='#d62828', fontweight='bold')\n",
    "\n",
    "today_x = sum(tracker.index <= TODAY) - 0.5\n",
    "if -0.5 <= today_x <= len(tracker) - 0.5:\n",
    "    ax.axvline(today_x, color='red', ls=':', lw=1.5, label='today')\n",
    "\n",
    "ax.set_xticks(x)\n",
    "ax.set_xticklabels([f'wk{n}' for n in tracker['week_num']], fontsize=8)\n",
    "ax.set_ylabel('km / week')\n",
    "ax.set_title('Weekly volume')\n",
    "ax.legend(loc='upper left')\n",
    "fig.tight_layout()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Long-run progression\n",
    "\n",
    "Race weeks (red stars) replace the planned long run with the race itself. Note the staircase: 22 \u2192 26 \u2192 22 (pre-race ease) \u2192 **30K** \u2192 recover \u2192 28 \u2192 32 \u2192 35 \u2192 22 (pre-race ease) \u2192 **50K** \u2192 recover \u2192 25 \u2192 35 \u2192 **40 (B2B peak)** \u2192 28 \u2192 18 \u2192 **50 mile**."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "fig, ax = plt.subplots(figsize=(15, 4.5))\n",
    "x = np.arange(len(tracker))\n",
    "ax.plot(x, tracker['long_run_km'], 'o-', color='#264653', lw=2, label='planned long run')\n",
    "\n",
    "el = np.asarray(tracker.index <= TODAY)\n",
    "if el.any():\n",
    "    ax.scatter(x[el], tracker.loc[el, 'longest_run_km'].to_numpy(),\n",
    "               s=80, color='#e76f51', zorder=5, label='actual longest of week')\n",
    "\n",
    "race_x = np.where(tracker['is_race'].to_numpy())[0]\n",
    "ax.scatter(race_x, tracker['long_run_km'].iloc[race_x].to_numpy(),\n",
    "           marker='*', s=280, color='#d62828', zorder=10, label='race')\n",
    "\n",
    "today_x = sum(tracker.index <= TODAY) - 0.5\n",
    "if -0.5 <= today_x <= len(tracker) - 0.5:\n",
    "    ax.axvline(today_x, color='red', ls=':', lw=1.5)\n",
    "\n",
    "ax.set_xticks(x)\n",
    "ax.set_xticklabels([f'wk{n}' for n in tracker['week_num']], fontsize=8)\n",
    "ax.set_ylabel('km')\n",
    "ax.set_title('Long-run progression \u2014 planned, actual, races')\n",
    "ax.legend(loc='upper left')\n",
    "fig.tight_layout()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Cumulative volume\n",
    "\n",
    "The forgiving lens \u2014 a missed week is recoverable as long as the cumulative line is within ~10% of plan. Chronic divergence for 3+ weeks is the signal to adjust the plan, not push harder."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "tracker['cum_planned'] = tracker['weekly_km'].cumsum()\n",
    "actual_for_cum = tracker['actual_km'].where(tracker.index <= TODAY, other=np.nan)\n",
    "tracker['cum_actual'] = actual_for_cum.cumsum()\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(15, 4.5))\n",
    "ax.plot(tracker.index, tracker['cum_planned'], color='#264653', lw=2, label='planned cumulative')\n",
    "ax.plot(tracker.index, tracker['cum_actual'], color='#2a9d8f', lw=2, label='actual cumulative')\n",
    "\n",
    "for label, d in RACES.items():\n",
    "    if tracker.index.min() <= d <= tracker.index.max() + pd.Timedelta(days=7):\n",
    "        ax.axvline(d, color='#d62828', alpha=0.5, lw=1)\n",
    "        ax.text(d, ax.get_ylim()[1] * 0.02, label.split('\u2014')[1].strip(),\n",
    "                rotation=90, ha='right', va='bottom', fontsize=8, color='#d62828')\n",
    "\n",
    "if PLAN_START <= TODAY <= tracker.index.max() + pd.Timedelta(days=7):\n",
    "    ax.axvline(TODAY, color='red', ls=':', lw=1.5, label='today')\n",
    "\n",
    "ax.set_ylabel('cumulative km')\n",
    "ax.set_title('Cumulative volume')\n",
    "ax.legend(loc='upper left')\n",
    "fig.autofmt_xdate()\n",
    "fig.tight_layout()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Adherence summary"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "elapsed_only = tracker[tracker.index <= TODAY]\n",
    "if len(elapsed_only) == 0:\n",
    "    print('Plan has not started yet \u2014 no completed weeks to score.')\n",
    "else:\n",
    "    counts = (elapsed_only['status']\n",
    "              .value_counts()\n",
    "              .reindex(['on track', 'over', 'under', 'missed'])\n",
    "              .fillna(0).astype(int))\n",
    "    print('Completed weeks by status:')\n",
    "    print(counts.to_string())\n",
    "    total_planned = elapsed_only['weekly_km'].sum()\n",
    "    total_actual  = elapsed_only['actual_km'].sum()\n",
    "    print()\n",
    "    print(f'Planned km through today: {total_planned:.0f}')\n",
    "    print(f'Actual km through today:  {total_actual:.0f}')\n",
    "    print(f'Recorded adherence: {total_actual / total_planned * 100:.0f}% of plan')\n",
    "    print()\n",
    "    print('Off-watch training (hikes, strength, unrecorded runs) is not in this number.')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Projected fitness / fatigue / form (Banister PMC)\n",
    "\n",
    "Combine historical training-load (from `activities.training_load`) with a forecast built from the plan above to project **CTL / ATL / TSB** through race day. The shape matters more than the absolute values \u2014 a clean taper should land TSB in **+10 to +25** on Sept 12.\n",
    "\n",
    "Forecasting assumption: each plan week's km are converted to daily training-load by multiplying weekly km \u00d7 the historical median TL/km from recent running, then distributing evenly Mon\u2013Sun. The Banister EWMAs (\u03c4=42 for CTL, \u03c4=7 for ATL) smooth out the day-of-week pattern anyway."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from openrun import banister, daily_training_load_series\n",
    "\n",
    "# Historical daily load (running + trail) up to today\n",
    "hist = daily_training_load_series(conn)\n",
    "\n",
    "# TL/km conversion factor \u2014 median across the last 12 months of running\n",
    "recent_acts = pd.read_sql('''\n",
    "    SELECT distance_m, training_load\n",
    "    FROM activities\n",
    "    WHERE activity_type IN ('running','trail_running')\n",
    "      AND training_load IS NOT NULL\n",
    "      AND date(start_time_local) >= date('now','-12 months')\n",
    "      AND distance_m >= 2000\n",
    "''', conn)\n",
    "recent_acts['tl_per_km'] = recent_acts['training_load'] / (recent_acts['distance_m'] / 1000)\n",
    "tl_per_km = recent_acts['tl_per_km'].median()\n",
    "print(f'historical TL/km (last 12 mo, median): {tl_per_km:.1f}')\n",
    "\n",
    "# Build forecast: distribute weekly load across days.\n",
    "# For race weeks: race day gets most of the km, lead-in days are tapered shakeouts.\n",
    "# For training weeks: long run Sat gets ~40%, two mid-week runs ~20% each, rest distributed.\n",
    "# Race-day TL/km is empirically lower (~7) than training (~11) \u2014 long ultras spread out load.\n",
    "RACE_DAY_TL_PER_KM = 7.0  # observed from prior 50K races (mean ~7.5)\n",
    "\n",
    "race_day_set = set(RACES.values())\n",
    "forecast_rows = []\n",
    "for week_start, row in tracker.iterrows():\n",
    "    week_days = [week_start + pd.Timedelta(days=i) for i in range(7)]\n",
    "    is_race_week = any(d in race_day_set for d in week_days)\n",
    "    if is_race_week:\n",
    "        race_d = next(d for d in week_days if d in race_day_set)\n",
    "        # Race itself: long_run_km on race day at race TL/km\n",
    "        race_load = row['long_run_km'] * RACE_DAY_TL_PER_KM\n",
    "        # Remaining km this week (shakeouts) at training TL/km, spread across 3 days\n",
    "        rem_km = max(row['weekly_km'] - row['long_run_km'], 0)\n",
    "        rem_load = rem_km * tl_per_km\n",
    "        for d in week_days:\n",
    "            if d == race_d:\n",
    "                forecast_rows.append((d, race_load))\n",
    "            elif d.weekday() in (0, 2, 4):  # Mon/Wed/Fri shakeouts\n",
    "                forecast_rows.append((d, rem_load / 3))\n",
    "            else:\n",
    "                forecast_rows.append((d, 0.0))\n",
    "    else:\n",
    "        # Training week: long run on Sat (40%), Tue+Thu mid-runs (20% each), Mon/Wed easy (10% each)\n",
    "        weights = {0:0.10, 1:0.20, 2:0.10, 3:0.20, 4:0.00, 5:0.40, 6:0.00}\n",
    "        total_load = row['weekly_km'] * tl_per_km\n",
    "        for d in week_days:\n",
    "            forecast_rows.append((d, total_load * weights.get(d.weekday(), 0)))\n",
    "forecast = pd.Series(dict(forecast_rows))\n",
    "forecast.index.name = 'd'\n",
    "\n",
    "# Splice: historical actual until today, forecast from tomorrow onward\n",
    "combined = pd.concat([\n",
    "    hist[hist.index <= TODAY],\n",
    "    forecast[forecast.index > TODAY],\n",
    "]).sort_index()\n",
    "combined = combined[~combined.index.duplicated(keep='first')]\n",
    "\n",
    "pmc = banister(combined)\n",
    "print(f'PMC range: {pmc.index.min().date()} \u2192 {pmc.index.max().date()}')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 7.5), sharex=True)\n",
    "\n",
    "# Fitness + fatigue\n",
    "ax1.plot(pmc.index, pmc['CTL'], color='#2a9d8f', lw=2.2, label='CTL (fitness, 42d)')\n",
    "ax1.plot(pmc.index, pmc['ATL'], color='#e76f51', lw=1.2, alpha=0.85, label='ATL (fatigue, 7d)')\n",
    "ax1.axvline(TODAY, color='red', ls=':', lw=1.5)\n",
    "ax1.text(TODAY, ax1.get_ylim()[1]*0.95, ' today ', color='red', fontsize=8, va='top')\n",
    "ax1.set_ylabel('training load')\n",
    "ax1.legend(loc='upper left')\n",
    "ax1.grid(alpha=0.3)\n",
    "ax1.set_title('Projected Performance Management Chart \u2014 historical + plan-based forecast')\n",
    "\n",
    "# Form (TSB)\n",
    "ax2.plot(pmc.index, pmc['TSB'], color='#264653', lw=1.5)\n",
    "ax2.axhspan(10, 25, color='#2a9d8f', alpha=0.12, label='race-ready (+10 to +25)')\n",
    "ax2.axhspan(-30, -10, color='#e9c46a', alpha=0.12, label='productive overload (\u221230 to \u221210)')\n",
    "ax2.axhline(0, color='gray', lw=0.6)\n",
    "ax2.axhline(-30, color='#e76f51', ls='--', lw=0.8)\n",
    "ax2.axvline(TODAY, color='red', ls=':', lw=1.5)\n",
    "\n",
    "# Annotate planned races\n",
    "for label, d in RACES.items():\n",
    "    if d in pmc.index:\n",
    "        tsb = pmc.loc[d, 'TSB']\n",
    "        ax2.axvline(d, color='#d62828', alpha=0.55, lw=1)\n",
    "        ax2.scatter([d], [tsb], color='#d62828', s=70, zorder=5)\n",
    "        ax2.annotate(f\"{label.split('\u2014')[1].strip()}\\nTSB={tsb:+.0f}\",\n",
    "                      xy=(d, tsb), xytext=(8, 12),\n",
    "                      textcoords='offset points', fontsize=8.5, color='#d62828',\n",
    "                      fontweight='bold')\n",
    "\n",
    "ax2.set_ylabel('TSB (form)')\n",
    "ax2.legend(loc='lower left', fontsize=9)\n",
    "ax2.grid(alpha=0.3)\n",
    "fig.autofmt_xdate()\n",
    "fig.tight_layout()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Race-day TSB check\n",
    "print('Projected fitness/fatigue/form on each race day:')\n",
    "print()\n",
    "for label, d in RACES.items():\n",
    "    if d not in pmc.index:\n",
    "        print(f'  {label}: outside PMC range'); continue\n",
    "    row = pmc.loc[d]\n",
    "    tsb = row['TSB']\n",
    "    if tsb < -30:   tag = 'severely fatigued \u2014 UNSAFE'\n",
    "    elif tsb < -10: tag = 'productive overload \u2014 not a race-day state'\n",
    "    elif tsb < 0:   tag = 'balanced \u2014 slightly underrested'\n",
    "    elif tsb < 10:  tag = 'sharpening \u2014 taper short'\n",
    "    elif tsb < 25:  tag = 'fresh / peaked \u2014 IDEAL race-day window'\n",
    "    else:           tag = 'detrained \u2014 taper too long'\n",
    "    print(f'  {label}  {d.date()}')\n",
    "    print(f'    CTL={row[\"CTL\"]:>5.1f}  ATL={row[\"ATL\"]:>5.1f}  TSB={tsb:>+5.1f}   \u2192  {tag}')\n",
    "    print()\n",
    "\n",
    "# Historical comparison \u2014 what TSB did prior 50K races land at?\n",
    "print('Historical comparison \u2014 TSB on prior 50K races:')\n",
    "race_history = pd.to_datetime(['2023-09-23','2024-09-21','2025-09-06','2025-09-20'])\n",
    "for rd in race_history:\n",
    "    if rd in pmc.index:\n",
    "        print(f'  {rd.date()}  CTL={pmc.loc[rd,\"CTL\"]:>5.1f}  TSB={pmc.loc[rd,\"TSB\"]:+5.1f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Reading the chart**\n",
    "\n",
    "- **CTL slope after today** is what the plan *promises*. A flat or declining CTL means the plan isn't building fitness \u2014 usually because volume isn't ramping fast enough (or you've already peaked).\n",
    "- **ATL spikes** before each race week are expected \u2014 that's the race effort hitting the 7-day window. The taper then lets ATL bleed off faster than CTL.\n",
    "- **TSB on race day** is the actionable number. If a race lands below +10, the plan's taper into that race is too short; if above +25, too long.\n",
    "- The two earlier races (30K, 50K) are mid-build B-races; **TSB at those races doesn't need to be in the +10\u201325 sweet spot** \u2014 slightly negative is fine, since they're training stimuli, not peak performances.\n",
    "- The **50-mile (wk 17)** is the A-race; TSB here is the one that matters. Adjust the wk 14\u201317 plan rows if it's outside +10 to +25."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Race-specific notes\n",
    "\n",
    "### Wk 4 \u2014 30K (June 13)\n",
    "\n",
    "- **Role:** longest pre-race long run + first dress rehearsal. Don't taper hard; this is training.\n",
    "- **Effort:** controlled aerobic, target avg HR < 155. Negative split is the win.\n",
    "- **Rehearse:** pack, shoes, nutrition cadence, salt. Whatever fails here gets fixed before the 50K.\n",
    "- **Recovery:** one easy week (wk 5) is enough.\n",
    "\n",
    "### Wk 10 \u2014 50K (July 25)\n",
    "\n",
    "- **Role:** real race, but also the calibration run for the 50-mile. Pace it at projected 50-mile effort + 5\u201310 bpm.\n",
    "- **Effort:** controlled. Goal is to finish strong, not PR.\n",
    "- **Calibration:** the average HR you can hold for this distance comfortably \u2248 your 50-mile ceiling. Note the number. Use it Sep 12.\n",
    "- **Recovery:** 2 weeks before resuming hard training (wk 11 deep recovery, wk 12 easy rebuild).\n",
    "\n",
    "### Wk 17 \u2014 50 MILE (September 12)\n",
    "\n",
    "- **Pacing rule:** the average HR from the 50K is the *ceiling*, not the target. Start 5\u201310 bpm below it.\n",
    "- **Fueling:** 60\u201390 g carb/hr from minute 30. Don't skip aid stations.\n",
    "- **Walking strategy:** walk every climb from the start. The race is won in the final 20 km by people who walked the first 20 climbs.\n",
    "- **Drop-bag essentials:** chafe-prevention, headlamp/spare batteries if late finish, dry socks at midway."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Key sessions to nail\n",
    "\n",
    "If everything else slips, these workouts move finish probability most:\n",
    "\n",
    "- **Wk 4 \u2014 30K race.** Validates fueling and kit. A messy 30K is a 50K-fix-it list.\n",
    "- **Wk 8 \u2014 peak long before 50K** (35 km, optionally B2B 28+15). Last big training stimulus before the calibration race.\n",
    "- **Wk 10 \u2014 50K race.** The calibration. Pace data here drives 50-mile pacing.\n",
    "- **Wk 14 \u2014 B2B peak (40 + 22).** The single most important week. Running on tired legs is the 50-mile race in miniature.\n",
    "- **Wk 17 \u2014 race week.** Don't add miles. Stay healthy. Show up."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": ".venv",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}