1.x updates

This commit is contained in:
2026-05-19 08:34:22 -04:00
parent 3f3fce62d3
commit 9d91ac8ebc
53 changed files with 4541 additions and 2111 deletions

View File

@@ -4,7 +4,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"# 01 Overview\n",
"# 01 \u2014 Overview\n",
"\n",
"Sanity-check the synced data: what's there, how complete it is, and what you've been up to recently.\n",
"\n",
@@ -13,7 +13,7 @@
},
{
"cell_type": "code",
"execution_count": 1,
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
@@ -22,7 +22,7 @@
"\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from analysis import open_conn, load_activities, load_wellness\n",
"from openrun import open_conn, load_activities, load_wellness\n",
"\n",
"pd.options.display.float_format = '{:.2f}'.format\n",
"conn = open_conn()"
@@ -514,7 +514,7 @@
"ax.set_xticklabels(['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'])\n",
"ax.set_ylabel('km')\n",
"ax.set_xlabel('')\n",
"ax.set_title('Monthly running km year over year')\n",
"ax.set_title('Monthly running km \u2014 year over year')\n",
"ax.legend(title='Year')\n",
"ax.grid(alpha=0.3)\n",
"plt.tight_layout()"
@@ -628,4 +628,4 @@
},
"nbformat": 4,
"nbformat_minor": 5
}
}

View File

