Compare commits

..

18 Commits

Author SHA1 Message Date
bolade 5844cb6cff Update context for report: change patient name to Keirstyn; adjust execution count in analysis notebook 2025-10-22 16:25:33 +01:00
bolade e58d9b0158 Refactor code structure for improved readability and maintainability 2025-10-22 16:01:17 +01:00
bolade 85ea73ade8 Refactor analysis notebook: comment out API calls and update CSV file handling; modify page 2 of report for content and structure adjustments 2025-10-22 15:45:57 +01:00
bolade 1d5625b61a Refactor code structure for improved readability and maintainability 2025-10-22 15:28:14 +01:00
bolade f5d304aec5 Refactor code structure for improved readability and maintainability 2025-10-22 01:11:32 +01:00
bolade d862577ecf Refactor code structure for improved readability and maintainability 2025-10-21 12:50:48 +01:00
bolade 2568e991e2 Implement code changes to enhance functionality and improve performance 2025-10-21 12:42:16 +01:00
bolade bad8f18f19 Refactor fuel mix calculations based on RER; update resting phase filters and add detailed markdown explanations. Adjust execution counts and outputs for clarity. 2025-10-21 12:35:16 +01:00
bolade e2f6eaab66 Refactor code structure for improved readability and maintainability 2025-10-21 12:22:40 +01:00
bolade 192c598e18 Refactor code structure for improved readability and maintainability 2025-10-15 14:57:50 +01:00
bolade 7e55ee6954 Refactor code structure for improved readability and maintainability 2025-10-04 00:06:45 +01:00
bolade 6b2c61a48e Implement code changes to enhance functionality and improve performance 2025-09-29 17:55:04 +01:00
bolade f52729d703 Add graph generation functionality and update charts
- Implemented GraphGenerator class for generating various physiological charts.
- Added methods for generating respiratory, fuel utilization, VO2 pulse, VO2 breath, fat metabolism, recovery, body fat percentage, body composition, and spirometry charts.
- Included functionality to save charts as PNG files or return them as base64 strings.
- Updated existing chart images in the graphs directory.
2025-09-29 11:45:09 +01:00
bolade 54e0189301 Enhance table styling and layout in report pages
- Updated table header and cell classes to center-align text for better readability in page_19.html.
- Adjusted padding and margins in page_7.html for improved layout and visual consistency.
- Reduced spacing in various sections to create a more compact and organized appearance.
2025-09-29 11:17:32 +01:00
bolade a20f21d288 Refactor page_7.html for improved layout and responsiveness
- Enhanced the structure of the Spirometry Assessment and Respiratory sections for better readability.
- Centered images and added max-width constraints to ensure proper scaling on different devices.
- Improved text formatting for clarity and consistency.
2025-09-29 10:42:23 +01:00
bolade d12add210b Refactor code structure for improved readability and maintainability 2025-09-29 09:54:05 +01:00
bolade a44a763640 Refactor code structure for improved readability and maintainability 2025-09-29 09:17:11 +01:00
bolade 604ef375aa Refactor footer context generation and enhance chart image styling for improved layout 2025-09-26 22:32:39 +01:00
38 changed files with 3621 additions and 502 deletions
+3 -1
View File
@@ -1,3 +1,5 @@
.venv
data/
data/
.env
Binary file not shown.
+89 -97
View File
@@ -5,99 +5,90 @@
"execution_count": 6,
"id": "b18c1027",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{'id': 'gen-1758708788-9UUhU8KfktBmyteT4BUC', 'provider': 'Google', 'model': 'google/gemini-2.5-flash-lite', 'object': 'chat.completion', 'created': 1758708788, 'choices': [{'logprobs': None, 'finish_reason': 'stop', 'native_finish_reason': 'STOP', 'index': 0, 'message': {'role': 'assistant', 'content': 'Parameters,Best,LLN,Pred.,%Pred.,ZScore,PRE#1,PRE#2,PRE#3\\nFVC,4.24,3.03,3.79,112.0,0.95,4.24,4.17,4.15\\nFEV1,3.26,2.53,3.16,103.3,0.28,3.26,3.21,3.14\\nFEV1/FVC%,76.89,72.47,83.78,91.8,-1.05,76.9,77.0,75.7\\nPEF,684,222,384,178.7,-,444,438,684\\nFEF2575,2.74,2.15,3.42,80.2,-0.84,2.74,2.68,2.48\\nFEF25,6.08,,,0.0,-,6.08,6.0,5.53\\nFEF50,3.06,,,0.0,-,3.06,3.1,2.77\\nFEF75,1.06,0.71,1.41,75.1,-0.72,1.06,1.12,0.94\\nPEFTime,79,,,49,-,79,40,39\\nEVol,78.0,,,77.0,-,78.0,77.0,197.0\\nFEV6,4.22,3.03,3.79,111.4,-,4.22,4.17,4.13', 'refusal': None, 'reasoning': None}}], 'usage': {'prompt_tokens': 1348, 'completion_tokens': 434, 'total_tokens': 1782, 'prompt_tokens_details': {'cached_tokens': 0}, 'completion_tokens_details': {'reasoning_tokens': 0, 'image_tokens': 0}}}\n",
"Content saved to extracted_table.csv\n"
]
}
],
"outputs": [],
"source": [
"\n",
"import requests\n",
"import json\n",
"import base64\n",
"from pathlib import Path\n",
"# import requests\n",
"# import json\n",
"# import base64\n",
"# from pathlib import Path\n",
"\n",
"API_KEY_REF = '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'\n",
"def encode_pdf_to_base64(pdf_path):\n",
" with open(pdf_path, \"rb\") as pdf_file:\n",
" return base64.b64encode(pdf_file.read()).decode('utf-8')\n",
"# API_KEY_REF = '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'\n",
"# def encode_pdf_to_base64(pdf_path):\n",
"# with open(pdf_path, \"rb\") as pdf_file:\n",
"# return base64.b64encode(pdf_file.read()).decode('utf-8')\n",
"\n",
"url = \"https://openrouter.ai/api/v1/chat/completions\"\n",
"headers = {\n",
" \"Authorization\": f\"Bearer {API_KEY_REF}\",\n",
" \"Content-Type\": \"application/json\"\n",
"}\n",
"# url = \"https://openrouter.ai/api/v1/chat/completions\"\n",
"# headers = {\n",
"# \"Authorization\": f\"Bearer {API_KEY_REF}\",\n",
"# \"Content-Type\": \"application/json\"\n",
"# }\n",
"\n",
"# Read and encode the PDF\n",
"pdf_path = \"data/~Moran~K~19910201~Spirometry Exam~20250729~20250729032843.pdf\"\n",
"base64_pdf = encode_pdf_to_base64(pdf_path)\n",
"data_url = f\"data:application/pdf;base64,{base64_pdf}\"\n",
"# # Read and encode the PDF\n",
"# pdf_path = \"data/~Moran~K~19910201~Spirometry Exam~20250729~20250729032843.pdf\"\n",
"# base64_pdf = encode_pdf_to_base64(pdf_path)\n",
"# data_url = f\"data:application/pdf;base64,{base64_pdf}\"\n",
"\n",
"messages = [\n",
" {\n",
" \"role\": \"user\",\n",
" \"content\": [\n",
" {\n",
" \"type\": \"text\",\n",
" \"text\": \"Please extract the table from the pdf and return the values in csv format, \"\n",
" \"note that it is the unit of parameter that is beside it and it should not be a column. \"\n",
" \"The '-' Should be treated as empty values.\"\n",
" \"do not add 'csv' at the start or end of the response\"\n",
" },\n",
" {\n",
" \"type\": \"file\",\n",
" \"file\": {\n",
" \"filename\": \"document.pdf\",\n",
" \"file_data\": data_url\n",
" }\n",
" },\n",
" ]\n",
" }\n",
"]\n",
"# messages = [\n",
"# {\n",
"# \"role\": \"user\",\n",
"# \"content\": [\n",
"# {\n",
"# \"type\": \"text\",\n",
"# \"text\": \"Please extract the Spirometry table from the pdf and return the values in csv format, \"\n",
"# \"note that it is the unit of parameter that is beside it and it should not be a column. \"\n",
"# \"The '-' Should be treated as empty values.\"\n",
"# \"do not add 'csv' at the start or end of the response\"\n",
"# },\n",
"# {\n",
"# \"type\": \"file\",\n",
"# \"file\": {\n",
"# \"filename\": \"document.pdf\",\n",
"# \"file_data\": data_url\n",
"# }\n",
"# },\n",
"# ]\n",
"# }\n",
"# ]\n",
"\n",
"# Optional: Configure PDF processing engine\n",
"# PDF parsing will still work even if the plugin is not explicitly set\n",
"plugins = [\n",
" {\n",
" \"id\": \"file-parser\",\n",
" \"pdf\": {\n",
" \"engine\": \"pdf-text\" # defaults to \"mistral-ocr\". See Pricing above\n",
" }\n",
" }\n",
"]\n",
"# # Optional: Configure PDF processing engine\n",
"# # PDF parsing will still work even if the plugin is not explicitly set\n",
"# plugins = [\n",
"# {\n",
"# \"id\": \"file-parser\",\n",
"# \"pdf\": {\n",
"# \"engine\": \"pdf-text\" # defaults to \"mistral-ocr\". See Pricing above\n",
"# }\n",
"# }\n",
"# ]\n",
"\n",
"payload = {\n",
" \"model\": \"google/gemini-2.5-flash-lite\",\n",
" \"messages\": messages,\n",
"}\n",
"# payload = {\n",
"# \"model\": \"google/gemini-2.5-flash-lite\",\n",
"# \"messages\": messages,\n",
"# }\n",
"\n",
"response = requests.post(url, headers=headers, json=payload)\n",
"# Get the response content\n",
"response_data = response.json()\n",
"print(response_data)\n",
"# response = requests.post(url, headers=headers, json=payload)\n",
"# # Get the response content\n",
"# response_data = response.json()\n",
"# print(response_data)\n",
"\n",
"# Extract the content from the response\n",
"if 'choices' in response_data and len(response_data['choices']) > 0:\n",
" content = response_data['choices'][0]['message']['content']\n",
"# # Extract the content from the response\n",
"# if 'choices' in response_data and len(response_data['choices']) > 0:\n",
"# content = response_data['choices'][0]['message']['content']\n",
" \n",
" # Save to a CSV file\n",
" output_file = \"extracted_table.csv\"\n",
" with open(output_file, 'w', encoding='utf-8') as f:\n",
" f.write(content)\n",
"# # Save to a CSV file\n",
"# output_file = \"extracted_table.csv\"\n",
"# with open(output_file, 'w', encoding='utf-8') as f:\n",
"# f.write(content)\n",
" \n",
" print(f\"Content saved to {output_file}\")\n",
"else:\n",
" print(\"No content found in response\")"
"# print(f\"Content saved to {output_file}\")\n",
"# else:\n",
"# print(\"No content found in response\")"
]
},
{
"cell_type": "code",
"execution_count": 12,
"execution_count": 7,
"id": "56a9d655",
"metadata": {},
"outputs": [
@@ -107,13 +98,13 @@
"text": [
"FVC Best: 4.24, FVC Pred: 112.0\n",
"FEV1 Best: 3.26, FEV1 Pred: 103.3\n",
"FEV1/FVC% Best: 76.89, FEV1/FVC% Pred: 91.8\n"
"FEV1/FVC% Best: 76.9, FEV1/FVC% Pred: 91.8\n"
]
}
],
"source": [
"import pandas as pd\n",
"spirometry_df = pd.read_csv(\"extracted_table.csv\")\n",
"spirometry_df = pd.read_csv(\"data/spirometry_data.csv\")\n",
"\n",
"fvc_best = spirometry_df.loc[spirometry_df['Parameters'] == 'FVC', 'Best'].values[0]\n",
"fvc_pred = spirometry_df.loc[spirometry_df['Parameters'] == 'FVC', '%Pred.'].values[0]\n",
@@ -131,7 +122,7 @@
},
{
"cell_type": "code",
"execution_count": 16,
"execution_count": 8,
"id": "990f4b4f",
"metadata": {},
"outputs": [
@@ -155,7 +146,7 @@
},
{
"cell_type": "code",
"execution_count": 18,
"execution_count": 9,
"id": "041cbc3d",
"metadata": {},
"outputs": [
@@ -171,7 +162,7 @@
"name": "stderr",
"output_type": "stream",
"text": [
"/tmp/ipykernel_301535/4157056299.py:3: FutureWarning: errors='ignore' is deprecated and will raise in a future version. Use to_numeric without passing `errors` and catch exceptions explicitly instead\n",
"/tmp/ipykernel_69398/4157056299.py:3: FutureWarning: errors='ignore' is deprecated and will raise in a future version. Use to_numeric without passing `errors` and catch exceptions explicitly instead\n",
" df = df.apply(pd.to_numeric, errors='ignore')\n"
]
}
@@ -204,7 +195,7 @@
},
{
"cell_type": "code",
"execution_count": 20,
"execution_count": 10,
"id": "de7cadd1",
"metadata": {},
"outputs": [
@@ -223,7 +214,7 @@
},
{
"cell_type": "code",
"execution_count": 24,
"execution_count": 11,
"id": "cb972ed3",
"metadata": {},
"outputs": [
@@ -320,7 +311,7 @@
"[1 rows x 147 columns]"
]
},
"execution_count": 24,
"execution_count": 11,
"metadata": {},
"output_type": "execute_result"
}
@@ -334,7 +325,7 @@
},
{
"cell_type": "code",
"execution_count": 26,
"execution_count": 12,
"id": "98d9295a",
"metadata": {},
"outputs": [
@@ -354,7 +345,7 @@
},
{
"cell_type": "code",
"execution_count": 32,
"execution_count": 13,
"id": "cdfeb309",
"metadata": {},
"outputs": [
@@ -418,7 +409,7 @@
},
{
"cell_type": "code",
"execution_count": 33,
"execution_count": 14,
"id": "4420cfea",
"metadata": {},
"outputs": [
@@ -476,7 +467,7 @@
},
{
"cell_type": "code",
"execution_count": 37,
"execution_count": 21,
"id": "62803668",
"metadata": {},
"outputs": [
@@ -561,7 +552,7 @@
},
{
"cell_type": "code",
"execution_count": 40,
"execution_count": 16,
"id": "07593b56",
"metadata": {},
"outputs": [
@@ -572,8 +563,8 @@
"Zone 1 (Active Recovery): 81.7 - 96.7 bpm\n",
"Zone 2 (Aerobic Base): 96.7 - 100.5 bpm\n",
"Zone 3 (Aerobic): 100.5 - 179.7 bpm\n",
"Zone 4 (Lactate Threshold): 179.7 - 199.7 bpm\n",
"Zone 5 (VO2 Max): 199.7+ bpm\n"
"Zone 4 (Lactate Threshold): 179.7 - 189.7 bpm\n",
"Zone 5 (VO2 Max): 189.7 - 199.7 bpm\n"
]
}
],
@@ -582,7 +573,8 @@
"zone_2_start = optimal_row['HR(bpm)_smoothed']\n",
"zone_3_start = vt1\n",
"zone_4_start = vt2['HeartRate'] - 10\n",
"zone_5_start = vt2['HeartRate'] + 10\n",
"zone_5_start = vt2['HeartRate']\n",
"zone_5_end = vt2['HeartRate'] + 10\n",
"\n",
"zone_1_end = zone_2_start\n",
"zone_2_end = vt1['HeartRate']\n",
@@ -593,12 +585,12 @@
"print(f\"Zone 2 (Aerobic Base): {zone_2_start:.1f} - {zone_2_end:.1f} bpm\")\n",
"print(f\"Zone 3 (Aerobic): {zone_3_start['HeartRate']:.1f} - {zone_3_end:.1f} bpm\")\n",
"print(f\"Zone 4 (Lactate Threshold): {zone_4_start:.1f} - {zone_4_end:.1f} bpm\")\n",
"print(f\"Zone 5 (VO2 Max): {zone_5_start:.1f}+ bpm\")"
"print(f\"Zone 5 (VO2 Max): {zone_5_start:.1f} - {zone_5_end:.1f} bpm\")"
]
},
{
"cell_type": "code",
"execution_count": 60,
"execution_count": 17,
"id": "c90415b2",
"metadata": {},
"outputs": [
@@ -661,7 +653,7 @@
},
{
"cell_type": "code",
"execution_count": 66,
"execution_count": 18,
"id": "c3b2cc59",
"metadata": {},
"outputs": [
@@ -750,7 +742,7 @@
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 19,
"id": "672d68f3",
"metadata": {},
"outputs": [
+25 -14
View File
@@ -12,7 +12,7 @@ def image_to_base64(image_path):
### Defining Page Contexts ###
page_1_context = {
"name": "John Doe",
"name": "Keirstyn",
"surname": "Moran",
"date": "July 29, 2025",
}
@@ -27,22 +27,28 @@ page_3_context = {
page_4_context = {
"body_composition_chart": image_to_base64(
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/page_1_body_composition.png"
"/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/page_1_body_fat.png"
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/body_fat_percent_chart.png"
),
"fat_percentage": "22.4",
}
page_5_context = {
"metabolism_chart": "",
"fuel_source_chart": "",
"resting_calories": 1540,
"neat_calories": 310,
"weight_loss_calories": 1725,
"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": 3575,
"total_calories": "null",
}
page_6_context = {
@@ -78,7 +84,7 @@ page_7_context = {
"peak_vt_bpm": 198,
"peak_vt_zone": 3,
"fev1_percentage": 85,
"lung_analysis_chart": "",
"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"
),
@@ -102,8 +108,8 @@ page_8_context = {
"zone1_bpm": "81-96bpm",
"zone2_bpm": "96-100bpm",
"zone3_bpm": "100-178bpm",
"zone4_bpm": "178-188bpm",
"zone5_bpm": "188-198bpm",
"zone4_bpm": "178-189bpm",
"zone5_bpm": "189-199bpm",
"zone1_speed": "3.5mph",
"zone2_speed": "3.5-4.0mph",
"zone3_speed": "4.0-6.5mph",
@@ -192,7 +198,12 @@ page_11_context = {
}
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 = {
@@ -308,7 +319,7 @@ page_17_context = {
page_18_context = {
"body_fat_percentage_chart": image_to_base64(
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/body_fat_percentage_chart.png"
"/home/oluwasanmi/Documents/Work/MKD/report_generation/fat_percentage_master_chart.png"
),
}
+319
View File
@@ -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")
+12
View File
@@ -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
1 Parameters,Best,LLN,Pred.,%Pred.,ZScore,PRE#1,PRE#2,PRE#3
2 FVC,L,4.24,3.03,3.79,112.0,0.95,4.24,4.17,4.15
3 FEV1,L,3.26,2.53,3.16,103.3,0.28,3.26,3.21,3.14
4 FEV1/FVC%,76.89,72.47,83.78,91.8,-1.05,76.9,77.0,75.7
5 PEF,L/m,684,222,384,178.7,-,444,438,684
6 FEF2575,L/s,2.74,2.15,3.42,80.2,-0.84,2.74,2.68,2.48
7 FEF25,L/s,6.08,-,-,-,6.08,6.0,5.53
8 FEF50,L/s,3.06,-,-,-,3.06,3.1,2.77
9 FEF75,L/s,1.06,0.71,1.41,75.1,-0.72,1.06,1.12,0.94
10 PEFTime,ms,-,-,79,-,79,49,39
11 Evol,mL,-,-,78.0,-,78.0,77.0,197.0
12 FEV6,L,4.22,3.03,3.79,111.4,-,4.22,4.17,4.13

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

+942
View File
@@ -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}")
Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 322 KiB

+24 -10
View File
@@ -6,7 +6,12 @@ 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,
@@ -15,16 +20,21 @@ header_context = {
"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))]
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]
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)
@@ -81,8 +91,11 @@ html_doc = f"""
}}
/* Prevent images from being too large */
img {{
max-height: 200px;
object-fit: contain;
max-height: 300px;
}}
/* Larger images for specific charts */
.chart-large {{
max-height: 500px !important;
}}
</style>
</head>
@@ -114,3 +127,4 @@ html_string_to_pdf(html_doc, "multi_page_report.pdf")
# pdfkit.from_string(html_doc, "truth_report.pdf", options=options)
print("✅ PDF generated: multi_page_report.pdf")
Binary file not shown.
+1094 -90
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -15,7 +15,7 @@
<div class="flex gap-8">
<!-- Chart Image -->
<div class="flex-1">
<img src="right-leg-chart.png" alt="Right Leg SMO2 Chart" class="w-full h-auto">
<img src= "data:image/png;base64,{{ right_leg }}" alt="Right Leg SMO2 Chart" class="w-full h-auto">
</div>
<!-- Right Side Info -->
@@ -48,7 +48,7 @@
<div class="flex gap-8">
<!-- Chart Image -->
<div class="flex-1">
<img src="left-leg-chart.png" alt="Left Leg SMO2 Chart" class="w-full h-auto">
<img src= "data:image/png;base64,{{ left_leg }}" alt="Left Leg SMO2 Chart" class="w-full h-auto">
</div>
<!-- Right Side Info -->
+4 -2
View File
@@ -25,11 +25,13 @@
</div>
<div class="w-full max-w-5xl">
<h1 class="text-2xl font-bold mb-4 text-center">Body Fat Percent Master Chart</h1>
<h1 class="text-2xl font-bold mb-4 text-center">
Body Fat Percent Master Chart
</h1>
<img
src="data:image/png;base64,{{ body_fat_percentage_chart }}"
alt="Body Fat Percentage"
class="w-full h-auto object-contain"
class="w-full h-auto object-contain chart-large"
/>
</div>
</div>
+196 -196
View File
@@ -18,42 +18,42 @@
<thead>
<tr class="bg-cyan-200">
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Age (M)
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Poor
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Below Average
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Average
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Above Average
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Good
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Excellent
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Athlete
</th>
@@ -62,169 +62,169 @@
<tbody>
<tr>
<td
class="border border-gray-300 p-1 font-medium"
class="border border-gray-300 p-1 font-medium text-center"
>
18-25
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
85bpm +
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
76-84bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
74-78bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
70-73bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
66-69bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
61-65bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
60-60bpm
</td>
</tr>
<tr>
<td
class="border border-gray-300 p-1 font-medium"
class="border border-gray-300 p-1 font-medium text-center"
>
26-35
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
83bpm +
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
77-82bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
73-76bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
69-72bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
65-68bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
60-64bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
55-59bpm
</td>
</tr>
<tr>
<td
class="border border-gray-300 p-1 font-medium"
class="border border-gray-300 p-1 font-medium text-center"
>
36-45
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
85bpm +
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
79-84bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
74-78bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
70-73bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
65-69bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
60-64bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
55-59bpm
</td>
</tr>
<tr>
<td
class="border border-gray-300 p-1 font-medium"
class="border border-gray-300 p-1 font-medium text-center"
>
46-55
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
84bpm +
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
76-83bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
73-77bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
70-72bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
66-69bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
61-65bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
56-60bpm
</td>
</tr>
<tr>
<td
class="border border-gray-300 p-1 font-medium"
class="border border-gray-300 p-1 font-medium text-center"
>
56-65
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
85bpm +
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
78-84bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
74-77bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
70-73bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
65-69bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
60-64bpm
</td>
<td class="border border-gray-300 p-1">
<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"
class="border border-gray-300 p-1 font-medium text-center"
>
65+
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
84bpm +
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
77-83bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
73-76bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
70-73bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
65-69bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
60-64bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
55-59bpm
</td>
</tr>
@@ -240,42 +240,42 @@
<thead>
<tr class="bg-cyan-200">
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Age (F)
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Poor
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Below Average
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Average
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Above Average
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Good
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Excellent
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Athlete
</th>
@@ -284,169 +284,169 @@
<tbody>
<tr>
<td
class="border border-gray-300 p-1 font-medium"
class="border border-gray-300 p-1 font-medium text-center"
>
18-25
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
81bpm +
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
74-81bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
73-78bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
66-69bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
62-65bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
56-61bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
50-55bpm
</td>
</tr>
<tr>
<td
class="border border-gray-300 p-1 font-medium"
class="border border-gray-300 p-1 font-medium text-center"
>
26-35
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
82bpm +
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
75-81bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
71-74bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
66-70bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
62-65bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
55-61bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
54-54bpm
</td>
</tr>
<tr>
<td
class="border border-gray-300 p-1 font-medium"
class="border border-gray-300 p-1 font-medium text-center"
>
36-45
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
83bpm +
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
76-82bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
71-75bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
67-70bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
63-66bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
57-62bpm
</td>
<td class="border border-gray-300 p-1">
<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"
class="border border-gray-300 p-1 font-medium text-center"
>
46-55
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
84bpm +
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
77-83bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
72-76bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
68-71bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
64-67bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
58-63bpm
</td>
<td class="border border-gray-300 p-1">
<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"
class="border border-gray-300 p-1 font-medium text-center"
>
56-65
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
82bpm +
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
76-81bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
72-75bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
68-71bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
62-67bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
57-61bpm
</td>
<td class="border border-gray-300 p-1">
<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"
class="border border-gray-300 p-1 font-medium text-center"
>
65+
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
80bpm +
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
74-79bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
70-73bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
66-69bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
62-65bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
56-61bpm
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
52-55bpm
</td>
</tr>
@@ -469,37 +469,37 @@
<thead>
<tr class="bg-cyan-200">
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Age (M)
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Very Poor
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Poor
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Fair
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Good
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Excellent
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Superior
</th>
@@ -508,126 +508,126 @@
<tbody>
<tr>
<td
class="border border-gray-300 p-1 font-medium"
class="border border-gray-300 p-1 font-medium text-center"
>
20-29
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
29.0-38.1
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
38.1-44.9
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
44.9-50.2
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
50.2-61.8
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
57.1-66.3
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
66.3+
</td>
</tr>
<tr>
<td
class="border border-gray-300 p-1 font-medium"
class="border border-gray-300 p-1 font-medium text-center"
>
30-39
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
27.2-34.1
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
34.1-39.6
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
39.6-45.2
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
45.2-51.6
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
51.6-59.8
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
59.8+
</td>
</tr>
<tr>
<td
class="border border-gray-300 p-1 font-medium"
class="border border-gray-300 p-1 font-medium text-center"
>
40-49
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
24.2-30.5
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
30.5-35.7
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
35.7-40.3
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
40.3-46.7
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
46.7-55.6
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
55.6+
</td>
</tr>
<tr>
<td
class="border border-gray-300 p-1 font-medium"
class="border border-gray-300 p-1 font-medium text-center"
>
50-59
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
20.9-26.1
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
26.1-30.7
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
30.7-35.1
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
35.1-41.2
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
41.2-50.7
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
50.7+
</td>
</tr>
<tr>
<td
class="border border-gray-300 p-1 font-medium"
class="border border-gray-300 p-1 font-medium text-center"
>
60-69
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
17.4-22.4
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
22.4-26.6
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
26.6-30.5
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
30.5-36.1
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
36.1-43.0
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
43.0+
</td>
</tr>
@@ -643,37 +643,37 @@
<thead>
<tr class="bg-cyan-200">
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Age (F)
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Very Poor
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Poor
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Fair
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Good
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Excellent
</th>
<th
class="border border-gray-300 p-1 font-bold"
class="border border-gray-300 p-1 font-bold text-center"
>
Superior
</th>
@@ -682,126 +682,126 @@
<tbody>
<tr>
<td
class="border border-gray-300 p-1 font-medium"
class="border border-gray-300 p-1 font-medium text-center"
>
20-29
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
21.7-28.6
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
28.6-34.6
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
34.6-40.6
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
40.6-46.5
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
46.5-56.0
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
56.0+
</td>
</tr>
<tr>
<td
class="border border-gray-300 p-1 font-medium"
class="border border-gray-300 p-1 font-medium text-center"
>
30-39
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
19.0-24.1
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
24.1-28.2
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
28.2-32.2
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
32.2-35.7
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
35.7-45.8
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
45.8+
</td>
</tr>
<tr>
<td
class="border border-gray-300 p-1 font-medium"
class="border border-gray-300 p-1 font-medium text-center"
>
40-49
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
17.0-21.3
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
21.3-24.9
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
24.9-28.7
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
28.7-34.0
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
34.0-41.7
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
41.7+
</td>
</tr>
<tr>
<td
class="border border-gray-300 p-1 font-medium"
class="border border-gray-300 p-1 font-medium text-center"
>
50-59
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
16.0-19.1
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
19.1-24.4
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
21.8-27.6
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
25.2-28.6
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
28.6-35.9
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
35.9+
</td>
</tr>
<tr>
<td
class="border border-gray-300 p-1 font-medium"
class="border border-gray-300 p-1 font-medium text-center"
>
60-69
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
13.4-16.5
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
16.5-18.9
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
18.9-21.2
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
21.2-24.6
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
24.6-29.4
</td>
<td class="border border-gray-300 p-1">
<td class="border border-gray-300 p-1 text-center">
29.4+
</td>
</tr>
+44 -30
View File
@@ -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,12 +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-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">
@@ -37,10 +71,10 @@
<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
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">
@@ -49,26 +83,12 @@
</div>
</div>
<!-- Fuel Utilization -->
<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"
>
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-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">
@@ -82,7 +102,7 @@
<div
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">
@@ -96,15 +116,12 @@
<div
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>
@@ -113,15 +130,12 @@
<div
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>
+4 -8
View File
@@ -13,19 +13,15 @@
<h3 class="text-2xl font-bold text-center text-black mb-6">Body Composition</h3>
<!-- Body Composition Chart -->
<div class="flex justify-center mb-8">
<div class="relative">
<div class="flex justify-center mb-16 w-full">
<img src="data:image/png;base64, {{ body_composition_chart}}"
alt="Body Composition Chart"
class="w-80 h-80 object-contain">
<!-- Chart Labels -->
</div>
class=" object-contain ">
</div>
<!-- Body Fat Percentage Section -->
<div class="mb-8">
<div class="mb-8">
<h3 class="text-2xl font-bold text-center text-black mb-6">Body Fat Percentage - {{ fat_percentage }}%</h3>
<!-- Body Fat Chart -->
<div class="flex justify-center">
<img src="data:image/png;base64, {{ body_fat_chart }}"
+53 -35
View File
@@ -1,38 +1,56 @@
<div class="w-full page bg-white p-8">
<!-- Header -->
<h1 class="text-3xl font-bold mb-6">Lung Analysis</h1>
<!-- Spirometry Assessment Section -->
<div class="mb-8">
<h2 class="text-xl font-semibold mb-4">Spirometry Assessment</h2>
<p class="text-sm text-gray-700 mb-6">
Spirometry is a diagnostic device that assesses how well a person breathes and how their lungs are functioning. Lung function
is crucial for oxygen delivery during physical activity. Comparing results to expected/normal values can highlight potential limitations
that would require additional lung training to improve overall physical activity.
</p>
<!-- Lung Volume Chart -->
<img src="data:image/png;base64,{{ lung_analysis_chart }}" alt="Lung Volume Analysis Chart" class="w-full mb-6">
<!-- Indications Box -->
<div class="bg-gray-200 rounded-lg p-4 text-center mb-8">
<h3 class="font-semibold text-lg mb-2">Indications</h3>
<p class="text-gray-700">{{ indication }}</p>
<div class="w-full page bg-white p-4">
<!-- Header -->
<h1 class="text-3xl font-bold mb-2">Lung Analysis</h1>
<!-- Spirometry Assessment Section -->
<div class="mb-2">
<h2 class="text-xl font-semibold mb-4">Spirometry Assessment</h2>
<p class="text-sm text-gray-700 mb-4">
Spirometry is a diagnostic device that assesses how well a person
breathes and how their lungs are functioning. Lung function is
crucial for oxygen delivery during physical activity. Comparing
results to expected/normal values can highlight potential
limitations that would require additional lung training to improve
overall physical activity.
</p>
<!-- Lung Volume Chart -->
<div class="flex justify-center">
<img
src="data:image/png;base64,{{ lung_analysis_chart }}"
alt="Lung Volume Analysis Chart"
class="w-full max-w-4xl h-auto object-contain"
/>
</div>
<!-- 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 class="text-gray-700">{{ indication }}</p>
</div>
</div>
</div>
<!-- Respiratory Section -->
<div class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-center">Respiratory</h2>
<!-- Respiratory Chart -->
<img src="data:image/png;base64,{{ respiratory_analysis_chart }}" alt="Respiratory Analysis Chart" class="w-full mb-4">
<!-- Peak VT Info Box -->
<div class="bg-gray-200 rounded-lg p-4 text-center">
<h3 class="font-semibold mb-2">Peak VT</h3>
<p class="text-sm">{{ peak_vt }} L/Breath which occurs at {{ peak_vt_bpm }} bpm (Zone {{ peak_vt_zone }})</p>
<p class="text-sm">{{ fev1_percentage }}% of FEV1</p>
<!-- Respiratory Section -->
<div class="mb-4">
<h2 class="text-xl font-semibold mb-4 text-center">Respiratory</h2>
<!-- Respiratory Chart -->
<div class="flex justify-center mb-4">
<img
src="data:image/png;base64,{{ respiratory_analysis_chart }}"
alt="Respiratory Analysis Chart"
class="w-full mb-4 object-contain max-w-4xl h-auto"
/>
</div>
<!-- Peak VT Info Box -->
<div class="bg-gray-200 rounded-lg p-4 text-center">
<h3 class="font-semibold mb-2">Peak VT</h3>
<p class="text-sm">
{{ peak_vt }} L/Breath which occurs at {{ peak_vt_bpm }} bpm
(Zone {{ peak_vt_zone }})
</p>
<p class="text-sm">{{ fev1_percentage }}% of FEV1</p>
</div>
</div>
</div>
</div>
+17 -17
View File
@@ -1,21 +1,21 @@
<div class="w-full page bg-white">
<!-- Main Content -->
<div class="flex flex-col items-center justify-center h-full">
<!-- Fuel Utilization Chart -->
<div class="w-full max-w-5xl">
<img
src="data:image/png;base64,{{ fuel_utilization_chart }}"
alt="Fuel Utilization Report - Institute of Science, Health and Performance"
class="w-full h-auto object-contain chart-large"
/>
</div>
<!-- Main Content -->
<div class="flex flex-col items-center justify-center h-full">
<!-- Fuel Utilization Chart -->
<div class="w-full max-w-5xl">
<img src="data:image/png;base64,{{ fuel_utilization_chart }}"
alt="Fuel Utilization Report - Institute of Science, Health and Performance"
class="w-full h-auto object-contain">
<!-- Chart Information -->
<div class="mt-8 text-center">
<p class="text-gray-700 text-sm">
Client: {{ client_name | default('Keirstyn Moran') }} |
Assessment Date: {{ assessment_date | default('July 29 2025') }}
</p>
</div>
</div>
<!-- Chart Information -->
<div class="mt-8 text-center">
<p class="text-gray-700 text-sm">
Client: {{ client_name | default('Keirstyn Moran') }} |
Assessment Date: {{ assessment_date | default('July 29 2025') }}
</p>
</div>
</div>
</div>
Binary file not shown.