Files
bio-performx/notebooks/graphs.ipynb
T

3490 lines
1.4 MiB
Plaintext
Raw Normal View History

{
"cells": [
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 2,
"id": "63f43af5",
"metadata": {},
"outputs": [],
"source": [
"import pandas as pd\n",
"import seaborn as sns\n",
"import matplotlib.pyplot as plt\n",
"import numpy as np\n",
"import os"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 3,
"id": "97da3d1c",
"metadata": {},
"outputs": [],
"source": [
"base_dir = os.path.dirname(os.path.abspath('.'))"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 4,
"id": "b0ee2af1",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Smoothed columns created:\n",
"['VO2(ml/min)_smoothed', 'VCO2(ml/min)_smoothed', 'HR(bpm)_smoothed', 'VT(l)_smoothed', 'BF(bpm)_smoothed', 'VE(l/min)_smoothed', 'VO2 Pulse_smoothed', 'VO2 Breath_smoothed', 'CHO_smoothed', 'FAT_smoothed']\n"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
2025-11-28 11:44:37 +01:00
"/tmp/ipykernel_38292/3076306744.py:3: FutureWarning: errors='ignore' is deprecated and will raise in a future version. Use to_numeric without passing `errors` and catch exceptions explicitly instead\n",
" df = df.apply(pd.to_numeric, errors='ignore')\n"
]
}
],
"source": [
"df = pd.read_csv(f'{base_dir}/data/Pnoe_20250729_1550-Moran_Keirstyn.csv', delimiter=';')\n",
"# Convert all columns to numeric where possible, coercing errors to NaN\n",
"df = df.apply(pd.to_numeric, errors='ignore')\n",
"df['VO2 Pulse'] = df['VO2(ml/min)'] / df['HR(bpm)'] # VO2 Pulse in mL/beat\n",
"df['VO2 Breath'] = df['VO2(ml/min)'] / df['BF(bpm)'] # VO2 per Breath in mL/breath\n",
"df['CHO'] = df['EE(kcal/min)'] * df['CARBS(%)']/100\n",
"df['FAT'] = df['EE(kcal/min)'] * df['FAT(%)']/100\n",
"# Smooth key columns using rolling window\n",
"window_size = 10\n",
"\n",
"# List of columns to smooth\n",
"columns_to_smooth = ['VO2(ml/min)', 'VCO2(ml/min)', 'HR(bpm)', 'VT(l)', 'BF(bpm)', 'VE(l/min)', 'VO2 Pulse', 'VO2 Breath', 'CHO', 'FAT']\n",
"\n",
"# Apply smoothing to each column\n",
"for col in columns_to_smooth:\n",
" if col in df.columns:\n",
" df[f'{col}_smoothed'] = df[col].rolling(window=window_size, min_periods=1).mean()\n",
"\n",
"print(\"Smoothed columns created:\")\n",
"print([col for col in df.columns if '_smoothed' in col])"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 5,
"id": "99116a35",
"metadata": {},
"outputs": [],
"source": [
"df_2 = pd.read_excel(f'{base_dir}/data/SECA body comp for all patients.xlsx')\n",
"spirometry_data = pd.read_csv(f'{base_dir}/data/spirometry_data.csv')\n",
2025-11-24 17:52:56 +01:00
"oxygenation = pd.read_csv(f'{base_dir}/data/Keirstyn Train Red NIRS Muscle Oxygen.csv')\n",
"oxygenation_2 = pd.read_csv(f'{base_dir}/data/muscle_oxygenation.csv', skiprows=445)"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 6,
"id": "fbd292c3",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"22.369999999999997\n"
]
}
],
"source": [
"print(df['VO2 Pulse'].max())"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 7,
"id": "4c439b2c",
"metadata": {},
"outputs": [
{
"data": {
2025-11-21 09:23:13 +01:00
"image/png": "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0e+BgoaIiIhEqnpNnfriiy/Yv39/ldsnT54cbLLu3r07I0eOpEOHDqSlpVFSUsK6det47733KC0tZcaMGXzwwQdcfPHFnHnmmTRr1oznnnuOzZs3AzB06FAmT54cfO7mzZvXqsaUlBSuvPJKnnnmGRYvXsyiRYvo3r0777zzDgDjxo1j8ODBfPTRR9U+PjY2lpEjR3LiiSfSokULmjVrRk5ODjNnzmTJkiUYY7j99tuZPHky8fHxHDp0iNdffz34+AsvvJCTTz6ZnJwctm3bxpw5cyo9/6uvvkp+fj4AHTt25PLLLycxMZGdO3eyevVqFi1aVOPvdd68ecGPa7sXyrp164IfJyUl0bdv32Pe/4cffghOHwK49dZbK01LO56FCxcGP965cyd//OMfg9cLCgr4+OOPmTZtGm+88QYXX3xxjZ+33LRp05gwYQJjx45lwYIFzJw5E4DNmzdz991389JLLwHO9K1yw4YN4/zzz6e0tJQdO3bwzTffsHbt2mqff9iwYSxbtgxwfu71XQBBRERExGvqFTTeeeed4AF7RUOHDqVTp04kJiayadMm9u3bx6JFi9i1axf5+fmcfPLJrFq1itWrVwPwv//9j4svvpjRo0czevRoPv3002DQ6N+/P3fccUd9yuTXv/41zz77LMYYnnrqKfr27RtsiL7llltYuXLlUR973XXXcd1117Fy5UpWrVpFZmYmUVFRTJo0iSVLlgBw4MABli5dytixYykpKQkuZZqcnMybb75Z6QDcGFNpZabCwsLgxzfffDP33HNPpa9f3v9QE5s2bQp+3KlTpxo/bt68ebz44ovB67fffjuJiYnHfMzjjz8eXNkqOTmZG264ocZfD2DPnj2VrsfGxnLdddcRHx/Piy++SE5ODqWlpfziF79g4sSJtGjRolbPf+aZZ/LFF19gWRbGGM4+++zgClZvvPEGTz/9NAkJCZV+/k899RQjR46s9Dxbt26tNkB17Ngx+HHFn7uIiIiIOBp11amCggJuuukm/vOf/wQPSqvT2Cs/9enTh7PPPpvPP/+c999/n9TUVAC6dOnCpEmTjhk0li9fzhVXXMGaNWuO+TXKv4e0tDT69+/PmjVrOHjwIN26dWPYsGH06tWLgQMHMnHiRLp16xZ83NixY4NN6b///e+ZOnUqffv2pU+fPowYMYKxY8fWeAPFjIyM4Mc1HfmZOnUql156KSUlJYDT6PyHP/zhmI/Zt28f//nPf4LXr7/+epKTk2v09coVFxdXuv7YY4/x61//GnB+Jj/+8Y8ByM3NZerUqVx99dW1ev7LL788uAKWZVlcdtllwaBRXFzMqlWrgj/f8v//M844g1GjRtGrVy9OOOEETj31VAYOHFjt81cMPhV/7iIiIiLiqFfQePnll485ZeS3v/0tr7zyynGfp7rlVhvaLbfcwueff05JSUnwwPDmm28+5kF8QUEB559/fpWz79Wp+D28+eabXHLJJXz//ffs3r2bjz/+OPg5n8/HrbfeyhNPPAHARRddxB133ME//vEPioqK+Prrr/n666+D9+/SpQvTpk2jf//+tf6ej+fJJ5/kjjvuCIbAa665hhdffDG42tTRPP3008GRgOjoaH7zm9/U+muXh71yFZfTPXJp3bqMGLRu3brS9TZt2lS6np2dDTirZm3evJn
"text/plain": [
"<Figure size 800x800 with 1 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"# Filter df_2 for Keirstyn Moran\n",
"keirstyn_data = df_2[df_2['LastName'].str.contains('Moran', case=False, na=False)]\n",
"# Get the fat mass percentage for Keirstyn\n",
"fat_percentage = keirstyn_data['Adult_FMP'].iloc[0]\n",
"weight_kg = keirstyn_data['Weight'].iloc[0]\n",
"age = keirstyn_data['Age'].iloc[0]\n",
"gender = keirstyn_data['Gender'].iloc[0]\n",
"lean_percentage = 100 - fat_percentage\n",
"\n",
"# Create donut chart\n",
"fat_mass_lbs = 27.6\n",
"lean_mass_lbs = 95.4\n",
"\n",
"# Calculate percentages from the provided weights\n",
"total_weight = fat_mass_lbs + lean_mass_lbs\n",
"fat_percentage = (fat_mass_lbs / total_weight) * 100\n",
"lean_percentage = (lean_mass_lbs / total_weight) * 100\n",
"\n",
"# Data for the chart\n",
"sizes = [fat_percentage, lean_percentage]\n",
"colors = ['#fde3ac', '#ff9966'] # Light yellow/tan and orange from the image\n",
"\n",
"plt.figure(figsize=(8, 8))\n",
"\n",
"# Create the donut chart without labels first\n",
"wedges, texts, autotexts = plt.pie(sizes,\n",
" autopct='', # Remove auto percentages\n",
" startangle=90,\n",
" wedgeprops=dict(width=0.5, edgecolor='w'),\n",
" colors=colors,\n",
" labels=['', '']) # Remove default labels\n",
"\n",
"# Add custom text annotations positioned manually\n",
"plt.text(-1, 1, 'Fat Mass (27.6lbs)\\n22.4%', \n",
" fontsize=14, fontweight='bold', ha='center', va='center',\n",
" bbox=dict(boxstyle=\"round,pad=0.3\", facecolor='white', alpha=0.8))\n",
"\n",
"plt.text(1, -1, 'Lean Mass (95.4lbs)\\n77.6%', \n",
" fontsize=14, fontweight='bold', ha='center', va='center',\n",
" bbox=dict(boxstyle=\"round,pad=0.3\", facecolor='white', alpha=0.8))\n",
"\n",
"# Set the title\n",
"plt.axis('equal') # Equal aspect ratio ensures that pie is drawn as a circle\n",
"plt.savefig(f'{base_dir}/graphs/body_composition_chart.png', bbox_inches='tight', dpi=600)\n",
"plt.show()"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 8,
"id": "a565f1b3",
"metadata": {},
"outputs": [
{
"data": {
2025-11-21 09:23:13 +01:00
"image/png": "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
"text/plain": [
"<Figure size 1000x200 with 1 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"import matplotlib.pyplot as plt\n",
"import seaborn as sns\n",
"\n",
"# Set a common style\n",
"sns.set_theme(style=\"whitegrid\")\n",
"\n",
"# Define the segments with muted colors\n",
"segments = [\n",
" ('#F8A8A8', 0, 15), # Muted Red/Salmon: 0% to 15%\n",
" ('#FFEECC', 15, 5), # Pale Yellow/Cream: 15% to 20%\n",
" ('#D0F0C0', 20, 15), # Pale Green/Mint: 20% to 35%\n",
" ('#FFEECC', 35, 5), # Pale Yellow/Cream: 35% to 40%\n",
" ('#F8A8A8', 40, 10) # Muted Red/Salmon: 40% to 50%\n",
"]\n",
"\n",
"target_value = 22.4\n",
"demographic = \"20-39\\n(F)\"\n",
"\n",
"fig, ax = plt.subplots(figsize=(10, 2))\n",
"\n",
"# Create the Segmented Bar\n",
"for color, start, length in segments:\n",
" ax.barh(y=0, width=length, left=start, height=1, color=color, edgecolor='black', linewidth=0.5)\n",
"\n",
"# Add the Indicator (Triangle)\n",
"ax.plot(target_value, 1.05, marker='v', color='black', markersize=10, clip_on=False, transform=ax.get_xaxis_transform())\n",
"\n",
"# Set Axis Properties and Labels\n",
"ax.set_xlim(0, 50)\n",
"ax.set_xticks(range(0, 51, 5))\n",
"ax.set_yticks([])\n",
"ax.text(-0.05, 0, demographic, transform=ax.get_yaxis_transform(), va='center', ha='right', fontsize=12)\n",
"\n",
"ax.set_xlim(0, 50)\n",
"ticks = range(0, 51, 5)\n",
"ax.set_xticks(ticks)\n",
"labels = [f\"{t}%\" for t in ticks]\n",
"ax.set_xticklabels(labels)\n",
"# Clean up spines and add small ticks\n",
"ax.spines['right'].set_visible(False)\n",
"ax.spines['top'].set_visible(False)\n",
"ax.spines['left'].set_visible(False)\n",
"ax.spines['bottom'].set_visible(True)\n",
"\n",
"for x in range(0, 51, 5):\n",
" ax.plot([x, x], [-0.05, -0.01], color='black', transform=ax.get_xaxis_transform(), clip_on=False)\n",
"\n",
"plt.tight_layout()\n",
"plt.savefig(f'{base_dir}/graphs/body_fat_percent_chart.png', bbox_inches='tight', dpi=300)\n",
"plt.show() # This is where the file is saved and displayed above."
]
},
{
"cell_type": "code",
2025-11-28 12:11:00 +01:00
"execution_count": 104,
"id": "470e871e",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
2025-11-21 09:23:13 +01:00
"Displaying Metabolism Chart...\n",
2025-11-28 11:44:37 +01:00
"Measured RMR: 1385 kcal/day, Predicted (Mifflin-St Jeor): 1242 kcal/day, Ratio: 1.12\n"
]
},
{
"data": {
2025-11-28 12:11:00 +01:00
"image/png": "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
"text/plain": [
2025-11-28 12:11:00 +01:00
"<Figure size 1150x250 with 1 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"\n",
2025-11-21 09:23:13 +01:00
"Displaying Fuel Source Chart...\n",
"Resting phase fuel mix: Fats 32.9%, Carbs 67.1%\n"
]
},
{
"data": {
2025-11-28 12:11:00 +01:00
"image/png": "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
"text/plain": [
2025-11-28 12:11:00 +01:00
"<Figure size 1150x250 with 1 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"import matplotlib.pyplot as plt\n",
"import matplotlib.patches as patches\n",
"\n",
"# Enable inline display for matplotlib\n",
"%matplotlib inline\n",
"\n",
2025-11-28 11:44:37 +01:00
"def mifflin_st_jeor(weight_kg, height_cm, age_years, sex):\n",
" \"\"\"\n",
2025-11-28 11:44:37 +01:00
" Computes the Mifflin-St Jeor predicted RMR (kcal/day).\n",
" sex must be \"male\" or \"female\"\n",
" \"\"\"\n",
2025-11-28 11:44:37 +01:00
" if sex.lower() == \"male\":\n",
" return 10 * weight_kg + 6.25 * height_cm - 5 * age_years + 5\n",
" elif sex.lower() == \"female\":\n",
" return 10 * weight_kg + 6.25 * height_cm - 5 * age_years - 161\n",
" else:\n",
" raise ValueError(f\"sex should be 'male' or 'female', got {sex}\")\n",
"\n",
"def rmr_classification(ratio):\n",
" if ratio < 0.7:\n",
" return 'Very Slow'\n",
" elif 0.7 <= ratio < 0.9:\n",
" return 'Slow'\n",
" elif 0.9 <= ratio <= 1.1:\n",
" return 'Average'\n",
" elif 1.1 < ratio <= 1.3:\n",
" return 'Fast'\n",
" else:\n",
" return 'Very Fast'\n",
"\n",
2025-11-28 12:11:00 +01:00
"def plot_metabolism_chart(weight_kg, height_cm, age_years, sex, df, fig_width=11.5, fig_height=2.5):\n",
2025-11-28 11:44:37 +01:00
" \"\"\"\n",
" Generates a 'Slow vs Fast Metabolism' chart styled to match the provided sample:\n",
" - Bar is rectangular (not curved).\n",
" - First and last ticks are omitted, no tick at 0.3 and 1.9.\n",
" - Ticks are thicker and positioned below the bar (don't encroach).\n",
" - All text labels are bold.\n",
" - (Modified) A gap is added between the title and the graph.\n",
" - Text color, tick color, and triangle are all gray.\n",
2025-11-28 12:11:00 +01:00
" - (MODIFIED) The main horizontal bar is now THICKER.\n",
2025-11-28 11:44:37 +01:00
" \"\"\"\n",
2025-11-28 12:11:00 +01:00
" fig, ax = plt.subplots(figsize=(fig_width, fig_height)) # set global uniform width\n",
"\n",
2025-11-28 11:44:37 +01:00
" # Identify resting phase and measured RMR\n",
" rest_phase = df[df['MET'] <= 1.1] # assuming <1.1 MET = rest\n",
" measured_rmr = rest_phase['EE(kcal/day)'].mean()\n",
" mifflin_rmr = mifflin_st_jeor(weight_kg, height_cm, age_years, sex)\n",
" ratio = measured_rmr / mifflin_rmr if mifflin_rmr > 0 else float('nan')\n",
" print(f\"Measured RMR: {measured_rmr:.0f} kcal/day, Predicted (Mifflin-St Jeor): {mifflin_rmr:.0f} kcal/day, Ratio: {ratio:.2f}\")\n",
"\n",
" # Bar setup\n",
" scale_edges = [0.3, 0.7, 0.9, 1.1, 1.3, 1.5, 1.9] # bar runs from 0.3 to 1.9\n",
" scale_labels = ['Very Slow', 'Slow', 'Average', 'Fast', 'Very Fast']\n",
" tick_edges = scale_edges[1:-1] # remove first and last tick (omit 0.3 and 1.9)\n",
"\n",
" x_start = scale_edges[0]\n",
" x_end = scale_edges[-1]\n",
2025-11-28 12:11:00 +01:00
" # ---- Make the bar THICKER by increasing bar_height and adjusting y_bar ----\n",
" bar_height = 0.36\n",
" y_bar = 0.48\n",
" # ---------------------------------------------------------------------------\n",
2025-11-28 11:44:37 +01:00
"\n",
" color_before = \"#B2FFC8\"\n",
" color_after = \"#ECEDF2\"\n",
" gray_color = \"#606060\" # new variable for consistent gray\n",
"\n",
" # Draw plain rectangle bar (no rounding)\n",
" ax.add_patch(\n",
" patches.Rectangle(\n",
" (x_start, y_bar), x_end - x_start, bar_height,\n",
" ec='none', fc=color_after, lw=0\n",
" )\n",
" )\n",
2025-11-28 11:44:37 +01:00
" # Highlighted rectangle\n",
" highlight_end = min(max(ratio, x_start), x_end)\n",
" if highlight_end > x_start:\n",
" ax.add_patch(\n",
" patches.Rectangle(\n",
" (x_start, y_bar), highlight_end - x_start, bar_height,\n",
" ec='none', fc=color_before, lw=0\n",
" )\n",
" )\n",
"\n",
" # kCals label, left-aligned, bold inside green, TEXT COLOR now gray\n",
" ax.text(\n",
" x_start + 0.07, y_bar + bar_height/2,\n",
" f\"{int(round(measured_rmr))}kCals\",\n",
" ha=\"left\", va=\"center\",\n",
" color=gray_color,\n",
" fontsize=12, weight='bold',\n",
" bbox=dict(boxstyle=\"round,pad=0.14\", ec=\"none\", fc=\"#B2FFC8\", alpha=1.0)\n",
" )\n",
"\n",
2025-11-28 11:44:37 +01:00
" # Triangle marker above highlight end, now gray\n",
" ax.plot(\n",
" [highlight_end], [y_bar + bar_height + 0.08],\n",
" marker='v', markersize=14, color=gray_color, clip_on=False\n",
" )\n",
"\n",
2025-11-28 11:44:37 +01:00
" # Draw ticks omit leftmost/rightmost (thicker and below bar), color gray\n",
" tick_width = 4.1\n",
" tick_bottom = y_bar - 0.07 # further below bar\n",
" tick_top = y_bar # at the base of bar\n",
" for edge in tick_edges:\n",
" ax.plot([edge, edge], [tick_bottom, tick_top], color=gray_color, lw=tick_width, solid_capstyle='butt', clip_on=False, zorder=2)\n",
"\n",
" # Label locations (place directly under each tick), text color gray\n",
" label_y = tick_bottom - 0.08\n",
" for label, tick in zip(scale_labels, tick_edges):\n",
" ax.text(\n",
" tick, label_y, label, ha='center', va='top',\n",
" fontsize=11, weight='bold',\n",
" color=gray_color\n",
" )\n",
" # Axis title: bold, with extra gap above the graph, color gray\n",
" ax.text(\n",
" x_start, y_bar + bar_height + 0.5, # changed 0.11 -> 0.17 for more gap\n",
" \"Slow vs Fast Metabolism\", ha='left', va='bottom',\n",
" fontsize=14, weight='bold'\n",
" )\n",
"\n",
2025-11-28 11:44:37 +01:00
" ax.set_xlim(x_start, x_end)\n",
" ax.set_ylim(0, 1)\n",
" ax.axis('off')\n",
2025-11-28 11:44:37 +01:00
"\n",
" plt.tight_layout()\n",
" plt.savefig(f'{base_dir}/graphs/metabolism_chart.png', bbox_inches='tight', dpi=300)\n",
2025-11-28 11:44:37 +01:00
" plt.close(fig)\n",
" return fig\n",
"\n",
2025-11-28 11:44:37 +01:00
"def frange(start, stop, step):\n",
" vals = []\n",
" while start < stop - step/2:\n",
" vals.append(round(start, 10))\n",
" start += step\n",
" return vals\n",
"\n",
2025-11-28 12:11:00 +01:00
"def plot_fuel_source_chart(fig_width=11.5, fig_height=2.5):\n",
" \"\"\"\n",
" Generates and displays the 'Fuel Source' chart.\n",
2025-11-28 12:11:00 +01:00
" Uniform width/height for match.\n",
" \"\"\"\n",
2025-11-28 12:11:00 +01:00
" fig, ax = plt.subplots(figsize=(fig_width, fig_height)) # uniform width/height\n",
"\n",
2025-11-28 11:44:37 +01:00
" rest_phase = df[df['RER'] == 0.9]\n",
" fat_rest = rest_phase['FAT(%)'].mean()\n",
" carb_rest = rest_phase['CARBS(%)'].mean()\n",
" print(f\"Resting phase fuel mix: Fats {fat_rest:.1f}%, Carbs {carb_rest:.1f}%\")\n",
"\n",
" fat_percentage = fat_rest\n",
" carb_percentage = 100 - fat_percentage\n",
" optimal_point = 75\n",
"\n",
2025-11-28 12:11:00 +01:00
" # Let the bars be a bit thicker as well: increase bar height and y\n",
" fats_bar = patches.FancyBboxPatch(\n",
2025-11-28 12:11:00 +01:00
" (0, 0.36), fat_percentage, 0.28,\n",
" boxstyle=\"round,pad=0,rounding_size=0.1\",\n",
" ec=\"none\", fc=\"#FEEAAB\",\n",
" )\n",
" ax.add_patch(fats_bar)\n",
" carbs_bar = patches.FancyBboxPatch(\n",
2025-11-28 12:11:00 +01:00
" (fat_percentage, 0.36), carb_percentage, 0.28,\n",
" boxstyle=\"round,pad=0,rounding_size=0.1\",\n",
" ec=\"none\", fc=\"#A7F5FF\",\n",
" )\n",
" ax.add_patch(carbs_bar)\n",
"\n",
2025-11-28 12:11:00 +01:00
" # Style: match font weight/color/size with other chart\n",
" label_fontprops = dict(fontsize=12, weight='bold', color='#333333')\n",
"\n",
" ax.text(fat_percentage / 2, 0.5, f'Fats\\n{fat_percentage:.0f}%', \n",
2025-11-28 12:11:00 +01:00
" ha='center', va='center', **label_fontprops)\n",
" ax.text(fat_percentage + carb_percentage / 2, 0.5, f'Carbs\\n{100-fat_percentage:.0f}%', \n",
2025-11-28 12:11:00 +01:00
" ha='center', va='center', **label_fontprops)\n",
"\n",
2025-11-28 12:11:00 +01:00
" ax.text(optimal_point, 0.9, 'Optimal', ha='center', va='center', fontsize=12, weight='bold', color='#606060')\n",
" ax.plot([optimal_point, optimal_point], [0.65, 0.8], color='#606060', lw=3)\n",
2025-11-28 11:44:37 +01:00
"\n",
2025-11-28 12:11:00 +01:00
" ax.plot(fat_percentage, 0.7, 'v', markersize=15, color='#606060', clip_on=False)\n",
2025-11-28 11:44:37 +01:00
"\n",
" positions = [0, 25, 50, 75, 100]\n",
2025-11-28 11:44:37 +01:00
" # Gray color for all ticks\n",
" tick_color = '#606060'\n",
" for pos in positions:\n",
2025-11-28 11:44:37 +01:00
" # Smallest ticks (first and last)\n",
" if pos == 0:\n",
2025-11-28 12:11:00 +01:00
" ax.text(pos + 0.5, 0.15, str(pos), ha='center', va='center', fontsize=12, color='#333333', weight='bold')\n",
" ax.plot([pos, pos], [0.25, 0.37], color=tick_color, lw=14, solid_capstyle='butt')\n",
2025-11-28 11:44:37 +01:00
" elif pos == 100:\n",
2025-11-28 12:11:00 +01:00
" ax.text(pos - 0.5, 0.15, str(pos), ha='center', va='center', fontsize=12, color='#333333', weight='bold')\n",
" ax.plot([pos, pos], [0.25, 0.37], color=tick_color, lw=14, solid_capstyle='butt')\n",
2025-11-28 11:44:37 +01:00
" else:\n",
2025-11-28 12:11:00 +01:00
" ax.text(pos, 0.15, str(pos), ha='center', va='center', fontsize=12, color='#333333', weight='bold')\n",
" ax.plot([pos, pos], [0.25, 0.37], color=tick_color, lw=8, solid_capstyle='butt')\n",
" # Uniform style for title\n",
" ax.set_title('Fuel Source', fontsize=14, weight='bold', loc='left', pad=22)\n",
2025-11-28 11:44:37 +01:00
" ax.set_xlim(0, 100)\n",
" ax.set_ylim(0, 1)\n",
" ax.axis('off')\n",
"\n",
" plt.tight_layout()\n",
" plt.savefig(f'{base_dir}/graphs/fuel_source_chart.png', bbox_inches='tight', dpi=300)\n",
2025-11-28 11:44:37 +01:00
" plt.close(fig)\n",
" return fig\n",
"\n",
"if __name__ == '__main__':\n",
2025-11-28 11:44:37 +01:00
" weight_kg = 56\n",
" height_cm = 162\n",
" age_years = 34\n",
" sex = \"female\"\n",
" print(\"Displaying Metabolism Chart...\")\n",
2025-11-28 11:44:37 +01:00
" fig1 = plot_metabolism_chart(weight_kg, height_cm, age_years, sex, df)\n",
" display(fig1)\n",
" \n",
" print(\"\\nDisplaying Fuel Source Chart...\")\n",
" fig2 = plot_fuel_source_chart()\n",
" display(fig2)"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 10,
"id": "0ab6f0b0",
"metadata": {},
"outputs": [
{
"data": {
2025-11-21 09:23:13 +01:00
"image/png": "iVBORw0KGgoAAAANSUhEUgAAA/YAAAFdCAYAAACpXPZQAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQAAeJJJREFUeJzt3Xd8FVX+//HXbek9oYQiRREioEDoSFXBglJWVqwgCtiwgKi4lrWLArKiKLiKICAiIk2FlS9NEOklKsUFQTqEhCQ39d6b+f2RX2YJBEi/Ke/n45EHk5kzcz735N6Qz5wz51gMwzAQERERERERkQrJ6u0ARERERERERKTolNiLiIiIiIiIVGBK7EVEREREREQqMCX2IiIiIiIiIhWYEnsRERERERGRCkyJvYiIiIiIiEgFpsReREREREREpAJTYi8iIiIiIiJSgSmxFxEREREREanAlNiLiIiIiIiIVGBK7EVEREREREQqMCX2IiIiIiIiIhWYEnsRERERERGRCkyJvYiIiJd8/vnn3Hvvveb3LVu2ZM+ePWVS9/Lly+nRo0eZ1CUiIiKlS4m9iFQZ9957L59//nmB9+cei4mJYffu3ea+5ORkGjduzOHDh0spUilP7r33Xpo1a0bLli1p27Yt9957L7/++mup1LVt2zYaN258yXKTJk3ikUceKZUYREREpOJRYi8icgkhISFMmDDB22GIFz399NNs27aNn376iZiYmHyTapfL5YXIRERERMDu7QBERMq7u+66iy+++IJNmzbRpk0bb4cjXuTr68vtt9/O9OnTeeihh4iIiCA1NZWffvqJp556ioEDBzJ58mQWL15MSkoKLVu25JVXXqFGjRoA/PHHH/zjH//gjz/+oFmzZjRv3jzP9Rs3bsyCBQuIiYkBYMmSJUydOpXDhw8TGhrKiBEjCAkJYcqUKWRnZ9OyZUsgp6ffMAy++OILZs+eTXx8PDExMfzzn//k8ssvB+D48eM8//zzbN++nfr169OzZ88ybLmyl5CQgNPpLPL5wcHBhIeHl2BEIiIipUeJvYjIJYSGhjJ06FDGjx/PnDlzvB2OeFF6ejpff/01tWvXJiwsjO+++44PPviA9957j8zMTN577z1+++03Zs+eTVhYGO+99x4jR45k1qxZuN1uHn74YW655RZmzpzJb7/9xvDhwy849H7FihW89tprTJw4kXbt2pGYmMiJEye46qqrGD58OLt27WLy5Mlm+dmzZzNv3jw+/vhj6tSpw+zZs3nooYf47rvv8PHxYdSoUdSpU4d169Zx9OhRhg4dWlbNVuZcLhdjx44lJSWlyNcICQnhtddew+FwlGBkIiIipUND8UVECmDQoEEcOXKE5cuXezsU8YIJEybQunVrrr/+evbv328m1J06daJz585YrVb8/Pz48ssvGTNmDNWrV8fHx4cnn3ySrVu3cuzYMbZv305iYiKPPfYYPj4+tGzZkptuuumCdc6ePZt7772XDh06YLVaiYyM5Kqrrrpo+ccff5z69etjt9u57777yMjIYOfOnRw7dozNmzfzzDPP4O/vz+WXX87AgQNLvJ3KC7vdTkREBBaLpUjnWywWwsPDsdvV/yEiIhWD/scSESkAPz8/HnvsMSZMmMCsWbO8HY6UsZEjRzJ48ODz9teqVcvcTkxMJC0tjbvvvjtPQulwODh27BgnT56kevXqeXqAa9euzf79+/Ot8+jRo/Tt27fAMR45coTRo0djs9nMfS6Xi+PHj+NwOPD19SUyMjJP3ZWVxWLh1ltv5YMPPijS+YZhcOuttxb5xoCIiEhZU2IvIlJAt99+O9OmTWPBggXeDkXKibMTv7CwMPz9/Zk7d675XPvZNm/ezMmTJ3G5XGZyf/To0Qteu1atWhw8ePCS9eaqWbMmzz//PF26dDnv2LFjx8jMzOT06dNmcn+xuiuDmJgY6tWrx19//YVhGAU+z2KxcNlll5nzHIiIiFQEGoovIlWKx+MhMzPT/MrKyrro/rPZbDaeeuopPv7447IOWyoAq9XKwIEDGTt2LMeOHQNyevG///57AK655hpCQ0OZPHkyWVlZ7Nixgx9++OGC1xs4cCAzZsxg48aNZGdnc/r0aX7//XcAoqKiOHr0KG632yx/99138/7775sjAJxOJ8uXL8fpdBIdHU2rVq0YN24cGRkZ7N+/n6+++qq0mqJcyO21L0xSD+qtFxGRikmJvYhUKe+88w5XX321+XXjjTdedP+5evXqRb169coyZKlARo4cSYsWLRg0aBAtW7bkb3/7G2vXrgVyhuR/9NFHrF27lnbt2jFu3Dj69+9/wWtdf/31jBkzhldffZXY2Fhuv/129u7dC8CNN95IUFAQHTp0oHXr1gDcc8899OvXjxEjRtCqVStuuukmlixZYl5v/PjxHD9+nA4dOvD000/zt7/9rRRbonzI7bUvaJJusVioV6+eeutFRKTCsRiFvZWdj+eee45vv/0WgBkzZtCuXbtiB1YW5s+fz5gxYwAYMWIEjz32WJ7jbrebTp06cebMGcLDw1m7dm2BJtI5fPgw1113HQCPPfYYI0aMKPngRURE5JJ+//33Qj1r/9hjj110kkIREZHyqEr32F9//fXmc47Lli077/j69es5c+YMAD179tTsuCIiIhVMQXvt1VsvIiIVWZVO7ENCQujcuTMAe/fuZd++fXmOL1261Ny+5ZZbyjQ2ERERKb6CPmuvZ+tFRKQiK7PE/vDhwzRu3JjGjRszadIkc//8+fPN/Rs2bABgw4YN5r4vv/ySt99+m44dO9KmTRueeOIJEhMT81z7l19+oW/fvjRv3pxbb72V1atXc++999K4cWN69Ohx0bjOTtjPnsTI7Xab61VXr16dNm3aALB//35GjRpFp06daNasGZ07d2bMmDEFml049zU999xz5r6zX+v8+fPPa6uJEycyYcIE2rVrR9u2bXn33XfxeDx8//339OrVi1atWjFkyBAOHz6cpy6n08m7775Lr169aNasGW3atOGhhx7it99+u2ScIiIilcmleu3VWy8iIhVduR9bPn78eFJSUszvly5dit1uZ/z48QD8+eefDB061JzBeu/evTzyyCOEhIQU6Prdu3fH39+f9PR0li1bZj5n/8svv5jD8G+88UasViu7d+/mzjvvJC0tzTz/5MmTzJ8/n1WrVvH1119Tp06dknjZpi+//NKMA+Df//43+/fvZ+XKlWbvw7p163j66aeZM2cOAKmpqdx5553mJEuQs5bxypUrWbduHdOmTTMnWxIREansLrWuvXrrRUSkoiv3Q/GtViuzZ89m3bp1XHnllUDO8/DZ2dkA5rJBAE8//TRbtmzhmWeeISEhoUDXDwwMpGvXrkDe4fj5DcN/8803zaT+nXfeYcuWLTz77LMAJCQk8N577xX35Z4nKyuL2bNns2LFCgIDAwFYsWIFt99+O5s2bTJn7t62bRsnTpwAYPr06ezduxebzcaHH35IXFwcy5Yto169emRlZfHWW2+VeJwiIiLl2YV67dVbLyIilUG5T+xvv/12YmNjiYqKokuXLkBO73N8fDyQk9BCzpq+DzzwAEFBQQwaNIjo6OgC13HucHy3282PP/4IQO3atWnRogXp6els3rwZgGbNmtGnTx+CgoK4//77qVmzJoC5pFFJuu6664iNjaV27dpcfvnl5v7hw4cTEhJCx44dzX25jwOsWbMGyFmX+9FHH6V58+b06tWLgwcPAvDrr7/idDpLPFYREZHy6kLP2qu3XkREKgOvD8X3eDwXPV6/fn1z29fX19zO7aU/efIkkPMcvNX6v/sUNWrU4NixYwWKoWvXrgQGBpKamsqyZcto2bKlOfz95ptvBiA5OdmM9eybBhaLhZo1a3L8+HHOnDlzyddzrtyRBxdSu3Ztc9vPz8/czo0hd1Z/+F+bFGS0QlJSEkFBQYWKVUREpCLL7bX/66+/MAwDi8XCZZddpt56kXIsOzsbj8dDdnb2JSfBFCmPLBYLVqsVm82WJ18taWWW2Pv4+JjbmZmZ5va5k76d6+wl5vK7m169enUOHTrEqVOnzP+
"text/plain": [
"<Figure size 1150x360 with 4 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"import os\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"import matplotlib.transforms as mtransforms\n",
"from matplotlib.patches import FancyBboxPatch\n",
"# Ensure data is loaded\n",
"try:\n",
" spirometry_df = spirometry_data.copy()\n",
"except NameError:\n",
" spirometry_df = pd.read_csv('data/spirometry_data.csv')\n",
"\n",
"# Coerce numeric columns\n",
"for col in ['Best', 'LLN', 'Pred.', '%Pred.', 'ZScore']:\n",
" if col in spirometry_df.columns:\n",
" spirometry_df[col] = pd.to_numeric(spirometry_df[col], errors='coerce')\n",
"\n",
"# Select rows of interest and prepare display values\n",
"rows_map = {\n",
" 'Lung Volume': 'FVC',\n",
" 'Lung Power': 'FEV1',\n",
" 'Power/Volume': 'FEV1/FVC%'\n",
"}\n",
"\n",
"records = []\n",
"for label, param in rows_map.items():\n",
" row = spirometry_df.loc[spirometry_df['Parameters'].str.strip() == param]\n",
" if row.empty:\n",
" continue\n",
" row = row.iloc[0]\n",
" records.append({\n",
" 'label': label,\n",
" 'param': param,\n",
" 'best': row['Best'],\n",
" 'pct': row['%Pred.'],\n",
" 'z': row['ZScore']\n",
" })\n",
"\n",
"# Figure setup\n",
"os.makedirs('graphs', exist_ok=True)\n",
"fig, axes = plt.subplots(nrows=3, ncols=1, figsize=(11.5, 3.6), sharex=True,\n",
" gridspec_kw={'hspace': 0.65})\n",
"\n",
"x_min, x_max = -5, 3\n",
"# Segment colors: red -> orange -> yellow -> green\n",
"segments = [\n",
" (-5, -4, '#f4a7a7'), # red-ish\n",
" (-4, -3, '#f7c49a'), # orange-ish\n",
" (-3, -1.7, '#f6e3a3'), # yellow-ish\n",
" (-1.7, 3, '#c9f0cc'), # green-ish\n",
"]\n",
"\n",
"ticks = np.arange(x_min, x_max + 1, 1)\n",
"labels = [str(i) for i in ticks]\n",
"\n",
"# Plot each row\n",
"for ax, rec in zip(axes, records):\n",
" # Background segments\n",
" for a, b, color in segments:\n",
" ax.barh(0, width=b-a, left=a, height=0.6, color=color, edgecolor='none')\n",
"\n",
" # LLN (-1) and Predicted (0) markers\n",
" # ax.axvline(-1, color='black', lw=1)\n",
" ax.axvline(0, color='black', lw=1)\n",
"\n",
" # Z-score pointer (downward triangle) at top of each panel\n",
" if pd.notna(rec['z']):\n",
" trans = mtransforms.blended_transform_factory(ax.transData, ax.transAxes)\n",
" ax.plot(float(rec['z']), 1.2, marker='v', markersize=12, color='dimgray',\n",
" transform=trans, clip_on=False)\n",
"\n",
" # Labels, ticks, and styling\n",
" ax.set_title(rec['label'], loc='left', fontsize=11, fontweight='bold', pad=2)\n",
" ax.set_xlim(x_min, x_max)\n",
" ax.set_yticks([])\n",
" ax.set_xticks(ticks)\n",
" ax.set_xticklabels(labels, fontsize=8)\n",
" ax.set_xlabel('')\n",
"\n",
"# Add x-axis label to the bottom axis\n",
"# axes[-1].set_xlabel('Z-score', fontsize=10)\n",
"\n",
"# Top annotations\n",
"axes[0].text(-1.7, 0.45, 'LLN', ha='center', va='bottom', fontsize=9)\n",
"axes[0].text(0, 0.45, 'Predicted', ha='center', va='bottom', fontsize=9)\n",
"\n",
"# Right-side summary boxes\n",
"fig.subplots_adjust(right=0.78)\n",
"box_ax = fig.add_axes([0.805, 0.06, 0.18, 0.90]) # [left, bottom, width, height]\n",
"box_ax.axis('off')\n",
"\n",
"# Helper to draw a pill-shaped text box\n",
"\n",
"def pill(ax, xy, text):\n",
" x, y = xy\n",
" # Draw rounded rectangle background\n",
" bbox = FancyBboxPatch((x-0.48, y-0.09), 0.96, 0.18,\n",
" boxstyle='round,pad=0.02,rounding_size=0.08',\n",
" ec='#dddddd', fc='#f3f3f3', linewidth=1.0)\n",
" ax.add_patch(bbox)\n",
" ax.text(x, y+0.025, text, ha='center', va='center', fontsize=11, fontweight='bold')\n",
" ax.text(x, y-0.055, 'of predicted', ha='center', va='center', fontsize=9, color='#555555')\n",
"\n",
"box_ax.set_xlim(0, 1)\n",
"box_ax.set_ylim(0, 1)\n",
"\n",
"# Prepare display strings and positions (top to bottom)\n",
"right_items = []\n",
"for rec in records:\n",
" name = 'FVC' if rec['param'] == 'FVC' else ('FEV1' if rec['param'] == 'FEV1' else 'FEV1/FVC')\n",
" unit = 'L' if rec['param'] in ('FVC', 'FEV1') else '%'\n",
" value_fmt = f\"{rec['best']:.2f}{unit}\"\n",
" pct_fmt = f\"{rec['pct']:.1f}%\"\n",
" right_items.append((name, value_fmt, pct_fmt))\n",
"\n",
"# Sort to match image order on the right (FVC, FEV1, FEV1/FVC)\n",
"order = ['FVC', 'FEV1', 'FEV1/FVC']\n",
"right_items_sorted = [next(item for item in right_items if item[0] == k) for k in order]\n",
"\n",
"ys = [0.82, 0.48, 0.15]\n",
"for (name, value_fmt, pct_fmt), y in zip(right_items_sorted, ys):\n",
" main_line = f\"{name}\\n{value_fmt} → {pct_fmt}\"\n",
" pill(box_ax, (0.5, y), main_line)\n",
"\n",
"plt.savefig(f'{base_dir}/graphs/spirometry_chart.png', dpi=300, bbox_inches='tight')\n",
"plt.show()"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 11,
"id": "ef8bc7ac",
"metadata": {},
"outputs": [
{
"data": {
2025-11-21 09:23:13 +01:00
"image/png": "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
"text/plain": [
"<Figure size 1800x500 with 2 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"first_unique_phase = df.drop_duplicates(subset='PHASE')\n",
"phase_times = first_unique_phase['T(sec)'].tolist()\n",
"\n",
"plt.figure(figsize=(18, 5))\n",
"ax1 = plt.subplot()\n",
"\n",
"# Plot VT with step-like appearance\n",
"line1 = sns.lineplot(data=df, x='T(sec)', y='VT(l)_smoothed', label='VT (L)')\n",
"ax1.set_xlabel('Time (sec)')\n",
"ax1.set_ylabel('VT (L)')\n",
"ax1.grid(True, alpha=0.1)\n",
"ax1.set_ylim(0, min(8, df['VT(l)_smoothed'].max()))\n",
"\n",
"# Set x-axis limits to remove padding\n",
"ax1.set_xlim(0, df['T(sec)'].max())\n",
"\n",
"# Plot speed as step function on secondary y-axis\n",
"ax2 = ax1.twinx()\n",
"ax1.set_xticks(np.arange(0, df['T(sec)'].max(), 200))\n",
"line2 = sns.lineplot(data=df, x='T(sec)', y='Speed', color='green', ax=ax2, \n",
" drawstyle='steps-post', linewidth=2, label='Speed')\n",
"ax2.set_ylabel('Speed')\n",
"ax2.set_ylim(0, min(30, df['Speed'].max()) + 1)\n",
"\n",
"# Remove default legends first\n",
"ax1.get_legend().remove()\n",
"ax2.get_legend().remove()\n",
"\n",
"# Combine legends from both axes in the top left\n",
"lines1, labels1 = ax1.get_legend_handles_labels()\n",
"lines2, labels2 = ax2.get_legend_handles_labels()\n",
"ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left')\n",
"\n",
"# Add colored background regions if you have phase information\n",
"ax1.axvspan(0, phase_times[1], alpha=0.2, color='lightblue')\n",
"ax1.axvspan(phase_times[1], phase_times[2], alpha=0.2, color='purple')\n",
"ax1.axvspan(phase_times[2], phase_times[3], alpha=0.2, color='lightgreen')\n",
"ax1.axvspan(phase_times[3], df['T(sec)'].max(), alpha=0.2, color='blue')\n",
"\n",
"plt.savefig(f'{base_dir}/graphs/respiratory.png', dpi=300, bbox_inches='tight')\n",
"plt.show()"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 12,
"id": "06244aa2",
"metadata": {},
"outputs": [
{
"data": {
2025-11-21 09:23:13 +01:00
"image/png": "iVBORw0KGgoAAAANSUhEUgAABdEAAAMfCAYAAAA5Z570AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzs3Xd4U9X/B/B3uvekA0rpADrYyChLKbJBNggypIIgSxFEvg6mICCIIojgzwEIyJSlKEvZm8oeZXSwSinQ0kF37u+PY1aTdKRJB7xfz5Mnyb3nnnNucnPTfnLu58gkSZJARERERERERERERERazMq6A0RERERERERERERE5RWD6EREREREREREREREejCITkRERERERERERESkB4PoRERERERERERERER6MIhORERERERERERERKQHg+hERERERERERERERHowiE5EREREREREREREpAeD6EREREREREREREREejCITkRERERERERERESkB4PoRERUYv7+/pDJZJDJZJgxY0ZZd0dp5cqVyn7JZDKNdeHh4crlERERyuWxsbEa2xw4cKB0O10E+vpOVNGU13NHeXPq1Cl07twZ7u7uMDMzU75mycnJZd21CnHOpKIr6HuzNLYvz2bMmKHcL39//7LuTomV5/MKERFRecQgOhFRBXDgwAGNf0r13SpSQDX/P9q6Ai/qweL8/7A+r/+oPy8BcvXgqPrNzs4OgYGBGDBgAPbv31/W3TSp/J/b2NjYUms7IiKizD8fJQ2QmzLArv76hIeHG7VuY3vw4AE6d+6MXbt24cmTJ5AkqVjbP3nyBFOmTEHDhg3h6OgIKysreHp6IjQ0FL169cLMmTNx584dE/WeFIryXa1+zJfH4/J5/d41NX1/s9nY2MDPzw8DBgzAoUOHSrVPJT2vEBERvYgsyroDREREptKkSRMsWLCgWNu4ublpbFO9enVjd6vERo8ejddeew0AUKdOnTLuTfFkZGQgJiYGMTEx2LBhA77//nuMHDmyrLtFZeTTTz/F06dPAQAtWrQo496UT7t378aTJ08AiGDc2LFj4efnBwCwtbUtcNu4uDi0atUKd+/e1ViemJiIxMREXLt2Ddu2bUP9+vXh6+trUP8qwjmTqDzKysrC7du3cfv2bWzYsAGzZ8/Gp59+Wiptl+S8QkRE9KJiEJ2IqALq378/Gjdu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
"text/plain": [
"<Figure size 1500x800 with 2 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"\n",
"# Group by speed and calculate mean for numeric columns only\n",
"speed_groups = df.groupby('Speed').mean(numeric_only=True).round(1)\n",
"\n",
"# Drop the first and last row from speed_groups\n",
"speed_groups = speed_groups.iloc[1:-1]\n",
"\n",
"# Filter data to only include speeds in the desired range\n",
"filtered_data = speed_groups[(speed_groups.index >= 3.5) & (speed_groups.index <= 7.5)]\n",
"\n",
"# Create figure with specific size\n",
"plt.figure(figsize=(15, 8))\n",
"plt.style.use('default')\n",
"\n",
"# Create stage labels and positions\n",
"stage_labels = [f'Stage {i}' for i in range(1, len(filtered_data) + 1)]\n",
"x_positions = np.arange(len(filtered_data))\n",
"\n",
"# Calculate fat and carbs energy expenditure from percentages\n",
"fat_ee = filtered_data['EE(kcal/min)'] * filtered_data['FAT(%)'] / 100\n",
"carbs_ee = filtered_data['EE(kcal/min)'] * filtered_data['CARBS(%)'] / 100\n",
"\n",
"# Create the main axis for the stacked bars\n",
"ax1 = plt.gca()\n",
"\n",
"# Create stacked bar chart with colors\n",
"bars_fat = ax1.bar(x_positions, fat_ee, color='#1f77b4', alpha=0.8, width=0.6, label='Fat')\n",
"bars_carbs = ax1.bar(x_positions, carbs_ee, bottom=fat_ee, color='#ff7f0e', alpha=0.8, width=0.6, label='Carbs')\n",
"\n",
"# Set labels and formatting for primary axis\n",
"ax1.set_xlabel('', fontsize=12)\n",
"ax1.set_ylabel('Fuel (kcal/min)', fontsize=12)\n",
"ax1.set_ylim(0, 20)\n",
"\n",
"# Add individual values on each bar segment\n",
"for i, (fat_val, carb_val, total_val) in enumerate(zip(fat_ee, carbs_ee, filtered_data['EE(kcal/min)'])):\n",
" if fat_val > 0.3: # Fat value\n",
" ax1.text(i, fat_val/2, f'{fat_val:.1f}', ha='center', va='center',\n",
" fontsize=9, fontweight='bold', color='white')\n",
" if carb_val > 0.3: # Carbs value\n",
" ax1.text(i, fat_val + carb_val/2, f'{carb_val:.1f}', ha='center', va='center',\n",
" fontsize=9, fontweight='bold', color='white')\n",
" # Total EE\n",
" ax1.text(i, total_val + 0.5, f'{total_val:.1f} kcal', ha='center', va='bottom',\n",
" fontsize=10, fontweight='bold', color='black')\n",
"\n",
"# Add speed labels below x-axis\n",
"for i, speed in enumerate(filtered_data.index):\n",
" ax1.text(i, -1.5, f'{speed:.1f} mph', ha='center', va='top', fontsize=9)\n",
" ax1.text(i, -2.8, f'{speed*1.609:.1f} min/km', ha='center', va='top', fontsize=8, color='gray')\n",
"\n",
"# Create secondary y-axis for heart rate\n",
"ax2 = ax1.twinx()\n",
"\n",
"# Plot heart rate line (no manual offset)\n",
"hr_line = ax2.plot(x_positions, filtered_data['HR(bpm)'],\n",
" marker='o', linewidth=3, markersize=8, color='red', label='Heart Rate')\n",
"\n",
"# Set heart rate axis formatting\n",
"ax2.set_ylabel('Heart Rate (bpm)', fontsize=12, color='red')\n",
"ax2.tick_params(axis='y', labelcolor='red')\n",
"\n",
"# Dynamically adjust HR axis to float above bars\n",
"max_bar_height = max(filtered_data['EE(kcal/min)'])\n",
"ax2.set_ylim(0, 220) # ensures HR line is above bars\n",
"\n",
"\n",
"# Add HR values above the points\n",
"for i, hr in enumerate(filtered_data['HR(bpm)']):\n",
" ax2.text(i, hr + 10, f'{int(hr)}bpm', ha='center', va='bottom',\n",
" fontsize=10, fontweight='bold', color='red')\n",
"\n",
"# Set x-axis formatting\n",
"ax1.set_xticks(x_positions)\n",
"ax1.set_xticklabels(stage_labels, fontsize=11)\n",
"\n",
"# Add title\n",
"plt.suptitle('Fuel Utilization Report - Institute of Science, Health and Performance',\n",
" fontsize=14, fontweight='bold', y=0.95)\n",
"\n",
"# Create legend\n",
"lines1, labels1 = ax1.get_legend_handles_labels()\n",
"lines2, labels2 = ax2.get_legend_handles_labels()\n",
"ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left',\n",
" frameon=True, fancybox=True, shadow=True)\n",
"\n",
"# Add grid\n",
"ax1.grid(True, alpha=0.3, linestyle='-', linewidth=0.5)\n",
"ax1.set_axisbelow(True)\n",
"\n",
"# Adjust layout\n",
"plt.tight_layout()\n",
"plt.subplots_adjust(bottom=0.1, top=0.9)\n",
"plt.savefig(f'{base_dir}/graphs/fuel_utilization_chart.png', dpi=300)\n",
"plt.show()"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 13,
"id": "8a1878a0",
"metadata": {},
"outputs": [
{
"data": {
2025-11-21 09:23:13 +01:00
"image/png": "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
"text/plain": [
"<Figure size 1800x500 with 3 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"first_unique_phase = df.drop_duplicates(subset='PHASE')\n",
"phase_times = first_unique_phase['T(sec)'].tolist()\n",
"\n",
"plt.figure(figsize=(18, 5))\n",
"ax1 = plt.subplot()\n",
"\n",
"# Plot VO2 Pulse\n",
"#\n",
"line1 = sns.lineplot(data=df, x='T(sec)', y='VO2 Pulse_smoothed', label='VO2 Pulse (mL/beat)', color='blue')\n",
"ax1.set_xlabel('Time (sec)')\n",
"ax1.set_ylabel('VO2 Pulse (mL/beat)')\n",
"# ax1.set_title('VO2 Pulse, Heart Rate, and Speed Over Time')\n",
"ax1.set_ylim(0, df['VO2 Pulse_smoothed'].max())\n",
"ax1.grid(True, alpha=0.1)\n",
"# Set x-axis limits to remove padding\n",
"ax1.set_xlim(0, df['T(sec)'].max())\n",
"# Create second y-axis for heart rate\n",
"#\n",
"ax2 = ax1.twinx()\n",
"line2 = sns.lineplot(data=df, x='T(sec)', y='HR(bpm)_smoothed', color='red', ax=ax2, \n",
" linewidth=2, label='Heart Rate (bpm)')\n",
"ax2.set_ylabel('Heart Rate (bpm)', color='red')\n",
"ax2.tick_params(axis='y', labelcolor='red')\n",
"ax2.set_ylim(60, df['HR(bpm)_smoothed'].max() + 1)\n",
"\n",
"# Create third y-axis for speed\n",
"ax3 = ax1.twinx()\n",
"ax3.spines['right'].set_position(('outward', 60))\n",
"\n",
"line3 = sns.lineplot(data=df, x='T(sec)', y='Speed', color='green', ax=ax3, \n",
" drawstyle='steps-post', linewidth=2, label='Speed')\n",
"ax3.set_ylabel('Speed', color='green')\n",
"ax3.tick_params(axis='y', labelcolor='green')\n",
"ax3.set_ylim(0, df['Speed'].max() + 1)\n",
"\n",
"ax1.set_xticks(np.arange(0, df['T(sec)'].max(), 200))\n",
"\n",
"# Remove default legends first\n",
"if ax1.get_legend():\n",
" ax1.get_legend().remove()\n",
"if ax2.get_legend():\n",
" ax2.get_legend().remove()\n",
"if ax3.get_legend():\n",
" ax3.get_legend().remove()\n",
"\n",
"# Combine legends from all axes in the top left\n",
"lines1, labels1 = ax1.get_legend_handles_labels()\n",
"lines2, labels2 = ax2.get_legend_handles_labels()\n",
"lines3, labels3 = ax3.get_legend_handles_labels()\n",
"ax1.legend(lines1 + lines2 + lines3, labels1 + labels2 + labels3, loc='upper left')\n",
"\n",
"# Add colored background regions if you have phase information\n",
"ax1.axvspan(0, phase_times[1], alpha=0.2, color='lightblue')\n",
"ax1.axvspan(phase_times[1], phase_times[2], alpha=0.2, color='purple')\n",
"ax1.axvspan(phase_times[2], phase_times[3], alpha=0.2, color='lightgreen')\n",
"ax1.axvspan(phase_times[3], df['T(sec)'].max(), alpha=0.2, color='blue')\n",
"\n",
"plt.savefig(f'{base_dir}/graphs/vo2_pulse_chart.png', bbox_inches='tight', dpi=300)\n",
"plt.show()"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 14,
"id": "7361fb05",
"metadata": {},
"outputs": [
{
"data": {
2025-11-21 09:23:13 +01:00
"image/png": "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
"text/plain": [
"<Figure size 1800x500 with 2 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"first_unique_phase = df.drop_duplicates(subset='PHASE')\n",
"phase_times = first_unique_phase['T(sec)'].tolist()\n",
"\n",
"plt.figure(figsize=(18, 5))\n",
"ax1 = plt.subplot()\n",
"\n",
"# Plot VT with step-like appearance\n",
"line1 = sns.lineplot(data=df, x='T(sec)', y='VO2 Breath_smoothed', label='VO2 per Breath (mL/breath)')\n",
"ax1.set_xlabel('Time (sec)')\n",
"ax1.set_ylabel('VO2 per Breath (mL/breath)')\n",
"# ax1.set_title('VO2 per Breath and Speed Over Time')\n",
"ax1.set_ylim(0, df['VO2 Breath_smoothed'].max() + 1)\n",
"ax1.grid(True, alpha=0.1)\n",
"# Set x-axis limits to remove padding\n",
"ax1.set_xlim(0, df['T(sec)'].max())\n",
"\n",
"# Plot speed as step function on secondary y-axis\n",
"ax2 = ax1.twinx()\n",
"ax1.set_xticks(np.arange(0, df['T(sec)'].max(), 200))\n",
"line2 = sns.lineplot(data=df, x='T(sec)', y='Speed', color='green', ax=ax2, \n",
" drawstyle='steps-post', linewidth=2, label='Speed')\n",
"ax2.set_ylim(0, df['Speed'].max() + 1)\n",
"ax2.set_ylabel('Speed')\n",
"\n",
"# Remove default legends first\n",
"ax1.get_legend().remove()\n",
"ax2.get_legend().remove()\n",
"\n",
"# Combine legends from both axes in the top left\n",
"lines1, labels1 = ax1.get_legend_handles_labels()\n",
"lines2, labels2 = ax2.get_legend_handles_labels()\n",
"ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left')\n",
"\n",
"# Add colored background regions if you have phase information\n",
"ax1.axvspan(0, phase_times[1], alpha=0.2, color='lightblue')\n",
"ax1.axvspan(phase_times[1], phase_times[2], alpha=0.2, color='purple')\n",
"ax1.axvspan(phase_times[2], phase_times[3], alpha=0.2, color='lightgreen')\n",
"ax1.axvspan(phase_times[3], df['T(sec)'].max(), alpha=0.2, color='blue')\n",
"\n",
"plt.savefig(f'{base_dir}/graphs/vo2_breath_chart.png', bbox_inches='tight', dpi=300)\n",
"plt.show()"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 15,
"id": "c89478ff",
"metadata": {},
"outputs": [
{
"data": {
2025-11-21 09:23:13 +01:00
"image/png": "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
"text/plain": [
"<Figure size 1800x500 with 2 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"first_unique_phase = df.drop_duplicates(subset='PHASE')\n",
"phase_times = first_unique_phase['T(sec)'].tolist()\n",
"\n",
"plt.figure(figsize=(18, 5))\n",
"ax1 = plt.subplot()\n",
"\n",
"df['CHO']\n",
"# Plot VT with step-like appearance\n",
"line1 = sns.lineplot(data=df, x='T(sec)', y='CHO_smoothed', label='CHO (kcal/min)')\n",
"ax1.set_xlabel('Time (sec)')\n",
"ax1.set_ylabel('CHO (g/min)')\n",
"# ax1.set_title('CHO and Speed Over Time')\n",
"ax1.grid(True, alpha=0.1)\n",
"# Set x-axis limits to remove padding\n",
"ax1.set_xlim(0, df['T(sec)'].max())\n",
"# Plot speed as step function on secondary y-axis\n",
"ax2 = ax1.twinx()\n",
"ax1.set_xticks(np.arange(0, df['T(sec)'].max(), 200))\n",
"line2 = sns.lineplot(data=df, x='T(sec)', y='FAT_smoothed', color='green', ax=ax2, label='FAT (kcal/min)')\n",
"ax2.set_ylabel('FAT (kcal/min)')\n",
"\n",
"ax2.set_ylim(0, 15) # ensures HR line is above bars\n",
"\n",
"# Remove default legends first\n",
"ax1.get_legend().remove()\n",
"ax2.get_legend().remove()\n",
"\n",
"# Combine legends from both axes in the top left\n",
"lines1, labels1 = ax1.get_legend_handles_labels()\n",
"lines2, labels2 = ax2.get_legend_handles_labels()\n",
"ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left')\n",
"\n",
"# Add colored background regions if you have phase information\n",
"ax1.axvspan(0, phase_times[1], alpha=0.2, color='lightblue')\n",
"ax1.axvspan(phase_times[1], phase_times[2], alpha=0.2, color='purple')\n",
"ax1.axvspan(phase_times[2], phase_times[3], alpha=0.2, color='lightgreen')\n",
"ax1.axvspan(phase_times[3], df['T(sec)'].max(), alpha=0.2, color='blue')\n",
"\n",
"plt.savefig(f'{base_dir}/graphs/fat_metabolism_chart.png', bbox_inches='tight', dpi=300)\n",
"plt.show()"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 16,
"id": "1db16040",
"metadata": {},
"outputs": [
{
"data": {
2025-11-21 09:23:13 +01:00
"image/png": "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
"text/plain": [
"<Figure size 1800x500 with 3 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"first_unique_phase = df.drop_duplicates(subset='PHASE')\n",
"phase_times = first_unique_phase['T(sec)'].tolist()\n",
"\n",
"plt.figure(figsize=(18, 5))\n",
"ax1 = plt.subplot()\n",
"\n",
"# Plot VO2 Pulse\n",
"line1 = sns.lineplot(data=df, x='T(sec)', y='VCO2(ml/min)_smoothed', label='VCO2 (ml/min)', color='blue')\n",
"ax1.set_xlabel('Time (sec)')\n",
"ax1.set_ylabel('VO2 Pulse (mL/beat)')\n",
"# ax1.set_title('VO2 Pulse, Heart Rate, and Speed Over Time')\n",
"ax1.set_ylim(0, df['VCO2(ml/min)'].max())\n",
"ax1.grid(True, alpha=0.1)\n",
"# Set x-axis limits to remove padding\n",
"ax1.set_xlim(0, df['T(sec)'].max())\n",
"\n",
"# Create second y-axis for heart rate\n",
"ax2 = ax1.twinx()\n",
"line2 = sns.lineplot(data=df, x='T(sec)', y='HR(bpm)_smoothed', color='red', ax=ax2, \n",
" linewidth=2, label='Heart Rate (bpm)')\n",
"ax2.set_ylabel('Heart Rate (bpm)', color='red')\n",
"ax2.set_ylim(df['HR(bpm)_smoothed'].min() - 1, df['HR(bpm)_smoothed'].max() + 1)\n",
"ax2.tick_params(axis='y', labelcolor='red')\n",
"\n",
"# Create third y-axis for speed\n",
"ax3 = ax1.twinx()\n",
"ax3.spines['right'].set_position(('outward', 60))\n",
"line3 = sns.lineplot(data=df, x='T(sec)', y='BF(bpm)_smoothed', color='green', ax=ax3, linewidth=2, label='BF (bpm)')\n",
"ax3.set_ylabel('BF (bpm)', color='green')\n",
"ax3.tick_params(axis='y', labelcolor='green')\n",
"ax3.set_ylim(0, df['BF(bpm)_smoothed'].max() + 1)\n",
"ax1.set_xticks(np.arange(0, df['T(sec)'].max(), 200))\n",
"\n",
"# Remove default legends first\n",
"if ax1.get_legend():\n",
" ax1.get_legend().remove()\n",
"if ax2.get_legend():\n",
" ax2.get_legend().remove()\n",
"if ax3.get_legend():\n",
" ax3.get_legend().remove()\n",
"\n",
"# Combine legends from all axes in the top left\n",
"lines1, labels1 = ax1.get_legend_handles_labels()\n",
"lines2, labels2 = ax2.get_legend_handles_labels()\n",
"lines3, labels3 = ax3.get_legend_handles_labels()\n",
"ax1.legend(lines1 + lines2 + lines3, labels1 + labels2 + labels3, loc='upper left')\n",
"\n",
"# Add colored background regions if you have phase information\n",
"ax1.axvspan(0, phase_times[1], alpha=0.2, color='lightblue')\n",
"ax1.axvspan(phase_times[1], phase_times[2], alpha=0.2, color='purple')\n",
"ax1.axvspan(phase_times[2], phase_times[3], alpha=0.2, color='lightgreen')\n",
"ax1.axvspan(phase_times[3], df['T(sec)'].max(), alpha=0.2, color='blue')\n",
"\n",
"plt.savefig(f'{base_dir}/graphs/recovery_chart.png', bbox_inches='tight', dpi=300)\n",
"plt.show()"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 17,
"id": "c8ad6076",
"metadata": {},
"outputs": [
{
"data": {
2025-11-28 11:44:37 +01:00
"image/png": "iVBORw0KGgoAAAANSUhEUgAABKUAAAIcCAYAAAAnqB3MAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQAA70NJREFUeJzs3Xd4FNX6wPHvbE+ym56QhIQkBELvoEhXmogUFRD02ntDvT+v3ntt4BXLtVesV65eUUAR7ApIR0Sq9B5KSCC97GbrzO+PIQtLAgQEI/B+nmee3Z09M3Nm9uyEfTnnPYqmaRpCCCGEEEIIIYQQQvyBDPVdASGEEEIIIYQQQghx7pGglBBCCCGEEEIIIYT4w0lQSgghhBBCCCGEEEL84SQoJYQQQgghhBBCCCH+cBKUEkIIIYQQQgghhBB/OAlKCSGEEEIIIYQQQog/nASlhBBCCCGEEEIIIcQfToJSQgghhBBCCCGEEOIPJ0EpIYQQQgghhBBCCPGHk6CUEEKIU+7OO++kf//+p23/fr+fBx98kLS0NAwGA8OHDz9m+a5du/Lggw+etvqcrHHjxqEoSn1X45RatmwZFouFXbt2ndT2OTk5KIrCpEmTTnrb559//qSODVBUVERERATffvvtSe+jLq6//noyMjLqXNZut5/W+lSbNGkSiqKQk5NzWvb/R52LoiiMGzfutB/ndDtbzkMIIYQ4GglKCSHEWURRlDot8+bNA6CgoIB7772X5s2bExYWRmJiIueddx4PPfQQlZWVwf2eyA/JnTt38t577/HPf/4zuO5UBAsO95///IfnnnuOESNG8N///pf777+fDRs2MG7cuFp/TD/00EO88cYb5Ofnn5Lj/1HmzZtX58+02tq1axkxYgTp6enYbDYaNmxI//79ee2110L2nZGRwaWXXhp8XVRUxHPPPUevXr1ISEggOjqarl27MmXKlBOq88MPP8yYMWNIT08PruvTp09IXcPCwmjbti0vv/wyqqqe5NX5fb799ttaf+zHxcVx88038+ijj/6h9XG5XIwbNy743TyVjrz+FouFzMxMbr31Vvbs2XPKj3c6LVq0iEGDBtGwYUNsNhuNGjViyJAhTJ48ub6rdkKqv9ufffZZfVfljKaqKh9++CHnn38+sbGxOBwOsrOzufbaa1m6dGmw3LH+PgghhKhfpvqugBBCiFPno48+Cnn94YcfMmvWrBrrW7RoQXFxMZ07d6a8vJwbb7yR5s2bU1RUxG+//cbEiRO54447TqpHwyuvvEJmZiYXXnjh7zqXY/npp59o2LAhL730UnDdZ599xvjx4+nTp0+NHijDhg0jMjKSN998kyeeeOK01etUa9GiRY3P7h//+Ad2u52HH364RvklS5Zw4YUX0qhRI2655RaSkpLYs2cPS5cu5ZVXXuGee+456rF+/vlnHn74YS655BIeeeQRTCYTn3/+OaNHj2bDhg2MHz/+uPVdvXo1s2fPZsmSJTXeS01N5emnnwagsLCQyZMnc//991NQUMCECROC5dLT06mqqsJsNh/3eL/Ht99+yxtvvFFrYOr222/n1Vdf5aeffuKiiy46Lcd/9913QwJyLpcreI379Olzyo93+PX3er1s2LCBt956ix9++IGNGzcSHh5+yo95qk2bNo0rr7yS9u3bc++99xITE8POnTtZsGAB7777LldddVV9V/GUq6qqwmSSf64fzdixY3njjTcYNmwYV199NSaTic2bN/Pdd9/RuHFjunbtChC8h9X290EIIUT9kr9yQghxFvnLX/4S8nrp0qXMmjWrxnqA5557jt27d7N48WK6desW8l55eTkWi+WEj+/z+fj444+5/fbbT3jbE3HgwAGio6PrXN5gMDBixAg+/PBDxo8ff8YMmWvQoEGNz+6ZZ54hPj6+1s90woQJREVF8euvv9a4PgcOHDjmsVq1asXWrVtDejjdeeed9OvXj2effZYHH3yQiIiIY+7jgw8+oFGjRsEfgoeLiooKqfPtt99O8+bNee2113jiiScwGo2A3tvPZrMd8zinW4sWLWjdujWTJk06bUGp0x10O9KR1x8gMzOTu+++m8WLF5/W4banyrhx42jZsiVLly6tcX86Xvs+U9X3d6G+qaqK1+ut9Trs37+fN998k1tuuYV33nkn5L2XX36ZgoKCP6qaQgghfgcZvieEEOeo7du3YzQaaw0gREZGntSPoUWLFlFYWEi/fv1Oqk4ej4fHH3+cJk2aYLVaSUtL48EHH8Tj8QCHhgHOnTuX9evXB4cjTZo0iZEjRwJw4YUX1himCNC/f3927drF6tWrj1uP559/nm7duhEXF0dYWBidOnWqdZiNoijcfffdzJgxg9atW2O1WmnVqhXff/99rdemS5cu2Gw2srKyePvtt0/qGh3L9u3badWqVa0Bu8TExGNum5mZGRKQAv38hg8fjsfjYceOHcc9/owZM7jooovqFPSz2Wx06dKFioqKkIDC0XJKTZs2jZYtW2Kz2WjdujVffPHFMfMyvfPOO2RlZWG1WunSpQu//vpr8L3rr7+eN954I3iORw6BBL29fPXVV2iadtRzKC0txWg08uqrrwbXFRYWYjAYiIuLC9n2jjvuICkpKaQO1XXPyckhISEBIBg0rS2XUG5uLsOHD8dut5OQkMADDzxAIBA4av2Op7o+x+uJM3PmTAYPHkxKSgpWq5WsrCz+9a9/1XrsX375hUsuuYSYmBgiIiJo27Ytr7zyyjH3v3r1ahISEujTp0/IsOEjbd++nS5dutQaMD9e+wZYtWoVgwYNIjIyErvdTt++fUOGeMGhnFoLFizgtttuIy4ujsjISK699lpKSkpq7PO7776jZ8+eRERE4HA4GDx4MOvXrz9uXerqyHZQnYdu27ZtXH/99URHRxMVFcUNN9yAy+Wqsf3//vc/OnXqRFhYGLGxsYwePbrGkM2tW7dyxRVXkJSUhM1mIzU1ldGjR1NWVnbMuvXp04fWrVuzYsUKunXrRlhYGJmZmbz11ls1yh7v3n74+d599918/PHHtGrVCqvVWuv9FPSh4pqm0b1791qvW3WbON7fhxNp32+88QaNGzcmLCyM8847j4ULF9KnT58avRvrer5CCCGkp5QQQpyz0tPTCQQCfPTRR1x33XWnZJ9LlixBURQ6dOhwwtuqqsrQoUNZtGgRt956Ky1atGDt2rW89NJLbNmyhRkzZpCQkMBHH33EhAkTqKysDA5Hatq0KWPHjuXVV1/ln//8Jy1atAAIPgJ06tQJgMWLFx+3fq+88gpDhw7l6quvxuv18umnnzJy5Ei+/vprBg8eHFJ20aJFTJ8+nTvvvBOHw8Grr77KFVdcwe7du4mLiwP0PE8DBgwgISGBcePG4ff7efzxx2nQoMEJX6djSU9P5+eff2bdunW0bt36lOyzOg9XfHz8Mcvl5uaye/duOnbsWOd9Vwegjtfr7ZtvvuHKK6+kTZs2PP3005SUlHDTTTfRsGHDWstPnjyZiooKbrvtNhRF4d///jeXX345O3bswGw2c9ttt7Fv375ah7ZW69SpEy+99BLr168/6rWMjo6mdevWLFiwgLFjxwJ6e1AUheLiYjZs2ECrVq0AWLhwIT179qx1PwkJCcEhs5dddhmXX345AG3btg2WCQQCDBw4kPPPP5/nn3+e2bNn88ILL5CVlcUdd9xxzOtXvX1hYSGg92jcuHFj8EdzbT/qDzdp0iTsdjt//etfsdvt/PTTTzz22GOUl5fz3HPPBcvNmjWLSy+9lOTkZO69916SkpLYuHEjX3/9Nffee2+t+/71118ZOHAgnTt3ZubMmYSFhR21Hunp6cyZM4e9e/eSmpp63HM+3Pr16+nZsyeRkZE8+OCDmM1m3n77bfr06cP8+fM5//zzQ8rffffdREdHM27cODZv3szEiRPZtWtXMBcUELx3Dhw4kGeffRaXy8XEiRPp0aMHq1atOq3DxEaNGkVmZiZPP/00K1eu5L333iMxMZFnn302WGbChAk8+uijjBo1iptvvpmCggJee+01evXqxapVq4iOjsbr9TJw4EA8Hg/33HM
"text/plain": [
"<Figure size 1200x550 with 1 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"# Plot TSI (Left) and TSI2 (Right) with Black Slope (Trend) Lines per Stage\n",
"from numpy.polynomial.polynomial import Polynomial\n",
"\n",
"plt.figure(figsize=(12, 5.5))\n",
"\n",
"# Plot TSI (Left Leg)\n",
"plt.plot(\n",
" oxygenation['Time'], oxygenation['TSI'], \n",
" label='TSI (Left Leg)', color='steelblue', linewidth=2\n",
")\n",
"\n",
"# Plot TSI2 (Right Leg)\n",
"plt.plot(\n",
" oxygenation['Time'], oxygenation['TSI-second'],\n",
" label='TSI2 (Right Leg)', color='orange', linewidth=2\n",
")\n",
"\n",
"# Define time intervals for stages (adjust these based on your test protocol)\n",
"# Looking at the data range, we'll create intervals\n",
"max_time = oxygenation['Time'].max()\n",
"intervals = [\n",
" (0, 250),\n",
" (250, 500),\n",
" (500, 750),\n",
" (750, 1000),\n",
" (1000, 1250),\n",
" (1250, 1500),\n",
" (1500, max_time)\n",
"]\n",
"\n",
"# Calculate and plot trend lines for each interval\n",
"for start_time, end_time in intervals:\n",
" # Filter data for this interval\n",
" mask_interval = (oxygenation['Time'] >= start_time) & (oxygenation['Time'] <= end_time)\n",
" \n",
" # TSI (Left Leg) trend for this interval\n",
" mask_left = mask_interval & ~oxygenation['TSI'].isna()\n",
" if mask_left.sum() > 1: # Need at least 2 points for a line\n",
" x_left = oxygenation.loc[mask_left, 'Time']\n",
" y_left = oxygenation.loc[mask_left, 'TSI']\n",
" coefs_left = Polynomial.fit(x_left, y_left, 1).convert().coef\n",
" trend_left = coefs_left[0] + coefs_left[1] * x_left\n",
" plt.plot(x_left, trend_left, color='black', linestyle='--', linewidth=2, alpha=0.8)\n",
" \n",
" # TSI-second (Right Leg) trend for this interval\n",
" mask_right = mask_interval & ~oxygenation['TSI-second'].isna()\n",
" if mask_right.sum() > 1: # Need at least 2 points for a line\n",
" x_right = oxygenation.loc[mask_right, 'Time']\n",
" y_right = oxygenation.loc[mask_right, 'TSI-second']\n",
" coefs_right = Polynomial.fit(x_right, y_right, 1).convert().coef\n",
" trend_right = coefs_right[0] + coefs_right[1] * x_right\n",
" plt.plot(x_right, trend_right, color='black', linestyle='--', linewidth=2, alpha=0.8)\n",
"\n",
"plt.xlabel('Time (s)')\n",
"plt.ylabel('TSI (%)')\n",
"plt.title('TSI (Left) and TSI2 (Right) with Black Slope Lines per Stage')\n",
"plt.legend(fontsize=10, loc='upper right')\n",
"plt.tight_layout()\n",
"plt.grid(alpha=0.25)\n",
"# plt.savefig('graphs/tsi_comparison_with_trends.png', bbox_inches='tight', dpi=160)\n",
"plt.show()"
]
},
2025-11-21 09:23:13 +01:00
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 18,
2025-11-21 09:23:13 +01:00
"id": "25327cc1",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Age: 45, Gender: male, VO2 Max: 35.5\n",
"Age Range: 40-49, Category: Poor\n"
]
},
{
"data": {
"image/png": "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
"text/plain": [
"<Figure size 1400x300 with 1 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"import matplotlib.pyplot as plt\n",
"import matplotlib.patches as patches\n",
"from matplotlib.table import Table\n",
"import numpy as np\n",
"\n",
"# VO2 Max Master Chart Data\n",
"VO2_MASTER_CHART = {\n",
" 'male': {\n",
" '20-29': {\n",
" 'Very Poor': (29.0, 38.1),\n",
" 'Poor': (38.1, 44.9),\n",
" 'Fair': (44.9, 50.2),\n",
" 'Good': (50.2, 61.8),\n",
" 'Excellent': (57.1, 66.3),\n",
" 'Superior': (66.3, None) # None means open-ended\n",
" },\n",
" '30-39': {\n",
" 'Very Poor': (27.2, 34.1),\n",
" 'Poor': (34.1, 39.6),\n",
" 'Fair': (39.6, 45.2),\n",
" 'Good': (45.2, 51.6),\n",
" 'Excellent': (51.6, 59.8),\n",
" 'Superior': (59.8, None)\n",
" },\n",
" '40-49': {\n",
" 'Very Poor': (24.2, 30.5),\n",
" 'Poor': (30.5, 35.7),\n",
" 'Fair': (35.7, 40.3),\n",
" 'Good': (40.3, 46.7),\n",
" 'Excellent': (46.7, 55.6),\n",
" 'Superior': (55.6, None)\n",
" },\n",
" '50-59': {\n",
" 'Very Poor': (20.9, 26.1),\n",
" 'Poor': (26.1, 30.7),\n",
" 'Fair': (30.7, 35.1),\n",
" 'Good': (35.1, 41.2),\n",
" 'Excellent': (41.2, 50.7),\n",
" 'Superior': (50.7, None)\n",
" },\n",
" '60-69': {\n",
" 'Very Poor': (17.4, 22.4),\n",
" 'Poor': (22.4, 26.6),\n",
" 'Fair': (26.6, 30.5),\n",
" 'Good': (30.5, 36.1),\n",
" 'Excellent': (36.1, 43.0),\n",
" 'Superior': (43.0, None)\n",
" }\n",
" },\n",
" 'female': {\n",
" '20-29': {\n",
" 'Very Poor': (21.7, 28.6),\n",
" 'Poor': (28.6, 34.6),\n",
" 'Fair': (34.6, 40.6),\n",
" 'Good': (40.6, 46.5),\n",
" 'Excellent': (46.5, 56.0),\n",
" 'Superior': (56.0, None)\n",
" },\n",
" '30-39': {\n",
" 'Very Poor': (19.0, 24.1),\n",
" 'Poor': (24.1, 28.2),\n",
" 'Fair': (28.2, 32.2),\n",
" 'Good': (32.2, 35.7),\n",
" 'Excellent': (35.7, 45.8),\n",
" 'Superior': (45.8, None)\n",
" },\n",
" '40-49': {\n",
" 'Very Poor': (17.0, 21.3),\n",
" 'Poor': (21.3, 24.9),\n",
" 'Fair': (24.9, 28.7),\n",
" 'Good': (28.7, 34.0),\n",
" 'Excellent': (34.0, 41.7),\n",
" 'Superior': (41.7, None)\n",
" },\n",
" '50-59': {\n",
" 'Very Poor': (16.0, 19.1),\n",
" 'Poor': (19.1, 24.4),\n",
" 'Fair': (21.8, 27.6),\n",
" 'Good': (25.2, 28.6),\n",
" 'Excellent': (28.6, 35.9),\n",
" 'Superior': (35.9, None)\n",
" },\n",
" '60-69': {\n",
" 'Very Poor': (13.4, 16.5),\n",
" 'Poor': (16.5, 18.9),\n",
" 'Fair': (18.9, 21.2),\n",
" 'Good': (21.2, 24.6),\n",
" 'Excellent': (24.6, 29.4),\n",
" 'Superior': (29.4, None)\n",
" }\n",
" }\n",
"}\n",
"\n",
"def get_age_range(age):\n",
" \"\"\"Determine age range from age in years.\"\"\"\n",
" if 20 <= age <= 29:\n",
" return '20-29'\n",
" elif 30 <= age <= 39:\n",
" return '30-39'\n",
" elif 40 <= age <= 49:\n",
" return '40-49'\n",
" elif 50 <= age <= 59:\n",
" return '50-59'\n",
" elif 60 <= age <= 69:\n",
" return '60-69'\n",
" else:\n",
" # Default to closest range\n",
" if age < 20:\n",
" return '20-29'\n",
" elif age >= 70:\n",
" return '60-69'\n",
" else:\n",
" return '30-39' # fallback\n",
"\n",
"def determine_category(vo2_max, age_range, gender):\n",
" \"\"\"Determine VO2 max category based on value, age, and gender.\"\"\"\n",
" gender_key = 'male' if gender.lower().startswith('m') else 'female'\n",
" ranges = VO2_MASTER_CHART[gender_key][age_range]\n",
" \n",
" categories = ['Very Poor', 'Poor', 'Fair', 'Good', 'Excellent', 'Superior']\n",
" \n",
" # Check Superior category first (open-ended)\n",
" min_val, max_val = ranges['Superior']\n",
" if max_val is None and vo2_max >= min_val:\n",
" print(f\"It is always superior\")\n",
" return 'Superior'\n",
" \n",
" # Check other categories from Excellent down to Very Poor\n",
" # Ranges are typically [min, max) - inclusive of min, exclusive of max\n",
" # But when value equals max, it belongs to the next category\n",
" for category in reversed(categories[:-1]): # Exclude Superior as we already checked it\n",
" min_val, max_val = ranges[category]\n",
" # Check if value falls in this range (inclusive of min, exclusive of max)\n",
" if min_val <= vo2_max < max_val:\n",
" return category\n",
" \n",
" # If value is below all ranges, return Very Poor\n",
" # This handles the case where vo2_max < min of Very Poor\n",
" return 'Very Poor'\n",
"\n",
"def format_range(min_val, max_val):\n",
" \"\"\"Format range as string.\"\"\"\n",
" if max_val is None:\n",
" return f\"{min_val}+\"\n",
" else:\n",
" return f\"{min_val}-{max_val}\"\n",
"\n",
"def generate_vo2_max_table(age, gender, vo2_max_value, save_path=None):\n",
" \"\"\"\n",
" Generate VO2 Max table with indicator arrow.\n",
" \n",
" Args:\n",
" age: Patient age in years\n",
" gender: 'male' or 'female' (or 'm'/'f')\n",
" vo2_max_value: Patient's VO2 max value\n",
" save_path: Optional path to save the figure\n",
" \n",
" Returns:\n",
" fig, ax: matplotlib figure and axes\n",
" \"\"\"\n",
" # Determine age range and category\n",
" age_range = get_age_range(age)\n",
" category = determine_category(vo2_max_value, age_range, gender)\n",
" \n",
" # Debug: print the determined category\n",
" print(f\"Age: {age}, Gender: {gender}, VO2 Max: {vo2_max_value}\")\n",
" print(f\"Age Range: {age_range}, Category: {category}\")\n",
" \n",
" # Get the appropriate data\n",
" gender_key = 'male' if gender.lower().startswith('m') else 'female'\n",
" ranges = VO2_MASTER_CHART[gender_key][age_range]\n",
" \n",
" # Prepare table data\n",
" headers = ['Age', 'Very Poor', 'Poor', 'Fair', 'Good', 'Excellent', 'Superior']\n",
" age_label = f\"{age_range} ({gender[0].upper()})\"\n",
" \n",
" row_data = [age_label]\n",
" for cat in ['Very Poor', 'Poor', 'Fair', 'Good', 'Excellent', 'Superior']:\n",
" min_val, max_val = ranges[cat]\n",
" row_data.append(format_range(min_val, max_val))\n",
" \n",
" # Create figure\n",
" fig, ax = plt.subplots(figsize=(14, 3))\n",
" ax.axis('off')\n",
" \n",
" # Create table\n",
" table_data = [headers, row_data]\n",
" table = ax.table(cellText=table_data,\n",
" cellLoc='center',\n",
" loc='center',\n",
" bbox=[0, 0, 1, 1])\n",
" \n",
" # Style the table\n",
" table.auto_set_font_size(False)\n",
" table.set_fontsize(11)\n",
" table.scale(1, 2.5)\n",
" \n",
" # Header row styling (cyan background)\n",
" for i in range(len(headers)):\n",
" cell = table[(0, i)]\n",
" cell.set_facecolor('#7dd3fc') # cyan-300 equivalent\n",
" cell.set_text_props(weight='bold', color='black')\n",
" cell.set_edgecolor('#9ca3af') # gray-400\n",
" cell.set_linewidth(1)\n",
" \n",
" # Find the column index for the category (before styling)\n",
" category_index = headers.index(category)\n",
" \n",
" # Data row styling\n",
" for i in range(len(row_data)):\n",
" cell = table[(1, i)]\n",
" if i == 0: # Age column\n",
" cell.set_facecolor('#a5f3fc') # cyan-200\n",
" cell.set_text_props(weight='semibold', color='black')\n",
" else:\n",
" cell.set_facecolor('#f3f4f6') # gray-100\n",
" cell.set_text_props(color='black')\n",
" # Bold the cell that corresponds to the patient's category\n",
" if i == category_index:\n",
" cell.set_text_props(weight='bold', color='black')\n",
" cell.set_edgecolor('#9ca3af') # gray-400\n",
" cell.set_linewidth(1)\n",
" \n",
" # Add arrow indicator below the category column\n",
" # Calculate position\n",
" cell_width = 1.0 / len(headers)\n",
" arrow_x = (category_index + 0.5) * cell_width\n",
" \n",
" # Draw arrow pointing up\n",
" arrow = patches.FancyArrowPatch(\n",
" (arrow_x, -0.15), (arrow_x, -0.05),\n",
" arrowstyle='->', mutation_scale=20, \n",
" linewidth=2, color='black',\n",
" transform=ax.transAxes\n",
" )\n",
" ax.add_patch(arrow)\n",
" \n",
" # Add triangle at the top\n",
" triangle = patches.RegularPolygon(\n",
" (arrow_x, -0.05), 3, radius=0.02,\n",
" orientation=np.pi/2, color='black',\n",
" transform=ax.transAxes\n",
" )\n",
" ax.add_patch(triangle)\n",
" \n",
" # Set title - calculate approximate percentile\n",
" # For Superior category, use 100th percentile as shown in template\n",
" if category == 'Superior':\n",
" percentile = '100th percentile'\n",
" else:\n",
" percentile_map = {\n",
" 'Very Poor': '1st-10th percentile',\n",
" 'Poor': '10th-20th percentile',\n",
" 'Fair': '20th-40th percentile',\n",
" 'Good': '40th-60th percentile',\n",
" 'Excellent': '60th-80th percentile'\n",
" }\n",
" percentile = percentile_map.get(category, 'N/A')\n",
" \n",
" title = f\"VO2 Max - {vo2_max_value:.1f} ({percentile})\"\n",
" ax.set_title(title, fontsize=14, fontweight='bold', pad=20)\n",
" \n",
" plt.tight_layout()\n",
" \n",
" if save_path:\n",
" plt.savefig(save_path, dpi=300, bbox_inches='tight')\n",
" \n",
" return fig, ax\n",
"\n",
"# Test the function\n",
"# Example: 30-year-old female with VO2 max of 49.5\n",
"fig, ax = generate_vo2_max_table(\n",
" age=45,\n",
" gender='male',\n",
" vo2_max_value=35.5,\n",
" save_path=f'{base_dir}/graphs/vo2_max_table.png'\n",
")\n",
"plt.show()\n"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 19,
"id": "c46b53f0",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Age: 50, Gender: male, Resting Heart Rate: 76\n",
"Age Range: 46-55, Category: Average\n"
]
},
{
"data": {
"image/png": "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
"text/plain": [
"<Figure size 1600x300 with 1 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"# Resting Heart Rate Master Chart Data\n",
"RHR_MASTER_CHART = {\n",
" 'male': {\n",
" '18-25': {\n",
" 'Poor': (85, None), # 85bpm+\n",
" 'Below Average': (79, 85),\n",
" 'Average': (74, 79),\n",
" 'Above Average': (70, 74),\n",
" 'Good': (66, 70),\n",
" 'Excellent': (61, 66),\n",
" 'Athlete': (40, 61)\n",
" },\n",
" '26-35': {\n",
" 'Poor': (83, None), # 83bpm+\n",
" 'Below Average': (77, 83),\n",
" 'Average': (73, 77),\n",
" 'Above Average': (69, 73),\n",
" 'Good': (65, 69),\n",
" 'Excellent': (60, 65),\n",
" 'Athlete': (42, 60)\n",
" },\n",
" '36-45': {\n",
" 'Poor': (85, None), # 85bpm+\n",
" 'Below Average': (79, 85),\n",
" 'Average': (74, 79),\n",
" 'Above Average': (70, 74),\n",
" 'Good': (65, 70),\n",
" 'Excellent': (60, 65),\n",
" 'Athlete': (45, 60)\n",
" },\n",
" '46-55': {\n",
" 'Poor': (84, None), # 84bpm+\n",
" 'Below Average': (78, 84),\n",
" 'Average': (74, 78),\n",
" 'Above Average': (70, 74),\n",
" 'Good': (66, 70),\n",
" 'Excellent': (61, 66),\n",
" 'Athlete': (48, 61)\n",
" },\n",
" '56-65': {\n",
" 'Poor': (84, None), # 84bpm+\n",
" 'Below Average': (78, 84),\n",
" 'Average': (74, 78),\n",
" 'Above Average': (70, 74),\n",
" 'Good': (65, 70),\n",
" 'Excellent': (60, 65),\n",
" 'Athlete': (50, 60)\n",
" },\n",
" '65+': {\n",
" 'Poor': (84, None), # 84bpm+\n",
" 'Below Average': (77, 84),\n",
" 'Average': (73, 77),\n",
" 'Above Average': (70, 73),\n",
" 'Good': (65, 70),\n",
" 'Excellent': (60, 65),\n",
" 'Athlete': (52, 60)\n",
" }\n",
" },\n",
" 'female': {\n",
" '18-25': {\n",
" 'Poor': (82, None), # 82bpm+\n",
" 'Below Average': (74, 82),\n",
" 'Average': (70, 74),\n",
" 'Above Average': (66, 70),\n",
" 'Good': (62, 66),\n",
" 'Excellent': (56, 62),\n",
" 'Athlete': (40, 56)\n",
" },\n",
" '26-35': {\n",
" 'Poor': (82, None), # 82bpm+\n",
" 'Below Average': (75, 82),\n",
" 'Average': (71, 75),\n",
" 'Above Average': (66, 71),\n",
" 'Good': (62, 66),\n",
" 'Excellent': (55, 62),\n",
" 'Athlete': (44, 55)\n",
" },\n",
" '36-45': {\n",
" 'Poor': (83, None), # 83bpm+\n",
" 'Below Average': (76, 83),\n",
" 'Average': (71, 76),\n",
" 'Above Average': (67, 71),\n",
" 'Good': (63, 67),\n",
" 'Excellent': (57, 63),\n",
" 'Athlete': (47, 57)\n",
" },\n",
" '46-55': {\n",
" 'Poor': (84, None), # 84bpm+\n",
" 'Below Average': (77, 84),\n",
" 'Average': (72, 77),\n",
" 'Above Average': (68, 72),\n",
" 'Good': (64, 68),\n",
" 'Excellent': (58, 64),\n",
" 'Athlete': (49, 58)\n",
" },\n",
" '56-65': {\n",
" 'Poor': (82, None), # 82bpm+\n",
" 'Below Average': (76, 82),\n",
" 'Average': (72, 76),\n",
" 'Above Average': (68, 72),\n",
" 'Good': (62, 68),\n",
" 'Excellent': (57, 62),\n",
" 'Athlete': (51, 57)\n",
" },\n",
" '65+': {\n",
" 'Poor': (80, None), # 80bpm+\n",
" 'Below Average': (74, 80),\n",
" 'Average': (70, 74),\n",
" 'Above Average': (66, 70),\n",
" 'Good': (62, 66),\n",
" 'Excellent': (56, 62),\n",
" 'Athlete': (52, 56)\n",
" }\n",
" }\n",
"}\n",
"\n",
"def get_rhr_age_range(age):\n",
" \"\"\"Determine age range from age in years for resting heart rate.\"\"\"\n",
" if 18 <= age <= 25:\n",
" return '18-25'\n",
" elif 26 <= age <= 35:\n",
" return '26-35'\n",
" elif 36 <= age <= 45:\n",
" return '36-45'\n",
" elif 46 <= age <= 55:\n",
" return '46-55'\n",
" elif 56 <= age <= 65:\n",
" return '56-65'\n",
" else:\n",
" # Default to closest range\n",
" if age < 18:\n",
" return '18-25'\n",
" elif age > 65:\n",
" return '65+'\n",
" else:\n",
" return '26-35' # fallback\n",
"\n",
"def determine_rhr_category(rhr, age_range, gender):\n",
" \"\"\"Determine resting heart rate category based on value, age, and gender.\"\"\"\n",
" gender_key = 'male' if gender.lower().startswith('m') else 'female'\n",
" ranges = RHR_MASTER_CHART[gender_key][age_range]\n",
" \n",
" categories = ['Poor', 'Below Average', 'Average', 'Above Average', 'Good', 'Excellent', 'Athlete']\n",
" \n",
" # Check Poor category first (open-ended at top)\n",
" min_val, max_val = ranges['Poor']\n",
" if max_val is None and rhr >= min_val:\n",
" return 'Poor'\n",
" \n",
" # Check other categories from Below Average down to Athlete\n",
" # For RHR, lower is better, so we check from highest to lowest\n",
" for category in ['Below Average', 'Average', 'Above Average', 'Good', 'Excellent', 'Athlete']:\n",
" min_val, max_val = ranges[category]\n",
" # Check if value falls in this range (inclusive of min, exclusive of max)\n",
" if min_val <= rhr < max_val:\n",
" return category\n",
" \n",
" # If value is below all ranges (below Athlete minimum), return Athlete\n",
" # This handles the case where rhr < min of Athlete\n",
" return 'Athlete'\n",
"\n",
"def format_rhr_range(min_val, max_val):\n",
" \"\"\"Format RHR range as string.\"\"\"\n",
" if max_val is None:\n",
" return f\"{min_val}bpm +\"\n",
" else:\n",
" return f\"{min_val}-{max_val}bpm\"\n",
"\n",
"def generate_resting_heart_rate_table(age, gender, rhr_value, save_path=None):\n",
" \"\"\"\n",
" Generate Resting Heart Rate table with indicator arrow.\n",
" \n",
" Args:\n",
" age: Patient age in years\n",
" gender: 'male' or 'female' (or 'm'/'f')\n",
" rhr_value: Patient's resting heart rate value in bpm\n",
" save_path: Optional path to save the figure\n",
" \n",
" Returns:\n",
" fig, ax: matplotlib figure and axes\n",
" \"\"\"\n",
" # Determine age range and category\n",
" age_range = get_rhr_age_range(age)\n",
" category = determine_rhr_category(rhr_value, age_range, gender)\n",
" \n",
" # Debug: print the determined category\n",
" print(f\"Age: {age}, Gender: {gender}, Resting Heart Rate: {rhr_value}\")\n",
" print(f\"Age Range: {age_range}, Category: {category}\")\n",
" \n",
" # Get the appropriate data\n",
" gender_key = 'male' if gender.lower().startswith('m') else 'female'\n",
" ranges = RHR_MASTER_CHART[gender_key][age_range]\n",
" \n",
" # Prepare table data\n",
" headers = ['Age', 'Poor', 'Below Average', 'Average', 'Above Average', 'Good', 'Excellent', 'Athlete']\n",
" age_label = f\"{age_range} ({gender[0].upper()})\"\n",
" \n",
" row_data = [age_label]\n",
" for cat in ['Poor', 'Below Average', 'Average', 'Above Average', 'Good', 'Excellent', 'Athlete']:\n",
" min_val, max_val = ranges[cat]\n",
" row_data.append(format_rhr_range(min_val, max_val))\n",
" \n",
" # Create figure\n",
" fig, ax = plt.subplots(figsize=(16, 3))\n",
" ax.axis('off')\n",
" \n",
" # Create table\n",
" table_data = [headers, row_data]\n",
" table = ax.table(cellText=table_data,\n",
" cellLoc='center',\n",
" loc='center',\n",
" bbox=[0, 0, 1, 1])\n",
" \n",
" # Style the table\n",
" table.auto_set_font_size(False)\n",
" table.set_fontsize(11)\n",
" table.scale(1, 2.5)\n",
" \n",
" # Header row styling (cyan background)\n",
" for i in range(len(headers)):\n",
" cell = table[(0, i)]\n",
" cell.set_facecolor('#7dd3fc') # cyan-300 equivalent\n",
" cell.set_text_props(weight='bold', color='black')\n",
" cell.set_edgecolor('#9ca3af') # gray-400\n",
" cell.set_linewidth(1)\n",
" \n",
" # Find the column index for the category (before styling)\n",
" category_index = headers.index(category)\n",
" \n",
" # Data row styling\n",
" for i in range(len(row_data)):\n",
" cell = table[(1, i)]\n",
" if i == 0: # Age column\n",
" cell.set_facecolor('#a5f3fc') # cyan-200\n",
" cell.set_text_props(weight='semibold', color='black')\n",
" else:\n",
" # Highlight the category cell with light green background\n",
" if i == category_index:\n",
" cell.set_facecolor('#d1fae5') # green-200 equivalent\n",
" cell.set_text_props(weight='bold', color='black')\n",
" else:\n",
" cell.set_facecolor('#f3f4f6') # gray-100\n",
" cell.set_text_props(color='black')\n",
" cell.set_edgecolor('#9ca3af') # gray-400\n",
" cell.set_linewidth(1)\n",
" \n",
" # Add arrow indicator below the category column\n",
" # Calculate position\n",
" cell_width = 1.0 / len(headers)\n",
" arrow_x = (category_index + 0.5) * cell_width\n",
" \n",
" # Draw arrow pointing up\n",
" arrow = patches.FancyArrowPatch(\n",
" (arrow_x, -0.15), (arrow_x, -0.05),\n",
" arrowstyle='->', mutation_scale=20, \n",
" linewidth=2, color='black',\n",
" transform=ax.transAxes\n",
" )\n",
" ax.add_patch(arrow)\n",
" \n",
" # Add triangle at the top\n",
" triangle = patches.RegularPolygon(\n",
" (arrow_x, -0.05), 3, radius=0.02,\n",
" orientation=np.pi/2, color='black',\n",
" transform=ax.transAxes\n",
" )\n",
" ax.add_patch(triangle)\n",
" \n",
" # Set title\n",
" title = f\"Resting Heart Rate - {rhr_value:.0f}bpm\"\n",
" ax.set_title(title, fontsize=14, fontweight='bold', pad=20)\n",
" \n",
" plt.tight_layout()\n",
" \n",
" if save_path:\n",
" plt.savefig(save_path, dpi=300, bbox_inches='tight')\n",
" \n",
" return fig, ax\n",
"\n",
"# Test the function\n",
"# Example: 30-year-old female with resting heart rate of 53bpm\n",
"fig, ax = generate_resting_heart_rate_table(\n",
" age=50,\n",
" gender='male',\n",
" rhr_value=76,\n",
" save_path=f'{base_dir}/graphs/resting_heart_rate_table.png'\n",
")\n",
"plt.show()\n"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 20,
"id": "84addc63",
"metadata": {},
2025-11-21 10:01:07 +01:00
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"\n",
"==================================================\n",
"Optimal Fat Burning Zone (highest fat:carb ratio):\n",
"Time: 164.0 seconds\n",
"Fat burn rate: 3.894 kcal/min\n",
"Carb burn rate: 1.575 kcal/min\n",
"Fat:Carb ratio: 2.47\n",
"Heart Rate: 96.7 bpm\n",
"VO2: 1147.9 ml/min\n",
2025-11-28 11:44:37 +01:00
"VT1: {'HeartRate': np.float64(100.5), 'Speed': np.float64(4.0), 'Time': np.float64(251.0)}\n",
"VT2: {'HeartRate': np.float64(189.71300000000002), 'Speed': np.float64(7.5), 'Time': np.float64(1524.0)}\n",
2025-11-21 10:01:07 +01:00
"Zone 1 (Active Recovery): 81.7 - 96.7 bpm\n",
"Zone 2 (Aerobic Base): 96.7 - 100.5 bpm\n",
"Zone 3 (Aerobic): 100.5 - 179.7 bpm\n",
"Zone 4 (Lactate Threshold): 179.7 - 189.7 bpm\n",
"Zone 5 (VO2 Max): 189.7 - 199.7 bpm\n"
]
}
],
"source": [
"\n",
"# Find the point where fat burning is highest and carb burning is lowest\n",
"# Using the smoothed data for more stable results\n",
"fat_burn_max_idx = df['FAT_smoothed'].idxmax()\n",
"carb_burn_min_idx = df['CHO_smoothed'].idxmin()\n",
"\n",
"\n",
"print(\"\\n\" + \"=\"*50)\n",
"\n",
"# Find the optimal fat burning zone (highest fat:carb ratio)\n",
"df['fat_carb_ratio'] = df['FAT_smoothed'] / (df['CHO_smoothed'] + 0.00000001) # Add small value to avoid division by zero\n",
"optimal_fat_idx = df['fat_carb_ratio'].idxmax()\n",
"optimal_row = df.loc[optimal_fat_idx]\n",
"\n",
"print(\"Optimal Fat Burning Zone (highest fat:carb ratio):\")\n",
"print(f\"Time: {optimal_row['T(sec)']} seconds\")\n",
"print(f\"Fat burn rate: {optimal_row['FAT_smoothed']:.3f} kcal/min\")\n",
"print(f\"Carb burn rate: {optimal_row['CHO_smoothed']:.3f} kcal/min\")\n",
"print(f\"Fat:Carb ratio: {optimal_row['fat_carb_ratio']:.2f}\")\n",
"print(f\"Heart Rate: {optimal_row['HR(bpm)_smoothed']:.1f} bpm\")\n",
"print(f\"VO2: {optimal_row['VO2(ml/min)_smoothed']:.1f} ml/min\")\n",
"\n",
"def detect_vt1(df, fat_col=\"FAT_smoothed\", carb_col=\"CHO_smoothed\"):\n",
" \"\"\"\n",
" Detect VT1 as the first index where carb burn > fat burn and remains higher.\n",
" \"\"\"\n",
" condition = df[carb_col] > df[fat_col]\n",
" crossover_indices = condition[condition].index\n",
"\n",
" if len(crossover_indices) == 0:\n",
" return None # No crossover found\n",
" \n",
" # Find first crossover where carbs remain higher for the rest\n",
" for idx in crossover_indices:\n",
" if all(df.loc[idx:][carb_col] > df.loc[idx:][fat_col]):\n",
" return idx\n",
" return None\n",
"\n",
"\n",
"def detect_vt2(df, vent_col=\"VE(l/min)_smoothed\", bf_col=\"BF(bpm)_smoothed\", smooth_window=5):\n",
" \"\"\"\n",
" Detect VT2 using slope/inflection method.\n",
" Works with either Ventilation (VE) or Breathing Frequency (Bf).\n",
" \"\"\"\n",
" col = vent_col if vent_col in df.columns else bf_col\n",
" \n",
" # Use already smoothed data\n",
" smoothed_col = col\n",
" \n",
" # Compute slope (first derivative)\n",
" df[\"slope\"] = df[smoothed_col].diff()\n",
" \n",
" # Detect inflection: largest change in slope (second derivative peak)\n",
" df[\"second_derivative\"] = df[\"slope\"].diff()\n",
" inflection_idx = df[\"second_derivative\"].idxmax()\n",
" \n",
" return inflection_idx\n",
"\n",
"\n",
"def analyze_thresholds(df_input):\n",
" # Use the existing dataframe\n",
" df_copy = df_input.copy()\n",
" \n",
" # --- Detect VT1 ---\n",
" vt1_idx = detect_vt1(df_copy)\n",
" vt1 = None\n",
" if vt1_idx is not None:\n",
" vt1 = {\n",
" \"HeartRate\": df_copy.loc[vt1_idx, \"HR(bpm)_smoothed\"],\n",
" \"Speed\": df_copy.loc[vt1_idx, \"Speed\"],\n",
" \"Time\": df_copy.loc[vt1_idx, \"T(sec)\"]\n",
" }\n",
" \n",
" # --- Detect VT2 ---\n",
" vt2_idx = detect_vt2(df_copy)\n",
" vt2 = None\n",
" if vt2_idx is not None:\n",
" vt2 = {\n",
" \"HeartRate\": df_copy.loc[vt2_idx, \"HR(bpm)_smoothed\"],\n",
" \"Speed\": df_copy.loc[vt2_idx, \"Speed\"],\n",
" \"Time\": df_copy.loc[vt2_idx, \"T(sec)\"]\n",
" }\n",
" \n",
" return vt1, vt2\n",
"\n",
"\n",
"vt1, vt2 = analyze_thresholds(df)\n",
"print(\"VT1:\", vt1)\n",
"print(\"VT2:\", vt2)\n",
"zone_1_start = optimal_row['HR(bpm)_smoothed'] - 15\n",
"zone_2_start = optimal_row['HR(bpm)_smoothed']\n",
"zone_3_start = vt1\n",
"zone_4_start = vt2['HeartRate'] - 10\n",
"zone_5_start = vt2['HeartRate']\n",
"zone_5_end = vt2['HeartRate'] + 10\n",
"\n",
"zone_1_end = zone_2_start\n",
"zone_2_end = vt1['HeartRate']\n",
"zone_3_end = zone_4_start\n",
"zone_4_end = zone_5_start\n",
"\n",
"print(f\"Zone 1 (Active Recovery): {zone_1_start:.1f} - {zone_1_end:.1f} bpm\")\n",
"print(f\"Zone 2 (Aerobic Base): {zone_2_start:.1f} - {zone_2_end:.1f} bpm\")\n",
"print(f\"Zone 3 (Aerobic): {zone_3_start['HeartRate']:.1f} - {zone_3_end:.1f} bpm\")\n",
"print(f\"Zone 4 (Lactate Threshold): {zone_4_start:.1f} - {zone_4_end:.1f} bpm\")\n",
"print(f\"Zone 5 (VO2 Max): {zone_5_start:.1f} - {zone_5_end:.1f} bpm\")"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 21,
2025-11-21 10:01:07 +01:00
"id": "f324fe94",
"metadata": {},
"outputs": [
{
"data": {
"image/png": "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
2025-11-21 10:01:07 +01:00
"text/plain": [
"<Figure size 1200x800 with 1 Axes>"
2025-11-21 10:01:07 +01:00
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"import math\n",
"\n",
2025-11-21 10:01:07 +01:00
"# Define the fixed rows\n",
"descriptions = [\n",
" \"Improves health and\\nrecovery capacity\",\n",
" \"Improves endurance\\nand fat burning\",\n",
" \"Improves Aerobic\\nfitness\",\n",
" \"Improves maximum\\nperformance capacity\",\n",
" \"Develops maximum\\nperformance and speed\"\n",
"]\n",
"\n",
"hr_percentages = [\n",
" \"55-65% of Max Heart Rate\",\n",
" \"65-75% of Max Heart Rate\",\n",
" \"80-85% of Max Heart Rate\",\n",
" \"85-88% of Max Heart Rate\",\n",
2025-11-21 10:34:09 +01:00
" \"90%+ of Max Heart Rate\"\n",
2025-11-21 10:01:07 +01:00
"]\n",
"\n",
2025-11-21 10:12:00 +01:00
"ideal_breath_ranges = [\n",
" \"Ideal Range: 15-20 breaths\",\n",
" \"Ideal Range: 20-25 breaths\",\n",
" \"Ideal Range: 25-30 breaths\",\n",
" \"Ideal Range: 30-35 breaths\",\n",
" \"Ideal Range: 40+ breaths\"\n",
"]\n",
"\n",
2025-11-21 10:01:07 +01:00
"# Define the zones for iteration\n",
"# Note: zone_3_start is a dictionary (vt1), others are floats\n",
"# Use math.floor to ensure values match the exact integer values requested\n",
2025-11-21 10:01:07 +01:00
"zones_list = [\n",
" (\"Zone 1\", math.floor(zone_1_start), math.floor(zone_1_end)),\n",
" (\"Zone 2\", math.floor(zone_2_start), math.floor(zone_2_end)),\n",
" (\"Zone 3\", math.floor(zone_3_start['HeartRate']), math.floor(zone_3_end)),\n",
" (\"Zone 4\", math.floor(zone_4_start), math.floor(zone_4_end)),\n",
" (\"Zone 5\", math.floor(zone_5_start), math.floor(zone_5_end))\n",
2025-11-21 10:01:07 +01:00
"]\n",
"\n",
"# Calculate metrics for each zone\n",
"zone_metrics = {\n",
" \"HR BPM\": [],\n",
" \"Speed\": [],\n",
" \"Pace\": [],\n",
" \"Calories\": [],\n",
" \"Carb Utilization\": [],\n",
" \"Breathing\": []\n",
"}\n",
"\n",
2025-11-21 10:12:00 +01:00
"for i, (name, start, end) in enumerate(zones_list):\n",
2025-11-21 10:01:07 +01:00
" # Filter dataframe for the current zone\n",
" mask = (df['HR(bpm)_smoothed'] >= start) & (df['HR(bpm)_smoothed'] <= end)\n",
" zone_df = df[mask]\n",
" \n",
" # HR BPM Range\n",
" zone_metrics[\"HR BPM\"].append(f\"{int(start)}-{int(end)} bpm\")\n",
" \n",
" if not zone_df.empty:\n",
2025-11-21 10:12:00 +01:00
" # Speed (Range)\n",
2025-11-21 10:34:09 +01:00
" # df['Speed'] is in mph\n",
" # Filter out 0 speeds for range calculation\n",
" speed_series = zone_df[zone_df['Speed'] > 0.1]['Speed']\n",
2025-11-21 10:12:00 +01:00
" \n",
2025-11-21 10:34:09 +01:00
" if not speed_series.empty:\n",
" min_speed = speed_series.min()\n",
" max_speed = speed_series.max()\n",
" \n",
" if abs(min_speed - max_speed) < 0.1:\n",
" zone_metrics[\"Speed\"].append(f\"{min_speed:.1f} mph\\n2% Incline\")\n",
" else:\n",
" zone_metrics[\"Speed\"].append(f\"{min_speed:.1f}-{max_speed:.1f} mph\\n2% Incline\")\n",
" \n",
" # Pace (Range)\n",
" # Higher speed = Lower pace value (faster)\n",
" # So max_speed -> min_pace_val, min_speed -> max_pace_val\n",
" \n",
" def speed_to_pace(s_mph):\n",
" if s_mph <= 0: return 0, 0\n",
" s_kmh = s_mph * 1.60934\n",
" p_min = 60 / s_kmh\n",
" p_m = int(p_min)\n",
" p_s = int((p_min % 1) * 60)\n",
" return p_m, p_s\n",
"\n",
" min_pace_m, min_pace_s = speed_to_pace(max_speed) # Fastest speed -> Lowest pace number\n",
" max_pace_m, max_pace_s = speed_to_pace(min_speed) # Slowest speed -> Highest pace number\n",
" \n",
" if min_pace_m == max_pace_m and min_pace_s == max_pace_s:\n",
" pace_str = f\"{min_pace_m}:{min_pace_s:02d} min/km Pace\"\n",
" else:\n",
" pace_str = f\"{max_pace_m}:{max_pace_s:02d}-{min_pace_m}:{min_pace_s:02d}\\nmin/km Pace\"\n",
2025-11-21 10:12:00 +01:00
" zone_metrics[\"Pace\"].append(pace_str)\n",
2025-11-21 10:01:07 +01:00
" else:\n",
2025-11-21 10:34:09 +01:00
" zone_metrics[\"Speed\"].append(\"-\\n2% Incline\")\n",
2025-11-21 10:01:07 +01:00
" zone_metrics[\"Pace\"].append(\"-\")\n",
" \n",
2025-11-21 10:34:09 +01:00
" # Calories (EE)\n",
" # Use raw EE(kcal/min) instead of smoothed sum of FAT+CHO\n",
" avg_cals = zone_df['EE(kcal/min)'].mean()\n",
2025-11-21 10:12:00 +01:00
" zone_metrics[\"Calories\"].append(f\"Avg:\\n{avg_cals:.1f} kcals/minute\")\n",
2025-11-21 10:01:07 +01:00
" \n",
" # Carb Utilization (g/min)\n",
2025-11-21 10:34:09 +01:00
" # Use raw CHO\n",
" avg_carbs_g = zone_df['CHO'].mean() / 4\n",
2025-11-21 10:12:00 +01:00
" zone_metrics[\"Carb Utilization\"].append(f\"Avg: {avg_carbs_g:.1f}g/min\\nCarb Utilization\")\n",
2025-11-21 10:01:07 +01:00
" \n",
" # Breathing (BF)\n",
" avg_breaths = zone_df['BF(bpm)_smoothed'].mean()\n",
2025-11-21 10:12:00 +01:00
" ideal_range = ideal_breath_ranges[i]\n",
" zone_metrics[\"Breathing\"].append(f\"Avg: {int(avg_breaths)} breaths\\n{ideal_range}\")\n",
2025-11-21 10:01:07 +01:00
" \n",
" else:\n",
2025-11-21 10:12:00 +01:00
" zone_metrics[\"Speed\"].append(\"-\\n2% Incline\")\n",
2025-11-21 10:01:07 +01:00
" zone_metrics[\"Pace\"].append(\"-\")\n",
" zone_metrics[\"Calories\"].append(\"-\")\n",
" zone_metrics[\"Carb Utilization\"].append(\"-\")\n",
2025-11-21 10:12:00 +01:00
" zone_metrics[\"Breathing\"].append(f\"-\\n{ideal_breath_ranges[i]}\")\n",
2025-11-21 10:01:07 +01:00
"\n",
"# Prepare data for the table\n",
"table_data = []\n",
"table_data.append(descriptions)\n",
"table_data.append(hr_percentages)\n",
"table_data.append(zone_metrics[\"HR BPM\"])\n",
"table_data.append(zone_metrics[\"Speed\"])\n",
"table_data.append(zone_metrics[\"Pace\"])\n",
"table_data.append(zone_metrics[\"Calories\"])\n",
"table_data.append(zone_metrics[\"Carb Utilization\"])\n",
"table_data.append(zone_metrics[\"Breathing\"])\n",
"\n",
"col_labels = [\"Zone 1\", \"Zone 2\", \"Zone 3\", \"Zone 4\", \"Zone 5\"]\n",
"\n",
"# Create the table plot\n",
"fig, ax = plt.subplots(figsize=(12, 8)) # Reduced width from 16 to 12\n",
2025-11-21 10:01:07 +01:00
"ax.axis('off')\n",
"\n",
2025-11-21 10:12:00 +01:00
"# Create table without rowLabels\n",
2025-11-21 10:01:07 +01:00
"table = ax.table(cellText=table_data,\n",
" colLabels=col_labels,\n",
" loc='center',\n",
" cellLoc='center')\n",
"\n",
"table.auto_set_font_size(False)\n",
"table.set_fontsize(10)\n",
2025-11-21 10:12:00 +01:00
"table.scale(1, 3.5) # Increased vertical scale for multi-line text\n",
2025-11-21 10:01:07 +01:00
"\n",
"# Styling\n",
"# Header row\n",
"for j, label in enumerate(col_labels):\n",
" cell = table[(0, j)]\n",
" cell.set_facecolor('#7dd3fc') # cyan-300\n",
" cell.set_text_props(weight='bold')\n",
"\n",
"# Row specific styling\n",
"colors = ['#fecaca', '#fecaca', '#fef08a', '#bbf7d0', '#bbf7d0']\n",
"\n",
"for j in range(len(col_labels)):\n",
2025-11-21 10:12:00 +01:00
" # HR BPM row is at index 2 (0-based in data) -> row 3 in table (0 is header, 1 is desc, 2 is HR%, 3 is HR BPM)\n",
2025-11-21 10:01:07 +01:00
" cell = table[(3, j)] \n",
" cell.set_facecolor(colors[j])\n",
" cell.set_text_props(weight='bold')\n",
" \n",
" # Breathing row is at index 7 -> row 8 in table\n",
" cell = table[(8, j)]\n",
" cell.set_facecolor(colors[j])\n",
" cell.set_text_props(weight='bold')\n",
"\n",
"plt.title(\"Personalized Heart Rate Zones\", fontsize=16, fontweight='bold', pad=5) # Reduced pad from 20 to 5\n",
2025-11-21 10:01:07 +01:00
"plt.tight_layout()\n",
"plt.show()"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 22,
2025-11-21 10:01:07 +01:00
"id": "9c00c366",
"metadata": {},
2025-11-24 17:52:56 +01:00
"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>Timestamp (seconds passed)</th>\n",
" <th>Lap/Event</th>\n",
" <th>Unnamed: 2</th>\n",
" <th>SmO2</th>\n",
" <th>HBDiff</th>\n",
" <th>Muscle state</th>\n",
" <th>Muscle trend</th>\n",
" <th>SmO2 unfiltered</th>\n",
" <th>O2HB unfiltered</th>\n",
" <th>HHb unfiltered</th>\n",
" <th>...</th>\n",
" <th>SmO2.1</th>\n",
" <th>HBDiff.1</th>\n",
" <th>Muscle state.1</th>\n",
" <th>Muscle trend.1</th>\n",
" <th>SmO2 unfiltered.1</th>\n",
" <th>O2HB unfiltered.1</th>\n",
" <th>HHb unfiltered.1</th>\n",
" <th>THb unfiltered.1</th>\n",
" <th>HBDiff unfiltered.1</th>\n",
" <th>Heart Rate (BPM).1</th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>0</th>\n",
" <td>0.37</td>\n",
" <td>0</td>\n",
" <td>NaN</td>\n",
" <td>72.40</td>\n",
" <td>-0.26</td>\n",
" <td>0</td>\n",
" <td>2</td>\n",
" <td>72.52</td>\n",
" <td>-3.27</td>\n",
" <td>-2.29</td>\n",
" <td>...</td>\n",
" <td>87.04</td>\n",
" <td>1.57</td>\n",
" <td>0</td>\n",
" <td>2</td>\n",
" <td>86.46</td>\n",
" <td>-0.64</td>\n",
" <td>-0.74</td>\n",
" <td>-20.30</td>\n",
" <td>1.78</td>\n",
" <td>72</td>\n",
" </tr>\n",
" <tr>\n",
" <th>1</th>\n",
" <td>0.46</td>\n",
" <td>0</td>\n",
" <td>NaN</td>\n",
" <td>72.41</td>\n",
" <td>-0.26</td>\n",
" <td>0</td>\n",
" <td>2</td>\n",
" <td>73.06</td>\n",
" <td>-3.27</td>\n",
" <td>-2.31</td>\n",
" <td>...</td>\n",
" <td>87.04</td>\n",
" <td>1.57</td>\n",
" <td>0</td>\n",
" <td>2</td>\n",
" <td>86.66</td>\n",
" <td>-0.66</td>\n",
" <td>-0.66</td>\n",
" <td>-20.24</td>\n",
" <td>1.68</td>\n",
" <td>72</td>\n",
" </tr>\n",
" <tr>\n",
" <th>2</th>\n",
" <td>0.57</td>\n",
" <td>0</td>\n",
" <td>NaN</td>\n",
" <td>72.41</td>\n",
" <td>-0.26</td>\n",
" <td>0</td>\n",
" <td>2</td>\n",
" <td>72.58</td>\n",
" <td>-3.31</td>\n",
" <td>-2.25</td>\n",
" <td>...</td>\n",
" <td>87.03</td>\n",
" <td>1.57</td>\n",
" <td>0</td>\n",
" <td>2</td>\n",
" <td>86.81</td>\n",
" <td>-0.69</td>\n",
" <td>-0.67</td>\n",
" <td>-20.28</td>\n",
" <td>1.66</td>\n",
" <td>72</td>\n",
" </tr>\n",
" <tr>\n",
" <th>3</th>\n",
" <td>0.66</td>\n",
" <td>0</td>\n",
" <td>NaN</td>\n",
" <td>72.42</td>\n",
" <td>-0.25</td>\n",
" <td>0</td>\n",
" <td>2</td>\n",
" <td>73.18</td>\n",
" <td>-3.29</td>\n",
" <td>-2.32</td>\n",
" <td>...</td>\n",
" <td>87.03</td>\n",
" <td>1.57</td>\n",
" <td>0</td>\n",
" <td>2</td>\n",
" <td>88.42</td>\n",
" <td>-0.70</td>\n",
" <td>-0.66</td>\n",
" <td>-20.28</td>\n",
" <td>1.64</td>\n",
" <td>72</td>\n",
" </tr>\n",
" <tr>\n",
" <th>4</th>\n",
" <td>0.75</td>\n",
" <td>0</td>\n",
" <td>NaN</td>\n",
" <td>72.43</td>\n",
" <td>-0.25</td>\n",
" <td>0</td>\n",
" <td>2</td>\n",
" <td>72.61</td>\n",
" <td>-3.23</td>\n",
" <td>-2.36</td>\n",
" <td>...</td>\n",
" <td>87.03</td>\n",
" <td>1.58</td>\n",
" <td>0</td>\n",
" <td>2</td>\n",
" <td>88.59</td>\n",
" <td>-0.51</td>\n",
" <td>-0.71</td>\n",
" <td>-20.14</td>\n",
" <td>1.88</td>\n",
" <td>72</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"<p>5 rows × 24 columns</p>\n",
"</div>"
],
"text/plain": [
" Timestamp (seconds passed) Lap/Event Unnamed: 2 SmO2 HBDiff \\\n",
"0 0.37 0 NaN 72.40 -0.26 \n",
"1 0.46 0 NaN 72.41 -0.26 \n",
"2 0.57 0 NaN 72.41 -0.26 \n",
"3 0.66 0 NaN 72.42 -0.25 \n",
"4 0.75 0 NaN 72.43 -0.25 \n",
"\n",
" Muscle state Muscle trend SmO2 unfiltered O2HB unfiltered \\\n",
"0 0 2 72.52 -3.27 \n",
"1 0 2 73.06 -3.27 \n",
"2 0 2 72.58 -3.31 \n",
"3 0 2 73.18 -3.29 \n",
"4 0 2 72.61 -3.23 \n",
"\n",
" HHb unfiltered ... SmO2.1 HBDiff.1 Muscle state.1 Muscle trend.1 \\\n",
"0 -2.29 ... 87.04 1.57 0 2 \n",
"1 -2.31 ... 87.04 1.57 0 2 \n",
"2 -2.25 ... 87.03 1.57 0 2 \n",
"3 -2.32 ... 87.03 1.57 0 2 \n",
"4 -2.36 ... 87.03 1.58 0 2 \n",
"\n",
" SmO2 unfiltered.1 O2HB unfiltered.1 HHb unfiltered.1 THb unfiltered.1 \\\n",
"0 86.46 -0.64 -0.74 -20.30 \n",
"1 86.66 -0.66 -0.66 -20.24 \n",
"2 86.81 -0.69 -0.67 -20.28 \n",
"3 88.42 -0.70 -0.66 -20.28 \n",
"4 88.59 -0.51 -0.71 -20.14 \n",
"\n",
" HBDiff unfiltered.1 Heart Rate (BPM).1 \n",
"0 1.78 72 \n",
"1 1.68 72 \n",
"2 1.66 72 \n",
"3 1.64 72 \n",
"4 1.88 72 \n",
"\n",
"[5 rows x 24 columns]"
]
},
2025-11-28 11:44:37 +01:00
"execution_count": 22,
2025-11-24 17:52:56 +01:00
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"oxygenation_2.head()"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 23,
2025-11-24 17:52:56 +01:00
"id": "0a624fa0",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"Index(['Timestamp (seconds passed)', 'Lap/Event', 'Unnamed: 2', 'SmO2',\n",
" 'HBDiff', 'Muscle state', 'Muscle trend', 'SmO2 unfiltered',\n",
" 'O2HB unfiltered', 'HHb unfiltered', 'THb unfiltered',\n",
" 'HBDiff unfiltered', 'Heart Rate (BPM)', 'Unnamed: 13', 'SmO2.1',\n",
" 'HBDiff.1', 'Muscle state.1', 'Muscle trend.1', 'SmO2 unfiltered.1',\n",
" 'O2HB unfiltered.1', 'HHb unfiltered.1', 'THb unfiltered.1',\n",
" 'HBDiff unfiltered.1', 'Heart Rate (BPM).1'],\n",
" dtype='object')"
]
},
2025-11-28 11:44:37 +01:00
"execution_count": 23,
2025-11-24 17:52:56 +01:00
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"oxygenation_2.columns"
]
},
{
"cell_type": "markdown",
"id": "803c7174",
"metadata": {},
"source": [
"# Train.Red SmO₂ Analysis\n",
"\n",
"Following the instructions from the PDF, we'll create a comprehensive muscle oxygenation plot with:\n",
"- Smoothed SmO₂ data for left and right legs\n",
"- Heart rate on secondary axis\n",
"- Stage annotations (warm-up, active laps, recovery)\n",
"- Recovery percentage calculations\n",
"- Optional breakpoint detection"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 24,
2025-11-24 17:52:56 +01:00
"id": "d18e5c4a",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Data shape: (17020, 28)\n",
"Time range: 0.37 to 1702.26 seconds\n",
2025-11-28 11:44:37 +01:00
"Unique laps: [np.int64(0), np.int64(1), np.int64(2), np.int64(3), np.int64(4), np.int64(5), np.int64(6), np.int64(7)]\n",
2025-11-24 17:52:56 +01:00
"\n",
"First few rows:\n"
]
},
{
"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>Timestamp (seconds passed)</th>\n",
" <th>Lap</th>\n",
" <th>Left_SmO2</th>\n",
" <th>Right_SmO2</th>\n",
" <th>Heart_Rate</th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>0</th>\n",
" <td>0.37</td>\n",
" <td>0</td>\n",
" <td>72.40</td>\n",
" <td>87.04</td>\n",
" <td>72</td>\n",
" </tr>\n",
" <tr>\n",
" <th>1</th>\n",
" <td>0.46</td>\n",
" <td>0</td>\n",
" <td>72.41</td>\n",
" <td>87.04</td>\n",
" <td>72</td>\n",
" </tr>\n",
" <tr>\n",
" <th>2</th>\n",
" <td>0.57</td>\n",
" <td>0</td>\n",
" <td>72.41</td>\n",
" <td>87.03</td>\n",
" <td>72</td>\n",
" </tr>\n",
" <tr>\n",
" <th>3</th>\n",
" <td>0.66</td>\n",
" <td>0</td>\n",
" <td>72.42</td>\n",
" <td>87.03</td>\n",
" <td>72</td>\n",
" </tr>\n",
" <tr>\n",
" <th>4</th>\n",
" <td>0.75</td>\n",
" <td>0</td>\n",
" <td>72.43</td>\n",
" <td>87.03</td>\n",
" <td>72</td>\n",
" </tr>\n",
" <tr>\n",
" <th>5</th>\n",
" <td>0.86</td>\n",
" <td>0</td>\n",
" <td>72.44</td>\n",
" <td>87.04</td>\n",
" <td>72</td>\n",
" </tr>\n",
" <tr>\n",
" <th>6</th>\n",
" <td>0.95</td>\n",
" <td>0</td>\n",
" <td>72.45</td>\n",
" <td>87.05</td>\n",
" <td>72</td>\n",
" </tr>\n",
" <tr>\n",
" <th>7</th>\n",
" <td>1.09</td>\n",
" <td>0</td>\n",
" <td>72.47</td>\n",
" <td>87.05</td>\n",
" <td>72</td>\n",
" </tr>\n",
" <tr>\n",
" <th>8</th>\n",
" <td>1.15</td>\n",
" <td>0</td>\n",
" <td>72.48</td>\n",
" <td>87.06</td>\n",
" <td>72</td>\n",
" </tr>\n",
" <tr>\n",
" <th>9</th>\n",
" <td>1.27</td>\n",
" <td>0</td>\n",
" <td>72.49</td>\n",
" <td>87.07</td>\n",
" <td>72</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
" Timestamp (seconds passed) Lap Left_SmO2 Right_SmO2 Heart_Rate\n",
"0 0.37 0 72.40 87.04 72\n",
"1 0.46 0 72.41 87.04 72\n",
"2 0.57 0 72.41 87.03 72\n",
"3 0.66 0 72.42 87.03 72\n",
"4 0.75 0 72.43 87.03 72\n",
"5 0.86 0 72.44 87.04 72\n",
"6 0.95 0 72.45 87.05 72\n",
"7 1.09 0 72.47 87.05 72\n",
"8 1.15 0 72.48 87.06 72\n",
"9 1.27 0 72.49 87.07 72"
]
},
2025-11-28 11:44:37 +01:00
"execution_count": 24,
2025-11-24 17:52:56 +01:00
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"# Step 1: Data Preparation\n",
"# Clean and prepare the oxygenation_2 dataframe\n",
"\n",
"# Rename columns for clarity\n",
"df_oxy = oxygenation_2.copy()\n",
"\n",
"# Convert timestamp to numeric\n",
"df_oxy['Timestamp (seconds passed)'] = pd.to_numeric(df_oxy['Timestamp (seconds passed)'], errors='coerce')\n",
"\n",
"# Convert SmO2 columns to numeric\n",
"df_oxy['Left_SmO2'] = pd.to_numeric(df_oxy['SmO2'], errors='coerce')\n",
"df_oxy['Right_SmO2'] = pd.to_numeric(df_oxy['SmO2.1'], errors='coerce')\n",
"df_oxy['Heart_Rate'] = pd.to_numeric(df_oxy['Heart Rate (BPM)'], errors='coerce')\n",
"df_oxy['Lap'] = pd.to_numeric(df_oxy['Lap/Event'], errors='coerce')\n",
"\n",
"# Drop rows with missing timestamps\n",
"df_oxy = df_oxy.dropna(subset=['Timestamp (seconds passed)'])\n",
"\n",
"# Sort by timestamp\n",
"df_oxy = df_oxy.sort_values('Timestamp (seconds passed)').reset_index(drop=True)\n",
"\n",
"print(f\"Data shape: {df_oxy.shape}\")\n",
"print(f\"Time range: {df_oxy['Timestamp (seconds passed)'].min():.2f} to {df_oxy['Timestamp (seconds passed)'].max():.2f} seconds\")\n",
"print(f\"Unique laps: {sorted(df_oxy['Lap'].dropna().unique())}\")\n",
"print(f\"\\nFirst few rows:\")\n",
"df_oxy[['Timestamp (seconds passed)', 'Lap', 'Left_SmO2', 'Right_SmO2', 'Heart_Rate']].head(10)"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 25,
2025-11-24 17:52:56 +01:00
"id": "a6f97a9b",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Average sampling interval: 0.090 seconds\n",
"Estimated sampling frequency: 11.11 Hz\n",
"Smoothing window: 111 samples (~10 seconds)\n",
"\n",
"Smoothing complete!\n"
]
},
{
"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>Timestamp (seconds passed)</th>\n",
" <th>Left_SmO2</th>\n",
" <th>Left_SmO2_smooth</th>\n",
" <th>Right_SmO2</th>\n",
" <th>Right_SmO2_smooth</th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>0</th>\n",
" <td>0.37</td>\n",
" <td>72.40</td>\n",
" <td>72.594643</td>\n",
" <td>87.04</td>\n",
" <td>87.107500</td>\n",
" </tr>\n",
" <tr>\n",
" <th>1</th>\n",
" <td>0.46</td>\n",
" <td>72.41</td>\n",
" <td>72.594386</td>\n",
" <td>87.04</td>\n",
" <td>87.105614</td>\n",
" </tr>\n",
" <tr>\n",
" <th>2</th>\n",
" <td>0.57</td>\n",
" <td>72.41</td>\n",
" <td>72.594138</td>\n",
" <td>87.03</td>\n",
" <td>87.103793</td>\n",
" </tr>\n",
" <tr>\n",
" <th>3</th>\n",
" <td>0.66</td>\n",
" <td>72.42</td>\n",
" <td>72.593898</td>\n",
" <td>87.03</td>\n",
" <td>87.101864</td>\n",
" </tr>\n",
" <tr>\n",
" <th>4</th>\n",
" <td>0.75</td>\n",
" <td>72.43</td>\n",
" <td>72.593667</td>\n",
" <td>87.03</td>\n",
" <td>87.099833</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
" Timestamp (seconds passed) Left_SmO2 Left_SmO2_smooth Right_SmO2 \\\n",
"0 0.37 72.40 72.594643 87.04 \n",
"1 0.46 72.41 72.594386 87.04 \n",
"2 0.57 72.41 72.594138 87.03 \n",
"3 0.66 72.42 72.593898 87.03 \n",
"4 0.75 72.43 72.593667 87.03 \n",
"\n",
" Right_SmO2_smooth \n",
"0 87.107500 \n",
"1 87.105614 \n",
"2 87.103793 \n",
"3 87.101864 \n",
"4 87.099833 "
]
},
2025-11-28 11:44:37 +01:00
"execution_count": 25,
2025-11-24 17:52:56 +01:00
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"# Step 2: Apply 10-second rolling mean smoothing\n",
"# Following PDF instructions: centered 10 second rolling mean\n",
"\n",
"# Estimate sampling frequency\n",
"time_diffs = df_oxy['Timestamp (seconds passed)'].diff().dropna()\n",
"avg_sampling_interval = time_diffs.median()\n",
"sampling_freq = 1 / avg_sampling_interval if avg_sampling_interval > 0 else 10\n",
"window_samples = int(10 * sampling_freq) # 10 seconds worth of samples\n",
"\n",
"print(f\"Average sampling interval: {avg_sampling_interval:.3f} seconds\")\n",
"print(f\"Estimated sampling frequency: {sampling_freq:.2f} Hz\")\n",
"print(f\"Smoothing window: {window_samples} samples (~10 seconds)\")\n",
"\n",
"# Apply centered rolling mean\n",
"df_oxy['Left_SmO2_smooth'] = df_oxy['Left_SmO2'].rolling(window=window_samples, center=True, min_periods=1).mean()\n",
"df_oxy['Right_SmO2_smooth'] = df_oxy['Right_SmO2'].rolling(window=window_samples, center=True, min_periods=1).mean()\n",
"df_oxy['Heart_Rate_smooth'] = df_oxy['Heart_Rate'].rolling(window=window_samples, center=True, min_periods=1).mean()\n",
"\n",
"print(\"\\nSmoothing complete!\")\n",
"df_oxy[['Timestamp (seconds passed)', 'Left_SmO2', 'Left_SmO2_smooth', 'Right_SmO2', 'Right_SmO2_smooth']].head()"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 26,
2025-11-24 17:52:56 +01:00
"id": "e6943523",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Lap start times:\n",
" Lap 0: 0.37 seconds (0.01 minutes)\n",
" Lap 1: 256.54 seconds (4.28 minutes)\n",
" Lap 2: 495.34 seconds (8.26 minutes)\n",
" Lap 3: 735.03 seconds (12.25 minutes)\n",
" Lap 4: 976.45 seconds (16.27 minutes)\n",
" Lap 5: 1215.00 seconds (20.25 minutes)\n",
" Lap 6: 1454.76 seconds (24.25 minutes)\n",
" Lap 7: 1543.36 seconds (25.72 minutes)\n",
"\n",
"Stage breakdown:\n",
" Warm-up: 0 - 256.54 seconds\n",
" Active test: 256.54 - 1543.36 seconds\n",
" Recovery: 1543.36 - 1702.26 seconds\n"
]
}
],
"source": [
"# Step 3: Identify test stages based on laps\n",
"# Find when each lap starts\n",
"\n",
"lap_changes = df_oxy[df_oxy['Lap'].diff() != 0].copy()\n",
"lap_starts = {}\n",
"\n",
"for idx, row in lap_changes.iterrows():\n",
" lap_num = int(row['Lap'])\n",
" lap_starts[lap_num] = row['Timestamp (seconds passed)']\n",
"\n",
"print(\"Lap start times:\")\n",
"for lap, time in sorted(lap_starts.items()):\n",
" print(f\" Lap {lap}: {time:.2f} seconds ({time/60:.2f} minutes)\")\n",
"\n",
"# Identify stages\n",
"# Assuming: Lap 0 = warm-up, Laps 1-6 = active test, Lap 7 = recovery\n",
"warm_up_end = lap_starts.get(1, df_oxy['Timestamp (seconds passed)'].max())\n",
"recovery_start = lap_starts.get(7, df_oxy['Timestamp (seconds passed)'].max())\n",
"\n",
"print(f\"\\nStage breakdown:\")\n",
"print(f\" Warm-up: 0 - {warm_up_end:.2f} seconds\")\n",
"print(f\" Active test: {warm_up_end:.2f} - {recovery_start:.2f} seconds\")\n",
"print(f\" Recovery: {recovery_start:.2f} - {df_oxy['Timestamp (seconds passed)'].max():.2f} seconds\")"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 27,
2025-11-24 17:52:56 +01:00
"id": "f4d26f1b",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Recovery Percentages:\n",
" Left Leg:\n",
" Warm-up avg: 75.37%\n",
" Recovery avg: 82.47%\n",
" Recovery: 109% of warm-up\n",
"\n",
" Right Leg:\n",
" Warm-up avg: 82.91%\n",
" Recovery avg: 80.03%\n",
" Recovery: 97% of warm-up\n"
]
}
],
"source": [
"# Step 4: Calculate recovery percentages\n",
"# Average SmO2 over last 30 seconds of warm-up and recovery\n",
"\n",
"# Last 30 seconds of warm-up\n",
"warm_up_last_30_start = warm_up_end - 30\n",
"warm_up_mask = (df_oxy['Timestamp (seconds passed)'] >= warm_up_last_30_start) & \\\n",
" (df_oxy['Timestamp (seconds passed)'] <= warm_up_end)\n",
"\n",
"# Last 30 seconds of recovery\n",
"recovery_end = df_oxy['Timestamp (seconds passed)'].max()\n",
"recovery_last_30_start = recovery_end - 30\n",
"recovery_mask = (df_oxy['Timestamp (seconds passed)'] >= recovery_last_30_start) & \\\n",
" (df_oxy['Timestamp (seconds passed)'] <= recovery_end)\n",
"\n",
"# Calculate averages\n",
"left_warmup_avg = df_oxy.loc[warm_up_mask, 'Left_SmO2_smooth'].mean()\n",
"left_recovery_avg = df_oxy.loc[recovery_mask, 'Left_SmO2_smooth'].mean()\n",
"left_recovery_pct = round((left_recovery_avg / left_warmup_avg) * 100)\n",
"\n",
"right_warmup_avg = df_oxy.loc[warm_up_mask, 'Right_SmO2_smooth'].mean()\n",
"right_recovery_avg = df_oxy.loc[recovery_mask, 'Right_SmO2_smooth'].mean()\n",
"right_recovery_pct = round((right_recovery_avg / right_warmup_avg) * 100)\n",
"\n",
"print(\"Recovery Percentages:\")\n",
"print(f\" Left Leg:\")\n",
"print(f\" Warm-up avg: {left_warmup_avg:.2f}%\")\n",
"print(f\" Recovery avg: {left_recovery_avg:.2f}%\")\n",
"print(f\" Recovery: {left_recovery_pct}% of warm-up\")\n",
"print(f\"\\n Right Leg:\")\n",
"print(f\" Warm-up avg: {right_warmup_avg:.2f}%\")\n",
"print(f\" Recovery avg: {right_recovery_avg:.2f}%\")\n",
"print(f\" Recovery: {right_recovery_pct}% of warm-up\")"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 28,
2025-11-24 17:52:56 +01:00
"id": "bf54eda5",
"metadata": {},
"outputs": [
{
"data": {
2025-11-28 11:44:37 +01:00
"image/png": "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
2025-11-24 17:52:56 +01:00
"text/plain": [
"<Figure size 1800x800 with 2 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"✓ Plot created successfully!\n"
]
}
],
"source": [
"# Step 5: Create comprehensive SmO₂ plot following PDF instructions\n",
"\n",
"fig, ax1 = plt.subplots(figsize=(18, 8))\n",
"\n",
"# Plot SmO₂ data on primary axis\n",
"time = df_oxy['Timestamp (seconds passed)']\n",
"ax1.plot(time, df_oxy['Left_SmO2_smooth'], \n",
" label=f'Left SmO₂ (Rec {left_recovery_pct}% of warm-up)', \n",
" color='#2E86AB', linewidth=2)\n",
"ax1.plot(time, df_oxy['Right_SmO2_smooth'], \n",
" label=f'Right SmO₂ (Rec {right_recovery_pct}% of warm-up)', \n",
" color='#A23B72', linewidth=2)\n",
"\n",
"ax1.set_xlabel('Time (seconds)', fontsize=12, fontweight='bold')\n",
"ax1.set_ylabel('SmO₂ (%)', fontsize=12, fontweight='bold')\n",
"ax1.tick_params(axis='y', labelcolor='black')\n",
"ax1.grid(True, alpha=0.3, linestyle='--')\n",
"\n",
"# Add secondary axis for heart rate\n",
"ax2 = ax1.twinx()\n",
"ax2.plot(time, df_oxy['Heart_Rate_smooth'], \n",
" label='Heart Rate', \n",
" color='red', linewidth=1.5, linestyle='--', alpha=0.7)\n",
"ax2.set_ylabel('Heart Rate (BPM)', fontsize=12, fontweight='bold', color='red')\n",
"ax2.tick_params(axis='y', labelcolor='red')\n",
"\n",
"# Add shaded regions for stages\n",
"# Warm-up (light shade)\n",
"ax1.axvspan(0, warm_up_end, alpha=0.15, color='blue', label='Warm-up')\n",
"\n",
"# Active test laps (alternating shades)\n",
"active_laps = [1, 2, 3, 4, 5, 6]\n",
"colors_active = ['yellow', 'orange'] * 3\n",
"for i, lap in enumerate(active_laps):\n",
" start = lap_starts.get(lap, 0)\n",
" end = lap_starts.get(lap + 1, recovery_start) if lap < 6 else recovery_start\n",
" ax1.axvspan(start, end, alpha=0.1, color=colors_active[i])\n",
"\n",
"# Recovery (gray shade)\n",
"ax1.axvspan(recovery_start, df_oxy['Timestamp (seconds passed)'].max(), \n",
" alpha=0.2, color='gray', label='Recovery')\n",
"\n",
"# Add vertical line at recovery start\n",
"ax1.axvline(x=recovery_start, color='black', linestyle='-', linewidth=2, alpha=0.7)\n",
"\n",
"# Add lap labels\n",
"for lap in range(1, 7):\n",
" start = lap_starts.get(lap, 0)\n",
" end = lap_starts.get(lap + 1, recovery_start) if lap < 6 else recovery_start\n",
" mid = (start + end) / 2\n",
" ax1.text(mid, ax1.get_ylim()[1] * 0.97, f'Lap {lap}', \n",
" ha='center', va='top', fontsize=10, fontweight='bold', \n",
" bbox=dict(boxstyle='round,pad=0.3', facecolor='white', alpha=0.7))\n",
"\n",
"# Title and legends\n",
"plt.title('Train.Red SmO₂ Ramp - Muscle Oxygenation Analysis', \n",
" fontsize=16, fontweight='bold', pad=20)\n",
"\n",
"# Combine legends from both axes\n",
"lines1, labels1 = ax1.get_legend_handles_labels()\n",
"lines2, labels2 = ax2.get_legend_handles_labels()\n",
"ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left', fontsize=10, framealpha=0.9)\n",
"\n",
"plt.tight_layout()\n",
"plt.savefig(f'{base_dir}/graphs/muscle_oxygenation_plot.png', dpi=300, bbox_inches='tight')\n",
"plt.show()\n",
"\n",
"print(\"✓ Plot created successfully!\")"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 29,
2025-11-24 17:52:56 +01:00
"id": "128c9f3e",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"======================================================================\n",
"MUSCLE OXYGENATION ANALYSIS - KEY VALUES\n",
"======================================================================\n",
"\n",
"1. BASELINE (Warm-up averages):\n",
" Left SmO₂: 75.37%\n",
" Right SmO₂: 82.91%\n",
"\n",
"2. RECOVERY (Last 30 seconds):\n",
" Left SmO₂: 82.47% (109% of baseline)\n",
" Right SmO₂: 80.03% (97% of baseline)\n",
"\n",
"3. MINIMUM VALUES (During active test):\n",
" Left SmO₂: 69.34% at 1536.6s (Lap 6)\n",
" Right SmO₂: 73.65% at 1535.5s (Lap 6)\n",
"\n",
"4. MAXIMUM VALUES (During active test):\n",
" Left SmO₂: 78.24% at 915.2s (Lap 3)\n",
" Right SmO₂: 82.59% at 256.5s (Lap 1)\n",
"\n",
"5. OXYGENATION DROP (Baseline to Minimum):\n",
" Left SmO₂: 6.03% drop (8.0% decrease)\n",
" Right SmO₂: 9.26% drop (11.2% decrease)\n",
"\n",
"6. AVERAGE SmO₂ PER LAP:\n",
" Lap 1: Left=75.42%, Right=81.27%, HR=107.3 bpm\n",
" Lap 2: Left=76.51%, Right=81.76%, HR=118.8 bpm\n",
" Lap 3: Left=77.35%, Right=81.28%, HR=131.2 bpm\n",
" Lap 4: Left=76.98%, Right=79.66%, HR=145.2 bpm\n",
" Lap 5: Left=74.28%, Right=77.96%, HR=157.7 bpm\n",
" Lap 6: Left=71.27%, Right=75.98%, HR=165.0 bpm\n",
"\n",
"7. HEART RATE:\n",
" Warm-up avg: 93.2 bpm\n",
" Max during test: 168.2 bpm\n",
" Recovery avg: 107.7 bpm\n",
"\n",
"======================================================================\n"
]
}
],
"source": [
"# Step 6: Extract key values and metrics from the test\n",
"\n",
"print(\"=\"*70)\n",
"print(\"MUSCLE OXYGENATION ANALYSIS - KEY VALUES\")\n",
"print(\"=\"*70)\n",
"\n",
"# 1. Baseline values (warm-up averages)\n",
"print(\"\\n1. BASELINE (Warm-up averages):\")\n",
"print(f\" Left SmO₂: {left_warmup_avg:.2f}%\")\n",
"print(f\" Right SmO₂: {right_warmup_avg:.2f}%\")\n",
"\n",
"# 2. Recovery values\n",
"print(\"\\n2. RECOVERY (Last 30 seconds):\")\n",
"print(f\" Left SmO₂: {left_recovery_avg:.2f}% ({left_recovery_pct}% of baseline)\")\n",
"print(f\" Right SmO₂: {right_recovery_avg:.2f}% ({right_recovery_pct}% of baseline)\")\n",
"\n",
"# 3. Minimum values during active test\n",
"active_mask = (df_oxy['Timestamp (seconds passed)'] >= warm_up_end) & \\\n",
" (df_oxy['Timestamp (seconds passed)'] <= recovery_start)\n",
"active_data = df_oxy[active_mask]\n",
"\n",
"left_min = active_data['Left_SmO2_smooth'].min()\n",
"left_min_time = active_data.loc[active_data['Left_SmO2_smooth'].idxmin(), 'Timestamp (seconds passed)']\n",
"left_min_lap = active_data.loc[active_data['Left_SmO2_smooth'].idxmin(), 'Lap']\n",
"\n",
"right_min = active_data['Right_SmO2_smooth'].min()\n",
"right_min_time = active_data.loc[active_data['Right_SmO2_smooth'].idxmin(), 'Timestamp (seconds passed)']\n",
"right_min_lap = active_data.loc[active_data['Right_SmO2_smooth'].idxmin(), 'Lap']\n",
"\n",
"print(\"\\n3. MINIMUM VALUES (During active test):\")\n",
"print(f\" Left SmO₂: {left_min:.2f}% at {left_min_time:.1f}s (Lap {int(left_min_lap)})\")\n",
"print(f\" Right SmO₂: {right_min:.2f}% at {right_min_time:.1f}s (Lap {int(right_min_lap)})\")\n",
"\n",
"# 4. Maximum values during active test\n",
"left_max = active_data['Left_SmO2_smooth'].max()\n",
"left_max_time = active_data.loc[active_data['Left_SmO2_smooth'].idxmax(), 'Timestamp (seconds passed)']\n",
"left_max_lap = active_data.loc[active_data['Left_SmO2_smooth'].idxmax(), 'Lap']\n",
"\n",
"right_max = active_data['Right_SmO2_smooth'].max()\n",
"right_max_time = active_data.loc[active_data['Right_SmO2_smooth'].idxmax(), 'Timestamp (seconds passed)']\n",
"right_max_lap = active_data.loc[active_data['Right_SmO2_smooth'].idxmax(), 'Lap']\n",
"\n",
"print(\"\\n4. MAXIMUM VALUES (During active test):\")\n",
"print(f\" Left SmO₂: {left_max:.2f}% at {left_max_time:.1f}s (Lap {int(left_max_lap)})\")\n",
"print(f\" Right SmO₂: {right_max:.2f}% at {right_max_time:.1f}s (Lap {int(right_max_lap)})\")\n",
"\n",
"# 5. Range/Drop during test\n",
"left_drop = left_warmup_avg - left_min\n",
"right_drop = right_warmup_avg - right_min\n",
"\n",
"print(\"\\n5. OXYGENATION DROP (Baseline to Minimum):\")\n",
"print(f\" Left SmO₂: {left_drop:.2f}% drop ({left_drop/left_warmup_avg*100:.1f}% decrease)\")\n",
"print(f\" Right SmO₂: {right_drop:.2f}% drop ({right_drop/right_warmup_avg*100:.1f}% decrease)\")\n",
"\n",
"# 6. Average values per lap\n",
"print(\"\\n6. AVERAGE SmO₂ PER LAP:\")\n",
"for lap in range(1, 7):\n",
" lap_mask = df_oxy['Lap'] == lap\n",
" lap_data = df_oxy[lap_mask]\n",
" if len(lap_data) > 0:\n",
" left_avg = lap_data['Left_SmO2_smooth'].mean()\n",
" right_avg = lap_data['Right_SmO2_smooth'].mean()\n",
" hr_avg = lap_data['Heart_Rate_smooth'].mean()\n",
" print(f\" Lap {lap}: Left={left_avg:.2f}%, Right={right_avg:.2f}%, HR={hr_avg:.1f} bpm\")\n",
"\n",
"# 7. Heart rate data\n",
"print(\"\\n7. HEART RATE:\")\n",
"hr_warmup = df_oxy[df_oxy['Timestamp (seconds passed)'] <= warm_up_end]['Heart_Rate_smooth'].mean()\n",
"hr_max = active_data['Heart_Rate_smooth'].max()\n",
"hr_recovery = df_oxy[recovery_mask]['Heart_Rate_smooth'].mean()\n",
"print(f\" Warm-up avg: {hr_warmup:.1f} bpm\")\n",
"print(f\" Max during test: {hr_max:.1f} bpm\")\n",
"print(f\" Recovery avg: {hr_recovery:.1f} bpm\")\n",
"\n",
"print(\"\\n\" + \"=\"*70)"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 30,
2025-11-24 17:52:56 +01:00
"id": "e80da1c3",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"======================================================================\n",
"ANALYSIS SUMMARY & INTERPRETATION\n",
"======================================================================\n",
"\n",
"📊 WHAT THE DATA SHOWS:\n",
"\n",
"1. MUSCLE OXYGEN PATTERNS:\n",
" • Both legs started with good oxygen levels (Left: 75.4%, Right: 82.9%)\n",
" • The right leg showed higher baseline oxygenation\n",
" • During the test, oxygen levels dropped as intensity increased\n",
" • Left leg dropped 6.0% and right leg dropped 9.3%\n",
"\n",
"2. LEG COMPARISON:\n",
" • Left leg maintained oxygen better (only 6.0% drop vs 9.3%)\n",
" • Right leg experienced more oxygen desaturation\n",
"\n",
"3. RECOVERY PERFORMANCE:\n",
" ✓ Excellent left leg recovery (109% - exceeded baseline!)\n",
" • Right leg recovered to 97% of baseline\n",
" ✓ Overall excellent recovery capacity\n",
"\n",
"4. INTENSITY PROGRESSION:\n",
" Heart rate increased steadily through the test:\n",
" Lap 1: 107 bpm\n",
" Lap 2: 119 bpm\n",
" Lap 3: 131 bpm\n",
" Lap 4: 145 bpm\n",
" Lap 5: 158 bpm\n",
" Lap 6: 165 bpm\n",
"\n",
"💡 TRAINING INSIGHTS:\n",
"\n",
"1. Oxygen Utilization:\n",
" ✓ Good oxygen extraction efficiency\n",
" → Muscles are effectively using available oxygen\n",
"\n",
"2. Endurance Capacity:\n",
" ✓ Maintained good oxygen levels through high intensity\n",
" → Strong aerobic capacity\n",
"\n",
"3. Recovery Ability:\n",
" Recovery period: 159 seconds (2.6 minutes)\n",
" ✓ Fast recovery - good cardiovascular fitness\n",
" → Can handle high-intensity training\n",
"\n",
"🎯 RECOMMENDATIONS:\n",
"\n",
"• Monitor the leg with larger oxygen drop for potential weakness\n",
"• Use these SmO₂ patterns to optimize training intensity zones\n",
"• Recovery percentages indicate readiness for next training session\n",
"• Consider interval training at intensities where oxygen starts to drop\n",
"\n",
"======================================================================\n"
]
}
],
"source": [
"# Step 7: Analysis Summary and Interpretation\n",
"\n",
"print(\"=\"*70)\n",
"print(\"ANALYSIS SUMMARY & INTERPRETATION\")\n",
"print(\"=\"*70)\n",
"\n",
"print(\"\\n📊 WHAT THE DATA SHOWS:\")\n",
"print(\"\\n1. MUSCLE OXYGEN PATTERNS:\")\n",
"print(f\" • Both legs started with good oxygen levels (Left: {left_warmup_avg:.1f}%, Right: {right_warmup_avg:.1f}%)\")\n",
"print(f\" • The right leg showed higher baseline oxygenation\")\n",
"print(f\" • During the test, oxygen levels dropped as intensity increased\")\n",
"print(f\" • Left leg dropped {left_drop:.1f}% and right leg dropped {right_drop:.1f}%\")\n",
"\n",
"print(\"\\n2. LEG COMPARISON:\")\n",
"if abs(left_drop - right_drop) > 2:\n",
" if left_drop < right_drop:\n",
" print(f\" • Left leg maintained oxygen better (only {left_drop:.1f}% drop vs {right_drop:.1f}%)\")\n",
" print(f\" • Right leg experienced more oxygen desaturation\")\n",
" else:\n",
" print(f\" • Right leg maintained oxygen better (only {right_drop:.1f}% drop vs {left_drop:.1f}%)\")\n",
" print(f\" • Left leg experienced more oxygen desaturation\")\n",
"else:\n",
" print(f\" • Both legs showed similar oxygen patterns (balanced)\")\n",
"\n",
"print(\"\\n3. RECOVERY PERFORMANCE:\")\n",
"if left_recovery_pct > 100:\n",
" print(f\" ✓ Excellent left leg recovery ({left_recovery_pct}% - exceeded baseline!)\")\n",
"else:\n",
" print(f\" • Left leg recovered to {left_recovery_pct}% of baseline\")\n",
"\n",
"if right_recovery_pct > 100:\n",
" print(f\" ✓ Excellent right leg recovery ({right_recovery_pct}% - exceeded baseline!)\")\n",
"else:\n",
" print(f\" • Right leg recovered to {right_recovery_pct}% of baseline\")\n",
"\n",
"avg_recovery = (left_recovery_pct + right_recovery_pct) / 2\n",
"if avg_recovery >= 100:\n",
" print(f\" ✓ Overall excellent recovery capacity\")\n",
"elif avg_recovery >= 95:\n",
" print(f\" ✓ Good recovery capacity\")\n",
"else:\n",
" print(f\" ⚠ Recovery may need attention (avg {avg_recovery:.0f}%)\")\n",
"\n",
"print(\"\\n4. INTENSITY PROGRESSION:\")\n",
"print(\" Heart rate increased steadily through the test:\")\n",
"for lap in range(1, 7):\n",
" lap_mask = df_oxy['Lap'] == lap\n",
" lap_hr = df_oxy[lap_mask]['Heart_Rate_smooth'].mean()\n",
" print(f\" Lap {lap}: {lap_hr:.0f} bpm\")\n",
"\n",
"print(\"\\n💡 TRAINING INSIGHTS:\")\n",
"print(\"\\n1. Oxygen Utilization:\")\n",
"if left_drop < 10 and right_drop < 10:\n",
" print(\" ✓ Good oxygen extraction efficiency\")\n",
" print(\" → Muscles are effectively using available oxygen\")\n",
"else:\n",
" print(\" ⚠ Significant oxygen desaturation observed\")\n",
" print(\" → May indicate approaching oxygen delivery limits\")\n",
"\n",
"print(\"\\n2. Endurance Capacity:\")\n",
"# Check if oxygen drops significantly in later laps\n",
"lap5_left = df_oxy[df_oxy['Lap'] == 5]['Left_SmO2_smooth'].mean()\n",
"lap5_right = df_oxy[df_oxy['Lap'] == 5]['Right_SmO2_smooth'].mean()\n",
"if (lap5_left > left_warmup_avg - 5) or (lap5_right > right_warmup_avg - 5):\n",
" print(\" ✓ Maintained good oxygen levels through high intensity\")\n",
" print(\" → Strong aerobic capacity\")\n",
"else:\n",
" print(\" • Oxygen levels decreased progressively with intensity\")\n",
" print(\" → Normal response to increasing workload\")\n",
"\n",
"print(\"\\n3. Recovery Ability:\")\n",
"recovery_time_sec = df_oxy['Timestamp (seconds passed)'].max() - recovery_start\n",
"print(f\" Recovery period: {recovery_time_sec:.0f} seconds ({recovery_time_sec/60:.1f} minutes)\")\n",
"if avg_recovery > 95:\n",
" print(\" ✓ Fast recovery - good cardiovascular fitness\")\n",
" print(\" → Can handle high-intensity training\")\n",
"else:\n",
" print(\" • Moderate recovery observed\")\n",
" print(\" → Consider extending rest periods between hard efforts\")\n",
"\n",
"print(\"\\n🎯 RECOMMENDATIONS:\")\n",
"print(\"\\n• Monitor the leg with larger oxygen drop for potential weakness\")\n",
"print(\"• Use these SmO₂ patterns to optimize training intensity zones\")\n",
"print(\"• Recovery percentages indicate readiness for next training session\")\n",
"print(\"• Consider interval training at intensities where oxygen starts to drop\")\n",
"\n",
"print(\"\\n\" + \"=\"*70)"
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 31,
2025-11-24 17:52:56 +01:00
"id": "fbeb6168",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"✓ Summary data exported to: /home/oluwasanmi/Documents/Work/MKD/report_generation/notebooks/oxygenation_2_output.csv\n",
"\n",
"Summary Table:\n",
" Metric Value\n",
" Left Baseline SmO2 (%) 75.37\n",
" Right Baseline SmO2 (%) 82.91\n",
" Left Minimum SmO2 (%) 69.34\n",
" Right Minimum SmO2 (%) 73.65\n",
" Left Maximum SmO2 (%) 78.24\n",
" Right Maximum SmO2 (%) 82.59\n",
" Left Recovery SmO2 (%) 82.47\n",
" Right Recovery SmO2 (%) 80.03\n",
" Left Recovery Percentage (%) 109\n",
"Right Recovery Percentage (%) 97\n",
" Left Oxygen Drop (%) 6.03\n",
" Right Oxygen Drop (%) 9.26\n",
" Warmup HR (bpm) 93.2\n",
" Maximum HR (bpm) 168.2\n",
" Recovery HR (bpm) 107.7\n",
" Test Duration (seconds) 1287\n",
" Recovery Duration (seconds) 159\n"
]
}
],
"source": [
"# Step 8: Export summary data to CSV for reporting\n",
"\n",
"# Create summary dataframe\n",
"summary_data = {\n",
" 'Metric': [\n",
" 'Left Baseline SmO2 (%)',\n",
" 'Right Baseline SmO2 (%)',\n",
" 'Left Minimum SmO2 (%)',\n",
" 'Right Minimum SmO2 (%)',\n",
" 'Left Maximum SmO2 (%)',\n",
" 'Right Maximum SmO2 (%)',\n",
" 'Left Recovery SmO2 (%)',\n",
" 'Right Recovery SmO2 (%)',\n",
" 'Left Recovery Percentage (%)',\n",
" 'Right Recovery Percentage (%)',\n",
" 'Left Oxygen Drop (%)',\n",
" 'Right Oxygen Drop (%)',\n",
" 'Warmup HR (bpm)',\n",
" 'Maximum HR (bpm)',\n",
" 'Recovery HR (bpm)',\n",
" 'Test Duration (seconds)',\n",
" 'Recovery Duration (seconds)',\n",
" ],\n",
" 'Value': [\n",
" f\"{left_warmup_avg:.2f}\",\n",
" f\"{right_warmup_avg:.2f}\",\n",
" f\"{left_min:.2f}\",\n",
" f\"{right_min:.2f}\",\n",
" f\"{left_max:.2f}\",\n",
" f\"{right_max:.2f}\",\n",
" f\"{left_recovery_avg:.2f}\",\n",
" f\"{right_recovery_avg:.2f}\",\n",
" f\"{left_recovery_pct}\",\n",
" f\"{right_recovery_pct}\",\n",
" f\"{left_drop:.2f}\",\n",
" f\"{right_drop:.2f}\",\n",
" f\"{hr_warmup:.1f}\",\n",
" f\"{hr_max:.1f}\",\n",
" f\"{hr_recovery:.1f}\",\n",
" f\"{recovery_start - warm_up_end:.0f}\",\n",
" f\"{df_oxy['Timestamp (seconds passed)'].max() - recovery_start:.0f}\",\n",
" ]\n",
"}\n",
"\n",
"summary_df = pd.DataFrame(summary_data)\n",
"\n",
"# Save to CSV\n",
"output_path = f'{base_dir}/notebooks/oxygenation_2_output.csv'\n",
"summary_df.to_csv(output_path, index=False)\n",
"\n",
"print(\"✓ Summary data exported to:\", output_path)\n",
"print(\"\\nSummary Table:\")\n",
"print(summary_df.to_string(index=False))"
]
},
{
"cell_type": "markdown",
"id": "f383e0a8",
"metadata": {},
"source": [
"## ✅ Analysis Complete!\n",
"\n",
"**Outputs Generated:**\n",
"1. **Graph**: `graphs/muscle_oxygenation_plot.png` - Comprehensive SmO₂ visualization with:\n",
" - Left and right leg muscle oxygenation (smoothed)\n",
" - Heart rate on secondary axis\n",
" - Stage shading (warm-up, active laps, recovery)\n",
" - Recovery percentages in legend\n",
" \n",
"2. **Data**: `notebooks/oxygenation_2_output.csv` - All key metrics exported\n",
"\n",
"**Key Findings:**\n",
"- Left leg showed better oxygen maintenance (6% drop vs 9.3% for right)\n",
"- Excellent recovery capacity (109% left, 97% right)\n",
"- Strong aerobic capacity maintained through high intensity\n",
"- Heart rate progressed steadily from 107 to 165 bpm\n",
"\n",
"The analysis follows the PDF instructions for Train.Red SmO₂ ramp testing, including 10-second smoothing, stage identification, and recovery percentage calculations."
]
2025-11-24 19:37:28 +01:00
},
{
"cell_type": "markdown",
"id": "d8dfb3fa",
"metadata": {},
"source": [
"## Integration with Report Generator\n",
"\n",
"The muscle oxygenation graph generation has been integrated into the report generation system:\n",
"\n",
"1. **GraphGenerator** method: `generate_muscle_oxygenation_chart(oxygenation_df, save_as_base64=True)`\n",
" - Returns: tuple of (base64_chart_string, metrics_dict)\n",
" \n",
"2. **ContextGenerator** now accepts `oxygenation_path` parameter in `load_data()`\n",
"\n",
"3. **Page 12** automatically includes:\n",
" - Full muscle oxygenation chart showing both legs\n",
" - Key metrics for both legs (baseline, minimum, drop, recovery)\n",
" - Heart rate data\n",
" - Summary findings\n",
"\n",
"The system will automatically generate the chart when an oxygenation CSV file is provided during report generation."
]
},
{
"cell_type": "code",
2025-11-28 11:44:37 +01:00
"execution_count": 32,
2025-11-24 19:37:28 +01:00
"id": "a9064ffe",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"✅ Graph generation successful!\n",
"\n",
"Chart size: 871248 characters (base64)\n",
"\n",
"Metrics extracted:\n",
" left_baseline_smo2: 75.4%\n",
" right_baseline_smo2: 82.9%\n",
" left_minimum_smo2: 69.3%\n",
" right_minimum_smo2: 73.7%\n",
" left_minimum_lap: Lap 6\n",
" right_minimum_lap: Lap 6\n",
" left_oxygen_drop: 6.0%\n",
" right_oxygen_drop: 9.3%\n",
" left_drop_percentage: 8% decrease\n",
" right_drop_percentage: 11% decrease\n",
" left_recovery_percentage: 109%\n",
" right_recovery_percentage: 97%\n",
" hr_warmup: 93\n",
" hr_max: 168\n",
" test_duration: ~21 minutes active test\n",
" recovery_assessment: Excellent recovery capacity\n"
]
}
],
"source": [
"# Test the GraphGenerator integration\n",
"import sys\n",
"import os\n",
"import pandas as pd\n",
"\n",
"# Set base_dir if not already defined\n",
"try:\n",
" base_dir\n",
"except NameError:\n",
" base_dir = os.path.dirname(os.path.abspath('.'))\n",
"\n",
"# Load oxygenation data if not already loaded\n",
"try:\n",
" oxygenation_2\n",
"except NameError:\n",
" print(\"Loading oxygenation data...\")\n",
" oxygenation_2 = pd.read_csv(f'{base_dir}/data/muscle_oxygenation.csv', skiprows=445)\n",
"\n",
"sys.path.append(f'{base_dir}/app')\n",
"\n",
"from services.graph_generator import GraphGenerator\n",
"\n",
"# Initialize the graph generator\n",
"graph_gen = GraphGenerator(charts_dir=f'{base_dir}/graphs')\n",
"\n",
"# Generate the chart using the same dataframe\n",
"try:\n",
" chart_b64, metrics = graph_gen.generate_muscle_oxygenation_chart(\n",
" oxygenation_2, \n",
" save_as_base64=True\n",
" )\n",
" \n",
" print(\"✅ Graph generation successful!\")\n",
" print(f\"\\nChart size: {len(chart_b64)} characters (base64)\")\n",
" print(f\"\\nMetrics extracted:\")\n",
" for key, value in metrics.items():\n",
" print(f\" {key}: {value}\")\n",
" \n",
"except Exception as e:\n",
" print(f\"❌ Error: {e}\")\n",
" import traceback\n",
" traceback.print_exc()"
]
},
{
"cell_type": "markdown",
"id": "7886483b",
"metadata": {},
"source": [
"## ✅ Integration Complete!\n",
"\n",
"The muscle oxygenation analysis has been successfully integrated into the report generation system.\n",
"\n",
"### What was implemented:\n",
"\n",
"1. **GraphGenerator Method** (`app/services/graph_generator.py`):\n",
" - Added `generate_muscle_oxygenation_chart()` method\n",
" - Processes Train.Red CSV data (with 10-second smoothing)\n",
" - Generates comprehensive chart with both legs and heart rate\n",
" - Returns both base64 image and extracted metrics dictionary\n",
"\n",
"2. **ContextGenerator Updates** (`app/services/context_generator.py`):\n",
" - Added `oxygenation_df` attribute\n",
" - Updated `load_data()` to accept optional `oxygenation_path` parameter\n",
" - Page 12 context now includes muscle oxygenation chart and all metrics\n",
"\n",
"3. **Page 12 Template** (`app/report_gen/page_12.html`):\n",
" - Replaced two separate leg charts with single comprehensive chart\n",
" - Added side-by-side metric cards for both legs\n",
" - Displays all key values: baseline, minimum, drop, recovery\n",
" - Includes summary findings section\n",
"\n",
"4. **Report Generation Pipeline** (`app/services/report_generator.py`, `app/main.py`):\n",
" - Updated to pass oxygenation CSV path through the system\n",
" - Automatically generates chart when oxygenation data is provided\n",
"\n",
"### How to use:\n",
"\n",
"When generating a report, simply provide the muscle oxygenation CSV file (Train.Red format), and the system will:\n",
"- Automatically load and process the data\n",
"- Generate the comprehensive visualization\n",
"- Extract all key metrics\n",
"- Include everything in Page 12 of the report\n",
"\n",
"No manual intervention required - it's fully automated!"
]
2025-11-28 11:44:37 +01:00
},
{
"cell_type": "code",
"execution_count": null,
"id": "a3486652",
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {
"kernelspec": {
2025-11-28 11:44:37 +01:00
"display_name": ".venv",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
2025-11-21 09:23:13 +01:00
"version": "3.12.6"
}
},
"nbformat": 4,
"nbformat_minor": 5
}