From 29ad9e2265954b3697468b3f114a1cb83cc59a1f Mon Sep 17 00:00:00 2001 From: bolade Date: Fri, 21 Nov 2025 12:15:42 +0100 Subject: [PATCH] sighh --- app/report_gen/page_8.html | 4 +- .../context_generator.cpython-312.pyc | Bin 40877 -> 43566 bytes .../graph_generator.cpython-312.pyc | Bin 46387 -> 49398 bytes .../report_generator.cpython-312.pyc | Bin 22047 -> 22040 bytes app/services/context_generator.py | 576 +++++++++++++++--- notebooks/test_page_5_rmr.py | 6 +- 6 files changed, 487 insertions(+), 99 deletions(-) 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 a4f5275fe33c926fe055410f78d7f62d86627e4d..b291d4c95624efa3c7613421626d428c18c51108 100644 GIT binary patch delta 17432 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 J1|e1de*qGszFGhP delta 14747 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 AQ2+n{ delta 6830 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�<7|HwA-i2=iw|V^NJ3gSfFA*N&dTSt4W* zVIy|IFzw;}EJETQnjlHP8-BI~y@#PGQw>G%HDV&1o`uROUPn*t*_!6}L5b>(xW<2bhrbor!y_ftRo^))|zeYn6+9jlQ} zrQSJKC`kwSrKdJ0Mf_V~{k+g(V-xG3B}|+Plk4d5LSOwEeZPVp+u@>M`c%A5kW|`*AgtuRL}_W=FZiXL;6B z+j?EUo6qC(T*AB|zJ+^Tn<09I5A%{a8pv|YCN9A%L3M)n0N{1ND*gk$C5fA{>Ff^2 z6vNC$f*B!$(rYyatO1DJ6iIjwMv0w%4}-E`4`qOktQR2I1ztuJUvIwTnDj2NGI9%4 zigPh97z_7D_=#_Jx|p@%0+>Nfxr~sI_ajV zTQ^zjz=VU@-(bivC{YkEVz8@2VRX?Ip_R1RuKCT(O>rX>q3^-R(Q}HA(R`oA1lV5_ zQZcuK(?T>`4){j^R&RM5#ftqLyk7t=0d4~iZWX6OkLCS;_A~K5?nwsvwXnQ3)=!tv zNr$svqW`3mOS-8SI=Z?W?yPR&mJ=vCQX?3GLxon7%>-79VLIyP?H<}K%S zZ#5>xLh>=Y15gP#L5j@m)rz46=;)@~e^)FPW(GuW1AYPcCE)J>{{V1d@;?N~fIkLr zGpM%#R{$RYz5ytJG!MZ4_N_c=U+SZ`B9eblIw1YKBOp0nmS0$b1S?8;zExi7`Z%Zdh zO;?;=MHSuJa;C1)7djSY+p)AsFGA0>h2P9t8i}d>VQo44l+@FIo8G}+J>Ooqp;+1} L{fABb5zF{LKaPaD diff --git a/app/services/__pycache__/report_generator.cpython-312.pyc b/app/services/__pycache__/report_generator.cpython-312.pyc index 9a5e4a9d167cb32d2c13a3008e7efbd5f2785028..602f5625b16d262c16b2816771214288d492d260 100644 GIT binary patch delta 2206 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 VUyeM%_x1!bnq*M^2+&a={Rd-03S 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)