diff --git a/app/estimated_carb_storage.png b/app/estimated_carb_storage.png new file mode 100644 index 0000000..ba4cffd Binary files /dev/null and b/app/estimated_carb_storage.png differ diff --git a/app/main.py b/app/main.py index 1251ec6..8dbedea 100644 --- a/app/main.py +++ b/app/main.py @@ -233,6 +233,7 @@ async def upload_files( str(pnoe_path), str(spirometry_csv_path), None, # No SECA file needed anymore + str(oxygenation_path) if oxygenation_path else None, # Oxygenation CSV ) # Set patient info manually since we're not reading from SECA weight_kg = float(weight.replace("lbs", "").replace("kg", "").strip()) @@ -442,6 +443,7 @@ async def edit_metrics(request: Request): str(pnoe_path), spirometry_csv_path, None, # No SECA file + str(oxygenation_path) if oxygenation_path else None, # Oxygenation CSV ) # Set patient info manually weight_str = patient_info.get("weight", "0") diff --git a/app/report_gen/page_12.html b/app/report_gen/page_12.html index 1546b03..aa5ecdd 100644 --- a/app/report_gen/page_12.html +++ b/app/report_gen/page_12.html @@ -1,76 +1,166 @@
-

Local Muscle Activity

-

Muscle Oxygenation Assessment

+

+ Local Muscle Activity +

+

+ Muscle Oxygenation Assessment +

- SMO2 testing (Skeletal Muscle Oxygen Saturation) is an analysis of how effectively oxygen is being used at a particular muscle. It helps determine limitations on if the muscle is effectively using oxygen when exercising. + SMO2 testing (Skeletal Muscle Oxygen Saturation) is an analysis of + how effectively oxygen is being used at a particular muscle. It + helps determine limitations on if the muscle is effectively using + oxygen when exercising.

- -
-

Indications - Right Leg

- -
- -
- Right Leg SMO2 Chart + +
+
+ Muscle Oxygenation Chart +
+
+ + +
+ +
+

+ Left Leg Analysis +

+ +
+
+
+ Baseline SmO₂ +
+
+ {{ left_baseline_smo2 | default('75.4%') }} +
+
+ +
+
+ Minimum SmO₂ +
+
+ {{ left_minimum_smo2 | default('69.3%') }} +
+
+ {{ left_minimum_lap | default('Lap 6') }} +
+
+ +
+
+ Oxygen Drop +
+
+ {{ left_oxygen_drop | default('6.0%') }} +
+
+ {{ left_drop_percentage | default('8% decrease') }} +
+
+ +
+
+ Recovery +
+
+ "Optimal >100%" +
+
+ {{ left_recovery_percentage | default('109%') }} +
+
- - -
-
-
Surplus
-
Supply > Demand at a heart rate and speed of:
-
n/a
+
+ + +
+

+ 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

- -
- -
- Left Leg SMO2 Chart -
- - -
-
-
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

+ + +
+ Fuelling Analysis Flowchart +
+ + +
+

+ 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": {