Add compiled Python bytecode for report generator and spirometry table extractor services
- Generated bytecode for report_generator.py and spirometry_table_extractor.py - These changes include the compiled .pyc files in the __pycache__ directory - The report generator service handles the generation of medical reports from uploaded files - The spirometry table extractor service extracts data from PDF files and processes it for further analysis
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,343 +0,0 @@
|
|||||||
import base64
|
|
||||||
|
|
||||||
|
|
||||||
def image_to_base64(image_path):
|
|
||||||
try:
|
|
||||||
with open(image_path, "rb") as image_file:
|
|
||||||
return base64.b64encode(image_file.read()).decode("utf-8")
|
|
||||||
except FileNotFoundError:
|
|
||||||
print(f"Warning: Image not found at {image_path}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
### Defining Page Contexts ###
|
|
||||||
page_1_context = {
|
|
||||||
"name": "John Doe",
|
|
||||||
"surname": "Moran",
|
|
||||||
"date": "July 29, 2025",
|
|
||||||
}
|
|
||||||
|
|
||||||
page_2_context = {
|
|
||||||
"content": "This is page 2 content",
|
|
||||||
}
|
|
||||||
|
|
||||||
page_3_context = {
|
|
||||||
"patient_name": "Keirstyn Moran",
|
|
||||||
}
|
|
||||||
|
|
||||||
page_4_context = {
|
|
||||||
"body_composition_chart": image_to_base64(
|
|
||||||
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/body_composition_chart.png"
|
|
||||||
),
|
|
||||||
"body_fat_chart": image_to_base64(
|
|
||||||
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/body_fat_percent_chart.png"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
page_5_context = {
|
|
||||||
"metabolism_chart": "",
|
|
||||||
"fuel_source_chart": "",
|
|
||||||
"resting_calories": 1540,
|
|
||||||
"neat_calories": 310,
|
|
||||||
"weight_loss_calories": 1725,
|
|
||||||
"weight_loss_rate": "1lb/week",
|
|
||||||
"total_calories": 3575,
|
|
||||||
}
|
|
||||||
|
|
||||||
page_6_context = {
|
|
||||||
"patient_name": "Keirstyn Moran",
|
|
||||||
"age": "34",
|
|
||||||
"height": "5'4\"",
|
|
||||||
"weight": "123lbs",
|
|
||||||
"focus": "Endurance",
|
|
||||||
"deficit_calories": "1725KCals",
|
|
||||||
"deficit_protein": "120g Protein",
|
|
||||||
"deficit_carbs": "155g Carbs",
|
|
||||||
"deficit_fat": "69g Fat",
|
|
||||||
"deficit_fiber": "25g Fibre",
|
|
||||||
"refeed_weekday_calories": "1615KCals",
|
|
||||||
"refeed_weekday_protein": "120g Protein",
|
|
||||||
"refeed_weekday_carbs": "142g Carbs",
|
|
||||||
"refeed_weekday_fat": "63g Fat",
|
|
||||||
"refeed_weekday_fiber": "24g Fibre",
|
|
||||||
"refeed_weekend_calories": "2000KCals",
|
|
||||||
"refeed_weekend_protein": "120g Protein",
|
|
||||||
"refeed_weekend_carbs": "190g Carbs",
|
|
||||||
"refeed_weekend_fat": "84g Fat",
|
|
||||||
"refeed_weekend_fiber": "30g Fibre",
|
|
||||||
"protein_percentage": "28%",
|
|
||||||
"carbs_percentage": "36%",
|
|
||||||
"fats_percentage": "36%",
|
|
||||||
"page_number": "6",
|
|
||||||
}
|
|
||||||
|
|
||||||
page_7_context = {
|
|
||||||
"indication": "No Respiratory Capacity Limitation",
|
|
||||||
"peak_vt": 3.2,
|
|
||||||
"peak_vt_bpm": 198,
|
|
||||||
"peak_vt_zone": 3,
|
|
||||||
"fev1_percentage": 85,
|
|
||||||
"lung_analysis_chart": image_to_base64("/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/spirometry_chart.png"),
|
|
||||||
"respiratory_analysis_chart": image_to_base64(
|
|
||||||
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/respiratory.png"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
page_8_context = {
|
|
||||||
"vo2_max_value": "49.5",
|
|
||||||
"vo2_max_percentile": "100th percentile",
|
|
||||||
"age_range": "30-39",
|
|
||||||
"very_poor_range": "19.0-24.1",
|
|
||||||
"poor_range": "24.1-28.2",
|
|
||||||
"fair_range": "28.2-32.2",
|
|
||||||
"good_range": "32.2-35.7",
|
|
||||||
"excellent_range": "35.7-45.8",
|
|
||||||
"superior_range": "45.8+",
|
|
||||||
"zone1_percentage": "55-65% of Max Heart Rate",
|
|
||||||
"zone2_percentage": "65-75% of Max Heart Rate",
|
|
||||||
"zone3_percentage": "80-85% of Max Heart Rate",
|
|
||||||
"zone4_percentage": "85-88% of Max Heart Rate",
|
|
||||||
"zone5_percentage": "90% of Max Heart Rate",
|
|
||||||
"zone1_bpm": "81-96bpm",
|
|
||||||
"zone2_bpm": "96-100bpm",
|
|
||||||
"zone3_bpm": "100-178bpm",
|
|
||||||
"zone4_bpm": "178-188bpm",
|
|
||||||
"zone5_bpm": "188-198bpm",
|
|
||||||
"zone1_speed": "3.5mph",
|
|
||||||
"zone2_speed": "3.5-4.0mph",
|
|
||||||
"zone3_speed": "4.0-6.5mph",
|
|
||||||
"zone4_speed": "6.5-7.0mph",
|
|
||||||
"zone5_speed": "7.0-8.0mph",
|
|
||||||
"zone1_incline": "2% Incline",
|
|
||||||
"zone2_incline": "2% Incline",
|
|
||||||
"zone3_incline": "2% Incline",
|
|
||||||
"zone4_incline": "2% Incline",
|
|
||||||
"zone5_incline": "2% Incline",
|
|
||||||
"zone1_pace": "10:39min/km Pace",
|
|
||||||
"zone2_pace": "10:39-9:19min/km Pace",
|
|
||||||
"zone3_pace": "9:19-5:44min/km Pace",
|
|
||||||
"zone4_pace": "5:44-5:20min/km Pace",
|
|
||||||
"zone5_pace": "5:20-4:40min/km Pace",
|
|
||||||
"zone1_calories": "4.4kcals/minute",
|
|
||||||
"zone2_calories": "5.9kcals/minute",
|
|
||||||
"zone3_calories": "9.4kcals/minute",
|
|
||||||
"zone4_calories": "12.5kcals/minute",
|
|
||||||
"zone5_calories": "12.8kcals/minute",
|
|
||||||
"zone1_carb": "Avg: 0.4g/min Carb Utilization",
|
|
||||||
"zone2_carb": "Avg: 0.6g/min Carb Utilization",
|
|
||||||
"zone3_carb": "Avg: 1.9g/min Carb Utilization",
|
|
||||||
"zone4_carb": "Avg: 2.9g/min Carb Utilization",
|
|
||||||
"zone5_carb": "Avg: 3.1g/min Carb Utilization",
|
|
||||||
"zone1_breaths": "Avg: 27 breaths",
|
|
||||||
"zone2_breaths": "Avg: 28 breaths",
|
|
||||||
"zone3_breaths": "Avg: 31 breaths",
|
|
||||||
"zone4_breaths": "Avg: 42 breaths",
|
|
||||||
"zone5_breaths": "Avg: 51 breaths",
|
|
||||||
"zone1_breath_range": "Ideal Range: 15-20 breaths",
|
|
||||||
"zone2_breath_range": "Ideal Range: 20-25 breaths",
|
|
||||||
"zone3_breath_range": "Ideal Range: 25-30 breaths",
|
|
||||||
"zone4_breath_range": "Ideal Range: 30-35 breaths",
|
|
||||||
"zone5_breath_range": "Ideal Range: 40+ breaths",
|
|
||||||
}
|
|
||||||
|
|
||||||
page_9_context = {
|
|
||||||
"fuel_utilization_chart": image_to_base64(
|
|
||||||
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/fuel_utilization_chart.png"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
page_10_context = {
|
|
||||||
"vo2_pulse_drop_bpm": "180 bpm",
|
|
||||||
"vo2_pulse_drop_zone": "Zone 4",
|
|
||||||
"vo2_pulse_chart": image_to_base64(
|
|
||||||
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/vo2_pulse_chart.png"
|
|
||||||
),
|
|
||||||
"vo2_breath_drop_bpm": "173 bpm",
|
|
||||||
"vo2_breath_drop_zone": "Zone 3",
|
|
||||||
"vo2_breath_chart": image_to_base64(
|
|
||||||
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/vo2_breath_chart.png"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
page_11_context = {
|
|
||||||
"fat_max_optimal": "*Optimal 10-12Kcals/minute",
|
|
||||||
"fat_max_value": "3.8Kcals/min",
|
|
||||||
"fat_max_heart_rate": "49% of Max Heart Rate",
|
|
||||||
"fat_max_bpm": "97 bpm",
|
|
||||||
"crossover_bpm": "100bpm",
|
|
||||||
"crossover_heart_rate": "51% of Max Heart Rate",
|
|
||||||
"fat_metabolism_note": "100bpm at a speed of 4.0mph and incline of 2%",
|
|
||||||
"fat_metabolism_chart": image_to_base64(
|
|
||||||
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/fat_metabolism_chart.png"
|
|
||||||
),
|
|
||||||
"cardiac_recovery_time": "(1 minute)",
|
|
||||||
"cardiac_recovery_percentage": "33%",
|
|
||||||
"metabolic_recovery_time": "(2 minute)",
|
|
||||||
"metabolic_recovery_percentage": "65%",
|
|
||||||
"breath_recovery_time": "(2.5 minute)",
|
|
||||||
"breath_recovery_percentage": "76%",
|
|
||||||
"recovery_chart": image_to_base64(
|
|
||||||
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/recovery_chart.png"
|
|
||||||
),
|
|
||||||
"resting_heart_rate": "53bpm",
|
|
||||||
"hr_age_range": "26-35",
|
|
||||||
"hr_poor": "82bpm +",
|
|
||||||
"hr_below_avg": "75-81bpm",
|
|
||||||
"hr_average": "71-74bpm",
|
|
||||||
"hr_above_avg": "66-70bpm",
|
|
||||||
"hr_good": "62-65bpm",
|
|
||||||
"hr_excellent": "55-61bpm",
|
|
||||||
"hr_athlete": "44-54bpm",
|
|
||||||
}
|
|
||||||
|
|
||||||
page_12_context = {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
page_13_context = {
|
|
||||||
"patient_name": "Keirstyn Moran",
|
|
||||||
"age": "34",
|
|
||||||
"height": "5'4\"",
|
|
||||||
"weight": "123lbs",
|
|
||||||
"focus": "Endurance",
|
|
||||||
"zone2_frequency": "3-4x/week",
|
|
||||||
"zone2_duration": "40+ minutes",
|
|
||||||
"zone2_hr_range": "96-110bpm",
|
|
||||||
"zone2_speed": "3.5-4.0mph",
|
|
||||||
"zone2_incline": "2% Incline",
|
|
||||||
"zone3_frequency": "1-2x/week",
|
|
||||||
"zone3_duration": "10-20 minutes",
|
|
||||||
"zone3_hr_range": "100-178bpm",
|
|
||||||
"zone3_speed": "4.0-6.5mph",
|
|
||||||
"zone3_incline": "2% Incline",
|
|
||||||
"zone3_target_hr": "140bpm",
|
|
||||||
"zone3_recovery_speed": "3.5mph",
|
|
||||||
"zone3_recovery_incline": "2% Incline",
|
|
||||||
"zone1_hr_range": "81-96bpm",
|
|
||||||
"zone1_duration": "4-8 minutes",
|
|
||||||
"zone3_repeats": "2-3 times",
|
|
||||||
"short_sets": "8-10",
|
|
||||||
"short_duration": "10-30 seconds",
|
|
||||||
"short_zone": "5",
|
|
||||||
"short_rpe": "10",
|
|
||||||
"short_recovery": "20-60 seconds",
|
|
||||||
"medium_sets": "6-8",
|
|
||||||
"medium_duration": "30-90 seconds",
|
|
||||||
"medium_zone": "4",
|
|
||||||
"medium_rpe": "8-9",
|
|
||||||
"medium_recovery": "30-90 seconds",
|
|
||||||
"long_sets": "4-6",
|
|
||||||
"long_duration": "5-10 minutes",
|
|
||||||
"long_zone": "3/4",
|
|
||||||
"long_rpe": "7-8",
|
|
||||||
"long_recovery": "2.5-5 minutes",
|
|
||||||
"tempo_sets": "2-3",
|
|
||||||
"tempo_duration": "10-20 minutes",
|
|
||||||
"tempo_zone": "3",
|
|
||||||
"tempo_rpe": "6-7",
|
|
||||||
"tempo_recovery": "4-8 minutes",
|
|
||||||
"cardio_sets": "1",
|
|
||||||
"cardio_duration": ">40 minutes",
|
|
||||||
"cardio_zone": "2",
|
|
||||||
"cardio_rpe": "4-5",
|
|
||||||
"cardio_recovery": "N/A",
|
|
||||||
"week1_mon_zone": "Zone 2",
|
|
||||||
"week1_mon_duration": "45 mins",
|
|
||||||
"week1_tue_zone": "Zone 2",
|
|
||||||
"week1_tue_duration": "45 mins",
|
|
||||||
"week1_wed_zone": "Zone 3",
|
|
||||||
"week1_wed_duration1": "10mins On",
|
|
||||||
"week1_wed_duration2": "8mins Rest",
|
|
||||||
"week1_wed_sets": "x2",
|
|
||||||
"week1_thu_content": "",
|
|
||||||
"week1_fri_zone": "Zone 2",
|
|
||||||
"week1_fri_duration": "45 mins",
|
|
||||||
"week1_sat_content": "",
|
|
||||||
"week1_sun_content": "",
|
|
||||||
"week2_mon_zone": "Zone 2",
|
|
||||||
"week2_mon_duration": "50 mins",
|
|
||||||
"week2_tue_zone": "Zone 2",
|
|
||||||
"week2_tue_duration": "50 mins",
|
|
||||||
"week2_wed_zone": "Zone 3",
|
|
||||||
"week2_wed_duration1": "10mins On",
|
|
||||||
"week2_wed_duration2": "6mins Rest",
|
|
||||||
"week2_wed_sets": "x2",
|
|
||||||
"week2_thu_content": "",
|
|
||||||
"week2_fri_zone": "Zone 2",
|
|
||||||
"week2_fri_duration": "50 mins",
|
|
||||||
"week2_sat_content": "",
|
|
||||||
"week2_sun_content": "",
|
|
||||||
"contact_email": "info@ishplabs.com",
|
|
||||||
"website": "www.ishplabs.com",
|
|
||||||
"social": "@ishplabs",
|
|
||||||
"page_number": "13",
|
|
||||||
}
|
|
||||||
|
|
||||||
page_14_context = {
|
|
||||||
"patient_name": "Keirstyn Moran",
|
|
||||||
"contact_email": "info@ishplabs.com",
|
|
||||||
"website": "www.ishplabs.com",
|
|
||||||
"social": "@ishplabs",
|
|
||||||
"page_number": "14",
|
|
||||||
}
|
|
||||||
|
|
||||||
page_15_context = {
|
|
||||||
"patient_name": "Keirstyn Moran",
|
|
||||||
"contact_email": "info@ishplabs.com",
|
|
||||||
"website": "www.ishplabs.com",
|
|
||||||
"social": "@ishplabs",
|
|
||||||
"page_number": "15",
|
|
||||||
}
|
|
||||||
|
|
||||||
page_16_context = {
|
|
||||||
"patient_name": "Keirstyn Moran",
|
|
||||||
"contact_email": "info@ishplabs.com",
|
|
||||||
"website": "www.ishplabs.com",
|
|
||||||
"social": "@ishplabs",
|
|
||||||
"page_number": "16",
|
|
||||||
}
|
|
||||||
|
|
||||||
page_17_context = {
|
|
||||||
"patient_name": "Keirstyn Moran",
|
|
||||||
"contact_email": "info@ishplabs.com",
|
|
||||||
"website": "www.ishplabs.com",
|
|
||||||
"social": "@ishplabs",
|
|
||||||
"page_number": "17",
|
|
||||||
}
|
|
||||||
|
|
||||||
page_18_context = {
|
|
||||||
"body_fat_percentage_chart": image_to_base64(
|
|
||||||
"/home/oluwasanmi/Documents/Work/MKD/report_generation/graphs/fat_percent_master_chart.png"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
page_19_context = {
|
|
||||||
"patient_name": "Keirstyn Moran",
|
|
||||||
"contact_email": "info@ishplabs.com",
|
|
||||||
"website": "www.ishplabs.com",
|
|
||||||
"social": "@ishplabs",
|
|
||||||
"page_number": "19",
|
|
||||||
}
|
|
||||||
|
|
||||||
context_list = [
|
|
||||||
page_1_context,
|
|
||||||
page_2_context,
|
|
||||||
page_3_context,
|
|
||||||
page_4_context,
|
|
||||||
page_5_context,
|
|
||||||
page_6_context,
|
|
||||||
page_7_context,
|
|
||||||
page_8_context,
|
|
||||||
page_9_context,
|
|
||||||
page_10_context,
|
|
||||||
page_11_context,
|
|
||||||
page_12_context,
|
|
||||||
page_13_context,
|
|
||||||
page_14_context,
|
|
||||||
page_15_context,
|
|
||||||
page_16_context,
|
|
||||||
page_17_context,
|
|
||||||
page_18_context,
|
|
||||||
page_19_context,
|
|
||||||
]
|
|
||||||
@@ -1,319 +1,180 @@
|
|||||||
import base64
|
"""
|
||||||
|
Context Generator Service
|
||||||
|
|
||||||
|
This service processes all data files and generates context dictionaries for each page
|
||||||
|
of the medical report. It performs analysis on Pnoe, Spirometry, and SECA data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
class ReportGenerator:
|
class ContextGenerator:
|
||||||
|
"""Generate context data for report pages"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.pnoe_df = None
|
self.pnoe_df = None
|
||||||
self.patient_df = None
|
|
||||||
self.spirometry_df = None
|
self.spirometry_df = None
|
||||||
self.seca_df = None
|
self.seca_df = None
|
||||||
self.patient_info = {}
|
self.patient_info = {}
|
||||||
self.charts_dir = Path("graphs")
|
|
||||||
self.charts_dir.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
def load_data(
|
def load_data(
|
||||||
self,
|
self,
|
||||||
pnoe_path: str,
|
pnoe_path: str,
|
||||||
patient_path: str,
|
|
||||||
spirometry_path: str,
|
spirometry_path: str,
|
||||||
seca_path: str = None,
|
seca_path: str,
|
||||||
):
|
):
|
||||||
"""Load all required datasets"""
|
"""Load all required datasets"""
|
||||||
self.pnoe_df = pd.read_csv(pnoe_path, delimiter=";")
|
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)
|
self.spirometry_df = pd.read_csv(spirometry_path)
|
||||||
if seca_path:
|
self.seca_df = pd.read_excel(seca_path)
|
||||||
self.seca_df = pd.read_excel(seca_path)
|
self._preprocess_pnoe_data()
|
||||||
|
|
||||||
# Apply preprocessing
|
def _preprocess_pnoe_data(self):
|
||||||
self._preprocess_data()
|
"""Apply preprocessing steps to Pnoe 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")
|
self.pnoe_df = self.pnoe_df.apply(pd.to_numeric, errors="ignore")
|
||||||
|
self.pnoe_df["VO2 Pulse"] = self.pnoe_df["VO2(ml/min)"] / self.pnoe_df["HR(bpm)"]
|
||||||
# Calculate derived columns
|
self.pnoe_df["VO2 Breath"] = self.pnoe_df["VO2(ml/min)"] / self.pnoe_df["BF(bpm)"]
|
||||||
self.pnoe_df["VO2 Pulse"] = (
|
self.pnoe_df["CHO"] = self.pnoe_df["EE(kcal/min)"] * self.pnoe_df["CARBS(%)"] / 100
|
||||||
self.pnoe_df["VO2(ml/min)"] / self.pnoe_df["HR(bpm)"]
|
self.pnoe_df["FAT"] = self.pnoe_df["EE(kcal/min)"] * self.pnoe_df["FAT(%)"] / 100
|
||||||
)
|
|
||||||
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
|
window_size = 10
|
||||||
columns_to_smooth = [
|
columns_to_smooth = ["VO2(ml/min)", "VCO2(ml/min)", "HR(bpm)", "VT(l)", "BF(bpm)", "VE(l/min)", "VO2 Pulse", "VO2 Breath", "CHO", "FAT"]
|
||||||
"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:
|
for col in columns_to_smooth:
|
||||||
if col in self.pnoe_df.columns:
|
if col in self.pnoe_df.columns:
|
||||||
self.pnoe_df[f"{col}_smoothed"] = (
|
self.pnoe_df[f"{col}_smoothed"] = self.pnoe_df[col].rolling(window=window_size, min_periods=1).mean()
|
||||||
self.pnoe_df[col].rolling(window=window_size, min_periods=1).mean()
|
|
||||||
)
|
|
||||||
|
|
||||||
def extract_patient_info(self, last_name: str) -> Dict:
|
def extract_patient_info(self, patient_name: str) -> Dict:
|
||||||
"""Extract patient information from datasets"""
|
"""Extract patient information from SECA dataset"""
|
||||||
if self.seca_df is not None:
|
if self.seca_df is not None:
|
||||||
patient_data = self.seca_df[
|
patient_data = self.seca_df[self.seca_df["LastName"].str.contains(patient_name, case=False, na=False)]
|
||||||
self.seca_df["LastName"].str.contains(last_name, case=False, na=False)
|
|
||||||
]
|
|
||||||
if not patient_data.empty:
|
if not patient_data.empty:
|
||||||
row = patient_data.iloc[0]
|
row = patient_data.iloc[0]
|
||||||
|
weight_kg = float(row.get("Weight", 0))
|
||||||
|
fat_pct = float(row.get("Adult_FMP", 0))
|
||||||
|
|
||||||
self.patient_info = {
|
self.patient_info = {
|
||||||
"name": f"{row.get('FirstName', '')} {last_name}",
|
"name": f"{row.get('FirstName', '')} {row.get('LastName', '')}",
|
||||||
|
"first_name": row.get("FirstName", ""),
|
||||||
|
"last_name": row.get("LastName", ""),
|
||||||
"age": int(row.get("Age", 0)),
|
"age": int(row.get("Age", 0)),
|
||||||
"height": f"{row.get('Height', '')}",
|
"height": f"{row.get('Height', '')}",
|
||||||
"weight": float(row.get("Weight", 0)),
|
"weight": weight_kg,
|
||||||
"gender": row.get("Gender", "").lower(),
|
"gender": row.get("Gender", "").lower(),
|
||||||
"fat_percentage": float(row.get("Adult_FMP", 0)),
|
"fat_percentage": fat_pct,
|
||||||
|
"fat_mass_lbs": weight_kg * fat_pct / 100 * 2.20462,
|
||||||
|
"lean_mass_lbs": weight_kg * (1 - fat_pct / 100) * 2.20462,
|
||||||
}
|
}
|
||||||
return self.patient_info
|
return self.patient_info
|
||||||
|
|
||||||
def calculate_spirometry_metrics(self) -> Dict:
|
def calculate_spirometry_metrics(self) -> Dict:
|
||||||
"""Calculate spirometry-related metrics"""
|
"""Calculate spirometry-related metrics"""
|
||||||
metrics = {}
|
metrics = {}
|
||||||
|
|
||||||
# Extract key spirometry values
|
|
||||||
for param in ["FVC", "FEV1", "FEV1/FVC%"]:
|
for param in ["FVC", "FEV1", "FEV1/FVC%"]:
|
||||||
row = self.spirometry_df.loc[self.spirometry_df["Parameters"] == param]
|
row = self.spirometry_df.loc[self.spirometry_df["Parameters"].str.strip() == param]
|
||||||
if not row.empty:
|
if not row.empty:
|
||||||
metrics[
|
param_key = param.lower().replace('/', '_').replace('%', '_pct')
|
||||||
f"{param.lower().replace('/', '_').replace('%', '_pct')}_best"
|
metrics[f"{param_key}_best"] = row["Best"].values[0]
|
||||||
] = row["Best"].values[0]
|
metrics[f"{param_key}_pred"] = row["%Pred."].values[0]
|
||||||
metrics[
|
|
||||||
f"{param.lower().replace('/', '_').replace('%', '_pct')}_pred"
|
|
||||||
] = row["%Pred."].values[0]
|
|
||||||
|
|
||||||
return metrics
|
return metrics
|
||||||
|
|
||||||
def calculate_pnoe_metrics(self) -> Dict:
|
def calculate_pnoe_metrics(self) -> Dict:
|
||||||
"""Calculate all Pnoe-derived metrics"""
|
"""Calculate all Pnoe-derived metrics"""
|
||||||
metrics = {}
|
metrics = {}
|
||||||
|
|
||||||
# Basic metrics
|
|
||||||
metrics["vo2_max"] = self.pnoe_df["VO2(ml/min)_smoothed"].max()
|
metrics["vo2_max"] = self.pnoe_df["VO2(ml/min)_smoothed"].max()
|
||||||
metrics["vo2_max_per_kg"] = metrics["vo2_max"] / self.patient_info["weight"]
|
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_idx = self.pnoe_df["VT(l)_smoothed"].idxmax()
|
||||||
peak_vt_row = self.pnoe_df.loc[peak_vt_idx]
|
peak_vt_row = self.pnoe_df.loc[peak_vt_idx]
|
||||||
metrics["peak_vt"] = peak_vt_row["VT(l)_smoothed"]
|
metrics["peak_vt"] = peak_vt_row["VT(l)_smoothed"]
|
||||||
metrics["peak_vt_hr"] = peak_vt_row["HR(bpm)_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_idx = self.pnoe_df["FAT_smoothed"].idxmax()
|
||||||
fat_max_row = self.pnoe_df.loc[fat_max_idx]
|
fat_max_row = self.pnoe_df.loc[fat_max_idx]
|
||||||
metrics["fat_max_value"] = fat_max_row["FAT_smoothed"]
|
metrics["fat_max_value"] = fat_max_row["FAT_smoothed"]
|
||||||
metrics["fat_max_hr"] = fat_max_row["HR(bpm)_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()
|
vt1, vt2 = self._detect_thresholds()
|
||||||
metrics["vt1"] = vt1
|
metrics["vt1"] = vt1
|
||||||
metrics["vt2"] = vt2
|
metrics["vt2"] = vt2
|
||||||
|
|
||||||
|
zones = self._calculate_hr_zones(vt1, vt2, fat_max_row)
|
||||||
|
metrics.update(zones)
|
||||||
return metrics
|
return metrics
|
||||||
|
|
||||||
def _detect_thresholds(self) -> Tuple[Optional[Dict], Optional[Dict]]:
|
def _detect_thresholds(self) -> Tuple[Optional[Dict], Optional[Dict]]:
|
||||||
"""Detect VT1 and VT2 thresholds"""
|
"""Detect VT1 and VT2 thresholds"""
|
||||||
# VT1: First crossover where carbs > fat
|
|
||||||
condition = self.pnoe_df["CHO_smoothed"] > self.pnoe_df["FAT_smoothed"]
|
condition = self.pnoe_df["CHO_smoothed"] > self.pnoe_df["FAT_smoothed"]
|
||||||
crossover_indices = condition[condition].index
|
crossover_indices = condition[condition].index
|
||||||
|
|
||||||
vt1 = None
|
vt1 = None
|
||||||
if len(crossover_indices) > 0:
|
if len(crossover_indices) > 0:
|
||||||
vt1_idx = crossover_indices[0]
|
vt1_idx = crossover_indices[0]
|
||||||
vt1_row = self.pnoe_df.loc[vt1_idx]
|
vt1_row = self.pnoe_df.loc[vt1_idx]
|
||||||
vt1 = {
|
vt1 = {"HeartRate": vt1_row["HR(bpm)_smoothed"], "Speed": vt1_row["Speed"], "Time": vt1_row["T(sec)"]}
|
||||||
"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()
|
ve_slope = self.pnoe_df["VE(l/min)_smoothed"].diff()
|
||||||
second_derivative = ve_slope.diff()
|
second_derivative = ve_slope.diff()
|
||||||
vt2_idx = second_derivative.idxmax()
|
vt2_idx = second_derivative.idxmax()
|
||||||
|
|
||||||
vt2 = None
|
vt2 = None
|
||||||
if pd.notna(vt2_idx):
|
if pd.notna(vt2_idx):
|
||||||
vt2_row = self.pnoe_df.loc[vt2_idx]
|
vt2_row = self.pnoe_df.loc[vt2_idx]
|
||||||
vt2 = {
|
vt2 = {"HeartRate": vt2_row["HR(bpm)_smoothed"], "Speed": vt2_row["Speed"], "Time": vt2_row["T(sec)"]}
|
||||||
"HeartRate": vt2_row["HR(bpm)_smoothed"],
|
|
||||||
"Speed": vt2_row["Speed"],
|
|
||||||
"Time": vt2_row["T(sec)"],
|
|
||||||
}
|
|
||||||
|
|
||||||
return vt1, vt2
|
return vt1, vt2
|
||||||
|
|
||||||
def _calculate_hr_zones(self) -> Dict:
|
def _calculate_hr_zones(self, vt1: Optional[Dict], vt2: Optional[Dict], fat_max_row: pd.Series) -> Dict:
|
||||||
"""Calculate heart rate zones"""
|
"""Calculate heart rate zones based on thresholds"""
|
||||||
max_hr = 220 - self.patient_info["age"]
|
zones = {}
|
||||||
|
if vt1 and vt2:
|
||||||
# Simplified zone calculation - you can make this more sophisticated
|
zone_1_start = fat_max_row["HR(bpm)_smoothed"] - 15
|
||||||
zones = {
|
zone_2_start = fat_max_row["HR(bpm)_smoothed"]
|
||||||
"zone1_bpm": f"{int(max_hr * 0.55)}-{int(max_hr * 0.65)}bpm",
|
zone_3_start = vt1["HeartRate"]
|
||||||
"zone2_bpm": f"{int(max_hr * 0.65)}-{int(max_hr * 0.75)}bpm",
|
zone_4_start = vt2["HeartRate"] - 10
|
||||||
"zone3_bpm": f"{int(max_hr * 0.75)}-{int(max_hr * 0.85)}bpm",
|
zone_5_start = vt2["HeartRate"] + 10
|
||||||
"zone4_bpm": f"{int(max_hr * 0.85)}-{int(max_hr * 0.95)}bpm",
|
|
||||||
"zone5_bpm": f"{int(max_hr * 0.95)}+bpm",
|
zones["zone1_bpm"] = f"{int(zone_1_start)}-{int(zone_2_start)}bpm"
|
||||||
}
|
zones["zone2_bpm"] = f"{int(zone_2_start)}-{int(vt1['HeartRate'])}bpm"
|
||||||
|
zones["zone3_bpm"] = f"{int(zone_3_start)}-{int(zone_4_start)}bpm"
|
||||||
|
zones["zone4_bpm"] = f"{int(zone_4_start)}-{int(zone_5_start)}bpm"
|
||||||
|
zones["zone5_bpm"] = f"{int(zone_5_start)}+bpm"
|
||||||
|
else:
|
||||||
|
max_hr = 220 - self.patient_info["age"]
|
||||||
|
zones["zone1_bpm"] = f"{int(max_hr * 0.55)}-{int(max_hr * 0.65)}bpm"
|
||||||
|
zones["zone2_bpm"] = f"{int(max_hr * 0.65)}-{int(max_hr * 0.75)}bpm"
|
||||||
|
zones["zone3_bpm"] = f"{int(max_hr * 0.75)}-{int(max_hr * 0.85)}bpm"
|
||||||
|
zones["zone4_bpm"] = f"{int(max_hr * 0.85)}-{int(max_hr * 0.95)}bpm"
|
||||||
|
zones["zone5_bpm"] = f"{int(max_hr * 0.95)}+bpm"
|
||||||
return zones
|
return zones
|
||||||
|
|
||||||
def generate_charts(self) -> Dict[str, str]:
|
def generate_all_contexts(self, patient_name: str, graphs: Dict[str, str]) -> List[Dict]:
|
||||||
"""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"""
|
"""Main method to generate all page contexts"""
|
||||||
# Extract patient info
|
self.extract_patient_info(patient_name)
|
||||||
self.extract_patient_info(last_name)
|
|
||||||
|
|
||||||
# Calculate metrics
|
|
||||||
spirometry_metrics = self.calculate_spirometry_metrics()
|
spirometry_metrics = self.calculate_spirometry_metrics()
|
||||||
pnoe_metrics = self.calculate_pnoe_metrics()
|
pnoe_metrics = self.calculate_pnoe_metrics()
|
||||||
|
|
||||||
# Generate charts
|
|
||||||
charts = self.generate_charts()
|
|
||||||
|
|
||||||
# Build contexts for each page
|
|
||||||
contexts = []
|
contexts = []
|
||||||
|
contexts.append({"name": self.patient_info["name"], "surname": self.patient_info["last_name"], "date": datetime.now().strftime("%B %d, %Y")})
|
||||||
# Page 1
|
contexts.append({"patient_name": self.patient_info["name"], "test_date": datetime.now().strftime("%B %d, %Y")})
|
||||||
contexts.append(
|
|
||||||
{
|
for i in range(4):
|
||||||
"name": self.patient_info["name"],
|
contexts.append({"patient_name": self.patient_info["name"], "page_number": i + 3})
|
||||||
"surname": last_name,
|
|
||||||
"date": "July 29, 2025",
|
fev1_percentage = 0
|
||||||
}
|
if spirometry_metrics.get("fvc_best"):
|
||||||
)
|
fev1_percentage = (pnoe_metrics["peak_vt"] / spirometry_metrics["fvc_best"]) * 100
|
||||||
|
|
||||||
# Page 2-6 (add as needed)
|
contexts.append({"peak_vt": f"{pnoe_metrics['peak_vt']:.2f}", "peak_vt_bpm": f"{int(pnoe_metrics['peak_vt_hr'])}", "fev1_percentage": f"{fev1_percentage:.1f}", "lung_analysis_chart": graphs.get("spirometry_chart", ""), "respiratory_analysis_chart": graphs.get("respiratory", "")})
|
||||||
for i in range(5):
|
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}", "zone1_bpm": pnoe_metrics.get("zone1_bpm", ""), "zone2_bpm": pnoe_metrics.get("zone2_bpm", ""), "zone3_bpm": pnoe_metrics.get("zone3_bpm", ""), "zone4_bpm": pnoe_metrics.get("zone4_bpm", ""), "zone5_bpm": pnoe_metrics.get("zone5_bpm", ""), "vo2_pulse_chart": graphs.get("vo2_pulse", "")})
|
||||||
contexts.append({})
|
contexts.append({"fat_max_value": f"{pnoe_metrics['fat_max_value']:.2f}", "fat_max_hr": f"{int(pnoe_metrics['fat_max_hr'])}", "fuel_utilization_chart": graphs.get("fuel_utilization", ""), "fat_metabolism_chart": graphs.get("fat_metabolism", "")})
|
||||||
|
contexts.append({"fat_percentage": f"{self.patient_info['fat_percentage']:.1f}", "fat_mass_lbs": f"{self.patient_info['fat_mass_lbs']:.1f}", "lean_mass_lbs": f"{self.patient_info['lean_mass_lbs']:.1f}", "body_composition_chart": graphs.get("body_composition", ""), "body_fat_percent_chart": graphs.get("body_fat_percent", "")})
|
||||||
# Page 7 - Spirometry
|
|
||||||
contexts.append(
|
for i in range(9):
|
||||||
{
|
contexts.append({"patient_name": self.patient_info["name"], "page_number": i + 11, "vo2_breath_chart": graphs.get("vo2_breath", ""), "recovery_chart": graphs.get("recovery", "")})
|
||||||
"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
|
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")
|
|
||||||
|
|||||||
+288
-295
@@ -1,6 +1,12 @@
|
|||||||
|
"""
|
||||||
|
Graph Generator Service
|
||||||
|
|
||||||
|
This service generates all the charts and visualizations required for the medical report.
|
||||||
|
Based on the analysis notebooks in services_dfdf/.
|
||||||
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict
|
|
||||||
|
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
import matplotlib.transforms as mtransforms
|
import matplotlib.transforms as mtransforms
|
||||||
@@ -11,13 +17,28 @@ from matplotlib.patches import FancyBboxPatch
|
|||||||
|
|
||||||
|
|
||||||
class GraphGenerator:
|
class GraphGenerator:
|
||||||
|
"""Generate all charts for medical reports"""
|
||||||
|
|
||||||
def __init__(self, charts_dir: str = "graphs"):
|
def __init__(self, charts_dir: str = "graphs"):
|
||||||
"""Initialize the GraphGenerator with output directory for charts"""
|
"""
|
||||||
|
Initialize the graph generator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
charts_dir: Directory to save generated charts
|
||||||
|
"""
|
||||||
self.charts_dir = Path(charts_dir)
|
self.charts_dir = Path(charts_dir)
|
||||||
self.charts_dir.mkdir(exist_ok=True)
|
self.charts_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
def _image_to_base64(self, image_path: Path) -> str:
|
def _image_to_base64(self, image_path: Path) -> str:
|
||||||
"""Convert image to base64 string"""
|
"""
|
||||||
|
Convert image file to base64 string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path: Path to image file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64 encoded string
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
with open(image_path, "rb") as image_file:
|
with open(image_path, "rb") as image_file:
|
||||||
return base64.b64encode(image_file.read()).decode("utf-8")
|
return base64.b64encode(image_file.read()).decode("utf-8")
|
||||||
@@ -25,27 +46,35 @@ class GraphGenerator:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
def generate_respiratory_chart(
|
def generate_respiratory_chart(
|
||||||
self, df: pd.DataFrame, save_as_base64: bool = False
|
self, df: pd.DataFrame, save_as_base64: bool = True
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate respiratory chart showing VT and Speed over time"""
|
"""
|
||||||
# Get phase times for background regions
|
Generate respiratory chart (VT and Speed over time).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
df: Processed DataFrame with smoothed columns
|
||||||
|
save_as_base64: If True, return base64 string, else return file path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64 string or file path
|
||||||
|
"""
|
||||||
first_unique_phase = df.drop_duplicates(subset="PHASE")
|
first_unique_phase = df.drop_duplicates(subset="PHASE")
|
||||||
phase_times = first_unique_phase["T(sec)"].tolist()
|
phase_times = first_unique_phase["T(sec)"].tolist()
|
||||||
|
|
||||||
plt.figure(figsize=(18, 5))
|
plt.figure(figsize=(18, 5))
|
||||||
ax1 = plt.subplot()
|
ax1 = plt.subplot()
|
||||||
|
|
||||||
# Plot VT with step-like appearance
|
# Plot VT
|
||||||
sns.lineplot(data=df, x="T(sec)", y="VT(l)_smoothed", label="VT (L)")
|
sns.lineplot(data=df, x="T(sec)", y="VT(l)_smoothed", label="VT (L)")
|
||||||
ax1.set_xlabel("Time (sec)")
|
ax1.set_xlabel("Time (sec)")
|
||||||
ax1.set_ylabel("VT (L)")
|
ax1.set_ylabel("VT (L)")
|
||||||
ax1.grid(True, alpha=0.1)
|
ax1.grid(True, alpha=0.1)
|
||||||
ax1.set_ylim(0, min(8, df["VT(l)_smoothed"].max()))
|
ax1.set_ylim(0, min(8, df["VT(l)_smoothed"].max()))
|
||||||
|
|
||||||
# Plot speed as step function on secondary y-axis
|
# Plot speed on secondary y-axis
|
||||||
ax2 = ax1.twinx()
|
ax2 = ax1.twinx()
|
||||||
ax1.set_xticks(np.arange(0, df["T(sec)"].max() + 200, 200))
|
ax1.set_xticks(np.arange(0, df["T(sec)"].max() + 200, 200))
|
||||||
line2 = sns.lineplot(
|
sns.lineplot(
|
||||||
data=df,
|
data=df,
|
||||||
x="T(sec)",
|
x="T(sec)",
|
||||||
y="Speed",
|
y="Speed",
|
||||||
@@ -58,11 +87,9 @@ class GraphGenerator:
|
|||||||
ax2.set_ylabel("Speed")
|
ax2.set_ylabel("Speed")
|
||||||
ax2.set_ylim(0, min(30, df["Speed"].max()) + 1)
|
ax2.set_ylim(0, min(30, df["Speed"].max()) + 1)
|
||||||
|
|
||||||
# Remove default legends first
|
# Combine legends
|
||||||
ax1.get_legend().remove()
|
ax1.get_legend().remove()
|
||||||
ax2.get_legend().remove()
|
ax2.get_legend().remove()
|
||||||
|
|
||||||
# Combine legends from both axes in the top left
|
|
||||||
lines1, labels1 = ax1.get_legend_handles_labels()
|
lines1, labels1 = ax1.get_legend_handles_labels()
|
||||||
lines2, labels2 = ax2.get_legend_handles_labels()
|
lines2, labels2 = ax2.get_legend_handles_labels()
|
||||||
ax1.legend(lines1 + lines2, labels1 + labels2, loc="upper left")
|
ax1.legend(lines1 + lines2, labels1 + labels2, loc="upper left")
|
||||||
@@ -81,12 +108,23 @@ class GraphGenerator:
|
|||||||
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||||
|
|
||||||
def generate_fuel_utilization_chart(
|
def generate_fuel_utilization_chart(
|
||||||
self, df: pd.DataFrame, save_as_base64: bool = False
|
self, df: pd.DataFrame, save_as_base64: bool = True
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate fuel utilization chart with stacked bars showing fat vs carbs"""
|
"""
|
||||||
# Group by speed and calculate mean for numeric columns only
|
Generate fuel utilization chart (CHO vs FAT by stage).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
df: Processed DataFrame with smoothed columns
|
||||||
|
save_as_base64: If True, return base64 string, else return file path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64 string or file path
|
||||||
|
"""
|
||||||
|
# Group by speed and calculate mean
|
||||||
speed_groups = df.groupby("Speed").mean(numeric_only=True).round(1)
|
speed_groups = df.groupby("Speed").mean(numeric_only=True).round(1)
|
||||||
speed_groups = speed_groups.iloc[1:-1]
|
speed_groups = speed_groups.iloc[1:-1]
|
||||||
|
|
||||||
|
# Filter data
|
||||||
filtered_data = speed_groups[
|
filtered_data = speed_groups[
|
||||||
(speed_groups.index >= 3.5) & (speed_groups.index <= 7.5)
|
(speed_groups.index >= 3.5) & (speed_groups.index <= 7.5)
|
||||||
]
|
]
|
||||||
@@ -94,19 +132,24 @@ class GraphGenerator:
|
|||||||
plt.figure(figsize=(15, 8))
|
plt.figure(figsize=(15, 8))
|
||||||
plt.style.use("default")
|
plt.style.use("default")
|
||||||
|
|
||||||
# Create stage labels and positions
|
|
||||||
stage_labels = [f"Stage {i}" for i in range(1, len(filtered_data) + 1)]
|
stage_labels = [f"Stage {i}" for i in range(1, len(filtered_data) + 1)]
|
||||||
x_positions = np.arange(len(filtered_data))
|
x_positions = np.arange(len(filtered_data))
|
||||||
|
|
||||||
# Calculate fat and carbs energy expenditure from percentages
|
# Calculate fat and carbs energy expenditure
|
||||||
fat_ee = filtered_data["EE(kcal/min)"] * filtered_data["FAT(%)"] / 100
|
fat_ee = filtered_data["EE(kcal/min)"] * filtered_data["FAT(%)"] / 100
|
||||||
carbs_ee = filtered_data["EE(kcal/min)"] * filtered_data["CARBS(%)"] / 100
|
carbs_ee = filtered_data["EE(kcal/min)"] * filtered_data["CARBS(%)"] / 100
|
||||||
|
|
||||||
# Create the main axis for the stacked bars
|
|
||||||
ax1 = plt.gca()
|
ax1 = plt.gca()
|
||||||
|
|
||||||
# Create stacked bar chart with colors
|
# Create stacked bar chart
|
||||||
ax1.bar(x_positions, fat_ee, color="#1f77b4", alpha=0.8, width=0.6, label="Fat")
|
ax1.bar(
|
||||||
|
x_positions,
|
||||||
|
fat_ee,
|
||||||
|
color="#1f77b4",
|
||||||
|
alpha=0.8,
|
||||||
|
width=0.6,
|
||||||
|
label="Fat",
|
||||||
|
)
|
||||||
ax1.bar(
|
ax1.bar(
|
||||||
x_positions,
|
x_positions,
|
||||||
carbs_ee,
|
carbs_ee,
|
||||||
@@ -117,16 +160,15 @@ class GraphGenerator:
|
|||||||
label="Carbs",
|
label="Carbs",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set labels and formatting for primary axis
|
|
||||||
ax1.set_xlabel("", fontsize=12)
|
ax1.set_xlabel("", fontsize=12)
|
||||||
ax1.set_ylabel("Fuel (kcal/min)", fontsize=12)
|
ax1.set_ylabel("Fuel (kcal/min)", fontsize=12)
|
||||||
ax1.set_ylim(0, 20)
|
ax1.set_ylim(0, 20)
|
||||||
|
|
||||||
# Add individual values on each bar segment
|
# Add values on bars
|
||||||
for i, (fat_val, carb_val, total_val) in enumerate(
|
for i, (fat_val, carb_val, total_val) in enumerate(
|
||||||
zip(fat_ee, carbs_ee, filtered_data["EE(kcal/min)"])
|
zip(fat_ee, carbs_ee, filtered_data["EE(kcal/min)"])
|
||||||
):
|
):
|
||||||
if fat_val > 0.3: # Fat value
|
if fat_val > 0.3:
|
||||||
ax1.text(
|
ax1.text(
|
||||||
i,
|
i,
|
||||||
fat_val / 2,
|
fat_val / 2,
|
||||||
@@ -137,7 +179,7 @@ class GraphGenerator:
|
|||||||
fontweight="bold",
|
fontweight="bold",
|
||||||
color="white",
|
color="white",
|
||||||
)
|
)
|
||||||
if carb_val > 0.3: # Carbs value
|
if carb_val > 0.3:
|
||||||
ax1.text(
|
ax1.text(
|
||||||
i,
|
i,
|
||||||
fat_val + carb_val / 2,
|
fat_val + carb_val / 2,
|
||||||
@@ -148,7 +190,6 @@ class GraphGenerator:
|
|||||||
fontweight="bold",
|
fontweight="bold",
|
||||||
color="white",
|
color="white",
|
||||||
)
|
)
|
||||||
# Total EE
|
|
||||||
ax1.text(
|
ax1.text(
|
||||||
i,
|
i,
|
||||||
total_val + 0.5,
|
total_val + 0.5,
|
||||||
@@ -160,7 +201,7 @@ class GraphGenerator:
|
|||||||
color="black",
|
color="black",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add speed labels below x-axis
|
# Add speed labels
|
||||||
for i, speed in enumerate(filtered_data.index):
|
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, -1.5, f"{speed:.1f} mph", ha="center", va="top", fontsize=9)
|
||||||
ax1.text(
|
ax1.text(
|
||||||
@@ -175,8 +216,6 @@ class GraphGenerator:
|
|||||||
|
|
||||||
# Create secondary y-axis for heart rate
|
# Create secondary y-axis for heart rate
|
||||||
ax2 = ax1.twinx()
|
ax2 = ax1.twinx()
|
||||||
|
|
||||||
# Plot heart rate line
|
|
||||||
ax2.plot(
|
ax2.plot(
|
||||||
x_positions,
|
x_positions,
|
||||||
filtered_data["HR(bpm)"],
|
filtered_data["HR(bpm)"],
|
||||||
@@ -187,12 +226,11 @@ class GraphGenerator:
|
|||||||
label="Heart Rate",
|
label="Heart Rate",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set heart rate axis formatting
|
|
||||||
ax2.set_ylabel("Heart Rate (bpm)", fontsize=12, color="red")
|
ax2.set_ylabel("Heart Rate (bpm)", fontsize=12, color="red")
|
||||||
ax2.tick_params(axis="y", labelcolor="red")
|
ax2.tick_params(axis="y", labelcolor="red")
|
||||||
ax2.set_ylim(0, 220)
|
ax2.set_ylim(0, 220)
|
||||||
|
|
||||||
# Add HR values above the points
|
# Add HR values
|
||||||
for i, hr in enumerate(filtered_data["HR(bpm)"]):
|
for i, hr in enumerate(filtered_data["HR(bpm)"]):
|
||||||
ax2.text(
|
ax2.text(
|
||||||
i,
|
i,
|
||||||
@@ -205,7 +243,6 @@ class GraphGenerator:
|
|||||||
color="red",
|
color="red",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set x-axis formatting
|
|
||||||
ax1.set_xticks(x_positions)
|
ax1.set_xticks(x_positions)
|
||||||
ax1.set_xticklabels(stage_labels, fontsize=11)
|
ax1.set_xticklabels(stage_labels, fontsize=11)
|
||||||
|
|
||||||
@@ -221,11 +258,9 @@ class GraphGenerator:
|
|||||||
shadow=True,
|
shadow=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add grid
|
|
||||||
ax1.grid(True, alpha=0.3, linestyle="-", linewidth=0.5)
|
ax1.grid(True, alpha=0.3, linestyle="-", linewidth=0.5)
|
||||||
ax1.set_axisbelow(True)
|
ax1.set_axisbelow(True)
|
||||||
|
|
||||||
# Adjust layout
|
|
||||||
plt.tight_layout()
|
plt.tight_layout()
|
||||||
plt.subplots_adjust(bottom=0.1, top=0.9)
|
plt.subplots_adjust(bottom=0.1, top=0.9)
|
||||||
|
|
||||||
@@ -236,9 +271,18 @@ class GraphGenerator:
|
|||||||
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||||
|
|
||||||
def generate_vo2_pulse_chart(
|
def generate_vo2_pulse_chart(
|
||||||
self, df: pd.DataFrame, save_as_base64: bool = False
|
self, df: pd.DataFrame, save_as_base64: bool = True
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate VO2 Pulse chart with heart rate and speed"""
|
"""
|
||||||
|
Generate VO2 Pulse chart with HR and Speed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
df: Processed DataFrame with smoothed columns
|
||||||
|
save_as_base64: If True, return base64 string, else return file path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64 string or file path
|
||||||
|
"""
|
||||||
first_unique_phase = df.drop_duplicates(subset="PHASE")
|
first_unique_phase = df.drop_duplicates(subset="PHASE")
|
||||||
phase_times = first_unique_phase["T(sec)"].tolist()
|
phase_times = first_unique_phase["T(sec)"].tolist()
|
||||||
|
|
||||||
@@ -292,12 +336,14 @@ class GraphGenerator:
|
|||||||
|
|
||||||
ax1.set_xticks(np.arange(0, df["T(sec)"].max() + 200, 200))
|
ax1.set_xticks(np.arange(0, df["T(sec)"].max() + 200, 200))
|
||||||
|
|
||||||
# Remove default legends first
|
# Combine legends
|
||||||
for ax in [ax1, ax2, ax3]:
|
if ax1.get_legend():
|
||||||
if ax.get_legend():
|
ax1.get_legend().remove()
|
||||||
ax.get_legend().remove()
|
if ax2.get_legend():
|
||||||
|
ax2.get_legend().remove()
|
||||||
|
if ax3.get_legend():
|
||||||
|
ax3.get_legend().remove()
|
||||||
|
|
||||||
# Combine legends from all axes
|
|
||||||
lines1, labels1 = ax1.get_legend_handles_labels()
|
lines1, labels1 = ax1.get_legend_handles_labels()
|
||||||
lines2, labels2 = ax2.get_legend_handles_labels()
|
lines2, labels2 = ax2.get_legend_handles_labels()
|
||||||
lines3, labels3 = ax3.get_legend_handles_labels()
|
lines3, labels3 = ax3.get_legend_handles_labels()
|
||||||
@@ -319,16 +365,24 @@ class GraphGenerator:
|
|||||||
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||||
|
|
||||||
def generate_vo2_breath_chart(
|
def generate_vo2_breath_chart(
|
||||||
self, df: pd.DataFrame, save_as_base64: bool = False
|
self, df: pd.DataFrame, save_as_base64: bool = True
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate VO2 per Breath chart"""
|
"""
|
||||||
|
Generate VO2 per Breath chart.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
df: Processed DataFrame with smoothed columns
|
||||||
|
save_as_base64: If True, return base64 string, else return file path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64 string or file path
|
||||||
|
"""
|
||||||
first_unique_phase = df.drop_duplicates(subset="PHASE")
|
first_unique_phase = df.drop_duplicates(subset="PHASE")
|
||||||
phase_times = first_unique_phase["T(sec)"].tolist()
|
phase_times = first_unique_phase["T(sec)"].tolist()
|
||||||
|
|
||||||
plt.figure(figsize=(18, 5))
|
plt.figure(figsize=(18, 5))
|
||||||
ax1 = plt.subplot()
|
ax1 = plt.subplot()
|
||||||
|
|
||||||
# Plot VO2 per Breath
|
|
||||||
sns.lineplot(
|
sns.lineplot(
|
||||||
data=df,
|
data=df,
|
||||||
x="T(sec)",
|
x="T(sec)",
|
||||||
@@ -340,7 +394,7 @@ class GraphGenerator:
|
|||||||
ax1.set_ylim(0, df["VO2 Breath_smoothed"].max() + 1)
|
ax1.set_ylim(0, df["VO2 Breath_smoothed"].max() + 1)
|
||||||
ax1.grid(True, alpha=0.1)
|
ax1.grid(True, alpha=0.1)
|
||||||
|
|
||||||
# Plot speed as step function on secondary y-axis
|
# Plot speed on secondary y-axis
|
||||||
ax2 = ax1.twinx()
|
ax2 = ax1.twinx()
|
||||||
ax1.set_xticks(np.arange(0, df["T(sec)"].max() + 200, 200))
|
ax1.set_xticks(np.arange(0, df["T(sec)"].max() + 200, 200))
|
||||||
sns.lineplot(
|
sns.lineplot(
|
||||||
@@ -356,11 +410,9 @@ class GraphGenerator:
|
|||||||
ax2.set_ylim(0, df["Speed"].max() + 1)
|
ax2.set_ylim(0, df["Speed"].max() + 1)
|
||||||
ax2.set_ylabel("Speed")
|
ax2.set_ylabel("Speed")
|
||||||
|
|
||||||
# Remove default legends first
|
# Combine legends
|
||||||
ax1.get_legend().remove()
|
ax1.get_legend().remove()
|
||||||
ax2.get_legend().remove()
|
ax2.get_legend().remove()
|
||||||
|
|
||||||
# Combine legends from both axes in the top left
|
|
||||||
lines1, labels1 = ax1.get_legend_handles_labels()
|
lines1, labels1 = ax1.get_legend_handles_labels()
|
||||||
lines2, labels2 = ax2.get_legend_handles_labels()
|
lines2, labels2 = ax2.get_legend_handles_labels()
|
||||||
ax1.legend(lines1 + lines2, labels1 + labels2, loc="upper left")
|
ax1.legend(lines1 + lines2, labels1 + labels2, loc="upper left")
|
||||||
@@ -379,9 +431,18 @@ class GraphGenerator:
|
|||||||
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||||
|
|
||||||
def generate_fat_metabolism_chart(
|
def generate_fat_metabolism_chart(
|
||||||
self, df: pd.DataFrame, save_as_base64: bool = False
|
self, df: pd.DataFrame, save_as_base64: bool = True
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate CHO and FAT metabolism chart"""
|
"""
|
||||||
|
Generate fat metabolism chart (CHO vs FAT over time).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
df: Processed DataFrame with smoothed columns
|
||||||
|
save_as_base64: If True, return base64 string, else return file path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64 string or file path
|
||||||
|
"""
|
||||||
first_unique_phase = df.drop_duplicates(subset="PHASE")
|
first_unique_phase = df.drop_duplicates(subset="PHASE")
|
||||||
phase_times = first_unique_phase["T(sec)"].tolist()
|
phase_times = first_unique_phase["T(sec)"].tolist()
|
||||||
|
|
||||||
@@ -391,7 +452,7 @@ class GraphGenerator:
|
|||||||
# Plot CHO
|
# Plot CHO
|
||||||
sns.lineplot(data=df, x="T(sec)", y="CHO_smoothed", label="CHO (kcal/min)")
|
sns.lineplot(data=df, x="T(sec)", y="CHO_smoothed", label="CHO (kcal/min)")
|
||||||
ax1.set_xlabel("Time (sec)")
|
ax1.set_xlabel("Time (sec)")
|
||||||
ax1.set_ylabel("CHO (kcal/min)")
|
ax1.set_ylabel("CHO (g/min)")
|
||||||
ax1.grid(True, alpha=0.1)
|
ax1.grid(True, alpha=0.1)
|
||||||
|
|
||||||
# Plot FAT on secondary y-axis
|
# Plot FAT on secondary y-axis
|
||||||
@@ -408,11 +469,9 @@ class GraphGenerator:
|
|||||||
ax2.set_ylabel("FAT (kcal/min)")
|
ax2.set_ylabel("FAT (kcal/min)")
|
||||||
ax2.set_ylim(0, 15)
|
ax2.set_ylim(0, 15)
|
||||||
|
|
||||||
# Remove default legends first
|
# Combine legends
|
||||||
ax1.get_legend().remove()
|
ax1.get_legend().remove()
|
||||||
ax2.get_legend().remove()
|
ax2.get_legend().remove()
|
||||||
|
|
||||||
# Combine legends from both axes in the top left
|
|
||||||
lines1, labels1 = ax1.get_legend_handles_labels()
|
lines1, labels1 = ax1.get_legend_handles_labels()
|
||||||
lines2, labels2 = ax2.get_legend_handles_labels()
|
lines2, labels2 = ax2.get_legend_handles_labels()
|
||||||
ax1.legend(lines1 + lines2, labels1 + labels2, loc="upper left")
|
ax1.legend(lines1 + lines2, labels1 + labels2, loc="upper left")
|
||||||
@@ -431,9 +490,18 @@ class GraphGenerator:
|
|||||||
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||||
|
|
||||||
def generate_recovery_chart(
|
def generate_recovery_chart(
|
||||||
self, df: pd.DataFrame, save_as_base64: bool = False
|
self, df: pd.DataFrame, save_as_base64: bool = True
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate recovery chart with VCO2, HR, and BF"""
|
"""
|
||||||
|
Generate recovery chart (VCO2, HR, and BF).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
df: Processed DataFrame with smoothed columns
|
||||||
|
save_as_base64: If True, return base64 string, else return file path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64 string or file path
|
||||||
|
"""
|
||||||
first_unique_phase = df.drop_duplicates(subset="PHASE")
|
first_unique_phase = df.drop_duplicates(subset="PHASE")
|
||||||
phase_times = first_unique_phase["T(sec)"].tolist()
|
phase_times = first_unique_phase["T(sec)"].tolist()
|
||||||
|
|
||||||
@@ -449,7 +517,7 @@ class GraphGenerator:
|
|||||||
color="blue",
|
color="blue",
|
||||||
)
|
)
|
||||||
ax1.set_xlabel("Time (sec)")
|
ax1.set_xlabel("Time (sec)")
|
||||||
ax1.set_ylabel("VCO2 (ml/min)")
|
ax1.set_ylabel("VO2 Pulse (mL/beat)")
|
||||||
ax1.set_ylim(0, df["VCO2(ml/min)"].max())
|
ax1.set_ylim(0, df["VCO2(ml/min)"].max())
|
||||||
ax1.grid(True, alpha=0.1)
|
ax1.grid(True, alpha=0.1)
|
||||||
|
|
||||||
@@ -468,7 +536,7 @@ class GraphGenerator:
|
|||||||
ax2.set_ylim(df["HR(bpm)_smoothed"].min(), df["HR(bpm)_smoothed"].max() + 1)
|
ax2.set_ylim(df["HR(bpm)_smoothed"].min(), df["HR(bpm)_smoothed"].max() + 1)
|
||||||
ax2.tick_params(axis="y", labelcolor="red")
|
ax2.tick_params(axis="y", labelcolor="red")
|
||||||
|
|
||||||
# Create third y-axis for breathing frequency
|
# Create third y-axis for BF
|
||||||
ax3 = ax1.twinx()
|
ax3 = ax1.twinx()
|
||||||
ax3.spines["right"].set_position(("outward", 60))
|
ax3.spines["right"].set_position(("outward", 60))
|
||||||
sns.lineplot(
|
sns.lineplot(
|
||||||
@@ -485,12 +553,14 @@ class GraphGenerator:
|
|||||||
ax3.set_ylim(0, df["BF(bpm)_smoothed"].max() + 1)
|
ax3.set_ylim(0, df["BF(bpm)_smoothed"].max() + 1)
|
||||||
ax1.set_xticks(np.arange(0, df["T(sec)"].max() + 200, 200))
|
ax1.set_xticks(np.arange(0, df["T(sec)"].max() + 200, 200))
|
||||||
|
|
||||||
# Remove default legends first
|
# Combine legends
|
||||||
for ax in [ax1, ax2, ax3]:
|
if ax1.get_legend():
|
||||||
if ax.get_legend():
|
ax1.get_legend().remove()
|
||||||
ax.get_legend().remove()
|
if ax2.get_legend():
|
||||||
|
ax2.get_legend().remove()
|
||||||
|
if ax3.get_legend():
|
||||||
|
ax3.get_legend().remove()
|
||||||
|
|
||||||
# Combine legends from all axes in the top left
|
|
||||||
lines1, labels1 = ax1.get_legend_handles_labels()
|
lines1, labels1 = ax1.get_legend_handles_labels()
|
||||||
lines2, labels2 = ax2.get_legend_handles_labels()
|
lines2, labels2 = ax2.get_legend_handles_labels()
|
||||||
lines3, labels3 = ax3.get_legend_handles_labels()
|
lines3, labels3 = ax3.get_legend_handles_labels()
|
||||||
@@ -511,129 +581,41 @@ class GraphGenerator:
|
|||||||
|
|
||||||
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
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(
|
def generate_body_composition_chart(
|
||||||
self, fat_mass_lbs: float, lean_mass_lbs: float, save_as_base64: bool = False
|
self, fat_mass_lbs: float, lean_mass_lbs: float, save_as_base64: bool = True
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate donut chart for body composition"""
|
"""
|
||||||
|
Generate body composition donut chart.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fat_mass_lbs: Fat mass in pounds
|
||||||
|
lean_mass_lbs: Lean mass in pounds
|
||||||
|
save_as_base64: If True, return base64 string, else return file path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64 string or file path
|
||||||
|
"""
|
||||||
# Calculate percentages
|
# Calculate percentages
|
||||||
total_weight = fat_mass_lbs + lean_mass_lbs
|
total_weight = fat_mass_lbs + lean_mass_lbs
|
||||||
fat_percentage = (fat_mass_lbs / total_weight) * 100
|
fat_percentage = (fat_mass_lbs / total_weight) * 100
|
||||||
lean_percentage = (lean_mass_lbs / total_weight) * 100
|
lean_percentage = (lean_mass_lbs / total_weight) * 100
|
||||||
|
|
||||||
# Data for the chart
|
|
||||||
sizes = [fat_percentage, lean_percentage]
|
sizes = [fat_percentage, lean_percentage]
|
||||||
colors = ["#fde3ac", "#ff9966"] # Light yellow/tan and orange
|
colors = ["#fde3ac", "#ff9966"]
|
||||||
|
|
||||||
plt.figure(figsize=(8, 8))
|
plt.figure(figsize=(8, 8))
|
||||||
|
|
||||||
# Create the donut chart without labels first
|
# Create donut chart
|
||||||
wedges, texts, autotexts = plt.pie(
|
plt.pie(
|
||||||
sizes,
|
sizes,
|
||||||
autopct="", # Remove auto percentages
|
autopct="",
|
||||||
startangle=90,
|
startangle=90,
|
||||||
wedgeprops=dict(width=0.5, edgecolor="w"),
|
wedgeprops=dict(width=0.5, edgecolor="w"),
|
||||||
colors=colors,
|
colors=colors,
|
||||||
labels=["", ""],
|
labels=["", ""],
|
||||||
) # Remove default labels
|
)
|
||||||
|
|
||||||
# Add custom text annotations positioned manually
|
# Add custom text annotations
|
||||||
plt.text(
|
plt.text(
|
||||||
-1,
|
-1,
|
||||||
1,
|
1,
|
||||||
@@ -656,8 +638,7 @@ class GraphGenerator:
|
|||||||
bbox=dict(boxstyle="round,pad=0.3", facecolor="white", alpha=0.8),
|
bbox=dict(boxstyle="round,pad=0.3", facecolor="white", alpha=0.8),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set the title
|
plt.axis("equal")
|
||||||
plt.axis("equal") # Equal aspect ratio ensures that pie is drawn as a circle
|
|
||||||
|
|
||||||
chart_path = self.charts_dir / "body_composition_chart.png"
|
chart_path = self.charts_dir / "body_composition_chart.png"
|
||||||
plt.savefig(chart_path, bbox_inches="tight", dpi=600)
|
plt.savefig(chart_path, bbox_inches="tight", dpi=600)
|
||||||
@@ -665,16 +646,142 @@ class GraphGenerator:
|
|||||||
|
|
||||||
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||||
|
|
||||||
def generate_spirometry_chart(
|
def generate_body_fat_percent_chart(
|
||||||
self, spirometry_df: pd.DataFrame, save_as_base64: bool = False
|
self,
|
||||||
|
fat_percentage: float,
|
||||||
|
age: int,
|
||||||
|
gender: str,
|
||||||
|
save_as_base64: bool = True,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate spirometry chart with Z-scores and ranges"""
|
"""
|
||||||
|
Generate body fat percentage chart.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fat_percentage: Body fat percentage
|
||||||
|
age: Patient age
|
||||||
|
gender: Patient gender ('male' or 'female')
|
||||||
|
save_as_base64: If True, return base64 string, else return file path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64 string or file path
|
||||||
|
"""
|
||||||
|
# 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 = "20-39" # Default
|
||||||
|
|
||||||
|
demographic = f"{age_group}\n({gender[0].upper()})"
|
||||||
|
|
||||||
|
# Define segments based on gender (female example)
|
||||||
|
if gender.lower() == "female":
|
||||||
|
segments = [
|
||||||
|
("#F8A8A8", 0, 15), # Muted Red: 0% to 15%
|
||||||
|
("#FFEECC", 15, 5), # Pale Yellow: 15% to 20%
|
||||||
|
("#D0F0C0", 20, 15), # Pale Green: 20% to 35%
|
||||||
|
("#FFEECC", 35, 5), # Pale Yellow: 35% to 40%
|
||||||
|
("#F8A8A8", 40, 10), # Muted Red: 40% to 50%
|
||||||
|
]
|
||||||
|
else: # male
|
||||||
|
segments = [
|
||||||
|
("#F8A8A8", 0, 5), # Muted Red: 0% to 5%
|
||||||
|
("#FFEECC", 5, 5), # Pale Yellow: 5% to 10%
|
||||||
|
("#D0F0C0", 10, 10), # Pale Green: 10% to 20%
|
||||||
|
("#FFEECC", 20, 5), # Pale Yellow: 20% to 25%
|
||||||
|
("#F8A8A8", 25, 25), # Muted Red: 25% to 50%
|
||||||
|
]
|
||||||
|
|
||||||
|
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(
|
||||||
|
fat_percentage,
|
||||||
|
1.05,
|
||||||
|
marker="v",
|
||||||
|
color="black",
|
||||||
|
markersize=10,
|
||||||
|
clip_on=False,
|
||||||
|
transform=ax.get_xaxis_transform(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set axis properties
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
ticks = range(0, 51, 5)
|
||||||
|
ax.set_xticks(ticks)
|
||||||
|
labels = [f"{t}%" for t in ticks]
|
||||||
|
ax.set_xticklabels(labels)
|
||||||
|
|
||||||
|
# Clean up spines
|
||||||
|
ax.spines["right"].set_visible(False)
|
||||||
|
ax.spines["top"].set_visible(False)
|
||||||
|
ax.spines["left"].set_visible(False)
|
||||||
|
ax.spines["bottom"].set_visible(True)
|
||||||
|
|
||||||
|
# Add tick marks
|
||||||
|
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_percent_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_spirometry_chart(
|
||||||
|
self, spirometry_df: pd.DataFrame, save_as_base64: bool = True
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate spirometry chart with Z-scores.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
spirometry_df: Spirometry DataFrame with parameters
|
||||||
|
save_as_base64: If True, return base64 string, else return file path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64 string or file path
|
||||||
|
"""
|
||||||
# Coerce numeric columns
|
# Coerce numeric columns
|
||||||
for col in ["Best", "LLN", "Pred.", "%Pred.", "ZScore"]:
|
for col in ["Best", "LLN", "Pred.", "%Pred.", "ZScore"]:
|
||||||
if col in spirometry_df.columns:
|
if col in spirometry_df.columns:
|
||||||
spirometry_df[col] = pd.to_numeric(spirometry_df[col], errors="coerce")
|
spirometry_df[col] = pd.to_numeric(spirometry_df[col], errors="coerce")
|
||||||
|
|
||||||
# Select rows of interest and prepare display values
|
# Select rows of interest
|
||||||
rows_map = {
|
rows_map = {
|
||||||
"Lung Volume": "FVC",
|
"Lung Volume": "FVC",
|
||||||
"Lung Power": "FEV1",
|
"Lung Power": "FEV1",
|
||||||
@@ -707,7 +814,7 @@ class GraphGenerator:
|
|||||||
)
|
)
|
||||||
|
|
||||||
x_min, x_max = -5, 3
|
x_min, x_max = -5, 3
|
||||||
# Segment colors: red -> orange -> yellow -> green
|
# Segment colors
|
||||||
segments = [
|
segments = [
|
||||||
(-5, -4, "#f4a7a7"), # red-ish
|
(-5, -4, "#f4a7a7"), # red-ish
|
||||||
(-4, -3, "#f7c49a"), # orange-ish
|
(-4, -3, "#f7c49a"), # orange-ish
|
||||||
@@ -726,10 +833,10 @@ class GraphGenerator:
|
|||||||
0, width=b - a, left=a, height=0.6, color=color, edgecolor="none"
|
0, width=b - a, left=a, height=0.6, color=color, edgecolor="none"
|
||||||
)
|
)
|
||||||
|
|
||||||
# LLN (-1) and Predicted (0) markers
|
# LLN and Predicted markers
|
||||||
ax.axvline(0, color="black", lw=1)
|
ax.axvline(0, color="black", lw=1)
|
||||||
|
|
||||||
# Z-score pointer (downward triangle) at top of each panel
|
# Z-score pointer
|
||||||
if pd.notna(rec["z"]):
|
if pd.notna(rec["z"]):
|
||||||
trans = mtransforms.blended_transform_factory(
|
trans = mtransforms.blended_transform_factory(
|
||||||
ax.transData, ax.transAxes
|
ax.transData, ax.transAxes
|
||||||
@@ -744,7 +851,7 @@ class GraphGenerator:
|
|||||||
clip_on=False,
|
clip_on=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Labels, ticks, and styling
|
# Labels and styling
|
||||||
ax.set_title(
|
ax.set_title(
|
||||||
rec["label"], loc="left", fontsize=11, fontweight="bold", pad=2
|
rec["label"], loc="left", fontsize=11, fontweight="bold", pad=2
|
||||||
)
|
)
|
||||||
@@ -760,15 +867,11 @@ class GraphGenerator:
|
|||||||
|
|
||||||
# Right-side summary boxes
|
# Right-side summary boxes
|
||||||
fig.subplots_adjust(right=0.78)
|
fig.subplots_adjust(right=0.78)
|
||||||
box_ax = fig.add_axes(
|
box_ax = fig.add_axes([0.805, 0.06, 0.18, 0.90])
|
||||||
[0.805, 0.06, 0.18, 0.90]
|
|
||||||
) # [left, bottom, width, height]
|
|
||||||
box_ax.axis("off")
|
box_ax.axis("off")
|
||||||
|
|
||||||
# Helper to draw a pill-shaped text box
|
|
||||||
def pill(ax, xy, text):
|
def pill(ax, xy, text):
|
||||||
x, y = xy
|
x, y = xy
|
||||||
# Draw rounded rectangle background
|
|
||||||
bbox = FancyBboxPatch(
|
bbox = FancyBboxPatch(
|
||||||
(x - 0.48, y - 0.09),
|
(x - 0.48, y - 0.09),
|
||||||
0.96,
|
0.96,
|
||||||
@@ -801,7 +904,7 @@ class GraphGenerator:
|
|||||||
box_ax.set_xlim(0, 1)
|
box_ax.set_xlim(0, 1)
|
||||||
box_ax.set_ylim(0, 1)
|
box_ax.set_ylim(0, 1)
|
||||||
|
|
||||||
# Prepare display strings and positions (top to bottom)
|
# Prepare display strings
|
||||||
right_items = []
|
right_items = []
|
||||||
for rec in records:
|
for rec in records:
|
||||||
name = (
|
name = (
|
||||||
@@ -814,7 +917,7 @@ class GraphGenerator:
|
|||||||
pct_fmt = f"{rec['pct']:.1f}%"
|
pct_fmt = f"{rec['pct']:.1f}%"
|
||||||
right_items.append((name, value_fmt, pct_fmt))
|
right_items.append((name, value_fmt, pct_fmt))
|
||||||
|
|
||||||
# Sort to match image order on the right (FVC, FEV1, FEV1/FVC)
|
# Sort to match order
|
||||||
order = ["FVC", "FEV1", "FEV1/FVC"]
|
order = ["FVC", "FEV1", "FEV1/FVC"]
|
||||||
right_items_sorted = [
|
right_items_sorted = [
|
||||||
next(item for item in right_items if item[0] == k) for k in order
|
next(item for item in right_items if item[0] == k) for k in order
|
||||||
@@ -830,113 +933,3 @@ class GraphGenerator:
|
|||||||
plt.close()
|
plt.close()
|
||||||
|
|
||||||
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
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}")
|
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
import datetime
|
|
||||||
import pandas as pd
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
class PageGenerator:
|
|
||||||
def __init__(self, pnoe_df: pd.DataFrame, seca_df: pd.DataFrame, spirometry_df: pd.DataFrame, patient_info: dict):
|
|
||||||
self.pnoe_df = pnoe_df
|
|
||||||
self.seca_df = seca_df
|
|
||||||
self.spirometry_df = spirometry_df
|
|
||||||
self.patient_info = patient_info
|
|
||||||
|
|
||||||
|
|
||||||
def page_1_context(self):
|
|
||||||
# Extract patient information
|
|
||||||
patient_name = self.patient_info.get("patient_name", "N/A")
|
|
||||||
context = {
|
|
||||||
"first_name": patient_name.split()[0] if patient_name else "N/A",
|
|
||||||
"surname": patient_name.split()[-1] if patient_name else "N/A",
|
|
||||||
"date": datetime.datetime.now().strftime("%B %d, %Y"),
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def page_3_context(self):
|
|
||||||
# Example: Extract detailed PNOE data for page 3
|
|
||||||
pnoe_details = self.pnoe_df.head(10).to_dict(orient="records")
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"pnoe_details": pnoe_details,
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def page_4_context(self):
|
|
||||||
# Example: Extract detailed Spirometry data for page 4
|
|
||||||
# Filter df_2 for Keirstyn Moran
|
|
||||||
patient_data = self.patient_info[self.patient_info['LastName'].str.contains('Moran', case=False, na=False)]
|
|
||||||
fat_percentage = patient_data['Adult_FMP'].iloc[0]
|
|
||||||
weight_kg = patient_data['Weight'].iloc[0]
|
|
||||||
lean_percentage = 100 - fat_percentage
|
|
||||||
|
|
||||||
fat_mass_lbs = weight_kg * (fat_percentage / 100) * 2.20462
|
|
||||||
lean_mass_lbs = weight_kg * (lean_percentage / 100) * 2.20462
|
|
||||||
|
|
||||||
total_weight = fat_mass_lbs + lean_mass_lbs
|
|
||||||
fat_percentage = (fat_mass_lbs / total_weight) * 100
|
|
||||||
lean_percentage = (lean_mass_lbs / total_weight) * 100
|
|
||||||
|
|
||||||
sizes = [fat_percentage, lean_percentage]
|
|
||||||
colors = ['#fde3ac', '#ff9966'] # Light yellow/tan and orange from the image
|
|
||||||
|
|
||||||
plt.figure(figsize=(8, 8))
|
|
||||||
|
|
||||||
wedges, texts, autotexts = plt.pie(sizes, autopct='', startangle=90, wedgeprops=dict(width=0.5, edgecolor='w'), colors=colors, labels=['', '']) # Remove default labels
|
|
||||||
|
|
||||||
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))
|
|
||||||
|
|
||||||
plt.axis('equal') # Equal aspect ratio ensures that pie is drawn as a circle
|
|
||||||
spirometry_details = self.spirometry_df.head(10).to_dict(orient="records")
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"spirometry_details": spirometry_details,
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def page_5_context(self):
|
|
||||||
# Example: Summary and conclusions for page 5
|
|
||||||
summary = {
|
|
||||||
"pnoe_summary": self.pnoe_df.describe().to_dict(),
|
|
||||||
"seca_summary": self.seca_df.describe().to_dict(),
|
|
||||||
"spirometry_summary": self.spirometry_df.describe().to_dict(),
|
|
||||||
}
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"summary": summary,
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def page_6_context(self):
|
|
||||||
# Example: Recommendations based on data for page 6
|
|
||||||
recommendations = [
|
|
||||||
"Increase cardiovascular training.",
|
|
||||||
"Incorporate strength training twice a week.",
|
|
||||||
"Monitor respiratory function regularly.",
|
|
||||||
]
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"recommendations": recommendations,
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def page_7_context(self):
|
|
||||||
# Example: Additional notes or references for page 7
|
|
||||||
notes = "This report is generated based on the provided data. Consult a healthcare professional for personalized advice."
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"notes": notes,
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def page_8_context(self):
|
|
||||||
# Example: Contact information and disclaimers for page 8
|
|
||||||
contact_info = {
|
|
||||||
"clinic_name": "Health Clinic",
|
|
||||||
"address": "123 Wellness St, Healthy City, HC 12345",
|
|
||||||
"phone": "(123) 456-7890",
|
|
||||||
"email": "contact@healthclinic.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"contact_info": contact_info,
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def page_9_context(self):
|
|
||||||
# Example: Graphs and visualizations for page 9
|
|
||||||
graphs = [
|
|
||||||
"graph1.png",
|
|
||||||
"graph2.png",
|
|
||||||
"graph3.png"
|
|
||||||
]
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"graphs": graphs,
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def page_10_context(self):
|
|
||||||
# Example: Final remarks and next steps for page 10
|
|
||||||
final_remarks = "Thank you for using our health assessment services. We look forward to assisting you in your health journey."
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"final_remarks": final_remarks,
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def page_11_context(self):
|
|
||||||
# Example: Additional resources and references for page 11
|
|
||||||
resources = [
|
|
||||||
{"title": "Healthy Living Guide", "link": "http://example.com/healthy-living"},
|
|
||||||
{"title": "Exercise Tips", "link": "http://example.com/exercise-tips"},
|
|
||||||
{"title": "Nutrition Advice", "link": "http://example.com/nutrition-advice"}
|
|
||||||
]
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"resources": resources,
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def page_12_context(self):
|
|
||||||
# Example: Feedback and survey information for page 12
|
|
||||||
feedback_info = "We value your feedback. Please visit http://example.com/feedback to share your experience."
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"feedback_info": feedback_info,
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def page_13_context(self):
|
|
||||||
# Example: Acknowledgments and credits for page 13
|
|
||||||
acknowledgments = "This report was generated using data analysis techniques and the expertise of our medical team."
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"acknowledgments": acknowledgments,
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def page_18_context(self):
|
|
||||||
# Example: Legal disclaimers and terms for page 18
|
|
||||||
legal_disclaimer = "This report is for informational purposes only and does not constitute medical advice."
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"legal_disclaimer": legal_disclaimer,
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
@@ -12,8 +12,9 @@ import pandas as pd
|
|||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
from playwright.sync_api import sync_playwright
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
from app.services.context import context_list
|
from app.services.context_generator import ContextGenerator
|
||||||
from app.services.graph_generator import GraphGenerator
|
from app.services.graph_generator import GraphGenerator
|
||||||
|
from app.services.spirometry_table_extractor import extract_spirometry_table_from_pdf
|
||||||
|
|
||||||
|
|
||||||
class ReportGeneratorService:
|
class ReportGeneratorService:
|
||||||
@@ -24,6 +25,7 @@ class ReportGeneratorService:
|
|||||||
template_dir: str = "app/report_gen",
|
template_dir: str = "app/report_gen",
|
||||||
graphs_dir: str = "graphs",
|
graphs_dir: str = "graphs",
|
||||||
reports_dir: str = "reports",
|
reports_dir: str = "reports",
|
||||||
|
data_dir: str = "data",
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize the report generator service.
|
Initialize the report generator service.
|
||||||
@@ -32,16 +34,20 @@ class ReportGeneratorService:
|
|||||||
template_dir: Directory containing Jinja2 templates
|
template_dir: Directory containing Jinja2 templates
|
||||||
graphs_dir: Directory to save generated graphs
|
graphs_dir: Directory to save generated graphs
|
||||||
reports_dir: Directory to save generated reports
|
reports_dir: Directory to save generated reports
|
||||||
|
data_dir: Directory to store extracted/processed data
|
||||||
"""
|
"""
|
||||||
self.template_dir = template_dir
|
self.template_dir = template_dir
|
||||||
self.graphs_dir = Path(graphs_dir)
|
self.graphs_dir = Path(graphs_dir)
|
||||||
self.reports_dir = Path(reports_dir)
|
self.reports_dir = Path(reports_dir)
|
||||||
self.graph_generator = GraphGenerator(charts_dir=str(graphs_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))
|
self.env = Environment(loader=FileSystemLoader(template_dir))
|
||||||
|
|
||||||
# Ensure directories exist
|
# Ensure directories exist
|
||||||
self.graphs_dir.mkdir(exist_ok=True)
|
self.graphs_dir.mkdir(exist_ok=True)
|
||||||
self.reports_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:
|
def process_pnoe_data(self, pnoe_csv_path: str) -> pd.DataFrame:
|
||||||
"""
|
"""
|
||||||
@@ -139,13 +145,16 @@ class ReportGeneratorService:
|
|||||||
else 0,
|
else 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
def generate_html(self, patient_info: Dict[str, Any]) -> str:
|
def generate_html(
|
||||||
|
self, patient_info: Dict[str, Any], context_list: List[Dict[str, Any]]
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Generate HTML content for the report.
|
Generate HTML content for the report.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
patient_info: Dictionary containing patient information
|
patient_info: Dictionary containing patient information
|
||||||
(patient_name, age, height, weight, focus)
|
(patient_name, age, height, weight, focus)
|
||||||
|
context_list: List of context dictionaries for each page
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Complete HTML document as string
|
Complete HTML document as string
|
||||||
@@ -277,35 +286,112 @@ class ReportGeneratorService:
|
|||||||
"""
|
"""
|
||||||
Generate complete medical report from uploaded files.
|
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:
|
Args:
|
||||||
spirometry_pdf_path: Path to Spirometry PDF file
|
spirometry_pdf_path: Path to Spirometry PDF file
|
||||||
pnoe_csv_path: Path to Pnoe CSV file
|
pnoe_csv_path: Path to Pnoe CSV file
|
||||||
seca_excel_path: Path to SECA Excel file
|
seca_excel_path: Path to SECA Excel file
|
||||||
patient_info: Dictionary containing patient information
|
patient_info: Dictionary containing patient information
|
||||||
output_filename: Optional custom output filename
|
output_filename: Optional custom output filename
|
||||||
n
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary containing report path, graphs generated, and analysis data
|
Dictionary containing report path, graphs generated, and analysis data
|
||||||
"""
|
"""
|
||||||
# Process data
|
# Step 1: Extract spirometry table from PDF
|
||||||
|
spirometry_csv_path = self.data_dir / "extracted_spirometry_table.csv"
|
||||||
|
extract_spirometry_table_from_pdf(spirometry_pdf_path)
|
||||||
|
|
||||||
|
# The extraction saves to current directory, move it to data_dir
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
if Path("extracted_spirometry_table.csv").exists():
|
||||||
|
shutil.move("extracted_spirometry_table.csv", spirometry_csv_path)
|
||||||
|
|
||||||
|
# Step 2: Process Pnoe data
|
||||||
df = self.process_pnoe_data(pnoe_csv_path)
|
df = self.process_pnoe_data(pnoe_csv_path)
|
||||||
|
|
||||||
# Generate graphs
|
# Step 3: Generate all graphs
|
||||||
graphs_generated = self.generate_graphs(df)
|
graphs_generated = self.generate_graphs(df)
|
||||||
|
|
||||||
# Calculate analysis metrics
|
# Create graph dictionary with base64 encoded images
|
||||||
|
graphs_dict = {}
|
||||||
|
for graph in graphs_generated:
|
||||||
|
# Read the graph file and convert to base64
|
||||||
|
graph_path = Path(graph["path"])
|
||||||
|
if graph_path.exists():
|
||||||
|
import base64
|
||||||
|
|
||||||
|
with open(graph_path, "rb") as f:
|
||||||
|
graphs_dict[graph["name"]] = base64.b64encode(f.read()).decode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Also generate body composition charts
|
||||||
|
# Extract patient data for these charts
|
||||||
|
patient_name = patient_info.get("patient_name", "").split()[-1] # Get last name
|
||||||
|
|
||||||
|
# Load SECA data to get body composition info
|
||||||
|
seca_df = pd.read_excel(seca_excel_path)
|
||||||
|
patient_data = seca_df[
|
||||||
|
seca_df["LastName"].str.contains(patient_name, case=False, na=False)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not patient_data.empty:
|
||||||
|
row = patient_data.iloc[0]
|
||||||
|
weight_kg = float(row.get("Weight", 0))
|
||||||
|
fat_pct = float(row.get("Adult_FMP", 0))
|
||||||
|
age = int(row.get("Age", patient_info.get("age", 25)))
|
||||||
|
gender = row.get("Gender", "female").lower()
|
||||||
|
|
||||||
|
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
|
||||||
|
body_comp_b64 = self.graph_generator.generate_body_composition_chart(
|
||||||
|
fat_mass_lbs, lean_mass_lbs, save_as_base64=True
|
||||||
|
)
|
||||||
|
graphs_dict["body_composition"] = body_comp_b64
|
||||||
|
|
||||||
|
# Generate body fat percent chart
|
||||||
|
body_fat_b64 = self.graph_generator.generate_body_fat_percent_chart(
|
||||||
|
fat_pct, age, gender, save_as_base64=True
|
||||||
|
)
|
||||||
|
graphs_dict["body_fat_percent"] = body_fat_b64
|
||||||
|
|
||||||
|
# Generate spirometry chart
|
||||||
|
spirometry_df = pd.read_csv(spirometry_csv_path)
|
||||||
|
spirometry_chart_b64 = self.graph_generator.generate_spirometry_chart(
|
||||||
|
spirometry_df, save_as_base64=True
|
||||||
|
)
|
||||||
|
graphs_dict["spirometry_chart"] = spirometry_chart_b64
|
||||||
|
|
||||||
|
# Step 4: Generate context for all pages
|
||||||
|
self.context_generator.load_data(
|
||||||
|
pnoe_csv_path, str(spirometry_csv_path), seca_excel_path
|
||||||
|
)
|
||||||
|
context_list = self.context_generator.generate_all_contexts(
|
||||||
|
patient_name, graphs_dict
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 5: Calculate analysis metrics
|
||||||
analysis_data = self.calculate_analysis_metrics(df)
|
analysis_data = self.calculate_analysis_metrics(df)
|
||||||
analysis_data["graphs_count"] = len(graphs_generated)
|
analysis_data["graphs_count"] = len(graphs_generated)
|
||||||
|
|
||||||
# Generate HTML
|
# Step 6: Generate HTML
|
||||||
html_content = self.generate_html(patient_info)
|
html_content = self.generate_html(patient_info, context_list)
|
||||||
|
|
||||||
# Generate PDF
|
# Step 7: Generate PDF
|
||||||
if output_filename is None:
|
if output_filename is None:
|
||||||
patient_name = patient_info.get("patient_name", "Unknown")
|
patient_name_full = patient_info.get("patient_name", "Unknown")
|
||||||
session_id = patient_info.get("session_id", "default")
|
session_id = patient_info.get("session_id", "default")
|
||||||
output_filename = (
|
output_filename = (
|
||||||
f"report_{patient_name.replace(' ', '_')}_{session_id}.pdf"
|
f"report_{patient_name_full.replace(' ', '_')}_{session_id}.pdf"
|
||||||
)
|
)
|
||||||
|
|
||||||
report_path = self.reports_dir / output_filename
|
report_path = self.reports_dir / output_filename
|
||||||
|
|||||||
@@ -1,807 +0,0 @@
|
|||||||
{
|
|
||||||
"cells": [
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 2,
|
|
||||||
"id": "b18c1027",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"name": "stdout",
|
|
||||||
"output_type": "stream",
|
|
||||||
"text": [
|
|
||||||
"{'id': 'gen-1759135172-DIhs7TMuaaVY0h3T2ibV', 'provider': 'Google', 'model': 'google/gemini-2.5-flash-lite', 'object': 'chat.completion', 'created': 1759135172, 'choices': [{'logprobs': None, 'finish_reason': 'stop', 'native_finish_reason': 'STOP', 'index': 0, 'message': {'role': 'assistant', 'content': 'Parameters,Best,LLN,Pred.,%Pred.,ZScore,PRE#1,PRE#2,PRE#3\\nFVC,L,4.24,3.03,3.79,112.0,0.95,4.24,4.17,4.15\\nFEV1,L,3.26,2.53,3.16,103.3,0.28,3.26,3.21,3.14\\nFEV1/FVC%,76.89,72.47,83.78,91.8,-1.05,76.9,77.0,75.7\\nPEF,L/m,684,222,384,178.7,-,444,438,684\\nFEF2575,L/s,2.74,2.15,3.42,80.2,-0.84,2.74,2.68,2.48\\nFEF25,L/s,6.08,-,-,-,6.08,6.0,5.53\\nFEF50,L/s,3.06,-,-,-,3.06,3.1,2.77\\nFEF75,L/s,1.06,0.71,1.41,75.1,-0.72,1.06,1.12,0.94\\nPEFTime,ms,-,-,79,-,79,49,39\\nEvol,mL,-,-,78.0,-,78.0,77.0,197.0\\nFEV6,L,4.22,3.03,3.79,111.4,-,4.22,4.17,4.13', 'refusal': None, 'reasoning': None}}], 'usage': {'prompt_tokens': 1350, 'completion_tokens': 454, 'total_tokens': 1804, 'prompt_tokens_details': {'cached_tokens': 0}, 'completion_tokens_details': {'reasoning_tokens': 0, 'image_tokens': 0}}}\n",
|
|
||||||
"Content saved to extracted_table.csv\n"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"\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",
|
|
||||||
"\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",
|
|
||||||
"\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",
|
|
||||||
"\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",
|
|
||||||
"\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",
|
|
||||||
" \n",
|
|
||||||
" print(f\"Content saved to {output_file}\")\n",
|
|
||||||
"else:\n",
|
|
||||||
" print(\"No content found in response\")"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 12,
|
|
||||||
"id": "56a9d655",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"name": "stdout",
|
|
||||||
"output_type": "stream",
|
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"import pandas as pd\n",
|
|
||||||
"spirometry_df = pd.read_csv(\"extracted_table.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",
|
|
||||||
"\n",
|
|
||||||
"fev1_best = spirometry_df.loc[spirometry_df['Parameters'] == 'FEV1', 'Best'].values[0]\n",
|
|
||||||
"fev1_pred = spirometry_df.loc[spirometry_df['Parameters'] == 'FEV1', '%Pred.'].values[0]\n",
|
|
||||||
"\n",
|
|
||||||
"fev1_fevc_best = spirometry_df.loc[spirometry_df['Parameters'] == 'FEV1/FVC%', 'Best'].values[0]\n",
|
|
||||||
"fev1_fevc_pred = spirometry_df.loc[spirometry_df['Parameters'] == 'FEV1/FVC%', '%Pred.'].values[0]\n",
|
|
||||||
"\n",
|
|
||||||
"print(f\"FVC Best: {fvc_best}, FVC Pred: {fvc_pred}\")\n",
|
|
||||||
"print(f\"FEV1 Best: {fev1_best}, FEV1 Pred: {fev1_pred}\")\n",
|
|
||||||
"print(f\"FEV1/FVC% Best: {fev1_fevc_best}, FEV1/FVC% Pred: {fev1_fevc_pred}\")"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 16,
|
|
||||||
"id": "990f4b4f",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"name": "stdout",
|
|
||||||
"output_type": "stream",
|
|
||||||
"text": [
|
|
||||||
"Peak VT: 2.75\n",
|
|
||||||
"HR at Peak VT: 155.0\n"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"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",
|
|
||||||
"hr = max_vt_row['HR(bpm)']\n",
|
|
||||||
"print(f\"HR at Peak VT: {hr}\")"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 18,
|
|
||||||
"id": "041cbc3d",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"name": "stdout",
|
|
||||||
"output_type": "stream",
|
|
||||||
"text": [
|
|
||||||
"Peak VT: 2.3770000000000002\n",
|
|
||||||
"HR at Peak VT: 171.525\n"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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",
|
|
||||||
" df = df.apply(pd.to_numeric, errors='ignore')\n"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"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",
|
|
||||||
"df['VO2 Breath'] = df['VO2(ml/min)'] / df['BF(bpm)'] # VO2 per Breath in mL/breath\n",
|
|
||||||
"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 = 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",
|
|
||||||
"\n",
|
|
||||||
"# Apply smoothing to each column\n",
|
|
||||||
"for col in columns_to_smooth:\n",
|
|
||||||
" if col in df.columns:\n",
|
|
||||||
" df[f'{col}_smoothed'] = df[col].rolling(window=window_size).mean()\n",
|
|
||||||
" \n",
|
|
||||||
"peak_vt = df['VT(l)_smoothed'].max()\n",
|
|
||||||
"max_vt_row = df.loc[df['VT(l)_smoothed'].idxmax()]\n",
|
|
||||||
"print(f\"Peak VT: {peak_vt}\")\n",
|
|
||||||
"hr = max_vt_row['HR(bpm)_smoothed']\n",
|
|
||||||
"print(f\"HR at Peak VT: {hr}\")"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 20,
|
|
||||||
"id": "de7cadd1",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"name": "stdout",
|
|
||||||
"output_type": "stream",
|
|
||||||
"text": [
|
|
||||||
"Percent FEV: 72.91411042944786\n"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"percent_fev = (peak_vt / fev1_best) * 100\n",
|
|
||||||
"print(f\"Percent FEV: {percent_fev}\")"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 24,
|
|
||||||
"id": "cb972ed3",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"text/html": [
|
|
||||||
"<div>\n",
|
|
||||||
"<style scoped>\n",
|
|
||||||
" .dataframe tbody tr th:only-of-type {\n",
|
|
||||||
" vertical-align: middle;\n",
|
|
||||||
" }\n",
|
|
||||||
"\n",
|
|
||||||
" .dataframe tbody tr th {\n",
|
|
||||||
" vertical-align: top;\n",
|
|
||||||
" }\n",
|
|
||||||
"\n",
|
|
||||||
" .dataframe thead th {\n",
|
|
||||||
" text-align: right;\n",
|
|
||||||
" }\n",
|
|
||||||
"</style>\n",
|
|
||||||
"<table border=\"1\" class=\"dataframe\">\n",
|
|
||||||
" <thead>\n",
|
|
||||||
" <tr style=\"text-align: right;\">\n",
|
|
||||||
" <th></th>\n",
|
|
||||||
" <th>MeasurementDate</th>\n",
|
|
||||||
" <th>Comment</th>\n",
|
|
||||||
" <th>ExternalDeviceId</th>\n",
|
|
||||||
" <th>ExternalPatientId</th>\n",
|
|
||||||
" <th>FirstName</th>\n",
|
|
||||||
" <th>LastName</th>\n",
|
|
||||||
" <th>BirthDate</th>\n",
|
|
||||||
" <th>Age</th>\n",
|
|
||||||
" <th>Ethnicity</th>\n",
|
|
||||||
" <th>Gender</th>\n",
|
|
||||||
" <th>...</th>\n",
|
|
||||||
" <th>Child_XC</th>\n",
|
|
||||||
" <th>Child_XC_Unit</th>\n",
|
|
||||||
" <th>Child_BIVA_ZRh</th>\n",
|
|
||||||
" <th>Child_BIVA_ZXcH</th>\n",
|
|
||||||
" <th>Child_PhA</th>\n",
|
|
||||||
" <th>Child_PhA_Unit</th>\n",
|
|
||||||
" <th>Child_REE_Kcal</th>\n",
|
|
||||||
" <th>Child_REE_MJ</th>\n",
|
|
||||||
" <th>Child_TEE_Kcal</th>\n",
|
|
||||||
" <th>Child_TEE_MJ</th>\n",
|
|
||||||
" </tr>\n",
|
|
||||||
" </thead>\n",
|
|
||||||
" <tbody>\n",
|
|
||||||
" <tr>\n",
|
|
||||||
" <th>13</th>\n",
|
|
||||||
" <td>2025-07-29T18:58:54.0000000Z</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>10000001583275_0055003f5631501320313557</td>\n",
|
|
||||||
" <td>KM6479696509</td>\n",
|
|
||||||
" <td>Keirstyn</td>\n",
|
|
||||||
" <td>Moran</td>\n",
|
|
||||||
" <td>1991-02-01T00:00:00.0000000Z</td>\n",
|
|
||||||
" <td>34</td>\n",
|
|
||||||
" <td>Caucasian</td>\n",
|
|
||||||
" <td>Female</td>\n",
|
|
||||||
" <td>...</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" </tr>\n",
|
|
||||||
" </tbody>\n",
|
|
||||||
"</table>\n",
|
|
||||||
"<p>1 rows × 147 columns</p>\n",
|
|
||||||
"</div>"
|
|
||||||
],
|
|
||||||
"text/plain": [
|
|
||||||
" MeasurementDate Comment \\\n",
|
|
||||||
"13 2025-07-29T18:58:54.0000000Z NaN \n",
|
|
||||||
"\n",
|
|
||||||
" ExternalDeviceId ExternalPatientId FirstName \\\n",
|
|
||||||
"13 10000001583275_0055003f5631501320313557 KM6479696509 Keirstyn \n",
|
|
||||||
"\n",
|
|
||||||
" LastName BirthDate Age Ethnicity Gender ... \\\n",
|
|
||||||
"13 Moran 1991-02-01T00:00:00.0000000Z 34 Caucasian Female ... \n",
|
|
||||||
"\n",
|
|
||||||
" Child_XC Child_XC_Unit Child_BIVA_ZRh Child_BIVA_ZXcH Child_PhA \\\n",
|
|
||||||
"13 NaN NaN NaN NaN NaN \n",
|
|
||||||
"\n",
|
|
||||||
" Child_PhA_Unit Child_REE_Kcal Child_REE_MJ Child_TEE_Kcal Child_TEE_MJ \n",
|
|
||||||
"13 NaN NaN NaN NaN NaN \n",
|
|
||||||
"\n",
|
|
||||||
"[1 rows x 147 columns]"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execution_count": 24,
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "execute_result"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"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"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 26,
|
|
||||||
"id": "98d9295a",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"name": "stdout",
|
|
||||||
"output_type": "stream",
|
|
||||||
"text": [
|
|
||||||
"VO2 Max: 47.906290322580645\n"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"v02_max = df['VO2(ml/min)_smoothed'].max()\n",
|
|
||||||
"weight = keirstyn_data['Weight'].iloc[0]\n",
|
|
||||||
"print(f\"VO2 Max: {v02_max/weight}\")"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 32,
|
|
||||||
"id": "cdfeb309",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"name": "stdout",
|
|
||||||
"output_type": "stream",
|
|
||||||
"text": [
|
|
||||||
"\n",
|
|
||||||
"==================================================\n",
|
|
||||||
"Optimal Fat Burning Zone (highest fat:carb ratio):\n",
|
|
||||||
"Time: 164.0 seconds\n",
|
|
||||||
"Fat burn rate: 3.894 kcal/min\n",
|
|
||||||
"Carb burn rate: 1.575 kcal/min\n",
|
|
||||||
"Fat:Carb ratio: 2.47\n",
|
|
||||||
"Heart Rate: 96.7 bpm\n",
|
|
||||||
"VO2: 1147.9 ml/min\n"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"# Find the point where fat burning is highest and carb burning is lowest\n",
|
|
||||||
"# Using the smoothed data for more stable results\n",
|
|
||||||
"fat_burn_max_idx = df['FAT_smoothed'].idxmax()\n",
|
|
||||||
"carb_burn_min_idx = df['CHO_smoothed'].idxmin()\n",
|
|
||||||
"\n",
|
|
||||||
"# # Get the data at maximum fat burning point\n",
|
|
||||||
"# max_fat_row = df.loc[fat_burn_max_idx]\n",
|
|
||||||
"# print(f\"Maximum Fat Burning Point:\")\n",
|
|
||||||
"# print(f\"Time: {max_fat_row['T(sec)']} seconds\")\n",
|
|
||||||
"# print(f\"Fat burn rate: {max_fat_row['FAT_smoothed']:.3f} kcal/min\")\n",
|
|
||||||
"# print(f\"Carb burn rate: {max_fat_row['CHO_smoothed']:.3f} kcal/min\")\n",
|
|
||||||
"# print(f\"Heart Rate: {max_fat_row['HR(bpm)_smoothed']:.1f} bpm\")\n",
|
|
||||||
"# print(f\"VO2: {max_fat_row['VO2(ml/min)_smoothed']:.1f} ml/min\")\n",
|
|
||||||
"\n",
|
|
||||||
"# print(\"\\n\" + \"=\"*50)\n",
|
|
||||||
"\n",
|
|
||||||
"# # Get the data at minimum carb burning point\n",
|
|
||||||
"# min_carb_row = df.loc[carb_burn_min_idx]\n",
|
|
||||||
"# print(f\"Minimum Carbohydrate Burning Point:\")\n",
|
|
||||||
"# print(f\"Time: {min_carb_row['T(sec)']} seconds\")\n",
|
|
||||||
"# print(f\"Fat burn rate: {min_carb_row['FAT_smoothed']:.3f} kcal/min\")\n",
|
|
||||||
"# print(f\"Carb burn rate: {min_carb_row['CHO_smoothed']:.3f} kcal/min\")\n",
|
|
||||||
"# print(f\"Heart Rate: {min_carb_row['HR(bpm)_smoothed']:.1f} bpm\")\n",
|
|
||||||
"# print(f\"VO2: {min_carb_row['VO2(ml/min)_smoothed']:.1f} ml/min\")\n",
|
|
||||||
"\n",
|
|
||||||
"print(\"\\n\" + \"=\"*50)\n",
|
|
||||||
"\n",
|
|
||||||
"# Find the optimal fat burning zone (highest fat:carb ratio)\n",
|
|
||||||
"df['fat_carb_ratio'] = df['FAT_smoothed'] / (df['CHO_smoothed'] + 0.00000001) # Add small value to avoid division by zero\n",
|
|
||||||
"optimal_fat_idx = df['fat_carb_ratio'].idxmax()\n",
|
|
||||||
"optimal_row = df.loc[optimal_fat_idx]\n",
|
|
||||||
"\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",
|
|
||||||
"print(f\"Fat:Carb ratio: {optimal_row['fat_carb_ratio']:.2f}\")\n",
|
|
||||||
"print(f\"Heart Rate: {optimal_row['HR(bpm)_smoothed']:.1f} bpm\")\n",
|
|
||||||
"print(f\"VO2: {optimal_row['VO2(ml/min)_smoothed']:.1f} ml/min\")"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 33,
|
|
||||||
"id": "4420cfea",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"name": "stdout",
|
|
||||||
"output_type": "stream",
|
|
||||||
"text": [
|
|
||||||
"Found 2 intersections at indices: [18, 47]\n",
|
|
||||||
"\n",
|
|
||||||
"Last intersection at index 47:\n",
|
|
||||||
"Time: 251.0 seconds\n",
|
|
||||||
"Fat burn rate: 3.040 kcal/min\n",
|
|
||||||
"Carb burn rate: 3.166 kcal/min\n",
|
|
||||||
"Heart Rate: 100.5 bpm\n",
|
|
||||||
"VO2: 1283.0 ml/min\n"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"# Find intersections where FAT_smoothed and CHO_smoothed cross each other\n",
|
|
||||||
"intersections = []\n",
|
|
||||||
"\n",
|
|
||||||
"for i in range(1, len(df)):\n",
|
|
||||||
" # Check if there's a crossover between consecutive points\n",
|
|
||||||
" prev_fat = df.iloc[i-1]['FAT_smoothed']\n",
|
|
||||||
" prev_cho = df.iloc[i-1]['CHO_smoothed']\n",
|
|
||||||
" curr_fat = df.iloc[i]['FAT_smoothed']\n",
|
|
||||||
" curr_cho = df.iloc[i]['CHO_smoothed']\n",
|
|
||||||
" \n",
|
|
||||||
" # Skip if any values are NaN\n",
|
|
||||||
" if pd.isna(prev_fat) or pd.isna(prev_cho) or pd.isna(curr_fat) or pd.isna(curr_cho):\n",
|
|
||||||
" continue\n",
|
|
||||||
" \n",
|
|
||||||
" # Check if lines cross (fat was above/below cho and now it's below/above)\n",
|
|
||||||
" if ((prev_fat > prev_cho and curr_fat < curr_cho) or \n",
|
|
||||||
" (prev_fat < prev_cho and curr_fat > curr_cho)):\n",
|
|
||||||
" intersections.append(i)\n",
|
|
||||||
"\n",
|
|
||||||
"print(f\"Found {len(intersections)} intersections at indices: {intersections}\")\n",
|
|
||||||
"\n",
|
|
||||||
"if intersections:\n",
|
|
||||||
" # Get the last intersection\n",
|
|
||||||
" last_intersection_idx = intersections[-1]\n",
|
|
||||||
" last_intersection_row = df.iloc[last_intersection_idx]\n",
|
|
||||||
" \n",
|
|
||||||
" print(f\"\\nLast intersection at index {last_intersection_idx}:\")\n",
|
|
||||||
" print(f\"Time: {last_intersection_row['T(sec)']} seconds\")\n",
|
|
||||||
" print(f\"Fat burn rate: {last_intersection_row['FAT_smoothed']:.3f} kcal/min\")\n",
|
|
||||||
" print(f\"Carb burn rate: {last_intersection_row['CHO_smoothed']:.3f} kcal/min\")\n",
|
|
||||||
" print(f\"Heart Rate: {last_intersection_row['HR(bpm)_smoothed']:.1f} bpm\")\n",
|
|
||||||
" print(f\"VO2: {last_intersection_row['VO2(ml/min)_smoothed']:.1f} ml/min\")\n",
|
|
||||||
"else:\n",
|
|
||||||
" print(\"No intersections found between FAT and CHO curves\")"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 37,
|
|
||||||
"id": "62803668",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"name": "stdout",
|
|
||||||
"output_type": "stream",
|
|
||||||
"text": [
|
|
||||||
"VT1: {'HeartRate': 100.5, 'Speed': 4.0, 'Time': 251.0}\n",
|
|
||||||
"VT2: {'HeartRate': 189.71300000000002, 'Speed': 7.5, 'Time': 1524.0}\n"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"def detect_vt1(df, fat_col=\"FAT_smoothed\", carb_col=\"CHO_smoothed\"):\n",
|
|
||||||
" \"\"\"\n",
|
|
||||||
" Detect VT1 as the first index where carb burn > fat burn and remains higher.\n",
|
|
||||||
" \"\"\"\n",
|
|
||||||
" condition = df[carb_col] > df[fat_col]\n",
|
|
||||||
" crossover_indices = condition[condition].index\n",
|
|
||||||
"\n",
|
|
||||||
" if len(crossover_indices) == 0:\n",
|
|
||||||
" return None # No crossover found\n",
|
|
||||||
" \n",
|
|
||||||
" # Find first crossover where carbs remain higher for the rest\n",
|
|
||||||
" for idx in crossover_indices:\n",
|
|
||||||
" if all(df.loc[idx:][carb_col] > df.loc[idx:][fat_col]):\n",
|
|
||||||
" return idx\n",
|
|
||||||
" return None\n",
|
|
||||||
"\n",
|
|
||||||
"\n",
|
|
||||||
"def detect_vt2(df, vent_col=\"VE(l/min)_smoothed\", bf_col=\"BF(bpm)_smoothed\", smooth_window=5):\n",
|
|
||||||
" \"\"\"\n",
|
|
||||||
" Detect VT2 using slope/inflection method.\n",
|
|
||||||
" Works with either Ventilation (VE) or Breathing Frequency (Bf).\n",
|
|
||||||
" \"\"\"\n",
|
|
||||||
" col = vent_col if vent_col in df.columns else bf_col\n",
|
|
||||||
" \n",
|
|
||||||
" # Use already smoothed data\n",
|
|
||||||
" smoothed_col = col\n",
|
|
||||||
" \n",
|
|
||||||
" # Compute slope (first derivative)\n",
|
|
||||||
" df[\"slope\"] = df[smoothed_col].diff()\n",
|
|
||||||
" \n",
|
|
||||||
" # Detect inflection: largest change in slope (second derivative peak)\n",
|
|
||||||
" df[\"second_derivative\"] = df[\"slope\"].diff()\n",
|
|
||||||
" inflection_idx = df[\"second_derivative\"].idxmax()\n",
|
|
||||||
" \n",
|
|
||||||
" return inflection_idx\n",
|
|
||||||
"\n",
|
|
||||||
"\n",
|
|
||||||
"def analyze_thresholds(df_input):\n",
|
|
||||||
" # Use the existing dataframe\n",
|
|
||||||
" df_copy = df_input.copy()\n",
|
|
||||||
" \n",
|
|
||||||
" # --- Detect VT1 ---\n",
|
|
||||||
" vt1_idx = detect_vt1(df_copy)\n",
|
|
||||||
" vt1 = None\n",
|
|
||||||
" if vt1_idx is not None:\n",
|
|
||||||
" vt1 = {\n",
|
|
||||||
" \"HeartRate\": df_copy.loc[vt1_idx, \"HR(bpm)_smoothed\"],\n",
|
|
||||||
" \"Speed\": df_copy.loc[vt1_idx, \"Speed\"],\n",
|
|
||||||
" \"Time\": df_copy.loc[vt1_idx, \"T(sec)\"]\n",
|
|
||||||
" }\n",
|
|
||||||
" \n",
|
|
||||||
" # --- Detect VT2 ---\n",
|
|
||||||
" vt2_idx = detect_vt2(df_copy)\n",
|
|
||||||
" vt2 = None\n",
|
|
||||||
" if vt2_idx is not None:\n",
|
|
||||||
" vt2 = {\n",
|
|
||||||
" \"HeartRate\": df_copy.loc[vt2_idx, \"HR(bpm)_smoothed\"],\n",
|
|
||||||
" \"Speed\": df_copy.loc[vt2_idx, \"Speed\"],\n",
|
|
||||||
" \"Time\": df_copy.loc[vt2_idx, \"T(sec)\"]\n",
|
|
||||||
" }\n",
|
|
||||||
" \n",
|
|
||||||
" return vt1, vt2\n",
|
|
||||||
"\n",
|
|
||||||
"\n",
|
|
||||||
"vt1, vt2 = analyze_thresholds(df)\n",
|
|
||||||
"print(\"VT1:\", vt1)\n",
|
|
||||||
"print(\"VT2:\", vt2)\n"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 40,
|
|
||||||
"id": "07593b56",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"name": "stdout",
|
|
||||||
"output_type": "stream",
|
|
||||||
"text": [
|
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"zone_1_start = optimal_row['HR(bpm)_smoothed'] - 15\n",
|
|
||||||
"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",
|
|
||||||
"\n",
|
|
||||||
"zone_1_end = zone_2_start\n",
|
|
||||||
"zone_2_end = vt1['HeartRate']\n",
|
|
||||||
"zone_3_end = zone_4_start\n",
|
|
||||||
"zone_4_end = zone_5_start\n",
|
|
||||||
"\n",
|
|
||||||
"print(f\"Zone 1 (Active Recovery): {zone_1_start:.1f} - {zone_1_end:.1f} bpm\")\n",
|
|
||||||
"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\")"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 60,
|
|
||||||
"id": "c90415b2",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"name": "stdout",
|
|
||||||
"output_type": "stream",
|
|
||||||
"text": [
|
|
||||||
"VO2 Max detected at index 202:\n",
|
|
||||||
"Time: 985.0 seconds\n",
|
|
||||||
"VO2 Breath: 58.2 ml/breath\n",
|
|
||||||
"VO2: 2167.8 ml/min\n",
|
|
||||||
"VO2 per kg: 38.8 ml/kg/min\n",
|
|
||||||
"Heart Rate: 170.5 bpm\n",
|
|
||||||
"Speed: 6.0 km/h\n",
|
|
||||||
"VO2 Breath Slope: -0.02\n"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"# Calculate the slope of VO2 Breath (first derivative)\n",
|
|
||||||
"df['vo2_breath_slope'] = df['VO2 Breath_smoothed'].diff()\n",
|
|
||||||
"\n",
|
|
||||||
"# Find points where slope is consistently zero or negative\n",
|
|
||||||
"# We'll use a rolling window to check for consistent negative/zero slope\n",
|
|
||||||
"window = len(df) // 3 # Number of consecutive points to check\n",
|
|
||||||
"\n",
|
|
||||||
"# Calculate rolling mean of slope to smooth out noise\n",
|
|
||||||
"df['vo2_breath_slope_smoothed'] = df['vo2_breath_slope'].rolling(window=window).mean()\n",
|
|
||||||
"\n",
|
|
||||||
"# Find where slope becomes consistently zero or negative\n",
|
|
||||||
"mask = df['vo2_breath_slope_smoothed'] <= 0\n",
|
|
||||||
"consistent_negative_indices = mask[mask].index\n",
|
|
||||||
"\n",
|
|
||||||
"if len(consistent_negative_indices) > 0:\n",
|
|
||||||
" # Find the first point where slope becomes consistently negative/zero\n",
|
|
||||||
" vo2_max_idx = consistent_negative_indices[0]\n",
|
|
||||||
" vo2_max_row = df.loc[vo2_max_idx]\n",
|
|
||||||
" \n",
|
|
||||||
" print(f\"VO2 Max detected at index {vo2_max_idx}:\")\n",
|
|
||||||
" print(f\"Time: {vo2_max_row['T(sec)']} seconds\")\n",
|
|
||||||
" print(f\"VO2 Breath: {vo2_max_row['VO2 Breath_smoothed']:.1f} ml/breath\")\n",
|
|
||||||
" print(f\"VO2: {vo2_max_row['VO2(ml/min)_smoothed']:.1f} ml/min\")\n",
|
|
||||||
" print(f\"VO2 per kg: {vo2_max_row['VO2(ml/min)_smoothed']/weight:.1f} ml/kg/min\")\n",
|
|
||||||
" print(f\"Heart Rate: {vo2_max_row['HR(bpm)_smoothed']:.1f} bpm\")\n",
|
|
||||||
" print(f\"Speed: {vo2_max_row['Speed']} km/h\")\n",
|
|
||||||
" print(f\"VO2 Breath Slope: {vo2_max_row['vo2_breath_slope_smoothed']:.2f}\")\n",
|
|
||||||
"else:\n",
|
|
||||||
" # If no consistent negative slope found, use the maximum VO2 Breath value\n",
|
|
||||||
" vo2_max_idx = df['VO2 Breath_smoothed'].idxmax()\n",
|
|
||||||
" vo2_max_row = df.loc[vo2_max_idx]\n",
|
|
||||||
" \n",
|
|
||||||
" print(f\"No consistent negative slope found. Using peak VO2 Breath at index {vo2_max_idx}:\")\n",
|
|
||||||
" print(f\"Time: {vo2_max_row['T(sec)']} seconds\")\n",
|
|
||||||
" print(f\"VO2 Breath: {vo2_max_row['VO2 Breath_smoothed']:.1f} ml/breath\")\n",
|
|
||||||
" print(f\"VO2: {vo2_max_row['VO2(ml/min)_smoothed']:.1f} ml/min\")\n",
|
|
||||||
" print(f\"VO2 per kg: {vo2_max_row['VO2(ml/min)_smoothed']/weight:.1f} ml/kg/min\")\n",
|
|
||||||
" print(f\"Heart Rate: {vo2_max_row['HR(bpm)_smoothed']:.1f} bpm\")\n",
|
|
||||||
" print(f\"Speed: {vo2_max_row['Speed']} km/h\")"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 66,
|
|
||||||
"id": "c3b2cc59",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"name": "stdout",
|
|
||||||
"output_type": "stream",
|
|
||||||
"text": [
|
|
||||||
"VO2 Pulse and HR slopes diverge consistently starting at index 89:\n",
|
|
||||||
"Time: 485.0 seconds\n",
|
|
||||||
"VO2 Pulse (smoothed): 13.91\n",
|
|
||||||
"Heart Rate (smoothed): 136.2 bpm\n",
|
|
||||||
"VO2 Pulse Slope: 0.672\n",
|
|
||||||
"HR Slope: 1.000\n",
|
|
||||||
"Slope Difference: 1.006\n",
|
|
||||||
"VO2: 1897.8 ml/min\n",
|
|
||||||
"Speed: 4.5 km/h\n",
|
|
||||||
"Threshold used: 0.615\n"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"# Calculate slopes for both VO2 Pulse and HR\n",
|
|
||||||
"df['vo2_pulse_slope'] = df['VO2 Pulse_smoothed'].diff()\n",
|
|
||||||
"df['hr_slope'] = df['HR(bpm)_smoothed'].diff()\n",
|
|
||||||
"\n",
|
|
||||||
"# Calculate the difference between the slopes\n",
|
|
||||||
"df['slope_difference'] = abs(df['vo2_pulse_slope'] - df['hr_slope'])\n",
|
|
||||||
"\n",
|
|
||||||
"# Find where the slope difference becomes consistently large (slopes diverge)\n",
|
|
||||||
"# Use a rolling window to smooth out noise\n",
|
|
||||||
"window_size = len(df) // 5 # Adjust window size as needed\n",
|
|
||||||
"df['slope_difference_smoothed'] = df['slope_difference'].rolling(window=window_size).mean()\n",
|
|
||||||
"\n",
|
|
||||||
"# Find the threshold - we'll use the 75th percentile of slope differences as threshold\n",
|
|
||||||
"threshold = df['slope_difference_smoothed'].quantile(0.75)\n",
|
|
||||||
"\n",
|
|
||||||
"# Find points where slope difference exceeds threshold\n",
|
|
||||||
"divergence_mask = df['slope_difference_smoothed'] > threshold\n",
|
|
||||||
"divergence_indices = divergence_mask[divergence_mask].index\n",
|
|
||||||
"\n",
|
|
||||||
"if len(divergence_indices) > 0:\n",
|
|
||||||
" # Find the first sustained divergence point\n",
|
|
||||||
" min_consecutive_points = 5\n",
|
|
||||||
" consistent_divergence_idx = None\n",
|
|
||||||
" \n",
|
|
||||||
" for start_idx in divergence_indices:\n",
|
|
||||||
" # Check if divergence is sustained for consecutive points\n",
|
|
||||||
" consecutive_count = 0\n",
|
|
||||||
" for j in range(start_idx, min(start_idx + min_consecutive_points, len(df))):\n",
|
|
||||||
" if j in divergence_indices:\n",
|
|
||||||
" consecutive_count += 1\n",
|
|
||||||
" else:\n",
|
|
||||||
" break\n",
|
|
||||||
" \n",
|
|
||||||
" if consecutive_count >= min_consecutive_points:\n",
|
|
||||||
" consistent_divergence_idx = start_idx\n",
|
|
||||||
" break\n",
|
|
||||||
" \n",
|
|
||||||
" if consistent_divergence_idx is not None:\n",
|
|
||||||
" divergence_row = df.iloc[consistent_divergence_idx]\n",
|
|
||||||
" \n",
|
|
||||||
" print(f\"VO2 Pulse and HR slopes diverge consistently starting at index {consistent_divergence_idx}:\")\n",
|
|
||||||
" print(f\"Time: {divergence_row['T(sec)']} seconds\")\n",
|
|
||||||
" print(f\"VO2 Pulse (smoothed): {divergence_row['VO2 Pulse_smoothed']:.2f}\")\n",
|
|
||||||
" print(f\"Heart Rate (smoothed): {divergence_row['HR(bpm)_smoothed']:.1f} bpm\")\n",
|
|
||||||
" print(f\"VO2 Pulse Slope: {divergence_row['vo2_pulse_slope']:.3f}\")\n",
|
|
||||||
" print(f\"HR Slope: {divergence_row['hr_slope']:.3f}\")\n",
|
|
||||||
" print(f\"Slope Difference: {divergence_row['slope_difference_smoothed']:.3f}\")\n",
|
|
||||||
" print(f\"VO2: {divergence_row['VO2(ml/min)_smoothed']:.1f} ml/min\")\n",
|
|
||||||
" print(f\"Speed: {divergence_row['Speed']} km/h\")\n",
|
|
||||||
" print(f\"Threshold used: {threshold:.3f}\")\n",
|
|
||||||
" else:\n",
|
|
||||||
" print(f\"No sustained divergence found. Threshold: {threshold:.3f}\")\n",
|
|
||||||
" # Show the point with maximum slope difference instead\n",
|
|
||||||
" max_diff_idx = df['slope_difference_smoothed'].idxmax()\n",
|
|
||||||
" max_diff_row = df.iloc[max_diff_idx]\n",
|
|
||||||
" \n",
|
|
||||||
" print(f\"\\nPoint with maximum slope difference at index {max_diff_idx}:\")\n",
|
|
||||||
" print(f\"Time: {max_diff_row['T(sec)']} seconds\")\n",
|
|
||||||
" print(f\"VO2 Pulse (smoothed): {max_diff_row['VO2 Pulse_smoothed']:.2f}\")\n",
|
|
||||||
" print(f\"Heart Rate (smoothed): {max_diff_row['HR(bpm)_smoothed']:.1f} bpm\")\n",
|
|
||||||
" print(f\"Slope Difference: {max_diff_row['slope_difference_smoothed']:.3f}\")\n",
|
|
||||||
"else:\n",
|
|
||||||
" print(\"No significant slope divergence found between VO2 Pulse and HR\")"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"id": "672d68f3",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"name": "stdout",
|
|
||||||
"output_type": "stream",
|
|
||||||
"text": [
|
|
||||||
"Maximum FAT_smoothed occurs at index 30:\n",
|
|
||||||
"Heart Rate (smoothed): 96.7 bpm\n",
|
|
||||||
"FAT (smoothed): 3.894 kcal/min\n"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"max_fat_smoothed_idx = df['FAT_smoothed'].idxmax()\n",
|
|
||||||
"max_fat_smoothed_row = df.loc[max_fat_smoothed_idx]\n",
|
|
||||||
"max_heart_rate = 220 - keirstyn_data['Age'].iloc[0]\n",
|
|
||||||
"\n",
|
|
||||||
"print(f\"Maximum FAT_smoothed occurs at index {max_fat_smoothed_idx}:\")\n",
|
|
||||||
"print(f\"Heart Rate (smoothed): {max_fat_smoothed_row['HR(bpm)_smoothed']:.1f} bpm\")\n",
|
|
||||||
"print(f\"FAT (smoothed): {max_fat_smoothed_row['FAT_smoothed']:.3f} kcal/min\")"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"id": "fe3b7605",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"metadata": {
|
|
||||||
"kernelspec": {
|
|
||||||
"display_name": "report_generation",
|
|
||||||
"language": "python",
|
|
||||||
"name": "python3"
|
|
||||||
},
|
|
||||||
"language_info": {
|
|
||||||
"codemirror_mode": {
|
|
||||||
"name": "ipython",
|
|
||||||
"version": 3
|
|
||||||
},
|
|
||||||
"file_extension": ".py",
|
|
||||||
"mimetype": "text/x-python",
|
|
||||||
"name": "python",
|
|
||||||
"nbconvert_exporter": "python",
|
|
||||||
"pygments_lexer": "ipython3",
|
|
||||||
"version": "3.12.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nbformat": 4,
|
|
||||||
"nbformat_minor": 5
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,318 +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
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
from app.services.context import context_list
|
|
||||||
from app.services.graph_generator import GraphGenerator
|
|
||||||
|
|
||||||
|
|
||||||
class ReportGeneratorService:
|
|
||||||
"""Service for generating medical performance reports"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
template_dir: str = "app/report_gen",
|
|
||||||
graphs_dir: str = "graphs",
|
|
||||||
reports_dir: str = "reports",
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Initialize the report generator service.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
template_dir: Directory containing Jinja2 templates
|
|
||||||
graphs_dir: Directory to save generated graphs
|
|
||||||
reports_dir: Directory to save generated reports
|
|
||||||
"""
|
|
||||||
self.template_dir = template_dir
|
|
||||||
self.graphs_dir = Path(graphs_dir)
|
|
||||||
self.reports_dir = Path(reports_dir)
|
|
||||||
self.graph_generator = GraphGenerator(charts_dir=str(graphs_dir))
|
|
||||||
self.env = Environment(loader=FileSystemLoader(template_dir))
|
|
||||||
|
|
||||||
# Ensure directories exist
|
|
||||||
self.graphs_dir.mkdir(exist_ok=True)
|
|
||||||
self.reports_dir.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
def process_pnoe_data(self, pnoe_csv_path: str) -> pd.DataFrame:
|
|
||||||
"""
|
|
||||||
Load and process Pnoe CSV data.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pnoe_csv_path: Path to Pnoe CSV file
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Processed DataFrame with smoothed columns
|
|
||||||
"""
|
|
||||||
# Load data
|
|
||||||
df = pd.read_csv(pnoe_csv_path, delimiter=";")
|
|
||||||
df = df.apply(pd.to_numeric, errors="ignore")
|
|
||||||
|
|
||||||
# Calculate derived columns
|
|
||||||
df["VO2 Pulse"] = df["VO2(ml/min)"] / df["HR(bpm)"]
|
|
||||||
df["VO2 Breath"] = df["VO2(ml/min)"] / df["BF(bpm)"]
|
|
||||||
df["CHO"] = df["EE(kcal/min)"] * df["CARBS(%)"] / 100
|
|
||||||
df["FAT"] = df["EE(kcal/min)"] * df["FAT(%)"] / 100
|
|
||||||
|
|
||||||
# Smooth columns
|
|
||||||
window_size = 10
|
|
||||||
columns_to_smooth = [
|
|
||||||
"VO2(ml/min)",
|
|
||||||
"VCO2(ml/min)",
|
|
||||||
"HR(bpm)",
|
|
||||||
"VT(l)",
|
|
||||||
"BF(bpm)",
|
|
||||||
"VE(l/min)",
|
|
||||||
"VO2 Pulse",
|
|
||||||
"VO2 Breath",
|
|
||||||
"CHO",
|
|
||||||
"FAT",
|
|
||||||
]
|
|
||||||
|
|
||||||
for col in columns_to_smooth:
|
|
||||||
if col in df.columns:
|
|
||||||
df[f"{col}_smoothed"] = (
|
|
||||||
df[col].rolling(window=window_size, min_periods=1).mean()
|
|
||||||
)
|
|
||||||
|
|
||||||
return df
|
|
||||||
|
|
||||||
def generate_graphs(self, df: pd.DataFrame) -> List[Dict[str, str]]:
|
|
||||||
"""
|
|
||||||
Generate all required graphs from processed data.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
df: Processed DataFrame with smoothed columns
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of dictionaries containing graph names and paths
|
|
||||||
"""
|
|
||||||
graphs_generated = []
|
|
||||||
|
|
||||||
# List of graphs to generate
|
|
||||||
graph_methods = [
|
|
||||||
("respiratory", self.graph_generator.generate_respiratory_chart),
|
|
||||||
("fuel_utilization", self.graph_generator.generate_fuel_utilization_chart),
|
|
||||||
("vo2_pulse", self.graph_generator.generate_vo2_pulse_chart),
|
|
||||||
("vo2_breath", self.graph_generator.generate_vo2_breath_chart),
|
|
||||||
("fat_metabolism", self.graph_generator.generate_fat_metabolism_chart),
|
|
||||||
("recovery", self.graph_generator.generate_recovery_chart),
|
|
||||||
]
|
|
||||||
|
|
||||||
for name, method in graph_methods:
|
|
||||||
try:
|
|
||||||
path = method(df, save_as_base64=False)
|
|
||||||
graphs_generated.append({"name": name, "path": str(path)})
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Warning: Could not generate {name} chart: {e}")
|
|
||||||
|
|
||||||
return graphs_generated
|
|
||||||
|
|
||||||
def calculate_analysis_metrics(self, df: pd.DataFrame) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Calculate basic analysis metrics from processed data.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
df: Processed DataFrame with smoothed columns
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary containing analysis metrics
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
"vo2_max": float(df["VO2(ml/min)_smoothed"].max())
|
|
||||||
if "VO2(ml/min)_smoothed" in df.columns
|
|
||||||
else 0,
|
|
||||||
"peak_vt": float(df["VT(l)_smoothed"].max())
|
|
||||||
if "VT(l)_smoothed" in df.columns
|
|
||||||
else 0,
|
|
||||||
"max_hr": float(df["HR(bpm)_smoothed"].max())
|
|
||||||
if "HR(bpm)_smoothed" in df.columns
|
|
||||||
else 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
def generate_html(self, patient_info: Dict[str, Any]) -> str:
|
|
||||||
"""
|
|
||||||
Generate HTML content for the report.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
patient_info: Dictionary containing patient information
|
|
||||||
(patient_name, age, height, weight, focus)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Complete HTML document as string
|
|
||||||
"""
|
|
||||||
html_pages = []
|
|
||||||
|
|
||||||
# Header context
|
|
||||||
header_context = {
|
|
||||||
"patient_name": patient_info.get("patient_name", ""),
|
|
||||||
"age": patient_info.get("age", ""),
|
|
||||||
"height": patient_info.get("height", ""),
|
|
||||||
"weight": patient_info.get("weight", ""),
|
|
||||||
"focus": patient_info.get("focus", "Endurance"),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Footer context
|
|
||||||
footer_context = [
|
|
||||||
{
|
|
||||||
"contact_email": "info@ishplabs.com",
|
|
||||||
"website": "www.ishplabs.com",
|
|
||||||
"social": "@ishplabs",
|
|
||||||
"page_number": i + 1,
|
|
||||||
}
|
|
||||||
for i in range(len(context_list))
|
|
||||||
]
|
|
||||||
|
|
||||||
# Render header
|
|
||||||
header_html = self.env.get_template("header.html").render(header_context)
|
|
||||||
|
|
||||||
# Render footers
|
|
||||||
footer_html_list = [
|
|
||||||
self.env.get_template("footer.html").render(context)
|
|
||||||
for context in footer_context
|
|
||||||
]
|
|
||||||
|
|
||||||
# Render pages
|
|
||||||
for i, context in enumerate(context_list):
|
|
||||||
template = self.env.get_template(f"page_{i + 1}.html").render(context)
|
|
||||||
|
|
||||||
if (i + 1) > 2:
|
|
||||||
full_html = f"""
|
|
||||||
<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;
|
|
||||||
}}
|
|
||||||
* {{
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}}
|
|
||||||
img {{
|
|
||||||
max-height: 300px;
|
|
||||||
}}
|
|
||||||
.chart-large {{
|
|
||||||
max-height: 500px !important;
|
|
||||||
}}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="m-0 p-0">
|
|
||||||
{final_html}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
return html_doc
|
|
||||||
|
|
||||||
def html_to_pdf(self, html_content: str, pdf_path: str) -> None:
|
|
||||||
"""
|
|
||||||
Convert HTML content to PDF file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
html_content: HTML content as string
|
|
||||||
pdf_path: Path where PDF should be saved
|
|
||||||
"""
|
|
||||||
with sync_playwright() as p:
|
|
||||||
browser = p.chromium.launch()
|
|
||||||
page = browser.new_page()
|
|
||||||
page.set_content(html_content)
|
|
||||||
page.pdf(path=pdf_path, format="A4", print_background=True)
|
|
||||||
browser.close()
|
|
||||||
|
|
||||||
def generate_report(
|
|
||||||
self,
|
|
||||||
spirometry_pdf_path: str,
|
|
||||||
pnoe_csv_path: str,
|
|
||||||
seca_excel_path: str,
|
|
||||||
patient_info: Dict[str, Any],
|
|
||||||
output_filename: str = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Generate complete medical report from uploaded files.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
spirometry_pdf_path: Path to Spirometry PDF file
|
|
||||||
pnoe_csv_path: Path to Pnoe CSV file
|
|
||||||
seca_excel_path: Path to SECA Excel file
|
|
||||||
patient_info: Dictionary containing patient information
|
|
||||||
output_filename: Optional custom output filename
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary containing report path, graphs generated, and analysis data
|
|
||||||
"""
|
|
||||||
# Process data
|
|
||||||
df = self.process_pnoe_data(pnoe_csv_path)
|
|
||||||
|
|
||||||
# Generate graphs
|
|
||||||
graphs_generated = self.generate_graphs(df)
|
|
||||||
|
|
||||||
# Calculate analysis metrics
|
|
||||||
analysis_data = self.calculate_analysis_metrics(df)
|
|
||||||
analysis_data["graphs_count"] = len(graphs_generated)
|
|
||||||
|
|
||||||
# Generate HTML
|
|
||||||
html_content = self.generate_html(patient_info)
|
|
||||||
|
|
||||||
# Generate PDF
|
|
||||||
if output_filename is None:
|
|
||||||
patient_name = patient_info.get("patient_name", "Unknown")
|
|
||||||
session_id = patient_info.get("session_id", "default")
|
|
||||||
output_filename = (
|
|
||||||
f"report_{patient_name.replace(' ', '_')}_{session_id}.pdf"
|
|
||||||
)
|
|
||||||
|
|
||||||
report_path = self.reports_dir / output_filename
|
|
||||||
self.html_to_pdf(html_content, str(report_path))
|
|
||||||
|
|
||||||
return {
|
|
||||||
"report_path": str(report_path),
|
|
||||||
"graphs_generated": graphs_generated,
|
|
||||||
"analysis_data": analysis_data,
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user