From 0090b7002c9ec857afed7c07d2d30b8754128ded Mon Sep 17 00:00:00 2001 From: bolade Date: Tue, 18 Nov 2025 17:15:22 +0100 Subject: [PATCH] feat: Remove deprecated body fat percentage chart and integrate master chart for report generation - Deleted the old body fat percentage chart image. - Updated report generation to load the new body fat percentage master chart for improved accuracy and consistency. - Refactored context generation to reference the new chart in the report structure. --- ...g => body_fat_percentage_master_chart.png} | Bin .../context_generator.cpython-312.pyc | Bin 30745 -> 31072 bytes .../graph_generator.cpython-312.pyc | Bin 46136 -> 46136 bytes .../report_generator.cpython-312.pyc | Bin 21250 -> 22070 bytes app/services/context_generator.py | 101 +++++++++++------- app/services/report_generator.py | 18 ++++ 6 files changed, 81 insertions(+), 38 deletions(-) rename app/{body_fat_percentage_chart.png => body_fat_percentage_master_chart.png} (100%) diff --git a/app/body_fat_percentage_chart.png b/app/body_fat_percentage_master_chart.png similarity index 100% rename from app/body_fat_percentage_chart.png rename to app/body_fat_percentage_master_chart.png diff --git a/app/services/__pycache__/context_generator.cpython-312.pyc b/app/services/__pycache__/context_generator.cpython-312.pyc index e3208d4cb2c2c303cf93d20665bf5557c5c23878..7171c8be9d7464edf317d5aac1eee4da692f8429 100644 GIT binary patch delta 1074 zcmbtSOK2Nc6n!IkB+W#UHRDeuTTVRsT9#Df_@~l|9nz9k$dcWleEeK_Q!A}U~+B>p}) z48Iw2>N;Nsc1a#0HJT*?fYd&GI+h#PP|cZ~r|)sz|0 zQeSXftGTBPdJcXc-gQ*?ky8AADC^Fhlv$VPmYMFt$R;ze9@)eFIlOX*3tpdyIV;1V z_4t}U>&bm7vz_9c%nTIqAjdYQ&L~PdR=PT%K9X=&J_xRNueIWqVVU)ci!#$+Y}jP@ zjnh#@*^ZTVr}R_gVIA?8^vM}FMR=uav8syrQMx>B*1?sel?X#L`%dFTlD(r+J{SPW z$Sw61DtVfR7ZWXTK0!7nqe=ZO70yla`27kM1)eGC=Py!qSA}ah@m#L}i(#jIdOmqM zHXBdJ7G{LR%!PFPvzgfWcq%<3#1fy!g|tU41h7yD;=Sxx{!3kx?PwR2%I?7P2Bqyk z(R-eH;ha)ErGNl{WS@@{+66p#s)>|tEUeW{m_Qr1ZS~7mzc?#f`^v3tWlz`LmfJ05 z)6v3kRjukY?rAkg=!`CFk#()8@h$xxseb)T!&8@jBd$Vp=|y0gAn8IH0fkF-)f%Iq z+Dp>KBm$U9>8e8LV`7k`#mOqbgoaemy~s^`q$16gW(y*f3;ugdQ#P8jXU{Ghk+zo7 zfmVCIclBLF)j}H3#!MpHo0(fWa&SmV3Ce~A9R?32ds$PyXdO_>JVYx2MX3C FF9BbsV=Vvx delta 795 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 0: @@ -317,7 +319,10 @@ class ContextGenerator: parts = zone_clean.split("-") if len(parts) == 2: try: - start, end = int(parts[0]), int(parts[1].replace("+", "")) + start, end = ( + int(parts[0]), + int(parts[1].replace("+", "")), + ) if start <= vo2_pulse_drop_bpm <= end: vo2_pulse_drop_zone = f"Zone {i}" break @@ -332,15 +337,17 @@ class ContextGenerator: break except ValueError: pass - + # Calculate slope of VO2 Breath vo2_breath_slope = self.pnoe_df["VO2 Breath_smoothed"].diff() - vo2_breath_slope_smoothed = vo2_breath_slope.rolling(window=window, min_periods=1).mean() - + vo2_breath_slope_smoothed = vo2_breath_slope.rolling( + window=window, min_periods=1 + ).mean() + # Find where VO2 Breath begins to drop mask_breath = vo2_breath_slope_smoothed <= 0 drop_indices_breath = mask_breath[mask_breath].index - + vo2_breath_drop_bpm = None vo2_breath_drop_zone = None if len(drop_indices_breath) > 0: @@ -357,7 +364,10 @@ class ContextGenerator: parts = zone_clean.split("-") if len(parts) == 2: try: - start, end = int(parts[0]), int(parts[1].replace("+", "")) + start, end = ( + int(parts[0]), + int(parts[1].replace("+", "")), + ) if start <= vo2_breath_drop_bpm <= end: vo2_breath_drop_zone = f"Zone {i}" break @@ -372,7 +382,7 @@ class ContextGenerator: break except ValueError: pass - + return { "vo2_pulse_drop_bpm": vo2_pulse_drop_bpm or 180, "vo2_pulse_drop_zone": vo2_pulse_drop_zone or "Zone 4", @@ -384,30 +394,37 @@ class ContextGenerator: """Calculate fat metabolism metrics for page 11""" fat_max_idx = self.pnoe_df["FAT_smoothed"].idxmax() fat_max_row = self.pnoe_df.loc[fat_max_idx] - + fat_max_value = pnoe_metrics.get("fat_max_value", 0) fat_max_hr = pnoe_metrics.get("fat_max_hr", 0) max_hr = 220 - self.patient_info["age"] fat_max_heart_rate_pct = (fat_max_hr / max_hr * 100) if max_hr > 0 else 0 - + # Find carbs and fat crossover point crossover_idx = None for idx in self.pnoe_df.index: - if self.pnoe_df.loc[idx, "CHO_smoothed"] > self.pnoe_df.loc[idx, "FAT_smoothed"]: + if ( + self.pnoe_df.loc[idx, "CHO_smoothed"] + > self.pnoe_df.loc[idx, "FAT_smoothed"] + ): crossover_idx = idx break - + crossover_bpm = None crossover_heart_rate_pct = None if crossover_idx is not None: crossover_row = self.pnoe_df.loc[crossover_idx] crossover_bpm = int(crossover_row["HR(bpm)_smoothed"]) - crossover_heart_rate_pct = (crossover_bpm / max_hr * 100) if max_hr > 0 else 0 - + crossover_heart_rate_pct = ( + (crossover_bpm / max_hr * 100) if max_hr > 0 else 0 + ) + # Get speed and incline at fat max fat_max_speed = fat_max_row.get("Speed", 0) - fat_max_incline = fat_max_row.get("Incline", 2.0) if "Incline" in fat_max_row else 2.0 - + fat_max_incline = ( + fat_max_row.get("Incline", 2.0) if "Incline" in fat_max_row else 2.0 + ) + return { "fat_max_value": f"{fat_max_value:.2f}Kcals/min", "fat_max_heart_rate": f"{fat_max_heart_rate_pct:.0f}% of Max Heart Rate", @@ -424,10 +441,10 @@ class ContextGenerator: peak_idx = self.pnoe_df["HR(bpm)_smoothed"].idxmax() peak_hr = self.pnoe_df.loc[peak_idx, "HR(bpm)_smoothed"] peak_time = self.pnoe_df.loc[peak_idx, "T(sec)"] - + # Find recovery phase (after peak) recovery_df = self.pnoe_df[self.pnoe_df["T(sec)"] > peak_time].copy() - + if len(recovery_df) == 0: return { "cardiac_recovery_time": "(1 minute)", @@ -437,36 +454,42 @@ class ContextGenerator: "breath_recovery_time": "(2.5 minute)", "breath_recovery_percentage": "76%", } - + # Cardiac recovery (1 minute) one_min_time = peak_time + 60 one_min_row = recovery_df[recovery_df["T(sec)"] <= one_min_time] if len(one_min_row) > 0: one_min_hr = one_min_row.iloc[-1]["HR(bpm)_smoothed"] - cardiac_recovery_pct = ((peak_hr - one_min_hr) / peak_hr * 100) if peak_hr > 0 else 0 + cardiac_recovery_pct = ( + ((peak_hr - one_min_hr) / peak_hr * 100) if peak_hr > 0 else 0 + ) else: cardiac_recovery_pct = 33 - + # Metabolic recovery (2 minutes) - using VCO2 two_min_time = peak_time + 120 peak_vco2 = self.pnoe_df.loc[peak_idx, "VCO2(ml/min)_smoothed"] two_min_row = recovery_df[recovery_df["T(sec)"] <= two_min_time] if len(two_min_row) > 0: two_min_vco2 = two_min_row.iloc[-1]["VCO2(ml/min)_smoothed"] - metabolic_recovery_pct = ((peak_vco2 - two_min_vco2) / peak_vco2 * 100) if peak_vco2 > 0 else 0 + metabolic_recovery_pct = ( + ((peak_vco2 - two_min_vco2) / peak_vco2 * 100) if peak_vco2 > 0 else 0 + ) else: metabolic_recovery_pct = 65 - + # Breath frequency recovery (2.5 minutes) two_five_min_time = peak_time + 150 peak_bf = self.pnoe_df.loc[peak_idx, "BF(bpm)_smoothed"] two_five_min_row = recovery_df[recovery_df["T(sec)"] <= two_five_min_time] if len(two_five_min_row) > 0: two_five_min_bf = two_five_min_row.iloc[-1]["BF(bpm)_smoothed"] - breath_recovery_pct = ((peak_bf - two_five_min_bf) / peak_bf * 100) if peak_bf > 0 else 0 + breath_recovery_pct = ( + ((peak_bf - two_five_min_bf) / peak_bf * 100) if peak_bf > 0 else 0 + ) else: breath_recovery_pct = 76 - + return { "cardiac_recovery_time": "(1 minute)", "cardiac_recovery_percentage": f"{int(cardiac_recovery_pct)}%", @@ -481,10 +504,10 @@ class ContextGenerator: # Get resting HR from beginning of test rest_phase = self.pnoe_df.head(30) # First 30 seconds resting_hr = rest_phase["HR(bpm)_smoothed"].mean() - + age = self.patient_info.get("age", 30) gender = self.patient_info.get("gender", "female").lower() - + # Determine age range if 26 <= age <= 35: age_range = "26-35" @@ -494,7 +517,7 @@ class ContextGenerator: age_range = "46-55" else: age_range = "26-35" # Default - + # HR ranges based on gender and age (simplified) if gender == "female": hr_ranges = { @@ -516,7 +539,7 @@ class ContextGenerator: "excellent": "55-61bpm", "athlete": "44-54bpm", } - + return { "resting_heart_rate": f"{int(resting_hr)}bpm", "hr_age_range": age_range, @@ -562,8 +585,8 @@ class ContextGenerator: if "RER" in self.pnoe_df.columns and "FAT(%)" in self.pnoe_df.columns: # Find rest phase with RER closest to 0.9 rest_phase = ( - self.pnoe_df[self.pnoe_df["MET"] <= 1.1].copy() - if "MET" in self.pnoe_df.columns + self.pnoe_df[self.pnoe_df["RER"] == 0.9].copy() + if "RER" in self.pnoe_df.columns else self.pnoe_df.copy() ) if not rest_phase.empty: @@ -720,7 +743,7 @@ class ContextGenerator: fat_metabolism_metrics = self._calculate_fat_metabolism_metrics(pnoe_metrics) recovery_metrics = self._calculate_recovery_metrics() resting_hr_metrics = self._calculate_resting_heart_rate_metrics() - + contexts["page_11"] = { "fat_metabolism_chart": graphs.get("fat_metabolism", ""), "recovery_chart": graphs.get("recovery", ""), @@ -735,14 +758,16 @@ class ContextGenerator: "patient_name": self.patient_info["name"], "page_number": i + 12, } - - # Page 18 - Glossary with Body Fat Percentage Chart + + # Page 18 - Glossary with Body Fat Percentage Master Chart contexts["page_18"] = { "patient_name": self.patient_info["name"], "page_number": 18, - "body_fat_percentage_chart": graphs.get("body_fat_percent", ""), + "body_fat_percentage_chart": graphs.get( + "body_fat_percentage_master_chart", "" + ), } - + # Page 19 contexts["page_19"] = { "patient_name": self.patient_info["name"], diff --git a/app/services/report_generator.py b/app/services/report_generator.py index 56f37c5..1919421 100644 --- a/app/services/report_generator.py +++ b/app/services/report_generator.py @@ -404,6 +404,23 @@ class ReportGeneratorService: print(f"Warning: Could not generate body fat percent chart: {e}") graphs_dict["body_fat_percent"] = "" + # Load static body fat percentage master chart for page 18 + master_chart_path = Path("app/body_fat_percentage_master_chart.png") + if master_chart_path.exists(): + try: + with open(master_chart_path, "rb") as f: + graphs_dict["body_fat_percentage_master_chart"] = base64.b64encode( + f.read() + ).decode("utf-8") + except Exception as e: + print(f"Warning: Could not load body fat percentage master chart: {e}") + graphs_dict["body_fat_percentage_master_chart"] = "" + else: + print( + f"Warning: Body fat percentage master chart not found at {master_chart_path}" + ) + graphs_dict["body_fat_percentage_master_chart"] = "" + # Generate spirometry chart print("Step 4: Generating spirometry chart...") try: @@ -419,6 +436,7 @@ class ReportGeneratorService: print("Spirometry chart generated successfully") except Exception as e: import traceback + error_details = traceback.format_exc() print(f"Warning: Could not generate spirometry chart: {e}") print(f"Error details: {error_details}")