Spaces:
Sleeping
Sleeping
# --- INSTALACIÓN DE DEPENDENCIAS ADICIONALES --- | |
import os | |
import sys | |
import subprocess | |
def install_packages(): | |
packages = ["gradio", "plotly", "seaborn", "pandas", "openpyxl", "scikit-learn", | |
"fpdf2", "python-docx", "kaleido"] | |
for package in packages: | |
try: | |
__import__(package) | |
except ImportError: | |
print(f"Instalando {package}...") | |
subprocess.check_call([sys.executable, "-m", "pip", "install", package]) | |
install_packages() | |
# --- IMPORTACIONES --- | |
import os | |
import io | |
import tempfile | |
import traceback | |
import zipfile | |
from typing import List, Tuple, Dict, Any, Optional | |
from abc import ABC, abstractmethod | |
from unittest.mock import MagicMock | |
from PIL import Image | |
import gradio as gr | |
import plotly.graph_objects as go | |
import numpy as np | |
import pandas as pd | |
import matplotlib.pyplot as plt | |
import seaborn as sns | |
from scipy.integrate import odeint | |
from scipy.optimize import curve_fit | |
from sklearn.metrics import mean_squared_error | |
from docx import Document | |
from docx.shared import Inches | |
from fpdf import FPDF | |
from fpdf.enums import XPos, YPos | |
# --- CONSTANTES --- | |
C_TIME = 'tiempo' | |
C_BIOMASS = 'biomass' | |
C_SUBSTRATE = 'substrate' | |
C_PRODUCT = 'product' | |
COMPONENTS = [C_BIOMASS, C_SUBSTRATE, C_PRODUCT] | |
# --- BLOQUE 1: ESTRUCTURA DE MODELOS CINÉTICOS ESCALABLE --- | |
class KineticModel(ABC): | |
def __init__(self, name: str, display_name: str, param_names: List[str]): | |
self.name, self.display_name, self.param_names = name, display_name, param_names | |
self.num_params = len(param_names) | |
def model_function(self, t: np.ndarray, *params: float) -> np.ndarray: pass | |
def diff_function(self, X: float, t: float, params: List[float]) -> float: return 0.0 | |
def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]: pass | |
def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]: pass | |
class LogisticModel(KineticModel): | |
def __init__(self): super().__init__("logistic", "Logístico", ["Xo", "Xm", "um"]) | |
def model_function(self, t: np.ndarray, *params: float) -> np.ndarray: | |
xo, xm, um = params | |
if xm <= 0 or xo <= 0 or xm < xo: return np.full_like(t, np.nan) | |
exp_arg = np.clip(um * t, -700, 700); term_exp = np.exp(exp_arg) | |
denominator = 1 - (xo / xm) * (1 - term_exp); denominator = np.where(denominator == 0, 1e-9, denominator) | |
return (xo * term_exp) / denominator | |
def diff_function(self, X: float, t: float, params: List[float]) -> float: | |
_, xm, um = params; return um * X * (1 - X / xm) if xm > 0 else 0.0 | |
def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]: | |
return [biomass[0] if len(biomass) > 0 and biomass[0] > 1e-6 else 1e-3, max(biomass) if len(biomass) > 0 else 1.0, 0.1] | |
def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]: | |
initial_biomass = biomass[0] if len(biomass) > 0 else 1e-9; max_biomass = max(biomass) if len(biomass) > 0 else 1.0 | |
return ([1e-9, initial_biomass, 1e-9], [max_biomass * 1.2, max_biomass * 5, np.inf]) | |
class GompertzModel(KineticModel): | |
def __init__(self): super().__init__("gompertz", "Gompertz", ["Xm", "um", "lag"]) | |
def model_function(self, t: np.ndarray, *params: float) -> np.ndarray: | |
xm, um, lag = params | |
if xm <= 0 or um <= 0: return np.full_like(t, np.nan) | |
exp_term = (um * np.e / xm) * (lag - t) + 1; exp_term_clipped = np.clip(exp_term, -700, 700) | |
return xm * np.exp(-np.exp(exp_term_clipped)) | |
def diff_function(self, X: float, t: float, params: List[float]) -> float: | |
xm, um, lag = params; k_val = um * np.e / xm | |
u_val = k_val * (lag - t) + 1; u_val_clipped = np.clip(u_val, -np.inf, 700) | |
return X * k_val * np.exp(u_val_clipped) if xm > 0 and X > 0 else 0.0 | |
def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]: | |
return [max(biomass) if len(biomass) > 0 else 1.0, 0.1, time[np.argmax(np.gradient(biomass))] if len(biomass) > 1 else 0] | |
def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]: | |
initial_biomass = min(biomass) if len(biomass) > 0 else 1e-9; max_biomass = max(biomass) if len(biomass) > 0 else 1.0 | |
return ([max(1e-9, initial_biomass), 1e-9, 0], [max_biomass * 5, np.inf, max(time) if len(time) > 0 else 1]) | |
class MoserModel(KineticModel): | |
def __init__(self): super().__init__("moser", "Moser", ["Xm", "um", "Ks"]) | |
def model_function(self, t: np.ndarray, *params: float) -> np.ndarray: | |
Xm, um, Ks = params; return Xm * (1 - np.exp(-um * (t - Ks))) if Xm > 0 and um > 0 else np.full_like(t, np.nan) | |
def diff_function(self, X: float, t: float, params: List[float]) -> float: | |
Xm, um, _ = params; return um * (Xm - X) if Xm > 0 else 0.0 | |
def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]: | |
return [max(biomass) if len(biomass) > 0 else 1.0, 0.1, 0] | |
def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]: | |
initial_biomass = min(biomass) if len(biomass) > 0 else 1e-9; max_biomass = max(biomass) if len(biomass) > 0 else 1.0 | |
return ([max(1e-9, initial_biomass), 1e-9, -np.inf], [max_biomass * 5, np.inf, np.inf]) | |
class BaranyiModel(KineticModel): | |
def __init__(self): super().__init__("baranyi", "Baranyi", ["X0", "Xm", "um", "lag"]) | |
def model_function(self, t: np.ndarray, *params: float) -> np.ndarray: | |
X0, Xm, um, lag = params | |
if X0 <= 0 or Xm <= X0 or um <= 0 or lag < 0: return np.full_like(t, np.nan) | |
A_t = t + (1 / um) * np.log(np.exp(-um * t) + np.exp(-um * lag) - np.exp(-um * (t + lag))) | |
exp_um_At = np.exp(np.clip(um * A_t, -700, 700)) | |
numerator = Xm; denominator = 1 + ((Xm / X0) - 1) * (1 / exp_um_At) | |
return numerator / np.where(denominator == 0, 1e-9, denominator) | |
def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]: | |
return [biomass[0] if len(biomass) > 0 and biomass[0] > 1e-6 else 1e-3, max(biomass) if len(biomass) > 0 else 1.0, 0.1, time[np.argmax(np.gradient(biomass))] if len(biomass) > 1 else 0.0] | |
def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]: | |
initial_biomass = biomass[0] if len(biomass) > 0 else 1e-9; max_biomass = max(biomass) if len(biomass) > 0 else 1.0 | |
return ([1e-9, max(1e-9, initial_biomass), 1e-9, 0], [max_biomass * 1.2, max_biomass * 10, np.inf, max(time) if len(time) > 0 else 1]) | |
# --- REGISTRO CENTRAL DE MODELOS --- | |
AVAILABLE_MODELS: Dict[str, KineticModel] = {model.name: model for model in [LogisticModel(), GompertzModel(), MoserModel(), BaranyiModel()]} | |
# --- CLASE DE AJUSTE DE BIOPROCESOS --- | |
class BioprocessFitter: | |
def __init__(self, kinetic_model: KineticModel, maxfev: int = 50000): | |
self.model, self.maxfev = kinetic_model, maxfev | |
self.params: Dict[str, Dict[str, float]] = {c: {} for c in COMPONENTS} | |
self.r2: Dict[str, float] = {}; self.rmse: Dict[str, float] = {} | |
self.data_time: Optional[np.ndarray] = None | |
self.data_means: Dict[str, Optional[np.ndarray]] = {c: None for c in COMPONENTS} | |
self.data_stds: Dict[str, Optional[np.ndarray]] = {c: None for c in COMPONENTS} | |
def _get_biomass_at_t(self, t: np.ndarray, p: List[float]) -> np.ndarray: return self.model.model_function(t, *p) | |
def _get_initial_biomass(self, p: List[float]) -> float: | |
if not p: return 0.0 | |
if any(k in self.model.param_names for k in ["Xo", "X0"]): | |
try: | |
idx = self.model.param_names.index("Xo") if "Xo" in self.model.param_names else self.model.param_names.index("X0") | |
return p[idx] | |
except (ValueError, IndexError): pass | |
return float(self.model.model_function(np.array([0]), *p)[0]) | |
def _calc_integral(self, t: np.ndarray, p: List[float]) -> np.ndarray: | |
X_t = self._get_biomass_at_t(t, p) | |
if np.any(np.isnan(X_t)): return np.full_like(t, np.nan) | |
integral_X = np.zeros_like(X_t) | |
if len(t) > 1: | |
dt = np.diff(t, prepend=t[0] - (t[1] - t[0] if len(t) > 1 else 1)) | |
integral_X = np.cumsum(X_t * dt) | |
return integral_X, X_t | |
def substrate(self, t: np.ndarray, so: float, p_c: float, q: float, bio_p: List[float]) -> np.ndarray: | |
integral, X_t = self._calc_integral(t, bio_p); X0 = self._get_initial_biomass(bio_p) | |
return so - p_c * (X_t - X0) - q * integral | |
def product(self, t: np.ndarray, po: float, alpha: float, beta: float, bio_p: List[float]) -> np.ndarray: | |
integral, X_t = self._calc_integral(t, bio_p); X0 = self._get_initial_biomass(bio_p) | |
return po + alpha * (X_t - X0) + beta * integral | |
def process_data_from_df(self, df: pd.DataFrame) -> None: | |
try: | |
time_col = [c for c in df.columns if c[1].strip().lower() == C_TIME][0] | |
self.data_time = df[time_col].dropna().to_numpy(); min_len = len(self.data_time) | |
def extract(name: str) -> Tuple[np.ndarray, np.ndarray]: | |
cols = [c for c in df.columns if c[1].strip().lower() == name.lower()] | |
if not cols: return np.array([]), np.array([]) | |
reps = [df[c].dropna().values[:min_len] for c in cols]; reps = [r for r in reps if len(r) == min_len] | |
if not reps: return np.array([]), np.array([]) | |
arr = np.array(reps); mean = np.mean(arr, axis=0) | |
std = np.std(arr, axis=0, ddof=1) if arr.shape[0] > 1 else np.zeros_like(mean) | |
return mean, std | |
self.data_means[C_BIOMASS], self.data_stds[C_BIOMASS] = extract('Biomasa') | |
self.data_means[C_SUBSTRATE], self.data_stds[C_SUBSTRATE] = extract('Sustrato') | |
self.data_means[C_PRODUCT], self.data_stds[C_PRODUCT] = extract('Producto') | |
except (IndexError, KeyError) as e: raise ValueError(f"Estructura de DataFrame inválida. Error: {e}") | |
def _fit_component(self, func, t, data, p0, bounds, sigma=None, *args): | |
try: | |
if sigma is not None: sigma = np.where(sigma == 0, 1e-9, sigma) | |
popt, _ = curve_fit(func, t, data, p0, bounds=bounds, maxfev=self.maxfev, ftol=1e-9, xtol=1e-9, sigma=sigma, absolute_sigma=bool(sigma is not None)) | |
pred = func(t, *popt, *args) | |
if np.any(np.isnan(pred)): return None, np.nan, np.nan | |
r2 = 1 - np.sum((data - pred)**2) / np.sum((data - np.mean(data))**2) | |
rmse = np.sqrt(mean_squared_error(data, pred)) | |
return list(popt), r2, rmse | |
except (RuntimeError, ValueError): return None, np.nan, np.nan | |
def fit_all_models(self) -> None: | |
t, bio_m, bio_s = self.data_time, self.data_means[C_BIOMASS], self.data_stds[C_BIOMASS] | |
if t is None or bio_m is None or len(bio_m) == 0: return | |
popt_bio = self._fit_biomass_model(t, bio_m, bio_s) | |
if popt_bio: | |
bio_p = list(self.params[C_BIOMASS].values()) | |
if self.data_means[C_SUBSTRATE] is not None and len(self.data_means[C_SUBSTRATE]) > 0: self._fit_substrate_model(t, self.data_means[C_SUBSTRATE], self.data_stds[C_SUBSTRATE], bio_p) | |
if self.data_means[C_PRODUCT] is not None and len(self.data_means[C_PRODUCT]) > 0: self._fit_product_model(t, self.data_means[C_PRODUCT], self.data_stds[C_PRODUCT], bio_p) | |
def _fit_biomass_model(self, t, data, std): | |
p0, bounds = self.model.get_initial_params(t, data), self.model.get_param_bounds(t, data) | |
popt, r2, rmse = self._fit_component(self.model.model_function, t, data, p0, bounds, std) | |
if popt: self.params[C_BIOMASS], self.r2[C_BIOMASS], self.rmse[C_BIOMASS] = dict(zip(self.model.param_names, popt)), r2, rmse | |
return popt | |
def _fit_substrate_model(self, t, data, std, bio_p): | |
p0, b = [data[0], 0.1, 0.01], ([0, -np.inf, -np.inf], [np.inf, np.inf, np.inf]) | |
popt, r2, rmse = self._fit_component(lambda t, so, p, q: self.substrate(t, so, p, q, bio_p), t, data, p0, b, std) | |
if popt: self.params[C_SUBSTRATE], self.r2[C_SUBSTRATE], self.rmse[C_SUBSTRATE] = {'So': popt[0], 'p': popt[1], 'q': popt[2]}, r2, rmse | |
def _fit_product_model(self, t, data, std, bio_p): | |
p0, b = [data[0] if len(data)>0 else 0, 0.1, 0.01], ([0, -np.inf, -np.inf], [np.inf, np.inf, np.inf]) | |
popt, r2, rmse = self._fit_component(lambda t, po, a, b: self.product(t, po, a, b, bio_p), t, data, p0, b, std) | |
if popt: self.params[C_PRODUCT], self.r2[C_PRODUCT], self.rmse[C_PRODUCT] = {'Po': popt[0], 'alpha': popt[1], 'beta': popt[2]}, r2, rmse | |
def system_ode(self, y, t, bio_p, sub_p, prod_p): | |
X, _, _ = y; dXdt = self.model.diff_function(X, t, bio_p) | |
return [dXdt, -sub_p.get('p',0)*dXdt - sub_p.get('q',0)*X, prod_p.get('alpha',0)*dXdt + prod_p.get('beta',0)*X] | |
def solve_odes(self, t_fine): | |
p = self.params; bio_d, sub_d, prod_d = p[C_BIOMASS], p[C_SUBSTRATE], p[C_PRODUCT] | |
if not bio_d: return None, None, None | |
try: | |
bio_p = list(bio_d.values()); y0 = [self._get_initial_biomass(bio_p), sub_d.get('So',0), prod_d.get('Po',0)] | |
sol = odeint(self.system_ode, y0, t_fine, args=(bio_p, sub_d, prod_d)) | |
return sol[:, 0], sol[:, 1], sol[:, 2] | |
except: return None, None, None | |
def _generate_fine_time_grid(self, t_exp): return np.linspace(min(t_exp), max(t_exp), 500) if t_exp is not None and len(t_exp) > 1 else np.array([]) | |
def get_model_curves_for_plot(self, t_fine, use_diff): | |
if use_diff and self.model.diff_function(1, 1, [1]*self.model.num_params) != 0: return self.solve_odes(t_fine) | |
X, S, P = None, None, None | |
if self.params[C_BIOMASS]: | |
bio_p = list(self.params[C_BIOMASS].values()); X = self.model.model_function(t_fine, *bio_p) | |
if self.params[C_SUBSTRATE]: S = self.substrate(t_fine, *list(self.params[C_SUBSTRATE].values()), bio_p) | |
if self.params[C_PRODUCT]: P = self.product(t_fine, *list(self.params[C_PRODUCT].values()), bio_p) | |
return X, S, P | |
def plot_individual_or_combined(self, cfg, mode): | |
t_exp, t_fine = cfg['time_exp'], self._generate_fine_time_grid(cfg['time_exp']) | |
X_m, S_m, P_m = self.get_model_curves_for_plot(t_fine, cfg.get('use_differential', False)) | |
sns.set_style(cfg.get('style', 'whitegrid')) | |
if mode == 'average': | |
fig, (ax1,ax2,ax3) = plt.subplots(3,1,figsize=(10,15),sharex=True) | |
fig.suptitle(f"Análisis: {cfg.get('exp_name','')} ({self.model.display_name})", fontsize=16); axes=[ax1,ax2,ax3] | |
else: | |
fig, ax1 = plt.subplots(figsize=(12,8)); fig.suptitle(f"Análisis: {cfg.get('exp_name','')} ({self.model.display_name})", fontsize=16) | |
ax2,ax3 = ax1.twinx(),ax1.twinx(); ax3.spines["right"].set_position(("axes",1.18)); axes=[ax1,ax2,ax3] | |
data_map = {C_BIOMASS:X_m, C_SUBSTRATE:S_m, C_PRODUCT:P_m} | |
comb_styles = {C_BIOMASS:{'c':'#0072B2','mc':'#56B4E9','m':'o','ls':'-'}, C_SUBSTRATE:{'c':'#009E73','mc':'#34E499','m':'s','ls':'--'}, C_PRODUCT:{'c':'#D55E00','mc':'#F0E442','m':'^','ls':'-.'}} | |
for ax, comp in zip(axes, COMPONENTS): | |
ylabel, data, std, model_data = cfg.get('axis_labels',{}).get(f'{comp}_label',comp.capitalize()), cfg.get(f'{comp}_exp'), cfg.get(f'{comp}_std'), data_map.get(comp) | |
if mode == 'combined': | |
s = comb_styles[comp]; pc, lc, ms, ls = s['c'], s['mc'], s['m'], s['ls'] | |
else: | |
pc,lc,ms,ls = cfg.get(f'{comp}_point_color'), cfg.get(f'{comp}_line_color'), cfg.get(f'{comp}_marker_style'), cfg.get(f'{comp}_line_style') | |
ax_c = pc if mode == 'combined' else 'black'; ax.set_ylabel(ylabel,color=ax_c); ax.tick_params(axis='y',labelcolor=ax_c) | |
if data is not None and len(data)>0: | |
if cfg.get('show_error_bars') and std is not None and np.any(std>0): ax.errorbar(t_exp, data, yerr=std, fmt=ms, color=pc, label=f'{comp.capitalize()} (Datos)', capsize=cfg.get('error_cap_size',3), elinewidth=cfg.get('error_line_width',1)) | |
else: ax.plot(t_exp, data, ls='', marker=ms, color=pc, label=f'{comp.capitalize()} (Datos)') | |
if model_data is not None and len(model_data)>0: ax.plot(t_fine, model_data, ls=ls, color=lc, label=f'{comp.capitalize()} (Modelo)') | |
if mode=='average' and cfg.get('show_legend',True): ax.legend(loc=cfg.get('legend_pos','best')) | |
if mode=='average' and cfg.get('show_params',True) and self.params[comp]: | |
decs = cfg.get('decimal_places',3); p_txt='\n'.join([f"{k}={format_number(v,decs)}" for k,v in self.params[comp].items()]) | |
full_txt=f"{p_txt}\nR²={format_number(self.r2.get(comp,0),3)}, RMSE={format_number(self.rmse.get(comp,0),3)}" | |
pos_x,ha = (0.95,'right') if 'right' in cfg.get('params_pos','upper right') else (0.05,'left') | |
ax.text(pos_x,0.95,full_txt,transform=ax.transAxes,va='top',ha=ha,bbox=dict(boxstyle='round,pad=0.4',fc='wheat',alpha=0.7)) | |
if mode=='combined' and cfg.get('show_legend',True): | |
h1,l1=axes[0].get_legend_handles_labels(); h2,l2=axes[1].get_legend_handles_labels(); h3,l3=axes[2].get_legend_handles_labels() | |
axes[0].legend(handles=h1+h2+h3, labels=l1+l2+l3, loc=cfg.get('legend_pos','best')) | |
axes[-1].set_xlabel(cfg.get('axis_labels',{}).get('x_label','Tiempo')); plt.tight_layout() | |
if mode=='combined': fig.subplots_adjust(right=0.8) | |
return fig | |
# --- FUNCIONES AUXILIARES, DE PLOTEO Y REPORTE (COMPLETAS) --- | |
def format_number(value: Any, decimals: int) -> str: | |
""" | |
Formatea un número para su visualización. Si decimals es 0, usa un formato inteligente. | |
""" | |
if not isinstance(value, (int, float, np.number)) or pd.isna(value): | |
return "" if pd.isna(value) else str(value) | |
decimals = int(decimals) | |
if decimals == 0: | |
if 0 < abs(value) < 1: | |
return f"{value:.2e}" | |
else: | |
return str(int(round(value, 0))) | |
return str(round(value, decimals)) | |
def plot_model_comparison_matplotlib(plot_config: Dict, models_results: List[Dict]) -> plt.Figure: | |
""" | |
Crea un gráfico de comparación de modelos estático usando Matplotlib/Seaborn. | |
""" | |
time_exp = plot_config['time_exp'] | |
# Usar un modelo cualquiera solo para generar la rejilla de tiempo | |
time_fine = BioprocessFitter(list(AVAILABLE_MODELS.values())[0])._generate_fine_time_grid(time_exp) | |
num_models = len(models_results) | |
palettes = { | |
C_BIOMASS: sns.color_palette("Blues", num_models), | |
C_SUBSTRATE: sns.color_palette("Greens", num_models), | |
C_PRODUCT: sns.color_palette("Reds", num_models) | |
} | |
line_styles = ['-', '--', '-.', ':'] | |
sns.set_style(plot_config.get('style', 'whitegrid')) | |
fig, ax1 = plt.subplots(figsize=(12, 8)) | |
# Configuración de los 3 ejes Y | |
ax1.set_xlabel(plot_config['axis_labels']['x_label']) | |
ax1.set_ylabel(plot_config['axis_labels']['biomass_label'], color="navy", fontsize=12) | |
ax1.tick_params(axis='y', labelcolor="navy") | |
ax2 = ax1.twinx() | |
ax3 = ax1.twinx() | |
ax3.spines["right"].set_position(("axes", 1.22)) | |
ax2.set_ylabel(plot_config['axis_labels']['substrate_label'], color="darkgreen", fontsize=12) | |
ax2.tick_params(axis='y', labelcolor="darkgreen") | |
ax3.set_ylabel(plot_config['axis_labels']['product_label'], color="darkred", fontsize=12) | |
ax3.tick_params(axis='y', labelcolor="darkred") | |
# Dibujar datos experimentales | |
data_markers = {C_BIOMASS: 'o', C_SUBSTRATE: 's', C_PRODUCT: '^'} | |
for ax, key, color, face in [(ax1, C_BIOMASS, 'navy', 'skyblue'), (ax2, C_SUBSTRATE, 'darkgreen', 'lightgreen'), (ax3, C_PRODUCT, 'darkred', 'lightcoral')]: | |
data_exp = plot_config.get(f'{key}_exp') | |
data_std = plot_config.get(f'{key}_std') | |
if data_exp is not None: | |
if plot_config.get('show_error_bars') and data_std is not None and np.any(data_std > 0): | |
ax.errorbar(time_exp, data_exp, yerr=data_std, fmt=data_markers[key], color=color, label=f'{key.capitalize()} (Datos)', zorder=10, markersize=8, markerfacecolor=face, markeredgecolor=color, capsize=plot_config.get('error_cap_size', 3), elinewidth=plot_config.get('error_line_width', 1)) | |
else: | |
ax.plot(time_exp, data_exp, ls='', marker=data_markers[key], label=f'{key.capitalize()} (Datos)', zorder=10, ms=8, mfc=face, mec=color, mew=1.5) | |
# Dibujar curvas de los modelos | |
for i, res in enumerate(models_results): | |
ls = line_styles[i % len(line_styles)] | |
model_info = AVAILABLE_MODELS.get(res["name"], MagicMock(display_name=res["name"])) | |
model_display_name = model_info.display_name | |
for key_short, ax, name_long in [('X', ax1, C_BIOMASS), ('S', ax2, C_SUBSTRATE), ('P', ax3, C_PRODUCT)]: | |
if res.get(key_short) is not None: | |
ax.plot(time_fine, res[key_short], color=palettes[name_long][i], ls=ls, label=f'{name_long.capitalize()} ({model_display_name})', alpha=0.9) | |
fig.subplots_adjust(left=0.3, right=0.78, top=0.92, bottom=0.35 if plot_config.get('show_params') else 0.1) | |
if plot_config.get('show_legend'): | |
h1, l1 = ax1.get_legend_handles_labels(); h2, l2 = ax2.get_legend_handles_labels(); h3, l3 = ax3.get_legend_handles_labels() | |
fig.legend(h1 + h2 + h3, l1 + l2 + l3, loc='center left', bbox_to_anchor=(0.0, 0.5), fancybox=True, shadow=True, fontsize='small') | |
if plot_config.get('show_params'): | |
total_width = 0.95; box_width = total_width / num_models; start_pos = (1.0 - total_width) / 2 | |
for i, res in enumerate(models_results): | |
model_info = AVAILABLE_MODELS.get(res["name"], MagicMock(display_name=res["name"])) | |
text = f"**{model_info.display_name}**\n" + _generate_model_param_text(res, plot_config.get('decimal_places', 3)) | |
fig.text(start_pos + i * box_width, 0.01, text, transform=fig.transFigure, fontsize=7.5, va='bottom', ha='left', bbox=dict(boxstyle='round,pad=0.4', fc='ivory', ec='gray', alpha=0.9)) | |
fig.suptitle(f"Comparación de Modelos: {plot_config.get('exp_name', '')}", fontsize=16) | |
return fig | |
def plot_model_comparison_plotly(plot_config: Dict, models_results: List[Dict]) -> go.Figure: | |
""" | |
Crea un gráfico de comparación de modelos interactivo usando Plotly. | |
""" | |
fig = go.Figure() | |
time_exp = plot_config['time_exp'] | |
time_fine = BioprocessFitter(list(AVAILABLE_MODELS.values())[0])._generate_fine_time_grid(time_exp) | |
num_models = len(models_results) | |
palettes = { | |
C_BIOMASS: sns.color_palette("Blues", n_colors=num_models).as_hex(), | |
C_SUBSTRATE: sns.color_palette("Greens", n_colors=num_models).as_hex(), | |
C_PRODUCT: sns.color_palette("Reds", n_colors=num_models).as_hex() | |
} | |
line_styles, data_markers = ['solid', 'dash', 'dot', 'dashdot'], {C_BIOMASS: 'circle-open', C_SUBSTRATE: 'square-open', C_PRODUCT: 'diamond-open'} | |
for key, y_axis, color in [(C_BIOMASS, 'y1', 'navy'), (C_SUBSTRATE, 'y2', 'darkgreen'), (C_PRODUCT, 'y3', 'darkred')]: | |
data_exp, data_std = plot_config.get(f'{key}_exp'), plot_config.get(f'{key}_std') | |
if data_exp is not None: | |
error_y_config = dict(type='data', array=data_std, visible=True) if plot_config.get('show_error_bars') and data_std is not None and np.any(data_std > 0) else None | |
fig.add_trace(go.Scatter(x=time_exp, y=data_exp, mode='markers', name=f'{key.capitalize()} (Datos)', marker=dict(color=color, size=10, symbol=data_markers[key], line=dict(width=2)), error_y=error_y_config, yaxis=y_axis, legendgroup="data")) | |
for i, res in enumerate(models_results): | |
ls = line_styles[i % len(line_styles)] | |
model_display_name = AVAILABLE_MODELS.get(res["name"], MagicMock(display_name=res["name"])).display_name | |
if res.get('X') is not None: fig.add_trace(go.Scatter(x=time_fine, y=res['X'], mode='lines', name=f'Biomasa ({model_display_name})', line=dict(color=palettes[C_BIOMASS][i], dash=ls), legendgroup=res["name"])) | |
if res.get('S') is not None: fig.add_trace(go.Scatter(x=time_fine, y=res['S'], mode='lines', name=f'Sustrato ({model_display_name})', line=dict(color=palettes[C_SUBSTRATE][i], dash=ls), yaxis='y2', legendgroup=res["name"])) | |
if res.get('P') is not None: fig.add_trace(go.Scatter(x=time_fine, y=res['P'], mode='lines', name=f'Producto ({model_display_name})', line=dict(color=palettes[C_PRODUCT][i], dash=ls), yaxis='y3', legendgroup=res["name"])) | |
if plot_config.get('show_params'): | |
x_positions = np.linspace(0, 1, num_models * 2 + 1)[1::2] | |
for i, res in enumerate(models_results): | |
model_display_name = AVAILABLE_MODELS.get(res["name"], MagicMock(display_name=res["name"])).display_name | |
text = f"<b>{model_display_name}</b><br>" + _generate_model_param_text(res, plot_config.get('decimal_places', 3)).replace('\n', '<br>') | |
fig.add_annotation(text=text, align='left', showarrow=False, xref='paper', yref='paper', x=x_positions[i], y=-0.35, bordercolor='gray', borderwidth=1, bgcolor='ivory', opacity=0.9) | |
fig.update_layout( | |
title=f"Comparación de Modelos (Interactivo): {plot_config.get('exp_name', '')}", | |
xaxis=dict(domain=[0.18, 0.82]), | |
yaxis=dict(title=plot_config['axis_labels']['biomass_label'], titlefont=dict(color='navy'), tickfont=dict(color='navy')), | |
yaxis2=dict(title=plot_config['axis_labels']['substrate_label'], titlefont=dict(color='darkgreen'), tickfont=dict(color='darkgreen'), overlaying='y', side='right'), | |
yaxis3=dict(title=plot_config['axis_labels']['product_label'], titlefont=dict(color='darkred'), tickfont=dict(color='darkred'), overlaying='y', side='right', position=0.85), | |
legend=dict(traceorder="grouped", yanchor="middle", y=0.5, xanchor="right", x=-0.15), | |
margin=dict(l=200, r=150, b=250 if plot_config.get('show_params') else 80, t=80), | |
template="seaborn", | |
showlegend=plot_config.get('show_legend', True) | |
) | |
return fig | |
def _generate_model_param_text(result: Dict, decimals: int) -> str: | |
"""Genera el texto formateado de los parámetros para las cajas de anotación.""" | |
text = "" | |
for comp in COMPONENTS: | |
if params := result.get('params', {}).get(comp): | |
p_str = ', '.join([f"{k}={format_number(v, decimals)}" for k, v in params.items()]) | |
r2 = result.get('r2', {}).get(comp, 0) | |
rmse = result.get('rmse', {}).get(comp, 0) | |
text += f"<i>{comp[:4].capitalize()}:</i> {p_str}\n(R²={format_number(r2, 3)}, RMSE={format_number(rmse, 3)})\n" | |
return text.strip() | |
def create_zip_file(image_list: List[Any]) -> Optional[str]: | |
if not image_list: | |
gr.Warning("No hay gráficos para descargar.") | |
return None | |
try: | |
zip_buffer = io.BytesIO() | |
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: | |
for i, fig in enumerate(image_list): | |
buf = io.BytesIO() | |
if isinstance(fig, go.Figure): buf.write(fig.to_image(format="png", scale=2, engine="kaleido")) | |
elif isinstance(fig, plt.Figure): fig.savefig(buf, format='png', dpi=200, bbox_inches='tight'); plt.close(fig) | |
elif isinstance(fig, Image.Image): fig.save(buf, 'PNG') | |
else: continue | |
buf.seek(0) | |
zf.writestr(f"grafico_{i+1}.png", buf.read()) | |
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp: | |
tmp.write(zip_buffer.getvalue()) | |
return tmp.name | |
except Exception as e: | |
traceback.print_exc() | |
gr.Error(f"Error al crear el archivo ZIP: {e}") | |
return None | |
def create_word_report(image_list: List[Any], table_df: pd.DataFrame, decimals: int) -> Optional[str]: | |
if not image_list and (table_df is None or table_df.empty): | |
gr.Warning("No hay datos ni gráficos para crear el reporte.") | |
return None | |
try: | |
doc = Document() | |
doc.add_heading('Reporte de Análisis de Cinéticas', 0) | |
if table_df is not None and not table_df.empty: | |
doc.add_heading('Tabla de Resultados', level=1) | |
table = doc.add_table(rows=1, cols=len(table_df.columns), style='Table Grid') | |
for i, col in enumerate(table_df.columns): table.cell(0, i).text = str(col) | |
for _, row in table_df.iterrows(): | |
cells = table.add_row().cells | |
for i, val in enumerate(row): cells[i].text = str(format_number(val, decimals)) | |
if image_list: | |
doc.add_page_break() | |
doc.add_heading('Gráficos Generados', level=1) | |
for i, fig in enumerate(image_list): | |
buf = io.BytesIO() | |
if isinstance(fig, go.Figure): buf.write(fig.to_image(format="png", scale=2, engine="kaleido")) | |
elif isinstance(fig, plt.Figure): fig.savefig(buf, format='png', dpi=200, bbox_inches='tight'); plt.close(fig) | |
elif isinstance(fig, Image.Image): fig.save(buf, 'PNG') | |
else: continue | |
buf.seek(0) | |
doc.add_paragraph(f'Gráfico {i+1}', style='Heading 3') | |
doc.add_picture(buf, width=Inches(6.0)) | |
with tempfile.NamedTemporaryFile(delete=False, suffix=".docx") as tmp: | |
doc.save(tmp.name) | |
return tmp.name | |
except Exception as e: | |
traceback.print_exc() | |
gr.Error(f"Error al crear el reporte de Word: {e}") | |
return None | |
def create_pdf_report(image_list: List[Any], table_df: pd.DataFrame, decimals: int) -> Optional[str]: | |
if not image_list and (table_df is None or table_df.empty): | |
gr.Warning("No hay datos ni gráficos para crear el reporte.") | |
return None | |
try: | |
pdf = FPDF() | |
pdf.set_auto_page_break(auto=True, margin=15) | |
pdf.add_page() | |
pdf.set_font("Helvetica", 'B', 16) | |
pdf.cell(0, 10, 'Reporte de Análisis de Cinéticas', new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='C') | |
if table_df is not None and not table_df.empty: | |
pdf.ln(10) | |
pdf.set_font("Helvetica", 'B', 12) | |
pdf.cell(0, 10, 'Tabla de Resultados', new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L') | |
pdf.set_font("Helvetica", 'B', 8) | |
effective_page_width = pdf.w - 2 * pdf.l_margin | |
num_cols = len(table_df.columns) | |
col_width = effective_page_width / num_cols if num_cols > 0 else 0 | |
if num_cols > 15: pdf.set_font_size(6) | |
elif num_cols > 10: pdf.set_font_size(7) | |
for col in table_df.columns: pdf.cell(col_width, 10, str(col), border=1, align='C') | |
pdf.ln() | |
pdf.set_font("Helvetica", '', 7) | |
if num_cols > 15: pdf.set_font_size(5) | |
elif num_cols > 10: pdf.set_font_size(6) | |
for _, row in table_df.iterrows(): | |
for val in row: pdf.cell(col_width, 10, str(format_number(val, decimals)), border=1, align='R') | |
pdf.ln() | |
if image_list: | |
for i, fig in enumerate(image_list): | |
pdf.add_page() | |
pdf.set_font("Helvetica", 'B', 12) | |
pdf.cell(0, 10, f'Gráfico {i+1}', new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L') | |
pdf.ln(5) | |
buf = io.BytesIO() | |
if isinstance(fig, go.Figure): buf.write(fig.to_image(format="png", scale=2, engine="kaleido")) | |
elif isinstance(fig, plt.Figure): fig.savefig(buf, format='png', dpi=200, bbox_inches='tight'); plt.close(fig) | |
elif isinstance(fig, Image.Image): fig.save(buf, 'PNG') | |
else: continue | |
buf.seek(0) | |
with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp_img: | |
tmp_img.write(buf.read()) | |
pdf.image(tmp_img.name, x=None, y=None, w=pdf.w - 20) | |
os.remove(tmp_img.name) | |
pdf_bytes = pdf.output() | |
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp: | |
tmp.write(pdf_bytes) | |
return tmp.name | |
except Exception as e: | |
traceback.print_exc() | |
gr.Error(f"Error al crear el reporte PDF: {e}") | |
return None | |
# --- FUNCIÓN PRINCIPAL DE ANÁLISIS --- | |
def run_analysis(file, model_names, mode, engine, exp_names, settings): | |
if not file: return [], pd.DataFrame(), "Error: Sube un archivo Excel.", pd.DataFrame() | |
if not model_names: return [], pd.DataFrame(), "Error: Selecciona un modelo.", pd.DataFrame() | |
try: xls = pd.ExcelFile(file.name) | |
except Exception as e: return [], pd.DataFrame(), f"Error al leer archivo: {e}", pd.DataFrame() | |
figs, results_data, msgs = [], [], [] | |
exp_list = [n.strip() for n in exp_names.split('\n') if n.strip()] | |
for i, sheet in enumerate(xls.sheet_names): | |
exp_name = exp_list[i] if i < len(exp_list) else f"Hoja '{sheet}'" | |
try: | |
df = pd.read_excel(xls, sheet_name=sheet, header=[0,1]) | |
reader = BioprocessFitter(list(AVAILABLE_MODELS.values())[0]) | |
reader.process_data_from_df(df) | |
if reader.data_time is None: msgs.append(f"WARN: Sin datos de tiempo en '{sheet}'."); continue | |
cfg = settings.copy(); cfg.update({'exp_name':exp_name, 'time_exp':reader.data_time}) | |
for c in COMPONENTS: cfg[f'{c}_exp'], cfg[f'{c}_std'] = reader.data_means[c], reader.data_stds[c] | |
t_fine, plot_results = reader._generate_fine_time_grid(reader.data_time), [] | |
for m_name in model_names: | |
if m_name not in AVAILABLE_MODELS: msgs.append(f"WARN: Modelo '{m_name}' no disponible."); continue | |
fitter = BioprocessFitter(AVAILABLE_MODELS[m_name], maxfev=int(settings.get('maxfev',50000))) | |
fitter.data_time, fitter.data_means, fitter.data_stds = reader.data_time, reader.data_means, reader.data_stds | |
fitter.fit_all_models() | |
row = {'Experimento':exp_name, 'Modelo':fitter.model.display_name} | |
for c in COMPONENTS: | |
if fitter.params[c]: row.update({f'{c.capitalize()}_{k}':v for k,v in fitter.params[c].items()}) | |
row[f'R2_{c.capitalize()}'], row[f'RMSE_{c.capitalize()}'] = fitter.r2.get(c), fitter.rmse.get(c) | |
results_data.append(row) | |
if mode in ["average","combined"]: | |
if hasattr(fitter,'plot_individual_or_combined'): figs.append(fitter.plot_individual_or_combined(cfg,mode)) | |
else: | |
X,S,P = fitter.get_model_curves_for_plot(t_fine, settings.get('use_differential',False)) | |
plot_results.append({'name':m_name, 'X':X, 'S':S, 'P':P, 'params':fitter.params, 'r2':fitter.r2, 'rmse':fitter.rmse}) | |
if mode=="model_comparison" and plot_results: | |
plot_func = plot_model_comparison_plotly if engine=='Plotly (Interactivo)' else plot_model_comparison_matplotlib | |
if 'plot_model_comparison_plotly' in globals(): figs.append(plot_func(cfg, plot_results)) | |
except Exception as e: msgs.append(f"ERROR en '{sheet}': {e}"); traceback.print_exc() | |
msg = "Análisis completado."+("\n"+"\n".join(msgs) if msgs else "") | |
df_res = pd.DataFrame(results_data).dropna(axis=1,how='all') | |
if not df_res.empty: | |
id_c, p_c, m_c = ['Experimento','Modelo'], sorted([c for c in df_res.columns if '_' in c and 'R2' not in c and 'RMSE' not in c]), sorted([c for c in df_res.columns if 'R2' in c or 'RMSE' in c]) | |
df_res = df_res[[c for c in id_c+p_c+m_c if c in df_res.columns]] | |
df_ui = df_res.copy() | |
for c in df_ui.select_dtypes(include=np.number).columns: df_ui[c] = df_ui[c].apply(lambda x:format_number(x,settings.get('decimal_places',3)) if pd.notna(x) else '') | |
else: df_ui = pd.DataFrame() | |
return figs, df_ui, msg, df_res | |
# --- INTERFAZ DE USUARIO DE GRADIO (COMPLETA) --- | |
def create_gradio_interface() -> gr.Blocks: | |
""" | |
Crea y configura la interfaz de usuario completa con Gradio. | |
""" | |
# Obtener las opciones de modelo dinámicamente del registro | |
MODEL_CHOICES = [(model.display_name, model.name) for model in AVAILABLE_MODELS.values()] | |
# Seleccionar por defecto los primeros 3 modelos o todos si hay menos de 3 | |
DEFAULT_MODELS = [m.name for m in list(AVAILABLE_MODELS.values())[:3]] | |
with gr.Blocks(theme=gr.themes.Soft(primary_hue="blue", secondary_hue="sky")) as demo: | |
gr.Markdown("# 🔬 Analizador de Cinéticas de Bioprocesos") | |
gr.Markdown("Sube tus datos, selecciona modelos, personaliza los gráficos y exporta los resultados.") | |
with gr.Tabs(): | |
# --- PESTAÑA 1: GUÍA Y FORMATO --- | |
with gr.TabItem("1. Guía y Formato de Datos"): | |
with gr.Row(): | |
with gr.Column(scale=2): | |
gr.Markdown( | |
""" | |
### Bienvenido al Analizador de Cinéticas | |
Esta herramienta te permite ajustar modelos matemáticos a tus datos de crecimiento microbiano. | |
**Pasos a seguir:** | |
1. Prepara tu archivo Excel según el formato especificado a la derecha. | |
2. Ve a la pestaña **"2. Configuración y Ejecución"**. | |
3. Sube tu archivo y selecciona los modelos cinéticos que deseas probar. | |
4. Ajusta las opciones de visualización y análisis según tus preferencias. | |
5. Haz clic en **"Analizar y Graficar"**. | |
6. Explora los resultados en la pestaña **"3. Resultados"**. | |
### Fórmulas de los Modelos | |
- **Logístico:** $ X(t) = \\frac{X_0 X_m e^{\\mu_m t}}{X_m - X_0 + X_0 e^{\\mu_m t}} $ | |
- **Gompertz:** $ X(t) = X_m \\exp\\left(-\\exp\\left(\\frac{\\mu_m e}{X_m}(\\lambda-t)+1\\right)\\right) $ | |
- **Moser:** $X(t) = X_m (1 - e^{-\\mu_m (t - K_s)})$ | |
""" | |
) | |
with gr.Column(scale=3): | |
gr.Markdown("### Formato del Archivo Excel") | |
gr.Markdown("Usa una **cabecera de dos niveles** para tus datos. La primera fila es el nombre de la réplica (ej. 'Rep1', 'Rep2') y la segunda el tipo de dato ('Tiempo', 'Biomasa', 'Sustrato', 'Producto').") | |
df_ejemplo = pd.DataFrame({ | |
('Rep1', 'Tiempo'): [0, 2, 4, 6], ('Rep1', 'Biomasa'): [0.1, 0.5, 2.5, 5.0], ('Rep1', 'Sustrato'): [10.0, 9.5, 7.0, 2.0], | |
('Rep2', 'Tiempo'): [0, 2, 4, 6], ('Rep2', 'Biomasa'): [0.12, 0.48, 2.6, 5.2], ('Rep2', 'Sustrato'): [10.2, 9.6, 7.1, 2.1], | |
}) | |
gr.DataFrame(df_ejemplo, interactive=False, label="Ejemplo de Formato") | |
# --- PESTAÑA 2: CONFIGURACIÓN Y EJECUCIÓN --- | |
with gr.TabItem("2. Configuración y Ejecución"): | |
with gr.Row(): | |
with gr.Column(scale=1): | |
file_input = gr.File(label="Sube tu archivo Excel (.xlsx)", file_types=['.xlsx']) | |
exp_names_input = gr.Textbox(label="Nombres de Experimentos (opcional)", placeholder="Nombre Hoja 1\nNombre Hoja 2\n...", lines=3, info="Un nombre por línea, en el mismo orden que las hojas del Excel.") | |
model_selection_input = gr.CheckboxGroup(choices=MODEL_CHOICES, label="Modelos a Probar", value=DEFAULT_MODELS) | |
analysis_mode_input = gr.Radio(["average", "combined", "model_comparison"], label="Modo de Análisis", value="average", info="Average: Gráficos separados.\nCombined: Un gráfico con 3 ejes.\nComparación: Gráfico global comparativo.") | |
plotting_engine_input = gr.Radio(["Seaborn (Estático)", "Plotly (Interactivo)"], label="Motor Gráfico (en modo Comparación)", value="Plotly (Interactivo)") | |
with gr.Column(scale=2): | |
with gr.Accordion("Opciones Generales de Análisis", open=True): | |
decimal_places_input = gr.Slider(0, 10, value=3, step=1, label="Precisión Decimal de Parámetros", info="0 para notación científica automática.") | |
show_params_input = gr.Checkbox(label="Mostrar Parámetros en Gráfico", value=True) | |
show_legend_input = gr.Checkbox(label="Mostrar Leyenda en Gráfico", value=True) | |
use_differential_input = gr.Checkbox(label="Usar EDO para graficar", value=False, info="Simula con ecuaciones diferenciales en lugar de la fórmula integral.") | |
maxfev_input = gr.Number(label="Iteraciones Máximas de Ajuste (maxfev)", value=50000) | |
with gr.Accordion("Etiquetas de los Ejes", open=True): | |
with gr.Row(): xlabel_input = gr.Textbox(label="Etiqueta Eje X", value="Tiempo (h)", interactive=True) | |
with gr.Row(): | |
ylabel_biomass_input = gr.Textbox(label="Etiqueta Biomasa", value="Biomasa (g/L)", interactive=True) | |
ylabel_substrate_input = gr.Textbox(label="Etiqueta Sustrato", value="Sustrato (g/L)", interactive=True) | |
ylabel_product_input = gr.Textbox(label="Etiqueta Producto", value="Producto (g/L)", interactive=True) | |
with gr.Accordion("Opciones de Estilo (Modo 'Average' y 'Combined')", open=False): | |
style_input = gr.Dropdown(['whitegrid', 'darkgrid', 'white', 'dark', 'ticks'], label="Estilo General (Matplotlib)", value='whitegrid') | |
with gr.Row(): | |
with gr.Column(): | |
gr.Markdown("**Biomasa**"); biomass_point_color_input = gr.ColorPicker(label="Color Puntos", value='#0072B2'); biomass_line_color_input = gr.ColorPicker(label="Color Línea", value='#56B4E9'); biomass_marker_style_input = gr.Dropdown(['o','s','^','D','p','*','X'], label="Marcador", value='o'); biomass_line_style_input = gr.Dropdown(['-','--','-.',':'], label="Estilo Línea", value='-') | |
with gr.Column(): | |
gr.Markdown("**Sustrato**"); substrate_point_color_input = gr.ColorPicker(label="Color Puntos", value='#009E73'); substrate_line_color_input = gr.ColorPicker(label="Color Línea", value='#34E499'); substrate_marker_style_input = gr.Dropdown(['o','s','^','D','p','*','X'], label="Marcador", value='s'); substrate_line_style_input = gr.Dropdown(['-','--','-.',':'], label="Estilo Línea", value='--') | |
with gr.Column(): | |
gr.Markdown("**Producto**"); product_point_color_input = gr.ColorPicker(label="Color Puntos", value='#D55E00'); product_line_color_input = gr.ColorPicker(label="Color Línea", value='#F0E442'); product_marker_style_input = gr.Dropdown(['o','s','^','D','p','*','X'], label="Marcador", value='^'); product_line_style_input = gr.Dropdown(['-','--','-.',':'], label="Estilo Línea", value='-.') | |
with gr.Row(): | |
legend_pos_input = gr.Radio(["best","upper right","upper left","lower left","lower right","center"], label="Posición Leyenda", value="best") | |
params_pos_input = gr.Radio(["upper right","upper left","lower right","lower left"], label="Posición Parámetros", value="upper right") | |
with gr.Accordion("Opciones de Barra de Error", open=False): | |
show_error_bars_input = gr.Checkbox(label="Mostrar barras de error (si hay réplicas)", value=True) | |
error_cap_size_input = gr.Slider(1, 10, 3, step=1, label="Tamaño Tapa Error") | |
error_line_width_input = gr.Slider(0.5, 5, 1.0, step=0.5, label="Grosor Línea Error") | |
simulate_btn = gr.Button("Analizar y Graficar", variant="primary") | |
# --- PESTAÑA 3: RESULTADOS --- | |
with gr.TabItem("3. Resultados"): | |
status_output = gr.Textbox(label="Estado del Análisis", interactive=False, lines=2) | |
gallery_output = gr.Gallery(label="Gráficos Generados", columns=1, height=600, object_fit="contain", preview=True) | |
with gr.Accordion("Descargar Reportes y Gráficos", open=True): | |
with gr.Row(): | |
zip_btn = gr.Button("Descargar Gráficos (.zip)"); word_btn = gr.Button("Descargar Reporte (.docx)"); pdf_btn = gr.Button("Descargar Reporte (.pdf)") | |
download_output = gr.File(label="Archivo de Descarga", interactive=False) | |
gr.Markdown("### Tabla de Resultados Numéricos"); table_output = gr.DataFrame(wrap=True) | |
with gr.Row(): | |
excel_btn = gr.Button("Descargar Tabla (.xlsx)"); csv_btn = gr.Button("Descargar Tabla (.csv)") | |
download_table_output = gr.File(label="Descargar Tabla", interactive=False) | |
df_for_export = gr.State(pd.DataFrame()); figures_for_export = gr.State([]) | |
# --- LÓGICA DE CONEXIÓN (WRAPPER Y EVENTOS .CLICK()) --- | |
demo.queue() | |
def simulation_wrapper(file, models, mode, engine, names, use_diff, s_par, s_leg, maxfev, decimals, x_label, bio_label, sub_label, prod_label, style, s_err, cap, lw, l_pos, p_pos, bio_pc, bio_lc, bio_ms, bio_ls, sub_pc, sub_lc, sub_ms, sub_ls, prod_pc, prod_lc, prod_ms, prod_ls): | |
try: | |
def rgba_to_hex(rgba_string: str) -> str: | |
if not isinstance(rgba_string, str) or rgba_string.startswith('#'): return rgba_string | |
try: | |
parts = rgba_string.lower().replace('rgba', '').replace('rgb', '').replace('(', '').replace(')', '') | |
r, g, b, *_ = map(float, parts.split(',')); return f'#{int(r):02x}{int(g):02x}{int(b):02x}' | |
except (ValueError, TypeError): return "#000000" | |
plot_settings = { | |
'decimal_places': int(decimals), 'use_differential': use_diff, 'style': style, 'show_legend': s_leg, 'show_params': s_par, 'maxfev': int(maxfev), | |
'axis_labels': {'x_label': x_label, 'biomass_label': bio_label, 'substrate_label': sub_label, 'product_label': prod_label}, | |
'legend_pos': l_pos, 'params_pos': p_pos, 'show_error_bars': s_err, 'error_cap_size': cap, 'error_line_width': lw, | |
f'{C_BIOMASS}_point_color': rgba_to_hex(bio_pc), f'{C_BIOMASS}_line_color': rgba_to_hex(bio_lc), f'{C_BIOMASS}_marker_style': bio_ms, f'{C_BIOMASS}_line_style': bio_ls, | |
f'{C_SUBSTRATE}_point_color': rgba_to_hex(sub_pc), f'{C_SUBSTRATE}_line_color': rgba_to_hex(sub_lc), f'{C_SUBSTRATE}_marker_style': sub_ms, f'{C_SUBSTRATE}_line_style': sub_ls, | |
f'{C_PRODUCT}_point_color': rgba_to_hex(prod_pc), f'{C_PRODUCT}_line_color': rgba_to_hex(prod_lc), f'{C_PRODUCT}_marker_style': prod_ms, f'{C_PRODUCT}_line_style': prod_ls, | |
} | |
figures, df_ui, msg, df_export = run_analysis(file, models, mode, engine, names, plot_settings) | |
image_list = [] | |
for fig in figures: | |
buf = io.BytesIO() | |
if isinstance(fig, go.Figure): buf.write(fig.to_image(format="png", scale=2, engine="kaleido")) | |
elif isinstance(fig, plt.Figure): fig.savefig(buf, format='png', bbox_inches='tight', dpi=150); plt.close(fig) | |
buf.seek(0); image_list.append(Image.open(buf).convert("RGB")) | |
return image_list, df_ui, msg, df_export, figures | |
except Exception as e: | |
print(f"--- ERROR CAPTURADO EN WRAPPER ---\n{traceback.format_exc()}"); return [], pd.DataFrame(), f"Error Crítico: {e}", pd.DataFrame(), [] | |
all_inputs = [ | |
file_input, model_selection_input, analysis_mode_input, plotting_engine_input, exp_names_input, | |
use_differential_input, show_params_input, show_legend_input, maxfev_input, decimal_places_input, | |
xlabel_input, ylabel_biomass_input, ylabel_substrate_input, ylabel_product_input, | |
style_input, show_error_bars_input, error_cap_size_input, error_line_width_input, legend_pos_input, params_pos_input, | |
biomass_point_color_input, biomass_line_color_input, biomass_marker_style_input, biomass_line_style_input, | |
substrate_point_color_input, substrate_line_color_input, substrate_marker_style_input, substrate_line_style_input, | |
product_point_color_input, product_line_color_input, product_marker_style_input, product_line_style_input | |
] | |
all_outputs = [gallery_output, table_output, status_output, df_for_export, figures_for_export] | |
simulate_btn.click(fn=simulation_wrapper, inputs=all_inputs, outputs=all_outputs) | |
zip_btn.click(fn=create_zip_file, inputs=[figures_for_export], outputs=[download_output]) | |
word_btn.click(fn=create_word_report, inputs=[figures_for_export, df_for_export, decimal_places_input], outputs=[download_output]) | |
pdf_btn.click(fn=create_pdf_report, inputs=[figures_for_export, df_for_export, decimal_places_input], outputs=[download_output]) | |
def export_table_to_file(df: pd.DataFrame, file_format: str) -> Optional[str]: | |
if df is None or df.empty: gr.Warning("No hay datos para exportar."); return None | |
suffix = ".xlsx" if file_format == "excel" else ".csv" | |
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: | |
if file_format == "excel": df.to_excel(tmp.name, index=False) | |
else: df.to_csv(tmp.name, index=False, encoding='utf-8-sig') | |
return tmp.name | |
excel_btn.click(fn=lambda df: export_table_to_file(df, "excel"), inputs=[df_for_export], outputs=[download_table_output]) | |
csv_btn.click(fn=lambda df: export_table_to_file(df, "csv"), inputs=[df_for_export], outputs=[download_table_output]) | |
return demo | |
# --- PUNTO DE ENTRADA PRINCIPAL --- | |
if __name__ == '__main__': | |
""" | |
Este bloque se ejecuta solo cuando el script es llamado directamente. | |
Crea la interfaz de Gradio y la lanza, haciendo que la aplicación | |
esté disponible en una URL local (y opcionalmente pública si share=True). | |
""" | |
# Todas las funciones necesarias (create_gradio_interface, run_analysis, | |
# funciones de ploteo y reporte) ya están definidas en el alcance global, | |
# por lo que no es necesario rellenar nada aquí. | |
# Crear la aplicación Gradio llamando a la función que la construye. | |
gradio_app = create_gradio_interface() | |
# Lanzar la aplicación. | |
# share=True: Crea un túnel público temporal a tu aplicación (útil para compartir). | |
# debug=True: Muestra más información de depuración en la consola si ocurren errores. | |
gradio_app.launch(share=True, debug=True) |