added extra page
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
@@ -233,6 +233,7 @@ async def upload_files(
|
|||||||
str(pnoe_path),
|
str(pnoe_path),
|
||||||
str(spirometry_csv_path),
|
str(spirometry_csv_path),
|
||||||
None, # No SECA file needed anymore
|
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
|
# Set patient info manually since we're not reading from SECA
|
||||||
weight_kg = float(weight.replace("lbs", "").replace("kg", "").strip())
|
weight_kg = float(weight.replace("lbs", "").replace("kg", "").strip())
|
||||||
@@ -442,6 +443,7 @@ async def edit_metrics(request: Request):
|
|||||||
str(pnoe_path),
|
str(pnoe_path),
|
||||||
spirometry_csv_path,
|
spirometry_csv_path,
|
||||||
None, # No SECA file
|
None, # No SECA file
|
||||||
|
str(oxygenation_path) if oxygenation_path else None, # Oxygenation CSV
|
||||||
)
|
)
|
||||||
# Set patient info manually
|
# Set patient info manually
|
||||||
weight_str = patient_info.get("weight", "0")
|
weight_str = patient_info.get("weight", "0")
|
||||||
|
|||||||
+149
-59
@@ -1,76 +1,166 @@
|
|||||||
<div class="page bg-white p-8 max-w-4xl mx-auto">
|
<div class="page bg-white p-8 max-w-4xl mx-auto">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-900 mb-4">Local Muscle Activity</h1>
|
<h1 class="text-3xl font-bold text-gray-900 mb-4">
|
||||||
<h2 class="text-xl font-semibold text-gray-800 mb-2">Muscle Oxygenation Assessment</h2>
|
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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Leg Section -->
|
<!-- Combined Muscle Oxygenation Chart -->
|
||||||
<div class="mb-12">
|
<div class="mb-6">
|
||||||
<h3 class="text-lg font-semibold text-center text-gray-800 mb-6">Indications - Right Leg</h3>
|
<div class="flex justify-center mb-4">
|
||||||
|
<img
|
||||||
<div class="flex gap-8">
|
src="data:image/png;base64,{{ muscle_oxygenation_chart }}"
|
||||||
<!-- Chart Image -->
|
alt="Muscle Oxygenation Chart"
|
||||||
<div class="flex-1">
|
class="w-full h-auto max-w-6xl"
|
||||||
<img src="right-leg-chart.png" alt="Right Leg SMO2 Chart" class="w-full h-auto">
|
/>
|
||||||
|
</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>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Right Side Info -->
|
|
||||||
<div class="w-48 space-y-4">
|
<!-- Right Leg Metrics -->
|
||||||
<div class="bg-gray-100 p-3 rounded">
|
<div class="bg-purple-50 p-4 rounded-lg border-2 border-purple-200">
|
||||||
<div class="text-xs font-semibold text-gray-700 mb-1">Surplus</div>
|
<h3 class="text-lg font-bold text-gray-900 mb-4 text-center">
|
||||||
<div class="text-xs text-gray-600">Supply > Demand at a heart rate and speed of:</div>
|
Right Leg Analysis
|
||||||
<div class="text-sm font-bold text-gray-800">n/a</div>
|
</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>
|
||||||
|
|
||||||
<div class="bg-gray-100 p-3 rounded">
|
<div class="bg-white p-3 rounded shadow-sm">
|
||||||
<div class="text-xs font-semibold text-gray-700 mb-1">Supply Threshold</div>
|
<div class="text-xs font-semibold text-gray-700 mb-1">
|
||||||
<div class="text-xs text-gray-600">Demand outstrips supply at a heart rate of:</div>
|
Minimum SmO₂
|
||||||
<div class="text-sm font-bold text-gray-800">154bpm @ 5.0mph</div>
|
</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>
|
||||||
|
|
||||||
<div class="bg-gray-100 p-3 rounded">
|
<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 font-semibold text-gray-700 mb-1">
|
||||||
<div class="text-xs text-gray-600">"Optimal >100%"</div>
|
Oxygen Drop
|
||||||
<div class="text-sm font-bold text-gray-800">n/a</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Left Leg Section -->
|
<!-- Key Findings Summary -->
|
||||||
<div>
|
<div class="bg-gray-100 p-4 rounded-lg">
|
||||||
<h3 class="text-lg font-semibold text-center text-gray-800 mb-6">Indications - Left Leg</h3>
|
<h3 class="text-base font-bold text-gray-900 mb-3">Key Findings</h3>
|
||||||
|
<div class="text-sm text-gray-700 space-y-2">
|
||||||
<div class="flex gap-8">
|
<p>
|
||||||
<!-- Chart Image -->
|
• <strong>Left leg</strong> showed better oxygen maintenance
|
||||||
<div class="flex-1">
|
during high-intensity work
|
||||||
<img src="left-leg-chart.png" alt="Left Leg SMO2 Chart" class="w-full h-auto">
|
</p>
|
||||||
</div>
|
<p>
|
||||||
|
•
|
||||||
<!-- Right Side Info -->
|
<strong
|
||||||
<div class="w-48 space-y-4">
|
>{{ recovery_assessment | default('Excellent recovery
|
||||||
<div class="bg-gray-100 p-3 rounded">
|
capacity') }}</strong
|
||||||
<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>
|
- both legs recovered well
|
||||||
<div class="text-sm font-bold text-gray-800">n/a</div>
|
</p>
|
||||||
</div>
|
<p>
|
||||||
|
• <strong>Heart rate progression:</strong> {{ hr_warmup |
|
||||||
<div class="bg-gray-100 p-3 rounded">
|
default('93') }} → {{ hr_max | default('168') }} bpm
|
||||||
<div class="text-xs font-semibold text-gray-700 mb-1">Supply Threshold</div>
|
</p>
|
||||||
<div class="text-xs text-gray-600">Demand outstrips supply at a heart rate of:</div>
|
<p>
|
||||||
<div class="text-sm font-bold text-gray-800">165 bpm @ 5.5mph</div>
|
• <strong>Test duration:</strong> {{ test_duration |
|
||||||
</div>
|
default('~21 minutes active test') }}
|
||||||
|
</p>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -18,6 +18,7 @@ class ContextGenerator:
|
|||||||
self.pnoe_df = None
|
self.pnoe_df = None
|
||||||
self.spirometry_df = None
|
self.spirometry_df = None
|
||||||
self.seca_df = None
|
self.seca_df = None
|
||||||
|
self.oxygenation_df = None
|
||||||
self.patient_info = {}
|
self.patient_info = {}
|
||||||
|
|
||||||
def load_data(
|
def load_data(
|
||||||
@@ -25,6 +26,7 @@ class ContextGenerator:
|
|||||||
pnoe_path: str,
|
pnoe_path: str,
|
||||||
spirometry_path: str,
|
spirometry_path: str,
|
||||||
seca_path: Optional[str] = None,
|
seca_path: Optional[str] = None,
|
||||||
|
oxygenation_path: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""Load all required datasets"""
|
"""Load all required datasets"""
|
||||||
self.pnoe_df = pd.read_csv(pnoe_path, delimiter=";")
|
self.pnoe_df = pd.read_csv(pnoe_path, delimiter=";")
|
||||||
@@ -33,6 +35,11 @@ class ContextGenerator:
|
|||||||
self.seca_df = pd.read_excel(seca_path)
|
self.seca_df = pd.read_excel(seca_path)
|
||||||
else:
|
else:
|
||||||
self.seca_df = None
|
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()
|
self._preprocess_pnoe_data()
|
||||||
|
|
||||||
def _preprocess_pnoe_data(self):
|
def _preprocess_pnoe_data(self):
|
||||||
@@ -1375,8 +1382,25 @@ class ContextGenerator:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pages 12-17
|
# Page 12 - Muscle Oxygenation
|
||||||
for i in range(6):
|
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}"] = {
|
contexts[f"page_{i + 12}"] = {
|
||||||
"patient_name": self.patient_info["name"],
|
"patient_name": self.patient_info["name"],
|
||||||
"page_number": i + 12,
|
"page_number": i + 12,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Based on the analysis notebooks in services_dfdf/.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import io
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import matplotlib
|
import matplotlib
|
||||||
@@ -1680,3 +1681,236 @@ class GraphGenerator:
|
|||||||
)
|
)
|
||||||
plt.close(fig)
|
plt.close(fig)
|
||||||
return str(output_path)
|
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
|
||||||
|
|||||||
@@ -503,6 +503,7 @@ class ReportGeneratorService:
|
|||||||
pnoe_csv_path,
|
pnoe_csv_path,
|
||||||
str(spirometry_csv_path),
|
str(spirometry_csv_path),
|
||||||
None, # No SECA file
|
None, # No SECA file
|
||||||
|
oxygenation_csv_path, # Pass oxygenation CSV path
|
||||||
)
|
)
|
||||||
# Set patient info manually
|
# Set patient info manually
|
||||||
self.context_generator.patient_info = {
|
self.context_generator.patient_info = {
|
||||||
|
|||||||
@@ -3240,6 +3240,152 @@
|
|||||||
"\n",
|
"\n",
|
||||||
"The analysis follows the PDF instructions for Train.Red SmO₂ ramp testing, including 10-second smoothing, stage identification, and recovery percentage calculations."
|
"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": {
|
"metadata": {
|
||||||
|
|||||||
Reference in New Issue
Block a user