C2MV commited on
Commit
2a68b16
·
verified ·
1 Parent(s): 01b1e82

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +710 -705
app.py CHANGED
@@ -1,10 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
  import io
3
  import tempfile
4
- from PIL import Image
5
-
6
- os.system("pip install --upgrade gradio")
 
 
7
 
 
 
 
8
  import numpy as np
9
  import pandas as pd
10
  import matplotlib.pyplot as plt
@@ -12,736 +35,718 @@ import seaborn as sns
12
  from scipy.integrate import odeint
13
  from scipy.optimize import curve_fit
14
  from sklearn.metrics import mean_squared_error
15
- import gradio as gr
16
-
17
-
18
- # --- CLASE PRINCIPAL DEL MODELO DE BIOPROCESO ---
19
- # Contiene toda la lógica matemática, ajuste y graficación.
20
-
21
- class BioprocessModel:
22
- """
23
- Clase para modelar, ajustar y simular cinéticas de bioprocesos.
24
- Incluye modelos para crecimiento de biomasa, consumo de sustrato y formación de producto.
25
- """
26
- def __init__(self, model_type='logistic', maxfev=50000):
27
- self.model_type = model_type
28
- self.maxfev = maxfev
29
- self.params = {}
30
- self.r2 = {}
31
- self.rmse = {}
32
- self.data_time = None
33
- self.data_biomass_mean = None
34
- self.data_substrate_mean = None
35
- self.data_product_mean = None
36
- self.data_biomass_std = None
37
- self.data_substrate_std = None
38
- self.data_product_std = None
39
- self.biomass_model_func = None # Función del modelo analítico (ej. logistic)
40
- self.biomass_diff_func = None # Función de la ecuación diferencial (ej. logistic_diff)
41
-
42
- # --- Modelos Analíticos de Biomasa ---
43
-
44
- @staticmethod
45
- def logistic(time, xo, xm, um):
46
- # Salvaguardas para evitar errores matemáticos
47
- if xm <= 0 or xo <= 0 or xm <= xo:
48
- return np.full_like(time, np.nan)
49
- # Previene división por cero y logaritmo de cero en casos extremos
50
- term_exp = np.exp(um * time)
51
- denominator = (1 - (xo / xm) * (1 - term_exp))
52
- # Si el denominador es cero, reemplázalo por un número pequeño para evitar error
53
- denominator = np.where(denominator == 0, 1e-9, denominator)
54
  return (xo * term_exp) / denominator
55
-
56
- @staticmethod
57
- def gompertz(time, xm, um, lag):
58
- # Salvaguardas
59
- if xm <= 0 or um <= 0:
60
- return np.full_like(time, np.nan)
61
- # Previene overflow en np.exp
62
- exp_term = (um * np.e / xm) * (lag - time) + 1
63
- exp_term_clipped = np.clip(exp_term, -np.inf, 700) # exp(709) es aprox. el float máximo
64
- return xm * np.exp(-np.exp(exp_term_clipped))
65
-
66
- @staticmethod
67
- def moser(time, Xm, um, Ks):
68
- # Forma simplificada, no dependiente de sustrato
69
- if Xm <= 0 or um <= 0:
70
- return np.full_like(time, np.nan)
71
- return Xm * (1 - np.exp(-um * (time - Ks)))
72
-
73
- @staticmethod
74
- def baranyi(time, X0, Xm, um, lag):
75
- # Salvaguardas
76
- if X0 <= 0 or Xm <= X0 or um <= 0 or lag < 0:
77
- return np.full_like(time, np.nan)
78
-
79
- # Argumento del logaritmo en A(t), previene valores no positivos
80
- log_arg_A = np.exp(-um * time) + np.exp(-um * lag) - np.exp(-um * (t + lag))
81
- log_arg_A = np.where(log_arg_A <= 1e-9, 1e-9, log_arg_A)
82
- A_t = time + (1 / um) * np.log(log_arg_A)
83
-
84
- # Previene overflow
85
- exp_um_At = np.exp(um * A_t)
86
- exp_um_At_clipped = np.clip(exp_um_At, -np.inf, 700)
87
-
88
- numerator = (Xm / X0) * exp_um_At_clipped
89
- denominator = (Xm / X0 - 1) + exp_um_At_clipped
90
- denominator = np.where(denominator == 0, 1e-9, denominator)
91
-
92
- return X0 * (numerator / denominator)
93
-
94
- # --- Ecuaciones Diferenciales de Biomasa ---
95
-
96
- @staticmethod
97
- def logistic_diff(X, t, params):
98
- _, xm, um = params
99
- if xm <= 0: return 0
100
- return um * X * (1 - X / xm)
101
-
102
- @staticmethod
103
- def gompertz_diff(X, t, params):
104
  xm, um, lag = params
105
- if xm <= 0 or X <= 0: return 0
106
- # Forma derivada d(Gompertz)/dt
107
- k_val = um * np.e / xm
108
- u_val = k_val * (lag - t) + 1
109
- u_val_clipped = np.clip(u_val, -np.inf, 700) # Previene overflow
110
- return X * k_val * np.exp(u_val_clipped)
111
-
112
- @staticmethod
113
- def moser_diff(X, t, params):
114
- Xm, um, _ = params
115
- if Xm <=0: return 0
116
- return um * (Xm - X)
117
-
118
- # --- Modelos de Sustrato y Producto (Luedeking-Piret) ---
119
-
120
- def _get_biomass_at_t(self, time, biomass_params_list):
121
- if self.biomass_model_func is None or not biomass_params_list:
122
- return np.full_like(time, np.nan)
123
- X_t = self.biomass_model_func(time, *biomass_params_list)
124
- return X_t
125
-
126
- def _get_initial_biomass(self, biomass_params_list):
127
- if self.model_type in ['logistic', 'baranyi']:
128
- return biomass_params_list[0]
129
- elif self.model_type in ['gompertz', 'moser']:
130
- return self.biomass_model_func(0, *biomass_params_list)
131
- return 0
132
-
133
- def substrate(self, time, so, p, q, biomass_params_list):
134
- X_t = self._get_biomass_at_t(time, biomass_params_list)
135
- if np.any(np.isnan(X_t)): return np.full_like(time, np.nan)
136
-
137
- integral_X = np.zeros_like(X_t)
138
- if len(time) > 1:
139
- dt = np.diff(time, prepend=time[0] - (time[1]-time[0] if len(time)>1 else 1))
140
- integral_X = np.cumsum(X_t * dt)
141
-
142
- X0 = self._get_initial_biomass(biomass_params_list)
143
- return so - p * (X_t - X0) - q * integral_X
144
-
145
- def product(self, time, po, alpha, beta, biomass_params_list):
146
- X_t = self._get_biomass_at_t(time, biomass_params_list)
147
- if np.any(np.isnan(X_t)): return np.full_like(time, np.nan)
148
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  integral_X = np.zeros_like(X_t)
150
- if len(time) > 1:
151
- dt = np.diff(time, prepend=time[0] - (time[1]-time[0] if len(time)>1 else 1))
152
  integral_X = np.cumsum(X_t * dt)
153
-
154
- X0 = self._get_initial_biomass(biomass_params_list)
155
- return po + alpha * (X_t - X0) + beta * integral_X
156
-
157
- # --- Procesamiento de Datos ---
158
-
159
- def process_data_from_df(self, df):
 
160
  try:
161
- time_col = [col for col in df.columns if col[1] == 'Tiempo'][0]
162
- self.data_time = df[time_col].dropna().values
163
- min_len = len(self.data_time)
164
-
165
- def extract_mean_std(component_name):
166
- cols = [col for col in df.columns if col[1] == component_name]
167
  if not cols: return np.array([]), np.array([])
168
-
169
- data_reps = [df[col].dropna().values for col in cols]
170
- # Alinea las réplicas con la longitud del tiempo
171
- aligned_reps = [rep for rep in data_reps if len(rep) == min_len]
172
-
173
- if not aligned_reps: return np.array([]), np.array([])
174
-
175
- data_np = np.array(aligned_reps)
176
- mean_vals = np.mean(data_np, axis=0)
177
- std_vals = np.std(data_np, axis=0, ddof=1) if data_np.shape[0] > 1 else np.zeros_like(mean_vals)
178
- return mean_vals, std_vals
179
-
180
- self.data_biomass_mean, self.data_biomass_std = extract_mean_std('Biomasa')
181
- self.data_substrate_mean, self.data_substrate_std = extract_mean_std('Sustrato')
182
- self.data_product_mean, self.data_product_std = extract_mean_std('Producto')
183
-
184
- except (IndexError, KeyError) as e:
185
- raise ValueError(f"El DataFrame no tiene la estructura esperada (columnas 'Tiempo', 'Biomasa', etc.). Error: {e}")
186
-
187
- # --- Lógica de Ajuste de Modelos (Curve Fitting) ---
188
-
189
- def set_model_functions(self):
190
- model_map = {
191
- 'logistic': (self.logistic, self.logistic_diff),
192
- 'gompertz': (self.gompertz, self.gompertz_diff),
193
- 'moser': (self.moser, self.moser_diff),
194
- 'baranyi': (self.baranyi, None) # Baranyi no tiene una EDO simple implementada
195
- }
196
- if self.model_type in model_map:
197
- self.biomass_model_func, self.biomass_diff_func = model_map[self.model_type]
198
- else:
199
- raise ValueError(f"Modelo de biomasa desconocido: {self.model_type}")
200
-
201
- def _fit_component(self, fit_func, time, data, initial_guesses, bounds, *args):
202
  try:
203
- popt, _ = curve_fit(fit_func, time, data, p0=initial_guesses, bounds=bounds, maxfev=self.maxfev, ftol=1e-9, xtol=1e-9)
204
- y_pred = fit_func(time, *popt, *args)
205
-
206
- if np.any(np.isnan(y_pred)) or np.any(np.isinf(y_pred)):
207
- return None, None, np.nan, np.nan
208
-
209
- ss_res = np.sum((data - y_pred) ** 2)
210
- ss_tot = np.sum((data - np.mean(data)) ** 2)
211
- r2 = 1 - (ss_res / ss_tot) if ss_tot > 0 else 1.0
212
- rmse = np.sqrt(mean_squared_error(data, y_pred))
213
- return popt, y_pred, r2, rmse
214
- except (RuntimeError, ValueError) as e:
215
- print(f"Error en curve_fit para {fit_func.__name__}: {e}")
216
- return None, None, np.nan, np.nan
217
-
218
- def fit_all_models(self, time, biomass, substrate, product):
219
- self.set_model_functions()
220
-
221
- # 1. Ajustar Biomasa
222
- y_pred_biomass = None
223
- if biomass is not None and len(biomass) > 0:
224
- popt_bio = self._fit_biomass_model(time, biomass)
225
- if popt_bio is not None:
226
- y_pred_biomass = self.biomass_model_func(time, *popt_bio)
227
-
228
- # 2. Ajustar Sustrato y Producto (si biomasa se ajustó correctamente)
229
- y_pred_substrate, y_pred_product = None, None
230
- if 'biomass' in self.params and self.params['biomass']:
231
- biomass_popt_list = list(self.params['biomass'].values())
232
- if substrate is not None and len(substrate) > 0:
233
- self._fit_substrate_model(time, substrate, biomass_popt_list)
234
- if 'substrate' in self.params and self.params['substrate']:
235
- substrate_popt = list(self.params['substrate'].values())
236
- y_pred_substrate = self.substrate(time, *substrate_popt, biomass_popt_list)
237
-
238
- if product is not None and len(product) > 0:
239
- self._fit_product_model(time, product, biomass_popt_list)
240
- if 'product' in self.params and self.params['product']:
241
- product_popt = list(self.params['product'].values())
242
- y_pred_product = self.product(time, *product_popt, biomass_popt_list)
243
-
244
- return y_pred_biomass, y_pred_substrate, y_pred_product
245
-
246
- def _fit_biomass_model(self, time, biomass):
247
- # Estimaciones iniciales y límites para cada modelo
248
- param_configs = {
249
- 'logistic': {
250
- 'p0': [biomass[0] if biomass[0] > 1e-6 else 1e-3, max(biomass), 0.1],
251
- 'bounds': ([1e-9, max(1e-9, biomass[0]), 1e-9], [max(biomass), np.inf, np.inf]),
252
- 'keys': ['Xo', 'Xm', 'um']
253
- },
254
- 'gompertz': {
255
- 'p0': [max(biomass), 0.1, time[np.argmax(np.gradient(biomass))] if len(biomass)>1 else 0],
256
- 'bounds': ([max(1e-9, min(biomass)), 1e-9, 0], [np.inf, np.inf, max(time) or 1]),
257
- 'keys': ['Xm', 'um', 'lag']
258
- },
259
- 'moser': {
260
- 'p0': [max(biomass), 0.1, 0],
261
- 'bounds': ([max(1e-9, min(biomass)), 1e-9, -np.inf], [np.inf, np.inf, np.inf]),
262
- 'keys': ['Xm', 'um', 'Ks']
263
- },
264
- 'baranyi': {
265
- '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],
266
- 'bounds': ([1e-9, max(1e-9, biomass[0]), 1e-9, 0], [max(biomass), np.inf, np.inf, max(time) or 1]),
267
- 'keys': ['X0', 'Xm', 'um', 'lag']
268
- }
269
- }
270
- config = param_configs[self.model_type]
271
- popt, _, r2, rmse = self._fit_component(self.biomass_model_func, time, biomass, config['p0'], config['bounds'])
272
-
273
- if popt is not None:
274
- self.params['biomass'] = dict(zip(config['keys'], popt))
275
- self.r2['biomass'] = r2
276
- self.rmse['biomass'] = rmse
277
  return popt
