Compare commits
11 Commits
main
...
0090b7002c
| Author | SHA1 | Date | |
|---|---|---|---|
| 0090b7002c | |||
| 7e985c497e | |||
| 83f50882e2 | |||
| 4f97691ff9 | |||
| 0a735d88c8 | |||
| 358898b7db | |||
| d66f3fd18b | |||
| 14dc64234d | |||
| 7a67aac678 | |||
| 11ee6b192f | |||
| 1d8136d6ad |
@@ -1,3 +1,13 @@
|
||||
.venv
|
||||
|
||||
data/
|
||||
data/
|
||||
|
||||
.env
|
||||
|
||||
/graphs
|
||||
|
||||
/data
|
||||
|
||||
/reports
|
||||
|
||||
/temp
|
||||
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
@@ -0,0 +1,631 @@
|
||||
"""
|
||||
FastAPI application for medical report generation.
|
||||
|
||||
This API provides a single endpoint that accepts all required files
|
||||
and patient information, then generates a comprehensive medical report.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import BaseModel
|
||||
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(
|
||||
title="Medical Report Generation API",
|
||||
description="API for generating medical performance reports with analysis and graphs",
|
||||
version="2.0.0",
|
||||
)
|
||||
|
||||
# Add session middleware
|
||||
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
|
||||
jinja_env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
|
||||
# Define output directories
|
||||
GRAPHS_DIR = Path("graphs")
|
||||
GRAPHS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
REPORTS_DIR = Path("reports")
|
||||
REPORTS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
TEMP_DIR = Path("temp")
|
||||
TEMP_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# Initialize report generator service
|
||||
report_service = ReportGeneratorService(
|
||||
template_dir="app/report_gen",
|
||||
graphs_dir=str(GRAPHS_DIR),
|
||||
reports_dir=str(REPORTS_DIR),
|
||||
)
|
||||
|
||||
|
||||
class ReportResponse(BaseModel):
|
||||
message: str
|
||||
report_path: str
|
||||
graphs_generated: list
|
||||
analysis_data: dict
|
||||
|
||||
|
||||
def render_template(template_name: str, context: dict) -> HTMLResponse:
|
||||
"""Helper function to render Jinja2 templates"""
|
||||
template = jinja_env.get_template(template_name)
|
||||
html_content = template.render(**context)
|
||||
return HTMLResponse(content=html_content, media_type="text/html")
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def root(request: Request):
|
||||
"""Root endpoint - Upload form page"""
|
||||
return render_template(
|
||||
"upload.html", {"request": request, "session": request.session}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/upload")
|
||||
async def upload_files(
|
||||
request: Request,
|
||||
first_name: str = Form(...),
|
||||
last_name: str = Form(...),
|
||||
age: int = Form(...),
|
||||
height: str = Form(...),
|
||||
weight: str = Form(...),
|
||||
gender: str = Form(...),
|
||||
fat_percentage: float = Form(...),
|
||||
focus: str = Form(default="Endurance"),
|
||||
session_id: str = Form(default="default"),
|
||||
spirometry_pdf: UploadFile = File(...),
|
||||
pnoe_csv: UploadFile = File(...),
|
||||
oxygenation_csv: UploadFile = File(None),
|
||||
):
|
||||
"""Handle file upload and generate report"""
|
||||
# Validate file types
|
||||
if not spirometry_pdf.filename.endswith(".pdf"):
|
||||
return render_template(
|
||||
"upload.html",
|
||||
{
|
||||
"request": request,
|
||||
"session": request.session,
|
||||
"error": "Spirometry file must be a PDF",
|
||||
},
|
||||
)
|
||||
|
||||
if not pnoe_csv.filename.endswith(".csv"):
|
||||
return render_template(
|
||||
"upload.html",
|
||||
{
|
||||
"request": request,
|
||||
"session": request.session,
|
||||
"error": "Pnoe file must be a CSV",
|
||||
},
|
||||
)
|
||||
|
||||
# Validate oxygenation CSV if provided
|
||||
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
|
||||
session_uuid = str(uuid.uuid4())
|
||||
session_temp_dir = TEMP_DIR / session_uuid
|
||||
session_temp_dir.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
# Save uploaded files
|
||||
spirometry_path = session_temp_dir / f"spirometry_{spirometry_pdf.filename}"
|
||||
pnoe_path = session_temp_dir / f"pnoe_{pnoe_csv.filename}"
|
||||
oxygenation_path = None
|
||||
|
||||
try:
|
||||
# Write files
|
||||
with open(spirometry_path, "wb") as f:
|
||||
shutil.copyfileobj(spirometry_pdf.file, f)
|
||||
|
||||
with open(pnoe_path, "wb") as f:
|
||||
shutil.copyfileobj(pnoe_csv.file, f)
|
||||
|
||||
# Save oxygenation CSV if provided
|
||||
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
|
||||
patient_name = f"{first_name} {last_name}"
|
||||
patient_info = {
|
||||
"patient_name": patient_name,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"age": age,
|
||||
"height": height,
|
||||
"weight": weight,
|
||||
"gender": gender,
|
||||
"fat_percentage": fat_percentage,
|
||||
"focus": focus,
|
||||
"session_id": session_id,
|
||||
}
|
||||
|
||||
# Generate report
|
||||
oxygenation_csv_path = str(oxygenation_path) if oxygenation_path else None
|
||||
result = await report_service.generate_report(
|
||||
spirometry_pdf_path=str(spirometry_path),
|
||||
pnoe_csv_path=str(pnoe_path),
|
||||
patient_info=patient_info,
|
||||
oxygenation_csv_path=oxygenation_csv_path,
|
||||
)
|
||||
|
||||
# Store in session
|
||||
request.session["patient_info"] = patient_info
|
||||
request.session["temp_dir"] = str(session_temp_dir)
|
||||
request.session["report_path"] = result["report_path"]
|
||||
request.session["graphs_generated"] = result["graphs_generated"]
|
||||
request.session["analysis_data"] = result["analysis_data"]
|
||||
|
||||
# Extract spirometry CSV path (it's saved in data_dir by the service)
|
||||
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
|
||||
# We need to find it or extract it again
|
||||
data_dir = PathLib("data")
|
||||
spirometry_csv_path = (
|
||||
data_dir / f"spirometry_{Path(spirometry_pdf.filename).stem}.csv"
|
||||
)
|
||||
|
||||
# If it doesn't exist, extract it
|
||||
if not spirometry_csv_path.exists():
|
||||
spirometry_csv_path = extract_spirometry_table_from_pdf(
|
||||
str(spirometry_path), output_dir=str(data_dir)
|
||||
)
|
||||
spirometry_csv_path = PathLib(spirometry_csv_path)
|
||||
|
||||
# Get calculated metrics for display and editing
|
||||
context_gen = ContextGenerator()
|
||||
context_gen.load_data(
|
||||
str(pnoe_path),
|
||||
str(spirometry_csv_path),
|
||||
None, # No SECA file needed anymore
|
||||
)
|
||||
# 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()
|
||||
pnoe_metrics = context_gen.calculate_pnoe_metrics()
|
||||
|
||||
# Store metrics in session
|
||||
request.session["metrics"] = {
|
||||
"spirometry": spirometry_metrics,
|
||||
"pnoe": pnoe_metrics,
|
||||
}
|
||||
request.session["spirometry_csv_path"] = str(spirometry_csv_path)
|
||||
|
||||
return RedirectResponse(url="/preview", status_code=303)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_details = traceback.format_exc()
|
||||
print(f"ERROR: {error_details}")
|
||||
return render_template(
|
||||
"upload.html",
|
||||
{
|
||||
"request": request,
|
||||
"session": request.session,
|
||||
"error": f"Error generating report: {str(e)}",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
# Close file handles
|
||||
spirometry_pdf.file.close()
|
||||
pnoe_csv.file.close()
|
||||
if oxygenation_csv and oxygenation_csv.filename:
|
||||
oxygenation_csv.file.close()
|
||||
|
||||
|
||||
@app.get("/preview", response_class=HTMLResponse)
|
||||
async def preview(request: Request):
|
||||
"""Preview generated report"""
|
||||
if not request.session.get("report_path"):
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
return render_template(
|
||||
"preview.html", {"request": request, "session": request.session}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/graphs/{filename}")
|
||||
async def serve_graph(filename: str):
|
||||
"""Serve graph images"""
|
||||
graph_path = GRAPHS_DIR / filename
|
||||
if not graph_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Graph not found")
|
||||
return FileResponse(path=graph_path, media_type="image/png")
|
||||
|
||||
|
||||
@app.get("/edit", response_class=HTMLResponse)
|
||||
async def edit_form(request: Request):
|
||||
"""Display edit metrics form"""
|
||||
if not request.session.get("metrics"):
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
return render_template(
|
||||
"edit.html", {"request": request, "session": request.session}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/edit")
|
||||
async def edit_metrics(request: Request):
|
||||
"""Handle metric edits and regenerate report"""
|
||||
if not request.session.get("temp_dir") or not request.session.get("patient_info"):
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
|
||||
# Get form data
|
||||
form_data = await request.form()
|
||||
|
||||
# Build metric overrides
|
||||
metric_overrides = {"pnoe": {}, "spirometry": {}}
|
||||
|
||||
# Pnoe overrides
|
||||
if form_data.get("vo2_max"):
|
||||
metric_overrides["pnoe"]["vo2_max"] = float(form_data["vo2_max"])
|
||||
if form_data.get("vo2_max_per_kg"):
|
||||
metric_overrides["pnoe"]["vo2_max_per_kg"] = float(form_data["vo2_max_per_kg"])
|
||||
if form_data.get("peak_vt"):
|
||||
metric_overrides["pnoe"]["peak_vt"] = float(form_data["peak_vt"])
|
||||
if form_data.get("peak_vt_hr"):
|
||||
metric_overrides["pnoe"]["peak_vt_hr"] = float(form_data["peak_vt_hr"])
|
||||
if form_data.get("fat_max_value"):
|
||||
metric_overrides["pnoe"]["fat_max_value"] = float(form_data["fat_max_value"])
|
||||
if form_data.get("fat_max_hr"):
|
||||
metric_overrides["pnoe"]["fat_max_hr"] = float(form_data["fat_max_hr"])
|
||||
|
||||
# VT1 and VT2 overrides
|
||||
if (
|
||||
form_data.get("vt1_hr")
|
||||
or form_data.get("vt1_speed")
|
||||
or form_data.get("vt1_time")
|
||||
):
|
||||
metric_overrides["pnoe"]["vt1"] = {
|
||||
"HeartRate": float(form_data.get("vt1_hr", 0)),
|
||||
"Speed": float(form_data.get("vt1_speed", 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")
|
||||
):
|
||||
metric_overrides["pnoe"]["vt2"] = {
|
||||
"HeartRate": float(form_data.get("vt2_hr", 0)),
|
||||
"Speed": float(form_data.get("vt2_speed", 0)),
|
||||
"Time": float(form_data.get("vt2_time", 0)),
|
||||
}
|
||||
|
||||
# Heart rate zones
|
||||
for i in range(1, 6):
|
||||
zone_key = f"zone{i}_bpm"
|
||||
if form_data.get(zone_key):
|
||||
metric_overrides["pnoe"][zone_key] = form_data[zone_key]
|
||||
|
||||
# Spirometry overrides
|
||||
if form_data.get("fvc_best"):
|
||||
metric_overrides["spirometry"]["fvc_best"] = float(form_data["fvc_best"])
|
||||
if form_data.get("fvc_pred"):
|
||||
metric_overrides["spirometry"]["fvc_pred"] = float(form_data["fvc_pred"])
|
||||
if form_data.get("fev1_best"):
|
||||
metric_overrides["spirometry"]["fev1_best"] = float(form_data["fev1_best"])
|
||||
if form_data.get("fev1_pred"):
|
||||
metric_overrides["spirometry"]["fev1_pred"] = float(form_data["fev1_pred"])
|
||||
if form_data.get("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"):
|
||||
metric_overrides["spirometry"]["fev1_fvc_pct_pred"] = float(
|
||||
form_data["fev1_fvc_pct_pred"]
|
||||
)
|
||||
|
||||
try:
|
||||
# Get file paths from session
|
||||
temp_dir = Path(request.session["temp_dir"])
|
||||
patient_info = request.session["patient_info"]
|
||||
|
||||
# Find files in temp directory
|
||||
spirometry_path = None
|
||||
pnoe_path = None
|
||||
oxygenation_path = None
|
||||
|
||||
for file_path in temp_dir.iterdir():
|
||||
if file_path.name.startswith("spirometry_"):
|
||||
spirometry_path = file_path
|
||||
elif file_path.name.startswith("pnoe_"):
|
||||
pnoe_path = file_path
|
||||
elif file_path.name.startswith("oxygenation_"):
|
||||
oxygenation_path = file_path
|
||||
|
||||
if not all([spirometry_path, pnoe_path]):
|
||||
raise ValueError("Could not find all required uploaded files")
|
||||
|
||||
# Regenerate report with overrides
|
||||
oxygenation_csv_path = str(oxygenation_path) if oxygenation_path else None
|
||||
result = await report_service.generate_report(
|
||||
spirometry_pdf_path=str(spirometry_path),
|
||||
pnoe_csv_path=str(pnoe_path),
|
||||
patient_info=patient_info,
|
||||
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
|
||||
request.session["report_path"] = result["report_path"]
|
||||
request.session["graphs_generated"] = result["graphs_generated"]
|
||||
request.session["analysis_data"] = result["analysis_data"]
|
||||
|
||||
# Recalculate metrics with overrides
|
||||
from services.context_generator import ContextGenerator
|
||||
|
||||
context_gen = ContextGenerator()
|
||||
spirometry_csv_path = request.session.get("spirometry_csv_path", "")
|
||||
if not spirometry_csv_path or not Path(spirometry_csv_path).exists():
|
||||
from pathlib import Path as PathLib
|
||||
|
||||
from services.spirometry_table_extractor import (
|
||||
extract_spirometry_table_from_pdf,
|
||||
)
|
||||
|
||||
data_dir = PathLib("data")
|
||||
spirometry_csv_path = extract_spirometry_table_from_pdf(
|
||||
str(spirometry_path), output_dir=str(data_dir)
|
||||
)
|
||||
spirometry_csv_path = str(PathLib(spirometry_csv_path))
|
||||
|
||||
context_gen.load_data(
|
||||
str(pnoe_path),
|
||||
spirometry_csv_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", ""))
|
||||
|
||||
spirometry_overrides = metric_overrides.get("spirometry", {})
|
||||
pnoe_overrides = metric_overrides.get("pnoe", {})
|
||||
spirometry_metrics = context_gen.calculate_spirometry_metrics(
|
||||
spirometry_overrides
|
||||
)
|
||||
pnoe_metrics = context_gen.calculate_pnoe_metrics(pnoe_overrides)
|
||||
|
||||
# Update metrics in session
|
||||
request.session["metrics"] = {
|
||||
"spirometry": spirometry_metrics,
|
||||
"pnoe": pnoe_metrics,
|
||||
}
|
||||
request.session["spirometry_csv_path"] = spirometry_csv_path
|
||||
|
||||
return RedirectResponse(url="/preview", status_code=303)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_details = traceback.format_exc()
|
||||
print(f"ERROR: {error_details}")
|
||||
return render_template(
|
||||
"edit.html",
|
||||
{
|
||||
"request": request,
|
||||
"session": request.session,
|
||||
"error": f"Error regenerating report: {str(e)}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {"status": "healthy", "service": "report-generation-api"}
|
||||
|
||||
|
||||
@app.post("/generate-report", response_model=ReportResponse)
|
||||
async def generate_report(
|
||||
patient_name: str = Form(..., description="Patient name"),
|
||||
age: int = Form(..., description="Patient age"),
|
||||
height: str = Form(..., description="Patient height (e.g., 5'4\")"),
|
||||
weight: str = Form(..., description="Patient weight (e.g., 123lbs)"),
|
||||
focus: str = Form(default="Endurance", description="Training focus"),
|
||||
session_id: str = Form(default="default", description="Session ID"),
|
||||
spirometry_pdf: UploadFile = File(..., description="Spirometry PDF file"),
|
||||
pnoe_csv: UploadFile = File(..., description="Pnoe CSV file"),
|
||||
seca_excel: UploadFile = File(..., description="SECA Excel file"),
|
||||
):
|
||||
"""
|
||||
Generate a comprehensive medical report from uploaded files.
|
||||
|
||||
This endpoint accepts all required files and patient information,
|
||||
processes the data, generates graphs, and returns a PDF report.
|
||||
|
||||
Args:
|
||||
spirometry_pdf: Spirometry PDF file
|
||||
pnoe_csv: Pnoe CSV data file
|
||||
seca_excel: SECA body composition Excel file
|
||||
patient_name: Name of the patient
|
||||
age: Patient age
|
||||
height: Patient height
|
||||
weight: Patient weight
|
||||
focus: Training focus (default: Endurance)
|
||||
session_id: Session identifier (default: default)
|
||||
|
||||
Returns:
|
||||
ReportResponse with report path, graphs generated, and analysis data
|
||||
"""
|
||||
# Validate file types
|
||||
if not spirometry_pdf.filename.endswith(".pdf"):
|
||||
raise HTTPException(status_code=400, detail="Spirometry file must be a PDF")
|
||||
|
||||
if not pnoe_csv.filename.endswith(".csv"):
|
||||
raise HTTPException(status_code=400, detail="Pnoe file must be a CSV")
|
||||
|
||||
if not seca_excel.filename.endswith((".xlsx", ".xls")):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="SECA file must be an Excel file (.xlsx or .xls)"
|
||||
)
|
||||
|
||||
# Create temporary directory for uploaded files
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Save uploaded files temporarily
|
||||
spirometry_path = temp_path / f"spirometry_{spirometry_pdf.filename}"
|
||||
pnoe_path = temp_path / f"pnoe_{pnoe_csv.filename}"
|
||||
seca_path = temp_path / f"seca_{seca_excel.filename}"
|
||||
|
||||
try:
|
||||
# Write files
|
||||
with open(spirometry_path, "wb") as f:
|
||||
shutil.copyfileobj(spirometry_pdf.file, f)
|
||||
|
||||
with open(pnoe_path, "wb") as f:
|
||||
shutil.copyfileobj(pnoe_csv.file, f)
|
||||
|
||||
with open(seca_path, "wb") as f:
|
||||
shutil.copyfileobj(seca_excel.file, f)
|
||||
|
||||
# Prepare patient information
|
||||
patient_info = {
|
||||
"patient_name": patient_name,
|
||||
"age": age,
|
||||
"height": height,
|
||||
"weight": weight,
|
||||
"focus": focus,
|
||||
"session_id": session_id,
|
||||
}
|
||||
|
||||
# Generate report using the service
|
||||
result = await report_service.generate_report(
|
||||
spirometry_pdf_path=str(spirometry_path),
|
||||
pnoe_csv_path=str(pnoe_path),
|
||||
seca_excel_path=str(seca_path),
|
||||
patient_info=patient_info,
|
||||
)
|
||||
|
||||
return ReportResponse(
|
||||
message="Report generated successfully",
|
||||
report_path=result["report_path"],
|
||||
graphs_generated=result["graphs_generated"],
|
||||
analysis_data=result["analysis_data"],
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_details = traceback.format_exc()
|
||||
print(f"ERROR: {error_details}") # This will show in terminal
|
||||
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error generating report: {str(e)}\n{error_details}",
|
||||
)
|
||||
finally:
|
||||
# Close file handles
|
||||
spirometry_pdf.file.close()
|
||||
pnoe_csv.file.close()
|
||||
seca_excel.file.close()
|
||||
|
||||
|
||||
@app.get("/download-report/{filename}")
|
||||
async def download_report(filename: str):
|
||||
"""
|
||||
Download a generated report.
|
||||
|
||||
Args:
|
||||
filename: Name of the report file
|
||||
|
||||
Returns:
|
||||
PDF file
|
||||
"""
|
||||
file_path = REPORTS_DIR / filename
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Report not found")
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
media_type="application/pdf",
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -26,7 +26,7 @@
|
||||
<!-- Name and Date Section -->
|
||||
<div class="text-right mt-16">
|
||||
<h2 class="text-4xl font-bold tracking-wider mb-2">
|
||||
{{ name|upper }}
|
||||
{{ first_name|upper }}
|
||||
</h2>
|
||||
<h2 class="text-4xl font-bold tracking-wider mb-6">
|
||||
{{ surname|upper }}
|
||||
@@ -0,0 +1,777 @@
|
||||
"""
|
||||
Context Generator Service
|
||||
|
||||
This service processes all data files and generates context dictionaries for each page
|
||||
of the medical report. It performs analysis on Pnoe, Spirometry, and SECA data.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class ContextGenerator:
|
||||
"""Generate context data for report pages"""
|
||||
|
||||
def __init__(self):
|
||||
self.pnoe_df = None
|
||||
self.spirometry_df = None
|
||||
self.seca_df = None
|
||||
self.patient_info = {}
|
||||
|
||||
def load_data(
|
||||
self,
|
||||
pnoe_path: str,
|
||||
spirometry_path: str,
|
||||
seca_path: Optional[str] = None,
|
||||
):
|
||||
"""Load all required datasets"""
|
||||
self.pnoe_df = pd.read_csv(pnoe_path, delimiter=";")
|
||||
self.spirometry_df = pd.read_csv(spirometry_path)
|
||||
if seca_path:
|
||||
self.seca_df = pd.read_excel(seca_path)
|
||||
else:
|
||||
self.seca_df = None
|
||||
self._preprocess_pnoe_data()
|
||||
|
||||
def _preprocess_pnoe_data(self):
|
||||
"""Apply preprocessing steps to Pnoe data"""
|
||||
# Convert numeric columns
|
||||
for col in self.pnoe_df.columns:
|
||||
try:
|
||||
self.pnoe_df[col] = pd.to_numeric(self.pnoe_df[col])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
self.pnoe_df["VO2 Pulse"] = (
|
||||
self.pnoe_df["VO2(ml/min)"] / self.pnoe_df["HR(bpm)"]
|
||||
)
|
||||
self.pnoe_df["VO2 Breath"] = (
|
||||
self.pnoe_df["VO2(ml/min)"] / self.pnoe_df["BF(bpm)"]
|
||||
)
|
||||
self.pnoe_df["CHO"] = (
|
||||
self.pnoe_df["EE(kcal/min)"] * self.pnoe_df["CARBS(%)"] / 100
|
||||
)
|
||||
self.pnoe_df["FAT"] = (
|
||||
self.pnoe_df["EE(kcal/min)"] * self.pnoe_df["FAT(%)"] / 100
|
||||
)
|
||||
|
||||
window_size = 10
|
||||
columns_to_smooth = [
|
||||
"VO2(ml/min)",
|
||||
"VCO2(ml/min)",
|
||||
"HR(bpm)",
|
||||
"VT(l)",
|
||||
"BF(bpm)",
|
||||
"VE(l/min)",
|
||||
"VO2 Pulse",
|
||||
"VO2 Breath",
|
||||
"CHO",
|
||||
"FAT",
|
||||
]
|
||||
|
||||
for col in columns_to_smooth:
|
||||
if col in self.pnoe_df.columns:
|
||||
self.pnoe_df[f"{col}_smoothed"] = (
|
||||
self.pnoe_df[col].rolling(window=window_size, min_periods=1).mean()
|
||||
)
|
||||
|
||||
def extract_patient_info(self, patient_name: str) -> Dict:
|
||||
"""Extract patient information from SECA dataset or use provided patient_info"""
|
||||
if self.seca_df is not None:
|
||||
patient_data = self.seca_df[
|
||||
self.seca_df["LastName"].str.contains(
|
||||
patient_name, case=False, na=False
|
||||
)
|
||||
]
|
||||
if not patient_data.empty:
|
||||
row = patient_data.iloc[0]
|
||||
weight_kg = float(row.get("Weight", 0))
|
||||
fat_pct = float(row.get("Adult_FMP", 0))
|
||||
|
||||
self.patient_info = {
|
||||
"name": f"{row.get('FirstName', '')} {row.get('LastName', '')}",
|
||||
"first_name": row.get("FirstName", ""),
|
||||
"last_name": row.get("LastName", ""),
|
||||
"age": int(row.get("Age", 0)),
|
||||
"height": f"{row.get('Height', '')}",
|
||||
"weight": weight_kg,
|
||||
"gender": row.get("Gender", "").lower(),
|
||||
"fat_percentage": fat_pct,
|
||||
"fat_mass_lbs": weight_kg * 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
|
||||
|
||||
def calculate_spirometry_metrics(
|
||||
self, metric_overrides: Optional[Dict] = None
|
||||
) -> Dict:
|
||||
"""Calculate spirometry-related metrics"""
|
||||
if metric_overrides is None:
|
||||
metric_overrides = {}
|
||||
|
||||
metrics = {}
|
||||
for param in ["FVC", "FEV1", "FEV1/FVC%"]:
|
||||
param_key = param.lower().replace("/", "_").replace("%", "_pct")
|
||||
|
||||
if f"{param_key}_best" in metric_overrides:
|
||||
metrics[f"{param_key}_best"] = float(
|
||||
metric_overrides[f"{param_key}_best"]
|
||||
)
|
||||
else:
|
||||
row = self.spirometry_df.loc[
|
||||
self.spirometry_df["Parameters"].str.strip() == param
|
||||
]
|
||||
if not row.empty:
|
||||
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:
|
||||
metrics[f"{param_key}_pred"] = float(
|
||||
metric_overrides[f"{param_key}_pred"]
|
||||
)
|
||||
else:
|
||||
row = self.spirometry_df.loc[
|
||||
self.spirometry_df["Parameters"].str.strip() == param
|
||||
]
|
||||
if not row.empty:
|
||||
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
|
||||
|
||||
def calculate_pnoe_metrics(self, metric_overrides: Optional[Dict] = None) -> Dict:
|
||||
"""Calculate all Pnoe-derived metrics"""
|
||||
if metric_overrides is None:
|
||||
metric_overrides = {}
|
||||
|
||||
metrics = {}
|
||||
|
||||
# VO2 Max metrics
|
||||
if "vo2_max" in metric_overrides:
|
||||
metrics["vo2_max"] = float(metric_overrides["vo2_max"])
|
||||
else:
|
||||
metrics["vo2_max"] = self.pnoe_df["VO2(ml/min)_smoothed"].max()
|
||||
|
||||
if "vo2_max_per_kg" in metric_overrides:
|
||||
metrics["vo2_max_per_kg"] = float(metric_overrides["vo2_max_per_kg"])
|
||||
else:
|
||||
metrics["vo2_max_per_kg"] = metrics["vo2_max"] / self.patient_info["weight"]
|
||||
|
||||
# Peak VT metrics
|
||||
if "peak_vt" in metric_overrides:
|
||||
metrics["peak_vt"] = float(metric_overrides["peak_vt"])
|
||||
# Need to get HR from override or calculate
|
||||
if "peak_vt_hr" in metric_overrides:
|
||||
metrics["peak_vt_hr"] = float(metric_overrides["peak_vt_hr"])
|
||||
else:
|
||||
peak_vt_idx = self.pnoe_df["VT(l)_smoothed"].idxmax()
|
||||
peak_vt_row = self.pnoe_df.loc[peak_vt_idx]
|
||||
metrics["peak_vt_hr"] = peak_vt_row["HR(bpm)_smoothed"]
|
||||
else:
|
||||
peak_vt_idx = self.pnoe_df["VT(l)_smoothed"].idxmax()
|
||||
peak_vt_row = self.pnoe_df.loc[peak_vt_idx]
|
||||
metrics["peak_vt"] = peak_vt_row["VT(l)_smoothed"]
|
||||
metrics["peak_vt_hr"] = peak_vt_row["HR(bpm)_smoothed"]
|
||||
|
||||
# Fat Max metrics
|
||||
if "fat_max_value" in metric_overrides:
|
||||
metrics["fat_max_value"] = float(metric_overrides["fat_max_value"])
|
||||
if "fat_max_hr" in metric_overrides:
|
||||
metrics["fat_max_hr"] = float(metric_overrides["fat_max_hr"])
|
||||
else:
|
||||
fat_max_idx = self.pnoe_df["FAT_smoothed"].idxmax()
|
||||
fat_max_row = self.pnoe_df.loc[fat_max_idx]
|
||||
metrics["fat_max_hr"] = fat_max_row["HR(bpm)_smoothed"]
|
||||
else:
|
||||
fat_max_idx = self.pnoe_df["FAT_smoothed"].idxmax()
|
||||
fat_max_row = self.pnoe_df.loc[fat_max_idx]
|
||||
metrics["fat_max_value"] = fat_max_row["FAT_smoothed"]
|
||||
metrics["fat_max_hr"] = fat_max_row["HR(bpm)_smoothed"]
|
||||
|
||||
# VT1 and VT2 thresholds
|
||||
if "vt1" in metric_overrides:
|
||||
metrics["vt1"] = metric_overrides["vt1"]
|
||||
else:
|
||||
vt1, _ = self._detect_thresholds()
|
||||
metrics["vt1"] = vt1
|
||||
|
||||
if "vt2" in metric_overrides:
|
||||
metrics["vt2"] = metric_overrides["vt2"]
|
||||
else:
|
||||
_, vt2 = self._detect_thresholds()
|
||||
metrics["vt2"] = vt2
|
||||
|
||||
# Heart rate zones
|
||||
if any(f"zone{i}_bpm" in metric_overrides for i in range(1, 6)):
|
||||
for i in range(1, 6):
|
||||
zone_key = f"zone{i}_bpm"
|
||||
if zone_key in metric_overrides:
|
||||
metrics[zone_key] = metric_overrides[zone_key]
|
||||
else:
|
||||
fat_max_idx = self.pnoe_df["FAT_smoothed"].idxmax()
|
||||
fat_max_row = self.pnoe_df.loc[fat_max_idx]
|
||||
zones = self._calculate_hr_zones(
|
||||
metrics["vt1"], metrics["vt2"], fat_max_row
|
||||
)
|
||||
metrics.update(zones)
|
||||
|
||||
return metrics
|
||||
|
||||
def _detect_thresholds(self) -> Tuple[Optional[Dict], Optional[Dict]]:
|
||||
"""Detect VT1 and VT2 thresholds"""
|
||||
condition = self.pnoe_df["CHO_smoothed"] > self.pnoe_df["FAT_smoothed"]
|
||||
crossover_indices = condition[condition].index
|
||||
|
||||
vt1 = None
|
||||
if len(crossover_indices) > 0:
|
||||
vt1_idx = crossover_indices[0]
|
||||
vt1_row = self.pnoe_df.loc[vt1_idx]
|
||||
vt1 = {
|
||||
"HeartRate": vt1_row["HR(bpm)_smoothed"],
|
||||
"Speed": vt1_row["Speed"],
|
||||
"Time": vt1_row["T(sec)"],
|
||||
}
|
||||
|
||||
ve_slope = self.pnoe_df["VE(l/min)_smoothed"].diff()
|
||||
second_derivative = ve_slope.diff()
|
||||
vt2_idx = second_derivative.idxmax()
|
||||
|
||||
vt2 = None
|
||||
if pd.notna(vt2_idx):
|
||||
vt2_row = self.pnoe_df.loc[vt2_idx]
|
||||
vt2 = {
|
||||
"HeartRate": vt2_row["HR(bpm)_smoothed"],
|
||||
"Speed": vt2_row["Speed"],
|
||||
"Time": vt2_row["T(sec)"],
|
||||
}
|
||||
|
||||
return vt1, vt2
|
||||
|
||||
def _calculate_hr_zones(
|
||||
self, vt1: Optional[Dict], vt2: Optional[Dict], fat_max_row: pd.Series
|
||||
) -> Dict:
|
||||
"""Calculate heart rate zones based on thresholds"""
|
||||
zones = {}
|
||||
if vt1 and vt2:
|
||||
zone_1_start = fat_max_row["HR(bpm)_smoothed"] - 15
|
||||
zone_2_start = fat_max_row["HR(bpm)_smoothed"]
|
||||
zone_3_start = vt1["HeartRate"]
|
||||
zone_4_start = vt2["HeartRate"] - 10
|
||||
zone_5_start = vt2["HeartRate"] + 10
|
||||
|
||||
zones["zone1_bpm"] = f"{int(zone_1_start)}-{int(zone_2_start)}bpm"
|
||||
zones["zone2_bpm"] = f"{int(zone_2_start)}-{int(vt1['HeartRate'])}bpm"
|
||||
zones["zone3_bpm"] = f"{int(zone_3_start)}-{int(zone_4_start)}bpm"
|
||||
zones["zone4_bpm"] = f"{int(zone_4_start)}-{int(zone_5_start)}bpm"
|
||||
zones["zone5_bpm"] = f"{int(zone_5_start)}+bpm"
|
||||
else:
|
||||
max_hr = 220 - self.patient_info["age"]
|
||||
zones["zone1_bpm"] = f"{int(max_hr * 0.55)}-{int(max_hr * 0.65)}bpm"
|
||||
zones["zone2_bpm"] = f"{int(max_hr * 0.65)}-{int(max_hr * 0.75)}bpm"
|
||||
zones["zone3_bpm"] = f"{int(max_hr * 0.75)}-{int(max_hr * 0.85)}bpm"
|
||||
zones["zone4_bpm"] = f"{int(max_hr * 0.85)}-{int(max_hr * 0.95)}bpm"
|
||||
zones["zone5_bpm"] = f"{int(max_hr * 0.95)}+bpm"
|
||||
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["RER"] == 0.9].copy()
|
||||
if "RER" 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(
|
||||
self,
|
||||
patient_name: str,
|
||||
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:
|
||||
metric_overrides = {}
|
||||
|
||||
self.extract_patient_info(patient_name)
|
||||
|
||||
# Extract metric overrides for spirometry and pnoe
|
||||
spirometry_overrides = metric_overrides.get("spirometry", {})
|
||||
pnoe_overrides = metric_overrides.get("pnoe", {})
|
||||
|
||||
spirometry_metrics = self.calculate_spirometry_metrics(spirometry_overrides)
|
||||
pnoe_metrics = self.calculate_pnoe_metrics(pnoe_overrides)
|
||||
rmr_metrics = self.calculate_rmr_and_fuel_source()
|
||||
|
||||
contexts = {}
|
||||
|
||||
# Page 1
|
||||
contexts["page_1"] = {
|
||||
"name": self.patient_info["name"],
|
||||
"surname": self.patient_info["last_name"],
|
||||
"date": datetime.now().strftime("%B %d, %Y"),
|
||||
}
|
||||
|
||||
# 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"],
|
||||
"page_number": i + 3,
|
||||
}
|
||||
|
||||
# Page 4 - Nutrition Guidelines with Body Composition
|
||||
contexts["page_4"] = {
|
||||
"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
|
||||
if spirometry_metrics.get("fvc_best"):
|
||||
fev1_percentage = (
|
||||
pnoe_metrics["peak_vt"] / spirometry_metrics["fvc_best"]
|
||||
) * 100
|
||||
|
||||
# Page 7
|
||||
contexts["page_7"] = {
|
||||
"peak_vt": f"{pnoe_metrics['peak_vt']:.2f}",
|
||||
"peak_vt_bpm": f"{int(pnoe_metrics['peak_vt_hr'])}",
|
||||
"fev1_percentage": f"{fev1_percentage:.1f}",
|
||||
"lung_analysis_chart": graphs.get("spirometry_chart", ""),
|
||||
"respiratory_analysis_chart": graphs.get("respiratory", ""),
|
||||
}
|
||||
|
||||
# Page 8
|
||||
contexts["page_8"] = {
|
||||
"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", ""),
|
||||
}
|
||||
|
||||
# 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 Master Chart
|
||||
contexts["page_18"] = {
|
||||
"patient_name": self.patient_info["name"],
|
||||
"page_number": 18,
|
||||
"body_fat_percentage_chart": graphs.get(
|
||||
"body_fat_percentage_master_chart", ""
|
||||
),
|
||||
}
|
||||
|
||||
# Page 19
|
||||
contexts["page_19"] = {
|
||||
"patient_name": self.patient_info["name"],
|
||||
"page_number": 19,
|
||||
}
|
||||
|
||||
return contexts
|
||||
@@ -0,0 +1,536 @@
|
||||
"""
|
||||
Report Generator Service
|
||||
|
||||
This service handles the generation of medical reports from uploaded files.
|
||||
It processes data, generates graphs, and creates PDF reports.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from playwright.async_api import async_playwright
|
||||
from services.context_generator import ContextGenerator
|
||||
from services.graph_generator import GraphGenerator
|
||||
from services.spirometry_table_extractor import extract_spirometry_table_from_pdf
|
||||
|
||||
|
||||
class ReportGeneratorService:
|
||||
"""Service for generating medical performance reports"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
template_dir: str = "app/report_gen",
|
||||
graphs_dir: str = "graphs",
|
||||
reports_dir: str = "reports",
|
||||
data_dir: str = "data",
|
||||
):
|
||||
"""
|
||||
Initialize the report generator service.
|
||||
|
||||
Args:
|
||||
template_dir: Directory containing Jinja2 templates
|
||||
graphs_dir: Directory to save generated graphs
|
||||
reports_dir: Directory to save generated reports
|
||||
data_dir: Directory to store extracted/processed data
|
||||
"""
|
||||
self.template_dir = template_dir
|
||||
self.graphs_dir = Path(graphs_dir)
|
||||
self.reports_dir = Path(reports_dir)
|
||||
self.data_dir = Path(data_dir)
|
||||
self.graph_generator = GraphGenerator(charts_dir=str(self.graphs_dir))
|
||||
self.context_generator = ContextGenerator()
|
||||
self.env = Environment(loader=FileSystemLoader(template_dir))
|
||||
|
||||
# Ensure directories exist
|
||||
self.graphs_dir.mkdir(exist_ok=True)
|
||||
self.reports_dir.mkdir(exist_ok=True)
|
||||
self.data_dir.mkdir(exist_ok=True)
|
||||
|
||||
def process_pnoe_data(self, pnoe_csv_path: str) -> pd.DataFrame:
|
||||
"""
|
||||
Load and process Pnoe CSV data.
|
||||
|
||||
Args:
|
||||
pnoe_csv_path: Path to Pnoe CSV file
|
||||
|
||||
Returns:
|
||||
Processed DataFrame with smoothed columns
|
||||
"""
|
||||
# Load data
|
||||
df = pd.read_csv(pnoe_csv_path, delimiter=";")
|
||||
|
||||
# Convert numeric columns (updated approach)
|
||||
for col in df.columns:
|
||||
try:
|
||||
df[col] = pd.to_numeric(df[col])
|
||||
except (ValueError, TypeError):
|
||||
pass # Keep as-is if not numeric
|
||||
|
||||
# Calculate derived columns
|
||||
df["VO2 Pulse"] = df["VO2(ml/min)"] / df["HR(bpm)"]
|
||||
df["VO2 Breath"] = df["VO2(ml/min)"] / df["BF(bpm)"]
|
||||
df["CHO"] = df["EE(kcal/min)"] * df["CARBS(%)"] / 100
|
||||
df["FAT"] = df["EE(kcal/min)"] * df["FAT(%)"] / 100
|
||||
|
||||
# Smooth columns
|
||||
window_size = 10
|
||||
columns_to_smooth = [
|
||||
"VO2(ml/min)",
|
||||
"VCO2(ml/min)",
|
||||
"HR(bpm)",
|
||||
"VT(l)",
|
||||
"BF(bpm)",
|
||||
"VE(l/min)",
|
||||
"VO2 Pulse",
|
||||
"VO2 Breath",
|
||||
"CHO",
|
||||
"FAT",
|
||||
]
|
||||
|
||||
for col in columns_to_smooth:
|
||||
if col in df.columns:
|
||||
df[f"{col}_smoothed"] = (
|
||||
df[col].rolling(window=window_size, min_periods=1).mean()
|
||||
)
|
||||
|
||||
return df
|
||||
|
||||
def generate_graphs(self, df: pd.DataFrame) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Generate all required graphs from processed data.
|
||||
|
||||
Args:
|
||||
df: Processed DataFrame with smoothed columns
|
||||
|
||||
Returns:
|
||||
List of dictionaries containing graph names and paths
|
||||
"""
|
||||
graphs_generated = []
|
||||
|
||||
# List of graphs to generate
|
||||
graph_methods = [
|
||||
("respiratory", self.graph_generator.generate_respiratory_chart),
|
||||
("fuel_utilization", self.graph_generator.generate_fuel_utilization_chart),
|
||||
("vo2_pulse", self.graph_generator.generate_vo2_pulse_chart),
|
||||
("vo2_breath", self.graph_generator.generate_vo2_breath_chart),
|
||||
("fat_metabolism", self.graph_generator.generate_fat_metabolism_chart),
|
||||
("recovery", self.graph_generator.generate_recovery_chart),
|
||||
]
|
||||
|
||||
for name, method in graph_methods:
|
||||
try:
|
||||
path = method(df, save_as_base64=False)
|
||||
graphs_generated.append({"name": name, "path": str(path)})
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not generate {name} chart: {e}")
|
||||
|
||||
return graphs_generated
|
||||
|
||||
def calculate_analysis_metrics(self, df: pd.DataFrame) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate basic analysis metrics from processed data.
|
||||
|
||||
Args:
|
||||
df: Processed DataFrame with smoothed columns
|
||||
|
||||
Returns:
|
||||
Dictionary containing analysis metrics
|
||||
"""
|
||||
return {
|
||||
"vo2_max": float(df["VO2(ml/min)_smoothed"].max())
|
||||
if "VO2(ml/min)_smoothed" in df.columns
|
||||
else 0,
|
||||
"peak_vt": float(df["VT(l)_smoothed"].max())
|
||||
if "VT(l)_smoothed" in df.columns
|
||||
else 0,
|
||||
"max_hr": float(df["HR(bpm)_smoothed"].max())
|
||||
if "HR(bpm)_smoothed" in df.columns
|
||||
else 0,
|
||||
}
|
||||
|
||||
def generate_html(
|
||||
self, patient_info: Dict[str, Any], contexts: Dict[str, Dict[str, Any]]
|
||||
) -> str:
|
||||
"""
|
||||
Generate HTML content for the report.
|
||||
|
||||
Args:
|
||||
patient_info: Dictionary containing patient information
|
||||
(patient_name, age, height, weight, focus)
|
||||
contexts: Dictionary with keys 'page_1', 'page_2', etc., each containing context data
|
||||
|
||||
Returns:
|
||||
Complete HTML document as string
|
||||
"""
|
||||
html_pages = []
|
||||
|
||||
# Header context
|
||||
header_context = {
|
||||
"patient_name": patient_info.get("patient_name", ""),
|
||||
"age": patient_info.get("age", ""),
|
||||
"height": patient_info.get("height", ""),
|
||||
"weight": patient_info.get("weight", ""),
|
||||
"focus": patient_info.get("focus", "Endurance"),
|
||||
}
|
||||
|
||||
# Get total number of pages
|
||||
num_pages = len(contexts)
|
||||
|
||||
# Footer context
|
||||
footer_context = [
|
||||
{
|
||||
"contact_email": "info@ishplabs.com",
|
||||
"website": "www.ishplabs.com",
|
||||
"social": "@ishplabs",
|
||||
"page_number": i + 1,
|
||||
}
|
||||
for i in range(num_pages)
|
||||
]
|
||||
|
||||
# Render header
|
||||
header_html = self.env.get_template("header.html").render(header_context)
|
||||
|
||||
# Render footers
|
||||
footer_html_list = [
|
||||
self.env.get_template("footer.html").render(context)
|
||||
for context in footer_context
|
||||
]
|
||||
|
||||
# Render pages - iterate through pages in order
|
||||
for i in range(1, num_pages + 1):
|
||||
page_key = f"page_{i}"
|
||||
context = contexts.get(page_key, {})
|
||||
template = self.env.get_template(f"page_{i}.html").render(context)
|
||||
|
||||
if i > 2:
|
||||
full_html = f"""
|
||||
<div class="page flex flex-col justify-between">
|
||||
<div>
|
||||
{header_html}
|
||||
</div>
|
||||
<main class="flex-grow p-4">
|
||||
{template}
|
||||
</main>
|
||||
<div class="border-t text-center text-sm text-gray-600">
|
||||
{footer_html_list[i - 1]}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
html_pages.append(full_html)
|
||||
else:
|
||||
html_pages.append(template)
|
||||
|
||||
# Combine with page breaks
|
||||
final_html = "<div class='page-break'></div>".join(html_pages)
|
||||
|
||||
# Wrap in full HTML document
|
||||
html_doc = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss/dist/tailwind.min.css" rel="stylesheet">
|
||||
<style>
|
||||
html, body {{
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}}
|
||||
.page-break {{ page-break-after: always; }}
|
||||
.page {{
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}}
|
||||
.page main {{
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}}
|
||||
* {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
img {{
|
||||
max-height: 300px;
|
||||
}}
|
||||
.chart-large {{
|
||||
max-height: 500px !important;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body class="m-0 p-0">
|
||||
{final_html}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return html_doc
|
||||
|
||||
async def html_to_pdf(self, html_content: str, pdf_path: str) -> None:
|
||||
"""
|
||||
Convert HTML content to PDF file.
|
||||
|
||||
Args:
|
||||
html_content: HTML content as string
|
||||
pdf_path: Path where PDF should be saved
|
||||
"""
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch()
|
||||
page = await browser.new_page()
|
||||
await page.set_content(html_content)
|
||||
await page.pdf(path=pdf_path, format="A4", print_background=True)
|
||||
await browser.close()
|
||||
|
||||
async def generate_report(
|
||||
self,
|
||||
spirometry_pdf_path: str,
|
||||
pnoe_csv_path: str,
|
||||
patient_info: Dict[str, Any],
|
||||
output_filename: str = None,
|
||||
metric_overrides: Optional[Dict[str, Any]] = None,
|
||||
oxygenation_csv_path: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate complete medical report from uploaded files.
|
||||
|
||||
This follows the complete workflow:
|
||||
1. Extract spirometry data from PDF
|
||||
2. Store all data in data directory
|
||||
3. Generate all graphs
|
||||
4. Generate context for each page
|
||||
5. Generate final HTML and PDF report
|
||||
|
||||
Args:
|
||||
spirometry_pdf_path: Path to Spirometry PDF file
|
||||
pnoe_csv_path: Path to Pnoe CSV file
|
||||
seca_excel_path: Path to SECA Excel file
|
||||
patient_info: Dictionary containing patient information
|
||||
output_filename: Optional custom output filename
|
||||
|
||||
Returns:
|
||||
Dictionary containing report path, graphs generated, and analysis data
|
||||
"""
|
||||
# Step 1: Extract spirometry table from PDF
|
||||
print("Step 1: Extracting spirometry data from PDF...")
|
||||
spirometry_csv_path = extract_spirometry_table_from_pdf(
|
||||
spirometry_pdf_path, output_dir=str(self.data_dir)
|
||||
)
|
||||
print(f"Spirometry data saved to: {spirometry_csv_path}")
|
||||
|
||||
# Step 2: Process Pnoe data
|
||||
print("Step 2: Processing Pnoe data...")
|
||||
df = self.process_pnoe_data(pnoe_csv_path)
|
||||
|
||||
# Step 3: Generate all graphs
|
||||
print("Step 3: Generating graphs...")
|
||||
graphs_generated = self.generate_graphs(df)
|
||||
|
||||
# Create graph dictionary with base64 encoded images
|
||||
import base64
|
||||
|
||||
graphs_dict = {}
|
||||
for graph in graphs_generated:
|
||||
# Read the graph file and convert to base64
|
||||
graph_path = Path(graph["path"])
|
||||
if graph_path.exists():
|
||||
with open(graph_path, "rb") as f:
|
||||
graphs_dict[graph["name"]] = base64.b64encode(f.read()).decode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
# Also generate body composition charts
|
||||
# Use patient info directly (no SECA file needed)
|
||||
fat_pct = patient_info.get("fat_percentage", 0)
|
||||
age = patient_info.get("age", 25)
|
||||
gender = patient_info.get("gender", "female").lower()
|
||||
|
||||
# Convert weight to kg if needed
|
||||
weight_str = str(patient_info.get("weight", "0"))
|
||||
# Extract numeric value and unit
|
||||
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
|
||||
|
||||
# Convert to kg if weight is in lbs
|
||||
if "lbs" in weight_str.lower():
|
||||
weight_kg = weight_value / 2.20462 # Convert lbs to kg
|
||||
else:
|
||||
weight_kg = weight_value # Already in kg or assume kg if no unit specified
|
||||
|
||||
# Calculate fat and lean mass in pounds
|
||||
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 (save as file first, then convert to base64)
|
||||
try:
|
||||
body_comp_path = self.graph_generator.generate_body_composition_chart(
|
||||
fat_mass_lbs, lean_mass_lbs, save_as_base64=False
|
||||
)
|
||||
graphs_generated.append(
|
||||
{"name": "body_composition", "path": str(body_comp_path)}
|
||||
)
|
||||
# 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"] = ""
|
||||
|
||||
# Load static body fat percentage master chart for page 18
|
||||
master_chart_path = Path("app/body_fat_percentage_master_chart.png")
|
||||
if master_chart_path.exists():
|
||||
try:
|
||||
with open(master_chart_path, "rb") as f:
|
||||
graphs_dict["body_fat_percentage_master_chart"] = base64.b64encode(
|
||||
f.read()
|
||||
).decode("utf-8")
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load body fat percentage master chart: {e}")
|
||||
graphs_dict["body_fat_percentage_master_chart"] = ""
|
||||
else:
|
||||
print(
|
||||
f"Warning: Body fat percentage master chart not found at {master_chart_path}"
|
||||
)
|
||||
graphs_dict["body_fat_percentage_master_chart"] = ""
|
||||
|
||||
# Generate spirometry chart
|
||||
print("Step 4: Generating spirometry chart...")
|
||||
try:
|
||||
spirometry_df = pd.read_csv(spirometry_csv_path)
|
||||
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_df, save_as_base64=True
|
||||
)
|
||||
graphs_dict["spirometry_chart"] = spirometry_chart_b64
|
||||
print("Spirometry chart generated successfully")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_details = traceback.format_exc()
|
||||
print(f"Warning: Could not generate spirometry chart: {e}")
|
||||
print(f"Error details: {error_details}")
|
||||
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
|
||||
print("Step 5: Generating page contexts...")
|
||||
patient_name = patient_info.get("patient_name", "")
|
||||
self.context_generator.load_data(
|
||||
pnoe_csv_path,
|
||||
str(spirometry_csv_path),
|
||||
None, # No SECA file
|
||||
)
|
||||
# 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
|
||||
)
|
||||
|
||||
# Step 5: Calculate analysis metrics
|
||||
analysis_data = self.calculate_analysis_metrics(df)
|
||||
analysis_data["graphs_count"] = len(graphs_generated)
|
||||
|
||||
# Step 6: Generate HTML
|
||||
html_content = self.generate_html(patient_info, contexts)
|
||||
|
||||
# Step 7: Generate PDF
|
||||
if output_filename is None:
|
||||
patient_name_full = patient_info.get("patient_name", "Unknown")
|
||||
session_id = patient_info.get("session_id", "default")
|
||||
output_filename = (
|
||||
f"report_{patient_name_full.replace(' ', '_')}_{session_id}.pdf"
|
||||
)
|
||||
|
||||
report_path = self.reports_dir / output_filename
|
||||
print(f"Generating PDF report at {report_path}")
|
||||
await self.html_to_pdf(html_content, str(report_path))
|
||||
|
||||
return {
|
||||
"report_path": str(report_path),
|
||||
"graphs_generated": graphs_generated,
|
||||
"analysis_data": analysis_data,
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import base64
|
||||
import os
|
||||
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
API_KEY_REF = os.getenv("OPENROUTER_API_KEY")
|
||||
|
||||
|
||||
def encode_pdf_to_base64(pdf_path):
|
||||
with open(pdf_path, "rb") as pdf_file:
|
||||
return base64.b64encode(pdf_file.read()).decode("utf-8")
|
||||
|
||||
|
||||
def extract_spirometry_table_from_pdf(pdf_path, output_dir="data"):
|
||||
"""
|
||||
Extract spirometry table from PDF using AI and save as clean CSV.
|
||||
|
||||
Args:
|
||||
pdf_path: Path to the spirometry PDF file
|
||||
output_dir: Directory to save the extracted CSV
|
||||
|
||||
Returns:
|
||||
Path to the saved CSV file
|
||||
"""
|
||||
import csv
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
url = "https://openrouter.ai/api/v1/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {API_KEY_REF}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Read and encode the PDF
|
||||
base64_pdf = encode_pdf_to_base64(pdf_path)
|
||||
data_url = f"data:application/pdf;base64,{base64_pdf}"
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Please extract the Spirometry table from the pdf and return ONLY the values in CSV format. "
|
||||
"The CSV should have these columns: Parameters,Pre,Best,LLN,Pred.,%Pred.,ZScore\n"
|
||||
"Rules:\n"
|
||||
"1. Include ONLY the data rows (FVC, FEV1, FEV1/FVC%, etc.)\n"
|
||||
"2. Do NOT include units in the data (units are part of parameter name)\n"
|
||||
"3. Use empty string for missing values (not '-' or 'N/A')\n"
|
||||
"4. Do NOT add 'csv' markers or code blocks\n"
|
||||
"5. First line should be the header\n"
|
||||
"Example format:\n"
|
||||
"Parameters,Pre,Best,LLN,Pred.,%Pred.,ZScore\n"
|
||||
"FVC,4.50,4.75,3.20,4.80,99,-0.10",
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"file": {"filename": "document.pdf", "file_data": data_url},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
payload = {
|
||||
"model": "google/gemini-2.5-flash-lite",
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
response_data = response.json()
|
||||
|
||||
if "choices" in response_data and len(response_data["choices"]) > 0:
|
||||
content = response_data["choices"][0]["message"]["content"]
|
||||
|
||||
# Clean the content - remove markdown code blocks if present
|
||||
content = re.sub(r"```csv\n?", "", content)
|
||||
content = re.sub(r"```\n?", "", content)
|
||||
content = content.strip()
|
||||
|
||||
# Parse and validate CSV
|
||||
lines = content.split("\n")
|
||||
if not lines:
|
||||
raise ValueError("No data extracted from PDF")
|
||||
|
||||
# Ensure output directory exists
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(exist_ok=True)
|
||||
output_file = output_path / "extracted_spirometry_table.csv"
|
||||
|
||||
# Write cleaned CSV with proper formatting
|
||||
with open(output_file, "w", encoding="utf-8", newline="") as f:
|
||||
# Parse the first line as header
|
||||
header_line = lines[0].strip()
|
||||
if "," in header_line:
|
||||
header = [col.strip() for col in header_line.split(",")]
|
||||
else:
|
||||
# Default header if not provided
|
||||
header = [
|
||||
"Parameters",
|
||||
"Pre",
|
||||
"Best",
|
||||
"LLN",
|
||||
"Pred.",
|
||||
"%Pred.",
|
||||
"ZScore",
|
||||
]
|
||||
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(header)
|
||||
|
||||
# Process data rows
|
||||
for line in lines[1:]:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Split by comma and clean each field
|
||||
fields = [field.strip() for field in line.split(",")]
|
||||
|
||||
# Ensure we have the right number of fields
|
||||
if len(fields) < len(header):
|
||||
# Pad with empty strings
|
||||
fields.extend([""] * (len(header) - len(fields)))
|
||||
elif len(fields) > len(header):
|
||||
# Take only the first N fields
|
||||
fields = fields[: len(header)]
|
||||
|
||||
# Replace '-' or 'N/A' with empty string
|
||||
fields = ["" if f in ["-", "N/A", "n/a", "NA"] else f for f in fields]
|
||||
|
||||
writer.writerow(fields)
|
||||
|
||||
return str(output_file)
|
||||
else:
|
||||
error_msg = response_data.get("error", {}).get("message", "Unknown error")
|
||||
raise Exception(f"No content found in response: {error_msg}")
|
||||
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Medical Report Generator{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
}
|
||||
</style>
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<nav class="bg-white shadow-sm border-b">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex items-center">
|
||||
<h1 class="text-xl font-bold text-gray-900">ISHP Report Generator</h1>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Upload</a>
|
||||
{% if session.get('report_path') %}
|
||||
<a href="/preview" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Preview</a>
|
||||
<a href="/edit" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Edit Metrics</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit Metrics - Report Generator{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="px-4 py-6 sm:px-0">
|
||||
{% if not session.get('metrics') %}
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||||
<p class="text-yellow-800">No metrics found. Please <a href="/" class="underline">generate a report</a> first.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
{% if error %}
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<p class="text-red-800">{{ error }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900">Edit Calculated Metrics</h2>
|
||||
<a href="/preview" class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
Back to Preview
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form action="/edit" method="post" class="space-y-8">
|
||||
<!-- Pnoe Metrics Section -->
|
||||
<div class="border-b border-gray-200 pb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Pnoe Metrics</h3>
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="vo2_max" class="block text-sm font-medium text-gray-700">VO2 Max (ml/min)</label>
|
||||
<input type="number" step="0.01" name="vo2_max" id="vo2_max"
|
||||
value="{{ '%.2f'|format(session.metrics.pnoe['vo2_max']) if session.metrics.pnoe.get('vo2_max') else '' }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
<div>
|
||||
<label for="vo2_max_per_kg" class="block text-sm font-medium text-gray-700">VO2 Max per kg (ml/min/kg)</label>
|
||||
<input type="number" step="0.01" name="vo2_max_per_kg" id="vo2_max_per_kg"
|
||||
value="{{ '%.2f'|format(session.metrics.pnoe['vo2_max_per_kg']) if session.metrics.pnoe.get('vo2_max_per_kg') else '' }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
<div>
|
||||
<label for="peak_vt" class="block text-sm font-medium text-gray-700">Peak VT (L)</label>
|
||||
<input type="number" step="0.01" name="peak_vt" id="peak_vt"
|
||||
value="{{ '%.2f'|format(session.metrics.pnoe['peak_vt']) if session.metrics.pnoe.get('peak_vt') else '' }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
<div>
|
||||
<label for="peak_vt_hr" class="block text-sm font-medium text-gray-700">Peak VT HR (bpm)</label>
|
||||
<input type="number" step="1" name="peak_vt_hr" id="peak_vt_hr"
|
||||
value="{{ '%.0f'|format(session.metrics.pnoe['peak_vt_hr']) if session.metrics.pnoe.get('peak_vt_hr') else '' }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
<div>
|
||||
<label for="fat_max_value" class="block text-sm font-medium text-gray-700">Fat Max Value (kcal/min)</label>
|
||||
<input type="number" step="0.01" name="fat_max_value" id="fat_max_value"
|
||||
value="{{ '%.2f'|format(session.metrics.pnoe['fat_max_value']) if session.metrics.pnoe.get('fat_max_value') else '' }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
<div>
|
||||
<label for="fat_max_hr" class="block text-sm font-medium text-gray-700">Fat Max HR (bpm)</label>
|
||||
<input type="number" step="1" name="fat_max_hr" id="fat_max_hr"
|
||||
value="{{ '%.0f'|format(session.metrics.pnoe['fat_max_hr']) if session.metrics.pnoe.get('fat_max_hr') else '' }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VT1 Section -->
|
||||
<div class="border-b border-gray-200 pb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">VT1 Threshold</h3>
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||
<div>
|
||||
<label for="vt1_hr" class="block text-sm font-medium text-gray-700">Heart Rate (bpm)</label>
|
||||
<input type="number" step="1" name="vt1_hr" id="vt1_hr"
|
||||
value="{{ '%.0f'|format(session.metrics.pnoe['vt1']['HeartRate']) if session.metrics.pnoe.get('vt1') and session.metrics.pnoe['vt1'].get('HeartRate') else '' }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
<div>
|
||||
<label for="vt1_speed" class="block text-sm font-medium text-gray-700">Speed (mph)</label>
|
||||
<input type="number" step="0.01" name="vt1_speed" id="vt1_speed"
|
||||
value="{{ '%.2f'|format(session.metrics.pnoe['vt1']['Speed']) if session.metrics.pnoe.get('vt1') and session.metrics.pnoe['vt1'].get('Speed') else '' }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
<div>
|
||||
<label for="vt1_time" class="block text-sm font-medium text-gray-700">Time (sec)</label>
|
||||
<input type="number" step="1" name="vt1_time" id="vt1_time"
|
||||
value="{{ '%.0f'|format(session.metrics.pnoe['vt1']['Time']) if session.metrics.pnoe.get('vt1') and session.metrics.pnoe['vt1'].get('Time') else '' }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VT2 Section -->
|
||||
<div class="border-b border-gray-200 pb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">VT2 Threshold</h3>
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||
<div>
|
||||
<label for="vt2_hr" class="block text-sm font-medium text-gray-700">Heart Rate (bpm)</label>
|
||||
<input type="number" step="1" name="vt2_hr" id="vt2_hr"
|
||||
value="{{ '%.0f'|format(session.metrics.pnoe['vt2']['HeartRate']) if session.metrics.pnoe.get('vt2') and session.metrics.pnoe['vt2'].get('HeartRate') else '' }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
<div>
|
||||
<label for="vt2_speed" class="block text-sm font-medium text-gray-700">Speed (mph)</label>
|
||||
<input type="number" step="0.01" name="vt2_speed" id="vt2_speed"
|
||||
value="{{ '%.2f'|format(session.metrics.pnoe['vt2']['Speed']) if session.metrics.pnoe.get('vt2') and session.metrics.pnoe['vt2'].get('Speed') else '' }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
<div>
|
||||
<label for="vt2_time" class="block text-sm font-medium text-gray-700">Time (sec)</label>
|
||||
<input type="number" step="1" name="vt2_time" id="vt2_time"
|
||||
value="{{ '%.0f'|format(session.metrics.pnoe['vt2']['Time']) if session.metrics.pnoe.get('vt2') and session.metrics.pnoe['vt2'].get('Time') else '' }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Heart Rate Zones -->
|
||||
<div class="border-b border-gray-200 pb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Heart Rate Zones</h3>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-5">
|
||||
{% for i in range(1, 6) %}
|
||||
{% set zone_key = "zone" + i|string + "_bpm" %}
|
||||
<div>
|
||||
<label for="{{ zone_key }}" class="block text-sm font-medium text-gray-700">Zone {{ i }} (e.g., 120-140bpm)</label>
|
||||
<input type="text" name="{{ zone_key }}" id="{{ zone_key }}"
|
||||
value="{{ session.metrics.pnoe[zone_key] if session.metrics.pnoe.get(zone_key) else '' }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spirometry Metrics -->
|
||||
{% if session.metrics.spirometry %}
|
||||
<div class="border-b border-gray-200 pb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Spirometry Metrics</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="fvc_best" class="block text-sm font-medium text-gray-700">FVC Best (L)</label>
|
||||
<input type="number" step="0.01" name="fvc_best" id="fvc_best"
|
||||
value="{{ '%.2f'|format(session.metrics.spirometry['fvc_best']) if session.metrics.spirometry.get('fvc_best') else '' }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
<div>
|
||||
<label for="fvc_pred" class="block text-sm font-medium text-gray-700">FVC % Predicted</label>
|
||||
<input type="number" step="0.1" name="fvc_pred" id="fvc_pred"
|
||||
value="{{ '%.1f'|format(session.metrics.spirometry['fvc_pred']) if session.metrics.spirometry.get('fvc_pred') else '' }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
<div>
|
||||
<label for="fev1_best" class="block text-sm font-medium text-gray-700">FEV1 Best (L)</label>
|
||||
<input type="number" step="0.01" name="fev1_best" id="fev1_best"
|
||||
value="{{ '%.2f'|format(session.metrics.spirometry['fev1_best']) if session.metrics.spirometry.get('fev1_best') else '' }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
<div>
|
||||
<label for="fev1_pred" class="block text-sm font-medium text-gray-700">FEV1 % Predicted</label>
|
||||
<input type="number" step="0.1" name="fev1_pred" id="fev1_pred"
|
||||
value="{{ '%.1f'|format(session.metrics.spirometry['fev1_pred']) if session.metrics.spirometry.get('fev1_pred') else '' }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
<div>
|
||||
<label for="fev1_fvc_pct_best" class="block text-sm font-medium text-gray-700">FEV1/FVC% Best</label>
|
||||
<input type="number" step="0.01" name="fev1_fvc_pct_best" id="fev1_fvc_pct_best"
|
||||
value="{{ '%.2f'|format(session.metrics.spirometry['fev1_fvc_pct_best']) if session.metrics.spirometry.get('fev1_fvc_pct_best') else '' }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
<div>
|
||||
<label for="fev1_fvc_pct_pred" class="block text-sm font-medium text-gray-700">FEV1/FVC% % Predicted</label>
|
||||
<input type="number" step="0.1" name="fev1_fvc_pct_pred" id="fev1_fvc_pct_pred"
|
||||
value="{{ '%.1f'|format(session.metrics.spirometry['fev1_fvc_pct_pred']) if session.metrics.spirometry.get('fev1_fvc_pct_pred') else '' }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex justify-end">
|
||||
<button type="submit"
|
||||
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Regenerate Report
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Report Preview - Report Generator{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="px-4 py-6 sm:px-0">
|
||||
{% if not session.get('report_path') %}
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||||
<p class="text-yellow-800">No report found. Please <a href="/" class="underline">upload files</a> first.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<div class="bg-white shadow rounded-lg mb-6">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900">Generated Report Preview</h2>
|
||||
<div class="flex space-x-3">
|
||||
<a href="/edit" class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
Edit Metrics
|
||||
</a>
|
||||
<a href="/download-report/{{ session.report_path.split('/')[-1] }}" class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700">
|
||||
Download PDF
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Patient Information -->
|
||||
<div class="border-b border-gray-200 pb-6 mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Patient Information</h3>
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Name</p>
|
||||
<p class="text-base font-medium text-gray-900">{{ session.patient_info['patient_name'] }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Age</p>
|
||||
<p class="text-base font-medium text-gray-900">{{ session.patient_info['age'] }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Height</p>
|
||||
<p class="text-base font-medium text-gray-900">{{ session.patient_info['height'] }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Weight</p>
|
||||
<p class="text-base font-medium text-gray-900">{{ session.patient_info['weight'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calculated Metrics -->
|
||||
{% if session.metrics %}
|
||||
<div class="space-y-6">
|
||||
<!-- Pnoe Metrics -->
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Pnoe Metrics</h3>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{% if session.metrics.pnoe.get('vo2_max') %}
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<p class="text-sm text-gray-500">VO2 Max</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ "%.2f"|format(session.metrics.pnoe['vo2_max']) }} ml/min</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if session.metrics.pnoe.get('vo2_max_per_kg') %}
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<p class="text-sm text-gray-500">VO2 Max per kg</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ "%.2f"|format(session.metrics.pnoe['vo2_max_per_kg']) }} ml/min/kg</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if session.metrics.pnoe.get('peak_vt') %}
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<p class="text-sm text-gray-500">Peak VT</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ "%.2f"|format(session.metrics.pnoe['peak_vt']) }} L</p>
|
||||
<p class="text-sm text-gray-500 mt-1">HR: {{ "%.0f"|format(session.metrics.pnoe['peak_vt_hr']) }} bpm</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if session.metrics.pnoe.get('fat_max_value') %}
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<p class="text-sm text-gray-500">Fat Max Value</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ "%.2f"|format(session.metrics.pnoe['fat_max_value']) }} kcal/min</p>
|
||||
<p class="text-sm text-gray-500 mt-1">HR: {{ "%.0f"|format(session.metrics.pnoe['fat_max_hr']) }} bpm</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VT1 and VT2 -->
|
||||
{% if session.metrics.pnoe.get('vt1') or session.metrics.pnoe.get('vt2') %}
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Ventilatory Thresholds</h3>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{% if session.metrics.pnoe.get('vt1') %}
|
||||
<div class="bg-blue-50 p-4 rounded-lg">
|
||||
<p class="text-sm font-medium text-blue-900 mb-2">VT1</p>
|
||||
<p class="text-sm text-blue-700">Heart Rate: {{ "%.0f"|format(session.metrics.pnoe['vt1']['HeartRate']) }} bpm</p>
|
||||
<p class="text-sm text-blue-700">Speed: {{ "%.2f"|format(session.metrics.pnoe['vt1']['Speed']) }} mph</p>
|
||||
<p class="text-sm text-blue-700">Time: {{ "%.0f"|format(session.metrics.pnoe['vt1']['Time']) }} sec</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if session.metrics.pnoe.get('vt2') %}
|
||||
<div class="bg-green-50 p-4 rounded-lg">
|
||||
<p class="text-sm font-medium text-green-900 mb-2">VT2</p>
|
||||
<p class="text-sm text-green-700">Heart Rate: {{ "%.0f"|format(session.metrics.pnoe['vt2']['HeartRate']) }} bpm</p>
|
||||
<p class="text-sm text-green-700">Speed: {{ "%.2f"|format(session.metrics.pnoe['vt2']['Speed']) }} mph</p>
|
||||
<p class="text-sm text-green-700">Time: {{ "%.0f"|format(session.metrics.pnoe['vt2']['Time']) }} sec</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Heart Rate Zones -->
|
||||
{% if session.metrics.pnoe.get('zone1_bpm') %}
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Heart Rate Zones</h3>
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-5">
|
||||
{% for i in range(1, 6) %}
|
||||
{% set zone_key = "zone" + i|string + "_bpm" %}
|
||||
{% if session.metrics.pnoe.get(zone_key) %}
|
||||
<div class="bg-gray-50 p-3 rounded-lg text-center">
|
||||
<p class="text-xs text-gray-500">Zone {{ i }}</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ session.metrics.pnoe[zone_key] }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Spirometry Metrics -->
|
||||
{% if session.metrics.spirometry %}
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Spirometry Metrics</h3>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{% if session.metrics.spirometry.get('fvc_best') %}
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<p class="text-sm text-gray-500">FVC Best</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ "%.2f"|format(session.metrics.spirometry['fvc_best']) }} L</p>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ "%.1f"|format(session.metrics.spirometry['fvc_pred']) }}% predicted</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if session.metrics.spirometry.get('fev1_best') %}
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<p class="text-sm text-gray-500">FEV1 Best</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ "%.2f"|format(session.metrics.spirometry['fev1_best']) }} L</p>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ "%.1f"|format(session.metrics.spirometry['fev1_pred']) }}% predicted</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if session.metrics.spirometry.get('fev1_fvc_pct_best') %}
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<p class="text-sm text-gray-500">FEV1/FVC%</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ "%.2f"|format(session.metrics.spirometry['fev1_fvc_pct_best']) }}%</p>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ "%.1f"|format(session.metrics.spirometry['fev1_fvc_pct_pred']) }}% predicted</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Graphs Section -->
|
||||
{% if session.graphs_generated %}
|
||||
<div class="mt-8">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Generated Graphs</h3>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{% for graph in session.graphs_generated %}
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<p class="text-sm font-medium text-gray-700 mb-2">{{ graph.name|replace('_', ' ')|title }}</p>
|
||||
<img src="/graphs/{{ graph.path.split('/')[-1] }}" alt="{{ graph.name }}" class="w-full h-auto rounded">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Upload Patient Data - Report Generator{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="px-4 py-6 sm:px-0">
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">Upload Patient Data and Files</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<p class="text-red-800">{{ error }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form action="/upload" method="post" enctype="multipart/form-data" class="space-y-6">
|
||||
<!-- Patient Information Section -->
|
||||
<div class="border-b border-gray-200 pb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Patient Information</h3>
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="first_name" class="block text-sm font-medium text-gray-700">First Name</label>
|
||||
<input type="text" name="first_name" id="first_name" required
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
<div>
|
||||
<label for="last_name" class="block text-sm font-medium text-gray-700">Last Name</label>
|
||||
<input type="text" name="last_name" id="last_name" required
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
<div>
|
||||
<label for="age" class="block text-sm font-medium text-gray-700">Age</label>
|
||||
<input type="number" name="age" id="age" required min="1" max="120"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
<div>
|
||||
<label for="height" class="block text-sm font-medium text-gray-700">Height (e.g., 5'4" or 165cm)</label>
|
||||
<input type="text" name="height" id="height" required
|
||||
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="5'4"">
|
||||
</div>
|
||||
<div>
|
||||
<label for="weight" class="block text-sm font-medium text-gray-700">Weight (e.g., 123lbs or 56kg)</label>
|
||||
<input type="text" name="weight" id="weight" required
|
||||
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="123lbs">
|
||||
</div>
|
||||
<div>
|
||||
<label for="gender" class="block text-sm font-medium text-gray-700">Gender</label>
|
||||
<select name="gender" id="gender" required
|
||||
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">
|
||||
<option value="">Select...</option>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="focus" class="block text-sm font-medium text-gray-700">Training Focus</label>
|
||||
<input type="text" name="focus" id="focus" value="Endurance"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
<div>
|
||||
<label for="session_id" class="block text-sm font-medium text-gray-700">Session ID</label>
|
||||
<input type="text" name="session_id" id="session_id" value="default"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Upload Section -->
|
||||
<div class="border-b border-gray-200 pb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Upload Files</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="spirometry_pdf" class="block text-sm font-medium text-gray-700">Spirometry PDF</label>
|
||||
<input type="file" name="spirometry_pdf" id="spirometry_pdf" accept=".pdf" required
|
||||
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>
|
||||
<label for="pnoe_csv" class="block text-sm font-medium text-gray-700">Pnoe CSV</label>
|
||||
<input type="file" name="pnoe_csv" id="pnoe_csv" accept=".csv" required
|
||||
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>
|
||||
<label for="fat_percentage" class="block text-sm font-medium text-gray-700">Body Fat Percentage (%)</label>
|
||||
<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">
|
||||
<p class="mt-1 text-xs text-gray-500">Upload NIRS muscle oxygen CSV file to generate TSI graph</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex justify-end">
|
||||
<button type="submit"
|
||||
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Generate Report
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
import base64
|
||||
|
||||
|
||||
def image_to_base64(image_path):
|
||||
try:
|
||||
with open(image_path, "rb") as image_file:
|
||||
return base64.b64encode(image_file.read()).decode("utf-8")
|
||||
except FileNotFoundError:
|
||||
print(f"Warning: Image not found at {image_path}")
|
||||
return ""
|
||||
|
||||
|
||||
### Defining Page Contexts ###
|
||||
page_1_context = {
|
||||
"name": "John Doe",
|
||||
"surname": "Moran",
|
||||
"date": "July 29, 2025",
|
||||
}
|
||||
|
||||
page_2_context = {
|
||||
"content": "This is page 2 content",
|
||||
}
|
||||
|
||||
page_3_context = {
|
||||
"patient_name": "Keirstyn Moran",
|
||||
}
|
||||
|
||||
page_4_context = {
|
||||
"body_composition_chart": image_to_base64(
|
||||
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/body_composition_chart.png"
|
||||
),
|
||||
"body_fat_chart": image_to_base64(
|
||||
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/body_fat_percent_chart.png"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
page_5_context = {
|
||||
"metabolism_chart": "",
|
||||
"fuel_source_chart": "",
|
||||
"resting_calories": 1540,
|
||||
"neat_calories": 310,
|
||||
"weight_loss_calories": 1725,
|
||||
"weight_loss_rate": "1lb/week",
|
||||
"total_calories": 3575,
|
||||
}
|
||||
|
||||
page_6_context = {
|
||||
"patient_name": "Keirstyn Moran",
|
||||
"age": "34",
|
||||
"height": "5'4\"",
|
||||
"weight": "123lbs",
|
||||
"focus": "Endurance",
|
||||
"deficit_calories": "1725KCals",
|
||||
"deficit_protein": "120g Protein",
|
||||
"deficit_carbs": "155g Carbs",
|
||||
"deficit_fat": "69g Fat",
|
||||
"deficit_fiber": "25g Fibre",
|
||||
"refeed_weekday_calories": "1615KCals",
|
||||
"refeed_weekday_protein": "120g Protein",
|
||||
"refeed_weekday_carbs": "142g Carbs",
|
||||
"refeed_weekday_fat": "63g Fat",
|
||||
"refeed_weekday_fiber": "24g Fibre",
|
||||
"refeed_weekend_calories": "2000KCals",
|
||||
"refeed_weekend_protein": "120g Protein",
|
||||
"refeed_weekend_carbs": "190g Carbs",
|
||||
"refeed_weekend_fat": "84g Fat",
|
||||
"refeed_weekend_fiber": "30g Fibre",
|
||||
"protein_percentage": "28%",
|
||||
"carbs_percentage": "36%",
|
||||
"fats_percentage": "36%",
|
||||
"page_number": "6",
|
||||
}
|
||||
|
||||
page_7_context = {
|
||||
"indication": "No Respiratory Capacity Limitation",
|
||||
"peak_vt": 3.2,
|
||||
"peak_vt_bpm": 198,
|
||||
"peak_vt_zone": 3,
|
||||
"fev1_percentage": 85,
|
||||
"lung_analysis_chart": image_to_base64("/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/spirometry_chart.png"),
|
||||
"respiratory_analysis_chart": image_to_base64(
|
||||
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/respiratory.png"
|
||||
),
|
||||
}
|
||||
|
||||
page_8_context = {
|
||||
"vo2_max_value": "49.5",
|
||||
"vo2_max_percentile": "100th percentile",
|
||||
"age_range": "30-39",
|
||||
"very_poor_range": "19.0-24.1",
|
||||
"poor_range": "24.1-28.2",
|
||||
"fair_range": "28.2-32.2",
|
||||
"good_range": "32.2-35.7",
|
||||
"excellent_range": "35.7-45.8",
|
||||
"superior_range": "45.8+",
|
||||
"zone1_percentage": "55-65% of Max Heart Rate",
|
||||
"zone2_percentage": "65-75% of Max Heart Rate",
|
||||
"zone3_percentage": "80-85% of Max Heart Rate",
|
||||
"zone4_percentage": "85-88% of Max Heart Rate",
|
||||
"zone5_percentage": "90% of Max Heart Rate",
|
||||
"zone1_bpm": "81-96bpm",
|
||||
"zone2_bpm": "96-100bpm",
|
||||
"zone3_bpm": "100-178bpm",
|
||||
"zone4_bpm": "178-188bpm",
|
||||
"zone5_bpm": "188-198bpm",
|
||||
"zone1_speed": "3.5mph",
|
||||
"zone2_speed": "3.5-4.0mph",
|
||||
"zone3_speed": "4.0-6.5mph",
|
||||
"zone4_speed": "6.5-7.0mph",
|
||||
"zone5_speed": "7.0-8.0mph",
|
||||
"zone1_incline": "2% Incline",
|
||||
"zone2_incline": "2% Incline",
|
||||
"zone3_incline": "2% Incline",
|
||||
"zone4_incline": "2% Incline",
|
||||
"zone5_incline": "2% Incline",
|
||||
"zone1_pace": "10:39min/km Pace",
|
||||
"zone2_pace": "10:39-9:19min/km Pace",
|
||||
"zone3_pace": "9:19-5:44min/km Pace",
|
||||
"zone4_pace": "5:44-5:20min/km Pace",
|
||||
"zone5_pace": "5:20-4:40min/km Pace",
|
||||
"zone1_calories": "4.4kcals/minute",
|
||||
"zone2_calories": "5.9kcals/minute",
|
||||
"zone3_calories": "9.4kcals/minute",
|
||||
"zone4_calories": "12.5kcals/minute",
|
||||
"zone5_calories": "12.8kcals/minute",
|
||||
"zone1_carb": "Avg: 0.4g/min Carb Utilization",
|
||||
"zone2_carb": "Avg: 0.6g/min Carb Utilization",
|
||||
"zone3_carb": "Avg: 1.9g/min Carb Utilization",
|
||||
"zone4_carb": "Avg: 2.9g/min Carb Utilization",
|
||||
"zone5_carb": "Avg: 3.1g/min Carb Utilization",
|
||||
"zone1_breaths": "Avg: 27 breaths",
|
||||
"zone2_breaths": "Avg: 28 breaths",
|
||||
"zone3_breaths": "Avg: 31 breaths",
|
||||
"zone4_breaths": "Avg: 42 breaths",
|
||||
"zone5_breaths": "Avg: 51 breaths",
|
||||
"zone1_breath_range": "Ideal Range: 15-20 breaths",
|
||||
"zone2_breath_range": "Ideal Range: 20-25 breaths",
|
||||
"zone3_breath_range": "Ideal Range: 25-30 breaths",
|
||||
"zone4_breath_range": "Ideal Range: 30-35 breaths",
|
||||
"zone5_breath_range": "Ideal Range: 40+ breaths",
|
||||
}
|
||||
|
||||
page_9_context = {
|
||||
"fuel_utilization_chart": image_to_base64(
|
||||
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/fuel_utilization_chart.png"
|
||||
),
|
||||
}
|
||||
|
||||
page_10_context = {
|
||||
"vo2_pulse_drop_bpm": "180 bpm",
|
||||
"vo2_pulse_drop_zone": "Zone 4",
|
||||
"vo2_pulse_chart": image_to_base64(
|
||||
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/vo2_pulse_chart.png"
|
||||
),
|
||||
"vo2_breath_drop_bpm": "173 bpm",
|
||||
"vo2_breath_drop_zone": "Zone 3",
|
||||
"vo2_breath_chart": image_to_base64(
|
||||
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/vo2_breath_chart.png"
|
||||
),
|
||||
}
|
||||
|
||||
page_11_context = {
|
||||
"fat_max_optimal": "*Optimal 10-12Kcals/minute",
|
||||
"fat_max_value": "3.8Kcals/min",
|
||||
"fat_max_heart_rate": "49% of Max Heart Rate",
|
||||
"fat_max_bpm": "97 bpm",
|
||||
"crossover_bpm": "100bpm",
|
||||
"crossover_heart_rate": "51% of Max Heart Rate",
|
||||
"fat_metabolism_note": "100bpm at a speed of 4.0mph and incline of 2%",
|
||||
"fat_metabolism_chart": image_to_base64(
|
||||
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/fat_metabolism_chart.png"
|
||||
),
|
||||
"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%",
|
||||
"recovery_chart": image_to_base64(
|
||||
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/recovery_chart.png"
|
||||
),
|
||||
"resting_heart_rate": "53bpm",
|
||||
"hr_age_range": "26-35",
|
||||
"hr_poor": "82bpm +",
|
||||
"hr_below_avg": "75-81bpm",
|
||||
"hr_average": "71-74bpm",
|
||||
"hr_above_avg": "66-70bpm",
|
||||
"hr_good": "62-65bpm",
|
||||
"hr_excellent": "55-61bpm",
|
||||
"hr_athlete": "44-54bpm",
|
||||
}
|
||||
|
||||
page_12_context = {
|
||||
|
||||
}
|
||||
|
||||
page_13_context = {
|
||||
"patient_name": "Keirstyn Moran",
|
||||
"age": "34",
|
||||
"height": "5'4\"",
|
||||
"weight": "123lbs",
|
||||
"focus": "Endurance",
|
||||
"zone2_frequency": "3-4x/week",
|
||||
"zone2_duration": "40+ minutes",
|
||||
"zone2_hr_range": "96-110bpm",
|
||||
"zone2_speed": "3.5-4.0mph",
|
||||
"zone2_incline": "2% Incline",
|
||||
"zone3_frequency": "1-2x/week",
|
||||
"zone3_duration": "10-20 minutes",
|
||||
"zone3_hr_range": "100-178bpm",
|
||||
"zone3_speed": "4.0-6.5mph",
|
||||
"zone3_incline": "2% Incline",
|
||||
"zone3_target_hr": "140bpm",
|
||||
"zone3_recovery_speed": "3.5mph",
|
||||
"zone3_recovery_incline": "2% Incline",
|
||||
"zone1_hr_range": "81-96bpm",
|
||||
"zone1_duration": "4-8 minutes",
|
||||
"zone3_repeats": "2-3 times",
|
||||
"short_sets": "8-10",
|
||||
"short_duration": "10-30 seconds",
|
||||
"short_zone": "5",
|
||||
"short_rpe": "10",
|
||||
"short_recovery": "20-60 seconds",
|
||||
"medium_sets": "6-8",
|
||||
"medium_duration": "30-90 seconds",
|
||||
"medium_zone": "4",
|
||||
"medium_rpe": "8-9",
|
||||
"medium_recovery": "30-90 seconds",
|
||||
"long_sets": "4-6",
|
||||
"long_duration": "5-10 minutes",
|
||||
"long_zone": "3/4",
|
||||
"long_rpe": "7-8",
|
||||
"long_recovery": "2.5-5 minutes",
|
||||
"tempo_sets": "2-3",
|
||||
"tempo_duration": "10-20 minutes",
|
||||
"tempo_zone": "3",
|
||||
"tempo_rpe": "6-7",
|
||||
"tempo_recovery": "4-8 minutes",
|
||||
"cardio_sets": "1",
|
||||
"cardio_duration": ">40 minutes",
|
||||
"cardio_zone": "2",
|
||||
"cardio_rpe": "4-5",
|
||||
"cardio_recovery": "N/A",
|
||||
"week1_mon_zone": "Zone 2",
|
||||
"week1_mon_duration": "45 mins",
|
||||
"week1_tue_zone": "Zone 2",
|
||||
"week1_tue_duration": "45 mins",
|
||||
"week1_wed_zone": "Zone 3",
|
||||
"week1_wed_duration1": "10mins On",
|
||||
"week1_wed_duration2": "8mins Rest",
|
||||
"week1_wed_sets": "x2",
|
||||
"week1_thu_content": "",
|
||||
"week1_fri_zone": "Zone 2",
|
||||
"week1_fri_duration": "45 mins",
|
||||
"week1_sat_content": "",
|
||||
"week1_sun_content": "",
|
||||
"week2_mon_zone": "Zone 2",
|
||||
"week2_mon_duration": "50 mins",
|
||||
"week2_tue_zone": "Zone 2",
|
||||
"week2_tue_duration": "50 mins",
|
||||
"week2_wed_zone": "Zone 3",
|
||||
"week2_wed_duration1": "10mins On",
|
||||
"week2_wed_duration2": "6mins Rest",
|
||||
"week2_wed_sets": "x2",
|
||||
"week2_thu_content": "",
|
||||
"week2_fri_zone": "Zone 2",
|
||||
"week2_fri_duration": "50 mins",
|
||||
"week2_sat_content": "",
|
||||
"week2_sun_content": "",
|
||||
"contact_email": "info@ishplabs.com",
|
||||
"website": "www.ishplabs.com",
|
||||
"social": "@ishplabs",
|
||||
"page_number": "13",
|
||||
}
|
||||
|
||||
page_14_context = {
|
||||
"patient_name": "Keirstyn Moran",
|
||||
"contact_email": "info@ishplabs.com",
|
||||
"website": "www.ishplabs.com",
|
||||
"social": "@ishplabs",
|
||||
"page_number": "14",
|
||||
}
|
||||
|
||||
page_15_context = {
|
||||
"patient_name": "Keirstyn Moran",
|
||||
"contact_email": "info@ishplabs.com",
|
||||
"website": "www.ishplabs.com",
|
||||
"social": "@ishplabs",
|
||||
"page_number": "15",
|
||||
}
|
||||
|
||||
page_16_context = {
|
||||
"patient_name": "Keirstyn Moran",
|
||||
"contact_email": "info@ishplabs.com",
|
||||
"website": "www.ishplabs.com",
|
||||
"social": "@ishplabs",
|
||||
"page_number": "16",
|
||||
}
|
||||
|
||||
page_17_context = {
|
||||
"patient_name": "Keirstyn Moran",
|
||||
"contact_email": "info@ishplabs.com",
|
||||
"website": "www.ishplabs.com",
|
||||
"social": "@ishplabs",
|
||||
"page_number": "17",
|
||||
}
|
||||
|
||||
page_18_context = {
|
||||
"body_fat_percentage_chart": image_to_base64(
|
||||
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/fat_percent_master_chart.png"
|
||||
),
|
||||
}
|
||||
|
||||
page_19_context = {
|
||||
"patient_name": "Keirstyn Moran",
|
||||
"contact_email": "info@ishplabs.com",
|
||||
"website": "www.ishplabs.com",
|
||||
"social": "@ishplabs",
|
||||
"page_number": "19",
|
||||
}
|
||||
|
||||
context_list = [
|
||||
page_1_context,
|
||||
page_2_context,
|
||||
page_3_context,
|
||||
page_4_context,
|
||||
page_5_context,
|
||||
page_6_context,
|
||||
page_7_context,
|
||||
page_8_context,
|
||||
page_9_context,
|
||||
page_10_context,
|
||||
page_11_context,
|
||||
page_12_context,
|
||||
page_13_context,
|
||||
page_14_context,
|
||||
page_15_context,
|
||||
page_16_context,
|
||||
page_17_context,
|
||||
page_18_context,
|
||||
page_19_context,
|
||||
]
|
||||
@@ -1,319 +0,0 @@
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class ReportGenerator:
|
||||
def __init__(self):
|
||||
self.pnoe_df = None
|
||||
self.patient_df = None
|
||||
self.spirometry_df = None
|
||||
self.seca_df = None
|
||||
self.patient_info = {}
|
||||
self.charts_dir = Path("graphs")
|
||||
self.charts_dir.mkdir(exist_ok=True)
|
||||
|
||||
def load_data(
|
||||
self,
|
||||
pnoe_path: str,
|
||||
patient_path: str,
|
||||
spirometry_path: str,
|
||||
seca_path: str = None,
|
||||
):
|
||||
"""Load all required datasets"""
|
||||
self.pnoe_df = pd.read_csv(pnoe_path, delimiter=";")
|
||||
self.patient_df = pd.read_csv(patient_path)
|
||||
self.spirometry_df = pd.read_csv(spirometry_path)
|
||||
if seca_path:
|
||||
self.seca_df = pd.read_excel(seca_path)
|
||||
|
||||
# Apply preprocessing
|
||||
self._preprocess_data()
|
||||
|
||||
def _preprocess_data(self):
|
||||
"""Apply preprocessing steps from your notebook"""
|
||||
# Convert to numeric
|
||||
self.pnoe_df = self.pnoe_df.apply(pd.to_numeric, errors="ignore")
|
||||
|
||||
# Calculate derived columns
|
||||
self.pnoe_df["VO2 Pulse"] = (
|
||||
self.pnoe_df["VO2(ml/min)"] / self.pnoe_df["HR(bpm)"]
|
||||
)
|
||||
self.pnoe_df["VO2 Breath"] = (
|
||||
self.pnoe_df["VO2(ml/min)"] / self.pnoe_df["BF(bpm)"]
|
||||
)
|
||||
self.pnoe_df["CHO"] = (
|
||||
self.pnoe_df["EE(kcal/min)"] * self.pnoe_df["CARBS(%)"] / 100
|
||||
)
|
||||
self.pnoe_df["FAT"] = (
|
||||
self.pnoe_df["EE(kcal/min)"] * self.pnoe_df["FAT(%)"] / 100
|
||||
)
|
||||
|
||||
# Apply smoothing
|
||||
window_size = 10
|
||||
columns_to_smooth = [
|
||||
"VO2(ml/min)",
|
||||
"VCO2(ml/min)",
|
||||
"HR(bpm)",
|
||||
"VT(l)",
|
||||
"BF(bpm)",
|
||||
"VE(l/min)",
|
||||
"VO2 Pulse",
|
||||
"VO2 Breath",
|
||||
"CHO",
|
||||
"FAT",
|
||||
]
|
||||
|
||||
for col in columns_to_smooth:
|
||||
if col in self.pnoe_df.columns:
|
||||
self.pnoe_df[f"{col}_smoothed"] = (
|
||||
self.pnoe_df[col].rolling(window=window_size, min_periods=1).mean()
|
||||
)
|
||||
|
||||
def extract_patient_info(self, last_name: str) -> Dict:
|
||||
"""Extract patient information from datasets"""
|
||||
if self.seca_df is not None:
|
||||
patient_data = self.seca_df[
|
||||
self.seca_df["LastName"].str.contains(last_name, case=False, na=False)
|
||||
]
|
||||
if not patient_data.empty:
|
||||
row = patient_data.iloc[0]
|
||||
self.patient_info = {
|
||||
"name": f"{row.get('FirstName', '')} {last_name}",
|
||||
"age": int(row.get("Age", 0)),
|
||||
"height": f"{row.get('Height', '')}",
|
||||
"weight": float(row.get("Weight", 0)),
|
||||
"gender": row.get("Gender", "").lower(),
|
||||
"fat_percentage": float(row.get("Adult_FMP", 0)),
|
||||
}
|
||||
return self.patient_info
|
||||
|
||||
def calculate_spirometry_metrics(self) -> Dict:
|
||||
"""Calculate spirometry-related metrics"""
|
||||
metrics = {}
|
||||
|
||||
# Extract key spirometry values
|
||||
for param in ["FVC", "FEV1", "FEV1/FVC%"]:
|
||||
row = self.spirometry_df.loc[self.spirometry_df["Parameters"] == param]
|
||||
if not row.empty:
|
||||
metrics[
|
||||
f"{param.lower().replace('/', '_').replace('%', '_pct')}_best"
|
||||
] = row["Best"].values[0]
|
||||
metrics[
|
||||
f"{param.lower().replace('/', '_').replace('%', '_pct')}_pred"
|
||||
] = row["%Pred."].values[0]
|
||||
|
||||
return metrics
|
||||
|
||||
def calculate_pnoe_metrics(self) -> Dict:
|
||||
"""Calculate all Pnoe-derived metrics"""
|
||||
metrics = {}
|
||||
|
||||
# Basic metrics
|
||||
metrics["vo2_max"] = self.pnoe_df["VO2(ml/min)_smoothed"].max()
|
||||
metrics["vo2_max_per_kg"] = metrics["vo2_max"] / self.patient_info["weight"]
|
||||
|
||||
# Peak VT
|
||||
peak_vt_idx = self.pnoe_df["VT(l)_smoothed"].idxmax()
|
||||
peak_vt_row = self.pnoe_df.loc[peak_vt_idx]
|
||||
metrics["peak_vt"] = peak_vt_row["VT(l)_smoothed"]
|
||||
metrics["peak_vt_hr"] = peak_vt_row["HR(bpm)_smoothed"]
|
||||
|
||||
# Fat burning metrics
|
||||
fat_max_idx = self.pnoe_df["FAT_smoothed"].idxmax()
|
||||
fat_max_row = self.pnoe_df.loc[fat_max_idx]
|
||||
metrics["fat_max_value"] = fat_max_row["FAT_smoothed"]
|
||||
metrics["fat_max_hr"] = fat_max_row["HR(bpm)_smoothed"]
|
||||
|
||||
# Calculate zones (simplified from your logic)
|
||||
metrics.update(self._calculate_hr_zones())
|
||||
|
||||
# VT1/VT2 detection
|
||||
vt1, vt2 = self._detect_thresholds()
|
||||
metrics["vt1"] = vt1
|
||||
metrics["vt2"] = vt2
|
||||
|
||||
return metrics
|
||||
|
||||
def _detect_thresholds(self) -> Tuple[Optional[Dict], Optional[Dict]]:
|
||||
"""Detect VT1 and VT2 thresholds"""
|
||||
# VT1: First crossover where carbs > fat
|
||||
condition = self.pnoe_df["CHO_smoothed"] > self.pnoe_df["FAT_smoothed"]
|
||||
crossover_indices = condition[condition].index
|
||||
|
||||
vt1 = None
|
||||
if len(crossover_indices) > 0:
|
||||
vt1_idx = crossover_indices[0]
|
||||
vt1_row = self.pnoe_df.loc[vt1_idx]
|
||||
vt1 = {
|
||||
"HeartRate": vt1_row["HR(bpm)_smoothed"],
|
||||
"Speed": vt1_row["Speed"],
|
||||
"Time": vt1_row["T(sec)"],
|
||||
}
|
||||
|
||||
# VT2: Ventilation inflection (simplified)
|
||||
ve_slope = self.pnoe_df["VE(l/min)_smoothed"].diff()
|
||||
second_derivative = ve_slope.diff()
|
||||
vt2_idx = second_derivative.idxmax()
|
||||
|
||||
vt2 = None
|
||||
if pd.notna(vt2_idx):
|
||||
vt2_row = self.pnoe_df.loc[vt2_idx]
|
||||
vt2 = {
|
||||
"HeartRate": vt2_row["HR(bpm)_smoothed"],
|
||||
"Speed": vt2_row["Speed"],
|
||||
"Time": vt2_row["T(sec)"],
|
||||
}
|
||||
|
||||
return vt1, vt2
|
||||
|
||||
def _calculate_hr_zones(self) -> Dict:
|
||||
"""Calculate heart rate zones"""
|
||||
max_hr = 220 - self.patient_info["age"]
|
||||
|
||||
# Simplified zone calculation - you can make this more sophisticated
|
||||
zones = {
|
||||
"zone1_bpm": f"{int(max_hr * 0.55)}-{int(max_hr * 0.65)}bpm",
|
||||
"zone2_bpm": f"{int(max_hr * 0.65)}-{int(max_hr * 0.75)}bpm",
|
||||
"zone3_bpm": f"{int(max_hr * 0.75)}-{int(max_hr * 0.85)}bpm",
|
||||
"zone4_bpm": f"{int(max_hr * 0.85)}-{int(max_hr * 0.95)}bpm",
|
||||
"zone5_bpm": f"{int(max_hr * 0.95)}+bpm",
|
||||
}
|
||||
return zones
|
||||
|
||||
def generate_charts(self) -> Dict[str, str]:
|
||||
"""Generate all charts and return base64 encoded versions"""
|
||||
charts = {}
|
||||
|
||||
# Generate fuel utilization chart
|
||||
charts["fuel_utilization_chart"] = self._create_fuel_chart()
|
||||
|
||||
# Generate VO2 pulse chart
|
||||
charts["vo2_pulse_chart"] = self._create_vo2_pulse_chart()
|
||||
|
||||
# Generate body composition chart
|
||||
charts["body_composition_chart"] = self._create_body_comp_chart()
|
||||
|
||||
# Add more chart generation methods...
|
||||
|
||||
return charts
|
||||
|
||||
def _create_fuel_chart(self) -> str:
|
||||
"""Create and save fuel utilization chart"""
|
||||
# Use your existing chart code but make it dynamic
|
||||
speed_groups = self.pnoe_df.groupby("Speed").mean(numeric_only=True).round(1)
|
||||
speed_groups = speed_groups.iloc[1:-1]
|
||||
filtered_data = speed_groups[
|
||||
(speed_groups.index >= 3.5) & (speed_groups.index <= 7.5)
|
||||
]
|
||||
|
||||
plt.figure(figsize=(15, 8))
|
||||
# ... your chart code here ...
|
||||
|
||||
chart_path = self.charts_dir / "fuel_utilization_chart.png"
|
||||
plt.savefig(chart_path, dpi=300)
|
||||
plt.close()
|
||||
|
||||
return self._image_to_base64(chart_path)
|
||||
|
||||
def _create_vo2_pulse_chart(self) -> str:
|
||||
"""Create VO2 pulse chart"""
|
||||
# Your VO2 pulse chart code here
|
||||
chart_path = self.charts_dir / "vo2_pulse_chart.png"
|
||||
# ... chart generation code ...
|
||||
return self._image_to_base64(chart_path)
|
||||
|
||||
def _create_body_comp_chart(self) -> str:
|
||||
"""Create body composition chart"""
|
||||
# Your body composition chart code here
|
||||
chart_path = self.charts_dir / "body_composition_chart.png"
|
||||
# ... chart generation code ...
|
||||
return self._image_to_base64(chart_path)
|
||||
|
||||
def _image_to_base64(self, image_path: Path) -> str:
|
||||
"""Convert image to base64"""
|
||||
try:
|
||||
with open(image_path, "rb") as image_file:
|
||||
return base64.b64encode(image_file.read()).decode("utf-8")
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
|
||||
def generate_all_contexts(self, last_name: str = "Moran") -> List[Dict]:
|
||||
"""Main method to generate all page contexts"""
|
||||
# Extract patient info
|
||||
self.extract_patient_info(last_name)
|
||||
|
||||
# Calculate metrics
|
||||
spirometry_metrics = self.calculate_spirometry_metrics()
|
||||
pnoe_metrics = self.calculate_pnoe_metrics()
|
||||
|
||||
# Generate charts
|
||||
charts = self.generate_charts()
|
||||
|
||||
# Build contexts for each page
|
||||
contexts = []
|
||||
|
||||
# Page 1
|
||||
contexts.append(
|
||||
{
|
||||
"name": self.patient_info["name"],
|
||||
"surname": last_name,
|
||||
"date": "July 29, 2025",
|
||||
}
|
||||
)
|
||||
|
||||
# Page 2-6 (add as needed)
|
||||
for i in range(5):
|
||||
contexts.append({})
|
||||
|
||||
# Page 7 - Spirometry
|
||||
contexts.append(
|
||||
{
|
||||
"peak_vt": pnoe_metrics["peak_vt"],
|
||||
"peak_vt_bpm": pnoe_metrics["peak_vt_hr"],
|
||||
"fev1_percentage": (
|
||||
pnoe_metrics["peak_vt"] / spirometry_metrics["fvc_best"]
|
||||
)
|
||||
* 100,
|
||||
"lung_analysis_chart": charts.get("spirometry_chart", ""),
|
||||
"respiratory_analysis_chart": charts.get("respiratory_chart", ""),
|
||||
}
|
||||
)
|
||||
|
||||
# Page 8 - VO2 Max and Zones
|
||||
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}",
|
||||
**pnoe_metrics, # Include all zone calculations
|
||||
}
|
||||
)
|
||||
|
||||
# Continue for all pages...
|
||||
# Add remaining pages as needed
|
||||
|
||||
return contexts
|
||||
|
||||
|
||||
# Usage for backend service
|
||||
def generate_report(
|
||||
pnoe_file, patient_file, spirometry_file, seca_file=None, patient_name="Moran"
|
||||
):
|
||||
"""Main function for backend service"""
|
||||
generator = ReportGenerator()
|
||||
generator.load_data(pnoe_file, patient_file, spirometry_file, seca_file)
|
||||
return generator.generate_all_contexts(patient_name)
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == "__main__":
|
||||
contexts = generate_report(
|
||||
"data/Pnoe_20250729_1550-Moran_Keirstyn.csv",
|
||||
"data/patient_data.csv",
|
||||
"data/spirometry_data.csv",
|
||||
"data/SECA body comp for all patients.xlsx",
|
||||
)
|
||||
print(f"Generated {len(contexts)} page contexts")
|
||||
@@ -1,12 +0,0 @@
|
||||
Parameters,Best,LLN,Pred.,%Pred.,ZScore,PRE#1,PRE#2,PRE#3
|
||||
FVC,L,4.24,3.03,3.79,112.0,0.95,4.24,4.17,4.15
|
||||
FEV1,L,3.26,2.53,3.16,103.3,0.28,3.26,3.21,3.14
|
||||
FEV1/FVC%,76.89,72.47,83.78,91.8,-1.05,76.9,77.0,75.7
|
||||
PEF,L/m,684,222,384,178.7,-,444,438,684
|
||||
FEF2575,L/s,2.74,2.15,3.42,80.2,-0.84,2.74,2.68,2.48
|
||||
FEF25,L/s,6.08,-,-,-,6.08,6.0,5.53
|
||||
FEF50,L/s,3.06,-,-,-,3.06,3.1,2.77
|
||||
FEF75,L/s,1.06,0.71,1.41,75.1,-0.72,1.06,1.12,0.94
|
||||
PEFTime,ms,-,-,79,-,79,49,39
|
||||
Evol,mL,-,-,78.0,-,78.0,77.0,197.0
|
||||
FEV6,L,4.22,3.03,3.79,111.4,-,4.22,4.17,4.13
|
||||
|
|
Before Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 396 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 292 KiB |
@@ -1,124 +0,0 @@
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from context import context_list
|
||||
|
||||
env = Environment(loader=FileSystemLoader("report_gen"))
|
||||
|
||||
html_pages = []
|
||||
|
||||
header_context = {
|
||||
"patient_name": "Keirstyn Moran",
|
||||
"age": 34,
|
||||
"height": "5'4\"",
|
||||
"weight": "123lbs",
|
||||
"focus": "Endurance",
|
||||
}
|
||||
|
||||
footer_context = [
|
||||
{
|
||||
"contact_email": "info@ishplabs.com ",
|
||||
"website": "www.ishplabs.com",
|
||||
"social": "@ishplabs",
|
||||
"page_number": i + 1,
|
||||
}
|
||||
for i in range(len(context_list))
|
||||
]
|
||||
|
||||
|
||||
header_html = env.get_template("header.html").render(header_context)
|
||||
footer_html_list = [
|
||||
env.get_template("footer.html").render(context) for context in footer_context
|
||||
]
|
||||
|
||||
for i, context in enumerate(context_list):
|
||||
template = env.get_template(f"page_{i + 1}.html").render(context)
|
||||
|
||||
if (i + 1) > 2:
|
||||
full_html = f"""
|
||||
<div class="page flex flex-col justify-between">
|
||||
<div>
|
||||
{header_html}
|
||||
</div>
|
||||
<main class="flex-grow p-4">
|
||||
{template}
|
||||
</main>
|
||||
<div class="border-t text-center text-sm text-gray-600">
|
||||
{footer_html_list[i]}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
html_pages.append(full_html)
|
||||
else:
|
||||
html_pages.append(template)
|
||||
|
||||
# Combine with page breaks
|
||||
final_html = "<div class='page-break'></div>".join(html_pages)
|
||||
# Wrap in full HTML document
|
||||
html_doc = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss/dist/tailwind.min.css" rel="stylesheet">
|
||||
<style>
|
||||
html, body {{
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}}
|
||||
.page-break {{ page-break-after: always; }}
|
||||
.page {{
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}}
|
||||
.page main {{
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}}
|
||||
/* Reset margins and padding everywhere */
|
||||
* {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
/* Prevent images from being too large */
|
||||
img {{
|
||||
max-height: 300px;
|
||||
}}
|
||||
/* Larger images for specific charts */
|
||||
.chart-large {{
|
||||
max-height: 500px !important;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body class="m-0 p-0">
|
||||
{final_html}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# Generate PDF
|
||||
|
||||
|
||||
def html_string_to_pdf(html_content, pdf_path):
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
page = browser.new_page()
|
||||
|
||||
# Set the HTML directly
|
||||
page.set_content(html_content)
|
||||
|
||||
# Export to PDF
|
||||
page.pdf(path=pdf_path, format="A4", print_background=True)
|
||||
|
||||
browser.close()
|
||||
|
||||
|
||||
html_string_to_pdf(html_doc, "multi_page_report.pdf")
|
||||
# pdfkit.from_string(html_doc, "truth_report.pdf", options=options)
|
||||
|
||||
print("✅ PDF generated: multi_page_report.pdf")
|
||||
@@ -2,102 +2,93 @@
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
"execution_count": 6,
|
||||
"id": "b18c1027",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"{'id': 'gen-1759135172-DIhs7TMuaaVY0h3T2ibV', 'provider': 'Google', 'model': 'google/gemini-2.5-flash-lite', 'object': 'chat.completion', 'created': 1759135172, 'choices': [{'logprobs': None, 'finish_reason': 'stop', 'native_finish_reason': 'STOP', 'index': 0, 'message': {'role': 'assistant', 'content': 'Parameters,Best,LLN,Pred.,%Pred.,ZScore,PRE#1,PRE#2,PRE#3\\nFVC,L,4.24,3.03,3.79,112.0,0.95,4.24,4.17,4.15\\nFEV1,L,3.26,2.53,3.16,103.3,0.28,3.26,3.21,3.14\\nFEV1/FVC%,76.89,72.47,83.78,91.8,-1.05,76.9,77.0,75.7\\nPEF,L/m,684,222,384,178.7,-,444,438,684\\nFEF2575,L/s,2.74,2.15,3.42,80.2,-0.84,2.74,2.68,2.48\\nFEF25,L/s,6.08,-,-,-,6.08,6.0,5.53\\nFEF50,L/s,3.06,-,-,-,3.06,3.1,2.77\\nFEF75,L/s,1.06,0.71,1.41,75.1,-0.72,1.06,1.12,0.94\\nPEFTime,ms,-,-,79,-,79,49,39\\nEvol,mL,-,-,78.0,-,78.0,77.0,197.0\\nFEV6,L,4.22,3.03,3.79,111.4,-,4.22,4.17,4.13', 'refusal': None, 'reasoning': None}}], 'usage': {'prompt_tokens': 1350, 'completion_tokens': 454, 'total_tokens': 1804, 'prompt_tokens_details': {'cached_tokens': 0}, 'completion_tokens_details': {'reasoning_tokens': 0, 'image_tokens': 0}}}\n",
|
||||
"Content saved to extracted_table.csv\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"\n",
|
||||
"import requests\n",
|
||||
"import json\n",
|
||||
"import base64\n",
|
||||
"from pathlib import Path\n",
|
||||
"# import requests\n",
|
||||
"# import json\n",
|
||||
"# import base64\n",
|
||||
"# from pathlib import Path\n",
|
||||
"\n",
|
||||
"API_KEY_REF = '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'\n",
|
||||
"def encode_pdf_to_base64(pdf_path):\n",
|
||||
" with open(pdf_path, \"rb\") as pdf_file:\n",
|
||||
" return base64.b64encode(pdf_file.read()).decode('utf-8')\n",
|
||||
"# API_KEY_REF = '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'\n",
|
||||
"# def encode_pdf_to_base64(pdf_path):\n",
|
||||
"# with open(pdf_path, \"rb\") as pdf_file:\n",
|
||||
"# return base64.b64encode(pdf_file.read()).decode('utf-8')\n",
|
||||
"\n",
|
||||
"url = \"https://openrouter.ai/api/v1/chat/completions\"\n",
|
||||
"headers = {\n",
|
||||
" \"Authorization\": f\"Bearer {API_KEY_REF}\",\n",
|
||||
" \"Content-Type\": \"application/json\"\n",
|
||||
"}\n",
|
||||
"# url = \"https://openrouter.ai/api/v1/chat/completions\"\n",
|
||||
"# headers = {\n",
|
||||
"# \"Authorization\": f\"Bearer {API_KEY_REF}\",\n",
|
||||
"# \"Content-Type\": \"application/json\"\n",
|
||||
"# }\n",
|
||||
"\n",
|
||||
"# Read and encode the PDF\n",
|
||||
"pdf_path = \"data/~Moran~K~19910201~Spirometry Exam~20250729~20250729032843.pdf\"\n",
|
||||
"base64_pdf = encode_pdf_to_base64(pdf_path)\n",
|
||||
"data_url = f\"data:application/pdf;base64,{base64_pdf}\"\n",
|
||||
"# # Read and encode the PDF\n",
|
||||
"# pdf_path = \"data/~Moran~K~19910201~Spirometry Exam~20250729~20250729032843.pdf\"\n",
|
||||
"# base64_pdf = encode_pdf_to_base64(pdf_path)\n",
|
||||
"# data_url = f\"data:application/pdf;base64,{base64_pdf}\"\n",
|
||||
"\n",
|
||||
"messages = [\n",
|
||||
" {\n",
|
||||
" \"role\": \"user\",\n",
|
||||
" \"content\": [\n",
|
||||
" {\n",
|
||||
" \"type\": \"text\",\n",
|
||||
" \"text\": \"Please extract the Spirometry table from the pdf and return the values in csv format, \"\n",
|
||||
" \"note that it is the unit of parameter that is beside it and it should not be a column. \"\n",
|
||||
" \"The '-' Should be treated as empty values.\"\n",
|
||||
" \"do not add 'csv' at the start or end of the response\"\n",
|
||||
" },\n",
|
||||
" {\n",
|
||||
" \"type\": \"file\",\n",
|
||||
" \"file\": {\n",
|
||||
" \"filename\": \"document.pdf\",\n",
|
||||
" \"file_data\": data_url\n",
|
||||
" }\n",
|
||||
" },\n",
|
||||
" ]\n",
|
||||
" }\n",
|
||||
"]\n",
|
||||
"# messages = [\n",
|
||||
"# {\n",
|
||||
"# \"role\": \"user\",\n",
|
||||
"# \"content\": [\n",
|
||||
"# {\n",
|
||||
"# \"type\": \"text\",\n",
|
||||
"# \"text\": \"Please extract the Spirometry table from the pdf and return the values in csv format, \"\n",
|
||||
"# \"note that it is the unit of parameter that is beside it and it should not be a column. \"\n",
|
||||
"# \"The '-' Should be treated as empty values.\"\n",
|
||||
"# \"do not add 'csv' at the start or end of the response\"\n",
|
||||
"# },\n",
|
||||
"# {\n",
|
||||
"# \"type\": \"file\",\n",
|
||||
"# \"file\": {\n",
|
||||
"# \"filename\": \"document.pdf\",\n",
|
||||
"# \"file_data\": data_url\n",
|
||||
"# }\n",
|
||||
"# },\n",
|
||||
"# ]\n",
|
||||
"# }\n",
|
||||
"# ]\n",
|
||||
"\n",
|
||||
"# Optional: Configure PDF processing engine\n",
|
||||
"# PDF parsing will still work even if the plugin is not explicitly set\n",
|
||||
"plugins = [\n",
|
||||
" {\n",
|
||||
" \"id\": \"file-parser\",\n",
|
||||
" \"pdf\": {\n",
|
||||
" \"engine\": \"pdf-text\" # defaults to \"mistral-ocr\". See Pricing above\n",
|
||||
" }\n",
|
||||
" }\n",
|
||||
"]\n",
|
||||
"# # Optional: Configure PDF processing engine\n",
|
||||
"# # PDF parsing will still work even if the plugin is not explicitly set\n",
|
||||
"# plugins = [\n",
|
||||
"# {\n",
|
||||
"# \"id\": \"file-parser\",\n",
|
||||
"# \"pdf\": {\n",
|
||||
"# \"engine\": \"pdf-text\" # defaults to \"mistral-ocr\". See Pricing above\n",
|
||||
"# }\n",
|
||||
"# }\n",
|
||||
"# ]\n",
|
||||
"\n",
|
||||
"payload = {\n",
|
||||
" \"model\": \"google/gemini-2.5-flash-lite\",\n",
|
||||
" \"messages\": messages,\n",
|
||||
"}\n",
|
||||
"# payload = {\n",
|
||||
"# \"model\": \"google/gemini-2.5-flash-lite\",\n",
|
||||
"# \"messages\": messages,\n",
|
||||
"# }\n",
|
||||
"\n",
|
||||
"response = requests.post(url, headers=headers, json=payload)\n",
|
||||
"# Get the response content\n",
|
||||
"response_data = response.json()\n",
|
||||
"print(response_data)\n",
|
||||
"# response = requests.post(url, headers=headers, json=payload)\n",
|
||||
"# # Get the response content\n",
|
||||
"# response_data = response.json()\n",
|
||||
"# print(response_data)\n",
|
||||
"\n",
|
||||
"# Extract the content from the response\n",
|
||||
"if 'choices' in response_data and len(response_data['choices']) > 0:\n",
|
||||
" content = response_data['choices'][0]['message']['content']\n",
|
||||
"# # Extract the content from the response\n",
|
||||
"# if 'choices' in response_data and len(response_data['choices']) > 0:\n",
|
||||
"# content = response_data['choices'][0]['message']['content']\n",
|
||||
" \n",
|
||||
" # Save to a CSV file\n",
|
||||
" output_file = \"extracted_table.csv\"\n",
|
||||
" with open(output_file, 'w', encoding='utf-8') as f:\n",
|
||||
" f.write(content)\n",
|
||||
"# # Save to a CSV file\n",
|
||||
"# output_file = \"extracted_table.csv\"\n",
|
||||
"# with open(output_file, 'w', encoding='utf-8') as f:\n",
|
||||
"# f.write(content)\n",
|
||||
" \n",
|
||||
" print(f\"Content saved to {output_file}\")\n",
|
||||
"else:\n",
|
||||
" print(\"No content found in response\")"
|
||||
"# print(f\"Content saved to {output_file}\")\n",
|
||||
"# else:\n",
|
||||
"# print(\"No content found in response\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 12,
|
||||
"execution_count": 7,
|
||||
"id": "56a9d655",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -107,13 +98,13 @@
|
||||
"text": [
|
||||
"FVC Best: 4.24, FVC Pred: 112.0\n",
|
||||
"FEV1 Best: 3.26, FEV1 Pred: 103.3\n",
|
||||
"FEV1/FVC% Best: 76.89, FEV1/FVC% Pred: 91.8\n"
|
||||
"FEV1/FVC% Best: 76.9, FEV1/FVC% Pred: 91.8\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"import pandas as pd\n",
|
||||
"spirometry_df = pd.read_csv(\"extracted_table.csv\")\n",
|
||||
"spirometry_df = pd.read_csv(\"data/spirometry_data.csv\")\n",
|
||||
"\n",
|
||||
"fvc_best = spirometry_df.loc[spirometry_df['Parameters'] == 'FVC', 'Best'].values[0]\n",
|
||||
"fvc_pred = spirometry_df.loc[spirometry_df['Parameters'] == 'FVC', '%Pred.'].values[0]\n",
|
||||
@@ -131,7 +122,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 16,
|
||||
"execution_count": 8,
|
||||
"id": "990f4b4f",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -155,7 +146,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 18,
|
||||
"execution_count": 9,
|
||||
"id": "041cbc3d",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -171,7 +162,7 @@
|
||||
"name": "stderr",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"/tmp/ipykernel_301535/4157056299.py:3: FutureWarning: errors='ignore' is deprecated and will raise in a future version. Use to_numeric without passing `errors` and catch exceptions explicitly instead\n",
|
||||
"/tmp/ipykernel_69398/4157056299.py:3: FutureWarning: errors='ignore' is deprecated and will raise in a future version. Use to_numeric without passing `errors` and catch exceptions explicitly instead\n",
|
||||
" df = df.apply(pd.to_numeric, errors='ignore')\n"
|
||||
]
|
||||
}
|
||||
@@ -204,7 +195,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 20,
|
||||
"execution_count": 10,
|
||||
"id": "de7cadd1",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -223,7 +214,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 24,
|
||||
"execution_count": 11,
|
||||
"id": "cb972ed3",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -320,7 +311,7 @@
|
||||
"[1 rows x 147 columns]"
|
||||
]
|
||||
},
|
||||
"execution_count": 24,
|
||||
"execution_count": 11,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
@@ -334,7 +325,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 26,
|
||||
"execution_count": 12,
|
||||
"id": "98d9295a",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -354,7 +345,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 32,
|
||||
"execution_count": 13,
|
||||
"id": "cdfeb309",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -418,7 +409,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 33,
|
||||
"execution_count": 14,
|
||||
"id": "4420cfea",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -476,7 +467,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 37,
|
||||
"execution_count": 21,
|
||||
"id": "62803668",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -561,7 +552,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 40,
|
||||
"execution_count": 16,
|
||||
"id": "07593b56",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -572,8 +563,8 @@
|
||||
"Zone 1 (Active Recovery): 81.7 - 96.7 bpm\n",
|
||||
"Zone 2 (Aerobic Base): 96.7 - 100.5 bpm\n",
|
||||
"Zone 3 (Aerobic): 100.5 - 179.7 bpm\n",
|
||||
"Zone 4 (Lactate Threshold): 179.7 - 199.7 bpm\n",
|
||||
"Zone 5 (VO2 Max): 199.7+ bpm\n"
|
||||
"Zone 4 (Lactate Threshold): 179.7 - 189.7 bpm\n",
|
||||
"Zone 5 (VO2 Max): 189.7 - 199.7 bpm\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -582,7 +573,8 @@
|
||||
"zone_2_start = optimal_row['HR(bpm)_smoothed']\n",
|
||||
"zone_3_start = vt1\n",
|
||||
"zone_4_start = vt2['HeartRate'] - 10\n",
|
||||
"zone_5_start = vt2['HeartRate'] + 10\n",
|
||||
"zone_5_start = vt2['HeartRate']\n",
|
||||
"zone_5_end = vt2['HeartRate'] + 10\n",
|
||||
"\n",
|
||||
"zone_1_end = zone_2_start\n",
|
||||
"zone_2_end = vt1['HeartRate']\n",
|
||||
@@ -593,12 +585,12 @@
|
||||
"print(f\"Zone 2 (Aerobic Base): {zone_2_start:.1f} - {zone_2_end:.1f} bpm\")\n",
|
||||
"print(f\"Zone 3 (Aerobic): {zone_3_start['HeartRate']:.1f} - {zone_3_end:.1f} bpm\")\n",
|
||||
"print(f\"Zone 4 (Lactate Threshold): {zone_4_start:.1f} - {zone_4_end:.1f} bpm\")\n",
|
||||
"print(f\"Zone 5 (VO2 Max): {zone_5_start:.1f}+ bpm\")"
|
||||
"print(f\"Zone 5 (VO2 Max): {zone_5_start:.1f} - {zone_5_end:.1f} bpm\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 60,
|
||||
"execution_count": 17,
|
||||
"id": "c90415b2",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -661,7 +653,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 66,
|
||||
"execution_count": 18,
|
||||
"id": "c3b2cc59",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -750,7 +742,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 19,
|
||||
"id": "672d68f3",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -774,6 +766,52 @@
|
||||
"print(f\"FAT (smoothed): {max_fat_smoothed_row['FAT_smoothed']:.3f} kcal/min\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "3521220f",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Estimated RMR from data: 1385 kcal/day\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"# 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"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "524e4cba",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Resting phase fuel mix: Fats 32.9%, Carbs 67.1%\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"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"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
@@ -1,5 +1,8 @@
|
||||
annotated-types==0.7.0
|
||||
anyio==4.11.0
|
||||
asttokens==3.0.0
|
||||
brotli==1.1.0
|
||||
certifi==2025.8.3
|
||||
cffi==2.0.0
|
||||
chardet==5.2.0
|
||||
charset-normalizer==3.4.3
|
||||
@@ -11,24 +14,39 @@ cssselect2==0.8.0
|
||||
cycler==0.12.1
|
||||
debugpy==1.8.17
|
||||
decorator==5.2.1
|
||||
dnspython==2.8.0
|
||||
email-validator==2.3.0
|
||||
et-xmlfile==2.0.0
|
||||
executing==2.2.1
|
||||
fastapi==0.118.0
|
||||
fastapi-cli==0.0.13
|
||||
fastapi-cloud-cli==0.3.0
|
||||
fonttools==4.60.0
|
||||
greenlet==3.2.4
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httptools==0.6.4
|
||||
httpx==0.28.1
|
||||
idna==3.10
|
||||
ipykernel==6.30.1
|
||||
ipython==9.5.0
|
||||
ipython-pygments-lexers==1.1.1
|
||||
itsdangerous==2.2.0
|
||||
jedi==0.19.2
|
||||
jinja2==3.1.6
|
||||
jupyter-client==8.6.3
|
||||
jupyter-core==5.8.1
|
||||
kiwisolver==1.4.9
|
||||
markdown-it-py==4.0.0
|
||||
markupsafe==3.0.2
|
||||
matplotlib==3.10.6
|
||||
matplotlib-inline==0.1.7
|
||||
mdurl==0.1.2
|
||||
nest-asyncio==1.6.0
|
||||
numpy==2.3.3
|
||||
opencv-python-headless==4.11.0.86
|
||||
openpyxl==3.1.5
|
||||
orjson==3.11.3
|
||||
packaging==25.0
|
||||
pandas==2.3.2
|
||||
pango==0.0.1
|
||||
@@ -38,12 +56,18 @@ pdfminer-six==20250506
|
||||
pexpect==4.9.0
|
||||
pillow==11.3.0
|
||||
platformdirs==4.4.0
|
||||
playwright==1.55.0
|
||||
prompt-toolkit==3.0.52
|
||||
psutil==7.1.0
|
||||
ptyprocess==0.7.0
|
||||
pure-eval==0.2.3
|
||||
pycparser==2.23
|
||||
pydantic==2.11.9
|
||||
pydantic-core==2.33.2
|
||||
pydantic-extra-types==2.10.5
|
||||
pydantic-settings==2.11.0
|
||||
pydyf==0.11.0
|
||||
pyee==13.0.0
|
||||
pygments==2.19.2
|
||||
pymupdf==1.26.4
|
||||
pyparsing==3.2.5
|
||||
@@ -51,17 +75,36 @@ pypdf==5.9.0
|
||||
pypdfium2==4.30.0
|
||||
pyphen==0.17.2
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.1.1
|
||||
python-multipart==0.0.20
|
||||
pytz==2025.2
|
||||
pyyaml==6.0.3
|
||||
pyzmq==27.1.0
|
||||
rich==14.1.0
|
||||
rich-toolkit==0.15.1
|
||||
rignore==0.7.0
|
||||
seaborn==0.13.2
|
||||
sentry-sdk==2.39.0
|
||||
shellingham==1.5.4
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
stack-data==0.6.3
|
||||
starlette==0.48.0
|
||||
tabulate==0.9.0
|
||||
tinycss2==1.4.0
|
||||
tinyhtml5==2.0.0
|
||||
tornado==6.5.2
|
||||
traitlets==5.14.3
|
||||
typer==0.19.2
|
||||
typing-extensions==4.15.0
|
||||
typing-inspection==0.4.2
|
||||
tzdata==2025.2
|
||||
ujson==5.11.0
|
||||
urllib3==2.5.0
|
||||
uvicorn==0.37.0
|
||||
uvloop==0.21.0
|
||||
watchfiles==1.1.0
|
||||
wcwidth==0.2.14
|
||||
webencodings==0.5.1
|
||||
websockets==15.0.1
|
||||
zopfli==0.2.3.post1
|
||||
|
||||