@@ -4,30 +4,21 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"# 02 Running\n",
"# 02 \u2014 Running\n",
"\n",
"Volume, pace, HR efficiency, training load."
]
},
{
"cell_type": "code",
"execution_count": 1,
"execution_count": null,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"331 runs from 2022-04-09 to 2026-05-10\n"
]
}
],
"outputs": [],
"source": [
"import sys\n",
"sys.path.insert(0, '..')\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from analysis import open_conn, load_activities\n",
"from openrun import open_conn, load_activities\n",
"\n",
"conn = open_conn()\n",
"runs = load_activities(conn, type='running')\n",
@@ -103,7 +94,7 @@
" 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_ylabel('Pace (min/km, faster \u2191)')\n",
"ax.set_title('Pace vs. HR by run, sized by distance')\n",
"ax.legend(title='Year')\n",
"ax.grid(alpha=0.3)\n",
@@ -147,10 +138,10 @@
"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",
"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",
"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_ylabel('Pace (min/km, faster \u2191)')\n",
"ax.set_title('Easy-pace trend (proxy for aerobic fitness)')\n",
"ax.legend()\n",
"ax.grid(alpha=0.3)\n",
@@ -160,14 +151,49 @@
{
"cell_type": "markdown",
"metadata": {},
"source": "## Training load 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* 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\nTSB 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\nThis replaces the older 7/28-day rolling ACWR plot same data, EWMAs are smoother and TSB gives you race-day-readiness directly."
"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."
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": "from analysis import banister, daily_training_load_series\n\ntl = daily_training_load_series(conn)\npmc = banister(tl)\n\nfig, (ax1, ax2) = plt.subplots(2, 1, figsize=(13, 7), sharex=True)\n\n# top panel: fitness + fatigue\nax1.plot(pmc.index, pmc['CTL'], color='#2a9d8f', lw=2, label='CTL (fitness, 42d)')\nax1.plot(pmc.index, pmc['ATL'], color='#e76f51', lw=1.2, alpha=0.85, label='ATL (fatigue, 7d)')\nax1.set_ylabel('training load')\nax1.legend(loc='upper left')\nax1.grid(alpha=0.3)\nax1.set_title('Performance Management Chart — CTL / ATL / TSB')\n\n# bottom panel: form\nax2.plot(pmc.index, pmc['TSB'], color='#264653', lw=1.5)\nax2.axhspan(10, 25, color='#2a9d8f', alpha=0.12, label='race-ready (+10 to +25)')\nax2.axhspan(-30, -10, color='#e9c46a', alpha=0.12, label='productive overload (30 to 10)')\nax2.axhline(0, color='gray', lw=0.6)\nax2.axhline(-30, color='#e76f51', ls='--', lw=0.8, label='injury-risk floor (30)')\nax2.set_ylabel('TSB (form)')\nax2.legend(loc='lower left', fontsize=9)\nax2.grid(alpha=0.3)\n\n# annotate prior race days\nrace_dates = pd.to_datetime(['2023-09-23', '2024-09-21', '2025-09-06', '2025-09-20'])\nfor 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')\nplt.tight_layout()"
"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()"
]
},
{
"cell_type": "markdown",

View File

@@ -4,7 +4,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"# 03 Recovery & wellness\n",
"# 03 \u2014 Recovery & wellness\n",
"\n",
"Sleep, HRV, RHR, body battery, and how they relate to training load."
]
@@ -16,23 +16,22 @@
"outputs": [],
"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_wellness, joined\n",
"from openrun import open_conn, load_wellness, joined\n",
"\n",
"conn = open_conn()\n",
"w = load_wellness(conn)\n",
"j = joined(conn)\n",
"print(f'{len(w)} days, {w.index.min().date()} {w.index.max().date()}')"
"print(f'{len(w)} days, {w.index.min().date()} \u2192 {w.index.max().date()}')"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Recent 30 days at a glance"
"## Recent 30 days \u2014 at a glance"
]
},
{
@@ -74,7 +73,7 @@
"fig, ax = plt.subplots(figsize=(13, 4))\n",
"stages.tail(60).plot.area(ax=ax, alpha=0.7, color=['#1f3a93','#6dd5fa','#9b59b6','#e74c3c'])\n",
"ax.set_ylabel('Hours')\n",
"ax.set_title('Sleep stages last 60 nights')\n",
"ax.set_title('Sleep stages \u2014 last 60 nights')\n",
"ax.grid(alpha=0.3)\n",
"plt.tight_layout()"
]
@@ -185,9 +184,15 @@
}
],
"metadata": {
"kernelspec": {"display_name": ".venv", "language": "python", "name": "python3"},
"language_info": {"name": "python"}
"kernelspec": {
"display_name": ".venv",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
}

View File

@@ -4,41 +4,32 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"# 04 Are you running more efficiently?\n",
"# 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 × avg_HR)`.\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 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",
"- 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": 1,
"execution_count": null,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"322 runs with HR + pace, 2022-2026\n"
]
}
],
"outputs": [],
"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",
"from openrun import open_conn, load_activities\n",
"\n",
"pd.options.display.float_format = '{:.2f}'.format\n",
"conn = open_conn()\n",
@@ -178,10 +169,10 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Yearly summary (runs 5 km)\n",
"## Yearly summary (runs \u2265 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)."
"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)."
]
},
{
@@ -301,7 +292,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Meters per heartbeat the headline efficiency view\n",
"## Meters per heartbeat \u2014 the headline efficiency view\n",
"\n",
"One line per year. Each line shows the median efficiency at each distance bucket."
]
@@ -371,7 +362,7 @@
"\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",
"ax2.set_ylabel('min/km (faster \u2193)'); ax2.grid(alpha=0.3); ax2.legend(title='Year')\n",
"plt.tight_layout()"
]
},
@@ -379,7 +370,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Pace vs HR scatter every run, colored by year\n",
"## 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."
]
@@ -407,7 +398,7 @@
" 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_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",
@@ -418,9 +409,9 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Easy runs only (HR < 155, 5km)\n",
"## Easy runs only (HR < 155, \u22655km)\n",
"\n",
"This is the cleanest comparison aerobic baseline pace at a given heart-rate ceiling.\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."
]
},
@@ -563,7 +554,7 @@
"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.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()"
@@ -573,7 +564,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Curve fits HR and pace as a function of distance, per year\n",
"## 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."
]
@@ -615,7 +606,7 @@
"\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_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()"
]
@@ -771,4 +762,4 @@
},
"nbformat": 4,
"nbformat_minor": 5
}
}

View File

