Good progress
This commit is contained in:
@@ -123,10 +123,6 @@
|
|||||||
|
|
||||||
<!-- Resting Heart Rate Table -->
|
<!-- Resting Heart Rate Table -->
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<h3 class="text-base font-bold text-black mb-2 text-center">
|
|
||||||
Resting Heart Rate - {{ resting_heart_rate | default('53bpm') }}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<img
|
<img
|
||||||
src="data:image/png;base64, {{ rhr_table }}"
|
src="data:image/png;base64, {{ rhr_table }}"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -707,10 +707,40 @@ class ContextGenerator:
|
|||||||
formatted_ranges[category] = f"{min_val}-{max_val}bpm"
|
formatted_ranges[category] = f"{min_val}-{max_val}bpm"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"age_range": f"{age_range} ({gender[0].upper()})",
|
"age_range": age_range,
|
||||||
"ranges": formatted_ranges,
|
"ranges": formatted_ranges,
|
||||||
|
"raw_ranges": ranges, # Keep raw ranges for category determination
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _determine_rhr_category(self, rhr: float, age: int, gender: str) -> 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:
|
def _calculate_zone_metrics(self, pnoe_metrics: Dict) -> Dict:
|
||||||
"""Calculate detailed metrics for each heart rate zone based on actual data"""
|
"""Calculate detailed metrics for each heart rate zone based on actual data"""
|
||||||
import math
|
import math
|
||||||
@@ -1294,14 +1324,24 @@ class ContextGenerator:
|
|||||||
self.patient_info["age"], self.patient_info["gender"]
|
self.patient_info["age"], self.patient_info["gender"]
|
||||||
)
|
)
|
||||||
|
|
||||||
gender_label = (
|
# Get resting heart rate value and determine category
|
||||||
"Age (F)"
|
# Extract numeric value from "53bpm" format (resting_hr_metrics already calculated above)
|
||||||
if self.patient_info["gender"].lower().startswith("f")
|
rhr_value_str = resting_hr_metrics.get("resting_heart_rate", "0bpm")
|
||||||
else "Age (M)"
|
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 = [
|
rhr_columns = [
|
||||||
gender_label,
|
"Age",
|
||||||
"Poor",
|
"Poor",
|
||||||
"Below Average",
|
"Below Average",
|
||||||
"Average",
|
"Average",
|
||||||
@@ -1312,7 +1352,7 @@ class ContextGenerator:
|
|||||||
]
|
]
|
||||||
rhr_data = [
|
rhr_data = [
|
||||||
[
|
[
|
||||||
rhr_table_info["age_range"],
|
age_range_label,
|
||||||
rhr_table_info["ranges"]["Poor"],
|
rhr_table_info["ranges"]["Poor"],
|
||||||
rhr_table_info["ranges"]["Below Average"],
|
rhr_table_info["ranges"]["Below Average"],
|
||||||
rhr_table_info["ranges"]["Average"],
|
rhr_table_info["ranges"]["Average"],
|
||||||
@@ -1322,13 +1362,13 @@ class ContextGenerator:
|
|||||||
rhr_table_info["ranges"]["Athlete"],
|
rhr_table_info["ranges"]["Athlete"],
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
rhr_colors = [["#b2ebf2"] + ["#f5f5f5"] * 7]
|
|
||||||
|
|
||||||
contexts["page_11"]["rhr_table"] = (
|
contexts["page_11"]["rhr_table"] = (
|
||||||
graph_generator.generate_resting_heart_rate_table(
|
graph_generator.generate_resting_heart_rate_table(
|
||||||
data=rhr_data,
|
data=rhr_data,
|
||||||
columns=rhr_columns,
|
columns=rhr_columns,
|
||||||
cell_colors=rhr_colors,
|
rhr_value=rhr_value,
|
||||||
|
category=category,
|
||||||
save_as_base64=True,
|
save_as_base64=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1334,7 +1334,7 @@ class GraphGenerator:
|
|||||||
from matplotlib.patches import FancyArrowPatch, RegularPolygon
|
from matplotlib.patches import FancyArrowPatch, RegularPolygon
|
||||||
|
|
||||||
# Fixed optimal sizing for VO2 Max table (7 columns, 1 data row)
|
# 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")
|
ax.axis("off")
|
||||||
|
|
||||||
# Create table
|
# Create table
|
||||||
@@ -1349,7 +1349,7 @@ class GraphGenerator:
|
|||||||
# Style the table
|
# Style the table
|
||||||
table.auto_set_font_size(False)
|
table.auto_set_font_size(False)
|
||||||
table.set_fontsize(11)
|
table.set_fontsize(11)
|
||||||
table.scale(1, 2.5)
|
table.scale(1, 1.8)
|
||||||
|
|
||||||
# Header row styling (cyan background)
|
# Header row styling (cyan background)
|
||||||
for i in range(len(columns)):
|
for i in range(len(columns)):
|
||||||
@@ -1423,7 +1423,7 @@ class GraphGenerator:
|
|||||||
percentile = percentile_map.get(category, "N/A")
|
percentile = percentile_map.get(category, "N/A")
|
||||||
|
|
||||||
title = f"VO2 Max - {vo2_max_value:.1f} ({percentile})"
|
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:
|
if save_as_base64:
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
@@ -1433,7 +1433,7 @@ class GraphGenerator:
|
|||||||
bbox_inches="tight",
|
bbox_inches="tight",
|
||||||
dpi=300,
|
dpi=300,
|
||||||
facecolor="white",
|
facecolor="white",
|
||||||
pad_inches=0.1,
|
pad_inches=0.05,
|
||||||
)
|
)
|
||||||
plt.close(fig)
|
plt.close(fig)
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
@@ -1447,7 +1447,7 @@ class GraphGenerator:
|
|||||||
bbox_inches="tight",
|
bbox_inches="tight",
|
||||||
dpi=300,
|
dpi=300,
|
||||||
facecolor="white",
|
facecolor="white",
|
||||||
pad_inches=0.1,
|
pad_inches=0.05,
|
||||||
)
|
)
|
||||||
plt.close(fig)
|
plt.close(fig)
|
||||||
return str(output_path)
|
return str(output_path)
|
||||||
@@ -1506,7 +1506,7 @@ class GraphGenerator:
|
|||||||
# Style the table
|
# Style the table
|
||||||
table.auto_set_font_size(False)
|
table.auto_set_font_size(False)
|
||||||
table.set_fontsize(11)
|
table.set_fontsize(11)
|
||||||
table.scale(1, 2.8)
|
table.scale(1, 2.0)
|
||||||
|
|
||||||
# Apply cell colors
|
# Apply cell colors
|
||||||
if cell_colors:
|
if cell_colors:
|
||||||
@@ -1558,15 +1558,19 @@ class GraphGenerator:
|
|||||||
self,
|
self,
|
||||||
data: list[list],
|
data: list[list],
|
||||||
columns: list[str],
|
columns: list[str],
|
||||||
|
rhr_value: float = None,
|
||||||
|
category: str = None,
|
||||||
cell_colors: list[list[str]] = None,
|
cell_colors: list[list[str]] = None,
|
||||||
save_as_base64: bool = True,
|
save_as_base64: bool = True,
|
||||||
) -> str:
|
) -> 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:
|
Args:
|
||||||
data: List of rows (each row is a list of values)
|
data: List of rows (each row is a list of values)
|
||||||
columns: List of column headers
|
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
|
cell_colors: Optional matrix of cell colors
|
||||||
save_as_base64: If True, return base64 string
|
save_as_base64: If True, return base64 string
|
||||||
|
|
||||||
@@ -1575,12 +1579,11 @@ class GraphGenerator:
|
|||||||
"""
|
"""
|
||||||
import io
|
import io
|
||||||
|
|
||||||
# Optimal sizing for RHR table (8 columns, 1 data row)
|
from matplotlib.patches import FancyArrowPatch, RegularPolygon
|
||||||
fig, ax = plt.subplots(figsize=(18, 2.5))
|
|
||||||
ax.axis("off")
|
|
||||||
|
|
||||||
# Even column widths
|
# Optimal sizing for RHR table (8 columns, 1 data row)
|
||||||
col_widths = [1.0 / len(columns)] * len(columns)
|
fig, ax = plt.subplots(figsize=(16, 2.2))
|
||||||
|
ax.axis("off")
|
||||||
|
|
||||||
# Create table
|
# Create table
|
||||||
table = ax.table(
|
table = ax.table(
|
||||||
@@ -1588,33 +1591,77 @@ class GraphGenerator:
|
|||||||
colLabels=columns,
|
colLabels=columns,
|
||||||
cellLoc="center",
|
cellLoc="center",
|
||||||
loc="center",
|
loc="center",
|
||||||
colColours=["#4dd0e1"] * len(columns),
|
bbox=[0, 0, 1, 1],
|
||||||
colWidths=col_widths,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Style the table
|
# Style the table
|
||||||
table.auto_set_font_size(False)
|
table.auto_set_font_size(False)
|
||||||
table.set_fontsize(11)
|
table.set_fontsize(11)
|
||||||
table.scale(1, 3.0)
|
table.scale(1, 1.8)
|
||||||
|
|
||||||
# Apply cell colors
|
# Header row styling (cyan background)
|
||||||
if cell_colors:
|
for i in range(len(columns)):
|
||||||
for i, row_colors in enumerate(cell_colors):
|
cell = table[(0, i)]
|
||||||
for j, color in enumerate(row_colors):
|
cell.set_facecolor("#7dd3fc") # cyan-300 equivalent
|
||||||
if color and j < len(columns):
|
cell.set_text_props(weight="bold", color="black", fontsize=12)
|
||||||
cell = table[(i + 1, j)]
|
cell.set_edgecolor("#9ca3af") # gray-400
|
||||||
cell.set_facecolor(color)
|
cell.set_linewidth(1)
|
||||||
|
|
||||||
# Style all cells
|
# Find the column index for the category (if provided)
|
||||||
for (row, col), cell in table.get_celld().items():
|
category_index = None
|
||||||
if row == 0:
|
if category and category in columns:
|
||||||
cell.set_text_props(weight="bold", fontsize=12)
|
category_index = columns.index(category)
|
||||||
cell.set_edgecolor("#333333")
|
|
||||||
cell.set_linewidth(1.5)
|
# 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:
|
else:
|
||||||
cell.set_edgecolor("#666666")
|
# Highlight the category cell with light green background
|
||||||
cell.set_linewidth(1.0)
|
if category_index is not None and i == category_index:
|
||||||
cell.set_text_props(fontsize=10)
|
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:
|
if save_as_base64:
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
@@ -1624,7 +1671,7 @@ class GraphGenerator:
|
|||||||
bbox_inches="tight",
|
bbox_inches="tight",
|
||||||
dpi=300,
|
dpi=300,
|
||||||
facecolor="white",
|
facecolor="white",
|
||||||
pad_inches=0.1,
|
pad_inches=0.05,
|
||||||
)
|
)
|
||||||
plt.close(fig)
|
plt.close(fig)
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
@@ -1638,7 +1685,7 @@ class GraphGenerator:
|
|||||||
bbox_inches="tight",
|
bbox_inches="tight",
|
||||||
dpi=300,
|
dpi=300,
|
||||||
facecolor="white",
|
facecolor="white",
|
||||||
pad_inches=0.1,
|
pad_inches=0.05,
|
||||||
)
|
)
|
||||||
plt.close(fig)
|
plt.close(fig)
|
||||||
return str(output_path)
|
return str(output_path)
|
||||||
|
|||||||
Reference in New Issue
Block a user