Files
openrun/examples/notebooks/02_running.ipynb

350 lines
321 KiB
Plaintext
Raw Permalink Normal View History

2026-05-18 12:53:24 -04:00
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
2026-05-19 08:34:22 -04:00
"# 02 \u2014 Running\n",
2026-05-18 12:53:24 -04:00
"\n",
"Volume, pace, HR efficiency, training load."
]
},
{
"cell_type": "code",
2026-05-19 08:34:22 -04:00
"execution_count": null,
2026-05-18 12:53:24 -04:00
"metadata": {},
2026-05-19 08:34:22 -04:00
"outputs": [],
2026-05-18 12:53:24 -04:00
"source": [
"import sys\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
2026-05-19 08:34:22 -04:00
"from openrun import open_conn, load_activities\n",
2026-05-18 12:53:24 -04:00
"\n",
"conn = open_conn()\n",
"runs = load_activities(conn, type='running')\n",
"print(f'{len(runs)} runs from {runs.start_time_local.min().date()} to {runs.start_time_local.max().date()}')"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Weekly volume (km) with 4-week rolling average"
]
},
{
"cell_type": "code",
"execution_count": 2,
"metadata": {},
"outputs": [
{
"data": {
"image/png": "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
"text/plain": [
"<Figure size 1300x400 with 1 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"weekly = (runs.set_index('start_time_local')['distance_km']\n",
" .resample('W-MON', label='left', closed='left').sum())\n",
"\n",
"fig, ax = plt.subplots(figsize=(13, 4))\n",
"weekly.plot(ax=ax, alpha=0.5, label='Weekly')\n",
"weekly.rolling(4, min_periods=1).mean().plot(ax=ax, color='C3', lw=2, label='4-week rolling')\n",
"ax.set_ylabel('km / week')\n",
"ax.set_title('Weekly running volume')\n",
"ax.legend()\n",
"ax.grid(alpha=0.3)\n",
"plt.tight_layout()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Pace vs. average HR (each dot = one run, sized by distance)"
]
},
{
"cell_type": "code",
"execution_count": 3,
"metadata": {},
"outputs": [
{
"data": {
"image/png": "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
"text/plain": [
"<Figure size 1000x600 with 1 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"valid = runs.dropna(subset=['pace_min_per_km', 'avg_hr']).copy()\n",
"valid['year'] = valid['start_time_local'].dt.year\n",
"\n",
"fig, ax = plt.subplots(figsize=(10, 6))\n",
"for yr, g in valid.groupby('year'):\n",
" ax.scatter(g['avg_hr'], g['pace_min_per_km'], s=g['distance_km'] * 6, alpha=0.5, label=str(yr))\n",
"ax.invert_yaxis() # faster pace = smaller number, want top\n",
"ax.set_xlabel('Avg HR (bpm)')\n",
2026-05-19 08:34:22 -04:00
"ax.set_ylabel('Pace (min/km, faster \u2191)')\n",
2026-05-18 12:53:24 -04:00
"ax.set_title('Pace vs. HR by run, sized by distance')\n",
"ax.legend(title='Year')\n",
"ax.grid(alpha=0.3)\n",
"plt.tight_layout()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Aerobic efficiency: pace at HR < 150 over time\n",
"Roughly tracks aerobic fitness. Lower = faster at the same HR = fitter."
]
},
{
"cell_type": "code",
"execution_count": 4,
"metadata": {},
"outputs": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"/Users/noise/Documents/obsidian/RunningLog/garmin/.venv/lib/python3.13/site-packages/pandas/plotting/_matplotlib/core.py:997: UserWarning: This axis already has a converter set and is updating to a potentially incompatible converter\n",
" return ax.plot(*args, **kwds)\n"
]
},
{
"data": {
"image/png": "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t+4XPSbUVVddZbbhyJEjZt1WGs7ovtPby6L7WMfB09daA21rYK0VYHps6r7SAFEfS4+fb775xgRaGuaW7Jpc1mtaHj329L2hz0v/6n014NZt1bER7dep+0iDat23+nrrvtSqND1eNfS1p+/P1q1bmxDVGkrpsavjLOp4ixp46vGhz1PD2M8++6zUJCKXXXaZ6SarYwXqY+k69bXV/V8e/aFA2wEN0/QHgfr165v1a1tR1g8E1veJvoe0K7u+Z/W11H1RFg2Yb7vtNnO7dk1WGqTb09s1oNT9pGGe3kdfC217rPS11uelbcITTzwhmZmZ8uqrr5pAVd/T1tds2LBhpm3Tdep1Wmmqz3HXrl22yzrZih4/9913n3ke+pglA1Wlr50eK7o+/bEDAAC/ZwEAAD7h7bff1uSgzFNERESxZTMzM4tdzs3NtbRv397Sq1cv23XPPfecue/BgwfLfcxvvvnGLPPVV18Vu75jx46W7t27V7rNuoze/5lnnrFdl5OTYzn55JMtiYmJZrtUfn6+ud5ecnKypV69epbRo0fbrluyZIlZ3+23317qsQoLC83fnTt3WkJCQixTp04tdvuGDRssoaGhpa4v6amnnjKPsWPHjlK36fXBwcGW33//vdj11157raVBgwaWQ4cOFbv+8ssvt8TFxdlej6VLl5p1nHjiicWe7/PPP2+u121Uul90/+h+sl9uxowZZrnK9n1eXp4lKCjIctddd5W6bdSoUWYdt912W7F9N3DgQEt4eLjteNDnr8vFxsZaDhw4UGwd1tfv8OHDtuvWr19v9s3IkSNt1+3du9cSHx9v6du3r3kep5xyiqVJkyaWlJQU2zKbN282j/Pqq68We4zBgwdbmjVrZntdy6P7ol27dsWumzdvnlnno48+Wuz6iy++2OyXbdu2Vfqalqfke0vdeOONlujoaEt2dra5rNvcunVrS//+/Yttv963efPmZn9YTZo0yWzD8OHDi61z3bp15vrrrruu2PXjx4831+t7wapp06bmujlz5tiu032sx6Tucyvr8ad/re873R69v77f7FW2363Hh75fymqn7N8/+vqUdcxal+3Tp0+xx7vzzjvNe/jo0aPmclpamqVWrVqW66+/vtj99+3bZ95f1uv1OZS1TfY+++wzs8yqVasslfnxxx/Nsh9//HGlywIA4A/obgwAgI95+eWXTWWM/Umr1uxpFzr7Sj6tVtNuelrlZWWtptPuk9qFtyx9+vQx3Sq1K6bVxo0b5bfffiu3wquk0NDQYl1GtYJQL2tFj3ZDVlqlaB3nTrdFq8q0qk0rA+23ec6cOaZaTyuOStLrlVYE6Tq0ilArLq0nrZDSSq2yujBXhXYZ1a6cVpoz6XYNGjTInLd/TK160n1v/xyUVtnZj+unr43SakFrd0jdP1oVZr+cdhXWrquV0f2n26LVWeWxn2zCWgGpVadaJWhPK7PsKyn37t0r69atM9tiX/mnFalasabdiK10n1uPV32Oej/tfhwbG2tbpk2bNnLGGWcUO8Z0+/WY1upK6+taFboNekzdfvvtxa7XajzdLyXfLyVf04rYv7e04lVfZ31uWtn2559/muv1eWpln3YfPnz4sO140C7Q2rV82bJlpd5z+lqXfA5KKxZLPgf15ZdfFrte36da+Wul+3jkyJGmyk6
"text/plain": [
"<Figure size 1300x400 with 1 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"easy = runs[(runs['avg_hr'] < 150) & (runs['distance_km'] >= 3)].dropna(subset=['pace_min_per_km']).copy()\n",
"monthly_easy = easy.set_index('start_time_local')['pace_min_per_km'].resample('ME').median()\n",
"\n",
"fig, ax = plt.subplots(figsize=(13, 4))\n",
2026-05-19 08:34:22 -04:00
"ax.scatter(easy['start_time_local'], easy['pace_min_per_km'], alpha=0.3, s=easy['distance_km']*5, label='runs (HR<150, \u22653km)')\n",
2026-05-18 12:53:24 -04:00
"monthly_easy.plot(ax=ax, color='C3', lw=2, marker='o', label='Monthly median')\n",
"ax.invert_yaxis()\n",
2026-05-19 08:34:22 -04:00
"ax.set_ylabel('Pace (min/km, faster \u2191)')\n",
2026-05-18 12:53:24 -04:00
"ax.set_title('Easy-pace trend (proxy for aerobic fitness)')\n",
"ax.legend()\n",
"ax.grid(alpha=0.3)\n",
"plt.tight_layout()"
]
},
{
"cell_type": "markdown",
"metadata": {},
2026-05-19 08:34:22 -04:00
"source": "## Training load \u2014 Banister CTL / ATL / TSB\n\nGarmin reports a per-activity training load. The standard endurance-training lens (TrainingPeaks \"Performance Management Chart\") tracks three derived numbers:\n\n- **CTL** *Chronic Training Load* \u2014 EWMA of daily load with \u03c4 = 42 days. **Fitness.**\n- **ATL** *Acute Training Load* \u2014 same EWMA with \u03c4 = 7 days. **Fatigue.**\n- **TSB** *Training Stress Balance* \u2014 yesterday's CTL minus yesterday's ATL. **Form.**\n\nTSB interpretation:\n\n| TSB | meaning |\n|---|---|\n| < \u221230 | severely fatigued (injury risk) |\n| \u221210 to \u221230 | productive overload \u2014 heart of a build |\n| \u221210 to 0 | balanced building |\n| 0 to +10 | sharpening |\n| **+10 to +25** | **fresh / peaked \u2014 race-day target** |\n| > +25 | detrained (taper too long) |\n\nThis replaces the older 7/28-day rolling ACWR plot \u2014 same data, EWMAs are smoother and TSB gives you race-day-readiness directly."
2026-05-18 12:53:24 -04:00
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
2026-05-19 08:34:22 -04:00
"source": [
"from openrun import banister, daily_training_load_series\n",
"\n",
"tl = daily_training_load_series(conn)\n",
"pmc = banister(tl)\n",
"\n",
"fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(13, 7), sharex=True)\n",
"\n",
"# top panel: fitness + fatigue\n",
"ax1.plot(pmc.index, pmc['CTL'], color='#2a9d8f', lw=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.set_ylabel('training load')\n",
"ax1.legend(loc='upper left')\n",
"ax1.grid(alpha=0.3)\n",
"ax1.set_title('Performance Management Chart \u2014 CTL / ATL / TSB')\n",
"\n",
"# bottom panel: form\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, label='injury-risk floor (\u221230)')\n",
"ax2.set_ylabel('TSB (form)')\n",
"ax2.legend(loc='lower left', fontsize=9)\n",
"ax2.grid(alpha=0.3)\n",
"\n",
"# annotate prior race days\n",
"race_dates = pd.to_datetime(['2023-09-23', '2024-09-21', '2025-09-06', '2025-09-20'])\n",
"for rd in race_dates:\n",
" if rd in pmc.index:\n",
" ax2.axvline(rd, color='#d62828', alpha=0.4, lw=1)\n",
" ax2.annotate(f\"{rd.strftime('%Y-%m-%d')}\\nTSB={pmc.loc[rd, 'TSB']:+.0f}\",\n",
" xy=(rd, pmc.loc[rd, 'TSB']), xytext=(5, 8),\n",
" textcoords='offset points', fontsize=8, color='#d62828')\n",
"plt.tight_layout()"
]
2026-05-18 12:53:24 -04:00
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Personal records (running)"
]
},
{
"cell_type": "code",
"execution_count": 6,
"metadata": {},
"outputs": [
{
"data": {
"text/html": [
"<div>\n",
"<style scoped>\n",
" .dataframe tbody tr th:only-of-type {\n",
" vertical-align: middle;\n",
" }\n",
"\n",
" .dataframe tbody tr th {\n",
" vertical-align: top;\n",
" }\n",
"\n",
" .dataframe thead th {\n",
" text-align: right;\n",
" }\n",
"</style>\n",
"<table border=\"1\" class=\"dataframe\">\n",
" <thead>\n",
" <tr style=\"text-align: right;\">\n",
" <th></th>\n",
" <th>distance</th>\n",
" <th>date</th>\n",
" <th>actual_km</th>\n",
" <th>time_min</th>\n",
" <th>pace_min_per_km</th>\n",
" <th>avg_hr</th>\n",
" <th>name</th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>0</th>\n",
" <td>5 km</td>\n",
" <td>2022-07-04</td>\n",
" <td>5.00</td>\n",
" <td>24.2</td>\n",
" <td>4.82</td>\n",
" <td>176.0</td>\n",
" <td>Milford Running</td>\n",
" </tr>\n",
" <tr>\n",
" <th>1</th>\n",
" <td>10 km</td>\n",
" <td>2023-10-28</td>\n",
" <td>9.65</td>\n",
" <td>54.6</td>\n",
" <td>5.63</td>\n",
" <td>136.0</td>\n",
" <td>Milford Running</td>\n",
" </tr>\n",
" <tr>\n",
" <th>2</th>\n",
" <td>15 km</td>\n",
" <td>2024-03-24</td>\n",
" <td>14.63</td>\n",
" <td>93.6</td>\n",
" <td>6.39</td>\n",
" <td>146.0</td>\n",
" <td>Surf City Running</td>\n",
" </tr>\n",
" <tr>\n",
" <th>3</th>\n",
" <td>21.0975 km</td>\n",
" <td>2025-08-09</td>\n",
" <td>21.03</td>\n",
" <td>156.8</td>\n",
" <td>7.23</td>\n",
" <td>152.0</td>\n",
" <td>Milford Running</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
" distance date actual_km time_min pace_min_per_km avg_hr \\\n",
"0 5 km 2022-07-04 5.00 24.2 4.82 176.0 \n",
"1 10 km 2023-10-28 9.65 54.6 5.63 136.0 \n",
"2 15 km 2024-03-24 14.63 93.6 6.39 146.0 \n",
"3 21.0975 km 2025-08-09 21.03 156.8 7.23 152.0 \n",
"\n",
" name \n",
"0 Milford Running \n",
"1 Milford Running \n",
"2 Surf City Running \n",
"3 Milford Running "
]
},
"execution_count": 6,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"def best_for_distance(df, target_km, tolerance=0.5):\n",
" near = df[df['distance_km'].between(target_km - tolerance, target_km + tolerance)]\n",
" if near.empty:\n",
" return None\n",
" return near.nsmallest(1, 'duration_min').iloc[0]\n",
"\n",
"rows = []\n",
"for d in [5, 10, 15, 21.0975, 42.195]:\n",
" best = best_for_distance(runs.dropna(subset=['duration_min']), d)\n",
" if best is None:\n",
" continue\n",
" rows.append({\n",
" 'distance': f'{d} km',\n",
" 'date': best['start_time_local'].date(),\n",
" 'actual_km': round(best['distance_km'], 2),\n",
" 'time_min': round(best['duration_min'], 1),\n",
" 'pace_min_per_km': round(best['pace_min_per_km'], 2) if pd.notna(best['pace_min_per_km']) else None,\n",
" 'avg_hr': best['avg_hr'],\n",
" 'name': best['activity_name'],\n",
" })\n",
"pd.DataFrame(rows)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "garmin (.venv)",
"language": "python",
"name": "garmin"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.13"
}
},
"nbformat": 4,
"nbformat_minor": 5
}