{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# 02 — Running\n",
    "\n",
    "Volume, pace, HR efficiency, training load."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import sys\n",
    "import pandas as pd\n",
    "import matplotlib.pyplot as plt\n",
    "from openrun import open_conn, load_activities\n",
    "\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": null,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 141312 characters]",
      "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": null,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 124880 characters]",
      "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",
    "ax.set_ylabel('Pace (min/km, faster ↑)')\n",
    "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": null,
   "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": "[encoded data, 50308 characters]",
      "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",
    "ax.scatter(easy['start_time_local'], easy['pace_min_per_km'], alpha=0.3, s=easy['distance_km']*5, label='runs (HR<150, ≥3km)')\n",
    "monthly_easy.plot(ax=ax, color='C3', lw=2, marker='o', label='Monthly median')\n",
    "ax.invert_yaxis()\n",
    "ax.set_ylabel('Pace (min/km, faster ↑)')\n",
    "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": {},
   "source": [
    "## Training load — Banister CTL / ATL / TSB\n",
    "\n",
    "Garmin 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* — EWMA of daily load with τ = 42 days. **Fitness.**\n",
    "- **ATL** *Acute Training Load* — same EWMA with τ = 7 days. **Fatigue.**\n",
    "- **TSB** *Training Stress Balance* — yesterday's CTL minus yesterday's ATL. **Form.**\n",
    "\n",
    "TSB interpretation:\n",
    "\n",
    "| TSB | meaning |\n",
    "|---|---|\n",
    "| < −30 | severely fatigued (injury risk) |\n",
    "| −10 to −30 | productive overload — heart of a build |\n",
    "| −10 to 0 | balanced building |\n",
    "| 0 to +10 | sharpening |\n",
    "| **+10 to +25** | **fresh / peaked — race-day target** |\n",
    "| > +25 | detrained (taper too long) |\n",
    "\n",
    "This replaces the older 7/28-day rolling ACWR plot — same data, EWMAs are smoother and TSB gives you race-day-readiness directly."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "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 — 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 (−30 to −10)')\n",
    "ax2.axhline(0, color='gray', lw=0.6)\n",
    "ax2.axhline(-30, color='#e76f51', ls='--', lw=0.8, label='injury-risk floor (−30)')\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()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Personal records (running)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "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
}
