File size: 39,194 Bytes
21df8ee 44f50e3 01b1e82 9faf081 ccd76a9 1105522 d248e5f 44f50e3 1105522 44f50e3 d248e5f 44f50e3 1105522 44f50e3 1105522 44f50e3 a3b612a 44f50e3 78bc459 44f50e3 b9b8eda 1105522 d248e5f 44f50e3 9faf081 44f50e3 78bc459 44f50e3 78bc459 1105522 c3b2589 44f50e3 8d47a43 1105522 78bc459 44f50e3 78bc459 44f50e3 26f229e 44f50e3 78bc459 44f50e3 b9b8eda 44f50e3 26f229e 44f50e3 b9b8eda 44f50e3 78bc459 1105522 44f50e3 d248e5f 44f50e3 78bc459 44f50e3 e028826 d248e5f c3b2589 44f50e3 c3b2589 44f50e3 9faf081 44f50e3 26f229e b9b8eda 9faf081 44f50e3 b9b8eda 44f50e3 9faf081 78bc459 44f50e3 26f229e b9b8eda 9faf081 44f50e3 ffa837b 44f50e3 b9b8eda 44f50e3 b9b8eda 44f50e3 ffa837b 44f50e3 b9b8eda 44f50e3 b9b8eda 44f50e3 d248e5f 44f50e3 b9b8eda 44f50e3 b9b8eda 44f50e3 b9b8eda 44f50e3 1f60596 44f50e3 b9b8eda 44f50e3 b9b8eda 44f50e3 b9b8eda 44f50e3 26f229e 44f50e3 9faf081 d248e5f 44f50e3 9faf081 44f50e3 d248e5f 9faf081 44f50e3 d248e5f 44f50e3 e028826 44f50e3 9faf081 44f50e3 e028826 44f50e3 26f229e 44f50e3 26f229e 44f50e3 9faf081 44f50e3 9faf081 44f50e3 1105522 ccd76a9 9faf081 44f50e3 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 |
import os
import io
import tempfile
from PIL import Image
os.system("pip install --upgrade gradio")
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy.integrate import odeint
from scipy.optimize import curve_fit
from sklearn.metrics import mean_squared_error
import gradio as gr
# --- CLASE PRINCIPAL DEL MODELO DE BIOPROCESO ---
# Contiene toda la lógica matemática, ajuste y graficación.
class BioprocessModel:
"""
Clase para modelar, ajustar y simular cinéticas de bioprocesos.
Incluye modelos para crecimiento de biomasa, consumo de sustrato y formación de producto.
"""
def __init__(self, model_type='logistic', maxfev=50000):
self.model_type = model_type
self.maxfev = maxfev
self.params = {}
self.r2 = {}
self.rmse = {}
self.data_time = None
self.data_biomass_mean = None
self.data_substrate_mean = None
self.data_product_mean = None
self.data_biomass_std = None
self.data_substrate_std = None
self.data_product_std = None
self.biomass_model_func = None # Función del modelo analítico (ej. logistic)
self.biomass_diff_func = None # Función de la ecuación diferencial (ej. logistic_diff)
# --- Modelos Analíticos de Biomasa ---
@staticmethod
def logistic(time, xo, xm, um):
# Salvaguardas para evitar errores matemáticos
if xm <= 0 or xo <= 0 or xm <= xo:
return np.full_like(time, np.nan)
# Previene división por cero y logaritmo de cero en casos extremos
term_exp = np.exp(um * time)
denominator = (1 - (xo / xm) * (1 - term_exp))
# Si el denominador es cero, reemplázalo por un número pequeño para evitar error
denominator = np.where(denominator == 0, 1e-9, denominator)
return (xo * term_exp) / denominator
@staticmethod
def gompertz(time, xm, um, lag):
# Salvaguardas
if xm <= 0 or um <= 0:
return np.full_like(time, np.nan)
# Previene overflow en np.exp
exp_term = (um * np.e / xm) * (lag - time) + 1
exp_term_clipped = np.clip(exp_term, -np.inf, 700) # exp(709) es aprox. el float máximo
return xm * np.exp(-np.exp(exp_term_clipped))
@staticmethod
def moser(time, Xm, um, Ks):
# Forma simplificada, no dependiente de sustrato
if Xm <= 0 or um <= 0:
return np.full_like(time, np.nan)
return Xm * (1 - np.exp(-um * (time - Ks)))
@staticmethod
def baranyi(time, X0, Xm, um, lag):
# Salvaguardas
if X0 <= 0 or Xm <= X0 or um <= 0 or lag < 0:
return np.full_like(time, np.nan)
# Argumento del logaritmo en A(t), previene valores no positivos
log_arg_A = np.exp(-um * time) + np.exp(-um * lag) - np.exp(-um * (t + lag))
log_arg_A = np.where(log_arg_A <= 1e-9, 1e-9, log_arg_A)
A_t = time + (1 / um) * np.log(log_arg_A)
# Previene overflow
exp_um_At = np.exp(um * A_t)
exp_um_At_clipped = np.clip(exp_um_At, -np.inf, 700)
numerator = (Xm / X0) * exp_um_At_clipped
denominator = (Xm / X0 - 1) + exp_um_At_clipped
denominator = np.where(denominator == 0, 1e-9, denominator)
return X0 * (numerator / denominator)
# --- Ecuaciones Diferenciales de Biomasa ---
@staticmethod
def logistic_diff(X, t, params):
_, xm, um = params
if xm <= 0: return 0
return um * X * (1 - X / xm)
@staticmethod
def gompertz_diff(X, t, params):
xm, um, lag = params
if xm <= 0 or X <= 0: return 0
# Forma derivada d(Gompertz)/dt
k_val = um * np.e / xm
u_val = k_val * (lag - t) + 1
u_val_clipped = np.clip(u_val, -np.inf, 700) # Previene overflow
return X * k_val * np.exp(u_val_clipped)
@staticmethod
def moser_diff(X, t, params):
Xm, um, _ = params
if Xm <=0: return 0
return um * (Xm - X)
# --- Modelos de Sustrato y Producto (Luedeking-Piret) ---
def _get_biomass_at_t(self, time, biomass_params_list):
if self.biomass_model_func is None or not biomass_params_list:
return np.full_like(time, np.nan)
X_t = self.biomass_model_func(time, *biomass_params_list)
return X_t
def _get_initial_biomass(self, biomass_params_list):
if self.model_type in ['logistic', 'baranyi']:
return biomass_params_list[0]
elif self.model_type in ['gompertz', 'moser']:
return self.biomass_model_func(0, *biomass_params_list)
return 0
def substrate(self, time, so, p, q, biomass_params_list):
X_t = self._get_biomass_at_t(time, biomass_params_list)
if np.any(np.isnan(X_t)): return np.full_like(time, np.nan)
integral_X = np.zeros_like(X_t)
if len(time) > 1:
dt = np.diff(time, prepend=time[0] - (time[1]-time[0] if len(time)>1 else 1))
integral_X = np.cumsum(X_t * dt)
X0 = self._get_initial_biomass(biomass_params_list)
return so - p * (X_t - X0) - q * integral_X
def product(self, time, po, alpha, beta, biomass_params_list):
X_t = self._get_biomass_at_t(time, biomass_params_list)
if np.any(np.isnan(X_t)): return np.full_like(time, np.nan)
integral_X = np.zeros_like(X_t)
if len(time) > 1:
dt = np.diff(time, prepend=time[0] - (time[1]-time[0] if len(time)>1 else 1))
integral_X = np.cumsum(X_t * dt)
X0 = self._get_initial_biomass(biomass_params_list)
return po + alpha * (X_t - X0) + beta * integral_X
# --- Procesamiento de Datos ---
def process_data_from_df(self, df):
try:
time_col = [col for col in df.columns if col[1] == 'Tiempo'][0]
self.data_time = df[time_col].dropna().values
min_len = len(self.data_time)
def extract_mean_std(component_name):
cols = [col for col in df.columns if col[1] == component_name]
if not cols: return np.array([]), np.array([])
data_reps = [df[col].dropna().values for col in cols]
# Alinea las réplicas con la longitud del tiempo
aligned_reps = [rep for rep in data_reps if len(rep) == min_len]
if not aligned_reps: return np.array([]), np.array([])
data_np = np.array(aligned_reps)
mean_vals = np.mean(data_np, axis=0)
std_vals = np.std(data_np, axis=0, ddof=1) if data_np.shape[0] > 1 else np.zeros_like(mean_vals)
return mean_vals, std_vals
self.data_biomass_mean, self.data_biomass_std = extract_mean_std('Biomasa')
self.data_substrate_mean, self.data_substrate_std = extract_mean_std('Sustrato')
self.data_product_mean, self.data_product_std = extract_mean_std('Producto')
except (IndexError, KeyError) as e:
raise ValueError(f"El DataFrame no tiene la estructura esperada (columnas 'Tiempo', 'Biomasa', etc.). Error: {e}")
# --- Lógica de Ajuste de Modelos (Curve Fitting) ---
def set_model_functions(self):
model_map = {
'logistic': (self.logistic, self.logistic_diff),
'gompertz': (self.gompertz, self.gompertz_diff),
'moser': (self.moser, self.moser_diff),
'baranyi': (self.baranyi, None) # Baranyi no tiene una EDO simple implementada
}
if self.model_type in model_map:
self.biomass_model_func, self.biomass_diff_func = model_map[self.model_type]
else:
raise ValueError(f"Modelo de biomasa desconocido: {self.model_type}")
def _fit_component(self, fit_func, time, data, initial_guesses, bounds, *args):
try:
popt, _ = curve_fit(fit_func, time, data, p0=initial_guesses, bounds=bounds, maxfev=self.maxfev, ftol=1e-9, xtol=1e-9)
y_pred = fit_func(time, *popt, *args)
if np.any(np.isnan(y_pred)) or np.any(np.isinf(y_pred)):
return None, None, np.nan, np.nan
ss_res = np.sum((data - y_pred) ** 2)
ss_tot = np.sum((data - np.mean(data)) ** 2)
r2 = 1 - (ss_res / ss_tot) if ss_tot > 0 else 1.0
rmse = np.sqrt(mean_squared_error(data, y_pred))
return popt, y_pred, r2, rmse
except (RuntimeError, ValueError) as e:
print(f"Error en curve_fit para {fit_func.__name__}: {e}")
return None, None, np.nan, np.nan
def fit_all_models(self, time, biomass, substrate, product):
self.set_model_functions()
# 1. Ajustar Biomasa
y_pred_biomass = None
if biomass is not None and len(biomass) > 0:
popt_bio = self._fit_biomass_model(time, biomass)
if popt_bio is not None:
y_pred_biomass = self.biomass_model_func(time, *popt_bio)
# 2. Ajustar Sustrato y Producto (si biomasa se ajustó correctamente)
y_pred_substrate, y_pred_product = None, None
if 'biomass' in self.params and self.params['biomass']:
biomass_popt_list = list(self.params['biomass'].values())
if substrate is not None and len(substrate) > 0:
self._fit_substrate_model(time, substrate, biomass_popt_list)
if 'substrate' in self.params and self.params['substrate']:
substrate_popt = list(self.params['substrate'].values())
y_pred_substrate = self.substrate(time, *substrate_popt, biomass_popt_list)
if product is not None and len(product) > 0:
self._fit_product_model(time, product, biomass_popt_list)
if 'product' in self.params and self.params['product']:
product_popt = list(self.params['product'].values())
y_pred_product = self.product(time, *product_popt, biomass_popt_list)
return y_pred_biomass, y_pred_substrate, y_pred_product
def _fit_biomass_model(self, time, biomass):
# Estimaciones iniciales y límites para cada modelo
param_configs = {
'logistic': {
'p0': [biomass[0] if biomass[0] > 1e-6 else 1e-3, max(biomass), 0.1],
'bounds': ([1e-9, max(1e-9, biomass[0]), 1e-9], [max(biomass), np.inf, np.inf]),
'keys': ['Xo', 'Xm', 'um']
},
'gompertz': {
'p0': [max(biomass), 0.1, time[np.argmax(np.gradient(biomass))] if len(biomass)>1 else 0],
'bounds': ([max(1e-9, min(biomass)), 1e-9, 0], [np.inf, np.inf, max(time) or 1]),
'keys': ['Xm', 'um', 'lag']
},
'moser': {
'p0': [max(biomass), 0.1, 0],
'bounds': ([max(1e-9, min(biomass)), 1e-9, -np.inf], [np.inf, np.inf, np.inf]),
'keys': ['Xm', 'um', 'Ks']
},
'baranyi': {
'p0': [biomass[0] if biomass[0] > 1e-6 else 1e-3, max(biomass), 0.1, time[np.argmax(np.gradient(biomass))] if len(biomass)>1 else 0],
'bounds': ([1e-9, max(1e-9, biomass[0]), 1e-9, 0], [max(biomass), np.inf, np.inf, max(time) or 1]),
'keys': ['X0', 'Xm', 'um', 'lag']
}
}
config = param_configs[self.model_type]
popt, _, r2, rmse = self._fit_component(self.biomass_model_func, time, biomass, config['p0'], config['bounds'])
if popt is not None:
self.params['biomass'] = dict(zip(config['keys'], popt))
self.r2['biomass'] = r2
self.rmse['biomass'] = rmse
return popt
def _fit_substrate_model(self, time, substrate, biomass_popt_list):
p0 = [substrate[0] if len(substrate) > 0 else 1.0, 0.1, 0.01]
bounds = ([0, -np.inf, -np.inf], [np.inf, np.inf, np.inf]) # p, q pueden ser negativos
fit_func = lambda t, so, p, q: self.substrate(t, so, p, q, biomass_popt_list)
popt, _, r2, rmse = self._fit_component(fit_func, time, substrate, p0, bounds)
if popt is not None:
self.params['substrate'] = {'So': popt[0], 'p': popt[1], 'q': popt[2]}
self.r2['substrate'] = r2
self.rmse['substrate'] = rmse
return popt
def _fit_product_model(self, time, product, biomass_popt_list):
p0 = [product[0] if len(product) > 0 else 0.0, 0.1, 0.01]
bounds = ([0, -np.inf, -np.inf], [np.inf, np.inf, np.inf]) # alpha, beta pueden ser negativos
fit_func = lambda t, po, alpha, beta: self.product(t, po, alpha, beta, biomass_popt_list)
popt, _, r2, rmse = self._fit_component(fit_func, time, product, p0, bounds)
if popt is not None:
self.params['product'] = {'Po': popt[0], 'alpha': popt[1], 'beta': popt[2]}
self.r2['product'] = r2
self.rmse['product'] = rmse
return popt
# --- Lógica de Ecuaciones Diferenciales (ODE) ---
def system_ode(self, y, t, biomass_params_list, substrate_params_list, product_params_list):
X, S, P = y
# dX/dt
dXdt = self.biomass_diff_func(X, t, biomass_params_list) if self.biomass_diff_func else 0.0
# dS/dt
p_val = substrate_params_list[1] if len(substrate_params_list) > 1 else 0
q_val = substrate_params_list[2] if len(substrate_params_list) > 2 else 0
dSdt = -p_val * dXdt - q_val * X
# dP/dt
alpha_val = product_params_list[1] if len(product_params_list) > 1 else 0
beta_val = product_params_list[2] if len(product_params_list) > 2 else 0
dPdt = alpha_val * dXdt + beta_val * X
return [dXdt, dSdt, dPdt]
def solve_odes(self, time_fine):
if not self.biomass_diff_func:
print(f"Resolución de EDO no soportada para el modelo {self.model_type}.")
return None, None, None
# Reune los parámetros necesarios
try:
bio_params = list(self.params['biomass'].values())
sub_params = list(self.params.get('substrate', {}).values())
prod_params = list(self.params.get('product', {}).values())
# Condiciones iniciales de las EDO
X0 = self._get_initial_biomass(bio_params)
S0 = self.params.get('substrate', {}).get('So', 0)
P0 = self.params.get('product', {}).get('Po', 0)
initial_conditions = [X0, S0, P0]
sol = odeint(self.system_ode, initial_conditions, time_fine,
args=(bio_params, sub_params, prod_params), rtol=1e-6, atol=1e-6)
return sol[:, 0], sol[:, 1], sol[:, 2]
except (KeyError, IndexError, Exception) as e:
print(f"Error preparando o resolviendo EDOs: {e}")
return None, None, None
# --- Generación de Gráficos ---
def _generate_fine_time_grid(self, time_exp):
if time_exp is None or len(time_exp) < 2:
return np.array([])
return np.linspace(np.min(time_exp), np.max(time_exp), 500)
def plot_results(self, plot_config):
use_differential = plot_config['use_differential']
time_exp = plot_config['time_exp']
time_fine = self._generate_fine_time_grid(time_exp)
# Determina qué datos de modelo mostrar: EDO o ajuste directo
if use_differential and self.biomass_diff_func:
X_model, S_model, P_model = self.solve_odes(time_fine)
time_model = time_fine
else:
if use_differential and not self.biomass_diff_func:
print(f"Advertencia: EDO no soportada para {self.model_type}. Mostrando ajuste directo.")
# Recalcula las curvas del modelo en la malla fina
X_model, S_model, P_model = None, None, None
time_model = time_fine
if 'biomass' in self.params:
bio_p = list(self.params['biomass'].values())
X_model = self.biomass_model_func(time_model, *bio_p)
if 'substrate' in self.params:
sub_p = list(self.params['substrate'].values())
S_model = self.substrate(time_model, *sub_p, bio_p)
if 'product' in self.params:
prod_p = list(self.params['product'].values())
P_model = self.product(time_model, *prod_p, bio_p)
# Configuración del gráfico
sns.set_style(plot_config['style'])
fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(10, 15), sharex=True)
fig.suptitle(f"{plot_config['exp_name']} ({self.model_type.capitalize()})", fontsize=16)
plot_details = [
(ax1, 'biomass', plot_config['axis_labels']['biomass_label'], plot_config['biomass_exp'], plot_config['biomass_std'], X_model),
(ax2, 'substrate', plot_config['axis_labels']['substrate_label'], plot_config['substrate_exp'], plot_config['substrate_std'], S_model),
(ax3, 'product', plot_config['axis_labels']['product_label'], plot_config['product_exp'], plot_config['product_std'], P_model)
]
for ax, comp_name, ylabel, data_exp, data_std, data_model in plot_details:
# Graficar datos experimentales
if data_exp is not None and len(data_exp) > 0:
if plot_config['show_error_bars'] and data_std is not None and len(data_std) > 0:
ax.errorbar(time_exp, data_exp, yerr=data_std, fmt=plot_config['marker_style'], color=plot_config['point_color'],
label='Datos Exp.', capsize=plot_config['error_cap_size'], elinewidth=plot_config['error_line_width'])
else:
ax.plot(time_exp, data_exp, linestyle='', marker=plot_config['marker_style'], color=plot_config['point_color'], label='Datos Exp.')
# Graficar modelo
if data_model is not None and len(data_model) > 0 and not np.all(np.isnan(data_model)):
ax.plot(time_model, data_model, linestyle=plot_config['line_style'], color=plot_config['line_color'], label='Modelo')
# Etiquetas y Títulos
ax.set_ylabel(ylabel)
ax.set_title(ylabel)
if plot_config['show_legend']: ax.legend(loc=plot_config['legend_pos'])
# Caja de parámetros
if plot_config['show_params'] and comp_name in self.params:
param_text = '\n'.join([f"{k} = {v:.3g}" for k, v in self.params[comp_name].items()])
r2_text = f"R² = {self.r2.get(comp_name, np.nan):.3f}"
rmse_text = f"RMSE = {self.rmse.get(comp_name, np.nan):.3f}"
full_text = f"{param_text}\n{r2_text}\n{rmse_text}"
pos_x, ha = (0.95, 'right') if 'right' in plot_config['params_pos'] else (0.05, 'left')
pos_y, va = (0.95, 'top') if 'upper' in plot_config['params_pos'] else (0.05, 'bottom')
ax.text(pos_x, pos_y, full_text, transform=ax.transAxes, verticalalignment=va, horizontalalignment=ha,
bbox={'boxstyle': 'round,pad=0.3', 'facecolor':'wheat', 'alpha':0.6})
ax3.set_xlabel(plot_config['axis_labels']['x_label'])
plt.tight_layout(rect=[0, 0.03, 1, 0.95])
# Convertir figura a imagen
buf = io.BytesIO()
fig.savefig(buf, format='png', bbox_inches='tight')
plt.close(fig)
buf.seek(0)
return Image.open(buf).convert("RGB")
def plot_combined_results(self, plot_config):
# Lógica de cálculo de modelo similar a plot_results
use_differential = plot_config['use_differential']
time_exp = plot_config['time_exp']
time_fine = self._generate_fine_time_grid(time_exp)
if use_differential and self.biomass_diff_func:
X_model, S_model, P_model = self.solve_odes(time_fine)
time_model = time_fine
else: #... (código idéntico a plot_results para cálculo de modelo)
X_model, S_model, P_model = None, None, None; time_model = time_fine
if 'biomass' in self.params:
bio_p = list(self.params['biomass'].values()); X_model = self.biomass_model_func(time_model, *bio_p)
if 'substrate' in self.params: S_model = self.substrate(time_model, *list(self.params['substrate'].values()), bio_p)
if 'product' in self.params: P_model = self.product(time_model, *list(self.params['product'].values()), bio_p)
# Colores fijos para claridad en el gráfico combinado
colors = {'Biomasa': '#0072B2', 'Sustrato': '#009E73', 'Producto': '#D55E00'}
model_colors = {'Biomasa': '#56B4E9', 'Sustrato': '#34E499', 'Producto': '#F0E442'}
sns.set_style(plot_config['style'])
fig, ax1 = plt.subplots(figsize=(12, 7))
fig.suptitle(f"{plot_config['exp_name']} ({self.model_type.capitalize()})", fontsize=16)
# Eje 1: Biomasa
ax1.set_xlabel(plot_config['axis_labels']['x_label'])
ax1.set_ylabel(plot_config['axis_labels']['biomass_label'], color=colors['Biomasa'])
if plot_config['biomass_exp'] is not None and len(plot_config['biomass_exp']) > 0:
ax1.plot(time_exp, plot_config['biomass_exp'], marker=plot_config['marker_style'], linestyle='', color=colors['Biomasa'], label='Biomasa (Datos)')
if X_model is not None: ax1.plot(time_model, X_model, color=model_colors['Biomasa'], linestyle=plot_config['line_style'], label='Biomasa (Modelo)')
ax1.tick_params(axis='y', labelcolor=colors['Biomasa'])
# Eje 2: Sustrato
ax2 = ax1.twinx()
ax2.set_ylabel(plot_config['axis_labels']['substrate_label'], color=colors['Sustrato'])
if plot_config['substrate_exp'] is not None and len(plot_config['substrate_exp']) > 0:
ax2.plot(time_exp, plot_config['substrate_exp'], marker=plot_config['marker_style'], linestyle='', color=colors['Sustrato'], label='Sustrato (Datos)')
if S_model is not None: ax2.plot(time_model, S_model, color=model_colors['Sustrato'], linestyle=plot_config['line_style'], label='Sustrato (Modelo)')
ax2.tick_params(axis='y', labelcolor=colors['Sustrato'])
# Eje 3: Producto
ax3 = ax1.twinx()
ax3.spines["right"].set_position(("axes", 1.18))
ax3.set_ylabel(plot_config['axis_labels']['product_label'], color=colors['Producto'])
if plot_config['product_exp'] is not None and len(plot_config['product_exp']) > 0:
ax3.plot(time_exp, plot_config['product_exp'], marker=plot_config['marker_style'], linestyle='', color=colors['Producto'], label='Producto (Datos)')
if P_model is not None: ax3.plot(time_model, P_model, color=model_colors['Producto'], linestyle=plot_config['line_style'], label='Producto (Modelo)')
ax3.tick_params(axis='y', labelcolor=colors['Producto'])
# Leyenda unificada
if plot_config['show_legend']:
h1, l1 = ax1.get_legend_handles_labels()
h2, l2 = ax2.get_legend_handles_labels()
h3, l3 = ax3.get_legend_handles_labels()
ax1.legend(h1 + h2 + h3, l1 + l2 + l3, loc=plot_config['legend_pos'])
# Caja de parámetros combinada
if plot_config['show_params']:
texts = []
for comp, label in [('biomass', 'Biomasa'), ('substrate', 'Sustrato'), ('product', 'Producto')]:
if comp in self.params:
p_text = '\n '.join([f"{k} = {v:.3g}" for k, v in self.params[comp].items()])
r2 = self.r2.get(comp, np.nan)
rmse = self.rmse.get(comp, np.nan)
texts.append(f"{label}:\n {p_text}\n R²={r2:.3f}, RMSE={rmse:.3f}")
full_text = "\n\n".join(texts)
pos_x, ha = (1.25, 'left') if plot_config['params_pos'] == 'outside right' else (0.05, 'left')
pos_y, va = (0.95, 'top')
ax1.text(pos_x, pos_y, full_text, transform=ax1.transAxes, fontsize=9,
verticalalignment=va, horizontalalignment=ha,
bbox=dict(boxstyle='round,pad=0.5', fc='wheat', alpha=0.7))
plt.tight_layout()
if plot_config['params_pos'] == 'outside right': fig.subplots_adjust(right=0.75)
buf = io.BytesIO()
fig.savefig(buf, format='png', bbox_inches='tight')
plt.close(fig)
buf.seek(0)
return Image.open(buf).convert("RGB")
# --- FUNCIÓN PRINCIPAL DE PROCESAMIENTO ---
# Orquesta la lectura de datos, el ajuste de modelos y la generación de salidas.
def run_analysis(file, selected_models, analysis_mode, exp_names_str, plot_settings):
if file is None:
return [], pd.DataFrame(), "Error: Por favor, sube un archivo Excel.", pd.DataFrame()
if not selected_models:
return [], pd.DataFrame(), "Error: Por favor, selecciona al menos un modelo.", pd.DataFrame()
try:
xls = pd.ExcelFile(file.name)
sheet_names = xls.sheet_names
except Exception as e:
return [], pd.DataFrame(), f"Error al leer el archivo: {e}", pd.DataFrame()
all_figures = []
all_results_data = []
messages = []
exp_names_list = [name.strip() for name in exp_names_str.split('\n') if name.strip()]
for i, sheet_name in enumerate(sheet_names):
exp_name_base = exp_names_list[i] if i < len(exp_names_list) else f"Hoja '{sheet_name}'"
try:
df = pd.read_excel(xls, sheet_name=sheet_name, header=[0, 1])
model_for_sheet = BioprocessModel()
model_for_sheet.process_data_from_df(df)
except Exception as e:
messages.append(f"Error procesando '{sheet_name}': {e}")
continue
# Lógica para modos 'average' y 'combinado'
if analysis_mode in ['average', 'combinado']:
if model_for_sheet.data_biomass_mean is None or len(model_for_sheet.data_biomass_mean) == 0:
messages.append(f"No hay datos de biomasa promedio en '{sheet_name}' para analizar.")
continue
for model_type in selected_models:
model_instance = BioprocessModel(model_type=model_type, maxfev=plot_settings['maxfev'])
model_instance.fit_all_models(
model_for_sheet.data_time,
model_for_sheet.data_biomass_mean,
model_for_sheet.data_substrate_mean,
model_for_sheet.data_product_mean
)
# Recopilar resultados para la tabla
result_row = {'Experimento': f"{exp_name_base} (Promedio)", 'Modelo': model_type.capitalize()}
for comp in ['biomass', 'substrate', 'product']:
if comp in model_instance.params:
for p_name, p_val in model_instance.params[comp].items():
result_row[f'{comp.capitalize()}_{p_name}'] = p_val
result_row[f'R2_{comp.capitalize()}'] = model_instance.r2.get(comp)
result_row[f'RMSE_{comp.capitalize()}'] = model_instance.rmse.get(comp)
all_results_data.append(result_row)
# Generar gráfico
current_plot_settings = plot_settings.copy()
current_plot_settings.update({
'exp_name': f"{exp_name_base} (Promedio)",
'time_exp': model_for_sheet.data_time,
'biomass_exp': model_for_sheet.data_biomass_mean, 'biomass_std': model_for_sheet.data_biomass_std,
'substrate_exp': model_for_sheet.data_substrate_mean, 'substrate_std': model_for_sheet.data_substrate_std,
'product_exp': model_for_sheet.data_product_mean, 'product_std': model_for_sheet.data_product_std,
})
plot_func = model_instance.plot_combined_results if analysis_mode == 'combinado' else model_instance.plot_results
fig = plot_func(current_plot_settings)
if fig: all_figures.append(fig)
# Lógica para modo 'independent'
elif analysis_mode == 'independent':
# ... (Lógica similar, iterando sobre las columnas de nivel 0 del DataFrame)
# Esta parte se omite por brevedad pero seguiría una estructura parecida a la original,
# llamando a fit_all_models y plot_results para cada experimento individual.
messages.append("El modo 'independent' aún no está completamente reimplementado en esta versión mejorada.")
final_message = "Análisis completado."
if messages:
final_message += " Mensajes:\n" + "\n".join(messages)
results_df = pd.DataFrame(all_results_data)
# Reordenar columnas para mejor legibilidad
if not results_df.empty:
id_cols = ['Experimento', 'Modelo']
param_cols = sorted([c for c in results_df.columns if '_' in c and 'R2' not in c and 'RMSE' not in c])
metric_cols = sorted([c for c in results_df.columns if 'R2' in c or 'RMSE' in c])
results_df = results_df[id_cols + param_cols + metric_cols]
return all_figures, results_df, final_message, results_df
# --- INTERFAZ DE USUARIO CON GRADIO ---
MODEL_CHOICES = [
("Logístico (3 parámetros)", "logistic"),
("Gompertz (3 parámetros)", "gompertz"),
("Moser (3 parámetros, simplificado)", "moser"),
("Baranyi (4 parámetros)", "baranyi")
]
def create_gradio_interface():
with gr.Blocks(theme=gr.themes.Soft(primary_hue="blue", secondary_hue="sky")) as demo:
gr.Markdown("# 🔬 Analizador de Cinéticas de Bioprocesos")
gr.Markdown("Sube tus datos experimentales, selecciona los modelos a ajustar y visualiza los resultados.")
with gr.Tabs() as tabs:
with gr.TabItem("1. Teoría y Modelos", id=0):
gr.Markdown(r"""
### Modelos de Crecimiento de Biomasa
Esta herramienta ajusta los datos de crecimiento de biomasa a varios modelos matemáticos comunes:
- **Logístico (3p: $X_0, X_m, \mu_m$):** Modelo sigmoidal clásico que describe el crecimiento con una capacidad de carga.
$$ X(t) = \frac{X_0 X_m e^{\mu_m t}}{X_m - X_0 + X_0 e^{\mu_m t}} $$
- **Gompertz (3p: $X_m, \mu_m, \lambda$):** Modelo sigmoidal asimétrico, a menudo usado en microbiología.
$$ X(t) = X_m \exp\left(-\exp\left(\frac{\mu_m e}{X_m}(\lambda-t)+1\right)\right) $$
- **Moser (3p: $X_m, \mu_m, K_s$):** Forma simplificada no dependiente de sustrato usada aquí.
$$ X(t)=X_m(1-e^{-\mu_m(t-K_s)}) $$
- **Baranyi (4p: $X_0, X_m, \mu_m, \lambda$):** Modelo mecanicista que separa la fase de latencia del crecimiento exponencial.
$$ \frac{dy}{dt} = \frac{Q(t)}{1+Q(t)}\mu_{max}\left(1-\frac{y(t)}{y_{max}}\right)y(t) $$
Donde $y = \ln(X)$, y $Q(t)$ modela el estado fisiológico de las células.
### Modelos de Sustrato y Producto
El consumo de sustrato (S) y la formación de producto (P) se modelan con la ecuación de **Luedeking-Piret**:
$$ \frac{dS}{dt} = -p \frac{dX}{dt} - q X \quad ; \quad \frac{dP}{dt} = \alpha \frac{dX}{dt} + \beta X $$
- $\alpha, p$: Coeficientes asociados al crecimiento.
- $\beta, q$: Coeficientes no asociados al crecimiento (mantenimiento).
""")
with gr.TabItem("2. Configuración de Simulación", id=1):
with gr.Row():
with gr.Column(scale=2):
file_input = gr.File(label="Sube tu archivo Excel (.xlsx)", file_types=['.xlsx'])
exp_names_input = gr.Textbox(
label="Nombres de Experimentos/Hojas (opcional, uno por línea)",
placeholder="Nombre para Hoja 1\nNombre para Hoja 2\n...",
lines=3,
info="Si se deja en blanco, se usarán los nombres de las hojas del archivo."
)
with gr.Column(scale=3):
gr.Markdown("**Configuración Principal**")
model_selection_input = gr.CheckboxGroup(choices=MODEL_CHOICES, label="Modelos de Biomasa a Probar", value=["logistic", "baranyi"])
analysis_mode_input = gr.Radio(["average", "combinado"], label="Modo de Análisis", value="average",
info="Average: Gráficos separados por componente. Combinado: Un solo gráfico con 3 ejes Y.")
use_differential_input = gr.Checkbox(label="Usar Ecuaciones Diferenciales (EDO) para graficar", value=False,
info="Si se marca, las curvas se generan resolviendo las EDO. Si no, se usa el ajuste analítico. Requiere que el modelo tenga EDO implementada.")
with gr.Accordion("Opciones de Gráfico y Ajuste", open=False):
with gr.Row():
style_input = gr.Dropdown(['whitegrid', 'darkgrid', 'white', 'dark', 'ticks'], label="Estilo de Gráfico", value='whitegrid')
line_color_input = gr.ColorPicker(label="Color de Línea (Modelo)", value='#0072B2')
point_color_input = gr.ColorPicker(label="Color de Puntos (Datos)", value='#D55E00')
with gr.Row():
line_style_input = gr.Dropdown(['-', '--', '-.', ':'], label="Estilo de Línea", value='-')
marker_style_input = gr.Dropdown(['o', 's', '^', 'D', 'x'], label="Estilo de Marcador", value='o')
maxfev_input = gr.Number(label="Iteraciones de ajuste (maxfev)", value=50000, minimum=1000)
with gr.Row():
show_legend_input = gr.Checkbox(label="Mostrar Leyenda", value=True)
legend_pos_input = gr.Radio(["best", "upper left", "upper right", "lower left", "lower right"], label="Posición Leyenda", value="best")
with gr.Row():
show_params_input = gr.Checkbox(label="Mostrar Parámetros", value=True)
params_pos_input = gr.Radio(["upper right", "upper left", "lower right", "lower left", "outside right"], label="Posición Parámetros", value="upper right")
with gr.Row():
show_error_bars_input = gr.Checkbox(label="Mostrar barras de error (si hay réplicas)", value=True)
error_cap_size_input = gr.Slider(1, 10, 3, step=1, label="Tamaño Tapa Error")
error_line_width_input = gr.Slider(0.5, 5, 1.0, step=0.5, label="Grosor Línea Error")
with gr.Accordion("Títulos de los Ejes", open=False):
with gr.Row():
xlabel_input = gr.Textbox("Tiempo (h)", label="Eje X")
ylabel_bio_input = gr.Textbox("Biomasa (g/L)", label="Eje Y - Biomasa")
ylabel_sub_input = gr.Textbox("Sustrato (g/L)", label="Eje Y - Sustrato")
ylabel_prod_input = gr.Textbox("Producto (g/L)", label="Eje Y - Producto")
simulate_btn = gr.Button("Analizar y Graficar", variant="primary", scale=1)
with gr.TabItem("3. Resultados", id=2):
status_output = gr.Textbox(label="Estado del Análisis", interactive=False)
gallery_output = gr.Gallery(label="Gráficos Generados", columns=[1], height='auto', object_fit="contain")
gr.Markdown("### Tabla de Parámetros y Métricas de Ajuste")
table_output = gr.Dataframe(label="Resultados Detallados", wrap=True, interactive=False)
# Componente 'State' para guardar el dataframe para exportación
df_for_export = gr.State(pd.DataFrame())
with gr.Row():
export_excel_btn = gr.Button("Exportar a Excel (.xlsx)")
export_csv_btn = gr.Button("Exportar a CSV (.csv)")
download_output = gr.File(label="Descargar Archivo", interactive=False)
# Lógica de los botones
def simulation_wrapper(file, models, mode, names, use_diff, style, lc, pc, ls, ms, maxfev, s_leg, l_pos, s_par, p_pos, s_err, cap, lw, xl, yl_b, yl_s, yl_p):
plot_settings = {
'use_differential': use_diff, 'style': style,
'line_color': lc, 'point_color': pc, 'line_style': ls, 'marker_style': ms,
'maxfev': int(maxfev), 'show_legend': s_leg, 'legend_pos': l_pos,
'show_params': s_par, 'params_pos': p_pos,
'show_error_bars': s_err, 'error_cap_size': cap, 'error_line_width': lw,
'axis_labels': {'x_label': xl, 'biomass_label': yl_b, 'substrate_label': yl_s, 'product_label': yl_p}
}
return run_analysis(file, models, mode, names, plot_settings)
simulate_btn.click(
fn=simulation_wrapper,
inputs=[
file_input, model_selection_input, analysis_mode_input, exp_names_input, use_differential_input,
style_input, line_color_input, point_color_input, line_style_input, marker_style_input, maxfev_input,
show_legend_input, legend_pos_input, show_params_input, params_pos_input,
show_error_bars_input, error_cap_size_input, error_line_width_input,
xlabel_input, ylabel_bio_input, ylabel_sub_input, ylabel_prod_input
],
outputs=[gallery_output, table_output, status_output, df_for_export]
)
def export_to_file(df, file_format):
if df is None or df.empty:
gr.Warning("No hay datos en la tabla para exportar.")
return None
suffix = ".xlsx" if file_format == "excel" else ".csv"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmpfile:
if file_format == "excel":
df.to_excel(tmpfile.name, index=False)
else:
df.to_csv(tmpfile.name, index=False, encoding='utf-8-sig')
return tmpfile.name
export_excel_btn.click(fn=lambda df: export_to_file(df, "excel"), inputs=[df_for_export], outputs=[download_output])
export_csv_btn.click(fn=lambda df: export_to_file(df, "csv"), inputs=[df_for_export], outputs=[download_output])
return demo
if __name__ == '__main__':
gradio_app = create_gradio_interface()
gradio_app.launch(share=True, debug=True) |