278
-
279
- def _fit_substrate_model(self, time, substrate, biomass_popt_list):
280
- p0 = [substrate[0] if len(substrate) > 0 else 1.0, 0.1, 0.01]
281
- bounds = ([0, -np.inf, -np.inf], [np.inf, np.inf, np.inf]) # p, q pueden ser negativos
282
- fit_func = lambda t, so, p, q: self.substrate(t, so, p, q, biomass_popt_list)
283
- popt, _, r2, rmse = self._fit_component(fit_func, time, substrate, p0, bounds)
284
- if popt is not None:
285
- self.params['substrate'] = {'So': popt[0], 'p': popt[1], 'q': popt[2]}
286
- self.r2['substrate'] = r2
287
- self.rmse['substrate'] = rmse
288
- return popt
289
-
290
- def _fit_product_model(self, time, product, biomass_popt_list):
291
- p0 = [product[0] if len(product) > 0 else 0.0, 0.1, 0.01]
292
- bounds = ([0, -np.inf, -np.inf], [np.inf, np.inf, np.inf]) # alpha, beta pueden ser negativos
293
- fit_func = lambda t, po, alpha, beta: self.product(t, po, alpha, beta, biomass_popt_list)
294
- popt, _, r2, rmse = self._fit_component(fit_func, time, product, p0, bounds)
295
- if popt is not None:
296
- self.params['product'] = {'Po': popt[0], 'alpha': popt[1], 'beta': popt[2]}
297
- self.r2['product'] = r2
298
- self.rmse['product'] = rmse
299
- return popt
300
-
301
- # --- Lógica de Ecuaciones Diferenciales (ODE) ---
302
-
303
- def system_ode(self, y, t, biomass_params_list, substrate_params_list, product_params_list):
304
- X, S, P = y
305
-
306
- # dX/dt
307
- dXdt = self.biomass_diff_func(X, t, biomass_params_list) if self.biomass_diff_func else 0.0
308
-
309
- # dS/dt
310
- p_val = substrate_params_list[1] if len(substrate_params_list) > 1 else 0
311
- q_val = substrate_params_list[2] if len(substrate_params_list) > 2 else 0
312
- dSdt = -p_val * dXdt - q_val * X
313
-
314
- # dP/dt
315
- alpha_val = product_params_list[1] if len(product_params_list) > 1 else 0
316
- beta_val = product_params_list[2] if len(product_params_list) > 2 else 0
317
- dPdt = alpha_val * dXdt + beta_val * X
318
-
319
- return [dXdt, dSdt, dPdt]
320
-
321
- def solve_odes(self, time_fine):
322
- if not self.biomass_diff_func:
323
- print(f"Resolución de EDO no soportada para el modelo {self.model_type}.")
324
- return None, None, None
325
-
326
- # Reune los parámetros necesarios
327
  try:
328
- bio_params = list(self.params['biomass'].values())
329
- sub_params = list(self.params.get('substrate', {}).values())
330
- prod_params = list(self.params.get('product', {}).values())
331
-
332
- # Condiciones iniciales de las EDO
333
- X0 = self._get_initial_biomass(bio_params)
334
- S0 = self.params.get('substrate', {}).get('So', 0)
335
- P0 = self.params.get('product', {}).get('Po', 0)
336
- initial_conditions = [X0, S0, P0]
337
-
338
- sol = odeint(self.system_ode, initial_conditions, time_fine,
339
- args=(bio_params, sub_params, prod_params), rtol=1e-6, atol=1e-6)
340
-
341
  return sol[:, 0], sol[:, 1], sol[:, 2]
342
- except (KeyError, IndexError, Exception) as e:
343
- print(f"Error preparando o resolviendo EDOs: {e}")
344
- return None, None, None
345
-
346
- # --- Generación de Gráficos ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
 
348
- def _generate_fine_time_grid(self, time_exp):
349
- if time_exp is None or len(time_exp) < 2:
350
- return np.array([])
351
- return np.linspace(np.min(time_exp), np.max(time_exp), 500)
352
-
353
- def plot_results(self, plot_config):
354
- use_differential = plot_config['use_differential']
355
- time_exp = plot_config['time_exp']
356
-
357
- time_fine = self._generate_fine_time_grid(time_exp)
358
-
359
- # Determina qué datos de modelo mostrar: EDO o ajuste directo
360
- if use_differential and self.biomass_diff_func:
361
- X_model, S_model, P_model = self.solve_odes(time_fine)
362
- time_model = time_fine
363
  else:
364
- if use_differential and not self.biomass_diff_func:
365
- print(f"Advertencia: EDO no soportada para {self.model_type}. Mostrando ajuste directo.")
366
 
367
- # Recalcula las curvas del modelo en la malla fina
368
- X_model, S_model, P_model = None, None, None
369
- time_model = time_fine
370
- if 'biomass' in self.params:
371
- bio_p = list(self.params['biomass'].values())
372
- X_model = self.biomass_model_func(time_model, *bio_p)
373
- if 'substrate' in self.params:
374
- sub_p = list(self.params['substrate'].values())
375
- S_model = self.substrate(time_model, *sub_p, bio_p)
376
- if 'product' in self.params:
377
- prod_p = list(self.params['product'].values())
378
- P_model = self.product(time_model, *prod_p, bio_p)
379
-
380
- # Configuración del gráfico
381
- sns.set_style(plot_config['style'])
382
- fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(10, 15), sharex=True)
383
- fig.suptitle(f"{plot_config['exp_name']} ({self.model_type.capitalize()})", fontsize=16)
384
-
385
- plot_details = [
386
- (ax1, 'biomass', plot_config['axis_labels']['biomass_label'], plot_config['biomass_exp'], plot_config['biomass_std'], X_model),
387
- (ax2, 'substrate', plot_config['axis_labels']['substrate_label'], plot_config['substrate_exp'], plot_config['substrate_std'], S_model),
388
- (ax3, 'product', plot_config['axis_labels']['product_label'], plot_config['product_exp'], plot_config['product_std'], P_model)
389
- ]
390
-
391
- for ax, comp_name, ylabel, data_exp, data_std, data_model in plot_details:
392
- # Graficar datos experimentales
393
- if data_exp is not None and len(data_exp) > 0:
394
- if plot_config['show_error_bars'] and data_std is not None and len(data_std) > 0:
395
- ax.errorbar(time_exp, data_exp, yerr=data_std, fmt=plot_config['marker_style'], color=plot_config['point_color'],
396
- label='Datos Exp.', capsize=plot_config['error_cap_size'], elinewidth=plot_config['error_line_width'])
397
- else:
398
- ax.plot(time_exp, data_exp, linestyle='', marker=plot_config['marker_style'], color=plot_config['point_color'], label='Datos Exp.')
399
-
400
- # Graficar modelo
401
- if data_model is not None and len(data_model) > 0 and not np.all(np.isnan(data_model)):
402
- ax.plot(time_model, data_model, linestyle=plot_config['line_style'], color=plot_config['line_color'], label='Modelo')
403
-
404
- # Etiquetas y Títulos
405
- ax.set_ylabel(ylabel)
406
- ax.set_title(ylabel)
407
- if plot_config['show_legend']: ax.legend(loc=plot_config['legend_pos'])
408
-
409
- # Caja de parámetros
410
- if plot_config['show_params'] and comp_name in self.params:
411
- param_text = '\n'.join([f"{k} = {v:.3g}" for k, v in self.params[comp_name].items()])
412
- r2_text = f"R² = {self.r2.get(comp_name, np.nan):.3f}"
413
- rmse_text = f"RMSE = {self.rmse.get(comp_name, np.nan):.3f}"
414
- full_text = f"{param_text}\n{r2_text}\n{rmse_text}"
415
-
416
- pos_x, ha = (0.95, 'right') if 'right' in plot_config['params_pos'] else (0.05, 'left')
417
- pos_y, va = (0.95, 'top') if 'upper' in plot_config['params_pos'] else (0.05, 'bottom')
418
- ax.text(pos_x, pos_y, full_text, transform=ax.transAxes, verticalalignment=va, horizontalalignment=ha,
419
- bbox={'boxstyle': 'round,pad=0.3', 'facecolor':'wheat', 'alpha':0.6})
420
-
421
- ax3.set_xlabel(plot_config['axis_labels']['x_label'])
422
- plt.tight_layout(rect=[0, 0.03, 1, 0.95])
423
-
424
- # Convertir figura a imagen
425
- buf = io.BytesIO()
426
- fig.savefig(buf, format='png', bbox_inches='tight')
427
- plt.close(fig)
428
- buf.seek(0)
429
- return Image.open(buf).convert("RGB")
430
-
431
- def plot_combined_results(self, plot_config):
432
- # Lógica de cálculo de modelo similar a plot_results
433
- use_differential = plot_config['use_differential']
434
- time_exp = plot_config['time_exp']
435
- time_fine = self._generate_fine_time_grid(time_exp)
436
-
437
- if use_differential and self.biomass_diff_func:
438
- X_model, S_model, P_model = self.solve_odes(time_fine)
439
- time_model = time_fine
440
- else: #... (código idéntico a plot_results para cálculo de modelo)
441
- X_model, S_model, P_model = None, None, None; time_model = time_fine
442
- if 'biomass' in self.params:
443
- bio_p = list(self.params['biomass'].values()); X_model = self.biomass_model_func(time_model, *bio_p)
444
- if 'substrate' in self.params: S_model = self.substrate(time_model, *list(self.params['substrate'].values()), bio_p)
445
- if 'product' in self.params: P_model = self.product(time_model, *list(self.params['product'].values()), bio_p)
446
-
447
- # Colores fijos para claridad en el gráfico combinado
448
- colors = {'Biomasa': '#0072B2', 'Sustrato': '#009E73', 'Producto': '#D55E00'}
449
- model_colors = {'Biomasa': '#56B4E9', 'Sustrato': '#34E499', 'Producto': '#F0E442'}
450
-
451
- sns.set_style(plot_config['style'])
452
- fig, ax1 = plt.subplots(figsize=(12, 7))
453
- fig.suptitle(f"{plot_config['exp_name']} ({self.model_type.capitalize()})", fontsize=16)
454
-
455
- # Eje 1: Biomasa
456
- ax1.set_xlabel(plot_config['axis_labels']['x_label'])
457
- ax1.set_ylabel(plot_config['axis_labels']['biomass_label'], color=colors['Biomasa'])
458
- if plot_config['biomass_exp'] is not None and len(plot_config['biomass_exp']) > 0:
459
- ax1.plot(time_exp, plot_config['biomass_exp'], marker=plot_config['marker_style'], linestyle='', color=colors['Biomasa'], label='Biomasa (Datos)')
460
- if X_model is not None: ax1.plot(time_model, X_model, color=model_colors['Biomasa'], linestyle=plot_config['line_style'], label='Biomasa (Modelo)')
461
- ax1.tick_params(axis='y', labelcolor=colors['Biomasa'])
462
-
463
- # Eje 2: Sustrato
464
- ax2 = ax1.twinx()
465
- ax2.set_ylabel(plot_config['axis_labels']['substrate_label'], color=colors['Sustrato'])
466
- if plot_config['substrate_exp'] is not None and len(plot_config['substrate_exp']) > 0:
467
- ax2.plot(time_exp, plot_config['substrate_exp'], marker=plot_config['marker_style'], linestyle='', color=colors['Sustrato'], label='Sustrato (Datos)')
468
- if S_model is not None: ax2.plot(time_model, S_model, color=model_colors['Sustrato'], linestyle=plot_config['line_style'], label='Sustrato (Modelo)')
469
- ax2.tick_params(axis='y', labelcolor=colors['Sustrato'])
470
-
471
- # Eje 3: Producto
472
- ax3 = ax1.twinx()
473
- ax3.spines["right"].set_position(("axes", 1.18))
474
- ax3.set_ylabel(plot_config['axis_labels']['product_label'], color=colors['Producto'])
475
- if plot_config['product_exp'] is not None and len(plot_config['product_exp']) > 0:
476
- ax3.plot(time_exp, plot_config['product_exp'], marker=plot_config['marker_style'], linestyle='', color=colors['Producto'], label='Producto (Datos)')
477
- if P_model is not None: ax3.plot(time_model, P_model, color=model_colors['Producto'], linestyle=plot_config['line_style'], label='Producto (Modelo)')
478
- ax3.tick_params(axis='y', labelcolor=colors['Producto'])
479
-
480
- # Leyenda unificada
481
- if plot_config['show_legend']:
482
- h1, l1 = ax1.get_legend_handles_labels()
483
- h2, l2 = ax2.get_legend_handles_labels()
484
- h3, l3 = ax3.get_legend_handles_labels()
485
- ax1.legend(h1 + h2 + h3, l1 + l2 + l3, loc=plot_config['legend_pos'])
486
-
487
- # Caja de parámetros combinada
488
- if plot_config['show_params']:
489
- texts = []
490
- for comp, label in [('biomass', 'Biomasa'), ('substrate', 'Sustrato'), ('product', 'Producto')]:
491
- if comp in self.params:
492
- p_text = '\n '.join([f"{k} = {v:.3g}" for k, v in self.params[comp].items()])
493
- r2 = self.r2.get(comp, np.nan)
494
- rmse = self.rmse.get(comp, np.nan)
495
- texts.append(f"{label}:\n {p_text}\n R²={r2:.3f}, RMSE={rmse:.3f}")
496
- full_text = "\n\n".join(texts)
497
- pos_x, ha = (1.25, 'left') if plot_config['params_pos'] == 'outside right' else (0.05, 'left')
498
- pos_y, va = (0.95, 'top')
499
- ax1.text(pos_x, pos_y, full_text, transform=ax1.transAxes, fontsize=9,
500
- verticalalignment=va, horizontalalignment=ha,
501
- bbox=dict(boxstyle='round,pad=0.5', fc='wheat', alpha=0.7))
502
-
503
- plt.tight_layout()
504
- if plot_config['params_pos'] == 'outside right': fig.subplots_adjust(right=0.75)
505
-
506
- buf = io.BytesIO()
507
- fig.savefig(buf, format='png', bbox_inches='tight')
508
- plt.close(fig)
509
- buf.seek(0)
510
- return Image.open(buf).convert("RGB")
511
-
512
 
