From 11ee6b192f708883bc9bff4760438fbd4c8b4858 Mon Sep 17 00:00:00 2001 From: bolade Date: Fri, 3 Oct 2025 21:41:00 +0100 Subject: [PATCH] feat: Implement report generator service for medical reports - Added ReportGeneratorService to handle generation of medical reports from uploaded files. - Implemented methods for processing Pnoe CSV data, generating graphs, and calculating analysis metrics. - Integrated Jinja2 for HTML report generation with customizable templates. - Added functionality to convert HTML content to PDF using Playwright. - Ensured proper directory structure for saving generated graphs and reports. --- .gitignore | 4 +- .../graph_generator.cpython-312.pyc | Bin 0 -> 35161 bytes app/api.py | 533 ------------------ app/main.py | 193 +++++++ app/{ => services}/graph_generator.py | 0 app/services/main.py | 124 ---- app/services/report_generator.py | 318 +++++++++++ app/services/spirometry_table_extractor.py | 64 +++ app/services_dfdf/__init__.py | 0 .../analysis.ipynb | 0 app/services_dfdf/context_generator.py | 0 .../notebook.ipynb | 0 app/services_dfdf/report_generator.py | 318 +++++++++++ 13 files changed, 896 insertions(+), 658 deletions(-) create mode 100644 app/__pycache__/graph_generator.cpython-312.pyc delete mode 100644 app/api.py create mode 100644 app/main.py rename app/{ => services}/graph_generator.py (100%) delete mode 100644 app/services/main.py create mode 100644 app/services/report_generator.py create mode 100644 app/services/spirometry_table_extractor.py create mode 100644 app/services_dfdf/__init__.py rename app/{services => services_dfdf}/analysis.ipynb (100%) create mode 100644 app/services_dfdf/context_generator.py rename app/{services => services_dfdf}/notebook.ipynb (100%) create mode 100644 app/services_dfdf/report_generator.py diff --git a/.gitignore b/.gitignore index bd16166..78abf35 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .venv -data/ \ No newline at end of file +data/ + +.env \ No newline at end of file diff --git a/app/__pycache__/graph_generator.cpython-312.pyc b/app/__pycache__/graph_generator.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f90b8669361be02d3179c47e6c8e9e97b7f15b71 GIT binary patch literal 35161 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 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/app/main.py b/app/main.py new file mode 100644 index 0000000..ae2fcb0 --- /dev/null +++ b/app/main.py @@ -0,0 +1,193 @@ +""" +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 shutil +import tempfile +from pathlib import Path + +from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi.responses import FileResponse +from pydantic import BaseModel + +from services.report_generator import ReportGeneratorService + +app = FastAPI( + title="Medical Report Generation API", + description="API for generating medical performance reports with analysis and graphs", + version="2.0.0", +) + +# Define output directories +GRAPHS_DIR = Path("graphs") +GRAPHS_DIR.mkdir(exist_ok=True) + +REPORTS_DIR = Path("reports") +REPORTS_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 + + +@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", + }, + } + + +@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 = 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: + raise HTTPException( + status_code=500, + detail=f"Error generating report: {str(e)}", + ) + 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) diff --git a/app/graph_generator.py b/app/services/graph_generator.py similarity index 100% rename from app/graph_generator.py rename to app/services/graph_generator.py diff --git a/app/services/main.py b/app/services/main.py deleted file mode 100644 index 5d152cb..0000000 --- a/app/services/main.py +++ /dev/null @@ -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""" -
-
- {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 - - -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") diff --git a/app/services/report_generator.py b/app/services/report_generator.py new file mode 100644 index 0000000..a1f6ced --- /dev/null +++ b/app/services/report_generator.py @@ -0,0 +1,318 @@ +""" +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 + +import pandas as pd +from jinja2 import Environment, FileSystemLoader +from playwright.sync_api import sync_playwright + +from app.services.context import context_list +from app.services.graph_generator import GraphGenerator + + +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", + ): + """ + 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 + """ + self.template_dir = template_dir + self.graphs_dir = Path(graphs_dir) + self.reports_dir = Path(reports_dir) + self.graph_generator = GraphGenerator(charts_dir=str(graphs_dir)) + self.env = Environment(loader=FileSystemLoader(template_dir)) + + # Ensure directories exist + self.graphs_dir.mkdir(exist_ok=True) + self.reports_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=";") + 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() + ) + + 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]) -> str: + """ + Generate HTML content for the report. + + Args: + patient_info: Dictionary containing patient information + (patient_name, age, height, weight, focus) + + 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"), + } + + # Footer context + footer_context = [ + { + "contact_email": "info@ishplabs.com", + "website": "www.ishplabs.com", + "social": "@ishplabs", + "page_number": i + 1, + } + for i in range(len(context_list)) + ] + + # 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 + for i, context in enumerate(context_list): + template = self.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} + + + """ + + return html_doc + + 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 + """ + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.set_content(html_content) + page.pdf(path=pdf_path, format="A4", print_background=True) + browser.close() + + def generate_report( + self, + spirometry_pdf_path: str, + pnoe_csv_path: str, + seca_excel_path: str, + patient_info: Dict[str, Any], + output_filename: str = None, + ) -> Dict[str, Any]: + """ + Generate complete medical report from uploaded files. + + 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 + """ + # Process data + df = self.process_pnoe_data(pnoe_csv_path) + + # Generate graphs + graphs_generated = self.generate_graphs(df) + + # Calculate analysis metrics + analysis_data = self.calculate_analysis_metrics(df) + analysis_data["graphs_count"] = len(graphs_generated) + + # Generate HTML + html_content = self.generate_html(patient_info) + + # Generate PDF + if output_filename is None: + patient_name = patient_info.get("patient_name", "Unknown") + session_id = patient_info.get("session_id", "default") + output_filename = ( + f"report_{patient_name.replace(' ', '_')}_{session_id}.pdf" + ) + + report_path = self.reports_dir / output_filename + self.html_to_pdf(html_content, str(report_path)) + + return { + "report_path": str(report_path), + "graphs_generated": graphs_generated, + "analysis_data": analysis_data, + } diff --git a/app/services/spirometry_table_extractor.py b/app/services/spirometry_table_extractor.py new file mode 100644 index 0000000..79f3901 --- /dev/null +++ b/app/services/spirometry_table_extractor.py @@ -0,0 +1,64 @@ +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): + 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 the values in csv format, " + "note that it is the unit of parameter that is beside it and it should not be a column. " + "The '-' Should be treated as empty values." + "do not add 'csv' at the start or end of the response", + }, + { + "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"] + + # Save to a CSV file + output_file = "extracted_spirometry_table.csv" + with open(output_file, "w", encoding="utf-8") as f: + f.write(content) + + return f"Extracted table saved to {output_file}" + else: + return "No content found in response" diff --git a/app/services_dfdf/__init__.py b/app/services_dfdf/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/analysis.ipynb b/app/services_dfdf/analysis.ipynb similarity index 100% rename from app/services/analysis.ipynb rename to app/services_dfdf/analysis.ipynb diff --git a/app/services_dfdf/context_generator.py b/app/services_dfdf/context_generator.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/notebook.ipynb b/app/services_dfdf/notebook.ipynb similarity index 100% rename from app/services/notebook.ipynb rename to app/services_dfdf/notebook.ipynb diff --git a/app/services_dfdf/report_generator.py b/app/services_dfdf/report_generator.py new file mode 100644 index 0000000..a1f6ced --- /dev/null +++ b/app/services_dfdf/report_generator.py @@ -0,0 +1,318 @@ +""" +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 + +import pandas as pd +from jinja2 import Environment, FileSystemLoader +from playwright.sync_api import sync_playwright + +from app.services.context import context_list +from app.services.graph_generator import GraphGenerator + + +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", + ): + """ + 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 + """ + self.template_dir = template_dir + self.graphs_dir = Path(graphs_dir) + self.reports_dir = Path(reports_dir) + self.graph_generator = GraphGenerator(charts_dir=str(graphs_dir)) + self.env = Environment(loader=FileSystemLoader(template_dir)) + + # Ensure directories exist + self.graphs_dir.mkdir(exist_ok=True) + self.reports_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=";") + 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() + ) + + 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]) -> str: + """ + Generate HTML content for the report. + + Args: + patient_info: Dictionary containing patient information + (patient_name, age, height, weight, focus) + + 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"), + } + + # Footer context + footer_context = [ + { + "contact_email": "info@ishplabs.com", + "website": "www.ishplabs.com", + "social": "@ishplabs", + "page_number": i + 1, + } + for i in range(len(context_list)) + ] + + # 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 + for i, context in enumerate(context_list): + template = self.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} + + + """ + + return html_doc + + 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 + """ + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.set_content(html_content) + page.pdf(path=pdf_path, format="A4", print_background=True) + browser.close() + + def generate_report( + self, + spirometry_pdf_path: str, + pnoe_csv_path: str, + seca_excel_path: str, + patient_info: Dict[str, Any], + output_filename: str = None, + ) -> Dict[str, Any]: + """ + Generate complete medical report from uploaded files. + + 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 + """ + # Process data + df = self.process_pnoe_data(pnoe_csv_path) + + # Generate graphs + graphs_generated = self.generate_graphs(df) + + # Calculate analysis metrics + analysis_data = self.calculate_analysis_metrics(df) + analysis_data["graphs_count"] = len(graphs_generated) + + # Generate HTML + html_content = self.generate_html(patient_info) + + # Generate PDF + if output_filename is None: + patient_name = patient_info.get("patient_name", "Unknown") + session_id = patient_info.get("session_id", "default") + output_filename = ( + f"report_{patient_name.replace(' ', '_')}_{session_id}.pdf" + ) + + report_path = self.reports_dir / output_filename + self.html_to_pdf(html_content, str(report_path)) + + return { + "report_path": str(report_path), + "graphs_generated": graphs_generated, + "analysis_data": analysis_data, + }