@@ -18,11 +18,10 @@
"outputs": [],
"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 (\n",
"from openrun import (\n",
" open_conn, load_splits, decoupling,\n",
" assign_hr_zone, cluster_routes, haversine_km,\n",
")\n",
@@ -166,7 +165,7 @@
"metadata": {},
"outputs": [],
"source": [
"from analysis import load_fit_records, fit_decoupling, fit_rolling_efficiency\n",
"from openrun import load_fit_records, fit_decoupling, fit_rolling_efficiency\n",
"\n",
"race_meta = pd.read_sql('''\n",
" SELECT a.activity_id, a.start_time_local, a.distance_m/1000 AS km, a.avg_hr\n",
@@ -627,7 +626,7 @@
"metadata": {},
"outputs": [],
"source": [
"from analysis import HR_ZONES_USER\n",
"from openrun import HR_ZONES_USER\n",
"\n",
"tiz = pd.read_sql('''\n",
" SELECT t.activity_id, t.z1_s, t.z2_s, t.z3_s, t.z4_s, t.z5_s, t.total_s, t.source,\n",
@@ -660,7 +659,7 @@
"metadata": {},
"outputs": [],
"source": [
"from analysis import load_fit_records, time_in_zone_from_fit, time_in_zone_from_splits\n",
"from openrun import load_fit_records, time_in_zone_from_fit, time_in_zone_from_splits\n",
"\n",
"race_aid = race_meta.iloc[-1]['activity_id'] # most recent 50K (2025-09-20)\n",
"race_date = race_meta.iloc[-1]['start_time_local'].date()\n",

View File

@@ -25,11 +25,10 @@
"metadata": {},
"outputs": [],
"source": [
"import sys; 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",
"from openrun import open_conn, load_activities\n",
"\n",
"PLAN_START = pd.Timestamp('2026-05-18')\n",
"RACES = {\n",
@@ -378,7 +377,7 @@
"metadata": {},
"outputs": [],
"source": [
"from analysis import banister, daily_training_load_series\n",
"from openrun import banister, daily_training_load_series\n",
"\n",
"# Historical daily load (running + trail) up to today\n",
"hist = daily_training_load_series(conn)\n",

View File

@@ -46,7 +46,7 @@ cells.append(code(
"import numpy as np",
"import pandas as pd",
"import matplotlib.pyplot as plt",
"from analysis import (",
"from openrun import (",
" open_conn, load_splits, decoupling,",
" assign_hr_zone, cluster_routes, haversine_km,",
")",
@@ -159,7 +159,7 @@ cells.append(md(
))
cells.append(code(
"from analysis import load_fit_records, fit_decoupling, fit_rolling_efficiency",
"from openrun import load_fit_records, fit_decoupling, fit_rolling_efficiency",
"",
"race_meta = pd.read_sql('''",
" SELECT a.activity_id, a.start_time_local, a.distance_m/1000 AS km, a.avg_hr",
@@ -524,7 +524,7 @@ cells.append(md(
))
cells.append(code(
"from analysis import HR_ZONES_USER",
"from openrun import HR_ZONES_USER",
"",
"tiz = pd.read_sql('''",
" SELECT t.activity_id, t.z1_s, t.z2_s, t.z3_s, t.z4_s, t.z5_s, t.total_s, t.source,",
@@ -549,7 +549,7 @@ cells.append(md(
))
cells.append(code(
"from analysis import load_fit_records, time_in_zone_from_fit, time_in_zone_from_splits",
"from openrun import load_fit_records, time_in_zone_from_fit, time_in_zone_from_splits",
"",
"race_aid = race_meta.iloc[-1]['activity_id'] # most recent 50K (2025-09-20)",
"race_date = race_meta.iloc[-1]['start_time_local'].date()",

View File

@@ -54,7 +54,7 @@ cells.append(code(
"import numpy as np",
"import pandas as pd",
"import matplotlib.pyplot as plt",
"from analysis import open_conn, load_activities",
"from openrun import open_conn, load_activities",
"",
"PLAN_START = pd.Timestamp('2026-05-18')",
"RACES = {",
@@ -354,7 +354,7 @@ cells.append(md(
))
cells.append(code(
"from analysis import banister, daily_training_load_series",
"from openrun import banister, daily_training_load_series",
"",
"# Historical daily load (running + trail) up to today",
"hist = daily_training_load_series(conn)",

View File

@@ -17,14 +17,11 @@
"metadata": {},
"outputs": [],
"source": [
"import sqlite3\n",
"from pathlib import Path\n",
"\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from openrun import open_conn\n",
"\n",
"DB = Path('..') / 'data' / 'garmin.db'\n",
"conn = sqlite3.connect(DB)\n",
"conn = open_conn()\n",
"\n",
"# What tables do we have, and how many rows in each?\n",
"tables = pd.read_sql(\"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name\", conn)\n",
@@ -37,7 +34,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Activities load into pandas"
"## Activities \u2014 load into pandas"
]
},
{
@@ -99,7 +96,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Sleep, stress, HRV daily timeline"
"## Sleep, stress, HRV \u2014 daily timeline"
]
},
{
@@ -178,4 +175,4 @@
},
"nbformat": 4,
"nbformat_minor": 5
}
}