513
- # --- FUNCIÓN PRINCIPAL DE PROCESAMIENTO ---
514
- # Orquesta la lectura de datos, el ajuste de modelos y la generación de salidas.
515
-
516
- def run_analysis(file, selected_models, analysis_mode, exp_names_str, plot_settings):
517
- if file is None:
518
- return [], pd.DataFrame(), "Error: Por favor, sube un archivo Excel.", pd.DataFrame()
519
- if not selected_models:
520
- return [], pd.DataFrame(), "Error: Por favor, selecciona al menos un modelo.", pd.DataFrame()
521
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
  try:
523
- xls = pd.ExcelFile(file.name)
524
- sheet_names = xls.sheet_names
 
 
 
 
 
 
 
 
 
 
 
525
  except Exception as e:
526
- return [], pd.DataFrame(), f"Error al leer el archivo: {e}", pd.DataFrame()
527
-
528
- all_figures = []
529
- all_results_data = []
530
- messages = []
531
- exp_names_list = [name.strip() for name in exp_names_str.split('\n') if name.strip()]
532
-
533
- for i, sheet_name in enumerate(sheet_names):
534
- exp_name_base = exp_names_list[i] if i < len(exp_names_list) else f"Hoja '{sheet_name}'"
535
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536
  try:
537
- df = pd.read_excel(xls, sheet_name=sheet_name, header=[0, 1])
538
- model_for_sheet = BioprocessModel()
539
- model_for_sheet.process_data_from_df(df)
540
- except Exception as e:
541
- messages.append(f"Error procesando '{sheet_name}': {e}")
542
- continue
543
-
544
- # Lógica para modos 'average' y 'combinado'
545
- if analysis_mode in ['average', 'combinado']:
546
- if model_for_sheet.data_biomass_mean is None or len(model_for_sheet.data_biomass_mean) == 0:
547
- messages.append(f"No hay datos de biomasa promedio en '{sheet_name}' para analizar.")
548
- continue
549
-
550
- for model_type in selected_models:
551
- model_instance = BioprocessModel(model_type=model_type, maxfev=plot_settings['maxfev'])
552
- model_instance.fit_all_models(
553
- model_for_sheet.data_time,
554
- model_for_sheet.data_biomass_mean,
555
- model_for_sheet.data_substrate_mean,
556
- model_for_sheet.data_product_mean
557
- )
558
-
559
- # Recopilar resultados para la tabla
560
- result_row = {'Experimento': f"{exp_name_base} (Promedio)", 'Modelo': model_type.capitalize()}
561
- for comp in ['biomass', 'substrate', 'product']:
562
- if comp in model_instance.params:
563
- for p_name, p_val in model_instance.params[comp].items():
564
- result_row[f'{comp.capitalize()}_{p_name}'] = p_val
565
- result_row[f'R2_{comp.capitalize()}'] = model_instance.r2.get(comp)
566
- result_row[f'RMSE_{comp.capitalize()}'] = model_instance.rmse.get(comp)
567
- all_results_data.append(result_row)
568
-
569
- # Generar gráfico
570
- current_plot_settings = plot_settings.copy()
571
- current_plot_settings.update({
572
- 'exp_name': f"{exp_name_base} (Promedio)",
573
- 'time_exp': model_for_sheet.data_time,
574
- 'biomass_exp': model_for_sheet.data_biomass_mean, 'biomass_std': model_for_sheet.data_biomass_std,
575
- 'substrate_exp': model_for_sheet.data_substrate_mean, 'substrate_std': model_for_sheet.data_substrate_std,
576
- 'product_exp': model_for_sheet.data_product_mean, 'product_std': model_for_sheet.data_product_std,
577
- })
578
-
579
- plot_func = model_instance.plot_combined_results if analysis_mode == 'combinado' else model_instance.plot_results
580
- fig = plot_func(current_plot_settings)
581
- if fig: all_figures.append(fig)
582
-
583
- # Lógica para modo 'independent'
584
- elif analysis_mode == 'independent':
585
- # ... (Lógica similar, iterando sobre las columnas de nivel 0 del DataFrame)
586
- # Esta parte se omite por brevedad pero seguiría una estructura parecida a la original,
587
- # llamando a fit_all_models y plot_results para cada experimento individual.
588
- messages.append("El modo 'independent' aún no está completamente reimplementado en esta versión mejorada.")
589
-
590
- final_message = "Análisis completado."
591
- if messages:
592
- final_message += " Mensajes:\n" + "\n".join(messages)
593
-
594
- results_df = pd.DataFrame(all_results_data)
595
- # Reordenar columnas para mejor legibilidad
596
- if not results_df.empty:
597
- id_cols = ['Experimento', 'Modelo']
598
- param_cols = sorted([c for c in results_df.columns if '_' in c and 'R2' not in c and 'RMSE' not in c])
599
- metric_cols = sorted([c for c in results_df.columns if 'R2' in c or 'RMSE' in c])
600
- results_df = results_df[id_cols + param_cols + metric_cols]
601
-
602
- return all_figures, results_df, final_message, results_df
603
-
604
-
605
- # --- INTERFAZ DE USUARIO CON GRADIO ---
606
-
607
- MODEL_CHOICES = [
608
- ("Logístico (3 parámetros)", "logistic"),
609
- ("Gompertz (3 parámetros)", "gompertz"),
610
- ("Moser (3 parámetros, simplificado)", "moser"),
611
- ("Baranyi (4 parámetros)", "baranyi")
612
- ]
613
-
614
- def create_gradio_interface():
615
  with gr.Blocks(theme=gr.themes.Soft(primary_hue="blue", secondary_hue="sky")) as demo:
616
  gr.Markdown("# 🔬 Analizador de Cinéticas de Bioprocesos")
617
- gr.Markdown("Sube tus datos experimentales, selecciona los modelos a ajustar y visualiza los resultados.")
618
-
619
- with gr.Tabs() as tabs:
620
- with gr.TabItem("1. Teoría y Modelos", id=0):
621
- gr.Markdown(r"""
622
- ### Modelos de Crecimiento de Biomasa
623
- Esta herramienta ajusta los datos de crecimiento de biomasa a varios modelos matemáticos comunes:
624
-
625
- - **Logístico (3p: $X_0, X_m, \mu_m$):** Modelo sigmoidal clásico que describe el crecimiento con una capacidad de carga.
626
- $$ X(t) = \frac{X_0 X_m e^{\mu_m t}}{X_m - X_0 + X_0 e^{\mu_m t}} $$
627
-
628
- - **Gompertz (3p: $X_m, \mu_m, \lambda$):** Modelo sigmoidal asimétrico, a menudo usado en microbiología.
629
- $$ X(t) = X_m \exp\left(-\exp\left(\frac{\mu_m e}{X_m}(\lambda-t)+1\right)\right) $$
630
-
631
- - **Moser (3p: $X_m, \mu_m, K_s$):** Forma simplificada no dependiente de sustrato usada aquí.
632
- $$ X(t)=X_m(1-e^{-\mu_m(t-K_s)}) $$
633
-
634
- - **Baranyi (4p: $X_0, X_m, \mu_m, \lambda$):** Modelo mecanicista que separa la fase de latencia del crecimiento exponencial.
635
- $$ \frac{dy}{dt} = \frac{Q(t)}{1+Q(t)}\mu_{max}\left(1-\frac{y(t)}{y_{max}}\right)y(t) $$
636
- Donde $y = \ln(X)$, y $Q(t)$ modela el estado fisiológico de las células.
637
-
638
- ### Modelos de Sustrato y Producto
639
- El consumo de sustrato (S) y la formación de producto (P) se modelan con la ecuación de **Luedeking-Piret**:
640
- $$ \frac{dS}{dt} = -p \frac{dX}{dt} - q X \quad ; \quad \frac{dP}{dt} = \alpha \frac{dX}{dt} + \beta X $$
641
- - $\alpha, p$: Coeficientes asociados al crecimiento.
642
- - $\beta, q$: Coeficientes no asociados al crecimiento (mantenimiento).
643
- """)
644
-
645
- with gr.TabItem("2. Configuración de Simulación", id=1):
646
  with gr.Row():
647
  with gr.Column(scale=2):
648
- file_input = gr.File(label="Sube tu archivo Excel (.xlsx)", file_types=['.xlsx'])
649
- exp_names_input = gr.Textbox(
650
- label="Nombres de Experimentos/Hojas (opcional, uno por línea)",
651
- placeholder="Nombre para Hoja 1\nNombre para Hoja 2\n...",
652
- lines=3,
653
- info="Si se deja en blanco, se usarán los nombres de las hojas del archivo."
 
 
 
 
 
 
 
 
 
 
 
 
654
  )
655
  with gr.Column(scale=3):
