939 lines
29 KiB
Python
939 lines
29 KiB
Python
"""
|
|
Graph Generator Service
|
|
|
|
This service generates all the charts and visualizations required for the medical report.
|
|
Based on the analysis notebooks in services_dfdf/.
|
|
"""
|
|
|
|
import base64
|
|
from pathlib import Path
|
|
|
|
import matplotlib
|
|
|
|
matplotlib.use("Agg") # Use non-interactive backend
|
|
import matplotlib.pyplot as plt
|
|
import matplotlib.transforms as mtransforms
|
|
import numpy as np
|
|
import pandas as pd
|
|
import seaborn as sns
|
|
from matplotlib.patches import FancyBboxPatch
|
|
|
|
|
|
class GraphGenerator:
|
|
"""Generate all charts for medical reports"""
|
|
|
|
def __init__(self, charts_dir: str = "graphs"):
|
|
"""
|
|
Initialize the graph generator.
|
|
|
|
Args:
|
|
charts_dir: Directory to save generated charts
|
|
"""
|
|
self.charts_dir = Path(charts_dir)
|
|
self.charts_dir.mkdir(exist_ok=True)
|
|
|
|
def _image_to_base64(self, image_path: Path) -> str:
|
|
"""
|
|
Convert image file to base64 string.
|
|
|
|
Args:
|
|
image_path: Path to image file
|
|
|
|
Returns:
|
|
Base64 encoded string
|
|
"""
|
|
try:
|
|
with open(image_path, "rb") as image_file:
|
|
return base64.b64encode(image_file.read()).decode("utf-8")
|
|
except FileNotFoundError:
|
|
return ""
|
|
|
|
def generate_respiratory_chart(
|
|
self, df: pd.DataFrame, save_as_base64: bool = True
|
|
) -> str:
|
|
"""
|
|
Generate respiratory chart (VT and Speed over time).
|
|
|
|
Args:
|
|
df: Processed DataFrame with smoothed columns
|
|
save_as_base64: If True, return base64 string, else return file path
|
|
|
|
Returns:
|
|
Base64 string or file path
|
|
"""
|
|
first_unique_phase = df.drop_duplicates(subset="PHASE")
|
|
phase_times = first_unique_phase["T(sec)"].tolist()
|
|
|
|
plt.figure(figsize=(18, 5))
|
|
ax1 = plt.subplot()
|
|
|
|
# Plot VT
|
|
sns.lineplot(data=df, x="T(sec)", y="VT(l)_smoothed", label="VT (L)")
|
|
ax1.set_xlabel("Time (sec)")
|
|
ax1.set_ylabel("VT (L)")
|
|
ax1.grid(True, alpha=0.1)
|
|
ax1.set_ylim(0, min(8, df["VT(l)_smoothed"].max()))
|
|
|
|
# Plot speed on secondary y-axis
|
|
ax2 = ax1.twinx()
|
|
ax1.set_xticks(np.arange(0, df["T(sec)"].max() + 200, 200))
|
|
sns.lineplot(
|
|
data=df,
|
|
x="T(sec)",
|
|
y="Speed",
|
|
color="green",
|
|
ax=ax2,
|
|
drawstyle="steps-post",
|
|
linewidth=2,
|
|
label="Speed",
|
|
)
|
|
ax2.set_ylabel("Speed")
|
|
ax2.set_ylim(0, min(30, df["Speed"].max()) + 1)
|
|
|
|
# Combine legends
|
|
ax1.get_legend().remove()
|
|
ax2.get_legend().remove()
|
|
lines1, labels1 = ax1.get_legend_handles_labels()
|
|
lines2, labels2 = ax2.get_legend_handles_labels()
|
|
ax1.legend(lines1 + lines2, labels1 + labels2, loc="upper left")
|
|
|
|
# Add colored background regions
|
|
if len(phase_times) >= 4:
|
|
ax1.axvspan(0, phase_times[1], alpha=0.2, color="lightblue")
|
|
ax1.axvspan(phase_times[1], phase_times[2], alpha=0.2, color="purple")
|
|
ax1.axvspan(phase_times[2], phase_times[3], alpha=0.2, color="lightgreen")
|
|
ax1.axvspan(phase_times[3], df["T(sec)"].max(), alpha=0.2, color="blue")
|
|
|
|
chart_path = self.charts_dir / "respiratory.png"
|
|
plt.savefig(chart_path, dpi=300, bbox_inches="tight")
|
|
plt.close()
|
|
|
|
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
|
|
|
def generate_fuel_utilization_chart(
|
|
self, df: pd.DataFrame, save_as_base64: bool = True
|
|
) -> str:
|
|
"""
|
|
Generate fuel utilization chart (CHO vs FAT by stage).
|
|
|
|
Args:
|
|
df: Processed DataFrame with smoothed columns
|
|
save_as_base64: If True, return base64 string, else return file path
|
|
|
|
Returns:
|
|
Base64 string or file path
|
|
"""
|
|
# Group by speed and calculate mean
|
|
speed_groups = df.groupby("Speed").mean(numeric_only=True).round(1)
|
|
speed_groups = speed_groups.iloc[1:-1]
|
|
|
|
# Filter data
|
|
filtered_data = speed_groups[
|
|
(speed_groups.index >= 3.5) & (speed_groups.index <= 7.5)
|
|
]
|
|
|
|
plt.figure(figsize=(15, 8))
|
|
plt.style.use("default")
|
|
|
|
stage_labels = [f"Stage {i}" for i in range(1, len(filtered_data) + 1)]
|
|
x_positions = np.arange(len(filtered_data))
|
|
|
|
# Calculate fat and carbs energy expenditure
|
|
fat_ee = filtered_data["EE(kcal/min)"] * filtered_data["FAT(%)"] / 100
|
|
carbs_ee = filtered_data["EE(kcal/min)"] * filtered_data["CARBS(%)"] / 100
|
|
|
|
ax1 = plt.gca()
|
|
|
|
# Create stacked bar chart
|
|
ax1.bar(
|
|
x_positions,
|
|
fat_ee,
|
|
color="#1f77b4",
|
|
alpha=0.8,
|
|
width=0.6,
|
|
label="Fat",
|
|
)
|
|
ax1.bar(
|
|
x_positions,
|
|
carbs_ee,
|
|
bottom=fat_ee,
|
|
color="#ff7f0e",
|
|
alpha=0.8,
|
|
width=0.6,
|
|
label="Carbs",
|
|
)
|
|
|
|
ax1.set_xlabel("", fontsize=12)
|
|
ax1.set_ylabel("Fuel (kcal/min)", fontsize=12)
|
|
ax1.set_ylim(0, 20)
|
|
|
|
# Add values on bars
|
|
for i, (fat_val, carb_val, total_val) in enumerate(
|
|
zip(fat_ee, carbs_ee, filtered_data["EE(kcal/min)"])
|
|
):
|
|
if fat_val > 0.3:
|
|
ax1.text(
|
|
i,
|
|
fat_val / 2,
|
|
f"{fat_val:.1f}",
|
|
ha="center",
|
|
va="center",
|
|
fontsize=9,
|
|
fontweight="bold",
|
|
color="white",
|
|
)
|
|
if carb_val > 0.3:
|
|
ax1.text(
|
|
i,
|
|
fat_val + carb_val / 2,
|
|
f"{carb_val:.1f}",
|
|
ha="center",
|
|
va="center",
|
|
fontsize=9,
|
|
fontweight="bold",
|
|
color="white",
|
|
)
|
|
ax1.text(
|
|
i,
|
|
total_val + 0.5,
|
|
f"{total_val:.1f} kcal",
|
|
ha="center",
|
|
va="bottom",
|
|
fontsize=10,
|
|
fontweight="bold",
|
|
color="black",
|
|
)
|
|
|
|
# Add speed labels
|
|
for i, speed in enumerate(filtered_data.index):
|
|
ax1.text(i, -1.5, f"{speed:.1f} mph", ha="center", va="top", fontsize=9)
|
|
ax1.text(
|
|
i,
|
|
-2.8,
|
|
f"{speed * 1.609:.1f} min/km",
|
|
ha="center",
|
|
va="top",
|
|
fontsize=8,
|
|
color="gray",
|
|
)
|
|
|
|
# Create secondary y-axis for heart rate
|
|
ax2 = ax1.twinx()
|
|
ax2.plot(
|
|
x_positions,
|
|
filtered_data["HR(bpm)"],
|
|
marker="o",
|
|
linewidth=3,
|
|
markersize=8,
|
|
color="red",
|
|
label="Heart Rate",
|
|
)
|
|
|
|
ax2.set_ylabel("Heart Rate (bpm)", fontsize=12, color="red")
|
|
ax2.tick_params(axis="y", labelcolor="red")
|
|
ax2.set_ylim(0, 220)
|
|
|
|
# Add HR values
|
|
for i, hr in enumerate(filtered_data["HR(bpm)"]):
|
|
ax2.text(
|
|
i,
|
|
hr + 10,
|
|
f"{int(hr)}bpm",
|
|
ha="center",
|
|
va="bottom",
|
|
fontsize=10,
|
|
fontweight="bold",
|
|
color="red",
|
|
)
|
|
|
|
ax1.set_xticks(x_positions)
|
|
ax1.set_xticklabels(stage_labels, fontsize=11)
|
|
|
|
# Create legend
|
|
lines1, labels1 = ax1.get_legend_handles_labels()
|
|
lines2, labels2 = ax2.get_legend_handles_labels()
|
|
ax1.legend(
|
|
lines1 + lines2,
|
|
labels1 + labels2,
|
|
loc="upper left",
|
|
frameon=True,
|
|
fancybox=True,
|
|
shadow=True,
|
|
)
|
|
|
|
ax1.grid(True, alpha=0.3, linestyle="-", linewidth=0.5)
|
|
ax1.set_axisbelow(True)
|
|
|
|
plt.tight_layout()
|
|
plt.subplots_adjust(bottom=0.1, top=0.9)
|
|
|
|
chart_path = self.charts_dir / "fuel_utilization_chart.png"
|
|
plt.savefig(chart_path, dpi=300)
|
|
plt.close()
|
|
|
|
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
|
|
|
def generate_vo2_pulse_chart(
|
|
self, df: pd.DataFrame, save_as_base64: bool = True
|
|
) -> str:
|
|
"""
|
|
Generate VO2 Pulse chart with HR and Speed.
|
|
|
|
Args:
|
|
df: Processed DataFrame with smoothed columns
|
|
save_as_base64: If True, return base64 string, else return file path
|
|
|
|
Returns:
|
|
Base64 string or file path
|
|
"""
|
|
first_unique_phase = df.drop_duplicates(subset="PHASE")
|
|
phase_times = first_unique_phase["T(sec)"].tolist()
|
|
|
|
plt.figure(figsize=(18, 5))
|
|
ax1 = plt.subplot()
|
|
|
|
# Plot VO2 Pulse
|
|
sns.lineplot(
|
|
data=df,
|
|
x="T(sec)",
|
|
y="VO2 Pulse_smoothed",
|
|
label="VO2 Pulse (mL/beat)",
|
|
color="blue",
|
|
)
|
|
ax1.set_xlabel("Time (sec)")
|
|
ax1.set_ylabel("VO2 Pulse (mL/beat)")
|
|
ax1.set_ylim(0, df["VO2 Pulse_smoothed"].max())
|
|
ax1.grid(True, alpha=0.1)
|
|
|
|
# Create second y-axis for heart rate
|
|
ax2 = ax1.twinx()
|
|
sns.lineplot(
|
|
data=df,
|
|
x="T(sec)",
|
|
y="HR(bpm)_smoothed",
|
|
color="red",
|
|
ax=ax2,
|
|
linewidth=2,
|
|
label="Heart Rate (bpm)",
|
|
)
|
|
ax2.set_ylabel("Heart Rate (bpm)", color="red")
|
|
ax2.tick_params(axis="y", labelcolor="red")
|
|
ax2.set_ylim(0, df["HR(bpm)_smoothed"].max() + 1)
|
|
|
|
# Create third y-axis for speed
|
|
ax3 = ax1.twinx()
|
|
ax3.spines["right"].set_position(("outward", 60))
|
|
sns.lineplot(
|
|
data=df,
|
|
x="T(sec)",
|
|
y="Speed",
|
|
color="green",
|
|
ax=ax3,
|
|
drawstyle="steps-post",
|
|
linewidth=2,
|
|
label="Speed",
|
|
)
|
|
ax3.set_ylabel("Speed", color="green")
|
|
ax3.tick_params(axis="y", labelcolor="green")
|
|
ax3.set_ylim(0, df["Speed"].max() + 1)
|
|
|
|
ax1.set_xticks(np.arange(0, df["T(sec)"].max() + 200, 200))
|
|
|
|
# Combine legends
|
|
if ax1.get_legend():
|
|
ax1.get_legend().remove()
|
|
if ax2.get_legend():
|
|
ax2.get_legend().remove()
|
|
if ax3.get_legend():
|
|
ax3.get_legend().remove()
|
|
|
|
lines1, labels1 = ax1.get_legend_handles_labels()
|
|
lines2, labels2 = ax2.get_legend_handles_labels()
|
|
lines3, labels3 = ax3.get_legend_handles_labels()
|
|
ax1.legend(
|
|
lines1 + lines2 + lines3, labels1 + labels2 + labels3, loc="upper left"
|
|
)
|
|
|
|
# Add colored background regions
|
|
if len(phase_times) >= 4:
|
|
ax1.axvspan(0, phase_times[1], alpha=0.2, color="lightblue")
|
|
ax1.axvspan(phase_times[1], phase_times[2], alpha=0.2, color="purple")
|
|
ax1.axvspan(phase_times[2], phase_times[3], alpha=0.2, color="lightgreen")
|
|
ax1.axvspan(phase_times[3], df["T(sec)"].max(), alpha=0.2, color="blue")
|
|
|
|
chart_path = self.charts_dir / "vo2_pulse_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_vo2_breath_chart(
|
|
self, df: pd.DataFrame, save_as_base64: bool = True
|
|
) -> str:
|
|
"""
|
|
Generate VO2 per Breath chart.
|
|
|
|
Args:
|
|
df: Processed DataFrame with smoothed columns
|
|
save_as_base64: If True, return base64 string, else return file path
|
|
|
|
Returns:
|
|
Base64 string or file path
|
|
"""
|
|
first_unique_phase = df.drop_duplicates(subset="PHASE")
|
|
phase_times = first_unique_phase["T(sec)"].tolist()
|
|
|
|
plt.figure(figsize=(18, 5))
|
|
ax1 = plt.subplot()
|
|
|
|
sns.lineplot(
|
|
data=df,
|
|
x="T(sec)",
|
|
y="VO2 Breath_smoothed",
|
|
label="VO2 per Breath (mL/breath)",
|
|
)
|
|
ax1.set_xlabel("Time (sec)")
|
|
ax1.set_ylabel("VO2 per Breath (mL/breath)")
|
|
ax1.set_ylim(0, df["VO2 Breath_smoothed"].max() + 1)
|
|
ax1.grid(True, alpha=0.1)
|
|
|
|
# Plot speed on secondary y-axis
|
|
ax2 = ax1.twinx()
|
|
ax1.set_xticks(np.arange(0, df["T(sec)"].max() + 200, 200))
|
|
sns.lineplot(
|
|
data=df,
|
|
x="T(sec)",
|
|
y="Speed",
|
|
color="green",
|
|
ax=ax2,
|
|
drawstyle="steps-post",
|
|
linewidth=2,
|
|
label="Speed",
|
|
)
|
|
ax2.set_ylim(0, df["Speed"].max() + 1)
|
|
ax2.set_ylabel("Speed")
|
|
|
|
# Combine legends
|
|
ax1.get_legend().remove()
|
|
ax2.get_legend().remove()
|
|
lines1, labels1 = ax1.get_legend_handles_labels()
|
|
lines2, labels2 = ax2.get_legend_handles_labels()
|
|
ax1.legend(lines1 + lines2, labels1 + labels2, loc="upper left")
|
|
|
|
# Add colored background regions
|
|
if len(phase_times) >= 4:
|
|
ax1.axvspan(0, phase_times[1], alpha=0.2, color="lightblue")
|
|
ax1.axvspan(phase_times[1], phase_times[2], alpha=0.2, color="purple")
|
|
ax1.axvspan(phase_times[2], phase_times[3], alpha=0.2, color="lightgreen")
|
|
ax1.axvspan(phase_times[3], df["T(sec)"].max(), alpha=0.2, color="blue")
|
|
|
|
chart_path = self.charts_dir / "vo2_breath_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_fat_metabolism_chart(
|
|
self, df: pd.DataFrame, save_as_base64: bool = True
|
|
) -> str:
|
|
"""
|
|
Generate fat metabolism chart (CHO vs FAT over time).
|
|
|
|
Args:
|
|
df: Processed DataFrame with smoothed columns
|
|
save_as_base64: If True, return base64 string, else return file path
|
|
|
|
Returns:
|
|
Base64 string or file path
|
|
"""
|
|
first_unique_phase = df.drop_duplicates(subset="PHASE")
|
|
phase_times = first_unique_phase["T(sec)"].tolist()
|
|
|
|
plt.figure(figsize=(18, 5))
|
|
ax1 = plt.subplot()
|
|
|
|
# Plot CHO
|
|
sns.lineplot(data=df, x="T(sec)", y="CHO_smoothed", label="CHO (kcal/min)")
|
|
ax1.set_xlabel("Time (sec)")
|
|
ax1.set_ylabel("CHO (g/min)")
|
|
ax1.grid(True, alpha=0.1)
|
|
|
|
# Plot FAT on secondary y-axis
|
|
ax2 = ax1.twinx()
|
|
ax1.set_xticks(np.arange(0, df["T(sec)"].max() + 200, 200))
|
|
sns.lineplot(
|
|
data=df,
|
|
x="T(sec)",
|
|
y="FAT_smoothed",
|
|
color="green",
|
|
ax=ax2,
|
|
label="FAT (kcal/min)",
|
|
)
|
|
ax2.set_ylabel("FAT (kcal/min)")
|
|
ax2.set_ylim(0, 15)
|
|
|
|
# Combine legends
|
|
ax1.get_legend().remove()
|
|
ax2.get_legend().remove()
|
|
lines1, labels1 = ax1.get_legend_handles_labels()
|
|
lines2, labels2 = ax2.get_legend_handles_labels()
|
|
ax1.legend(lines1 + lines2, labels1 + labels2, loc="upper left")
|
|
|
|
# Add colored background regions
|
|
if len(phase_times) >= 4:
|
|
ax1.axvspan(0, phase_times[1], alpha=0.2, color="lightblue")
|
|
ax1.axvspan(phase_times[1], phase_times[2], alpha=0.2, color="purple")
|
|
ax1.axvspan(phase_times[2], phase_times[3], alpha=0.2, color="lightgreen")
|
|
ax1.axvspan(phase_times[3], df["T(sec)"].max(), alpha=0.2, color="blue")
|
|
|
|
chart_path = self.charts_dir / "fat_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_recovery_chart(
|
|
self, df: pd.DataFrame, save_as_base64: bool = True
|
|
) -> str:
|
|
"""
|
|
Generate recovery chart (VCO2, HR, and BF).
|
|
|
|
Args:
|
|
df: Processed DataFrame with smoothed columns
|
|
save_as_base64: If True, return base64 string, else return file path
|
|
|
|
Returns:
|
|
Base64 string or file path
|
|
"""
|
|
first_unique_phase = df.drop_duplicates(subset="PHASE")
|
|
phase_times = first_unique_phase["T(sec)"].tolist()
|
|
|
|
plt.figure(figsize=(18, 5))
|
|
ax1 = plt.subplot()
|
|
|
|
# Plot VCO2
|
|
sns.lineplot(
|
|
data=df,
|
|
x="T(sec)",
|
|
y="VCO2(ml/min)_smoothed",
|
|
label="VCO2 (ml/min)",
|
|
color="blue",
|
|
)
|
|
ax1.set_xlabel("Time (sec)")
|
|
ax1.set_ylabel("VO2 Pulse (mL/beat)")
|
|
ax1.set_ylim(0, df["VCO2(ml/min)"].max())
|
|
ax1.grid(True, alpha=0.1)
|
|
|
|
# Create second y-axis for heart rate
|
|
ax2 = ax1.twinx()
|
|
sns.lineplot(
|
|
data=df,
|
|
x="T(sec)",
|
|
y="HR(bpm)_smoothed",
|
|
color="red",
|
|
ax=ax2,
|
|
linewidth=2,
|
|
label="Heart Rate (bpm)",
|
|
)
|
|
ax2.set_ylabel("Heart Rate (bpm)", color="red")
|
|
ax2.set_ylim(df["HR(bpm)_smoothed"].min(), df["HR(bpm)_smoothed"].max() + 1)
|
|
ax2.tick_params(axis="y", labelcolor="red")
|
|
|
|
# Create third y-axis for BF
|
|
ax3 = ax1.twinx()
|
|
ax3.spines["right"].set_position(("outward", 60))
|
|
sns.lineplot(
|
|
data=df,
|
|
x="T(sec)",
|
|
y="BF(bpm)_smoothed",
|
|
color="green",
|
|
ax=ax3,
|
|
linewidth=2,
|
|
label="BF (bpm)",
|
|
)
|
|
ax3.set_ylabel("BF (bpm)", color="green")
|
|
ax3.tick_params(axis="y", labelcolor="green")
|
|
ax3.set_ylim(0, df["BF(bpm)_smoothed"].max() + 1)
|
|
ax1.set_xticks(np.arange(0, df["T(sec)"].max() + 200, 200))
|
|
|
|
# Combine legends
|
|
if ax1.get_legend():
|
|
ax1.get_legend().remove()
|
|
if ax2.get_legend():
|
|
ax2.get_legend().remove()
|
|
if ax3.get_legend():
|
|
ax3.get_legend().remove()
|
|
|
|
lines1, labels1 = ax1.get_legend_handles_labels()
|
|
lines2, labels2 = ax2.get_legend_handles_labels()
|
|
lines3, labels3 = ax3.get_legend_handles_labels()
|
|
ax1.legend(
|
|
lines1 + lines2 + lines3, labels1 + labels2 + labels3, loc="upper left"
|
|
)
|
|
|
|
# Add colored background regions
|
|
if len(phase_times) >= 4:
|
|
ax1.axvspan(0, phase_times[1], alpha=0.2, color="lightblue")
|
|
ax1.axvspan(phase_times[1], phase_times[2], alpha=0.2, color="purple")
|
|
ax1.axvspan(phase_times[2], phase_times[3], alpha=0.2, color="lightgreen")
|
|
ax1.axvspan(phase_times[3], df["T(sec)"].max(), alpha=0.2, color="blue")
|
|
|
|
chart_path = self.charts_dir / "recovery_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_body_composition_chart(
|
|
self, fat_mass_lbs: float, lean_mass_lbs: float, save_as_base64: bool = True
|
|
) -> str:
|
|
"""
|
|
Generate body composition donut chart.
|
|
|
|
Args:
|
|
fat_mass_lbs: Fat mass in pounds
|
|
lean_mass_lbs: Lean mass in pounds
|
|
save_as_base64: If True, return base64 string, else return file path
|
|
|
|
Returns:
|
|
Base64 string or file path
|
|
"""
|
|
# Calculate percentages
|
|
total_weight = fat_mass_lbs + lean_mass_lbs
|
|
fat_percentage = (fat_mass_lbs / total_weight) * 100
|
|
lean_percentage = (lean_mass_lbs / total_weight) * 100
|
|
|
|
sizes = [fat_percentage, lean_percentage]
|
|
colors = ["#fde3ac", "#ff9966"]
|
|
|
|
plt.figure(figsize=(8, 8))
|
|
|
|
# Create donut chart
|
|
plt.pie(
|
|
sizes,
|
|
autopct="",
|
|
startangle=90,
|
|
wedgeprops=dict(width=0.5, edgecolor="w"),
|
|
colors=colors,
|
|
labels=["", ""],
|
|
)
|
|
|
|
# Add custom text annotations
|
|
plt.text(
|
|
-1,
|
|
1,
|
|
f"Fat Mass ({fat_mass_lbs:.1f}lbs)\n{fat_percentage:.1f}%",
|
|
fontsize=14,
|
|
fontweight="bold",
|
|
ha="center",
|
|
va="center",
|
|
bbox=dict(boxstyle="round,pad=0.3", facecolor="white", alpha=0.8),
|
|
)
|
|
|
|
plt.text(
|
|
1,
|
|
-1,
|
|
f"Lean Mass ({lean_mass_lbs:.1f}lbs)\n{lean_percentage:.1f}%",
|
|
fontsize=14,
|
|
fontweight="bold",
|
|
ha="center",
|
|
va="center",
|
|
bbox=dict(boxstyle="round,pad=0.3", facecolor="white", alpha=0.8),
|
|
)
|
|
|
|
plt.axis("equal")
|
|
|
|
chart_path = self.charts_dir / "body_composition_chart.png"
|
|
plt.savefig(chart_path, bbox_inches="tight", dpi=600)
|
|
plt.close()
|
|
|
|
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|
|
|
|
def generate_body_fat_percent_chart(
|
|
self,
|
|
fat_percentage: float,
|
|
age: int,
|
|
gender: str,
|
|
save_as_base64: bool = True,
|
|
) -> str:
|
|
"""
|
|
Generate body fat percentage chart.
|
|
|
|
Args:
|
|
fat_percentage: Body fat percentage
|
|
age: Patient age
|
|
gender: Patient gender ('male' or 'female')
|
|
save_as_base64: If True, return base64 string, else return file path
|
|
|
|
Returns:
|
|
Base64 string or file path
|
|
"""
|
|
# Determine age group
|
|
if 20 <= age <= 39:
|
|
age_group = "20-39"
|
|
elif 40 <= age <= 59:
|
|
age_group = "40-59"
|
|
elif 60 <= age <= 79:
|
|
age_group = "60-79"
|
|
else:
|
|
age_group = "20-39" # Default
|
|
|
|
demographic = f"{age_group}\n({gender[0].upper()})"
|
|
|
|
# Define segments based on gender (female example)
|
|
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%
|
|
]
|
|
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%
|
|
]
|
|
|
|
fig, ax = plt.subplots(figsize=(10, 2))
|
|
|
|
# Create the segmented bar
|
|
for color, start, length in segments:
|
|
ax.barh(
|
|
y=0,
|
|
width=length,
|
|
left=start,
|
|
height=1,
|
|
color=color,
|
|
edgecolor="black",
|
|
linewidth=0.5,
|
|
)
|
|
|
|
# Add the indicator (triangle)
|
|
ax.plot(
|
|
fat_percentage,
|
|
1.05,
|
|
marker="v",
|
|
color="black",
|
|
markersize=10,
|
|
clip_on=False,
|
|
transform=ax.get_xaxis_transform(),
|
|
)
|
|
|
|
# Set axis properties
|
|
ax.set_xlim(0, 50)
|
|
ax.set_xticks(range(0, 51, 5))
|
|
ax.set_yticks([])
|
|
ax.text(
|
|
-0.05,
|
|
0,
|
|
demographic,
|
|
transform=ax.get_yaxis_transform(),
|
|
va="center",
|
|
ha="right",
|
|
fontsize=12,
|
|
)
|
|
|
|
ticks = range(0, 51, 5)
|
|
ax.set_xticks(ticks)
|
|
labels = [f"{t}%" for t in ticks]
|
|
ax.set_xticklabels(labels)
|
|
|
|
# Clean up spines
|
|
ax.spines["right"].set_visible(False)
|
|
ax.spines["top"].set_visible(False)
|
|
ax.spines["left"].set_visible(False)
|
|
ax.spines["bottom"].set_visible(True)
|
|
|
|
# Add tick marks
|
|
for x in range(0, 51, 5):
|
|
ax.plot(
|
|
[x, x],
|
|
[-0.05, -0.01],
|
|
color="black",
|
|
transform=ax.get_xaxis_transform(),
|
|
clip_on=False,
|
|
)
|
|
|
|
plt.tight_layout()
|
|
|
|
chart_path = self.charts_dir / "body_fat_percent_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_spirometry_chart(
|
|
self, spirometry_df: pd.DataFrame, save_as_base64: bool = True
|
|
) -> str:
|
|
"""
|
|
Generate spirometry chart with Z-scores.
|
|
|
|
Args:
|
|
spirometry_df: Spirometry DataFrame with parameters
|
|
save_as_base64: If True, return base64 string, else return file path
|
|
|
|
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")
|
|
|
|
# Select rows of interest
|
|
rows_map = {
|
|
"Lung Volume": "FVC",
|
|
"Lung Power": "FEV1",
|
|
"Power/Volume": "FEV1/FVC%",
|
|
}
|
|
|
|
records = []
|
|
for label, param in rows_map.items():
|
|
row = spirometry_df.loc[spirometry_df["Parameters"].str.strip() == param]
|
|
if row.empty:
|
|
continue
|
|
row = row.iloc[0]
|
|
records.append(
|
|
{
|
|
"label": label,
|
|
"param": param,
|
|
"best": row["Best"],
|
|
"pct": row["%Pred."],
|
|
"z": row["ZScore"],
|
|
}
|
|
)
|
|
|
|
# Figure setup
|
|
fig, axes = plt.subplots(
|
|
nrows=3,
|
|
ncols=1,
|
|
figsize=(11.5, 3.6),
|
|
sharex=True,
|
|
gridspec_kw={"hspace": 0.65},
|
|
)
|
|
|
|
x_min, x_max = -5, 3
|
|
# Segment colors
|
|
segments = [
|
|
(-5, -4, "#f4a7a7"), # red-ish
|
|
(-4, -3, "#f7c49a"), # orange-ish
|
|
(-3, -1.7, "#f6e3a3"), # yellow-ish
|
|
(-1.7, 3, "#c9f0cc"), # green-ish
|
|
]
|
|
|
|
ticks = np.arange(x_min, x_max + 1, 1)
|
|
labels = [str(i) for i in ticks]
|
|
|
|
# Plot each row
|
|
for ax, rec in zip(axes, records):
|
|
# Background segments
|
|
for a, b, color in segments:
|
|
ax.barh(
|
|
0, width=b - a, left=a, height=0.6, color=color, edgecolor="none"
|
|
)
|
|
|
|
# LLN and Predicted markers
|
|
ax.axvline(0, color="black", lw=1)
|
|
|
|
# Z-score pointer
|
|
if pd.notna(rec["z"]):
|
|
trans = mtransforms.blended_transform_factory(
|
|
ax.transData, ax.transAxes
|
|
)
|
|
ax.plot(
|
|
float(rec["z"]),
|
|
1.2,
|
|
marker="v",
|
|
markersize=12,
|
|
color="dimgray",
|
|
transform=trans,
|
|
clip_on=False,
|
|
)
|
|
|
|
# Labels and styling
|
|
ax.set_title(
|
|
rec["label"], loc="left", fontsize=11, fontweight="bold", pad=2
|
|
)
|
|
ax.set_xlim(x_min, x_max)
|
|
ax.set_yticks([])
|
|
ax.set_xticks(ticks)
|
|
ax.set_xticklabels(labels, fontsize=8)
|
|
ax.set_xlabel("")
|
|
|
|
# Top annotations
|
|
axes[0].text(-1.7, 0.45, "LLN", ha="center", va="bottom", fontsize=9)
|
|
axes[0].text(0, 0.45, "Predicted", ha="center", va="bottom", fontsize=9)
|
|
|
|
# Right-side summary boxes
|
|
fig.subplots_adjust(right=0.78)
|
|
box_ax = fig.add_axes([0.805, 0.06, 0.18, 0.90])
|
|
box_ax.axis("off")
|
|
|
|
def pill(ax, xy, text):
|
|
x, y = xy
|
|
bbox = FancyBboxPatch(
|
|
(x - 0.48, y - 0.09),
|
|
0.96,
|
|
0.18,
|
|
boxstyle="round,pad=0.02,rounding_size=0.08",
|
|
ec="#dddddd",
|
|
fc="#f3f3f3",
|
|
linewidth=1.0,
|
|
)
|
|
ax.add_patch(bbox)
|
|
ax.text(
|
|
x,
|
|
y + 0.025,
|
|
text,
|
|
ha="center",
|
|
va="center",
|
|
fontsize=11,
|
|
fontweight="bold",
|
|
)
|
|
ax.text(
|
|
x,
|
|
y - 0.055,
|
|
"of predicted",
|
|
ha="center",
|
|
va="center",
|
|
fontsize=9,
|
|
color="#555555",
|
|
)
|
|
|
|
box_ax.set_xlim(0, 1)
|
|
box_ax.set_ylim(0, 1)
|
|
|
|
# Prepare display strings
|
|
right_items = []
|
|
for rec in records:
|
|
name = (
|
|
"FVC"
|
|
if rec["param"] == "FVC"
|
|
else ("FEV1" if rec["param"] == "FEV1" else "FEV1/FVC")
|
|
)
|
|
unit = "L" if rec["param"] in ("FVC", "FEV1") else "%"
|
|
value_fmt = f"{rec['best']:.2f}{unit}"
|
|
pct_fmt = f"{rec['pct']:.1f}%"
|
|
right_items.append((name, value_fmt, pct_fmt))
|
|
|
|
# Sort to match order
|
|
order = ["FVC", "FEV1", "FEV1/FVC"]
|
|
right_items_sorted = [
|
|
next(item for item in right_items if item[0] == k) for k in order
|
|
]
|
|
|
|
ys = [0.82, 0.48, 0.15]
|
|
for (name, value_fmt, pct_fmt), y in zip(right_items_sorted, ys):
|
|
main_line = f"{name}\n{value_fmt} → {pct_fmt}"
|
|
pill(box_ax, (0.5, y), main_line)
|
|
|
|
chart_path = self.charts_dir / "spirometry_chart.png"
|
|
plt.savefig(chart_path, dpi=300, bbox_inches="tight")
|
|
plt.close()
|
|
|
|
return self._image_to_base64(chart_path) if save_as_base64 else str(chart_path)
|