feat: Enhance medical report generation with new features and improved data handling
- Added body fat percentage input and optional muscle oxygenation CSV upload in the upload form. - Implemented TSI chart generation based on muscle oxygenation data. - Updated report generation to include metabolism and fuel source charts. - Refactored context generation to eliminate reliance on SECA data, using patient info directly instead. - Improved error handling and logging for graph generation processes. - Enhanced HTML templates for better user experience and functionality.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
+211
-99
@@ -13,10 +13,13 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
|
||||||
from services.report_generator import ReportGeneratorService
|
from services.report_generator import ReportGeneratorService
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
from starlette.requests import Request as StarletteRequest
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Medical Report Generation API",
|
title="Medical Report Generation API",
|
||||||
@@ -25,7 +28,32 @@ app = FastAPI(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Add session middleware
|
# Add session middleware
|
||||||
app.add_middleware(SessionMiddleware, secret_key=os.getenv("SECRET_KEY", "your-secret-key-change-in-production"))
|
app.add_middleware(
|
||||||
|
SessionMiddleware,
|
||||||
|
secret_key=os.getenv("SECRET_KEY", "your-secret-key-change-in-production"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Add security headers middleware to allow external scripts
|
||||||
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: StarletteRequest, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
# Allow external scripts and styles (for Tailwind CDN)
|
||||||
|
# Only add CSP for HTML responses
|
||||||
|
content_type = response.headers.get("content-type", "").lower()
|
||||||
|
if "text/html" in content_type:
|
||||||
|
response.headers["Content-Security-Policy"] = (
|
||||||
|
"default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https:; style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https:; img-src 'self' data: https:;"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
|
|
||||||
|
# Mount static files (if static directory exists)
|
||||||
|
static_dir = Path("static")
|
||||||
|
if static_dir.exists():
|
||||||
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
# Setup templates
|
# Setup templates
|
||||||
jinja_env = Environment(loader=FileSystemLoader("app/templates"))
|
jinja_env = Environment(loader=FileSystemLoader("app/templates"))
|
||||||
@@ -59,13 +87,15 @@ def render_template(template_name: str, context: dict) -> HTMLResponse:
|
|||||||
"""Helper function to render Jinja2 templates"""
|
"""Helper function to render Jinja2 templates"""
|
||||||
template = jinja_env.get_template(template_name)
|
template = jinja_env.get_template(template_name)
|
||||||
html_content = template.render(**context)
|
html_content = template.render(**context)
|
||||||
return HTMLResponse(content=html_content)
|
return HTMLResponse(content=html_content, media_type="text/html")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def root(request: Request):
|
async def root(request: Request):
|
||||||
"""Root endpoint - Upload form page"""
|
"""Root endpoint - Upload form page"""
|
||||||
return render_template("upload.html", {"request": request, "session": request.session})
|
return render_template(
|
||||||
|
"upload.html", {"request": request, "session": request.session}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/upload")
|
@app.post("/upload")
|
||||||
@@ -77,56 +107,73 @@ async def upload_files(
|
|||||||
height: str = Form(...),
|
height: str = Form(...),
|
||||||
weight: str = Form(...),
|
weight: str = Form(...),
|
||||||
gender: str = Form(...),
|
gender: str = Form(...),
|
||||||
|
fat_percentage: float = Form(...),
|
||||||
focus: str = Form(default="Endurance"),
|
focus: str = Form(default="Endurance"),
|
||||||
session_id: str = Form(default="default"),
|
session_id: str = Form(default="default"),
|
||||||
spirometry_pdf: UploadFile = File(...),
|
spirometry_pdf: UploadFile = File(...),
|
||||||
pnoe_csv: UploadFile = File(...),
|
pnoe_csv: UploadFile = File(...),
|
||||||
seca_excel: UploadFile = File(...),
|
oxygenation_csv: UploadFile = File(None),
|
||||||
):
|
):
|
||||||
"""Handle file upload and generate report"""
|
"""Handle file upload and generate report"""
|
||||||
# Validate file types
|
# Validate file types
|
||||||
if not spirometry_pdf.filename.endswith(".pdf"):
|
if not spirometry_pdf.filename.endswith(".pdf"):
|
||||||
return render_template("upload.html", {
|
return render_template(
|
||||||
"request": request,
|
"upload.html",
|
||||||
"session": request.session,
|
{
|
||||||
"error": "Spirometry file must be a PDF"
|
"request": request,
|
||||||
})
|
"session": request.session,
|
||||||
|
"error": "Spirometry file must be a PDF",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if not pnoe_csv.filename.endswith(".csv"):
|
if not pnoe_csv.filename.endswith(".csv"):
|
||||||
return render_template("upload.html", {
|
return render_template(
|
||||||
"request": request,
|
"upload.html",
|
||||||
"session": request.session,
|
{
|
||||||
"error": "Pnoe file must be a CSV"
|
"request": request,
|
||||||
})
|
"session": request.session,
|
||||||
|
"error": "Pnoe file must be a CSV",
|
||||||
if not seca_excel.filename.endswith((".xlsx", ".xls")):
|
},
|
||||||
return render_template("upload.html", {
|
)
|
||||||
"request": request,
|
|
||||||
"session": request.session,
|
# Validate oxygenation CSV if provided
|
||||||
"error": "SECA file must be an Excel file (.xlsx or .xls)"
|
if oxygenation_csv and oxygenation_csv.filename:
|
||||||
})
|
if not oxygenation_csv.filename.endswith(".csv"):
|
||||||
|
return render_template(
|
||||||
|
"upload.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"session": request.session,
|
||||||
|
"error": "Oxygenation file must be a CSV",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# Create session-specific temp directory
|
# Create session-specific temp directory
|
||||||
session_uuid = str(uuid.uuid4())
|
session_uuid = str(uuid.uuid4())
|
||||||
session_temp_dir = TEMP_DIR / session_uuid
|
session_temp_dir = TEMP_DIR / session_uuid
|
||||||
session_temp_dir.mkdir(exist_ok=True, parents=True)
|
session_temp_dir.mkdir(exist_ok=True, parents=True)
|
||||||
|
|
||||||
# Save uploaded files
|
# Save uploaded files
|
||||||
spirometry_path = session_temp_dir / f"spirometry_{spirometry_pdf.filename}"
|
spirometry_path = session_temp_dir / f"spirometry_{spirometry_pdf.filename}"
|
||||||
pnoe_path = session_temp_dir / f"pnoe_{pnoe_csv.filename}"
|
pnoe_path = session_temp_dir / f"pnoe_{pnoe_csv.filename}"
|
||||||
seca_path = session_temp_dir / f"seca_{seca_excel.filename}"
|
oxygenation_path = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Write files
|
# Write files
|
||||||
with open(spirometry_path, "wb") as f:
|
with open(spirometry_path, "wb") as f:
|
||||||
shutil.copyfileobj(spirometry_pdf.file, f)
|
shutil.copyfileobj(spirometry_pdf.file, f)
|
||||||
|
|
||||||
with open(pnoe_path, "wb") as f:
|
with open(pnoe_path, "wb") as f:
|
||||||
shutil.copyfileobj(pnoe_csv.file, f)
|
shutil.copyfileobj(pnoe_csv.file, f)
|
||||||
|
|
||||||
with open(seca_path, "wb") as f:
|
# Save oxygenation CSV if provided
|
||||||
shutil.copyfileobj(seca_excel.file, f)
|
if oxygenation_csv and oxygenation_csv.filename:
|
||||||
|
oxygenation_path = (
|
||||||
|
session_temp_dir / f"oxygenation_{oxygenation_csv.filename}"
|
||||||
|
)
|
||||||
|
with open(oxygenation_path, "wb") as f:
|
||||||
|
shutil.copyfileobj(oxygenation_csv.file, f)
|
||||||
|
|
||||||
# Prepare patient information
|
# Prepare patient information
|
||||||
patient_name = f"{first_name} {last_name}"
|
patient_name = f"{first_name} {last_name}"
|
||||||
patient_info = {
|
patient_info = {
|
||||||
@@ -137,76 +184,100 @@ async def upload_files(
|
|||||||
"height": height,
|
"height": height,
|
||||||
"weight": weight,
|
"weight": weight,
|
||||||
"gender": gender,
|
"gender": gender,
|
||||||
|
"fat_percentage": fat_percentage,
|
||||||
"focus": focus,
|
"focus": focus,
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Generate report
|
# Generate report
|
||||||
|
oxygenation_csv_path = str(oxygenation_path) if oxygenation_path else None
|
||||||
result = await report_service.generate_report(
|
result = await report_service.generate_report(
|
||||||
spirometry_pdf_path=str(spirometry_path),
|
spirometry_pdf_path=str(spirometry_path),
|
||||||
pnoe_csv_path=str(pnoe_path),
|
pnoe_csv_path=str(pnoe_path),
|
||||||
seca_excel_path=str(seca_path),
|
|
||||||
patient_info=patient_info,
|
patient_info=patient_info,
|
||||||
|
oxygenation_csv_path=oxygenation_csv_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store in session
|
# Store in session
|
||||||
request.session["patient_info"] = patient_info
|
request.session["patient_info"] = patient_info
|
||||||
request.session["temp_dir"] = str(session_temp_dir)
|
request.session["temp_dir"] = str(session_temp_dir)
|
||||||
request.session["report_path"] = result["report_path"]
|
request.session["report_path"] = result["report_path"]
|
||||||
request.session["graphs_generated"] = result["graphs_generated"]
|
request.session["graphs_generated"] = result["graphs_generated"]
|
||||||
request.session["analysis_data"] = result["analysis_data"]
|
request.session["analysis_data"] = result["analysis_data"]
|
||||||
|
|
||||||
# Extract spirometry CSV path (it's saved in data_dir by the service)
|
# Extract spirometry CSV path (it's saved in data_dir by the service)
|
||||||
from services.spirometry_table_extractor import extract_spirometry_table_from_pdf
|
|
||||||
from services.context_generator import ContextGenerator
|
|
||||||
from pathlib import Path as PathLib
|
from pathlib import Path as PathLib
|
||||||
|
|
||||||
|
from services.context_generator import ContextGenerator
|
||||||
|
from services.spirometry_table_extractor import (
|
||||||
|
extract_spirometry_table_from_pdf,
|
||||||
|
)
|
||||||
|
|
||||||
# The spirometry CSV is extracted during report generation
|
# The spirometry CSV is extracted during report generation
|
||||||
# We need to find it or extract it again
|
# We need to find it or extract it again
|
||||||
data_dir = PathLib("data")
|
data_dir = PathLib("data")
|
||||||
spirometry_csv_path = data_dir / f"spirometry_{Path(spirometry_pdf.filename).stem}.csv"
|
spirometry_csv_path = (
|
||||||
|
data_dir / f"spirometry_{Path(spirometry_pdf.filename).stem}.csv"
|
||||||
|
)
|
||||||
|
|
||||||
# If it doesn't exist, extract it
|
# If it doesn't exist, extract it
|
||||||
if not spirometry_csv_path.exists():
|
if not spirometry_csv_path.exists():
|
||||||
spirometry_csv_path = extract_spirometry_table_from_pdf(
|
spirometry_csv_path = extract_spirometry_table_from_pdf(
|
||||||
str(spirometry_path), output_dir=str(data_dir)
|
str(spirometry_path), output_dir=str(data_dir)
|
||||||
)
|
)
|
||||||
spirometry_csv_path = PathLib(spirometry_csv_path)
|
spirometry_csv_path = PathLib(spirometry_csv_path)
|
||||||
|
|
||||||
# Get calculated metrics for display and editing
|
# Get calculated metrics for display and editing
|
||||||
context_gen = ContextGenerator()
|
context_gen = ContextGenerator()
|
||||||
context_gen.load_data(
|
context_gen.load_data(
|
||||||
str(pnoe_path),
|
str(pnoe_path),
|
||||||
str(spirometry_csv_path),
|
str(spirometry_csv_path),
|
||||||
str(seca_path)
|
None, # No SECA file needed anymore
|
||||||
)
|
)
|
||||||
context_gen.extract_patient_info(last_name) # Extract patient info
|
# Set patient info manually since we're not reading from SECA
|
||||||
|
weight_kg = float(weight.replace("lbs", "").replace("kg", "").strip())
|
||||||
|
if "lbs" in weight.lower():
|
||||||
|
weight_kg = weight_kg / 2.20462 # Convert lbs to kg
|
||||||
|
|
||||||
|
context_gen.patient_info = {
|
||||||
|
"name": first_name,
|
||||||
|
"last_name": last_name,
|
||||||
|
"age": age,
|
||||||
|
"weight": weight_kg,
|
||||||
|
"fat_percentage": fat_percentage,
|
||||||
|
"gender": gender,
|
||||||
|
}
|
||||||
spirometry_metrics = context_gen.calculate_spirometry_metrics()
|
spirometry_metrics = context_gen.calculate_spirometry_metrics()
|
||||||
pnoe_metrics = context_gen.calculate_pnoe_metrics()
|
pnoe_metrics = context_gen.calculate_pnoe_metrics()
|
||||||
|
|
||||||
# Store metrics in session
|
# Store metrics in session
|
||||||
request.session["metrics"] = {
|
request.session["metrics"] = {
|
||||||
"spirometry": spirometry_metrics,
|
"spirometry": spirometry_metrics,
|
||||||
"pnoe": pnoe_metrics,
|
"pnoe": pnoe_metrics,
|
||||||
}
|
}
|
||||||
request.session["spirometry_csv_path"] = str(spirometry_csv_path)
|
request.session["spirometry_csv_path"] = str(spirometry_csv_path)
|
||||||
|
|
||||||
return RedirectResponse(url="/preview", status_code=303)
|
return RedirectResponse(url="/preview", status_code=303)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
error_details = traceback.format_exc()
|
error_details = traceback.format_exc()
|
||||||
print(f"ERROR: {error_details}")
|
print(f"ERROR: {error_details}")
|
||||||
return render_template("upload.html", {
|
return render_template(
|
||||||
"request": request,
|
"upload.html",
|
||||||
"session": request.session,
|
{
|
||||||
"error": f"Error generating report: {str(e)}"
|
"request": request,
|
||||||
})
|
"session": request.session,
|
||||||
|
"error": f"Error generating report: {str(e)}",
|
||||||
|
},
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
# Close file handles
|
# Close file handles
|
||||||
spirometry_pdf.file.close()
|
spirometry_pdf.file.close()
|
||||||
pnoe_csv.file.close()
|
pnoe_csv.file.close()
|
||||||
seca_excel.file.close()
|
if oxygenation_csv and oxygenation_csv.filename:
|
||||||
|
oxygenation_csv.file.close()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/preview", response_class=HTMLResponse)
|
@app.get("/preview", response_class=HTMLResponse)
|
||||||
@@ -214,7 +285,9 @@ async def preview(request: Request):
|
|||||||
"""Preview generated report"""
|
"""Preview generated report"""
|
||||||
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)
|
||||||
return render_template("preview.html", {"request": request, "session": request.session})
|
return render_template(
|
||||||
|
"preview.html", {"request": request, "session": request.session}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/graphs/{filename}")
|
@app.get("/graphs/{filename}")
|
||||||
@@ -231,7 +304,9 @@ async def edit_form(request: Request):
|
|||||||
"""Display edit metrics form"""
|
"""Display edit metrics form"""
|
||||||
if not request.session.get("metrics"):
|
if not request.session.get("metrics"):
|
||||||
return RedirectResponse(url="/", status_code=303)
|
return RedirectResponse(url="/", status_code=303)
|
||||||
return render_template("edit.html", {"request": request, "session": request.session})
|
return render_template(
|
||||||
|
"edit.html", {"request": request, "session": request.session}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/edit")
|
@app.post("/edit")
|
||||||
@@ -239,16 +314,13 @@ async def edit_metrics(request: Request):
|
|||||||
"""Handle metric edits and regenerate report"""
|
"""Handle metric edits and regenerate report"""
|
||||||
if not request.session.get("temp_dir") or not request.session.get("patient_info"):
|
if not request.session.get("temp_dir") or not request.session.get("patient_info"):
|
||||||
return RedirectResponse(url="/", status_code=303)
|
return RedirectResponse(url="/", status_code=303)
|
||||||
|
|
||||||
# Get form data
|
# Get form data
|
||||||
form_data = await request.form()
|
form_data = await request.form()
|
||||||
|
|
||||||
# Build metric overrides
|
# Build metric overrides
|
||||||
metric_overrides = {
|
metric_overrides = {"pnoe": {}, "spirometry": {}}
|
||||||
"pnoe": {},
|
|
||||||
"spirometry": {}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Pnoe overrides
|
# Pnoe overrides
|
||||||
if form_data.get("vo2_max"):
|
if form_data.get("vo2_max"):
|
||||||
metric_overrides["pnoe"]["vo2_max"] = float(form_data["vo2_max"])
|
metric_overrides["pnoe"]["vo2_max"] = float(form_data["vo2_max"])
|
||||||
@@ -262,28 +334,36 @@ async def edit_metrics(request: Request):
|
|||||||
metric_overrides["pnoe"]["fat_max_value"] = float(form_data["fat_max_value"])
|
metric_overrides["pnoe"]["fat_max_value"] = float(form_data["fat_max_value"])
|
||||||
if form_data.get("fat_max_hr"):
|
if form_data.get("fat_max_hr"):
|
||||||
metric_overrides["pnoe"]["fat_max_hr"] = float(form_data["fat_max_hr"])
|
metric_overrides["pnoe"]["fat_max_hr"] = float(form_data["fat_max_hr"])
|
||||||
|
|
||||||
# VT1 and VT2 overrides
|
# VT1 and VT2 overrides
|
||||||
if form_data.get("vt1_hr") or form_data.get("vt1_speed") or form_data.get("vt1_time"):
|
if (
|
||||||
|
form_data.get("vt1_hr")
|
||||||
|
or form_data.get("vt1_speed")
|
||||||
|
or form_data.get("vt1_time")
|
||||||
|
):
|
||||||
metric_overrides["pnoe"]["vt1"] = {
|
metric_overrides["pnoe"]["vt1"] = {
|
||||||
"HeartRate": float(form_data.get("vt1_hr", 0)),
|
"HeartRate": float(form_data.get("vt1_hr", 0)),
|
||||||
"Speed": float(form_data.get("vt1_speed", 0)),
|
"Speed": float(form_data.get("vt1_speed", 0)),
|
||||||
"Time": float(form_data.get("vt1_time", 0))
|
"Time": float(form_data.get("vt1_time", 0)),
|
||||||
}
|
}
|
||||||
|
|
||||||
if form_data.get("vt2_hr") or form_data.get("vt2_speed") or form_data.get("vt2_time"):
|
if (
|
||||||
|
form_data.get("vt2_hr")
|
||||||
|
or form_data.get("vt2_speed")
|
||||||
|
or form_data.get("vt2_time")
|
||||||
|
):
|
||||||
metric_overrides["pnoe"]["vt2"] = {
|
metric_overrides["pnoe"]["vt2"] = {
|
||||||
"HeartRate": float(form_data.get("vt2_hr", 0)),
|
"HeartRate": float(form_data.get("vt2_hr", 0)),
|
||||||
"Speed": float(form_data.get("vt2_speed", 0)),
|
"Speed": float(form_data.get("vt2_speed", 0)),
|
||||||
"Time": float(form_data.get("vt2_time", 0))
|
"Time": float(form_data.get("vt2_time", 0)),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Heart rate zones
|
# Heart rate zones
|
||||||
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):
|
if form_data.get(zone_key):
|
||||||
metric_overrides["pnoe"][zone_key] = form_data[zone_key]
|
metric_overrides["pnoe"][zone_key] = form_data[zone_key]
|
||||||
|
|
||||||
# Spirometry overrides
|
# Spirometry overrides
|
||||||
if form_data.get("fvc_best"):
|
if form_data.get("fvc_best"):
|
||||||
metric_overrides["spirometry"]["fvc_best"] = float(form_data["fvc_best"])
|
metric_overrides["spirometry"]["fvc_best"] = float(form_data["fvc_best"])
|
||||||
@@ -294,88 +374,120 @@ async def edit_metrics(request: Request):
|
|||||||
if form_data.get("fev1_pred"):
|
if form_data.get("fev1_pred"):
|
||||||
metric_overrides["spirometry"]["fev1_pred"] = float(form_data["fev1_pred"])
|
metric_overrides["spirometry"]["fev1_pred"] = float(form_data["fev1_pred"])
|
||||||
if form_data.get("fev1_fvc_pct_best"):
|
if form_data.get("fev1_fvc_pct_best"):
|
||||||
metric_overrides["spirometry"]["fev1_fvc_pct_best"] = float(form_data["fev1_fvc_pct_best"])
|
metric_overrides["spirometry"]["fev1_fvc_pct_best"] = float(
|
||||||
|
form_data["fev1_fvc_pct_best"]
|
||||||
|
)
|
||||||
if form_data.get("fev1_fvc_pct_pred"):
|
if form_data.get("fev1_fvc_pct_pred"):
|
||||||
metric_overrides["spirometry"]["fev1_fvc_pct_pred"] = float(form_data["fev1_fvc_pct_pred"])
|
metric_overrides["spirometry"]["fev1_fvc_pct_pred"] = float(
|
||||||
|
form_data["fev1_fvc_pct_pred"]
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get file paths from session
|
# Get file paths from session
|
||||||
temp_dir = Path(request.session["temp_dir"])
|
temp_dir = Path(request.session["temp_dir"])
|
||||||
patient_info = request.session["patient_info"]
|
patient_info = request.session["patient_info"]
|
||||||
|
|
||||||
# Find files in temp directory
|
# Find files in temp directory
|
||||||
spirometry_path = None
|
spirometry_path = None
|
||||||
pnoe_path = None
|
pnoe_path = None
|
||||||
seca_path = None
|
oxygenation_path = None
|
||||||
|
|
||||||
for file_path in temp_dir.iterdir():
|
for file_path in temp_dir.iterdir():
|
||||||
if file_path.name.startswith("spirometry_"):
|
if file_path.name.startswith("spirometry_"):
|
||||||
spirometry_path = file_path
|
spirometry_path = file_path
|
||||||
elif file_path.name.startswith("pnoe_"):
|
elif file_path.name.startswith("pnoe_"):
|
||||||
pnoe_path = file_path
|
pnoe_path = file_path
|
||||||
elif file_path.name.startswith("seca_"):
|
elif file_path.name.startswith("oxygenation_"):
|
||||||
seca_path = file_path
|
oxygenation_path = file_path
|
||||||
|
|
||||||
if not all([spirometry_path, pnoe_path, seca_path]):
|
if not all([spirometry_path, pnoe_path]):
|
||||||
raise ValueError("Could not find all uploaded files")
|
raise ValueError("Could not find all required uploaded files")
|
||||||
|
|
||||||
# Regenerate report with overrides
|
# Regenerate report with overrides
|
||||||
|
oxygenation_csv_path = str(oxygenation_path) if oxygenation_path else None
|
||||||
result = await report_service.generate_report(
|
result = await report_service.generate_report(
|
||||||
spirometry_pdf_path=str(spirometry_path),
|
spirometry_pdf_path=str(spirometry_path),
|
||||||
pnoe_csv_path=str(pnoe_path),
|
pnoe_csv_path=str(pnoe_path),
|
||||||
seca_excel_path=str(seca_path),
|
|
||||||
patient_info=patient_info,
|
patient_info=patient_info,
|
||||||
metric_overrides=metric_overrides if (metric_overrides["pnoe"] or metric_overrides["spirometry"]) else None,
|
metric_overrides=metric_overrides
|
||||||
|
if (metric_overrides["pnoe"] or metric_overrides["spirometry"])
|
||||||
|
else None,
|
||||||
|
oxygenation_csv_path=oxygenation_csv_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update session with new report
|
# Update session with new report
|
||||||
request.session["report_path"] = result["report_path"]
|
request.session["report_path"] = result["report_path"]
|
||||||
request.session["graphs_generated"] = result["graphs_generated"]
|
request.session["graphs_generated"] = result["graphs_generated"]
|
||||||
request.session["analysis_data"] = result["analysis_data"]
|
request.session["analysis_data"] = result["analysis_data"]
|
||||||
|
|
||||||
# Recalculate metrics with overrides
|
# Recalculate metrics with overrides
|
||||||
from services.context_generator import ContextGenerator
|
from services.context_generator import ContextGenerator
|
||||||
|
|
||||||
context_gen = ContextGenerator()
|
context_gen = ContextGenerator()
|
||||||
spirometry_csv_path = request.session.get("spirometry_csv_path", "")
|
spirometry_csv_path = request.session.get("spirometry_csv_path", "")
|
||||||
if not spirometry_csv_path or not Path(spirometry_csv_path).exists():
|
if not spirometry_csv_path or not Path(spirometry_csv_path).exists():
|
||||||
from services.spirometry_table_extractor import extract_spirometry_table_from_pdf
|
|
||||||
from pathlib import Path as PathLib
|
from pathlib import Path as PathLib
|
||||||
|
|
||||||
|
from services.spirometry_table_extractor import (
|
||||||
|
extract_spirometry_table_from_pdf,
|
||||||
|
)
|
||||||
|
|
||||||
data_dir = PathLib("data")
|
data_dir = PathLib("data")
|
||||||
spirometry_csv_path = extract_spirometry_table_from_pdf(
|
spirometry_csv_path = extract_spirometry_table_from_pdf(
|
||||||
str(spirometry_path), output_dir=str(data_dir)
|
str(spirometry_path), output_dir=str(data_dir)
|
||||||
)
|
)
|
||||||
spirometry_csv_path = str(PathLib(spirometry_csv_path))
|
spirometry_csv_path = str(PathLib(spirometry_csv_path))
|
||||||
|
|
||||||
context_gen.load_data(
|
context_gen.load_data(
|
||||||
str(pnoe_path),
|
str(pnoe_path),
|
||||||
spirometry_csv_path,
|
spirometry_csv_path,
|
||||||
str(seca_path)
|
None, # No SECA file
|
||||||
)
|
)
|
||||||
|
# Set patient info manually
|
||||||
|
weight_str = patient_info.get("weight", "0")
|
||||||
|
weight_kg = float(weight_str.replace("lbs", "").replace("kg", "").strip())
|
||||||
|
if "lbs" in weight_str.lower():
|
||||||
|
weight_kg = weight_kg / 2.20462 # Convert lbs to kg
|
||||||
|
|
||||||
|
context_gen.patient_info = {
|
||||||
|
"name": patient_info.get("first_name", ""),
|
||||||
|
"last_name": patient_info.get("last_name", ""),
|
||||||
|
"age": patient_info.get("age", 25),
|
||||||
|
"weight": weight_kg,
|
||||||
|
"fat_percentage": patient_info.get("fat_percentage", 0),
|
||||||
|
"gender": patient_info.get("gender", "female"),
|
||||||
|
}
|
||||||
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", {})
|
||||||
pnoe_overrides = metric_overrides.get("pnoe", {})
|
pnoe_overrides = metric_overrides.get("pnoe", {})
|
||||||
spirometry_metrics = context_gen.calculate_spirometry_metrics(spirometry_overrides)
|
spirometry_metrics = context_gen.calculate_spirometry_metrics(
|
||||||
|
spirometry_overrides
|
||||||
|
)
|
||||||
pnoe_metrics = context_gen.calculate_pnoe_metrics(pnoe_overrides)
|
pnoe_metrics = context_gen.calculate_pnoe_metrics(pnoe_overrides)
|
||||||
|
|
||||||
# Update metrics in session
|
# Update metrics in session
|
||||||
request.session["metrics"] = {
|
request.session["metrics"] = {
|
||||||
"spirometry": spirometry_metrics,
|
"spirometry": spirometry_metrics,
|
||||||
"pnoe": pnoe_metrics,
|
"pnoe": pnoe_metrics,
|
||||||
}
|
}
|
||||||
request.session["spirometry_csv_path"] = spirometry_csv_path
|
request.session["spirometry_csv_path"] = spirometry_csv_path
|
||||||
|
|
||||||
return RedirectResponse(url="/preview", status_code=303)
|
return RedirectResponse(url="/preview", status_code=303)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
error_details = traceback.format_exc()
|
error_details = traceback.format_exc()
|
||||||
print(f"ERROR: {error_details}")
|
print(f"ERROR: {error_details}")
|
||||||
return render_template("edit.html", {
|
return render_template(
|
||||||
"request": request,
|
"edit.html",
|
||||||
"session": request.session,
|
{
|
||||||
"error": f"Error regenerating report: {str(e)}"
|
"request": request,
|
||||||
})
|
"session": request.session,
|
||||||
|
"error": f"Error regenerating report: {str(e)}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -6,7 +6,7 @@ of the medical report. It performs analysis on Pnoe, Spirometry, and SECA data.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, Optional, Tuple
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
@@ -24,12 +24,15 @@ class ContextGenerator:
|
|||||||
self,
|
self,
|
||||||
pnoe_path: str,
|
pnoe_path: str,
|
||||||
spirometry_path: str,
|
spirometry_path: str,
|
||||||
seca_path: str,
|
seca_path: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""Load all required datasets"""
|
"""Load all required datasets"""
|
||||||
self.pnoe_df = pd.read_csv(pnoe_path, delimiter=";")
|
self.pnoe_df = pd.read_csv(pnoe_path, delimiter=";")
|
||||||
self.spirometry_df = pd.read_csv(spirometry_path)
|
self.spirometry_df = pd.read_csv(spirometry_path)
|
||||||
self.seca_df = pd.read_excel(seca_path)
|
if seca_path:
|
||||||
|
self.seca_df = pd.read_excel(seca_path)
|
||||||
|
else:
|
||||||
|
self.seca_df = None
|
||||||
self._preprocess_pnoe_data()
|
self._preprocess_pnoe_data()
|
||||||
|
|
||||||
def _preprocess_pnoe_data(self):
|
def _preprocess_pnoe_data(self):
|
||||||
@@ -75,7 +78,7 @@ class ContextGenerator:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def extract_patient_info(self, patient_name: str) -> Dict:
|
def extract_patient_info(self, patient_name: str) -> Dict:
|
||||||
"""Extract patient information from SECA dataset"""
|
"""Extract patient information from SECA dataset or use provided patient_info"""
|
||||||
if self.seca_df is not None:
|
if self.seca_df is not None:
|
||||||
patient_data = self.seca_df[
|
patient_data = self.seca_df[
|
||||||
self.seca_df["LastName"].str.contains(
|
self.seca_df["LastName"].str.contains(
|
||||||
@@ -99,49 +102,73 @@ class ContextGenerator:
|
|||||||
"fat_mass_lbs": weight_kg * fat_pct / 100 * 2.20462,
|
"fat_mass_lbs": weight_kg * fat_pct / 100 * 2.20462,
|
||||||
"lean_mass_lbs": weight_kg * (1 - fat_pct / 100) * 2.20462,
|
"lean_mass_lbs": weight_kg * (1 - fat_pct / 100) * 2.20462,
|
||||||
}
|
}
|
||||||
|
# If patient_info is already set (from manual input), calculate fat_mass and lean_mass
|
||||||
|
elif "weight" in self.patient_info and "fat_percentage" in self.patient_info:
|
||||||
|
weight_kg = self.patient_info["weight"]
|
||||||
|
fat_pct = self.patient_info["fat_percentage"]
|
||||||
|
self.patient_info["fat_mass_lbs"] = weight_kg * fat_pct / 100 * 2.20462
|
||||||
|
self.patient_info["lean_mass_lbs"] = (
|
||||||
|
weight_kg * (1 - fat_pct / 100) * 2.20462
|
||||||
|
)
|
||||||
return self.patient_info
|
return self.patient_info
|
||||||
|
|
||||||
def calculate_spirometry_metrics(self, metric_overrides: Optional[Dict] = None) -> Dict:
|
def calculate_spirometry_metrics(
|
||||||
|
self, metric_overrides: Optional[Dict] = None
|
||||||
|
) -> Dict:
|
||||||
"""Calculate spirometry-related metrics"""
|
"""Calculate spirometry-related metrics"""
|
||||||
if metric_overrides is None:
|
if metric_overrides is None:
|
||||||
metric_overrides = {}
|
metric_overrides = {}
|
||||||
|
|
||||||
metrics = {}
|
metrics = {}
|
||||||
for param in ["FVC", "FEV1", "FEV1/FVC%"]:
|
for param in ["FVC", "FEV1", "FEV1/FVC%"]:
|
||||||
param_key = param.lower().replace("/", "_").replace("%", "_pct")
|
param_key = param.lower().replace("/", "_").replace("%", "_pct")
|
||||||
|
|
||||||
if f"{param_key}_best" in metric_overrides:
|
if f"{param_key}_best" in metric_overrides:
|
||||||
metrics[f"{param_key}_best"] = float(metric_overrides[f"{param_key}_best"])
|
metrics[f"{param_key}_best"] = float(
|
||||||
|
metric_overrides[f"{param_key}_best"]
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
row = self.spirometry_df.loc[
|
row = self.spirometry_df.loc[
|
||||||
self.spirometry_df["Parameters"].str.strip() == param
|
self.spirometry_df["Parameters"].str.strip() == param
|
||||||
]
|
]
|
||||||
if not row.empty:
|
if not row.empty:
|
||||||
metrics[f"{param_key}_best"] = row["Best"].values[0]
|
value = row["Best"].values[0]
|
||||||
|
if pd.notna(value):
|
||||||
|
try:
|
||||||
|
metrics[f"{param_key}_best"] = float(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass # Skip if conversion fails
|
||||||
|
|
||||||
if f"{param_key}_pred" in metric_overrides:
|
if f"{param_key}_pred" in metric_overrides:
|
||||||
metrics[f"{param_key}_pred"] = float(metric_overrides[f"{param_key}_pred"])
|
metrics[f"{param_key}_pred"] = float(
|
||||||
|
metric_overrides[f"{param_key}_pred"]
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
row = self.spirometry_df.loc[
|
row = self.spirometry_df.loc[
|
||||||
self.spirometry_df["Parameters"].str.strip() == param
|
self.spirometry_df["Parameters"].str.strip() == param
|
||||||
]
|
]
|
||||||
if not row.empty:
|
if not row.empty:
|
||||||
metrics[f"{param_key}_pred"] = row["%Pred."].values[0]
|
value = row["%Pred."].values[0]
|
||||||
|
if pd.notna(value):
|
||||||
|
try:
|
||||||
|
metrics[f"{param_key}_pred"] = float(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass # Skip if conversion fails
|
||||||
return metrics
|
return metrics
|
||||||
|
|
||||||
def calculate_pnoe_metrics(self, metric_overrides: Optional[Dict] = None) -> Dict:
|
def calculate_pnoe_metrics(self, metric_overrides: Optional[Dict] = None) -> Dict:
|
||||||
"""Calculate all Pnoe-derived metrics"""
|
"""Calculate all Pnoe-derived metrics"""
|
||||||
if metric_overrides is None:
|
if metric_overrides is None:
|
||||||
metric_overrides = {}
|
metric_overrides = {}
|
||||||
|
|
||||||
metrics = {}
|
metrics = {}
|
||||||
|
|
||||||
# VO2 Max metrics
|
# VO2 Max metrics
|
||||||
if "vo2_max" in metric_overrides:
|
if "vo2_max" in metric_overrides:
|
||||||
metrics["vo2_max"] = float(metric_overrides["vo2_max"])
|
metrics["vo2_max"] = float(metric_overrides["vo2_max"])
|
||||||
else:
|
else:
|
||||||
metrics["vo2_max"] = self.pnoe_df["VO2(ml/min)_smoothed"].max()
|
metrics["vo2_max"] = self.pnoe_df["VO2(ml/min)_smoothed"].max()
|
||||||
|
|
||||||
if "vo2_max_per_kg" in metric_overrides:
|
if "vo2_max_per_kg" in metric_overrides:
|
||||||
metrics["vo2_max_per_kg"] = float(metric_overrides["vo2_max_per_kg"])
|
metrics["vo2_max_per_kg"] = float(metric_overrides["vo2_max_per_kg"])
|
||||||
else:
|
else:
|
||||||
@@ -184,7 +211,7 @@ class ContextGenerator:
|
|||||||
else:
|
else:
|
||||||
vt1, _ = self._detect_thresholds()
|
vt1, _ = self._detect_thresholds()
|
||||||
metrics["vt1"] = vt1
|
metrics["vt1"] = vt1
|
||||||
|
|
||||||
if "vt2" in metric_overrides:
|
if "vt2" in metric_overrides:
|
||||||
metrics["vt2"] = metric_overrides["vt2"]
|
metrics["vt2"] = metric_overrides["vt2"]
|
||||||
else:
|
else:
|
||||||
@@ -200,9 +227,11 @@ class ContextGenerator:
|
|||||||
else:
|
else:
|
||||||
fat_max_idx = self.pnoe_df["FAT_smoothed"].idxmax()
|
fat_max_idx = self.pnoe_df["FAT_smoothed"].idxmax()
|
||||||
fat_max_row = self.pnoe_df.loc[fat_max_idx]
|
fat_max_row = self.pnoe_df.loc[fat_max_idx]
|
||||||
zones = self._calculate_hr_zones(metrics["vt1"], metrics["vt2"], fat_max_row)
|
zones = self._calculate_hr_zones(
|
||||||
|
metrics["vt1"], metrics["vt2"], fat_max_row
|
||||||
|
)
|
||||||
metrics.update(zones)
|
metrics.update(zones)
|
||||||
|
|
||||||
return metrics
|
return metrics
|
||||||
|
|
||||||
def _detect_thresholds(self) -> Tuple[Optional[Dict], Optional[Dict]]:
|
def _detect_thresholds(self) -> Tuple[Optional[Dict], Optional[Dict]]:
|
||||||
@@ -261,95 +290,463 @@ class ContextGenerator:
|
|||||||
zones["zone5_bpm"] = f"{int(max_hr * 0.95)}+bpm"
|
zones["zone5_bpm"] = f"{int(max_hr * 0.95)}+bpm"
|
||||||
return zones
|
return zones
|
||||||
|
|
||||||
|
def _calculate_vo2_drop_points(self, pnoe_metrics: Dict) -> Dict:
|
||||||
|
"""Calculate VO2 Pulse and VO2 Breath drop points"""
|
||||||
|
# Calculate slope of VO2 Pulse
|
||||||
|
vo2_pulse_slope = self.pnoe_df["VO2 Pulse_smoothed"].diff()
|
||||||
|
window = max(1, len(self.pnoe_df) // 3) # Ensure window is at least 1
|
||||||
|
vo2_pulse_slope_smoothed = vo2_pulse_slope.rolling(window=window, min_periods=1).mean()
|
||||||
|
|
||||||
|
# Find where VO2 Pulse begins to drop (slope becomes negative)
|
||||||
|
mask_pulse = vo2_pulse_slope_smoothed <= 0
|
||||||
|
drop_indices_pulse = mask_pulse[mask_pulse].index
|
||||||
|
|
||||||
|
vo2_pulse_drop_bpm = None
|
||||||
|
vo2_pulse_drop_zone = None
|
||||||
|
if len(drop_indices_pulse) > 0:
|
||||||
|
drop_idx = drop_indices_pulse[0]
|
||||||
|
drop_row = self.pnoe_df.loc[drop_idx]
|
||||||
|
vo2_pulse_drop_bpm = int(drop_row["HR(bpm)_smoothed"])
|
||||||
|
# Determine zone based on HR zones
|
||||||
|
if pnoe_metrics.get("zone1_bpm") and vo2_pulse_drop_bpm:
|
||||||
|
zones = [pnoe_metrics.get(f"zone{i}_bpm", "") for i in range(1, 6)]
|
||||||
|
for i, zone_str in enumerate(zones, 1):
|
||||||
|
if zone_str:
|
||||||
|
zone_clean = zone_str.replace("bpm", "").strip()
|
||||||
|
if "-" in zone_clean:
|
||||||
|
parts = zone_clean.split("-")
|
||||||
|
if len(parts) == 2:
|
||||||
|
try:
|
||||||
|
start, end = int(parts[0]), int(parts[1].replace("+", ""))
|
||||||
|
if start <= vo2_pulse_drop_bpm <= end:
|
||||||
|
vo2_pulse_drop_zone = f"Zone {i}"
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
elif "+" in zone_clean:
|
||||||
|
# Zone 5 format: "180+bpm"
|
||||||
|
try:
|
||||||
|
start = int(zone_clean.replace("+", ""))
|
||||||
|
if vo2_pulse_drop_bpm >= start:
|
||||||
|
vo2_pulse_drop_zone = f"Zone {i}"
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Calculate slope of VO2 Breath
|
||||||
|
vo2_breath_slope = self.pnoe_df["VO2 Breath_smoothed"].diff()
|
||||||
|
vo2_breath_slope_smoothed = vo2_breath_slope.rolling(window=window, min_periods=1).mean()
|
||||||
|
|
||||||
|
# Find where VO2 Breath begins to drop
|
||||||
|
mask_breath = vo2_breath_slope_smoothed <= 0
|
||||||
|
drop_indices_breath = mask_breath[mask_breath].index
|
||||||
|
|
||||||
|
vo2_breath_drop_bpm = None
|
||||||
|
vo2_breath_drop_zone = None
|
||||||
|
if len(drop_indices_breath) > 0:
|
||||||
|
drop_idx = drop_indices_breath[0]
|
||||||
|
drop_row = self.pnoe_df.loc[drop_idx]
|
||||||
|
vo2_breath_drop_bpm = int(drop_row["HR(bpm)_smoothed"])
|
||||||
|
# Determine zone
|
||||||
|
if pnoe_metrics.get("zone1_bpm") and vo2_breath_drop_bpm:
|
||||||
|
zones = [pnoe_metrics.get(f"zone{i}_bpm", "") for i in range(1, 6)]
|
||||||
|
for i, zone_str in enumerate(zones, 1):
|
||||||
|
if zone_str:
|
||||||
|
zone_clean = zone_str.replace("bpm", "").strip()
|
||||||
|
if "-" in zone_clean:
|
||||||
|
parts = zone_clean.split("-")
|
||||||
|
if len(parts) == 2:
|
||||||
|
try:
|
||||||
|
start, end = int(parts[0]), int(parts[1].replace("+", ""))
|
||||||
|
if start <= vo2_breath_drop_bpm <= end:
|
||||||
|
vo2_breath_drop_zone = f"Zone {i}"
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
elif "+" in zone_clean:
|
||||||
|
# Zone 5 format: "180+bpm"
|
||||||
|
try:
|
||||||
|
start = int(zone_clean.replace("+", ""))
|
||||||
|
if vo2_breath_drop_bpm >= start:
|
||||||
|
vo2_breath_drop_zone = f"Zone {i}"
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"vo2_pulse_drop_bpm": vo2_pulse_drop_bpm or 180,
|
||||||
|
"vo2_pulse_drop_zone": vo2_pulse_drop_zone or "Zone 4",
|
||||||
|
"vo2_breath_drop_bpm": vo2_breath_drop_bpm or 173,
|
||||||
|
"vo2_breath_drop_zone": vo2_breath_drop_zone or "Zone 3",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _calculate_fat_metabolism_metrics(self, pnoe_metrics: Dict) -> Dict:
|
||||||
|
"""Calculate fat metabolism metrics for page 11"""
|
||||||
|
fat_max_idx = self.pnoe_df["FAT_smoothed"].idxmax()
|
||||||
|
fat_max_row = self.pnoe_df.loc[fat_max_idx]
|
||||||
|
|
||||||
|
fat_max_value = pnoe_metrics.get("fat_max_value", 0)
|
||||||
|
fat_max_hr = pnoe_metrics.get("fat_max_hr", 0)
|
||||||
|
max_hr = 220 - self.patient_info["age"]
|
||||||
|
fat_max_heart_rate_pct = (fat_max_hr / max_hr * 100) if max_hr > 0 else 0
|
||||||
|
|
||||||
|
# Find carbs and fat crossover point
|
||||||
|
crossover_idx = None
|
||||||
|
for idx in self.pnoe_df.index:
|
||||||
|
if self.pnoe_df.loc[idx, "CHO_smoothed"] > self.pnoe_df.loc[idx, "FAT_smoothed"]:
|
||||||
|
crossover_idx = idx
|
||||||
|
break
|
||||||
|
|
||||||
|
crossover_bpm = None
|
||||||
|
crossover_heart_rate_pct = None
|
||||||
|
if crossover_idx is not None:
|
||||||
|
crossover_row = self.pnoe_df.loc[crossover_idx]
|
||||||
|
crossover_bpm = int(crossover_row["HR(bpm)_smoothed"])
|
||||||
|
crossover_heart_rate_pct = (crossover_bpm / max_hr * 100) if max_hr > 0 else 0
|
||||||
|
|
||||||
|
# Get speed and incline at fat max
|
||||||
|
fat_max_speed = fat_max_row.get("Speed", 0)
|
||||||
|
fat_max_incline = fat_max_row.get("Incline", 2.0) if "Incline" in fat_max_row else 2.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"fat_max_value": f"{fat_max_value:.2f}Kcals/min",
|
||||||
|
"fat_max_heart_rate": f"{fat_max_heart_rate_pct:.0f}% of Max Heart Rate",
|
||||||
|
"fat_max_bpm": f"{int(fat_max_hr)} bpm",
|
||||||
|
"fat_max_optimal": "*Optimal 10-12Kcals/minute",
|
||||||
|
"crossover_bpm": f"{crossover_bpm or 100}bpm",
|
||||||
|
"crossover_heart_rate": f"{crossover_heart_rate_pct or 51:.0f}% of Max Heart Rate",
|
||||||
|
"fat_metabolism_note": f"{crossover_bpm or 100}bpm at a speed of {fat_max_speed:.1f}mph and incline of {fat_max_incline:.0f}%",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _calculate_recovery_metrics(self) -> Dict:
|
||||||
|
"""Calculate recovery metrics for page 11"""
|
||||||
|
# Find peak exercise point (max HR)
|
||||||
|
peak_idx = self.pnoe_df["HR(bpm)_smoothed"].idxmax()
|
||||||
|
peak_hr = self.pnoe_df.loc[peak_idx, "HR(bpm)_smoothed"]
|
||||||
|
peak_time = self.pnoe_df.loc[peak_idx, "T(sec)"]
|
||||||
|
|
||||||
|
# Find recovery phase (after peak)
|
||||||
|
recovery_df = self.pnoe_df[self.pnoe_df["T(sec)"] > peak_time].copy()
|
||||||
|
|
||||||
|
if len(recovery_df) == 0:
|
||||||
|
return {
|
||||||
|
"cardiac_recovery_time": "(1 minute)",
|
||||||
|
"cardiac_recovery_percentage": "33%",
|
||||||
|
"metabolic_recovery_time": "(2 minute)",
|
||||||
|
"metabolic_recovery_percentage": "65%",
|
||||||
|
"breath_recovery_time": "(2.5 minute)",
|
||||||
|
"breath_recovery_percentage": "76%",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cardiac recovery (1 minute)
|
||||||
|
one_min_time = peak_time + 60
|
||||||
|
one_min_row = recovery_df[recovery_df["T(sec)"] <= one_min_time]
|
||||||
|
if len(one_min_row) > 0:
|
||||||
|
one_min_hr = one_min_row.iloc[-1]["HR(bpm)_smoothed"]
|
||||||
|
cardiac_recovery_pct = ((peak_hr - one_min_hr) / peak_hr * 100) if peak_hr > 0 else 0
|
||||||
|
else:
|
||||||
|
cardiac_recovery_pct = 33
|
||||||
|
|
||||||
|
# Metabolic recovery (2 minutes) - using VCO2
|
||||||
|
two_min_time = peak_time + 120
|
||||||
|
peak_vco2 = self.pnoe_df.loc[peak_idx, "VCO2(ml/min)_smoothed"]
|
||||||
|
two_min_row = recovery_df[recovery_df["T(sec)"] <= two_min_time]
|
||||||
|
if len(two_min_row) > 0:
|
||||||
|
two_min_vco2 = two_min_row.iloc[-1]["VCO2(ml/min)_smoothed"]
|
||||||
|
metabolic_recovery_pct = ((peak_vco2 - two_min_vco2) / peak_vco2 * 100) if peak_vco2 > 0 else 0
|
||||||
|
else:
|
||||||
|
metabolic_recovery_pct = 65
|
||||||
|
|
||||||
|
# Breath frequency recovery (2.5 minutes)
|
||||||
|
two_five_min_time = peak_time + 150
|
||||||
|
peak_bf = self.pnoe_df.loc[peak_idx, "BF(bpm)_smoothed"]
|
||||||
|
two_five_min_row = recovery_df[recovery_df["T(sec)"] <= two_five_min_time]
|
||||||
|
if len(two_five_min_row) > 0:
|
||||||
|
two_five_min_bf = two_five_min_row.iloc[-1]["BF(bpm)_smoothed"]
|
||||||
|
breath_recovery_pct = ((peak_bf - two_five_min_bf) / peak_bf * 100) if peak_bf > 0 else 0
|
||||||
|
else:
|
||||||
|
breath_recovery_pct = 76
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cardiac_recovery_time": "(1 minute)",
|
||||||
|
"cardiac_recovery_percentage": f"{int(cardiac_recovery_pct)}%",
|
||||||
|
"metabolic_recovery_time": "(2 minute)",
|
||||||
|
"metabolic_recovery_percentage": f"{int(metabolic_recovery_pct)}%",
|
||||||
|
"breath_recovery_time": "(2.5 minute)",
|
||||||
|
"breath_recovery_percentage": f"{int(breath_recovery_pct)}%",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _calculate_resting_heart_rate_metrics(self) -> Dict:
|
||||||
|
"""Calculate resting heart rate metrics for page 11"""
|
||||||
|
# Get resting HR from beginning of test
|
||||||
|
rest_phase = self.pnoe_df.head(30) # First 30 seconds
|
||||||
|
resting_hr = rest_phase["HR(bpm)_smoothed"].mean()
|
||||||
|
|
||||||
|
age = self.patient_info.get("age", 30)
|
||||||
|
gender = self.patient_info.get("gender", "female").lower()
|
||||||
|
|
||||||
|
# Determine age range
|
||||||
|
if 26 <= age <= 35:
|
||||||
|
age_range = "26-35"
|
||||||
|
elif 36 <= age <= 45:
|
||||||
|
age_range = "36-45"
|
||||||
|
elif 46 <= age <= 55:
|
||||||
|
age_range = "46-55"
|
||||||
|
else:
|
||||||
|
age_range = "26-35" # Default
|
||||||
|
|
||||||
|
# HR ranges based on gender and age (simplified)
|
||||||
|
if gender == "female":
|
||||||
|
hr_ranges = {
|
||||||
|
"poor": "82bpm +",
|
||||||
|
"below_avg": "75-81bpm",
|
||||||
|
"average": "71-74bpm",
|
||||||
|
"above_avg": "66-70bpm",
|
||||||
|
"good": "62-65bpm",
|
||||||
|
"excellent": "55-61bpm",
|
||||||
|
"athlete": "44-54bpm",
|
||||||
|
}
|
||||||
|
else: # male
|
||||||
|
hr_ranges = {
|
||||||
|
"poor": "82bpm +",
|
||||||
|
"below_avg": "75-81bpm",
|
||||||
|
"average": "71-74bpm",
|
||||||
|
"above_avg": "66-70bpm",
|
||||||
|
"good": "62-65bpm",
|
||||||
|
"excellent": "55-61bpm",
|
||||||
|
"athlete": "44-54bpm",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"resting_heart_rate": f"{int(resting_hr)}bpm",
|
||||||
|
"hr_age_range": age_range,
|
||||||
|
"hr_poor": hr_ranges["poor"],
|
||||||
|
"hr_below_avg": hr_ranges["below_avg"],
|
||||||
|
"hr_average": hr_ranges["average"],
|
||||||
|
"hr_above_avg": hr_ranges["above_avg"],
|
||||||
|
"hr_good": hr_ranges["good"],
|
||||||
|
"hr_excellent": hr_ranges["excellent"],
|
||||||
|
"hr_athlete": hr_ranges["athlete"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def calculate_rmr_and_fuel_source(self) -> Dict:
|
||||||
|
"""Calculate RMR and fuel source from pnoe data"""
|
||||||
|
metrics = {}
|
||||||
|
|
||||||
|
# Calculate RMR from resting phase (MET <= 1.1)
|
||||||
|
if "MET" in self.pnoe_df.columns and "EE(kcal/day)" in self.pnoe_df.columns:
|
||||||
|
rest_phase = self.pnoe_df[self.pnoe_df["MET"] <= 1.1]
|
||||||
|
if not rest_phase.empty:
|
||||||
|
rmr = rest_phase["EE(kcal/day)"].mean()
|
||||||
|
metrics["rmr_kcal"] = float(rmr)
|
||||||
|
else:
|
||||||
|
# Fallback: use minimum EE(kcal/min) * 1440 (minutes per day)
|
||||||
|
if "EE(kcal/min)" in self.pnoe_df.columns:
|
||||||
|
min_ee = self.pnoe_df["EE(kcal/min)"].min()
|
||||||
|
metrics["rmr_kcal"] = float(min_ee * 1440)
|
||||||
|
else:
|
||||||
|
metrics["rmr_kcal"] = 1500.0 # Default fallback
|
||||||
|
else:
|
||||||
|
# Fallback: estimate from weight (simplified)
|
||||||
|
weight_kg = self.patient_info.get("weight", 70)
|
||||||
|
gender = self.patient_info.get("gender", "female").lower()
|
||||||
|
|
||||||
|
# Simplified RMR estimation: 22 kcal/kg/day for men, 20 for women
|
||||||
|
if gender == "male":
|
||||||
|
rmr = weight_kg * 22
|
||||||
|
else:
|
||||||
|
rmr = weight_kg * 20
|
||||||
|
metrics["rmr_kcal"] = float(rmr)
|
||||||
|
|
||||||
|
# Calculate fuel source from resting phase (RER == 0.9 or closest)
|
||||||
|
if "RER" in self.pnoe_df.columns and "FAT(%)" in self.pnoe_df.columns:
|
||||||
|
# Find rest phase with RER closest to 0.9
|
||||||
|
rest_phase = (
|
||||||
|
self.pnoe_df[self.pnoe_df["MET"] <= 1.1].copy()
|
||||||
|
if "MET" in self.pnoe_df.columns
|
||||||
|
else self.pnoe_df.copy()
|
||||||
|
)
|
||||||
|
if not rest_phase.empty:
|
||||||
|
# Find row with RER closest to 0.9
|
||||||
|
if "RER" in rest_phase.columns:
|
||||||
|
rest_phase["RER_diff"] = abs(rest_phase["RER"] - 0.9)
|
||||||
|
closest_idx = rest_phase["RER_diff"].idxmin()
|
||||||
|
fat_pct = rest_phase.loc[closest_idx, "FAT(%)"]
|
||||||
|
metrics["rest_fat_percentage"] = float(fat_pct)
|
||||||
|
else:
|
||||||
|
# Use mean FAT(%) from rest phase
|
||||||
|
metrics["rest_fat_percentage"] = float(rest_phase["FAT(%)"].mean())
|
||||||
|
else:
|
||||||
|
# Fallback: use overall mean
|
||||||
|
metrics["rest_fat_percentage"] = float(self.pnoe_df["FAT(%)"].mean())
|
||||||
|
else:
|
||||||
|
# Fallback: use a default value
|
||||||
|
metrics["rest_fat_percentage"] = 75.0
|
||||||
|
|
||||||
|
# Calculate caloric values for page 5
|
||||||
|
rmr = metrics["rmr_kcal"]
|
||||||
|
neat = rmr * 0.25 # NEAT is typically 20-30% of RMR
|
||||||
|
weight_loss_rate = 1.0 # 1 lb per week
|
||||||
|
weight_loss_calories = 500.0 # 500 kcal deficit per day for 1 lb/week
|
||||||
|
total_calories = rmr + neat - weight_loss_calories
|
||||||
|
|
||||||
|
metrics["resting_calories"] = int(rmr)
|
||||||
|
metrics["neat_calories"] = int(neat)
|
||||||
|
metrics["weight_loss_calories"] = int(weight_loss_calories)
|
||||||
|
metrics["weight_loss_rate"] = weight_loss_rate
|
||||||
|
metrics["total_calories"] = int(total_calories)
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
|
||||||
def generate_all_contexts(
|
def generate_all_contexts(
|
||||||
self, patient_name: str, graphs: Dict[str, str], metric_overrides: Optional[Dict] = None
|
self,
|
||||||
) -> List[Dict]:
|
patient_name: str,
|
||||||
"""Main method to generate all page contexts"""
|
graphs: Dict[str, str],
|
||||||
|
metric_overrides: Optional[Dict] = None,
|
||||||
|
) -> Dict[str, Dict]:
|
||||||
|
"""Main method to generate all page contexts
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with keys 'page_1', 'page_2', etc., each containing context data for that page
|
||||||
|
"""
|
||||||
if metric_overrides is None:
|
if metric_overrides is None:
|
||||||
metric_overrides = {}
|
metric_overrides = {}
|
||||||
|
|
||||||
self.extract_patient_info(patient_name)
|
self.extract_patient_info(patient_name)
|
||||||
|
|
||||||
# Extract metric overrides for spirometry and pnoe
|
# Extract metric overrides for spirometry and pnoe
|
||||||
spirometry_overrides = metric_overrides.get("spirometry", {})
|
spirometry_overrides = metric_overrides.get("spirometry", {})
|
||||||
pnoe_overrides = metric_overrides.get("pnoe", {})
|
pnoe_overrides = metric_overrides.get("pnoe", {})
|
||||||
|
|
||||||
spirometry_metrics = self.calculate_spirometry_metrics(spirometry_overrides)
|
spirometry_metrics = self.calculate_spirometry_metrics(spirometry_overrides)
|
||||||
pnoe_metrics = self.calculate_pnoe_metrics(pnoe_overrides)
|
pnoe_metrics = self.calculate_pnoe_metrics(pnoe_overrides)
|
||||||
|
rmr_metrics = self.calculate_rmr_and_fuel_source()
|
||||||
|
|
||||||
contexts = []
|
contexts = {}
|
||||||
contexts.append(
|
|
||||||
{
|
# Page 1
|
||||||
"name": self.patient_info["name"],
|
contexts["page_1"] = {
|
||||||
"surname": self.patient_info["last_name"],
|
"name": self.patient_info["name"],
|
||||||
"date": datetime.now().strftime("%B %d, %Y"),
|
"surname": self.patient_info["last_name"],
|
||||||
}
|
"date": datetime.now().strftime("%B %d, %Y"),
|
||||||
)
|
}
|
||||||
contexts.append(
|
|
||||||
{
|
# Page 2
|
||||||
|
contexts["page_2"] = {
|
||||||
|
"patient_name": self.patient_info["name"],
|
||||||
|
"test_date": datetime.now().strftime("%B %d, %Y"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pages 3, 6 (pages 4 and 5 are handled separately)
|
||||||
|
for i in [0, 3]: # Skip indices 1 and 2 which are pages 4 and 5
|
||||||
|
contexts[f"page_{i + 3}"] = {
|
||||||
"patient_name": self.patient_info["name"],
|
"patient_name": self.patient_info["name"],
|
||||||
"test_date": datetime.now().strftime("%B %d, %Y"),
|
"page_number": i + 3,
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
for i in range(4):
|
# Page 4 - Nutrition Guidelines with Body Composition
|
||||||
contexts.append(
|
contexts["page_4"] = {
|
||||||
{"patient_name": self.patient_info["name"], "page_number": i + 3}
|
"patient_name": self.patient_info["name"],
|
||||||
)
|
"page_number": 4,
|
||||||
|
"fat_percentage": f"{self.patient_info['fat_percentage']:.1f}",
|
||||||
|
"body_composition_chart": graphs.get("body_composition", ""),
|
||||||
|
"body_fat_chart": graphs.get("body_fat_percent", ""), # Alias for template
|
||||||
|
"body_fat_percent_chart": graphs.get(
|
||||||
|
"body_fat_percent", ""
|
||||||
|
), # Keep for consistency
|
||||||
|
}
|
||||||
|
|
||||||
|
# Page 5 - Resting Metabolic Rate Assessment
|
||||||
|
contexts["page_5"] = {
|
||||||
|
"patient_name": self.patient_info["name"],
|
||||||
|
"page_number": 5,
|
||||||
|
"metabolism_chart": graphs.get("metabolism_chart", ""),
|
||||||
|
"fuel_source_chart": graphs.get("fuel_source_chart", ""),
|
||||||
|
"resting_calories": rmr_metrics.get("resting_calories", 1500),
|
||||||
|
"neat_calories": rmr_metrics.get("neat_calories", 375),
|
||||||
|
"weight_loss_calories": rmr_metrics.get("weight_loss_calories", 500),
|
||||||
|
"weight_loss_rate": rmr_metrics.get("weight_loss_rate", 1.0),
|
||||||
|
"total_calories": rmr_metrics.get("total_calories", 1375),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate FEV1 percentage for page 7
|
||||||
fev1_percentage = 0
|
fev1_percentage = 0
|
||||||
if spirometry_metrics.get("fvc_best"):
|
if spirometry_metrics.get("fvc_best"):
|
||||||
fev1_percentage = (
|
fev1_percentage = (
|
||||||
pnoe_metrics["peak_vt"] / spirometry_metrics["fvc_best"]
|
pnoe_metrics["peak_vt"] / spirometry_metrics["fvc_best"]
|
||||||
) * 100
|
) * 100
|
||||||
|
|
||||||
contexts.append(
|
# 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", ""),
|
||||||
}
|
}
|
||||||
)
|
|
||||||
contexts.append(
|
|
||||||
{
|
|
||||||
"vo2_max_value": f"{pnoe_metrics['vo2_max_per_kg']:.1f}",
|
|
||||||
"age_range": f"{self.patient_info['age'] // 10 * 10}-{self.patient_info['age'] // 10 * 10 + 9}",
|
|
||||||
"zone1_bpm": pnoe_metrics.get("zone1_bpm", ""),
|
|
||||||
"zone2_bpm": pnoe_metrics.get("zone2_bpm", ""),
|
|
||||||
"zone3_bpm": pnoe_metrics.get("zone3_bpm", ""),
|
|
||||||
"zone4_bpm": pnoe_metrics.get("zone4_bpm", ""),
|
|
||||||
"zone5_bpm": pnoe_metrics.get("zone5_bpm", ""),
|
|
||||||
"vo2_pulse_chart": graphs.get("vo2_pulse", ""),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
contexts.append(
|
|
||||||
{
|
|
||||||
"fat_max_value": f"{pnoe_metrics['fat_max_value']:.2f}",
|
|
||||||
"fat_max_hr": f"{int(pnoe_metrics['fat_max_hr'])}",
|
|
||||||
"fuel_utilization_chart": graphs.get("fuel_utilization", ""),
|
|
||||||
"fat_metabolism_chart": graphs.get("fat_metabolism", ""),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
contexts.append(
|
|
||||||
{
|
|
||||||
"fat_percentage": f"{self.patient_info['fat_percentage']:.1f}",
|
|
||||||
"fat_mass_lbs": f"{self.patient_info['fat_mass_lbs']:.1f}",
|
|
||||||
"lean_mass_lbs": f"{self.patient_info['lean_mass_lbs']:.1f}",
|
|
||||||
"body_composition_chart": graphs.get("body_composition", ""),
|
|
||||||
"body_fat_percent_chart": graphs.get("body_fat_percent", ""),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
for i in range(9):
|
# Page 8
|
||||||
contexts.append(
|
contexts["page_8"] = {
|
||||||
{
|
"vo2_max_value": f"{pnoe_metrics['vo2_max_per_kg']:.1f}",
|
||||||
"patient_name": self.patient_info["name"],
|
"age_range": f"{self.patient_info['age'] // 10 * 10}-{self.patient_info['age'] // 10 * 10 + 9}",
|
||||||
"page_number": i + 11,
|
"zone1_bpm": pnoe_metrics.get("zone1_bpm", ""),
|
||||||
"vo2_breath_chart": graphs.get("vo2_breath", ""),
|
"zone2_bpm": pnoe_metrics.get("zone2_bpm", ""),
|
||||||
"recovery_chart": graphs.get("recovery", ""),
|
"zone3_bpm": pnoe_metrics.get("zone3_bpm", ""),
|
||||||
}
|
"zone4_bpm": pnoe_metrics.get("zone4_bpm", ""),
|
||||||
)
|
"zone5_bpm": pnoe_metrics.get("zone5_bpm", ""),
|
||||||
|
"vo2_pulse_chart": graphs.get("vo2_pulse", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Page 9
|
||||||
|
contexts["page_9"] = {
|
||||||
|
"fat_max_value": f"{pnoe_metrics['fat_max_value']:.2f}",
|
||||||
|
"fat_max_hr": f"{int(pnoe_metrics['fat_max_hr'])}",
|
||||||
|
"fuel_utilization_chart": graphs.get("fuel_utilization", ""),
|
||||||
|
"fat_metabolism_chart": graphs.get("fat_metabolism", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Page 10 - VO2 Pulse and VO2 Breath
|
||||||
|
vo2_drop_metrics = self._calculate_vo2_drop_points(pnoe_metrics)
|
||||||
|
contexts["page_10"] = {
|
||||||
|
"vo2_pulse_chart": graphs.get("vo2_pulse", ""),
|
||||||
|
"vo2_breath_chart": graphs.get("vo2_breath", ""),
|
||||||
|
"vo2_pulse_drop_bpm": f"{vo2_drop_metrics['vo2_pulse_drop_bpm']} bpm",
|
||||||
|
"vo2_pulse_drop_zone": vo2_drop_metrics["vo2_pulse_drop_zone"],
|
||||||
|
"vo2_breath_drop_bpm": f"{vo2_drop_metrics['vo2_breath_drop_bpm']} bpm",
|
||||||
|
"vo2_breath_drop_zone": vo2_drop_metrics["vo2_breath_drop_zone"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Page 11 - Fat Metabolism and Recovery
|
||||||
|
fat_metabolism_metrics = self._calculate_fat_metabolism_metrics(pnoe_metrics)
|
||||||
|
recovery_metrics = self._calculate_recovery_metrics()
|
||||||
|
resting_hr_metrics = self._calculate_resting_heart_rate_metrics()
|
||||||
|
|
||||||
|
contexts["page_11"] = {
|
||||||
|
"fat_metabolism_chart": graphs.get("fat_metabolism", ""),
|
||||||
|
"recovery_chart": graphs.get("recovery", ""),
|
||||||
|
**fat_metabolism_metrics,
|
||||||
|
**recovery_metrics,
|
||||||
|
**resting_hr_metrics,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pages 12-17
|
||||||
|
for i in range(6):
|
||||||
|
contexts[f"page_{i + 12}"] = {
|
||||||
|
"patient_name": self.patient_info["name"],
|
||||||
|
"page_number": i + 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Page 18 - Glossary with Body Fat Percentage Chart
|
||||||
|
contexts["page_18"] = {
|
||||||
|
"patient_name": self.patient_info["name"],
|
||||||
|
"page_number": 18,
|
||||||
|
"body_fat_percentage_chart": graphs.get("body_fat_percent", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Page 19
|
||||||
|
contexts["page_19"] = {
|
||||||
|
"patient_name": self.patient_info["name"],
|
||||||
|
"page_number": 19,
|
||||||
|
}
|
||||||
|
|
||||||
return contexts
|
return contexts
|
||||||
|
|||||||
+392
-23
@@ -584,6 +584,105 @@ 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_tsi_chart(
|
||||||
|
self, oxygenation_df: pd.DataFrame, save_as_base64: bool = True
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate TSI (Tissue Saturation Index) chart with trend lines per stage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
oxygenation_df: DataFrame with Time, TSI, and TSI-second columns
|
||||||
|
save_as_base64: If True, return base64 string, else return file path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64 string or file path
|
||||||
|
"""
|
||||||
|
from numpy.polynomial.polynomial import Polynomial
|
||||||
|
|
||||||
|
plt.figure(figsize=(12, 5.5))
|
||||||
|
|
||||||
|
# Plot TSI (Left Leg)
|
||||||
|
plt.plot(
|
||||||
|
oxygenation_df["Time"],
|
||||||
|
oxygenation_df["TSI"],
|
||||||
|
label="TSI (Left Leg)",
|
||||||
|
color="steelblue",
|
||||||
|
linewidth=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Plot TSI2 (Right Leg)
|
||||||
|
plt.plot(
|
||||||
|
oxygenation_df["Time"],
|
||||||
|
oxygenation_df["TSI-second"],
|
||||||
|
label="TSI2 (Right Leg)",
|
||||||
|
color="orange",
|
||||||
|
linewidth=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define time intervals for stages (adjust these based on your test protocol)
|
||||||
|
max_time = oxygenation_df["Time"].max()
|
||||||
|
intervals = [
|
||||||
|
(0, 250),
|
||||||
|
(250, 500),
|
||||||
|
(500, 750),
|
||||||
|
(750, 1000),
|
||||||
|
(1000, 1250),
|
||||||
|
(1250, 1500),
|
||||||
|
(1500, max_time),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Calculate and plot trend lines for each interval
|
||||||
|
for start_time, end_time in intervals:
|
||||||
|
# Filter data for this interval
|
||||||
|
mask_interval = (oxygenation_df["Time"] >= start_time) & (
|
||||||
|
oxygenation_df["Time"] <= end_time
|
||||||
|
)
|
||||||
|
|
||||||
|
# TSI (Left Leg) trend for this interval
|
||||||
|
mask_left = mask_interval & ~oxygenation_df["TSI"].isna()
|
||||||
|
if mask_left.sum() > 1: # Need at least 2 points for a line
|
||||||
|
x_left = oxygenation_df.loc[mask_left, "Time"]
|
||||||
|
y_left = oxygenation_df.loc[mask_left, "TSI"]
|
||||||
|
coefs_left = Polynomial.fit(x_left, y_left, 1).convert().coef
|
||||||
|
trend_left = coefs_left[0] + coefs_left[1] * x_left
|
||||||
|
plt.plot(
|
||||||
|
x_left,
|
||||||
|
trend_left,
|
||||||
|
color="black",
|
||||||
|
linestyle="--",
|
||||||
|
linewidth=2,
|
||||||
|
alpha=0.8,
|
||||||
|
)
|
||||||
|
|
||||||
|
# TSI-second (Right Leg) trend for this interval
|
||||||
|
mask_right = mask_interval & ~oxygenation_df["TSI-second"].isna()
|
||||||
|
if mask_right.sum() > 1: # Need at least 2 points for a line
|
||||||
|
x_right = oxygenation_df.loc[mask_right, "Time"]
|
||||||
|
y_right = oxygenation_df.loc[mask_right, "TSI-second"]
|
||||||
|
coefs_right = Polynomial.fit(x_right, y_right, 1).convert().coef
|
||||||
|
trend_right = coefs_right[0] + coefs_right[1] * x_right
|
||||||
|
plt.plot(
|
||||||
|
x_right,
|
||||||
|
trend_right,
|
||||||
|
color="black",
|
||||||
|
linestyle="--",
|
||||||
|
linewidth=2,
|
||||||
|
alpha=0.8,
|
||||||
|
)
|
||||||
|
|
||||||
|
plt.xlabel("Time (s)")
|
||||||
|
plt.ylabel("TSI (%)")
|
||||||
|
plt.title("TSI (Left) and TSI2 (Right) with Black Slope Lines per Stage")
|
||||||
|
plt.legend(fontsize=10, loc="upper right")
|
||||||
|
plt.grid(alpha=0.25)
|
||||||
|
plt.tight_layout()
|
||||||
|
|
||||||
|
chart_path = self.charts_dir / "tsi_chart.png"
|
||||||
|
plt.savefig(chart_path, bbox_inches="tight", dpi=160)
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||||
|
|
||||||
def generate_body_composition_chart(
|
def generate_body_composition_chart(
|
||||||
self, fat_mass_lbs: float, lean_mass_lbs: float, save_as_base64: bool = True
|
self, fat_mass_lbs: float, lean_mass_lbs: float, save_as_base64: bool = True
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -678,25 +777,52 @@ class GraphGenerator:
|
|||||||
else:
|
else:
|
||||||
age_group = "20-39" # Default
|
age_group = "20-39" # Default
|
||||||
|
|
||||||
demographic = f"{age_group}\n({gender[0].upper()})"
|
gender_abbrev = "M" if gender.lower() == "male" else "F"
|
||||||
|
demographic = f"{age_group}\n({gender_abbrev})"
|
||||||
|
|
||||||
# Define segments based on gender (female example)
|
# Define segments based on gender and age group
|
||||||
if gender.lower() == "female":
|
if gender.lower() == "female":
|
||||||
segments = [
|
if age_group == "20-39":
|
||||||
("#F8A8A8", 0, 15), # Muted Red: 0% to 15%
|
segments = [
|
||||||
("#FFEECC", 15, 5), # Pale Yellow: 15% to 20%
|
("#F8A8A8", 0, 15), # Bad: 0-15%
|
||||||
("#D0F0C0", 20, 15), # Pale Green: 20% to 35%
|
("#FFEECC", 15, 5), # Okay: 15-20%
|
||||||
("#FFEECC", 35, 5), # Pale Yellow: 35% to 40%
|
("#D0F0C0", 20, 15), # Good: 20-35%
|
||||||
("#F8A8A8", 40, 10), # Muted Red: 40% to 50%
|
("#FFEECC", 35, 5), # Okay: 35-40%
|
||||||
]
|
("#F8A8A8", 40, 10), # Bad: 40-50%
|
||||||
|
]
|
||||||
|
else: # 40-59 and 60-79 have same ranges for females
|
||||||
|
segments = [
|
||||||
|
("#F8A8A8", 0, 20), # Bad: 0-20%
|
||||||
|
("#FFEECC", 20, 5), # Okay: 20-25%
|
||||||
|
("#D0F0C0", 25, 10), # Good: 25-35%
|
||||||
|
("#FFEECC", 35, 5), # Okay: 35-40%
|
||||||
|
("#F8A8A8", 40, 10), # Bad: 40-50%
|
||||||
|
]
|
||||||
else: # male
|
else: # male
|
||||||
segments = [
|
if age_group == "20-39":
|
||||||
("#F8A8A8", 0, 5), # Muted Red: 0% to 5%
|
segments = [
|
||||||
("#FFEECC", 5, 5), # Pale Yellow: 5% to 10%
|
("#F8A8A8", 0, 5), # Bad: 0-5%
|
||||||
("#D0F0C0", 10, 10), # Pale Green: 10% to 20%
|
("#FFEECC", 5, 5), # Okay: 5-10%
|
||||||
("#FFEECC", 20, 5), # Pale Yellow: 20% to 25%
|
("#D0F0C0", 10, 10), # Good: 10-20%
|
||||||
("#F8A8A8", 25, 25), # Muted Red: 25% to 50%
|
("#FFEECC", 20, 5), # Okay: 20-25%
|
||||||
]
|
("#F8A8A8", 25, 25), # Bad: 25-50%
|
||||||
|
]
|
||||||
|
elif age_group == "40-59":
|
||||||
|
segments = [
|
||||||
|
("#F8A8A8", 0, 5), # Bad: 0-5%
|
||||||
|
("#FFEECC", 5, 5), # Okay: 5-10%
|
||||||
|
("#D0F0C0", 10, 10), # Good: 10-20%
|
||||||
|
("#FFEECC", 20, 10), # Okay: 20-30%
|
||||||
|
("#F8A8A8", 30, 20), # Bad: 30-50%
|
||||||
|
]
|
||||||
|
else: # 60-79
|
||||||
|
segments = [
|
||||||
|
("#F8A8A8", 0, 5), # Bad: 0-5%
|
||||||
|
("#FFEECC", 5, 5), # Okay: 5-10%
|
||||||
|
("#D0F0C0", 10, 15), # Good: 10-25%
|
||||||
|
("#FFEECC", 25, 5), # Okay: 25-30%
|
||||||
|
("#F8A8A8", 30, 20), # Bad: 30-50%
|
||||||
|
]
|
||||||
|
|
||||||
fig, ax = plt.subplots(figsize=(10, 2))
|
fig, ax = plt.subplots(figsize=(10, 2))
|
||||||
|
|
||||||
@@ -779,10 +905,40 @@ class GraphGenerator:
|
|||||||
Returns:
|
Returns:
|
||||||
Base64 string or file path
|
Base64 string or file path
|
||||||
"""
|
"""
|
||||||
# Coerce numeric columns
|
# Coerce numeric columns - handle various column name formats
|
||||||
for col in ["Best", "LLN", "Pred.", "%Pred.", "ZScore"]:
|
# Map standard column names to possible variations
|
||||||
if col in spirometry_df.columns:
|
column_aliases = {
|
||||||
spirometry_df[col] = pd.to_numeric(spirometry_df[col], errors="coerce")
|
"Best": ["Best", "best", "BEST"],
|
||||||
|
"LLN": ["LLN", "lln"],
|
||||||
|
"Pred.": ["Pred.", "Pred", "pred", "Predicted", "predicted"],
|
||||||
|
"%Pred.": [
|
||||||
|
"%Pred.",
|
||||||
|
"%Pred",
|
||||||
|
"%pred",
|
||||||
|
"% Pred.",
|
||||||
|
"% Pred",
|
||||||
|
"Pred %",
|
||||||
|
"Pred%",
|
||||||
|
],
|
||||||
|
"ZScore": ["ZScore", "Z-Score", "z-score", "Zscore", "zscore", "Z Score"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find and normalize column names
|
||||||
|
column_mapping = {}
|
||||||
|
for target_col, possible_names in column_aliases.items():
|
||||||
|
for col_name in possible_names:
|
||||||
|
if col_name in spirometry_df.columns:
|
||||||
|
column_mapping[target_col] = col_name
|
||||||
|
# Convert to numeric
|
||||||
|
spirometry_df[col_name] = pd.to_numeric(
|
||||||
|
spirometry_df[col_name], errors="coerce"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# If standard columns don't exist, create aliases
|
||||||
|
for target_col, source_col in column_mapping.items():
|
||||||
|
if target_col not in spirometry_df.columns and source_col != target_col:
|
||||||
|
spirometry_df[target_col] = spirometry_df[source_col]
|
||||||
|
|
||||||
# Select rows of interest
|
# Select rows of interest
|
||||||
rows_map = {
|
rows_map = {
|
||||||
@@ -793,20 +949,49 @@ class GraphGenerator:
|
|||||||
|
|
||||||
records = []
|
records = []
|
||||||
for label, param in rows_map.items():
|
for label, param in rows_map.items():
|
||||||
|
# Try exact match first
|
||||||
row = spirometry_df.loc[spirometry_df["Parameters"].str.strip() == param]
|
row = spirometry_df.loc[spirometry_df["Parameters"].str.strip() == param]
|
||||||
if row.empty:
|
if row.empty:
|
||||||
|
# Try case-insensitive match
|
||||||
|
row = spirometry_df.loc[
|
||||||
|
spirometry_df["Parameters"].str.strip().str.upper() == param.upper()
|
||||||
|
]
|
||||||
|
if row.empty:
|
||||||
|
# Try matching without % sign
|
||||||
|
if "%" in param:
|
||||||
|
param_no_pct = param.replace("%", "")
|
||||||
|
row = spirometry_df.loc[
|
||||||
|
spirometry_df["Parameters"].str.strip() == param_no_pct
|
||||||
|
]
|
||||||
|
if row.empty:
|
||||||
|
print(f"Warning: Could not find parameter '{param}' in spirometry data")
|
||||||
|
print(f"Available parameters: {spirometry_df['Parameters'].tolist()}")
|
||||||
continue
|
continue
|
||||||
row = row.iloc[0]
|
row = row.iloc[0]
|
||||||
|
# Get values with fallbacks for column name variations
|
||||||
|
best_val = row.get("Best", row.get("best", pd.NA))
|
||||||
|
pct_val = row.get(
|
||||||
|
"%Pred.", row.get("%Pred", row.get("Pred %", row.get("Pred%", pd.NA)))
|
||||||
|
)
|
||||||
|
z_val = row.get("ZScore", row.get("Z-Score", row.get("Zscore", pd.NA)))
|
||||||
|
|
||||||
records.append(
|
records.append(
|
||||||
{
|
{
|
||||||
"label": label,
|
"label": label,
|
||||||
"param": param,
|
"param": param,
|
||||||
"best": row["Best"],
|
"best": best_val,
|
||||||
"pct": row["%Pred."],
|
"pct": pct_val,
|
||||||
"z": row["ZScore"],
|
"z": z_val,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Validate we have exactly 3 records
|
||||||
|
if len(records) != 3:
|
||||||
|
raise ValueError(
|
||||||
|
f"Expected 3 spirometry parameters (FVC, FEV1, FEV1/FVC%), "
|
||||||
|
f"but found {len(records)}. Found: {[r['param'] for r in records]}"
|
||||||
|
)
|
||||||
|
|
||||||
# Figure setup
|
# Figure setup
|
||||||
fig, axes = plt.subplots(
|
fig, axes = plt.subplots(
|
||||||
nrows=3,
|
nrows=3,
|
||||||
@@ -936,3 +1121,187 @@ class GraphGenerator:
|
|||||||
plt.close()
|
plt.close()
|
||||||
|
|
||||||
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(
|
||||||
|
self, rmr_kcal: float, save_as_base64: bool = True
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate metabolism chart (Slow vs Fast Metabolism).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rmr_kcal: Resting metabolic rate in kcal/day
|
||||||
|
save_as_base64: If True, return base64 string, else return file path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64 string or file path
|
||||||
|
"""
|
||||||
|
from matplotlib.patches import FancyBboxPatch
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(10, 2.5))
|
||||||
|
|
||||||
|
# Chart data and positions
|
||||||
|
categories = ["Very Slow", "Slow", "Average", "Fast", "Very Fast"]
|
||||||
|
positions = [1500, 3000, 4500, 6000, 7500]
|
||||||
|
indicator_pos = rmr_kcal
|
||||||
|
highlight_end = rmr_kcal
|
||||||
|
|
||||||
|
# Main Bar (Background)
|
||||||
|
main_bar = FancyBboxPatch(
|
||||||
|
(0, 0.4),
|
||||||
|
9000,
|
||||||
|
0.2,
|
||||||
|
boxstyle="round,pad=0,rounding_size=0.1",
|
||||||
|
ec="none",
|
||||||
|
fc="#E0E0E0",
|
||||||
|
)
|
||||||
|
ax.add_patch(main_bar)
|
||||||
|
|
||||||
|
# Highlighted Bar
|
||||||
|
highlight_bar = FancyBboxPatch(
|
||||||
|
(0, 0.4),
|
||||||
|
highlight_end,
|
||||||
|
0.2,
|
||||||
|
boxstyle="round,pad=0,rounding_size=0.1",
|
||||||
|
ec="none",
|
||||||
|
fc="#B2FFC8",
|
||||||
|
)
|
||||||
|
ax.add_patch(highlight_bar)
|
||||||
|
|
||||||
|
# Text and Labels
|
||||||
|
ax.text(
|
||||||
|
highlight_end / 2,
|
||||||
|
0.5,
|
||||||
|
f"{rmr_kcal:.0f}kCals",
|
||||||
|
ha="center",
|
||||||
|
va="center",
|
||||||
|
color="#006400",
|
||||||
|
fontsize=14,
|
||||||
|
weight="bold",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Indicator Triangle
|
||||||
|
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, 9000)
|
||||||
|
ax.set_ylim(0, 1)
|
||||||
|
ax.axis("off")
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
|
||||||
|
chart_path = self.charts_dir / "metabolism_chart.png"
|
||||||
|
plt.savefig(chart_path, bbox_inches="tight", dpi=300)
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||||
|
|
||||||
|
def generate_fuel_source_chart(
|
||||||
|
self, fat_percentage: float, save_as_base64: bool = True
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate fuel source chart (Fats vs Carbs).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fat_percentage: Fat percentage at rest
|
||||||
|
save_as_base64: If True, return base64 string, else return file path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64 string or file path
|
||||||
|
"""
|
||||||
|
from matplotlib.patches import FancyBboxPatch
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(10, 2.5))
|
||||||
|
|
||||||
|
carb_percentage = 100 - fat_percentage
|
||||||
|
optimal_point = 75
|
||||||
|
|
||||||
|
# Main Bars (Fats and Carbs)
|
||||||
|
# Fats bar (yellow)
|
||||||
|
fats_bar = FancyBboxPatch(
|
||||||
|
(0, 0.4),
|
||||||
|
fat_percentage,
|
||||||
|
0.2,
|
||||||
|
boxstyle="round,pad=0,rounding_size=0.1",
|
||||||
|
ec="none",
|
||||||
|
fc="#FEEAAB",
|
||||||
|
)
|
||||||
|
ax.add_patch(fats_bar)
|
||||||
|
|
||||||
|
# Carbs bar (blue) - starts where the fats bar ends
|
||||||
|
carbs_bar = FancyBboxPatch(
|
||||||
|
(fat_percentage, 0.4),
|
||||||
|
carb_percentage,
|
||||||
|
0.2,
|
||||||
|
boxstyle="round,pad=0,rounding_size=0.1",
|
||||||
|
ec="none",
|
||||||
|
fc="#A7F5FF",
|
||||||
|
)
|
||||||
|
ax.add_patch(carbs_bar)
|
||||||
|
|
||||||
|
# Text and Labels
|
||||||
|
ax.text(
|
||||||
|
fat_percentage / 2,
|
||||||
|
0.5,
|
||||||
|
f"Fats\n{fat_percentage:.1f}%",
|
||||||
|
ha="center",
|
||||||
|
va="center",
|
||||||
|
color="#333333",
|
||||||
|
fontsize=12,
|
||||||
|
weight="bold",
|
||||||
|
)
|
||||||
|
ax.text(
|
||||||
|
fat_percentage + carb_percentage / 2,
|
||||||
|
0.5,
|
||||||
|
f"Carbs\n{carb_percentage:.1f}%",
|
||||||
|
ha="center",
|
||||||
|
va="center",
|
||||||
|
color="#333333",
|
||||||
|
fontsize=12,
|
||||||
|
weight="bold",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add 'Optimal' label
|
||||||
|
ax.text(optimal_point, 0.75, "Optimal", ha="center", va="center", fontsize=12)
|
||||||
|
|
||||||
|
# Indicator Triangle
|
||||||
|
ax.plot(
|
||||||
|
fat_percentage, 0.65, "v", markersize=15, color="#606060", clip_on=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ticks and Labels
|
||||||
|
positions = [0, 25, 50, 75, 100]
|
||||||
|
for pos in positions:
|
||||||
|
ax.text(
|
||||||
|
pos,
|
||||||
|
0.15,
|
||||||
|
str(pos),
|
||||||
|
ha="center",
|
||||||
|
va="center",
|
||||||
|
fontsize=12,
|
||||||
|
color="#333333",
|
||||||
|
)
|
||||||
|
ax.plot([pos, pos], [0.35, 0.39], color="grey", lw=5)
|
||||||
|
|
||||||
|
# Add a special tick for the 'Optimal' point
|
||||||
|
ax.plot([optimal_point, optimal_point], [0.6, 0.7], color="black", lw=2)
|
||||||
|
|
||||||
|
# Chart Styling
|
||||||
|
ax.set_title("Fuel Source", fontsize=18, weight="bold", loc="left")
|
||||||
|
ax.set_ylim(0, 1)
|
||||||
|
ax.axis("off")
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
|
||||||
|
chart_path = self.charts_dir / "fuel_source_chart.png"
|
||||||
|
plt.savefig(chart_path, bbox_inches="tight", dpi=300)
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ class ReportGeneratorService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def generate_html(
|
def generate_html(
|
||||||
self, patient_info: Dict[str, Any], context_list: List[Dict[str, Any]]
|
self, patient_info: Dict[str, Any], contexts: Dict[str, Dict[str, Any]]
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Generate HTML content for the report.
|
Generate HTML content for the report.
|
||||||
@@ -159,7 +159,7 @@ class ReportGeneratorService:
|
|||||||
Args:
|
Args:
|
||||||
patient_info: Dictionary containing patient information
|
patient_info: Dictionary containing patient information
|
||||||
(patient_name, age, height, weight, focus)
|
(patient_name, age, height, weight, focus)
|
||||||
context_list: List of context dictionaries for each page
|
contexts: Dictionary with keys 'page_1', 'page_2', etc., each containing context data
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Complete HTML document as string
|
Complete HTML document as string
|
||||||
@@ -175,6 +175,9 @@ class ReportGeneratorService:
|
|||||||
"focus": patient_info.get("focus", "Endurance"),
|
"focus": patient_info.get("focus", "Endurance"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Get total number of pages
|
||||||
|
num_pages = len(contexts)
|
||||||
|
|
||||||
# Footer context
|
# Footer context
|
||||||
footer_context = [
|
footer_context = [
|
||||||
{
|
{
|
||||||
@@ -183,7 +186,7 @@ class ReportGeneratorService:
|
|||||||
"social": "@ishplabs",
|
"social": "@ishplabs",
|
||||||
"page_number": i + 1,
|
"page_number": i + 1,
|
||||||
}
|
}
|
||||||
for i in range(len(context_list))
|
for i in range(num_pages)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Render header
|
# Render header
|
||||||
@@ -195,11 +198,13 @@ class ReportGeneratorService:
|
|||||||
for context in footer_context
|
for context in footer_context
|
||||||
]
|
]
|
||||||
|
|
||||||
# Render pages
|
# Render pages - iterate through pages in order
|
||||||
for i, context in enumerate(context_list):
|
for i in range(1, num_pages + 1):
|
||||||
template = self.env.get_template(f"page_{i + 1}.html").render(context)
|
page_key = f"page_{i}"
|
||||||
|
context = contexts.get(page_key, {})
|
||||||
|
template = self.env.get_template(f"page_{i}.html").render(context)
|
||||||
|
|
||||||
if (i + 1) > 2:
|
if i > 2:
|
||||||
full_html = f"""
|
full_html = f"""
|
||||||
<div class="page flex flex-col justify-between">
|
<div class="page flex flex-col justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -209,7 +214,7 @@ class ReportGeneratorService:
|
|||||||
{template}
|
{template}
|
||||||
</main>
|
</main>
|
||||||
<div class="border-t text-center text-sm text-gray-600">
|
<div class="border-t text-center text-sm text-gray-600">
|
||||||
{footer_html_list[i]}
|
{footer_html_list[i - 1]}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
@@ -284,10 +289,10 @@ class ReportGeneratorService:
|
|||||||
self,
|
self,
|
||||||
spirometry_pdf_path: str,
|
spirometry_pdf_path: str,
|
||||||
pnoe_csv_path: str,
|
pnoe_csv_path: str,
|
||||||
seca_excel_path: str,
|
|
||||||
patient_info: Dict[str, Any],
|
patient_info: Dict[str, Any],
|
||||||
output_filename: str = None,
|
output_filename: str = None,
|
||||||
metric_overrides: Optional[Dict[str, Any]] = None,
|
metric_overrides: Optional[Dict[str, Any]] = None,
|
||||||
|
oxygenation_csv_path: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Generate complete medical report from uploaded files.
|
Generate complete medical report from uploaded files.
|
||||||
@@ -325,69 +330,165 @@ class ReportGeneratorService:
|
|||||||
graphs_generated = self.generate_graphs(df)
|
graphs_generated = self.generate_graphs(df)
|
||||||
|
|
||||||
# Create graph dictionary with base64 encoded images
|
# Create graph dictionary with base64 encoded images
|
||||||
|
import base64
|
||||||
|
|
||||||
graphs_dict = {}
|
graphs_dict = {}
|
||||||
for graph in graphs_generated:
|
for graph in graphs_generated:
|
||||||
# Read the graph file and convert to base64
|
# Read the graph file and convert to base64
|
||||||
graph_path = Path(graph["path"])
|
graph_path = Path(graph["path"])
|
||||||
if graph_path.exists():
|
if graph_path.exists():
|
||||||
import base64
|
|
||||||
|
|
||||||
with open(graph_path, "rb") as f:
|
with open(graph_path, "rb") as f:
|
||||||
graphs_dict[graph["name"]] = base64.b64encode(f.read()).decode(
|
graphs_dict[graph["name"]] = base64.b64encode(f.read()).decode(
|
||||||
"utf-8"
|
"utf-8"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Also generate body composition charts
|
# Also generate body composition charts
|
||||||
# Extract patient data for these charts
|
# Use patient info directly (no SECA file needed)
|
||||||
patient_name = patient_info.get("patient_name", "").split()[-1] # Get last name
|
fat_pct = patient_info.get("fat_percentage", 0)
|
||||||
|
age = patient_info.get("age", 25)
|
||||||
|
gender = patient_info.get("gender", "female").lower()
|
||||||
|
|
||||||
# Load SECA data to get body composition info
|
# Convert weight to kg if needed
|
||||||
seca_df = pd.read_excel(seca_excel_path)
|
weight_str = str(patient_info.get("weight", "0"))
|
||||||
patient_data = seca_df[
|
# Extract numeric value and unit
|
||||||
seca_df["LastName"].str.contains(patient_name, case=False, na=False)
|
weight_str_clean = (
|
||||||
]
|
weight_str.replace("lbs", "").replace("kg", "").replace(" ", "").strip()
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
weight_value = float(weight_str_clean)
|
||||||
|
except ValueError:
|
||||||
|
print(f"Warning: Could not parse weight '{weight_str}', using default 0")
|
||||||
|
weight_value = 0.0
|
||||||
|
|
||||||
if not patient_data.empty:
|
# Convert to kg if weight is in lbs
|
||||||
row = patient_data.iloc[0]
|
if "lbs" in weight_str.lower():
|
||||||
weight_kg = float(row.get("Weight", 0))
|
weight_kg = weight_value / 2.20462 # Convert lbs to kg
|
||||||
fat_pct = float(row.get("Adult_FMP", 0))
|
else:
|
||||||
age = int(row.get("Age", patient_info.get("age", 25)))
|
weight_kg = weight_value # Already in kg or assume kg if no unit specified
|
||||||
gender = row.get("Gender", "female").lower()
|
|
||||||
|
|
||||||
fat_mass_lbs = weight_kg * fat_pct / 100 * 2.20462
|
# Calculate fat and lean mass in pounds
|
||||||
lean_mass_lbs = weight_kg * (1 - fat_pct / 100) * 2.20462
|
fat_mass_lbs = weight_kg * fat_pct / 100 * 2.20462
|
||||||
|
lean_mass_lbs = weight_kg * (1 - fat_pct / 100) * 2.20462
|
||||||
|
|
||||||
# Generate body composition chart
|
# Generate body composition chart (save as file first, then convert to base64)
|
||||||
body_comp_b64 = self.graph_generator.generate_body_composition_chart(
|
try:
|
||||||
fat_mass_lbs, lean_mass_lbs, save_as_base64=True
|
body_comp_path = self.graph_generator.generate_body_composition_chart(
|
||||||
|
fat_mass_lbs, lean_mass_lbs, save_as_base64=False
|
||||||
)
|
)
|
||||||
graphs_dict["body_composition"] = body_comp_b64
|
graphs_generated.append(
|
||||||
|
{"name": "body_composition", "path": str(body_comp_path)}
|
||||||
# Generate body fat percent chart
|
|
||||||
body_fat_b64 = self.graph_generator.generate_body_fat_percent_chart(
|
|
||||||
fat_pct, age, gender, save_as_base64=True
|
|
||||||
)
|
)
|
||||||
graphs_dict["body_fat_percent"] = body_fat_b64
|
# Convert to base64 for graphs_dict
|
||||||
|
with open(body_comp_path, "rb") as f:
|
||||||
|
graphs_dict["body_composition"] = base64.b64encode(f.read()).decode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not generate body composition chart: {e}")
|
||||||
|
graphs_dict["body_composition"] = ""
|
||||||
|
|
||||||
|
# Generate body fat percent chart (save as file first, then convert to base64)
|
||||||
|
try:
|
||||||
|
body_fat_path = self.graph_generator.generate_body_fat_percent_chart(
|
||||||
|
fat_pct, age, gender, save_as_base64=False
|
||||||
|
)
|
||||||
|
graphs_generated.append(
|
||||||
|
{"name": "body_fat_percent", "path": str(body_fat_path)}
|
||||||
|
)
|
||||||
|
# Convert to base64 for graphs_dict
|
||||||
|
with open(body_fat_path, "rb") as f:
|
||||||
|
graphs_dict["body_fat_percent"] = base64.b64encode(f.read()).decode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not generate body fat percent chart: {e}")
|
||||||
|
graphs_dict["body_fat_percent"] = ""
|
||||||
|
|
||||||
# Generate spirometry chart
|
# Generate spirometry chart
|
||||||
print("Step 4: Generating spirometry chart...")
|
print("Step 4: Generating spirometry chart...")
|
||||||
try:
|
try:
|
||||||
spirometry_df = pd.read_csv(spirometry_csv_path)
|
spirometry_df = pd.read_csv(spirometry_csv_path)
|
||||||
print(f"Spirometry data loaded: {len(spirometry_df)} rows")
|
print(f"Spirometry data loaded: {len(spirometry_df)} rows")
|
||||||
|
print(f"Spirometry columns: {spirometry_df.columns.tolist()}")
|
||||||
|
if "Parameters" in spirometry_df.columns:
|
||||||
|
print(f"Available parameters: {spirometry_df['Parameters'].tolist()}")
|
||||||
spirometry_chart_b64 = self.graph_generator.generate_spirometry_chart(
|
spirometry_chart_b64 = self.graph_generator.generate_spirometry_chart(
|
||||||
spirometry_df, save_as_base64=True
|
spirometry_df, save_as_base64=True
|
||||||
)
|
)
|
||||||
graphs_dict["spirometry_chart"] = spirometry_chart_b64
|
graphs_dict["spirometry_chart"] = spirometry_chart_b64
|
||||||
|
print("Spirometry chart generated successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
error_details = traceback.format_exc()
|
||||||
print(f"Warning: Could not generate spirometry chart: {e}")
|
print(f"Warning: Could not generate spirometry chart: {e}")
|
||||||
|
print(f"Error details: {error_details}")
|
||||||
graphs_dict["spirometry_chart"] = ""
|
graphs_dict["spirometry_chart"] = ""
|
||||||
|
|
||||||
|
# Generate TSI chart if oxygenation CSV is provided
|
||||||
|
if oxygenation_csv_path:
|
||||||
|
print("Step 4.5: Generating TSI chart...")
|
||||||
|
try:
|
||||||
|
oxygenation_df = pd.read_csv(oxygenation_csv_path)
|
||||||
|
tsi_chart_b64 = self.graph_generator.generate_tsi_chart(
|
||||||
|
oxygenation_df, save_as_base64=True
|
||||||
|
)
|
||||||
|
graphs_dict["tsi_chart"] = tsi_chart_b64
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not generate TSI chart: {e}")
|
||||||
|
graphs_dict["tsi_chart"] = ""
|
||||||
|
|
||||||
|
# Generate metabolism and fuel source charts for page 5
|
||||||
|
print("Step 4.6: Generating metabolism and fuel source charts...")
|
||||||
|
try:
|
||||||
|
# Calculate RMR and fuel source from pnoe data
|
||||||
|
from services.context_generator import ContextGenerator
|
||||||
|
|
||||||
|
temp_context_gen = ContextGenerator()
|
||||||
|
temp_context_gen.load_data(pnoe_csv_path, str(spirometry_csv_path), None)
|
||||||
|
temp_context_gen.patient_info = {
|
||||||
|
"name": patient_info.get("first_name", ""),
|
||||||
|
"last_name": patient_info.get("last_name", ""),
|
||||||
|
"age": patient_info.get("age", 25),
|
||||||
|
"weight": weight_kg,
|
||||||
|
"fat_percentage": fat_pct,
|
||||||
|
"gender": gender,
|
||||||
|
}
|
||||||
|
rmr_metrics = temp_context_gen.calculate_rmr_and_fuel_source()
|
||||||
|
|
||||||
|
# Generate metabolism chart
|
||||||
|
metabolism_chart_b64 = self.graph_generator.generate_metabolism_chart(
|
||||||
|
rmr_metrics["rmr_kcal"], save_as_base64=True
|
||||||
|
)
|
||||||
|
graphs_dict["metabolism_chart"] = metabolism_chart_b64
|
||||||
|
|
||||||
|
# Generate fuel source chart
|
||||||
|
fuel_source_chart_b64 = self.graph_generator.generate_fuel_source_chart(
|
||||||
|
rmr_metrics["rest_fat_percentage"], save_as_base64=True
|
||||||
|
)
|
||||||
|
graphs_dict["fuel_source_chart"] = fuel_source_chart_b64
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not generate metabolism/fuel source charts: {e}")
|
||||||
|
graphs_dict["metabolism_chart"] = ""
|
||||||
|
graphs_dict["fuel_source_chart"] = ""
|
||||||
|
|
||||||
# Step 5: Generate context for all pages
|
# Step 5: Generate context for all pages
|
||||||
print("Step 5: Generating page contexts...")
|
print("Step 5: Generating page contexts...")
|
||||||
|
patient_name = patient_info.get("patient_name", "")
|
||||||
self.context_generator.load_data(
|
self.context_generator.load_data(
|
||||||
pnoe_csv_path, str(spirometry_csv_path), seca_excel_path
|
pnoe_csv_path,
|
||||||
|
str(spirometry_csv_path),
|
||||||
|
None, # No SECA file
|
||||||
)
|
)
|
||||||
context_list = self.context_generator.generate_all_contexts(
|
# Set patient info manually
|
||||||
|
self.context_generator.patient_info = {
|
||||||
|
"name": patient_info.get("first_name", ""),
|
||||||
|
"last_name": patient_info.get("last_name", ""),
|
||||||
|
"age": patient_info.get("age", 25),
|
||||||
|
"weight": weight_kg,
|
||||||
|
"fat_percentage": fat_pct,
|
||||||
|
"gender": gender,
|
||||||
|
}
|
||||||
|
contexts = self.context_generator.generate_all_contexts(
|
||||||
patient_name, graphs_dict, metric_overrides=metric_overrides
|
patient_name, graphs_dict, metric_overrides=metric_overrides
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -396,7 +497,7 @@ class ReportGeneratorService:
|
|||||||
analysis_data["graphs_count"] = len(graphs_generated)
|
analysis_data["graphs_count"] = len(graphs_generated)
|
||||||
|
|
||||||
# Step 6: Generate HTML
|
# Step 6: Generate HTML
|
||||||
html_content = self.generate_html(patient_info, context_list)
|
html_content = self.generate_html(patient_info, contexts)
|
||||||
|
|
||||||
# Step 7: Generate PDF
|
# Step 7: Generate PDF
|
||||||
if output_filename is None:
|
if output_filename is None:
|
||||||
|
|||||||
@@ -39,3 +39,4 @@
|
|||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -84,9 +84,16 @@
|
|||||||
class="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
|
class="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="seca_excel" class="block text-sm font-medium text-gray-700">SECA Excel</label>
|
<label for="fat_percentage" class="block text-sm font-medium text-gray-700">Body Fat Percentage (%)</label>
|
||||||
<input type="file" name="seca_excel" id="seca_excel" accept=".xlsx,.xls" required
|
<input type="number" step="0.1" name="fat_percentage" id="fat_percentage" required min="0" max="100"
|
||||||
|
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"
|
||||||
|
placeholder="22.5">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="oxygenation_csv" class="block text-sm font-medium text-gray-700">Muscle Oxygenation CSV (Optional)</label>
|
||||||
|
<input type="file" name="oxygenation_csv" id="oxygenation_csv" accept=".csv"
|
||||||
class="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
|
class="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Upload NIRS muscle oxygen CSV file to generate TSI graph</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+17
-4
@@ -72,7 +72,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": 37,
|
"execution_count": null,
|
||||||
"id": "99116a35",
|
"id": "99116a35",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
@@ -237,7 +237,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": 41,
|
"execution_count": null,
|
||||||
"id": "470e871e",
|
"id": "470e871e",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -290,7 +290,14 @@
|
|||||||
" # --- Chart data and positions ---\n",
|
" # --- Chart data and positions ---\n",
|
||||||
" categories = ['Very Slow', 'Slow', 'Average', 'Fast', 'Very Fast']\n",
|
" categories = ['Very Slow', 'Slow', 'Average', 'Fast', 'Very Fast']\n",
|
||||||
" positions = [1500, 3000, 4500, 6000, 7500]\n",
|
" positions = [1500, 3000, 4500, 6000, 7500]\n",
|
||||||
" kcal_value = 1386\n",
|
" # Step 1: Filter resting phase (usually lowest VO2 or MET values)\n",
|
||||||
|
" rest_phase = df[df['MET'] <= 1.1] # assuming <1.1 MET means rest\n",
|
||||||
|
"\n",
|
||||||
|
" # Step 2: Compute resting metabolic rate\n",
|
||||||
|
" rmr = rest_phase['EE(kcal/day)'].mean()\n",
|
||||||
|
"\n",
|
||||||
|
" print(f\"Estimated RMR from data: {rmr:.0f} kcal/day\")\n",
|
||||||
|
" kcal_value = rmr\n",
|
||||||
" # Position the indicator and highlight based on the kcal value\n",
|
" # Position the indicator and highlight based on the kcal value\n",
|
||||||
" # For this example, we'll place it in the 'Very Slow' section.\n",
|
" # For this example, we'll place it in the 'Very Slow' section.\n",
|
||||||
" indicator_pos = kcal_value\n",
|
" indicator_pos = kcal_value\n",
|
||||||
@@ -349,7 +356,13 @@
|
|||||||
" fig, ax = plt.subplots(figsize=(10, 2.5))\n",
|
" fig, ax = plt.subplots(figsize=(10, 2.5))\n",
|
||||||
"\n",
|
"\n",
|
||||||
" # --- Chart data and positions ---\n",
|
" # --- Chart data and positions ---\n",
|
||||||
" fat_percentage = 33\n",
|
" rest_phase = df[df['RER'] == 0.9] # filter rest data\n",
|
||||||
|
" fat_rest = rest_phase['FAT(%)'].mean()\n",
|
||||||
|
" carb_rest = rest_phase['CARBS(%)'].mean()\n",
|
||||||
|
"\n",
|
||||||
|
" print(f\"Resting phase fuel mix: Fats {fat_rest:.1f}%, Carbs {carb_rest:.1f}%\")\n",
|
||||||
|
"\n",
|
||||||
|
" fat_percentage = fat_rest\n",
|
||||||
" carb_percentage = 100 - fat_percentage\n",
|
" carb_percentage = 100 - fat_percentage\n",
|
||||||
" optimal_point = 75\n",
|
" optimal_point = 75\n",
|
||||||
"\n",
|
"\n",
|
||||||
|
|||||||
@@ -18,13 +18,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": 19,
|
"execution_count": null,
|
||||||
"id": "da5ac3c1",
|
"id": "da5ac3c1",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"source": [
|
||||||
"pnoe_df = pd.read_csv('data/pnoe_data.csv', delimiter=';')\n",
|
"pnoe_df = pd.read_csv('data/pnoe_data.csv', delimiter=';')\n",
|
||||||
"patients_info = pd.read_excel('data/patients_data.xlsx')\n",
|
|
||||||
"spirometry_df = pd.read_csv('data/spirometry_data.csv')"
|
"spirometry_df = pd.read_csv('data/spirometry_data.csv')"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -254,7 +253,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": 11,
|
"execution_count": null,
|
||||||
"id": "2fa8ff13",
|
"id": "2fa8ff13",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -270,15 +269,10 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": [
|
"source": [
|
||||||
"def body_composition_chart(first_name='Keirstyn', last_name='Moran'):\n",
|
"def body_composition_chart(fat_percentage=22.4, weight_kg=70):\n",
|
||||||
"\n",
|
"\n",
|
||||||
" \n",
|
" \n",
|
||||||
" #=========================== Body Composition Donut Chart ========================#\n",
|
" #=========================== Body Composition Donut Chart ========================#\n",
|
||||||
" patient_data = patients_info[(patients_info['FirstName'].str.contains(first_name, case=False, na=False)) & \n",
|
|
||||||
" (patients_info['LastName'].str.contains(last_name, case=False, na=False))]\n",
|
|
||||||
"# Get the fat mass percentage for Keirstyn\n",
|
|
||||||
" fat_percentage = patient_data['Adult_FMP'].iloc[0]\n",
|
|
||||||
" weight_kg = patient_data['Weight'].iloc[0]\n",
|
|
||||||
" lean_percentage = 100 - fat_percentage\n",
|
" lean_percentage = 100 - fat_percentage\n",
|
||||||
"\n",
|
"\n",
|
||||||
"# Create donut chart\n",
|
"# Create donut chart\n",
|
||||||
|
|||||||
Reference in New Issue
Block a user