added extra page

This commit is contained in:
bolade
2025-11-24 19:37:28 +01:00
parent 580ad5d248
commit 8e8280bcb0
11 changed files with 1073 additions and 61 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

+2
View File
@@ -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")
+149 -59
View File
@@ -1,76 +1,166 @@
<div class="page bg-white p-8 max-w-4xl mx-auto">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-4">Local Muscle Activity</h1>
<h2 class="text-xl font-semibold text-gray-800 mb-2">Muscle Oxygenation Assessment</h2>
<h1 class="text-3xl font-bold text-gray-900 mb-4">
Local Muscle Activity
</h1>
<h2 class="text-xl font-semibold text-gray-800 mb-2">
Muscle Oxygenation Assessment
</h2>
<p class="text-sm text-gray-600 leading-relaxed">
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.
</p>
</div>
<!-- Right Leg Section -->
<div class="mb-12">
<h3 class="text-lg font-semibold text-center text-gray-800 mb-6">Indications - Right Leg</h3>
<div class="flex gap-8">
<!-- Chart Image -->
<div class="flex-1">
<img src="right-leg-chart.png" alt="Right Leg SMO2 Chart" class="w-full h-auto">
<!-- Combined Muscle Oxygenation Chart -->
<div class="mb-6">
<div class="flex justify-center mb-4">
<img
src="data:image/png;base64,{{ muscle_oxygenation_chart }}"
alt="Muscle Oxygenation Chart"
class="w-full h-auto max-w-6xl"
/>
</div>
</div>
<!-- Metrics Summary Grid -->
<div class="grid grid-cols-2 gap-6 mb-6">
<!-- Left Leg Metrics -->
<div class="bg-blue-50 p-4 rounded-lg border-2 border-blue-200">
<h3 class="text-lg font-bold text-gray-900 mb-4 text-center">
Left Leg Analysis
</h3>
<div class="space-y-3">
<div class="bg-white p-3 rounded shadow-sm">
<div class="text-xs font-semibold text-gray-700 mb-1">
Baseline SmO₂
</div>
<div class="text-lg font-bold text-gray-900">
{{ left_baseline_smo2 | default('75.4%') }}
</div>
</div>
<div class="bg-white p-3 rounded shadow-sm">
<div class="text-xs font-semibold text-gray-700 mb-1">
Minimum SmO₂
</div>
<div class="text-lg font-bold text-gray-900">
{{ left_minimum_smo2 | default('69.3%') }}
</div>
<div class="text-xs text-gray-600 mt-1">
{{ left_minimum_lap | default('Lap 6') }}
</div>
</div>
<div class="bg-white p-3 rounded shadow-sm">
<div class="text-xs font-semibold text-gray-700 mb-1">
Oxygen Drop
</div>
<div class="text-lg font-bold text-gray-900">
{{ left_oxygen_drop | default('6.0%') }}
</div>
<div class="text-xs text-gray-600 mt-1">
{{ left_drop_percentage | default('8% decrease') }}
</div>
</div>
<div class="bg-white p-3 rounded shadow-sm">
<div class="text-xs font-semibold text-gray-700 mb-1">
Recovery
</div>
<div class="text-xs text-gray-600 mb-1">
"Optimal >100%"
</div>
<div class="text-lg font-bold text-green-600">
{{ left_recovery_percentage | default('109%') }}
</div>
</div>
</div>
<!-- Right Side Info -->
<div class="w-48 space-y-4">
<div class="bg-gray-100 p-3 rounded">
<div class="text-xs font-semibold text-gray-700 mb-1">Surplus</div>
<div class="text-xs text-gray-600">Supply > Demand at a heart rate and speed of:</div>
<div class="text-sm font-bold text-gray-800">n/a</div>
</div>
<!-- Right Leg Metrics -->
<div class="bg-purple-50 p-4 rounded-lg border-2 border-purple-200">
<h3 class="text-lg font-bold text-gray-900 mb-4 text-center">
Right Leg Analysis
</h3>
<div class="space-y-3">
<div class="bg-white p-3 rounded shadow-sm">
<div class="text-xs font-semibold text-gray-700 mb-1">
Baseline SmO₂
</div>
<div class="text-lg font-bold text-gray-900">
{{ right_baseline_smo2 | default('82.9%') }}
</div>
</div>
<div class="bg-gray-100 p-3 rounded">
<div class="text-xs font-semibold text-gray-700 mb-1">Supply Threshold</div>
<div class="text-xs text-gray-600">Demand outstrips supply at a heart rate of:</div>
<div class="text-sm font-bold text-gray-800">154bpm @ 5.0mph</div>
<div class="bg-white p-3 rounded shadow-sm">
<div class="text-xs font-semibold text-gray-700 mb-1">
Minimum SmO₂
</div>
<div class="text-lg font-bold text-gray-900">
{{ right_minimum_smo2 | default('73.7%') }}
</div>
<div class="text-xs text-gray-600 mt-1">
{{ right_minimum_lap | default('Lap 6') }}
</div>
</div>
<div class="bg-gray-100 p-3 rounded">
<div class="text-xs font-semibold text-gray-700 mb-1">Recovery</div>
<div class="text-xs text-gray-600">"Optimal >100%"</div>
<div class="text-sm font-bold text-gray-800">n/a</div>
<div class="bg-white p-3 rounded shadow-sm">
<div class="text-xs font-semibold text-gray-700 mb-1">
Oxygen Drop
</div>
<div class="text-lg font-bold text-gray-900">
{{ right_oxygen_drop | default('9.3%') }}
</div>
<div class="text-xs text-gray-600 mt-1">
{{ right_drop_percentage | default('11% decrease') }}
</div>
</div>
<div class="bg-white p-3 rounded shadow-sm">
<div class="text-xs font-semibold text-gray-700 mb-1">
Recovery
</div>
<div class="text-xs text-gray-600 mb-1">
"Optimal >100%"
</div>
<div class="text-lg font-bold text-blue-600">
{{ right_recovery_percentage | default('97%') }}
</div>
</div>
</div>
</div>
</div>
<!-- Left Leg Section -->
<div>
<h3 class="text-lg font-semibold text-center text-gray-800 mb-6">Indications - Left Leg</h3>
<div class="flex gap-8">
<!-- Chart Image -->
<div class="flex-1">
<img src="left-leg-chart.png" alt="Left Leg SMO2 Chart" class="w-full h-auto">
</div>
<!-- Right Side Info -->
<div class="w-48 space-y-4">
<div class="bg-gray-100 p-3 rounded">
<div class="text-xs font-semibold text-gray-700 mb-1">Surplus</div>
<div class="text-xs text-gray-600">Supply > Demand at a heart rate and speed of:</div>
<div class="text-sm font-bold text-gray-800">n/a</div>
</div>
<div class="bg-gray-100 p-3 rounded">
<div class="text-xs font-semibold text-gray-700 mb-1">Supply Threshold</div>
<div class="text-xs text-gray-600">Demand outstrips supply at a heart rate of:</div>
<div class="text-sm font-bold text-gray-800">165 bpm @ 5.5mph</div>
</div>
<div class="bg-gray-100 p-3 rounded">
<div class="text-xs font-semibold text-gray-700 mb-1">Recovery</div>
<div class="text-xs text-gray-600">"Optimal >100%"</div>
<div class="text-sm font-bold text-gray-800">n/a</div>
</div>
</div>
<!-- Key Findings Summary -->
<div class="bg-gray-100 p-4 rounded-lg">
<h3 class="text-base font-bold text-gray-900 mb-3">Key Findings</h3>
<div class="text-sm text-gray-700 space-y-2">
<p>
<strong>Left leg</strong> showed better oxygen maintenance
during high-intensity work
</p>
<p>
<strong
>{{ recovery_assessment | default('Excellent recovery
capacity') }}</strong
>
- both legs recovered well
</p>
<p>
<strong>Heart rate progression:</strong> {{ hr_warmup |
default('93') }} → {{ hr_max | default('168') }} bpm
</p>
<p>
<strong>Test duration:</strong> {{ test_duration |
default('~21 minutes active test') }}
</p>
</div>
</div>
</div>
</div>
+515
View File
@@ -0,0 +1,515 @@
<div class="w-full page bg-white">
<!-- Main Content -->
<div class="px-8 py-6">
<!-- Page Title -->
<h1 class="text-3xl font-bold text-black mb-6">Fuelling Analysis</h1>
<!-- Flowchart Image -->
<div class="mb-8 flex justify-center">
<img
src="app/estimated_carb_storage.png"
alt="Fuelling Analysis Flowchart"
class="w-full max-w-4xl h-auto object-contain"
/>
</div>
<!-- Carbohydrate Storage Table -->
<div class="mb-8">
<h2 class="text-xl font-bold text-black mb-4 text-center">
Estimated Carbohydrate Storage by Weight and Sex in Athletes
</h2>
<div class="flex justify-center">
<table
class="table-auto border-collapse border border-gray-400 text-sm"
>
<thead>
<tr class="bg-gray-200">
<th
class="border border-gray-400 px-4 py-2 text-center"
>
Weight (kg)
</th>
<th
class="border border-gray-400 px-4 py-2 text-center"
>
Sex
</th>
<th
class="border border-gray-400 px-4 py-2 text-center"
>
Muscle Glycogen (g)
</th>
<th
class="border border-gray-400 px-4 py-2 text-center"
>
Liver Glycogen (g)
</th>
<th
class="border border-gray-400 px-4 py-2 text-center"
>
Blood Glucose (g)
</th>
<th
class="border border-gray-400 px-4 py-2 text-center"
>
Total Carb (g)
</th>
<th
class="border border-gray-400 px-4 py-2 text-center"
>
Total Carb (kcal)
</th>
</tr>
</thead>
<tbody>
<tr>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
50
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
male
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
292
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
105
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
4.5
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
402
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
1608
</td>
</tr>
<tr class="bg-gray-50">
<td
class="border border-gray-400 px-4 py-2 text-center"
>
50
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
female
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
228
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
85
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
4.5
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
317
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
1268
</td>
</tr>
<tr>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
60
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
male
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
351
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
105
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
4.5
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
460
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
1842
</td>
</tr>
<tr class="bg-gray-50">
<td
class="border border-gray-400 px-4 py-2 text-center"
>
60
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
female
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
273
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
85
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
4.5
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
362
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
1450
</td>
</tr>
<tr>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
70
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
male
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
410
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
105
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
4.5
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
519
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
2076
</td>
</tr>
<tr class="bg-gray-50">
<td
class="border border-gray-400 px-4 py-2 text-center"
>
70
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
female
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
318
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
85
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
4.5
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
408
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
1632
</td>
</tr>
<tr>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
80
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
male
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
468
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
105
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
4.5
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
578
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
2310
</td>
</tr>
<tr class="bg-gray-50">
<td
class="border border-gray-400 px-4 py-2 text-center"
>
80
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
female
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
364
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
85
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
4.5
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
454
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
1814
</td>
</tr>
<tr>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
90
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
male
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
526
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
105
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
4.5
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
636
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
2544
</td>
</tr>
<tr class="bg-gray-50">
<td
class="border border-gray-400 px-4 py-2 text-center"
>
90
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
female
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
409
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
85
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
4.5
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
499
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
1996
</td>
</tr>
<tr>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
100
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
male
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
585
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
105
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
4.5
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
694
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
2778
</td>
</tr>
<tr class="bg-gray-50">
<td
class="border border-gray-400 px-4 py-2 text-center"
>
100
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
female
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
455
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
85
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
4.5
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
544
</td>
<td
class="border border-gray-400 px-4 py-2 text-center"
>
2178
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
+26 -2
View File
@@ -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,
+234
View File
@@ -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
+1
View File
@@ -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 = {
+146
View File
@@ -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": {