+
+ Right Leg Analysis
+
+
+
+
+
+ Baseline SmO₂
+
+
+ {{ right_baseline_smo2 | default('82.9%') }}
+
-
-
-
Supply Threshold
-
Demand outstrips supply at a heart rate of:
-
154bpm @ 5.0mph
+
+
+
+ Minimum SmO₂
+
+
+ {{ right_minimum_smo2 | default('73.7%') }}
+
+
+ {{ right_minimum_lap | default('Lap 6') }}
+
-
-
-
Recovery
-
"Optimal >100%"
-
n/a
+
+
+
+ Oxygen Drop
+
+
+ {{ right_oxygen_drop | default('9.3%') }}
+
+
+ {{ right_drop_percentage | default('11% decrease') }}
+
+
+
+
+
+ Recovery
+
+
+ "Optimal >100%"
+
+
+ {{ right_recovery_percentage | default('97%') }}
+
-
-
-
Indications - Left Leg
-
-
-
-
-

-
-
-
-
-
-
Surplus
-
Supply > Demand at a heart rate and speed of:
-
n/a
-
-
-
-
Supply Threshold
-
Demand outstrips supply at a heart rate of:
-
165 bpm @ 5.5mph
-
-
-
-
Recovery
-
"Optimal >100%"
-
n/a
-
-
+
+
+
Key Findings
+
+
+ • Left leg showed better oxygen maintenance
+ during high-intensity work
+
+
+ •
+ {{ recovery_assessment | default('Excellent recovery
+ capacity') }}
+ - both legs recovered well
+
+
+ • Heart rate progression: {{ hr_warmup |
+ default('93') }} → {{ hr_max | default('168') }} bpm
+
+
+ • Test duration: {{ test_duration |
+ default('~21 minutes active test') }}
+
-
\ No newline at end of file
+
diff --git a/app/report_gen/page_9.5.html b/app/report_gen/page_9.5.html
new file mode 100644
index 0000000..4d42a00
--- /dev/null
+++ b/app/report_gen/page_9.5.html
@@ -0,0 +1,515 @@
+
+
+
+
+
Fuelling Analysis
+
+
+
+

+
+
+
+
+
+ Estimated Carbohydrate Storage by Weight and Sex in Athletes
+
+
+
+
+
+
+ |
+ Weight (kg)
+ |
+
+ Sex
+ |
+
+ Muscle Glycogen (g)
+ |
+
+ Liver Glycogen (g)
+ |
+
+ Blood Glucose (g)
+ |
+
+ Total Carb (g)
+ |
+
+ Total Carb (kcal)
+ |
+
+
+
+
+ |
+ 50
+ |
+
+ male
+ |
+
+ 292
+ |
+
+ 105
+ |
+
+ 4.5
+ |
+
+ 402
+ |
+
+ 1608
+ |
+
+
+ |
+ 50
+ |
+
+ female
+ |
+
+ 228
+ |
+
+ 85
+ |
+
+ 4.5
+ |
+
+ 317
+ |
+
+ 1268
+ |
+
+
+ |
+ 60
+ |
+
+ male
+ |
+
+ 351
+ |
+
+ 105
+ |
+
+ 4.5
+ |
+
+ 460
+ |
+
+ 1842
+ |
+
+
+ |
+ 60
+ |
+
+ female
+ |
+
+ 273
+ |
+
+ 85
+ |
+
+ 4.5
+ |
+
+ 362
+ |
+
+ 1450
+ |
+
+
+ |
+ 70
+ |
+
+ male
+ |
+
+ 410
+ |
+
+ 105
+ |
+
+ 4.5
+ |
+
+ 519
+ |
+
+ 2076
+ |
+
+
+ |
+ 70
+ |
+
+ female
+ |
+
+ 318
+ |
+
+ 85
+ |
+
+ 4.5
+ |
+
+ 408
+ |
+
+ 1632
+ |
+
+
+ |
+ 80
+ |
+
+ male
+ |
+
+ 468
+ |
+
+ 105
+ |
+
+ 4.5
+ |
+
+ 578
+ |
+
+ 2310
+ |
+
+
+ |
+ 80
+ |
+
+ female
+ |
+
+ 364
+ |
+
+ 85
+ |
+
+ 4.5
+ |
+
+ 454
+ |
+
+ 1814
+ |
+
+
+ |
+ 90
+ |
+
+ male
+ |
+
+ 526
+ |
+
+ 105
+ |
+
+ 4.5
+ |
+
+ 636
+ |
+
+ 2544
+ |
+
+
+ |
+ 90
+ |
+
+ female
+ |
+
+ 409
+ |
+
+ 85
+ |
+
+ 4.5
+ |
+
+ 499
+ |
+
+ 1996
+ |
+
+
+ |
+ 100
+ |
+
+ male
+ |
+
+ 585
+ |
+
+ 105
+ |
+
+ 4.5
+ |
+
+ 694
+ |
+
+ 2778
+ |
+
+
+ |
+ 100
+ |
+
+ female
+ |
+
+ 455
+ |
+
+ 85
+ |
+
+ 4.5
+ |
+
+ 544
+ |
+
+ 2178
+ |
+
+
+
+
+
+
+
diff --git a/app/services/__pycache__/context_generator.cpython-312.pyc b/app/services/__pycache__/context_generator.cpython-312.pyc
index 8705f8a..9e63d99 100644
Binary files a/app/services/__pycache__/context_generator.cpython-312.pyc and b/app/services/__pycache__/context_generator.cpython-312.pyc differ
diff --git a/app/services/__pycache__/graph_generator.cpython-312.pyc b/app/services/__pycache__/graph_generator.cpython-312.pyc
index 480e77b..6c836b3 100644
Binary files a/app/services/__pycache__/graph_generator.cpython-312.pyc and b/app/services/__pycache__/graph_generator.cpython-312.pyc differ
diff --git a/app/services/__pycache__/report_generator.cpython-312.pyc b/app/services/__pycache__/report_generator.cpython-312.pyc
index de346c8..40335d4 100644
Binary files a/app/services/__pycache__/report_generator.cpython-312.pyc and b/app/services/__pycache__/report_generator.cpython-312.pyc differ
diff --git a/app/services/context_generator.py b/app/services/context_generator.py
index de02bf4..b5574d1 100644
--- a/app/services/context_generator.py
+++ b/app/services/context_generator.py
@@ -18,6 +18,7 @@ class ContextGenerator:
self.pnoe_df = None
self.spirometry_df = None
self.seca_df = None
+ self.oxygenation_df = None
self.patient_info = {}
def load_data(
@@ -25,6 +26,7 @@ class ContextGenerator:
pnoe_path: str,
spirometry_path: str,
seca_path: Optional[str] = None,
+ oxygenation_path: Optional[str] = None,
):
"""Load all required datasets"""
self.pnoe_df = pd.read_csv(pnoe_path, delimiter=";")
@@ -33,6 +35,11 @@ class ContextGenerator:
self.seca_df = pd.read_excel(seca_path)
else:
self.seca_df = None
+ if oxygenation_path:
+ # Load muscle oxygenation data with skiprows to skip Train.Red metadata
+ self.oxygenation_df = pd.read_csv(oxygenation_path, skiprows=445)
+ else:
+ self.oxygenation_df = None
self._preprocess_pnoe_data()
def _preprocess_pnoe_data(self):
@@ -1375,8 +1382,25 @@ class ContextGenerator:
)
)
- # Pages 12-17
- for i in range(6):
+ # Page 12 - Muscle Oxygenation
+ contexts["page_12"] = {
+ "patient_name": self.patient_info["name"],
+ "page_number": 12,
+ }
+
+ # Generate muscle oxygenation chart if data is available
+ if graph_generator and self.oxygenation_df is not None:
+ try:
+ chart_str, metrics = graph_generator.generate_muscle_oxygenation_chart(
+ self.oxygenation_df, save_as_base64=True
+ )
+ contexts["page_12"]["muscle_oxygenation_chart"] = chart_str
+ contexts["page_12"].update(metrics)
+ except Exception as e:
+ print(f"Warning: Could not generate muscle oxygenation chart: {e}")
+
+ # Pages 13-17
+ for i in range(1, 6):
contexts[f"page_{i + 12}"] = {
"patient_name": self.patient_info["name"],
"page_number": i + 12,
diff --git a/app/services/graph_generator.py b/app/services/graph_generator.py
index 441bc78..0bcb6b5 100644
--- a/app/services/graph_generator.py
+++ b/app/services/graph_generator.py
@@ -6,6 +6,7 @@ Based on the analysis notebooks in services_dfdf/.
"""
import base64
+import io
from pathlib import Path
import matplotlib
@@ -1680,3 +1681,236 @@ class GraphGenerator:
)
plt.close(fig)
return str(output_path)
+
+ def generate_muscle_oxygenation_chart(
+ self, oxygenation_df: pd.DataFrame, save_as_base64: bool = True
+ ) -> tuple:
+ """
+ Generate comprehensive muscle oxygenation (SmO2) chart with both legs and heart rate.
+
+ Args:
+ oxygenation_df: DataFrame with muscle oxygenation data (Train.Red CSV format)
+ save_as_base64: If True, return base64 string, else return file path
+
+ Returns:
+ Tuple of (chart_string, metrics_dict) where metrics_dict contains key values
+ """
+ # Data preparation
+ df_oxy = oxygenation_df.copy()
+
+ # Convert columns to numeric
+ df_oxy["Timestamp (seconds passed)"] = pd.to_numeric(
+ df_oxy["Timestamp (seconds passed)"], errors="coerce"
+ )
+ df_oxy["Left_SmO2"] = pd.to_numeric(df_oxy["SmO2"], errors="coerce")
+ df_oxy["Right_SmO2"] = pd.to_numeric(df_oxy["SmO2.1"], errors="coerce")
+ df_oxy["Heart_Rate"] = pd.to_numeric(
+ df_oxy["Heart Rate (BPM)"], errors="coerce"
+ )
+ df_oxy["Lap"] = pd.to_numeric(df_oxy["Lap/Event"], errors="coerce")
+
+ # Drop rows with missing timestamps
+ df_oxy = df_oxy.dropna(subset=["Timestamp (seconds passed)"])
+ df_oxy = df_oxy.sort_values("Timestamp (seconds passed)").reset_index(drop=True)
+
+ # Apply 10-second rolling mean smoothing
+ time_diffs = df_oxy["Timestamp (seconds passed)"].diff().dropna()
+ avg_sampling_interval = time_diffs.median()
+ sampling_freq = 1 / avg_sampling_interval if avg_sampling_interval > 0 else 10
+ window_samples = int(10 * sampling_freq)
+
+ df_oxy["Left_SmO2_smooth"] = (
+ df_oxy["Left_SmO2"]
+ .rolling(window=window_samples, center=True, min_periods=1)
+ .mean()
+ )
+ df_oxy["Right_SmO2_smooth"] = (
+ df_oxy["Right_SmO2"]
+ .rolling(window=window_samples, center=True, min_periods=1)
+ .mean()
+ )
+ df_oxy["Heart_Rate_smooth"] = (
+ df_oxy["Heart_Rate"]
+ .rolling(window=window_samples, center=True, min_periods=1)
+ .mean()
+ )
+
+ # Identify stage boundaries
+ lap_changes = df_oxy[df_oxy["Lap"].diff() != 0].copy()
+ lap_starts = {}
+ for idx, row in lap_changes.iterrows():
+ lap_num = int(row["Lap"])
+ lap_starts[lap_num] = row["Timestamp (seconds passed)"]
+
+ warm_up_end = lap_starts.get(1, df_oxy["Timestamp (seconds passed)"].max())
+ recovery_start = lap_starts.get(7, df_oxy["Timestamp (seconds passed)"].max())
+
+ # Calculate recovery percentages
+ warm_up_last_30_start = warm_up_end - 30
+ warm_up_mask = (
+ df_oxy["Timestamp (seconds passed)"] >= warm_up_last_30_start
+ ) & (df_oxy["Timestamp (seconds passed)"] <= warm_up_end)
+
+ recovery_end = df_oxy["Timestamp (seconds passed)"].max()
+ recovery_last_30_start = recovery_end - 30
+ recovery_mask = (
+ df_oxy["Timestamp (seconds passed)"] >= recovery_last_30_start
+ ) & (df_oxy["Timestamp (seconds passed)"] <= recovery_end)
+
+ left_warmup_avg = df_oxy.loc[warm_up_mask, "Left_SmO2_smooth"].mean()
+ left_recovery_avg = df_oxy.loc[recovery_mask, "Left_SmO2_smooth"].mean()
+ left_recovery_pct = round((left_recovery_avg / left_warmup_avg) * 100)
+
+ right_warmup_avg = df_oxy.loc[warm_up_mask, "Right_SmO2_smooth"].mean()
+ right_recovery_avg = df_oxy.loc[recovery_mask, "Right_SmO2_smooth"].mean()
+ right_recovery_pct = round((right_recovery_avg / right_warmup_avg) * 100)
+
+ # Calculate key metrics
+ active_mask = (df_oxy["Timestamp (seconds passed)"] >= warm_up_end) & (
+ df_oxy["Timestamp (seconds passed)"] <= recovery_start
+ )
+ active_data = df_oxy[active_mask]
+
+ left_min = active_data["Left_SmO2_smooth"].min()
+ left_min_lap = int(
+ active_data.loc[active_data["Left_SmO2_smooth"].idxmin(), "Lap"]
+ )
+ right_min = active_data["Right_SmO2_smooth"].min()
+ right_min_lap = int(
+ active_data.loc[active_data["Right_SmO2_smooth"].idxmin(), "Lap"]
+ )
+
+ left_drop = left_warmup_avg - left_min
+ right_drop = right_warmup_avg - right_min
+
+ hr_warmup = df_oxy[df_oxy["Timestamp (seconds passed)"] <= warm_up_end][
+ "Heart_Rate_smooth"
+ ].mean()
+ hr_max = active_data["Heart_Rate_smooth"].max()
+
+ # Create the plot
+ fig, ax1 = plt.subplots(figsize=(18, 8))
+
+ time = df_oxy["Timestamp (seconds passed)"]
+ ax1.plot(
+ time,
+ df_oxy["Left_SmO2_smooth"],
+ label=f"Left SmO₂ (Rec {left_recovery_pct}% of warm-up)",
+ color="#2E86AB",
+ linewidth=2,
+ )
+ ax1.plot(
+ time,
+ df_oxy["Right_SmO2_smooth"],
+ label=f"Right SmO₂ (Rec {right_recovery_pct}% of warm-up)",
+ color="#A23B72",
+ linewidth=2,
+ )
+
+ ax1.set_xlabel("Time (seconds)", fontsize=12, fontweight="bold")
+ ax1.set_ylabel("SmO₂ (%)", fontsize=12, fontweight="bold")
+ ax1.tick_params(axis="y", labelcolor="black")
+ ax1.grid(True, alpha=0.3, linestyle="--")
+
+ # Add secondary axis for heart rate
+ ax2 = ax1.twinx()
+ ax2.plot(
+ time,
+ df_oxy["Heart_Rate_smooth"],
+ label="Heart Rate",
+ color="red",
+ linewidth=1.5,
+ linestyle="--",
+ alpha=0.7,
+ )
+ ax2.set_ylabel("Heart Rate (BPM)", fontsize=12, fontweight="bold", color="red")
+ ax2.tick_params(axis="y", labelcolor="red")
+
+ # Add shaded regions
+ ax1.axvspan(0, warm_up_end, alpha=0.15, color="blue", label="Warm-up")
+
+ active_laps = [1, 2, 3, 4, 5, 6]
+ colors_active = ["yellow", "orange"] * 3
+ for i, lap in enumerate(active_laps):
+ start = lap_starts.get(lap, 0)
+ end = lap_starts.get(lap + 1, recovery_start) if lap < 6 else recovery_start
+ ax1.axvspan(start, end, alpha=0.1, color=colors_active[i])
+
+ ax1.axvspan(
+ recovery_start, recovery_end, alpha=0.2, color="gray", label="Recovery"
+ )
+ ax1.axvline(
+ x=recovery_start, color="black", linestyle="-", linewidth=2, alpha=0.7
+ )
+
+ # Add lap labels
+ for lap in range(1, 7):
+ start = lap_starts.get(lap, 0)
+ end = lap_starts.get(lap + 1, recovery_start) if lap < 6 else recovery_start
+ mid = (start + end) / 2
+ ax1.text(
+ mid,
+ ax1.get_ylim()[1] * 0.97,
+ f"Lap {lap}",
+ ha="center",
+ va="top",
+ fontsize=10,
+ fontweight="bold",
+ bbox=dict(boxstyle="round,pad=0.3", facecolor="white", alpha=0.7),
+ )
+
+ plt.title(
+ "Train.Red SmO₂ Ramp - Muscle Oxygenation Analysis",
+ fontsize=16,
+ fontweight="bold",
+ pad=20,
+ )
+
+ # Combine legends
+ lines1, labels1 = ax1.get_legend_handles_labels()
+ lines2, labels2 = ax2.get_legend_handles_labels()
+ ax1.legend(
+ lines1 + lines2,
+ labels1 + labels2,
+ loc="upper left",
+ fontsize=10,
+ framealpha=0.9,
+ )
+
+ plt.tight_layout()
+
+ # Prepare metrics dictionary
+ metrics = {
+ "left_baseline_smo2": f"{left_warmup_avg:.1f}%",
+ "right_baseline_smo2": f"{right_warmup_avg:.1f}%",
+ "left_minimum_smo2": f"{left_min:.1f}%",
+ "right_minimum_smo2": f"{right_min:.1f}%",
+ "left_minimum_lap": f"Lap {left_min_lap}",
+ "right_minimum_lap": f"Lap {right_min_lap}",
+ "left_oxygen_drop": f"{left_drop:.1f}%",
+ "right_oxygen_drop": f"{right_drop:.1f}%",
+ "left_drop_percentage": f"{(left_drop / left_warmup_avg * 100):.0f}% decrease",
+ "right_drop_percentage": f"{(right_drop / right_warmup_avg * 100):.0f}% decrease",
+ "left_recovery_percentage": f"{left_recovery_pct}%",
+ "right_recovery_percentage": f"{right_recovery_pct}%",
+ "hr_warmup": f"{hr_warmup:.0f}",
+ "hr_max": f"{hr_max:.0f}",
+ "test_duration": f"~{(recovery_start - warm_up_end) / 60:.0f} minutes active test",
+ "recovery_assessment": "Excellent recovery capacity"
+ if (left_recovery_pct + right_recovery_pct) / 2 >= 100
+ else "Good recovery capacity",
+ }
+
+ # Save or return
+ if save_as_base64:
+ buf = io.BytesIO()
+ plt.savefig(buf, format="png", dpi=300, bbox_inches="tight")
+ plt.close(fig)
+ buf.seek(0)
+ chart_str = base64.b64encode(buf.read()).decode("utf-8")
+ return chart_str, metrics
+ else:
+ output_path = self.charts_dir / "muscle_oxygenation_chart.png"
+ plt.savefig(output_path, dpi=300, bbox_inches="tight")
+ plt.close(fig)
+ return str(output_path), metrics
diff --git a/app/services/report_generator.py b/app/services/report_generator.py
index 55b5498..cfb8b89 100644
--- a/app/services/report_generator.py
+++ b/app/services/report_generator.py
@@ -503,6 +503,7 @@ class ReportGeneratorService:
pnoe_csv_path,
str(spirometry_csv_path),
None, # No SECA file
+ oxygenation_csv_path, # Pass oxygenation CSV path
)
# Set patient info manually
self.context_generator.patient_info = {
diff --git a/notebooks/graphs.ipynb b/notebooks/graphs.ipynb
index ca29ad3..7156f26 100644
--- a/notebooks/graphs.ipynb
+++ b/notebooks/graphs.ipynb
@@ -3240,6 +3240,152 @@
"\n",
"The analysis follows the PDF instructions for Train.Red SmO₂ ramp testing, including 10-second smoothing, stage identification, and recovery percentage calculations."
]
+ },
+ {
+ "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",
+ "execution_count": 3,
+ "id": "a9064ffe",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Loading oxygenation data...\n",
+ "✅ 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!"
+ ]
}
],
"metadata": {