Compare commits

...

11 Commits

Author SHA1 Message Date
bolade 0090b7002c feat: Remove deprecated body fat percentage chart and integrate master chart for report generation
- Deleted the old body fat percentage chart image.
- Updated report generation to load the new body fat percentage master chart for improved accuracy and consistency.
- Refactored context generation to reference the new chart in the report structure.
2025-11-18 17:15:22 +01:00
bolade 7e985c497e feat: Enhance medical report generation with new features and improved data handling
- Added body fat percentage input and optional muscle oxygenation CSV upload in the upload form.
- Implemented TSI chart generation based on muscle oxygenation data.
- Updated report generation to include metabolism and fuel source charts.
- Refactored context generation to eliminate reliance on SECA data, using patient info directly instead.
- Improved error handling and logging for graph generation processes.
- Enhanced HTML templates for better user experience and functionality.
2025-11-18 16:57:39 +01:00
bolade 83f50882e2 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
2025-11-17 17:15:44 +01:00
bolade 4f97691ff9 feat: Improve report generation performance by optimizing data processing and enhancing CSV handling for spirometry tables 2025-11-08 19:49:57 +01:00
bolade 0a735d88c8 feat: Refactor report generation to use async methods and improve error handling; enhance spirometry table extraction with better CSV formatting 2025-10-04 10:35:02 +01:00
bolade 358898b7db feat: Enhance context generation and report generation services with improved data handling and structure 2025-10-04 10:25:10 +01:00
bolade d66f3fd18b Add compiled Python bytecode for report generator and spirometry table extractor services
- Generated bytecode for report_generator.py and spirometry_table_extractor.py
- These changes include the compiled .pyc files in the __pycache__ directory
- The report generator service handles the generation of medical reports from uploaded files
- The spirometry table extractor service extracts data from PDF files and processes it for further analysis
2025-10-04 10:07:40 +01:00
bolade 14dc64234d feat: Update patient name extraction and enhance page context generation in PageGenerator 2025-10-03 22:58:20 +01:00
bolade 7a67aac678 feat: Add PageGenerator class for generating report pages with patient data 2025-10-03 22:16:45 +01:00
bolade 11ee6b192f 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.
2025-10-03 21:41:00 +01:00
bolade 1d8136d6ad Refactor code structure for improved readability and maintainability 2025-10-03 19:19:39 +01:00
58 changed files with 5371 additions and 2723 deletions
+11 -1
View File
@@ -1,3 +1,13 @@
.venv
data/
data/
.env
/graphs
/data
/reports
/temp
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

