Files
openrun/notebooks/_build_06.py
2026-05-18 12:53:24 -04:00

548 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 20232025 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 analysis 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: 6090 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 56 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** — 85115% of plan",
"- **over** — > 115% *(usually fine; watch fatigue if two in a row)*",
"- **under** — 5085% *(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 MonSun. The Banister EWMAs (τ=42 for CTL, τ=7 for ATL) smooth out the day-of-week pattern anyway.",
))
cells.append(code(
"from analysis 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 +1025 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 1417 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 + 510 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 510 bpm below it.",
"- **Fueling:** 6090 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)")