2026-05-18 12:53:24 -04:00
""" 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 " ,
2026-05-19 08:34:22 -04:00
" from openrun import open_conn, load_activities " ,
2026-05-18 12:53:24 -04:00
" " ,
" 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 % o f 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 % o f 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} % o f 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 (
2026-05-19 08:34:22 -04:00
" from openrun import banister, daily_training_load_series " ,
2026-05-18 12:53:24 -04:00
" " ,
" # 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 % e ach, 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 % e ach), Mon/Wed easy (10 % e ach) " ,
" 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) " )