656
- gr.Markdown("**Configuración Principal**")
657
- model_selection_input = gr.CheckboxGroup(choices=MODEL_CHOICES, label="Modelos de Biomasa a Probar", value=["logistic", "baranyi"])
658
- analysis_mode_input = gr.Radio(["average", "combinado"], label="Modo de Análisis", value="average",
659
- info="Average: Gráficos separados por componente. Combinado: Un solo gráfico con 3 ejes Y.")
660
- use_differential_input = gr.Checkbox(label="Usar Ecuaciones Diferenciales (EDO) para graficar", value=False,
661
- 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.")
662
-
663
- with gr.Accordion("Opciones de Gráfico y Ajuste", open=False):
664
- with gr.Row():
665
- style_input = gr.Dropdown(['whitegrid', 'darkgrid', 'white', 'dark', 'ticks'], label="Estilo de Gráfico", value='whitegrid')
666
- line_color_input = gr.ColorPicker(label="Color de Línea (Modelo)", value='#0072B2')
667
- point_color_input = gr.ColorPicker(label="Color de Puntos (Datos)", value='#D55E00')
668
- with gr.Row():
669
- line_style_input = gr.Dropdown(['-', '--', '-.', ':'], label="Estilo de Línea", value='-')
670
- marker_style_input = gr.Dropdown(['o', 's', '^', 'D', 'x'], label="Estilo de Marcador", value='o')
671
- maxfev_input = gr.Number(label="Iteraciones de ajuste (maxfev)", value=50000, minimum=1000)
672
- with gr.Row():
673
- show_legend_input = gr.Checkbox(label="Mostrar Leyenda", value=True)
674
- legend_pos_input = gr.Radio(["best", "upper left", "upper right", "lower left", "lower right"], label="Posición Leyenda", value="best")
675
- with gr.Row():
676
- show_params_input = gr.Checkbox(label="Mostrar Parámetros", value=True)
677
- params_pos_input = gr.Radio(["upper right", "upper left", "lower right", "lower left", "outside right"], label="Posición Parámetros", value="upper right")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
678
  with gr.Row():
679
- show_error_bars_input = gr.Checkbox(label="Mostrar barras de error (si hay réplicas)", value=True)
680
- error_cap_size_input = gr.Slider(1, 10, 3, step=1, label="Tamaño Tapa Error")
681
- error_line_width_input = gr.Slider(0.5, 5, 1.0, step=0.5, label="Grosor Línea Error")
682
- with gr.Accordion("Títulos de los Ejes", open=False):
683
- with gr.Row():
684
- xlabel_input = gr.Textbox("Tiempo (h)", label="Eje X")
685
- ylabel_bio_input = gr.Textbox("Biomasa (g/L)", label="Eje Y - Biomasa")
686
- ylabel_sub_input = gr.Textbox("Sustrato (g/L)", label="Eje Y - Sustrato")
687
- ylabel_prod_input = gr.Textbox("Producto (g/L)", label="Eje Y - Producto")
688
-
689
- simulate_btn = gr.Button("Analizar y Graficar", variant="primary", scale=1)
690
-
691
- with gr.TabItem("3. Resultados", id=2):
692
- status_output = gr.Textbox(label="Estado del Análisis", interactive=False)
693
- gallery_output = gr.Gallery(label="Gráficos Generados", columns=[1], height='auto', object_fit="contain")
694
- gr.Markdown("### Tabla de Parámetros y Métricas de Ajuste")
695
- table_output = gr.Dataframe(label="Resultados Detallados", wrap=True, interactive=False)
696
- # Componente 'State' para guardar el dataframe para exportación
697
- df_for_export = gr.State(pd.DataFrame())
698
  with gr.Row():
699
- export_excel_btn = gr.Button("Exportar a Excel (.xlsx)")
700
- export_csv_btn = gr.Button("Exportar a CSV (.csv)")
701
- download_output = gr.File(label="Descargar Archivo", interactive=False)
702
-
703
- # Lógica de los botones
704
- 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):
705
- plot_settings = {
706
- 'use_differential': use_diff, 'style': style,
707
- 'line_color': lc, 'point_color': pc, 'line_style': ls, 'marker_style': ms,
708
- 'maxfev': int(maxfev), 'show_legend': s_leg, 'legend_pos': l_pos,
709
- 'show_params': s_par, 'params_pos': p_pos,
710
- 'show_error_bars': s_err, 'error_cap_size': cap, 'error_line_width': lw,
711
- 'axis_labels': {'x_label': xl, 'biomass_label': yl_b, 'substrate_label': yl_s, 'product_label': yl_p}
712
- }
713
- return run_analysis(file, models, mode, names, plot_settings)
714
-
715
- simulate_btn.click(
716
- fn=simulation_wrapper,
717
- inputs=[
718
- file_input, model_selection_input, analysis_mode_input, exp_names_input, use_differential_input,
719
- style_input, line_color_input, point_color_input, line_style_input, marker_style_input, maxfev_input,
720
- show_legend_input, legend_pos_input, show_params_input, params_pos_input,
721
- show_error_bars_input, error_cap_size_input, error_line_width_input,
722
- xlabel_input, ylabel_bio_input, ylabel_sub_input, ylabel_prod_input
723
- ],
724
- outputs=[gallery_output, table_output, status_output, df_for_export]
725
- )
726
-
727
- def export_to_file(df, file_format):
728
- if df is None or df.empty:
729
- gr.Warning("No hay datos en la tabla para exportar.")
730
- return None
731
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
732
  suffix = ".xlsx" if file_format == "excel" else ".csv"
733
- with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmpfile:
734
- if file_format == "excel":
735
- df.to_excel(tmpfile.name, index=False)
736
- else:
737
- df.to_csv(tmpfile.name, index=False, encoding='utf-8-sig')
738
- return tmpfile.name
739
-
740
- export_excel_btn.click(fn=lambda df: export_to_file(df, "excel"), inputs=[df_for_export], outputs=[download_output])
741
- export_csv_btn.click(fn=lambda df: export_to_file(df, "csv"), inputs=[df_for_export], outputs=[download_output])
742
-
743
  return demo
744
 
 
 
745
  if __name__ == '__main__':
 
 
 
 
 
 
 
 
 
 
 
746
  gradio_app = create_gradio_interface()
 
 
 
 
747
  gradio_app.launch(share=True, debug=True)
 
1
+ # --- INSTALACIÓN DE DEPENDENCIAS ADICIONALES ---
2
+ import os
3
+ import sys
4
+ import subprocess
5
+
6
+ def install_packages():
7
+ packages = ["gradio", "plotly", "seaborn", "pandas", "openpyxl", "scikit-learn",
8
+ "fpdf2", "python-docx", "kaleido"]
9
+ for package in packages:
10
+ try:
11
+ __import__(package)
12
+ except ImportError:
13
+ print(f"Instalando {package}...")
14
+ subprocess.check_call([sys.executable, "-m", "pip", "install", package])
15
+
16
+ install_packages()
17
+
18
+ # --- IMPORTACIONES ---
19
  import os
20
  import io
21
  import tempfile
22
+ import traceback
23
+ import zipfile
24
+ from typing import List, Tuple, Dict, Any, Optional
25
+ from abc import ABC, abstractmethod
26
+ from unittest.mock import MagicMock
27
 
28
+ from PIL import Image
29
+ import gradio as gr
30
+ import plotly.graph_objects as go
31
  import numpy as np
32
  import pandas as pd
33
  import matplotlib.pyplot as plt
 
35
  from scipy.integrate import odeint
36
  from scipy.optimize import curve_fit
37
  from sklearn.metrics import mean_squared_error
38
+ from docx import Document
39
+ from docx.shared import Inches
40
+ from fpdf import FPDF
41
+ from fpdf.enums import XPos, YPos
42
+
43
+ # --- CONSTANTES ---
44
+ C_TIME = 'tiempo'
45
+ C_BIOMASS = 'biomass'
46
+ C_SUBSTRATE = 'substrate'
47
+ C_PRODUCT = 'product'
48
+ COMPONENTS = [C_BIOMASS, C_SUBSTRATE, C_PRODUCT]
49
+
50
+ # --- BLOQUE 1: ESTRUCTURA DE MODELOS CINÉTICOS ESCALABLE ---
51
+
52
+ class KineticModel(ABC):
53
+ def __init__(self, name: str, display_name: str, param_names: List[str]):
54
+ self.name, self.display_name, self.param_names = name, display_name, param_names
55
+ self.num_params = len(param_names)
56
+
57
+ @abstractmethod
58
+ def model_function(self, t: np.ndarray, *params: float) -> np.ndarray: pass
59
+ def diff_function(self, X: float, t: float, params: List[float]) -> float: return 0.0
60
+ @abstractmethod
61
+ def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]: pass
62
+ @abstractmethod
63
+ def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]: pass
64
+
65
+ class LogisticModel(KineticModel):
66
+ def __init__(self): super().__init__("logistic", "Logístico", ["Xo", "Xm", "um"])
67
+ def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
68
+ xo, xm, um = params
69
+ if xm <= 0 or xo <= 0 or xm < xo: return np.full_like(t, np.nan)
70
+ exp_arg = np.clip(um * t, -700, 700); term_exp = np.exp(exp_arg)
71
+ denominator = 1 - (xo / xm) * (1 - term_exp); denominator = np.where(denominator == 0, 1e-9, denominator)
 
 
 
 
 
72
  return (xo * term_exp) / denominator
73
+ def diff_function(self, X: float, t: float, params: List[float]) -> float:
74
+ _, xm, um = params; return um * X * (1 - X / xm) if xm > 0 else 0.0
75
+ def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
76
+ return [biomass[0] if len(biomass) > 0 and biomass[0] > 1e-6 else 1e-3, max(biomass) if len(biomass) > 0 else 1.0, 0.1]
77
+ def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
78
+ initial_biomass = biomass[0] if len(biomass) > 0 else 1e-9; max_biomass = max(biomass) if len(biomass) > 0 else 1.0
79
+ return ([1e-9, initial_biomass, 1e-9], [max_biomass * 1.2, max_biomass * 5, np.inf])
80
+
81
+ class GompertzModel(KineticModel):
82
+ def __init__(self): super().__init__("gompertz", "Gompertz", ["Xm", "um", "lag"])
83
+ def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  xm, um, lag = params
85
+ if xm <= 0 or um <= 0: return np.full_like(t, np.nan)
86
+ exp_term = (um * np.e / xm) * (lag - t) + 1; exp_term_clipped = np.clip(exp_term, -700, 700)
87
+ return xm * np.exp(-np.exp(exp_term_clipped))
88
+ def diff_function(self, X: float, t: float, params: List[float]) -> float:
89
+ xm, um, lag = params; k_val = um * np.e / xm
90
+ u_val = k_val * (lag - t) + 1; u_val_clipped = np.clip(u_val, -np.inf, 700)
91
+ return X * k_val * np.exp(u_val_clipped) if xm > 0 and X > 0 else 0.0
92
+ def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
93
+ return [max(biomass) if len(biomass) > 0 else 1.0, 0.1, time[np.argmax(np.gradient(biomass))] if len(biomass) > 1 else 0]
94
+ def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
95
+ initial_biomass = min(biomass) if len(biomass) > 0 else 1e-9; max_biomass = max(biomass) if len(biomass) > 0 else 1.0
96
+ return ([max(1e-9, initial_biomass), 1e-9, 0], [max_biomass * 5, np.inf, max(time) if len(time) > 0 else 1])
97
+
98
+ class MoserModel(KineticModel):
99
+ def __init__(self): super().__init__("moser", "Moser", ["Xm", "um", "Ks"])
100
+ def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
101
+ Xm, um, Ks = params; return Xm * (1 - np.exp(-um * (t - Ks))) if Xm > 0 and um > 0 else np.full_like(t, np.nan)
102
+ def diff_function(self, X: float, t: float, params: List[float]) -> float:
103
+ Xm, um, _ = params; return um * (Xm - X) if Xm > 0 else 0.0
104
+ def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
105
+ return [max(biomass) if len(biomass) > 0 else 1.0, 0.1, 0]
106
+ def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
107
+ initial_biomass = min(biomass) if len(biomass) > 0 else 1e-9; max_biomass = max(biomass) if len(biomass) > 0 else 1.0
108
+ return ([max(1e-9, initial_biomass), 1e-9, -np.inf], [max_biomass * 5, np.inf, np.inf])
109
+
110
+ class BaranyiModel(KineticModel):
111
+ def __init__(self): super().__init__("baranyi", "Baranyi", ["X0", "Xm", "um", "lag"])
112
+ def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
113
+ X0, Xm, um, lag = params
114
+ if X0 <= 0 or Xm <= X0 or um <= 0 or lag < 0: return np.full_like(t, np.nan)
115
+ A_t = t + (1 / um) * np.log(np.exp(-um * t) + np.exp(-um * lag) - np.exp(-um * (t + lag)))
116
+ exp_um_At = np.exp(np.clip(um * A_t, -700, 700))
117
+ numerator = Xm; denominator = 1 + ((Xm / X0) - 1) * (1 / exp_um_At)
118
+ return numerator / np.where(denominator == 0, 1e-9, denominator)
119
+ def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
120
+ return [biomass[0] if len(biomass) > 0 and biomass[0] > 1e-6 else 1e-3, max(biomass) if len(biomass) > 0 else 1.0, 0.1, time[np.argmax(np.gradient(biomass))] if len(biomass) > 1 else 0.0]
121
+ def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
122
+ initial_biomass = biomass[0] if len(biomass) > 0 else 1e-9; max_biomass = max(biomass) if len(biomass) > 0 else 1.0
123
+ return ([1e-9, max(1e-9, initial_biomass), 1e-9, 0], [max_biomass * 1.2, max_biomass * 10, np.inf, max(time) if len(time) > 0 else 1])
124
+
125
+ # --- REGISTRO CENTRAL DE MODELOS ---
126
+ AVAILABLE_MODELS: Dict[str, KineticModel] = {model.name: model for model in [LogisticModel(), GompertzModel(), MoserModel(), BaranyiModel()]}
127
+
128
+ # --- CLASE DE AJUSTE DE BIOPROCESOS ---
129
+ class BioprocessFitter:
130
+ def __init__(self, kinetic_model: KineticModel, maxfev: int = 50000):
131
+ self.model, self.maxfev = kinetic_model, maxfev
132
+ self.params: Dict[str, Dict[str, float]] = {c: {} for c in COMPONENTS}
133
+ self.r2: Dict[str, float] = {}; self.rmse: Dict[str, float] = {}
134
+ self.data_time: Optional[np.ndarray] = None
135
+ self.data_means: Dict[str, Optional[np.ndarray]] = {c: None for c in COMPONENTS}
136
+ self.data_stds: Dict[str, Optional[np.ndarray]] = {c: None for c in COMPONENTS}
137
+
138
+ def _get_biomass_at_t(self, t: np.ndarray, p: List[float]) -> np.ndarray: return self.model.model_function(t, *p)
139
+ def _get_initial_biomass(self, p: List[float]) -> float:
140
+ if not p: return 0.0
141
+ if any(k in self.model.param_names for k in ["Xo", "X0"]):
142
+ try:
143
+ idx = self.model.param_names.index("Xo") if "Xo" in self.model.param_names else self.model.param_names.index("X0")
144
+ return p[idx]
145
+ except (ValueError, IndexError): pass
146
+ return float(self.model.model_function(np.array([0]), *p)[0])
147
+ def _calc_integral(self, t: np.ndarray, p: List[float]) -> np.ndarray:
148
+ X_t = self._get_biomass_at_t(t, p)
149
+ if np.any(np.isnan(X_t)): return np.full_like(t, np.nan)
150
  integral_X = np.zeros_like(X_t)
