diff --git a/app/report_gen/page_8.html b/app/report_gen/page_8.html index 63d7391..7dfd679 100644 --- a/app/report_gen/page_8.html +++ b/app/report_gen/page_8.html @@ -17,11 +17,6 @@
-

- VO2 Max - {{ vo2_max_value | default('49.5') }} ({{ - vo2_max_percentile | default('100th percentile') }}) -

-
Dict: """Calculate VO2 Max table data based on age and gender""" - # VO2 Max Master Chart Data (from notebook) + # VO2 Max Master Chart Data (from notebook - matching exact values) 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), + "Very Poor": (29.0, 38.1), + "Poor": (38.1, 44.9), + "Fair": (44.9, 50.2), + "Good": (50.2, 61.8), + "Excellent": (57.1, 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), + "Very Poor": (27.2, 34.1), + "Poor": (34.1, 39.6), + "Fair": (39.6, 45.2), + "Good": (45.2, 51.6), + "Excellent": (51.6, 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), + "Very Poor": (24.2, 30.5), + "Poor": (30.5, 35.7), + "Fair": (35.7, 40.3), + "Good": (40.3, 46.7), + "Excellent": (46.7, 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), + "Very Poor": (20.9, 26.1), + "Poor": (26.1, 30.7), + "Fair": (30.7, 35.1), + "Good": (35.1, 41.2), + "Excellent": (41.2, 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), + "60-69 (M)": { + "Very Poor": (17.4, 22.4), + "Poor": (22.4, 26.6), + "Fair": (26.6, 30.5), + "Good": (30.5, 36.1), + "Excellent": (36.1, 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), + "Very Poor": (21.7, 28.6), + "Poor": (28.6, 34.6), + "Fair": (34.6, 40.6), + "Good": (40.6, 46.5), + "Excellent": (46.5, 56.0), "Superior": (56.0, None), }, "30-39 (F)": { - "Very Poor": (None, 24.1), + "Very Poor": (19.0, 24.1), "Poor": (24.1, 28.2), "Fair": (28.2, 32.2), "Good": (32.2, 35.7), @@ -896,42 +896,50 @@ class ContextGenerator: "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), + "Very Poor": (17.0, 21.3), + "Poor": (21.3, 24.9), + "Fair": (24.9, 28.7), + "Good": (28.7, 34.0), + "Excellent": (34.0, 41.7), + "Superior": (41.7, 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), + "Very Poor": (16.0, 19.1), + "Poor": (19.1, 24.4), + "Fair": (21.8, 27.6), + "Good": (25.2, 28.6), + "Excellent": (28.6, 35.9), + "Superior": (35.9, 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), + "60-69 (F)": { + "Very Poor": (13.4, 16.5), + "Poor": (16.5, 18.9), + "Fair": (18.9, 21.2), + "Good": (21.2, 24.6), + "Excellent": (24.6, 29.4), + "Superior": (29.4, None), }, } - # Determine age bracket - if age < 30: + # Determine age bracket (matching notebook logic) + if 20 <= age <= 29: age_key = "20-29" - elif age < 40: + elif 30 <= age <= 39: age_key = "30-39" - elif age < 50: + elif 40 <= age <= 49: age_key = "40-49" - elif age < 60: + elif 50 <= age <= 59: age_key = "50-59" + elif 60 <= age <= 69: + age_key = "60-69" else: - age_key = "60+" + # Default to closest range + if age < 20: + age_key = "20-29" + elif age >= 70: + age_key = "60-69" + else: + age_key = "30-39" # fallback gender_key = "(M)" if gender.lower() == "male" else "(F)" key = f"{age_key} {gender_key}" @@ -951,8 +959,35 @@ class ContextGenerator: return { "age_range": age_key, "ranges": result, + "raw_ranges": ranges, # Keep raw ranges for category determination } + def _determine_vo2_max_category(self, vo2_max: float, age: int, gender: str) -> str: + """Determine VO2 max category based on value, age, and gender (matching notebook logic)""" + vo2_max_table_info = self._calculate_vo2_max_table_data(age, gender) + ranges = vo2_max_table_info["raw_ranges"] + + categories = ["Very Poor", "Poor", "Fair", "Good", "Excellent", "Superior"] + + # Check Superior category first (open-ended) + min_val, max_val = ranges["Superior"] + if max_val is None and vo2_max >= min_val: + return "Superior" + + # Check other categories from Excellent down to Very Poor + # Ranges are typically [min, max) - inclusive of min, exclusive of max + for category in reversed( + categories[:-1] + ): # Exclude Superior as we already checked it + min_val, max_val = ranges[category] + # Check if value falls in this range (inclusive of min, exclusive of max) + if min_val <= vo2_max < max_val: + return category + + # If value is below all ranges, return Very Poor + # This handles the case where vo2_max < min of Very Poor + return "Very Poor" + def calculate_rmr_and_fuel_source(self) -> Dict: """Calculate RMR and fuel source from pnoe data""" metrics = {} @@ -1128,11 +1163,22 @@ class ContextGenerator: self.patient_info["age"], self.patient_info["gender"] ) + # Determine patient's VO2 max category + vo2_max_value = pnoe_metrics.get("vo2_max_per_kg", 0.0) + category = self._determine_vo2_max_category( + vo2_max_value, + self.patient_info["age"], + self.patient_info["gender"], + ) + # VO2 Max Table + gender_label = ( + "F" if self.patient_info["gender"].lower() == "female" else "M" + ) + age_range_label = f"{vo2_max_table_info['age_range']} ({gender_label})" + vo2_max_columns = [ - "Age (F)" - if self.patient_info["gender"].lower() == "female" - else "Age (M)", + "Age", "Very Poor", "Poor", "Fair", @@ -1142,7 +1188,7 @@ class ContextGenerator: ] vo2_max_data = [ [ - vo2_max_table_info["age_range"], + age_range_label, vo2_max_table_info["ranges"]["Very Poor"], vo2_max_table_info["ranges"]["Poor"], vo2_max_table_info["ranges"]["Fair"], @@ -1151,23 +1197,13 @@ class ContextGenerator: vo2_max_table_info["ranges"]["Superior"], ] ] - vo2_max_colors = [ - [ - "#b2ebf2", - "#f5f5f5", - "#f5f5f5", - "#f5f5f5", - "#f5f5f5", - "#f5f5f5", - "#f5f5f5", - ] - ] contexts["page_8"]["vo2_max_table"] = ( graph_generator.generate_vo2_max_table( data=vo2_max_data, columns=vo2_max_columns, - cell_colors=vo2_max_colors, + vo2_max_value=vo2_max_value, + category=category, save_as_base64=True, ) ) diff --git a/app/services/graph_generator.py b/app/services/graph_generator.py index 0245525..340d210 100644 --- a/app/services/graph_generator.py +++ b/app/services/graph_generator.py @@ -1310,15 +1310,19 @@ class GraphGenerator: self, data: list[list], columns: list[str], + vo2_max_value: float = None, + category: str = None, cell_colors: list[list[str]] = None, save_as_base64: bool = True, ) -> str: """ - Generate VO2 Max table as an image with optimized sizing. + Generate VO2 Max table as an image with optimized sizing, highlighting the patient's category. Args: data: List of rows (each row is a list of values) columns: List of column headers + vo2_max_value: Patient's VO2 max value (for title and arrow) + category: Category that the patient falls into (e.g., 'Good', 'Excellent') cell_colors: Optional matrix of cell colors save_as_base64: If True, return base64 string @@ -1327,12 +1331,11 @@ class GraphGenerator: """ import io - # Fixed optimal sizing for VO2 Max table (8 columns, 1 data row) - fig, ax = plt.subplots(figsize=(16, 2.5)) - ax.axis("off") + from matplotlib.patches import FancyArrowPatch, RegularPolygon - # Even column widths for VO2 Max table - col_widths = [1.0 / len(columns)] * len(columns) + # Fixed optimal sizing for VO2 Max table (7 columns, 1 data row) + fig, ax = plt.subplots(figsize=(14, 3)) + ax.axis("off") # Create table table = ax.table( @@ -1340,33 +1343,87 @@ class GraphGenerator: colLabels=columns, cellLoc="center", loc="center", - colColours=["#4dd0e1"] * len(columns), - colWidths=col_widths, + bbox=[0, 0, 1, 1], ) # Style the table table.auto_set_font_size(False) table.set_fontsize(11) - table.scale(1, 3.0) + table.scale(1, 2.5) - # Apply cell colors - if cell_colors: - for i, row_colors in enumerate(cell_colors): - for j, color in enumerate(row_colors): - if color and j < len(columns): - cell = table[(i + 1, j)] - cell.set_facecolor(color) + # Header row styling (cyan background) + for i in range(len(columns)): + cell = table[(0, i)] + cell.set_facecolor("#7dd3fc") # cyan-300 equivalent + cell.set_text_props(weight="bold", color="black", fontsize=12) + cell.set_edgecolor("#9ca3af") # gray-400 + cell.set_linewidth(1) - # Style all cells - for (row, col), cell in table.get_celld().items(): - if row == 0: - cell.set_text_props(weight="bold", fontsize=12) - cell.set_edgecolor("#333333") - cell.set_linewidth(1.5) + # Find the column index for the category (if provided) + category_index = None + if category and category in columns: + category_index = columns.index(category) + + # Data row styling + for i in range(len(data[0])): + cell = table[(1, i)] + if i == 0: # Age column + cell.set_facecolor("#a5f3fc") # cyan-200 + cell.set_text_props(weight="semibold", color="black", fontsize=11) else: - cell.set_edgecolor("#666666") - cell.set_linewidth(1.0) - cell.set_text_props(fontsize=10) + cell.set_facecolor("#f3f4f6") # gray-100 + cell.set_text_props(color="black", fontsize=10) + # Bold the cell that corresponds to the patient's category + if category_index is not None and i == category_index: + cell.set_text_props(weight="bold", color="black", fontsize=11) + cell.set_edgecolor("#9ca3af") # gray-400 + cell.set_linewidth(1) + + # Add arrow indicator below the category column + if category_index is not None: + # Calculate position + cell_width = 1.0 / len(columns) + arrow_x = (category_index + 0.5) * cell_width + + # Draw arrow pointing up + arrow = FancyArrowPatch( + (arrow_x, -0.15), + (arrow_x, -0.05), + arrowstyle="->", + mutation_scale=20, + linewidth=2, + color="black", + transform=ax.transAxes, + ) + ax.add_patch(arrow) + + # Add triangle at the top + triangle = RegularPolygon( + (arrow_x, -0.05), + 3, + radius=0.02, + orientation=np.pi / 2, + color="black", + transform=ax.transAxes, + ) + ax.add_patch(triangle) + + # Set title - calculate approximate percentile + if vo2_max_value is not None: + if category == "Superior": + percentile = "100th percentile" + else: + percentile_map = { + "Very Poor": "1st-10th percentile", + "Poor": "10th-20th percentile", + "Fair": "20th-40th percentile", + "Good": "40th-60th percentile", + "Excellent": "60th-80th percentile", + } + percentile = percentile_map.get(category, "N/A") + + title = f"VO2 Max - {vo2_max_value:.1f} ({percentile})" + ax.set_title(title, fontsize=14, fontweight="bold", pad=20) if save_as_base64: buf = io.BytesIO() diff --git a/notebooks/graphs.ipynb b/notebooks/graphs.ipynb index b5c88df..7d12178 100644 --- a/notebooks/graphs.ipynb +++ b/notebooks/graphs.ipynb @@ -2066,7 +2066,7 @@ ], "metadata": { "kernelspec": { - "display_name": "report-generation", + "display_name": ".venv", "language": "python", "name": "python3" },