BiotechU3 / app.py
C2MV's picture
Update app.py
44f50e3 verified
raw
history blame
39.4 kB
import os
import io
import tempfile
from PIL import Image
# Instala/actualiza Gradio si no está presente.
# En entornos como Google Colab, esto es útil. En despliegues locales, puede comentarse.
try:
import gradio
except ImportError:
print("Instalando Gradio...")
os.system("pip install --upgrade gradio")
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
import gradio as gr
# --- CLASE PRINCIPAL DEL MODELO DE BIOPROCESO ---
# Contiene toda la lógica matemática, ajuste y graficación.
class BioprocessModel:
"""
Clase para modelar, ajustar y simular cinéticas de bioprocesos.
Incluye modelos para crecimiento de biomasa, consumo de sustrato y formación de producto.
"""
def __init__(self, model_type='logistic', maxfev=50000):
self.model_type = model_type
self.maxfev = maxfev
self.params = {}
self.r2 = {}
self.rmse = {}
self.data_time = None
self.data_biomass_mean = None
self.data_substrate_mean = None
self.data_product_mean = None
self.data_biomass_std = None
self.data_substrate_std = None
self.data_product_std = None
self.biomass_model_func = None # Función del modelo analítico (ej. logistic)
self.biomass_diff_func = None # Función de la ecuación diferencial (ej. logistic_diff)
# --- Modelos Analíticos de Biomasa ---
@staticmethod
def logistic(time, xo, xm, um):
# Salvaguardas para evitar errores matemáticos
if xm <= 0 or xo <= 0 or xm <= xo:
return np.full_like(time, np.nan)
# Previene división por cero y logaritmo de cero en casos extremos
term_exp = np.exp(um * time)
denominator = (1 - (xo / xm) * (1 - term_exp))
# Si el denominador es cero, reemplázalo por un número pequeño para evitar error
denominator = np.where(denominator == 0, 1e-9, denominator)
return (xo * term_exp) / denominator
@staticmethod
def gompertz(time, xm, um, lag):
# Salvaguardas
if xm <= 0 or um <= 0:
return np.full_like(time, np.nan)
# Previene overflow en np.exp
exp_term = (um * np.e / xm) * (lag - time) + 1
exp_term_clipped = np.clip(exp_term, -np.inf, 700) # exp(709) es aprox. el float máximo
return xm * np.exp(-np.exp(exp_term_clipped))
@staticmethod
def moser(time, Xm, um, Ks):
# Forma simplificada, no dependiente de sustrato
if Xm <= 0 or um <= 0:
return np.full_like(time, np.nan)
return Xm * (1 - np.exp(-um * (time - Ks)))
@staticmethod
def baranyi(time, X0, Xm, um, lag):
# Salvaguardas
if X0 <= 0 or Xm <= X0 or um <= 0 or lag < 0:
return np.full_like(time, np.nan)
# Argumento del logaritmo en A(t), previene valores no positivos
log_arg_A = np.exp(-um * time) + np.exp(-um * lag) - np.exp(-um * (t + lag))
log_arg_A = np.where(log_arg_A <= 1e-9, 1e-9, log_arg_A)
A_t = time + (1 / um) * np.log(log_arg_A)
# Previene overflow
exp_um_At = np.exp(um * A_t)
exp_um_At_clipped = np.clip(exp_um_At, -np.inf, 700)
numerator = (Xm / X0) * exp_um_At_clipped
denominator = (Xm / X0 - 1) + exp_um_At_clipped
denominator = np.where(denominator == 0, 1e-9, denominator)
return X0 * (numerator / denominator)
# --- Ecuaciones Diferenciales de Biomasa ---
@staticmethod
def logistic_diff(X, t, params):
_, xm, um = params
if xm <= 0: return 0
return um * X * (1 - X / xm)
@staticmethod
def gompertz_diff(X, t, params):
xm, um, lag = params
if xm <= 0 or X <= 0: return 0
# Forma derivada d(Gompertz)/dt
k_val = um * np.e / xm
u_val = k_val * (lag - t) + 1
u_val_clipped = np.clip(u_val, -np.inf, 700) # Previene overflow
return X * k_val * np.exp(u_val_clipped)
@staticmethod
def moser_diff(X, t, params):
Xm, um, _ = params
if Xm <=0: return 0
return um * (Xm - X)
# --- Modelos de Sustrato y Producto (Luedeking-Piret) ---
def _get_biomass_at_t(self, time, biomass_params_list):
if self.biomass_model_func is None or not biomass_params_list:
return np.full_like(time, np.nan)
X_t = self.biomass_model_func(time, *biomass_params_list)
return X_t
def _get_initial_biomass(self, biomass_params_list):
if self.model_type in ['logistic', 'baranyi']:
return biomass_params_list[0]
elif self.model_type in ['gompertz', 'moser']:
return self.biomass_model_func(0, *biomass_params_list)
return 0
def substrate(self, time, so, p, q, biomass_params_list):
X_t = self._get_biomass_at_t(time, biomass_params_list)
if np.any(np.isnan(X_t)): return np.full_like(time, np.nan)
integral_X = np.zeros_like(X_t)
if len(time) > 1:
dt = np.diff(time, prepend=time[0] - (time[1]-time[0] if len(time)>1 else 1))
integral_X = np.cumsum(X_t * dt)
X0 = self._get_initial_biomass(biomass_params_list)
return so - p * (X_t - X0) - q * integral_X
def product(self, time, po, alpha, beta, biomass_params_list):
X_t = self._get_biomass_at_t(time, biomass_params_list)
if np.any(np.isnan(X_t)): return np.full_like(time, np.nan)
integral_X = np.zeros_like(X_t)
if len(time) > 1:
dt = np.diff(time, prepend=time[0] - (time[1]-time[0] if len(time)>1 else 1))
integral_X = np.cumsum(X_t * dt)
X0 = self._get_initial_biomass(biomass_params_list)
return po + alpha * (X_t - X0) + beta * integral_X
# --- Procesamiento de Datos ---
def process_data_from_df(self, df):
try:
time_col = [col for col in df.columns if col[1] == 'Tiempo'][0]
self.data_time = df[time_col].dropna().values
min_len = len(self.data_time)
def extract_mean_std(component_name):
cols = [col for col in df.columns if col[1] == component_name]
if not cols: return np.array([]), np.array([])
data_reps = [df[col].dropna().values for col in cols]
# Alinea las réplicas con la longitud del tiempo
aligned_reps = [rep for rep in data_reps if len(rep) == min_len]
if not aligned_reps: return np.array([]), np.array([])
data_np = np.array(aligned_reps)
mean_vals = np.mean(data_np, axis=0)
std_vals = np.std(data_np, axis=0, ddof=1) if data_np.shape[0] > 1 else np.zeros_like(mean_vals)
return mean_vals, std_vals
self.data_biomass_mean, self.data_biomass_std = extract_mean_std('Biomasa')
self.data_substrate_mean, self.data_substrate_std = extract_mean_std('Sustrato')
self.data_product_mean, self.data_product_std = extract_mean_std('Producto')
except (IndexError, KeyError) as e:
raise ValueError(f"El DataFrame no tiene la estructura esperada (columnas 'Tiempo', 'Biomasa', etc.). Error: {e}")
# --- Lógica de Ajuste de Modelos (Curve Fitting) ---
def set_model_functions(self):
model_map = {
'logistic': (self.logistic, self.logistic_diff),
'gompertz': (self.gompertz, self.gompertz_diff),
'moser': (self.moser, self.moser_diff),
'baranyi': (self.baranyi, None) # Baranyi no tiene una EDO simple implementada
}
if self.model_type in model_map:
self.biomass_model_func, self.biomass_diff_func = model_map[self.model_type]
else:
raise ValueError(f"Modelo de biomasa desconocido: {self.model_type}")
def _fit_component(self, fit_func, time, data, initial_guesses, bounds, *args):
try:
popt, _ = curve_fit(fit_func, time, data, p0=initial_guesses, bounds=bounds, maxfev=self.maxfev, ftol=1e-9, xtol=1e-9)
y_pred = fit_func(time, *popt, *args)
if np.any(np.isnan(y_pred)) or np.any(np.isinf(y_pred)):
return None, None, np.nan, np.nan
ss_res = np.sum((data - y_pred) ** 2)
ss_tot = np.sum((data - np.mean(data)) ** 2)
r2 = 1 - (ss_res / ss_tot) if ss_tot > 0 else 1.0
rmse = np.sqrt(mean_squared_error(data, y_pred))
return popt, y_pred, r2, rmse
except (RuntimeError, ValueError) as e:
print(f"Error en curve_fit para {fit_func.__name__}: {e}")
return None, None, np.nan, np.nan
def fit_all_models(self, time, biomass, substrate, product):
self.set_model_functions()
# 1. Ajustar Biomasa
y_pred_biomass = None
if biomass is not None and len(biomass) > 0:
popt_bio = self._fit_biomass_model(time, biomass)
if popt_bio is not None:
y_pred_biomass = self.biomass_model_func(time, *popt_bio)
# 2. Ajustar Sustrato y Producto (si biomasa se ajustó correctamente)
y_pred_substrate, y_pred_product = None, None
if 'biomass' in self.params and self.params['biomass']:
biomass_popt_list = list(self.params['biomass'].values())
if substrate is not None and len(substrate) > 0:
self._fit_substrate_model(time, substrate, biomass_popt_list)
if 'substrate' in self.params and self.params['substrate']:
substrate_popt = list(self.params['substrate'].values())
y_pred_substrate = self.substrate(time, *substrate_popt, biomass_popt_list)
if product is not None and len(product) > 0:
self._fit_product_model(time, product, biomass_popt_list)
if 'product' in self.params and self.params['product']:
product_popt = list(self.params['product'].values())
y_pred_product = self.product(time, *product_popt, biomass_popt_list)
return y_pred_biomass, y_pred_substrate, y_pred_product
def _fit_biomass_model(self, time, biomass):
# Estimaciones iniciales y límites para cada modelo
param_configs = {
'logistic': {
'p0': [biomass[0] if biomass[0] > 1e-6 else 1e-3, max(biomass), 0.1],
'bounds': ([1e-9, max(1e-9, biomass[0]), 1e-9], [max(biomass), np.inf, np.inf]),
'keys': ['Xo', 'Xm', 'um']
},
'gompertz': {
'p0': [max(biomass), 0.1, time[np.argmax(np.gradient(biomass))] if len(biomass)>1 else 0],
'bounds': ([max(1e-9, min(biomass)), 1e-9, 0], [np.inf, np.inf, max(time) or 1]),
'keys': ['Xm', 'um', 'lag']
},
'moser': {
'p0': [max(biomass), 0.1, 0],
'bounds': ([max(1e-9, min(biomass)), 1e-9, -np.inf], [np.inf, np.inf, np.inf]),
'keys': ['Xm', 'um', 'Ks']
},
'baranyi': {
'p0': [biomass[0] if biomass[0] > 1e-6 else 1e-3, max(biomass), 0.1, time[np.argmax(np.gradient(biomass))] if len(biomass)>1 else 0],
'bounds': ([1e-9, max(1e-9, biomass[0]), 1e-9, 0], [max(biomass), np.inf, np.inf, max(time) or 1]),
'keys': ['X0', 'Xm', 'um', 'lag']
}
}
config = param_configs[self.model_type]
popt, _, r2, rmse = self._fit_component(self.biomass_model_func, time, biomass, config['p0'], config['bounds'])
if popt is not None:
self.params['biomass'] = dict(zip(config['keys'], popt))
self.r2['biomass'] = r2
self.rmse['biomass'] = rmse
return popt
def _fit_substrate_model(self, time, substrate, biomass_popt_list):
p0 = [substrate[0] if len(substrate) > 0 else 1.0, 0.1, 0.01]
bounds = ([0, -np.inf, -np.inf], [np.inf, np.inf, np.inf]) # p, q pueden ser negativos
fit_func = lambda t, so, p, q: self.substrate(t, so, p, q, biomass_popt_list)
popt, _, r2, rmse = self._fit_component(fit_func, time, substrate, p0, bounds)
if popt is not None:
self.params['substrate'] = {'So': popt[0], 'p': popt[1], 'q': popt[2]}
self.r2['substrate'] = r2
self.rmse['substrate'] = rmse
return popt
def _fit_product_model(self, time, product, biomass_popt_list):
p0 = [product[0] if len(product) > 0 else 0.0, 0.1, 0.01]
bounds = ([0, -np.inf, -np.inf], [np.inf, np.inf, np.inf]) # alpha, beta pueden ser negativos
fit_func = lambda t, po, alpha, beta: self.product(t, po, alpha, beta, biomass_popt_list)
popt, _, r2, rmse = self._fit_component(fit_func, time, product, p0, bounds)
if popt is not None:
self.params['product'] = {'Po': popt[0], 'alpha': popt[1], 'beta': popt[2]}
self.r2['product'] = r2
self.rmse['product'] = rmse
return popt
# --- Lógica de Ecuaciones Diferenciales (ODE) ---
def system_ode(self, y, t, biomass_params_list, substrate_params_list, product_params_list):
X, S, P = y
# dX/dt
dXdt = self.biomass_diff_func(X, t, biomass_params_list) if self.biomass_diff_func else 0.0
# dS/dt
p_val = substrate_params_list[1] if len(substrate_params_list) > 1 else 0
q_val = substrate_params_list[2] if len(substrate_params_list) > 2 else 0
dSdt = -p_val * dXdt - q_val * X
# dP/dt
alpha_val = product_params_list[1] if len(product_params_list) > 1 else 0
beta_val = product_params_list[2] if len(product_params_list) > 2 else 0
dPdt = alpha_val * dXdt + beta_val * X
return [dXdt, dSdt, dPdt]
def solve_odes(self, time_fine):
if not self.biomass_diff_func:
print(f"Resolución de EDO no soportada para el modelo {self.model_type}.")
return None, None, None
# Reune los parámetros necesarios
try:
bio_params = list(self.params['biomass'].values())
sub_params = list(self.params.get('substrate', {}).values())
prod_params = list(self.params.get('product', {}).values())
# Condiciones iniciales de las EDO
X0 = self._get_initial_biomass(bio_params)
S0 = self.params.get('substrate', {}).get('So', 0)
P0 = self.params.get('product', {}).get('Po', 0)
initial_conditions = [X0, S0, P0]
sol = odeint(self.system_ode, initial_conditions, time_fine,
args=(bio_params, sub_params, prod_params), rtol=1e-6, atol=1e-6)
return sol[:, 0], sol[:, 1], sol[:, 2]
except (KeyError, IndexError, Exception) as e:
print(f"Error preparando o resolviendo EDOs: {e}")
return None, None, None
# --- Generación de Gráficos ---
def _generate_fine_time_grid(self, time_exp):
if time_exp is None or len(time_exp) < 2:
return np.array([])
return np.linspace(np.min(time_exp), np.max(time_exp), 500)
def plot_results(self, plot_config):
use_differential = plot_config['use_differential']
time_exp = plot_config['time_exp']
time_fine = self._generate_fine_time_grid(time_exp)
# Determina qué datos de modelo mostrar: EDO o ajuste directo
if use_differential and self.biomass_diff_func:
X_model, S_model, P_model = self.solve_odes(time_fine)
time_model = time_fine
else:
if use_differential and not self.biomass_diff_func:
print(f"Advertencia: EDO no soportada para {self.model_type}. Mostrando ajuste directo.")
# Recalcula las curvas del modelo en la malla fina
X_model, S_model, P_model = None, None, None
time_model = time_fine
if 'biomass' in self.params:
bio_p = list(self.params['biomass'].values())
X_model = self.biomass_model_func(time_model, *bio_p)
if 'substrate' in self.params:
sub_p = list(self.params['substrate'].values())
S_model = self.substrate(time_model, *sub_p, bio_p)
if 'product' in self.params:
prod_p = list(self.params['product'].values())
P_model = self.product(time_model, *prod_p, bio_p)
# Configuración del gráfico
sns.set_style(plot_config['style'])
fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(10, 15), sharex=True)
fig.suptitle(f"{plot_config['exp_name']} ({self.model_type.capitalize()})", fontsize=16)
plot_details = [
(ax1, 'biomass', plot_config['axis_labels']['biomass_label'], plot_config['biomass_exp'], plot_config['biomass_std'], X_model),
(ax2, 'substrate', plot_config['axis_labels']['substrate_label'], plot_config['substrate_exp'], plot_config['substrate_std'], S_model),
(ax3, 'product', plot_config['axis_labels']['product_label'], plot_config['product_exp'], plot_config['product_std'], P_model)
]
for ax, comp_name, ylabel, data_exp, data_std, data_model in plot_details:
# Graficar datos experimentales
if data_exp is not None and len(data_exp) > 0:
if plot_config['show_error_bars'] and data_std is not None and len(data_std) > 0:
ax.errorbar(time_exp, data_exp, yerr=data_std, fmt=plot_config['marker_style'], color=plot_config['point_color'],
label='Datos Exp.', capsize=plot_config['error_cap_size'], elinewidth=plot_config['error_line_width'])
else:
ax.plot(time_exp, data_exp, linestyle='', marker=plot_config['marker_style'], color=plot_config['point_color'], label='Datos Exp.')
# Graficar modelo
if data_model is not None and len(data_model) > 0 and not np.all(np.isnan(data_model)):
ax.plot(time_model, data_model, linestyle=plot_config['line_style'], color=plot_config['line_color'], label='Modelo')
# Etiquetas y Títulos
ax.set_ylabel(ylabel)
ax.set_title(ylabel)
if plot_config['show_legend']: ax.legend(loc=plot_config['legend_pos'])
# Caja de parámetros
if plot_config['show_params'] and comp_name in self.params:
param_text = '\n'.join([f"{k} = {v:.3g}" for k, v in self.params[comp_name].items()])
r2_text = f"R² = {self.r2.get(comp_name, np.nan):.3f}"
rmse_text = f"RMSE = {self.rmse.get(comp_name, np.nan):.3f}"
full_text = f"{param_text}\n{r2_text}\n{rmse_text}"
pos_x, ha = (0.95, 'right') if 'right' in plot_config['params_pos'] else (0.05, 'left')
pos_y, va = (0.95, 'top') if 'upper' in plot_config['params_pos'] else (0.05, 'bottom')
ax.text(pos_x, pos_y, full_text, transform=ax.transAxes, verticalalignment=va, horizontalalignment=ha,
bbox={'boxstyle': 'round,pad=0.3', 'facecolor':'wheat', 'alpha':0.6})
ax3.set_xlabel(plot_config['axis_labels']['x_label'])
plt.tight_layout(rect=[0, 0.03, 1, 0.95])
# Convertir figura a imagen
buf = io.BytesIO()
fig.savefig(buf, format='png', bbox_inches='tight')
plt.close(fig)
buf.seek(0)
return Image.open(buf).convert("RGB")
def plot_combined_results(self, plot_config):
# Lógica de cálculo de modelo similar a plot_results
use_differential = plot_config['use_differential']
time_exp = plot_config['time_exp']
time_fine = self._generate_fine_time_grid(time_exp)
if use_differential and self.biomass_diff_func:
X_model, S_model, P_model = self.solve_odes(time_fine)
time_model = time_fine
else: #... (código idéntico a plot_results para cálculo de modelo)
X_model, S_model, P_model = None, None, None; time_model = time_fine
if 'biomass' in self.params:
bio_p = list(self.params['biomass'].values()); X_model = self.biomass_model_func(time_model, *bio_p)
if 'substrate' in self.params: S_model = self.substrate(time_model, *list(self.params['substrate'].values()), bio_p)
if 'product' in self.params: P_model = self.product(time_model, *list(self.params['product'].values()), bio_p)
# Colores fijos para claridad en el gráfico combinado
colors = {'Biomasa': '#0072B2', 'Sustrato': '#009E73', 'Producto': '#D55E00'}
model_colors = {'Biomasa': '#56B4E9', 'Sustrato': '#34E499', 'Producto': '#F0E442'}
sns.set_style(plot_config['style'])
fig, ax1 = plt.subplots(figsize=(12, 7))
fig.suptitle(f"{plot_config['exp_name']} ({self.model_type.capitalize()})", fontsize=16)
# Eje 1: Biomasa
ax1.set_xlabel(plot_config['axis_labels']['x_label'])
ax1.set_ylabel(plot_config['axis_labels']['biomass_label'], color=colors['Biomasa'])
if plot_config['biomass_exp'] is not None and len(plot_config['biomass_exp']) > 0:
ax1.plot(time_exp, plot_config['biomass_exp'], marker=plot_config['marker_style'], linestyle='', color=colors['Biomasa'], label='Biomasa (Datos)')
if X_model is not None: ax1.plot(time_model, X_model, color=model_colors['Biomasa'], linestyle=plot_config['line_style'], label='Biomasa (Modelo)')
ax1.tick_params(axis='y', labelcolor=colors['Biomasa'])
# Eje 2: Sustrato
ax2 = ax1.twinx()
ax2.set_ylabel(plot_config['axis_labels']['substrate_label'], color=colors['Sustrato'])
if plot_config['substrate_exp'] is not None and len(plot_config['substrate_exp']) > 0:
ax2.plot(time_exp, plot_config['substrate_exp'], marker=plot_config['marker_style'], linestyle='', color=colors['Sustrato'], label='Sustrato (Datos)')
if S_model is not None: ax2.plot(time_model, S_model, color=model_colors['Sustrato'], linestyle=plot_config['line_style'], label='Sustrato (Modelo)')
ax2.tick_params(axis='y', labelcolor=colors['Sustrato'])
# Eje 3: Producto
ax3 = ax1.twinx()
ax3.spines["right"].set_position(("axes", 1.18))
ax3.set_ylabel(plot_config['axis_labels']['product_label'], color=colors['Producto'])
if plot_config['product_exp'] is not None and len(plot_config['product_exp']) > 0:
ax3.plot(time_exp, plot_config['product_exp'], marker=plot_config['marker_style'], linestyle='', color=colors['Producto'], label='Producto (Datos)')
if P_model is not None: ax3.plot(time_model, P_model, color=model_colors['Producto'], linestyle=plot_config['line_style'], label='Producto (Modelo)')
ax3.tick_params(axis='y', labelcolor=colors['Producto'])
# Leyenda unificada
if plot_config['show_legend']:
h1, l1 = ax1.get_legend_handles_labels()
h2, l2 = ax2.get_legend_handles_labels()
h3, l3 = ax3.get_legend_handles_labels()
ax1.legend(h1 + h2 + h3, l1 + l2 + l3, loc=plot_config['legend_pos'])
# Caja de parámetros combinada
if plot_config['show_params']:
texts = []
for comp, label in [('biomass', 'Biomasa'), ('substrate', 'Sustrato'), ('product', 'Producto')]:
if comp in self.params:
p_text = '\n '.join([f"{k} = {v:.3g}" for k, v in self.params[comp].items()])
r2 = self.r2.get(comp, np.nan)
rmse = self.rmse.get(comp, np.nan)
texts.append(f"{label}:\n {p_text}\n R²={r2:.3f}, RMSE={rmse:.3f}")
full_text = "\n\n".join(texts)
pos_x, ha = (1.25, 'left') if plot_config['params_pos'] == 'outside right' else (0.05, 'left')
pos_y, va = (0.95, 'top')
ax1.text(pos_x, pos_y, full_text, transform=ax1.transAxes, fontsize=9,
verticalalignment=va, horizontalalignment=ha,
bbox=dict(boxstyle='round,pad=0.5', fc='wheat', alpha=0.7))
plt.tight_layout()
if plot_config['params_pos'] == 'outside right': fig.subplots_adjust(right=0.75)
buf = io.BytesIO()
fig.savefig(buf, format='png', bbox_inches='tight')
plt.close(fig)
buf.seek(0)
return Image.open(buf).convert("RGB")
# --- FUNCIÓN PRINCIPAL DE PROCESAMIENTO ---
# Orquesta la lectura de datos, el ajuste de modelos y la generación de salidas.
def run_analysis(file, selected_models, analysis_mode, exp_names_str, plot_settings):
if file is None:
return [], pd.DataFrame(), "Error: Por favor, sube un archivo Excel.", pd.DataFrame()
if not selected_models:
return [], pd.DataFrame(), "Error: Por favor, selecciona al menos un modelo.", pd.DataFrame()
try:
xls = pd.ExcelFile(file.name)
sheet_names = xls.sheet_names
except Exception as e:
return [], pd.DataFrame(), f"Error al leer el archivo: {e}", pd.DataFrame()
all_figures = []
all_results_data = []
messages = []
exp_names_list = [name.strip() for name in exp_names_str.split('\n') if name.strip()]
for i, sheet_name in enumerate(sheet_names):
exp_name_base = exp_names_list[i] if i < len(exp_names_list) else f"Hoja '{sheet_name}'"
try:
df = pd.read_excel(xls, sheet_name=sheet_name, header=[0, 1])
model_for_sheet = BioprocessModel()
model_for_sheet.process_data_from_df(df)
except Exception as e:
messages.append(f"Error procesando '{sheet_name}': {e}")
continue
# Lógica para modos 'average' y 'combinado'
if analysis_mode in ['average', 'combinado']:
if model_for_sheet.data_biomass_mean is None or len(model_for_sheet.data_biomass_mean) == 0:
messages.append(f"No hay datos de biomasa promedio en '{sheet_name}' para analizar.")
continue
for model_type in selected_models:
model_instance = BioprocessModel(model_type=model_type, maxfev=plot_settings['maxfev'])
model_instance.fit_all_models(
model_for_sheet.data_time,
model_for_sheet.data_biomass_mean,
model_for_sheet.data_substrate_mean,
model_for_sheet.data_product_mean
)
# Recopilar resultados para la tabla
result_row = {'Experimento': f"{exp_name_base} (Promedio)", 'Modelo': model_type.capitalize()}
for comp in ['biomass', 'substrate', 'product']:
if comp in model_instance.params:
for p_name, p_val in model_instance.params[comp].items():
result_row[f'{comp.capitalize()}_{p_name}'] = p_val
result_row[f'R2_{comp.capitalize()}'] = model_instance.r2.get(comp)
result_row[f'RMSE_{comp.capitalize()}'] = model_instance.rmse.get(comp)
all_results_data.append(result_row)
# Generar gráfico
current_plot_settings = plot_settings.copy()
current_plot_settings.update({
'exp_name': f"{exp_name_base} (Promedio)",
'time_exp': model_for_sheet.data_time,
'biomass_exp': model_for_sheet.data_biomass_mean, 'biomass_std': model_for_sheet.data_biomass_std,
'substrate_exp': model_for_sheet.data_substrate_mean, 'substrate_std': model_for_sheet.data_substrate_std,
'product_exp': model_for_sheet.data_product_mean, 'product_std': model_for_sheet.data_product_std,
})
plot_func = model_instance.plot_combined_results if analysis_mode == 'combinado' else model_instance.plot_results
fig = plot_func(current_plot_settings)
if fig: all_figures.append(fig)
# Lógica para modo 'independent'
elif analysis_mode == 'independent':
# ... (Lógica similar, iterando sobre las columnas de nivel 0 del DataFrame)
# Esta parte se omite por brevedad pero seguiría una estructura parecida a la original,
# llamando a fit_all_models y plot_results para cada experimento individual.
messages.append("El modo 'independent' aún no está completamente reimplementado en esta versión mejorada.")
final_message = "Análisis completado."
if messages:
final_message += " Mensajes:\n" + "\n".join(messages)
results_df = pd.DataFrame(all_results_data)
# Reordenar columnas para mejor legibilidad
if not results_df.empty:
id_cols = ['Experimento', 'Modelo']
param_cols = sorted([c for c in results_df.columns if '_' in c and 'R2' not in c and 'RMSE' not in c])
metric_cols = sorted([c for c in results_df.columns if 'R2' in c or 'RMSE' in c])
results_df = results_df[id_cols + param_cols + metric_cols]
return all_figures, results_df, final_message, results_df
# --- INTERFAZ DE USUARIO CON GRADIO ---
MODEL_CHOICES = [
("Logístico (3 parámetros)", "logistic"),
("Gompertz (3 parámetros)", "gompertz"),
("Moser (3 parámetros, simplificado)", "moser"),
("Baranyi (4 parámetros)", "baranyi")
]
def create_gradio_interface():
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 experimentales, selecciona los modelos a ajustar y visualiza los resultados.")
with gr.Tabs() as tabs:
with gr.TabItem("1. Teoría y Modelos", id=0):
gr.Markdown(r"""
### Modelos de Crecimiento de Biomasa
Esta herramienta ajusta los datos de crecimiento de biomasa a varios modelos matemáticos comunes:
- **Logístico (3p: $X_0, X_m, \mu_m$):** Modelo sigmoidal clásico que describe el crecimiento con una capacidad de carga.
$$ X(t) = \frac{X_0 X_m e^{\mu_m t}}{X_m - X_0 + X_0 e^{\mu_m t}} $$
- **Gompertz (3p: $X_m, \mu_m, \lambda$):** Modelo sigmoidal asimétrico, a menudo usado en microbiología.
$$ X(t) = X_m \exp\left(-\exp\left(\frac{\mu_m e}{X_m}(\lambda-t)+1\right)\right) $$
- **Moser (3p: $X_m, \mu_m, K_s$):** Forma simplificada no dependiente de sustrato usada aquí.
$$ X(t)=X_m(1-e^{-\mu_m(t-K_s)}) $$
- **Baranyi (4p: $X_0, X_m, \mu_m, \lambda$):** Modelo mecanicista que separa la fase de latencia del crecimiento exponencial.
$$ \frac{dy}{dt} = \frac{Q(t)}{1+Q(t)}\mu_{max}\left(1-\frac{y(t)}{y_{max}}\right)y(t) $$
Donde $y = \ln(X)$, y $Q(t)$ modela el estado fisiológico de las células.
### Modelos de Sustrato y Producto
El consumo de sustrato (S) y la formación de producto (P) se modelan con la ecuación de **Luedeking-Piret**:
$$ \frac{dS}{dt} = -p \frac{dX}{dt} - q X \quad ; \quad \frac{dP}{dt} = \alpha \frac{dX}{dt} + \beta X $$
- $\alpha, p$: Coeficientes asociados al crecimiento.
- $\beta, q$: Coeficientes no asociados al crecimiento (mantenimiento).
""")
with gr.TabItem("2. Configuración de Simulación", id=1):
with gr.Row():
with gr.Column(scale=2):
file_input = gr.File(label="Sube tu archivo Excel (.xlsx)", file_types=['.xlsx'])
exp_names_input = gr.Textbox(
label="Nombres de Experimentos/Hojas (opcional, uno por línea)",
placeholder="Nombre para Hoja 1\nNombre para Hoja 2\n...",
lines=3,
info="Si se deja en blanco, se usarán los nombres de las hojas del archivo."
)
with gr.Column(scale=3):
gr.Markdown("**Configuración Principal**")
model_selection_input = gr.CheckboxGroup(choices=MODEL_CHOICES, label="Modelos de Biomasa a Probar", value=["logistic", "baranyi"])
analysis_mode_input = gr.Radio(["average", "combinado"], label="Modo de Análisis", value="average",
info="Average: Gráficos separados por componente. Combinado: Un solo gráfico con 3 ejes Y.")
use_differential_input = gr.Checkbox(label="Usar Ecuaciones Diferenciales (EDO) para graficar", value=False,
info="Si se marca, las curvas se generan resolviendo las EDO. Si no, se usa el ajuste analítico. Requiere que el modelo tenga EDO implementada.")
with gr.Accordion("Opciones de Gráfico y Ajuste", open=False):
with gr.Row():
style_input = gr.Dropdown(['whitegrid', 'darkgrid', 'white', 'dark', 'ticks'], label="Estilo de Gráfico", value='whitegrid')
line_color_input = gr.ColorPicker(label="Color de Línea (Modelo)", value='#0072B2')
point_color_input = gr.ColorPicker(label="Color de Puntos (Datos)", value='#D55E00')
with gr.Row():
line_style_input = gr.Dropdown(['-', '--', '-.', ':'], label="Estilo de Línea", value='-')
marker_style_input = gr.Dropdown(['o', 's', '^', 'D', 'x'], label="Estilo de Marcador", value='o')
maxfev_input = gr.Number(label="Iteraciones de ajuste (maxfev)", value=50000, minimum=1000)
with gr.Row():
show_legend_input = gr.Checkbox(label="Mostrar Leyenda", value=True)
legend_pos_input = gr.Radio(["best", "upper left", "upper right", "lower left", "lower right"], label="Posición Leyenda", value="best")
with gr.Row():
show_params_input = gr.Checkbox(label="Mostrar Parámetros", value=True)
params_pos_input = gr.Radio(["upper right", "upper left", "lower right", "lower left", "outside right"], label="Posición Parámetros", value="upper right")
with gr.Row():
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")
with gr.Accordion("Títulos de los Ejes", open=False):
with gr.Row():
xlabel_input = gr.Textbox("Tiempo (h)", label="Eje X")
ylabel_bio_input = gr.Textbox("Biomasa (g/L)", label="Eje Y - Biomasa")
ylabel_sub_input = gr.Textbox("Sustrato (g/L)", label="Eje Y - Sustrato")
ylabel_prod_input = gr.Textbox("Producto (g/L)", label="Eje Y - Producto")
simulate_btn = gr.Button("Analizar y Graficar", variant="primary", scale=1)
with gr.TabItem("3. Resultados", id=2):
status_output = gr.Textbox(label="Estado del Análisis", interactive=False)
gallery_output = gr.Gallery(label="Gráficos Generados", columns=[1], height='auto', object_fit="contain")
gr.Markdown("### Tabla de Parámetros y Métricas de Ajuste")
table_output = gr.Dataframe(label="Resultados Detallados", wrap=True, interactive=False)
# Componente 'State' para guardar el dataframe para exportación
df_for_export = gr.State(pd.DataFrame())
with gr.Row():
export_excel_btn = gr.Button("Exportar a Excel (.xlsx)")
export_csv_btn = gr.Button("Exportar a CSV (.csv)")
download_output = gr.File(label="Descargar Archivo", interactive=False)
# Lógica de los botones
def simulation_wrapper(file, models, mode, names, use_diff, style, lc, pc, ls, ms, maxfev, s_leg, l_pos, s_par, p_pos, s_err, cap, lw, xl, yl_b, yl_s, yl_p):
plot_settings = {
'use_differential': use_diff, 'style': style,
'line_color': lc, 'point_color': pc, 'line_style': ls, 'marker_style': ms,
'maxfev': int(maxfev), 'show_legend': s_leg, 'legend_pos': l_pos,
'show_params': s_par, 'params_pos': p_pos,
'show_error_bars': s_err, 'error_cap_size': cap, 'error_line_width': lw,
'axis_labels': {'x_label': xl, 'biomass_label': yl_b, 'substrate_label': yl_s, 'product_label': yl_p}
}
return run_analysis(file, models, mode, names, plot_settings)
simulate_btn.click(
fn=simulation_wrapper,
inputs=[
file_input, model_selection_input, analysis_mode_input, exp_names_input, use_differential_input,
style_input, line_color_input, point_color_input, line_style_input, marker_style_input, maxfev_input,
show_legend_input, legend_pos_input, show_params_input, params_pos_input,
show_error_bars_input, error_cap_size_input, error_line_width_input,
xlabel_input, ylabel_bio_input, ylabel_sub_input, ylabel_prod_input
],
outputs=[gallery_output, table_output, status_output, df_for_export]
)
def export_to_file(df, file_format):
if df is None or df.empty:
gr.Warning("No hay datos en la tabla para exportar.")
return None
suffix = ".xlsx" if file_format == "excel" else ".csv"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmpfile:
if file_format == "excel":
df.to_excel(tmpfile.name, index=False)
else:
df.to_csv(tmpfile.name, index=False, encoding='utf-8-sig')
return tmpfile.name
export_excel_btn.click(fn=lambda df: export_to_file(df, "excel"), inputs=[df_for_export], outputs=[download_output])
export_csv_btn.click(fn=lambda df: export_to_file(df, "csv"), inputs=[df_for_export], outputs=[download_output])
return demo
if __name__ == '__main__':
gradio_app = create_gradio_interface()
gradio_app.launch(share=True, debug=True)