diff --git a/__pycache__/context.cpython-312.pyc b/__pycache__/context.cpython-312.pyc deleted file mode 100644 index 9989700..0000000 Binary files a/__pycache__/context.cpython-312.pyc and /dev/null differ diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..c513151 --- /dev/null +++ b/app/api.py @@ -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""" +
+
+ {header_html} +
+
+ {template} +
+
+ {footer_html_list[i]} +
+
+ """ + html_pages.append(full_html) + else: + html_pages.append(template) + + # Combine with page breaks + final_html = "
".join(html_pages) + + # Wrap in full HTML document + html_doc = f""" + + + + + + + + + {final_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) diff --git a/graph_generator.py b/app/graph_generator.py similarity index 100% rename from graph_generator.py rename to app/graph_generator.py diff --git a/report_gen/footer.html b/app/report_gen/footer.html similarity index 100% rename from report_gen/footer.html rename to app/report_gen/footer.html diff --git a/report_gen/header.html b/app/report_gen/header.html similarity index 100% rename from report_gen/header.html rename to app/report_gen/header.html diff --git a/report_gen/page_1.html b/app/report_gen/page_1.html similarity index 100% rename from report_gen/page_1.html rename to app/report_gen/page_1.html diff --git a/report_gen/page_10.html b/app/report_gen/page_10.html similarity index 100% rename from report_gen/page_10.html rename to app/report_gen/page_10.html diff --git a/report_gen/page_11.html b/app/report_gen/page_11.html similarity index 100% rename from report_gen/page_11.html rename to app/report_gen/page_11.html diff --git a/report_gen/page_12.html b/app/report_gen/page_12.html similarity index 100% rename from report_gen/page_12.html rename to app/report_gen/page_12.html diff --git a/report_gen/page_13.html b/app/report_gen/page_13.html similarity index 100% rename from report_gen/page_13.html rename to app/report_gen/page_13.html diff --git a/report_gen/page_14.html b/app/report_gen/page_14.html similarity index 100% rename from report_gen/page_14.html rename to app/report_gen/page_14.html diff --git a/report_gen/page_15.html b/app/report_gen/page_15.html similarity index 100% rename from report_gen/page_15.html rename to app/report_gen/page_15.html diff --git a/report_gen/page_16.html b/app/report_gen/page_16.html similarity index 100% rename from report_gen/page_16.html rename to app/report_gen/page_16.html diff --git a/report_gen/page_17.html b/app/report_gen/page_17.html similarity index 100% rename from report_gen/page_17.html rename to app/report_gen/page_17.html diff --git a/report_gen/page_18.html b/app/report_gen/page_18.html similarity index 100% rename from report_gen/page_18.html rename to app/report_gen/page_18.html diff --git a/report_gen/page_19.html b/app/report_gen/page_19.html similarity index 100% rename from report_gen/page_19.html rename to app/report_gen/page_19.html diff --git a/report_gen/page_2.html b/app/report_gen/page_2.html similarity index 100% rename from report_gen/page_2.html rename to app/report_gen/page_2.html diff --git a/report_gen/page_3.html b/app/report_gen/page_3.html similarity index 100% rename from report_gen/page_3.html rename to app/report_gen/page_3.html diff --git a/report_gen/page_4.html b/app/report_gen/page_4.html similarity index 100% rename from report_gen/page_4.html rename to app/report_gen/page_4.html diff --git a/report_gen/page_5.html b/app/report_gen/page_5.html similarity index 100% rename from report_gen/page_5.html rename to app/report_gen/page_5.html diff --git a/report_gen/page_6.html b/app/report_gen/page_6.html similarity index 100% rename from report_gen/page_6.html rename to app/report_gen/page_6.html diff --git a/report_gen/page_7.html b/app/report_gen/page_7.html similarity index 100% rename from report_gen/page_7.html rename to app/report_gen/page_7.html diff --git a/report_gen/page_8.html b/app/report_gen/page_8.html similarity index 100% rename from report_gen/page_8.html rename to app/report_gen/page_8.html diff --git a/report_gen/page_9.html b/app/report_gen/page_9.html similarity index 100% rename from report_gen/page_9.html rename to app/report_gen/page_9.html diff --git a/analysis.ipynb b/app/services/analysis.ipynb similarity index 100% rename from analysis.ipynb rename to app/services/analysis.ipynb diff --git a/context.py b/app/services/context.py similarity index 100% rename from context.py rename to app/services/context.py diff --git a/context_generator.py b/app/services/context_generator.py similarity index 100% rename from context_generator.py rename to app/services/context_generator.py diff --git a/main.py b/app/services/main.py similarity index 100% rename from main.py rename to app/services/main.py diff --git a/notebook.ipynb b/app/services/notebook.ipynb similarity index 100% rename from notebook.ipynb rename to app/services/notebook.ipynb diff --git a/extracted_table.csv b/extracted_table.csv deleted file mode 100644 index 7482e84..0000000 --- a/extracted_table.csv +++ /dev/null @@ -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 \ No newline at end of file diff --git a/graphs/body_composition_chart.png b/graphs/body_composition_chart.png deleted file mode 100644 index 337db2f..0000000 Binary files a/graphs/body_composition_chart.png and /dev/null differ diff --git a/graphs/body_fat_percent_chart.png b/graphs/body_fat_percent_chart.png deleted file mode 100644 index 3d01923..0000000 Binary files a/graphs/body_fat_percent_chart.png and /dev/null differ diff --git a/graphs/fat_metabolism_chart.png b/graphs/fat_metabolism_chart.png deleted file mode 100644 index 6960491..0000000 Binary files a/graphs/fat_metabolism_chart.png and /dev/null differ diff --git a/graphs/fat_percent_master_chart.png b/graphs/fat_percent_master_chart.png deleted file mode 100644 index 7e24de3..0000000 Binary files a/graphs/fat_percent_master_chart.png and /dev/null differ diff --git a/graphs/fuel_utilization_chart.png b/graphs/fuel_utilization_chart.png deleted file mode 100644 index e475ca3..0000000 Binary files a/graphs/fuel_utilization_chart.png and /dev/null differ diff --git a/graphs/recovery_chart.png b/graphs/recovery_chart.png deleted file mode 100644 index 57c6feb..0000000 Binary files a/graphs/recovery_chart.png and /dev/null differ diff --git a/graphs/respiratory.png b/graphs/respiratory.png deleted file mode 100644 index ffa0ee6..0000000 Binary files a/graphs/respiratory.png and /dev/null differ diff --git a/graphs/spirometry_chart.png b/graphs/spirometry_chart.png deleted file mode 100644 index 0c8b5b5..0000000 Binary files a/graphs/spirometry_chart.png and /dev/null differ diff --git a/graphs/vo2_breath_chart.png b/graphs/vo2_breath_chart.png deleted file mode 100644 index 0a5fe3a..0000000 Binary files a/graphs/vo2_breath_chart.png and /dev/null differ diff --git a/graphs/vo2_pulse_chart.png b/graphs/vo2_pulse_chart.png deleted file mode 100644 index 2527dea..0000000 Binary files a/graphs/vo2_pulse_chart.png and /dev/null differ diff --git a/multi_page_report.pdf b/multi_page_report.pdf deleted file mode 100644 index a54b515..0000000 Binary files a/multi_page_report.pdf and /dev/null differ diff --git a/requirements.txt b/requirements.txt index e06511a..0a6b7b8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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