
str:
+ """Determine resting heart rate category based on value, age, and gender (matching notebook logic)"""
+ rhr_table_info = self._calculate_rhr_table_data(age, gender)
+ ranges = rhr_table_info["raw_ranges"]
+
+ # Check Poor category first (open-ended at top)
+ min_val, max_val = ranges["Poor"]
+ if max_val is None and rhr >= min_val:
+ return "Poor"
+
+ # Check other categories from Below Average down to Athlete
+ # For RHR, lower is better, so we check from highest to lowest
+ for category in [
+ "Below Average",
+ "Average",
+ "Above Average",
+ "Good",
+ "Excellent",
+ "Athlete",
+ ]:
+ min_val, max_val = ranges[category]
+ # Check if value falls in this range (inclusive of min, exclusive of max)
+ if min_val <= rhr < max_val:
+ return category
+
+ # If value is below all ranges (below Athlete minimum), return Athlete
+ # This handles the case where rhr < min of Athlete
+ return "Athlete"
+
def _calculate_zone_metrics(self, pnoe_metrics: Dict) -> Dict:
"""Calculate detailed metrics for each heart rate zone based on actual data"""
import math
@@ -1294,14 +1324,24 @@ class ContextGenerator:
self.patient_info["age"], self.patient_info["gender"]
)
- gender_label = (
- "Age (F)"
- if self.patient_info["gender"].lower().startswith("f")
- else "Age (M)"
+ # Get resting heart rate value and determine category
+ # Extract numeric value from "53bpm" format (resting_hr_metrics already calculated above)
+ rhr_value_str = resting_hr_metrics.get("resting_heart_rate", "0bpm")
+ rhr_value = float(rhr_value_str.replace("bpm", "").strip())
+
+ category = self._determine_rhr_category(
+ rhr_value,
+ self.patient_info["age"],
+ self.patient_info["gender"],
)
+ gender_label = (
+ "F" if self.patient_info["gender"].lower().startswith("f") else "M"
+ )
+ age_range_label = f"{rhr_table_info['age_range']} ({gender_label})"
+
rhr_columns = [
- gender_label,
+ "Age",
"Poor",
"Below Average",
"Average",
@@ -1312,7 +1352,7 @@ class ContextGenerator:
]
rhr_data = [
[
- rhr_table_info["age_range"],
+ age_range_label,
rhr_table_info["ranges"]["Poor"],
rhr_table_info["ranges"]["Below Average"],
rhr_table_info["ranges"]["Average"],
@@ -1322,13 +1362,13 @@ class ContextGenerator:
rhr_table_info["ranges"]["Athlete"],
]
]
- rhr_colors = [["#b2ebf2"] + ["#f5f5f5"] * 7]
contexts["page_11"]["rhr_table"] = (
graph_generator.generate_resting_heart_rate_table(
data=rhr_data,
columns=rhr_columns,
- cell_colors=rhr_colors,
+ rhr_value=rhr_value,
+ category=category,
save_as_base64=True,
)
)
diff --git a/app/services/graph_generator.py b/app/services/graph_generator.py
index 340d210..4c9f31a 100644
--- a/app/services/graph_generator.py
+++ b/app/services/graph_generator.py
@@ -1334,7 +1334,7 @@ class GraphGenerator:
from matplotlib.patches import FancyArrowPatch, RegularPolygon
# Fixed optimal sizing for VO2 Max table (7 columns, 1 data row)
- fig, ax = plt.subplots(figsize=(14, 3))
+ fig, ax = plt.subplots(figsize=(14, 2.2))
ax.axis("off")
# Create table
@@ -1349,7 +1349,7 @@ class GraphGenerator:
# Style the table
table.auto_set_font_size(False)
table.set_fontsize(11)
- table.scale(1, 2.5)
+ table.scale(1, 1.8)
# Header row styling (cyan background)
for i in range(len(columns)):
@@ -1423,7 +1423,7 @@ class GraphGenerator:
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)
+ ax.set_title(title, fontsize=14, fontweight="bold", pad=10)
if save_as_base64:
buf = io.BytesIO()
@@ -1433,7 +1433,7 @@ class GraphGenerator:
bbox_inches="tight",
dpi=300,
facecolor="white",
- pad_inches=0.1,
+ pad_inches=0.05,
)
plt.close(fig)
buf.seek(0)
@@ -1447,7 +1447,7 @@ class GraphGenerator:
bbox_inches="tight",
dpi=300,
facecolor="white",
- pad_inches=0.1,
+ pad_inches=0.05,
)
plt.close(fig)
return str(output_path)
@@ -1506,7 +1506,7 @@ class GraphGenerator:
# Style the table
table.auto_set_font_size(False)
table.set_fontsize(11)
- table.scale(1, 2.8)
+ table.scale(1, 2.0)
# Apply cell colors
if cell_colors:
@@ -1558,15 +1558,19 @@ class GraphGenerator:
self,
data: list[list],
columns: list[str],
+ rhr_value: float = None,
+ category: str = None,
cell_colors: list[list[str]] = None,
save_as_base64: bool = True,
) -> str:
"""
- Generate Resting Heart Rate table as an image with optimized sizing.
+ Generate Resting Heart Rate 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
+ rhr_value: Patient's resting heart rate value in bpm (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
@@ -1575,12 +1579,11 @@ class GraphGenerator:
"""
import io
- # Optimal sizing for RHR table (8 columns, 1 data row)
- fig, ax = plt.subplots(figsize=(18, 2.5))
- ax.axis("off")
+ from matplotlib.patches import FancyArrowPatch, RegularPolygon
- # Even column widths
- col_widths = [1.0 / len(columns)] * len(columns)
+ # Optimal sizing for RHR table (8 columns, 1 data row)
+ fig, ax = plt.subplots(figsize=(16, 2.2))
+ ax.axis("off")
# Create table
table = ax.table(
@@ -1588,33 +1591,77 @@ 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, 1.8)
- # 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)
+ # Highlight the category cell with light green background
+ if category_index is not None and i == category_index:
+ cell.set_facecolor("#d1fae5") # green-200 equivalent
+ cell.set_text_props(weight="bold", color="black", fontsize=11)
+ else:
+ cell.set_facecolor("#f3f4f6") # gray-100
+ cell.set_text_props(color="black", fontsize=10)
+ 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
+ if rhr_value is not None:
+ title = f"Resting Heart Rate - {rhr_value:.0f}bpm"
+ ax.set_title(title, fontsize=14, fontweight="bold", pad=10)
if save_as_base64:
buf = io.BytesIO()
@@ -1624,7 +1671,7 @@ class GraphGenerator:
bbox_inches="tight",
dpi=300,
facecolor="white",
- pad_inches=0.1,
+ pad_inches=0.05,
)
plt.close(fig)
buf.seek(0)
@@ -1638,7 +1685,7 @@ class GraphGenerator:
bbox_inches="tight",
dpi=300,
facecolor="white",
- pad_inches=0.1,
+ pad_inches=0.05,
)
plt.close(fig)
return str(output_path)