Compare commits
3 Commits
f0e90aa772
...
dynamic
| Author | SHA1 | Date | |
|---|---|---|---|
| 35ea522283 | |||
| fc62b64624 | |||
| e66b9e6c29 |
Binary file not shown.
Binary file not shown.
+128
-56
@@ -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,
|
||||||
|
|||||||
@@ -471,3 +471,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user