Update app.py
Browse files
app.py
CHANGED
@@ -1,10 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
import os
|
2 |
import io
|
3 |
import tempfile
|
4 |
-
|
5 |
-
|
6 |
-
|
|
|
|
|
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 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
self.
|
32 |
-
self.
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
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 |
-
|
57 |
-
def
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
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
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
if
|
116 |
-
return
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
def
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
def
|
127 |
-
if
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
return
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
149 |
integral_X = np.zeros_like(X_t)
|
150 |
-
if len(
|
151 |
-
dt = np.diff(
|
152 |
integral_X = np.cumsum(X_t * dt)
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
|
|
160 |
try:
|
161 |
-
time_col = [
|
162 |
-
self.data_time = df[time_col].dropna().
|
163 |
-
|
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 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
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 |
-
|
204 |
-
|
205 |
-
|
206 |
-
if np.any(np.isnan(
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
220 |
-
|
221 |
-
|
222 |
-
|
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 |
-
|
280 |
-
|
281 |
-
|
282 |
-
|
283 |
-
|
284 |
-
|
285 |
-
|
286 |
-
|
287 |
-
|
288 |
-
return
|
289 |
-
|
290 |
-
|
291 |
-
|
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 |
-
|
329 |
-
|
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
|
343 |
-
|
344 |
-
|
345 |
-
|
346 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
347 |
|
348 |
-
|
349 |
-
|
350 |
-
|
351 |
-
|
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 |
-
|
365 |
-
print(f"Advertencia: EDO no soportada para {self.model_type}. Mostrando ajuste directo.")
|
366 |
|
367 |
-
|
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 |
-
|
514 |
-
|
515 |
-
|
516 |
-
|
517 |
-
|
518 |
-
|
519 |
-
|
520 |
-
|
521 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
522 |
try:
|
523 |
-
|
524 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
525 |
except Exception as e:
|
526 |
-
|
527 |
-
|
528 |
-
|
529 |
-
|
530 |
-
|
531 |
-
|
532 |
-
|
533 |
-
|
534 |
-
|
535 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
536 |
try:
|
537 |
-
df = pd.read_excel(xls, sheet_name=
|
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 |
-
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
|
618 |
-
|
619 |
-
with gr.Tabs()
|
620 |
-
|
621 |
-
|
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 |
-
|
649 |
-
|
650 |
-
|
651 |
-
|
652 |
-
|
653 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
654 |
)
|
655 |
with gr.Column(scale=3):
|
656 |
-
gr.Markdown("
|
657 |
-
|
658 |
-
|
659 |
-
|
660 |
-
|
661 |
-
|
662 |
-
|
663 |
-
|
664 |
-
|
665 |
-
|
666 |
-
|
667 |
-
|
668 |
-
|
669 |
-
|
670 |
-
|
671 |
-
|
672 |
-
|
673 |
-
|
674 |
-
|
675 |
-
|
676 |
-
|
677 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
678 |
with gr.Row():
|
679 |
-
|
680 |
-
|
681 |
-
|
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 |
-
|
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 |
-
return
|
731 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
732 |
suffix = ".xlsx" if file_format == "excel" else ".csv"
|
733 |
-
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as
|
734 |
-
if file_format == "excel":
|
735 |
-
|
736 |
-
|
737 |
-
|
738 |
-
|
739 |
-
|
740 |
-
|
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)
|