import os import io import tempfile from PIL import Image 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)