""" 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 typing import Any, Dict, Optional, Tuple import pandas as pd class ContextGenerator: """Generate context data for report pages""" def __init__(self): self.pnoe_df = None self.spirometry_df = None self.seca_df = None self.patient_info = {} def load_data( self, pnoe_path: str, spirometry_path: str, seca_path: Optional[str] = None, ): """Load all required datasets""" self.pnoe_df = pd.read_csv(pnoe_path, delimiter=";") self.spirometry_df = pd.read_csv(spirometry_path) if seca_path: self.seca_df = pd.read_excel(seca_path) else: self.seca_df = None self._preprocess_pnoe_data() def _preprocess_pnoe_data(self): """Apply preprocessing steps to Pnoe data""" # Convert numeric columns for col in self.pnoe_df.columns: try: self.pnoe_df[col] = pd.to_numeric(self.pnoe_df[col]) except (ValueError, TypeError): pass self.pnoe_df["VO2 Pulse"] = ( self.pnoe_df["VO2(ml/min)"] / self.pnoe_df["HR(bpm)"] ) self.pnoe_df["VO2 Breath"] = ( self.pnoe_df["VO2(ml/min)"] / self.pnoe_df["BF(bpm)"] ) self.pnoe_df["CHO"] = ( self.pnoe_df["EE(kcal/min)"] * self.pnoe_df["CARBS(%)"] / 100 ) self.pnoe_df["FAT"] = ( self.pnoe_df["EE(kcal/min)"] * self.pnoe_df["FAT(%)"] / 100 ) window_size = 10 columns_to_smooth = [ "VO2(ml/min)", "VCO2(ml/min)", "HR(bpm)", "VT(l)", "BF(bpm)", "VE(l/min)", "VO2 Pulse", "VO2 Breath", "CHO", "FAT", ] for col in columns_to_smooth: if col in self.pnoe_df.columns: self.pnoe_df[f"{col}_smoothed"] = ( self.pnoe_df[col].rolling(window=window_size, min_periods=1).mean() ) def extract_patient_info(self, patient_name: str) -> Dict: """Extract patient information from SECA dataset or use provided patient_info""" if self.seca_df is not None: patient_data = self.seca_df[ self.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)) self.patient_info = { "name": f"{row.get('FirstName', '')} {row.get('LastName', '')}", "first_name": row.get("FirstName", ""), "last_name": row.get("LastName", ""), "age": int(row.get("Age", 0)), "height": f"{row.get('Height', '')}", "weight": weight_kg, "gender": row.get("Gender", "").lower(), "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, } # If patient_info is already set (from manual input), calculate fat_mass and lean_mass elif "weight" in self.patient_info and "fat_percentage" in self.patient_info: weight_kg = self.patient_info["weight"] fat_pct = self.patient_info["fat_percentage"] self.patient_info["fat_mass_lbs"] = weight_kg * fat_pct / 100 * 2.20462 self.patient_info["lean_mass_lbs"] = ( weight_kg * (1 - fat_pct / 100) * 2.20462 ) return self.patient_info def calculate_spirometry_metrics( self, metric_overrides: Optional[Dict] = None ) -> Dict: """Calculate spirometry-related metrics""" if metric_overrides is None: metric_overrides = {} metrics = {} for param in ["FVC", "FEV1", "FEV1/FVC%"]: param_key = param.lower().replace("/", "_").replace("%", "_pct") if f"{param_key}_best" in metric_overrides: metrics[f"{param_key}_best"] = float( metric_overrides[f"{param_key}_best"] ) else: row = self.spirometry_df.loc[ self.spirometry_df["Parameters"].str.strip() == param ] if not row.empty: value = row["Best"].values[0] if pd.notna(value): try: metrics[f"{param_key}_best"] = float(value) except (ValueError, TypeError): pass # Skip if conversion fails if f"{param_key}_pred" in metric_overrides: metrics[f"{param_key}_pred"] = float( metric_overrides[f"{param_key}_pred"] ) else: row = self.spirometry_df.loc[ self.spirometry_df["Parameters"].str.strip() == param ] if not row.empty: value = row["%Pred."].values[0] if pd.notna(value): try: metrics[f"{param_key}_pred"] = float(value) except (ValueError, TypeError): pass # Skip if conversion fails return metrics def calculate_pnoe_metrics(self, metric_overrides: Optional[Dict] = None) -> Dict: """Calculate all Pnoe-derived metrics""" if metric_overrides is None: metric_overrides = {} metrics = {} # VO2 Max metrics if "vo2_max" in metric_overrides: metrics["vo2_max"] = float(metric_overrides["vo2_max"]) else: metrics["vo2_max"] = self.pnoe_df["VO2(ml/min)_smoothed"].max() if "vo2_max_per_kg" in metric_overrides: metrics["vo2_max_per_kg"] = float(metric_overrides["vo2_max_per_kg"]) else: metrics["vo2_max_per_kg"] = metrics["vo2_max"] / self.patient_info["weight"] # Peak VT metrics if "peak_vt" in metric_overrides: metrics["peak_vt"] = float(metric_overrides["peak_vt"]) # Need to get HR from override or calculate if "peak_vt_hr" in metric_overrides: metrics["peak_vt_hr"] = float(metric_overrides["peak_vt_hr"]) else: peak_vt_idx = self.pnoe_df["VT(l)_smoothed"].idxmax() peak_vt_row = self.pnoe_df.loc[peak_vt_idx] metrics["peak_vt_hr"] = peak_vt_row["HR(bpm)_smoothed"] else: peak_vt_idx = self.pnoe_df["VT(l)_smoothed"].idxmax() peak_vt_row = self.pnoe_df.loc[peak_vt_idx] metrics["peak_vt"] = peak_vt_row["VT(l)_smoothed"] metrics["peak_vt_hr"] = peak_vt_row["HR(bpm)_smoothed"] # Fat Max metrics if "fat_max_value" in metric_overrides: metrics["fat_max_value"] = float(metric_overrides["fat_max_value"]) if "fat_max_hr" in metric_overrides: metrics["fat_max_hr"] = float(metric_overrides["fat_max_hr"]) else: fat_max_idx = self.pnoe_df["FAT_smoothed"].idxmax() fat_max_row = self.pnoe_df.loc[fat_max_idx] metrics["fat_max_hr"] = fat_max_row["HR(bpm)_smoothed"] else: fat_max_idx = self.pnoe_df["FAT_smoothed"].idxmax() fat_max_row = self.pnoe_df.loc[fat_max_idx] metrics["fat_max_value"] = fat_max_row["FAT_smoothed"] metrics["fat_max_hr"] = fat_max_row["HR(bpm)_smoothed"] # VT1 and VT2 thresholds if "vt1" in metric_overrides: metrics["vt1"] = metric_overrides["vt1"] else: vt1, _ = self._detect_thresholds() metrics["vt1"] = vt1 if "vt2" in metric_overrides: metrics["vt2"] = metric_overrides["vt2"] else: _, vt2 = self._detect_thresholds() metrics["vt2"] = vt2 # Heart rate zones if any(f"zone{i}_bpm" in metric_overrides for i in range(1, 6)): for i in range(1, 6): zone_key = f"zone{i}_bpm" if zone_key in metric_overrides: metrics[zone_key] = metric_overrides[zone_key] else: fat_max_idx = self.pnoe_df["FAT_smoothed"].idxmax() fat_max_row = self.pnoe_df.loc[fat_max_idx] zones = self._calculate_hr_zones( metrics["vt1"], metrics["vt2"], fat_max_row ) metrics.update(zones) return metrics def _detect_thresholds(self) -> Tuple[Optional[Dict], Optional[Dict]]: """Detect VT1 and VT2 thresholds""" condition = self.pnoe_df["CHO_smoothed"] > self.pnoe_df["FAT_smoothed"] crossover_indices = condition[condition].index vt1 = None if len(crossover_indices) > 0: vt1_idx = crossover_indices[0] vt1_row = self.pnoe_df.loc[vt1_idx] vt1 = { "HeartRate": vt1_row["HR(bpm)_smoothed"], "Speed": vt1_row["Speed"], "Time": vt1_row["T(sec)"], } ve_slope = self.pnoe_df["VE(l/min)_smoothed"].diff() second_derivative = ve_slope.diff() vt2_idx = second_derivative.idxmax() vt2 = None if pd.notna(vt2_idx): vt2_row = self.pnoe_df.loc[vt2_idx] vt2 = { "HeartRate": vt2_row["HR(bpm)_smoothed"], "Speed": vt2_row["Speed"], "Time": vt2_row["T(sec)"], } return vt1, vt2 def _calculate_hr_zones( self, vt1: Optional[Dict], vt2: Optional[Dict], fat_max_row: pd.Series ) -> Dict: """Calculate heart rate zones based on thresholds""" zones = {} if vt1 and vt2: zone_1_start = fat_max_row["HR(bpm)_smoothed"] - 15 zone_2_start = fat_max_row["HR(bpm)_smoothed"] zone_3_start = vt1["HeartRate"] zone_4_start = vt2["HeartRate"] - 10 zone_5_start = vt2["HeartRate"] + 10 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 def _calculate_vo2_drop_points(self, pnoe_metrics: Dict) -> Dict: """Calculate VO2 Pulse and VO2 Breath drop points""" # Calculate slope of VO2 Pulse vo2_pulse_slope = self.pnoe_df["VO2 Pulse_smoothed"].diff() window = max(1, len(self.pnoe_df) // 3) # Ensure window is at least 1 vo2_pulse_slope_smoothed = vo2_pulse_slope.rolling( window=window, min_periods=1 ).mean() # Find where VO2 Pulse begins to drop (slope becomes negative) mask_pulse = vo2_pulse_slope_smoothed <= 0 drop_indices_pulse = mask_pulse[mask_pulse].index vo2_pulse_drop_bpm = None vo2_pulse_drop_zone = None if len(drop_indices_pulse) > 0: drop_idx = drop_indices_pulse[0] drop_row = self.pnoe_df.loc[drop_idx] vo2_pulse_drop_bpm = int(drop_row["HR(bpm)_smoothed"]) # Determine zone based on HR zones if pnoe_metrics.get("zone1_bpm") and vo2_pulse_drop_bpm: zones = [pnoe_metrics.get(f"zone{i}_bpm", "") for i in range(1, 6)] for i, zone_str in enumerate(zones, 1): if zone_str: zone_clean = zone_str.replace("bpm", "").strip() if "-" in zone_clean: parts = zone_clean.split("-") if len(parts) == 2: try: start, end = ( int(parts[0]), int(parts[1].replace("+", "")), ) if start <= vo2_pulse_drop_bpm <= end: vo2_pulse_drop_zone = f"Zone {i}" break except ValueError: pass elif "+" in zone_clean: # Zone 5 format: "180+bpm" try: start = int(zone_clean.replace("+", "")) if vo2_pulse_drop_bpm >= start: vo2_pulse_drop_zone = f"Zone {i}" 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() # 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: drop_idx = drop_indices_breath[0] drop_row = self.pnoe_df.loc[drop_idx] vo2_breath_drop_bpm = int(drop_row["HR(bpm)_smoothed"]) # Determine zone if pnoe_metrics.get("zone1_bpm") and vo2_breath_drop_bpm: zones = [pnoe_metrics.get(f"zone{i}_bpm", "") for i in range(1, 6)] for i, zone_str in enumerate(zones, 1): if zone_str: zone_clean = zone_str.replace("bpm", "").strip() if "-" in zone_clean: parts = zone_clean.split("-") if len(parts) == 2: try: start, end = ( int(parts[0]), int(parts[1].replace("+", "")), ) if start <= vo2_breath_drop_bpm <= end: vo2_breath_drop_zone = f"Zone {i}" break except ValueError: pass elif "+" in zone_clean: # Zone 5 format: "180+bpm" try: start = int(zone_clean.replace("+", "")) if vo2_breath_drop_bpm >= start: vo2_breath_drop_zone = f"Zone {i}" 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", "vo2_breath_drop_bpm": vo2_breath_drop_bpm or 173, "vo2_breath_drop_zone": vo2_breath_drop_zone or "Zone 3", } def _calculate_fat_metabolism_metrics(self, pnoe_metrics: Dict) -> Dict: """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"] ): 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 ) # 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 ) 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", "fat_max_bpm": f"{int(fat_max_hr)} bpm", "fat_max_optimal": "*Optimal 10-12Kcals/minute", "crossover_bpm": f"{crossover_bpm or 100}bpm", "crossover_heart_rate": f"{crossover_heart_rate_pct or 51:.0f}% of Max Heart Rate", "fat_metabolism_note": f"{crossover_bpm or 100}bpm at a speed of {fat_max_speed:.1f}mph and incline of {fat_max_incline:.0f}%", } def _calculate_recovery_metrics(self) -> Dict: """Calculate recovery metrics for page 11""" # Find peak exercise point (max HR) 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)", "cardiac_recovery_percentage": "33%", "metabolic_recovery_time": "(2 minute)", "metabolic_recovery_percentage": "65%", "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 ) 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 ) 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 ) else: breath_recovery_pct = 76 return { "cardiac_recovery_time": "(1 minute)", "cardiac_recovery_percentage": f"{int(cardiac_recovery_pct)}%", "metabolic_recovery_time": "(2 minute)", "metabolic_recovery_percentage": f"{int(metabolic_recovery_pct)}%", "breath_recovery_time": "(2.5 minute)", "breath_recovery_percentage": f"{int(breath_recovery_pct)}%", } def _calculate_resting_heart_rate_metrics(self) -> Dict: """Calculate resting heart rate metrics for page 11""" # 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" elif 36 <= age <= 45: age_range = "36-45" elif 46 <= age <= 55: age_range = "46-55" else: age_range = "26-35" # Default # HR ranges based on gender and age (simplified) if gender == "female": hr_ranges = { "poor": "82bpm +", "below_avg": "75-81bpm", "average": "71-74bpm", "above_avg": "66-70bpm", "good": "62-65bpm", "excellent": "55-61bpm", "athlete": "44-54bpm", } else: # male hr_ranges = { "poor": "82bpm +", "below_avg": "75-81bpm", "average": "71-74bpm", "above_avg": "66-70bpm", "good": "62-65bpm", "excellent": "55-61bpm", "athlete": "44-54bpm", } return { "resting_heart_rate": f"{int(resting_hr)}bpm", "hr_age_range": age_range, "hr_poor": hr_ranges["poor"], "hr_below_avg": hr_ranges["below_avg"], "hr_average": hr_ranges["average"], "hr_above_avg": hr_ranges["above_avg"], "hr_good": hr_ranges["good"], "hr_excellent": hr_ranges["excellent"], "hr_athlete": hr_ranges["athlete"], } def calculate_rmr_and_fuel_source(self) -> Dict: """Calculate RMR and fuel source from pnoe data""" metrics = {} # Calculate RMR from resting phase (MET <= 1.1) if "MET" in self.pnoe_df.columns and "EE(kcal/day)" in self.pnoe_df.columns: rest_phase = self.pnoe_df[self.pnoe_df["MET"] <= 1.1] if not rest_phase.empty: rmr = rest_phase["EE(kcal/day)"].mean() metrics["rmr_kcal"] = float(rmr) else: # Fallback: use minimum EE(kcal/min) * 1440 (minutes per day) if "EE(kcal/min)" in self.pnoe_df.columns: min_ee = self.pnoe_df["EE(kcal/min)"].min() metrics["rmr_kcal"] = float(min_ee * 1440) else: metrics["rmr_kcal"] = 1500.0 # Default fallback else: # Fallback: estimate from weight (simplified) weight_kg = self.patient_info.get("weight", 70) gender = self.patient_info.get("gender", "female").lower() # Simplified RMR estimation: 22 kcal/kg/day for men, 20 for women if gender == "male": rmr = weight_kg * 22 else: rmr = weight_kg * 20 metrics["rmr_kcal"] = float(rmr) # Calculate fuel source from resting phase (RER == 0.9 or closest) 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["RER"] == 0.9].copy() if "RER" in self.pnoe_df.columns else self.pnoe_df.copy() ) if not rest_phase.empty: # Find row with RER closest to 0.9 if "RER" in rest_phase.columns: rest_phase["RER_diff"] = abs(rest_phase["RER"] - 0.9) closest_idx = rest_phase["RER_diff"].idxmin() fat_pct = rest_phase.loc[closest_idx, "FAT(%)"] metrics["rest_fat_percentage"] = float(fat_pct) else: # Use mean FAT(%) from rest phase metrics["rest_fat_percentage"] = float(rest_phase["FAT(%)"].mean()) else: # Fallback: use overall mean metrics["rest_fat_percentage"] = float(self.pnoe_df["FAT(%)"].mean()) else: # Fallback: use a default value metrics["rest_fat_percentage"] = 75.0 # Calculate caloric values for page 5 rmr = metrics["rmr_kcal"] neat = rmr * 0.25 # NEAT is typically 20-30% of RMR weight_loss_rate = 1.0 # 1 lb per week weight_loss_calories = 500.0 # 500 kcal deficit per day for 1 lb/week total_calories = rmr + neat - weight_loss_calories metrics["resting_calories"] = int(rmr) metrics["neat_calories"] = int(neat) metrics["weight_loss_calories"] = int(weight_loss_calories) metrics["weight_loss_rate"] = weight_loss_rate metrics["total_calories"] = int(total_calories) return metrics def generate_all_contexts( self, patient_name: str, graphs: Dict[str, str], metric_overrides: Optional[Dict] = None, graph_generator: Optional[Any] = None, ) -> Dict[str, Dict]: """Main method to generate all page contexts Returns: Dictionary with keys 'page_1', 'page_2', etc., each containing context data for that page """ if metric_overrides is None: metric_overrides = {} self.extract_patient_info(patient_name) # Extract metric overrides for spirometry and pnoe spirometry_overrides = metric_overrides.get("spirometry", {}) pnoe_overrides = metric_overrides.get("pnoe", {}) spirometry_metrics = self.calculate_spirometry_metrics(spirometry_overrides) pnoe_metrics = self.calculate_pnoe_metrics(pnoe_overrides) rmr_metrics = self.calculate_rmr_and_fuel_source() contexts = {} # Page 1 contexts["page_1"] = { "name": self.patient_info["name"], "surname": self.patient_info["last_name"], "date": datetime.now().strftime("%B %d, %Y"), } # Page 2 contexts["page_2"] = { "patient_name": self.patient_info["name"], "test_date": datetime.now().strftime("%B %d, %Y"), } # Pages 3, 6 (pages 4 and 5 are handled separately) for i in [0, 3]: # Skip indices 1 and 2 which are pages 4 and 5 contexts[f"page_{i + 3}"] = { "patient_name": self.patient_info["name"], "page_number": i + 3, } # Page 4 - Nutrition Guidelines with Body Composition contexts["page_4"] = { "patient_name": self.patient_info["name"], "page_number": 4, "fat_percentage": f"{self.patient_info['fat_percentage']:.1f}", "body_composition_chart": graphs.get("body_composition", ""), "body_fat_chart": graphs.get("body_fat_percent", ""), # Alias for template "body_fat_percent_chart": graphs.get( "body_fat_percent", "" ), # Keep for consistency } # Page 5 - Resting Metabolic Rate Assessment contexts["page_5"] = { "patient_name": self.patient_info["name"], "page_number": 5, "metabolism_chart": graphs.get("metabolism_chart", ""), "fuel_source_chart": graphs.get("fuel_source_chart", ""), "resting_calories": rmr_metrics.get("resting_calories", 1500), "neat_calories": rmr_metrics.get("neat_calories", 375), "weight_loss_calories": rmr_metrics.get("weight_loss_calories", 500), "weight_loss_rate": rmr_metrics.get("weight_loss_rate", 1.0), "total_calories": rmr_metrics.get("total_calories", 1375), } # Calculate FEV1 percentage for page 7 fev1_percentage = 0 if spirometry_metrics.get("fvc_best"): fev1_percentage = ( pnoe_metrics["peak_vt"] / spirometry_metrics["fvc_best"] ) * 100 # Page 7 contexts["page_7"] = { "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", ""), } # Page 8 contexts["page_8"] = { "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", ""), } if graph_generator: # VO2 Max Table vo2_max_columns = [ "Age (F)", "Very Poor", "Poor", "Fair", "Good", "Excellent", "Superior", ] 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_colors = [ [ "#b2ebf2", "#f5f5f5", "#f5f5f5", "#f5f5f5", "#f5f5f5", "#f5f5f5", "#f5f5f5", ] ] contexts["page_8"]["vo2_max_table"] = graph_generator.generate_table_image( data=vo2_max_data, columns=vo2_max_columns, cell_colors=vo2_max_colors, header_color="#4dd0e1", 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"], ] 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"] = { "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", ""), } # Page 10 - VO2 Pulse and VO2 Breath vo2_drop_metrics = self._calculate_vo2_drop_points(pnoe_metrics) contexts["page_10"] = { "vo2_pulse_chart": graphs.get("vo2_pulse", ""), "vo2_breath_chart": graphs.get("vo2_breath", ""), "vo2_pulse_drop_bpm": f"{vo2_drop_metrics['vo2_pulse_drop_bpm']} bpm", "vo2_pulse_drop_zone": vo2_drop_metrics["vo2_pulse_drop_zone"], "vo2_breath_drop_bpm": f"{vo2_drop_metrics['vo2_breath_drop_bpm']} bpm", "vo2_breath_drop_zone": vo2_drop_metrics["vo2_breath_drop_zone"], } # Page 11 - Fat Metabolism and Recovery 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", ""), **fat_metabolism_metrics, **recovery_metrics, **resting_hr_metrics, } if graph_generator: # Page 11 Resting Heart Rate Table rhr_columns = [ "Age (F)", "Poor", "Below Average", "Average", "Above Average", "Good", "Excellent", "Athlete", ] 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_colors = [["#b2ebf2"] + ["#f5f5f5"] * 7] contexts["page_11"]["rhr_table"] = graph_generator.generate_table_image( data=rhr_data, columns=rhr_columns, cell_colors=rhr_colors, header_color="#4dd0e1", save_as_base64=True, ) # Pages 12-17 for i in range(6): contexts[f"page_{i + 12}"] = { "patient_name": self.patient_info["name"], "page_number": i + 12, } # 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_percentage_master_chart", "" ), } # Page 19 contexts["page_19"] = { "patient_name": self.patient_info["name"], "page_number": 19, } return contexts