Add HTML templates for medical report generator with navigation, upload, edit, and preview functionalities
- Created base template with navigation and layout structure - Implemented upload.html for patient data and file uploads - Developed edit.html for editing calculated metrics - Added preview.html for displaying generated report previews - Enhanced user experience with Tailwind CSS styling
This commit is contained in:
@@ -9,3 +9,5 @@ data/
|
||||
/data
|
||||
|
||||
/reports
|
||||
|
||||
/temp
|
||||
+335
-13
@@ -5,13 +5,17 @@ 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, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import BaseModel
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from services.report_generator import ReportGeneratorService
|
||||
|
||||
app = FastAPI(
|
||||
@@ -20,6 +24,12 @@ app = FastAPI(
|
||||
version="2.0.0",
|
||||
)
|
||||
|
||||
# Add session middleware
|
||||
app.add_middleware(SessionMiddleware, secret_key=os.getenv("SECRET_KEY", "your-secret-key-change-in-production"))
|
||||
|
||||
# Setup templates
|
||||
jinja_env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
|
||||
# Define output directories
|
||||
GRAPHS_DIR = Path("graphs")
|
||||
GRAPHS_DIR.mkdir(exist_ok=True)
|
||||
@@ -27,6 +37,9 @@ 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",
|
||||
@@ -42,19 +55,328 @@ class ReportResponse(BaseModel):
|
||||
analysis_data: dict
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint with API information"""
|
||||
return {
|
||||
"message": "Medical Report Generation API",
|
||||
"version": "2.0.0",
|
||||
"endpoints": {
|
||||
"generate_report": "POST /generate-report",
|
||||
"download_report": "GET /download-report/{filename}",
|
||||
"health": "GET /health",
|
||||
},
|
||||
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)
|
||||
|
||||
|
||||
@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(...),
|
||||
focus: str = Form(default="Endurance"),
|
||||
session_id: str = Form(default="default"),
|
||||
spirometry_pdf: UploadFile = File(...),
|
||||
pnoe_csv: UploadFile = File(...),
|
||||
seca_excel: UploadFile = File(...),
|
||||
):
|
||||
"""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"
|
||||
})
|
||||
|
||||
if not seca_excel.filename.endswith((".xlsx", ".xls")):
|
||||
return render_template("upload.html", {
|
||||
"request": request,
|
||||
"session": request.session,
|
||||
"error": "SECA file must be an Excel file (.xlsx or .xls)"
|
||||
})
|
||||
|
||||
# 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}"
|
||||
seca_path = session_temp_dir / 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_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,
|
||||
"focus": focus,
|
||||
"session_id": session_id,
|
||||
}
|
||||
|
||||
# Generate report
|
||||
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,
|
||||
)
|
||||
|
||||
# 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 services.spirometry_table_extractor import extract_spirometry_table_from_pdf
|
||||
from services.context_generator import ContextGenerator
|
||||
from pathlib import Path as PathLib
|
||||
|
||||
# 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),
|
||||
str(seca_path)
|
||||
)
|
||||
context_gen.extract_patient_info(last_name) # Extract patient info
|
||||
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()
|
||||
seca_excel.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
|
||||
seca_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("seca_"):
|
||||
seca_path = file_path
|
||||
|
||||
if not all([spirometry_path, pnoe_path, seca_path]):
|
||||
raise ValueError("Could not find all uploaded files")
|
||||
|
||||
# Regenerate report with overrides
|
||||
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,
|
||||
metric_overrides=metric_overrides if (metric_overrides["pnoe"] or metric_overrides["spirometry"]) else None,
|
||||
)
|
||||
|
||||
# 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 services.spirometry_table_extractor import extract_spirometry_table_from_pdf
|
||||
from pathlib import Path as PathLib
|
||||
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,
|
||||
str(seca_path)
|
||||
)
|
||||
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():
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -101,41 +101,108 @@ class ContextGenerator:
|
||||
}
|
||||
return self.patient_info
|
||||
|
||||
def calculate_spirometry_metrics(self) -> Dict:
|
||||
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:
|
||||
param_key = param.lower().replace("/", "_").replace("%", "_pct")
|
||||
metrics[f"{param_key}_best"] = row["Best"].values[0]
|
||||
|
||||
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:
|
||||
metrics[f"{param_key}_pred"] = row["%Pred."].values[0]
|
||||
return metrics
|
||||
|
||||
def calculate_pnoe_metrics(self) -> Dict:
|
||||
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, vt2 = self._detect_thresholds()
|
||||
# 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
|
||||
|
||||
zones = self._calculate_hr_zones(vt1, vt2, fat_max_row)
|
||||
# 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]]:
|
||||
@@ -195,12 +262,20 @@ class ContextGenerator:
|
||||
return zones
|
||||
|
||||
def generate_all_contexts(
|
||||
self, patient_name: str, graphs: Dict[str, str]
|
||||
self, patient_name: str, graphs: Dict[str, str], metric_overrides: Optional[Dict] = None
|
||||
) -> List[Dict]:
|
||||
"""Main method to generate all page contexts"""
|
||||
if metric_overrides is None:
|
||||
metric_overrides = {}
|
||||
|
||||
self.extract_patient_info(patient_name)
|
||||
spirometry_metrics = self.calculate_spirometry_metrics()
|
||||
pnoe_metrics = self.calculate_pnoe_metrics()
|
||||
|
||||
# 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)
|
||||
|
||||
contexts = []
|
||||
contexts.append(
|
||||
|
||||
@@ -6,7 +6,7 @@ It processes data, generates graphs, and creates PDF reports.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
@@ -287,6 +287,7 @@ class ReportGeneratorService:
|
||||
seca_excel_path: str,
|
||||
patient_info: Dict[str, Any],
|
||||
output_filename: str = None,
|
||||
metric_overrides: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate complete medical report from uploaded files.
|
||||
@@ -387,7 +388,7 @@ class ReportGeneratorService:
|
||||
pnoe_csv_path, str(spirometry_csv_path), seca_excel_path
|
||||
)
|
||||
context_list = self.context_generator.generate_all_contexts(
|
||||
patient_name, graphs_dict
|
||||
patient_name, graphs_dict, metric_overrides=metric_overrides
|
||||
)
|
||||
|
||||
# Step 5: Calculate analysis metrics
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<!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,106 @@
|
||||
{% 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="seca_excel" class="block text-sm font-medium text-gray-700">SECA Excel</label>
|
||||
<input type="file" name="seca_excel" id="seca_excel" accept=".xlsx,.xls" 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>
|
||||
</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 %}
|
||||
|
||||
@@ -766,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,
|
||||
|
||||
+715
-1729
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user