151
+ if len(t) > 1:
152
+ dt = np.diff(t, prepend=t[0] - (t[1] - t[0] if len(t) > 1 else 1))
153
  integral_X = np.cumsum(X_t * dt)
154
+ return integral_X, X_t
155
+ def substrate(self, t: np.ndarray, so: float, p_c: float, q: float, bio_p: List[float]) -> np.ndarray:
156
+ integral, X_t = self._calc_integral(t, bio_p); X0 = self._get_initial_biomass(bio_p)
157
+ return so - p_c * (X_t - X0) - q * integral
158
+ def product(self, t: np.ndarray, po: float, alpha: float, beta: float, bio_p: List[float]) -> np.ndarray:
159
+ integral, X_t = self._calc_integral(t, bio_p); X0 = self._get_initial_biomass(bio_p)
160
+ return po + alpha * (X_t - X0) + beta * integral
161
+ def process_data_from_df(self, df: pd.DataFrame) -> None:
162
  try:
163
+ time_col = [c for c in df.columns if c[1].strip().lower() == C_TIME][0]
164
+ self.data_time = df[time_col].dropna().to_numpy(); min_len = len(self.data_time)
165
+ def extract(name: str) -> Tuple[np.ndarray, np.ndarray]:
166
+ cols = [c for c in df.columns if c[1].strip().lower() == name.lower()]
 
 
167
  if not cols: return np.array([]), np.array([])
168
+ reps = [df[c].dropna().values[:min_len] for c in cols]; reps = [r for r in reps if len(r) == min_len]
169
+ if not reps: return np.array([]), np.array([])
170
+ arr = np.array(reps); mean = np.mean(arr, axis=0)
171
+ std = np.std(arr, axis=0, ddof=1) if arr.shape[0] > 1 else np.zeros_like(mean)
172
+ return mean, std
173
+ self.data_means[C_BIOMASS], self.data_stds[C_BIOMASS] = extract('Biomasa')
174
+ self.data_means[C_SUBSTRATE], self.data_stds[C_SUBSTRATE] = extract('Sustrato')
175
+ self.data_means[C_PRODUCT], self.data_stds[C_PRODUCT] = extract('Producto')
176
+ except (IndexError, KeyError) as e: raise ValueError(f"Estructura de DataFrame inválida. Error: {e}")
177
+ def _fit_component(self, func, t, data, p0, bounds, sigma=None, *args):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  try:
179
+ if sigma is not None: sigma = np.where(sigma == 0, 1e-9, sigma)
180
+ popt, _ = curve_fit(func, t, data, p0, bounds=bounds, maxfev=self.maxfev, ftol=1e-9, xtol=1e-9, sigma=sigma, absolute_sigma=bool(sigma is not None))
181
+ pred = func(t, *popt, *args)
182
+ if np.any(np.isnan(pred)): return None, np.nan, np.nan
183
+ r2 = 1 - np.sum((data - pred)**2) / np.sum((data - np.mean(data))**2)
184
+ rmse = np.sqrt(mean_squared_error(data, pred))
185
+ return list(popt), r2, rmse
186
+ except (RuntimeError, ValueError): return None, np.nan, np.nan
187
+ def fit_all_models(self) -> None:
188
+ t, bio_m, bio_s = self.data_time, self.data_means[C_BIOMASS], self.data_stds[C_BIOMASS]
189
+ if t is None or bio_m is None or len(bio_m) == 0: return
190
+ popt_bio = self._fit_biomass_model(t, bio_m, bio_s)
191
+ if popt_bio:
192
+ bio_p = list(self.params[C_BIOMASS].values())
193
+ if self.data_means[C_SUBSTRATE] is not None and len(self.data_means[C_SUBSTRATE]) > 0: self._fit_substrate_model(t, self.data_means[C_SUBSTRATE], self.data_stds[C_SUBSTRATE], bio_p)
194
+ if self.data_means[C_PRODUCT] is not None and len(self.data_means[C_PRODUCT]) > 0: self._fit_product_model(t, self.data_means[C_PRODUCT], self.data_stds[C_PRODUCT], bio_p)
195
+ def _fit_biomass_model(self, t, data, std):
196
+ p0, bounds = self.model.get_initial_params(t, data), self.model.get_param_bounds(t, data)
197
+ popt, r2, rmse = self._fit_component(self.model.model_function, t, data, p0, bounds, std)
198
+ if popt: self.params[C_BIOMASS], self.r2[C_BIOMASS], self.rmse[C_BIOMASS] = dict(zip(self.model.param_names, popt)), r2, rmse
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  return popt
200
+ def _fit_substrate_model(self, t, data, std, bio_p):
201
+ p0, b = [data[0], 0.1, 0.01], ([0, -np.inf, -np.inf], [np.inf, np.inf, np.inf])
202
+ popt, r2, rmse = self._fit_component(lambda t, so, p, q: self.substrate(t, so, p, q, bio_p), t, data, p0, b, std)
203
+ if popt: self.params[C_SUBSTRATE], self.r2[C_SUBSTRATE], self.rmse[C_SUBSTRATE] = {'So': popt[0], 'p': popt[1], 'q': popt[2]}, r2, rmse
204
+ def _fit_product_model(self, t, data, std, bio_p):
205
+ p0, b = [data[0] if len(data)>0 else 0, 0.1, 0.01], ([0, -np.inf, -np.inf], [np.inf, np.inf, np.inf])
206
+ popt, r2, rmse = self._fit_component(lambda t, po, a, b: self.product(t, po, a, b, bio_p), t, data, p0, b, std)
207
+ if popt: self.params[C_PRODUCT], self.r2[C_PRODUCT], self.rmse[C_PRODUCT] = {'Po': popt[0], 'alpha': popt[1], 'beta': popt[2]}, r2, rmse
208
+ def system_ode(self, y, t, bio_p, sub_p, prod_p):
209
+ X, _, _ = y; dXdt = self.model.diff_function(X, t, bio_p)
210
+ return [dXdt, -sub_p.get('p',0)*dXdt - sub_p.get('q',0)*X, prod_p.get('alpha',0)*dXdt + prod_p.get('beta',0)*X]
211
+ def solve_odes(self, t_fine):
212
+ p = self.params; bio_d, sub_d, prod_d = p[C_BIOMASS], p[C_SUBSTRATE], p[C_PRODUCT]
213
+ if not bio_d: return None, None, None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  try:
215
+ bio_p = list(bio_d.values()); y0 = [self._get_initial_biomass(bio_p), sub_d.get('So',0), prod_d.get('Po',0)]
216
+ sol = odeint(self.system_ode, y0, t_fine, args=(bio_p, sub_d, prod_d))
 
 
 
 
 
 
 
 
 
 
 
217
  return sol[:, 0], sol[:, 1], sol[:, 2]
218
+ except: return None, None, None
219
+ def _generate_fine_time_grid(self, t_exp): return np.linspace(min(t_exp), max(t_exp), 500) if t_exp is not None and len(t_exp) > 1 else np.array([])
220
+ def get_model_curves_for_plot(self, t_fine, use_diff):
221
+ if use_diff and self.model.diff_function(1, 1, [1]*self.model.num_params) != 0: return self.solve_odes(t_fine)
222
+ X, S, P = None, None, None
223
+ if self.params[C_BIOMASS]:
224
+ bio_p = list(self.params[C_BIOMASS].values()); X = self.model.model_function(t_fine, *bio_p)
225
+ if self.params[C_SUBSTRATE]: S = self.substrate(t_fine, *list(self.params[C_SUBSTRATE].values()), bio_p)
226
+ if self.params[C_PRODUCT]: P = self.product(t_fine, *list(self.params[C_PRODUCT].values()), bio_p)
227
+ return X, S, P
228
+ def plot_individual_or_combined(self, cfg, mode):
229
+ t_exp, t_fine = cfg['time_exp'], self._generate_fine_time_grid(cfg['time_exp'])
230
+ X_m, S_m, P_m = self.get_model_curves_for_plot(t_fine, cfg.get('use_differential', False))
231
+ sns.set_style(cfg.get('style', 'whitegrid'))
232
+ if mode == 'average':
233
+ fig, (ax1,ax2,ax3) = plt.subplots(3,1,figsize=(10,15),sharex=True)
234
+ fig.suptitle(f"Análisis: {cfg.get('exp_name','')} ({self.model.display_name})", fontsize=16); axes=[ax1,ax2,ax3]
235
+ else:
236
+ fig, ax1 = plt.subplots(figsize=(12,8)); fig.suptitle(f"Análisis: {cfg.get('exp_name','')} ({self.model.display_name})", fontsize=16)
237
+ ax2,ax3 = ax1.twinx(),ax1.twinx(); ax3.spines["right"].set_position(("axes",1.18)); axes=[ax1,ax2,ax3]
238
+ data_map = {C_BIOMASS:X_m, C_SUBSTRATE:S_m, C_PRODUCT:P_m}
239
+ comb_styles = {C_BIOMASS:{'c':'#0072B2','mc':'#56B4E9','m':'o','ls':'-'}, C_SUBSTRATE:{'c':'#009E73','mc':'#34E499','m':'s','ls':'--'}, C_PRODUCT:{'c':'#D55E00','mc':'#F0E442','m':'^','ls':'-.'}}
240
+ for ax, comp in zip(axes, COMPONENTS):
241
+ ylabel, data, std, model_data = cfg.get('axis_labels',{}).get(f'{comp}_label',comp.capitalize()), cfg.get(f'{comp}_exp'), cfg.get(f'{comp}_std'), data_map.get(comp)
242
+ if mode == 'combined':
243
+ s = comb_styles[comp]; pc, lc, ms, ls = s['c'], s['mc'], s['m'], s['ls']
244
+ else:
245
+ pc,lc,ms,ls = cfg.get(f'{comp}_point_color'), cfg.get(f'{comp}_line_color'), cfg.get(f'{comp}_marker_style'), cfg.get(f'{comp}_line_style')
246
+ ax_c = pc if mode == 'combined' else 'black'; ax.set_ylabel(ylabel,color=ax_c); ax.tick_params(axis='y',labelcolor=ax_c)
247
+ if data is not None and len(data)>0:
248
+ if cfg.get('show_error_bars') and std is not None and np.any(std>0): ax.errorbar(t_exp, data, yerr=std, fmt=ms, color=pc, label=f'{comp.capitalize()} (Datos)', capsize=cfg.get('error_cap_size',3), elinewidth=cfg.get('error_line_width',1))
249
+ else: ax.plot(t_exp, data, ls='', marker=ms, color=pc, label=f'{comp.capitalize()} (Datos)')
250
+ if model_data is not None and len(model_data)>0: ax.plot(t_fine, model_data, ls=ls, color=lc, label=f'{comp.capitalize()} (Modelo)')
251
+ if mode=='average' and cfg.get('show_legend',True): ax.legend(loc=cfg.get('legend_pos','best'))
252
+ if mode=='average' and cfg.get('show_params',True) and self.params[comp]:
253
+ decs = cfg.get('decimal_places',3); p_txt='\n'.join([f"{k}={format_number(v,decs)}" for k,v in self.params[comp].items()])
254
+ full_txt=f"{p_txt}\nR²={format_number(self.r2.get(comp,0),3)}, RMSE={format_number(self.rmse.get(comp,0),3)}"
255
+ pos_x,ha = (0.95,'right') if 'right' in cfg.get('params_pos','upper right') else (0.05,'left')
256
+ ax.text(pos_x,0.95,full_txt,transform=ax.transAxes,va='top',ha=ha,bbox=dict(boxstyle='round,pad=0.4',fc='wheat',alpha=0.7))
257
+ if mode=='combined' and cfg.get('show_legend',True):
258
+ h1,l1=axes[0].get_legend_handles_labels(); h2,l2=axes[1].get_legend_handles_labels(); h3,l3=axes[2].get_legend_handles_labels()
259
+ axes[0].legend(handles=h1+h2+h3, labels=l1+l2+l3, loc=cfg.get('legend_pos','best'))
260
+ axes[-1].set_xlabel(cfg.get('axis_labels',{}).get('x_label','Tiempo')); plt.tight_layout()
261
+ if mode=='combined': fig.subplots_adjust(right=0.8)
262
+ return fig
263
+
264
+ # --- FUNCIONES AUXILIARES, DE PLOTEO Y REPORTE (COMPLETAS) ---
265
+
266
+ def format_number(value: Any, decimals: int) -> str:
267
+ """
268
+ Formatea un número para su visualización. Si decimals es 0, usa un formato inteligente.
269
+ """
270
+ if not isinstance(value, (int, float, np.number)) or pd.isna(value):
271
+ return "" if pd.isna(value) else str(value)
272
 
