Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5844cb6cff | |||
| e58d9b0158 | |||
| 85ea73ade8 | |||
| 1d5625b61a | |||
| f5d304aec5 | |||
| d862577ecf | |||
| 2568e991e2 | |||
| bad8f18f19 | |||
| e2f6eaab66 | |||
| 192c598e18 | |||
| 7e55ee6954 |
@@ -2,12 +2,4 @@
|
||||
|
||||
data/
|
||||
|
||||
.env
|
||||
|
||||
/graphs
|
||||
|
||||
/data
|
||||
|
||||
/reports
|
||||
|
||||
/temp
|
||||
.env
|
||||
@@ -1 +0,0 @@
|
||||
3.12
|
||||
@@ -2,7 +2,7 @@
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"execution_count": 6,
|
||||
"id": "b18c1027",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
@@ -88,7 +88,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 3,
|
||||
"execution_count": 7,
|
||||
"id": "56a9d655",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -104,10 +104,7 @@
|
||||
],
|
||||
"source": [
|
||||
"import pandas as pd\n",
|
||||
"import os\n",
|
||||
"\n",
|
||||
"base_dir = os.path.dirname(os.path.abspath('.'))\n",
|
||||
"spirometry_df = pd.read_csv(f\"{base_dir}/data/spirometry_data.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",
|
||||
@@ -125,7 +122,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 4,
|
||||
"execution_count": 8,
|
||||
"id": "990f4b4f",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -139,7 +136,7 @@
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"df = pd.read_csv(f'{base_dir}/data/Pnoe_20250729_1550-Moran_Keirstyn.csv', delimiter=';')\n",
|
||||
"df = pd.read_csv('data/Pnoe_20250729_1550-Moran_Keirstyn.csv', delimiter=';')\n",
|
||||
"peak_vt = df['VT(l)'].max()\n",
|
||||
"max_vt_row = df.loc[df['VT(l)'].idxmax()]\n",
|
||||
"print(f\"Peak VT: {peak_vt}\")\n",
|
||||
@@ -149,7 +146,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 19,
|
||||
"execution_count": 9,
|
||||
"id": "041cbc3d",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -157,21 +154,21 @@
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Peak VT: 2.3844444444444446\n",
|
||||
"HR at Peak VT: 172.80555555555554\n"
|
||||
"Peak VT: 2.3770000000000002\n",
|
||||
"HR at Peak VT: 171.525\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "stderr",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"/tmp/ipykernel_53922/361246798.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"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"df = pd.read_csv(f'{base_dir}/data/Pnoe_20250729_1550-Moran_Keirstyn.csv', delimiter=';')\n",
|
||||
"df = pd.read_csv('data/Pnoe_20250729_1550-Moran_Keirstyn.csv', delimiter=';')\n",
|
||||
"# Convert all columns to numeric where possible, coercing errors to NaN\n",
|
||||
"df = df.apply(pd.to_numeric, errors='ignore')\n",
|
||||
"df['VO2 Pulse'] = df['VO2(ml/min)'] / df['HR(bpm)'] # VO2 Pulse in mL/beat\n",
|
||||
@@ -179,7 +176,7 @@
|
||||
"df['CHO'] = df['EE(kcal/min)'] * df['CARBS(%)']/100\n",
|
||||
"df['FAT'] = df['EE(kcal/min)'] * df['FAT(%)']/100\n",
|
||||
"# Smooth key columns using rolling window\n",
|
||||
"window_size = 9\n",
|
||||
"window_size = 10\n",
|
||||
"\n",
|
||||
"# List of columns to smooth\n",
|
||||
"columns_to_smooth = ['VO2(ml/min)', 'VCO2(ml/min)', 'HR(bpm)', 'VT(l)', 'BF(bpm)', 'VE(l/min)', 'VO2 Pulse', 'VO2 Breath', 'CHO', 'FAT']\n",
|
||||
@@ -198,7 +195,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 20,
|
||||
"execution_count": 10,
|
||||
"id": "de7cadd1",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -206,7 +203,7 @@
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Percent FEV: 73.14246762099523\n"
|
||||
"Percent FEV: 72.91411042944786\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -217,7 +214,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 21,
|
||||
"execution_count": 11,
|
||||
"id": "cb972ed3",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -314,13 +311,13 @@
|
||||
"[1 rows x 147 columns]"
|
||||
]
|
||||
},
|
||||
"execution_count": 21,
|
||||
"execution_count": 11,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"personal_df = pd.read_excel(f'{base_dir}/data/SECA body comp for all patients.xlsx')\n",
|
||||
"personal_df = pd.read_excel('data/SECA body comp for all patients.xlsx')\n",
|
||||
"\n",
|
||||
"keirstyn_data = personal_df[personal_df['LastName'].str.contains('Moran', case=False, na=False)]\n",
|
||||
"keirstyn_data"
|
||||
@@ -328,7 +325,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 22,
|
||||
"execution_count": 12,
|
||||
"id": "98d9295a",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -336,7 +333,7 @@
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"VO2 Max: 48.19062126642772\n"
|
||||
"VO2 Max: 47.906290322580645\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -348,7 +345,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 13,
|
||||
"id": "cdfeb309",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -401,7 +398,7 @@
|
||||
"optimal_fat_idx = df['fat_carb_ratio'].idxmax()\n",
|
||||
"optimal_row = df.loc[optimal_fat_idx]\n",
|
||||
"\n",
|
||||
"print(\"Optimal Fat Burning Zone (highest fat:carb ratio):\")\n",
|
||||
"print(f\"Optimal Fat Burning Zone (highest fat:carb ratio):\")\n",
|
||||
"print(f\"Time: {optimal_row['T(sec)']} seconds\")\n",
|
||||
"print(f\"Fat burn rate: {optimal_row['FAT_smoothed']:.3f} kcal/min\")\n",
|
||||
"print(f\"Carb burn rate: {optimal_row['CHO_smoothed']:.3f} kcal/min\")\n",
|
||||
@@ -412,7 +409,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 14,
|
||||
"id": "4420cfea",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -470,7 +467,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 21,
|
||||
"id": "62803668",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -555,7 +552,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 16,
|
||||
"id": "07593b56",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -593,7 +590,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 17,
|
||||
"id": "c90415b2",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -656,7 +653,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 18,
|
||||
"id": "c3b2cc59",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -745,7 +742,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 19,
|
||||
"id": "672d68f3",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -769,52 +766,6 @@
|
||||
"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,
|
||||
@@ -826,7 +777,7 @@
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": ".venv",
|
||||
"display_name": "report_generation",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
@@ -840,7 +791,7 @@
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.12.6"
|
||||
"version": "3.12.3"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 99 KiB |
@@ -1,714 +0,0 @@
|
||||
"""
|
||||
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 datetime import datetime
|
||||
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"),
|
||||
next_testing_date: str = Form(...),
|
||||
report_type: str = Form(default="full"),
|
||||
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}"
|
||||
print(f"DEBUG: Received next_testing_date: '{next_testing_date}'")
|
||||
|
||||
# Generate session_id internally using timestamp for unique identification
|
||||
session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
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,
|
||||
"next_testing_date": next_testing_date,
|
||||
}
|
||||
|
||||
# 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,
|
||||
report_type=report_type,
|
||||
)
|
||||
|
||||
# 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["report_type"] = report_type
|
||||
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
|
||||
str(oxygenation_path) if oxygenation_path else None, # Oxygenation CSV
|
||||
)
|
||||
# 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"""
|
||||
# Check for required session data
|
||||
if not request.session.get("report_path"):
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
|
||||
# Ensure metrics exist in session, initialize if missing
|
||||
if "metrics" not in request.session:
|
||||
request.session["metrics"] = {"pnoe": {}, "spirometry": {}}
|
||||
|
||||
# Ensure patient_info exists
|
||||
if "patient_info" not in request.session:
|
||||
request.session["patient_info"] = {}
|
||||
|
||||
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"""
|
||||
# Check for required session data
|
||||
if not request.session.get("report_path") or not request.session.get(
|
||||
"patient_info"
|
||||
):
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
|
||||
# Ensure metrics exist in session, initialize if missing
|
||||
if "metrics" not in request.session:
|
||||
request.session["metrics"] = {"pnoe": {}, "spirometry": {}}
|
||||
|
||||
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()
|
||||
|
||||
# Helper function to safely convert form values to float
|
||||
def safe_float(value):
|
||||
"""Convert form value to float, return None if empty or invalid"""
|
||||
if not value or value.strip() == "":
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
# Build metric overrides
|
||||
metric_overrides = {"pnoe": {}, "spirometry": {}}
|
||||
|
||||
# Pnoe overrides - only add if value is provided and valid
|
||||
vo2_max_val = safe_float(form_data.get("vo2_max"))
|
||||
if vo2_max_val is not None:
|
||||
metric_overrides["pnoe"]["vo2_max"] = vo2_max_val
|
||||
|
||||
vo2_max_per_kg_val = safe_float(form_data.get("vo2_max_per_kg"))
|
||||
if vo2_max_per_kg_val is not None:
|
||||
metric_overrides["pnoe"]["vo2_max_per_kg"] = vo2_max_per_kg_val
|
||||
|
||||
peak_vt_val = safe_float(form_data.get("peak_vt"))
|
||||
if peak_vt_val is not None:
|
||||
metric_overrides["pnoe"]["peak_vt"] = peak_vt_val
|
||||
|
||||
peak_vt_hr_val = safe_float(form_data.get("peak_vt_hr"))
|
||||
if peak_vt_hr_val is not None:
|
||||
metric_overrides["pnoe"]["peak_vt_hr"] = peak_vt_hr_val
|
||||
|
||||
fat_max_value_val = safe_float(form_data.get("fat_max_value"))
|
||||
if fat_max_value_val is not None:
|
||||
metric_overrides["pnoe"]["fat_max_value"] = fat_max_value_val
|
||||
|
||||
fat_max_hr_val = safe_float(form_data.get("fat_max_hr"))
|
||||
if fat_max_hr_val is not None:
|
||||
metric_overrides["pnoe"]["fat_max_hr"] = fat_max_hr_val
|
||||
|
||||
# VT1 and VT2 overrides - use existing values if not provided
|
||||
existing_metrics = request.session.get("metrics", {})
|
||||
existing_pnoe = existing_metrics.get("pnoe", {})
|
||||
existing_vt1 = existing_pnoe.get("vt1", {})
|
||||
existing_vt2 = existing_pnoe.get("vt2", {})
|
||||
|
||||
vt1_hr_val = safe_float(form_data.get("vt1_hr"))
|
||||
vt1_speed_val = safe_float(form_data.get("vt1_speed"))
|
||||
vt1_time_val = safe_float(form_data.get("vt1_time"))
|
||||
|
||||
if vt1_hr_val is not None or vt1_speed_val is not None or vt1_time_val is not None:
|
||||
vt1_dict = {
|
||||
"HeartRate": vt1_hr_val
|
||||
if vt1_hr_val is not None
|
||||
else existing_vt1.get("HeartRate", 0),
|
||||
"Speed": vt1_speed_val
|
||||
if vt1_speed_val is not None
|
||||
else existing_vt1.get("Speed", 0),
|
||||
"Time": vt1_time_val
|
||||
if vt1_time_val is not None
|
||||
else existing_vt1.get("Time", 0),
|
||||
}
|
||||
metric_overrides["pnoe"]["vt1"] = vt1_dict
|
||||
|
||||
vt2_hr_val = safe_float(form_data.get("vt2_hr"))
|
||||
vt2_speed_val = safe_float(form_data.get("vt2_speed"))
|
||||
vt2_time_val = safe_float(form_data.get("vt2_time"))
|
||||
|
||||
if vt2_hr_val is not None or vt2_speed_val is not None or vt2_time_val is not None:
|
||||
vt2_dict = {
|
||||
"HeartRate": vt2_hr_val
|
||||
if vt2_hr_val is not None
|
||||
else existing_vt2.get("HeartRate", 0),
|
||||
"Speed": vt2_speed_val
|
||||
if vt2_speed_val is not None
|
||||
else existing_vt2.get("Speed", 0),
|
||||
"Time": vt2_time_val
|
||||
if vt2_time_val is not None
|
||||
else existing_vt2.get("Time", 0),
|
||||
}
|
||||
metric_overrides["pnoe"]["vt2"] = vt2_dict
|
||||
|
||||
# Heart rate zones - only add if value is provided
|
||||
for i in range(1, 6):
|
||||
zone_key = f"zone{i}_bpm"
|
||||
zone_val = form_data.get(zone_key)
|
||||
if zone_val and zone_val.strip():
|
||||
metric_overrides["pnoe"][zone_key] = zone_val.strip()
|
||||
|
||||
# Spirometry overrides - only add if value is provided and valid
|
||||
fvc_best_val = safe_float(form_data.get("fvc_best"))
|
||||
if fvc_best_val is not None:
|
||||
metric_overrides["spirometry"]["fvc_best"] = fvc_best_val
|
||||
|
||||
fvc_pred_val = safe_float(form_data.get("fvc_pred"))
|
||||
if fvc_pred_val is not None:
|
||||
metric_overrides["spirometry"]["fvc_pred"] = fvc_pred_val
|
||||
|
||||
fev1_best_val = safe_float(form_data.get("fev1_best"))
|
||||
if fev1_best_val is not None:
|
||||
metric_overrides["spirometry"]["fev1_best"] = fev1_best_val
|
||||
|
||||
fev1_pred_val = safe_float(form_data.get("fev1_pred"))
|
||||
if fev1_pred_val is not None:
|
||||
metric_overrides["spirometry"]["fev1_pred"] = fev1_pred_val
|
||||
|
||||
fev1_fvc_pct_best_val = safe_float(form_data.get("fev1_fvc_pct_best"))
|
||||
if fev1_fvc_pct_best_val is not None:
|
||||
metric_overrides["spirometry"]["fev1_fvc_pct_best"] = fev1_fvc_pct_best_val
|
||||
|
||||
fev1_fvc_pct_pred_val = safe_float(form_data.get("fev1_fvc_pct_pred"))
|
||||
if fev1_fvc_pct_pred_val is not None:
|
||||
metric_overrides["spirometry"]["fev1_fvc_pct_pred"] = fev1_fvc_pct_pred_val
|
||||
|
||||
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
|
||||
# Get report_type from session or default to "full"
|
||||
report_type = request.session.get("report_type", "full")
|
||||
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,
|
||||
report_type=report_type,
|
||||
)
|
||||
|
||||
# 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
|
||||
str(oxygenation_path) if oxygenation_path else None, # Oxygenation CSV
|
||||
)
|
||||
# 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"),
|
||||
}
|
||||
# Calculate fat_mass and lean_mass (extract_patient_info does this when no SECA file)
|
||||
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"),
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
# Generate session_id internally using timestamp for unique identification
|
||||
session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
# 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)
|
||||
@@ -1,515 +0,0 @@
|
||||
<div class="w-full page bg-white">
|
||||
<!-- Main Content -->
|
||||
<div class="px-8 py-6">
|
||||
<!-- Page Title -->
|
||||
<h1 class="text-3xl font-bold text-black mb-6">Fuelling Analysis</h1>
|
||||
|
||||
<!-- Flowchart Image -->
|
||||
<div class="mb-8 flex justify-center">
|
||||
<img
|
||||
src="data:image/png;base64,{{ fuelling_analysis_flowchart }}"
|
||||
alt="Fuelling Analysis Flowchart"
|
||||
class="w-full max-w-4xl h-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Carbohydrate Storage Table -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-bold text-black mb-4 text-center">
|
||||
Estimated Carbohydrate Storage by Weight and Sex in Athletes
|
||||
</h2>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<table
|
||||
class="table-auto border-collapse border border-gray-400 text-sm"
|
||||
>
|
||||
<thead>
|
||||
<tr class="bg-gray-200">
|
||||
<th
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
Weight (kg)
|
||||
</th>
|
||||
<th
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
Sex
|
||||
</th>
|
||||
<th
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
Muscle Glycogen (g)
|
||||
</th>
|
||||
<th
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
Liver Glycogen (g)
|
||||
</th>
|
||||
<th
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
Blood Glucose (g)
|
||||
</th>
|
||||
<th
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
Total Carb (g)
|
||||
</th>
|
||||
<th
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
Total Carb (kcal)
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
50
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
male
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
292
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
105
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
4.5
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
402
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
1608
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="bg-gray-50">
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
50
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
female
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
228
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
85
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
4.5
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
317
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
1268
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
60
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
male
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
351
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
105
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
4.5
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
460
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
1842
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="bg-gray-50">
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
60
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
female
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
273
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
85
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
4.5
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
362
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
1450
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
70
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
male
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
410
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
105
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
4.5
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
519
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
2076
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="bg-gray-50">
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
70
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
female
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
318
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
85
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
4.5
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
408
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
1632
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
80
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
male
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
468
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
105
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
4.5
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
578
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
2310
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="bg-gray-50">
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
80
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
female
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
364
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
85
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
4.5
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
454
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
1814
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
90
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
male
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
526
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
105
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
4.5
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
636
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
2544
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="bg-gray-50">
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
90
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
female
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
409
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
85
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
4.5
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
499
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
1996
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
100
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
male
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
585
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
105
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
4.5
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
694
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
2778
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="bg-gray-50">
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
100
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
female
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
455
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
85
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
4.5
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
544
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-400 px-4 py-2 text-center"
|
||||
>
|
||||
2178
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,161 +0,0 @@
|
||||
<div class="page bg-white p-8 max-w-4xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-4">
|
||||
Local Muscle Activity
|
||||
</h1>
|
||||
<h2 class="text-xl font-semibold text-gray-800 mb-2">
|
||||
Muscle Oxygenation Assessment
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 leading-relaxed">
|
||||
SMO2 testing (Skeletal Muscle Oxygen Saturation) is an analysis of
|
||||
how effectively oxygen is being used at a particular muscle. It
|
||||
helps determine limitations on if the muscle is effectively using
|
||||
oxygen when exercising.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Combined Muscle Oxygenation Chart -->
|
||||
<div class="mb-6">
|
||||
<div class="flex justify-center mb-4">
|
||||
<img
|
||||
src="data:image/png;base64,{{ muscle_oxygenation_chart }}"
|
||||
alt="Muscle Oxygenation Chart"
|
||||
class="w-full h-auto max-w-6xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metrics Summary Grid: Left and Right as two side-by-side panels, fields as two columns inside each -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<!-- Left Leg Metrics -->
|
||||
<div class="bg-blue-50 p-3 rounded-lg border-l-4 border-blue-300 h-full flex flex-col">
|
||||
<h3 class="text-base font-bold text-gray-900 mb-3 text-center">
|
||||
Left Leg Analysis
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<!-- Baseline SmO2 -->
|
||||
<div class="bg-white p-2 rounded shadow-sm col-span-2 md:col-span-1">
|
||||
<div class="text-xs font-semibold text-gray-700">
|
||||
Baseline SmO₂
|
||||
</div>
|
||||
<div class="text-base font-bold text-gray-900">
|
||||
{{ left_baseline_smo2 | default('75.4%') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Minimum SmO2 -->
|
||||
<div class="bg-white p-2 rounded shadow-sm col-span-2 md:col-span-1">
|
||||
<div class="text-xs font-semibold text-gray-700">
|
||||
Minimum SmO₂
|
||||
</div>
|
||||
<div class="text-base font-bold text-gray-900">
|
||||
{{ left_minimum_smo2 | default('69.3%') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ left_minimum_lap | default('Lap 6') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Oxygen Drop -->
|
||||
<div class="bg-white p-2 rounded shadow-sm col-span-2 md:col-span-1">
|
||||
<div class="text-xs font-semibold text-gray-700">
|
||||
Oxygen Drop
|
||||
</div>
|
||||
<div class="text-base font-bold text-gray-900">
|
||||
{{ left_oxygen_drop | default('6.0%') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ left_drop_percentage | default('8% decrease') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Recovery -->
|
||||
<div class="bg-white p-2 rounded shadow-sm col-span-2 md:col-span-1">
|
||||
<div class="text-xs font-semibold text-gray-700">
|
||||
Recovery
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">Optimal >100%</div>
|
||||
<div class="text-base font-bold text-green-600">
|
||||
{{ left_recovery_percentage | default('109%') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Leg Metrics -->
|
||||
<div class="bg-purple-50 p-3 rounded-lg border-l-4 border-purple-300 h-full flex flex-col">
|
||||
<h3 class="text-base font-bold text-gray-900 mb-3 text-center">
|
||||
Right Leg Analysis
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<!-- Baseline SmO2 -->
|
||||
<div class="bg-white p-2 rounded shadow-sm col-span-2 md:col-span-1">
|
||||
<div class="text-xs font-semibold text-gray-700">
|
||||
Baseline SmO₂
|
||||
</div>
|
||||
<div class="text-base font-bold text-gray-900">
|
||||
{{ right_baseline_smo2 | default('82.9%') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Minimum SmO2 -->
|
||||
<div class="bg-white p-2 rounded shadow-sm col-span-2 md:col-span-1">
|
||||
<div class="text-xs font-semibold text-gray-700">
|
||||
Minimum SmO₂
|
||||
</div>
|
||||
<div class="text-base font-bold text-gray-900">
|
||||
{{ right_minimum_smo2 | default('73.7%') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ right_minimum_lap | default('Lap 6') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Oxygen Drop -->
|
||||
<div class="bg-white p-2 rounded shadow-sm col-span-2 md:col-span-1">
|
||||
<div class="text-xs font-semibold text-gray-700">
|
||||
Oxygen Drop
|
||||
</div>
|
||||
<div class="text-base font-bold text-gray-900">
|
||||
{{ right_oxygen_drop | default('9.3%') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ right_drop_percentage | default('11% decrease') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Recovery -->
|
||||
<div class="bg-white p-2 rounded shadow-sm col-span-2 md:col-span-1">
|
||||
<div class="text-xs font-semibold text-gray-700">
|
||||
Recovery
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">Optimal >100%</div>
|
||||
<div class="text-base font-bold text-blue-600">
|
||||
{{ right_recovery_percentage | default('97%') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Findings Summary -->
|
||||
<div class="bg-gray-100 p-3 rounded-lg">
|
||||
<h3 class="text-sm font-bold text-gray-900 mb-2">Key Findings</h3>
|
||||
<div class="text-xs text-gray-700 space-y-1">
|
||||
<p>
|
||||
• <strong>Left leg</strong> showed better oxygen maintenance
|
||||
during high-intensity work
|
||||
</p>
|
||||
<p>
|
||||
•
|
||||
<strong>
|
||||
{{ recovery_assessment | default('Excellent recovery capacity') }}
|
||||
</strong>
|
||||
- both legs recovered well
|
||||
</p>
|
||||
<p>
|
||||
• <strong>Heart rate progression:</strong> {{ hr_warmup |
|
||||
default('93') }} → {{ hr_max | default('168') }} bpm
|
||||
</p>
|
||||
<p>
|
||||
• <strong>Test duration:</strong> {{ test_duration |
|
||||
default('~21 minutes active test') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,529 +0,0 @@
|
||||
<div class="w-full page bg-white">
|
||||
<!-- Header Section -->
|
||||
<!-- Main Content -->
|
||||
<div class="px-8 py-6">
|
||||
<!-- Page Title -->
|
||||
<h1 class="text-3xl font-bold text-black mb-8 text-center">
|
||||
Training Recommendations
|
||||
</h1>
|
||||
|
||||
<!-- Training Recommendations Section -->
|
||||
<div class="grid grid-cols-2 gap-8 mb-8">
|
||||
<!-- Left Side: Zone Recommendations -->
|
||||
<div class="bg-gray-200 p-4 rounded-lg border-2 border-gray-300">
|
||||
<!-- Zone 2 Recommendations -->
|
||||
<div class="mb-4">
|
||||
<h3 class="text-sm font-bold text-black mb-2">
|
||||
Zone 2 {{ zone2_frequency | default('3-4x/week') }}:
|
||||
</h3>
|
||||
<ul
|
||||
class="text-xs text-black space-y-0.5 list-disc list-inside ml-2"
|
||||
>
|
||||
<li>
|
||||
{{ zone2_duration | default('40+ minutes') }} of
|
||||
Steady State Cardio (HR
|
||||
<span class="border-b border-black"
|
||||
>{{ zone2_hr_range | default('____') }}</span
|
||||
>
|
||||
bpm)
|
||||
</li>
|
||||
<li>
|
||||
<span class="border-b border-black"
|
||||
>{{ zone2_speed | default('___') }}</span
|
||||
>
|
||||
mph at {{ zone2_incline | default('2% Incline') }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Zone 3 Recommendations -->
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-black mb-2">
|
||||
Zone 3: {{ zone3_frequency | default('1-2x/week') }}:
|
||||
</h3>
|
||||
<ul
|
||||
class="text-xs text-black space-y-0.5 list-disc list-inside ml-2"
|
||||
>
|
||||
<li>
|
||||
{{ zone3_duration | default('10-20 minutes') }} in
|
||||
zone 3 (HR
|
||||
<span class="border-b border-black"
|
||||
>{{ zone3_hr_range | default('____') }}</span
|
||||
>
|
||||
bpm)
|
||||
</li>
|
||||
<li>
|
||||
<span class="border-b border-black"
|
||||
>{{ zone3_speed | default('___') }}</span
|
||||
>mph + at {{ zone3_incline | default('2% Incline')
|
||||
}}
|
||||
</li>
|
||||
<li class="text-orange-500">
|
||||
Slow down cadence until HR reaches
|
||||
<span class="border-b border-orange-500"
|
||||
>{{ zone3_target_hr | default('___') }}</span
|
||||
>bpm
|
||||
</li>
|
||||
<li>
|
||||
<span class="border-b border-black"
|
||||
>{{ zone3_recovery_speed | default('____')
|
||||
}}</span
|
||||
>mph at {{ zone3_recovery_incline | default('2%
|
||||
Incline') }}
|
||||
</li>
|
||||
<li class="text-orange-500">
|
||||
Maintain HR in zone 1 (<span
|
||||
class="border-b border-orange-500"
|
||||
>{{ zone1_hr_range | default('____') }}</span
|
||||
>bpm) for {{ zone1_duration | default('4-8 minutes')
|
||||
}}
|
||||
</li>
|
||||
<li>
|
||||
Repeat {{ zone3_repeats | default('2-3 times') }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Side: Training Table -->
|
||||
<div>
|
||||
<table class="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
class="bg-cyan-300 border border-gray-400 px-2 py-1.5 text-black font-bold text-xs"
|
||||
>
|
||||
Type
|
||||
</th>
|
||||
<th
|
||||
class="bg-cyan-300 border border-gray-400 px-2 py-1.5 text-black font-bold text-xs"
|
||||
>
|
||||
Sets
|
||||
</th>
|
||||
<th
|
||||
class="bg-cyan-300 border border-gray-400 px-2 py-1.5 text-black font-bold text-xs"
|
||||
>
|
||||
Effort Duration
|
||||
</th>
|
||||
<th
|
||||
class="bg-cyan-300 border border-gray-400 px-2 py-1.5 text-black font-bold text-xs"
|
||||
>
|
||||
Zone
|
||||
</th>
|
||||
<th
|
||||
class="bg-cyan-300 border border-gray-400 px-2 py-1.5 text-black font-bold text-xs"
|
||||
>
|
||||
RPE
|
||||
</th>
|
||||
<th
|
||||
class="bg-cyan-300 border border-gray-400 px-2 py-1.5 text-black font-bold text-xs"
|
||||
>
|
||||
Recovery Duration
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Short Row -->
|
||||
<tr>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black font-medium text-xs"
|
||||
>
|
||||
Short
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ short_sets | default('8-10') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ short_duration | default('10-30 seconds') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ short_zone | default('5') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ short_rpe | default('10') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ short_recovery | default('20-60 seconds') }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Medium Row -->
|
||||
<tr>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black font-medium text-xs"
|
||||
>
|
||||
Medium
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ medium_sets | default('6-8') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ medium_duration | default('30-90 seconds') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ medium_zone | default('4') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ medium_rpe | default('8-9') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ medium_recovery | default('30-90 seconds') }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Long Row -->
|
||||
<tr>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black font-medium text-xs"
|
||||
>
|
||||
Long
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ long_sets | default('4-6') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ long_duration | default('5-10 minutes') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ long_zone | default('3/4') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ long_rpe | default('7-8') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ long_recovery | default('2.5-5 minutes') }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Tempo Row -->
|
||||
<tr>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black font-medium text-xs"
|
||||
>
|
||||
Tempo
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ tempo_sets | default('2-3') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ tempo_duration | default('10-20 minutes') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ tempo_zone | default('3') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ tempo_rpe | default('6-7') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ tempo_recovery | default('4-8 minutes') }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Cardio Row -->
|
||||
<tr>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black font-medium text-xs"
|
||||
>
|
||||
Cardio
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ cardio_sets | default('1') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ cardio_duration | default('>40 minutes') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ cardio_zone | default('2') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ cardio_rpe | default('4-5') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 px-2 py-1.5 text-center text-black text-xs"
|
||||
>
|
||||
{{ cardio_recovery | default('N/A') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Training Week Example Section -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-black mb-6 text-center">
|
||||
Training Week Example with Progression
|
||||
</h2>
|
||||
|
||||
<!-- Week 1 -->
|
||||
<div class="mb-4">
|
||||
<div class="grid grid-cols-7 gap-1">
|
||||
<!-- Monday -->
|
||||
<div class="bg-cyan-300 p-2 text-center">
|
||||
<div class="font-bold text-black text-sm mb-1">
|
||||
Monday
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tuesday -->
|
||||
<div class="bg-cyan-300 p-2 text-center">
|
||||
<div class="font-bold text-black text-sm mb-1">
|
||||
Tuesday
|
||||
</div>
|
||||
</div>
|
||||
<!-- Wednesday -->
|
||||
<div class="bg-cyan-300 p-2 text-center">
|
||||
<div class="font-bold text-black text-sm mb-1">
|
||||
Wednesday
|
||||
</div>
|
||||
</div>
|
||||
<!-- Thursday -->
|
||||
<div class="bg-cyan-300 p-2 text-center">
|
||||
<div class="font-bold text-black text-sm mb-1">
|
||||
Thursday
|
||||
</div>
|
||||
</div>
|
||||
<!-- Friday -->
|
||||
<div class="bg-cyan-300 p-2 text-center">
|
||||
<div class="font-bold text-black text-sm mb-1">
|
||||
Friday
|
||||
</div>
|
||||
</div>
|
||||
<!-- Saturday -->
|
||||
<div class="bg-cyan-300 p-2 text-center">
|
||||
<div class="font-bold text-black text-sm mb-1">
|
||||
Saturday
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sunday -->
|
||||
<div class="bg-cyan-300 p-2 text-center">
|
||||
<div class="font-bold text-black text-sm mb-1">
|
||||
Sunday
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-7 gap-1 mt-1">
|
||||
<!-- Monday Content -->
|
||||
<div class="bg-gray-200 p-2 text-center min-h-[60px]">
|
||||
<div class="text-xs text-black">
|
||||
{{ week1_mon_zone | default('Zone 2') }}
|
||||
</div>
|
||||
<div class="text-xs text-black">
|
||||
{{ week1_mon_duration | default('45 mins') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tuesday Content -->
|
||||
<div class="bg-gray-200 p-2 text-center min-h-[60px]">
|
||||
<div class="text-xs text-black">
|
||||
{{ week1_tue_zone | default('Zone 2') }}
|
||||
</div>
|
||||
<div class="text-xs text-black">
|
||||
{{ week1_tue_duration | default('45 mins') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Wednesday Content -->
|
||||
<div class="bg-gray-200 p-2 text-center min-h-[60px]">
|
||||
<div class="text-xs text-black">
|
||||
{{ week1_wed_zone | default('Zone 3') }}
|
||||
</div>
|
||||
<div class="text-xs text-black">
|
||||
{{ week1_wed_duration1 | default('10mins On') }}
|
||||
</div>
|
||||
<div class="text-xs text-black">
|
||||
{{ week1_wed_duration2 | default('8mins Rest') }}
|
||||
</div>
|
||||
<div class="text-xs text-black">
|
||||
{{ week1_wed_sets | default('x2') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Thursday Content -->
|
||||
<div class="bg-gray-200 p-2 text-center min-h-[60px]">
|
||||
<div class="text-xs text-black">
|
||||
{{ week1_thu_content | default('') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Friday Content -->
|
||||
<div class="bg-gray-200 p-2 text-center min-h-[60px]">
|
||||
<div class="text-xs text-black">
|
||||
{{ week1_fri_zone | default('Zone 2') }}
|
||||
</div>
|
||||
<div class="text-xs text-black">
|
||||
{{ week1_fri_duration | default('45 mins') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Saturday Content -->
|
||||
<div class="bg-gray-200 p-2 text-center min-h-[60px]">
|
||||
<div class="text-xs text-black">
|
||||
{{ week1_sat_content | default('') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sunday Content -->
|
||||
<div class="bg-gray-200 p-2 text-center min-h-[60px]">
|
||||
<div class="text-xs text-black">
|
||||
{{ week1_sun_content | default('') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Week 2 -->
|
||||
<div class="mb-4">
|
||||
<div class="grid grid-cols-7 gap-1">
|
||||
<!-- Monday -->
|
||||
<div class="bg-cyan-300 p-2 text-center">
|
||||
<div class="font-bold text-black text-sm mb-1">
|
||||
Monday
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tuesday -->
|
||||
<div class="bg-cyan-300 p-2 text-center">
|
||||
<div class="font-bold text-black text-sm mb-1">
|
||||
Tuesday
|
||||
</div>
|
||||
</div>
|
||||
<!-- Wednesday -->
|
||||
<div class="bg-cyan-300 p-2 text-center">
|
||||
<div class="font-bold text-black text-sm mb-1">
|
||||
Wednesday
|
||||
</div>
|
||||
</div>
|
||||
<!-- Thursday -->
|
||||
<div class="bg-cyan-300 p-2 text-center">
|
||||
<div class="font-bold text-black text-sm mb-1">
|
||||
Thursday
|
||||
</div>
|
||||
</div>
|
||||
<!-- Friday -->
|
||||
<div class="bg-cyan-300 p-2 text-center">
|
||||
<div class="font-bold text-black text-sm mb-1">
|
||||
Friday
|
||||
</div>
|
||||
</div>
|
||||
<!-- Saturday -->
|
||||
<div class="bg-cyan-300 p-2 text-center">
|
||||
<div class="font-bold text-black text-sm mb-1">
|
||||
Saturday
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sunday -->
|
||||
<div class="bg-cyan-300 p-2 text-center">
|
||||
<div class="font-bold text-black text-sm mb-1">
|
||||
Sunday
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-7 gap-1 mt-1">
|
||||
<!-- Monday Content -->
|
||||
<div class="bg-gray-200 p-2 text-center min-h-[60px]">
|
||||
<div class="text-xs text-black">
|
||||
{{ week2_mon_zone | default('Zone 2') }}
|
||||
</div>
|
||||
<div class="text-xs text-black">
|
||||
{{ week2_mon_duration | default('50 mins') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tuesday Content -->
|
||||
<div class="bg-gray-200 p-2 text-center min-h-[60px]">
|
||||
<div class="text-xs text-black">
|
||||
{{ week2_tue_zone | default('Zone 2') }}
|
||||
</div>
|
||||
<div class="text-xs text-black">
|
||||
{{ week2_tue_duration | default('50 mins') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Wednesday Content -->
|
||||
<div class="bg-gray-200 p-2 text-center min-h-[60px]">
|
||||
<div class="text-xs text-black">
|
||||
{{ week2_wed_zone | default('Zone 3') }}
|
||||
</div>
|
||||
<div class="text-xs text-black">
|
||||
{{ week2_wed_duration1 | default('10mins On') }}
|
||||
</div>
|
||||
<div class="text-xs text-black">
|
||||
{{ week2_wed_duration2 | default('6mins Rest') }}
|
||||
</div>
|
||||
<div class="text-xs text-black">
|
||||
{{ week2_wed_sets | default('x2') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Thursday Content -->
|
||||
<div class="bg-gray-200 p-2 text-center min-h-[60px]">
|
||||
<div class="text-xs text-black">
|
||||
{{ week2_thu_content | default('') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Friday Content -->
|
||||
<div class="bg-gray-200 p-2 text-center min-h-[60px]">
|
||||
<div class="text-xs text-black">
|
||||
{{ week2_fri_zone | default('Zone 2') }}
|
||||
</div>
|
||||
<div class="text-xs text-black">
|
||||
{{ week2_fri_duration | default('50 mins') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Saturday Content -->
|
||||
<div class="bg-gray-200 p-2 text-center min-h-[60px]">
|
||||
<div class="text-xs text-black">
|
||||
{{ week2_sat_content | default('') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sunday Content -->
|
||||
<div class="bg-gray-200 p-2 text-center min-h-[60px]">
|
||||
<div class="text-xs text-black">
|
||||
{{ week2_sun_content | default('') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Section -->
|
||||
</div>
|
||||
@@ -1,109 +0,0 @@
|
||||
<div class="w-full page bg-white">
|
||||
<!-- Main Content -->
|
||||
<div class="px-8 py-6">
|
||||
<!-- Page Title -->
|
||||
<h1 class="text-3xl font-bold text-black mb-8">Next Steps:</h1>
|
||||
|
||||
<!-- Calorie Tracking Section -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-bold text-black mb-4">Calorie Tracking</h2>
|
||||
<ul class="text-black space-y-2 list-disc list-inside ml-4">
|
||||
<li>
|
||||
Download and create an account with My Fitness Pal (or
|
||||
preferred nutrition tracker)
|
||||
</li>
|
||||
<li>
|
||||
Fill out the "My Profile" section with your goals (ie:
|
||||
height, weight, target weight)
|
||||
<ul class="mt-2 ml-6 space-y-1 list-disc list-inside">
|
||||
<li>Input your Macros</li>
|
||||
<li>Click the three dots on the bottom right corner</li>
|
||||
<li>Click "Goals"</li>
|
||||
<li>
|
||||
Click "Calorie, Carbs, Protein and Fat Goals" under
|
||||
the Nutrition Goals
|
||||
</li>
|
||||
<li>
|
||||
Set the Calories, Carbs, Protein, and Fat to the
|
||||
recommended macro outlined above.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
Once completed fill out your food intake from each meal on
|
||||
the main page
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Recommendation Note -->
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-black italic font-semibold">
|
||||
It's highly recommended to purchase a weight and food scale
|
||||
for more accurate results.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daily Tasks Section -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-bold text-black mb-4">Daily Tasks</h2>
|
||||
<ul class="text-black space-y-2 list-disc list-inside ml-4">
|
||||
<li>
|
||||
Weigh yourself in the morning, after your first bowel
|
||||
movement, and naked
|
||||
</li>
|
||||
<li>Log your weight into your my fitness pal app</li>
|
||||
<li>
|
||||
Track calories in grams - FOLLOW YOUR PERSONAL
|
||||
RECOMMENDATIONS.
|
||||
</li>
|
||||
<li>
|
||||
Log in a diary:
|
||||
<ul class="mt-2 ml-6 space-y-1 list-disc list-inside">
|
||||
<li>
|
||||
Log any additional prescribed recommendation (i.e
|
||||
breath work)
|
||||
</li>
|
||||
<li>
|
||||
Complete the prescribed training recommendations
|
||||
(i.e Zone 2 Training)
|
||||
</li>
|
||||
<li>
|
||||
Log additional physical activity (i.e Monday -
|
||||
Strength Training 1 hour)
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Two weeks after Appointment Section -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-bold text-black mb-4">
|
||||
Two weeks after Appointment
|
||||
</h2>
|
||||
<ul class="text-black space-y-2 list-disc list-inside ml-4">
|
||||
<li>
|
||||
Should you find the macronutrient breakdown difficult to
|
||||
follow, reach out to us to discuss a change within your
|
||||
caloric parameters
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Contact Information -->
|
||||
<div class="mb-12 text-center">
|
||||
<p class="text-lg font-bold text-black">
|
||||
Should you have any questions or concerns please contact us!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Recommended Next Testing Date -->
|
||||
<div class="mb-8 text-center">
|
||||
<h2 class="text-2xl font-bold text-black">
|
||||
<span class="underline">Recommended Next Testing Date:</span>
|
||||
<span class="ml-2">{{ next_testing_date }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,35 +0,0 @@
|
||||
<div class="w-full page bg-white">
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="px-8 py-6">
|
||||
<!-- Page Title -->
|
||||
<h1 class="text-3xl font-bold text-black mb-6">Glossary</h1>
|
||||
|
||||
<!-- Body Fat Percentage -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-lg font-bold text-black mb-2">Body Fat Percentage:</h2>
|
||||
<p class="text-sm text-black leading-relaxed">The percentage of your overall body weight that is composed of fat cells. Body fat percentage can be reduced by either losing weight from fat mass, while maintaining lean mass, or maintaining fat mass while increasing lean mass.</p>
|
||||
</div>
|
||||
|
||||
<!-- Metabolic Rate -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-lg font-bold text-black mb-2">Metabolic Rate:</h2>
|
||||
<p class="text-sm text-black leading-relaxed">Metabolic Rate measures the number of calories your body burns for basic functions and movement, based on factors like weight, age, gender, and height. A higher metabolic rate helps prevent weight gain and supports weight loss by ensuring you burn enough calories. Tracking metabolic rate is key for managing weight and preventing conditions linked to metabolic dysfunction. Positive influences include resistance exercise, proper sleep, and adequate protein, while negative factors include extreme dieting, yo-yo dieting, and excessive cardio. Improving it involves resistance training and optimal nutrition.</p>
|
||||
</div>
|
||||
|
||||
<!-- Fuel Source -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-lg font-bold text-black mb-2">Fuel Source:</h2>
|
||||
<p class="text-sm text-black leading-relaxed mb-2">Fat-burning efficiency measures your cells' ability to use fat as fuel, reflecting mitochondrial and cellular health. It indicates how well your body balances fat and carbohydrate usage to support energy needs, assessed by analyzing oxygen and carbon dioxide in your breath. High fat-burning efficiency suggests strong metabolic and mitochondrial function, linked to better weight management and longevity.</p>
|
||||
<p class="text-sm text-black leading-relaxed">To improve fat-burning efficiency, focus on Zone 2 endurance training and potentially intermittent fasting to enhance oxygen absorption and cellular function. Zone 5 interval training will also help improve fat burning mitochondrial density and capillarization. Factors that reduce fat burning ability include diets high in processed foods, alcohol, and large meals before bed. Conditions related to metabolic stress also hinder fat burning abilities.</p>
|
||||
</div>
|
||||
|
||||
<!-- NEAT -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-lg font-bold text-black mb-2">NEAT (Non-Exercise Activity Thermogenesis)</h2>
|
||||
<p class="text-sm text-black leading-relaxed">refers to the energy expended for all activities that are not deliberate exercise or structured physical activity. This includes daily movements such as walking, fidgeting, standing, cleaning, typing, and even simple tasks like cooking or shopping. NEAT contributes significantly to the total caloric expenditure and plays a key role in maintaining body weight and overall energy balance. It varies widely among individuals, depending on lifestyle, occupation, and habits.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,474 +0,0 @@
|
||||
<div class="w-full page bg-white">
|
||||
<!-- Main Content -->
|
||||
<div class="p-8">
|
||||
<!-- Body Fat Percent Master Chart Section -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold mb-4 text-center">
|
||||
Body Fat Percent Master Chart
|
||||
</h1>
|
||||
<div class="w-full max-w-5xl mx-auto">
|
||||
<img
|
||||
src="data:image/png;base64,{{ body_fat_percentage_chart }}"
|
||||
alt="Body Fat Percentage"
|
||||
class="w-full h-auto object-contain chart-large"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resting Heart Rate Section -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-bold mb-4 text-center">
|
||||
Resting Heart Rate
|
||||
</h2>
|
||||
|
||||
<!-- Male Table -->
|
||||
<div class="mb-4">
|
||||
<table
|
||||
class="w-full border-collapse border border-gray-300 text-xs"
|
||||
>
|
||||
<thead>
|
||||
<tr class="bg-blue-300">
|
||||
<th
|
||||
class="border border-gray-300 p-1 font-bold text-center"
|
||||
>
|
||||
Age (M)
|
||||
</th>
|
||||
<th
|
||||
class="border border-gray-300 p-1 font-bold text-center"
|
||||
>
|
||||
Poor
|
||||
</th>
|
||||
<th
|
||||
class="border border-gray-300 p-1 font-bold text-center"
|
||||
>
|
||||
Below Average
|
||||
</th>
|
||||
<th
|
||||
class="border border-gray-300 p-1 font-bold text-center"
|
||||
>
|
||||
Average
|
||||
</th>
|
||||
<th
|
||||
class="border border-gray-300 p-1 font-bold text-center"
|
||||
>
|
||||
Above Average
|
||||
</th>
|
||||
<th
|
||||
class="border border-gray-300 p-1 font-bold text-center"
|
||||
>
|
||||
Good
|
||||
</th>
|
||||
<th
|
||||
class="border border-gray-300 p-1 font-bold text-center"
|
||||
>
|
||||
Excellent
|
||||
</th>
|
||||
<th
|
||||
class="border border-gray-300 p-1 font-bold text-center"
|
||||
>
|
||||
Athlete
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
class="border border-gray-300 p-1 font-medium text-center"
|
||||
>
|
||||
18-25
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
85bpm +
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
79-84bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
74-78bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
70-73bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
66-69bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
61-65bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
40-60bpm
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="border border-gray-300 p-1 font-medium text-center"
|
||||
>
|
||||
26-35
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
83bpm +
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
77-82bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
73-76bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
69-72bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
65-68bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
60-64bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
42-59bpm
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="border border-gray-300 p-1 font-medium text-center"
|
||||
>
|
||||
36-45
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
85bpm +
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
79-84bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
74-78bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
70-73bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
65-69bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
60-64bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
45-59bpm
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="border border-gray-300 p-1 font-medium text-center"
|
||||
>
|
||||
46-55
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
84bpm +
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
78-83bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
74-77bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
70-73bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
66-69bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
61-65bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
48-60bpm
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="border border-gray-300 p-1 font-medium text-center"
|
||||
>
|
||||
56-65
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
84bpm +
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
78-83bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
74-77bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
70-73bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
65-69bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
60-64bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
50-59bpm
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="border border-gray-300 p-1 font-medium text-center"
|
||||
>
|
||||
65+
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
84bpm +
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
77-83bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
73-76bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
70-73bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
65-69bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
60-64bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
52-59bpm
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Female Table -->
|
||||
<div class="mb-4">
|
||||
<table
|
||||
class="w-full border-collapse border border-gray-300 text-xs"
|
||||
>
|
||||
<thead>
|
||||
<tr class="bg-blue-300">
|
||||
<th
|
||||
class="border border-gray-300 p-1 font-bold text-center"
|
||||
>
|
||||
Age (F)
|
||||
</th>
|
||||
<th
|
||||
class="border border-gray-300 p-1 font-bold text-center"
|
||||
>
|
||||
Poor
|
||||
</th>
|
||||
<th
|
||||
class="border border-gray-300 p-1 font-bold text-center"
|
||||
>
|
||||
Below Average
|
||||
</th>
|
||||
<th
|
||||
class="border border-gray-300 p-1 font-bold text-center"
|
||||
>
|
||||
Average
|
||||
</th>
|
||||
<th
|
||||
class="border border-gray-300 p-1 font-bold text-center"
|
||||
>
|
||||
Above Average
|
||||
</th>
|
||||
<th
|
||||
class="border border-gray-300 p-1 font-bold text-center"
|
||||
>
|
||||
Good
|
||||
</th>
|
||||
<th
|
||||
class="border border-gray-300 p-1 font-bold text-center"
|
||||
>
|
||||
Excellent
|
||||
</th>
|
||||
<th
|
||||
class="border border-gray-300 p-1 font-bold text-center"
|
||||
>
|
||||
Athlete
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
class="border border-gray-300 p-1 font-medium text-center"
|
||||
>
|
||||
18-25
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
82bpm +
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
74-81bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
70-73bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
66-69bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
62-65bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
56-61bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
40-55bpm
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="border border-gray-300 p-1 font-medium text-center"
|
||||
>
|
||||
26-35
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
82bpm +
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
75-81bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
71-74bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
66-70bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
62-65bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
55-61bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
44-54bpm
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="border border-gray-300 p-1 font-medium text-center"
|
||||
>
|
||||
36-45
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
83bpm +
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
76-82bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
71-75bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
67-70bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
63-66bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
57-62bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
47-56bpm
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="border border-gray-300 p-1 font-medium text-center"
|
||||
>
|
||||
46-55
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
84bpm +
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
77-83bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
72-76bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
68-71bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
64-67bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
58-63bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
49-57bpm
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="border border-gray-300 p-1 font-medium text-center"
|
||||
>
|
||||
56-65
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
82bpm +
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
76-81bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
72-75bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
68-71bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
62-67bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
57-61bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
51-56bpm
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="border border-gray-300 p-1 font-medium text-center"
|
||||
>
|
||||
65+
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
80bpm +
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
74-79bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
70-73bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
66-69bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
62-65bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
56-61bpm
|
||||
</td>
|
||||
<td class="border border-gray-300 p-1 text-center">
|
||||
52-55bpm
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
<div class="bg-white w-full page m-0 px-10">
|
||||
<div class="px-16 py-10">
|
||||
<!-- Table of Contents Header -->
|
||||
<div class="mb-8">
|
||||
<h1
|
||||
class="text-5xl font-bold text-black mb-6 tracking-wide border-b-4 border-blue-500 pb-2 text-center"
|
||||
>
|
||||
TABLE OF CONTENTS
|
||||
</h1>
|
||||
<div class="w-full h-1 bg-cyan-400"></div>
|
||||
</div>
|
||||
|
||||
<!-- Table of Contents Items -->
|
||||
<div class="flex flex-col justify-between space-y-6 py-6">
|
||||
<!-- Nutrition Guidelines -->
|
||||
<div class="flex items-start bg-gray-200 h-24">
|
||||
<div
|
||||
class="bg-black text-white text-4xl font-extrabold w-24 h-24 flex items-center justify-center mr-8 flex-shrink-0"
|
||||
style="border-radius: 0;"
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<div class="flex flex-col flex-1 py-1 justify-center h-full">
|
||||
<h2 class="text-2xl font-semibold text-black">
|
||||
Nutrition Guidelines
|
||||
</h2>
|
||||
<p class="text-gray-600 text-base">
|
||||
Ultrasound & Body Composition
|
||||
</p>
|
||||
<p class="text-gray-600 text-base">
|
||||
Resting Metabolic Rate Assessment
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nutrition Recommendations -->
|
||||
<div class="flex items-start bg-gray-200 h-24">
|
||||
<div
|
||||
class="bg-black text-white text-4xl font-extrabold w-24 h-24 flex items-center justify-center mr-8 flex-shrink-0"
|
||||
style="border-radius: 0;"
|
||||
>
|
||||
4
|
||||
</div>
|
||||
<div class="flex flex-col py-1 flex-1 justify-center h-full">
|
||||
<h2 class="text-2xl font-semibold text-black mb-3">
|
||||
Nutrition Recommendations
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Next Steps -->
|
||||
<div class="flex items-start bg-gray-200 h-24">
|
||||
<div
|
||||
class="bg-black text-white text-4xl font-extrabold w-24 h-24 flex items-center justify-center mr-8 flex-shrink-0"
|
||||
style="border-radius: 0;"
|
||||
>
|
||||
5
|
||||
</div>
|
||||
<div class="flex flex-col h-full justify-center flex-1">
|
||||
<h2 class="text-2xl font-semibold text-black">
|
||||
Next Steps
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
<!-- No sub-items -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Glossary -->
|
||||
<div class="flex items-start bg-gray-200 h-24">
|
||||
<div
|
||||
class="bg-black text-white text-4xl font-extrabold w-24 h-24 flex items-center justify-center mr-8 flex-shrink-0"
|
||||
style="border-radius: 0;"
|
||||
>
|
||||
6
|
||||
</div>
|
||||
<div class="flex flex-col h-full justify-center flex-1">
|
||||
<h2 class="text-2xl font-semibold text-black">
|
||||
Glossary
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
<!-- No sub-items -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,114 +0,0 @@
|
||||
<div class="w-full bg-white">
|
||||
<!-- Header Section -->
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="px-8 py-2">
|
||||
<!-- Page Title -->
|
||||
<h1 class="text-3xl font-bold text-black mb-3">Nutrition Guidelines</h1>
|
||||
|
||||
<!-- Section Title -->
|
||||
<h2 class="text-xl font-bold text-black mb-2">
|
||||
Resting Metabolic Rate Assessment
|
||||
</h2>
|
||||
<p class="text-gray-700 text-sm mb-4">
|
||||
The resting metabolic rate assessment determines the number of
|
||||
calories that you burn at rest, and metabolic health. It is also an
|
||||
indicator of overall health and well-being.
|
||||
</p>
|
||||
|
||||
<!-- Slow vs Fast Metabolism Section -->
|
||||
<div class="mb-6">
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
src="data:image/png;base64,{{ metabolism_chart }}"
|
||||
alt="Slow vs Fast Metabolism Chart"
|
||||
class="max-w-full h-auto max-h-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fuel Source Section -->
|
||||
<div class="mb-6">
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
src="data:image/png;base64,{{ fuel_source_chart }}"
|
||||
alt="Fuel Source Chart"
|
||||
class="max-w-full h-auto max-h-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Caloric Intake Section -->
|
||||
<div class="px-6 mb-6">
|
||||
<h3 class="text-2xl font-bold text-black mb-4 text-center">
|
||||
Caloric Intake
|
||||
</h3>
|
||||
|
||||
<!-- Calculation Formula -->
|
||||
<div class="flex items-center justify-center space-x-4 text-center">
|
||||
<!-- Resting Metabolic -->
|
||||
<div class="bg-gray-200 p-3 rounded-lg">
|
||||
<div class="text-lg font-bold text-black">
|
||||
{{ resting_calories }}kCals
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 mt-1">
|
||||
<div>Resting</div>
|
||||
<div>Metabolic</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plus sign -->
|
||||
<div class="text-2xl font-bold text-black">+</div>
|
||||
|
||||
<!-- NEAT -->
|
||||
<div class="bg-gray-200 p-3 rounded-lg">
|
||||
<div class="text-lg font-bold text-black">
|
||||
{{ neat_calories }}kCals
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 mt-1">NEAT</div>
|
||||
</div>
|
||||
|
||||
<!-- Minus sign -->
|
||||
<div class="text-2xl font-bold text-black">-</div>
|
||||
|
||||
<!-- Weight Loss -->
|
||||
<div class="bg-gray-200 p-3 rounded-lg">
|
||||
<div class="text-lg font-bold text-black">
|
||||
{{ weight_loss_calories }}kCals
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 mt-1">
|
||||
<div>to lose {{ weight_loss_rate }}lbs</div>
|
||||
<div>per week</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equals sign -->
|
||||
<div class="text-2xl font-bold text-black">=</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="bg-gray-200 p-3 rounded-lg">
|
||||
<div class="text-lg font-bold text-black">
|
||||
~{{ total_calories }}kCals
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resting Heart Rate Table Section -->
|
||||
{% if rhr_table %}
|
||||
<div class="mb-4">
|
||||
<h2 class="text-xl font-bold text-black mb-4 text-center">
|
||||
Resting Heart Rate
|
||||
</h2>
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
src="data:image/png;base64, {{ rhr_table }}"
|
||||
alt="Resting Heart Rate Table"
|
||||
class="table-image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
<div class="w-full page bg-white">
|
||||
<div class="px-1 py-6">
|
||||
<!-- Page Title -->
|
||||
<h1 class="text-2xl font-bold text-black mb-6 text-center">
|
||||
Weekly Meal Plan Breakdown
|
||||
</h1>
|
||||
|
||||
<!-- Caloric Deficit Example Section -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-bold text-black mb-4 text-center">
|
||||
Caloric Deficit Example
|
||||
</h2>
|
||||
|
||||
<!-- Weekly Grid -->
|
||||
<div class="grid grid-cols-7 gap-0 mb-6">
|
||||
{% set days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] %}
|
||||
{% for i in range(7) %}
|
||||
<div
|
||||
class="border border-gray-300 text-center
|
||||
{% if i == 0 %} rounded-tl-lg rounded-bl-lg {% endif %}
|
||||
{% if i == 6 %} rounded-tr-lg rounded-br-lg {% endif %}
|
||||
{% if i < 5 %} bg-cyan-300 {% else %} bg-white {% endif %}"
|
||||
style="padding: 0"
|
||||
>
|
||||
<!-- Day Row (Always blue) -->
|
||||
<div class="bg-blue-300
|
||||
{% if i == 0 %} rounded-tl-lg {% endif %}
|
||||
{% if i == 6 %} rounded-tr-lg {% endif %}
|
||||
text-black text-sm font-bold px-2 py-1"
|
||||
style="border-top-left-radius: {% if i == 0 %}0.5rem{% else %}0{% endif %}; border-top-right-radius: {% if i == 6 %}0.5rem{% else %}0{% endif %};"
|
||||
>
|
||||
{{ days[i] }}
|
||||
</div>
|
||||
<!-- Macro Body (fills to the bottom, cyan or white) -->
|
||||
<div class="flex flex-col items-center py-1 px-2">
|
||||
<div class="font-bold text-sm text-black mb-1">
|
||||
{{ deficit_calories | default('1725KCals') }} KCals
|
||||
</div>
|
||||
<div class="text-xs text-black leading-tight text-left">
|
||||
<div>{{ deficit_protein | default('120g Protein') }}</div>
|
||||
<div>{{ deficit_carbs | default('155g Carbs') }}</div>
|
||||
<div>{{ deficit_fat | default('69g Fat') }}</div>
|
||||
<div>{{ deficit_fiber | default('25g Fibre') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Caloric Deficit with Maintenance/Refeed Example Section -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-bold text-black mb-4 text-center">
|
||||
Caloric Deficit with Maintenance/Refeed Example
|
||||
</h2>
|
||||
|
||||
<!-- Weekly Grid -->
|
||||
<div class="grid grid-cols-7 gap-0 mb-6">
|
||||
{% for i in range(7) %}
|
||||
<div class="border border-gray-300 text-center
|
||||
{% if i == 0 %} rounded-tl-lg rounded-bl-lg {% endif %}
|
||||
{% if i == 6 %} rounded-tr-lg rounded-br-lg {% endif %}
|
||||
{% if i < 5 %} bg-cyan-300 {% else %} bg-white {% endif %}"
|
||||
style="padding: 0"
|
||||
>
|
||||
<!-- Day Row (Always blue) -->
|
||||
<div class="bg-blue-300
|
||||
{% if i == 0 %} rounded-tl-lg {% endif %}
|
||||
{% if i == 6 %} rounded-tr-lg {% endif %}
|
||||
text-black text-sm font-bold px-2 py-1"
|
||||
style="border-top-left-radius: {% if i == 0 %}0.5rem{% else %}0{% endif %}; border-top-right-radius: {% if i == 6 %}0.5rem{% else %}0{% endif %};"
|
||||
>
|
||||
{{ days[i] }}
|
||||
</div>
|
||||
<!-- Macro Body (fills to the bottom, cyan or white) -->
|
||||
{% if i < 5 %}
|
||||
<div class="flex flex-col items-center py-1 px-2">
|
||||
<div class="font-bold text-sm text-black mb-1">
|
||||
{{ refeed_weekday_calories | default('1615KCals') }} KCals
|
||||
</div>
|
||||
<div class="text-xs text-black leading-tight text-left">
|
||||
<div>{{ refeed_weekday_protein | default('120g Protein') }}</div>
|
||||
<div>{{ refeed_weekday_carbs | default('142g Carbs') }}</div>
|
||||
<div>{{ refeed_weekday_fat | default('63g Fat') }}</div>
|
||||
<div>{{ refeed_weekday_fiber | default('24g Fibre') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="flex flex-col items-center py-1 px-2">
|
||||
<div class="font-bold text-black mb-1">
|
||||
{{ refeed_weekend_calories | default('2000KCals') }} KCals
|
||||
</div>
|
||||
<div class="text-xs text-black leading-tight text-left">
|
||||
<div>{{ refeed_weekend_protein | default('120g Protein') }}</div>
|
||||
<div>{{ refeed_weekend_carbs | default('190g Carbs') }}</div>
|
||||
<div>{{ refeed_weekend_fat | default('84g Fat') }}</div>
|
||||
<div>{{ refeed_weekend_fiber | default('30g Fibre') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Macronutrients Recommendations Section -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-black mb-8 text-center">
|
||||
Macronutrients Recommendations
|
||||
</h2>
|
||||
|
||||
<!-- Macronutrient Boxes -->
|
||||
<div class="flex justify-center space-x-12">
|
||||
<!-- Protein -->
|
||||
<div class="bg-gray-200 p-6 rounded-lg text-center">
|
||||
<div class="text-3xl font-bold text-black mb-2">
|
||||
{{ protein_percentage | default('28%') }}
|
||||
</div>
|
||||
<div class="text-lg font-semibold text-black">Protein</div>
|
||||
</div>
|
||||
|
||||
<!-- Carbs -->
|
||||
<div class="bg-gray-200 p-6 rounded-lg text-center">
|
||||
<div class="text-3xl font-bold text-black mb-2">
|
||||
{{ carbs_percentage | default('36%') }}
|
||||
</div>
|
||||
<div class="text-lg font-semibold text-black">Carbs</div>
|
||||
</div>
|
||||
|
||||
<!-- Fats -->
|
||||
<div class="bg-gray-200 p-6 rounded-lg text-center">
|
||||
<div class="text-3xl font-bold text-black mb-2">
|
||||
{{ fats_percentage | default('36%') }}
|
||||
</div>
|
||||
<div class="text-lg font-semibold text-black">Fats</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,42 +0,0 @@
|
||||
<div class="w-full page bg-white">
|
||||
<!-- Main Content -->
|
||||
<div class="px-8 py-6">
|
||||
<!-- Page Title -->
|
||||
<h1 class="text-3xl font-bold text-black mb-6">Cardio Metrics</h1>
|
||||
|
||||
<!-- Active Metabolic Rate Assessment Section -->
|
||||
<h2 class="text-xl font-bold text-black mb-4">
|
||||
Active Metabolic Rate Assessment
|
||||
</h2>
|
||||
<p class="text-gray-700 text-sm mb-8">
|
||||
The active metabolic rate assessment is a key measure of aerobic
|
||||
fitness. It helps determine your specific heart rate zones and how
|
||||
well your body uses carbohydrates and fats as fuel while you
|
||||
exercise. It is also an indicator of overall health and wellbeing.
|
||||
</p>
|
||||
|
||||
<!-- VO2 Max Section -->
|
||||
<div class="mb-8">
|
||||
<!-- VO2 Max Table -->
|
||||
<div class="mb-8 flex justify-center">
|
||||
<img
|
||||
src="data:image/png;base64, {{ vo2_max_table }}"
|
||||
alt="VO2 Max Table"
|
||||
class="table-image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Personalized Heart Rate Zones Section -->
|
||||
<div class="mb-8">
|
||||
<!-- Heart Rate Zones Table -->
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
src="data:image/png;base64, {{ hr_zones_table }}"
|
||||
alt="Heart Rate Zones Table"
|
||||
class="table-image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,620 +0,0 @@
|
||||
"""
|
||||
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]], report_type: str = "full"
|
||||
) -> 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
|
||||
report_type: Type of report to generate ("full" or "minimal")
|
||||
|
||||
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"),
|
||||
}
|
||||
|
||||
# Define page mappings for full vs minimal reports
|
||||
if report_type == "minimal":
|
||||
# Minimal report: pages 1, 2, 4, 5, 6, 16, 17, 19, 20
|
||||
# Map to minimal report pages 1-8
|
||||
# Page mapping: (original_page_num, template_name, minimal_page_num)
|
||||
page_mapping = [
|
||||
(1, "page_1.html", 1),
|
||||
(2, "page_2_minimal.html", 2),
|
||||
(4, "page_4.html", 3),
|
||||
(5, "page_5_minimal.html", 4),
|
||||
(6, "page_6.html", 5),
|
||||
(16, "page_16.html", 6),
|
||||
(17, "page_17_minimal.html", 7),
|
||||
(19, "page_19_20_minimal.html", 8), # Combined page
|
||||
]
|
||||
else:
|
||||
# Full report: all pages 1-20
|
||||
page_mapping = [
|
||||
(i, f"page_{i}.html", i) for i in range(1, 21)
|
||||
]
|
||||
|
||||
num_pages = len(page_mapping)
|
||||
|
||||
# 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 based on mapping
|
||||
for idx, (original_page_num, template_name, minimal_page_num) in enumerate(page_mapping):
|
||||
# For combined page_19_20_minimal, use the combined context
|
||||
if template_name == "page_19_20_minimal.html":
|
||||
page_key = "page_19_20_minimal"
|
||||
else:
|
||||
page_key = f"page_{original_page_num}"
|
||||
context = contexts.get(page_key, {})
|
||||
template = self.env.get_template(template_name).render(context)
|
||||
|
||||
# Pages 1 and 2 don't have headers/footers in full report
|
||||
# In minimal report, only page 1 doesn't have header/footer
|
||||
page_num_in_report = minimal_page_num if report_type == "minimal" else original_page_num
|
||||
if page_num_in_report > 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[idx]}
|
||||
</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;
|
||||
}}
|
||||
.table-image {{
|
||||
max-height: none !important;
|
||||
width: auto !important;
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
object-fit: contain;
|
||||
}}
|
||||
</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,
|
||||
report_type: str = "full",
|
||||
) -> 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"] = ""
|
||||
|
||||
# Load static fuelling analysis flowchart for page 10
|
||||
flowchart_path = Path("app/estimated_carb_storage.png")
|
||||
if flowchart_path.exists():
|
||||
try:
|
||||
with open(flowchart_path, "rb") as f:
|
||||
graphs_dict["fuelling_analysis_flowchart"] = base64.b64encode(
|
||||
f.read()
|
||||
).decode("utf-8")
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load fuelling analysis flowchart: {e}")
|
||||
graphs_dict["fuelling_analysis_flowchart"] = ""
|
||||
else:
|
||||
print(f"Warning: Fuelling analysis flowchart not found at {flowchart_path}")
|
||||
graphs_dict["fuelling_analysis_flowchart"] = ""
|
||||
|
||||
# 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()
|
||||
|
||||
# Convert height to cm if available
|
||||
height_cm = None
|
||||
height_str = patient_info.get("height", "")
|
||||
if height_str:
|
||||
try:
|
||||
# Try to parse height string (e.g., "5'4"", "165cm", "165")
|
||||
import re
|
||||
# Check if it's in feet'inches" format
|
||||
feet_inches_match = re.match(r"(\d+)'(\d+)\"", height_str)
|
||||
if feet_inches_match:
|
||||
feet = int(feet_inches_match.group(1))
|
||||
inches = int(feet_inches_match.group(2))
|
||||
height_cm = (feet * 12 + inches) * 2.54
|
||||
# Check if it ends with cm
|
||||
elif "cm" in height_str.lower():
|
||||
height_cm = float(re.sub(r"[^\d.]", "", height_str))
|
||||
# Otherwise try to parse as number (assume cm)
|
||||
else:
|
||||
height_cm = float(re.sub(r"[^\d.]", "", height_str))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
# Generate metabolism chart with ratio calculation if we have all parameters
|
||||
metabolism_chart_b64 = self.graph_generator.generate_metabolism_chart(
|
||||
rmr_metrics["rmr_kcal"],
|
||||
weight_kg=weight_kg,
|
||||
height_cm=height_cm,
|
||||
age_years=patient_info.get("age", None),
|
||||
sex=gender,
|
||||
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
|
||||
oxygenation_csv_path, # Pass oxygenation CSV path
|
||||
)
|
||||
# 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,
|
||||
"next_testing_date": patient_info.get("next_testing_date", "Contact us for scheduling"),
|
||||
}
|
||||
contexts = self.context_generator.generate_all_contexts(
|
||||
patient_name,
|
||||
graphs_dict,
|
||||
metric_overrides=metric_overrides,
|
||||
graph_generator=self.graph_generator,
|
||||
report_type=report_type,
|
||||
)
|
||||
|
||||
# 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, report_type=report_type)
|
||||
|
||||
# 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,
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
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}")
|
||||
@@ -1,42 +0,0 @@
|
||||
<!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>
|
||||
|
||||
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
{% 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 %}
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
{% 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 %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,372 +0,0 @@
|
||||
{% 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
|
||||
id="upload-form"
|
||||
action="/upload"
|
||||
method="post"
|
||||
enctype="multipart/form-data"
|
||||
class="space-y-6"
|
||||
>
|
||||
<!-- Patient Information Section -->
|
||||
<div class="border-b border-gray-200 pb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">
|
||||
Patient Information
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label
|
||||
for="first_name"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>First Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="first_name"
|
||||
id="first_name"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="last_name"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>Last Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="last_name"
|
||||
id="last_name"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="age"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>Age</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
name="age"
|
||||
id="age"
|
||||
required
|
||||
min="1"
|
||||
max="120"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="height"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>Height (e.g., 5'4" or 165cm)</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="height"
|
||||
id="height"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border"
|
||||
placeholder="5'4""
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="weight"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>Weight (e.g., 123lbs or 56kg)</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="weight"
|
||||
id="weight"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border"
|
||||
placeholder="123lbs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="gender"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>Gender</label
|
||||
>
|
||||
<select
|
||||
name="gender"
|
||||
id="gender"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border"
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="focus"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>Training Focus</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="focus"
|
||||
id="focus"
|
||||
value="Endurance"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Report Type</label
|
||||
>
|
||||
<div class="mt-1 space-y-2">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="report_type"
|
||||
id="report_type_full"
|
||||
value="full"
|
||||
checked
|
||||
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300"
|
||||
/>
|
||||
<label
|
||||
for="report_type_full"
|
||||
class="ml-2 block text-sm text-gray-700"
|
||||
>
|
||||
Full Report
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="report_type"
|
||||
id="report_type_minimal"
|
||||
value="minimal"
|
||||
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300"
|
||||
/>
|
||||
<label
|
||||
for="report_type_minimal"
|
||||
class="ml-2 block text-sm text-gray-700"
|
||||
>
|
||||
Minimal Report
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>Recommended Next Testing Date</label
|
||||
>
|
||||
<div class="mt-1 grid grid-cols-2 gap-3">
|
||||
<select
|
||||
id="next_testing_month"
|
||||
required
|
||||
class="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="">Month</option>
|
||||
<option value="January">January</option>
|
||||
<option value="February">February</option>
|
||||
<option value="March">March</option>
|
||||
<option value="April">April</option>
|
||||
<option value="May">May</option>
|
||||
<option value="June">June</option>
|
||||
<option value="July">July</option>
|
||||
<option value="August">August</option>
|
||||
<option value="September">September</option>
|
||||
<option value="October">October</option>
|
||||
<option value="November">November</option>
|
||||
<option value="December">December</option>
|
||||
</select>
|
||||
<select
|
||||
id="next_testing_year"
|
||||
required
|
||||
class="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="">Year</option>
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
type="hidden"
|
||||
name="next_testing_date"
|
||||
id="next_testing_date"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<script>
|
||||
// Populate year dropdown
|
||||
(function () {
|
||||
const yearSelect =
|
||||
document.getElementById(
|
||||
"next_testing_year"
|
||||
);
|
||||
const currentYear = new Date().getFullYear();
|
||||
for (
|
||||
let i = currentYear;
|
||||
i <= currentYear + 10;
|
||||
i++
|
||||
) {
|
||||
const option =
|
||||
document.createElement("option");
|
||||
option.value = i;
|
||||
option.textContent = i;
|
||||
yearSelect.appendChild(option);
|
||||
}
|
||||
|
||||
// Combine month and year into hidden input
|
||||
const monthSelect =
|
||||
document.getElementById(
|
||||
"next_testing_month"
|
||||
);
|
||||
const dateInput =
|
||||
document.getElementById(
|
||||
"next_testing_date"
|
||||
);
|
||||
|
||||
function updateDateInput() {
|
||||
const month = monthSelect.value;
|
||||
const year = yearSelect.value;
|
||||
if (month && year) {
|
||||
dateInput.value = month + " " + year;
|
||||
} else {
|
||||
dateInput.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
monthSelect.addEventListener(
|
||||
"change",
|
||||
updateDateInput
|
||||
);
|
||||
yearSelect.addEventListener(
|
||||
"change",
|
||||
updateDateInput
|
||||
);
|
||||
|
||||
// Validate form submission
|
||||
const form =
|
||||
document.getElementById("upload-form");
|
||||
form.addEventListener("submit", function (e) {
|
||||
const month = monthSelect.value;
|
||||
const year = yearSelect.value;
|
||||
if (!month || !year) {
|
||||
e.preventDefault();
|
||||
alert(
|
||||
"Please select both month and year for the recommended next testing date."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Ensure hidden input is set before submission
|
||||
updateDateInput();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</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 %}
|
||||
@@ -0,0 +1,354 @@
|
||||
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": "Keirstyn",
|
||||
"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"
|
||||
),
|
||||
|
||||
"fat_percentage": "22.4",
|
||||
}
|
||||
|
||||
|
||||
page_5_context = {
|
||||
"metabolism_chart": image_to_base64(
|
||||
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/metabolism_chart.png"
|
||||
),
|
||||
"fuel_source_chart": image_to_base64(
|
||||
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/fuel_source_chart.png"
|
||||
),
|
||||
"resting_calories": 1385,
|
||||
"neat_calories": "null",
|
||||
"weight_loss_calories": "null",
|
||||
"weight_loss_rate": "1lb/week",
|
||||
"total_calories": "null",
|
||||
}
|
||||
|
||||
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-189bpm",
|
||||
"zone5_bpm": "189-199bpm",
|
||||
"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 = {
|
||||
"right_leg": image_to_base64(
|
||||
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/right_leg.png"
|
||||
),
|
||||
"left_leg": image_to_base64(
|
||||
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/left_leg.png"
|
||||
),
|
||||
}
|
||||
|
||||
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/fat_percentage_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,
|
||||
]
|
||||
@@ -0,0 +1,319 @@
|
||||
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")
|
||||
@@ -0,0 +1,12 @@
|
||||
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
|
||||
|
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
@@ -0,0 +1,942 @@
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.transforms as mtransforms
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import seaborn as sns
|
||||
from matplotlib.patches import FancyBboxPatch
|
||||
|
||||
|
||||
class GraphGenerator:
|
||||
def __init__(self, charts_dir: str = "graphs"):
|
||||
"""Initialize the GraphGenerator with output directory for charts"""
|
||||
self.charts_dir = Path(charts_dir)
|
||||
self.charts_dir.mkdir(exist_ok=True)
|
||||
|
||||
def _image_to_base64(self, image_path: Path) -> str:
|
||||
"""Convert image to base64 string"""
|
||||
try:
|
||||
with open(image_path, "rb") as image_file:
|
||||
return base64.b64encode(image_file.read()).decode("utf-8")
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
|
||||
def generate_respiratory_chart(
|
||||
self, df: pd.DataFrame, save_as_base64: bool = False
|
||||
) -> str:
|
||||
"""Generate respiratory chart showing VT and Speed over time"""
|
||||
# Get phase times for background regions
|
||||
first_unique_phase = df.drop_duplicates(subset="PHASE")
|
||||
phase_times = first_unique_phase["T(sec)"].tolist()
|
||||
|
||||
plt.figure(figsize=(18, 5))
|
||||
ax1 = plt.subplot()
|
||||
|
||||
# Plot VT with step-like appearance
|
||||
sns.lineplot(data=df, x="T(sec)", y="VT(l)_smoothed", label="VT (L)")
|
||||
ax1.set_xlabel("Time (sec)")
|
||||
ax1.set_ylabel("VT (L)")
|
||||
ax1.grid(True, alpha=0.1)
|
||||
ax1.set_ylim(0, min(8, df["VT(l)_smoothed"].max()))
|
||||
|
||||
# Plot speed as step function on secondary y-axis
|
||||
ax2 = ax1.twinx()
|
||||
ax1.set_xticks(np.arange(0, df["T(sec)"].max() + 200, 200))
|
||||
line2 = sns.lineplot(
|
||||
data=df,
|
||||
x="T(sec)",
|
||||
y="Speed",
|
||||
color="green",
|
||||
ax=ax2,
|
||||
drawstyle="steps-post",
|
||||
linewidth=2,
|
||||
label="Speed",
|
||||
)
|
||||
ax2.set_ylabel("Speed")
|
||||
ax2.set_ylim(0, min(30, df["Speed"].max()) + 1)
|
||||
|
||||
# Remove default legends first
|
||||
ax1.get_legend().remove()
|
||||
ax2.get_legend().remove()
|
||||
|
||||
# Combine legends from both axes in the top left
|
||||
lines1, labels1 = ax1.get_legend_handles_labels()
|
||||
lines2, labels2 = ax2.get_legend_handles_labels()
|
||||
ax1.legend(lines1 + lines2, labels1 + labels2, loc="upper left")
|
||||
|
||||
# Add colored background regions
|
||||
if len(phase_times) >= 4:
|
||||
ax1.axvspan(0, phase_times[1], alpha=0.2, color="lightblue")
|
||||
ax1.axvspan(phase_times[1], phase_times[2], alpha=0.2, color="purple")
|
||||
ax1.axvspan(phase_times[2], phase_times[3], alpha=0.2, color="lightgreen")
|
||||
ax1.axvspan(phase_times[3], df["T(sec)"].max(), alpha=0.2, color="blue")
|
||||
|
||||
chart_path = self.charts_dir / "respiratory.png"
|
||||
plt.savefig(chart_path, dpi=300, bbox_inches="tight")
|
||||
plt.close()
|
||||
|
||||
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||
|
||||
def generate_fuel_utilization_chart(
|
||||
self, df: pd.DataFrame, save_as_base64: bool = False
|
||||
) -> str:
|
||||
"""Generate fuel utilization chart with stacked bars showing fat vs carbs"""
|
||||
# Group by speed and calculate mean for numeric columns only
|
||||
speed_groups = 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))
|
||||
plt.style.use("default")
|
||||
|
||||
# Create stage labels and positions
|
||||
stage_labels = [f"Stage {i}" for i in range(1, len(filtered_data) + 1)]
|
||||
x_positions = np.arange(len(filtered_data))
|
||||
|
||||
# Calculate fat and carbs energy expenditure from percentages
|
||||
fat_ee = filtered_data["EE(kcal/min)"] * filtered_data["FAT(%)"] / 100
|
||||
carbs_ee = filtered_data["EE(kcal/min)"] * filtered_data["CARBS(%)"] / 100
|
||||
|
||||
# Create the main axis for the stacked bars
|
||||
ax1 = plt.gca()
|
||||
|
||||
# Create stacked bar chart with colors
|
||||
ax1.bar(x_positions, fat_ee, color="#1f77b4", alpha=0.8, width=0.6, label="Fat")
|
||||
ax1.bar(
|
||||
x_positions,
|
||||
carbs_ee,
|
||||
bottom=fat_ee,
|
||||
color="#ff7f0e",
|
||||
alpha=0.8,
|
||||
width=0.6,
|
||||
label="Carbs",
|
||||
)
|
||||
|
||||
# Set labels and formatting for primary axis
|
||||
ax1.set_xlabel("", fontsize=12)
|
||||
ax1.set_ylabel("Fuel (kcal/min)", fontsize=12)
|
||||
ax1.set_ylim(0, 20)
|
||||
|
||||
# Add individual values on each bar segment
|
||||
for i, (fat_val, carb_val, total_val) in enumerate(
|
||||
zip(fat_ee, carbs_ee, filtered_data["EE(kcal/min)"])
|
||||
):
|
||||
if fat_val > 0.3: # Fat value
|
||||
ax1.text(
|
||||
i,
|
||||
fat_val / 2,
|
||||
f"{fat_val:.1f}",
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=9,
|
||||
fontweight="bold",
|
||||
color="white",
|
||||
)
|
||||
if carb_val > 0.3: # Carbs value
|
||||
ax1.text(
|
||||
i,
|
||||
fat_val + carb_val / 2,
|
||||
f"{carb_val:.1f}",
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=9,
|
||||
fontweight="bold",
|
||||
color="white",
|
||||
)
|
||||
# Total EE
|
||||
ax1.text(
|
||||
i,
|
||||
total_val + 0.5,
|
||||
f"{total_val:.1f} kcal",
|
||||
ha="center",
|
||||
va="bottom",
|
||||
fontsize=10,
|
||||
fontweight="bold",
|
||||
color="black",
|
||||
)
|
||||
|
||||
# Add speed labels below x-axis
|
||||
for i, speed in enumerate(filtered_data.index):
|
||||
ax1.text(i, -1.5, f"{speed:.1f} mph", ha="center", va="top", fontsize=9)
|
||||
ax1.text(
|
||||
i,
|
||||
-2.8,
|
||||
f"{speed * 1.609:.1f} min/km",
|
||||
ha="center",
|
||||
va="top",
|
||||
fontsize=8,
|
||||
color="gray",
|
||||
)
|
||||
|
||||
# Create secondary y-axis for heart rate
|
||||
ax2 = ax1.twinx()
|
||||
|
||||
# Plot heart rate line
|
||||
ax2.plot(
|
||||
x_positions,
|
||||
filtered_data["HR(bpm)"],
|
||||
marker="o",
|
||||
linewidth=3,
|
||||
markersize=8,
|
||||
color="red",
|
||||
label="Heart Rate",
|
||||
)
|
||||
|
||||
# Set heart rate axis formatting
|
||||
ax2.set_ylabel("Heart Rate (bpm)", fontsize=12, color="red")
|
||||
ax2.tick_params(axis="y", labelcolor="red")
|
||||
ax2.set_ylim(0, 220)
|
||||
|
||||
# Add HR values above the points
|
||||
for i, hr in enumerate(filtered_data["HR(bpm)"]):
|
||||
ax2.text(
|
||||
i,
|
||||
hr + 10,
|
||||
f"{int(hr)}bpm",
|
||||
ha="center",
|
||||
va="bottom",
|
||||
fontsize=10,
|
||||
fontweight="bold",
|
||||
color="red",
|
||||
)
|
||||
|
||||
# Set x-axis formatting
|
||||
ax1.set_xticks(x_positions)
|
||||
ax1.set_xticklabels(stage_labels, fontsize=11)
|
||||
|
||||
# Create legend
|
||||
lines1, labels1 = ax1.get_legend_handles_labels()
|
||||
lines2, labels2 = ax2.get_legend_handles_labels()
|
||||
ax1.legend(
|
||||
lines1 + lines2,
|
||||
labels1 + labels2,
|
||||
loc="upper left",
|
||||
frameon=True,
|
||||
fancybox=True,
|
||||
shadow=True,
|
||||
)
|
||||
|
||||
# Add grid
|
||||
ax1.grid(True, alpha=0.3, linestyle="-", linewidth=0.5)
|
||||
ax1.set_axisbelow(True)
|
||||
|
||||
# Adjust layout
|
||||
plt.tight_layout()
|
||||
plt.subplots_adjust(bottom=0.1, top=0.9)
|
||||
|
||||
chart_path = self.charts_dir / "fuel_utilization_chart.png"
|
||||
plt.savefig(chart_path, dpi=300)
|
||||
plt.close()
|
||||
|
||||
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||
|
||||
def generate_vo2_pulse_chart(
|
||||
self, df: pd.DataFrame, save_as_base64: bool = False
|
||||
) -> str:
|
||||
"""Generate VO2 Pulse chart with heart rate and speed"""
|
||||
first_unique_phase = df.drop_duplicates(subset="PHASE")
|
||||
phase_times = first_unique_phase["T(sec)"].tolist()
|
||||
|
||||
plt.figure(figsize=(18, 5))
|
||||
ax1 = plt.subplot()
|
||||
|
||||
# Plot VO2 Pulse
|
||||
sns.lineplot(
|
||||
data=df,
|
||||
x="T(sec)",
|
||||
y="VO2 Pulse_smoothed",
|
||||
label="VO2 Pulse (mL/beat)",
|
||||
color="blue",
|
||||
)
|
||||
ax1.set_xlabel("Time (sec)")
|
||||
ax1.set_ylabel("VO2 Pulse (mL/beat)")
|
||||
ax1.set_ylim(0, df["VO2 Pulse_smoothed"].max())
|
||||
ax1.grid(True, alpha=0.1)
|
||||
|
||||
# Create second y-axis for heart rate
|
||||
ax2 = ax1.twinx()
|
||||
sns.lineplot(
|
||||
data=df,
|
||||
x="T(sec)",
|
||||
y="HR(bpm)_smoothed",
|
||||
color="red",
|
||||
ax=ax2,
|
||||
linewidth=2,
|
||||
label="Heart Rate (bpm)",
|
||||
)
|
||||
ax2.set_ylabel("Heart Rate (bpm)", color="red")
|
||||
ax2.tick_params(axis="y", labelcolor="red")
|
||||
ax2.set_ylim(0, df["HR(bpm)_smoothed"].max() + 1)
|
||||
|
||||
# Create third y-axis for speed
|
||||
ax3 = ax1.twinx()
|
||||
ax3.spines["right"].set_position(("outward", 60))
|
||||
sns.lineplot(
|
||||
data=df,
|
||||
x="T(sec)",
|
||||
y="Speed",
|
||||
color="green",
|
||||
ax=ax3,
|
||||
drawstyle="steps-post",
|
||||
linewidth=2,
|
||||
label="Speed",
|
||||
)
|
||||
ax3.set_ylabel("Speed", color="green")
|
||||
ax3.tick_params(axis="y", labelcolor="green")
|
||||
ax3.set_ylim(0, df["Speed"].max() + 1)
|
||||
|
||||
ax1.set_xticks(np.arange(0, df["T(sec)"].max() + 200, 200))
|
||||
|
||||
# Remove default legends first
|
||||
for ax in [ax1, ax2, ax3]:
|
||||
if ax.get_legend():
|
||||
ax.get_legend().remove()
|
||||
|
||||
# Combine legends from all axes
|
||||
lines1, labels1 = ax1.get_legend_handles_labels()
|
||||
lines2, labels2 = ax2.get_legend_handles_labels()
|
||||
lines3, labels3 = ax3.get_legend_handles_labels()
|
||||
ax1.legend(
|
||||
lines1 + lines2 + lines3, labels1 + labels2 + labels3, loc="upper left"
|
||||
)
|
||||
|
||||
# Add colored background regions
|
||||
if len(phase_times) >= 4:
|
||||
ax1.axvspan(0, phase_times[1], alpha=0.2, color="lightblue")
|
||||
ax1.axvspan(phase_times[1], phase_times[2], alpha=0.2, color="purple")
|
||||
ax1.axvspan(phase_times[2], phase_times[3], alpha=0.2, color="lightgreen")
|
||||
ax1.axvspan(phase_times[3], df["T(sec)"].max(), alpha=0.2, color="blue")
|
||||
|
||||
chart_path = self.charts_dir / "vo2_pulse_chart.png"
|
||||
plt.savefig(chart_path, bbox_inches="tight", dpi=300)
|
||||
plt.close()
|
||||
|
||||
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||
|
||||
def generate_vo2_breath_chart(
|
||||
self, df: pd.DataFrame, save_as_base64: bool = False
|
||||
) -> str:
|
||||
"""Generate VO2 per Breath chart"""
|
||||
first_unique_phase = df.drop_duplicates(subset="PHASE")
|
||||
phase_times = first_unique_phase["T(sec)"].tolist()
|
||||
|
||||
plt.figure(figsize=(18, 5))
|
||||
ax1 = plt.subplot()
|
||||
|
||||
# Plot VO2 per Breath
|
||||
sns.lineplot(
|
||||
data=df,
|
||||
x="T(sec)",
|
||||
y="VO2 Breath_smoothed",
|
||||
label="VO2 per Breath (mL/breath)",
|
||||
)
|
||||
ax1.set_xlabel("Time (sec)")
|
||||
ax1.set_ylabel("VO2 per Breath (mL/breath)")
|
||||
ax1.set_ylim(0, df["VO2 Breath_smoothed"].max() + 1)
|
||||
ax1.grid(True, alpha=0.1)
|
||||
|
||||
# Plot speed as step function on secondary y-axis
|
||||
ax2 = ax1.twinx()
|
||||
ax1.set_xticks(np.arange(0, df["T(sec)"].max() + 200, 200))
|
||||
sns.lineplot(
|
||||
data=df,
|
||||
x="T(sec)",
|
||||
y="Speed",
|
||||
color="green",
|
||||
ax=ax2,
|
||||
drawstyle="steps-post",
|
||||
linewidth=2,
|
||||
label="Speed",
|
||||
)
|
||||
ax2.set_ylim(0, df["Speed"].max() + 1)
|
||||
ax2.set_ylabel("Speed")
|
||||
|
||||
# Remove default legends first
|
||||
ax1.get_legend().remove()
|
||||
ax2.get_legend().remove()
|
||||
|
||||
# Combine legends from both axes in the top left
|
||||
lines1, labels1 = ax1.get_legend_handles_labels()
|
||||
lines2, labels2 = ax2.get_legend_handles_labels()
|
||||
ax1.legend(lines1 + lines2, labels1 + labels2, loc="upper left")
|
||||
|
||||
# Add colored background regions
|
||||
if len(phase_times) >= 4:
|
||||
ax1.axvspan(0, phase_times[1], alpha=0.2, color="lightblue")
|
||||
ax1.axvspan(phase_times[1], phase_times[2], alpha=0.2, color="purple")
|
||||
ax1.axvspan(phase_times[2], phase_times[3], alpha=0.2, color="lightgreen")
|
||||
ax1.axvspan(phase_times[3], df["T(sec)"].max(), alpha=0.2, color="blue")
|
||||
|
||||
chart_path = self.charts_dir / "vo2_breath_chart.png"
|
||||
plt.savefig(chart_path, bbox_inches="tight", dpi=300)
|
||||
plt.close()
|
||||
|
||||
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||
|
||||
def generate_fat_metabolism_chart(
|
||||
self, df: pd.DataFrame, save_as_base64: bool = False
|
||||
) -> str:
|
||||
"""Generate CHO and FAT metabolism chart"""
|
||||
first_unique_phase = df.drop_duplicates(subset="PHASE")
|
||||
phase_times = first_unique_phase["T(sec)"].tolist()
|
||||
|
||||
plt.figure(figsize=(18, 5))
|
||||
ax1 = plt.subplot()
|
||||
|
||||
# Plot CHO
|
||||
sns.lineplot(data=df, x="T(sec)", y="CHO_smoothed", label="CHO (kcal/min)")
|
||||
ax1.set_xlabel("Time (sec)")
|
||||
ax1.set_ylabel("CHO (kcal/min)")
|
||||
ax1.grid(True, alpha=0.1)
|
||||
|
||||
# Plot FAT on secondary y-axis
|
||||
ax2 = ax1.twinx()
|
||||
ax1.set_xticks(np.arange(0, df["T(sec)"].max() + 200, 200))
|
||||
sns.lineplot(
|
||||
data=df,
|
||||
x="T(sec)",
|
||||
y="FAT_smoothed",
|
||||
color="green",
|
||||
ax=ax2,
|
||||
label="FAT (kcal/min)",
|
||||
)
|
||||
ax2.set_ylabel("FAT (kcal/min)")
|
||||
ax2.set_ylim(0, 15)
|
||||
|
||||
# Remove default legends first
|
||||
ax1.get_legend().remove()
|
||||
ax2.get_legend().remove()
|
||||
|
||||
# Combine legends from both axes in the top left
|
||||
lines1, labels1 = ax1.get_legend_handles_labels()
|
||||
lines2, labels2 = ax2.get_legend_handles_labels()
|
||||
ax1.legend(lines1 + lines2, labels1 + labels2, loc="upper left")
|
||||
|
||||
# Add colored background regions
|
||||
if len(phase_times) >= 4:
|
||||
ax1.axvspan(0, phase_times[1], alpha=0.2, color="lightblue")
|
||||
ax1.axvspan(phase_times[1], phase_times[2], alpha=0.2, color="purple")
|
||||
ax1.axvspan(phase_times[2], phase_times[3], alpha=0.2, color="lightgreen")
|
||||
ax1.axvspan(phase_times[3], df["T(sec)"].max(), alpha=0.2, color="blue")
|
||||
|
||||
chart_path = self.charts_dir / "fat_metabolism_chart.png"
|
||||
plt.savefig(chart_path, bbox_inches="tight", dpi=300)
|
||||
plt.close()
|
||||
|
||||
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||
|
||||
def generate_recovery_chart(
|
||||
self, df: pd.DataFrame, save_as_base64: bool = False
|
||||
) -> str:
|
||||
"""Generate recovery chart with VCO2, HR, and BF"""
|
||||
first_unique_phase = df.drop_duplicates(subset="PHASE")
|
||||
phase_times = first_unique_phase["T(sec)"].tolist()
|
||||
|
||||
plt.figure(figsize=(18, 5))
|
||||
ax1 = plt.subplot()
|
||||
|
||||
# Plot VCO2
|
||||
sns.lineplot(
|
||||
data=df,
|
||||
x="T(sec)",
|
||||
y="VCO2(ml/min)_smoothed",
|
||||
label="VCO2 (ml/min)",
|
||||
color="blue",
|
||||
)
|
||||
ax1.set_xlabel("Time (sec)")
|
||||
ax1.set_ylabel("VCO2 (ml/min)")
|
||||
ax1.set_ylim(0, df["VCO2(ml/min)"].max())
|
||||
ax1.grid(True, alpha=0.1)
|
||||
|
||||
# Create second y-axis for heart rate
|
||||
ax2 = ax1.twinx()
|
||||
sns.lineplot(
|
||||
data=df,
|
||||
x="T(sec)",
|
||||
y="HR(bpm)_smoothed",
|
||||
color="red",
|
||||
ax=ax2,
|
||||
linewidth=2,
|
||||
label="Heart Rate (bpm)",
|
||||
)
|
||||
ax2.set_ylabel("Heart Rate (bpm)", color="red")
|
||||
ax2.set_ylim(df["HR(bpm)_smoothed"].min(), df["HR(bpm)_smoothed"].max() + 1)
|
||||
ax2.tick_params(axis="y", labelcolor="red")
|
||||
|
||||
# Create third y-axis for breathing frequency
|
||||
ax3 = ax1.twinx()
|
||||
ax3.spines["right"].set_position(("outward", 60))
|
||||
sns.lineplot(
|
||||
data=df,
|
||||
x="T(sec)",
|
||||
y="BF(bpm)_smoothed",
|
||||
color="green",
|
||||
ax=ax3,
|
||||
linewidth=2,
|
||||
label="BF (bpm)",
|
||||
)
|
||||
ax3.set_ylabel("BF (bpm)", color="green")
|
||||
ax3.tick_params(axis="y", labelcolor="green")
|
||||
ax3.set_ylim(0, df["BF(bpm)_smoothed"].max() + 1)
|
||||
ax1.set_xticks(np.arange(0, df["T(sec)"].max() + 200, 200))
|
||||
|
||||
# Remove default legends first
|
||||
for ax in [ax1, ax2, ax3]:
|
||||
if ax.get_legend():
|
||||
ax.get_legend().remove()
|
||||
|
||||
# Combine legends from all axes in the top left
|
||||
lines1, labels1 = ax1.get_legend_handles_labels()
|
||||
lines2, labels2 = ax2.get_legend_handles_labels()
|
||||
lines3, labels3 = ax3.get_legend_handles_labels()
|
||||
ax1.legend(
|
||||
lines1 + lines2 + lines3, labels1 + labels2 + labels3, loc="upper left"
|
||||
)
|
||||
|
||||
# Add colored background regions
|
||||
if len(phase_times) >= 4:
|
||||
ax1.axvspan(0, phase_times[1], alpha=0.2, color="lightblue")
|
||||
ax1.axvspan(phase_times[1], phase_times[2], alpha=0.2, color="purple")
|
||||
ax1.axvspan(phase_times[2], phase_times[3], alpha=0.2, color="lightgreen")
|
||||
ax1.axvspan(phase_times[3], df["T(sec)"].max(), alpha=0.2, color="blue")
|
||||
|
||||
chart_path = self.charts_dir / "recovery_chart.png"
|
||||
plt.savefig(chart_path, bbox_inches="tight", dpi=300)
|
||||
plt.close()
|
||||
|
||||
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||
|
||||
def generate_body_fat_percentage_chart(
|
||||
self,
|
||||
gender: str,
|
||||
age: int,
|
||||
body_fat_percentage: float,
|
||||
save_as_base64: bool = False,
|
||||
) -> str:
|
||||
"""Generate body fat percentage chart with ranges"""
|
||||
# Define the segments with muted colors
|
||||
segments = [
|
||||
("#F8A8A8", 0, 15), # Muted Red/Salmon: 0% to 15%
|
||||
("#FFEECC", 15, 5), # Pale Yellow/Cream: 15% to 20%
|
||||
("#D0F0C0", 20, 15), # Pale Green/Mint: 20% to 35%
|
||||
("#FFEECC", 35, 5), # Pale Yellow/Cream: 35% to 40%
|
||||
("#F8A8A8", 40, 10), # Muted Red/Salmon: 40% to 50%
|
||||
]
|
||||
|
||||
# Determine age group
|
||||
if 20 <= age <= 39:
|
||||
age_group = "20-39"
|
||||
elif 40 <= age <= 59:
|
||||
age_group = "40-59"
|
||||
elif 60 <= age <= 79:
|
||||
age_group = "60-79"
|
||||
else:
|
||||
age_group = "N/A"
|
||||
|
||||
demographic = f"{age_group}\n({gender[0].upper()})"
|
||||
|
||||
fig, ax = plt.subplots(figsize=(10, 2))
|
||||
|
||||
# Create the Segmented Bar
|
||||
for color, start, length in segments:
|
||||
ax.barh(
|
||||
y=0,
|
||||
width=length,
|
||||
left=start,
|
||||
height=1,
|
||||
color=color,
|
||||
edgecolor="black",
|
||||
linewidth=0.5,
|
||||
)
|
||||
|
||||
# Add the Indicator (Triangle)
|
||||
ax.plot(
|
||||
body_fat_percentage,
|
||||
1.05,
|
||||
marker="v",
|
||||
color="black",
|
||||
markersize=10,
|
||||
clip_on=False,
|
||||
transform=ax.get_xaxis_transform(),
|
||||
)
|
||||
|
||||
# Set Axis Properties and Labels
|
||||
ax.set_xlim(0, 50)
|
||||
ax.set_xticks(range(0, 51, 5))
|
||||
ax.set_yticks([])
|
||||
ax.text(
|
||||
-0.05,
|
||||
0,
|
||||
demographic,
|
||||
transform=ax.get_yaxis_transform(),
|
||||
va="center",
|
||||
ha="right",
|
||||
fontsize=12,
|
||||
)
|
||||
|
||||
ax.set_xlim(0, 50)
|
||||
ticks = range(0, 51, 5)
|
||||
ax.set_xticks(ticks)
|
||||
labels = [f"{t}%" for t in ticks]
|
||||
ax.set_xticklabels(labels)
|
||||
|
||||
# Clean up spines and add small ticks
|
||||
ax.spines["right"].set_visible(False)
|
||||
ax.spines["top"].set_visible(False)
|
||||
ax.spines["left"].set_visible(False)
|
||||
ax.spines["bottom"].set_visible(True)
|
||||
|
||||
for x in range(0, 51, 5):
|
||||
ax.plot(
|
||||
[x, x],
|
||||
[-0.05, -0.01],
|
||||
color="black",
|
||||
transform=ax.get_xaxis_transform(),
|
||||
clip_on=False,
|
||||
)
|
||||
|
||||
plt.tight_layout()
|
||||
|
||||
chart_path = self.charts_dir / "body_fat_percentage_chart.png"
|
||||
plt.savefig(chart_path, bbox_inches="tight", dpi=300)
|
||||
plt.close()
|
||||
|
||||
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||
|
||||
def generate_body_composition_chart(
|
||||
self, fat_mass_lbs: float, lean_mass_lbs: float, save_as_base64: bool = False
|
||||
) -> str:
|
||||
"""Generate donut chart for body composition"""
|
||||
# Calculate percentages
|
||||
total_weight = fat_mass_lbs + lean_mass_lbs
|
||||
fat_percentage = (fat_mass_lbs / total_weight) * 100
|
||||
lean_percentage = (lean_mass_lbs / total_weight) * 100
|
||||
|
||||
# Data for the chart
|
||||
sizes = [fat_percentage, lean_percentage]
|
||||
colors = ["#fde3ac", "#ff9966"] # Light yellow/tan and orange
|
||||
|
||||
plt.figure(figsize=(8, 8))
|
||||
|
||||
# Create the donut chart without labels first
|
||||
wedges, texts, autotexts = plt.pie(
|
||||
sizes,
|
||||
autopct="", # Remove auto percentages
|
||||
startangle=90,
|
||||
wedgeprops=dict(width=0.5, edgecolor="w"),
|
||||
colors=colors,
|
||||
labels=["", ""],
|
||||
) # Remove default labels
|
||||
|
||||
# Add custom text annotations positioned manually
|
||||
plt.text(
|
||||
-1,
|
||||
1,
|
||||
f"Fat Mass ({fat_mass_lbs:.1f}lbs)\n{fat_percentage:.1f}%",
|
||||
fontsize=14,
|
||||
fontweight="bold",
|
||||
ha="center",
|
||||
va="center",
|
||||
bbox=dict(boxstyle="round,pad=0.3", facecolor="white", alpha=0.8),
|
||||
)
|
||||
|
||||
plt.text(
|
||||
1,
|
||||
-1,
|
||||
f"Lean Mass ({lean_mass_lbs:.1f}lbs)\n{lean_percentage:.1f}%",
|
||||
fontsize=14,
|
||||
fontweight="bold",
|
||||
ha="center",
|
||||
va="center",
|
||||
bbox=dict(boxstyle="round,pad=0.3", facecolor="white", alpha=0.8),
|
||||
)
|
||||
|
||||
# Set the title
|
||||
plt.axis("equal") # Equal aspect ratio ensures that pie is drawn as a circle
|
||||
|
||||
chart_path = self.charts_dir / "body_composition_chart.png"
|
||||
plt.savefig(chart_path, bbox_inches="tight", dpi=600)
|
||||
plt.close()
|
||||
|
||||
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||
|
||||
def generate_spirometry_chart(
|
||||
self, spirometry_df: pd.DataFrame, save_as_base64: bool = False
|
||||
) -> str:
|
||||
"""Generate spirometry chart with Z-scores and ranges"""
|
||||
# Coerce numeric columns
|
||||
for col in ["Best", "LLN", "Pred.", "%Pred.", "ZScore"]:
|
||||
if col in spirometry_df.columns:
|
||||
spirometry_df[col] = pd.to_numeric(spirometry_df[col], errors="coerce")
|
||||
|
||||
# Select rows of interest and prepare display values
|
||||
rows_map = {
|
||||
"Lung Volume": "FVC",
|
||||
"Lung Power": "FEV1",
|
||||
"Power/Volume": "FEV1/FVC%",
|
||||
}
|
||||
|
||||
records = []
|
||||
for label, param in rows_map.items():
|
||||
row = spirometry_df.loc[spirometry_df["Parameters"].str.strip() == param]
|
||||
if row.empty:
|
||||
continue
|
||||
row = row.iloc[0]
|
||||
records.append(
|
||||
{
|
||||
"label": label,
|
||||
"param": param,
|
||||
"best": row["Best"],
|
||||
"pct": row["%Pred."],
|
||||
"z": row["ZScore"],
|
||||
}
|
||||
)
|
||||
|
||||
# Figure setup
|
||||
fig, axes = plt.subplots(
|
||||
nrows=3,
|
||||
ncols=1,
|
||||
figsize=(11.5, 3.6),
|
||||
sharex=True,
|
||||
gridspec_kw={"hspace": 0.65},
|
||||
)
|
||||
|
||||
x_min, x_max = -5, 3
|
||||
# Segment colors: red -> orange -> yellow -> green
|
||||
segments = [
|
||||
(-5, -4, "#f4a7a7"), # red-ish
|
||||
(-4, -3, "#f7c49a"), # orange-ish
|
||||
(-3, -1.7, "#f6e3a3"), # yellow-ish
|
||||
(-1.7, 3, "#c9f0cc"), # green-ish
|
||||
]
|
||||
|
||||
ticks = np.arange(x_min, x_max + 1, 1)
|
||||
labels = [str(i) for i in ticks]
|
||||
|
||||
# Plot each row
|
||||
for ax, rec in zip(axes, records):
|
||||
# Background segments
|
||||
for a, b, color in segments:
|
||||
ax.barh(
|
||||
0, width=b - a, left=a, height=0.6, color=color, edgecolor="none"
|
||||
)
|
||||
|
||||
# LLN (-1) and Predicted (0) markers
|
||||
ax.axvline(0, color="black", lw=1)
|
||||
|
||||
# Z-score pointer (downward triangle) at top of each panel
|
||||
if pd.notna(rec["z"]):
|
||||
trans = mtransforms.blended_transform_factory(
|
||||
ax.transData, ax.transAxes
|
||||
)
|
||||
ax.plot(
|
||||
float(rec["z"]),
|
||||
1.2,
|
||||
marker="v",
|
||||
markersize=12,
|
||||
color="dimgray",
|
||||
transform=trans,
|
||||
clip_on=False,
|
||||
)
|
||||
|
||||
# Labels, ticks, and styling
|
||||
ax.set_title(
|
||||
rec["label"], loc="left", fontsize=11, fontweight="bold", pad=2
|
||||
)
|
||||
ax.set_xlim(x_min, x_max)
|
||||
ax.set_yticks([])
|
||||
ax.set_xticks(ticks)
|
||||
ax.set_xticklabels(labels, fontsize=8)
|
||||
ax.set_xlabel("")
|
||||
|
||||
# Top annotations
|
||||
axes[0].text(-1.7, 0.45, "LLN", ha="center", va="bottom", fontsize=9)
|
||||
axes[0].text(0, 0.45, "Predicted", ha="center", va="bottom", fontsize=9)
|
||||
|
||||
# Right-side summary boxes
|
||||
fig.subplots_adjust(right=0.78)
|
||||
box_ax = fig.add_axes(
|
||||
[0.805, 0.06, 0.18, 0.90]
|
||||
) # [left, bottom, width, height]
|
||||
box_ax.axis("off")
|
||||
|
||||
# Helper to draw a pill-shaped text box
|
||||
def pill(ax, xy, text):
|
||||
x, y = xy
|
||||
# Draw rounded rectangle background
|
||||
bbox = FancyBboxPatch(
|
||||
(x - 0.48, y - 0.09),
|
||||
0.96,
|
||||
0.18,
|
||||
boxstyle="round,pad=0.02,rounding_size=0.08",
|
||||
ec="#dddddd",
|
||||
fc="#f3f3f3",
|
||||
linewidth=1.0,
|
||||
)
|
||||
ax.add_patch(bbox)
|
||||
ax.text(
|
||||
x,
|
||||
y + 0.025,
|
||||
text,
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=11,
|
||||
fontweight="bold",
|
||||
)
|
||||
ax.text(
|
||||
x,
|
||||
y - 0.055,
|
||||
"of predicted",
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=9,
|
||||
color="#555555",
|
||||
)
|
||||
|
||||
box_ax.set_xlim(0, 1)
|
||||
box_ax.set_ylim(0, 1)
|
||||
|
||||
# Prepare display strings and positions (top to bottom)
|
||||
right_items = []
|
||||
for rec in records:
|
||||
name = (
|
||||
"FVC"
|
||||
if rec["param"] == "FVC"
|
||||
else ("FEV1" if rec["param"] == "FEV1" else "FEV1/FVC")
|
||||
)
|
||||
unit = "L" if rec["param"] in ("FVC", "FEV1") else "%"
|
||||
value_fmt = f"{rec['best']:.2f}{unit}"
|
||||
pct_fmt = f"{rec['pct']:.1f}%"
|
||||
right_items.append((name, value_fmt, pct_fmt))
|
||||
|
||||
# Sort to match image order on the right (FVC, FEV1, FEV1/FVC)
|
||||
order = ["FVC", "FEV1", "FEV1/FVC"]
|
||||
right_items_sorted = [
|
||||
next(item for item in right_items if item[0] == k) for k in order
|
||||
]
|
||||
|
||||
ys = [0.82, 0.48, 0.15]
|
||||
for (name, value_fmt, pct_fmt), y in zip(right_items_sorted, ys):
|
||||
main_line = f"{name}\n{value_fmt} → {pct_fmt}"
|
||||
pill(box_ax, (0.5, y), main_line)
|
||||
|
||||
chart_path = self.charts_dir / "spirometry_chart.png"
|
||||
plt.savefig(chart_path, dpi=300, bbox_inches="tight")
|
||||
plt.close()
|
||||
|
||||
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||
|
||||
def generate_all_charts(
|
||||
self,
|
||||
pnoe_df: pd.DataFrame,
|
||||
spirometry_df: pd.DataFrame,
|
||||
patient_data: Dict,
|
||||
save_as_base64: bool = False,
|
||||
) -> Dict[str, str]:
|
||||
"""Generate all charts at once and return dictionary of paths/base64 strings"""
|
||||
charts = {}
|
||||
|
||||
# Generate physiological charts
|
||||
charts["respiratory"] = self.generate_respiratory_chart(pnoe_df, save_as_base64)
|
||||
charts["fuel_utilization_chart"] = self.generate_fuel_utilization_chart(
|
||||
pnoe_df, save_as_base64
|
||||
)
|
||||
charts["vo2_pulse_chart"] = self.generate_vo2_pulse_chart(
|
||||
pnoe_df, save_as_base64
|
||||
)
|
||||
charts["vo2_breath_chart"] = self.generate_vo2_breath_chart(
|
||||
pnoe_df, save_as_base64
|
||||
)
|
||||
charts["fat_metabolism_chart"] = self.generate_fat_metabolism_chart(
|
||||
pnoe_df, save_as_base64
|
||||
)
|
||||
charts["recovery_chart"] = self.generate_recovery_chart(pnoe_df, save_as_base64)
|
||||
|
||||
# Generate body composition charts
|
||||
if (
|
||||
"gender" in patient_data
|
||||
and "age" in patient_data
|
||||
and "fat_percentage" in patient_data
|
||||
):
|
||||
charts["body_fat_percentage_chart"] = (
|
||||
self.generate_body_fat_percentage_chart(
|
||||
patient_data["gender"],
|
||||
patient_data["age"],
|
||||
patient_data["fat_percentage"],
|
||||
save_as_base64,
|
||||
)
|
||||
)
|
||||
|
||||
if "fat_mass_lbs" in patient_data and "lean_mass_lbs" in patient_data:
|
||||
charts["body_composition_chart"] = self.generate_body_composition_chart(
|
||||
patient_data["fat_mass_lbs"],
|
||||
patient_data["lean_mass_lbs"],
|
||||
save_as_base64,
|
||||
)
|
||||
|
||||
# Generate spirometry chart
|
||||
charts["spirometry_chart"] = self.generate_spirometry_chart(
|
||||
spirometry_df, save_as_base64
|
||||
)
|
||||
|
||||
return charts
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == "__main__":
|
||||
# Initialize graph generator
|
||||
generator = GraphGenerator()
|
||||
|
||||
# Load sample data (you would pass your actual dataframes)
|
||||
pnoe_df = pd.read_csv("data/Pnoe_20250729_1550-Moran_Keirstyn.csv", delimiter=";")
|
||||
spirometry_df = pd.read_csv("data/spirometry_data.csv")
|
||||
|
||||
# Preprocess pnoe data (same as in your notebook)
|
||||
pnoe_df = pnoe_df.apply(pd.to_numeric, errors="ignore")
|
||||
pnoe_df["VO2 Pulse"] = pnoe_df["VO2(ml/min)"] / pnoe_df["HR(bpm)"]
|
||||
pnoe_df["VO2 Breath"] = pnoe_df["VO2(ml/min)"] / pnoe_df["BF(bpm)"]
|
||||
pnoe_df["CHO"] = pnoe_df["EE(kcal/min)"] * pnoe_df["CARBS(%)"] / 100
|
||||
pnoe_df["FAT"] = pnoe_df["EE(kcal/min)"] * 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 pnoe_df.columns:
|
||||
pnoe_df[f"{col}_smoothed"] = (
|
||||
pnoe_df[col].rolling(window=window_size, min_periods=1).mean()
|
||||
)
|
||||
|
||||
# Patient data
|
||||
patient_data = {
|
||||
"gender": "female",
|
||||
"age": 25,
|
||||
"fat_percentage": 22.4,
|
||||
"fat_mass_lbs": 27.6,
|
||||
"lean_mass_lbs": 95.4,
|
||||
}
|
||||
|
||||
# Generate all charts
|
||||
charts = generator.generate_all_charts(
|
||||
pnoe_df, spirometry_df, patient_data, save_as_base64=True
|
||||
)
|
||||
|
||||
print(f"Generated {len(charts)} charts:")
|
||||
for chart_name in charts.keys():
|
||||
print(f"- {chart_name}")
|
||||
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 425 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 322 KiB |
@@ -0,0 +1,130 @@
|
||||
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 = []
|
||||
a = 1
|
||||
b = "string"
|
||||
c = [1,2,3,5]
|
||||
d = {
|
||||
"key": "value"
|
||||
}
|
||||
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")
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def mifflin_st_jeor(weight_kg, height_cm, age_years, sex):
|
||||
"""
|
||||
Compute predicted RMR with Mifflin St Jeor.
|
||||
sex: 'male' or 'female'
|
||||
"""
|
||||
base = 10.0 * weight_kg + 6.25 * height_cm - 5.0 * age_years
|
||||
if sex.lower().startswith("m"):
|
||||
return base + 5.0
|
||||
else:
|
||||
return base - 161.0
|
||||
|
||||
|
||||
def classify_metabolism(measured_kcal_day, predicted_kcal_day):
|
||||
"""
|
||||
Classify metabolic rate relative to prediction.
|
||||
Returns (label, ratio).
|
||||
"""
|
||||
ratio = measured_kcal_day / predicted_kcal_day
|
||||
|
||||
if ratio < 0.70:
|
||||
label = "very slow"
|
||||
elif ratio < 0.90:
|
||||
label = "slow"
|
||||
elif ratio <= 1.10:
|
||||
label = "average"
|
||||
elif ratio <= 1.30:
|
||||
label = "fast"
|
||||
else:
|
||||
label = "very fast"
|
||||
|
||||
return label, ratio
|
||||
|
||||
|
||||
def find_sampling_window(df):
|
||||
"""
|
||||
Derive number of samples that represent about 2 minutes.
|
||||
"""
|
||||
dt = df["T(sec)"].diff().median()
|
||||
if dt is None or dt <= 0:
|
||||
raise ValueError("Invalid time step in T(sec)")
|
||||
|
||||
samples = int(round(120.0 / dt))
|
||||
if samples < 1:
|
||||
samples = 1
|
||||
return samples
|
||||
|
||||
|
||||
def rolling_stable_window(df, window_samples):
|
||||
"""
|
||||
Find the most stable 2-minute window using rolling standard deviation.
|
||||
Returns:
|
||||
means_series, t_start, t_end
|
||||
"""
|
||||
cols_mean = [
|
||||
"VO2(ml/min)",
|
||||
"VCO2(ml/min)",
|
||||
"VE(l/min)",
|
||||
"VT(l)",
|
||||
"BF(bpm)",
|
||||
"EE(kcal/min)",
|
||||
"RER",
|
||||
"CARBS(%)",
|
||||
"FAT(%)",
|
||||
]
|
||||
|
||||
cols_std = [
|
||||
"VO2(ml/min)",
|
||||
"VCO2(ml/min)",
|
||||
"VE(l/min)",
|
||||
"VT(l)",
|
||||
"BF(bpm)",
|
||||
]
|
||||
|
||||
roll_mean = df[cols_mean].rolling(window_samples, min_periods=window_samples).mean()
|
||||
roll_std = df[cols_std].rolling(window_samples, min_periods=window_samples).std()
|
||||
|
||||
# Sum std devs to get stability score; use skipna=False to preserve NaN for incomplete windows
|
||||
stability_score = roll_std.sum(axis=1, skipna=False)
|
||||
|
||||
# Find index with lowest stability score (dropna to ignore incomplete windows)
|
||||
best_idx = stability_score.dropna().idxmin()
|
||||
|
||||
means_series = roll_mean.loc[best_idx].copy()
|
||||
|
||||
start_idx = max(best_idx - window_samples + 1, 0)
|
||||
end_idx = best_idx
|
||||
|
||||
t_start = float(df["T(sec)"].iloc[start_idx])
|
||||
t_end = float(df["T(sec)"].iloc[end_idx])
|
||||
|
||||
return means_series, t_start, t_end
|
||||
|
||||
|
||||
def manual_window_means(df, t_start, t_end):
|
||||
"""
|
||||
Compute mean values inside a user-selected time window.
|
||||
"""
|
||||
mask = (df["T(sec)"] >= t_start) & (df["T(sec)"] <= t_end)
|
||||
slice_df = df.loc[mask].copy()
|
||||
|
||||
if slice_df.empty:
|
||||
raise ValueError("Manual window has no rows inside T(sec) range")
|
||||
|
||||
cols = [
|
||||
"VO2(ml/min)",
|
||||
"VCO2(ml/min)",
|
||||
"VE(l/min)",
|
||||
"VT(l)",
|
||||
"BF(bpm)",
|
||||
"EE(kcal/min)",
|
||||
"RER",
|
||||
"CARBS(%)",
|
||||
"FAT(%)",
|
||||
]
|
||||
|
||||
means = slice_df[cols].mean()
|
||||
return means, float(t_start), float(t_end)
|
||||
|
||||
|
||||
def load_pnoe_csv(path):
|
||||
"""
|
||||
Load and clean a PNOE CSV file.
|
||||
"""
|
||||
df = pd.read_csv(path, sep=";")
|
||||
|
||||
numeric_cols = [
|
||||
"T(sec)",
|
||||
"VO2(ml/min)",
|
||||
"VCO2(ml/min)",
|
||||
"RER",
|
||||
"VE(l/min)",
|
||||
"VT(l)",
|
||||
"BF(bpm)",
|
||||
"EE(kcal/min)",
|
||||
"CARBS(%)",
|
||||
"FAT(%)",
|
||||
]
|
||||
|
||||
for col in numeric_cols:
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
|
||||
df = df.dropna(subset=["VO2(ml/min)", "EE(kcal/min)"]).reset_index(drop=True)
|
||||
return df
|
||||
|
||||
|
||||
def analyze_pnoe_rmr(
|
||||
path,
|
||||
weight_kg,
|
||||
height_cm,
|
||||
age_years,
|
||||
sex,
|
||||
subject_name=None,
|
||||
test_date=None,
|
||||
manual_window=None,
|
||||
):
|
||||
"""
|
||||
Analyze resting RMR from a PNOE CSV file.
|
||||
|
||||
manual_window:
|
||||
None for automatic stable window
|
||||
or (t_start_sec, t_end_sec) for user-chosen window
|
||||
"""
|
||||
df = load_pnoe_csv(path)
|
||||
window_samples = find_sampling_window(df)
|
||||
|
||||
# Automatic stable window
|
||||
auto_means, auto_t_start, auto_t_end = rolling_stable_window(df, window_samples)
|
||||
|
||||
# Manual override if provided
|
||||
manual_means = None
|
||||
manual_t_start = None
|
||||
manual_t_end = None
|
||||
|
||||
if manual_window is not None:
|
||||
t_start_manual, t_end_manual = manual_window
|
||||
manual_means, manual_t_start, manual_t_end = manual_window_means(
|
||||
df, t_start_manual, t_end_manual
|
||||
)
|
||||
chosen_source = "manual"
|
||||
chosen_means = manual_means
|
||||
chosen_t_start = manual_t_start
|
||||
chosen_t_end = manual_t_end
|
||||
else:
|
||||
chosen_source = "auto"
|
||||
chosen_means = auto_means
|
||||
chosen_t_start = auto_t_start
|
||||
chosen_t_end = auto_t_end
|
||||
|
||||
kcal_per_min = float(chosen_means["EE(kcal/min)"])
|
||||
rmr_kcal_day = kcal_per_min * 1440.0
|
||||
|
||||
predicted_kcal_day = mifflin_st_jeor(weight_kg, height_cm, age_years, sex)
|
||||
label, ratio = classify_metabolism(rmr_kcal_day, predicted_kcal_day)
|
||||
|
||||
def pack_metrics(prefix, means, t_start, t_end):
|
||||
if means is None:
|
||||
return {}
|
||||
return {
|
||||
f"{prefix}_window_start_sec": t_start,
|
||||
f"{prefix}_window_end_sec": t_end,
|
||||
f"{prefix}_VO2_L_min": float(means["VO2(ml/min)"]) / 1000.0,
|
||||
f"{prefix}_VCO2_L_min": float(means["VCO2(ml/min)"]) / 1000.0,
|
||||
f"{prefix}_VE_L_min": float(means["VE(l/min)"]),
|
||||
f"{prefix}_VT_L": float(means["VT(l)"]),
|
||||
f"{prefix}_BF_bpm": float(means["BF(bpm)"]),
|
||||
f"{prefix}_RER": float(means["RER"]),
|
||||
f"{prefix}_Fat_percent": float(means["FAT(%)"]),
|
||||
f"{prefix}_Carb_percent": float(means["CARBS(%)"]),
|
||||
f"{prefix}_kcal_per_min": float(means["EE(kcal/min)"]),
|
||||
}
|
||||
|
||||
result = {
|
||||
"subject_name": subject_name,
|
||||
"test_date": test_date,
|
||||
"sex": sex,
|
||||
"weight_kg": weight_kg,
|
||||
"height_cm": height_cm,
|
||||
"age_years": age_years,
|
||||
"chosen_window_source": chosen_source,
|
||||
"chosen_window_start_sec": chosen_t_start,
|
||||
"chosen_window_end_sec": chosen_t_end,
|
||||
"RMR_kcal_day": rmr_kcal_day,
|
||||
"Mifflin_kcal_day": predicted_kcal_day,
|
||||
"Measured_to_Mifflin_ratio": ratio,
|
||||
"Metabolic_classification": label,
|
||||
}
|
||||
|
||||
result.update(pack_metrics("auto", auto_means, auto_t_start, auto_t_end))
|
||||
result.update(pack_metrics("manual", manual_means, manual_t_start, manual_t_end))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
result = analyze_pnoe_rmr(
|
||||
path="/home/oluwasanmi/Documents/Work/MKD/report_generation/data/Pnoe_20250729_1550-Moran_Keirstyn.csv",
|
||||
weight_kg=56,
|
||||
height_cm=162,
|
||||
age_years=34,
|
||||
sex="female",
|
||||
subject_name="Cullen Pacas",
|
||||
test_date="2025-11-12",
|
||||
manual_window=None, # or (t_start_sec, t_end_sec)
|
||||
)
|
||||
|
||||
for key, value in result.items():
|
||||
print(f"{key}: {value}")
|
||||
@@ -1,18 +0,0 @@
|
||||
Metric,Value
|
||||
Left Baseline SmO2 (%),75.37
|
||||
Right Baseline SmO2 (%),82.91
|
||||
Left Minimum SmO2 (%),69.34
|
||||
Right Minimum SmO2 (%),73.65
|
||||
Left Maximum SmO2 (%),78.24
|
||||
Right Maximum SmO2 (%),82.59
|
||||
Left Recovery SmO2 (%),82.47
|
||||
Right Recovery SmO2 (%),80.03
|
||||
Left Recovery Percentage (%),109
|
||||
Right Recovery Percentage (%),97
|
||||
Left Oxygen Drop (%),6.03
|
||||
Right Oxygen Drop (%),9.26
|
||||
Warmup HR (bpm),93.2
|
||||
Maximum HR (bpm),168.2
|
||||
Recovery HR (bpm),107.7
|
||||
Test Duration (seconds),1287
|
||||
Recovery Duration (seconds),159
|
||||
|
@@ -1,192 +0,0 @@
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
# --- CONFIGURATION TABLES (From your PDFs) ---
|
||||
|
||||
# From deficit.pdf
|
||||
ACTIVITY_MULTIPLIERS = {
|
||||
"Sedentary": 1.2, "Light": 1.375, "Moderate": 1.55, "Active": 1.7, "Extreme": 1.9
|
||||
}
|
||||
|
||||
# From deficit.pdf (Weight Loss kg -> Calorie Deficit)
|
||||
DEFICIT_TABLE = {
|
||||
0.1: 85, 0.2: 169, 0.3: 254, 0.4: 339, 0.5: 423,
|
||||
0.6: 508, 0.7: 593, 0.8: 677, 0.9: 762, 1.0: 847,
|
||||
1.1: 931, 1.2: 1016
|
||||
}
|
||||
|
||||
# From no_deficit.pdf (Protein Multipliers g/kg of Lean Body Mass)
|
||||
PROTEIN_GUIDELINES = {
|
||||
(0, 30): {'maintenance': 1.9, 'deficit': 2.3},
|
||||
(30, 40): {'maintenance': 2.15, 'deficit': 2.6},
|
||||
(40, 50): {'maintenance': 2.45, 'deficit': 2.95},
|
||||
(50, 60): {'maintenance': 2.75, 'deficit': 3.3},
|
||||
(60, 100): {'maintenance': 3.05, 'deficit': 3.65}
|
||||
}
|
||||
|
||||
def analyze_pnoe_data(csv_path):
|
||||
"""
|
||||
Parses PNOE CSV. FIX: Uses MEDIAN instead of MEAN to avoid outliers.
|
||||
"""
|
||||
df = pd.read_csv(csv_path, delimiter=';')
|
||||
df.columns = df.columns.str.strip()
|
||||
|
||||
# Filter for RMR window (assumed T=60s to T=300s, 4 minutes of stable rest)
|
||||
df_stable = df[(df['T(sec)'] >= 60) & (df['T(sec)'] <= 300)].copy()
|
||||
|
||||
# Ensure data columns are numeric
|
||||
for col in ['EE(kcal/day)', 'RER', 'T(sec)']:
|
||||
df_stable.loc[:, col] = pd.to_numeric(df_stable[col], errors='coerce')
|
||||
|
||||
df_stable.dropna(subset=['EE(kcal/day)', 'RER'], inplace=True)
|
||||
|
||||
if not df_stable.empty:
|
||||
# **CRITICAL CHANGE: Use Median instead of Mean**
|
||||
rmr_measured = df_stable['EE(kcal/day)'].median()
|
||||
rer = df_stable['RER'].median()
|
||||
else:
|
||||
# Fallback if window is empty
|
||||
rmr_measured = 1386.0
|
||||
rer = 0.85
|
||||
|
||||
# Calculate Fuel Source
|
||||
clamped_rer = max(0.7, min(1.0, rer))
|
||||
percent_carbs = (clamped_rer - 0.7) / 0.3
|
||||
percent_fat = 1.0 - percent_carbs
|
||||
|
||||
return {
|
||||
"measured_rmr": int(round(rmr_measured)),
|
||||
"rer": round(rer, 2),
|
||||
"fuel_source": {
|
||||
"fat_percent": round(percent_fat * 100, 1),
|
||||
"carb_percent": round(percent_carbs * 100, 1)
|
||||
}
|
||||
}
|
||||
|
||||
def assess_metabolic_health(measured_rmr, weight_kg, height_cm, age, sex):
|
||||
"""
|
||||
Calculates Predicted RMR (Mifflin-St Jeor) and compares to Measured RMR.
|
||||
"""
|
||||
# Mifflin-St Jeor Formula
|
||||
if sex.lower() == 'male':
|
||||
predicted_rmr = (10 * weight_kg) + (6.25 * height_cm) - (5 * age) + 5
|
||||
else:
|
||||
predicted_rmr = (10 * weight_kg) + (6.25 * height_cm) - (5 * age) - 161
|
||||
|
||||
variance = ((measured_rmr - predicted_rmr) / predicted_rmr) * 100
|
||||
|
||||
# Interpretation
|
||||
if variance > 10:
|
||||
metabolism_type = "Fast"
|
||||
elif variance < -10:
|
||||
metabolism_type = "Slow"
|
||||
else:
|
||||
metabolism_type = "Normal"
|
||||
|
||||
return {
|
||||
"predicted_rmr_mifflin": int(round(predicted_rmr)),
|
||||
"variance_percent": round(variance, 1),
|
||||
"metabolism_type": metabolism_type
|
||||
}
|
||||
|
||||
def generate_nutrition_plan(measured_rmr, weight_kg, body_fat_percent, age, activity_level, weekly_weight_loss_goal_kg):
|
||||
"""
|
||||
Calculates TDEE, applies Deficit, and calculates Macros based on uploaded PDFs.
|
||||
"""
|
||||
# 1. TDEE (Maintenance Calories)
|
||||
multiplier = ACTIVITY_MULTIPLIERS.get(activity_level, 1.2)
|
||||
maintenance_calories = measured_rmr * multiplier
|
||||
|
||||
# 2. Daily Calorie Target
|
||||
daily_deficit = DEFICIT_TABLE.get(weekly_weight_loss_goal_kg, 0)
|
||||
target_calories = maintenance_calories - daily_deficit
|
||||
is_deficit = daily_deficit > 0
|
||||
|
||||
# 3. Protein Needs (Based on Lean Body Mass and age/deficit status)
|
||||
lean_mass_kg = weight_kg * (1 - (body_fat_percent / 100))
|
||||
|
||||
protein_multiplier = 1.8 # default fallback
|
||||
for (min_age, max_age), values in PROTEIN_GUIDELINES.items():
|
||||
if min_age <= age < max_age:
|
||||
protein_multiplier = values['deficit'] if is_deficit else values['maintenance']
|
||||
break
|
||||
|
||||
daily_protein_grams = lean_mass_kg * protein_multiplier
|
||||
protein_calories = daily_protein_grams * 4
|
||||
|
||||
# 4. Remaining Macros (Fats and Carbs)
|
||||
FAT_PERCENT_OF_TOTAL_CALORIES = 0.28 # Standard 25-30% fat allocation
|
||||
|
||||
fat_calories = target_calories * FAT_PERCENT_OF_TOTAL_CALORIES
|
||||
fat_grams = fat_calories / 9
|
||||
|
||||
carb_calories = target_calories - protein_calories - fat_calories
|
||||
carb_grams = carb_calories / 4
|
||||
|
||||
if carb_calories < 0:
|
||||
carb_calories = 0
|
||||
carb_grams = 0
|
||||
|
||||
return {
|
||||
"tdee_maintenance": int(round(maintenance_calories)),
|
||||
"daily_deficit": daily_deficit,
|
||||
"target_calories": int(round(target_calories)),
|
||||
"macros": {
|
||||
"protein_g": int(round(daily_protein_grams)),
|
||||
"fats_g": int(round(fat_grams)),
|
||||
"carbs_g": int(round(carb_grams))
|
||||
},
|
||||
"caloric_breakdown": {
|
||||
"protein_kcal": int(round(protein_calories)),
|
||||
"fats_kcal": int(round(fat_calories)),
|
||||
"carbs_kcal": int(round(carb_calories))
|
||||
}
|
||||
}
|
||||
|
||||
# --- EXECUTION EXAMPLE ---
|
||||
|
||||
# 1. Run Analysis on the CSV
|
||||
# Replace with your actual file path
|
||||
csv_result = analyze_pnoe_data('/home/oluwasanmi/Documents/Work/MKD/report_generation/data/Pnoe_20250729_1550-Moran_Keirstyn.csv')
|
||||
|
||||
# 2. Inputs for the Calculation (These would come from your UI/Form)
|
||||
user_weight = 85.0 # kg
|
||||
user_height = 180.0 # cm
|
||||
user_age = 35
|
||||
user_sex = 'male'
|
||||
user_body_fat = 20.0 # %
|
||||
user_activity = 'Moderate' # From the PDF list
|
||||
user_goal_loss = 0.5 # kg per week
|
||||
|
||||
# 3. Assess Health
|
||||
health_assessment = assess_metabolic_health(
|
||||
measured_rmr=csv_result['measured_rmr'],
|
||||
weight_kg=user_weight,
|
||||
height_cm=user_height,
|
||||
age=user_age,
|
||||
sex=user_sex
|
||||
)
|
||||
|
||||
# 4. Get Nutrition Plan
|
||||
nutrition_plan = generate_nutrition_plan(
|
||||
measured_rmr=csv_result['measured_rmr'],
|
||||
weight_kg=user_weight,
|
||||
body_fat_percent=user_body_fat,
|
||||
age=user_age,
|
||||
activity_level=user_activity,
|
||||
weekly_weight_loss_goal_kg=user_goal_loss
|
||||
)
|
||||
|
||||
# --- OUTPUT ---
|
||||
print("--- METABOLIC REPORT ---")
|
||||
print(f"Measured RMR: {csv_result['measured_rmr']} kcal/day")
|
||||
print(f"Predicted RMR: {health_assessment['predicted_rmr_mifflin']} kcal/day")
|
||||
print(f"Metabolism Status: {health_assessment['metabolism_type']} ({health_assessment['variance_percent']}%)")
|
||||
print(f"Fuel Source: {csv_result['fuel_source']['fat_percent']}% Fat, {csv_result['fuel_source']['carb_percent']}% Carbs")
|
||||
print("\n--- NUTRITION PLAN ---")
|
||||
print(f"Goal: Lose {user_goal_loss} kg/week")
|
||||
print(f"Daily Calorie Target: {nutrition_plan['target_calories']} kcal (Deficit: {nutrition_plan['daily_deficit']})")
|
||||
print("\nDaily Macros:")
|
||||
print(f"Protein: {nutrition_plan['macros']['protein_g']}g")
|
||||
print(f"Fats: {nutrition_plan['macros']['fats_g']}g")
|
||||
print(f"Carbs: {nutrition_plan['macros']['carbs_g']}g")
|
||||
@@ -1,266 +0,0 @@
|
||||
"""
|
||||
Test script for Page 6 - Meal Plan Calculations
|
||||
Using Keirstyn Moran's actual data
|
||||
|
||||
Expected values from PDF (Page 6):
|
||||
Row 1 (Caloric Deficit - 7 days same):
|
||||
- Calories: 1725 kCals
|
||||
- Protein: 120g (28%)
|
||||
- Carbs: 155g (36%)
|
||||
- Fat: 69g (36%)
|
||||
- Fiber: 25g
|
||||
|
||||
Row 2 (Caloric Deficit with Refeed - 5 weekdays low, 2 weekend high):
|
||||
Weekdays (5 days):
|
||||
- Calories: 1615 kCals
|
||||
- Protein: 120g
|
||||
- Carbs: 142g
|
||||
- Fat: 63g
|
||||
- Fiber: 24g
|
||||
|
||||
Weekends (2 days):
|
||||
- Calories: 2000 kCals
|
||||
- Protein: 120g
|
||||
- Carbs: 190g
|
||||
- Fat: 84g
|
||||
- Fiber: 30g
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/Users/macbook/bio-performx')
|
||||
|
||||
from app.services.context_generator import ContextGenerator
|
||||
|
||||
# Keirstyn Moran's patient data from PDF
|
||||
PATIENT_DATA = {
|
||||
"name": "Keirstyn Moran",
|
||||
"first_name": "Keirstyn",
|
||||
"last_name": "Moran",
|
||||
"age": 34,
|
||||
"height": "5'4\"", # 162.56 cm
|
||||
"weight": 55.79, # 123 lbs = 55.79 kg
|
||||
"gender": "female",
|
||||
"fat_percentage": 20.0, # Estimated
|
||||
"activity_level": "moderate",
|
||||
}
|
||||
|
||||
# RMR metrics from Page 5 (using expected PDF values)
|
||||
RMR_METRICS_EXPECTED = {
|
||||
"total_calories": 1725,
|
||||
"resting_calories": 1386,
|
||||
"neat_calories": 762,
|
||||
"weight_loss_calories": 423,
|
||||
}
|
||||
|
||||
def main():
|
||||
print("=" * 80)
|
||||
print("PAGE 6 - MEAL PLAN CALCULATION TEST")
|
||||
print("=" * 80)
|
||||
print(f"\nPatient: {PATIENT_DATA['name']}")
|
||||
print(f"Weight: {PATIENT_DATA['weight']}kg ({PATIENT_DATA['weight'] * 2.20462:.1f}lbs)")
|
||||
print(f"Body Fat: {PATIENT_DATA['fat_percentage']}%")
|
||||
|
||||
# Create context generator
|
||||
gen = ContextGenerator()
|
||||
|
||||
# Set patient info manually
|
||||
gen.patient_info = PATIENT_DATA.copy()
|
||||
|
||||
# Calculate fat mass and lean mass
|
||||
weight_kg = PATIENT_DATA["weight"]
|
||||
fat_pct = PATIENT_DATA["fat_percentage"]
|
||||
lean_mass_kg = weight_kg * (1 - fat_pct / 100)
|
||||
lean_mass_lbs = lean_mass_kg * 2.20462
|
||||
|
||||
gen.patient_info["fat_mass_lbs"] = weight_kg * fat_pct / 100 * 2.20462
|
||||
gen.patient_info["lean_mass_lbs"] = lean_mass_lbs
|
||||
|
||||
print(f"Lean Mass: {lean_mass_lbs:.2f} lbs ({lean_mass_kg:.2f} kg)")
|
||||
print(f"Fat Mass: {gen.patient_info['fat_mass_lbs']:.2f} lbs")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("CALCULATING MEAL PLAN (using our formula)")
|
||||
print("=" * 80)
|
||||
print(f"\nTotal Daily Calories (from Page 5): {RMR_METRICS_EXPECTED['total_calories']} kcal")
|
||||
|
||||
# Calculate meal plan using our formula
|
||||
try:
|
||||
meal_metrics = gen.calculate_meal_plan_breakdown(RMR_METRICS_EXPECTED)
|
||||
|
||||
print("\n--- Protein Calculation (Bio-PerformX Formula) ---")
|
||||
print(f"Formula: Total Body Weight (kg) × 2.15 g/kg")
|
||||
print(f" = {weight_kg:.2f} × 2.15")
|
||||
protein_grams = weight_kg * 2.15
|
||||
print(f" = {protein_grams:.0f}g protein")
|
||||
protein_calories = protein_grams * 4
|
||||
print(f" = {protein_calories:.0f} kcal from protein")
|
||||
|
||||
print("\n--- Carbs and Fats (50/50 split of remaining calories) ---")
|
||||
remaining = RMR_METRICS_EXPECTED['total_calories'] - protein_calories
|
||||
print(f"Remaining calories: {RMR_METRICS_EXPECTED['total_calories']} - {protein_calories:.0f} = {remaining:.0f} kcal")
|
||||
print(f"Carbs (50%): {remaining * 0.5:.0f} kcal ÷ 4 = {remaining * 0.5 / 4:.0f}g")
|
||||
print(f"Fats (50%): {remaining * 0.5:.0f} kcal ÷ 9 = {remaining * 0.5 / 9:.0f}g")
|
||||
|
||||
print("\n--- Fiber Calculation ---")
|
||||
print(f"Formula: 15g per 1000 calories")
|
||||
print(f" = {RMR_METRICS_EXPECTED['total_calories']} ÷ 1000 × 15")
|
||||
print(f" = {RMR_METRICS_EXPECTED['total_calories'] / 1000 * 15:.0f}g")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("ROW 1: CALORIC DEFICIT (7 days same)")
|
||||
print("=" * 80)
|
||||
print(f"Calories: {meal_metrics['deficit_calories']} kcal")
|
||||
print(f"Protein: {meal_metrics['deficit_protein']}g ({meal_metrics['protein_percentage']}%)")
|
||||
print(f"Carbs: {meal_metrics['deficit_carbs']}g ({meal_metrics['carbs_percentage']}%)")
|
||||
print(f"Fat: {meal_metrics['deficit_fat']}g ({meal_metrics['fats_percentage']}%)")
|
||||
print(f"Fiber: {meal_metrics['deficit_fiber']}g")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("ROW 2: CALORIC DEFICIT WITH REFEED (5 weekdays + 2 weekends)")
|
||||
print("=" * 80)
|
||||
|
||||
print("\nWeekdays (5 days):")
|
||||
print(f"Calories: {meal_metrics['refeed_weekday_calories']} kcal")
|
||||
print(f"Protein: {meal_metrics['refeed_weekday_protein']}g")
|
||||
print(f"Carbs: {meal_metrics['refeed_weekday_carbs']}g")
|
||||
print(f"Fat: {meal_metrics['refeed_weekday_fat']}g")
|
||||
print(f"Fiber: {meal_metrics['refeed_weekday_fiber']}g")
|
||||
|
||||
print("\nWeekends (2 days):")
|
||||
print(f"Calories: {meal_metrics['refeed_weekend_calories']} kcal")
|
||||
print(f"Protein: {meal_metrics['refeed_weekend_protein']}g")
|
||||
print(f"Carbs: {meal_metrics['refeed_weekend_carbs']}g")
|
||||
print(f"Fat: {meal_metrics['refeed_weekend_fat']}g")
|
||||
print(f"Fiber: {meal_metrics['refeed_weekend_fiber']}g")
|
||||
|
||||
print("\n--- Weekly Total Verification ---")
|
||||
weekly_total_row1 = meal_metrics['deficit_calories'] * 7
|
||||
weekly_total_row2 = (meal_metrics['refeed_weekday_calories'] * 5) + (meal_metrics['refeed_weekend_calories'] * 2)
|
||||
print(f"Row 1 Weekly Total: {meal_metrics['deficit_calories']} × 7 = {weekly_total_row1} kcal")
|
||||
print(f"Row 2 Weekly Total: ({meal_metrics['refeed_weekday_calories']} × 5) + ({meal_metrics['refeed_weekend_calories']} × 2) = {weekly_total_row2} kcal")
|
||||
print(f"Difference: {abs(weekly_total_row1 - weekly_total_row2)} kcal (should be ~0)")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("EXPECTED VALUES (From PDF Page 6)")
|
||||
print("=" * 80)
|
||||
|
||||
print("\nRow 1 (Deficit - 7 days):")
|
||||
print("Calories: 1725 kcal")
|
||||
print("Protein: 120g (28%)")
|
||||
print("Carbs: 155g (36%)")
|
||||
print("Fat: 69g (36%)")
|
||||
print("Fiber: 25g")
|
||||
|
||||
print("\nRow 2 Weekdays:")
|
||||
print("Calories: 1615 kcal")
|
||||
print("Protein: 120g")
|
||||
print("Carbs: 142g")
|
||||
print("Fat: 63g")
|
||||
print("Fiber: 24g")
|
||||
|
||||
print("\nRow 2 Weekends:")
|
||||
print("Calories: 2000 kcal")
|
||||
print("Protein: 120g")
|
||||
print("Carbs: 190g")
|
||||
print("Fat: 84g")
|
||||
print("Fiber: 30g")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("COMPARISON")
|
||||
print("=" * 80)
|
||||
|
||||
expected_row1 = {
|
||||
"calories": 1725,
|
||||
"protein": 120,
|
||||
"carbs": 155,
|
||||
"fat": 69,
|
||||
"fiber": 25
|
||||
}
|
||||
|
||||
expected_weekday = {
|
||||
"calories": 1615,
|
||||
"protein": 120,
|
||||
"carbs": 142,
|
||||
"fat": 63,
|
||||
"fiber": 24
|
||||
}
|
||||
|
||||
expected_weekend = {
|
||||
"calories": 2000,
|
||||
"protein": 120,
|
||||
"carbs": 190,
|
||||
"fat": 84,
|
||||
"fiber": 30
|
||||
}
|
||||
|
||||
def compare(label, expected_val, actual_val, unit=""):
|
||||
diff = actual_val - expected_val
|
||||
pct_diff = (diff / expected_val * 100) if expected_val != 0 else 0
|
||||
status = "✓" if abs(pct_diff) < 5 else "✗"
|
||||
print(f"{status} {label:25} Expected: {expected_val:5}{unit} Actual: {actual_val:5}{unit} Diff: {diff:+5.0f} ({pct_diff:+.1f}%)")
|
||||
|
||||
print("\nRow 1 (Deficit - 7 days):")
|
||||
compare("Calories", expected_row1['calories'], meal_metrics['deficit_calories'], " kcal")
|
||||
compare("Protein", expected_row1['protein'], meal_metrics['deficit_protein'], "g")
|
||||
compare("Carbs", expected_row1['carbs'], meal_metrics['deficit_carbs'], "g")
|
||||
compare("Fat", expected_row1['fat'], meal_metrics['deficit_fat'], "g")
|
||||
compare("Fiber", expected_row1['fiber'], meal_metrics['deficit_fiber'], "g")
|
||||
|
||||
print("\nRow 2 Weekdays:")
|
||||
compare("Calories", expected_weekday['calories'], meal_metrics['refeed_weekday_calories'], " kcal")
|
||||
compare("Protein", expected_weekday['protein'], meal_metrics['refeed_weekday_protein'], "g")
|
||||
compare("Carbs", expected_weekday['carbs'], meal_metrics['refeed_weekday_carbs'], "g")
|
||||
compare("Fat", expected_weekday['fat'], meal_metrics['refeed_weekday_fat'], "g")
|
||||
compare("Fiber", expected_weekday['fiber'], meal_metrics['refeed_weekday_fiber'], "g")
|
||||
|
||||
print("\nRow 2 Weekends:")
|
||||
compare("Calories", expected_weekend['calories'], meal_metrics['refeed_weekend_calories'], " kcal")
|
||||
compare("Protein", expected_weekend['protein'], meal_metrics['refeed_weekend_protein'], "g")
|
||||
compare("Carbs", expected_weekend['carbs'], meal_metrics['refeed_weekend_carbs'], "g")
|
||||
compare("Fat", expected_weekend['fat'], meal_metrics['refeed_weekend_fat'], "g")
|
||||
compare("Fiber", expected_weekend['fiber'], meal_metrics['refeed_weekend_fiber'], "g")
|
||||
|
||||
# Overall assessment
|
||||
row1_match = all([
|
||||
abs(meal_metrics['deficit_calories'] - expected_row1['calories']) <= 5,
|
||||
abs(meal_metrics['deficit_protein'] - expected_row1['protein']) <= 5,
|
||||
abs(meal_metrics['deficit_carbs'] - expected_row1['carbs']) <= 5,
|
||||
abs(meal_metrics['deficit_fat'] - expected_row1['fat']) <= 5,
|
||||
])
|
||||
|
||||
weekday_match = all([
|
||||
abs(meal_metrics['refeed_weekday_calories'] - expected_weekday['calories']) <= 10,
|
||||
abs(meal_metrics['refeed_weekday_protein'] - expected_weekday['protein']) <= 5,
|
||||
abs(meal_metrics['refeed_weekday_carbs'] - expected_weekday['carbs']) <= 5,
|
||||
abs(meal_metrics['refeed_weekday_fat'] - expected_weekday['fat']) <= 5,
|
||||
])
|
||||
|
||||
weekend_match = all([
|
||||
abs(meal_metrics['refeed_weekend_calories'] - expected_weekend['calories']) <= 10,
|
||||
abs(meal_metrics['refeed_weekend_protein'] - expected_weekend['protein']) <= 5,
|
||||
abs(meal_metrics['refeed_weekend_carbs'] - expected_weekend['carbs']) <= 10,
|
||||
abs(meal_metrics['refeed_weekend_fat'] - expected_weekend['fat']) <= 5,
|
||||
])
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
if row1_match and weekday_match and weekend_match:
|
||||
print("✓ SUCCESS: Our formula produces values matching the PDF!")
|
||||
else:
|
||||
print("✗ WARNING: Significant differences found. Check:")
|
||||
if not row1_match:
|
||||
print(" - Row 1 calculations (daily deficit)")
|
||||
if not weekday_match:
|
||||
print(" - Weekday calculations (10% reduction)")
|
||||
if not weekend_match:
|
||||
print(" - Weekend calculations (maintaining weekly total)")
|
||||
print("\nNote: Protein formula is Bio-PerformX specific: Lean Mass (lbs) × 2.2")
|
||||
print("=" * 80)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ Error calculating metrics: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -18,12 +18,13 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 19,
|
||||
"id": "da5ac3c1",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"pnoe_df = pd.read_csv('data/pnoe_data.csv', delimiter=';')\n",
|
||||
"patients_info = pd.read_excel('data/patients_data.xlsx')\n",
|
||||
"spirometry_df = pd.read_csv('data/spirometry_data.csv')"
|
||||
]
|
||||
},
|
||||
@@ -253,7 +254,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 11,
|
||||
"id": "2fa8ff13",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -269,10 +270,15 @@
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"def body_composition_chart(fat_percentage=22.4, weight_kg=70):\n",
|
||||
"def body_composition_chart(first_name='Keirstyn', last_name='Moran'):\n",
|
||||
"\n",
|
||||
" \n",
|
||||
" #=========================== Body Composition Donut Chart ========================#\n",
|
||||
" patient_data = patients_info[(patients_info['FirstName'].str.contains(first_name, case=False, na=False)) & \n",
|
||||
" (patients_info['LastName'].str.contains(last_name, case=False, na=False))]\n",
|
||||
"# Get the fat mass percentage for Keirstyn\n",
|
||||
" fat_percentage = patient_data['Adult_FMP'].iloc[0]\n",
|
||||
" weight_kg = patient_data['Weight'].iloc[0]\n",
|
||||
" lean_percentage = 100 - fat_percentage\n",
|
||||
"\n",
|
||||
"# Create donut chart\n",
|
||||
@@ -765,7 +771,7 @@
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": ".venv",
|
||||
"display_name": "report_generation",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
@@ -1,15 +0,0 @@
|
||||
[project]
|
||||
name = "report-generation"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi[all]>=0.121.3",
|
||||
"matplotlib>=3.10.7",
|
||||
"openpyxl>=3.1.5",
|
||||
"pandas>=2.3.3",
|
||||
"playwright>=1.56.0",
|
||||
"requests>=2.32.5",
|
||||
"seaborn>=0.13.2",
|
||||
]
|
||||
@@ -46,12 +46,12 @@
|
||||
|
||||
<!-- Fat Metabolism Graph -->
|
||||
<div class="mb-4">
|
||||
<!-- <div class="bg-gray-100 p-2 rounded-lg mb-2">
|
||||
<div class="bg-gray-100 p-2 rounded-lg mb-2">
|
||||
<p class="text-black font-semibold text-center text-sm"></p>
|
||||
{{ fat_metabolism_note | default('100bpm at a speed of
|
||||
4.0mph and incline of 2%') }}
|
||||
</p>
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
@@ -123,13 +123,108 @@
|
||||
|
||||
<!-- Resting Heart Rate Table -->
|
||||
<div class="mb-2">
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
src="data:image/png;base64, {{ rhr_table }}"
|
||||
alt="Resting Heart Rate Table"
|
||||
class="table-image"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="text-base font-bold text-black mb-2 text-center">
|
||||
Resting Heart Rate - {{ resting_heart_rate | default('53bpm') }}
|
||||
</h3>
|
||||
|
||||
<table class="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
class="bg-cyan-300 border border-gray-400 p-2 text-black font-bold"
|
||||
>
|
||||
Age (F)
|
||||
</th>
|
||||
<th
|
||||
class="bg-cyan-300 border border-gray-400 p-2 text-black font-bold"
|
||||
>
|
||||
Poor
|
||||
</th>
|
||||
<th
|
||||
class="bg-cyan-300 border border-gray-400 p-2 text-black font-bold"
|
||||
>
|
||||
Below Average
|
||||
</th>
|
||||
<th
|
||||
class="bg-cyan-300 border border-gray-400 p-2 text-black font-bold"
|
||||
>
|
||||
Average
|
||||
</th>
|
||||
<th
|
||||
class="bg-cyan-300 border border-gray-400 p-2 text-black font-bold"
|
||||
>
|
||||
Above Average
|
||||
</th>
|
||||
<th
|
||||
class="bg-cyan-300 border border-gray-400 p-2 text-black font-bold"
|
||||
>
|
||||
Good
|
||||
</th>
|
||||
<th
|
||||
class="bg-cyan-300 border border-gray-400 p-2 text-black font-bold"
|
||||
>
|
||||
Excellent
|
||||
</th>
|
||||
<th
|
||||
class="bg-cyan-300 border border-gray-400 p-2 text-black font-bold relative"
|
||||
>
|
||||
Athlete
|
||||
<!-- Arrow indicator -->
|
||||
<div
|
||||
class="absolute -bottom-3 left-1/2 transform -translate-x-1/2"
|
||||
>
|
||||
<div
|
||||
class="w-0 h-0 border-l-3 border-r-3 border-t-6 border-transparent border-t-black"
|
||||
></div>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
class="bg-cyan-200 border border-gray-400 p-2 text-black font-semibold text-center"
|
||||
>
|
||||
{{ hr_age_range | default('26-35') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 p-2 text-black text-center"
|
||||
>
|
||||
{{ hr_poor | default('82bpm +') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 p-2 text-black text-center"
|
||||
>
|
||||
{{ hr_below_avg | default('75-81bpm') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 p-2 text-black text-center"
|
||||
>
|
||||
{{ hr_average | default('71-74bpm') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 p-2 text-black text-center"
|
||||
>
|
||||
{{ hr_above_avg | default('66-70bpm') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 p-2 text-black text-center"
|
||||
>
|
||||
{{ hr_good | default('62-65bpm') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-gray-100 border border-gray-400 p-2 text-black text-center"
|
||||
>
|
||||
{{ hr_excellent | default('55-61bpm') }}
|
||||
</td>
|
||||
<td
|
||||
class="bg-green-200 border border-gray-400 p-2 text-black text-center font-bold"
|
||||
>
|
||||
{{ hr_athlete | default('44-54bpm') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<div class="page bg-white p-8 max-w-4xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-4">Local Muscle Activity</h1>
|
||||
<h2 class="text-xl font-semibold text-gray-800 mb-2">Muscle Oxygenation Assessment</h2>
|
||||
<p class="text-sm text-gray-600 leading-relaxed">
|
||||
SMO2 testing (Skeletal Muscle Oxygen Saturation) is an analysis of how effectively oxygen is being used at a particular muscle. It helps determine limitations on if the muscle is effectively using oxygen when exercising.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Right Leg Section -->
|
||||
<div class="mb-12">
|
||||
<h3 class="text-lg font-semibold text-center text-gray-800 mb-6">Indications - Right Leg</h3>
|
||||
|
||||
<div class="flex gap-8">
|
||||
<!-- Chart Image -->
|
||||
<div class="flex-1">
|
||||
<img src= "data:image/png;base64,{{ right_leg }}" alt="Right Leg SMO2 Chart" class="w-full h-auto">
|
||||
</div>
|
||||
|
||||
<!-- Right Side Info -->
|
||||
<div class="w-48 space-y-4">
|
||||
<div class="bg-gray-100 p-3 rounded">
|
||||
<div class="text-xs font-semibold text-gray-700 mb-1">Surplus</div>
|
||||
<div class="text-xs text-gray-600">Supply > Demand at a heart rate and speed of:</div>
|
||||
<div class="text-sm font-bold text-gray-800">n/a</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-100 p-3 rounded">
|
||||
<div class="text-xs font-semibold text-gray-700 mb-1">Supply Threshold</div>
|
||||
<div class="text-xs text-gray-600">Demand outstrips supply at a heart rate of:</div>
|
||||
<div class="text-sm font-bold text-gray-800">154bpm @ 5.0mph</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-100 p-3 rounded">
|
||||
<div class="text-xs font-semibold text-gray-700 mb-1">Recovery</div>
|
||||
<div class="text-xs text-gray-600">"Optimal >100%"</div>
|
||||
<div class="text-sm font-bold text-gray-800">n/a</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Left Leg Section -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-center text-gray-800 mb-6">Indications - Left Leg</h3>
|
||||
|
||||
<div class="flex gap-8">
|
||||
<!-- Chart Image -->
|
||||
<div class="flex-1">
|
||||
<img src= "data:image/png;base64,{{ left_leg }}" alt="Left Leg SMO2 Chart" class="w-full h-auto">
|
||||
</div>
|
||||
|
||||
<!-- Right Side Info -->
|
||||
<div class="w-48 space-y-4">
|
||||
<div class="bg-gray-100 p-3 rounded">
|
||||
<div class="text-xs font-semibold text-gray-700 mb-1">Surplus</div>
|
||||
<div class="text-xs text-gray-600">Supply > Demand at a heart rate and speed of:</div>
|
||||
<div class="text-sm font-bold text-gray-800">n/a</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-100 p-3 rounded">
|
||||
<div class="text-xs font-semibold text-gray-700 mb-1">Supply Threshold</div>
|
||||
<div class="text-xs text-gray-600">Demand outstrips supply at a heart rate of:</div>
|
||||
<div class="text-sm font-bold text-gray-800">165 bpm @ 5.5mph</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-100 p-3 rounded">
|
||||
<div class="text-xs font-semibold text-gray-700 mb-1">Recovery</div>
|
||||
<div class="text-xs text-gray-600">"Optimal >100%"</div>
|
||||
<div class="text-sm font-bold text-gray-800">n/a</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,216 @@
|
||||
<div class="w-full page bg-white">
|
||||
<!-- Header Section -->
|
||||
<!-- Main Content -->
|
||||
<div class="px-8 py-6">
|
||||
<!-- Page Title -->
|
||||
<h1 class="text-3xl font-bold text-black mb-8 text-center">Training Recommendations</h1>
|
||||
|
||||
<!-- Training Recommendations Section -->
|
||||
<div class="grid grid-cols-2 gap-8 mb-8">
|
||||
<!-- Left Side: Zone Recommendations -->
|
||||
<div class="bg-gray-200 p-6 rounded-lg">
|
||||
<!-- Zone 2 Recommendations -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-bold text-black mb-3">Zone 2 {{ zone2_frequency | default('3-4x/week') }}:</h3>
|
||||
<ul class="text-sm text-black space-y-1 list-disc list-inside">
|
||||
<li>{{ zone2_duration | default('40+ minutes') }} of Steady State Cardio (HR {{ zone2_hr_range | default('____') }} bpm)</li>
|
||||
<li>{{ zone2_speed | default('____ mph') }} at {{ zone2_incline | default('2% Incline') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Zone 3 Recommendations -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-bold text-black mb-3">Zone 3 {{ zone3_frequency | default('1-2x/week') }}:</h3>
|
||||
<ul class="text-sm text-black space-y-1 list-disc list-inside">
|
||||
<li>{{ zone3_duration | default('10-20 minutes') }} in zone 3 (HR {{ zone3_hr_range | default('____ bpm') }})</li>
|
||||
<li>{{ zone3_speed | default('____mph') }} + at {{ zone3_incline | default('2% Incline') }}</li>
|
||||
<li>Slow down cadence until HR reaches {{ zone3_target_hr | default('___ bpm') }}</li>
|
||||
<li>{{ zone3_recovery_speed | default('____mph') }} at {{ zone3_recovery_incline | default('2% Incline') }}</li>
|
||||
<li>Maintain HR in zone 1 ({{ zone1_hr_range | default('____bpm') }}) for {{ zone1_duration | default('4-8 minutes') }}</li>
|
||||
<li>Repeat {{ zone3_repeats | default('2-3 times') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Side: Training Table -->
|
||||
<div>
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="bg-cyan-300 border border-gray-400 p-2 text-black font-bold">Type</th>
|
||||
<th class="bg-cyan-300 border border-gray-400 p-2 text-black font-bold">Sets</th>
|
||||
<th class="bg-cyan-300 border border-gray-400 p-2 text-black font-bold">Effort Duration</th>
|
||||
<th class="bg-cyan-300 border border-gray-400 p-2 text-black font-bold">Zone</th>
|
||||
<th class="bg-cyan-300 border border-gray-400 p-2 text-black font-bold">RPE</th>
|
||||
<th class="bg-cyan-300 border border-gray-400 p-2 text-black font-bold">Recovery Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Short Row -->
|
||||
<tr>
|
||||
<td class="border border-gray-400 p-2 text-center text-black font-semibold">Short</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ short_sets | default('8-10') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ short_duration | default('10-30 seconds') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ short_zone | default('5') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ short_rpe | default('10') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ short_recovery | default('20-60 seconds') }}</td>
|
||||
</tr>
|
||||
|
||||
<!-- Medium Row -->
|
||||
<tr>
|
||||
<td class="border border-gray-400 p-2 text-center text-black font-semibold">Medium</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ medium_sets | default('6-8') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ medium_duration | default('30-90 seconds') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ medium_zone | default('4') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ medium_rpe | default('8-9') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ medium_recovery | default('30-90 seconds') }}</td>
|
||||
</tr>
|
||||
|
||||
<!-- Long Row -->
|
||||
<tr>
|
||||
<td class="border border-gray-400 p-2 text-center text-black font-semibold">Long</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ long_sets | default('4-6') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ long_duration | default('5-10 minutes') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ long_zone | default('3/4') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ long_rpe | default('7-8') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ long_recovery | default('2.5-5 minutes') }}</td>
|
||||
</tr>
|
||||
|
||||
<!-- Tempo Row -->
|
||||
<tr>
|
||||
<td class="border border-gray-400 p-2 text-center text-black font-semibold">Tempo</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ tempo_sets | default('2-3') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ tempo_duration | default('10-20 minutes') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ tempo_zone | default('3') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ tempo_rpe | default('6-7') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ tempo_recovery | default('4-8 minutes') }}</td>
|
||||
</tr>
|
||||
|
||||
<!-- Cardio Row -->
|
||||
<tr>
|
||||
<td class="border border-gray-400 p-2 text-center text-black font-semibold">Cardio</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ cardio_sets | default('1') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ cardio_duration | default('>40 minutes') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ cardio_zone | default('2') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ cardio_rpe | default('4-5') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ cardio_recovery | default('N/A') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Training Week Example Section -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-black mb-6 text-center">Training Week Example with Progression</h2>
|
||||
|
||||
<!-- Week 1 -->
|
||||
<div class="mb-6">
|
||||
<div class="grid grid-cols-7 gap-2">
|
||||
<!-- Monday -->
|
||||
<div class="bg-cyan-300 p-3 text-center rounded-lg">
|
||||
<div class="font-bold text-black mb-1">Monday</div>
|
||||
<div class="text-sm text-black">{{ week1_mon_zone | default('Zone 2') }}</div>
|
||||
<div class="text-sm text-black">{{ week1_mon_duration | default('45 mins') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Tuesday -->
|
||||
<div class="bg-cyan-300 p-3 text-center rounded-lg">
|
||||
<div class="font-bold text-black mb-1">Tuesday</div>
|
||||
<div class="text-sm text-black">{{ week1_tue_zone | default('Zone 2') }}</div>
|
||||
<div class="text-sm text-black">{{ week1_tue_duration | default('45 mins') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Wednesday -->
|
||||
<div class="bg-cyan-300 p-3 text-center rounded-lg">
|
||||
<div class="font-bold text-black mb-1">Wednesday</div>
|
||||
<div class="text-sm text-black">{{ week1_wed_zone | default('Zone 3') }}</div>
|
||||
<div class="text-sm text-black">{{ week1_wed_duration1 | default('10mins On') }}</div>
|
||||
<div class="text-sm text-black">{{ week1_wed_duration2 | default('8mins Rest') }}</div>
|
||||
<div class="text-sm text-black">{{ week1_wed_sets | default('x2') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Thursday -->
|
||||
<div class="bg-gray-200 p-3 text-center rounded-lg">
|
||||
<div class="font-bold text-black mb-1">Thursday</div>
|
||||
<div class="text-sm text-black">{{ week1_thu_content | default('') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Friday -->
|
||||
<div class="bg-cyan-300 p-3 text-center rounded-lg">
|
||||
<div class="font-bold text-black mb-1">Friday</div>
|
||||
<div class="text-sm text-black">{{ week1_fri_zone | default('Zone 2') }}</div>
|
||||
<div class="text-sm text-black">{{ week1_fri_duration | default('45 mins') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Saturday -->
|
||||
<div class="bg-gray-200 p-3 text-center rounded-lg">
|
||||
<div class="font-bold text-black mb-1">Saturday</div>
|
||||
<div class="text-sm text-black">{{ week1_sat_content | default('') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Sunday -->
|
||||
<div class="bg-gray-200 p-3 text-center rounded-lg">
|
||||
<div class="font-bold text-black mb-1">Sunday</div>
|
||||
<div class="text-sm text-black">{{ week1_sun_content | default('') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Week 2 -->
|
||||
<div class="mb-6">
|
||||
<div class="grid grid-cols-7 gap-2">
|
||||
<!-- Monday -->
|
||||
<div class="bg-cyan-300 p-3 text-center rounded-lg">
|
||||
<div class="font-bold text-black mb-1">Monday</div>
|
||||
<div class="text-sm text-black">{{ week2_mon_zone | default('Zone 2') }}</div>
|
||||
<div class="text-sm text-black">{{ week2_mon_duration | default('50 mins') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Tuesday -->
|
||||
<div class="bg-cyan-300 p-3 text-center rounded-lg">
|
||||
<div class="font-bold text-black mb-1">Tuesday</div>
|
||||
<div class="text-sm text-black">{{ week2_tue_zone | default('Zone 2') }}</div>
|
||||
<div class="text-sm text-black">{{ week2_tue_duration | default('50 mins') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Wednesday -->
|
||||
<div class="bg-cyan-300 p-3 text-center rounded-lg">
|
||||
<div class="font-bold text-black mb-1">Wednesday</div>
|
||||
<div class="text-sm text-black">{{ week2_wed_zone | default('Zone 3') }}</div>
|
||||
<div class="text-sm text-black">{{ week2_wed_duration1 | default('10mins On') }}</div>
|
||||
<div class="text-sm text-black">{{ week2_wed_duration2 | default('6mins Rest') }}</div>
|
||||
<div class="text-sm text-black">{{ week2_wed_sets | default('x2') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Thursday -->
|
||||
<div class="bg-gray-200 p-3 text-center rounded-lg">
|
||||
<div class="font-bold text-black mb-1">Thursday</div>
|
||||
<div class="text-sm text-black">{{ week2_thu_content | default('') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Friday -->
|
||||
<div class="bg-cyan-300 p-3 text-center rounded-lg">
|
||||
<div class="font-bold text-black mb-1">Friday</div>
|
||||
<div class="text-sm text-black">{{ week2_fri_zone | default('Zone 2') }}</div>
|
||||
<div class="text-sm text-black">{{ week2_fri_duration | default('50 mins') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Saturday -->
|
||||
<div class="bg-gray-200 p-3 text-center rounded-lg">
|
||||
<div class="font-bold text-black mb-1">Saturday</div>
|
||||
<div class="text-sm text-black">{{ week2_sat_content | default('') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Sunday -->
|
||||
<div class="bg-gray-200 p-3 text-center rounded-lg">
|
||||
<div class="font-bold text-black mb-1">Sunday</div>
|
||||
<div class="text-sm text-black">{{ week2_sun_content | default('') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Section -->
|
||||
</div>
|
||||
@@ -0,0 +1,70 @@
|
||||
<div class="w-full page bg-white">
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="px-8 py-6">
|
||||
<!-- Page Title -->
|
||||
<h1 class="text-3xl font-bold text-black mb-8">Next Steps:</h1>
|
||||
|
||||
<!-- Calorie Tracking Section -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-bold text-black mb-4">Calorie Tracking</h2>
|
||||
<ul class="text-black space-y-2 list-disc list-inside ml-4">
|
||||
<li>Download and create an account with My Fitness Pal (or preferred nutrition tracker)</li>
|
||||
<li>Fill out the "My Profile" section with your goals (ie: height, weight, target weight)
|
||||
<ul class="mt-2 ml-6 space-y-1 list-disc list-inside">
|
||||
<li>Input your Macros</li>
|
||||
<li>Click the three dots on the bottom right corner</li>
|
||||
<li>Click "Goals"</li>
|
||||
<li>Click "Calorie, Carbs, Protein and Fat Goals" under the Nutrition Goals</li>
|
||||
<li>Set the Calories, Carbs, Protein, and Fat to the recommended macro outlined above.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Once completed fill out your food intake from each meal on the main page</li>
|
||||
</ul>
|
||||
|
||||
<!-- Recommendation Note -->
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-black italic font-semibold">It's highly recommended to purchase a weight and food scale for more accurate results.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daily Tasks Section -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-bold text-black mb-4">Daily Tasks</h2>
|
||||
<ul class="text-black space-y-2 list-disc list-inside ml-4">
|
||||
<li>Weigh yourself in the morning, after your first bowel movement, and naked</li>
|
||||
<li>Log your weight into your my fitness pal app</li>
|
||||
<li>Track calories in grams - FOLLOW YOUR PERSONAL RECOMMENDATIONS.</li>
|
||||
<li>Log in a diary:
|
||||
<ul class="mt-2 ml-6 space-y-1 list-disc list-inside">
|
||||
<li>Log any additional prescribed recommendation (i.e breath work)</li>
|
||||
<li>Complete the prescribed training recommendations (i.e Zone 2 Training)</li>
|
||||
<li>Log additional physical activity (i.e Monday - Strength Training 1 hour)</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Two weeks after Appointment Section -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-bold text-black mb-4">Two weeks after Appointment</h2>
|
||||
<ul class="text-black space-y-2 list-disc list-inside ml-4">
|
||||
<li>Should you find the macronutrient breakdown difficult to follow, reach out to us to discuss a change within your caloric parameters</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Contact Information -->
|
||||
<div class="mb-12 text-center">
|
||||
<p class="text-lg font-bold text-black">Should you have any questions or concerns please contact us!</p>
|
||||
</div>
|
||||
|
||||
<!-- Recommended Next Testing Date -->
|
||||
<div class="mb-8 text-center">
|
||||
<h2 class="text-2xl font-bold text-black">
|
||||
<span class="underline">Recommended Next Testing Date:</span>
|
||||
<span class="ml-2">October 2025</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -16,7 +16,7 @@
|
||||
class="w-full border-collapse border border-gray-300 text-xs"
|
||||
>
|
||||
<thead>
|
||||
<tr class="bg-blue-300">
|
||||
<tr class="bg-cyan-200">
|
||||
<th
|
||||
class="border border-gray-300 p-1 font-bold text-center"
|
||||
>
|
||||
@@ -238,7 +238,7 @@
|
||||
class="w-full border-collapse border border-gray-300 text-xs"
|
||||
>
|
||||
<thead>
|
||||
<tr class="bg-blue-300">
|
||||
<tr class="bg-cyan-200">
|
||||
<th
|
||||
class="border border-gray-300 p-1 font-bold text-center"
|
||||
>
|
||||
@@ -467,7 +467,7 @@
|
||||
class="w-full border-collapse border border-gray-300 text-xs"
|
||||
>
|
||||
<thead>
|
||||
<tr class="bg-blue-300">
|
||||
<tr class="bg-cyan-200">
|
||||
<th
|
||||
class="border border-gray-300 p-1 font-bold text-center"
|
||||
>
|
||||
@@ -641,7 +641,7 @@
|
||||
class="w-full border-collapse border border-gray-300 text-xs"
|
||||
>
|
||||
<thead>
|
||||
<tr class="bg-blue-300">
|
||||
<tr class="bg-cyan-200">
|
||||
<th
|
||||
class="border border-gray-300 p-1 font-bold text-center"
|
||||
>
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="bg-white w-full page m-0 px-10">
|
||||
<div class="px-16 py-10">
|
||||
<div class="px-16 pt-10">
|
||||
<!-- Table of Contents Header -->
|
||||
<div class="mb-8">
|
||||
<div class="mb-2">
|
||||
<h1
|
||||
class="text-5xl font-bold text-black mb-6 tracking-wide border-b-4 border-blue-500 pb-2 text-center"
|
||||
>
|
||||
@@ -12,13 +12,46 @@
|
||||
|
||||
<!-- Table of Contents Items -->
|
||||
<div class="flex flex-col justify-between space-y-6 py-6">
|
||||
<!-- Nutrition Guidelines -->
|
||||
<div class="flex items-start bg-gray-200 h-24">
|
||||
<div
|
||||
class="bg-black text-white text-2xl font-bold w-16 h-full flex items-center justify-center mr-8 flex-shrink-0"
|
||||
>
|
||||
4
|
||||
</div>
|
||||
<div class="flex flex-col flex-1 py-1 justify-center h-full">
|
||||
<h2 class="text-2xl font-semibold text-black">
|
||||
Nutrition Guidelines
|
||||
</h2>
|
||||
<p class="text-gray-600 text-base">
|
||||
Ultrasound & Body Composition Assessment
|
||||
</p>
|
||||
<p class="text-gray-600 text-base">
|
||||
Resting Metabolic Rate Assessment
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nutrition Recommendations -->
|
||||
<div class="flex items-start bg-gray-200 h-24">
|
||||
<div
|
||||
class="bg-black text-white text-2xl font-bold w-16 h-full flex items-center justify-center mr-8 flex-shrink-0"
|
||||
>
|
||||
6
|
||||
</div>
|
||||
<div class="flex flex-col py-1 flex-1 justify-center h-full">
|
||||
<h2 class="text-2xl font-semibold text-black">
|
||||
Nutrition Recommendations
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lung Analysis -->
|
||||
<div class="flex items-start bg-gray-200 h-24">
|
||||
<div
|
||||
class="bg-black text-white text-4xl font-extrabold w-24 h-24 flex items-center justify-center mr-8 flex-shrink-0"
|
||||
style="border-radius: 0;"
|
||||
class="bg-black text-white text-2xl font-bold w-16 h-full flex items-center justify-center mr-8 flex-shrink-0"
|
||||
>
|
||||
3
|
||||
7
|
||||
</div>
|
||||
<div class="flex flex-col flex-1 py-1 justify-center h-full">
|
||||
<h2 class="text-2xl font-semibold text-black">
|
||||
@@ -36,13 +69,12 @@
|
||||
<!-- Cardio Metrics -->
|
||||
<div class="flex items-start bg-gray-200 h-24">
|
||||
<div
|
||||
class="bg-black text-white text-4xl font-extrabold w-24 h-24 flex items-center justify-center mr-8 flex-shrink-0"
|
||||
style="border-radius: 0;"
|
||||
class="bg-black text-white text-2xl font-bold w-16 h-full flex items-center justify-center mr-8 flex-shrink-0"
|
||||
>
|
||||
4
|
||||
8
|
||||
</div>
|
||||
<div class="flex flex-col py-1 flex-1 justify-center h-full">
|
||||
<h2 class="text-2xl font-semibold text-black mb-3">
|
||||
<div class="flex flex-col py-1 flex-1 justify-center h-full">
|
||||
<h2 class="text-2xl font-semibold text-black">
|
||||
Cardio Metrics
|
||||
</h2>
|
||||
<p class="text-gray-600 text-base">
|
||||
@@ -51,28 +83,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fuel Utilization -->
|
||||
<div class="flex items-start bg-gray-200 h-24">
|
||||
<div
|
||||
class="bg-black text-white text-4xl font-extrabold w-24 h-24 flex items-center justify-center mr-8 flex-shrink-0"
|
||||
style="border-radius: 0;"
|
||||
>
|
||||
5
|
||||
</div>
|
||||
<div class="flex flex-col py-1 flex-1 justify-center flex-1 h-full">
|
||||
<h2 class="text-2xl font-semibold text-black">
|
||||
Fuel Utilization
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Local Muscle Activity -->
|
||||
<div class="flex items-start bg-gray-200 h-24">
|
||||
<div
|
||||
class="bg-black text-white text-4xl font-extrabold w-24 h-24 flex items-center justify-center mr-8 flex-shrink-0"
|
||||
style="border-radius: 0;"
|
||||
class="bg-black text-white text-2xl font-bold w-16 h-full flex items-center justify-center mr-8 flex-shrink-0"
|
||||
>
|
||||
9
|
||||
11
|
||||
</div>
|
||||
<div class="flex flex-col justify-center h-full flex-1">
|
||||
<h2 class="text-2xl font-semibold text-black">
|
||||
@@ -84,10 +100,9 @@
|
||||
<!-- Training Recommendations -->
|
||||
<div class="flex items-start bg-gray-200 h-24">
|
||||
<div
|
||||
class="bg-black text-white text-4xl font-extrabold w-24 h-24 flex items-center justify-center mr-8 flex-shrink-0"
|
||||
style="border-radius: 0;"
|
||||
class="bg-black text-white text-2xl font-bold w-16 h-full flex items-center justify-center mr-8 flex-shrink-0"
|
||||
>
|
||||
10
|
||||
12
|
||||
</div>
|
||||
<div class="flex flex-col h-full justify-center flex-1">
|
||||
<h2 class="text-2xl font-semibold text-black">
|
||||
@@ -99,36 +114,28 @@
|
||||
<!-- Next Steps -->
|
||||
<div class="flex items-start bg-gray-200 h-24">
|
||||
<div
|
||||
class="bg-black text-white text-4xl font-extrabold w-24 h-24 flex items-center justify-center mr-8 flex-shrink-0"
|
||||
style="border-radius: 0;"
|
||||
class="bg-black text-white text-2xl font-bold w-16 h-full flex items-center justify-center mr-8 flex-shrink-0"
|
||||
>
|
||||
12
|
||||
14
|
||||
</div>
|
||||
<div class="flex flex-col h-full justify-center flex-1">
|
||||
<h2 class="text-2xl font-semibold text-black">
|
||||
Next Steps
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
<!-- No sub-items -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Glossary -->
|
||||
<div class="flex items-start bg-gray-200 h-24">
|
||||
<div
|
||||
class="bg-black text-white text-4xl font-extrabold w-24 h-24 flex items-center justify-center mr-8 flex-shrink-0"
|
||||
style="border-radius: 0;"
|
||||
class="bg-black text-white text-2xl font-bold w-16 h-full flex items-center justify-center mr-8 flex-shrink-0"
|
||||
>
|
||||
13
|
||||
15
|
||||
</div>
|
||||
<div class="flex flex-col h-full justify-center flex-1">
|
||||
<h2 class="text-2xl font-semibold text-black">
|
||||
Glossary
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
<!-- No sub-items -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,219 @@
|
||||
<div class="w-full page bg-white">
|
||||
<!-- Header Section -->
|
||||
<!-- main content -->
|
||||
<div class="px-8 py-6">
|
||||
<!-- Page Title -->
|
||||
<h1 class="text-3xl font-bold text-black mb-8 text-center">Weekly Meal Plan Breakdown</h1>
|
||||
|
||||
<!-- Caloric Deficit Example Section -->
|
||||
<div class="mb-12">
|
||||
<h2 class="text-2xl font-bold text-black mb-6 text-center">Caloric Deficit Example</h2>
|
||||
|
||||
<!-- Weekly Grid -->
|
||||
<div class="grid grid-cols-7 gap-3 mb-8">
|
||||
<!-- Monday -->
|
||||
<div class="bg-cyan-300 p-3 text-left rounded-lg">
|
||||
<div class="font-bold text-black mb-2">Monday</div>
|
||||
<div class="text-lg font-bold text-black mb-2">{{ deficit_calories | default('1725KCals') }}</div>
|
||||
<div class="text-xs text-black space-y-1">
|
||||
<div>{{ deficit_protein | default('120g Protein') }}</div>
|
||||
<div>{{ deficit_carbs | default('155g Carbs') }}</div>
|
||||
<div>{{ deficit_fat | default('69g Fat') }}</div>
|
||||
<div>{{ deficit_fiber | default('25g Fibre') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tuesday -->
|
||||
<div class="bg-cyan-300 p-3 text-left rounded-lg">
|
||||
<div class="font-bold text-black mb-2">Tuesday</div>
|
||||
<div class="text-lg font-bold text-black mb-2">{{ deficit_calories | default('1725KCals') }}</div>
|
||||
<div class="text-xs text-black space-y-1">
|
||||
<div>{{ deficit_protein | default('120g Protein') }}</div>
|
||||
<div class="text-xs text-black">{{ deficit_carbs | default('155g Carbs') }}</div>
|
||||
<div>{{ deficit_fat | default('69g Fat') }}</div>
|
||||
<div>{{ deficit_fiber | default('25g Fibre') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wednesday -->
|
||||
<div class="bg-cyan-300 p-3 text-left rounded-lg">
|
||||
<div class="font-bold text-black mb-2">Wednesday</div>
|
||||
<div class="text-lg font-bold text-black mb-2">{{ deficit_calories | default('1725KCals') }}</div>
|
||||
<div class="text-xs text-black space-y-1">
|
||||
<div>{{ deficit_protein | default('120g Protein') }}</div>
|
||||
<div>{{ deficit_carbs | default('155g Carbs') }}</div>
|
||||
<div>{{ deficit_fat | default('69g Fat') }}</div>
|
||||
<div>{{ deficit_fiber | default('25g Fibre') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Thursday -->
|
||||
<div class="bg-cyan-300 p-3 text-left rounded-lg">
|
||||
<div class="font-bold text-black mb-2">Thursday</div>
|
||||
<div class="text-lg font-bold text-black mb-2">{{ deficit_calories | default('1725KCals') }}</div>
|
||||
<div class="text-xs text-black space-y-1">
|
||||
<div>{{ deficit_protein | default('120g Protein') }}</div>
|
||||
<div>{{ deficit_carbs | default('155g Carbs') }}</div>
|
||||
<div>{{ deficit_fat | default('69g Fat') }}</div>
|
||||
<div>{{ deficit_fiber | default('25g Fibre') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Friday -->
|
||||
<div class="bg-cyan-300 p-3 text-left rounded-lg">
|
||||
<div class="font-bold text-black mb-2">Friday</div>
|
||||
<div class="text-lg font-bold text-black mb-2">{{ deficit_calories | default('1725KCals') }}</div>
|
||||
<div class="text-xs text-black space-y-1">
|
||||
<div>{{ deficit_protein | default('120g Protein') }}</div>
|
||||
<div>{{ deficit_carbs | default('155g Carbs') }}</div>
|
||||
<div>{{ deficit_fat | default('69g Fat') }}</div>
|
||||
<div>{{ deficit_fiber | default('25g Fibre') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Saturday -->
|
||||
<div class="p-3 text-left rounded-lg">
|
||||
<div class="font-bold text-black mb-2">Saturday</div>
|
||||
<div class="text-lg font-bold text-black mb-2">{{ deficit_calories | default('1725KCals') }}</div>
|
||||
<div class="text-xs text-black space-y-1">
|
||||
<div>{{ deficit_protein | default('120g Protein') }}</div>
|
||||
<div>{{ deficit_carbs | default('155g Carbs') }}</div>
|
||||
<div>{{ deficit_fat | default('69g Fat') }}</div>
|
||||
<div>{{ deficit_fiber | default('25g Fibre') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sunday -->
|
||||
<div class="p-3 text-left rounded-lg">
|
||||
<div class="font-bold text-black mb-2">Sunday</div>
|
||||
<div class="text-lg font-bold text-black mb-2">{{ deficit_calories | default('1725KCals') }}</div>
|
||||
<div class="text-xs text-black space-y-1">
|
||||
<div>{{ deficit_protein | default('120g Protein') }}</div>
|
||||
<div>{{ deficit_carbs | default('155g Carbs') }}</div>
|
||||
<div>{{ deficit_fat | default('69g Fat') }}</div>
|
||||
<div>{{ deficit_fiber | default('25g Fibre') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Caloric Deficit with Maintenance/Refeed Example Section -->
|
||||
<div class="mb-12">
|
||||
<h2 class="text-2xl font-bold text-black mb-6 text-center">Caloric Deficit with Maintenance/Refeed Example</h2>
|
||||
|
||||
<!-- Weekly Grid -->
|
||||
<div class="grid grid-cols-7 gap-2 mb-8">
|
||||
<!-- Monday -->
|
||||
<div class="bg-cyan-300 p-3 text-left rounded-lg">
|
||||
<div class="font-bold text-black mb-2">Monday</div>
|
||||
<div class="text-lg font-bold text-black mb-2">{{ refeed_weekday_calories | default('1615KCals') }}</div>
|
||||
<div class="text-xs text-black space-y-1">
|
||||
<div>{{ refeed_weekday_protein | default('120g Protein') }}</div>
|
||||
<div>{{ refeed_weekday_carbs | default('142g Carbs') }}</div>
|
||||
<div>{{ refeed_weekday_fat | default('63g Fat') }}</div>
|
||||
<div>{{ refeed_weekday_fiber | default('24g Fibre') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tuesday -->
|
||||
<div class="bg-cyan-300 p-3 text-left rounded-lg">
|
||||
<div class="font-bold text-black mb-2">Tuesday</div>
|
||||
<div class="text-lg font-bold text-black mb-2">{{ refeed_weekday_calories | default('1615KCals') }}</div>
|
||||
<div class="text-xs text-black space-y-1">
|
||||
<div>{{ refeed_weekday_protein | default('120g Protein') }}</div>
|
||||
<div>{{ refeed_weekday_carbs | default('142g Carbs') }}</div>
|
||||
<div>{{ refeed_weekday_fat | default('63g Fat') }}</div>
|
||||
<div>{{ refeed_weekday_fiber | default('24g Fibre') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wednesday -->
|
||||
<div class="bg-cyan-300 p-3 text-left rounded-lg">
|
||||
<div class="font-bold text-black mb-2">Wednesday</div>
|
||||
<div class="text-lg font-bold text-black mb-2">{{ refeed_weekday_calories | default('1615KCals') }}</div>
|
||||
<div class="text-xs text-black space-y-1">
|
||||
<div>{{ refeed_weekday_protein | default('120g Protein') }}</div>
|
||||
<div>{{ refeed_weekday_carbs | default('142g Carbs') }}</div>
|
||||
<div>{{ refeed_weekday_fat | default('63g Fat') }}</div>
|
||||
<div>{{ refeed_weekday_fiber | default('24g Fibre') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Thursday -->
|
||||
<div class="p-3 text-left rounded-lg">
|
||||
<div class="font-bold text-black mb-2">Thursday</div>
|
||||
<div class="text-lg font-bold text-black mb-2">{{ refeed_weekday_calories | default('1615KCals') }}</div>
|
||||
<div class="text-xs text-black space-y-1">
|
||||
<div>{{ refeed_weekday_protein | default('120g Protein') }}</div>
|
||||
<div>{{ refeed_weekday_carbs | default('142g Carbs') }}</div>
|
||||
<div>{{ refeed_weekday_fat | default('63g Fat') }}</div>
|
||||
<div>{{ refeed_weekday_fiber | default('24g Fibre') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Friday -->
|
||||
<div class="p-3 text-left rounded-lg">
|
||||
<div class="font-bold text-black mb-2">Friday</div>
|
||||
<div class="text-lg font-bold text-black mb-2">{{ refeed_weekday_calories | default('1615KCals') }}</div>
|
||||
<div class="text-xs text-black space-y-1">
|
||||
<div>{{ refeed_weekday_protein | default('120g Protein') }}</div>
|
||||
<div>{{ refeed_weekday_carbs | default('142g Carbs') }}</div>
|
||||
<div>{{ refeed_weekday_fat | default('63g Fat') }}</div>
|
||||
<div>{{ refeed_weekday_fiber | default('24g Fibre') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Saturday -->
|
||||
<div class="p-3 text-left rounded-lg">
|
||||
<div class="font-bold text-black mb-2">Saturday</div>
|
||||
<div class="text-lg font-bold text-black mb-2">{{ refeed_weekend_calories | default('2000KCals') }}</div>
|
||||
<div class="text-xs text-black space-y-1">
|
||||
<div>{{ refeed_weekend_protein | default('120g Protein') }}</div>
|
||||
<div>{{ refeed_weekend_carbs | default('190g Carbs') }}</div>
|
||||
<div>{{ refeed_weekend_fat | default('84g Fat') }}</div>
|
||||
<div>{{ refeed_weekend_fiber | default('30g Fibre') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sunday -->
|
||||
<div class="p-3 text-left rounded-lg">
|
||||
<div class="font-bold text-black mb-2">Sunday</div>
|
||||
<div class="text-lg font-bold text-black mb-2">{{ refeed_weekend_calories | default('2000KCals') }}</div>
|
||||
<div class="text-xs text-black space-y-1">
|
||||
<div>{{ refeed_weekend_protein | default('120g Protein') }}</div>
|
||||
<div>{{ refeed_weekend_carbs | default('190g Carbs') }}</div>
|
||||
<div>{{ refeed_weekend_fat | default('84g Fat') }}</div>
|
||||
<div>{{ refeed_weekend_fiber | default('30g Fibre') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Macronutrients Recommendations Section -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-black mb-8 text-center">Macronutrients Recommendations</h2>
|
||||
|
||||
<!-- Macronutrient Boxes -->
|
||||
<div class="flex justify-center space-x-12">
|
||||
<!-- Protein -->
|
||||
<div class="bg-gray-200 p-6 rounded-lg text-center">
|
||||
<div class="text-3xl font-bold text-black mb-2">{{ protein_percentage | default('28%') }}</div>
|
||||
<div class="text-lg font-semibold text-black">Protein</div>
|
||||
</div>
|
||||
|
||||
<!-- Carbs -->
|
||||
<div class="bg-gray-200 p-6 rounded-lg text-center">
|
||||
<div class="text-3xl font-bold text-black mb-2">{{ carbs_percentage | default('36%') }}</div>
|
||||
<div class="text-lg font-semibold text-black">Carbs</div>
|
||||
</div>
|
||||
|
||||
<!-- Fats -->
|
||||
<div class="bg-gray-200 p-6 rounded-lg text-center">
|
||||
<div class="text-3xl font-bold text-black mb-2">{{ fats_percentage | default('36%') }}</div>
|
||||
<div class="text-lg font-semibold text-black">Fats</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -26,7 +26,7 @@
|
||||
<!-- Indications Box -->
|
||||
<div class="bg-gray-200 rounded-lg p-4 text-center mb-2">
|
||||
<h3 class="font-semibold text-lg mb-2">Indications</h3>
|
||||
<p >{{ indication | default('No Respiratory Capacity Limitation')}}</p>
|
||||
<p class="text-gray-700">{{ indication }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<div class="w-full page bg-white">
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="px-8 py-6">
|
||||
<!-- Page Title -->
|
||||
<h1 class="text-3xl font-bold text-black mb-6">Cardio Metrics</h1>
|
||||
|
||||
<!-- Active Metabolic Rate Assessment Section -->
|
||||
<h2 class="text-xl font-bold text-black mb-4">Active Metabolic Rate Assessment</h2>
|
||||
<p class="text-gray-700 text-sm mb-8">The active metabolic rate assessment is a key measure of aerobic fitness. It helps determine your specific heart rate zones and how well your body uses carbohydrates and fats as fuel while you exercise. It is also an indicator of overall health and wellbeing.</p>
|
||||
|
||||
<!-- VO2 Max Section -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-xl font-bold text-black mb-4 text-center">VO2 Max - {{ vo2_max_value | default('49.5') }} ({{ vo2_max_percentile | default('100th percentile') }})</h3>
|
||||
|
||||
<!-- VO2 Max Table -->
|
||||
<div class="mb-8">
|
||||
<table class="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="bg-cyan-300 border border-gray-400 p-3 text-black font-bold">Age (F)</th>
|
||||
<th class="bg-cyan-300 border border-gray-400 p-3 text-black font-bold">Very Poor</th>
|
||||
<th class="bg-cyan-300 border border-gray-400 p-3 text-black font-bold">Poor</th>
|
||||
<th class="bg-cyan-300 border border-gray-400 p-3 text-black font-bold">Fair</th>
|
||||
<th class="bg-cyan-300 border border-gray-400 p-3 text-black font-bold">Good</th>
|
||||
<th class="bg-cyan-300 border border-gray-400 p-3 text-black font-bold">Excellent</th>
|
||||
<th class="bg-cyan-300 border border-gray-400 p-3 text-black font-bold relative">
|
||||
Superior
|
||||
<!-- Arrow indicator -->
|
||||
<div class="absolute -bottom-4 left-1/2 transform -translate-x-1/2">
|
||||
<div class="w-0 h-0 border-l-4 border-r-4 border-t-8 border-transparent border-t-black"></div>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="bg-cyan-200 border border-gray-400 p-3 text-black font-semibold">{{ age_range | default('30-39') }}</td>
|
||||
<td class="bg-gray-100 border border-gray-400 p-3 text-black text-center">{{ very_poor_range | default('19.0-24.1') }}</td>
|
||||
<td class="bg-gray-100 border border-gray-400 p-3 text-black text-center">{{ poor_range | default('24.1-28.2') }}</td>
|
||||
<td class="bg-gray-100 border border-gray-400 p-3 text-black text-center">{{ fair_range | default('28.2-32.2') }}</td>
|
||||
<td class="bg-gray-100 border border-gray-400 p-3 text-black text-center">{{ good_range | default('32.2-35.7') }}</td>
|
||||
<td class="bg-gray-100 border border-gray-400 p-3 text-black text-center">{{ excellent_range | default('35.7-45.8') }}</td>
|
||||
<td class="bg-gray-100 border border-gray-400 p-3 text-black text-center font-bold">{{ superior_range | default('45.8+') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Personalized Heart Rate Zones Section -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-xl font-bold text-black mb-6 text-center">Personalized Heart Rate Zones</h3>
|
||||
|
||||
<!-- Heart Rate Zones Table -->
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="bg-cyan-300 border border-gray-400 p-2 text-black font-bold">Zone 1</th>
|
||||
<th class="bg-cyan-300 border border-gray-400 p-2 text-black font-bold">Zone 2</th>
|
||||
<th class="bg-cyan-300 border border-gray-400 p-2 text-black font-bold">Zone 3</th>
|
||||
<th class="bg-cyan-300 border border-gray-400 p-2 text-black font-bold">Zone 4</th>
|
||||
<th class="bg-cyan-300 border border-gray-400 p-2 text-black font-bold">Zone 5</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Zone Descriptions -->
|
||||
<tr>
|
||||
<td class="border border-gray-400 p-3 text-center">
|
||||
<div class="text-black font-semibold mb-1">Improves health and recovery capacity</div>
|
||||
</td>
|
||||
<td class="border border-gray-400 p-3 text-center">
|
||||
<div class="text-black font-semibold mb-1">Improves endurance and fat burning</div>
|
||||
</td>
|
||||
<td class="border border-gray-400 p-3 text-center">
|
||||
<div class="text-black font-semibold mb-1">Improves Aerobic fitness</div>
|
||||
</td>
|
||||
<td class="border border-gray-400 p-3 text-center">
|
||||
<div class="text-black font-semibold mb-1">Improves maximum performance capacity</div>
|
||||
</td>
|
||||
<td class="border border-gray-400 p-3 text-center">
|
||||
<div class="text-black font-semibold mb-1">Develops maximum performance and speed</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Heart Rate Percentages -->
|
||||
<tr>
|
||||
<td class="border border-gray-400 p-2 text-center text-black font-semibold">{{ zone1_percentage | default('55-65% of Max Heart Rate') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black font-semibold">{{ zone2_percentage | default('65-75% of Max Heart Rate') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black font-semibold">{{ zone3_percentage | default('80-85% of Max Heart Rate') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black font-semibold">{{ zone4_percentage | default('85-88% of Max Heart Rate') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black font-semibold">{{ zone5_percentage | default('90% of Max Heart Rate') }}</td>
|
||||
</tr>
|
||||
|
||||
<!-- Heart Rate BPM -->
|
||||
<tr>
|
||||
<td class="bg-red-200 border border-gray-400 p-2 text-center text-black font-bold">{{ zone1_bpm | default('81-96bpm') }}</td>
|
||||
<td class="bg-red-200 border border-gray-400 p-2 text-center text-black font-bold">{{ zone2_bpm | default('96-100bpm') }}</td>
|
||||
<td class="bg-yellow-200 border border-gray-400 p-2 text-center text-black font-bold">{{ zone3_bpm | default('100-178bpm') }}</td>
|
||||
<td class="bg-green-200 border border-gray-400 p-2 text-center text-black font-bold">{{ zone4_bpm | default('178-188bpm') }}</td>
|
||||
<td class="bg-green-200 border border-gray-400 p-2 text-center text-black font-bold">{{ zone5_bpm | default('188-198bpm') }}</td>
|
||||
</tr>
|
||||
|
||||
<!-- Speed -->
|
||||
<tr>
|
||||
<td class="border border-gray-400 p-2 text-center">
|
||||
<div class="text-black font-semibold">{{ zone1_speed | default('3.5mph') }}</div>
|
||||
<div class="text-black text-xs">{{ zone1_incline | default('2% Incline') }}</div>
|
||||
</td>
|
||||
<td class="border border-gray-400 p-2 text-center">
|
||||
<div class="text-black font-semibold">{{ zone2_speed | default('3.5-4.0mph') }}</div>
|
||||
<div class="text-black text-xs">{{ zone2_incline | default('2% Incline') }}</div>
|
||||
</td>
|
||||
<td class="border border-gray-400 p-2 text-center">
|
||||
<div class="text-black font-semibold">{{ zone3_speed | default('4.0-6.5mph') }}</div>
|
||||
<div class="text-black text-xs">{{ zone3_incline | default('2% Incline') }}</div>
|
||||
</td>
|
||||
<td class="border border-gray-400 p-2 text-center">
|
||||
<div class="text-black font-semibold">{{ zone4_speed | default('6.5-7.0mph') }}</div>
|
||||
<div class="text-black text-xs">{{ zone4_incline | default('2% Incline') }}</div>
|
||||
</td>
|
||||
<td class="border border-gray-400 p-2 text-center">
|
||||
<div class="text-black font-semibold">{{ zone5_speed | default('7.0-8.0mph') }}</div>
|
||||
<div class="text-black text-xs">{{ zone5_incline | default('2% Incline') }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Pace -->
|
||||
<tr>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ zone1_pace | default('10:39min/km Pace') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ zone2_pace | default('10:39-9:19min/km Pace') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ zone3_pace | default('9:19-5:44min/km Pace') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ zone4_pace | default('5:44-5:20min/km Pace') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ zone5_pace | default('5:20-4:40min/km Pace') }}</td>
|
||||
</tr>
|
||||
|
||||
<!-- Average Calories -->
|
||||
<tr>
|
||||
<td class="border border-gray-400 p-2 text-center">
|
||||
<div class="text-black text-xs">Avg:</div>
|
||||
<div class="text-black font-semibold">{{ zone1_calories | default('4.4kcals/minute') }}</div>
|
||||
</td>
|
||||
<td class="border border-gray-400 p-2 text-center">
|
||||
<div class="text-black text-xs">Avg:</div>
|
||||
<div class="text-black font-semibold">{{ zone2_calories | default('5.9kcals/minute') }}</div>
|
||||
</td>
|
||||
<td class="border border-gray-400 p-2 text-center">
|
||||
<div class="text-black text-xs">Avg:</div>
|
||||
<div class="text-black font-semibold">{{ zone3_calories | default('9.4kcals/minute') }}</div>
|
||||
</td>
|
||||
<td class="border border-gray-400 p-2 text-center">
|
||||
<div class="text-black text-xs">Avg:</div>
|
||||
<div class="text-black font-semibold">{{ zone4_calories | default('12.5kcals/minute') }}</div>
|
||||
</td>
|
||||
<td class="border border-gray-400 p-2 text-center">
|
||||
<div class="text-black text-xs">Avg:</div>
|
||||
<div class="text-black font-semibold">{{ zone5_calories | default('12.8kcals/minute') }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Carb Utilization -->
|
||||
<tr>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ zone1_carb | default('Avg: 0.4g/min Carb Utilization') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ zone2_carb | default('Avg: 0.6g/min Carb Utilization') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ zone3_carb | default('Avg: 1.9g/min Carb Utilization') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ zone4_carb | default('Avg: 2.9g/min Carb Utilization') }}</td>
|
||||
<td class="border border-gray-400 p-2 text-center text-black">{{ zone5_carb | default('Avg: 3.1g/min Carb Utilization') }}</td>
|
||||
</tr>
|
||||
|
||||
<!-- Breathing -->
|
||||
<tr>
|
||||
<td class="bg-red-200 border border-gray-400 p-2 text-center">
|
||||
<div class="text-black font-bold">{{ zone1_breaths | default('Avg: 27 breaths') }}</div>
|
||||
<div class="text-black text-xs italic">{{ zone1_breath_range | default('Ideal Range: 15-20 breaths') }}</div>
|
||||
</td>
|
||||
<td class="bg-red-200 border border-gray-400 p-2 text-center">
|
||||
<div class="text-black font-bold">{{ zone2_breaths | default('Avg: 28 breaths') }}</div>
|
||||
<div class="text-black text-xs italic">{{ zone2_breath_range | default('Ideal Range: 20-25 breaths') }}</div>
|
||||
</td>
|
||||
<td class="bg-yellow-200 border border-gray-400 p-2 text-center">
|
||||
<div class="text-black font-bold">{{ zone3_breaths | default('Avg: 31 breaths') }}</div>
|
||||
<div class="text-black text-xs italic">{{ zone3_breath_range | default('Ideal Range: 25-30 breaths') }}</div>
|
||||
</td>
|
||||
<td class="bg-green-200 border border-gray-400 p-2 text-center">
|
||||
<div class="text-black font-bold">{{ zone4_breaths | default('Avg: 42 breaths') }}</div>
|
||||
<div class="text-black text-xs italic">{{ zone4_breath_range | default('Ideal Range: 30-35 breaths') }}</div>
|
||||
</td>
|
||||
<td class="bg-green-200 border border-gray-400 p-2 text-center">
|
||||
<div class="text-black font-bold">{{ zone5_breaths | default('Avg: 51 breaths') }}</div>
|
||||
<div class="text-black text-xs italic">{{ zone5_breath_range | default('Ideal Range: 40+ breaths') }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1,12 +1,6 @@
|
||||
<div class="w-full page bg-white">
|
||||
<!-- Main Content -->
|
||||
<div class="flex flex-col items-center justify-center h-full">
|
||||
<!-- Title -->
|
||||
<h1 class="text-2xl font-bold text-center text-gray-800 mb-6">
|
||||
Fuel Utilization Report - Institute of Science, Health and
|
||||
Performance
|
||||
</h1>
|
||||
|
||||
<!-- Fuel Utilization Chart -->
|
||||
<div class="w-full max-w-5xl">
|
||||
<img
|
||||
@@ -19,8 +13,8 @@
|
||||
<!-- Chart Information -->
|
||||
<div class="mt-8 text-center">
|
||||
<p class="text-gray-700 text-sm">
|
||||
Client: {{ client_name }} | Assessment Date: {{ assessment_date
|
||||
}}
|
||||
Client: {{ client_name | default('Keirstyn Moran') }} |
|
||||
Assessment Date: {{ assessment_date | default('July 29 2025') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,8 +1,5 @@
|
||||
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
|
||||
@@ -14,39 +11,24 @@ 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
|
||||
@@ -56,18 +38,12 @@ 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
|
||||
@@ -75,36 +51,17 @@ 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
|
||||
|
||||