diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,7 +1,7 @@ -# 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 +#import os +#!pip install gradio seaborn scipy scikit-learn openpyxl pydantic==1.10.0 -q -from pydantic import BaseModel # ConfigDict ya no es necesario en Pydantic V2 si solo usas arbitrary_types_allowed +from pydantic import BaseModel, ConfigDict import numpy as np import pandas as pd import matplotlib.pyplot as plt @@ -14,19 +14,7 @@ import io from PIL import Image import tempfile -# --- 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 YourModel(BaseModel): class Config: arbitrary_types_allowed = True @@ -38,29 +26,20 @@ class BioprocessModel: self.datax = [] self.datas = [] self.datap = [] - self.dataxp = [] # Promedios + self.dataxp = [] self.datasp = [] self.datapp = [] - self.datax_std = [] # Desviaciones estándar + self.datax_std = [] 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): - 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 + return (xo * np.exp(um * time)) / (1 - (xo / xm) * (1 - np.exp(um * time))) @staticmethod def gompertz(time, xm, um, lag): @@ -68,7 +47,13 @@ class BioprocessModel: @staticmethod def moser(time, Xm, um, Ks): - return Xm * (1 - np.exp(-um * (time - 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)) + @staticmethod def logistic_diff(X, t, params): @@ -78,91 +63,107 @@ class BioprocessModel: @staticmethod def gompertz_diff(X, t, params): xm, um, lag = params - return X * (um * np.e / xm) * np.exp((um * np.e / xm) * (lag - t) + 1) + # 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) + @staticmethod def moser_diff(X, t, params): Xm, um, Ks = params return um * (Xm - X) - 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 + 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 - 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 + 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_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): - 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 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 _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) + 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 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: - 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([])) + 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))) + + self.time = time - _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': @@ -174,800 +175,1057 @@ 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, bounds=None): - p0 = None - fit_func = None - param_names = [] + 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 - 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.") + 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}") self.params['biomass'] = {} + self.r2['biomass'] = np.nan + self.rmse['biomass'] = np.nan return None - - # 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)}).") + except Exception as e: + print(f"Error general en fit_biomass_{self.model_type}: {e}") self.params['biomass'] = {} + self.r2['biomass'] = np.nan + self.rmse['biomass'] = np.nan return None - - 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 - + def fit_substrate(self, time, substrate, biomass_params): 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 + 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: - 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 en fit_biomass_{self.model_type}: {e}") - self.params['biomass'] = {} - return None + 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 + 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 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] = {} + 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 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] = {} + 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 return None - model_func = self.substrate if fit_type == 'substrate' else self.product - + 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 + + 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 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, *params_fit: model_func(t, *params_fit, biomass_params_list), - time_fit, data_fit, p0=p0_values, maxfev=self.maxfev + 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' ) - self.params[fit_type] = dict(zip(param_names, popt)) - y_pred_fit = model_func(time_fit, *popt, biomass_params_list) + self.params['product'] = {'po': popt[0], 'alpha': popt[1], 'beta': popt[2]} + y_pred = self.product(time, *popt, current_biomass_params) - 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 + 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)) else: - 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 + 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 except Exception as e: - print(f"Error en fit_{fit_type}_{self.model_type}: {e}") - self.params[fit_type] = {} + print(f"Error general en fit_product_{self.model_type}: {e}") + self.params['product'] = {} + self.r2['product'] = np.nan + self.rmse['product'] = np.nan return None - 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 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) - 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) + if len(time) == 0: return np.array([]) + time_fine = np.linspace(time.min(), time.max(), 500) + return time_fine - def system(self, y, t, biomass_params_list, substrate_params_dict, product_params_dict): + def system(self, y, t, biomass_params_tuple, substrate_params_tuple, product_params_tuple, model_type): 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) + # 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 + + so, p, q = substrate_params_tuple + po, alpha, beta = product_params_tuple + + # 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 - return [dXdt, dSdt, dPdt] + + # 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) - def get_initial_conditions(self, time, biomass, substrate, product): - # 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) + return [dXdt, dSdt, dPdt] - time_min_val = np.nanmin(time) if len(time)>0 and np.any(np.isfinite(time)) else 0 + + def get_initial_conditions(self, time, biomass, substrate, product): + X0, S0, P0 = 0,0,0 # Defaults if 'biomass' in self.params and self.params['biomass']: if self.model_type == 'logistic': - X0 = self.params['biomass']['xo'] + X0 = self.params['biomass'].get('xo', biomass[0] if len(biomass)>0 else 0) elif self.model_type == 'gompertz': - 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)) + 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) elif self.model_type == 'moser': - 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))) - + 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] + if 'substrate' in self.params and self.params['substrate']: - S0 = self.params['substrate']['so'] - + 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']['po'] + P0 = self.params['product'].get('po', product[0] if len(product)>0 else 0) + elif len(product) > 0: + P0 = product[0] + + # 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) return [X0, S0, P0] + def solve_differential_equations(self, time, biomass, substrate, product): - biomass_params_list = self._get_biomass_model_params_as_list() - if biomass_params_list is None: + if 'biomass' not in self.params or not self.params['biomass']: 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) < 2 : # Si generate_fine_time_grid devolvió el time original y era muy corto - print("No hay suficiente rango de tiempo para resolver EDOs.") + if len(time_fine) == 0: # Si time_fine está vacío (porque time estaba vacío) return None, None, None, time try: sol = odeint(self.system, initial_conditions, time_fine, - 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 + 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 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_error_values, substrate_error_values, product_error_values, + biomass_std=None, substrate_std=None, product_std=None, 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, - time_unit='', biomass_unit='', substrate_unit='', product_unit='', - error_bar_capsize=5): + # AGREGAR ESTOS NUEVOS PARÁMETROS: + x_label='Tiempo', y_label_biomass='Biomasa', + y_label_substrate='Sustrato', y_label_product='Producto'): - sns.set_style(style) - 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.") + 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 + + + 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())) + fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(10, 15)) - fig.suptitle(f'{experiment_name} (Modelo: {self.model_type.capitalize()})', fontsize=16) + fig.suptitle(f'{experiment_name} ({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 - - 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') + + 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)) ] - 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) + 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) else: - ax.plot(time_finite_data, data_finite_values, - marker=marker_style, linestyle='', color=point_color, + ax.plot(time, data, marker=marker_style, linestyle='', color=point_color, label='Datos experimentales') - 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') + # 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) - ax.set_xlabel(xlabel_full) + ax.set_xlabel(x_label) ax.set_ylabel(ylabel) if show_legend: ax.legend(loc=legend_position) - 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)) + 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) 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', 'facecolor':'white', 'alpha':0.7}) + 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) + plt.tight_layout(rect=[0, 0.03, 1, 0.95]) + buf = io.BytesIO() - fig.savefig(buf, format='png') + fig.savefig(buf, format='png', dpi=150) # Aumentar DPI para mejor calidad 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_error_values, substrate_error_values, product_error_values, + biomass_std=None, substrate_std=None, product_std=None, 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, - time_unit='', biomass_unit='', substrate_unit='', product_unit='', - error_bar_capsize=5): - sns.set_style(style) - 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} (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 + # 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 - fig, ax1 = plt.subplots(figsize=(12, 7)) - fig.suptitle(f'{experiment_name} (Modelo: {self.model_type.capitalize()})', fontsize=16) + 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())) + 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) - 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'} - - 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)') + # 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)') 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(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.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.tick_params(axis='y', labelcolor=colors['Sustrato']) - + ax3 = ax1.twinx() - 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.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.tick_params(axis='y', labelcolor=colors['Producto']) - 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) + 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_params: - 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') + 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 if params_position == 'outside right': - 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)) + 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 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, - fontsize=8, verticalalignment=va_align, horizontalalignment=ha_align, - bbox={'boxstyle':'round', 'facecolor':'white', 'alpha':0.7}) + 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 - plt.tight_layout(rect=[0, 0.03, 1, 0.95]) buf = io.BytesIO() - fig.savefig(buf, format='png') + fig.savefig(buf, format='png', dpi=150) buf.seek(0) image = Image.open(buf).convert("RGB") plt.close(fig) + return image -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: - return [], pd.DataFrame(), "Por favor, sube un archivo Excel." +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'): + if file is None: + print("No se ha subido ningún archivo.") + return [], pd.DataFrame() try: xls = pd.ExcelFile(file.name) except Exception as e: - return [], pd.DataFrame(), f"Error al leer el archivo Excel: {e}" + print(f"Error al leer el archivo Excel: {e}") + return [], pd.DataFrame() sheet_names = xls.sheet_names - 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.") + figures = [] + comparison_data = [] + experiment_counter = 0 for sheet_name in sheet_names: try: - 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]]) + 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]) + except Exception as e: print(f"Error al leer la hoja '{sheet_name}': {e}") continue - if analysis_mode == 'independent': + # 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 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: - 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"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}") + # 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 - elif analysis_mode in ['average', 'combinado']: - model_data_loader = BioprocessModel() + 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: - model_data_loader.process_data(df) - except ValueError as ve: - print(f"Error en hoja '{sheet_name}': {ve}. Saltando.") - continue - - if len(model_data_loader.time) == 0: - print(f"No hay datos de tiempo válidos en '{sheet_name}'. Saltando.") + # 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 - 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) + 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") + + + 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) + }) + + 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) experiment_counter += 1 - comparison_df = pd.DataFrame(comparison_data_list) + + comparison_df = pd.DataFrame(comparison_data) + 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=['R² Biomasa', 'R² Sustrato', 'R² Producto', 'RMSE Biomasa', 'RMSE Sustrato', 'RMSE Producto'], - ascending=[False, False, False, True, True, True] + 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) ).reset_index(drop=True) else: - comparison_df_sorted = pd.DataFrame(columns=[ + comparison_df_sorted = pd.DataFrame(columns=[ # Asegurar que el DF vacío tenga las columnas correctas 'Experimento', 'Modelo', 'R² Biomasa', 'RMSE Biomasa', 'R² Sustrato', 'RMSE Sustrato', 'R² Producto', 'RMSE Producto' ]) - return figures_list, comparison_df_sorted, "Proceso completado." + + + return figures, comparison_df_sorted def create_interface(): - with gr.Blocks(theme=gr.themes.Soft()) as demo: - gr.Markdown("# Modelos de Bioproceso: Logístico, Gompertz, Moser y Luedeking-Piret") + with gr.Blocks(theme=gr.themes.Soft()) as demo: # Usar un tema suave + gr.Markdown("# Modelos Cinéticos de Bioprocesos") gr.Markdown(r""" - ## 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 - $$ + 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". """) with gr.Tabs(): - 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("Carga y Configuración Principal"): + file_input = gr.File(label="Subir archivo Excel (.xlsx)", file_types=['.xlsx']) - with gr.TabItem("Personalización de Gráficos"): with gr.Row(): - 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" + 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." ) - 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" - ) - with gr.Row(): - 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" + 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." + ) ) - 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(): - 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') + + 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(): - 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.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" + ) + 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.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 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") - - 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") + 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") - 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 + gr.Markdown("---") + gr.Markdown("## Resultados") + + 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 ) - return figures, comparison_df, comparison_df, message + 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 simulate_btn.click( - 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] + 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] ) - def export_excel(df_to_export): + def export_excel_fn(df_to_export): if df_to_export is None or df_to_export.empty: - 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) + 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) return tmp.name - 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) + 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.") + return demo if __name__ == '__main__': - app_interface = create_interface() - app_interface.launch(share=True, debug=True) \ No newline at end of file + # 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=True, debug=True) # share=True para enlace público, debug=True para ver errores en consola \ No newline at end of file