Compare commits

..

3 Commits

Author SHA1 Message Date
bolade 35ea522283 Checkpoint 3 2025-11-28 16:19:32 +01:00
bolade fc62b64624 Another Solid Checkpoint 2025-11-28 12:11:00 +01:00
bolade e66b9e6c29 perfectionist 2025-11-28 11:44:37 +01:00
20 changed files with 1110 additions and 486 deletions
Binary file not shown.
Binary file not shown.
+128 -56
View File
@@ -9,6 +9,7 @@ import os
import shutil import shutil
import tempfile import tempfile
import uuid import uuid
from datetime import datetime
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
@@ -109,7 +110,6 @@ async def upload_files(
gender: str = Form(...), gender: str = Form(...),
fat_percentage: float = Form(...), fat_percentage: float = Form(...),
focus: str = Form(default="Endurance"), focus: str = Form(default="Endurance"),
session_id: str = Form(default="default"),
next_testing_date: str = Form(...), next_testing_date: str = Form(...),
report_type: str = Form(default="full"), report_type: str = Form(default="full"),
spirometry_pdf: UploadFile = File(...), spirometry_pdf: UploadFile = File(...),
@@ -179,6 +179,10 @@ async def upload_files(
# Prepare patient information # Prepare patient information
patient_name = f"{first_name} {last_name}" patient_name = f"{first_name} {last_name}"
print(f"DEBUG: Received next_testing_date: '{next_testing_date}'") print(f"DEBUG: Received next_testing_date: '{next_testing_date}'")
# Generate session_id internally using timestamp for unique identification
session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
patient_info = { patient_info = {
"patient_name": patient_name, "patient_name": patient_name,
"first_name": first_name, "first_name": first_name,
@@ -290,8 +294,18 @@ async def upload_files(
@app.get("/preview", response_class=HTMLResponse) @app.get("/preview", response_class=HTMLResponse)
async def preview(request: Request): async def preview(request: Request):
"""Preview generated report""" """Preview generated report"""
# Check for required session data
if not request.session.get("report_path"): if not request.session.get("report_path"):
return RedirectResponse(url="/", status_code=303) return RedirectResponse(url="/", status_code=303)
# Ensure metrics exist in session, initialize if missing
if "metrics" not in request.session:
request.session["metrics"] = {"pnoe": {}, "spirometry": {}}
# Ensure patient_info exists
if "patient_info" not in request.session:
request.session["patient_info"] = {}
return render_template( return render_template(
"preview.html", {"request": request, "session": request.session} "preview.html", {"request": request, "session": request.session}
) )
@@ -309,8 +323,16 @@ async def serve_graph(filename: str):
@app.get("/edit", response_class=HTMLResponse) @app.get("/edit", response_class=HTMLResponse)
async def edit_form(request: Request): async def edit_form(request: Request):
"""Display edit metrics form""" """Display edit metrics form"""
if not request.session.get("metrics"): # Check for required session data
if not request.session.get("report_path") or not request.session.get(
"patient_info"
):
return RedirectResponse(url="/", status_code=303) return RedirectResponse(url="/", status_code=303)
# Ensure metrics exist in session, initialize if missing
if "metrics" not in request.session:
request.session["metrics"] = {"pnoe": {}, "spirometry": {}}
return render_template( return render_template(
"edit.html", {"request": request, "session": request.session} "edit.html", {"request": request, "session": request.session}
) )
@@ -325,69 +347,117 @@ async def edit_metrics(request: Request):
# Get form data # Get form data
form_data = await request.form() form_data = await request.form()
# Helper function to safely convert form values to float
def safe_float(value):
"""Convert form value to float, return None if empty or invalid"""
if not value or value.strip() == "":
return None
try:
return float(value)
except (ValueError, TypeError):
return None
# Build metric overrides # Build metric overrides
metric_overrides = {"pnoe": {}, "spirometry": {}} metric_overrides = {"pnoe": {}, "spirometry": {}}
# Pnoe overrides # Pnoe overrides - only add if value is provided and valid
if form_data.get("vo2_max"): vo2_max_val = safe_float(form_data.get("vo2_max"))
metric_overrides["pnoe"]["vo2_max"] = float(form_data["vo2_max"]) if vo2_max_val is not None:
if form_data.get("vo2_max_per_kg"): metric_overrides["pnoe"]["vo2_max"] = vo2_max_val
metric_overrides["pnoe"]["vo2_max_per_kg"] = float(form_data["vo2_max_per_kg"])
if form_data.get("peak_vt"):
metric_overrides["pnoe"]["peak_vt"] = float(form_data["peak_vt"])
if form_data.get("peak_vt_hr"):
metric_overrides["pnoe"]["peak_vt_hr"] = float(form_data["peak_vt_hr"])
if form_data.get("fat_max_value"):
metric_overrides["pnoe"]["fat_max_value"] = float(form_data["fat_max_value"])
if form_data.get("fat_max_hr"):
metric_overrides["pnoe"]["fat_max_hr"] = float(form_data["fat_max_hr"])
# VT1 and VT2 overrides vo2_max_per_kg_val = safe_float(form_data.get("vo2_max_per_kg"))
if ( if vo2_max_per_kg_val is not None:
form_data.get("vt1_hr") metric_overrides["pnoe"]["vo2_max_per_kg"] = vo2_max_per_kg_val
or form_data.get("vt1_speed")
or form_data.get("vt1_time") peak_vt_val = safe_float(form_data.get("peak_vt"))
): if peak_vt_val is not None:
metric_overrides["pnoe"]["vt1"] = { metric_overrides["pnoe"]["peak_vt"] = peak_vt_val
"HeartRate": float(form_data.get("vt1_hr", 0)),
"Speed": float(form_data.get("vt1_speed", 0)), peak_vt_hr_val = safe_float(form_data.get("peak_vt_hr"))
"Time": float(form_data.get("vt1_time", 0)), if peak_vt_hr_val is not None:
metric_overrides["pnoe"]["peak_vt_hr"] = peak_vt_hr_val
fat_max_value_val = safe_float(form_data.get("fat_max_value"))
if fat_max_value_val is not None:
metric_overrides["pnoe"]["fat_max_value"] = fat_max_value_val
fat_max_hr_val = safe_float(form_data.get("fat_max_hr"))
if fat_max_hr_val is not None:
metric_overrides["pnoe"]["fat_max_hr"] = fat_max_hr_val
# VT1 and VT2 overrides - use existing values if not provided
existing_metrics = request.session.get("metrics", {})
existing_pnoe = existing_metrics.get("pnoe", {})
existing_vt1 = existing_pnoe.get("vt1", {})
existing_vt2 = existing_pnoe.get("vt2", {})
vt1_hr_val = safe_float(form_data.get("vt1_hr"))
vt1_speed_val = safe_float(form_data.get("vt1_speed"))
vt1_time_val = safe_float(form_data.get("vt1_time"))
if vt1_hr_val is not None or vt1_speed_val is not None or vt1_time_val is not None:
vt1_dict = {
"HeartRate": vt1_hr_val
if vt1_hr_val is not None
else existing_vt1.get("HeartRate", 0),
"Speed": vt1_speed_val
if vt1_speed_val is not None
else existing_vt1.get("Speed", 0),
"Time": vt1_time_val
if vt1_time_val is not None
else existing_vt1.get("Time", 0),
} }
metric_overrides["pnoe"]["vt1"] = vt1_dict
if ( vt2_hr_val = safe_float(form_data.get("vt2_hr"))
form_data.get("vt2_hr") vt2_speed_val = safe_float(form_data.get("vt2_speed"))
or form_data.get("vt2_speed") vt2_time_val = safe_float(form_data.get("vt2_time"))
or form_data.get("vt2_time")
): if vt2_hr_val is not None or vt2_speed_val is not None or vt2_time_val is not None:
metric_overrides["pnoe"]["vt2"] = { vt2_dict = {
"HeartRate": float(form_data.get("vt2_hr", 0)), "HeartRate": vt2_hr_val
"Speed": float(form_data.get("vt2_speed", 0)), if vt2_hr_val is not None
"Time": float(form_data.get("vt2_time", 0)), else existing_vt2.get("HeartRate", 0),
"Speed": vt2_speed_val
if vt2_speed_val is not None
else existing_vt2.get("Speed", 0),
"Time": vt2_time_val
if vt2_time_val is not None
else existing_vt2.get("Time", 0),
} }
metric_overrides["pnoe"]["vt2"] = vt2_dict
# Heart rate zones # Heart rate zones - only add if value is provided
for i in range(1, 6): for i in range(1, 6):
zone_key = f"zone{i}_bpm" zone_key = f"zone{i}_bpm"
if form_data.get(zone_key): zone_val = form_data.get(zone_key)
metric_overrides["pnoe"][zone_key] = form_data[zone_key] if zone_val and zone_val.strip():
metric_overrides["pnoe"][zone_key] = zone_val.strip()
# Spirometry overrides # Spirometry overrides - only add if value is provided and valid
if form_data.get("fvc_best"): fvc_best_val = safe_float(form_data.get("fvc_best"))
metric_overrides["spirometry"]["fvc_best"] = float(form_data["fvc_best"]) if fvc_best_val is not None:
if form_data.get("fvc_pred"): metric_overrides["spirometry"]["fvc_best"] = fvc_best_val
metric_overrides["spirometry"]["fvc_pred"] = float(form_data["fvc_pred"])
if form_data.get("fev1_best"): fvc_pred_val = safe_float(form_data.get("fvc_pred"))
metric_overrides["spirometry"]["fev1_best"] = float(form_data["fev1_best"]) if fvc_pred_val is not None:
if form_data.get("fev1_pred"): metric_overrides["spirometry"]["fvc_pred"] = fvc_pred_val
metric_overrides["spirometry"]["fev1_pred"] = float(form_data["fev1_pred"])
if form_data.get("fev1_fvc_pct_best"): fev1_best_val = safe_float(form_data.get("fev1_best"))
metric_overrides["spirometry"]["fev1_fvc_pct_best"] = float( if fev1_best_val is not None:
form_data["fev1_fvc_pct_best"] metric_overrides["spirometry"]["fev1_best"] = fev1_best_val
)
if form_data.get("fev1_fvc_pct_pred"): fev1_pred_val = safe_float(form_data.get("fev1_pred"))
metric_overrides["spirometry"]["fev1_fvc_pct_pred"] = float( if fev1_pred_val is not None:
form_data["fev1_fvc_pct_pred"] metric_overrides["spirometry"]["fev1_pred"] = fev1_pred_val
)
fev1_fvc_pct_best_val = safe_float(form_data.get("fev1_fvc_pct_best"))
if fev1_fvc_pct_best_val is not None:
metric_overrides["spirometry"]["fev1_fvc_pct_best"] = fev1_fvc_pct_best_val
fev1_fvc_pct_pred_val = safe_float(form_data.get("fev1_fvc_pct_pred"))
if fev1_fvc_pct_pred_val is not None:
metric_overrides["spirometry"]["fev1_fvc_pct_pred"] = fev1_fvc_pct_pred_val
try: try:
# Get file paths from session # Get file paths from session
@@ -468,6 +538,7 @@ async def edit_metrics(request: Request):
"fat_percentage": patient_info.get("fat_percentage", 0), "fat_percentage": patient_info.get("fat_percentage", 0),
"gender": patient_info.get("gender", "female"), "gender": patient_info.get("gender", "female"),
} }
# Calculate fat_mass and lean_mass (extract_patient_info does this when no SECA file)
context_gen.extract_patient_info(patient_info.get("last_name", "")) context_gen.extract_patient_info(patient_info.get("last_name", ""))
spirometry_overrides = metric_overrides.get("spirometry", {}) spirometry_overrides = metric_overrides.get("spirometry", {})
@@ -514,7 +585,6 @@ async def generate_report(
height: str = Form(..., description="Patient height (e.g., 5'4\")"), height: str = Form(..., description="Patient height (e.g., 5'4\")"),
weight: str = Form(..., description="Patient weight (e.g., 123lbs)"), weight: str = Form(..., description="Patient weight (e.g., 123lbs)"),
focus: str = Form(default="Endurance", description="Training focus"), focus: str = Form(default="Endurance", description="Training focus"),
session_id: str = Form(default="default", description="Session ID"),
spirometry_pdf: UploadFile = File(..., description="Spirometry PDF file"), spirometry_pdf: UploadFile = File(..., description="Spirometry PDF file"),
pnoe_csv: UploadFile = File(..., description="Pnoe CSV file"), pnoe_csv: UploadFile = File(..., description="Pnoe CSV file"),
seca_excel: UploadFile = File(..., description="SECA Excel file"), seca_excel: UploadFile = File(..., description="SECA Excel file"),
@@ -534,7 +604,6 @@ async def generate_report(
height: Patient height height: Patient height
weight: Patient weight weight: Patient weight
focus: Training focus (default: Endurance) focus: Training focus (default: Endurance)
session_id: Session identifier (default: default)
Returns: Returns:
ReportResponse with report path, graphs generated, and analysis data ReportResponse with report path, graphs generated, and analysis data
@@ -571,6 +640,9 @@ async def generate_report(
with open(seca_path, "wb") as f: with open(seca_path, "wb") as f:
shutil.copyfileobj(seca_excel.file, f) shutil.copyfileobj(seca_excel.file, f)
# Generate session_id internally using timestamp for unique identification
session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
# Prepare patient information # Prepare patient information
patient_info = { patient_info = {
"patient_name": patient_name, "patient_name": patient_name,
+1
View File
@@ -471,3 +471,4 @@
+14 -7
View File
@@ -15,7 +15,8 @@
<!-- Lung Analysis --> <!-- Lung Analysis -->
<div class="flex items-start bg-gray-200 h-24"> <div class="flex items-start bg-gray-200 h-24">
<div <div
class="bg-black text-white text-2xl font-bold w-16 h-full flex items-center justify-center mr-8 flex-shrink-0" class="bg-black text-white text-4xl font-extrabold w-24 h-24 flex items-center justify-center mr-8 flex-shrink-0"
style="border-radius: 0;"
> >
3 3
</div> </div>
@@ -35,7 +36,8 @@
<!-- Cardio Metrics --> <!-- Cardio Metrics -->
<div class="flex items-start bg-gray-200 h-24"> <div class="flex items-start bg-gray-200 h-24">
<div <div
class="bg-black text-white text-2xl font-bold w-16 h-full flex items-center justify-center mr-8 flex-shrink-0" class="bg-black text-white text-4xl font-extrabold w-24 h-24 flex items-center justify-center mr-8 flex-shrink-0"
style="border-radius: 0;"
> >
4 4
</div> </div>
@@ -52,7 +54,8 @@
<!-- Fuel Utilization --> <!-- Fuel Utilization -->
<div class="flex items-start bg-gray-200 h-24"> <div class="flex items-start bg-gray-200 h-24">
<div <div
class="bg-black text-white text-2xl font-bold w-16 h-full flex items-center justify-center mr-8 flex-shrink-0" class="bg-black text-white text-4xl font-extrabold w-24 h-24 flex items-center justify-center mr-8 flex-shrink-0"
style="border-radius: 0;"
> >
5 5
</div> </div>
@@ -66,7 +69,8 @@
<!-- Local Muscle Activity --> <!-- Local Muscle Activity -->
<div class="flex items-start bg-gray-200 h-24"> <div class="flex items-start bg-gray-200 h-24">
<div <div
class="bg-black text-white text-2xl font-bold w-16 h-full flex items-center justify-center mr-8 flex-shrink-0" class="bg-black text-white text-4xl font-extrabold w-24 h-24 flex items-center justify-center mr-8 flex-shrink-0"
style="border-radius: 0;"
> >
9 9
</div> </div>
@@ -80,7 +84,8 @@
<!-- Training Recommendations --> <!-- Training Recommendations -->
<div class="flex items-start bg-gray-200 h-24"> <div class="flex items-start bg-gray-200 h-24">
<div <div
class="bg-black text-white text-2xl font-bold w-16 h-full flex items-center justify-center mr-8 flex-shrink-0" class="bg-black text-white text-4xl font-extrabold w-24 h-24 flex items-center justify-center mr-8 flex-shrink-0"
style="border-radius: 0;"
> >
10 10
</div> </div>
@@ -94,7 +99,8 @@
<!-- Next Steps --> <!-- Next Steps -->
<div class="flex items-start bg-gray-200 h-24"> <div class="flex items-start bg-gray-200 h-24">
<div <div
class="bg-black text-white text-2xl font-bold w-16 h-full flex items-center justify-center mr-8 flex-shrink-0" class="bg-black text-white text-4xl font-extrabold w-24 h-24 flex items-center justify-center mr-8 flex-shrink-0"
style="border-radius: 0;"
> >
12 12
</div> </div>
@@ -111,7 +117,8 @@
<!-- Glossary --> <!-- Glossary -->
<div class="flex items-start bg-gray-200 h-24"> <div class="flex items-start bg-gray-200 h-24">
<div <div
class="bg-black text-white text-2xl font-bold w-16 h-full flex items-center justify-center mr-8 flex-shrink-0" class="bg-black text-white text-4xl font-extrabold w-24 h-24 flex items-center justify-center mr-8 flex-shrink-0"
style="border-radius: 0;"
> >
13 13
</div> </div>
+8 -8
View File
@@ -15,7 +15,8 @@
<!-- Nutrition Guidelines --> <!-- Nutrition Guidelines -->
<div class="flex items-start bg-gray-200 h-24"> <div class="flex items-start bg-gray-200 h-24">
<div <div
class="bg-black text-white text-2xl font-bold w-16 h-full flex items-center justify-center mr-8 flex-shrink-0" class="bg-black text-white text-4xl font-extrabold w-24 h-24 flex items-center justify-center mr-8 flex-shrink-0"
style="border-radius: 0;"
> >
3 3
</div> </div>
@@ -35,7 +36,8 @@
<!-- Nutrition Recommendations --> <!-- Nutrition Recommendations -->
<div class="flex items-start bg-gray-200 h-24"> <div class="flex items-start bg-gray-200 h-24">
<div <div
class="bg-black text-white text-2xl font-bold w-16 h-full flex items-center justify-center mr-8 flex-shrink-0" class="bg-black text-white text-4xl font-extrabold w-24 h-24 flex items-center justify-center mr-8 flex-shrink-0"
style="border-radius: 0;"
> >
4 4
</div> </div>
@@ -49,7 +51,8 @@
<!-- Next Steps --> <!-- Next Steps -->
<div class="flex items-start bg-gray-200 h-24"> <div class="flex items-start bg-gray-200 h-24">
<div <div
class="bg-black text-white text-2xl font-bold w-16 h-full flex items-center justify-center mr-8 flex-shrink-0" class="bg-black text-white text-4xl font-extrabold w-24 h-24 flex items-center justify-center mr-8 flex-shrink-0"
style="border-radius: 0;"
> >
5 5
</div> </div>
@@ -66,7 +69,8 @@
<!-- Glossary --> <!-- Glossary -->
<div class="flex items-start bg-gray-200 h-24"> <div class="flex items-start bg-gray-200 h-24">
<div <div
class="bg-black text-white text-2xl font-bold w-16 h-full flex items-center justify-center mr-8 flex-shrink-0" class="bg-black text-white text-4xl font-extrabold w-24 h-24 flex items-center justify-center mr-8 flex-shrink-0"
style="border-radius: 0;"
> >
6 6
</div> </div>
@@ -82,7 +86,3 @@
</div> </div>
</div> </div>
</div> </div>
+3 -3
View File
@@ -34,7 +34,7 @@
<!-- Macro Body (fills to the bottom, cyan or white) --> <!-- Macro Body (fills to the bottom, cyan or white) -->
<div class="flex flex-col items-center py-1 px-2"> <div class="flex flex-col items-center py-1 px-2">
<div class="font-bold text-sm text-black mb-1"> <div class="font-bold text-sm text-black mb-1">
{{ deficit_calories | default('1725KCals') }} {{ deficit_calories | default('1725KCals') }} KCals
</div> </div>
<div class="text-xs text-black leading-tight text-left"> <div class="text-xs text-black leading-tight text-left">
<div>{{ deficit_protein | default('120g Protein') }}</div> <div>{{ deficit_protein | default('120g Protein') }}</div>
@@ -76,7 +76,7 @@
{% if i < 5 %} {% if i < 5 %}
<div class="flex flex-col items-center py-1 px-2"> <div class="flex flex-col items-center py-1 px-2">
<div class="font-bold text-sm text-black mb-1"> <div class="font-bold text-sm text-black mb-1">
{{ refeed_weekday_calories | default('1615KCals') }} {{ refeed_weekday_calories | default('1615KCals') }} KCals
</div> </div>
<div class="text-xs text-black leading-tight text-left"> <div class="text-xs text-black leading-tight text-left">
<div>{{ refeed_weekday_protein | default('120g Protein') }}</div> <div>{{ refeed_weekday_protein | default('120g Protein') }}</div>
@@ -88,7 +88,7 @@
{% else %} {% else %}
<div class="flex flex-col items-center py-1 px-2"> <div class="flex flex-col items-center py-1 px-2">
<div class="font-bold text-black mb-1"> <div class="font-bold text-black mb-1">
{{ refeed_weekend_calories | default('2000KCals') }} {{ refeed_weekend_calories | default('2000KCals') }} KCals
</div> </div>
<div class="text-xs text-black leading-tight text-left"> <div class="text-xs text-black leading-tight text-left">
<div>{{ refeed_weekend_protein | default('120g Protein') }}</div> <div>{{ refeed_weekend_protein | default('120g Protein') }}</div>
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Indications Box --> <!-- Indications Box -->
<div class="bg-gray-200 rounded-lg p-4 text-center mb-2"> <div class="bg-gray-200 rounded-lg p-4 text-center mb-2">
<h3 class="font-semibold text-lg mb-2">Indications</h3> <h3 class="font-semibold text-lg mb-2">Indications</h3>
<p class="text-gray-700">{{ indication }}</p> <p >{{ indication | default('No Respiratory Capacity Limitation')}}</p>
</div> </div>
</div> </div>
+64 -32
View File
@@ -232,10 +232,15 @@ class ContextGenerator:
if zone_key in metric_overrides: if zone_key in metric_overrides:
metrics[zone_key] = metric_overrides[zone_key] metrics[zone_key] = metric_overrides[zone_key]
else: else:
fat_max_idx = self.pnoe_df["FAT_smoothed"].idxmax() # Use optimal fat burning zone (highest fat:carb ratio) - same as _calculate_zone_metrics
fat_max_row = self.pnoe_df.loc[fat_max_idx] # This ensures consistency between zone calculations and zone metrics
self.pnoe_df["fat_carb_ratio"] = self.pnoe_df["FAT_smoothed"] / (
self.pnoe_df["CHO_smoothed"] + 0.00000001
)
optimal_fat_idx = self.pnoe_df["fat_carb_ratio"].idxmax()
optimal_row = self.pnoe_df.loc[optimal_fat_idx]
zones = self._calculate_hr_zones( zones = self._calculate_hr_zones(
metrics["vt1"], metrics["vt2"], fat_max_row metrics["vt1"], metrics["vt2"], optimal_row
) )
metrics.update(zones) metrics.update(zones)
@@ -280,29 +285,46 @@ class ContextGenerator:
return vt1, vt2 return vt1, vt2
def _calculate_hr_zones( def _calculate_hr_zones(
self, vt1: Optional[Dict], vt2: Optional[Dict], fat_max_row: pd.Series self, vt1: Optional[Dict], vt2: Optional[Dict], optimal_row: pd.Series
) -> Dict: ) -> Dict:
"""Calculate heart rate zones based on thresholds""" """Calculate heart rate zones based on thresholds
Uses optimal fat burning zone (highest fat:carb ratio) to match _calculate_zone_metrics.
This ensures consistency between zone string calculations and zone metrics table.
"""
import math
zones = {} zones = {}
if vt1 and vt2: if vt1 and vt2:
zone_1_start = fat_max_row["HR(bpm)_smoothed"] - 15 # Use same zone boundary calculation as _calculate_zone_metrics
zone_2_start = fat_max_row["HR(bpm)_smoothed"] zone_1_start = math.floor(optimal_row["HR(bpm)_smoothed"] - 15)
zone_3_start = vt1["HeartRate"] zone_2_start = math.floor(optimal_row["HR(bpm)_smoothed"])
zone_4_start = vt2["HeartRate"] - 10 zone_3_start = math.floor(vt1["HeartRate"])
zone_5_start = vt2["HeartRate"] + 10 zone_4_start = math.floor(vt2["HeartRate"] - 10)
zone_5_start = math.floor(vt2["HeartRate"])
# zone_5_end is calculated for consistency with _calculate_zone_metrics
# (not used in string format since zone 5 is open-ended: "+bpm")
zone_5_end = math.floor(vt2["HeartRate"] + 10) # noqa: F841
zones["zone1_bpm"] = f"{int(zone_1_start)}-{int(zone_2_start)}bpm" # Calculate zone ends to match _calculate_zone_metrics exactly
zones["zone2_bpm"] = f"{int(zone_2_start)}-{int(vt1['HeartRate'])}bpm" zone_1_end = zone_2_start
zones["zone3_bpm"] = f"{int(zone_3_start)}-{int(zone_4_start)}bpm" zone_2_end = math.floor(vt1["HeartRate"])
zones["zone4_bpm"] = f"{int(zone_4_start)}-{int(zone_5_start)}bpm" zone_3_end = zone_4_start
zones["zone5_bpm"] = f"{int(zone_5_start)}+bpm" zone_4_end = zone_5_start
# Format zones to match _calculate_zone_metrics output
zones["zone1_bpm"] = f"{int(zone_1_start)}-{int(zone_1_end)}bpm"
zones["zone2_bpm"] = f"{int(zone_2_start)}-{int(zone_2_end)}bpm"
zones["zone3_bpm"] = f"{int(zone_3_start)}-{int(zone_3_end)}bpm"
zones["zone4_bpm"] = f"{int(zone_4_start)}-{int(zone_4_end)}bpm"
zones["zone5_bpm"] = f"{int(zone_5_start)}-{int(zone_5_end)}bpm"
else: else:
max_hr = 220 - self.patient_info["age"] max_hr = 220 - self.patient_info["age"]
zones["zone1_bpm"] = f"{int(max_hr * 0.55)}-{int(max_hr * 0.65)}bpm" zones["zone1_bpm"] = f"{int(max_hr * 0.55)}-{int(max_hr * 0.65)}bpm"
zones["zone2_bpm"] = f"{int(max_hr * 0.65)}-{int(max_hr * 0.75)}bpm" zones["zone2_bpm"] = f"{int(max_hr * 0.65)}-{int(max_hr * 0.75)}bpm"
zones["zone3_bpm"] = f"{int(max_hr * 0.75)}-{int(max_hr * 0.85)}bpm" zones["zone3_bpm"] = f"{int(max_hr * 0.75)}-{int(max_hr * 0.85)}bpm"
zones["zone4_bpm"] = f"{int(max_hr * 0.85)}-{int(max_hr * 0.95)}bpm" zones["zone4_bpm"] = f"{int(max_hr * 0.85)}-{int(max_hr * 0.95)}bpm"
zones["zone5_bpm"] = f"{int(max_hr * 0.95)}+bpm" zones["zone5_bpm"] = f"{int(max_hr * 0.95)}-{int(max_hr * 1.05)}bpm"
return zones return zones
def _calculate_vo2_drop_points(self, pnoe_metrics: Dict) -> Dict: def _calculate_vo2_drop_points(self, pnoe_metrics: Dict) -> Dict:
@@ -1180,7 +1202,9 @@ class ContextGenerator:
"page_number": 4, "page_number": 4,
"fat_percentage": f"{self.patient_info['fat_percentage']:.1f}", "fat_percentage": f"{self.patient_info['fat_percentage']:.1f}",
"body_composition_chart": graphs.get("body_composition", ""), "body_composition_chart": graphs.get("body_composition", ""),
"body_fat_chart": graphs.get("body_fat_percent", ""), # Alias for template "body_fat_chart": graphs.get(
"body_fat_percent", ""
), # Alias for template
"body_fat_percent_chart": graphs.get( "body_fat_percent_chart": graphs.get(
"body_fat_percent", "" "body_fat_percent", ""
), # Keep for consistency ), # Keep for consistency
@@ -1199,29 +1223,29 @@ class ContextGenerator:
"weight_loss_rate": rmr_metrics.get("weight_loss_rate", 1.0), "weight_loss_rate": rmr_metrics.get("weight_loss_rate", 1.0),
"total_calories": rmr_metrics.get("total_calories", 1375), "total_calories": rmr_metrics.get("total_calories", 1375),
} }
# For minimal reports, also generate resting heart rate table for page_5 # For minimal reports, also generate resting heart rate table for page_5
if report_type == "minimal" and graph_generator: if report_type == "minimal" and graph_generator:
resting_hr_metrics = self._calculate_resting_heart_rate_metrics() resting_hr_metrics = self._calculate_resting_heart_rate_metrics()
rhr_table_info = self._calculate_rhr_table_data( rhr_table_info = self._calculate_rhr_table_data(
self.patient_info["age"], self.patient_info["gender"] self.patient_info["age"], self.patient_info["gender"]
) )
# Get resting heart rate value and determine category # Get resting heart rate value and determine category
rhr_value_str = resting_hr_metrics.get("resting_heart_rate", "0bpm") rhr_value_str = resting_hr_metrics.get("resting_heart_rate", "0bpm")
rhr_value = float(rhr_value_str.replace("bpm", "").strip()) rhr_value = float(rhr_value_str.replace("bpm", "").strip())
category = self._determine_rhr_category( category = self._determine_rhr_category(
rhr_value, rhr_value,
self.patient_info["age"], self.patient_info["age"],
self.patient_info["gender"], self.patient_info["gender"],
) )
gender_label = ( gender_label = (
"F" if self.patient_info["gender"].lower().startswith("f") else "M" "F" if self.patient_info["gender"].lower().startswith("f") else "M"
) )
age_range_label = f"{rhr_table_info['age_range']} ({gender_label})" age_range_label = f"{rhr_table_info['age_range']} ({gender_label})"
rhr_columns = [ rhr_columns = [
"Age", "Age",
"Poor", "Poor",
@@ -1244,7 +1268,7 @@ class ContextGenerator:
rhr_table_info["ranges"]["Athlete"], rhr_table_info["ranges"]["Athlete"],
] ]
] ]
contexts["page_5"]["rhr_table"] = ( contexts["page_5"]["rhr_table"] = (
graph_generator.generate_resting_heart_rate_table( graph_generator.generate_resting_heart_rate_table(
data=rhr_data, data=rhr_data,
@@ -1265,12 +1289,16 @@ class ContextGenerator:
"deficit_carbs": f"{int(rmr_metrics.get('total_calories', 1600) * 0.39 / 4)}g Carbs", "deficit_carbs": f"{int(rmr_metrics.get('total_calories', 1600) * 0.39 / 4)}g Carbs",
"deficit_fat": f"{int(rmr_metrics.get('total_calories', 1600) * 0.39 / 9)}g Fat", "deficit_fat": f"{int(rmr_metrics.get('total_calories', 1600) * 0.39 / 9)}g Fat",
"deficit_fiber": "24g Fibre", "deficit_fiber": "24g Fibre",
"refeed_weekday_calories": int(rmr_metrics.get("total_calories", 1600) * 0.85), "refeed_weekday_calories": int(
rmr_metrics.get("total_calories", 1600) * 0.85
),
"refeed_weekday_protein": f"{int(rmr_metrics.get('total_calories', 1600) * 0.85 * 0.22 / 4)}g Protein", "refeed_weekday_protein": f"{int(rmr_metrics.get('total_calories', 1600) * 0.85 * 0.22 / 4)}g Protein",
"refeed_weekday_carbs": f"{int(rmr_metrics.get('total_calories', 1600) * 0.85 * 0.39 / 4)}g Carbs", "refeed_weekday_carbs": f"{int(rmr_metrics.get('total_calories', 1600) * 0.85 * 0.39 / 4)}g Carbs",
"refeed_weekday_fat": f"{int(rmr_metrics.get('total_calories', 1600) * 0.85 * 0.39 / 9)}g Fat", "refeed_weekday_fat": f"{int(rmr_metrics.get('total_calories', 1600) * 0.85 * 0.39 / 9)}g Fat",
"refeed_weekday_fiber": "20g Fibre", "refeed_weekday_fiber": "20g Fibre",
"refeed_weekend_calories": int(rmr_metrics.get("total_calories", 1600) * 1.375), "refeed_weekend_calories": int(
rmr_metrics.get("total_calories", 1600) * 1.375
),
"refeed_weekend_protein": f"{int(rmr_metrics.get('total_calories', 1600) * 1.375 * 0.22 / 4)}g Protein", "refeed_weekend_protein": f"{int(rmr_metrics.get('total_calories', 1600) * 1.375 * 0.22 / 4)}g Protein",
"refeed_weekend_carbs": f"{int(rmr_metrics.get('total_calories', 1600) * 1.375 * 0.39 / 4)}g Carbs", "refeed_weekend_carbs": f"{int(rmr_metrics.get('total_calories', 1600) * 1.375 * 0.39 / 4)}g Carbs",
"refeed_weekend_fat": f"{int(rmr_metrics.get('total_calories', 1600) * 1.375 * 0.39 / 9)}g Fat", "refeed_weekend_fat": f"{int(rmr_metrics.get('total_calories', 1600) * 1.375 * 0.39 / 9)}g Fat",
@@ -1291,12 +1319,12 @@ class ContextGenerator:
# Page 7 # Page 7
contexts["page_7"] = { contexts["page_7"] = {
"peak_vt": f"{pnoe_metrics['peak_vt']:.2f}", "peak_vt": f"{pnoe_metrics['peak_vt']:.2f}",
"peak_vt_bpm": f"{int(pnoe_metrics['peak_vt_hr'])}", "peak_vt_bpm": f"{int(pnoe_metrics['peak_vt_hr'])}",
"fev1_percentage": f"{fev1_percentage:.1f}", "fev1_percentage": f"{fev1_percentage:.1f}",
"lung_analysis_chart": graphs.get("spirometry_chart", ""), "lung_analysis_chart": graphs.get("spirometry_chart", ""),
"respiratory_analysis_chart": graphs.get("respiratory", ""), "respiratory_analysis_chart": graphs.get("respiratory", ""),
} }
# Page 8 # Page 8
contexts["page_8"] = { contexts["page_8"] = {
@@ -1562,7 +1590,11 @@ class ContextGenerator:
} }
# For minimal reports, create combined context for page_19_20_minimal # For minimal reports, create combined context for page_19_20_minimal
if report_type == "minimal" and 19 in pages_to_generate and 20 in pages_to_generate: if (
report_type == "minimal"
and 19 in pages_to_generate
and 20 in pages_to_generate
):
contexts["page_19_20_minimal"] = { contexts["page_19_20_minimal"] = {
"patient_name": self.patient_info["name"], "patient_name": self.patient_info["name"],
"body_fat_percentage_chart": graphs.get( "body_fat_percentage_chart": graphs.get(
+223 -90
View File
@@ -1124,80 +1124,163 @@ class GraphGenerator:
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path) return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
def generate_metabolism_chart( def generate_metabolism_chart(
self, rmr_kcal: float, save_as_base64: bool = True self,
rmr_kcal: float,
weight_kg: float = None,
height_cm: float = None,
age_years: int = None,
sex: str = None,
save_as_base64: bool = True,
) -> str: ) -> str:
""" """
Generate metabolism chart (Slow vs Fast Metabolism). Generate metabolism chart (Slow vs Fast Metabolism).
Matches the notebook implementation with ratio-based scale (0.3 to 1.9).
Args: Args:
rmr_kcal: Resting metabolic rate in kcal/day rmr_kcal: Resting metabolic rate in kcal/day (measured RMR)
weight_kg: Weight in kg (optional, for calculating ratio)
height_cm: Height in cm (optional, for calculating ratio)
age_years: Age in years (optional, for calculating ratio)
sex: Sex ("male" or "female", optional, for calculating ratio)
save_as_base64: If True, return base64 string, else return file path save_as_base64: If True, return base64 string, else return file path
Returns: Returns:
Base64 string or file path Base64 string or file path
""" """
from matplotlib.patches import FancyBboxPatch from matplotlib.patches import Rectangle
fig, ax = plt.subplots(figsize=(10, 2.5)) fig, ax = plt.subplots(figsize=(11.5, 2.5))
# Chart data and positions # Calculate ratio if we have all required parameters
# Use normalized positions (0-100 scale) for uniform bar length ratio = None
categories = ["Very Slow", "Slow", "Average", "Fast", "Very Fast"] if all([weight_kg, height_cm, age_years, sex]):
positions = [10, 30, 50, 70, 90] # Normalized positions on 0-100 scale # Mifflin-St Jeor equation
if sex.lower() == "male":
mifflin_rmr = 10 * weight_kg + 6.25 * height_cm - 5 * age_years + 5
elif sex.lower() == "female":
mifflin_rmr = 10 * weight_kg + 6.25 * height_cm - 5 * age_years - 161
else:
mifflin_rmr = None
# Normalize the kcal value to 0-100 scale (assuming range 0-9000 kcal) if mifflin_rmr and mifflin_rmr > 0:
max_kcal = 9000 ratio = rmr_kcal / mifflin_rmr
normalized_value = (rmr_kcal / max_kcal) * 100
indicator_pos = normalized_value
highlight_end = normalized_value
# Main Bar (Background) - using 0-100 scale # Bar setup - using ratio scale from 0.3 to 1.9 (as in notebook)
main_bar = FancyBboxPatch( scale_edges = [0.3, 0.7, 0.9, 1.1, 1.3, 1.5, 1.9]
(0, 0.4), scale_labels = ["Very Slow", "Slow", "Average", "Fast", "Very Fast"]
100, tick_edges = scale_edges[1:-1] # Remove first and last tick (omit 0.3 and 1.9)
0.2,
boxstyle="round,pad=0,rounding_size=0.1", x_start = scale_edges[0]
ec="none", x_end = scale_edges[-1]
fc="#E0E0E0", # Make the bar THICKER by increasing bar_height and adjusting y_bar
bar_height = 0.36
y_bar = 0.48
color_before = "#B2FFC8"
color_after = "#ECEDF2"
gray_color = "#606060"
# If we have a ratio, use it; otherwise map rmr_kcal to the scale
if ratio is not None:
highlight_end = min(max(ratio, x_start), x_end)
else:
# Fallback: map rmr_kcal to scale (assuming typical range 1000-3000 kcal/day)
# Map to 0.3-1.9 scale
min_rmr = 1000
max_rmr = 3000
normalized = (rmr_kcal - min_rmr) / (max_rmr - min_rmr)
highlight_end = x_start + normalized * (x_end - x_start)
highlight_end = min(max(highlight_end, x_start), x_end)
# Draw plain rectangle bar (no rounding)
ax.add_patch(
Rectangle(
(x_start, y_bar),
x_end - x_start,
bar_height,
ec="none",
fc=color_after,
lw=0,
)
) )
ax.add_patch(main_bar)
# Highlighted Bar # Highlighted rectangle
highlight_bar = FancyBboxPatch( if highlight_end > x_start:
(0, 0.4), ax.add_patch(
highlight_end, Rectangle(
0.2, (x_start, y_bar),
boxstyle="round,pad=0,rounding_size=0.1", highlight_end - x_start,
ec="none", bar_height,
fc="#B2FFC8", ec="none",
) fc=color_before,
ax.add_patch(highlight_bar) lw=0,
)
)
# Text and Labels (show actual kcal value) # kCals label, left-aligned, bold inside green, TEXT COLOR gray
ax.text( ax.text(
highlight_end / 2, x_start + 0.07,
0.5, y_bar + bar_height / 2,
f"{rmr_kcal:.0f}kCals", f"{int(round(rmr_kcal))}kCals",
ha="center", ha="left",
va="center", va="center",
color="#006400", color=gray_color,
fontsize=12,
weight="bold",
bbox=dict(boxstyle="round,pad=0.14", ec="none", fc="#B2FFC8", alpha=1.0),
)
# Triangle marker above highlight end, gray
ax.plot(
[highlight_end],
[y_bar + bar_height + 0.08],
marker="v",
markersize=14,
color=gray_color,
clip_on=False,
)
# Draw ticks omit leftmost/rightmost (thicker and below bar), color gray
tick_width = 4.1
tick_bottom = y_bar - 0.07 # further below bar
tick_top = y_bar # at the base of bar
for edge in tick_edges:
ax.plot(
[edge, edge],
[tick_bottom, tick_top],
color=gray_color,
lw=tick_width,
solid_capstyle="butt",
clip_on=False,
zorder=2,
)
# Label locations (place directly under each tick), text color gray
label_y = tick_bottom - 0.08
for label, tick in zip(scale_labels, tick_edges):
ax.text(
tick,
label_y,
label,
ha="center",
va="top",
fontsize=11,
weight="bold",
color=gray_color,
)
# Axis title: bold, with extra gap above the graph
ax.text(
x_start,
y_bar + bar_height + 0.5,
"Slow vs Fast Metabolism",
ha="left",
va="bottom",
fontsize=14, fontsize=14,
weight="bold", weight="bold",
) )
# Indicator Triangle ax.set_xlim(x_start, x_end)
ax.plot(indicator_pos, 0.65, "v", markersize=15, color="#606060", clip_on=False)
# Ticks and Labels
for pos, label in zip(positions, categories):
ax.text(
pos, 0.15, label, ha="center", va="center", fontsize=12, color="#333333"
)
ax.plot([pos, pos], [0.35, 0.39], color="grey", lw=5)
# Chart Styling
ax.set_title("Slow vs Fast Metabolism", fontsize=18, weight="bold", loc="left")
ax.set_xlim(0, 100) # Normalized scale for uniformity
ax.set_ylim(0, 1) ax.set_ylim(0, 1)
ax.axis("off") ax.axis("off")
@@ -1214,6 +1297,7 @@ class GraphGenerator:
) -> str: ) -> str:
""" """
Generate fuel source chart (Fats vs Carbs). Generate fuel source chart (Fats vs Carbs).
Matches the notebook implementation with proper tick styling.
Args: Args:
fat_percentage: Fat percentage at rest fat_percentage: Fat percentage at rest
@@ -1224,84 +1308,133 @@ class GraphGenerator:
""" """
from matplotlib.patches import FancyBboxPatch from matplotlib.patches import FancyBboxPatch
fig, ax = plt.subplots(figsize=(10, 2.5)) fig, ax = plt.subplots(figsize=(11.5, 2.5))
carb_percentage = 100 - fat_percentage carb_percentage = 100 - fat_percentage
optimal_point = 75 optimal_point = 75
# Main Bars (Fats and Carbs) # Let the bars be a bit thicker as well: increase bar height and y
# Fats bar (yellow)
fats_bar = FancyBboxPatch( fats_bar = FancyBboxPatch(
(0, 0.4), (0, 0.36),
fat_percentage, fat_percentage,
0.2, 0.28,
boxstyle="round,pad=0,rounding_size=0.1", boxstyle="round,pad=0,rounding_size=0.1",
ec="none", ec="none",
fc="#FEEAAB", fc="#FEEAAB",
) )
ax.add_patch(fats_bar) ax.add_patch(fats_bar)
# Carbs bar (blue) - starts where the fats bar ends
carbs_bar = FancyBboxPatch( carbs_bar = FancyBboxPatch(
(fat_percentage, 0.4), (fat_percentage, 0.36),
carb_percentage, carb_percentage,
0.2, 0.28,
boxstyle="round,pad=0,rounding_size=0.1", boxstyle="round,pad=0,rounding_size=0.1",
ec="none", ec="none",
fc="#A7F5FF", fc="#A7F5FF",
) )
ax.add_patch(carbs_bar) ax.add_patch(carbs_bar)
# Text and Labels # Style: match font weight/color/size with other chart
label_fontprops = dict(fontsize=12, weight="bold", color="#333333")
ax.text( ax.text(
fat_percentage / 2, fat_percentage / 2,
0.5, 0.5,
f"Fats\n{fat_percentage:.1f}%", f"Fats\n{fat_percentage:.0f}%",
ha="center", ha="center",
va="center", va="center",
color="#333333", **label_fontprops,
fontsize=12,
weight="bold",
) )
ax.text( ax.text(
fat_percentage + carb_percentage / 2, fat_percentage + carb_percentage / 2,
0.5, 0.5,
f"Carbs\n{carb_percentage:.1f}%", f"Carbs\n{100 - fat_percentage:.0f}%",
ha="center", ha="center",
va="center", va="center",
color="#333333", **label_fontprops,
fontsize=12,
weight="bold",
) )
# Add 'Optimal' label # Add 'Optimal' label
ax.text(optimal_point, 0.75, "Optimal", ha="center", va="center", fontsize=12) ax.text(
optimal_point,
# Indicator Triangle 0.9,
ax.plot( "Optimal",
fat_percentage, 0.65, "v", markersize=15, color="#606060", clip_on=False ha="center",
va="center",
fontsize=12,
weight="bold",
color="#606060",
) )
# Ticks and Labels # Optimal point line
ax.plot([optimal_point, optimal_point], [0.65, 0.8], color="#606060", lw=3)
# Indicator Triangle
ax.plot(fat_percentage, 0.7, "v", markersize=15, color="#606060", clip_on=False)
# Ticks and Labels - matching notebook implementation
positions = [0, 25, 50, 75, 100] positions = [0, 25, 50, 75, 100]
tick_color = "#606060"
for pos in positions: for pos in positions:
ax.text( # Smallest ticks (first and last) are thicker
pos, if pos == 0:
0.15, ax.text(
str(pos), pos + 0.5,
ha="center", 0.15,
va="center", str(pos),
fontsize=12, ha="center",
color="#333333", va="center",
) fontsize=12,
ax.plot([pos, pos], [0.35, 0.39], color="grey", lw=5) color="#333333",
weight="bold",
)
ax.plot(
[pos, pos],
[0.25, 0.37],
color=tick_color,
lw=14,
solid_capstyle="butt",
)
elif pos == 100:
ax.text(
pos - 0.5,
0.15,
str(pos),
ha="center",
va="center",
fontsize=12,
color="#333333",
weight="bold",
)
ax.plot(
[pos, pos],
[0.25, 0.37],
color=tick_color,
lw=14,
solid_capstyle="butt",
)
else:
ax.text(
pos,
0.15,
str(pos),
ha="center",
va="center",
fontsize=12,
color="#333333",
weight="bold",
)
ax.plot(
[pos, pos],
[0.25, 0.37],
color=tick_color,
lw=8,
solid_capstyle="butt",
)
# Add a special tick for the 'Optimal' point # Chart Styling - uniform style for title
ax.plot([optimal_point, optimal_point], [0.6, 0.7], color="black", lw=2) ax.set_title("Fuel Source", fontsize=14, weight="bold", loc="left", pad=22)
ax.set_xlim(0, 100)
# Chart Styling
ax.set_title("Fuel Source", fontsize=18, weight="bold", loc="left")
ax.set_xlim(0, 100) # Normalized scale for uniformity
ax.set_ylim(0, 1) ax.set_ylim(0, 1)
ax.axis("off") ax.axis("off")
+29 -2
View File
@@ -524,9 +524,36 @@ class ReportGeneratorService:
} }
rmr_metrics = temp_context_gen.calculate_rmr_and_fuel_source() rmr_metrics = temp_context_gen.calculate_rmr_and_fuel_source()
# Generate metabolism chart # Convert height to cm if available
height_cm = None
height_str = patient_info.get("height", "")
if height_str:
try:
# Try to parse height string (e.g., "5'4"", "165cm", "165")
import re
# Check if it's in feet'inches" format
feet_inches_match = re.match(r"(\d+)'(\d+)\"", height_str)
if feet_inches_match:
feet = int(feet_inches_match.group(1))
inches = int(feet_inches_match.group(2))
height_cm = (feet * 12 + inches) * 2.54
# Check if it ends with cm
elif "cm" in height_str.lower():
height_cm = float(re.sub(r"[^\d.]", "", height_str))
# Otherwise try to parse as number (assume cm)
else:
height_cm = float(re.sub(r"[^\d.]", "", height_str))
except (ValueError, AttributeError):
pass
# Generate metabolism chart with ratio calculation if we have all parameters
metabolism_chart_b64 = self.graph_generator.generate_metabolism_chart( metabolism_chart_b64 = self.graph_generator.generate_metabolism_chart(
rmr_metrics["rmr_kcal"], save_as_base64=True rmr_metrics["rmr_kcal"],
weight_kg=weight_kg,
height_cm=height_cm,
age_years=patient_info.get("age", None),
sex=gender,
save_as_base64=True,
) )
graphs_dict["metabolism_chart"] = metabolism_chart_b64 graphs_dict["metabolism_chart"] = metabolism_chart_b64
+163 -75
View File
@@ -1,24 +1,32 @@
{% extends "base.html" %} {% extends "base.html" %} {% block title %}Report Preview - Report Generator{%
endblock %} {% block content %}
{% block title %}Report Preview - Report Generator{% endblock %}
{% block content %}
<div class="px-4 py-6 sm:px-0"> <div class="px-4 py-6 sm:px-0">
{% if not session.get('report_path') %} {% if not session.get('report_path') %}
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6"> <div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p class="text-yellow-800">No report found. Please <a href="/" class="underline">upload files</a> first.</p> <p class="text-yellow-800">
No report found. Please
<a href="/" class="underline">upload files</a> first.
</p>
</div> </div>
{% else %} {% else %}
<div class="bg-white shadow rounded-lg mb-6"> <div class="bg-white shadow rounded-lg mb-6">
<div class="px-4 py-5 sm:p-6"> <div class="px-4 py-5 sm:p-6">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-gray-900">Generated Report Preview</h2> <h2 class="text-2xl font-bold text-gray-900">
Generated Report Preview
</h2>
<div class="flex space-x-3"> <div class="flex space-x-3">
<a href="/edit" class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"> <a
href="/edit"
class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Edit Metrics Edit Metrics
</a> </a>
<a href="/download-report/{{ session.report_path.split('/')[-1] }}" class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"> <a
href="/download-report/{{ session.report_path.split('/')[-1] }}"
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
Download PDF Download PDF
</a> </a>
</div> </div>
@@ -26,23 +34,33 @@
<!-- Patient Information --> <!-- Patient Information -->
<div class="border-b border-gray-200 pb-6 mb-6"> <div class="border-b border-gray-200 pb-6 mb-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Patient Information</h3> <h3 class="text-lg font-medium text-gray-900 mb-4">
Patient Information
</h3>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div> <div>
<p class="text-sm text-gray-500">Name</p> <p class="text-sm text-gray-500">Name</p>
<p class="text-base font-medium text-gray-900">{{ session.patient_info['patient_name'] }}</p> <p class="text-base font-medium text-gray-900">
{{ session.patient_info['patient_name'] }}
</p>
</div> </div>
<div> <div>
<p class="text-sm text-gray-500">Age</p> <p class="text-sm text-gray-500">Age</p>
<p class="text-base font-medium text-gray-900">{{ session.patient_info['age'] }}</p> <p class="text-base font-medium text-gray-900">
{{ session.patient_info['age'] }}
</p>
</div> </div>
<div> <div>
<p class="text-sm text-gray-500">Height</p> <p class="text-sm text-gray-500">Height</p>
<p class="text-base font-medium text-gray-900">{{ session.patient_info['height'] }}</p> <p class="text-base font-medium text-gray-900">
{{ session.patient_info['height'] }}
</p>
</div> </div>
<div> <div>
<p class="text-sm text-gray-500">Weight</p> <p class="text-sm text-gray-500">Weight</p>
<p class="text-base font-medium text-gray-900">{{ session.patient_info['weight'] }}</p> <p class="text-base font-medium text-gray-900">
{{ session.patient_info['weight'] }}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -52,56 +70,113 @@
<div class="space-y-6"> <div class="space-y-6">
<!-- Pnoe Metrics --> <!-- Pnoe Metrics -->
<div> <div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Pnoe Metrics</h3> <h3 class="text-lg font-medium text-gray-900 mb-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> Pnoe Metrics
</h3>
<div
class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
>
{% if session.metrics.pnoe.get('vo2_max') %} {% if session.metrics.pnoe.get('vo2_max') %}
<div class="bg-gray-50 p-4 rounded-lg"> <div class="bg-gray-50 p-4 rounded-lg">
<p class="text-sm text-gray-500">VO2 Max</p> <p class="text-sm text-gray-500">VO2 Max</p>
<p class="text-2xl font-bold text-gray-900">{{ "%.2f"|format(session.metrics.pnoe['vo2_max']) }} ml/min</p> <p class="text-2xl font-bold text-gray-900">
{{
"%.2f"|format(session.metrics.pnoe['vo2_max'])
}} ml/min
</p>
</div> </div>
{% endif %} {% endif %} {% if
{% if session.metrics.pnoe.get('vo2_max_per_kg') %} session.metrics.pnoe.get('vo2_max_per_kg') %}
<div class="bg-gray-50 p-4 rounded-lg"> <div class="bg-gray-50 p-4 rounded-lg">
<p class="text-sm text-gray-500">VO2 Max per kg</p> <p class="text-sm text-gray-500">VO2 Max per kg</p>
<p class="text-2xl font-bold text-gray-900">{{ "%.2f"|format(session.metrics.pnoe['vo2_max_per_kg']) }} ml/min/kg</p> <p class="text-2xl font-bold text-gray-900">
{{
"%.2f"|format(session.metrics.pnoe['vo2_max_per_kg'])
}} ml/min/kg
</p>
</div> </div>
{% endif %} {% endif %} {% if session.metrics.pnoe.get('peak_vt') %}
{% if session.metrics.pnoe.get('peak_vt') %}
<div class="bg-gray-50 p-4 rounded-lg"> <div class="bg-gray-50 p-4 rounded-lg">
<p class="text-sm text-gray-500">Peak VT</p> <p class="text-sm text-gray-500">Peak VT</p>
<p class="text-2xl font-bold text-gray-900">{{ "%.2f"|format(session.metrics.pnoe['peak_vt']) }} L</p> <p class="text-2xl font-bold text-gray-900">
<p class="text-sm text-gray-500 mt-1">HR: {{ "%.0f"|format(session.metrics.pnoe['peak_vt_hr']) }} bpm</p> {{
"%.2f"|format(session.metrics.pnoe['peak_vt'])
}} L
</p>
<p class="text-sm text-gray-500 mt-1">
HR: {{
"%.0f"|format(session.metrics.pnoe['peak_vt_hr'])
}} bpm
</p>
</div> </div>
{% endif %} {% endif %} {% if
{% if session.metrics.pnoe.get('fat_max_value') %} session.metrics.pnoe.get('fat_max_value') %}
<div class="bg-gray-50 p-4 rounded-lg"> <div class="bg-gray-50 p-4 rounded-lg">
<p class="text-sm text-gray-500">Fat Max Value</p> <p class="text-sm text-gray-500">Fat Max Value</p>
<p class="text-2xl font-bold text-gray-900">{{ "%.2f"|format(session.metrics.pnoe['fat_max_value']) }} kcal/min</p> <p class="text-2xl font-bold text-gray-900">
<p class="text-sm text-gray-500 mt-1">HR: {{ "%.0f"|format(session.metrics.pnoe['fat_max_hr']) }} bpm</p> {{
"%.2f"|format(session.metrics.pnoe['fat_max_value'])
}} kcal/min
</p>
<p class="text-sm text-gray-500 mt-1">
HR: {{
"%.0f"|format(session.metrics.pnoe['fat_max_hr'])
}} bpm
</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!-- VT1 and VT2 --> <!-- VT1 and VT2 -->
{% if session.metrics.pnoe.get('vt1') or session.metrics.pnoe.get('vt2') %} {% if session.metrics.pnoe.get('vt1') or
session.metrics.pnoe.get('vt2') %}
<div> <div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Ventilatory Thresholds</h3> <h3 class="text-lg font-medium text-gray-900 mb-4">
Ventilatory Thresholds
</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
{% if session.metrics.pnoe.get('vt1') %} {% if session.metrics.pnoe.get('vt1') %}
<div class="bg-blue-50 p-4 rounded-lg"> <div class="bg-blue-50 p-4 rounded-lg">
<p class="text-sm font-medium text-blue-900 mb-2">VT1</p> <p class="text-sm font-medium text-blue-900 mb-2">
<p class="text-sm text-blue-700">Heart Rate: {{ "%.0f"|format(session.metrics.pnoe['vt1']['HeartRate']) }} bpm</p> VT1
<p class="text-sm text-blue-700">Speed: {{ "%.2f"|format(session.metrics.pnoe['vt1']['Speed']) }} mph</p> </p>
<p class="text-sm text-blue-700">Time: {{ "%.0f"|format(session.metrics.pnoe['vt1']['Time']) }} sec</p> <p class="text-sm text-blue-700">
Heart Rate: {{
"%.0f"|format(session.metrics.pnoe['vt1']['HeartRate'])
}} bpm
</p>
<p class="text-sm text-blue-700">
Speed: {{
"%.2f"|format(session.metrics.pnoe['vt1']['Speed'])
}} mph
</p>
<p class="text-sm text-blue-700">
Time: {{
"%.0f"|format(session.metrics.pnoe['vt1']['Time'])
}} sec
</p>
</div> </div>
{% endif %} {% endif %} {% if session.metrics.pnoe.get('vt2') %}
{% if session.metrics.pnoe.get('vt2') %}
<div class="bg-green-50 p-4 rounded-lg"> <div class="bg-green-50 p-4 rounded-lg">
<p class="text-sm font-medium text-green-900 mb-2">VT2</p> <p class="text-sm font-medium text-green-900 mb-2">
<p class="text-sm text-green-700">Heart Rate: {{ "%.0f"|format(session.metrics.pnoe['vt2']['HeartRate']) }} bpm</p> VT2
<p class="text-sm text-green-700">Speed: {{ "%.2f"|format(session.metrics.pnoe['vt2']['Speed']) }} mph</p> </p>
<p class="text-sm text-green-700">Time: {{ "%.0f"|format(session.metrics.pnoe['vt2']['Time']) }} sec</p> <p class="text-sm text-green-700">
Heart Rate: {{
"%.0f"|format(session.metrics.pnoe['vt2']['HeartRate'])
}} bpm
</p>
<p class="text-sm text-green-700">
Speed: {{
"%.2f"|format(session.metrics.pnoe['vt2']['Speed'])
}} mph
</p>
<p class="text-sm text-green-700">
Time: {{
"%.0f"|format(session.metrics.pnoe['vt2']['Time'])
}} sec
</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -111,17 +186,20 @@
<!-- Heart Rate Zones --> <!-- Heart Rate Zones -->
{% if session.metrics.pnoe.get('zone1_bpm') %} {% if session.metrics.pnoe.get('zone1_bpm') %}
<div> <div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Heart Rate Zones</h3> <h3 class="text-lg font-medium text-gray-900 mb-4">
Heart Rate Zones
</h3>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-5"> <div class="grid grid-cols-1 gap-2 sm:grid-cols-5">
{% for i in range(1, 6) %} {% for i in range(1, 6) %} {% set zone_key = "zone" +
{% set zone_key = "zone" + i|string + "_bpm" %} i|string + "_bpm" %} {% if
{% if session.metrics.pnoe.get(zone_key) %} session.metrics.pnoe.get(zone_key) %}
<div class="bg-gray-50 p-3 rounded-lg text-center"> <div class="bg-gray-50 p-3 rounded-lg text-center">
<p class="text-xs text-gray-500">Zone {{ i }}</p> <p class="text-xs text-gray-500">Zone {{ i }}</p>
<p class="text-sm font-medium text-gray-900">{{ session.metrics.pnoe[zone_key] }}</p> <p class="text-sm font-medium text-gray-900">
{{ session.metrics.pnoe[zone_key] }}
</p>
</div> </div>
{% endif %} {% endif %} {% endfor %}
{% endfor %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -129,27 +207,53 @@
<!-- Spirometry Metrics --> <!-- Spirometry Metrics -->
{% if session.metrics.spirometry %} {% if session.metrics.spirometry %}
<div> <div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Spirometry Metrics</h3> <h3 class="text-lg font-medium text-gray-900 mb-4">
Spirometry Metrics
</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
{% if session.metrics.spirometry.get('fvc_best') %} {% if session.metrics.spirometry.get('fvc_best') %}
<div class="bg-gray-50 p-4 rounded-lg"> <div class="bg-gray-50 p-4 rounded-lg">
<p class="text-sm text-gray-500">FVC Best</p> <p class="text-sm text-gray-500">FVC Best</p>
<p class="text-2xl font-bold text-gray-900">{{ "%.2f"|format(session.metrics.spirometry['fvc_best']) }} L</p> <p class="text-2xl font-bold text-gray-900">
<p class="text-sm text-gray-500 mt-1">{{ "%.1f"|format(session.metrics.spirometry['fvc_pred']) }}% predicted</p> {{
"%.2f"|format(session.metrics.spirometry['fvc_best'])
}} L
</p>
<p class="text-sm text-gray-500 mt-1">
{{
"%.1f"|format(session.metrics.spirometry['fvc_pred'])
}}% predicted
</p>
</div> </div>
{% endif %} {% endif %} {% if
{% if session.metrics.spirometry.get('fev1_best') %} session.metrics.spirometry.get('fev1_best') %}
<div class="bg-gray-50 p-4 rounded-lg"> <div class="bg-gray-50 p-4 rounded-lg">
<p class="text-sm text-gray-500">FEV1 Best</p> <p class="text-sm text-gray-500">FEV1 Best</p>
<p class="text-2xl font-bold text-gray-900">{{ "%.2f"|format(session.metrics.spirometry['fev1_best']) }} L</p> <p class="text-2xl font-bold text-gray-900">
<p class="text-sm text-gray-500 mt-1">{{ "%.1f"|format(session.metrics.spirometry['fev1_pred']) }}% predicted</p> {{
"%.2f"|format(session.metrics.spirometry['fev1_best'])
}} L
</p>
<p class="text-sm text-gray-500 mt-1">
{{
"%.1f"|format(session.metrics.spirometry['fev1_pred'])
}}% predicted
</p>
</div> </div>
{% endif %} {% endif %} {% if
{% if session.metrics.spirometry.get('fev1_fvc_pct_best') %} session.metrics.spirometry.get('fev1_fvc_pct_best') %}
<div class="bg-gray-50 p-4 rounded-lg"> <div class="bg-gray-50 p-4 rounded-lg">
<p class="text-sm text-gray-500">FEV1/FVC%</p> <p class="text-sm text-gray-500">FEV1/FVC%</p>
<p class="text-2xl font-bold text-gray-900">{{ "%.2f"|format(session.metrics.spirometry['fev1_fvc_pct_best']) }}%</p> <p class="text-2xl font-bold text-gray-900">
<p class="text-sm text-gray-500 mt-1">{{ "%.1f"|format(session.metrics.spirometry['fev1_fvc_pct_pred']) }}% predicted</p> {{
"%.2f"|format(session.metrics.spirometry['fev1_fvc_pct_best'])
}}%
</p>
<p class="text-sm text-gray-500 mt-1">
{{
"%.1f"|format(session.metrics.spirometry['fev1_fvc_pct_pred'])
}}% predicted
</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -157,24 +261,8 @@
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
<!-- Graphs Section -->
{% if session.graphs_generated %}
<div class="mt-8">
<h3 class="text-lg font-medium text-gray-900 mb-4">Generated Graphs</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
{% for graph in session.graphs_generated %}
<div class="bg-gray-50 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-700 mb-2">{{ graph.name|replace('_', ' ')|title }}</p>
<img src="/graphs/{{ graph.path.split('/')[-1] }}" alt="{{ graph.name }}" class="w-full h-auto rounded">
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
-14
View File
@@ -132,20 +132,6 @@ Generator{% endblock %} {% block content %}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border"
/> />
</div> </div>
<div>
<label
for="session_id"
class="block text-sm font-medium text-gray-700"
>Session ID</label
>
<input
type="text"
name="session_id"
id="session_id"
value="default"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border"
/>
</div>
<div> <div>
<label <label
class="block text-sm font-medium text-gray-700 mb-2" class="block text-sm font-medium text-gray-700 mb-2"
Binary file not shown.
+23 -20
View File
@@ -2,7 +2,7 @@
"cells": [ "cells": [
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": null, "execution_count": 1,
"id": "b18c1027", "id": "b18c1027",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
@@ -88,7 +88,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": null, "execution_count": 3,
"id": "56a9d655", "id": "56a9d655",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -104,7 +104,10 @@
], ],
"source": [ "source": [
"import pandas as pd\n", "import pandas as pd\n",
"spirometry_df = pd.read_csv(\"data/spirometry_data.csv\")\n", "import os\n",
"\n",
"base_dir = os.path.dirname(os.path.abspath('.'))\n",
"spirometry_df = pd.read_csv(f\"{base_dir}/data/spirometry_data.csv\")\n",
"\n", "\n",
"fvc_best = spirometry_df.loc[spirometry_df['Parameters'] == 'FVC', 'Best'].values[0]\n", "fvc_best = spirometry_df.loc[spirometry_df['Parameters'] == 'FVC', 'Best'].values[0]\n",
"fvc_pred = spirometry_df.loc[spirometry_df['Parameters'] == 'FVC', '%Pred.'].values[0]\n", "fvc_pred = spirometry_df.loc[spirometry_df['Parameters'] == 'FVC', '%Pred.'].values[0]\n",
@@ -122,7 +125,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": null, "execution_count": 4,
"id": "990f4b4f", "id": "990f4b4f",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -136,7 +139,7 @@
} }
], ],
"source": [ "source": [
"df = pd.read_csv('data/Pnoe_20250729_1550-Moran_Keirstyn.csv', delimiter=';')\n", "df = pd.read_csv(f'{base_dir}/data/Pnoe_20250729_1550-Moran_Keirstyn.csv', delimiter=';')\n",
"peak_vt = df['VT(l)'].max()\n", "peak_vt = df['VT(l)'].max()\n",
"max_vt_row = df.loc[df['VT(l)'].idxmax()]\n", "max_vt_row = df.loc[df['VT(l)'].idxmax()]\n",
"print(f\"Peak VT: {peak_vt}\")\n", "print(f\"Peak VT: {peak_vt}\")\n",
@@ -146,7 +149,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": null, "execution_count": 19,
"id": "041cbc3d", "id": "041cbc3d",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -154,21 +157,21 @@
"name": "stdout", "name": "stdout",
"output_type": "stream", "output_type": "stream",
"text": [ "text": [
"Peak VT: 2.3770000000000002\n", "Peak VT: 2.3844444444444446\n",
"HR at Peak VT: 171.525\n" "HR at Peak VT: 172.80555555555554\n"
] ]
}, },
{ {
"name": "stderr", "name": "stderr",
"output_type": "stream", "output_type": "stream",
"text": [ "text": [
"/tmp/ipykernel_69398/4157056299.py:3: FutureWarning: errors='ignore' is deprecated and will raise in a future version. Use to_numeric without passing `errors` and catch exceptions explicitly instead\n", "/tmp/ipykernel_53922/361246798.py:3: FutureWarning: errors='ignore' is deprecated and will raise in a future version. Use to_numeric without passing `errors` and catch exceptions explicitly instead\n",
" df = df.apply(pd.to_numeric, errors='ignore')\n" " df = df.apply(pd.to_numeric, errors='ignore')\n"
] ]
} }
], ],
"source": [ "source": [
"df = pd.read_csv('data/Pnoe_20250729_1550-Moran_Keirstyn.csv', delimiter=';')\n", "df = pd.read_csv(f'{base_dir}/data/Pnoe_20250729_1550-Moran_Keirstyn.csv', delimiter=';')\n",
"# Convert all columns to numeric where possible, coercing errors to NaN\n", "# Convert all columns to numeric where possible, coercing errors to NaN\n",
"df = df.apply(pd.to_numeric, errors='ignore')\n", "df = df.apply(pd.to_numeric, errors='ignore')\n",
"df['VO2 Pulse'] = df['VO2(ml/min)'] / df['HR(bpm)'] # VO2 Pulse in mL/beat\n", "df['VO2 Pulse'] = df['VO2(ml/min)'] / df['HR(bpm)'] # VO2 Pulse in mL/beat\n",
@@ -176,7 +179,7 @@
"df['CHO'] = df['EE(kcal/min)'] * df['CARBS(%)']/100\n", "df['CHO'] = df['EE(kcal/min)'] * df['CARBS(%)']/100\n",
"df['FAT'] = df['EE(kcal/min)'] * df['FAT(%)']/100\n", "df['FAT'] = df['EE(kcal/min)'] * df['FAT(%)']/100\n",
"# Smooth key columns using rolling window\n", "# Smooth key columns using rolling window\n",
"window_size = 10\n", "window_size = 9\n",
"\n", "\n",
"# List of columns to smooth\n", "# List of columns to smooth\n",
"columns_to_smooth = ['VO2(ml/min)', 'VCO2(ml/min)', 'HR(bpm)', 'VT(l)', 'BF(bpm)', 'VE(l/min)', 'VO2 Pulse', 'VO2 Breath', 'CHO', 'FAT']\n", "columns_to_smooth = ['VO2(ml/min)', 'VCO2(ml/min)', 'HR(bpm)', 'VT(l)', 'BF(bpm)', 'VE(l/min)', 'VO2 Pulse', 'VO2 Breath', 'CHO', 'FAT']\n",
@@ -195,7 +198,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": null, "execution_count": 20,
"id": "de7cadd1", "id": "de7cadd1",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -203,7 +206,7 @@
"name": "stdout", "name": "stdout",
"output_type": "stream", "output_type": "stream",
"text": [ "text": [
"Percent FEV: 72.91411042944786\n" "Percent FEV: 73.14246762099523\n"
] ]
} }
], ],
@@ -214,7 +217,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": null, "execution_count": 21,
"id": "cb972ed3", "id": "cb972ed3",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -311,13 +314,13 @@
"[1 rows x 147 columns]" "[1 rows x 147 columns]"
] ]
}, },
"execution_count": 11, "execution_count": 21,
"metadata": {}, "metadata": {},
"output_type": "execute_result" "output_type": "execute_result"
} }
], ],
"source": [ "source": [
"personal_df = pd.read_excel('data/SECA body comp for all patients.xlsx')\n", "personal_df = pd.read_excel(f'{base_dir}/data/SECA body comp for all patients.xlsx')\n",
"\n", "\n",
"keirstyn_data = personal_df[personal_df['LastName'].str.contains('Moran', case=False, na=False)]\n", "keirstyn_data = personal_df[personal_df['LastName'].str.contains('Moran', case=False, na=False)]\n",
"keirstyn_data" "keirstyn_data"
@@ -325,7 +328,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": null, "execution_count": 22,
"id": "98d9295a", "id": "98d9295a",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -333,7 +336,7 @@
"name": "stdout", "name": "stdout",
"output_type": "stream", "output_type": "stream",
"text": [ "text": [
"VO2 Max: 47.906290322580645\n" "VO2 Max: 48.19062126642772\n"
] ]
} }
], ],
@@ -823,7 +826,7 @@
], ],
"metadata": { "metadata": {
"kernelspec": { "kernelspec": {
"display_name": "report_generation", "display_name": ".venv",
"language": "python", "language": "python",
"name": "python3" "name": "python3"
}, },
@@ -837,7 +840,7 @@
"name": "python", "name": "python",
"nbconvert_exporter": "python", "nbconvert_exporter": "python",
"pygments_lexer": "ipython3", "pygments_lexer": "ipython3",
"version": "3.12.3" "version": "3.12.6"
} }
}, },
"nbformat": 4, "nbformat": 4,
+249
View File
@@ -0,0 +1,249 @@
import pandas as pd
def mifflin_st_jeor(weight_kg, height_cm, age_years, sex):
"""
Compute predicted RMR with Mifflin St Jeor.
sex: 'male' or 'female'
"""
base = 10.0 * weight_kg + 6.25 * height_cm - 5.0 * age_years
if sex.lower().startswith("m"):
return base + 5.0
else:
return base - 161.0
def classify_metabolism(measured_kcal_day, predicted_kcal_day):
"""
Classify metabolic rate relative to prediction.
Returns (label, ratio).
"""
ratio = measured_kcal_day / predicted_kcal_day
if ratio < 0.70:
label = "very slow"
elif ratio < 0.90:
label = "slow"
elif ratio <= 1.10:
label = "average"
elif ratio <= 1.30:
label = "fast"
else:
label = "very fast"
return label, ratio
def find_sampling_window(df):
"""
Derive number of samples that represent about 2 minutes.
"""
dt = df["T(sec)"].diff().median()
if dt is None or dt <= 0:
raise ValueError("Invalid time step in T(sec)")
samples = int(round(120.0 / dt))
if samples < 1:
samples = 1
return samples
def rolling_stable_window(df, window_samples):
"""
Find the most stable 2-minute window using rolling standard deviation.
Returns:
means_series, t_start, t_end
"""
cols_mean = [
"VO2(ml/min)",
"VCO2(ml/min)",
"VE(l/min)",
"VT(l)",
"BF(bpm)",
"EE(kcal/min)",
"RER",
"CARBS(%)",
"FAT(%)",
]
cols_std = [
"VO2(ml/min)",
"VCO2(ml/min)",
"VE(l/min)",
"VT(l)",
"BF(bpm)",
]
roll_mean = df[cols_mean].rolling(window_samples, min_periods=window_samples).mean()
roll_std = df[cols_std].rolling(window_samples, min_periods=window_samples).std()
# Sum std devs to get stability score; use skipna=False to preserve NaN for incomplete windows
stability_score = roll_std.sum(axis=1, skipna=False)
# Find index with lowest stability score (dropna to ignore incomplete windows)
best_idx = stability_score.dropna().idxmin()
means_series = roll_mean.loc[best_idx].copy()
start_idx = max(best_idx - window_samples + 1, 0)
end_idx = best_idx
t_start = float(df["T(sec)"].iloc[start_idx])
t_end = float(df["T(sec)"].iloc[end_idx])
return means_series, t_start, t_end
def manual_window_means(df, t_start, t_end):
"""
Compute mean values inside a user-selected time window.
"""
mask = (df["T(sec)"] >= t_start) & (df["T(sec)"] <= t_end)
slice_df = df.loc[mask].copy()
if slice_df.empty:
raise ValueError("Manual window has no rows inside T(sec) range")
cols = [
"VO2(ml/min)",
"VCO2(ml/min)",
"VE(l/min)",
"VT(l)",
"BF(bpm)",
"EE(kcal/min)",
"RER",
"CARBS(%)",
"FAT(%)",
]
means = slice_df[cols].mean()
return means, float(t_start), float(t_end)
def load_pnoe_csv(path):
"""
Load and clean a PNOE CSV file.
"""
df = pd.read_csv(path, sep=";")
numeric_cols = [
"T(sec)",
"VO2(ml/min)",
"VCO2(ml/min)",
"RER",
"VE(l/min)",
"VT(l)",
"BF(bpm)",
"EE(kcal/min)",
"CARBS(%)",
"FAT(%)",
]
for col in numeric_cols:
df[col] = pd.to_numeric(df[col], errors="coerce")
df = df.dropna(subset=["VO2(ml/min)", "EE(kcal/min)"]).reset_index(drop=True)
return df
def analyze_pnoe_rmr(
path,
weight_kg,
height_cm,
age_years,
sex,
subject_name=None,
test_date=None,
manual_window=None,
):
"""
Analyze resting RMR from a PNOE CSV file.
manual_window:
None for automatic stable window
or (t_start_sec, t_end_sec) for user-chosen window
"""
df = load_pnoe_csv(path)
window_samples = find_sampling_window(df)
# Automatic stable window
auto_means, auto_t_start, auto_t_end = rolling_stable_window(df, window_samples)
# Manual override if provided
manual_means = None
manual_t_start = None
manual_t_end = None
if manual_window is not None:
t_start_manual, t_end_manual = manual_window
manual_means, manual_t_start, manual_t_end = manual_window_means(
df, t_start_manual, t_end_manual
)
chosen_source = "manual"
chosen_means = manual_means
chosen_t_start = manual_t_start
chosen_t_end = manual_t_end
else:
chosen_source = "auto"
chosen_means = auto_means
chosen_t_start = auto_t_start
chosen_t_end = auto_t_end
kcal_per_min = float(chosen_means["EE(kcal/min)"])
rmr_kcal_day = kcal_per_min * 1440.0
predicted_kcal_day = mifflin_st_jeor(weight_kg, height_cm, age_years, sex)
label, ratio = classify_metabolism(rmr_kcal_day, predicted_kcal_day)
def pack_metrics(prefix, means, t_start, t_end):
if means is None:
return {}
return {
f"{prefix}_window_start_sec": t_start,
f"{prefix}_window_end_sec": t_end,
f"{prefix}_VO2_L_min": float(means["VO2(ml/min)"]) / 1000.0,
f"{prefix}_VCO2_L_min": float(means["VCO2(ml/min)"]) / 1000.0,
f"{prefix}_VE_L_min": float(means["VE(l/min)"]),
f"{prefix}_VT_L": float(means["VT(l)"]),
f"{prefix}_BF_bpm": float(means["BF(bpm)"]),
f"{prefix}_RER": float(means["RER"]),
f"{prefix}_Fat_percent": float(means["FAT(%)"]),
f"{prefix}_Carb_percent": float(means["CARBS(%)"]),
f"{prefix}_kcal_per_min": float(means["EE(kcal/min)"]),
}
result = {
"subject_name": subject_name,
"test_date": test_date,
"sex": sex,
"weight_kg": weight_kg,
"height_cm": height_cm,
"age_years": age_years,
"chosen_window_source": chosen_source,
"chosen_window_start_sec": chosen_t_start,
"chosen_window_end_sec": chosen_t_end,
"RMR_kcal_day": rmr_kcal_day,
"Mifflin_kcal_day": predicted_kcal_day,
"Measured_to_Mifflin_ratio": ratio,
"Metabolic_classification": label,
}
result.update(pack_metrics("auto", auto_means, auto_t_start, auto_t_end))
result.update(pack_metrics("manual", manual_means, manual_t_start, manual_t_end))
return result
result = analyze_pnoe_rmr(
path="/home/oluwasanmi/Documents/Work/MKD/report_generation/data/Pnoe_20250729_1550-Moran_Keirstyn.csv",
weight_kg=56,
height_cm=162,
age_years=34,
sex="female",
subject_name="Cullen Pacas",
test_date="2025-11-12",
manual_window=None, # or (t_start_sec, t_end_sec)
)
for key, value in result.items():
print(f"{key}: {value}")
+204 -178
View File
File diff suppressed because one or more lines are too long