+631
View File
@@ -0,0 +1,631 @@
"""
FastAPI application for medical report generation.
This API provides a single endpoint that accepts all required files
and patient information, then generates a comprehensive medical report.
"""
import os
import shutil
import tempfile
import uuid
from pathlib import Path
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from jinja2 import Environment, FileSystemLoader
from pydantic import BaseModel
from services.report_generator import ReportGeneratorService
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.sessions import SessionMiddleware
from starlette.requests import Request as StarletteRequest
app = FastAPI(
title="Medical Report Generation API",
description="API for generating medical performance reports with analysis and graphs",
version="2.0.0",
)
# Add session middleware
app.add_middleware(
SessionMiddleware,
secret_key=os.getenv("SECRET_KEY", "your-secret-key-change-in-production"),
)
# Add security headers middleware to allow external scripts
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: StarletteRequest, call_next):
response = await call_next(request)
# Allow external scripts and styles (for Tailwind CDN)
# Only add CSP for HTML responses
content_type = response.headers.get("content-type", "").lower()
if "text/html" in content_type:
response.headers["Content-Security-Policy"] = (
"default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https:; style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https:; img-src 'self' data: https:;"
)
return response
app.add_middleware(SecurityHeadersMiddleware)
# Mount static files (if static directory exists)
static_dir = Path("static")
if static_dir.exists():
app.mount("/static", StaticFiles(directory="static"), name="static")
# Setup templates
jinja_env = Environment(loader=FileSystemLoader("app/templates"))
# Define output directories
GRAPHS_DIR = Path("graphs")
GRAPHS_DIR.mkdir(exist_ok=True)
REPORTS_DIR = Path("reports")
REPORTS_DIR.mkdir(exist_ok=True)
TEMP_DIR = Path("temp")
TEMP_DIR.mkdir(exist_ok=True)
# Initialize report generator service
report_service = ReportGeneratorService(
template_dir="app/report_gen",
graphs_dir=str(GRAPHS_DIR),
reports_dir=str(REPORTS_DIR),
)
class ReportResponse(BaseModel):
message: str
report_path: str
graphs_generated: list
analysis_data: dict
def render_template(template_name: str, context: dict) -> HTMLResponse:
"""Helper function to render Jinja2 templates"""
template = jinja_env.get_template(template_name)
html_content = template.render(**context)
return HTMLResponse(content=html_content, media_type="text/html")
@app.get("/", response_class=HTMLResponse)
async def root(request: Request):
"""Root endpoint - Upload form page"""
return render_template(
"upload.html", {"request": request, "session": request.session}
)
@app.post("/upload")
async def upload_files(
request: Request,
first_name: str = Form(...),
last_name: str = Form(...),
age: int = Form(...),
height: str = Form(...),
weight: str = Form(...),
gender: str = Form(...),
fat_percentage: float = Form(...),
focus: str = Form(default="Endurance"),
session_id: str = Form(default="default"),
spirometry_pdf: UploadFile = File(...),
pnoe_csv: UploadFile = File(...),
oxygenation_csv: UploadFile = File(None),
):
"""Handle file upload and generate report"""
# Validate file types
if not spirometry_pdf.filename.endswith(".pdf"):
return render_template(
"upload.html",
{
"request": request,
"session": request.session,
"error": "Spirometry file must be a PDF",
},
)
if not pnoe_csv.filename.endswith(".csv"):
return render_template(
"upload.html",
{
"request": request,
"session": request.session,
"error": "Pnoe file must be a CSV",
},
)
# Validate oxygenation CSV if provided
if oxygenation_csv and oxygenation_csv.filename:
if not oxygenation_csv.filename.endswith(".csv"):
return render_template(
"upload.html",
{
"request": request,
"session": request.session,
"error": "Oxygenation file must be a CSV",
},
)
# Create session-specific temp directory
session_uuid = str(uuid.uuid4())
session_temp_dir = TEMP_DIR / session_uuid
session_temp_dir.mkdir(exist_ok=True, parents=True)
# Save uploaded files
spirometry_path = session_temp_dir / f"spirometry_{spirometry_pdf.filename}"
pnoe_path = session_temp_dir / f"pnoe_{pnoe_csv.filename}"
oxygenation_path = None
try:
# Write files
with open(spirometry_path, "wb") as f:
shutil.copyfileobj(spirometry_pdf.file, f)
with open(pnoe_path, "wb") as f:
shutil.copyfileobj(pnoe_csv.file, f)
# Save oxygenation CSV if provided
if oxygenation_csv and oxygenation_csv.filename:
oxygenation_path = (
session_temp_dir / f"oxygenation_{oxygenation_csv.filename}"
)
with open(oxygenation_path, "wb") as f:
shutil.copyfileobj(oxygenation_csv.file, f)
# Prepare patient information
patient_name = f"{first_name} {last_name}"
patient_info = {
"patient_name": patient_name,
"first_name": first_name,
"last_name": last_name,
"age": age,
"height": height,
"weight": weight,
"gender": gender,
"fat_percentage": fat_percentage,
"focus": focus,
"session_id": session_id,
}
# Generate report
oxygenation_csv_path = str(oxygenation_path) if oxygenation_path else None
result = await report_service.generate_report(
spirometry_pdf_path=str(spirometry_path),
pnoe_csv_path=str(pnoe_path),
patient_info=patient_info,
oxygenation_csv_path=oxygenation_csv_path,
)
# Store in session
request.session["patient_info"] = patient_info
request.session["temp_dir"] = str(session_temp_dir)
request.session["report_path"] = result["report_path"]
request.session["graphs_generated"] = result["graphs_generated"]
request.session["analysis_data"] = result["analysis_data"]
# Extract spirometry CSV path (it's saved in data_dir by the service)
from pathlib import Path as PathLib
from services.context_generator import ContextGenerator
from services.spirometry_table_extractor import (
extract_spirometry_table_from_pdf,
)
# The spirometry CSV is extracted during report generation
# We need to find it or extract it again
data_dir = PathLib("data")
spirometry_csv_path = (
data_dir / f"spirometry_{Path(spirometry_pdf.filename).stem}.csv"
)
# If it doesn't exist, extract it
if not spirometry_csv_path.exists():
spirometry_csv_path = extract_spirometry_table_from_pdf(
str(spirometry_path), output_dir=str(data_dir)
)
spirometry_csv_path = PathLib(spirometry_csv_path)
# Get calculated metrics for display and editing
context_gen = ContextGenerator()
context_gen.load_data(
str(pnoe_path),
str(spirometry_csv_path),
None, # No SECA file needed anymore
)
# Set patient info manually since we're not reading from SECA
weight_kg = float(weight.replace("lbs", "").replace("kg", "").strip())
if "lbs" in weight.lower():
weight_kg = weight_kg / 2.20462 # Convert lbs to kg
context_gen.patient_info = {
"name": first_name,
"last_name": last_name,
"age": age,
"weight": weight_kg,
"fat_percentage": fat_percentage,
"gender": gender,
}
spirometry_metrics = context_gen.calculate_spirometry_metrics()
pnoe_metrics = context_gen.calculate_pnoe_metrics()
# Store metrics in session
request.session["metrics"] = {
"spirometry": spirometry_metrics,
"pnoe": pnoe_metrics,
}
request.session["spirometry_csv_path"] = str(spirometry_csv_path)
return RedirectResponse(url="/preview", status_code=303)
except Exception as e:
import traceback
error_details = traceback.format_exc()
print(f"ERROR: {error_details}")
return render_template(
"upload.html",
{
"request": request,
"session": request.session,
"error": f"Error generating report: {str(e)}",
},
)
finally:
# Close file handles
spirometry_pdf.file.close()
pnoe_csv.file.close()
if oxygenation_csv and oxygenation_csv.filename:
oxygenation_csv.file.close()
@app.get("/preview", response_class=HTMLResponse)
async def preview(request: Request):
"""Preview generated report"""
if not request.session.get("report_path"):
return RedirectResponse(url="/", status_code=303)
return render_template(
"preview.html", {"request": request, "session": request.session}
)
@app.get("/graphs/{filename}")
async def serve_graph(filename: str):
"""Serve graph images"""
graph_path = GRAPHS_DIR / filename
if not graph_path.exists():
raise HTTPException(status_code=404, detail="Graph not found")
return FileResponse(path=graph_path, media_type="image/png")
@app.get("/edit", response_class=HTMLResponse)
async def edit_form(request: Request):
"""Display edit metrics form"""
if not request.session.get("metrics"):
return RedirectResponse(url="/", status_code=303)
return render_template(
"edit.html", {"request": request, "session": request.session}
)
@app.post("/edit")
async def edit_metrics(request: Request):
"""Handle metric edits and regenerate report"""
if not request.session.get("temp_dir") or not request.session.get("patient_info"):
return RedirectResponse(url="/", status_code=303)
# Get form data
form_data = await request.form()
# Build metric overrides
metric_overrides = {"pnoe": {}, "spirometry": {}}
# Pnoe overrides
if form_data.get("vo2_max"):
metric_overrides["pnoe"]["vo2_max"] = float(form_data["vo2_max"])
if form_data.get("vo2_max_per_kg"):
metric_overrides["pnoe"]["vo2_max_per_kg"] = float(form_data["vo2_max_per_kg"])
if form_data.get("peak_vt"):
metric_overrides["pnoe"]["peak_vt"] = float(form_data["peak_vt"])
if form_data.get("peak_vt_hr"):
metric_overrides["pnoe"]["peak_vt_hr"] = float(form_data["peak_vt_hr"])
if form_data.get("fat_max_value"):
metric_overrides["pnoe"]["fat_max_value"] = float(form_data["fat_max_value"])
if form_data.get("fat_max_hr"):
metric_overrides["pnoe"]["fat_max_hr"] = float(form_data["fat_max_hr"])
# VT1 and VT2 overrides
if (
form_data.get("vt1_hr")
or form_data.get("vt1_speed")
or form_data.get("vt1_time")
):
metric_overrides["pnoe"]["vt1"] = {
"HeartRate": float(form_data.get("vt1_hr", 0)),
"Speed": float(form_data.get("vt1_speed", 0)),
"Time": float(form_data.get("vt1_time", 0)),
}
if (
form_data.get("vt2_hr")
or form_data.get("vt2_speed")
or form_data.get("vt2_time")
):
metric_overrides["pnoe"]["vt2"] = {
"HeartRate": float(form_data.get("vt2_hr", 0)),
"Speed": float(form_data.get("vt2_speed", 0)),
"Time": float(form_data.get("vt2_time", 0)),
}
# Heart rate zones
for i in range(1, 6):
zone_key = f"zone{i}_bpm"
if form_data.get(zone_key):
metric_overrides["pnoe"][zone_key] = form_data[zone_key]
# Spirometry overrides
if form_data.get("fvc_best"):
metric_overrides["spirometry"]["fvc_best"] = float(form_data["fvc_best"])
if form_data.get("fvc_pred"):
metric_overrides["spirometry"]["fvc_pred"] = float(form_data["fvc_pred"])
if form_data.get("fev1_best"):
metric_overrides["spirometry"]["fev1_best"] = float(form_data["fev1_best"])
if form_data.get("fev1_pred"):
metric_overrides["spirometry"]["fev1_pred"] = float(form_data["fev1_pred"])
if form_data.get("fev1_fvc_pct_best"):
metric_overrides["spirometry"]["fev1_fvc_pct_best"] = float(
form_data["fev1_fvc_pct_best"]
)
if form_data.get("fev1_fvc_pct_pred"):
metric_overrides["spirometry"]["fev1_fvc_pct_pred"] = float(
form_data["fev1_fvc_pct_pred"]
)
try:
# Get file paths from session
temp_dir = Path(request.session["temp_dir"])
patient_info = request.session["patient_info"]
# Find files in temp directory
spirometry_path = None
pnoe_path = None
oxygenation_path = None
for file_path in temp_dir.iterdir():
if file_path.name.startswith("spirometry_"):
spirometry_path = file_path
elif file_path.name.startswith("pnoe_"):
pnoe_path = file_path
elif file_path.name.startswith("oxygenation_"):
oxygenation_path = file_path
if not all([spirometry_path, pnoe_path]):
raise ValueError("Could not find all required uploaded files")
# Regenerate report with overrides
oxygenation_csv_path = str(oxygenation_path) if oxygenation_path else None
result = await report_service.generate_report(
spirometry_pdf_path=str(spirometry_path),
pnoe_csv_path=str(pnoe_path),
patient_info=patient_info,
metric_overrides=metric_overrides
if (metric_overrides["pnoe"] or metric_overrides["spirometry"])
else None,
oxygenation_csv_path=oxygenation_csv_path,
)
# Update session with new report
request.session["report_path"] = result["report_path"]
request.session["graphs_generated"] = result["graphs_generated"]
request.session["analysis_data"] = result["analysis_data"]
# Recalculate metrics with overrides
from services.context_generator import ContextGenerator
context_gen = ContextGenerator()
spirometry_csv_path = request.session.get("spirometry_csv_path", "")
if not spirometry_csv_path or not Path(spirometry_csv_path).exists():
from pathlib import Path as PathLib
from services.spirometry_table_extractor import (
extract_spirometry_table_from_pdf,
)
data_dir = PathLib("data")
spirometry_csv_path = extract_spirometry_table_from_pdf(
str(spirometry_path), output_dir=str(data_dir)
)
spirometry_csv_path = str(PathLib(spirometry_csv_path))
context_gen.load_data(
str(pnoe_path),
spirometry_csv_path,
None, # No SECA file
)
# Set patient info manually
weight_str = patient_info.get("weight", "0")
weight_kg = float(weight_str.replace("lbs", "").replace("kg", "").strip())
if "lbs" in weight_str.lower():
weight_kg = weight_kg / 2.20462 # Convert lbs to kg
context_gen.patient_info = {
"name": patient_info.get("first_name", ""),
"last_name": patient_info.get("last_name", ""),
"age": patient_info.get("age", 25),
"weight": weight_kg,
"fat_percentage": patient_info.get("fat_percentage", 0),
"gender": patient_info.get("gender", "female"),
}
context_gen.extract_patient_info(patient_info.get("last_name", ""))
spirometry_overrides = metric_overrides.get("spirometry", {})
pnoe_overrides = metric_overrides.get("pnoe", {})
spirometry_metrics = context_gen.calculate_spirometry_metrics(
spirometry_overrides
)
pnoe_metrics = context_gen.calculate_pnoe_metrics(pnoe_overrides)
# Update metrics in session
request.session["metrics"] = {
"spirometry": spirometry_metrics,
"pnoe": pnoe_metrics,
}
request.session["spirometry_csv_path"] = spirometry_csv_path
return RedirectResponse(url="/preview", status_code=303)
except Exception as e:
import traceback
error_details = traceback.format_exc()
print(f"ERROR: {error_details}")
return render_template(
"edit.html",
{
"request": request,
"session": request.session,
"error": f"Error regenerating report: {str(e)}",
},
)
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy", "service": "report-generation-api"}
@app.post("/generate-report", response_model=ReportResponse)
async def generate_report(
patient_name: str = Form(..., description="Patient name"),
age: int = Form(..., description="Patient age"),
height: str = Form(..., description="Patient height (e.g., 5'4\")"),
weight: str = Form(..., description="Patient weight (e.g., 123lbs)"),
focus: str = Form(default="Endurance", description="Training focus"),
session_id: str = Form(default="default", description="Session ID"),
spirometry_pdf: UploadFile = File(..., description="Spirometry PDF file"),
pnoe_csv: UploadFile = File(..., description="Pnoe CSV file"),
seca_excel: UploadFile = File(..., description="SECA Excel file"),
):
"""
Generate a comprehensive medical report from uploaded files.
This endpoint accepts all required files and patient information,
processes the data, generates graphs, and returns a PDF report.
Args:
spirometry_pdf: Spirometry PDF file
pnoe_csv: Pnoe CSV data file
seca_excel: SECA body composition Excel file
patient_name: Name of the patient
age: Patient age
height: Patient height
weight: Patient weight
focus: Training focus (default: Endurance)
session_id: Session identifier (default: default)
Returns:
ReportResponse with report path, graphs generated, and analysis data
"""
# Validate file types
if not spirometry_pdf.filename.endswith(".pdf"):
raise HTTPException(status_code=400, detail="Spirometry file must be a PDF")
if not pnoe_csv.filename.endswith(".csv"):
raise HTTPException(status_code=400, detail="Pnoe file must be a CSV")
if not seca_excel.filename.endswith((".xlsx", ".xls")):
raise HTTPException(
status_code=400, detail="SECA file must be an Excel file (.xlsx or .xls)"
)
# Create temporary directory for uploaded files
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Save uploaded files temporarily
spirometry_path = temp_path / f"spirometry_{spirometry_pdf.filename}"
pnoe_path = temp_path / f"pnoe_{pnoe_csv.filename}"
seca_path = temp_path / f"seca_{seca_excel.filename}"
try:
# Write files
with open(spirometry_path, "wb") as f:
shutil.copyfileobj(spirometry_pdf.file, f)
with open(pnoe_path, "wb") as f:
shutil.copyfileobj(pnoe_csv.file, f)
with open(seca_path, "wb") as f:
shutil.copyfileobj(seca_excel.file, f)
# Prepare patient information
patient_info = {
"patient_name": patient_name,
"age": age,
"height": height,
"weight": weight,
"focus": focus,
"session_id": session_id,
}
# Generate report using the service
result = await report_service.generate_report(
spirometry_pdf_path=str(spirometry_path),
pnoe_csv_path=str(pnoe_path),
seca_excel_path=str(seca_path),
patient_info=patient_info,
)
return ReportResponse(
message="Report generated successfully",
report_path=result["report_path"],
graphs_generated=result["graphs_generated"],
analysis_data=result["analysis_data"],
)
except Exception as e:
import traceback
error_details = traceback.format_exc()
print(f"ERROR: {error_details}") # This will show in terminal
raise HTTPException(
status_code=500,
detail=f"Error generating report: {str(e)}\n{error_details}",
)
finally:
# Close file handles
spirometry_pdf.file.close()
pnoe_csv.file.close()
seca_excel.file.close()
@app.get("/download-report/{filename}")
async def download_report(filename: str):
"""
Download a generated report.
Args:
filename: Name of the report file
Returns:
PDF file
"""
file_path = REPORTS_DIR / filename
if not file_path.exists():
raise HTTPException(status_code=404, detail="Report not found")
return FileResponse(
path=file_path,
media_type="application/pdf",
filename=filename,
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
@@ -26,7 +26,7 @@
<!-- Name and Date Section -->
<div class="text-right mt-16">
<h2 class="text-4xl font-bold tracking-wider mb-2">
{{ name|upper }}
{{ first_name|upper }}
</h2>
<h2 class="text-4xl font-bold tracking-wider mb-6">
{{ surname|upper }}
Binary file not shown.
+777
View File
@@ -0,0 +1,777 @@
"""
Context Generator Service
This service processes all data files and generates context dictionaries for each page
of the medical report. It performs analysis on Pnoe, Spirometry, and SECA data.
"""
from datetime import datetime
from typing import Dict, Optional, Tuple
import pandas as pd
class ContextGenerator:
"""Generate context data for report pages"""
def __init__(self):
self.pnoe_df = None
self.spirometry_df = None
self.seca_df = None
self.patient_info = {}
def load_data(
self,
pnoe_path: str,
spirometry_path: str,
seca_path: Optional[str] = None,
):
"""Load all required datasets"""
self.pnoe_df = pd.read_csv(pnoe_path, delimiter=";")
self.spirometry_df = pd.read_csv(spirometry_path)
if seca_path:
self.seca_df = pd.read_excel(seca_path)
else:
self.seca_df = None
self._preprocess_pnoe_data()
def _preprocess_pnoe_data(self):
"""Apply preprocessing steps to Pnoe data"""
# Convert numeric columns
for col in self.pnoe_df.columns:
try:
self.pnoe_df[col] = pd.to_numeric(self.pnoe_df[col])
except (ValueError, TypeError):
pass
self.pnoe_df["VO2 Pulse"] = (
self.pnoe_df["VO2(ml/min)"] / self.pnoe_df["HR(bpm)"]
)
self.pnoe_df["VO2 Breath"] = (
self.pnoe_df["VO2(ml/min)"] / self.pnoe_df["BF(bpm)"]
)
self.pnoe_df["CHO"] = (
self.pnoe_df["EE(kcal/min)"] * self.pnoe_df["CARBS(%)"] / 100
)
self.pnoe_df["FAT"] = (
self.pnoe_df["EE(kcal/min)"] * self.pnoe_df["FAT(%)"] / 100
)
window_size = 10
columns_to_smooth = [
"VO2(ml/min)",
"VCO2(ml/min)",
"HR(bpm)",
"VT(l)",
"BF(bpm)",
"VE(l/min)",
"VO2 Pulse",
"VO2 Breath",
"CHO",
"FAT",
]
for col in columns_to_smooth:
if col in self.pnoe_df.columns:
self.pnoe_df[f"{col}_smoothed"] = (
self.pnoe_df[col].rolling(window=window_size, min_periods=1).mean()
)
def extract_patient_info(self, patient_name: str) -> Dict:
"""Extract patient information from SECA dataset or use provided patient_info"""
if self.seca_df is not None:
patient_data = self.seca_df[
self.seca_df["LastName"].str.contains(
patient_name, case=False, na=False
)
]
if not patient_data.empty:
row = patient_data.iloc[0]
weight_kg = float(row.get("Weight", 0))
fat_pct = float(row.get("Adult_FMP", 0))
self.patient_info = {
"name": f"{row.get('FirstName', '')} {row.get('LastName', '')}",
"first_name": row.get("FirstName", ""),
"last_name": row.get("LastName", ""),
"age": int(row.get("Age", 0)),
"height": f"{row.get('Height', '')}",
"weight": weight_kg,
"gender": row.get("Gender", "").lower(),
"fat_percentage": fat_pct,
"fat_mass_lbs": weight_kg * fat_pct / 100 * 2.20462,
"lean_mass_lbs": weight_kg * (1 - fat_pct / 100) * 2.20462,
}
# If patient_info is already set (from manual input), calculate fat_mass and lean_mass
elif "weight" in self.patient_info and "fat_percentage" in self.patient_info:
weight_kg = self.patient_info["weight"]
fat_pct = self.patient_info["fat_percentage"]
self.patient_info["fat_mass_lbs"] = weight_kg * fat_pct / 100 * 2.20462
self.patient_info["lean_mass_lbs"] = (
weight_kg * (1 - fat_pct / 100) * 2.20462
)
return self.patient_info
def calculate_spirometry_metrics(
self, metric_overrides: Optional[Dict] = None
) -> Dict:
"""Calculate spirometry-related metrics"""
if metric_overrides is None:
metric_overrides = {}
metrics = {}
for param in ["FVC", "FEV1", "FEV1/FVC%"]:
param_key = param.lower().replace("/", "_").replace("%", "_pct")
if f"{param_key}_best" in metric_overrides:
metrics[f"{param_key}_best"] = float(
metric_overrides[f"{param_key}_best"]
)
else:
row = self.spirometry_df.loc[
self.spirometry_df["Parameters"].str.strip() == param
]
if not row.empty:
value = row["Best"].values[0]
if pd.notna(value):
try:
metrics[f"{param_key}_best"] = float(value)
except (ValueError, TypeError):
pass # Skip if conversion fails
if f"{param_key}_pred" in metric_overrides:
metrics[f"{param_key}_pred"] = float(
metric_overrides[f"{param_key}_pred"]
)
else:
row = self.spirometry_df.loc[
self.spirometry_df["Parameters"].str.strip() == param
]
if not row.empty:
value = row["%Pred."].values[0]
if pd.notna(value):
try:
metrics[f"{param_key}_pred"] = float(value)
except (ValueError, TypeError):
pass # Skip if conversion fails
return metrics
def calculate_pnoe_metrics(self, metric_overrides: Optional[Dict] = None) -> Dict:
"""Calculate all Pnoe-derived metrics"""
if metric_overrides is None:
metric_overrides = {}
metrics = {}
# VO2 Max metrics
if "vo2_max" in metric_overrides:
metrics["vo2_max"] = float(metric_overrides["vo2_max"])
else:
metrics["vo2_max"] = self.pnoe_df["VO2(ml/min)_smoothed"].max()
if "vo2_max_per_kg" in metric_overrides:
metrics["vo2_max_per_kg"] = float(metric_overrides["vo2_max_per_kg"])
else:
metrics["vo2_max_per_kg"] = metrics["vo2_max"] / self.patient_info["weight"]
# Peak VT metrics
if "peak_vt" in metric_overrides:
metrics["peak_vt"] = float(metric_overrides["peak_vt"])
# Need to get HR from override or calculate
if "peak_vt_hr" in metric_overrides:
metrics["peak_vt_hr"] = float(metric_overrides["peak_vt_hr"])
else:
peak_vt_idx = self.pnoe_df["VT(l)_smoothed"].idxmax()
peak_vt_row = self.pnoe_df.loc[peak_vt_idx]
metrics["peak_vt_hr"] = peak_vt_row["HR(bpm)_smoothed"]
else:
peak_vt_idx = self.pnoe_df["VT(l)_smoothed"].idxmax()
peak_vt_row = self.pnoe_df.loc[peak_vt_idx]
metrics["peak_vt"] = peak_vt_row["VT(l)_smoothed"]
metrics["peak_vt_hr"] = peak_vt_row["HR(bpm)_smoothed"]
# Fat Max metrics
if "fat_max_value" in metric_overrides:
metrics["fat_max_value"] = float(metric_overrides["fat_max_value"])
if "fat_max_hr" in metric_overrides:
metrics["fat_max_hr"] = float(metric_overrides["fat_max_hr"])
else:
fat_max_idx = self.pnoe_df["FAT_smoothed"].idxmax()
fat_max_row = self.pnoe_df.loc[fat_max_idx]
metrics["fat_max_hr"] = fat_max_row["HR(bpm)_smoothed"]
else:
fat_max_idx = self.pnoe_df["FAT_smoothed"].idxmax()
fat_max_row = self.pnoe_df.loc[fat_max_idx]
metrics["fat_max_value"] = fat_max_row["FAT_smoothed"]
metrics["fat_max_hr"] = fat_max_row["HR(bpm)_smoothed"]
# VT1 and VT2 thresholds
if "vt1" in metric_overrides:
metrics["vt1"] = metric_overrides["vt1"]
else:
vt1, _ = self._detect_thresholds()
metrics["vt1"] = vt1
if "vt2" in metric_overrides:
metrics["vt2"] = metric_overrides["vt2"]
else:
_, vt2 = self._detect_thresholds()
metrics["vt2"] = vt2
# Heart rate zones
if any(f"zone{i}_bpm" in metric_overrides for i in range(1, 6)):
for i in range(1, 6):
zone_key = f"zone{i}_bpm"
if zone_key in metric_overrides:
metrics[zone_key] = metric_overrides[zone_key]
else:
fat_max_idx = self.pnoe_df["FAT_smoothed"].idxmax()
fat_max_row = self.pnoe_df.loc[fat_max_idx]
zones = self._calculate_hr_zones(
metrics["vt1"], metrics["vt2"], fat_max_row
)
metrics.update(zones)
return metrics
def _detect_thresholds(self) -> Tuple[Optional[Dict], Optional[Dict]]:
"""Detect VT1 and VT2 thresholds"""
condition = self.pnoe_df["CHO_smoothed"] > self.pnoe_df["FAT_smoothed"]
crossover_indices = condition[condition].index
vt1 = None
if len(crossover_indices) > 0:
vt1_idx = crossover_indices[0]
vt1_row = self.pnoe_df.loc[vt1_idx]
vt1 = {
"HeartRate": vt1_row["HR(bpm)_smoothed"],
"Speed": vt1_row["Speed"],
"Time": vt1_row["T(sec)"],
}
ve_slope = self.pnoe_df["VE(l/min)_smoothed"].diff()
second_derivative = ve_slope.diff()
vt2_idx = second_derivative.idxmax()
vt2 = None
if pd.notna(vt2_idx):
vt2_row = self.pnoe_df.loc[vt2_idx]
vt2 = {
"HeartRate": vt2_row["HR(bpm)_smoothed"],
"Speed": vt2_row["Speed"],
"Time": vt2_row["T(sec)"],
}
return vt1, vt2
def _calculate_hr_zones(
self, vt1: Optional[Dict], vt2: Optional[Dict], fat_max_row: pd.Series
) -> Dict:
"""Calculate heart rate zones based on thresholds"""
zones = {}
if vt1 and vt2:
zone_1_start = fat_max_row["HR(bpm)_smoothed"] - 15
zone_2_start = fat_max_row["HR(bpm)_smoothed"]
zone_3_start = vt1["HeartRate"]
zone_4_start = vt2["HeartRate"] - 10
zone_5_start = vt2["HeartRate"] + 10
zones["zone1_bpm"] = f"{int(zone_1_start)}-{int(zone_2_start)}bpm"
zones["zone2_bpm"] = f"{int(zone_2_start)}-{int(vt1['HeartRate'])}bpm"
zones["zone3_bpm"] = f"{int(zone_3_start)}-{int(zone_4_start)}bpm"
zones["zone4_bpm"] = f"{int(zone_4_start)}-{int(zone_5_start)}bpm"
zones["zone5_bpm"] = f"{int(zone_5_start)}+bpm"
else:
max_hr = 220 - self.patient_info["age"]
zones["zone1_bpm"] = f"{int(max_hr * 0.55)}-{int(max_hr * 0.65)}bpm"
zones["zone2_bpm"] = f"{int(max_hr * 0.65)}-{int(max_hr * 0.75)}bpm"
zones["zone3_bpm"] = f"{int(max_hr * 0.75)}-{int(max_hr * 0.85)}bpm"
zones["zone4_bpm"] = f"{int(max_hr * 0.85)}-{int(max_hr * 0.95)}bpm"
zones["zone5_bpm"] = f"{int(max_hr * 0.95)}+bpm"
return zones
def _calculate_vo2_drop_points(self, pnoe_metrics: Dict) -> Dict:
"""Calculate VO2 Pulse and VO2 Breath drop points"""
# Calculate slope of VO2 Pulse
vo2_pulse_slope = self.pnoe_df["VO2 Pulse_smoothed"].diff()
window = max(1, len(self.pnoe_df) // 3) # Ensure window is at least 1
vo2_pulse_slope_smoothed = vo2_pulse_slope.rolling(
window=window, min_periods=1
).mean()
# Find where VO2 Pulse begins to drop (slope becomes negative)
mask_pulse = vo2_pulse_slope_smoothed <= 0
drop_indices_pulse = mask_pulse[mask_pulse].index
vo2_pulse_drop_bpm = None
vo2_pulse_drop_zone = None
if len(drop_indices_pulse) > 0:
drop_idx = drop_indices_pulse[0]
drop_row = self.pnoe_df.loc[drop_idx]
vo2_pulse_drop_bpm = int(drop_row["HR(bpm)_smoothed"])
# Determine zone based on HR zones
if pnoe_metrics.get("zone1_bpm") and vo2_pulse_drop_bpm:
zones = [pnoe_metrics.get(f"zone{i}_bpm", "") for i in range(1, 6)]
for i, zone_str in enumerate(zones, 1):
if zone_str:
zone_clean = zone_str.replace("bpm", "").strip()
if "-" in zone_clean:
parts = zone_clean.split("-")
if len(parts) == 2:
try:
start, end = (
int(parts[0]),
int(parts[1].replace("+", "")),
)
if start <= vo2_pulse_drop_bpm <= end:
vo2_pulse_drop_zone = f"Zone {i}"
break
except ValueError:
pass
elif "+" in zone_clean:
# Zone 5 format: "180+bpm"
try:
start = int(zone_clean.replace("+", ""))
if vo2_pulse_drop_bpm >= start:
vo2_pulse_drop_zone = f"Zone {i}"
break
except ValueError:
pass
# Calculate slope of VO2 Breath
vo2_breath_slope = self.pnoe_df["VO2 Breath_smoothed"].diff()
vo2_breath_slope_smoothed = vo2_breath_slope.rolling(
window=window, min_periods=1
).mean()
# Find where VO2 Breath begins to drop
mask_breath = vo2_breath_slope_smoothed <= 0
drop_indices_breath = mask_breath[mask_breath].index
vo2_breath_drop_bpm = None
vo2_breath_drop_zone = None
if len(drop_indices_breath) > 0:
drop_idx = drop_indices_breath[0]
drop_row = self.pnoe_df.loc[drop_idx]
vo2_breath_drop_bpm = int(drop_row["HR(bpm)_smoothed"])
# Determine zone
if pnoe_metrics.get("zone1_bpm") and vo2_breath_drop_bpm:
zones = [pnoe_metrics.get(f"zone{i}_bpm", "") for i in range(1, 6)]
for i, zone_str in enumerate(zones, 1):
if zone_str:
zone_clean = zone_str.replace("bpm", "").strip()
if "-" in zone_clean:
parts = zone_clean.split("-")
if len(parts) == 2:
try:
start, end = (
int(parts[0]),
int(parts[1].replace("+", "")),
)
if start <= vo2_breath_drop_bpm <= end:
vo2_breath_drop_zone = f"Zone {i}"
break
except ValueError:
pass
elif "+" in zone_clean:
# Zone 5 format: "180+bpm"
try:
start = int(zone_clean.replace("+", ""))
if vo2_breath_drop_bpm >= start:
vo2_breath_drop_zone = f"Zone {i}"
break
except ValueError:
pass
return {
"vo2_pulse_drop_bpm": vo2_pulse_drop_bpm or 180,
"vo2_pulse_drop_zone": vo2_pulse_drop_zone or "Zone 4",
"vo2_breath_drop_bpm": vo2_breath_drop_bpm or 173,
"vo2_breath_drop_zone": vo2_breath_drop_zone or "Zone 3",
}
def _calculate_fat_metabolism_metrics(self, pnoe_metrics: Dict) -> Dict:
"""Calculate fat metabolism metrics for page 11"""
fat_max_idx = self.pnoe_df["FAT_smoothed"].idxmax()
fat_max_row = self.pnoe_df.loc[fat_max_idx]
fat_max_value = pnoe_metrics.get("fat_max_value", 0)
fat_max_hr = pnoe_metrics.get("fat_max_hr", 0)
max_hr = 220 - self.patient_info["age"]
fat_max_heart_rate_pct = (fat_max_hr / max_hr * 100) if max_hr > 0 else 0
# Find carbs and fat crossover point
crossover_idx = None
for idx in self.pnoe_df.index:
if (
self.pnoe_df.loc[idx, "CHO_smoothed"]
> self.pnoe_df.loc[idx, "FAT_smoothed"]
):
crossover_idx = idx
break
crossover_bpm = None
crossover_heart_rate_pct = None
if crossover_idx is not None:
crossover_row = self.pnoe_df.loc[crossover_idx]
crossover_bpm = int(crossover_row["HR(bpm)_smoothed"])
crossover_heart_rate_pct = (
(crossover_bpm / max_hr * 100) if max_hr > 0 else 0
)
# Get speed and incline at fat max
fat_max_speed = fat_max_row.get("Speed", 0)
fat_max_incline = (
fat_max_row.get("Incline", 2.0) if "Incline" in fat_max_row else 2.0
)
return {
"fat_max_value": f"{fat_max_value:.2f}Kcals/min",
"fat_max_heart_rate": f"{fat_max_heart_rate_pct:.0f}% of Max Heart Rate",
"fat_max_bpm": f"{int(fat_max_hr)} bpm",
"fat_max_optimal": "*Optimal 10-12Kcals/minute",
"crossover_bpm": f"{crossover_bpm or 100}bpm",
"crossover_heart_rate": f"{crossover_heart_rate_pct or 51:.0f}% of Max Heart Rate",
"fat_metabolism_note": f"{crossover_bpm or 100}bpm at a speed of {fat_max_speed:.1f}mph and incline of {fat_max_incline:.0f}%",
}
def _calculate_recovery_metrics(self) -> Dict:
"""Calculate recovery metrics for page 11"""
# Find peak exercise point (max HR)
peak_idx = self.pnoe_df["HR(bpm)_smoothed"].idxmax()
peak_hr = self.pnoe_df.loc[peak_idx, "HR(bpm)_smoothed"]
peak_time = self.pnoe_df.loc[peak_idx, "T(sec)"]
# Find recovery phase (after peak)
recovery_df = self.pnoe_df[self.pnoe_df["T(sec)"] > peak_time].copy()
if len(recovery_df) == 0:
return {
"cardiac_recovery_time": "(1 minute)",
"cardiac_recovery_percentage": "33%",
"metabolic_recovery_time": "(2 minute)",
"metabolic_recovery_percentage": "65%",
"breath_recovery_time": "(2.5 minute)",
"breath_recovery_percentage": "76%",
}
# Cardiac recovery (1 minute)
one_min_time = peak_time + 60
one_min_row = recovery_df[recovery_df["T(sec)"] <= one_min_time]
if len(one_min_row) > 0:
one_min_hr = one_min_row.iloc[-1]["HR(bpm)_smoothed"]
cardiac_recovery_pct = (
((peak_hr - one_min_hr) / peak_hr * 100) if peak_hr > 0 else 0
)
else:
cardiac_recovery_pct = 33
# Metabolic recovery (2 minutes) - using VCO2
two_min_time = peak_time + 120
peak_vco2 = self.pnoe_df.loc[peak_idx, "VCO2(ml/min)_smoothed"]
two_min_row = recovery_df[recovery_df["T(sec)"] <= two_min_time]
if len(two_min_row) > 0:
two_min_vco2 = two_min_row.iloc[-1]["VCO2(ml/min)_smoothed"]
metabolic_recovery_pct = (
((peak_vco2 - two_min_vco2) / peak_vco2 * 100) if peak_vco2 > 0 else 0
)
else:
metabolic_recovery_pct = 65
# Breath frequency recovery (2.5 minutes)
two_five_min_time = peak_time + 150
peak_bf = self.pnoe_df.loc[peak_idx, "BF(bpm)_smoothed"]
two_five_min_row = recovery_df[recovery_df["T(sec)"] <= two_five_min_time]
if len(two_five_min_row) > 0:
two_five_min_bf = two_five_min_row.iloc[-1]["BF(bpm)_smoothed"]
breath_recovery_pct = (
((peak_bf - two_five_min_bf) / peak_bf * 100) if peak_bf > 0 else 0
)
else:
breath_recovery_pct = 76
return {
"cardiac_recovery_time": "(1 minute)",
"cardiac_recovery_percentage": f"{int(cardiac_recovery_pct)}%",
"metabolic_recovery_time": "(2 minute)",
"metabolic_recovery_percentage": f"{int(metabolic_recovery_pct)}%",
"breath_recovery_time": "(2.5 minute)",
"breath_recovery_percentage": f"{int(breath_recovery_pct)}%",
}
def _calculate_resting_heart_rate_metrics(self) -> Dict:
"""Calculate resting heart rate metrics for page 11"""
# Get resting HR from beginning of test
rest_phase = self.pnoe_df.head(30) # First 30 seconds
resting_hr = rest_phase["HR(bpm)_smoothed"].mean()
age = self.patient_info.get("age", 30)
gender = self.patient_info.get("gender", "female").lower()
# Determine age range
if 26 <= age <= 35:
age_range = "26-35"
elif 36 <= age <= 45:
age_range = "36-45"
elif 46 <= age <= 55:
age_range = "46-55"
else:
age_range = "26-35" # Default
# HR ranges based on gender and age (simplified)
if gender == "female":
hr_ranges = {
"poor": "82bpm +",
"below_avg": "75-81bpm",
"average": "71-74bpm",
"above_avg": "66-70bpm",
"good": "62-65bpm",
"excellent": "55-61bpm",
"athlete": "44-54bpm",
}
else: # male
hr_ranges = {
"poor": "82bpm +",
"below_avg": "75-81bpm",
"average": "71-74bpm",
"above_avg": "66-70bpm",
"good": "62-65bpm",
"excellent": "55-61bpm",
"athlete": "44-54bpm",
}
return {
"resting_heart_rate": f"{int(resting_hr)}bpm",
"hr_age_range": age_range,
"hr_poor": hr_ranges["poor"],
"hr_below_avg": hr_ranges["below_avg"],
"hr_average": hr_ranges["average"],
"hr_above_avg": hr_ranges["above_avg"],
"hr_good": hr_ranges["good"],
"hr_excellent": hr_ranges["excellent"],
"hr_athlete": hr_ranges["athlete"],
}
def calculate_rmr_and_fuel_source(self) -> Dict:
"""Calculate RMR and fuel source from pnoe data"""
metrics = {}
# Calculate RMR from resting phase (MET <= 1.1)
if "MET" in self.pnoe_df.columns and "EE(kcal/day)" in self.pnoe_df.columns:
rest_phase = self.pnoe_df[self.pnoe_df["MET"] <= 1.1]
if not rest_phase.empty:
rmr = rest_phase["EE(kcal/day)"].mean()
metrics["rmr_kcal"] = float(rmr)
else:
# Fallback: use minimum EE(kcal/min) * 1440 (minutes per day)
if "EE(kcal/min)" in self.pnoe_df.columns:
min_ee = self.pnoe_df["EE(kcal/min)"].min()
metrics["rmr_kcal"] = float(min_ee * 1440)
else:
metrics["rmr_kcal"] = 1500.0 # Default fallback
else:
# Fallback: estimate from weight (simplified)
weight_kg = self.patient_info.get("weight", 70)
gender = self.patient_info.get("gender", "female").lower()
# Simplified RMR estimation: 22 kcal/kg/day for men, 20 for women
if gender == "male":
rmr = weight_kg * 22
else:
rmr = weight_kg * 20
metrics["rmr_kcal"] = float(rmr)
# Calculate fuel source from resting phase (RER == 0.9 or closest)
if "RER" in self.pnoe_df.columns and "FAT(%)" in self.pnoe_df.columns:
# Find rest phase with RER closest to 0.9
rest_phase = (
self.pnoe_df[self.pnoe_df["RER"] == 0.9].copy()
if "RER" in self.pnoe_df.columns
else self.pnoe_df.copy()
)
if not rest_phase.empty:
# Find row with RER closest to 0.9
if "RER" in rest_phase.columns:
rest_phase["RER_diff"] = abs(rest_phase["RER"] - 0.9)
closest_idx = rest_phase["RER_diff"].idxmin()
fat_pct = rest_phase.loc[closest_idx, "FAT(%)"]
metrics["rest_fat_percentage"] = float(fat_pct)
else:
# Use mean FAT(%) from rest phase
metrics["rest_fat_percentage"] = float(rest_phase["FAT(%)"].mean())
else:
# Fallback: use overall mean
metrics["rest_fat_percentage"] = float(self.pnoe_df["FAT(%)"].mean())
else:
# Fallback: use a default value
metrics["rest_fat_percentage"] = 75.0
# Calculate caloric values for page 5
rmr = metrics["rmr_kcal"]
neat = rmr * 0.25 # NEAT is typically 20-30% of RMR
weight_loss_rate = 1.0 # 1 lb per week
weight_loss_calories = 500.0 # 500 kcal deficit per day for 1 lb/week
total_calories = rmr + neat - weight_loss_calories
metrics["resting_calories"] = int(rmr)
metrics["neat_calories"] = int(neat)
metrics["weight_loss_calories"] = int(weight_loss_calories)
metrics["weight_loss_rate"] = weight_loss_rate
metrics["total_calories"] = int(total_calories)
return metrics
def generate_all_contexts(
self,
patient_name: str,
graphs: Dict[str, str],
metric_overrides: Optional[Dict] = None,
) -> Dict[str, Dict]:
"""Main method to generate all page contexts
Returns:
Dictionary with keys 'page_1', 'page_2', etc., each containing context data for that page
"""
if metric_overrides is None:
metric_overrides = {}
self.extract_patient_info(patient_name)
# Extract metric overrides for spirometry and pnoe
spirometry_overrides = metric_overrides.get("spirometry", {})
pnoe_overrides = metric_overrides.get("pnoe", {})
spirometry_metrics = self.calculate_spirometry_metrics(spirometry_overrides)
pnoe_metrics = self.calculate_pnoe_metrics(pnoe_overrides)
rmr_metrics = self.calculate_rmr_and_fuel_source()
contexts = {}
# Page 1
contexts["page_1"] = {
"name": self.patient_info["name"],
"surname": self.patient_info["last_name"],
"date": datetime.now().strftime("%B %d, %Y"),
}
# Page 2
contexts["page_2"] = {
"patient_name": self.patient_info["name"],
"test_date": datetime.now().strftime("%B %d, %Y"),
}
# Pages 3, 6 (pages 4 and 5 are handled separately)
for i in [0, 3]: # Skip indices 1 and 2 which are pages 4 and 5
contexts[f"page_{i + 3}"] = {
"patient_name": self.patient_info["name"],
"page_number": i + 3,
}
# Page 4 - Nutrition Guidelines with Body Composition
contexts["page_4"] = {
"patient_name": self.patient_info["name"],
"page_number": 4,
"fat_percentage": f"{self.patient_info['fat_percentage']:.1f}",
"body_composition_chart": graphs.get("body_composition", ""),
"body_fat_chart": graphs.get("body_fat_percent", ""), # Alias for template
"body_fat_percent_chart": graphs.get(
"body_fat_percent", ""
), # Keep for consistency
}
# Page 5 - Resting Metabolic Rate Assessment
contexts["page_5"] = {
"patient_name": self.patient_info["name"],
"page_number": 5,
"metabolism_chart": graphs.get("metabolism_chart", ""),
"fuel_source_chart": graphs.get("fuel_source_chart", ""),
"resting_calories": rmr_metrics.get("resting_calories", 1500),
"neat_calories": rmr_metrics.get("neat_calories", 375),
"weight_loss_calories": rmr_metrics.get("weight_loss_calories", 500),
"weight_loss_rate": rmr_metrics.get("weight_loss_rate", 1.0),
"total_calories": rmr_metrics.get("total_calories", 1375),
}
# Calculate FEV1 percentage for page 7
fev1_percentage = 0
if spirometry_metrics.get("fvc_best"):
fev1_percentage = (
pnoe_metrics["peak_vt"] / spirometry_metrics["fvc_best"]
) * 100
# Page 7
contexts["page_7"] = {
"peak_vt": f"{pnoe_metrics['peak_vt']:.2f}",
"peak_vt_bpm": f"{int(pnoe_metrics['peak_vt_hr'])}",
"fev1_percentage": f"{fev1_percentage:.1f}",
"lung_analysis_chart": graphs.get("spirometry_chart", ""),
"respiratory_analysis_chart": graphs.get("respiratory", ""),
}
# Page 8
contexts["page_8"] = {
"vo2_max_value": f"{pnoe_metrics['vo2_max_per_kg']:.1f}",
"age_range": f"{self.patient_info['age'] // 10 * 10}-{self.patient_info['age'] // 10 * 10 + 9}",
"zone1_bpm": pnoe_metrics.get("zone1_bpm", ""),
"zone2_bpm": pnoe_metrics.get("zone2_bpm", ""),
"zone3_bpm": pnoe_metrics.get("zone3_bpm", ""),
"zone4_bpm": pnoe_metrics.get("zone4_bpm", ""),
"zone5_bpm": pnoe_metrics.get("zone5_bpm", ""),
"vo2_pulse_chart": graphs.get("vo2_pulse", ""),
}
# Page 9
contexts["page_9"] = {
"fat_max_value": f"{pnoe_metrics['fat_max_value']:.2f}",
"fat_max_hr": f"{int(pnoe_metrics['fat_max_hr'])}",
"fuel_utilization_chart": graphs.get("fuel_utilization", ""),
"fat_metabolism_chart": graphs.get("fat_metabolism", ""),
}
# Page 10 - VO2 Pulse and VO2 Breath
vo2_drop_metrics = self._calculate_vo2_drop_points(pnoe_metrics)
contexts["page_10"] = {
"vo2_pulse_chart": graphs.get("vo2_pulse", ""),
"vo2_breath_chart": graphs.get("vo2_breath", ""),
"vo2_pulse_drop_bpm": f"{vo2_drop_metrics['vo2_pulse_drop_bpm']} bpm",
"vo2_pulse_drop_zone": vo2_drop_metrics["vo2_pulse_drop_zone"],
"vo2_breath_drop_bpm": f"{vo2_drop_metrics['vo2_breath_drop_bpm']} bpm",
"vo2_breath_drop_zone": vo2_drop_metrics["vo2_breath_drop_zone"],
}
# Page 11 - Fat Metabolism and Recovery
fat_metabolism_metrics = self._calculate_fat_metabolism_metrics(pnoe_metrics)
recovery_metrics = self._calculate_recovery_metrics()
resting_hr_metrics = self._calculate_resting_heart_rate_metrics()
contexts["page_11"] = {
"fat_metabolism_chart": graphs.get("fat_metabolism", ""),
"recovery_chart": graphs.get("recovery", ""),
**fat_metabolism_metrics,
**recovery_metrics,
**resting_hr_metrics,
}
# Pages 12-17
for i in range(6):
contexts[f"page_{i + 12}"] = {
"patient_name": self.patient_info["name"],
"page_number": i + 12,
}
# Page 18 - Glossary with Body Fat Percentage Master Chart
contexts["page_18"] = {
"patient_name": self.patient_info["name"],
"page_number": 18,
"body_fat_percentage_chart": graphs.get(
"body_fat_percentage_master_chart", ""
),
}
# Page 19
contexts["page_19"] = {
"patient_name": self.patient_info["name"],
"page_number": 19,
}
return contexts
File diff suppressed because it is too large Load Diff
+536
View File
@@ -0,0 +1,536 @@
"""
Report Generator Service
This service handles the generation of medical reports from uploaded files.
It processes data, generates graphs, and creates PDF reports.
"""
from pathlib import Path
from typing import Any, Dict, List, Optional
import pandas as pd
from jinja2 import Environment, FileSystemLoader
from playwright.async_api import async_playwright
from services.context_generator import ContextGenerator
from services.graph_generator import GraphGenerator
from services.spirometry_table_extractor import extract_spirometry_table_from_pdf
class ReportGeneratorService:
"""Service for generating medical performance reports"""
def __init__(
self,
template_dir: str = "app/report_gen",
graphs_dir: str = "graphs",
reports_dir: str = "reports",
data_dir: str = "data",
):
"""
Initialize the report generator service.
Args:
template_dir: Directory containing Jinja2 templates
graphs_dir: Directory to save generated graphs
reports_dir: Directory to save generated reports
data_dir: Directory to store extracted/processed data
"""
self.template_dir = template_dir
self.graphs_dir = Path(graphs_dir)
self.reports_dir = Path(reports_dir)
self.data_dir = Path(data_dir)
self.graph_generator = GraphGenerator(charts_dir=str(self.graphs_dir))
self.context_generator = ContextGenerator()
self.env = Environment(loader=FileSystemLoader(template_dir))
# Ensure directories exist
self.graphs_dir.mkdir(exist_ok=True)
self.reports_dir.mkdir(exist_ok=True)
self.data_dir.mkdir(exist_ok=True)
def process_pnoe_data(self, pnoe_csv_path: str) -> pd.DataFrame:
"""
Load and process Pnoe CSV data.
Args:
pnoe_csv_path: Path to Pnoe CSV file
Returns:
Processed DataFrame with smoothed columns
"""
# Load data
df = pd.read_csv(pnoe_csv_path, delimiter=";")
# Convert numeric columns (updated approach)
for col in df.columns:
try:
df[col] = pd.to_numeric(df[col])
except (ValueError, TypeError):
pass # Keep as-is if not numeric
# Calculate derived columns
df["VO2 Pulse"] = df["VO2(ml/min)"] / df["HR(bpm)"]
df["VO2 Breath"] = df["VO2(ml/min)"] / df["BF(bpm)"]
df["CHO"] = df["EE(kcal/min)"] * df["CARBS(%)"] / 100
df["FAT"] = df["EE(kcal/min)"] * df["FAT(%)"] / 100
# Smooth columns
window_size = 10
columns_to_smooth = [
"VO2(ml/min)",
"VCO2(ml/min)",
"HR(bpm)",
"VT(l)",
"BF(bpm)",
"VE(l/min)",
"VO2 Pulse",
"VO2 Breath",
"CHO",
"FAT",
]
for col in columns_to_smooth:
if col in df.columns:
df[f"{col}_smoothed"] = (
df[col].rolling(window=window_size, min_periods=1).mean()
)
return df
def generate_graphs(self, df: pd.DataFrame) -> List[Dict[str, str]]:
"""
Generate all required graphs from processed data.
Args:
df: Processed DataFrame with smoothed columns
Returns:
List of dictionaries containing graph names and paths
"""
graphs_generated = []
# List of graphs to generate
graph_methods = [
("respiratory", self.graph_generator.generate_respiratory_chart),
("fuel_utilization", self.graph_generator.generate_fuel_utilization_chart),
("vo2_pulse", self.graph_generator.generate_vo2_pulse_chart),
("vo2_breath", self.graph_generator.generate_vo2_breath_chart),
("fat_metabolism", self.graph_generator.generate_fat_metabolism_chart),
("recovery", self.graph_generator.generate_recovery_chart),
]
for name, method in graph_methods:
try:
path = method(df, save_as_base64=False)
graphs_generated.append({"name": name, "path": str(path)})
except Exception as e:
print(f"Warning: Could not generate {name} chart: {e}")
return graphs_generated
def calculate_analysis_metrics(self, df: pd.DataFrame) -> Dict[str, Any]:
"""
Calculate basic analysis metrics from processed data.
Args:
df: Processed DataFrame with smoothed columns
Returns:
Dictionary containing analysis metrics
"""
return {
"vo2_max": float(df["VO2(ml/min)_smoothed"].max())
if "VO2(ml/min)_smoothed" in df.columns
else 0,
"peak_vt": float(df["VT(l)_smoothed"].max())
if "VT(l)_smoothed" in df.columns
else 0,
"max_hr": float(df["HR(bpm)_smoothed"].max())
if "HR(bpm)_smoothed" in df.columns
else 0,
}
def generate_html(
self, patient_info: Dict[str, Any], contexts: Dict[str, Dict[str, Any]]
) -> str:
"""
Generate HTML content for the report.
Args:
patient_info: Dictionary containing patient information
(patient_name, age, height, weight, focus)
contexts: Dictionary with keys 'page_1', 'page_2', etc., each containing context data
Returns:
Complete HTML document as string
"""
html_pages = []
# Header context
header_context = {
"patient_name": patient_info.get("patient_name", ""),
"age": patient_info.get("age", ""),
"height": patient_info.get("height", ""),
"weight": patient_info.get("weight", ""),
"focus": patient_info.get("focus", "Endurance"),
}
# Get total number of pages
num_pages = len(contexts)
# Footer context
footer_context = [
{
"contact_email": "info@ishplabs.com",
"website": "www.ishplabs.com",
"social": "@ishplabs",
"page_number": i + 1,
}
for i in range(num_pages)
]
# Render header
header_html = self.env.get_template("header.html").render(header_context)
# Render footers
footer_html_list = [
self.env.get_template("footer.html").render(context)
for context in footer_context
]
# Render pages - iterate through pages in order
for i in range(1, num_pages + 1):
page_key = f"page_{i}"
context = contexts.get(page_key, {})
template = self.env.get_template(f"page_{i}.html").render(context)
if i > 2:
full_html = f"""
<div class="page flex flex-col justify-between">
<div>
{header_html}
</div>
<main class="flex-grow p-4">
{template}
</main>
<div class="border-t text-center text-sm text-gray-600">
{footer_html_list[i - 1]}
</div>
</div>
"""
html_pages.append(full_html)
else:
html_pages.append(template)
# Combine with page breaks
final_html = "<div class='page-break'></div>".join(html_pages)
# Wrap in full HTML document
html_doc = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link href="https://cdn.jsdelivr.net/npm/tailwindcss/dist/tailwind.min.css" rel="stylesheet">
<style>
html, body {{
height: 100%;
margin: 0;
padding: 0;
}}
.page-break {{ page-break-after: always; }}
.page {{
height: 100vh;
min-height: 100vh;
display: flex;
flex-direction: column;
}}
.page main {{
flex: 1;
overflow: hidden;
}}
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
img {{
max-height: 300px;
}}
.chart-large {{
max-height: 500px !important;
}}
</style>
</head>
<body class="m-0 p-0">
{final_html}
</body>
</html>
"""
return html_doc
async def html_to_pdf(self, html_content: str, pdf_path: str) -> None:
"""
Convert HTML content to PDF file.
Args:
html_content: HTML content as string
pdf_path: Path where PDF should be saved
"""
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()
await page.set_content(html_content)
await page.pdf(path=pdf_path, format="A4", print_background=True)
await browser.close()
async def generate_report(
self,
spirometry_pdf_path: str,
pnoe_csv_path: str,
patient_info: Dict[str, Any],
output_filename: str = None,
metric_overrides: Optional[Dict[str, Any]] = None,
oxygenation_csv_path: Optional[str] = None,
) -> Dict[str, Any]:
"""
Generate complete medical report from uploaded files.
This follows the complete workflow:
1. Extract spirometry data from PDF
2. Store all data in data directory
3. Generate all graphs
4. Generate context for each page
5. Generate final HTML and PDF report
Args:
spirometry_pdf_path: Path to Spirometry PDF file
pnoe_csv_path: Path to Pnoe CSV file
seca_excel_path: Path to SECA Excel file
patient_info: Dictionary containing patient information
output_filename: Optional custom output filename
Returns:
Dictionary containing report path, graphs generated, and analysis data
"""
# Step 1: Extract spirometry table from PDF
print("Step 1: Extracting spirometry data from PDF...")
spirometry_csv_path = extract_spirometry_table_from_pdf(
spirometry_pdf_path, output_dir=str(self.data_dir)
)
print(f"Spirometry data saved to: {spirometry_csv_path}")
# Step 2: Process Pnoe data
print("Step 2: Processing Pnoe data...")
df = self.process_pnoe_data(pnoe_csv_path)
# Step 3: Generate all graphs
print("Step 3: Generating graphs...")
graphs_generated = self.generate_graphs(df)
# Create graph dictionary with base64 encoded images
import base64
graphs_dict = {}
for graph in graphs_generated:
# Read the graph file and convert to base64
graph_path = Path(graph["path"])
if graph_path.exists():
with open(graph_path, "rb") as f:
graphs_dict[graph["name"]] = base64.b64encode(f.read()).decode(
"utf-8"
)
# Also generate body composition charts
# Use patient info directly (no SECA file needed)
fat_pct = patient_info.get("fat_percentage", 0)
age = patient_info.get("age", 25)
gender = patient_info.get("gender", "female").lower()
# Convert weight to kg if needed
weight_str = str(patient_info.get("weight", "0"))
# Extract numeric value and unit
weight_str_clean = (
weight_str.replace("lbs", "").replace("kg", "").replace(" ", "").strip()
)
try:
weight_value = float(weight_str_clean)
except ValueError:
print(f"Warning: Could not parse weight '{weight_str}', using default 0")
weight_value = 0.0
# Convert to kg if weight is in lbs
if "lbs" in weight_str.lower():
weight_kg = weight_value / 2.20462 # Convert lbs to kg
else:
weight_kg = weight_value # Already in kg or assume kg if no unit specified
# Calculate fat and lean mass in pounds
fat_mass_lbs = weight_kg * fat_pct / 100 * 2.20462
lean_mass_lbs = weight_kg * (1 - fat_pct / 100) * 2.20462
# Generate body composition chart (save as file first, then convert to base64)
try:
body_comp_path = self.graph_generator.generate_body_composition_chart(
fat_mass_lbs, lean_mass_lbs, save_as_base64=False
)
graphs_generated.append(
{"name": "body_composition", "path": str(body_comp_path)}
)
# Convert to base64 for graphs_dict
with open(body_comp_path, "rb") as f:
graphs_dict["body_composition"] = base64.b64encode(f.read()).decode(
"utf-8"
)
except Exception as e:
print(f"Warning: Could not generate body composition chart: {e}")
graphs_dict["body_composition"] = ""
# Generate body fat percent chart (save as file first, then convert to base64)
try:
body_fat_path = self.graph_generator.generate_body_fat_percent_chart(
fat_pct, age, gender, save_as_base64=False
)
graphs_generated.append(
{"name": "body_fat_percent", "path": str(body_fat_path)}
)
# Convert to base64 for graphs_dict
with open(body_fat_path, "rb") as f:
graphs_dict["body_fat_percent"] = base64.b64encode(f.read()).decode(
"utf-8"
)
except Exception as e:
print(f"Warning: Could not generate body fat percent chart: {e}")
graphs_dict["body_fat_percent"] = ""
# Load static body fat percentage master chart for page 18
master_chart_path = Path("app/body_fat_percentage_master_chart.png")
if master_chart_path.exists():
try:
with open(master_chart_path, "rb") as f:
graphs_dict["body_fat_percentage_master_chart"] = base64.b64encode(
f.read()
).decode("utf-8")
except Exception as e:
print(f"Warning: Could not load body fat percentage master chart: {e}")
graphs_dict["body_fat_percentage_master_chart"] = ""
else:
print(
f"Warning: Body fat percentage master chart not found at {master_chart_path}"
)
graphs_dict["body_fat_percentage_master_chart"] = ""
# Generate spirometry chart
print("Step 4: Generating spirometry chart...")
try:
spirometry_df = pd.read_csv(spirometry_csv_path)
print(f"Spirometry data loaded: {len(spirometry_df)} rows")
print(f"Spirometry columns: {spirometry_df.columns.tolist()}")
if "Parameters" in spirometry_df.columns:
print(f"Available parameters: {spirometry_df['Parameters'].tolist()}")
spirometry_chart_b64 = self.graph_generator.generate_spirometry_chart(
spirometry_df, save_as_base64=True
)
graphs_dict["spirometry_chart"] = spirometry_chart_b64
print("Spirometry chart generated successfully")
except Exception as e:
import traceback
error_details = traceback.format_exc()
print(f"Warning: Could not generate spirometry chart: {e}")
print(f"Error details: {error_details}")
graphs_dict["spirometry_chart"] = ""
# Generate TSI chart if oxygenation CSV is provided
if oxygenation_csv_path:
print("Step 4.5: Generating TSI chart...")
try:
oxygenation_df = pd.read_csv(oxygenation_csv_path)
tsi_chart_b64 = self.graph_generator.generate_tsi_chart(
oxygenation_df, save_as_base64=True
)
graphs_dict["tsi_chart"] = tsi_chart_b64
except Exception as e:
print(f"Warning: Could not generate TSI chart: {e}")
graphs_dict["tsi_chart"] = ""
# Generate metabolism and fuel source charts for page 5
print("Step 4.6: Generating metabolism and fuel source charts...")
try:
# Calculate RMR and fuel source from pnoe data
from services.context_generator import ContextGenerator
temp_context_gen = ContextGenerator()
temp_context_gen.load_data(pnoe_csv_path, str(spirometry_csv_path), None)
temp_context_gen.patient_info = {
"name": patient_info.get("first_name", ""),
"last_name": patient_info.get("last_name", ""),
"age": patient_info.get("age", 25),
"weight": weight_kg,
"fat_percentage": fat_pct,
"gender": gender,
}
rmr_metrics = temp_context_gen.calculate_rmr_and_fuel_source()
# Generate metabolism chart
metabolism_chart_b64 = self.graph_generator.generate_metabolism_chart(
rmr_metrics["rmr_kcal"], save_as_base64=True
)
graphs_dict["metabolism_chart"] = metabolism_chart_b64
# Generate fuel source chart
fuel_source_chart_b64 = self.graph_generator.generate_fuel_source_chart(
rmr_metrics["rest_fat_percentage"], save_as_base64=True
)
graphs_dict["fuel_source_chart"] = fuel_source_chart_b64
except Exception as e:
print(f"Warning: Could not generate metabolism/fuel source charts: {e}")
graphs_dict["metabolism_chart"] = ""
graphs_dict["fuel_source_chart"] = ""
# Step 5: Generate context for all pages
print("Step 5: Generating page contexts...")
patient_name = patient_info.get("patient_name", "")
self.context_generator.load_data(
pnoe_csv_path,
str(spirometry_csv_path),
None, # No SECA file
)
# Set patient info manually
self.context_generator.patient_info = {
"name": patient_info.get("first_name", ""),
"last_name": patient_info.get("last_name", ""),
"age": patient_info.get("age", 25),
"weight": weight_kg,
"fat_percentage": fat_pct,
"gender": gender,
}
contexts = self.context_generator.generate_all_contexts(
patient_name, graphs_dict, metric_overrides=metric_overrides
)
# Step 5: Calculate analysis metrics
analysis_data = self.calculate_analysis_metrics(df)
analysis_data["graphs_count"] = len(graphs_generated)
# Step 6: Generate HTML
html_content = self.generate_html(patient_info, contexts)
# Step 7: Generate PDF
if output_filename is None:
patient_name_full = patient_info.get("patient_name", "Unknown")
session_id = patient_info.get("session_id", "default")
output_filename = (
f"report_{patient_name_full.replace(' ', '_')}_{session_id}.pdf"
)
report_path = self.reports_dir / output_filename
print(f"Generating PDF report at {report_path}")
await self.html_to_pdf(html_content, str(report_path))
return {
"report_path": str(report_path),
"graphs_generated": graphs_generated,
"analysis_data": analysis_data,
}
+139
View File
@@ -0,0 +1,139 @@
import base64
import os
import requests
from dotenv import load_dotenv
load_dotenv()
API_KEY_REF = os.getenv("OPENROUTER_API_KEY")
def encode_pdf_to_base64(pdf_path):
with open(pdf_path, "rb") as pdf_file:
return base64.b64encode(pdf_file.read()).decode("utf-8")
def extract_spirometry_table_from_pdf(pdf_path, output_dir="data"):
"""
Extract spirometry table from PDF using AI and save as clean CSV.
Args:
pdf_path: Path to the spirometry PDF file
output_dir: Directory to save the extracted CSV
Returns:
Path to the saved CSV file
"""
import csv
import re
from pathlib import Path
url = "https://openrouter.ai/api/v1/chat/completions"
headers = {
"Authorization": f"Bearer {API_KEY_REF}",
"Content-Type": "application/json",
}
# Read and encode the PDF
base64_pdf = encode_pdf_to_base64(pdf_path)
data_url = f"data:application/pdf;base64,{base64_pdf}"
messages = [
{
"role": "user",
"content": [
{
"type": "text",
"text": "Please extract the Spirometry table from the pdf and return ONLY the values in CSV format. "
"The CSV should have these columns: Parameters,Pre,Best,LLN,Pred.,%Pred.,ZScore\n"
"Rules:\n"
"1. Include ONLY the data rows (FVC, FEV1, FEV1/FVC%, etc.)\n"
"2. Do NOT include units in the data (units are part of parameter name)\n"
"3. Use empty string for missing values (not '-' or 'N/A')\n"
"4. Do NOT add 'csv' markers or code blocks\n"
"5. First line should be the header\n"
"Example format:\n"
"Parameters,Pre,Best,LLN,Pred.,%Pred.,ZScore\n"
"FVC,4.50,4.75,3.20,4.80,99,-0.10",
},
{
"type": "file",
"file": {"filename": "document.pdf", "file_data": data_url},
},
],
}
]
payload = {
"model": "google/gemini-2.5-flash-lite",
"messages": messages,
}
response = requests.post(url, headers=headers, json=payload)
response_data = response.json()
if "choices" in response_data and len(response_data["choices"]) > 0:
content = response_data["choices"][0]["message"]["content"]
# Clean the content - remove markdown code blocks if present
content = re.sub(r"```csv\n?", "", content)
content = re.sub(r"```\n?", "", content)
content = content.strip()
# Parse and validate CSV
lines = content.split("\n")
if not lines:
raise ValueError("No data extracted from PDF")
# Ensure output directory exists
output_path = Path(output_dir)
output_path.mkdir(exist_ok=True)
output_file = output_path / "extracted_spirometry_table.csv"
# Write cleaned CSV with proper formatting
with open(output_file, "w", encoding="utf-8", newline="") as f:
# Parse the first line as header
header_line = lines[0].strip()
if "," in header_line:
header = [col.strip() for col in header_line.split(",")]
else:
# Default header if not provided
header = [
"Parameters",
"Pre",
"Best",
"LLN",
"Pred.",
"%Pred.",
"ZScore",
]
writer = csv.writer(f)
writer.writerow(header)
# Process data rows
for line in lines[1:]:
line = line.strip()
if not line:
continue
# Split by comma and clean each field
fields = [field.strip() for field in line.split(",")]
# Ensure we have the right number of fields
if len(fields) < len(header):
# Pad with empty strings
fields.extend([""] * (len(header) - len(fields)))
elif len(fields) > len(header):
# Take only the first N fields
fields = fields[: len(header)]
# Replace '-' or 'N/A' with empty string
fields = ["" if f in ["-", "N/A", "n/a", "NA"] else f for f in fields]
writer.writerow(fields)
return str(output_file)
else:
error_msg = response_data.get("error", {}).get("message", "Unknown error")
raise Exception(f"No content found in response: {error_msg}")
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Medical Report Generator{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
</style>
{% block extra_head %}{% endblock %}
</head>
<body class="bg-gray-50 min-h-screen">
<nav class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<h1 class="text-xl font-bold text-gray-900">ISHP Report Generator</h1>
</div>
<div class="flex items-center space-x-4">
<a href="/" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Upload</a>
{% if session.get('report_path') %}
<a href="/preview" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Preview</a>
<a href="/edit" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Edit Metrics</a>
{% endif %}
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
{% block content %}{% endblock %}
</main>
{% block extra_scripts %}{% endblock %}
</body>
</html>
+197
View File
@@ -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 %}
+180
View File
@@ -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 %}
+113
View File
@@ -0,0 +1,113 @@
{% extends "base.html" %}
{% block title %}Upload Patient Data - Report Generator{% endblock %}
{% block content %}
<div class="px-4 py-6 sm:px-0">
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Upload Patient Data and Files</h2>
{% if error %}
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p class="text-red-800">{{ error }}</p>
</div>
{% endif %}
<form action="/upload" method="post" enctype="multipart/form-data" class="space-y-6">
<!-- Patient Information Section -->
<div class="border-b border-gray-200 pb-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Patient Information</h3>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label for="first_name" class="block text-sm font-medium text-gray-700">First Name</label>
<input type="text" name="first_name" id="first_name" required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
</div>
<div>
<label for="last_name" class="block text-sm font-medium text-gray-700">Last Name</label>
<input type="text" name="last_name" id="last_name" required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
</div>
<div>
<label for="age" class="block text-sm font-medium text-gray-700">Age</label>
<input type="number" name="age" id="age" required min="1" max="120"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
</div>
<div>
<label for="height" class="block text-sm font-medium text-gray-700">Height (e.g., 5'4" or 165cm)</label>
<input type="text" name="height" id="height" required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border"
placeholder="5'4&quot;">
</div>
<div>
<label for="weight" class="block text-sm font-medium text-gray-700">Weight (e.g., 123lbs or 56kg)</label>
<input type="text" name="weight" id="weight" required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border"
placeholder="123lbs">
</div>
<div>
<label for="gender" class="block text-sm font-medium text-gray-700">Gender</label>
<select name="gender" id="gender" required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
<option value="">Select...</option>
<option value="male">Male</option>
<option value="female">Female</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label for="focus" class="block text-sm font-medium text-gray-700">Training Focus</label>
<input type="text" name="focus" id="focus" value="Endurance"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
</div>
<div>
<label for="session_id" class="block text-sm font-medium text-gray-700">Session ID</label>
<input type="text" name="session_id" id="session_id" value="default"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border">
</div>
</div>
</div>
<!-- File Upload Section -->
<div class="border-b border-gray-200 pb-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Upload Files</h3>
<div class="space-y-4">
<div>
<label for="spirometry_pdf" class="block text-sm font-medium text-gray-700">Spirometry PDF</label>
<input type="file" name="spirometry_pdf" id="spirometry_pdf" accept=".pdf" required
class="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
</div>
<div>
<label for="pnoe_csv" class="block text-sm font-medium text-gray-700">Pnoe CSV</label>
<input type="file" name="pnoe_csv" id="pnoe_csv" accept=".csv" required
class="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
</div>
<div>
<label for="fat_percentage" class="block text-sm font-medium text-gray-700">Body Fat Percentage (%)</label>
<input type="number" step="0.1" name="fat_percentage" id="fat_percentage" required min="0" max="100"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border"
placeholder="22.5">
</div>
<div>
<label for="oxygenation_csv" class="block text-sm font-medium text-gray-700">Muscle Oxygenation CSV (Optional)</label>
<input type="file" name="oxygenation_csv" id="oxygenation_csv" accept=".csv"
class="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
<p class="mt-1 text-xs text-gray-500">Upload NIRS muscle oxygen CSV file to generate TSI graph</p>
</div>
</div>
</div>
<!-- Submit Button -->
<div class="flex justify-end">
<button type="submit"
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Generate Report
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
-343
View File
@@ -1,343 +0,0 @@
import base64
def image_to_base64(image_path):
try:
with open(image_path, "rb") as image_file:
return base64.b64encode(image_file.read()).decode("utf-8")
except FileNotFoundError:
print(f"Warning: Image not found at {image_path}")
return ""
### Defining Page Contexts ###
page_1_context = {
"name": "John Doe",
"surname": "Moran",
"date": "July 29, 2025",
}
page_2_context = {
"content": "This is page 2 content",
}
page_3_context = {
"patient_name": "Keirstyn Moran",
}
page_4_context = {
"body_composition_chart": image_to_base64(
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/body_composition_chart.png"
),
"body_fat_chart": image_to_base64(
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/body_fat_percent_chart.png"
),
}
page_5_context = {
"metabolism_chart": "",
"fuel_source_chart": "",
"resting_calories": 1540,
"neat_calories": 310,
"weight_loss_calories": 1725,
"weight_loss_rate": "1lb/week",
"total_calories": 3575,
}
page_6_context = {
"patient_name": "Keirstyn Moran",
"age": "34",
"height": "5'4\"",
"weight": "123lbs",
"focus": "Endurance",
"deficit_calories": "1725KCals",
"deficit_protein": "120g Protein",
"deficit_carbs": "155g Carbs",
"deficit_fat": "69g Fat",
"deficit_fiber": "25g Fibre",
"refeed_weekday_calories": "1615KCals",
"refeed_weekday_protein": "120g Protein",
"refeed_weekday_carbs": "142g Carbs",
"refeed_weekday_fat": "63g Fat",
"refeed_weekday_fiber": "24g Fibre",
"refeed_weekend_calories": "2000KCals",
"refeed_weekend_protein": "120g Protein",
"refeed_weekend_carbs": "190g Carbs",
"refeed_weekend_fat": "84g Fat",
"refeed_weekend_fiber": "30g Fibre",
"protein_percentage": "28%",
"carbs_percentage": "36%",
"fats_percentage": "36%",
"page_number": "6",
}
page_7_context = {
"indication": "No Respiratory Capacity Limitation",
"peak_vt": 3.2,
"peak_vt_bpm": 198,
"peak_vt_zone": 3,
"fev1_percentage": 85,
"lung_analysis_chart": image_to_base64("/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/spirometry_chart.png"),
"respiratory_analysis_chart": image_to_base64(
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/respiratory.png"
),
}
page_8_context = {
"vo2_max_value": "49.5",
"vo2_max_percentile": "100th percentile",
"age_range": "30-39",
"very_poor_range": "19.0-24.1",
"poor_range": "24.1-28.2",
"fair_range": "28.2-32.2",
"good_range": "32.2-35.7",
"excellent_range": "35.7-45.8",
"superior_range": "45.8+",
"zone1_percentage": "55-65% of Max Heart Rate",
"zone2_percentage": "65-75% of Max Heart Rate",
"zone3_percentage": "80-85% of Max Heart Rate",
"zone4_percentage": "85-88% of Max Heart Rate",
"zone5_percentage": "90% of Max Heart Rate",
"zone1_bpm": "81-96bpm",
"zone2_bpm": "96-100bpm",
"zone3_bpm": "100-178bpm",
"zone4_bpm": "178-188bpm",
"zone5_bpm": "188-198bpm",
"zone1_speed": "3.5mph",
"zone2_speed": "3.5-4.0mph",
"zone3_speed": "4.0-6.5mph",
"zone4_speed": "6.5-7.0mph",
"zone5_speed": "7.0-8.0mph",
"zone1_incline": "2% Incline",
"zone2_incline": "2% Incline",
"zone3_incline": "2% Incline",
"zone4_incline": "2% Incline",
"zone5_incline": "2% Incline",
"zone1_pace": "10:39min/km Pace",
"zone2_pace": "10:39-9:19min/km Pace",
"zone3_pace": "9:19-5:44min/km Pace",
"zone4_pace": "5:44-5:20min/km Pace",
"zone5_pace": "5:20-4:40min/km Pace",
"zone1_calories": "4.4kcals/minute",
"zone2_calories": "5.9kcals/minute",
"zone3_calories": "9.4kcals/minute",
"zone4_calories": "12.5kcals/minute",
"zone5_calories": "12.8kcals/minute",
"zone1_carb": "Avg: 0.4g/min Carb Utilization",
"zone2_carb": "Avg: 0.6g/min Carb Utilization",
"zone3_carb": "Avg: 1.9g/min Carb Utilization",
"zone4_carb": "Avg: 2.9g/min Carb Utilization",
"zone5_carb": "Avg: 3.1g/min Carb Utilization",
"zone1_breaths": "Avg: 27 breaths",
"zone2_breaths": "Avg: 28 breaths",
"zone3_breaths": "Avg: 31 breaths",
"zone4_breaths": "Avg: 42 breaths",
"zone5_breaths": "Avg: 51 breaths",
"zone1_breath_range": "Ideal Range: 15-20 breaths",
"zone2_breath_range": "Ideal Range: 20-25 breaths",
"zone3_breath_range": "Ideal Range: 25-30 breaths",
"zone4_breath_range": "Ideal Range: 30-35 breaths",
"zone5_breath_range": "Ideal Range: 40+ breaths",
}
page_9_context = {
"fuel_utilization_chart": image_to_base64(
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/fuel_utilization_chart.png"
),
}
page_10_context = {
"vo2_pulse_drop_bpm": "180 bpm",
"vo2_pulse_drop_zone": "Zone 4",
"vo2_pulse_chart": image_to_base64(
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/vo2_pulse_chart.png"
),
"vo2_breath_drop_bpm": "173 bpm",
"vo2_breath_drop_zone": "Zone 3",
"vo2_breath_chart": image_to_base64(
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/vo2_breath_chart.png"
),
}
page_11_context = {
"fat_max_optimal": "*Optimal 10-12Kcals/minute",
"fat_max_value": "3.8Kcals/min",
"fat_max_heart_rate": "49% of Max Heart Rate",
"fat_max_bpm": "97 bpm",
"crossover_bpm": "100bpm",
"crossover_heart_rate": "51% of Max Heart Rate",
"fat_metabolism_note": "100bpm at a speed of 4.0mph and incline of 2%",
"fat_metabolism_chart": image_to_base64(
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/fat_metabolism_chart.png"
),
"cardiac_recovery_time": "(1 minute)",
"cardiac_recovery_percentage": "33%",
"metabolic_recovery_time": "(2 minute)",
"metabolic_recovery_percentage": "65%",
"breath_recovery_time": "(2.5 minute)",
"breath_recovery_percentage": "76%",
"recovery_chart": image_to_base64(
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/recovery_chart.png"
),
"resting_heart_rate": "53bpm",
"hr_age_range": "26-35",
"hr_poor": "82bpm +",
"hr_below_avg": "75-81bpm",
"hr_average": "71-74bpm",
"hr_above_avg": "66-70bpm",
"hr_good": "62-65bpm",
"hr_excellent": "55-61bpm",
"hr_athlete": "44-54bpm",
}
page_12_context = {
}
page_13_context = {
"patient_name": "Keirstyn Moran",
"age": "34",
"height": "5'4\"",
"weight": "123lbs",
"focus": "Endurance",
"zone2_frequency": "3-4x/week",
"zone2_duration": "40+ minutes",
"zone2_hr_range": "96-110bpm",
"zone2_speed": "3.5-4.0mph",
"zone2_incline": "2% Incline",
"zone3_frequency": "1-2x/week",
"zone3_duration": "10-20 minutes",
"zone3_hr_range": "100-178bpm",
"zone3_speed": "4.0-6.5mph",
"zone3_incline": "2% Incline",
"zone3_target_hr": "140bpm",
"zone3_recovery_speed": "3.5mph",
"zone3_recovery_incline": "2% Incline",
"zone1_hr_range": "81-96bpm",
"zone1_duration": "4-8 minutes",
"zone3_repeats": "2-3 times",
"short_sets": "8-10",
"short_duration": "10-30 seconds",
"short_zone": "5",
"short_rpe": "10",
"short_recovery": "20-60 seconds",
"medium_sets": "6-8",
"medium_duration": "30-90 seconds",
"medium_zone": "4",
"medium_rpe": "8-9",
"medium_recovery": "30-90 seconds",
"long_sets": "4-6",
"long_duration": "5-10 minutes",
"long_zone": "3/4",
"long_rpe": "7-8",
"long_recovery": "2.5-5 minutes",
"tempo_sets": "2-3",
"tempo_duration": "10-20 minutes",
"tempo_zone": "3",
"tempo_rpe": "6-7",
"tempo_recovery": "4-8 minutes",
"cardio_sets": "1",
"cardio_duration": ">40 minutes",
"cardio_zone": "2",
"cardio_rpe": "4-5",
"cardio_recovery": "N/A",
"week1_mon_zone": "Zone 2",
"week1_mon_duration": "45 mins",
"week1_tue_zone": "Zone 2",
"week1_tue_duration": "45 mins",
"week1_wed_zone": "Zone 3",
"week1_wed_duration1": "10mins On",
"week1_wed_duration2": "8mins Rest",
"week1_wed_sets": "x2",
"week1_thu_content": "",
"week1_fri_zone": "Zone 2",
"week1_fri_duration": "45 mins",
"week1_sat_content": "",
"week1_sun_content": "",
"week2_mon_zone": "Zone 2",
"week2_mon_duration": "50 mins",
"week2_tue_zone": "Zone 2",
"week2_tue_duration": "50 mins",
"week2_wed_zone": "Zone 3",
"week2_wed_duration1": "10mins On",
"week2_wed_duration2": "6mins Rest",
"week2_wed_sets": "x2",
"week2_thu_content": "",
"week2_fri_zone": "Zone 2",
"week2_fri_duration": "50 mins",
"week2_sat_content": "",
"week2_sun_content": "",
"contact_email": "info@ishplabs.com",
"website": "www.ishplabs.com",
"social": "@ishplabs",
"page_number": "13",
}
page_14_context = {
"patient_name": "Keirstyn Moran",
"contact_email": "info@ishplabs.com",
"website": "www.ishplabs.com",
"social": "@ishplabs",
"page_number": "14",
}
page_15_context = {
"patient_name": "Keirstyn Moran",
"contact_email": "info@ishplabs.com",
"website": "www.ishplabs.com",
"social": "@ishplabs",
"page_number": "15",
}
page_16_context = {
"patient_name": "Keirstyn Moran",
"contact_email": "info@ishplabs.com",
"website": "www.ishplabs.com",
"social": "@ishplabs",
"page_number": "16",
}
page_17_context = {
"patient_name": "Keirstyn Moran",
"contact_email": "info@ishplabs.com",
"website": "www.ishplabs.com",
"social": "@ishplabs",
"page_number": "17",
}
page_18_context = {
"body_fat_percentage_chart": image_to_base64(
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/fat_percent_master_chart.png"
),
}
page_19_context = {
"patient_name": "Keirstyn Moran",
"contact_email": "info@ishplabs.com",
"website": "www.ishplabs.com",
"social": "@ishplabs",
"page_number": "19",
}
context_list = [
page_1_context,
page_2_context,
page_3_context,
page_4_context,
page_5_context,
page_6_context,
page_7_context,
page_8_context,
page_9_context,
page_10_context,
page_11_context,
page_12_context,
page_13_context,
page_14_context,
page_15_context,
page_16_context,
page_17_context,
page_18_context,
page_19_context,
]
-319
View File
@@ -1,319 +0,0 @@
import base64
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import matplotlib.pyplot as plt
import pandas as pd
class ReportGenerator:
def __init__(self):
self.pnoe_df = None
self.patient_df = None
self.spirometry_df = None
self.seca_df = None
self.patient_info = {}
self.charts_dir = Path("graphs")
self.charts_dir.mkdir(exist_ok=True)
def load_data(
self,
pnoe_path: str,
patient_path: str,
spirometry_path: str,
seca_path: str = None,
):
"""Load all required datasets"""
self.pnoe_df = pd.read_csv(pnoe_path, delimiter=";")
self.patient_df = pd.read_csv(patient_path)
self.spirometry_df = pd.read_csv(spirometry_path)
if seca_path:
self.seca_df = pd.read_excel(seca_path)
# Apply preprocessing
self._preprocess_data()
def _preprocess_data(self):
"""Apply preprocessing steps from your notebook"""
# Convert to numeric
self.pnoe_df = self.pnoe_df.apply(pd.to_numeric, errors="ignore")
# Calculate derived columns
self.pnoe_df["VO2 Pulse"] = (
self.pnoe_df["VO2(ml/min)"] / self.pnoe_df["HR(bpm)"]
)
self.pnoe_df["VO2 Breath"] = (
self.pnoe_df["VO2(ml/min)"] / self.pnoe_df["BF(bpm)"]
)
self.pnoe_df["CHO"] = (
self.pnoe_df["EE(kcal/min)"] * self.pnoe_df["CARBS(%)"] / 100
)
self.pnoe_df["FAT"] = (
self.pnoe_df["EE(kcal/min)"] * self.pnoe_df["FAT(%)"] / 100
)
# Apply smoothing
window_size = 10
columns_to_smooth = [
"VO2(ml/min)",
"VCO2(ml/min)",
"HR(bpm)",
"VT(l)",
"BF(bpm)",
"VE(l/min)",
"VO2 Pulse",
"VO2 Breath",
"CHO",
"FAT",
]
for col in columns_to_smooth:
if col in self.pnoe_df.columns:
self.pnoe_df[f"{col}_smoothed"] = (
self.pnoe_df[col].rolling(window=window_size, min_periods=1).mean()
)
def extract_patient_info(self, last_name: str) -> Dict:
"""Extract patient information from datasets"""
if self.seca_df is not None:
patient_data = self.seca_df[
self.seca_df["LastName"].str.contains(last_name, case=False, na=False)
]
if not patient_data.empty:
row = patient_data.iloc[0]
self.patient_info = {
"name": f"{row.get('FirstName', '')} {last_name}",
"age": int(row.get("Age", 0)),
"height": f"{row.get('Height', '')}",
"weight": float(row.get("Weight", 0)),
"gender": row.get("Gender", "").lower(),
"fat_percentage": float(row.get("Adult_FMP", 0)),
}
return self.patient_info
def calculate_spirometry_metrics(self) -> Dict:
"""Calculate spirometry-related metrics"""
metrics = {}
# Extract key spirometry values
for param in ["FVC", "FEV1", "FEV1/FVC%"]:
row = self.spirometry_df.loc[self.spirometry_df["Parameters"] == param]
if not row.empty:
metrics[
f"{param.lower().replace('/', '_').replace('%', '_pct')}_best"
] = row["Best"].values[0]
metrics[
f"{param.lower().replace('/', '_').replace('%', '_pct')}_pred"
] = row["%Pred."].values[0]
return metrics
def calculate_pnoe_metrics(self) -> Dict:
"""Calculate all Pnoe-derived metrics"""
metrics = {}
# Basic metrics
metrics["vo2_max"] = self.pnoe_df["VO2(ml/min)_smoothed"].max()
metrics["vo2_max_per_kg"] = metrics["vo2_max"] / self.patient_info["weight"]
# Peak VT
peak_vt_idx = self.pnoe_df["VT(l)_smoothed"].idxmax()
peak_vt_row = self.pnoe_df.loc[peak_vt_idx]
metrics["peak_vt"] = peak_vt_row["VT(l)_smoothed"]
metrics["peak_vt_hr"] = peak_vt_row["HR(bpm)_smoothed"]
# Fat burning metrics
fat_max_idx = self.pnoe_df["FAT_smoothed"].idxmax()
fat_max_row = self.pnoe_df.loc[fat_max_idx]
metrics["fat_max_value"] = fat_max_row["FAT_smoothed"]
metrics["fat_max_hr"] = fat_max_row["HR(bpm)_smoothed"]
# Calculate zones (simplified from your logic)
metrics.update(self._calculate_hr_zones())
# VT1/VT2 detection
vt1, vt2 = self._detect_thresholds()
metrics["vt1"] = vt1
metrics["vt2"] = vt2
return metrics
def _detect_thresholds(self) -> Tuple[Optional[Dict], Optional[Dict]]:
"""Detect VT1 and VT2 thresholds"""
# VT1: First crossover where carbs > fat
condition = self.pnoe_df["CHO_smoothed"] > self.pnoe_df["FAT_smoothed"]
crossover_indices = condition[condition].index
vt1 = None
if len(crossover_indices) > 0:
vt1_idx = crossover_indices[0]
vt1_row = self.pnoe_df.loc[vt1_idx]
vt1 = {
"HeartRate": vt1_row["HR(bpm)_smoothed"],
"Speed": vt1_row["Speed"],
"Time": vt1_row["T(sec)"],
}
# VT2: Ventilation inflection (simplified)
ve_slope = self.pnoe_df["VE(l/min)_smoothed"].diff()
second_derivative = ve_slope.diff()
vt2_idx = second_derivative.idxmax()
vt2 = None
if pd.notna(vt2_idx):
vt2_row = self.pnoe_df.loc[vt2_idx]
vt2 = {
"HeartRate": vt2_row["HR(bpm)_smoothed"],
"Speed": vt2_row["Speed"],
"Time": vt2_row["T(sec)"],
}
return vt1, vt2
def _calculate_hr_zones(self) -> Dict:
"""Calculate heart rate zones"""
max_hr = 220 - self.patient_info["age"]
# Simplified zone calculation - you can make this more sophisticated
zones = {
"zone1_bpm": f"{int(max_hr * 0.55)}-{int(max_hr * 0.65)}bpm",
"zone2_bpm": f"{int(max_hr * 0.65)}-{int(max_hr * 0.75)}bpm",
"zone3_bpm": f"{int(max_hr * 0.75)}-{int(max_hr * 0.85)}bpm",
"zone4_bpm": f"{int(max_hr * 0.85)}-{int(max_hr * 0.95)}bpm",
"zone5_bpm": f"{int(max_hr * 0.95)}+bpm",
}
return zones
def generate_charts(self) -> Dict[str, str]:
"""Generate all charts and return base64 encoded versions"""
charts = {}
# Generate fuel utilization chart
charts["fuel_utilization_chart"] = self._create_fuel_chart()
# Generate VO2 pulse chart
charts["vo2_pulse_chart"] = self._create_vo2_pulse_chart()
# Generate body composition chart
charts["body_composition_chart"] = self._create_body_comp_chart()
# Add more chart generation methods...
return charts
def _create_fuel_chart(self) -> str:
"""Create and save fuel utilization chart"""
# Use your existing chart code but make it dynamic
speed_groups = self.pnoe_df.groupby("Speed").mean(numeric_only=True).round(1)
speed_groups = speed_groups.iloc[1:-1]
filtered_data = speed_groups[
(speed_groups.index >= 3.5) & (speed_groups.index <= 7.5)
]
plt.figure(figsize=(15, 8))
# ... your chart code here ...
chart_path = self.charts_dir / "fuel_utilization_chart.png"
plt.savefig(chart_path, dpi=300)
plt.close()
return self._image_to_base64(chart_path)
def _create_vo2_pulse_chart(self) -> str:
"""Create VO2 pulse chart"""
# Your VO2 pulse chart code here
chart_path = self.charts_dir / "vo2_pulse_chart.png"
# ... chart generation code ...
return self._image_to_base64(chart_path)
def _create_body_comp_chart(self) -> str:
"""Create body composition chart"""
# Your body composition chart code here
chart_path = self.charts_dir / "body_composition_chart.png"
# ... chart generation code ...
return self._image_to_base64(chart_path)
def _image_to_base64(self, image_path: Path) -> str:
"""Convert image to base64"""
try:
with open(image_path, "rb") as image_file:
return base64.b64encode(image_file.read()).decode("utf-8")
except FileNotFoundError:
return ""
def generate_all_contexts(self, last_name: str = "Moran") -> List[Dict]:
"""Main method to generate all page contexts"""
# Extract patient info
self.extract_patient_info(last_name)
# Calculate metrics
spirometry_metrics = self.calculate_spirometry_metrics()
pnoe_metrics = self.calculate_pnoe_metrics()
# Generate charts
charts = self.generate_charts()
# Build contexts for each page
contexts = []
# Page 1
contexts.append(
{
"name": self.patient_info["name"],
"surname": last_name,
"date": "July 29, 2025",
}
)
# Page 2-6 (add as needed)
for i in range(5):
contexts.append({})
# Page 7 - Spirometry
contexts.append(
{
"peak_vt": pnoe_metrics["peak_vt"],
"peak_vt_bpm": pnoe_metrics["peak_vt_hr"],
"fev1_percentage": (
pnoe_metrics["peak_vt"] / spirometry_metrics["fvc_best"]
)
* 100,
"lung_analysis_chart": charts.get("spirometry_chart", ""),
"respiratory_analysis_chart": charts.get("respiratory_chart", ""),
}
)
# Page 8 - VO2 Max and Zones
contexts.append(
{
"vo2_max_value": f"{pnoe_metrics['vo2_max_per_kg']:.1f}",
"age_range": f"{self.patient_info['age'] // 10 * 10}-{self.patient_info['age'] // 10 * 10 + 9}",
**pnoe_metrics, # Include all zone calculations
}
)
# Continue for all pages...
# Add remaining pages as needed
return contexts
# Usage for backend service
def generate_report(
pnoe_file, patient_file, spirometry_file, seca_file=None, patient_name="Moran"
):
"""Main function for backend service"""
generator = ReportGenerator()
generator.load_data(pnoe_file, patient_file, spirometry_file, seca_file)
return generator.generate_all_contexts(patient_name)
# Example usage
if __name__ == "__main__":
contexts = generate_report(
"data/Pnoe_20250729_1550-Moran_Keirstyn.csv",
"data/patient_data.csv",
"data/spirometry_data.csv",
"data/SECA body comp for all patients.xlsx",
)
print(f"Generated {len(contexts)} page contexts")
-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: 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

-124
View File
@@ -1,124 +0,0 @@
from jinja2 import Environment, FileSystemLoader
from playwright.sync_api import sync_playwright
from context import context_list
env = Environment(loader=FileSystemLoader("report_gen"))
html_pages = []
header_context = {
"patient_name": "Keirstyn Moran",
"age": 34,
"height": "5'4\"",
"weight": "123lbs",
"focus": "Endurance",
}
footer_context = [
{
"contact_email": "info@ishplabs.com ",
"website": "www.ishplabs.com",
"social": "@ishplabs",
"page_number": i + 1,
}
for i in range(len(context_list))
]
header_html = env.get_template("header.html").render(header_context)
footer_html_list = [
env.get_template("footer.html").render(context) for context in footer_context
]
for i, context in enumerate(context_list):
template = env.get_template(f"page_{i + 1}.html").render(context)
if (i + 1) > 2:
full_html = f"""
<div class="page flex flex-col justify-between">
<div>
{header_html}
</div>
<main class="flex-grow p-4">
{template}
</main>
<div class="border-t text-center text-sm text-gray-600">
{footer_html_list[i]}
</div>
</div>
"""
html_pages.append(full_html)
else:
html_pages.append(template)
# Combine with page breaks
final_html = "<div class='page-break'></div>".join(html_pages)
# Wrap in full HTML document
html_doc = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link href="https://cdn.jsdelivr.net/npm/tailwindcss/dist/tailwind.min.css" rel="stylesheet">
<style>
html, body {{
height: 100%;
margin: 0;
padding: 0;
}}
.page-break {{ page-break-after: always; }}
.page {{
height: 100vh;
min-height: 100vh;
display: flex;
flex-direction: column;
}}
.page main {{
flex: 1;
overflow: hidden;
}}
/* Reset margins and padding everywhere */
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
/* Prevent images from being too large */
img {{
max-height: 300px;
}}
/* Larger images for specific charts */
.chart-large {{
max-height: 500px !important;
}}
</style>
</head>
<body class="m-0 p-0">
{final_html}
</body>
</html>
"""
# Generate PDF
def html_string_to_pdf(html_content, pdf_path):
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
# Set the HTML directly
page.set_content(html_content)
# Export to PDF
page.pdf(path=pdf_path, format="A4", print_background=True)
browser.close()
html_string_to_pdf(html_doc, "multi_page_report.pdf")
# pdfkit.from_string(html_doc, "truth_report.pdf", options=options)
print("✅ PDF generated: multi_page_report.pdf")
Binary file not shown.
-1550
View File
File diff suppressed because one or more lines are too long
+136 -98
View File
@@ -2,102 +2,93 @@
"cells": [
{
"cell_type": "code",
"execution_count": 2,
"execution_count": 6,
"id": "b18c1027",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{'id': 'gen-1759135172-DIhs7TMuaaVY0h3T2ibV', 'provider': 'Google', 'model': 'google/gemini-2.5-flash-lite', 'object': 'chat.completion', 'created': 1759135172, 'choices': [{'logprobs': None, 'finish_reason': 'stop', 'native_finish_reason': 'STOP', 'index': 0, 'message': {'role': 'assistant', 'content': 'Parameters,Best,LLN,Pred.,%Pred.,ZScore,PRE#1,PRE#2,PRE#3\\nFVC,L,4.24,3.03,3.79,112.0,0.95,4.24,4.17,4.15\\nFEV1,L,3.26,2.53,3.16,103.3,0.28,3.26,3.21,3.14\\nFEV1/FVC%,76.89,72.47,83.78,91.8,-1.05,76.9,77.0,75.7\\nPEF,L/m,684,222,384,178.7,-,444,438,684\\nFEF2575,L/s,2.74,2.15,3.42,80.2,-0.84,2.74,2.68,2.48\\nFEF25,L/s,6.08,-,-,-,6.08,6.0,5.53\\nFEF50,L/s,3.06,-,-,-,3.06,3.1,2.77\\nFEF75,L/s,1.06,0.71,1.41,75.1,-0.72,1.06,1.12,0.94\\nPEFTime,ms,-,-,79,-,79,49,39\\nEvol,mL,-,-,78.0,-,78.0,77.0,197.0\\nFEV6,L,4.22,3.03,3.79,111.4,-,4.22,4.17,4.13', 'refusal': None, 'reasoning': None}}], 'usage': {'prompt_tokens': 1350, 'completion_tokens': 454, 'total_tokens': 1804, 'prompt_tokens_details': {'cached_tokens': 0}, 'completion_tokens_details': {'reasoning_tokens': 0, 'image_tokens': 0}}}\n",
"Content saved to extracted_table.csv\n"
]
}
],
"outputs": [],
"source": [
"\n",
"import requests\n",
"import json\n",
"import base64\n",
"from pathlib import Path\n",
"# import requests\n",
"# import json\n",
"# import base64\n",
"# from pathlib import Path\n",
"\n",
"API_KEY_REF = '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'\n",
"def encode_pdf_to_base64(pdf_path):\n",
" with open(pdf_path, \"rb\") as pdf_file:\n",
" return base64.b64encode(pdf_file.read()).decode('utf-8')\n",
"# API_KEY_REF = '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'\n",
"# def encode_pdf_to_base64(pdf_path):\n",
"# with open(pdf_path, \"rb\") as pdf_file:\n",
"# return base64.b64encode(pdf_file.read()).decode('utf-8')\n",
"\n",
"url = \"https://openrouter.ai/api/v1/chat/completions\"\n",
"headers = {\n",
" \"Authorization\": f\"Bearer {API_KEY_REF}\",\n",
" \"Content-Type\": \"application/json\"\n",
"}\n",
"# url = \"https://openrouter.ai/api/v1/chat/completions\"\n",
"# headers = {\n",
"# \"Authorization\": f\"Bearer {API_KEY_REF}\",\n",
"# \"Content-Type\": \"application/json\"\n",
"# }\n",
"\n",
"# Read and encode the PDF\n",
"pdf_path = \"data/~Moran~K~19910201~Spirometry Exam~20250729~20250729032843.pdf\"\n",
"base64_pdf = encode_pdf_to_base64(pdf_path)\n",
"data_url = f\"data:application/pdf;base64,{base64_pdf}\"\n",
"# # Read and encode the PDF\n",
"# pdf_path = \"data/~Moran~K~19910201~Spirometry Exam~20250729~20250729032843.pdf\"\n",
"# base64_pdf = encode_pdf_to_base64(pdf_path)\n",
"# data_url = f\"data:application/pdf;base64,{base64_pdf}\"\n",
"\n",
"messages = [\n",
" {\n",
" \"role\": \"user\",\n",
" \"content\": [\n",
" {\n",
" \"type\": \"text\",\n",
" \"text\": \"Please extract the Spirometry table from the pdf and return the values in csv format, \"\n",
" \"note that it is the unit of parameter that is beside it and it should not be a column. \"\n",
" \"The '-' Should be treated as empty values.\"\n",
" \"do not add 'csv' at the start or end of the response\"\n",
" },\n",
" {\n",
" \"type\": \"file\",\n",
" \"file\": {\n",
" \"filename\": \"document.pdf\",\n",
" \"file_data\": data_url\n",
" }\n",
" },\n",
" ]\n",
" }\n",
"]\n",
"# messages = [\n",
"# {\n",
"# \"role\": \"user\",\n",
"# \"content\": [\n",
"# {\n",
"# \"type\": \"text\",\n",
"# \"text\": \"Please extract the Spirometry table from the pdf and return the values in csv format, \"\n",
"# \"note that it is the unit of parameter that is beside it and it should not be a column. \"\n",
"# \"The '-' Should be treated as empty values.\"\n",
"# \"do not add 'csv' at the start or end of the response\"\n",
"# },\n",
"# {\n",
"# \"type\": \"file\",\n",
"# \"file\": {\n",
"# \"filename\": \"document.pdf\",\n",
"# \"file_data\": data_url\n",
"# }\n",
"# },\n",
"# ]\n",
"# }\n",
"# ]\n",
"\n",
"# Optional: Configure PDF processing engine\n",
"# PDF parsing will still work even if the plugin is not explicitly set\n",
"plugins = [\n",
" {\n",
" \"id\": \"file-parser\",\n",
" \"pdf\": {\n",
" \"engine\": \"pdf-text\" # defaults to \"mistral-ocr\". See Pricing above\n",
" }\n",
" }\n",
"]\n",
"# # Optional: Configure PDF processing engine\n",
"# # PDF parsing will still work even if the plugin is not explicitly set\n",
"# plugins = [\n",
"# {\n",
"# \"id\": \"file-parser\",\n",
"# \"pdf\": {\n",
"# \"engine\": \"pdf-text\" # defaults to \"mistral-ocr\". See Pricing above\n",
"# }\n",
"# }\n",
"# ]\n",
"\n",
"payload = {\n",
" \"model\": \"google/gemini-2.5-flash-lite\",\n",
" \"messages\": messages,\n",
"}\n",
"# payload = {\n",
"# \"model\": \"google/gemini-2.5-flash-lite\",\n",
"# \"messages\": messages,\n",
"# }\n",
"\n",
"response = requests.post(url, headers=headers, json=payload)\n",
"# Get the response content\n",
"response_data = response.json()\n",
"print(response_data)\n",
"# response = requests.post(url, headers=headers, json=payload)\n",
"# # Get the response content\n",
"# response_data = response.json()\n",
"# print(response_data)\n",
"\n",
"# Extract the content from the response\n",
"if 'choices' in response_data and len(response_data['choices']) > 0:\n",
" content = response_data['choices'][0]['message']['content']\n",
"# # Extract the content from the response\n",
"# if 'choices' in response_data and len(response_data['choices']) > 0:\n",
"# content = response_data['choices'][0]['message']['content']\n",
" \n",
" # Save to a CSV file\n",
" output_file = \"extracted_table.csv\"\n",
" with open(output_file, 'w', encoding='utf-8') as f:\n",
" f.write(content)\n",
"# # Save to a CSV file\n",
"# output_file = \"extracted_table.csv\"\n",
"# with open(output_file, 'w', encoding='utf-8') as f:\n",
"# f.write(content)\n",
" \n",
" print(f\"Content saved to {output_file}\")\n",
"else:\n",
" print(\"No content found in response\")"
"# print(f\"Content saved to {output_file}\")\n",
"# else:\n",
"# print(\"No content found in response\")"
]
},
{
"cell_type": "code",
"execution_count": 12,
"execution_count": 7,
"id": "56a9d655",
"metadata": {},
"outputs": [
@@ -107,13 +98,13 @@
"text": [
"FVC Best: 4.24, FVC Pred: 112.0\n",
"FEV1 Best: 3.26, FEV1 Pred: 103.3\n",
"FEV1/FVC% Best: 76.89, FEV1/FVC% Pred: 91.8\n"
"FEV1/FVC% Best: 76.9, FEV1/FVC% Pred: 91.8\n"
]
}
],
"source": [
"import pandas as pd\n",
"spirometry_df = pd.read_csv(\"extracted_table.csv\")\n",
"spirometry_df = pd.read_csv(\"data/spirometry_data.csv\")\n",
"\n",
"fvc_best = spirometry_df.loc[spirometry_df['Parameters'] == 'FVC', 'Best'].values[0]\n",
"fvc_pred = spirometry_df.loc[spirometry_df['Parameters'] == 'FVC', '%Pred.'].values[0]\n",
@@ -131,7 +122,7 @@
},
{
"cell_type": "code",
"execution_count": 16,
"execution_count": 8,
"id": "990f4b4f",
"metadata": {},
"outputs": [
@@ -155,7 +146,7 @@
},
{
"cell_type": "code",
"execution_count": 18,
"execution_count": 9,
"id": "041cbc3d",
"metadata": {},
"outputs": [
@@ -171,7 +162,7 @@
"name": "stderr",
"output_type": "stream",
"text": [
"/tmp/ipykernel_301535/4157056299.py:3: FutureWarning: errors='ignore' is deprecated and will raise in a future version. Use to_numeric without passing `errors` and catch exceptions explicitly instead\n",
"/tmp/ipykernel_69398/4157056299.py:3: FutureWarning: errors='ignore' is deprecated and will raise in a future version. Use to_numeric without passing `errors` and catch exceptions explicitly instead\n",
" df = df.apply(pd.to_numeric, errors='ignore')\n"
]
}
@@ -204,7 +195,7 @@
},
{
"cell_type": "code",
"execution_count": 20,
"execution_count": 10,
"id": "de7cadd1",
"metadata": {},
"outputs": [
@@ -223,7 +214,7 @@
},
{
"cell_type": "code",
"execution_count": 24,
"execution_count": 11,
"id": "cb972ed3",
"metadata": {},
"outputs": [
@@ -320,7 +311,7 @@
"[1 rows x 147 columns]"
]
},
"execution_count": 24,
"execution_count": 11,
"metadata": {},
"output_type": "execute_result"
}
@@ -334,7 +325,7 @@
},
{
"cell_type": "code",
"execution_count": 26,
"execution_count": 12,
"id": "98d9295a",
"metadata": {},
"outputs": [
@@ -354,7 +345,7 @@
},
{
"cell_type": "code",
"execution_count": 32,
"execution_count": 13,
"id": "cdfeb309",
"metadata": {},
"outputs": [
@@ -418,7 +409,7 @@
},
{
"cell_type": "code",
"execution_count": 33,
"execution_count": 14,
"id": "4420cfea",
"metadata": {},
"outputs": [
@@ -476,7 +467,7 @@
},
{
"cell_type": "code",
"execution_count": 37,
"execution_count": 21,
"id": "62803668",
"metadata": {},
"outputs": [
@@ -561,7 +552,7 @@
},
{
"cell_type": "code",
"execution_count": 40,
"execution_count": 16,
"id": "07593b56",
"metadata": {},
"outputs": [
@@ -572,8 +563,8 @@
"Zone 1 (Active Recovery): 81.7 - 96.7 bpm\n",
"Zone 2 (Aerobic Base): 96.7 - 100.5 bpm\n",
"Zone 3 (Aerobic): 100.5 - 179.7 bpm\n",
"Zone 4 (Lactate Threshold): 179.7 - 199.7 bpm\n",
"Zone 5 (VO2 Max): 199.7+ bpm\n"
"Zone 4 (Lactate Threshold): 179.7 - 189.7 bpm\n",
"Zone 5 (VO2 Max): 189.7 - 199.7 bpm\n"
]
}
],
@@ -582,7 +573,8 @@
"zone_2_start = optimal_row['HR(bpm)_smoothed']\n",
"zone_3_start = vt1\n",
"zone_4_start = vt2['HeartRate'] - 10\n",
"zone_5_start = vt2['HeartRate'] + 10\n",
"zone_5_start = vt2['HeartRate']\n",
"zone_5_end = vt2['HeartRate'] + 10\n",
"\n",
"zone_1_end = zone_2_start\n",
"zone_2_end = vt1['HeartRate']\n",
@@ -593,12 +585,12 @@
"print(f\"Zone 2 (Aerobic Base): {zone_2_start:.1f} - {zone_2_end:.1f} bpm\")\n",
"print(f\"Zone 3 (Aerobic): {zone_3_start['HeartRate']:.1f} - {zone_3_end:.1f} bpm\")\n",
"print(f\"Zone 4 (Lactate Threshold): {zone_4_start:.1f} - {zone_4_end:.1f} bpm\")\n",
"print(f\"Zone 5 (VO2 Max): {zone_5_start:.1f}+ bpm\")"
"print(f\"Zone 5 (VO2 Max): {zone_5_start:.1f} - {zone_5_end:.1f} bpm\")"
]
},
{
"cell_type": "code",
"execution_count": 60,
"execution_count": 17,
"id": "c90415b2",
"metadata": {},
"outputs": [
@@ -661,7 +653,7 @@
},
{
"cell_type": "code",
"execution_count": 66,
"execution_count": 18,
"id": "c3b2cc59",
"metadata": {},
"outputs": [
@@ -750,7 +742,7 @@
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 19,
"id": "672d68f3",
"metadata": {},
"outputs": [
@@ -774,6 +766,52 @@
"print(f\"FAT (smoothed): {max_fat_smoothed_row['FAT_smoothed']:.3f} kcal/min\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "3521220f",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Estimated RMR from data: 1385 kcal/day\n"
]
}
],
"source": [
"# Step 1: Filter resting phase (usually lowest VO2 or MET values)\n",
"rest_phase = df[df['MET'] <= 1.1] # assuming <1.1 MET means rest\n",
"\n",
"# Step 2: Compute resting metabolic rate\n",
"rmr = rest_phase['EE(kcal/day)'].mean()\n",
"\n",
"print(f\"Estimated RMR from data: {rmr:.0f} kcal/day\")\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "524e4cba",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Resting phase fuel mix: Fats 32.9%, Carbs 67.1%\n"
]
}
],
"source": [
"rest_phase = df[df['RER'] == 0.9] # filter rest data\n",
"fat_rest = rest_phase['FAT(%)'].mean()\n",
"carb_rest = rest_phase['CARBS(%)'].mean()\n",
"\n",
"print(f\"Resting phase fuel mix: Fats {fat_rest:.1f}%, Carbs {carb_rest:.1f}%\")\n"
]
},
{
"cell_type": "code",
"execution_count": null,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+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