273
+ decimals = int(decimals)
274
+
275
+ if decimals == 0:
276
+ if 0 < abs(value) < 1:
277
+ return f"{value:.2e}"
 
 
 
 
 
 
 
 
 
 
278
  else:
279
+ return str(int(round(value, 0)))
 
280
 
281
+ return str(round(value, decimals))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
 
283
+ def plot_model_comparison_matplotlib(plot_config: Dict, models_results: List[Dict]) -> plt.Figure:
284
+ """
285
+ Crea un gráfico de comparación de modelos estático usando Matplotlib/Seaborn.
286
+ """
287
+ time_exp = plot_config['time_exp']
288
+ # Usar un modelo cualquiera solo para generar la rejilla de tiempo
289
+ time_fine = BioprocessFitter(list(AVAILABLE_MODELS.values())[0])._generate_fine_time_grid(time_exp)
290
+ num_models = len(models_results)
291
+
292
+ palettes = {
293
+ C_BIOMASS: sns.color_palette("Blues", num_models),
294
+ C_SUBSTRATE: sns.color_palette("Greens", num_models),
295
+ C_PRODUCT: sns.color_palette("Reds", num_models)
296
+ }
297
+ line_styles = ['-', '--', '-.', ':']
298
+
299
+ sns.set_style(plot_config.get('style', 'whitegrid'))
300
+ fig, ax1 = plt.subplots(figsize=(12, 8))
301
+
302
+ # Configuración de los 3 ejes Y
303
+ ax1.set_xlabel(plot_config['axis_labels']['x_label'])
304
+ ax1.set_ylabel(plot_config['axis_labels']['biomass_label'], color="navy", fontsize=12)
305
+ ax1.tick_params(axis='y', labelcolor="navy")
306
+ ax2 = ax1.twinx()
307
+ ax3 = ax1.twinx()
308
+ ax3.spines["right"].set_position(("axes", 1.22))
309
+ ax2.set_ylabel(plot_config['axis_labels']['substrate_label'], color="darkgreen", fontsize=12)
310
+ ax2.tick_params(axis='y', labelcolor="darkgreen")
311
+ ax3.set_ylabel(plot_config['axis_labels']['product_label'], color="darkred", fontsize=12)
312
+ ax3.tick_params(axis='y', labelcolor="darkred")
313
+
314
+ # Dibujar datos experimentales
315
+ data_markers = {C_BIOMASS: 'o', C_SUBSTRATE: 's', C_PRODUCT: '^'}
316
+ for ax, key, color, face in [(ax1, C_BIOMASS, 'navy', 'skyblue'), (ax2, C_SUBSTRATE, 'darkgreen', 'lightgreen'), (ax3, C_PRODUCT, 'darkred', 'lightcoral')]:
317
+ data_exp = plot_config.get(f'{key}_exp')
318
+ data_std = plot_config.get(f'{key}_std')
319
+ if data_exp is not None:
320
+ if plot_config.get('show_error_bars') and data_std is not None and np.any(data_std > 0):
321
+ ax.errorbar(time_exp, data_exp, yerr=data_std, fmt=data_markers[key], color=color, label=f'{key.capitalize()} (Datos)', zorder=10, markersize=8, markerfacecolor=face, markeredgecolor=color, capsize=plot_config.get('error_cap_size', 3), elinewidth=plot_config.get('error_line_width', 1))
322
+ else:
323
+ ax.plot(time_exp, data_exp, ls='', marker=data_markers[key], label=f'{key.capitalize()} (Datos)', zorder=10, ms=8, mfc=face, mec=color, mew=1.5)
324
+
325
+ # Dibujar curvas de los modelos
326
+ for i, res in enumerate(models_results):
327
+ ls = line_styles[i % len(line_styles)]
328
+ model_info = AVAILABLE_MODELS.get(res["name"], MagicMock(display_name=res["name"]))
329
+ model_display_name = model_info.display_name
330
+ for key_short, ax, name_long in [('X', ax1, C_BIOMASS), ('S', ax2, C_SUBSTRATE), ('P', ax3, C_PRODUCT)]:
331
+ if res.get(key_short) is not None:
332
+ ax.plot(time_fine, res[key_short], color=palettes[name_long][i], ls=ls, label=f'{name_long.capitalize()} ({model_display_name})', alpha=0.9)
333
+
334
+ fig.subplots_adjust(left=0.3, right=0.78, top=0.92, bottom=0.35 if plot_config.get('show_params') else 0.1)
335
+
336
+ if plot_config.get('show_legend'):
337
+ h1, l1 = ax1.get_legend_handles_labels(); h2, l2 = ax2.get_legend_handles_labels(); h3, l3 = ax3.get_legend_handles_labels()
338
+ fig.legend(h1 + h2 + h3, l1 + l2 + l3, loc='center left', bbox_to_anchor=(0.0, 0.5), fancybox=True, shadow=True, fontsize='small')
339
+
340
+ if plot_config.get('show_params'):
341
+ total_width = 0.95; box_width = total_width / num_models; start_pos = (1.0 - total_width) / 2
342
+ for i, res in enumerate(models_results):
343
+ model_info = AVAILABLE_MODELS.get(res["name"], MagicMock(display_name=res["name"]))
344
+ text = f"**{model_info.display_name}**\n" + _generate_model_param_text(res, plot_config.get('decimal_places', 3))
345
+ fig.text(start_pos + i * box_width, 0.01, text, transform=fig.transFigure, fontsize=7.5, va='bottom', ha='left', bbox=dict(boxstyle='round,pad=0.4', fc='ivory', ec='gray', alpha=0.9))
346
+
347
+ fig.suptitle(f"Comparación de Modelos: {plot_config.get('exp_name', '')}", fontsize=16)
348
+ return fig
349
+
350
+ def plot_model_comparison_plotly(plot_config: Dict, models_results: List[Dict]) -> go.Figure:
351
+ """
352
+ Crea un gráfico de comparación de modelos interactivo usando Plotly.
353
+ """
354
+ fig = go.Figure()
355
+ time_exp = plot_config['time_exp']
356
+ time_fine = BioprocessFitter(list(AVAILABLE_MODELS.values())[0])._generate_fine_time_grid(time_exp)
357
+ num_models = len(models_results)
358
+ palettes = {
359
+ C_BIOMASS: sns.color_palette("Blues", n_colors=num_models).as_hex(),
360
+ C_SUBSTRATE: sns.color_palette("Greens", n_colors=num_models).as_hex(),
361
+ C_PRODUCT: sns.color_palette("Reds", n_colors=num_models).as_hex()
362
+ }
363
+ line_styles, data_markers = ['solid', 'dash', 'dot', 'dashdot'], {C_BIOMASS: 'circle-open', C_SUBSTRATE: 'square-open', C_PRODUCT: 'diamond-open'}
364
+
365
+ for key, y_axis, color in [(C_BIOMASS, 'y1', 'navy'), (C_SUBSTRATE, 'y2', 'darkgreen'), (C_PRODUCT, 'y3', 'darkred')]:
366
+ data_exp, data_std = plot_config.get(f'{key}_exp'), plot_config.get(f'{key}_std')
367
+ if data_exp is not None:
368
+ error_y_config = dict(type='data', array=data_std, visible=True) if plot_config.get('show_error_bars') and data_std is not None and np.any(data_std > 0) else None
369
+ fig.add_trace(go.Scatter(x=time_exp, y=data_exp, mode='markers', name=f'{key.capitalize()} (Datos)', marker=dict(color=color, size=10, symbol=data_markers[key], line=dict(width=2)), error_y=error_y_config, yaxis=y_axis, legendgroup="data"))
370
+
371
+ for i, res in enumerate(models_results):
372
+ ls = line_styles[i % len(line_styles)]
373
+ model_display_name = AVAILABLE_MODELS.get(res["name"], MagicMock(display_name=res["name"])).display_name
374
+ if res.get('X') is not None: fig.add_trace(go.Scatter(x=time_fine, y=res['X'], mode='lines', name=f'Biomasa ({model_display_name})', line=dict(color=palettes[C_BIOMASS][i], dash=ls), legendgroup=res["name"]))
375
+ if res.get('S') is not None: fig.add_trace(go.Scatter(x=time_fine, y=res['S'], mode='lines', name=f'Sustrato ({model_display_name})', line=dict(color=palettes[C_SUBSTRATE][i], dash=ls), yaxis='y2', legendgroup=res["name"]))
376
+ if res.get('P') is not None: fig.add_trace(go.Scatter(x=time_fine, y=res['P'], mode='lines', name=f'Producto ({model_display_name})', line=dict(color=palettes[C_PRODUCT][i], dash=ls), yaxis='y3', legendgroup=res["name"]))
377
+
378
+ if plot_config.get('show_params'):
379
+ x_positions = np.linspace(0, 1, num_models * 2 + 1)[1::2]
380
+ for i, res in enumerate(models_results):
381
+ model_display_name = AVAILABLE_MODELS.get(res["name"], MagicMock(display_name=res["name"])).display_name
382
+ text = f"<b>{model_display_name}</b><br>" + _generate_model_param_text(res, plot_config.get('decimal_places', 3)).replace('\n', '<br>')
383
+ fig.add_annotation(text=text, align='left', showarrow=False, xref='paper', yref='paper', x=x_positions[i], y=-0.35, bordercolor='gray', borderwidth=1, bgcolor='ivory', opacity=0.9)
384
+
385
+ fig.update_layout(
386
+ title=f"Comparación de Modelos (Interactivo): {plot_config.get('exp_name', '')}",
387
+ xaxis=dict(domain=[0.18, 0.82]),
388
+ yaxis=dict(title=plot_config['axis_labels']['biomass_label'], titlefont=dict(color='navy'), tickfont=dict(color='navy')),
389
+ yaxis2=dict(title=plot_config['axis_labels']['substrate_label'], titlefont=dict(color='darkgreen'), tickfont=dict(color='darkgreen'), overlaying='y', side='right'),
390
+ yaxis3=dict(title=plot_config['axis_labels']['product_label'], titlefont=dict(color='darkred'), tickfont=dict(color='darkred'), overlaying='y', side='right', position=0.85),
391
+ legend=dict(traceorder="grouped", yanchor="middle", y=0.5, xanchor="right", x=-0.15),
392
+ margin=dict(l=200, r=150, b=250 if plot_config.get('show_params') else 80, t=80),
393
+ template="seaborn",
394
+ showlegend=plot_config.get('show_legend', True)
395
+ )
396
+ return fig
397
+
398
+ def _generate_model_param_text(result: Dict, decimals: int) -> str:
399
+ """Genera el texto formateado de los parámetros para las cajas de anotación."""
400
+ text = ""
401
+ for comp in COMPONENTS:
402
+ if params := result.get('params', {}).get(comp):
403
+ p_str = ', '.join([f"{k}={format_number(v, decimals)}" for k, v in params.items()])
404
+ r2 = result.get('r2', {}).get(comp, 0)
405
+ rmse = result.get('rmse', {}).get(comp, 0)
406
+ text += f"<i>{comp[:4].capitalize()}:</i> {p_str}\n(R²={format_number(r2, 3)}, RMSE={format_number(rmse, 3)})\n"
407
+ return text.strip()
408
+
409
+ def create_zip_file(image_list: List[Any]) -> Optional[str]:
410
+ if not image_list:
411
+ gr.Warning("No hay gráficos para descargar.")
412
+ return None
413
  try:
414
+ zip_buffer = io.BytesIO()
415
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
416
+ for i, fig in enumerate(image_list):
417
+ buf = io.BytesIO()
418
+ if isinstance(fig, go.Figure): buf.write(fig.to_image(format="png", scale=2, engine="kaleido"))
419
+ elif isinstance(fig, plt.Figure): fig.savefig(buf, format='png', dpi=200, bbox_inches='tight'); plt.close(fig)
420
+ elif isinstance(fig, Image.Image): fig.save(buf, 'PNG')
421
+ else: continue
422
+ buf.seek(0)
423
+ zf.writestr(f"grafico_{i+1}.png", buf.read())
424
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp:
425
+ tmp.write(zip_buffer.getvalue())
426
+ return tmp.name
427
  except Exception as e:
