Files
openrun/examples/notebooks/04_efficiency.ipynb

775 lines
575 KiB
Plaintext
Raw Normal View History

2026-05-18 12:53:24 -04:00
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# 04 — 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 × 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 → we bucket by distance.\n",
"- Effort type: hard intervals are not comparable to easy aerobic runs → 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": 1,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"322 runs with HR + pace, 2022-2026\n"
]
}
],
"source": [
"import sys\n",
"sys.path.insert(0, '..')\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from analysis 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 ≥ 5 km)\n",
"\n",
"If `m/beat` is *increasing* year over year → more efficient.\n",
"If it's *decreasing* with HR roughly constant → 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 — 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": "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
"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": "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
"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 ↓)'); ax2.grid(alpha=0.3); ax2.legend(title='Year')\n",
"plt.tight_layout()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Pace vs HR scatter — 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": "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
"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 ↑)')\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, ≥5km)\n",
"\n",
"This is the cleanest comparison — 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": "iVBORw0KGgoAAAANSUhEUgAABQkAAAGGCAYAAADYVwfrAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjksIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvJkbTWQAAAAlwSFlzAAAPYQAAD2EBqD+naQAAugJJREFUeJzs3QWYVGUXB/Az211sA0uIilIS0iEdFpiAtPCh0ohISQiCCCiIIEq30gqKlNKdBgiSC9vdOTvfc96ducz2zO70/H/PM3Lv5N07M9eZMydkCoVCQQAAAAAAAAAAAGC1bIy9AQAAAAAAAAAAAGBcCBICAAAAAAAAAABYOQQJAQAAAAAAAAAArByChAAAAAAAAAAAAFYOQUIAAAAAAAAAAAArhyAhAAAAAAAAAACAlUOQEAAAAAAAAAAAwMohSAgAAAAAAAAAAGDlECQEAAAAAAAAAACwcggSAgAAgFn577//qEuXLuTp6UkymYz27Nkjzr9w4QK1bNmSXF1dxflXr16lmTNnimVtvfDCC+IExnX06FHx/PG/piQ1NZX8/f1p8+bNxt4U0IN169aJ193Fixf1un9/++03cnNzo5iYGL0+DgAAgKYQJAQAANDDl8uSTmfPnsX+rqCBAwfSX3/9RZ999hlt3LiRmjRpQjk5OfTmm29SfHw8ffXVV+L8atWqYV+bieXLl4v3jrlYsmQJubu7U+/evaXzVAHp2NjYYm9TvXp1eumllwqcV/j44OHhQe3ataNffvmFjOHgwYP07rvvUt26dcnW1lZsc3Hu379f4jHuhx9+KHDdQYMGFXu92rVrG+ivMs1tYd26daNatWrRvHnzjPL4AAAAhdkVOQcAAAAq7NNPP6UaNWoUOZ+/EEL5ZWRk0JkzZ2jq1Kk0cuRI6fx///2XHjx4QCtXrqShQ4dK50+bNo0mTZpUrmAJGDZI6OvrK4I46tq2bSuecwcHB5N5OjggzUHCcePGiUBaRXXu3JkGDBhACoVCvIa//fZbevnll2n//v3UtWtXMqQtW7bQjz/+SI0aNaLg4OAyr9+nTx/q0aNHgfNatGhR5HqOjo60atWqAudxJrAxmNK2sOHDh9OECRNo1qxZIvAMAABgTAgSAgAA6EH37t1Fhpu5Sk9PJxcXFzI1qrI8Ly+vAudHR0cXe76dnZ04acuUglKWIC8vj7Kzs8nJyUmr29nY2Gh9G33bt2+feB2+9dZbOrm/p556ivr16yetv/766/Tss8+KQKSugoQpKSkUFxdXYmagyty5c0Wg3d7eXmQ9/v3336Ven4OJ6tteEn4PanI9QzClbVE936NGjaLt27fTkCFDjL05AABg5VBuDAAAYCQLFy4UPfQqVapEzs7O1LhxY9qxY0eR6x06dIhat24tAmDcv+rpp5+mKVOmSL3RuAffmDFjitzu0aNHItOprFI27r3H5YWXLl0SmVscHFTdP5ficRllYRxsUM/6UpVZnzp1isaPH09+fn5iu3r16qVxvy3OBnzjjTfIx8dHBIY4yPrzzz9Ll/N2qEqIP/roI/F4qu3gEk3GJcd8vqqfYEk9CTdt2kRNmzYVf6u3t7f4u9WzB4vrSZiVlUUzZswQ2aCcjVS1alWaOHGiOF8dPx5nOXKvRN6vfN06deqI/mOFhYWFifJOztri63H26fvvvy8Canfv3hX3xeXThZ0+fVpctnXr1lL3KQdP+f4DAgLEPm3QoAGtX7++QFYc7+/BgwcXuW1ycrK4DWc5lXcfcM8+/tv5usX9/Yyfw3/++YeOHTsmlX+q9n1xPQlVr9c///xTPO/8HPL2qN47fD/NmjUT7yl+rxw+fLjY/c4BGd4vqudnzZo1pAl+Xnmbn3jiCdKHZ555RmRV3rlzp8L3dfLkSfHcBgUFSb07S8OvQw4QaiMtLU28Xssil8vFa0oXVqxYQe+99x7t3btX/KChrfJsS0JCgjhmVKlShW7evCnO42MPH5NDQ0NFUJWXK1euTMuWLROXc1uEDh06iGMhH7s4U7Mw7m1Zv359+umnn7T+OwAAAHQNQUIAAAA9SEpKEr3J1E+cyaOOM4UaNmwoSpM5g4czXDjIpd6PjIMn/OWTgzB8vUWLFtErr7wignGMv5RyII5LBPmLrzoOIHEJ4zvvvFPm9vK2cfbjc889R4sXL6b27duX6+/mjJhr166JQBIHu/hLvHpZcEn472zevDnduHFDlAfz38lfrHv27Em7d+8W13nttdekgBmXOXLfQd5WLtdTBTVHjx4tzudy5JJwWV///v1FMIT3Ka9zsOv3338vNROO9zsHdrkUdOnSpWLbeHvefvvtYoMzH3zwgehZ98UXX1BmZqbIGFJ/DYSHh4ugA/dw4/v4+uuvxXZxkIsDHzVr1qRWrVoVOxyDz+PSxFdffbXEbeYyXQ6o8f7g18CCBQtEWSUHNvi1x3gf8OuHA0iFAz18Hr/uVH33tN0HvD+5JJcv48crKYuNn0MOvHBfON7Wsp4/VcCG3xccDOT9y4E+3k5+H/C/XAL7+eefiwAWB545k04lKipKvNY4eMivTd42DjJyMJW3pSwcoOUMupJwX8zC730+8f7T9NjBfx8Hr8uD/z5+rnl/tmnTRmQ+cgk+P1e6xu8dPgZxMPn5558vsUyfX8/cb5FffxyUHjFihPiBo7w4uHvkyBHxeuQfWfjY9c0339C9e/fKvG15toWfPw728b7l9ycHn1X4uMuPz8cQfi3y65xfV/zDCfcc5B875s+fL96vXFZe3DbyD0T8ugIAADA6BQAAAOjM2rVrFfy/1+JOjo6OBa6bnp5eYD07O1tRt25dRYcOHaTzvvrqK3HbmJiYEh/zwIED4jr79+8vcH79+vUV7dq1K3Ob+Tp8+xUrVhS5jM+fMWNGkfOrVaumGDhwYJG/u1OnToq8vDzp/HHjxilsbW0ViYmJpW5Dx44dFfXq1VNkZmZK5/H9tGzZUvHkk09K5927d088zoIFCwrc/o8//hDnb9++vcD5vO3qH3f+++8/hY2NjaJXr14KuVxe4Lrq2837RH3fbdy4UdzuxIkTBW7D+4zv/9SpUwX2mYODg+L27dvSedeuXRPnL126VDpvwIAB4j4vXLhQZH+otuW7774Tt7tx40aB14mvr2+B/V+cxYsXi9tu2rSpwG1btGihcHNzUyQnJxd4/ezdu7fA7Xv06KGoWbNmufcBX/eff/5RaKJOnTrFvlZVzyv/W/j1umXLFum8f//9V3rMs2fPSuer/jZ+faq8++67iqCgIEVsbGyBx+rdu7fC09OzyPtSXU5OjkImkyk+/PDDIpepXmulnV588cUCt+HzeHv4/R0dHa24ePGiolu3bsW+xkuTm5srnr+ePXsq7OzsxHuOH2vHjh3iOS8Pvj2/z4vz4MEDRZcuXRTffvut4ueffxavtZCQELH/9+3bV+C6kyZNUnz88ceKH3/8UbF161bxuuW/r1WrVmJ/VgQ/7wsXLlS0b99eYW9vL+73mWeeUUyYMEG8Zgr/7Zpui+p4xu/NiIgI8frk98L9+/cL3J/q9nPnzpXOS0hIUDg7O4vXyQ8//FBgW0s6nvLt+bKoqKgK7Q8AAICKQk9CAAAAPeByM+41pq7wkAMuh1ThzCHOSOHMH/USUlWPPS5F47JB7tFWWKdOnUSZIGeXceYK415iXI7J/cU0wZlYxZWcaut///tfgfJe/ns404wHMnBJXUmZV5x1xll9nPGlnvXFPdk4K5HLQ7mMr6I4O44zuqZPn15kXxZXlqzC/cK4DJSzs9Sn13J2Efvjjz9E6bj6c6Jejsp/O2cvcQkx423gbeGMvOJ6V6q2hfvecSk5P7ezZ88W5x04cEBsQ1l91X799VcKDAwUWZcqnDnI2ZZ8HmdEcTYe/w1c3spZeKrpu/x65DJ39VJjbfcBlwJzbz194Ow19cnCnNnF7xV+jXB2oYpqWbXfOS63c+dOsV95Wf3v4NcaZ3VevnxZZHCW9Frl25WW5cf3z891YSU9X6tXrxYn9eeIS7i5bF8TnHXJWWucmcr7Yc6cOSJjjUuM9SUkJES8DtVxFiw/3x9++CG9+OK
"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}, ≥5km)')\n",
"ax.legend()\n",
"ax.grid(alpha=0.3)\n",
"plt.tight_layout()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Curve fits — 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": "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
"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 ↑)')\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
}