{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# 04 \u2014 Are you running more efficiently?\n",
    "\n",
    "Heart rate and speed as a function of distance, year over year.\n",
    "\n",
    "## Method\n",
    "\n",
    "**Efficiency metric:** *meters per heartbeat* = `distance_m / (duration_min \u00d7 avg_HR)`.\n",
    "Higher = more forward motion produced per beat = more aerobically efficient.\n",
    "\n",
    "**Controlling for confounders:**\n",
    "- Distance: longer runs naturally drift HR up and pace down \u2192 we bucket by distance.\n",
    "- Effort type: hard intervals are not comparable to easy aerobic runs \u2192 we report an *all runs* view and an *easy runs only* (HR < 155) view.\n",
    "- Activity type: filtered to `running` (excludes trail, cycling, etc.)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import sys\n",
    "import numpy as np\n",
    "import pandas as pd\n",
    "import matplotlib.pyplot as plt\n",
    "from openrun import open_conn, load_activities\n",
    "\n",
    "pd.options.display.float_format = '{:.2f}'.format\n",
    "conn = open_conn()\n",
    "\n",
    "runs = load_activities(conn, type='running').dropna(subset=['avg_hr','pace_min_per_km'])\n",
    "runs['mpb'] = runs['distance_m'] / (runs['duration_s'] / 60 * runs['avg_hr'])\n",
    "runs['dist_bucket'] = pd.cut(runs['distance_km'],\n",
    "    bins=[0, 3, 6, 10, 16, 22, 100],\n",
    "    labels=['<3 km','3-6 km','6-10 km','10-16 km','16-22 km','>22 km'])\n",
    "print(f'{len(runs)} runs with HR + pace, {runs.year.min()}-{runs.year.max()}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Sample sizes\n",
    "Year-over-year claims need enough runs in each bucket. Buckets with <5 runs in a year are noisy."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "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>year</th>\n",
       "      <th>2022</th>\n",
       "      <th>2023</th>\n",
       "      <th>2024</th>\n",
       "      <th>2025</th>\n",
       "      <th>2026</th>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>dist_bucket</th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>&lt;3 km</th>\n",
       "      <td>2</td>\n",
       "      <td>2</td>\n",
       "      <td>3</td>\n",
       "      <td>5</td>\n",
       "      <td>1</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3-6 km</th>\n",
       "      <td>32</td>\n",
       "      <td>32</td>\n",
       "      <td>15</td>\n",
       "      <td>32</td>\n",
       "      <td>12</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>6-10 km</th>\n",
       "      <td>9</td>\n",
       "      <td>29</td>\n",
       "      <td>34</td>\n",
       "      <td>16</td>\n",
       "      <td>5</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>10-16 km</th>\n",
       "      <td>14</td>\n",
       "      <td>12</td>\n",
       "      <td>11</td>\n",
       "      <td>13</td>\n",
       "      <td>4</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>16-22 km</th>\n",
       "      <td>0</td>\n",
       "      <td>4</td>\n",
       "      <td>7</td>\n",
       "      <td>7</td>\n",
       "      <td>2</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>&gt;22 km</th>\n",
       "      <td>0</td>\n",
       "      <td>5</td>\n",
       "      <td>6</td>\n",
       "      <td>8</td>\n",
       "      <td>0</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "year         2022  2023  2024  2025  2026\n",
       "dist_bucket                              \n",
       "<3 km           2     2     3     5     1\n",
       "3-6 km         32    32    15    32    12\n",
       "6-10 km         9    29    34    16     5\n",
       "10-16 km       14    12    11    13     4\n",
       "16-22 km        0     4     7     7     2\n",
       ">22 km          0     5     6     8     0"
      ]
     },
     "execution_count": 2,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "runs.pivot_table(index='dist_bucket', columns='year', values='activity_id',\n",
    "                 aggfunc='count', fill_value=0, observed=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Yearly summary (runs \u2265 5 km)\n",
    "\n",
    "If `m/beat` is *increasing* year over year \u2192 more efficient.\n",
    "If it's *decreasing* with HR roughly constant \u2192 losing fitness *or* something is off (sensor, conditions)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "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>runs</th>\n",
       "      <th>median_pace</th>\n",
       "      <th>median_hr</th>\n",
       "      <th>median_mpb</th>\n",
       "      <th>total_km</th>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>year</th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>2022</th>\n",
       "      <td>39</td>\n",
       "      <td>5.98</td>\n",
       "      <td>152.00</td>\n",
       "      <td>1.08</td>\n",
       "      <td>334.63</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2023</th>\n",
       "      <td>63</td>\n",
       "      <td>6.16</td>\n",
       "      <td>147.00</td>\n",
       "      <td>1.09</td>\n",
       "      <td>682.24</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2024</th>\n",
       "      <td>62</td>\n",
       "      <td>6.46</td>\n",
       "      <td>146.00</td>\n",
       "      <td>1.04</td>\n",
       "      <td>710.54</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2025</th>\n",
       "      <td>47</td>\n",
       "      <td>6.90</td>\n",
       "      <td>149.00</td>\n",
       "      <td>0.94</td>\n",
       "      <td>705.61</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2026</th>\n",
       "      <td>14</td>\n",
       "      <td>7.93</td>\n",
       "      <td>150.50</td>\n",
       "      <td>0.81</td>\n",
       "      <td>145.87</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "      runs  median_pace  median_hr  median_mpb  total_km\n",
       "year                                                    \n",
       "2022    39         5.98     152.00        1.08    334.63\n",
       "2023    63         6.16     147.00        1.09    682.24\n",
       "2024    62         6.46     146.00        1.04    710.54\n",
       "2025    47         6.90     149.00        0.94    705.61\n",
       "2026    14         7.93     150.50        0.81    145.87"
      ]
     },
     "execution_count": 3,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "serious = runs[runs['distance_km'] >= 5]\n",
    "yearly = serious.groupby('year').agg(\n",
    "    runs=('activity_id','count'),\n",
    "    median_pace=('pace_min_per_km','median'),\n",
    "    median_hr=('avg_hr','median'),\n",
    "    median_mpb=('mpb','median'),\n",
    "    total_km=('distance_km','sum'),\n",
    ").round(2)\n",
    "yearly"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Meters per heartbeat \u2014 the headline efficiency view\n",
    "\n",
    "One line per year. Each line shows the median efficiency at each distance bucket."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 81452 characters]",
      "text/plain": [
       "<Figure size 1100x500 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "eff_pv = runs.pivot_table(index='dist_bucket', columns='year',\n",
    "                          values='mpb', aggfunc='median', observed=True)\n",
    "fig, ax = plt.subplots(figsize=(11, 5))\n",
    "eff_pv.plot(ax=ax, marker='o')\n",
    "ax.set_ylabel('Meters per heartbeat')\n",
    "ax.set_xlabel('Run distance')\n",
    "ax.set_title('Aerobic efficiency by distance, year over year')\n",
    "ax.legend(title='Year')\n",
    "ax.grid(alpha=0.3)\n",
    "plt.tight_layout()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Same picture split out: HR and pace separately"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 118548 characters]",
      "text/plain": [
       "<Figure size 1300x500 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "hr_pv = runs.pivot_table(index='dist_bucket', columns='year',\n",
    "                         values='avg_hr', aggfunc='median', observed=True)\n",
    "pace_pv = runs.pivot_table(index='dist_bucket', columns='year',\n",
    "                           values='pace_min_per_km', aggfunc='median', observed=True)\n",
    "\n",
    "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5))\n",
    "hr_pv.plot(ax=ax1, marker='o')\n",
    "ax1.set_title('Median avg HR by distance')\n",
    "ax1.set_ylabel('bpm'); ax1.grid(alpha=0.3); ax1.legend(title='Year')\n",
    "\n",
    "pace_pv.plot(ax=ax2, marker='o')\n",
    "ax2.set_title('Median pace by distance')\n",
    "ax2.set_ylabel('min/km (faster \u2193)'); ax2.grid(alpha=0.3); ax2.legend(title='Year')\n",
    "plt.tight_layout()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Pace vs HR scatter \u2014 every run, colored by year\n",
    "\n",
    "If you're getting more efficient, points should drift **right and up** (faster pace at same HR) over time."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 126292 characters]",
      "text/plain": [
       "<Figure size 1000x700 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "fig, ax = plt.subplots(figsize=(10, 7))\n",
    "for yr, g in runs.groupby('year'):\n",
    "    ax.scatter(g['avg_hr'], g['pace_min_per_km'], s=g['distance_km']*5,\n",
    "               alpha=0.5, label=str(yr))\n",
    "ax.invert_yaxis()\n",
    "ax.set_xlabel('Avg HR (bpm)')\n",
    "ax.set_ylabel('Pace (min/km, faster \u2191)')\n",
    "ax.set_title('Every run, sized by distance, colored by year')\n",
    "ax.legend(title='Year')\n",
    "ax.grid(alpha=0.3)\n",
    "plt.tight_layout()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Easy runs only (HR < 155, \u22655km)\n",
    "\n",
    "This is the cleanest comparison \u2014 aerobic baseline pace at a given heart-rate ceiling.\n",
    "Hard sessions vary a lot; easy runs are more reproducible."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "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>n</th>\n",
       "      <th>median_pace</th>\n",
       "      <th>median_hr</th>\n",
       "      <th>median_mpb</th>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>year</th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>2022</th>\n",
       "      <td>22</td>\n",
       "      <td>6.11</td>\n",
       "      <td>147.00</td>\n",
       "      <td>1.12</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2023</th>\n",
       "      <td>47</td>\n",
       "      <td>6.15</td>\n",
       "      <td>144.00</td>\n",
       "      <td>1.11</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2024</th>\n",
       "      <td>42</td>\n",
       "      <td>6.42</td>\n",
       "      <td>142.00</td>\n",
       "      <td>1.10</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2025</th>\n",
       "      <td>32</td>\n",
       "      <td>6.84</td>\n",
       "      <td>144.50</td>\n",
       "      <td>0.98</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2026</th>\n",
       "      <td>10</td>\n",
       "      <td>7.75</td>\n",
       "      <td>149.50</td>\n",
       "      <td>0.85</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "       n  median_pace  median_hr  median_mpb\n",
       "year                                        \n",
       "2022  22         6.11     147.00        1.12\n",
       "2023  47         6.15     144.00        1.11\n",
       "2024  42         6.42     142.00        1.10\n",
       "2025  32         6.84     144.50        0.98\n",
       "2026  10         7.75     149.50        0.85"
      ]
     },
     "execution_count": 7,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "HR_CEIL = 155\n",
    "easy = runs[(runs['avg_hr'] < HR_CEIL) & (runs['distance_km'] >= 5)]\n",
    "easy_summary = easy.groupby('year').agg(\n",
    "    n=('activity_id','count'),\n",
    "    median_pace=('pace_min_per_km','median'),\n",
    "    median_hr=('avg_hr','median'),\n",
    "    median_mpb=('mpb','median'),\n",
    ").round(2)\n",
    "easy_summary"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "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, 63688 characters]",
      "text/plain": [
       "<Figure size 1300x400 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "monthly = (easy.set_index('start_time_local')\n",
    "                .sort_index()['mpb']\n",
    "                .resample('ME').median())\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(13, 4))\n",
    "ax.scatter(easy['start_time_local'], easy['mpb'], alpha=0.4, s=easy['distance_km']*4, label='Run (sized by km)')\n",
    "monthly.plot(ax=ax, color='C3', lw=2, marker='o', label='Monthly median')\n",
    "ax.set_ylabel('Meters per heartbeat')\n",
    "ax.set_title(f'Easy-run efficiency over time (HR < {HR_CEIL}, \u22655km)')\n",
    "ax.legend()\n",
    "ax.grid(alpha=0.3)\n",
    "plt.tight_layout()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Curve fits \u2014 HR and pace as a function of distance, per year\n",
    "\n",
    "Smooth curves instead of bucketed bars, so you can see the shape clearly."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 175164 characters]",
      "text/plain": [
       "<Figure size 1300x500 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "from numpy.polynomial import polynomial as P\n",
    "\n",
    "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5))\n",
    "x_grid = np.linspace(1, runs['distance_km'].max(), 100)\n",
    "for yr, g in runs.groupby('year'):\n",
    "    g = g.dropna(subset=['distance_km','avg_hr','pace_min_per_km'])\n",
    "    if len(g) < 8:\n",
    "        continue\n",
    "    # Fit a low-order polynomial in log-distance (HR + pace both roughly log-shaped vs distance)\n",
    "    for ax, y_col, label in [(ax1, 'avg_hr', 'Avg HR'), (ax2, 'pace_min_per_km', 'Pace (min/km)')]:\n",
    "        coef = np.polyfit(np.log(g['distance_km']), g[y_col], 2)\n",
    "        ax.scatter(g['distance_km'], g[y_col], alpha=0.3, s=15, label=None)\n",
    "        ax.plot(x_grid, np.polyval(coef, np.log(x_grid)), lw=2, label=str(yr))\n",
    "\n",
    "ax1.set_xscale('log')\n",
    "ax1.set_xlabel('Distance (km, log)'); ax1.set_ylabel('Avg HR (bpm)')\n",
    "ax1.set_title('HR vs distance, per year'); ax1.legend(title='Year'); ax1.grid(alpha=0.3)\n",
    "\n",
    "ax2.set_xscale('log')\n",
    "ax2.invert_yaxis()\n",
    "ax2.set_xlabel('Distance (km, log)'); ax2.set_ylabel('Pace (min/km, faster \u2191)')\n",
    "ax2.set_title('Pace vs distance, per year'); ax2.legend(title='Year'); ax2.grid(alpha=0.3)\n",
    "plt.tight_layout()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Interpretation\n",
    "\n",
    "**If m/beat is going up, pace is going down (faster), HR roughly flat:** you're getting fitter.\n",
    "\n",
    "**If m/beat is going down, pace is going up (slower), HR roughly flat:** you're losing aerobic fitness OR something is biasing the data:\n",
    "- HR sensor change (wrist optical drift higher than chest strap by ~5-10 bpm)\n",
    "- Hotter / hillier running environment\n",
    "- More easy/recovery running by design (intent shift)\n",
    "- Reduced training volume per week\n",
    "- Injury, illness, or major life change cutting consistent training\n",
    "\n",
    "**Quick sensor sanity check:** look at your maximum HR per year. If max HR drops sharply, the sensor or device may have changed."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "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>n_runs</th>\n",
       "      <th>median_avg_hr</th>\n",
       "      <th>median_max_hr</th>\n",
       "      <th>p95_max_hr</th>\n",
       "      <th>max_hr</th>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>year</th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>2022</th>\n",
       "      <td>57</td>\n",
       "      <td>153.00</td>\n",
       "      <td>181.00</td>\n",
       "      <td>193.20</td>\n",
       "      <td>198.00</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2023</th>\n",
       "      <td>84</td>\n",
       "      <td>146.00</td>\n",
       "      <td>169.00</td>\n",
       "      <td>186.80</td>\n",
       "      <td>190.00</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2024</th>\n",
       "      <td>76</td>\n",
       "      <td>143.50</td>\n",
       "      <td>173.00</td>\n",
       "      <td>186.20</td>\n",
       "      <td>189.00</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2025</th>\n",
       "      <td>81</td>\n",
       "      <td>147.00</td>\n",
       "      <td>175.00</td>\n",
       "      <td>185.00</td>\n",
       "      <td>191.00</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2026</th>\n",
       "      <td>24</td>\n",
       "      <td>149.50</td>\n",
       "      <td>177.50</td>\n",
       "      <td>190.00</td>\n",
       "      <td>191.00</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "      n_runs  median_avg_hr  median_max_hr  p95_max_hr  max_hr\n",
       "year                                                          \n",
       "2022      57         153.00         181.00      193.20  198.00\n",
       "2023      84         146.00         169.00      186.80  190.00\n",
       "2024      76         143.50         173.00      186.20  189.00\n",
       "2025      81         147.00         175.00      185.00  191.00\n",
       "2026      24         149.50         177.50      190.00  191.00"
      ]
     },
     "execution_count": 10,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "runs.groupby('year').agg(\n",
    "    n_runs=('activity_id','count'),\n",
    "    median_avg_hr=('avg_hr','median'),\n",
    "    median_max_hr=('max_hr','median'),\n",
    "    p95_max_hr=('max_hr', lambda s: s.quantile(0.95)),\n",
    "    max_hr=('max_hr','max'),\n",
    ").round(1)"
   ]
  }
 ],
 "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
}