""" 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.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)