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