diff --git a/app/report_gen/page_8.html b/app/report_gen/page_8.html index 8e17b38..52cb11f 100644 --- a/app/report_gen/page_8.html +++ b/app/report_gen/page_8.html @@ -27,7 +27,7 @@ VO2 Max Table @@ -43,7 +43,7 @@ Heart Rate Zones Table diff --git a/app/services/__pycache__/context_generator.cpython-312.pyc b/app/services/__pycache__/context_generator.cpython-312.pyc index a4f5275..b291d4c 100644 Binary files a/app/services/__pycache__/context_generator.cpython-312.pyc and b/app/services/__pycache__/context_generator.cpython-312.pyc differ diff --git a/app/services/__pycache__/graph_generator.cpython-312.pyc b/app/services/__pycache__/graph_generator.cpython-312.pyc index c3d5418..6caa01b 100644 Binary files a/app/services/__pycache__/graph_generator.cpython-312.pyc and b/app/services/__pycache__/graph_generator.cpython-312.pyc differ diff --git a/app/services/__pycache__/report_generator.cpython-312.pyc b/app/services/__pycache__/report_generator.cpython-312.pyc index 9a5e4a9..602f562 100644 Binary files a/app/services/__pycache__/report_generator.cpython-312.pyc and b/app/services/__pycache__/report_generator.cpython-312.pyc differ diff --git a/app/services/context_generator.py b/app/services/context_generator.py index 090f282..fd9a1f7 100644 --- a/app/services/context_generator.py +++ b/app/services/context_generator.py @@ -552,6 +552,407 @@ class ContextGenerator: "hr_athlete": hr_ranges["athlete"], } + def _calculate_rhr_table_data(self, age: int, gender: str) -> dict: + """ + Calculate Resting Heart Rate reference table data. + + Args: + age: Patient age + gender: Patient gender + + Returns: + Dictionary containing age_range and ranges + """ + # Determine age range + if 18 <= age <= 25: + age_range = "18-25" + elif 26 <= age <= 35: + age_range = "26-35" + elif 36 <= age <= 45: + age_range = "36-45" + elif 46 <= age <= 55: + age_range = "46-55" + elif 56 <= age <= 65: + age_range = "56-65" + elif age > 65: + age_range = "65+" + else: + age_range = "18-25" # default for under 18 + + # RHR Master Chart + rhr_chart = { + "male": { + "18-25": { + "Poor": (85, None), + "Below Average": (79, 85), + "Average": (74, 79), + "Above Average": (70, 74), + "Good": (66, 70), + "Excellent": (61, 66), + "Athlete": (40, 61), + }, + "26-35": { + "Poor": (83, None), + "Below Average": (77, 83), + "Average": (73, 77), + "Above Average": (69, 73), + "Good": (65, 69), + "Excellent": (60, 65), + "Athlete": (42, 60), + }, + "36-45": { + "Poor": (85, None), + "Below Average": (79, 85), + "Average": (74, 79), + "Above Average": (70, 74), + "Good": (65, 70), + "Excellent": (60, 65), + "Athlete": (45, 60), + }, + "46-55": { + "Poor": (84, None), + "Below Average": (78, 84), + "Average": (74, 78), + "Above Average": (70, 74), + "Good": (66, 70), + "Excellent": (61, 66), + "Athlete": (48, 61), + }, + "56-65": { + "Poor": (84, None), + "Below Average": (78, 84), + "Average": (74, 78), + "Above Average": (70, 74), + "Good": (65, 70), + "Excellent": (60, 65), + "Athlete": (50, 60), + }, + "65+": { + "Poor": (84, None), + "Below Average": (77, 84), + "Average": (73, 77), + "Above Average": (70, 73), + "Good": (65, 70), + "Excellent": (60, 65), + "Athlete": (52, 60), + }, + }, + "female": { + "18-25": { + "Poor": (82, None), + "Below Average": (74, 82), + "Average": (70, 74), + "Above Average": (66, 70), + "Good": (62, 66), + "Excellent": (56, 62), + "Athlete": (40, 56), + }, + "26-35": { + "Poor": (82, None), + "Below Average": (75, 82), + "Average": (71, 75), + "Above Average": (66, 71), + "Good": (62, 66), + "Excellent": (55, 62), + "Athlete": (44, 55), + }, + "36-45": { + "Poor": (83, None), + "Below Average": (76, 83), + "Average": (71, 76), + "Above Average": (67, 71), + "Good": (63, 67), + "Excellent": (57, 63), + "Athlete": (47, 57), + }, + "46-55": { + "Poor": (84, None), + "Below Average": (77, 84), + "Average": (72, 77), + "Above Average": (68, 72), + "Good": (64, 68), + "Excellent": (58, 64), + "Athlete": (49, 58), + }, + "56-65": { + "Poor": (82, None), + "Below Average": (76, 82), + "Average": (72, 76), + "Above Average": (68, 72), + "Good": (62, 68), + "Excellent": (57, 62), + "Athlete": (51, 57), + }, + "65+": { + "Poor": (80, None), + "Below Average": (74, 80), + "Average": (70, 74), + "Above Average": (66, 70), + "Good": (62, 66), + "Excellent": (56, 62), + "Athlete": (52, 56), + }, + }, + } + + gender_key = "male" if gender.lower().startswith("m") else "female" + ranges = rhr_chart[gender_key][age_range] + + # Format ranges + formatted_ranges = {} + for category, (min_val, max_val) in ranges.items(): + if max_val is None: + formatted_ranges[category] = f"{min_val}bpm +" + else: + formatted_ranges[category] = f"{min_val}-{max_val}bpm" + + return { + "age_range": f"{age_range} ({gender[0].upper()})", + "ranges": formatted_ranges, + } + + def _calculate_zone_metrics(self, pnoe_metrics: Dict) -> Dict: + """Calculate detailed metrics for each heart rate zone based on actual data""" + import math + + # Get zone boundaries + fat_max_idx = self.pnoe_df["FAT_smoothed"].idxmax() + optimal_row = self.pnoe_df.loc[fat_max_idx] + + # Detect VT1 and VT2 + vt1 = pnoe_metrics.get("vt1") + vt2 = pnoe_metrics.get("vt2") + + if not vt1 or not vt2: + # Return default values if thresholds not detected + return {} + + # Define zone boundaries (from notebook logic) + zone_1_start = math.floor(optimal_row["HR(bpm)_smoothed"] - 15) + zone_2_start = math.floor(optimal_row["HR(bpm)_smoothed"]) + zone_3_start = math.floor(vt1["HeartRate"]) + zone_4_start = math.floor(vt2["HeartRate"] - 10) + zone_5_start = math.floor(vt2["HeartRate"]) + + zone_1_end = zone_2_start + zone_2_end = math.floor(vt1["HeartRate"]) + zone_3_end = zone_4_start + zone_4_end = zone_5_start + zone_5_end = math.floor(vt2["HeartRate"] + 10) + + zones_list = [ + ("Zone 1", zone_1_start, zone_1_end), + ("Zone 2", zone_2_start, zone_2_end), + ("Zone 3", zone_3_start, zone_3_end), + ("Zone 4", zone_4_start, zone_4_end), + ("Zone 5", zone_5_start, zone_5_end), + ] + + ideal_breath_ranges = [ + "Ideal Range: 15-20 breaths", + "Ideal Range: 20-25 breaths", + "Ideal Range: 25-30 breaths", + "Ideal Range: 30-35 breaths", + "Ideal Range: 40+ breaths", + ] + + def speed_to_pace(s_mph): + """Convert speed in mph to pace in min/km""" + if s_mph <= 0: + return 0, 0 + s_kmh = s_mph * 1.60934 + p_min = 60 / s_kmh + p_m = int(p_min) + p_s = int((p_min % 1) * 60) + return p_m, p_s + + zone_data = [] + for i, (name, start, end) in enumerate(zones_list): + # Filter dataframe for the current zone + mask = (self.pnoe_df["HR(bpm)_smoothed"] >= start) & ( + self.pnoe_df["HR(bpm)_smoothed"] <= end + ) + zone_df = self.pnoe_df[mask] + + if not zone_df.empty: + # Speed calculation + speed_series = zone_df[zone_df["Speed"] > 0.1]["Speed"] + if not speed_series.empty: + min_speed = speed_series.min() + max_speed = speed_series.max() + + if abs(min_speed - max_speed) < 0.1: + speed_str = f"{min_speed:.1f}mph\n2% Incline" + else: + speed_str = f"{min_speed:.1f}-{max_speed:.1f}mph\n2% Incline" + + # Pace calculation (max speed -> min pace, min speed -> max pace) + min_pace_m, min_pace_s = speed_to_pace(max_speed) + max_pace_m, max_pace_s = speed_to_pace(min_speed) + + if min_pace_m == max_pace_m and min_pace_s == max_pace_s: + pace_str = f"{min_pace_m}:{min_pace_s:02d}min/km Pace" + else: + pace_str = ( + f"{max_pace_m}:{max_pace_s:02d}-{min_pace_m}:{min_pace_s:02d}\n" + f"min/km Pace" + ) + else: + speed_str = "-\n2% Incline" + pace_str = "-" + + # Calories (using raw EE) + avg_cals = zone_df["EE(kcal/min)"].mean() + calories_str = f"Avg:\n{avg_cals:.1f}kcals/minute" + + # Carb utilization (g/min) + avg_carbs_g = zone_df["CHO"].mean() / 4 + carb_str = f"Avg: {avg_carbs_g:.1f}g/min\nCarb Utilization" + + # Breathing + avg_breaths = zone_df["BF(bpm)_smoothed"].mean() + breath_str = ( + f"Avg: {int(avg_breaths)} breaths\n{ideal_breath_ranges[i]}" + ) + else: + speed_str = "-\n2% Incline" + pace_str = "-" + calories_str = "-" + carb_str = "-" + breath_str = f"-\n{ideal_breath_ranges[i]}" + + zone_data.append( + { + "zone_name": name, + "hr_bpm": f"{int(start)}-{int(end)}bpm", + "speed": speed_str, + "pace": pace_str, + "calories": calories_str, + "carb": carb_str, + "breathing": breath_str, + } + ) + + return {"zones": zone_data} + + def _calculate_vo2_max_table_data(self, age: int, gender: str) -> Dict: + """Calculate VO2 Max table data based on age and gender""" + # VO2 Max Master Chart Data (from notebook) + vo2_max_data = { + "20-29 (M)": { + "Very Poor": (None, 38.1), + "Poor": (38.1, 44.1), + "Fair": (44.1, 51.0), + "Good": (51.0, 56.9), + "Excellent": (56.9, 66.3), + "Superior": (66.3, None), + }, + "30-39 (M)": { + "Very Poor": (None, 34.1), + "Poor": (34.1, 39.5), + "Fair": (39.5, 45.3), + "Good": (45.3, 51.3), + "Excellent": (51.3, 59.8), + "Superior": (59.8, None), + }, + "40-49 (M)": { + "Very Poor": (None, 30.5), + "Poor": (30.5, 35.4), + "Fair": (35.4, 40.9), + "Good": (40.9, 46.3), + "Excellent": (46.3, 55.6), + "Superior": (55.6, None), + }, + "50-59 (M)": { + "Very Poor": (None, 26.1), + "Poor": (26.1, 30.9), + "Fair": (30.9, 35.7), + "Good": (35.7, 40.9), + "Excellent": (40.9, 50.7), + "Superior": (50.7, None), + }, + "60+ (M)": { + "Very Poor": (None, 22.4), + "Poor": (22.4, 26.5), + "Fair": (26.5, 32.2), + "Good": (32.2, 36.3), + "Excellent": (36.3, 43.0), + "Superior": (43.0, None), + }, + "20-29 (F)": { + "Very Poor": (None, 28.6), + "Poor": (28.6, 33.7), + "Fair": (33.7, 38.5), + "Good": (38.5, 43.8), + "Excellent": (43.8, 56.0), + "Superior": (56.0, None), + }, + "30-39 (F)": { + "Very Poor": (None, 24.1), + "Poor": (24.1, 28.2), + "Fair": (28.2, 32.2), + "Good": (32.2, 35.7), + "Excellent": (35.7, 45.8), + "Superior": (45.8, None), + }, + "40-49 (F)": { + "Very Poor": (None, 22.7), + "Poor": (22.7, 26.5), + "Fair": (26.5, 30.5), + "Good": (30.5, 35.0), + "Excellent": (35.0, 42.3), + "Superior": (42.3, None), + }, + "50-59 (F)": { + "Very Poor": (None, 21.5), + "Poor": (21.5, 24.9), + "Fair": (24.9, 28.7), + "Good": (28.7, 32.9), + "Excellent": (32.9, 40.4), + "Superior": (40.4, None), + }, + "60+ (F)": { + "Very Poor": (None, 19.0), + "Poor": (19.0, 22.7), + "Fair": (22.7, 26.1), + "Good": (26.1, 30.1), + "Excellent": (30.1, 36.7), + "Superior": (36.7, None), + }, + } + + # Determine age bracket + if age < 30: + age_key = "20-29" + elif age < 40: + age_key = "30-39" + elif age < 50: + age_key = "40-49" + elif age < 60: + age_key = "50-59" + else: + age_key = "60+" + + gender_key = "(M)" if gender.lower() == "male" else "(F)" + key = f"{age_key} {gender_key}" + + ranges = vo2_max_data.get(key, vo2_max_data["30-39 (F)"]) # Default + + # Format the ranges for display + result = {} + for category, (min_val, max_val) in ranges.items(): + if min_val is None: + result[category] = f"<{max_val:.1f}" + elif max_val is None: + result[category] = f"{min_val:.1f}+" + else: + result[category] = f"{min_val:.1f}-{max_val:.1f}" + + return { + "age_range": age_key, + "ranges": result, + } + def calculate_rmr_and_fuel_source(self) -> Dict: """Calculate RMR and fuel source from pnoe data""" metrics = {} @@ -722,9 +1123,16 @@ class ContextGenerator: } if graph_generator: + # Calculate VO2 Max table data + vo2_max_table_info = self._calculate_vo2_max_table_data( + self.patient_info["age"], self.patient_info["gender"] + ) + # VO2 Max Table vo2_max_columns = [ - "Age (F)", + "Age (F)" + if self.patient_info["gender"].lower() == "female" + else "Age (M)", "Very Poor", "Poor", "Fair", @@ -734,13 +1142,13 @@ class ContextGenerator: ] vo2_max_data = [ [ - contexts["page_8"]["age_range"], - "19.0-24.1", - "24.1-28.2", - "28.2-32.2", - "32.2-35.7", - "35.7-45.8", - "45.8+", + vo2_max_table_info["age_range"], + vo2_max_table_info["ranges"]["Very Poor"], + vo2_max_table_info["ranges"]["Poor"], + vo2_max_table_info["ranges"]["Fair"], + vo2_max_table_info["ranges"]["Good"], + vo2_max_table_info["ranges"]["Excellent"], + vo2_max_table_info["ranges"]["Superior"], ] ] vo2_max_colors = [ @@ -763,84 +1171,54 @@ class ContextGenerator: save_as_base64=True, ) - # Heart Rate Zones Table - hr_zones_columns = ["Zone 1", "Zone 2", "Zone 3", "Zone 4", "Zone 5"] - hr_zones_data = [ - [ - "Improves health and recovery capacity", - "Improves endurance and fat burning", - "Improves Aerobic fitness", - "Improves maximum performance capacity", - "Develops maximum performance and speed", - ], - [ - "55-65% of Max Heart Rate", - "65-75% of Max Heart Rate", - "80-85% of Max Heart Rate", - "85-88% of Max Heart Rate", - "90% of Max Heart Rate", - ], - [ - pnoe_metrics.get("zone1_bpm", "81-96bpm"), - pnoe_metrics.get("zone2_bpm", "96-100bpm"), - pnoe_metrics.get("zone3_bpm", "100-178bpm"), - pnoe_metrics.get("zone4_bpm", "178-188bpm"), - pnoe_metrics.get("zone5_bpm", "188-198bpm"), - ], - [ - "3.5mph\n2% Incline", - "3.5-4.0mph\n2% Incline", - "4.0-6.5mph\n2% Incline", - "6.5-7.0mph\n2% Incline", - "7.0-8.0mph\n2% Incline", - ], - [ - "10:39min/km Pace", - "10:39-9:19min/km Pace", - "9:19-5:44min/km Pace", - "5:44-5:20min/km Pace", - "5:20-4:40min/km Pace", - ], - [ - "Avg:\n4.4kcals/minute", - "Avg:\n5.9kcals/minute", - "Avg:\n9.4kcals/minute", - "Avg:\n12.5kcals/minute", - "Avg:\n12.8kcals/minute", - ], - [ - "Avg: 0.4g/min\nCarb Utilization", - "Avg: 0.6g/min\nCarb Utilization", - "Avg: 1.9g/min\nCarb Utilization", - "Avg: 2.9g/min\nCarb Utilization", - "Avg: 3.1g/min\nCarb Utilization", - ], - [ - "Avg: 27 breaths\nIdeal: 15-20", - "Avg: 28 breaths\nIdeal: 20-25", - "Avg: 31 breaths\nIdeal: 25-30", - "Avg: 42 breaths\nIdeal: 30-35", - "Avg: 51 breaths\nIdeal: 40+", - ], - ] - hr_zones_colors = [ - ["#ffffff"] * 5, - ["#ffffff"] * 5, - ["#ffcdd2", "#ffcdd2", "#fff9c4", "#c8e6c9", "#c8e6c9"], - ["#ffffff"] * 5, - ["#ffffff"] * 5, - ["#ffffff"] * 5, - ["#ffffff"] * 5, - ["#ffcdd2", "#ffcdd2", "#fff9c4", "#c8e6c9", "#c8e6c9"], - ] + # Calculate zone metrics for the table + zone_metrics = self._calculate_zone_metrics(pnoe_metrics) - contexts["page_8"]["hr_zones_table"] = graph_generator.generate_table_image( - data=hr_zones_data, - columns=hr_zones_columns, - cell_colors=hr_zones_colors, - header_color="#4dd0e1", - save_as_base64=True, - ) + if zone_metrics.get("zones"): + # Heart Rate Zones Table + hr_zones_columns = ["Zone 1", "Zone 2", "Zone 3", "Zone 4", "Zone 5"] + hr_zones_data = [ + [ + "Improves health and recovery capacity", + "Improves endurance and fat burning", + "Improves Aerobic fitness", + "Improves maximum performance capacity", + "Develops maximum performance and speed", + ], + [ + "55-65% of Max Heart Rate", + "65-75% of Max Heart Rate", + "80-85% of Max Heart Rate", + "85-88% of Max Heart Rate", + "90% of Max Heart Rate", + ], + [zone_metrics["zones"][i]["hr_bpm"] for i in range(5)], + [zone_metrics["zones"][i]["speed"] for i in range(5)], + [zone_metrics["zones"][i]["pace"] for i in range(5)], + [zone_metrics["zones"][i]["calories"] for i in range(5)], + [zone_metrics["zones"][i]["carb"] for i in range(5)], + [zone_metrics["zones"][i]["breathing"] for i in range(5)], + ] + hr_zones_colors = [ + ["#ffffff"] * 5, + ["#ffffff"] * 5, + ["#ffcdd2", "#ffcdd2", "#fff9c4", "#c8e6c9", "#c8e6c9"], + ["#ffffff"] * 5, + ["#ffffff"] * 5, + ["#ffffff"] * 5, + ["#ffffff"] * 5, + ["#ffcdd2", "#ffcdd2", "#fff9c4", "#c8e6c9", "#c8e6c9"], + ] + + contexts["page_8"]["hr_zones_table"] = ( + graph_generator.generate_table_image( + data=hr_zones_data, + columns=hr_zones_columns, + cell_colors=hr_zones_colors, + header_color="#4dd0e1", + save_as_base64=True, + ) + ) # Page 9 contexts["page_9"] = { @@ -876,8 +1254,18 @@ class ContextGenerator: if graph_generator: # Page 11 Resting Heart Rate Table + rhr_table_info = self._calculate_rhr_table_data( + self.patient_info["age"], self.patient_info["gender"] + ) + + gender_label = ( + "Age (F)" + if self.patient_info["gender"].lower().startswith("f") + else "Age (M)" + ) + rhr_columns = [ - "Age (F)", + gender_label, "Poor", "Below Average", "Average", @@ -888,14 +1276,14 @@ class ContextGenerator: ] rhr_data = [ [ - contexts["page_11"]["hr_age_range"], - contexts["page_11"]["hr_poor"], - contexts["page_11"]["hr_below_avg"], - contexts["page_11"]["hr_average"], - contexts["page_11"]["hr_above_avg"], - contexts["page_11"]["hr_good"], - contexts["page_11"]["hr_excellent"], - contexts["page_11"]["hr_athlete"], + rhr_table_info["age_range"], + rhr_table_info["ranges"]["Poor"], + rhr_table_info["ranges"]["Below Average"], + rhr_table_info["ranges"]["Average"], + rhr_table_info["ranges"]["Above Average"], + rhr_table_info["ranges"]["Good"], + rhr_table_info["ranges"]["Excellent"], + rhr_table_info["ranges"]["Athlete"], ] ] rhr_colors = [["#b2ebf2"] + ["#f5f5f5"] * 7] diff --git a/notebooks/test_page_5_rmr.py b/notebooks/test_page_5_rmr.py index 8ac7f0f..158eb27 100644 --- a/notebooks/test_page_5_rmr.py +++ b/notebooks/test_page_5_rmr.py @@ -14,7 +14,7 @@ Expected values from PDF (Page 5): import sys import pandas as pd -sys.path.insert(0, '/Users/macbook/bio-performx') +sys.path.insert(0, '/home/oluwasanmi/Documents/Work/MKD/report_generation') from app.services.context_generator import ContextGenerator @@ -41,8 +41,8 @@ PATIENT_DATA = { USE_PDF_RMR = True # Set to True to use PDF's measured RMR instead of calculating from CSV # File paths -PNOE_FILE = "Pnoe_20250729_1550-Moran_Keirstyn (2).csv" -SPIROMETRY_FILE = "data/extracted_spirometry_table.csv" +PNOE_FILE = "/home/oluwasanmi/Documents/Work/MKD/report_generation/data/Pnoe_20250729_1550-Moran_Keirstyn.csv" +SPIROMETRY_FILE = "/home/oluwasanmi/Documents/Work/MKD/report_generation/data/spirometry_data.csv" def main(): print("=" * 80)