428
+ traceback.print_exc()
429
+ gr.Error(f"Error al crear el archivo ZIP: {e}")
430
+ return None
431
+
432
+ def create_word_report(image_list: List[Any], table_df: pd.DataFrame, decimals: int) -> Optional[str]:
433
+ if not image_list and (table_df is None or table_df.empty):
434
+ gr.Warning("No hay datos ni gráficos para crear el reporte.")
435
+ return None
436
+ try:
437
+ doc = Document()
438
+ doc.add_heading('Reporte de Análisis de Cinéticas', 0)
439
+ if table_df is not None and not table_df.empty:
440
+ doc.add_heading('Tabla de Resultados', level=1)
441
+ table = doc.add_table(rows=1, cols=len(table_df.columns), style='Table Grid')
442
+ for i, col in enumerate(table_df.columns): table.cell(0, i).text = str(col)
443
+ for _, row in table_df.iterrows():
444
+ cells = table.add_row().cells
445
+ for i, val in enumerate(row): cells[i].text = str(format_number(val, decimals))
446
+ if image_list:
447
+ doc.add_page_break()
448
+ doc.add_heading('Gráficos Generados', level=1)
449
+ for i, fig in enumerate(image_list):
450
+ buf = io.BytesIO()
451
+ if isinstance(fig, go.Figure): buf.write(fig.to_image(format="png", scale=2, engine="kaleido"))
452
+ elif isinstance(fig, plt.Figure): fig.savefig(buf, format='png', dpi=200, bbox_inches='tight'); plt.close(fig)
453
+ elif isinstance(fig, Image.Image): fig.save(buf, 'PNG')
454
+ else: continue
455
+ buf.seek(0)
456
+ doc.add_paragraph(f'Gráfico {i+1}', style='Heading 3')
457
+ doc.add_picture(buf, width=Inches(6.0))
458
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".docx") as tmp:
459
+ doc.save(tmp.name)
460
+ return tmp.name
461
+ except Exception as e:
462
+ traceback.print_exc()
463
+ gr.Error(f"Error al crear el reporte de Word: {e}")
464
+ return None
465
+
466
+ def create_pdf_report(image_list: List[Any], table_df: pd.DataFrame, decimals: int) -> Optional[str]:
467
+ if not image_list and (table_df is None or table_df.empty):
468
+ gr.Warning("No hay datos ni gráficos para crear el reporte.")
469
+ return None
470
+ try:
471
+ pdf = FPDF()
472
+ pdf.set_auto_page_break(auto=True, margin=15)
473
+ pdf.add_page()
474
+ pdf.set_font("Helvetica", 'B', 16)
475
+ pdf.cell(0, 10, 'Reporte de Análisis de Cinéticas', new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='C')
476
+ if table_df is not None and not table_df.empty:
477
+ pdf.ln(10)
478
+ pdf.set_font("Helvetica", 'B', 12)
479
+ pdf.cell(0, 10, 'Tabla de Resultados', new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
480
+ pdf.set_font("Helvetica", 'B', 8)
481
+ effective_page_width = pdf.w - 2 * pdf.l_margin
482
+ num_cols = len(table_df.columns)
483
+ col_width = effective_page_width / num_cols if num_cols > 0 else 0
484
+ if num_cols > 15: pdf.set_font_size(6)
485
+ elif num_cols > 10: pdf.set_font_size(7)
486
+ for col in table_df.columns: pdf.cell(col_width, 10, str(col), border=1, align='C')
487
+ pdf.ln()
488
+ pdf.set_font("Helvetica", '', 7)
489
+ if num_cols > 15: pdf.set_font_size(5)
490
+ elif num_cols > 10: pdf.set_font_size(6)
491
+ for _, row in table_df.iterrows():
492
+ for val in row: pdf.cell(col_width, 10, str(format_number(val, decimals)), border=1, align='R')
493
+ pdf.ln()
494
+ if image_list:
495
+ for i, fig in enumerate(image_list):
496
+ pdf.add_page()
497
+ pdf.set_font("Helvetica", 'B', 12)
498
+ pdf.cell(0, 10, f'Gráfico {i+1}', new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
499
+ pdf.ln(5)
500
+ buf = io.BytesIO()
501
+ if isinstance(fig, go.Figure): buf.write(fig.to_image(format="png", scale=2, engine="kaleido"))
502
+ elif isinstance(fig, plt.Figure): fig.savefig(buf, format='png', dpi=200, bbox_inches='tight'); plt.close(fig)
503
+ elif isinstance(fig, Image.Image): fig.save(buf, 'PNG')
504
+ else: continue
505
+ buf.seek(0)
506
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp_img:
507
+ tmp_img.write(buf.read())
508
+ pdf.image(tmp_img.name, x=None, y=None, w=pdf.w - 20)
509
+ os.remove(tmp_img.name)
510
+ pdf_bytes = pdf.output()
511
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
512
+ tmp.write(pdf_bytes)
513
+ return tmp.name
514
+ except Exception as e:
515
+ traceback.print_exc()
516
+ gr.Error(f"Error al crear el reporte PDF: {e}")
517
+ return None
518
+
519
+ # --- FUNCIÓN PRINCIPAL DE ANÁLISIS ---
520
+ def run_analysis(file, model_names, mode, engine, exp_names, settings):
521
+ if not file: return [], pd.DataFrame(), "Error: Sube un archivo Excel.", pd.DataFrame()
522
+ if not model_names: return [], pd.DataFrame(), "Error: Selecciona un modelo.", pd.DataFrame()
523
+ try: xls = pd.ExcelFile(file.name)
524
+ except Exception as e: return [], pd.DataFrame(), f"Error al leer archivo: {e}", pd.DataFrame()
525
+ figs, results_data, msgs = [], [], []
526
+ exp_list = [n.strip() for n in exp_names.split('\n') if n.strip()]
527
+ for i, sheet in enumerate(xls.sheet_names):
528
+ exp_name = exp_list[i] if i < len(exp_list) else f"Hoja '{sheet}'"
529
  try:
530
+ df = pd.read_excel(xls, sheet_name=sheet, header=[0,1])
531
+ reader = BioprocessFitter(list(AVAILABLE_MODELS.values())[0])
532
+ reader.process_data_from_df(df)
533
+ if reader.data_time is None: msgs.append(f"WARN: Sin datos de tiempo en '{sheet}'."); continue
534
+ cfg = settings.copy(); cfg.update({'exp_name':exp_name, 'time_exp':reader.data_time})
535
+ for c in COMPONENTS: cfg[f'{c}_exp'], cfg[f'{c}_std'] = reader.data_means[c], reader.data_stds[c]
536
+ t_fine, plot_results = reader._generate_fine_time_grid(reader.data_time), []
537
+ for m_name in model_names:
538
+ if m_name not in AVAILABLE_MODELS: msgs.append(f"WARN: Modelo '{m_name}' no disponible."); continue
539
+ fitter = BioprocessFitter(AVAILABLE_MODELS[m_name], maxfev=int(settings.get('maxfev',50000)))
540
+ fitter.data_time, fitter.data_means, fitter.data_stds = reader.data_time, reader.data_means, reader.data_stds
541
+ fitter.fit_all_models()
542
+ row = {'Experimento':exp_name, 'Modelo':fitter.model.display_name}
543
+ for c in COMPONENTS:
544
+ if fitter.params[c]: row.update({f'{c.capitalize()}_{k}':v for k,v in fitter.params[c].items()})
545
+ row[f'R2_{c.capitalize()}'], row[f'RMSE_{c.capitalize()}'] = fitter.r2.get(c), fitter.rmse.get(c)
546
+ results_data.append(row)
547
+ if mode in ["average","combined"]:
548
+ if hasattr(fitter,'plot_individual_or_combined'): figs.append(fitter.plot_individual_or_combined(cfg,mode))
549
+ else:
550
+ X,S,P = fitter.get_model_curves_for_plot(t_fine, settings.get('use_differential',False))
551
+ plot_results.append({'name':m_name, 'X':X, 'S':S, 'P':P, 'params':fitter.params, 'r2':fitter.r2, 'rmse':fitter.rmse})
552
+ if mode=="model_comparison" and plot_results:
553
+ plot_func = plot_model_comparison_plotly if engine=='Plotly (Interactivo)' else plot_model_comparison_matplotlib
554
+ if 'plot_model_comparison_plotly' in globals(): figs.append(plot_func(cfg, plot_results))
555
+ except Exception as e: msgs.append(f"ERROR en '{sheet}': {e}"); traceback.print_exc()
556
+ msg = "Análisis completado."+("\n"+"\n".join(msgs) if msgs else "")
557
+ df_res = pd.DataFrame(results_data).dropna(axis=1,how='all')
558
+ if not df_res.empty:
559
+ id_c, p_c, m_c = ['Experimento','Modelo'], sorted([c for c in df_res.columns if '_' in c and 'R2' not in c and 'RMSE' not in c]), sorted([c for c in df_res.columns if 'R2' in c or 'RMSE' in c])
560
+ df_res = df_res[[c for c in id_c+p_c+m_c if c in df_res.columns]]
561
+ df_ui = df_res.copy()
562
+ for c in df_ui.select_dtypes(include=np.number).columns: df_ui[c] = df_ui[c].apply(lambda x:format_number(x,settings.get('decimal_places',3)) if pd.notna(x) else '')
563
+ else: df_ui = pd.DataFrame()
564
+ return figs, df_ui, msg, df_res
565
+
566
+ # --- INTERFAZ DE USUARIO DE GRADIO (COMPLETA) ---
567
+
568
+ def create_gradio_interface() -> gr.Blocks:
569
+ """
570
+ Crea y configura la interfaz de usuario completa con Gradio.
571
+ """
572
+ # Obtener las opciones de modelo dinámicamente del registro
573
+ MODEL_CHOICES = [(model.display_name, model.name) for model in AVAILABLE_MODELS.values()]
574
+ # Seleccionar por defecto los primeros 3 modelos o todos si hay menos de 3
575
+ DEFAULT_MODELS = [m.name for m in list(AVAILABLE_MODELS.values())[:3]]
576
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
577
  with gr.Blocks(theme=gr.themes.Soft(primary_hue="blue", secondary_hue="sky")) as demo:
578
  gr.Markdown("# 🔬 Analizador de Cinéticas de Bioprocesos")
579
+ gr.Markdown("Sube tus datos, selecciona modelos, personaliza los gráficos y exporta los resultados.")
580
+
581
+ with gr.Tabs():
582
+ # --- PESTAÑA 1: GUÍA Y FORMATO ---
583
+ with gr.TabItem("1. Guía y Formato de Datos"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
584
  with gr.Row():
585
  with gr.Column(scale=2):
586
+ gr.Markdown(
587
+ """
588
+ ### Bienvenido al Analizador de Cinéticas
589
+ Esta herramienta te permite ajustar modelos matemáticos a tus datos de crecimiento microbiano.
590
+
591
+ **Pasos a seguir:**
592
+ 1. Prepara tu archivo Excel según el formato especificado a la derecha.
593
+ 2. Ve a la pestaña **"2. Configuración y Ejecución"**.
594
+ 3. Sube tu archivo y selecciona los modelos cinéticos que deseas probar.
595
+ 4. Ajusta las opciones de visualización y análisis según tus preferencias.
596
+ 5. Haz clic en **"Analizar y Graficar"**.
597
+ 6. Explora los resultados en la pestaña **"3. Resultados"**.
598
+
599
+ ### Fórmulas de los Modelos
600
+ - **Logístico:** $ X(t) = \\frac{X_0 X_m e^{\\mu_m t}}{X_m - X_0 + X_0 e^{\\mu_m t}} $
601
+ - **Gompertz:** $ X(t) = X_m \\exp\\left(-\\exp\\left(\\frac{\\mu_m e}{X_m}(\\lambda-t)+1\\right)\\right) $
602
+ - **Moser:** $X(t) = X_m (1 - e^{-\\mu_m (t - K_s)})$
603
+ """
604
  )
605
  with gr.Column(scale=3):
606
+ gr.Markdown("### Formato del Archivo Excel")
607
+ gr.Markdown("Usa una **cabecera de dos niveles** para tus datos. La primera fila es el nombre de la réplica (ej. 'Rep1', 'Rep2') y la segunda el tipo de dato ('Tiempo', 'Biomasa', 'Sustrato', 'Producto').")
608
+ df_ejemplo = pd.DataFrame({
609
+ ('Rep1', 'Tiempo'): [0, 2, 4, 6], ('Rep1', 'Biomasa'): [0.1, 0.5, 2.5, 5.0], ('Rep1', 'Sustrato'): [10.0, 9.5, 7.0, 2.0],
610
+ ('Rep2', 'Tiempo'): [0, 2, 4, 6], ('Rep2', 'Biomasa'): [0.12, 0.48, 2.6, 5.2], ('Rep2', 'Sustrato'): [10.2, 9.6, 7.1, 2.1],
611
+ })
612
+ gr.DataFrame(df_ejemplo, interactive=False, label="Ejemplo de Formato")
613
+
614
+ # --- PESTAÑA 2: CONFIGURACIÓN Y EJECUCIÓN ---
615
+ with gr.TabItem("2. Configuración y Ejecución"):
616
+ with gr.Row():
617
+ with gr.Column(scale=1):
618
+ file_input = gr.File(label="Sube tu archivo Excel (.xlsx)", file_types=['.xlsx'])
619
+ exp_names_input = gr.Textbox(label="Nombres de Experimentos (opcional)", placeholder="Nombre Hoja 1\nNombre Hoja 2\n...", lines=3, info="Un nombre por línea, en el mismo orden que las hojas del Excel.")
620
+ model_selection_input = gr.CheckboxGroup(choices=MODEL_CHOICES, label="Modelos a Probar", value=DEFAULT_MODELS)
621
+ analysis_mode_input = gr.Radio(["average", "combined", "model_comparison"], label="Modo de Análisis", value="average", info="Average: Gráficos separados.\nCombined: Un gráfico con 3 ejes.\nComparación: Gráfico global comparativo.")
622
+ plotting_engine_input = gr.Radio(["Seaborn (Estático)", "Plotly (Interactivo)"], label="Motor Gráfico (en modo Comparación)", value="Plotly (Interactivo)")
623
+
624
+ with gr.Column(scale=2):
625
+ with gr.Accordion("Opciones Generales de Análisis", open=True):
626
+ decimal_places_input = gr.Slider(0, 10, value=3, step=1, label="Precisión Decimal de Parámetros", info="0 para notación científica automática.")
627
+ show_params_input = gr.Checkbox(label="Mostrar Parámetros en Gráfico", value=True)
628
+ show_legend_input = gr.Checkbox(label="Mostrar Leyenda en Gráfico", value=True)
629
+ use_differential_input = gr.Checkbox(label="Usar EDO para graficar", value=False, info="Simula con ecuaciones diferenciales en lugar de la fórmula integral.")
630
+ maxfev_input = gr.Number(label="Iteraciones Máximas de Ajuste (maxfev)", value=50000)
631
+
632
+ with gr.Accordion("Etiquetas de los Ejes", open=True):
633
+ with gr.Row(): xlabel_input = gr.Textbox(label="Etiqueta Eje X", value="Tiempo (h)", interactive=True)
634
+ with gr.Row():
635
+ ylabel_biomass_input = gr.Textbox(label="Etiqueta Biomasa", value="Biomasa (g/L)", interactive=True)
636
+ ylabel_substrate_input = gr.Textbox(label="Etiqueta Sustrato", value="Sustrato (g/L)", interactive=True)
637
+ ylabel_product_input = gr.Textbox(label="Etiqueta Producto", value="Producto (g/L)", interactive=True)
638
+
639
+ with gr.Accordion("Opciones de Estilo (Modo 'Average' y 'Combined')", open=False):
640
+ style_input = gr.Dropdown(['whitegrid', 'darkgrid', 'white', 'dark', 'ticks'], label="Estilo General (Matplotlib)", value='whitegrid')
641
+ with gr.Row():
642
+ with gr.Column():
643
+ gr.Markdown("**Biomasa**"); biomass_point_color_input = gr.ColorPicker(label="Color Puntos", value='#0072B2'); biomass_line_color_input = gr.ColorPicker(label="Color Línea", value='#56B4E9'); biomass_marker_style_input = gr.Dropdown(['o','s','^','D','p','*','X'], label="Marcador", value='o'); biomass_line_style_input = gr.Dropdown(['-','--','-.',':'], label="Estilo Línea", value='-')
644
+ with gr.Column():
645
+ gr.Markdown("**Sustrato**"); substrate_point_color_input = gr.ColorPicker(label="Color Puntos", value='#009E73'); substrate_line_color_input = gr.ColorPicker(label="Color Línea", value='#34E499'); substrate_marker_style_input = gr.Dropdown(['o','s','^','D','p','*','X'], label="Marcador", value='s'); substrate_line_style_input = gr.Dropdown(['-','--','-.',':'], label="Estilo Línea", value='--')
646
+ with gr.Column():
647
+ gr.Markdown("**Producto**"); product_point_color_input = gr.ColorPicker(label="Color Puntos", value='#D55E00'); product_line_color_input = gr.ColorPicker(label="Color Línea", value='#F0E442'); product_marker_style_input = gr.Dropdown(['o','s','^','D','p','*','X'], label="Marcador", value='^'); product_line_style_input = gr.Dropdown(['-','--','-.',':'], label="Estilo Línea", value='-.')
648
+ with gr.Row():
649
+ legend_pos_input = gr.Radio(["best","upper right","upper left","lower left","lower right","center"], label="Posición Leyenda", value="best")
650
+ params_pos_input = gr.Radio(["upper right","upper left","lower right","lower left"], label="Posición Parámetros", value="upper right")
651
+
652
+ with gr.Accordion("Opciones de Barra de Error", open=False):
653
+ show_error_bars_input = gr.Checkbox(label="Mostrar barras de error (si hay réplicas)", value=True)
654
+ error_cap_size_input = gr.Slider(1, 10, 3, step=1, label="Tamaño Tapa Error")
655
+ error_line_width_input = gr.Slider(0.5, 5, 1.0, step=0.5, label="Grosor Línea Error")
656
+
657
+ simulate_btn = gr.Button("Analizar y Graficar", variant="primary")
658
+
659
+ # --- PESTAÑA 3: RESULTADOS ---
660
+ with gr.TabItem("3. Resultados"):
661
+ status_output = gr.Textbox(label="Estado del Análisis", interactive=False, lines=2)
662
+ gallery_output = gr.Gallery(label="Gráficos Generados", columns=1, height=600, object_fit="contain", preview=True)
663
+ with gr.Accordion("Descargar Reportes y Gráficos", open=True):
664
  with gr.Row():
665
+ zip_btn = gr.Button("Descargar Gráficos (.zip)"); word_btn = gr.Button("Descargar Reporte (.docx)"); pdf_btn = gr.Button("Descargar Reporte (.pdf)")
666
+ download_output = gr.File(label="Archivo de Descarga", interactive=False)
667
+ gr.Markdown("### Tabla de Resultados Numéricos"); table_output = gr.DataFrame(wrap=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
668
  with gr.Row():
669
+ excel_btn = gr.Button("Descargar Tabla (.xlsx)"); csv_btn = gr.Button("Descargar Tabla (.csv)")
670
+ download_table_output = gr.File(label="Descargar Tabla", interactive=False)
671
+ df_for_export = gr.State(pd.DataFrame()); figures_for_export = gr.State([])
672
+
673
+ # --- LÓGICA DE CONEXIÓN (WRAPPER Y EVENTOS .CLICK()) ---
674
+ demo.queue()
675
+
676
+ def simulation_wrapper(file, models, mode, engine, names, use_diff, s_par, s_leg, maxfev, decimals, x_label, bio_label, sub_label, prod_label, style, s_err, cap, lw, l_pos, p_pos, bio_pc, bio_lc, bio_ms, bio_ls, sub_pc, sub_lc, sub_ms, sub_ls, prod_pc, prod_lc, prod_ms, prod_ls):
677
+ try:
678
+ def rgba_to_hex(rgba_string: str) -> str:
679
+ if not isinstance(rgba_string, str) or rgba_string.startswith('#'): return rgba_string
680
+ try:
681
+ parts = rgba_string.lower().replace('rgba', '').replace('rgb', '').replace('(', '').replace(')', '')
682
+ r, g, b, *_ = map(float, parts.split(',')); return f'#{int(r):02x}{int(g):02x}{int(b):02x}'
683
+ except (ValueError, TypeError): return "#000000"
684
+
685
+ plot_settings = {
686
+ 'decimal_places': int(decimals), 'use_differential': use_diff, 'style': style, 'show_legend': s_leg, 'show_params': s_par, 'maxfev': int(maxfev),
687
+ 'axis_labels': {'x_label': x_label, 'biomass_label': bio_label, 'substrate_label': sub_label, 'product_label': prod_label},
688
+ 'legend_pos': l_pos, 'params_pos': p_pos, 'show_error_bars': s_err, 'error_cap_size': cap, 'error_line_width': lw,
689
+ f'{C_BIOMASS}_point_color': rgba_to_hex(bio_pc), f'{C_BIOMASS}_line_color': rgba_to_hex(bio_lc), f'{C_BIOMASS}_marker_style': bio_ms, f'{C_BIOMASS}_line_style': bio_ls,
690
+ f'{C_SUBSTRATE}_point_color': rgba_to_hex(sub_pc), f'{C_SUBSTRATE}_line_color': rgba_to_hex(sub_lc), f'{C_SUBSTRATE}_marker_style': sub_ms, f'{C_SUBSTRATE}_line_style': sub_ls,
691
+ f'{C_PRODUCT}_point_color': rgba_to_hex(prod_pc), f'{C_PRODUCT}_line_color': rgba_to_hex(prod_lc), f'{C_PRODUCT}_marker_style': prod_ms, f'{C_PRODUCT}_line_style': prod_ls,
692
+ }
693
+ figures, df_ui, msg, df_export = run_analysis(file, models, mode, engine, names, plot_settings)
694
+ image_list = []
695
+ for fig in figures:
696
+ buf = io.BytesIO()
697
+ if isinstance(fig, go.Figure): buf.write(fig.to_image(format="png", scale=2, engine="kaleido"))
698
+ elif isinstance(fig, plt.Figure): fig.savefig(buf, format='png', bbox_inches='tight', dpi=150); plt.close(fig)
699
+ buf.seek(0); image_list.append(Image.open(buf).convert("RGB"))
700
+ return image_list, df_ui, msg, df_export, figures
701
+ except Exception as e:
702
+ print(f"--- ERROR CAPTURADO EN WRAPPER ---\n{traceback.format_exc()}"); return [], pd.DataFrame(), f"Error Crítico: {e}", pd.DataFrame(), []
703
+
704
+ all_inputs = [
705
+ file_input, model_selection_input, analysis_mode_input, plotting_engine_input, exp_names_input,
706
+ use_differential_input, show_params_input, show_legend_input, maxfev_input, decimal_places_input,
707
+ xlabel_input, ylabel_biomass_input, ylabel_substrate_input, ylabel_product_input,
708
+ style_input, show_error_bars_input, error_cap_size_input, error_line_width_input, legend_pos_input, params_pos_input,
709
+ biomass_point_color_input, biomass_line_color_input, biomass_marker_style_input, biomass_line_style_input,
710
+ substrate_point_color_input, substrate_line_color_input, substrate_marker_style_input, substrate_line_style_input,
711
+ product_point_color_input, product_line_color_input, product_marker_style_input, product_line_style_input
712
+ ]
713
+ all_outputs = [gallery_output, table_output, status_output, df_for_export, figures_for_export]
714
+
715
+ simulate_btn.click(fn=simulation_wrapper, inputs=all_inputs, outputs=all_outputs)
716
+ zip_btn.click(fn=create_zip_file, inputs=[figures_for_export], outputs=[download_output])
717
+ word_btn.click(fn=create_word_report, inputs=[figures_for_export, df_for_export, decimal_places_input], outputs=[download_output])
718
+ pdf_btn.click(fn=create_pdf_report, inputs=[figures_for_export, df_for_export, decimal_places_input], outputs=[download_output])
719
+
720
+ def export_table_to_file(df: pd.DataFrame, file_format: str) -> Optional[str]:
721
+ if df is None or df.empty: gr.Warning("No hay datos para exportar."); return None
722
  suffix = ".xlsx" if file_format == "excel" else ".csv"
723
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
724
+ if file_format == "excel": df.to_excel(tmp.name, index=False)
725
+ else: df.to_csv(tmp.name, index=False, encoding='utf-8-sig')
726
+ return tmp.name
727
+
728
+ excel_btn.click(fn=lambda df: export_table_to_file(df, "excel"), inputs=[df_for_export], outputs=[download_table_output])
729
+ csv_btn.click(fn=lambda df: export_table_to_file(df, "csv"), inputs=[df_for_export], outputs=[download_table_output])
730
+
 
 
731
  return demo
732
 
733
+ # --- PUNTO DE ENTRADA PRINCIPAL ---
734
+
735
  if __name__ == '__main__':
736
+ """
737
+ Este bloque se ejecuta solo cuando el script es llamado directamente.
738
+ Crea la interfaz de Gradio y la lanza, haciendo que la aplicación
739
+ esté disponible en una URL local (y opcionalmente pública si share=True).
740
+ """
741
+
742
+ # Todas las funciones necesarias (create_gradio_interface, run_analysis,
743
+ # funciones de ploteo y reporte) ya están definidas en el alcance global,
744
+ # por lo que no es necesario rellenar nada aquí.
745
+
746
+ # Crear la aplicación Gradio llamando a la función que la construye.
747
  gradio_app = create_gradio_interface()
748
+
749
+ # Lanzar la aplicación.
750
+ # share=True: Crea un túnel público temporal a tu aplicación (útil para compartir).
751
+ # debug=True: Muestra más información de depuración en la consola si ocurren errores.
752
  gradio_app.launch(share=True, debug=True)