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