saving
This commit is contained in:
@@ -17,11 +17,6 @@
|
||||
|
||||
<!-- VO2 Max Section -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-xl font-bold text-black mb-4 text-center">
|
||||
VO2 Max - {{ vo2_max_value | default('49.5') }} ({{
|
||||
vo2_max_percentile | default('100th percentile') }})
|
||||
</h3>
|
||||
|
||||
<!-- VO2 Max Table -->
|
||||
<div class="mb-8 flex justify-center">
|
||||
<img
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -837,58 +837,58 @@ class ContextGenerator:
|
||||
|
||||
def _calculate_vo2_max_table_data(self, age: int, gender: str) -> 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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -2066,7 +2066,7 @@
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "report-generation",
|
||||
"display_name": ".venv",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user