diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,7 +1,7 @@ -#import os -#!pip install gradio seaborn scipy scikit-learn openpyxl pydantic==1.10.0 -q +# import os # No parece usarse directamente, se puede quitar si no hay un uso oculto +# !pip install gradio seaborn scipy scikit-learn openpyxl pydantic==1.10.0 -q # Ejecutar en el entorno -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel # ConfigDict ya no es necesario en Pydantic V2 si solo usas arbitrary_types_allowed import numpy as np import pandas as pd import matplotlib.pyplot as plt @@ -14,7 +14,19 @@ import io from PIL import Image import tempfile -class YourModel(BaseModel): +# --- Constantes para nombres de columnas y etiquetas --- +COL_TIME = 'Tiempo' +COL_BIOMASS = 'Biomasa' +COL_SUBSTRATE = 'Sustrato' +COL_PRODUCT = 'Producto' + +LABEL_TIME = 'Tiempo' +LABEL_BIOMASS = 'Biomasa' +LABEL_SUBSTRATE = 'Sustrato' +LABEL_PRODUCT = 'Producto' +# --- Fin Constantes --- + +class YourModel(BaseModel): # Esto parece ser un vestigio, no se usa. Se puede quitar si es así. class Config: arbitrary_types_allowed = True @@ -26,20 +38,29 @@ class BioprocessModel: self.datax = [] self.datas = [] self.datap = [] - self.dataxp = [] + self.dataxp = [] # Promedios self.datasp = [] self.datapp = [] - self.datax_std = [] + self.datax_std = [] # Desviaciones estándar self.datas_std = [] self.datap_std = [] + self.datax_sem = [] # Errores estándar de la media + self.datas_sem = [] + self.datap_sem = [] + self.n_reps_x = [] # Número de réplicas para biomasa + self.n_reps_s = [] # Número de réplicas para sustrato + self.n_reps_p = [] # Número de réplicas para producto self.biomass_model = None self.biomass_diff = None self.model_type = model_type self.maxfev = maxfev + self.time = np.array([]) @staticmethod def logistic(time, xo, xm, um): - return (xo * np.exp(um * time)) / (1 - (xo / xm) * (1 - np.exp(um * time))) + denominator = (1 - (xo / xm) * (1 - np.exp(um * time))) + denominator = np.where(np.abs(denominator) < 1e-9, np.sign(denominator) * 1e-9 if np.any(denominator) else 1e-9, denominator) + return (xo * np.exp(um * time)) / denominator @staticmethod def gompertz(time, xm, um, lag): @@ -47,13 +68,7 @@ class BioprocessModel: @staticmethod def moser(time, Xm, um, Ks): - # Asegurarse de que el argumento de np.exp no sea demasiado grande - arg = -um * (time - Ks) - # Evitar overflow estableciendo un límite inferior para el exponente - # (o manejar valores muy pequeños de tiempo - Ks de otra manera si es necesario) - # Por ahora, simplemente calculamos como está, pero es un punto a considerar si hay errores. - return Xm * (1 - np.exp(arg)) - + return Xm * (1 - np.exp(-um * (time - Ks))) @staticmethod def logistic_diff(X, t, params): @@ -63,107 +78,91 @@ class BioprocessModel: @staticmethod def gompertz_diff(X, t, params): xm, um, lag = params - # Evitar división por cero si X es muy pequeño o cero - if X == 0: return 0 - # Evitar overflow en np.exp - exponent_val = (um * np.e / xm) * (lag - t) + 1 - if exponent_val > np.log(np.finfo(float).max / (um * np.e / xm)) - np.log(abs(X) if X != 0 else 1): # Aproximación para evitar overflow - return X * (um * np.e / xm) * 1e10 # Un valor grande pero no infinito - return X * (um * np.e / xm) * np.exp(exponent_val) - + return X * (um * np.e / xm) * np.exp((um * np.e / xm) * (lag - t) + 1) @staticmethod def moser_diff(X, t, params): Xm, um, Ks = params return um * (Xm - X) - def substrate(self, time, so, p, q, biomass_params): - X_t = self.biomass_model(time, *biomass_params) - # dXdt = np.gradient(X_t, time, edge_order=2) # Usar edge_order=2 para mejor estimación en bordes - # Usar una diferencia central más robusta si es posible, o manejar bordes con cuidado - if len(time) < 2: - dXdt = np.zeros_like(X_t) - else: - dXdt = np.gradient(X_t, time, edge_order=1) # edge_order=1 es más seguro para datos ruidosos - - 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)) # Estimar dt para el primer punto - integral_X = np.cumsum(X_t * dt) - return so - p * (X_t - biomass_params[0]) - q * integral_X - - - def product(self, time, po, alpha, beta, biomass_params): - X_t = self.biomass_model(time, *biomass_params) - # dXdt = np.gradient(X_t, time, edge_order=2) - if len(time) < 2: - dXdt = np.zeros_like(X_t) - else: - dXdt = np.gradient(X_t, time, edge_order=1) + def _get_biomass_model_params_as_list(self): + if 'biomass' not in self.params or not self.params['biomass']: + return None + if self.model_type == 'logistic': + return [self.params['biomass']['xo'], self.params['biomass']['xm'], self.params['biomass']['um']] + elif self.model_type == 'gompertz': + return [self.params['biomass']['xm'], self.params['biomass']['um'], self.params['biomass']['lag']] + elif self.model_type == 'moser': + return [self.params['biomass']['Xm'], self.params['biomass']['um'], self.params['biomass']['Ks']] + return None - 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) - return po + alpha * (X_t - biomass_params[0]) + beta * integral_X + def substrate(self, time, so, p, q, biomass_params_list): + if biomass_params_list is None: return np.full_like(time, so) + X_t = self.biomass_model(time, *biomass_params_list) + integral_X = np.cumsum(X_t) * np.gradient(time) + return so - p * (X_t - biomass_params_list[0]) - q * integral_X + def product(self, time, po, alpha, beta, biomass_params_list): + if biomass_params_list is None: return np.full_like(time, po) + X_t = self.biomass_model(time, *biomass_params_list) + integral_X = np.cumsum(X_t) * np.gradient(time) + return po + alpha * (X_t - biomass_params_list[0]) + beta * integral_X def process_data(self, df): - # Asegurar que los nombres de columna de nivel 1 existan - valid_level1_cols = df.columns.get_level_values(1) - - biomass_cols = [col for col in df.columns if col[1] == 'Biomasa' and col[0] in df.columns.levels[0]] - substrate_cols = [col for col in df.columns if col[1] == 'Sustrato' and col[0] in df.columns.levels[0]] - product_cols = [col for col in df.columns if col[1] == 'Producto' and col[0] in df.columns.levels[0]] - time_cols = [col for col in df.columns if col[1] == 'Tiempo' and col[0] in df.columns.levels[0]] - - if not time_cols: - raise ValueError("No se encontró la columna 'Tiempo' en el DataFrame.") - time_col = time_cols[0] # Asumimos que el tiempo es el mismo para todos los experimentos en una hoja - time = df[time_col].dropna().values # Usar dropna() para manejar NaNs si los hay - - if biomass_cols: - data_biomass = [df[col].dropna().values for col in biomass_cols] - # Asegurar que todos los arrays tengan la misma longitud que el tiempo - data_biomass = [arr[:len(time)] for arr in data_biomass if len(arr) >= len(time)] - if data_biomass: # Solo procesar si hay datos válidos - data_biomass_np = np.array(data_biomass) - self.datax.append(data_biomass_np) - self.dataxp.append(np.mean(data_biomass_np, axis=0)) - self.datax_std.append(np.std(data_biomass_np, axis=0, ddof=1)) - else: # Si no hay datos válidos, añadir arrays vacíos o de ceros con la forma correcta - self.datax.append(np.array([]).reshape(0,len(time)) if len(time)>0 else np.array([])) - self.dataxp.append(np.zeros(len(time))) - self.datax_std.append(np.zeros(len(time))) - - if substrate_cols: - data_substrate = [df[col].dropna().values for col in substrate_cols] - data_substrate = [arr[:len(time)] for arr in data_substrate if len(arr) >= len(time)] - if data_substrate: - data_substrate_np = np.array(data_substrate) - self.datas.append(data_substrate_np) - self.datasp.append(np.mean(data_substrate_np, axis=0)) - self.datas_std.append(np.std(data_substrate_np, axis=0, ddof=1)) - else: - self.datas.append(np.array([]).reshape(0,len(time)) if len(time)>0 else np.array([])) - self.datasp.append(np.zeros(len(time))) - self.datas_std.append(np.zeros(len(time))) - - if product_cols: - data_product = [df[col].dropna().values for col in product_cols] - data_product = [arr[:len(time)] for arr in data_product if len(arr) >= len(time)] - if data_product: - data_product_np = np.array(data_product) - self.datap.append(data_product_np) - self.datapp.append(np.mean(data_product_np, axis=0)) - self.datap_std.append(np.std(data_product_np, axis=0, ddof=1)) - else: - self.datap.append(np.array([]).reshape(0,len(time)) if len(time)>0 else np.array([])) - self.datapp.append(np.zeros(len(time))) - self.datap_std.append(np.zeros(len(time))) - + biomass_cols = [col for col in df.columns if col[1] == COL_BIOMASS] + substrate_cols = [col for col in df.columns if col[1] == COL_SUBSTRATE] + product_cols = [col for col in df.columns if col[1] == COL_PRODUCT] + + time_col_tuple = [col for col in df.columns if col[1] == COL_TIME] + if not time_col_tuple: + raise ValueError(f"No se encontró la columna de '{COL_TIME}' en los datos.") + time_col = time_col_tuple[0] + time = df[time_col].values self.time = time + def _process_type(data_cols, avg_list, std_list, sem_list, n_reps_list, raw_data_list): + if data_cols: + valid_data_arrays = [] + for col in data_cols: + try: + numeric_col = pd.to_numeric(df[col], errors='coerce').values + valid_data_arrays.append(numeric_col) + except KeyError: + print(f"Advertencia: Columna {col} no encontrada, se omitirá para el promedio/std/sem.") + continue + + if not valid_data_arrays: + avg_list.append(np.full_like(time, np.nan)) + std_list.append(np.full_like(time, np.nan)) + sem_list.append(np.full_like(time, np.nan)) + n_reps_list.append(np.zeros_like(time, dtype=int)) # Modificado para que sea un array de ceros + raw_data_list.append(np.array([])) + return + + data_reps = np.array(valid_data_arrays) + raw_data_list.append(data_reps) + + avg_list.append(np.nanmean(data_reps, axis=0)) + current_std = np.nanstd(data_reps, axis=0, ddof=1) + std_list.append(current_std) + + n_valid_reps_per_timepoint = np.sum(~np.isnan(data_reps), axis=0) + n_reps_list.append(n_valid_reps_per_timepoint) + + current_sem = np.zeros_like(current_std) * np.nan + valid_indices_for_sem = (n_valid_reps_per_timepoint > 1) + current_sem[valid_indices_for_sem] = current_std[valid_indices_for_sem] / np.sqrt(n_valid_reps_per_timepoint[valid_indices_for_sem]) + sem_list.append(current_sem) + else: + avg_list.append(np.full_like(time, np.nan)) + std_list.append(np.full_like(time, np.nan)) + sem_list.append(np.full_like(time, np.nan)) + n_reps_list.append(np.zeros_like(time, dtype=int)) # Modificado para que sea un array de ceros + raw_data_list.append(np.array([])) + + _process_type(biomass_cols, self.dataxp, self.datax_std, self.datax_sem, self.n_reps_x, self.datax) + _process_type(substrate_cols, self.datasp, self.datas_std, self.datas_sem, self.n_reps_s, self.datas) + _process_type(product_cols, self.datapp, self.datap_std, self.datap_sem, self.n_reps_p, self.datap) def fit_model(self): if self.model_type == 'logistic': @@ -175,1057 +174,800 @@ class BioprocessModel: elif self.model_type == 'moser': self.biomass_model = self.moser self.biomass_diff = self.moser_diff + else: + raise ValueError(f"Tipo de modelo desconocido: {self.model_type}") - def fit_biomass(self, time, biomass): - try: - # Asegurar que biomasa no esté vacío y tenga valores finitos - if biomass is None or len(biomass) == 0 or not np.all(np.isfinite(biomass)): - print(f"Datos de biomasa inválidos para {self.model_type}.") - return None + def fit_biomass(self, time, biomass, bounds=None): + p0 = None + fit_func = None + param_names = [] - if self.model_type == 'logistic': - # p0 = [min(biomass) if len(biomass)>0 else 0.1, max(biomass)*1.5 if max(biomass)>0 else 1.0, 0.1] - p0_xo = biomass[0] if len(biomass)>0 else 0.1 - p0_xm = max(biomass)*1.5 if len(biomass)>0 and max(biomass)>0 else 1.0 - p0_um = 0.1 - p0 = [p0_xo, p0_xm, p0_um] - bounds = ([0, 0, 0], [np.inf, np.inf, np.inf]) # Añadir bounds - popt, _ = curve_fit(self.logistic, time, biomass, p0=p0, maxfev=self.maxfev, bounds=bounds, method='trf') - self.params['biomass'] = {'xo': popt[0], 'xm': popt[1], 'um': popt[2]} - y_pred = self.logistic(time, *popt) - elif self.model_type == 'gompertz': - p0_xm = max(biomass) if len(biomass)>0 and max(biomass)>0 else 1.0 - p0_um = 0.1 - # Estimar lag como el tiempo donde la tasa de crecimiento es máxima - # o donde la biomasa alcanza, por ejemplo, el 10% de Xm si es más simple - p0_lag = time[np.argmax(np.gradient(biomass))] if len(biomass)>1 else time[0] if len(time)>0 else 0 - p0 = [p0_xm, p0_um, p0_lag] - bounds = ([0, 0, 0], [np.inf, np.inf, max(time) if len(time)>0 else np.inf]) # lag no debe ser mayor que el tiempo total - popt, _ = curve_fit(self.gompertz, time, biomass, p0=p0, maxfev=self.maxfev, bounds=bounds, method='trf') - self.params['biomass'] = {'xm': popt[0], 'um': popt[1], 'lag': popt[2]} - y_pred = self.gompertz(time, *popt) - elif self.model_type == 'moser': - p0_Xm = max(biomass) if len(biomass)>0 and max(biomass)>0 else 1.0 - p0_um = 0.1 - p0_Ks = time[0] if len(time)>0 else 0 # Ks podría ser el inicio del crecimiento - p0 = [p0_Xm, p0_um, p0_Ks] - bounds = ([0, 0, -np.inf], [np.inf, np.inf, np.inf]) # Ks puede ser negativo - popt, _ = curve_fit(self.moser, time, biomass, p0=p0, maxfev=self.maxfev, bounds=bounds, method='trf') - self.params['biomass'] = {'Xm': popt[0], 'um': popt[1], 'Ks': popt[2]} - y_pred = self.moser(time, *popt) - else: # Default case or error - return None - - # Calcular R2 y RMSE solo si y_pred es válido - if y_pred is not None and np.all(np.isfinite(y_pred)) and len(y_pred) == len(biomass): - if np.sum((biomass - np.mean(biomass)) ** 2) == 0: # Evitar división por cero si todos los y son iguales - self.r2['biomass'] = 1.0 if np.allclose(biomass, y_pred) else 0.0 - else: - self.r2['biomass'] = 1 - (np.sum((biomass - y_pred) ** 2) / np.sum((biomass - np.mean(biomass)) ** 2)) - self.rmse['biomass'] = np.sqrt(mean_squared_error(biomass, y_pred)) - else: - self.r2['biomass'] = np.nan - self.rmse['biomass'] = np.nan - return None # Indicar fallo si y_pred no es válido - return y_pred - except RuntimeError as e: # Específicamente para errores de curve_fit - print(f"Error de convergencia en fit_biomass_{self.model_type}: {e}") + if not np.any(np.isfinite(biomass)): # Si toda la biomasa es NaN o inf + print(f"Error en fit_biomass_{self.model_type}: Todos los datos de biomasa son no finitos.") self.params['biomass'] = {} - self.r2['biomass'] = np.nan - self.rmse['biomass'] = np.nan return None - except Exception as e: - print(f"Error general en fit_biomass_{self.model_type}: {e}") + + # Filtrar NaNs de biomasa y tiempo correspondiente para el ajuste + finite_mask = np.isfinite(biomass) + time_fit = time[finite_mask] + biomass_fit = biomass[finite_mask] + + if len(time_fit) < 2 : # No suficientes puntos para ajustar + print(f"Error en fit_biomass_{self.model_type}: No hay suficientes puntos de datos finitos para el ajuste ({len(time_fit)}).") self.params['biomass'] = {} - self.r2['biomass'] = np.nan - self.rmse['biomass'] = np.nan return None - def fit_substrate(self, time, substrate, biomass_params): - try: - if substrate is None or len(substrate) == 0 or not np.all(np.isfinite(substrate)): - print(f"Datos de sustrato inválidos para {self.model_type}.") - return None - if not biomass_params: # Si no hay parámetros de biomasa, no se puede ajustar sustrato - print(f"No hay parámetros de biomasa para ajustar sustrato con {self.model_type}.") - return None - - p0_so = substrate[0] if len(substrate)>0 else 1.0 - p0 = [p0_so, 0.01, 0.01] - bounds = ([0, 0, 0], [np.inf, np.inf, np.inf]) # Asumiendo parámetros no negativos - - if self.model_type == 'logistic': - current_biomass_params = [biomass_params['xo'], biomass_params['xm'], biomass_params['um']] - elif self.model_type == 'gompertz': - current_biomass_params = [biomass_params['xm'], biomass_params['um'], biomass_params['lag']] - elif self.model_type == 'moser': - current_biomass_params = [biomass_params['Xm'], biomass_params['um'], biomass_params['Ks']] - else: - return None - - popt, _ = curve_fit( - lambda t, so, p, q: self.substrate(t, so, p, q, current_biomass_params), - time, substrate, p0=p0, maxfev=self.maxfev, bounds=bounds, method='trf' - ) - self.params['substrate'] = {'so': popt[0], 'p': popt[1], 'q': popt[2]} - y_pred = self.substrate(time, *popt, current_biomass_params) - if y_pred is not None and np.all(np.isfinite(y_pred)) and len(y_pred) == len(substrate): - if np.sum((substrate - np.mean(substrate)) ** 2) == 0: - self.r2['substrate'] = 1.0 if np.allclose(substrate, y_pred) else 0.0 - else: - self.r2['substrate'] = 1 - (np.sum((substrate - y_pred) ** 2) / np.sum((substrate - np.mean(substrate)) ** 2)) - self.rmse['substrate'] = np.sqrt(mean_squared_error(substrate, y_pred)) - else: - self.r2['substrate'] = np.nan - self.rmse['substrate'] = np.nan - return None - return y_pred - except RuntimeError as e: - print(f"Error de convergencia en fit_substrate_{self.model_type}: {e}") - self.params['substrate'] = {} - self.r2['substrate'] = np.nan - self.rmse['substrate'] = np.nan + if self.model_type == 'logistic': + p0 = [max(1e-6,min(biomass_fit)), max(biomass_fit)*1.5 if max(biomass_fit)>0 else 1.0, 0.1] + fit_func = self.logistic + param_names = ['xo', 'xm', 'um'] + elif self.model_type == 'gompertz': + grad_b = np.gradient(biomass_fit) + lag_guess = time_fit[np.argmax(grad_b)] if len(time_fit) > 1 and np.any(grad_b > 1e-3) else time_fit[0] + p0 = [max(biomass_fit) if max(biomass_fit)>0 else 1.0, 0.1, lag_guess] + fit_func = self.gompertz + param_names = ['xm', 'um', 'lag'] + elif self.model_type == 'moser': + p0 = [max(biomass_fit) if max(biomass_fit)>0 else 1.0, 0.1, time_fit[0]] + fit_func = self.moser + param_names = ['Xm', 'um', 'Ks'] + + if fit_func is None: + print(f"Modelo de biomasa no configurado para {self.model_type}") return None + + try: + if bounds: + p0_bounded = [] + for i, val in enumerate(p0): + low = bounds[0][i] if bounds[0] and i < len(bounds[0]) else -np.inf + high = bounds[1][i] if bounds[1] and i < len(bounds[1]) else np.inf + p0_bounded.append(np.clip(val, low, high)) + p0 = p0_bounded + + popt, _ = curve_fit(fit_func, time_fit, biomass_fit, p0=p0, maxfev=self.maxfev, bounds=bounds or (-np.inf, np.inf)) + self.params['biomass'] = dict(zip(param_names, popt)) + y_pred_fit = fit_func(time_fit, *popt) # Predicción solo para datos ajustados + + # Para R2 y RMSE, usar solo los datos que se usaron para el ajuste + if np.sum((biomass_fit - np.mean(biomass_fit)) ** 2) < 1e-9: + self.r2['biomass'] = 1.0 if np.sum((biomass_fit - y_pred_fit) ** 2) < 1e-9 else 0.0 + else: + self.r2['biomass'] = 1 - (np.sum((biomass_fit - y_pred_fit) ** 2) / np.sum((biomass_fit - np.mean(biomass_fit)) ** 2)) + self.rmse['biomass'] = np.sqrt(mean_squared_error(biomass_fit, y_pred_fit)) + + # Devolver predicción para el 'time' original, no solo time_fit + y_pred_full = fit_func(time, *popt) + return y_pred_full except Exception as e: - print(f"Error general en fit_substrate_{self.model_type}: {e}") - self.params['substrate'] = {} - self.r2['substrate'] = np.nan - self.rmse['substrate'] = np.nan + print(f"Error en fit_biomass_{self.model_type}: {e}") + self.params['biomass'] = {} return None - def fit_product(self, time, product, biomass_params): - try: - if product is None or len(product) == 0 or not np.all(np.isfinite(product)): - print(f"Datos de producto inválidos para {self.model_type}.") - return None - if not biomass_params: - print(f"No hay parámetros de biomasa para ajustar producto con {self.model_type}.") - return None + def _fit_consumption_production(self, time, data, fit_type, p0_values, param_names): + biomass_params_list = self._get_biomass_model_params_as_list() + if biomass_params_list is None: + print(f"Parámetros de biomasa no disponibles para ajustar {fit_type}.") + return None - p0_po = product[0] if len(product)>0 else 0.0 - p0 = [p0_po, 0.01, 0.01] - bounds = ([0, 0, 0], [np.inf, np.inf, np.inf]) # Asumiendo parámetros no negativos + if not np.any(np.isfinite(data)): + print(f"Error en fit_{fit_type}_{self.model_type}: Todos los datos de {fit_type} son no finitos.") + self.params[fit_type] = {} + return None - if self.model_type == 'logistic': - current_biomass_params = [biomass_params['xo'], biomass_params['xm'], biomass_params['um']] - elif self.model_type == 'gompertz': - current_biomass_params = [biomass_params['xm'], biomass_params['um'], biomass_params['lag']] - elif self.model_type == 'moser': - current_biomass_params = [biomass_params['Xm'], biomass_params['um'], biomass_params['Ks']] - else: - return None + finite_mask = np.isfinite(data) + time_fit = time[finite_mask] + data_fit = data[finite_mask] + + if len(time_fit) < 2: + print(f"Error en fit_{fit_type}_{self.model_type}: No hay suficientes puntos de datos finitos para el ajuste ({len(time_fit)}).") + self.params[fit_type] = {} + return None + model_func = self.substrate if fit_type == 'substrate' else self.product + + try: popt, _ = curve_fit( - lambda t, po, alpha, beta: self.product(t, po, alpha, beta, current_biomass_params), - time, product, p0=p0, maxfev=self.maxfev, bounds=bounds, method='trf' + lambda t, *params_fit: model_func(t, *params_fit, biomass_params_list), + time_fit, data_fit, p0=p0_values, maxfev=self.maxfev ) - self.params['product'] = {'po': popt[0], 'alpha': popt[1], 'beta': popt[2]} - y_pred = self.product(time, *popt, current_biomass_params) + self.params[fit_type] = dict(zip(param_names, popt)) + y_pred_fit = model_func(time_fit, *popt, biomass_params_list) - if y_pred is not None and np.all(np.isfinite(y_pred)) and len(y_pred) == len(product): - if np.sum((product - np.mean(product)) ** 2) == 0: - self.r2['product'] = 1.0 if np.allclose(product, y_pred) else 0.0 - else: - self.r2['product'] = 1 - (np.sum((product - y_pred) ** 2) / np.sum((product - np.mean(product)) ** 2)) - self.rmse['product'] = np.sqrt(mean_squared_error(product, y_pred)) + if np.sum((data_fit - np.mean(data_fit)) ** 2) < 1e-9: + self.r2[fit_type] = 1.0 if np.sum((data_fit - y_pred_fit) ** 2) < 1e-9 else 0.0 else: - self.r2['product'] = np.nan - self.rmse['product'] = np.nan - return None - return y_pred - except RuntimeError as e: - print(f"Error de convergencia en fit_product_{self.model_type}: {e}") - self.params['product'] = {} - self.r2['product'] = np.nan - self.rmse['product'] = np.nan - return None + self.r2[fit_type] = 1 - (np.sum((data_fit - y_pred_fit) ** 2) / np.sum((data_fit - np.mean(data_fit)) ** 2)) + self.rmse[fit_type] = np.sqrt(mean_squared_error(data_fit, y_pred_fit)) + + y_pred_full = model_func(time, *popt, biomass_params_list) + return y_pred_full except Exception as e: - print(f"Error general en fit_product_{self.model_type}: {e}") - self.params['product'] = {} - self.r2['product'] = np.nan - self.rmse['product'] = np.nan + print(f"Error en fit_{fit_type}_{self.model_type}: {e}") + self.params[fit_type] = {} return None - def generate_fine_time_grid(self, time): - if len(time) == 0: return np.array([]) - time_fine = np.linspace(time.min(), time.max(), 500) - return time_fine + def fit_substrate(self, time, substrate): + p0_s = [max(1e-6, np.nanmin(substrate)) if np.any(np.isfinite(substrate)) else 1.0, 0.01, 0.01] + param_names_s = ['so', 'p', 'q'] + return self._fit_consumption_production(time, substrate, 'substrate', p0_s, param_names_s) - def system(self, y, t, biomass_params_tuple, substrate_params_tuple, product_params_tuple, model_type): - X, S, P = y + def fit_product(self, time, product): + p0_p = [max(1e-6, np.nanmin(product)) if np.any(np.isfinite(product)) else 0.0, 0.01, 0.01] + param_names_p = ['po', 'alpha', 'beta'] + return self._fit_consumption_production(time, product, 'product', p0_p, param_names_p) - # Asegurar que los parámetros no sean None o vacíos - if not biomass_params_tuple: biomass_params_tuple = (0,0,0) # Default - if not substrate_params_tuple: substrate_params_tuple = (0,0,0) - if not product_params_tuple: product_params_tuple = (0,0,0) - - - if model_type == 'logistic': - # xo, xm, um = biomass_params_tuple # xo no se usa en la diff - dXdt = self.logistic_diff(X, t, biomass_params_tuple) - elif model_type == 'gompertz': - # xm, um, lag = biomass_params_tuple - dXdt = self.gompertz_diff(X, t, biomass_params_tuple) - elif model_type == 'moser': - # Xm, um, Ks = biomass_params_tuple - dXdt = self.moser_diff(X, t, biomass_params_tuple) - else: - dXdt = 0.0 + def generate_fine_time_grid(self, time): + if len(time) < 2: return time + time_min, time_max = np.nanmin(time), np.nanmax(time) + if np.isnan(time_min) or np.isnan(time_max) or time_min == time_max : return time + return np.linspace(time_min, time_max, 500) - so, p, q = substrate_params_tuple - po, alpha, beta = product_params_tuple + def system(self, y, t, biomass_params_list, substrate_params_dict, product_params_dict): + X, S, P = y + dXdt = 0.0 + if self.model_type == 'logistic': + dXdt = self.logistic_diff(X, t, biomass_params_list) + elif self.model_type == 'gompertz': + dXdt = self.gompertz_diff(X, t, biomass_params_list) + elif self.model_type == 'moser': + dXdt = self.moser_diff(X, t, biomass_params_list) + + p = substrate_params_dict.get('p', 0) + q = substrate_params_dict.get('q', 0) + alpha = product_params_dict.get('alpha', 0) + beta = product_params_dict.get('beta', 0) - # Evitar valores negativos no físicos para S y P si es necesario - # Esto es una simplificación, modelos más complejos podrían manejar esto de otra forma dSdt = -p * dXdt - q * X dPdt = alpha * dXdt + beta * X - - # if S + dSdt * (t_step if 't_step' in locals() else 0.01) < 0: dSdt = -S / (t_step if 't_step' in locals() else 0.01) # Evita S negativo - # if P + dPdt * (t_step if 't_step' in locals() else 0.01) < 0: dPdt = -P / (t_step if 't_step' in locals() else 0.01) # Evita P negativo (raro) - - return [dXdt, dSdt, dPdt] - def get_initial_conditions(self, time, biomass, substrate, product): - X0, S0, P0 = 0,0,0 # Defaults + # Default a los primeros datos finitos + def get_first_finite(arr, default_val=0.0): + finite_arr = arr[np.isfinite(arr)] + return finite_arr[0] if len(finite_arr) > 0 else default_val + + X0 = get_first_finite(biomass, 0.1) # Default a 0.1 si no hay datos finitos + S0 = get_first_finite(substrate, 0.0) + P0 = get_first_finite(product, 0.0) + + time_min_val = np.nanmin(time) if len(time)>0 and np.any(np.isfinite(time)) else 0 if 'biomass' in self.params and self.params['biomass']: if self.model_type == 'logistic': - X0 = self.params['biomass'].get('xo', biomass[0] if len(biomass)>0 else 0) + X0 = self.params['biomass']['xo'] elif self.model_type == 'gompertz': - xm = self.params['biomass'].get('xm', 0) - um = self.params['biomass'].get('um', 0) - lag = self.params['biomass'].get('lag', 0) - # Calcular X0 para Gompertz en t=0 (o el primer punto de tiempo) - t_initial = time[0] if len(time)>0 else 0 - X0 = xm * np.exp(-np.exp((um * np.e / xm)*(lag - t_initial)+1)) if xm > 0 else (biomass[0] if len(biomass)>0 else 0) + xm, um, lag = self.params['biomass']['xm'], self.params['biomass']['um'], self.params['biomass']['lag'] + X0 = xm * np.exp(-np.exp((um * np.e / xm)*(lag - time_min_val)+1)) elif self.model_type == 'moser': - Xm = self.params['biomass'].get('Xm', 0) - um = self.params['biomass'].get('um', 0) - Ks = self.params['biomass'].get('Ks', 0) - t_initial = time[0] if len(time)>0 else 0 - X0 = Xm*(1 - np.exp(-um*(t_initial - Ks))) if Xm > 0 else (biomass[0] if len(biomass)>0 else 0) - elif len(biomass) > 0: - X0 = biomass[0] - + Xm, um, Ks = self.params['biomass']['Xm'], self.params['biomass']['um'], self.params['biomass']['Ks'] + X0 = Xm*(1 - np.exp(-um*(time_min_val - Ks))) + if 'substrate' in self.params and self.params['substrate']: - S0 = self.params['substrate'].get('so', substrate[0] if len(substrate)>0 else 0) - elif len(substrate) > 0: - S0 = substrate[0] - - if 'product' in self.params and self.params['product']: - P0 = self.params['product'].get('po', product[0] if len(product)>0 else 0) - elif len(product) > 0: - P0 = product[0] + S0 = self.params['substrate']['so'] - # Asegurar que las condiciones iniciales no sean NaN - X0 = X0 if np.isfinite(X0) else (biomass[0] if len(biomass)>0 and np.isfinite(biomass[0]) else 0) - S0 = S0 if np.isfinite(S0) else (substrate[0] if len(substrate)>0 and np.isfinite(substrate[0]) else 0) - P0 = P0 if np.isfinite(P0) else (product[0] if len(product)>0 and np.isfinite(product[0]) else 0) + if 'product' in self.params and self.params['product']: + P0 = self.params['product']['po'] return [X0, S0, P0] - def solve_differential_equations(self, time, biomass, substrate, product): - if 'biomass' not in self.params or not self.params['biomass']: + biomass_params_list = self._get_biomass_model_params_as_list() + if biomass_params_list is None: print("No hay parámetros de biomasa, no se pueden resolver las EDO.") return None, None, None, time - if len(time) == 0: - print("Tiempo vacío, no se pueden resolver EDO.") - return None, None, None, time - - biomass_p = self.params['biomass'] - if self.model_type == 'logistic': - biomass_params_tuple = (biomass_p.get('xo',0), biomass_p.get('xm',1), biomass_p.get('um',0.1)) - elif self.model_type == 'gompertz': - biomass_params_tuple = (biomass_p.get('xm',1), biomass_p.get('um',0.1), biomass_p.get('lag',0)) - elif self.model_type == 'moser': - biomass_params_tuple = (biomass_p.get('Xm',1), biomass_p.get('um',0.1), biomass_p.get('Ks',0)) - else: - biomass_params_tuple = (0,0,0) # Default - - substrate_p = self.params.get('substrate', {}) - substrate_params_tuple = (substrate_p.get('so',0), substrate_p.get('p',0), substrate_p.get('q',0)) - - product_p = self.params.get('product', {}) - product_params_tuple = (product_p.get('po',0), product_p.get('alpha',0), product_p.get('beta',0)) + substrate_params_dict = self.params.get('substrate', {}) + product_params_dict = self.params.get('product', {}) + initial_conditions = self.get_initial_conditions(time, biomass, substrate, product) time_fine = self.generate_fine_time_grid(time) - if len(time_fine) == 0: # Si time_fine está vacío (porque time estaba vacío) + + if len(time_fine) < 2 : # Si generate_fine_time_grid devolvió el time original y era muy corto + print("No hay suficiente rango de tiempo para resolver EDOs.") return None, None, None, time try: sol = odeint(self.system, initial_conditions, time_fine, - args=(biomass_params_tuple, substrate_params_tuple, product_params_tuple, self.model_type), - tcrit=time) # Añadir tcrit para mejorar la precisión en los puntos de datos originales + args=(biomass_params_list, substrate_params_dict, product_params_dict)) + X, S, P = sol[:, 0], sol[:, 1], sol[:, 2] + return X, S, P, time_fine except Exception as e: print(f"Error al resolver EDOs: {e}") return None, None, None, time_fine - - X = sol[:, 0] - S = sol[:, 1] - P = sol[:, 2] - - # Opcional: asegurar que S y P no sean negativos si no tiene sentido físico - S = np.maximum(S, 0) - # P = np.maximum(P, 0) # Producto usualmente no necesita esto - - return X, S, P, time_fine - def plot_results(self, time, biomass, substrate, product, y_pred_biomass, y_pred_substrate, y_pred_product, - biomass_std=None, substrate_std=None, product_std=None, + biomass_error_values, substrate_error_values, product_error_values, experiment_name='', legend_position='best', params_position='upper right', - show_legend=True, show_params=True, - style='whitegrid', + show_legend=True, show_params=True, style='whitegrid', line_color='#0000FF', point_color='#000000', line_style='-', marker_style='o', use_differential=False, - # AGREGAR ESTOS NUEVOS PARÁMETROS: - x_label='Tiempo', y_label_biomass='Biomasa', - y_label_substrate='Sustrato', y_label_product='Producto'): - - if y_pred_biomass is None and not use_differential: # Si no hay ajuste y no se usan EDOs, no graficar - print(f"No se pudo ajustar biomasa para {experiment_name} con {self.model_type} y no se usan EDOs. Omitiendo figura.") - return None - if len(time) == 0: - print(f"No hay datos de tiempo para graficar para {experiment_name}. Omitiendo figura.") - return None - + time_unit='', biomass_unit='', substrate_unit='', product_unit='', + error_bar_capsize=5): sns.set_style(style) - time_to_plot = time # Por defecto - - if use_differential and 'biomass' in self.params and self.params['biomass']: - # Asegurarse de que los datos originales no estén vacíos - if len(biomass)>0 and len(substrate)>0 and len(product)>0: - X_ode, S_ode, P_ode, time_fine_ode = self.solve_differential_equations(time, biomass, substrate, product) - if X_ode is not None: # Si la solución de EDO es exitosa - y_pred_biomass, y_pred_substrate, y_pred_product = X_ode, S_ode, P_ode - time_to_plot = time_fine_ode - else: # Si falla la EDO, usar los ajustes de curve_fit si existen - time_to_plot = self.generate_fine_time_grid(time) - if y_pred_biomass is not None: # Re-evaluar el modelo ajustado en la malla fina - biomass_p = self.params.get('biomass', {}) - if biomass_p: - y_pred_biomass = self.biomass_model(time_to_plot, *biomass_p.values()) - if y_pred_substrate is not None and 'biomass' in self.params and self.params['biomass']: - substrate_p = self.params.get('substrate', {}) - if substrate_p: - y_pred_substrate = self.substrate(time_to_plot, *substrate_p.values(), list(self.params['biomass'].values())) - if y_pred_product is not None and 'biomass' in self.params and self.params['biomass']: - product_p = self.params.get('product', {}) - if product_p: - y_pred_product = self.product(time_to_plot, *product_p.values(), list(self.params['biomass'].values())) - else: # Si los datos originales están vacíos, no se puede usar EDO - print(f"Datos originales vacíos para {experiment_name}, no se pueden usar EDOs.") - use_differential = False # Forzar a no usar EDO - time_to_plot = self.generate_fine_time_grid(time) # Usar malla fina para ajustes si existen - # Re-evaluar modelos ajustados en la malla fina - if y_pred_biomass is not None: - biomass_p = self.params.get('biomass', {}) - if biomass_p: y_pred_biomass = self.biomass_model(time_to_plot, *biomass_p.values()) - if y_pred_substrate is not None and 'biomass' in self.params and self.params['biomass']: - substrate_p = self.params.get('substrate', {}) - if substrate_p: y_pred_substrate = self.substrate(time_to_plot, *substrate_p.values(), list(self.params['biomass'].values())) - if y_pred_product is not None and 'biomass' in self.params and self.params['biomass']: - product_p = self.params.get('product', {}) - if product_p: y_pred_product = self.product(time_to_plot, *product_p.values(), list(self.params['biomass'].values())) - - elif y_pred_biomass is not None: # No EDO, pero hay ajuste, usar malla fina - time_to_plot = self.generate_fine_time_grid(time) - biomass_p = self.params.get('biomass', {}) - if biomass_p: y_pred_biomass = self.biomass_model(time_to_plot, *biomass_p.values()) - - if y_pred_substrate is not None and 'biomass' in self.params and self.params['biomass']: - substrate_p = self.params.get('substrate', {}) - if substrate_p: y_pred_substrate = self.substrate(time_to_plot, *substrate_p.values(), list(self.params['biomass'].values())) - if y_pred_product is not None and 'biomass' in self.params and self.params['biomass']: - product_p = self.params.get('product', {}) - if product_p: y_pred_product = self.product(time_to_plot, *product_p.values(), list(self.params['biomass'].values())) - + time_to_plot = time + + if use_differential: + X_ode, S_ode, P_ode, time_fine_ode = self.solve_differential_equations(time, biomass, substrate, product) + if X_ode is not None: + y_pred_biomass, y_pred_substrate, y_pred_product = X_ode, S_ode, P_ode + time_to_plot = time_fine_ode + else: + print(f"Fallo al resolver EDOs para {experiment_name}, usando ajustes si existen.") + if y_pred_biomass is None and not np.any(np.isfinite(biomass)): return None # No graficar si no hay nada + elif y_pred_biomass is None and not np.any(np.isfinite(biomass)): + print(f"No hay datos de biomasa ni ajuste para {experiment_name}. Omitiendo figura.") + return None fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(10, 15)) - fig.suptitle(f'{experiment_name} ({self.model_type.capitalize()})', fontsize=16) - + fig.suptitle(f'{experiment_name} (Modelo: {self.model_type.capitalize()})', fontsize=16) - plots = [ - (ax1, biomass, y_pred_biomass, biomass_std, y_label_biomass, 'Modelo', self.params.get('biomass', {}), - self.r2.get('biomass', np.nan), self.rmse.get('biomass', np.nan)), - (ax2, substrate, y_pred_substrate, substrate_std, y_label_substrate, 'Modelo', self.params.get('substrate', {}), - self.r2.get('substrate', np.nan), self.rmse.get('substrate', np.nan)), - (ax3, product, y_pred_product, product_std, y_label_product, 'Modelo', self.params.get('product', {}), - self.r2.get('product', np.nan), self.rmse.get('product', np.nan)) + xlabel_full = f'{LABEL_TIME} ({time_unit})' if time_unit else LABEL_TIME + ylabel_biomass_full = f'{LABEL_BIOMASS} ({biomass_unit})' if biomass_unit else LABEL_BIOMASS + ylabel_substrate_full = f'{LABEL_SUBSTRATE} ({substrate_unit})' if substrate_unit else LABEL_SUBSTRATE + ylabel_product_full = f'{LABEL_PRODUCT} ({product_unit})' if product_unit else LABEL_PRODUCT + + plots_config = [ + (ax1, biomass, y_pred_biomass, biomass_error_values, ylabel_biomass_full, 'biomass'), + (ax2, substrate, y_pred_substrate, substrate_error_values, ylabel_substrate_full, 'substrate'), + (ax3, product, y_pred_product, product_error_values, ylabel_product_full, 'product') ] - for idx, (ax, data, y_pred, data_std, ylabel, model_name, params_dict, r2, rmse) in enumerate(plots): - # Solo graficar datos experimentales si existen y son válidos - if data is not None and len(data) > 0 and np.all(np.isfinite(data)): - if data_std is not None and len(data_std) == len(data) and np.all(np.isfinite(data_std)): - ax.errorbar(time, data, yerr=data_std, fmt=marker_style, color=point_color, - label='Datos experimentales', capsize=5, elinewidth=1, markeredgewidth=1) + for ax, data, y_pred, data_error_vals, ylabel, param_key in plots_config: + if data is not None and np.any(np.isfinite(data)): + finite_data_mask = np.isfinite(data) + time_finite_data = time[finite_data_mask] + data_finite_values = data[finite_data_mask] + + if data_error_vals is not None and np.any(np.isfinite(data_error_vals)) and len(data_error_vals) == len(time): + plot_error_vals = np.copy(data_error_vals[finite_data_mask]) + plot_error_vals[~np.isfinite(plot_error_vals)] = 0 + + ax.errorbar(time_finite_data, data_finite_values, + yerr=plot_error_vals, + fmt=marker_style, color=point_color, + label='Datos experimentales', capsize=error_bar_capsize, + elinewidth=1, markeredgewidth=1) else: - ax.plot(time, data, marker=marker_style, linestyle='', color=point_color, + ax.plot(time_finite_data, data_finite_values, + marker=marker_style, linestyle='', color=point_color, label='Datos experimentales') - # Solo graficar predicciones si existen y son válidas - if y_pred is not None and len(y_pred) > 0 and np.all(np.isfinite(y_pred)) and len(time_to_plot) == len(y_pred): - ax.plot(time_to_plot, y_pred, linestyle=line_style, color=line_color, label=model_name) + if y_pred is not None and len(y_pred) == len(time_to_plot) and np.any(np.isfinite(y_pred)): + ax.plot(time_to_plot, y_pred, linestyle=line_style, color=line_color, label='Modelo') - ax.set_xlabel(x_label) + ax.set_xlabel(xlabel_full) ax.set_ylabel(ylabel) if show_legend: ax.legend(loc=legend_position) - ax.set_title(f'{ylabel}') - - if show_params and params_dict and all(isinstance(v, (int, float)) and np.isfinite(v) for v in params_dict.values()): - param_text = '\n'.join([f"{k} = {v:.3g}" for k, v in params_dict.items()]) # Usar .3g para mejor formato - text = f"{param_text}\nR² = {r2:.3f}\nRMSE = {rmse:.3f}" - if params_position == 'outside right': - bbox_props = dict(boxstyle='round,pad=0.3', facecolor='white', alpha=0.7, edgecolor='gray') - ax.annotate(text, xy=(1.02, 0.5), xycoords='axes fraction', - xytext=(10,0), textcoords='offset points', # Pequeño offset - verticalalignment='center', bbox=bbox_props, fontsize=9) + ax.set_title(f'{ylabel.split(" (")[0]}') + + current_params = self.params.get(param_key, {}) + r2 = self.r2.get(param_key, np.nan) + rmse = self.rmse.get(param_key, np.nan) + + if show_params and current_params: + valid_params = {k: v for k, v in current_params.items() if np.isfinite(v)} + param_text = '\n'.join([f"{k} = {v:.3g}" for k, v in valid_params.items()]) + text = f"{param_text}\nR² = {r2:.3f}\nRMSE = {rmse:.3g}" + + text_x, ha = (0.95, 'right') if 'right' in params_position else (0.05, 'left') + text_y, va = (0.95, 'top') if 'upper' in params_position else (0.05, 'bottom') + if params_position == 'outside right': + fig.subplots_adjust(right=0.75) + ax.annotate(text, xy=(1.05, 0.5), xycoords='axes fraction', + verticalalignment='center', bbox=dict(boxstyle='round', facecolor='white', alpha=0.7)) else: - text_x, text_y, ha, va = 0.05, 0.95, 'left', 'top' # Default upper left - if params_position == 'upper right': text_x, ha = 0.95, 'right' - elif params_position == 'lower left': text_y, va = 0.05, 'bottom' - elif params_position == 'lower right': text_x, text_y, ha, va = 0.95, 0.05, 'right', 'bottom' - ax.text(text_x, text_y, text, transform=ax.transAxes, - verticalalignment=va, horizontalalignment=ha, - bbox={'boxstyle': 'round,pad=0.3', 'facecolor':'white', 'alpha':0.7, 'edgecolor':'gray'}, fontsize=9) - ax.grid(True, linestyle=':', alpha=0.7) - + verticalalignment=va, horizontalalignment=ha, + bbox={'boxstyle': 'round', 'facecolor':'white', 'alpha':0.7}) plt.tight_layout(rect=[0, 0.03, 1, 0.95]) - buf = io.BytesIO() - fig.savefig(buf, format='png', dpi=150) # Aumentar DPI para mejor calidad + fig.savefig(buf, format='png') buf.seek(0) image = Image.open(buf).convert("RGB") plt.close(fig) - return image def plot_combined_results(self, time, biomass, substrate, product, y_pred_biomass, y_pred_substrate, y_pred_product, - biomass_std=None, substrate_std=None, product_std=None, + biomass_error_values, substrate_error_values, product_error_values, experiment_name='', legend_position='best', params_position='upper right', - show_legend=True, show_params=True, - style='whitegrid', + show_legend=True, show_params=True, style='whitegrid', line_color='#0000FF', point_color='#000000', line_style='-', marker_style='o', use_differential=False, - # AGREGAR ESTOS NUEVOS PARÁMETROS: - x_label='Tiempo', y_label_biomass='Biomasa', - y_label_substrate='Sustrato', y_label_product='Producto'): - - if y_pred_biomass is None and not use_differential: - print(f"No se pudo ajustar biomasa para {experiment_name} con {self.model_type} (combinado). Omitiendo figura.") - return None - if len(time) == 0: - print(f"No hay datos de tiempo para graficar (combinado) para {experiment_name}. Omitiendo figura.") - return None - + time_unit='', biomass_unit='', substrate_unit='', product_unit='', + error_bar_capsize=5): sns.set_style(style) - time_to_plot = time # Por defecto - - if use_differential and 'biomass' in self.params and self.params['biomass']: - if len(biomass)>0 and len(substrate)>0 and len(product)>0: - X_ode, S_ode, P_ode, time_fine_ode = self.solve_differential_equations(time, biomass, substrate, product) - if X_ode is not None: - y_pred_biomass, y_pred_substrate, y_pred_product = X_ode, S_ode, P_ode - time_to_plot = time_fine_ode - else: # Fallback a ajustes si EDO falla - time_to_plot = self.generate_fine_time_grid(time) - if y_pred_biomass is not None: - biomass_p = self.params.get('biomass', {}) - if biomass_p: y_pred_biomass = self.biomass_model(time_to_plot, *biomass_p.values()) - if y_pred_substrate is not None and 'biomass' in self.params and self.params['biomass']: - substrate_p = self.params.get('substrate', {}) - if substrate_p: y_pred_substrate = self.substrate(time_to_plot, *substrate_p.values(), list(self.params['biomass'].values())) - if y_pred_product is not None and 'biomass' in self.params and self.params['biomass']: - product_p = self.params.get('product', {}) - if product_p: y_pred_product = self.product(time_to_plot, *product_p.values(), list(self.params['biomass'].values())) + time_to_plot = time + + if use_differential: + X_ode, S_ode, P_ode, time_fine_ode = self.solve_differential_equations(time, biomass, substrate, product) + if X_ode is not None: + y_pred_biomass, y_pred_substrate, y_pred_product = X_ode, S_ode, P_ode + time_to_plot = time_fine_ode else: - print(f"Datos originales vacíos para {experiment_name} (combinado), no se pueden usar EDOs.") - use_differential = False - time_to_plot = self.generate_fine_time_grid(time) - if y_pred_biomass is not None: - biomass_p = self.params.get('biomass', {}) - if biomass_p: y_pred_biomass = self.biomass_model(time_to_plot, *biomass_p.values()) - if y_pred_substrate is not None and 'biomass' in self.params and self.params['biomass']: - substrate_p = self.params.get('substrate', {}) - if substrate_p: y_pred_substrate = self.substrate(time_to_plot, *substrate_p.values(), list(self.params['biomass'].values())) - if y_pred_product is not None and 'biomass' in self.params and self.params['biomass']: - product_p = self.params.get('product', {}) - if product_p: y_pred_product = self.product(time_to_plot, *product_p.values(), list(self.params['biomass'].values())) - - elif y_pred_biomass is not None: # No EDO, pero hay ajuste, usar malla fina - time_to_plot = self.generate_fine_time_grid(time) - biomass_p = self.params.get('biomass', {}) - if biomass_p: y_pred_biomass = self.biomass_model(time_to_plot, *biomass_p.values()) - - if y_pred_substrate is not None and 'biomass' in self.params and self.params['biomass']: - substrate_p = self.params.get('substrate', {}) - if substrate_p: y_pred_substrate = self.substrate(time_to_plot, *substrate_p.values(), list(self.params['biomass'].values())) - if y_pred_product is not None and 'biomass' in self.params and self.params['biomass']: - product_p = self.params.get('product', {}) - if product_p: y_pred_product = self.product(time_to_plot, *product_p.values(), list(self.params['biomass'].values())) - - - fig, ax1 = plt.subplots(figsize=(12, 7)) # Un poco más ancho para acomodar texto - fig.suptitle(f'{experiment_name} ({self.model_type.capitalize()})', fontsize=16) + print(f"Fallo al resolver EDOs para {experiment_name} (combinado), usando ajustes si existen.") + if y_pred_biomass is None and not np.any(np.isfinite(biomass)): return None + elif y_pred_biomass is None and not np.any(np.isfinite(biomass)): + print(f"No hay datos de biomasa ni ajuste para {experiment_name}. Omitiendo figura combinada.") + return None + + fig, ax1 = plt.subplots(figsize=(12, 7)) + fig.suptitle(f'{experiment_name} (Modelo: {self.model_type.capitalize()})', fontsize=16) + + xlabel_full = f'{LABEL_TIME} ({time_unit})' if time_unit else LABEL_TIME + ylabel_biomass_full = f'{LABEL_BIOMASS} ({biomass_unit})' if biomass_unit else LABEL_BIOMASS + ylabel_substrate_full = f'{LABEL_SUBSTRATE} ({substrate_unit})' if substrate_unit else LABEL_SUBSTRATE + ylabel_product_full = f'{LABEL_PRODUCT} ({product_unit})' if product_unit else LABEL_PRODUCT colors = {'Biomasa': 'blue', 'Sustrato': 'green', 'Producto': 'red'} - # Usar los colores de línea y punto definidos por el usuario si es posible, - # pero necesitamos 3 colores distintos. - # Por ahora, mantendremos los colores fijos para la gráfica combinada para claridad. - - ax1.set_xlabel(x_label) - ax1.set_ylabel(y_label_biomass, color=colors['Biomasa']) - if biomass is not None and len(biomass)>0 and np.all(np.isfinite(biomass)): - if biomass_std is not None and len(biomass_std)==len(biomass) and np.all(np.isfinite(biomass_std)): - ax1.errorbar(time, biomass, yerr=biomass_std, fmt=marker_style, color=colors['Biomasa'], - label=f'{y_label_biomass} (Datos)', capsize=5, elinewidth=1, markeredgewidth=1) - else: - ax1.plot(time, biomass, marker=marker_style, linestyle='', color=colors['Biomasa'], - label=f'{y_label_biomass} (Datos)') - if y_pred_biomass is not None and len(y_pred_biomass)>0 and np.all(np.isfinite(y_pred_biomass)) and len(time_to_plot)==len(y_pred_biomass): - ax1.plot(time_to_plot, y_pred_biomass, linestyle=line_style, color=colors['Biomasa'], - label=f'{y_label_biomass} (Modelo)') + + def plot_data_with_errors(ax, t, data, error_vals, color, label_prefix, marker, cap_size): + if data is not None and np.any(np.isfinite(data)): + t_finite = t[np.isfinite(data)] + data_finite = data[np.isfinite(data)] + + if error_vals is not None and np.any(np.isfinite(error_vals)) and len(error_vals) == len(t): + error_vals_finite = np.copy(error_vals[np.isfinite(data)]) + error_vals_finite[~np.isfinite(error_vals_finite)] = 0 + ax.errorbar(t_finite, data_finite, yerr=error_vals_finite, fmt=marker, color=color, + label=f'{label_prefix} (Datos)', capsize=cap_size, elinewidth=1, markeredgewidth=1) + else: + ax.plot(t_finite, data_finite, marker=marker, linestyle='', color=color, + label=f'{label_prefix} (Datos)') + + ax1.set_xlabel(xlabel_full) + ax1.set_ylabel(ylabel_biomass_full, color=colors['Biomasa']) + plot_data_with_errors(ax1, time, biomass, biomass_error_values, colors['Biomasa'], LABEL_BIOMASS, marker_style, error_bar_capsize) + if y_pred_biomass is not None and len(y_pred_biomass) == len(time_to_plot) and np.any(np.isfinite(y_pred_biomass)): + ax1.plot(time_to_plot, y_pred_biomass, linestyle=line_style, color=colors['Biomasa'], label=f'{LABEL_BIOMASS} (Modelo)') ax1.tick_params(axis='y', labelcolor=colors['Biomasa']) - ax1.grid(True, linestyle=':', alpha=0.7, axis='y') # Grid solo para el eje y primario ax2 = ax1.twinx() - ax2.set_ylabel(y_label_substrate, color=colors['Sustrato']) - if substrate is not None and len(substrate)>0 and np.all(np.isfinite(substrate)): - if substrate_std is not None and len(substrate_std)==len(substrate) and np.all(np.isfinite(substrate_std)): - ax2.errorbar(time, substrate, yerr=substrate_std, fmt=marker_style, markerfacecolor='none', markeredgecolor=colors['Sustrato'], ecolor=colors['Sustrato'], - label=f'{y_label_substrate} (Datos)', capsize=5, elinewidth=1, markeredgewidth=1) - else: - ax2.plot(time, substrate, marker=marker_style, markerfacecolor='none', markeredgecolor=colors['Sustrato'], linestyle='', color=colors['Sustrato'], - label=f'{y_label_substrate} (Datos)') - if y_pred_substrate is not None and len(y_pred_substrate)>0 and np.all(np.isfinite(y_pred_substrate)) and len(time_to_plot)==len(y_pred_substrate): - ax2.plot(time_to_plot, y_pred_substrate, linestyle=line_style, color=colors['Sustrato'], - label=f'{y_label_substrate} (Modelo)') + ax2.set_ylabel(ylabel_substrate_full, color=colors['Sustrato']) + plot_data_with_errors(ax2, time, substrate, substrate_error_values, colors['Sustrato'], LABEL_SUBSTRATE, marker_style, error_bar_capsize) + if y_pred_substrate is not None and len(y_pred_substrate) == len(time_to_plot) and np.any(np.isfinite(y_pred_substrate)): + ax2.plot(time_to_plot, y_pred_substrate, linestyle=line_style, color=colors['Sustrato'], label=f'{LABEL_SUBSTRATE} (Modelo)') ax2.tick_params(axis='y', labelcolor=colors['Sustrato']) - + ax3 = ax1.twinx() - ax3.spines["right"].set_position(("axes", 1.15)) # Ajustar posición para evitar superposición - # ax3.set_frame_on(True) # Ya está por defecto - # ax3.patch.set_visible(False) # No es necesario si el frame está on - # for sp in ax3.spines.values(): - # sp.set_visible(True) # Ya está por defecto - - ax3.set_ylabel(y_label_product, color=colors['Producto']) - if product is not None and len(product)>0 and np.all(np.isfinite(product)): - if product_std is not None and len(product_std)==len(product) and np.all(np.isfinite(product_std)): - ax3.errorbar(time, product, yerr=product_std, fmt=marker_style, markerfacecolor='none', markeredgecolor=colors['Producto'], ecolor=colors['Producto'], - label=f'{y_label_product} (Datos)', capsize=5, elinewidth=1, markeredgewidth=1) - else: - ax3.plot(time, product, marker=marker_style, markerfacecolor='none', markeredgecolor=colors['Producto'], linestyle='', color=colors['Producto'], - label=f'{y_label_product} (Datos)') - if y_pred_product is not None and len(y_pred_product)>0 and np.all(np.isfinite(y_pred_product)) and len(time_to_plot)==len(y_pred_product): - ax3.plot(time_to_plot, y_pred_product, linestyle=line_style, color=colors['Producto'], - label=f'{y_label_product} (Modelo)') + ax3.spines["right"].set_position(("axes", 1.15)) + ax3.set_frame_on(True) + ax3.patch.set_visible(False) + ax3.set_ylabel(ylabel_product_full, color=colors['Producto']) + plot_data_with_errors(ax3, time, product, product_error_values, colors['Producto'], LABEL_PRODUCT, marker_style, error_bar_capsize) + if y_pred_product is not None and len(y_pred_product) == len(time_to_plot) and np.any(np.isfinite(y_pred_product)): + ax3.plot(time_to_plot, y_pred_product, linestyle=line_style, color=colors['Producto'], label=f'{LABEL_PRODUCT} (Modelo)') ax3.tick_params(axis='y', labelcolor=colors['Producto']) - lines_labels = [ax.get_legend_handles_labels() for ax in [ax1, ax2, ax3] if ax.has_data()] - if lines_labels: - lines, labels = [sum(lol, []) for lol in zip(*lines_labels)] - if show_legend and lines: # Solo mostrar leyenda si hay algo que mostrar - ax1.legend(lines, labels, loc=legend_position, fontsize=9) + if show_legend: + handles, labels = [], [] + for ax_leg in [ax1, ax2, ax3]: + h, l = ax_leg.get_legend_handles_labels() + handles.extend(h); labels.extend(l) + unique_labels_dict = {} + for h, l in zip(handles, labels): + if l not in unique_labels_dict: unique_labels_dict[l] = h + if unique_labels_dict: + ax1.legend(unique_labels_dict.values(), unique_labels_dict.keys(), loc=legend_position) if show_params: - texts_to_join = [] - if 'biomass' in self.params and self.params['biomass'] and all(isinstance(v, (int, float)) and np.isfinite(v) for v in self.params['biomass'].values()): - param_text_biomass = '\n'.join([f"{k} = {v:.3g}" for k, v in self.params['biomass'].items()]) - texts_to_join.append(f"{y_label_biomass}:\n{param_text_biomass}\nR² = {self.r2.get('biomass', np.nan):.3f}\nRMSE = {self.rmse.get('biomass', np.nan):.3f}") - - if 'substrate' in self.params and self.params['substrate'] and all(isinstance(v, (int, float)) and np.isfinite(v) for v in self.params['substrate'].values()): - param_text_substrate = '\n'.join([f"{k} = {v:.3g}" for k, v in self.params['substrate'].items()]) - texts_to_join.append(f"{y_label_substrate}:\n{param_text_substrate}\nR² = {self.r2.get('substrate', np.nan):.3f}\nRMSE = {self.rmse.get('substrate', np.nan):.3f}") - - if 'product' in self.params and self.params['product'] and all(isinstance(v, (int, float)) and np.isfinite(v) for v in self.params['product'].values()): - param_text_product = '\n'.join([f"{k} = {v:.3g}" for k, v in self.params['product'].items()]) - texts_to_join.append(f"{y_label_product}:\n{param_text_product}\nR² = {self.r2.get('product', np.nan):.3f}\nRMSE = {self.rmse.get('product', np.nan):.3f}") - - total_text = "\n\n".join(texts_to_join) - - if total_text: # Solo mostrar si hay texto + texts = [] + for param_key, param_label_text in [('biomass', LABEL_BIOMASS), ('substrate', LABEL_SUBSTRATE), ('product', LABEL_PRODUCT)]: + current_params_dict = self.params.get(param_key, {}) + r2_val = self.r2.get(param_key, np.nan) + rmse_val = self.rmse.get(param_key, np.nan) + if current_params_dict: + valid_params_dict = {k: v for k, v in current_params_dict.items() if np.isfinite(v)} + param_text_ind = '\n'.join([f"{k} = {v:.3g}" for k, v in valid_params_dict.items()]) + texts.append(f"{param_label_text}:\n{param_text_ind}\nR² = {r2_val:.3f}\nRMSE = {rmse_val:.3g}") + total_text = "\n\n".join(texts) + + if total_text: + text_x, ha_align = (0.95, 'right') if 'right' in params_position else (0.05, 'left') + text_y, va_align = (0.95, 'top') if 'upper' in params_position else (0.05, 'bottom') if params_position == 'outside right': - bbox_props = dict(boxstyle='round,pad=0.3', facecolor='white', alpha=0.7, edgecolor='gray') - # Usar ax3 para anotar fuera, ya que es el más a la derecha - ax3.annotate(total_text, xy=(1.20, 0.5), xycoords='axes fraction', # Ajustar xy para que no se solape con el eje - xytext=(10,0), textcoords='offset points', - verticalalignment='center', bbox=bbox_props, fontsize=8) # Reducir fontsize + fig.subplots_adjust(right=0.70) + ax3.annotate(total_text, xy=(1.25, 0.5), xycoords='axes fraction', + fontsize=8, verticalalignment='center', bbox=dict(boxstyle='round', facecolor='white', alpha=0.7)) else: - text_x, text_y, ha, va = 0.02, 0.98, 'left', 'top' # Default upper left, un poco más adentro - if params_position == 'upper right': text_x, ha = 0.98, 'right' - elif params_position == 'lower left': text_y, va = 0.02, 'bottom' - elif params_position == 'lower right': text_x, text_y, ha, va = 0.98, 0.02, 'right', 'bottom' - ax1.text(text_x, text_y, total_text, transform=ax1.transAxes, - verticalalignment=va, horizontalalignment=ha, - bbox={'boxstyle':'round,pad=0.3', 'facecolor':'white', 'alpha':0.7, 'edgecolor':'gray'}, fontsize=8) # Reducir fontsize - - plt.tight_layout(rect=[0, 0.03, 0.85 if params_position == 'outside right' and show_params else 1, 0.95]) # Ajustar right para outside params + fontsize=8, verticalalignment=va_align, horizontalalignment=ha_align, + bbox={'boxstyle':'round', 'facecolor':'white', 'alpha':0.7}) + plt.tight_layout(rect=[0, 0.03, 1, 0.95]) buf = io.BytesIO() - fig.savefig(buf, format='png', dpi=150) + fig.savefig(buf, format='png') buf.seek(0) image = Image.open(buf).convert("RGB") plt.close(fig) - return image -def process_all_data(file, legend_position, params_position, model_types, experiment_names, lower_bounds, upper_bounds, - mode='independent', style='whitegrid', line_color='#0000FF', point_color='#000000', - line_style='-', marker_style='o', show_legend=True, show_params=True, use_differential=False, - maxfev_val=50000, - # AGREGAR ESTOS NUEVOS PARÁMETROS: - x_label='Tiempo', y_label_biomass='Biomasa', - y_label_substrate='Sustrato', y_label_product='Producto'): - +def _process_and_plot_single_experiment( + time_exp, biomass, substrate, product, + biomass_sd, substrate_sd, product_sd, + biomass_sem, substrate_sem, product_sem, + experiment_name, model_type_str, maxfev_val, + legend_position, params_position, show_legend, show_params, + style, line_color, point_color, line_style, marker_style, + use_differential, plot_mode, bounds_biomass, + time_unit, biomass_unit, substrate_unit, product_unit, + error_bar_type, error_bar_capsize): + + model = BioprocessModel(model_type=model_type_str, maxfev=maxfev_val) + model.fit_model() + y_pred_biomass = model.fit_biomass(time_exp, biomass, bounds=bounds_biomass) + + current_comparison_data = { + 'Experimento': experiment_name, + 'Modelo': model_type_str.capitalize(), + 'R² Biomasa': np.nan, 'RMSE Biomasa': np.nan, + 'R² Sustrato': np.nan, 'RMSE Sustrato': np.nan, + 'R² Producto': np.nan, 'RMSE Producto': np.nan + } + y_pred_substrate, y_pred_product = None, None + + if y_pred_biomass is not None and 'biomass' in model.params and model.params['biomass']: + current_comparison_data.update({ + 'R² Biomasa': model.r2.get('biomass', np.nan), + 'RMSE Biomasa': model.rmse.get('biomass', np.nan) + }) + if substrate is not None and len(substrate) > 0 and np.any(np.isfinite(substrate)): # Check for finite values + y_pred_substrate = model.fit_substrate(time_exp, substrate) + if y_pred_substrate is not None: + current_comparison_data.update({ + 'R² Sustrato': model.r2.get('substrate', np.nan), + 'RMSE Sustrato': model.rmse.get('substrate', np.nan) + }) + if product is not None and len(product) > 0 and np.any(np.isfinite(product)): # Check for finite values + y_pred_product = model.fit_product(time_exp, product) + if y_pred_product is not None: + current_comparison_data.update({ + 'R² Producto': model.r2.get('product', np.nan), + 'RMSE Producto': model.rmse.get('product', np.nan) + }) + else: + print(f"No se pudo ajustar biomasa para {experiment_name} con {model_type_str}.") + + error_values_to_plot = { + 'biomass': biomass_sd if error_bar_type == 'sd' else biomass_sem, + 'substrate': substrate_sd if error_bar_type == 'sd' else substrate_sem, + 'product': product_sd if error_bar_type == 'sd' else product_sem, + } + if plot_mode == 'independent': # No error bars from replicates in independent mode + error_values_to_plot = {'biomass': None, 'substrate': None, 'product': None} + + fig = None + plot_args_tuple = (time_exp, biomass, substrate, product, + y_pred_biomass, y_pred_substrate, y_pred_product, + error_values_to_plot['biomass'], error_values_to_plot['substrate'], error_values_to_plot['product'], + experiment_name, legend_position, params_position, + show_legend, show_params, style, + line_color, point_color, line_style, marker_style, + use_differential, + time_unit, biomass_unit, substrate_unit, product_unit, + error_bar_capsize) + + if plot_mode == 'combinado': + fig = model.plot_combined_results(*plot_args_tuple) + else: + fig = model.plot_results(*plot_args_tuple) + + return fig, current_comparison_data + +def process_all_data(file, legend_position, params_position, model_types_selected, analysis_mode, experiment_names, + lower_bounds_biomass_str, upper_bounds_biomass_str, + style_plot, line_color_plot, point_color_plot, line_style_plot, marker_style_plot, + show_legend_plot, show_params_plot, use_differential_eqs, maxfev_val, + time_unit_str, biomass_unit_str, substrate_unit_str, product_unit_str, + error_bar_type_selected, error_bar_capsize_selected): if file is None: - print("No se ha subido ningún archivo.") - return [], pd.DataFrame() + return [], pd.DataFrame(), "Por favor, sube un archivo Excel." + try: xls = pd.ExcelFile(file.name) except Exception as e: - print(f"Error al leer el archivo Excel: {e}") - return [], pd.DataFrame() + return [], pd.DataFrame(), f"Error al leer el archivo Excel: {e}" sheet_names = xls.sheet_names - figures = [] - comparison_data = [] - experiment_counter = 0 + figures_list = [] + comparison_data_list = [] + experiment_counter = 0 + + parsed_bounds_biomass = ([-np.inf]*3, [np.inf]*3) + try: + if lower_bounds_biomass_str.strip(): + lb = [float(x.strip()) for x in lower_bounds_biomass_str.split(',')] + if len(lb) == 3 : parsed_bounds_biomass = (lb, parsed_bounds_biomass[1]) + if upper_bounds_biomass_str.strip(): + ub = [float(x.strip()) for x in upper_bounds_biomass_str.split(',')] + if len(ub) == 3 : parsed_bounds_biomass = (parsed_bounds_biomass[0], ub) + except ValueError: + print("Advertencia: Bounds para biomasa no son válidos.") for sheet_name in sheet_names: try: - df = pd.read_excel(file.name, sheet_name=sheet_name, header=[0, 1]) - # Limpiar nombres de columnas (quitar espacios extra, etc.) - df.columns = pd.MultiIndex.from_tuples([(str(c1).strip(), str(c2).strip()) for c1, c2 in df.columns]) - + df = pd.read_excel(xls, sheet_name=sheet_name, header=[0, 1]) + for col_level0 in df.columns.levels[0]: # Asegurar que sean numéricas + for col_level1 in [COL_TIME, COL_BIOMASS, COL_SUBSTRATE, COL_PRODUCT]: + if (col_level0, col_level1) in df.columns: + df[(col_level0, col_level1)] = pd.to_numeric(df[(col_level0, col_level1)], errors='coerce') + # Eliminar filas que son completamente NaN en Tiempo y Biomasa (principales) + df = df.dropna(how='all', subset=[(c[0], c[1]) for c in df.columns if c[1] in [COL_TIME, COL_BIOMASS]]) except Exception as e: print(f"Error al leer la hoja '{sheet_name}': {e}") continue - # Validar que las columnas necesarias existan antes de procesar - required_cols_level1 = ['Tiempo', 'Biomasa', 'Sustrato', 'Producto'] - actual_cols_level1 = df.columns.get_level_values(1).unique() - if not all(rc in actual_cols_level1 for rc in required_cols_level1): - print(f"Advertencia: La hoja '{sheet_name}' no contiene todas las columnas requeridas (Tiempo, Biomasa, Sustrato, Producto) en el nivel 1 del encabezado. Saltando esta hoja.") - continue - - model_dummy_for_preprocessing = BioprocessModel() # Instancia solo para procesar datos de la hoja actual - try: - model_dummy_for_preprocessing.process_data(df) - except ValueError as e: - print(f"Error al procesar datos de la hoja '{sheet_name}': {e}. Saltando esta hoja.") - continue - - time_global_sheet = model_dummy_for_preprocessing.time # Tiempo base de la hoja - - if not time_global_sheet.size: # Si no hay datos de tiempo, saltar hoja - print(f"No se encontraron datos de tiempo válidos en la hoja '{sheet_name}'. Saltando.") - continue - - - if mode == 'independent': - # Iterar sobre los experimentos definidos por el primer nivel de las columnas - # Asegurarse de que los nombres de los experimentos (nivel 0) sean únicos y válidos + if analysis_mode == 'independent': unique_experiments_in_sheet = df.columns.levels[0] - for exp_col_name in unique_experiments_in_sheet: - # Extraer datos para este experimento específico try: - # Filtrar el DataFrame para este experimento - exp_df = df[exp_col_name] - time_exp = exp_df['Tiempo'].dropna().values - biomass = exp_df['Biomasa'].dropna().values - substrate = exp_df['Sustrato'].dropna().values - product = exp_df['Producto'].dropna().values - - # Asegurar que todos los arrays tengan la misma longitud que time_exp - min_len = len(time_exp) - biomass = biomass[:min_len] - substrate = substrate[:min_len] - product = product[:min_len] - - if not (len(time_exp) > 0 and len(biomass) > 0 and len(substrate) > 0 and len(product) > 0): - print(f"Datos insuficientes para el experimento '{exp_col_name}' en la hoja '{sheet_name}'. Saltando.") - continue - + time_exp = df[(exp_col_name, COL_TIME)].dropna().values + if len(time_exp) == 0: continue # No hay datos de tiempo + + biomass_exp = df[(exp_col_name, COL_BIOMASS)].values if (exp_col_name, COL_BIOMASS) in df else np.full(len(time_exp), np.nan) + substrate_exp = df[(exp_col_name, COL_SUBSTRATE)].values if (exp_col_name, COL_SUBSTRATE) in df else np.full(len(time_exp), np.nan) + product_exp = df[(exp_col_name, COL_PRODUCT)].values if (exp_col_name, COL_PRODUCT) in df else np.full(len(time_exp), np.nan) + + def _align_data(data_array, target_len): + if len(data_array) == target_len: return data_array + if len(data_array) > target_len: return data_array[:target_len] + return np.pad(data_array, (0, target_len - len(data_array)), 'constant', constant_values=np.nan) + + biomass_exp = _align_data(biomass_exp, len(time_exp)) + substrate_exp = _align_data(substrate_exp, len(time_exp)) + product_exp = _align_data(product_exp, len(time_exp)) + + current_exp_name_label = (experiment_names[experiment_counter] if experiment_counter < len(experiment_names) + else f"{sheet_name} - {exp_col_name}") + + for model_t in model_types_selected: + fig, comp_data = _process_and_plot_single_experiment( + time_exp, biomass_exp, substrate_exp, product_exp, + None, None, None, # SDs + None, None, None, # SEMs + current_exp_name_label, model_t, int(maxfev_val), + legend_position, params_position, show_legend_plot, show_params_plot, + style_plot, line_color_plot, point_color_plot, line_style_plot, marker_style_plot, + use_differential_eqs, analysis_mode, parsed_bounds_biomass, + time_unit_str, biomass_unit_str, substrate_unit_str, product_unit_str, + error_bar_type_selected, int(error_bar_capsize_selected) + ) + if fig: figures_list.append(fig) + comparison_data_list.append(comp_data) + experiment_counter += 1 except KeyError as e: - print(f"Faltan columnas (Tiempo, Biomasa, Sustrato o Producto) para el experimento '{exp_col_name}' en la hoja '{sheet_name}': {e}. Saltando este experimento.") - continue - except Exception as e: # Captura general para otros errores de extracción - print(f"Error al extraer datos para el experimento '{exp_col_name}' en la hoja '{sheet_name}': {e}. Saltando este experimento.") - continue - - - biomass_std = None # Para 'independent', no hay std a menos que se calcule de réplicas dentro de este "experimento" - substrate_std = None - product_std = None - - current_experiment_name_label = (experiment_names[experiment_counter] if experiment_counter < len(experiment_names) and experiment_names[experiment_counter] - else f"{sheet_name} - {exp_col_name}") - - - for model_type in model_types: - model = BioprocessModel(model_type=model_type, maxfev=maxfev_val) - model.fit_model() # Configura self.biomass_model y self.biomass_diff - - y_pred_biomass = model.fit_biomass(time_exp, biomass) - - if y_pred_biomass is None or not model.params.get('biomass'): - print(f"Fallo el ajuste de biomasa para {current_experiment_name_label}, modelo {model_type}.") - y_pred_substrate = None - y_pred_product = None - else: - y_pred_substrate = model.fit_substrate(time_exp, substrate, model.params['biomass']) - y_pred_product = model.fit_product(time_exp, product, model.params['biomass']) - - comparison_data.append({ - 'Experimento': current_experiment_name_label, - 'Modelo': model_type.capitalize(), - 'R² Biomasa': model.r2.get('biomass', np.nan), - 'RMSE Biomasa': model.rmse.get('biomass', np.nan), - 'R² Sustrato': model.r2.get('substrate', np.nan), - 'RMSE Sustrato': model.rmse.get('substrate', np.nan), - 'R² Producto': model.r2.get('product', np.nan), - 'RMSE Producto': model.rmse.get('product', np.nan) - }) - - # Siempre intentar graficar, plot_results manejará y_pred_x None - fig = model.plot_results(time_exp, biomass, substrate, product, - y_pred_biomass, y_pred_substrate, y_pred_product, - biomass_std, substrate_std, product_std, - current_experiment_name_label, - legend_position, params_position, - show_legend, show_params, - style, - line_color, point_color, line_style, marker_style, - use_differential, - x_label, y_label_biomass, y_label_substrate, y_label_product) - if fig is not None: - figures.append(fig) - experiment_counter += 1 - - - elif mode in ['average', 'combinado']: # Promedio de todos los experimentos en la hoja - try: - # Usar los datos preprocesados por model_dummy_for_preprocessing - time_exp = model_dummy_for_preprocessing.time - biomass = model_dummy_for_preprocessing.dataxp[-1] if model_dummy_for_preprocessing.dataxp else np.array([]) - substrate = model_dummy_for_preprocessing.datasp[-1] if model_dummy_for_preprocessing.datasp else np.array([]) - product = model_dummy_for_preprocessing.datapp[-1] if model_dummy_for_preprocessing.datapp else np.array([]) - - if not (time_exp.size > 0 and biomass.size > 0 and substrate.size > 0 and product.size > 0): - print(f"Datos promedio insuficientes en la hoja '{sheet_name}'. Saltando.") - continue # Saltar al siguiente sheet_name - - except IndexError: # Si dataxp, etc., están vacíos - print(f"No se pudieron obtener datos promedio de la hoja '{sheet_name}'. Saltando esta hoja.") - continue - - biomass_std = model_dummy_for_preprocessing.datax_std[-1] if model_dummy_for_preprocessing.datax_std and len(model_dummy_for_preprocessing.datax_std[-1]) == len(biomass) else None - substrate_std = model_dummy_for_preprocessing.datas_std[-1] if model_dummy_for_preprocessing.datas_std and len(model_dummy_for_preprocessing.datas_std[-1]) == len(substrate) else None - product_std = model_dummy_for_preprocessing.datap_std[-1] if model_dummy_for_preprocessing.datap_std and len(model_dummy_for_preprocessing.datap_std[-1]) == len(product) else None - - current_experiment_name_label = (experiment_names[experiment_counter] if experiment_counter < len(experiment_names) and experiment_names[experiment_counter] - else f"{sheet_name} - Promedio") + print(f"Advertencia: Falta columna {e} para '{exp_col_name}' en '{sheet_name}'.") + except Exception as e_exp: + print(f"Error procesando '{exp_col_name}' en '{sheet_name}': {e_exp}") + elif analysis_mode in ['average', 'combinado']: + model_data_loader = BioprocessModel() + try: + model_data_loader.process_data(df) + except ValueError as ve: + print(f"Error en hoja '{sheet_name}': {ve}. Saltando.") + continue - for model_type in model_types: - model = BioprocessModel(model_type=model_type, maxfev=maxfev_val) - model.fit_model() - - y_pred_biomass = model.fit_biomass(time_exp, biomass) - - if y_pred_biomass is None or not model.params.get('biomass'): - print(f"Fallo el ajuste de biomasa para {current_experiment_name_label}, modelo {model_type}.") - y_pred_substrate = None - y_pred_product = None - else: - y_pred_substrate = model.fit_substrate(time_exp, substrate, model.params['biomass']) - y_pred_product = model.fit_product(time_exp, product, model.params['biomass']) - - comparison_data.append({ - 'Experimento': current_experiment_name_label, - 'Modelo': model_type.capitalize(), - 'R² Biomasa': model.r2.get('biomass', np.nan), - 'RMSE Biomasa': model.rmse.get('biomass', np.nan), - 'R² Sustrato': model.r2.get('substrate', np.nan), - 'RMSE Sustrato': model.rmse.get('substrate', np.nan), - 'R² Producto': model.r2.get('product', np.nan), - 'RMSE Producto': model.rmse.get('product', np.nan) - }) + if len(model_data_loader.time) == 0: + print(f"No hay datos de tiempo válidos en '{sheet_name}'. Saltando.") + continue - PlottingFunction = model.plot_combined_results if mode == 'combinado' else model.plot_results - fig = PlottingFunction(time_exp, biomass, substrate, product, - y_pred_biomass, y_pred_substrate, y_pred_product, - biomass_std, substrate_std, product_std, - current_experiment_name_label, - legend_position, params_position, - show_legend, show_params, - style, - line_color, point_color, line_style, marker_style, - use_differential, - x_label, y_label_biomass, y_label_substrate, y_label_product) - if fig is not None: - figures.append(fig) + time_avg = model_data_loader.time + biomass_avg = model_data_loader.dataxp[-1] if model_data_loader.dataxp else np.array([]) + substrate_avg = model_data_loader.datasp[-1] if model_data_loader.datasp else np.array([]) + product_avg = model_data_loader.datapp[-1] if model_data_loader.datapp else np.array([]) + + biomass_std_avg = model_data_loader.datax_std[-1] if model_data_loader.datax_std and len(model_data_loader.datax_std[-1]) == len(time_avg) else None + substrate_std_avg = model_data_loader.datas_std[-1] if model_data_loader.datas_std and len(model_data_loader.datas_std[-1]) == len(time_avg) else None + product_std_avg = model_data_loader.datap_std[-1] if model_data_loader.datap_std and len(model_data_loader.datap_std[-1]) == len(time_avg) else None + + biomass_sem_avg = model_data_loader.datax_sem[-1] if model_data_loader.datax_sem and len(model_data_loader.datax_sem[-1]) == len(time_avg) else None + substrate_sem_avg = model_data_loader.datas_sem[-1] if model_data_loader.datas_sem and len(model_data_loader.datas_sem[-1]) == len(time_avg) else None + product_sem_avg = model_data_loader.datap_sem[-1] if model_data_loader.datap_sem and len(model_data_loader.datap_sem[-1]) == len(time_avg) else None + + current_exp_name_label = (experiment_names[experiment_counter] if experiment_counter < len(experiment_names) + else f"{sheet_name} (Promedio)") + + for model_t in model_types_selected: + fig, comp_data = _process_and_plot_single_experiment( + time_avg, biomass_avg, substrate_avg, product_avg, + biomass_std_avg, substrate_std_avg, product_std_avg, + biomass_sem_avg, substrate_sem_avg, product_sem_avg, + current_exp_name_label, model_t, int(maxfev_val), + legend_position, params_position, show_legend_plot, show_params_plot, + style_plot, line_color_plot, point_color_plot, line_style_plot, marker_style_plot, + use_differential_eqs, analysis_mode, parsed_bounds_biomass, + time_unit_str, biomass_unit_str, substrate_unit_str, product_unit_str, + error_bar_type_selected, int(error_bar_capsize_selected) + ) + if fig: figures_list.append(fig) + comparison_data_list.append(comp_data) experiment_counter += 1 - - comparison_df = pd.DataFrame(comparison_data) - + comparison_df = pd.DataFrame(comparison_data_list) if not comparison_df.empty: - # Convertir a numérico antes de ordenar, errores a NaN para que no rompa el sort - for col in ['R² Biomasa', 'RMSE Biomasa', 'R² Sustrato', 'RMSE Sustrato', 'R² Producto', 'RMSE Producto']: - comparison_df[col] = pd.to_numeric(comparison_df[col], errors='coerce') - comparison_df_sorted = comparison_df.sort_values( - by=['Experimento', 'R² Biomasa', 'R² Sustrato', 'R² Producto', 'RMSE Biomasa', 'RMSE Sustrato', 'RMSE Producto'], - ascending=[True, False, False, False, True, True, True] # Ordenar por experimento, luego por R2 (desc) y RMSE (asc) + by=['R² Biomasa', 'R² Sustrato', 'R² Producto', 'RMSE Biomasa', 'RMSE Sustrato', 'RMSE Producto'], + ascending=[False, False, False, True, True, True] ).reset_index(drop=True) else: - comparison_df_sorted = pd.DataFrame(columns=[ # Asegurar que el DF vacío tenga las columnas correctas + comparison_df_sorted = pd.DataFrame(columns=[ 'Experimento', 'Modelo', 'R² Biomasa', 'RMSE Biomasa', 'R² Sustrato', 'RMSE Sustrato', 'R² Producto', 'RMSE Producto' ]) - - - return figures, comparison_df_sorted + return figures_list, comparison_df_sorted, "Proceso completado." def create_interface(): - with gr.Blocks(theme=gr.themes.Soft()) as demo: # Usar un tema suave - gr.Markdown("# Modelos Cinéticos de Bioprocesos") + with gr.Blocks(theme=gr.themes.Soft()) as demo: + gr.Markdown("# Modelos de Bioproceso: Logístico, Gompertz, Moser y Luedeking-Piret") gr.Markdown(r""" - Ajuste y visualización de modelos cinéticos (Logístico, Gompertz, Moser) para crecimiento microbiano, - consumo de sustrato y formación de producto (Luedeking-Piret). - **Instrucciones:** - 1. Cargue un archivo Excel. El formato esperado es: - - Cada hoja representa un conjunto de datos o condición experimental. - - La primera fila del Excel debe ser el nombre del tratamiento/experimento (ej: Control, Trat1, Trat2...). - - La segunda fila debe ser el tipo de dato (Tiempo, Biomasa, Sustrato, Producto). - - Las columnas subsiguientes son las réplicas o mediciones. - - Ejemplo: - | Experimento A | Experimento A | Experimento B | Experimento B | - |---------------|---------------|---------------|---------------| - | Tiempo | Biomasa | Tiempo | Biomasa | - | 0 | 0.1 | 0 | 0.12 | - | 1 | 0.5 | 1 | 0.6 | - | ... | ... | ... | ... | - 2. Configure los parámetros de visualización y modelado. - 3. Haga clic en "Simular". + ## Ecuaciones Diferenciales Utilizadas + + **Biomasa:** + + - Logístico: + $$ + \frac{dX}{dt} = \mu_m X\left(1 - \frac{X}{X_m}\right) + $$ + + - Gompertz: + $$ + X(t) = X_m \exp\left(-\exp\left(\left(\frac{\mu_m e}{X_m}\right)(\text{lag}-t)+1\right)\right) + $$ + Ecuación diferencial: + $$ + \frac{dX}{dt} = X(t)\left(\frac{\mu_m e}{X_m}\right)\exp\left(\left(\frac{\mu_m e}{X_m}\right)(\text{lag}-t)+1\right) + $$ + + - Moser (simplificado): + $$ + X(t)=X_m(1-e^{-\mu_m(t-K_s)}) + $$ + $$ + \frac{dX}{dt}=\mu_m(X_m - X) + $$ + + **Sustrato y Producto (Luedeking-Piret):** + $$ + \frac{dS}{dt} = -p \frac{dX}{dt} - q X + $$ + $$ + \frac{dP}{dt} = \alpha \frac{dX}{dt} + \beta X + $$ """) with gr.Tabs(): - with gr.TabItem("Carga y Configuración Principal"): - file_input = gr.File(label="Subir archivo Excel (.xlsx)", file_types=['.xlsx']) + with gr.TabItem("Configuración Principal"): + file_input = gr.File(label="Subir archivo Excel (.xlsx)") + experiment_names = gr.Textbox( + label="Nombres de los experimentos (uno por línea, opcional)", + placeholder="Tratamiento A\nTratamiento B\n...\nSi se deja vacío, se usarán nombres de hoja/columna.", + lines=3 + ) + model_types = gr.CheckboxGroup( + choices=["logistic", "gompertz", "moser"], + label="Tipo(s) de Modelo de Biomasa", + value=["logistic"] + ) + analysis_mode = gr.Radio( + choices=[ + ("Procesar cada réplica/columna independientemente", "independent"), + ("Promediar réplicas por hoja (gráficos separados)", "average"), + ("Promediar réplicas por hoja (gráfico combinado)", "combinado") + ], + label="Modo de Análisis de Datos del Excel", value="independent" + ) + use_differential = gr.Checkbox(label="Usar EDOs para predecir y graficar curvas", value=False) + maxfev_input = gr.Number(label="maxfev (Máx. iteraciones para ajuste)", value=50000, precision=0) + + with gr.Accordion("Bounds para Parámetros de Biomasa (opcional)", open=False): + gr.Markdown("Especificar bounds como `valor1,valor2,valor3`. Parámetros: (X0, Xm, um) Logístico, (Xm, um, lag) Gompertz, (Xm, um, Ks) Moser.") + lower_bounds_biomass = gr.Textbox(label="Lower Bounds Biomasa (ej: 0.01,1,0.01)") + upper_bounds_biomass = gr.Textbox(label="Upper Bounds Biomasa (ej: 1,10,1)") + with gr.TabItem("Personalización de Gráficos"): with gr.Row(): - model_types = gr.CheckboxGroup( - choices=["logistic", "gompertz", "moser"], - label="Tipo(s) de Modelo de Crecimiento", - value=["logistic"], - info="Seleccione uno o más modelos para ajustar." + show_legend = gr.Checkbox(label="Mostrar Leyenda", value=True) + legend_position = gr.Dropdown( + choices=["best", "upper left", "upper right", "lower left", "lower right", "center left", "center right", "lower center", "upper center", "center"], + label="Posición Leyenda", value="best" ) - mode = gr.Radio( - ["independent", "average", "combinado"], - label="Modo de Análisis de Datos", - value="independent", - info=( - "- Independent: Analiza cada columna de 'Experimento' (nivel 0 del encabezado) por separado.\n" - "- Average: Promedia todas las réplicas dentro de una hoja y ajusta un modelo a los promedios.\n" - "- Combinado: Similar a 'Average', pero grafica Biomasa, Sustrato y Producto en un solo gráfico con múltiples ejes Y." - ) + with gr.Row(): + show_params = gr.Checkbox(label="Mostrar Parámetros/Estadísticas", value=True) + params_position = gr.Dropdown( + choices=["upper left", "upper right", "lower left", "lower right", "outside right"], + label="Posición Parámetros", value="upper right" ) - - experiment_names = gr.Textbox( - label="Nombres de los Tratamientos/Experimentos (opcional, uno por línea)", - placeholder="Tratamiento Control\nTratamiento con Inductor\n...", - lines=3, - info="Si se deja vacío, se usarán los nombres de las hojas/columnas del Excel." - ) - maxfev_input = gr.Number(label="maxfev (Máx. iteraciones para ajuste)", value=50000, minimum=1000, step=1000) - use_differential = gr.Checkbox(label="Resolver y graficar usando Ecuaciones Diferenciales (EDOs)", value=False, - info="Si se marca, las curvas ajustadas se generarán resolviendo las EDOs del sistema. Si no, se usarán las ecuaciones algebraicas ajustadas.") - - with gr.TabItem("Configuración de Gráficos"): with gr.Row(): - with gr.Column(scale=1): - gr.Markdown("#### Apariencia General") - style_dropdown = gr.Dropdown(choices=['whitegrid', 'darkgrid', 'white', 'dark', 'ticks'], label="Estilo de gráfico (Seaborn)", value='whitegrid') - show_legend = gr.Checkbox(label="Mostrar Leyenda", value=True) - legend_position = gr.Radio( - choices=["best", "upper left", "upper right", "lower left", "lower right"], - label="Posición de la leyenda", value="best" - ) - with gr.Column(scale=1): - gr.Markdown("#### Parámetros en Gráfico") - show_params = gr.Checkbox(label="Mostrar Parámetros y Métricas (R², RMSE)", value=True) - params_positions = ["upper right", "upper left", "lower left", "lower right", "outside right"] - params_position = gr.Radio( - choices=params_positions, label="Posición de los parámetros", value="upper right" - ) - + error_bar_type_radio = gr.Radio( + choices=[("Desviación Estándar (SD)", "sd"), ("Error Estándar de la Media (SEM)", "sem")], + label="Tipo de Barra de Error (para modos Promedio/Combinado)", value="sd" + ) + error_bar_capsize_slider = gr.Slider(minimum=0, maximum=10, value=3, step=1, + label="Tamaño 'Cap' Barras de Error (0 para quitar)") with gr.Row(): - with gr.Column(scale=1): - gr.Markdown("#### Colores y Estilos de Línea") - line_color_picker = gr.ColorPicker(label="Color de la línea del modelo", value='#0000FF') - point_color_picker = gr.ColorPicker(label="Color de los puntos (datos)", value='#000000') - with gr.Column(scale=1): - gr.Markdown("#### Estilos de Marcador y Línea") - line_style_options = ['-', '--', '-.', ':'] - line_style_dropdown = gr.Dropdown(choices=line_style_options, label="Estilo de línea del modelo", value='-') - marker_style_options = ['o', 's', '^', 'v', 'D', 'x', '+', '*'] - marker_style_dropdown = gr.Dropdown(choices=marker_style_options, label="Estilo de marcador (datos)", value='o') - - # Controles para nombres de ejes + style_dropdown = gr.Dropdown(choices=['whitegrid', 'darkgrid', 'white', 'dark', 'ticks'], label="Estilo Seaborn", value='whitegrid') + line_style_dropdown = gr.Dropdown(choices=['-', '--', '-.', ':'], label="Estilo Línea Modelo", value='-') + marker_style_dropdown = gr.Dropdown(choices=['o', 's', '^', 'v', 'D', 'x', '+', '*'], label="Estilo Punto Datos", value='o') with gr.Row(): - with gr.Column(scale=1): - gr.Markdown("#### Etiquetas de Ejes") - x_axis_label = gr.Textbox( - label="Etiqueta del eje X", value="Tiempo (h)", - placeholder="Ejemplo: Tiempo (h), Days, Hours" - ) - y_axis_biomass = gr.Textbox( - label="Etiqueta del eje Y - Biomasa", value="Biomasa (g/L)", - placeholder="Ejemplo: Biomasa (g/L), Cell Density" - ) - with gr.Column(scale=1): - gr.Markdown("#### (Continuación Etiquetas Y)") # Espaciador - y_axis_substrate = gr.Textbox( - label="Etiqueta del eje Y - Sustrato", value="Sustrato (g/L)", - placeholder="Ejemplo: Sustrato (g/L), Glucose" - ) - y_axis_product = gr.Textbox( - label="Etiqueta del eje Y - Producto", value="Producto (g/L)", - placeholder="Ejemplo: Producto (g/L), Ethanol" - ) - # Bounds (opcional, podría ser una característica avanzada para otra pestaña) - # with gr.TabItem("Configuración Avanzada (Bounds)"): - # gr.Markdown("Opcional: Definir límites para los parámetros del modelo durante el ajuste. Usar con precaución.") - # with gr.Row(): - # lower_bounds = gr.Textbox( - # label="Límites Inferiores (uno por línea, formato: p1,p2,p3)", - # placeholder="Ej: 0,0,0 (para logístico xo,xm,um)\n0,0,0 (para sustrato so,p,q)\n...", - # lines=3, info="Dejar vacío para no usar límites inferiores." - # ) - # upper_bounds = gr.Textbox( - # label="Límites Superiores (uno por línea, formato: p1,p2,p3)", - # placeholder="Ej: inf,inf,inf\ninf,inf,inf\n...", - # lines=3, info="Usar 'inf' para infinito. Dejar vacío para no usar límites superiores." - # ) - # Por ahora, los bounds no se usarán explícitamente desde la UI para simplificar. - # Se pueden añadir internamente en fit_biomass, etc. si es necesario. - lower_bounds = gr.Textbox(value="", visible=False) # Ocultos por ahora - upper_bounds = gr.Textbox(value="", visible=False) # Ocultos por ahora - - - simulate_btn = gr.Button("Simular y Graficar", variant="primary") - - gr.Markdown("---") - gr.Markdown("## Resultados") + line_color_picker = gr.ColorPicker(label="Color Línea Modelo", value='#0000FF') + point_color_picker = gr.ColorPicker(label="Color Puntos Datos", value='#000000') + + gr.Markdown("### Unidades para los Ejes (opcional)") + with gr.Row(): + time_unit_input = gr.Textbox(label="Unidad de Tiempo", placeholder="ej: h") + biomass_unit_input = gr.Textbox(label="Unidad de Biomasa", placeholder="ej: g/L") + with gr.Row(): + substrate_unit_input = gr.Textbox(label="Unidad de Sustrato", placeholder="ej: g/L") + product_unit_input = gr.Textbox(label="Unidad de Producto", placeholder="ej: g/L") - with gr.Tabs(): - with gr.TabItem("Gráficos"): - output_gallery = gr.Gallery(label="Figuras Generadas", columns=[2,1], height='auto', object_fit="contain") - with gr.TabItem("Tabla Comparativa"): - output_table = gr.Dataframe( - label="Tabla Comparativa de Modelos y Métricas de Ajuste", - headers=["Experimento", "Modelo", "R² Biomasa", "RMSE Biomasa", - "R² Sustrato", "RMSE Sustrato", "R² Producto", "RMSE Producto"], - interactive=False, - wrap=True, - height=400 - ) - export_btn = gr.Button("Exportar Tabla a Excel") - file_output_excel = gr.File(label="Descargar Tabla Excel", file_count="single") - - - state_df = gr.State(pd.DataFrame()) # Para guardar el dataframe de la tabla para exportar - - def process_and_plot_wrapper(file, legend_pos, params_pos, model_ts, analysis_mode, exp_names_str, - # lower_b_str, upper_b_str, # Omitidos por ahora - plot_style, line_c, point_c, line_s, marker_s, - show_leg, show_par, use_diff_eq, maxfev, - x_lab, y_lab_bio, y_lab_sub, y_lab_prod): # Nuevos parámetros de etiquetas - - if file is None: - gr.Warning("Por favor, cargue un archivo Excel.") - return [], pd.DataFrame(), pd.DataFrame() # Devuelve valores vacíos para todas las salidas - - experiment_names_list = [name.strip() for name in exp_names_str.strip().split('\n') if name.strip()] - - # Bounds no se usan desde la UI por ahora - lower_bounds_list = [] - upper_bounds_list = [] - - # Llamada a la función principal de procesamiento - figures, comparison_df = process_all_data( - file, legend_pos, params_pos, model_ts, experiment_names_list, - lower_bounds_list, upper_bounds_list, # Pasando listas vacías - mode=analysis_mode, style=plot_style, - line_color=line_c, point_color=point_c, line_style=line_s, marker_style=marker_s, - show_legend=show_leg, show_params=show_par, use_differential=use_diff_eq, - maxfev_val=int(maxfev), - # Pasando las nuevas etiquetas - x_label=x_lab, y_label_biomass=y_lab_bio, - y_label_substrate=y_lab_sub, y_label_product=y_lab_prod + simulate_btn = gr.Button("Generar Modelos y Gráficos", variant="primary") + status_message = gr.Textbox(label="Estado", interactive=False) + + # CORRECCIÓN AQUÍ: columns=[1,2] cambiado a columns=(1,2) + output_gallery = gr.Gallery(label="Resultados Gráficos", columns=(1,2), height='auto', object_fit="contain") + + output_table = gr.Dataframe( + label="Tabla Comparativa de Modelos", + headers=["Experimento", "Modelo", "R² Biomasa", "RMSE Biomasa", + "R² Sustrato", "RMSE Sustrato", "R² Producto", "RMSE Producto"], + interactive=False, wrap=True + ) + state_df_for_export = gr.State() + + def run_simulation_wrapper( + file, exp_names_str, models_sel, mode_sel, use_diff_eq, maxfev, + lb_biomass_str, ub_biomass_str, + show_leg, leg_pos, show_par, par_pos, + err_bar_type, err_bar_caps, + style_sel, lstyle_sel, mstyle_sel, lcolor, pcolor, + t_unit, b_unit, s_unit, p_unit): + + exp_names_list = [name.strip() for name in exp_names_str.strip().split('\n') if name.strip()] + figures, comparison_df, message = process_all_data( + file, leg_pos, par_pos, models_sel, mode_sel, exp_names_list, + lb_biomass_str, ub_biomass_str, + style_sel, lcolor, pcolor, lstyle_sel, mstyle_sel, + show_leg, show_par, use_diff_eq, maxfev, + t_unit, b_unit, s_unit, p_unit, + err_bar_type, err_bar_caps ) - if not figures and comparison_df.empty: - gr.Info("No se generaron figuras ni datos. Revise la consola para mensajes de error o advertencias sobre el formato del archivo.") - - - return figures, comparison_df, comparison_df # El tercer output es para state_df + return figures, comparison_df, comparison_df, message simulate_btn.click( - fn=process_and_plot_wrapper, - inputs=[file_input, legend_position, params_position, model_types, mode, experiment_names, - # lower_bounds, upper_bounds, # Omitidos - style_dropdown, line_color_picker, point_color_picker, line_style_dropdown, marker_style_dropdown, - show_legend, show_params, use_differential, maxfev_input, - # AGREGAR ESTOS INPUTS: - x_axis_label, y_axis_biomass, y_axis_substrate, y_axis_product], - outputs=[output_gallery, output_table, state_df] + fn=run_simulation_wrapper, + inputs=[ + file_input, experiment_names, model_types, analysis_mode, use_differential, maxfev_input, + lower_bounds_biomass, upper_bounds_biomass, + show_legend, legend_position, show_params, params_position, + error_bar_type_radio, error_bar_capsize_slider, + style_dropdown, line_style_dropdown, marker_style_dropdown, line_color_picker, point_color_picker, + time_unit_input, biomass_unit_input, substrate_unit_input, product_unit_input + ], + outputs=[output_gallery, output_table, state_df_for_export, status_message] ) - def export_excel_fn(df_to_export): + def export_excel(df_to_export): if df_to_export is None or df_to_export.empty: - gr.Info("No hay datos en la tabla para exportar.") - return None - try: - with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp: - df_to_export.to_excel(tmp.name, index=False) + with tempfile.NamedTemporaryFile(prefix="no_data_", suffix=".xlsx", delete=False) as tmp: + pd.DataFrame({"Mensaje": ["No hay datos para exportar."]}).to_excel(tmp.name, index=False) return tmp.name - except Exception as e: - gr.Error(f"Error al exportar a Excel: {e}") - return None - - export_btn.click( - fn=export_excel_fn, - inputs=state_df, - outputs=file_output_excel - ) - - gr.Markdown("---") - gr.Markdown("Desarrollado con Gradio y Python. Modelo base y mejoras por la comunidad.") - + with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp: + df_to_export.to_excel(tmp.name, index=False) + return tmp.name + export_btn = gr.Button("Exportar Tabla a Excel") + file_output_excel = gr.File(label="Descargar Tabla Excel") + export_btn.click(fn=export_excel, inputs=state_df_for_export, outputs=file_output_excel) return demo if __name__ == '__main__': - # Para ejecutar localmente si no estás en un notebook - # import os - # os.environ['GRADIO_TEMP_DIR'] = os.path.join(os.getcwd(), "gradio_tmp") # Opcional: definir dir temporal - - demo = create_interface() - demo.launch(share=False, debug=True) # share=True para enlace público, debug=True para ver errores en consola \ No newline at end of file + app_interface = create_interface() + app_interface.launch(share=True, debug=True) \ No newline at end of file