feat: Enhance medical report generation with new features and improved data handling
- Added body fat percentage input and optional muscle oxygenation CSV upload in the upload form. - Implemented TSI chart generation based on muscle oxygenation data. - Updated report generation to include metabolism and fuel source charts. - Refactored context generation to eliminate reliance on SECA data, using patient info directly instead. - Improved error handling and logging for graph generation processes. - Enhanced HTML templates for better user experience and functionality.
This commit is contained in:
+392
-23
@@ -584,6 +584,105 @@ class GraphGenerator:
|
||||
|
||||
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||
|
||||
def generate_tsi_chart(
|
||||
self, oxygenation_df: pd.DataFrame, save_as_base64: bool = True
|
||||
) -> str:
|
||||
"""
|
||||
Generate TSI (Tissue Saturation Index) chart with trend lines per stage.
|
||||
|
||||
Args:
|
||||
oxygenation_df: DataFrame with Time, TSI, and TSI-second columns
|
||||
save_as_base64: If True, return base64 string, else return file path
|
||||
|
||||
Returns:
|
||||
Base64 string or file path
|
||||
"""
|
||||
from numpy.polynomial.polynomial import Polynomial
|
||||
|
||||
plt.figure(figsize=(12, 5.5))
|
||||
|
||||
# Plot TSI (Left Leg)
|
||||
plt.plot(
|
||||
oxygenation_df["Time"],
|
||||
oxygenation_df["TSI"],
|
||||
label="TSI (Left Leg)",
|
||||
color="steelblue",
|
||||
linewidth=2,
|
||||
)
|
||||
|
||||
# Plot TSI2 (Right Leg)
|
||||
plt.plot(
|
||||
oxygenation_df["Time"],
|
||||
oxygenation_df["TSI-second"],
|
||||
label="TSI2 (Right Leg)",
|
||||
color="orange",
|
||||
linewidth=2,
|
||||
)
|
||||
|
||||
# Define time intervals for stages (adjust these based on your test protocol)
|
||||
max_time = oxygenation_df["Time"].max()
|
||||
intervals = [
|
||||
(0, 250),
|
||||
(250, 500),
|
||||
(500, 750),
|
||||
(750, 1000),
|
||||
(1000, 1250),
|
||||
(1250, 1500),
|
||||
(1500, max_time),
|
||||
]
|
||||
|
||||
# Calculate and plot trend lines for each interval
|
||||
for start_time, end_time in intervals:
|
||||
# Filter data for this interval
|
||||
mask_interval = (oxygenation_df["Time"] >= start_time) & (
|
||||
oxygenation_df["Time"] <= end_time
|
||||
)
|
||||
|
||||
# TSI (Left Leg) trend for this interval
|
||||
mask_left = mask_interval & ~oxygenation_df["TSI"].isna()
|
||||
if mask_left.sum() > 1: # Need at least 2 points for a line
|
||||
x_left = oxygenation_df.loc[mask_left, "Time"]
|
||||
y_left = oxygenation_df.loc[mask_left, "TSI"]
|
||||
coefs_left = Polynomial.fit(x_left, y_left, 1).convert().coef
|
||||
trend_left = coefs_left[0] + coefs_left[1] * x_left
|
||||
plt.plot(
|
||||
x_left,
|
||||
trend_left,
|
||||
color="black",
|
||||
linestyle="--",
|
||||
linewidth=2,
|
||||
alpha=0.8,
|
||||
)
|
||||
|
||||
# TSI-second (Right Leg) trend for this interval
|
||||
mask_right = mask_interval & ~oxygenation_df["TSI-second"].isna()
|
||||
if mask_right.sum() > 1: # Need at least 2 points for a line
|
||||
x_right = oxygenation_df.loc[mask_right, "Time"]
|
||||
y_right = oxygenation_df.loc[mask_right, "TSI-second"]
|
||||
coefs_right = Polynomial.fit(x_right, y_right, 1).convert().coef
|
||||
trend_right = coefs_right[0] + coefs_right[1] * x_right
|
||||
plt.plot(
|
||||
x_right,
|
||||
trend_right,
|
||||
color="black",
|
||||
linestyle="--",
|
||||
linewidth=2,
|
||||
alpha=0.8,
|
||||
)
|
||||
|
||||
plt.xlabel("Time (s)")
|
||||
plt.ylabel("TSI (%)")
|
||||
plt.title("TSI (Left) and TSI2 (Right) with Black Slope Lines per Stage")
|
||||
plt.legend(fontsize=10, loc="upper right")
|
||||
plt.grid(alpha=0.25)
|
||||
plt.tight_layout()
|
||||
|
||||
chart_path = self.charts_dir / "tsi_chart.png"
|
||||
plt.savefig(chart_path, bbox_inches="tight", dpi=160)
|
||||
plt.close()
|
||||
|
||||
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||
|
||||
def generate_body_composition_chart(
|
||||
self, fat_mass_lbs: float, lean_mass_lbs: float, save_as_base64: bool = True
|
||||
) -> str:
|
||||
@@ -678,25 +777,52 @@ class GraphGenerator:
|
||||
else:
|
||||
age_group = "20-39" # Default
|
||||
|
||||
demographic = f"{age_group}\n({gender[0].upper()})"
|
||||
gender_abbrev = "M" if gender.lower() == "male" else "F"
|
||||
demographic = f"{age_group}\n({gender_abbrev})"
|
||||
|
||||
# Define segments based on gender (female example)
|
||||
# Define segments based on gender and age group
|
||||
if gender.lower() == "female":
|
||||
segments = [
|
||||
("#F8A8A8", 0, 15), # Muted Red: 0% to 15%
|
||||
("#FFEECC", 15, 5), # Pale Yellow: 15% to 20%
|
||||
("#D0F0C0", 20, 15), # Pale Green: 20% to 35%
|
||||
("#FFEECC", 35, 5), # Pale Yellow: 35% to 40%
|
||||
("#F8A8A8", 40, 10), # Muted Red: 40% to 50%
|
||||
]
|
||||
if age_group == "20-39":
|
||||
segments = [
|
||||
("#F8A8A8", 0, 15), # Bad: 0-15%
|
||||
("#FFEECC", 15, 5), # Okay: 15-20%
|
||||
("#D0F0C0", 20, 15), # Good: 20-35%
|
||||
("#FFEECC", 35, 5), # Okay: 35-40%
|
||||
("#F8A8A8", 40, 10), # Bad: 40-50%
|
||||
]
|
||||
else: # 40-59 and 60-79 have same ranges for females
|
||||
segments = [
|
||||
("#F8A8A8", 0, 20), # Bad: 0-20%
|
||||
("#FFEECC", 20, 5), # Okay: 20-25%
|
||||
("#D0F0C0", 25, 10), # Good: 25-35%
|
||||
("#FFEECC", 35, 5), # Okay: 35-40%
|
||||
("#F8A8A8", 40, 10), # Bad: 40-50%
|
||||
]
|
||||
else: # male
|
||||
segments = [
|
||||
("#F8A8A8", 0, 5), # Muted Red: 0% to 5%
|
||||
("#FFEECC", 5, 5), # Pale Yellow: 5% to 10%
|
||||
("#D0F0C0", 10, 10), # Pale Green: 10% to 20%
|
||||
("#FFEECC", 20, 5), # Pale Yellow: 20% to 25%
|
||||
("#F8A8A8", 25, 25), # Muted Red: 25% to 50%
|
||||
]
|
||||
if age_group == "20-39":
|
||||
segments = [
|
||||
("#F8A8A8", 0, 5), # Bad: 0-5%
|
||||
("#FFEECC", 5, 5), # Okay: 5-10%
|
||||
("#D0F0C0", 10, 10), # Good: 10-20%
|
||||
("#FFEECC", 20, 5), # Okay: 20-25%
|
||||
("#F8A8A8", 25, 25), # Bad: 25-50%
|
||||
]
|
||||
elif age_group == "40-59":
|
||||
segments = [
|
||||
("#F8A8A8", 0, 5), # Bad: 0-5%
|
||||
("#FFEECC", 5, 5), # Okay: 5-10%
|
||||
("#D0F0C0", 10, 10), # Good: 10-20%
|
||||
("#FFEECC", 20, 10), # Okay: 20-30%
|
||||
("#F8A8A8", 30, 20), # Bad: 30-50%
|
||||
]
|
||||
else: # 60-79
|
||||
segments = [
|
||||
("#F8A8A8", 0, 5), # Bad: 0-5%
|
||||
("#FFEECC", 5, 5), # Okay: 5-10%
|
||||
("#D0F0C0", 10, 15), # Good: 10-25%
|
||||
("#FFEECC", 25, 5), # Okay: 25-30%
|
||||
("#F8A8A8", 30, 20), # Bad: 30-50%
|
||||
]
|
||||
|
||||
fig, ax = plt.subplots(figsize=(10, 2))
|
||||
|
||||
@@ -779,10 +905,40 @@ class GraphGenerator:
|
||||
Returns:
|
||||
Base64 string or file path
|
||||
"""
|
||||
# Coerce numeric columns
|
||||
for col in ["Best", "LLN", "Pred.", "%Pred.", "ZScore"]:
|
||||
if col in spirometry_df.columns:
|
||||
spirometry_df[col] = pd.to_numeric(spirometry_df[col], errors="coerce")
|
||||
# Coerce numeric columns - handle various column name formats
|
||||
# Map standard column names to possible variations
|
||||
column_aliases = {
|
||||
"Best": ["Best", "best", "BEST"],
|
||||
"LLN": ["LLN", "lln"],
|
||||
"Pred.": ["Pred.", "Pred", "pred", "Predicted", "predicted"],
|
||||
"%Pred.": [
|
||||
"%Pred.",
|
||||
"%Pred",
|
||||
"%pred",
|
||||
"% Pred.",
|
||||
"% Pred",
|
||||
"Pred %",
|
||||
"Pred%",
|
||||
],
|
||||
"ZScore": ["ZScore", "Z-Score", "z-score", "Zscore", "zscore", "Z Score"],
|
||||
}
|
||||
|
||||
# Find and normalize column names
|
||||
column_mapping = {}
|
||||
for target_col, possible_names in column_aliases.items():
|
||||
for col_name in possible_names:
|
||||
if col_name in spirometry_df.columns:
|
||||
column_mapping[target_col] = col_name
|
||||
# Convert to numeric
|
||||
spirometry_df[col_name] = pd.to_numeric(
|
||||
spirometry_df[col_name], errors="coerce"
|
||||
)
|
||||
break
|
||||
|
||||
# If standard columns don't exist, create aliases
|
||||
for target_col, source_col in column_mapping.items():
|
||||
if target_col not in spirometry_df.columns and source_col != target_col:
|
||||
spirometry_df[target_col] = spirometry_df[source_col]
|
||||
|
||||
# Select rows of interest
|
||||
rows_map = {
|
||||
@@ -793,20 +949,49 @@ class GraphGenerator:
|
||||
|
||||
records = []
|
||||
for label, param in rows_map.items():
|
||||
# Try exact match first
|
||||
row = spirometry_df.loc[spirometry_df["Parameters"].str.strip() == param]
|
||||
if row.empty:
|
||||
# Try case-insensitive match
|
||||
row = spirometry_df.loc[
|
||||
spirometry_df["Parameters"].str.strip().str.upper() == param.upper()
|
||||
]
|
||||
if row.empty:
|
||||
# Try matching without % sign
|
||||
if "%" in param:
|
||||
param_no_pct = param.replace("%", "")
|
||||
row = spirometry_df.loc[
|
||||
spirometry_df["Parameters"].str.strip() == param_no_pct
|
||||
]
|
||||
if row.empty:
|
||||
print(f"Warning: Could not find parameter '{param}' in spirometry data")
|
||||
print(f"Available parameters: {spirometry_df['Parameters'].tolist()}")
|
||||
continue
|
||||
row = row.iloc[0]
|
||||
# Get values with fallbacks for column name variations
|
||||
best_val = row.get("Best", row.get("best", pd.NA))
|
||||
pct_val = row.get(
|
||||
"%Pred.", row.get("%Pred", row.get("Pred %", row.get("Pred%", pd.NA)))
|
||||
)
|
||||
z_val = row.get("ZScore", row.get("Z-Score", row.get("Zscore", pd.NA)))
|
||||
|
||||
records.append(
|
||||
{
|
||||
"label": label,
|
||||
"param": param,
|
||||
"best": row["Best"],
|
||||
"pct": row["%Pred."],
|
||||
"z": row["ZScore"],
|
||||
"best": best_val,
|
||||
"pct": pct_val,
|
||||
"z": z_val,
|
||||
}
|
||||
)
|
||||
|
||||
# Validate we have exactly 3 records
|
||||
if len(records) != 3:
|
||||
raise ValueError(
|
||||
f"Expected 3 spirometry parameters (FVC, FEV1, FEV1/FVC%), "
|
||||
f"but found {len(records)}. Found: {[r['param'] for r in records]}"
|
||||
)
|
||||
|
||||
# Figure setup
|
||||
fig, axes = plt.subplots(
|
||||
nrows=3,
|
||||
@@ -936,3 +1121,187 @@ class GraphGenerator:
|
||||
plt.close()
|
||||
|
||||
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||
|
||||
def generate_metabolism_chart(
|
||||
self, rmr_kcal: float, save_as_base64: bool = True
|
||||
) -> str:
|
||||
"""
|
||||
Generate metabolism chart (Slow vs Fast Metabolism).
|
||||
|
||||
Args:
|
||||
rmr_kcal: Resting metabolic rate in kcal/day
|
||||
save_as_base64: If True, return base64 string, else return file path
|
||||
|
||||
Returns:
|
||||
Base64 string or file path
|
||||
"""
|
||||
from matplotlib.patches import FancyBboxPatch
|
||||
|
||||
fig, ax = plt.subplots(figsize=(10, 2.5))
|
||||
|
||||
# Chart data and positions
|
||||
categories = ["Very Slow", "Slow", "Average", "Fast", "Very Fast"]
|
||||
positions = [1500, 3000, 4500, 6000, 7500]
|
||||
indicator_pos = rmr_kcal
|
||||
highlight_end = rmr_kcal
|
||||
|
||||
# Main Bar (Background)
|
||||
main_bar = FancyBboxPatch(
|
||||
(0, 0.4),
|
||||
9000,
|
||||
0.2,
|
||||
boxstyle="round,pad=0,rounding_size=0.1",
|
||||
ec="none",
|
||||
fc="#E0E0E0",
|
||||
)
|
||||
ax.add_patch(main_bar)
|
||||
|
||||
# Highlighted Bar
|
||||
highlight_bar = FancyBboxPatch(
|
||||
(0, 0.4),
|
||||
highlight_end,
|
||||
0.2,
|
||||
boxstyle="round,pad=0,rounding_size=0.1",
|
||||
ec="none",
|
||||
fc="#B2FFC8",
|
||||
)
|
||||
ax.add_patch(highlight_bar)
|
||||
|
||||
# Text and Labels
|
||||
ax.text(
|
||||
highlight_end / 2,
|
||||
0.5,
|
||||
f"{rmr_kcal:.0f}kCals",
|
||||
ha="center",
|
||||
va="center",
|
||||
color="#006400",
|
||||
fontsize=14,
|
||||
weight="bold",
|
||||
)
|
||||
|
||||
# Indicator Triangle
|
||||
ax.plot(indicator_pos, 0.65, "v", markersize=15, color="#606060", clip_on=False)
|
||||
|
||||
# Ticks and Labels
|
||||
for pos, label in zip(positions, categories):
|
||||
ax.text(
|
||||
pos, 0.15, label, ha="center", va="center", fontsize=12, color="#333333"
|
||||
)
|
||||
ax.plot([pos, pos], [0.35, 0.39], color="grey", lw=5)
|
||||
|
||||
# Chart Styling
|
||||
ax.set_title("Slow vs Fast Metabolism", fontsize=18, weight="bold", loc="left")
|
||||
ax.set_xlim(0, 9000)
|
||||
ax.set_ylim(0, 1)
|
||||
ax.axis("off")
|
||||
|
||||
plt.tight_layout()
|
||||
|
||||
chart_path = self.charts_dir / "metabolism_chart.png"
|
||||
plt.savefig(chart_path, bbox_inches="tight", dpi=300)
|
||||
plt.close()
|
||||
|
||||
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||
|
||||
def generate_fuel_source_chart(
|
||||
self, fat_percentage: float, save_as_base64: bool = True
|
||||
) -> str:
|
||||
"""
|
||||
Generate fuel source chart (Fats vs Carbs).
|
||||
|
||||
Args:
|
||||
fat_percentage: Fat percentage at rest
|
||||
save_as_base64: If True, return base64 string, else return file path
|
||||
|
||||
Returns:
|
||||
Base64 string or file path
|
||||
"""
|
||||
from matplotlib.patches import FancyBboxPatch
|
||||
|
||||
fig, ax = plt.subplots(figsize=(10, 2.5))
|
||||
|
||||
carb_percentage = 100 - fat_percentage
|
||||
optimal_point = 75
|
||||
|
||||
# Main Bars (Fats and Carbs)
|
||||
# Fats bar (yellow)
|
||||
fats_bar = FancyBboxPatch(
|
||||
(0, 0.4),
|
||||
fat_percentage,
|
||||
0.2,
|
||||
boxstyle="round,pad=0,rounding_size=0.1",
|
||||
ec="none",
|
||||
fc="#FEEAAB",
|
||||
)
|
||||
ax.add_patch(fats_bar)
|
||||
|
||||
# Carbs bar (blue) - starts where the fats bar ends
|
||||
carbs_bar = FancyBboxPatch(
|
||||
(fat_percentage, 0.4),
|
||||
carb_percentage,
|
||||
0.2,
|
||||
boxstyle="round,pad=0,rounding_size=0.1",
|
||||
ec="none",
|
||||
fc="#A7F5FF",
|
||||
)
|
||||
ax.add_patch(carbs_bar)
|
||||
|
||||
# Text and Labels
|
||||
ax.text(
|
||||
fat_percentage / 2,
|
||||
0.5,
|
||||
f"Fats\n{fat_percentage:.1f}%",
|
||||
ha="center",
|
||||
va="center",
|
||||
color="#333333",
|
||||
fontsize=12,
|
||||
weight="bold",
|
||||
)
|
||||
ax.text(
|
||||
fat_percentage + carb_percentage / 2,
|
||||
0.5,
|
||||
f"Carbs\n{carb_percentage:.1f}%",
|
||||
ha="center",
|
||||
va="center",
|
||||
color="#333333",
|
||||
fontsize=12,
|
||||
weight="bold",
|
||||
)
|
||||
|
||||
# Add 'Optimal' label
|
||||
ax.text(optimal_point, 0.75, "Optimal", ha="center", va="center", fontsize=12)
|
||||
|
||||
# Indicator Triangle
|
||||
ax.plot(
|
||||
fat_percentage, 0.65, "v", markersize=15, color="#606060", clip_on=False
|
||||
)
|
||||
|
||||
# Ticks and Labels
|
||||
positions = [0, 25, 50, 75, 100]
|
||||
for pos in positions:
|
||||
ax.text(
|
||||
pos,
|
||||
0.15,
|
||||
str(pos),
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=12,
|
||||
color="#333333",
|
||||
)
|
||||
ax.plot([pos, pos], [0.35, 0.39], color="grey", lw=5)
|
||||
|
||||
# Add a special tick for the 'Optimal' point
|
||||
ax.plot([optimal_point, optimal_point], [0.6, 0.7], color="black", lw=2)
|
||||
|
||||
# Chart Styling
|
||||
ax.set_title("Fuel Source", fontsize=18, weight="bold", loc="left")
|
||||
ax.set_ylim(0, 1)
|
||||
ax.axis("off")
|
||||
|
||||
plt.tight_layout()
|
||||
|
||||
chart_path = self.charts_dir / "fuel_source_chart.png"
|
||||
plt.savefig(chart_path, bbox_inches="tight", dpi=300)
|
||||
plt.close()
|
||||
|
||||
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
||||
|
||||
Reference in New Issue
Block a user