Refactor code structure for improved readability and maintainability

This commit is contained in:
bolade
2025-10-03 19:19:39 +01:00
parent 6b2c61a48e
commit 1d8136d6ad
42 changed files with 576 additions and 12 deletions
Binary file not shown.
+533
View File
@@ -0,0 +1,533 @@
"""
FastAPI application for report generation with file uploads.
This API allows users to:
1. Upload required files (Spirometry PDF, Pnoe CSV, SECA Excel)
2. Generate reports with graphs and analysis
"""
import shutil
from pathlib import Path
from typing import Dict, Optional
import pandas as pd
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel
from graph_generator import GraphGenerator
app = FastAPI(
title="Medical Report Generation API",
description="API for generating medical performance reports with analysis and graphs",
version="1.0.0",
)
# Define upload directory
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)
# Define output directories
GRAPHS_DIR = Path("graphs")
GRAPHS_DIR.mkdir(exist_ok=True)
REPORTS_DIR = Path("reports")
REPORTS_DIR.mkdir(exist_ok=True)
# Storage for uploaded files metadata
uploaded_files_store: Dict[str, Dict[str, str]] = {}
class FileUploadResponse(BaseModel):
message: str
filename: str
file_type: str
file_path: str
class ReportRequest(BaseModel):
patient_name: str
age: int
height: str
weight: str
focus: str = "Endurance"
session_id: Optional[str] = "default"
class ReportResponse(BaseModel):
message: str
report_path: str
graphs_generated: list
analysis_data: dict
@app.get("/")
async def root():
"""Root endpoint with API information"""
return {
"message": "Medical Report Generation API",
"version": "1.0.0",
"endpoints": {
"upload_spirometry": "/upload/spirometry",
"upload_pnoe": "/upload/pnoe",
"upload_seca": "/upload/seca",
"generate_report": "/generate-report",
"list_uploads": "/uploads",
"health": "/health",
},
}
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy", "service": "report-generation-api"}
@app.post("/upload/spirometry", response_model=FileUploadResponse)
async def upload_spirometry_pdf(
file: UploadFile = File(...), session_id: str = "default"
):
"""
Upload Spirometry PDF file for analysis.
Args:
file: Spirometry PDF file
session_id: Session identifier to group files together (default: "default")
Returns:
FileUploadResponse with upload details
"""
if not file.filename.endswith(".pdf"):
raise HTTPException(status_code=400, detail="Only PDF files are allowed")
# Create session directory
session_dir = UPLOAD_DIR / session_id
session_dir.mkdir(exist_ok=True)
# Save file
file_path = session_dir / f"spirometry_{file.filename}"
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
# Store metadata
if session_id not in uploaded_files_store:
uploaded_files_store[session_id] = {}
uploaded_files_store[session_id]["spirometry_pdf"] = str(file_path)
return FileUploadResponse(
message="Spirometry PDF uploaded successfully",
filename=file.filename,
file_type="spirometry_pdf",
file_path=str(file_path),
)
@app.post("/upload/pnoe", response_model=FileUploadResponse)
async def upload_pnoe_csv(file: UploadFile = File(...), session_id: str = "default"):
"""
Upload Pnoe CSV file for metabolic analysis.
Args:
file: Pnoe CSV file
session_id: Session identifier to group files together (default: "default")
Returns:
FileUploadResponse with upload details
"""
if not file.filename.endswith(".csv"):
raise HTTPException(status_code=400, detail="Only CSV files are allowed")
# Create session directory
session_dir = UPLOAD_DIR / session_id
session_dir.mkdir(exist_ok=True)
# Save file
file_path = session_dir / f"pnoe_{file.filename}"
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
# Store metadata
if session_id not in uploaded_files_store:
uploaded_files_store[session_id] = {}
uploaded_files_store[session_id]["pnoe_csv"] = str(file_path)
return FileUploadResponse(
message="Pnoe CSV uploaded successfully",
filename=file.filename,
file_type="pnoe_csv",
file_path=str(file_path),
)
@app.post("/upload/seca", response_model=FileUploadResponse)
async def upload_seca_excel(file: UploadFile = File(...), session_id: str = "default"):
"""
Upload SECA body composition Excel file.
Args:
file: SECA Excel file (.xlsx)
session_id: Session identifier to group files together (default: "default")
Returns:
FileUploadResponse with upload details
"""
if not file.filename.endswith((".xlsx", ".xls")):
raise HTTPException(
status_code=400, detail="Only Excel files (.xlsx, .xls) are allowed"
)
# Create session directory
session_dir = UPLOAD_DIR / session_id
session_dir.mkdir(exist_ok=True)
# Save file
file_path = session_dir / f"seca_{file.filename}"
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
# Store metadata
if session_id not in uploaded_files_store:
uploaded_files_store[session_id] = {}
uploaded_files_store[session_id]["seca_excel"] = str(file_path)
return FileUploadResponse(
message="SECA Excel uploaded successfully",
filename=file.filename,
file_type="seca_excel",
file_path=str(file_path),
)
@app.get("/uploads")
async def list_uploads(session_id: str = "default"):
"""
List all uploaded files for a session.
Args:
session_id: Session identifier (default: "default")
Returns:
Dictionary of uploaded files
"""
if session_id not in uploaded_files_store:
return {"session_id": session_id, "files": {}, "message": "No files uploaded"}
return {
"session_id": session_id,
"files": uploaded_files_store[session_id],
"files_count": len(uploaded_files_store[session_id]),
}
@app.post("/generate-report", response_model=ReportResponse)
async def generate_report(report_request: ReportRequest):
"""
Generate a comprehensive medical report with graphs and analysis.
Args:
report_request: Report configuration including patient details
Returns:
ReportResponse with report path and analysis data
"""
session_id = report_request.session_id
# Check if all required files are uploaded
if session_id not in uploaded_files_store:
raise HTTPException(
status_code=400,
detail=f"No files found for session '{session_id}'. Please upload files first.",
)
files = uploaded_files_store[session_id]
required_files = ["spirometry_pdf", "pnoe_csv", "seca_excel"]
missing_files = [f for f in required_files if f not in files]
if missing_files:
raise HTTPException(
status_code=400,
detail=f"Missing required files: {', '.join(missing_files)}. Please upload all files first.",
)
try:
# Initialize graph generator
graph_gen = GraphGenerator(charts_dir=str(GRAPHS_DIR))
# Load and process Pnoe data
df = pd.read_csv(files["pnoe_csv"], delimiter=";")
df = df.apply(pd.to_numeric, errors="ignore")
# 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()
)
# Generate graphs
graphs_generated = []
# Generate all available graphs from the graph generator
try:
respiratory_path = graph_gen.generate_respiratory_chart(
df, save_as_base64=False
)
graphs_generated.append(
{"name": "respiratory", "path": str(respiratory_path)}
)
except Exception as e:
print(f"Warning: Could not generate respiratory chart: {e}")
try:
fuel_util_path = graph_gen.generate_fuel_utilization_chart(
df, save_as_base64=False
)
graphs_generated.append(
{"name": "fuel_utilization", "path": str(fuel_util_path)}
)
except Exception as e:
print(f"Warning: Could not generate fuel utilization chart: {e}")
try:
vo2_pulse_path = graph_gen.generate_vo2_pulse_chart(
df, save_as_base64=False
)
graphs_generated.append({"name": "vo2_pulse", "path": str(vo2_pulse_path)})
except Exception as e:
print(f"Warning: Could not generate VO2 pulse chart: {e}")
try:
vo2_breath_path = graph_gen.generate_vo2_breath_chart(
df, save_as_base64=False
)
graphs_generated.append(
{"name": "vo2_breath", "path": str(vo2_breath_path)}
)
except Exception as e:
print(f"Warning: Could not generate VO2 breath chart: {e}")
try:
fat_metabolism_path = graph_gen.generate_fat_metabolism_chart(
df, save_as_base64=False
)
graphs_generated.append(
{"name": "fat_metabolism", "path": str(fat_metabolism_path)}
)
except Exception as e:
print(f"Warning: Could not generate fat metabolism chart: {e}")
try:
recovery_path = graph_gen.generate_recovery_chart(df, save_as_base64=False)
graphs_generated.append({"name": "recovery", "path": str(recovery_path)})
except Exception as e:
print(f"Warning: Could not generate recovery chart: {e}")
# Calculate basic analysis metrics
analysis_data = {
"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,
"graphs_count": len(graphs_generated),
}
# Generate PDF report using existing main.py logic
from jinja2 import Environment, FileSystemLoader
from context import context_list
from main import html_string_to_pdf
env = Environment(loader=FileSystemLoader("report_gen"))
html_pages = []
header_context = {
"patient_name": report_request.patient_name,
"age": report_request.age,
"height": report_request.height,
"weight": report_request.weight,
"focus": report_request.focus,
}
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;
}}
* {{
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>
"""
# Generate PDF
report_filename = (
f"report_{report_request.patient_name.replace(' ', '_')}_{session_id}.pdf"
)
report_path = REPORTS_DIR / report_filename
html_string_to_pdf(html_doc, str(report_path))
return ReportResponse(
message="Report generated successfully",
report_path=str(report_path),
graphs_generated=graphs_generated,
analysis_data=analysis_data,
)
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Error generating report: {str(e)}"
)
@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,
)
@app.delete("/uploads/{session_id}")
async def delete_session_uploads(session_id: str):
"""
Delete all uploaded files for a session.
Args:
session_id: Session identifier
Returns:
Success message
"""
if session_id not in uploaded_files_store:
raise HTTPException(status_code=404, detail="Session not found")
# Delete files
session_dir = UPLOAD_DIR / session_id
if session_dir.exists():
shutil.rmtree(session_dir)
# Remove from store
del uploaded_files_store[session_id]
return {"message": f"Session '{session_id}' deleted successfully"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
View File
-12
View File
@@ -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
1 Parameters,Best,LLN,Pred.,%Pred.,ZScore,PRE#1,PRE#2,PRE#3
2 FVC,L,4.24,3.03,3.79,112.0,0.95,4.24,4.17,4.15
3 FEV1,L,3.26,2.53,3.16,103.3,0.28,3.26,3.21,3.14
4 FEV1/FVC%,76.89,72.47,83.78,91.8,-1.05,76.9,77.0,75.7
5 PEF,L/m,684,222,384,178.7,-,444,438,684
6 FEF2575,L/s,2.74,2.15,3.42,80.2,-0.84,2.74,2.68,2.48
7 FEF25,L/s,6.08,-,-,-,6.08,6.0,5.53
8 FEF50,L/s,3.06,-,-,-,3.06,3.1,2.77
9 FEF75,L/s,1.06,0.71,1.41,75.1,-0.72,1.06,1.12,0.94
10 PEFTime,ms,-,-,79,-,79,49,39
11 Evol,mL,-,-,78.0,-,78.0,77.0,197.0
12 FEV6,L,4.22,3.03,3.79,111.4,-,4.22,4.17,4.13
Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.
+43
View File
@@ -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