C2MV commited on
Commit
14384f4
·
verified ·
1 Parent(s): d248e5f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +687 -677
app.py CHANGED
@@ -1,7 +1,7 @@
1
- #import os
2
- #!pip install gradio seaborn scipy scikit-learn openpyxl pydantic==1.10.0 -q
3
 
4
- from pydantic import BaseModel, ConfigDict
5
  import numpy as np
6
  import pandas as pd
7
  import matplotlib.pyplot as plt
@@ -14,7 +14,20 @@ import io
14
  from PIL import Image
15
  import tempfile
16
 
17
- class YourModel(BaseModel):
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  class Config:
19
  arbitrary_types_allowed = True
20
 
@@ -36,17 +49,22 @@ class BioprocessModel:
36
  self.biomass_diff = None
37
  self.model_type = model_type
38
  self.maxfev = maxfev
 
39
 
40
  @staticmethod
41
  def logistic(time, xo, xm, um):
42
- return (xo * np.exp(um * time)) / (1 - (xo / xm) * (1 - np.exp(um * time)))
 
 
 
 
43
 
44
  @staticmethod
45
  def gompertz(time, xm, um, lag):
46
  return xm * np.exp(-np.exp((um * np.e / xm) * (lag - time) + 1))
47
 
48
  @staticmethod
49
- def moser(time, Xm, um, Ks):
50
  return Xm * (1 - np.exp(-um * (time - Ks)))
51
 
52
  @staticmethod
@@ -60,50 +78,83 @@ class BioprocessModel:
60
  return X * (um * np.e / xm) * np.exp((um * np.e / xm) * (lag - t) + 1)
61
 
62
  @staticmethod
63
- def moser_diff(X, t, params):
64
  Xm, um, Ks = params
65
- return um * (Xm - X)
66
-
67
- def substrate(self, time, so, p, q, biomass_params):
68
- X_t = self.biomass_model(time, *biomass_params)
69
- dXdt = np.gradient(X_t, time)
70
- integral_X = np.cumsum(X_t) * np.gradient(time)
71
- return so - p * (X_t - biomass_params[0]) - q * integral_X
72
 
73
- def product(self, time, po, alpha, beta, biomass_params):
74
- X_t = self.biomass_model(time, *biomass_params)
75
- dXdt = np.gradient(X_t, time)
76
- integral_X = np.cumsum(X_t) * np.gradient(time)
77
- return po + alpha * (X_t - biomass_params[0]) + beta * integral_X
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
  def process_data(self, df):
80
- biomass_cols = [col for col in df.columns if col[1] == 'Biomasa']
81
- substrate_cols = [col for col in df.columns if col[1] == 'Sustrato']
82
- product_cols = [col for col in df.columns if col[1] == 'Producto']
83
-
84
- time_col = [col for col in df.columns if col[1] == 'Tiempo'][0]
 
 
 
 
85
  time = df[time_col].values
86
 
87
- data_biomass = [df[col].values for col in biomass_cols]
88
- data_biomass = np.array(data_biomass)
89
- self.datax.append(data_biomass)
90
- self.dataxp.append(np.mean(data_biomass, axis=0))
91
- self.datax_std.append(np.std(data_biomass, axis=0, ddof=1))
92
-
93
- data_substrate = [df[col].values for col in substrate_cols]
94
- data_substrate = np.array(data_substrate)
95
- self.datas.append(data_substrate)
96
- self.datasp.append(np.mean(data_substrate, axis=0))
97
- self.datas_std.append(np.std(data_substrate, axis=0, ddof=1))
98
-
99
- data_product = [df[col].values for col in product_cols]
100
- data_product = np.array(data_product)
101
- self.datap.append(data_product)
102
- self.datapp.append(np.mean(data_product, axis=0))
103
- self.datap_std.append(np.std(data_product, axis=0, ddof=1))
 
 
 
 
 
 
 
 
 
 
104
 
105
  self.time = time
106
 
 
107
  def fit_model(self):
108
  if self.model_type == 'logistic':
109
  self.biomass_model = self.logistic
@@ -114,811 +165,770 @@ class BioprocessModel:
114
  elif self.model_type == 'moser':
115
  self.biomass_model = self.moser
116
  self.biomass_diff = self.moser_diff
 
 
117
 
118
- def fit_biomass(self, time, biomass):
119
- try:
120
- if self.model_type == 'logistic':
121
- p0 = [min(biomass), max(biomass)*1.5 if max(biomass)>0 else 1.0, 0.1]
122
- popt, _ = curve_fit(self.logistic, time, biomass, p0=p0, maxfev=self.maxfev)
123
- self.params['biomass'] = {'xo': popt[0], 'xm': popt[1], 'um': popt[2]}
124
- y_pred = self.logistic(time, *popt)
125
- elif self.model_type == 'gompertz':
126
- p0 = [max(biomass) if max(biomass)>0 else 1.0, 0.1, time[np.argmax(np.gradient(biomass))]]
127
- popt, _ = curve_fit(self.gompertz, time, biomass, p0=p0, maxfev=self.maxfev)
128
- self.params['biomass'] = {'xm': popt[0], 'um': popt[1], 'lag': popt[2]}
129
- y_pred = self.gompertz(time, *popt)
130
- elif self.model_type == 'moser':
131
- p0 = [max(biomass) if max(biomass)>0 else 1.0, 0.1, min(time)]
132
- popt, _ = curve_fit(self.moser, time, biomass, p0=p0, maxfev=self.maxfev)
133
- self.params['biomass'] = {'Xm': popt[0], 'um': popt[1], 'Ks': popt[2]}
134
- y_pred = self.moser(time, *popt)
135
 
136
- self.r2['biomass'] = 1 - (np.sum((biomass - y_pred) ** 2) / np.sum((biomass - np.mean(biomass)) ** 2))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  self.rmse['biomass'] = np.sqrt(mean_squared_error(biomass, y_pred))
138
  return y_pred
139
  except Exception as e:
140
  print(f"Error en fit_biomass_{self.model_type}: {e}")
 
141
  return None
142
 
143
- def fit_substrate(self, time, substrate, biomass_params):
144
- try:
145
- if self.model_type == 'logistic':
146
- p0 = [min(substrate), 0.01, 0.01]
147
- popt, _ = curve_fit(
148
- lambda t, so, p, q: self.substrate(t, so, p, q, [biomass_params['xo'], biomass_params['xm'], biomass_params['um']]),
149
- time, substrate, p0=p0, maxfev=self.maxfev
150
- )
151
- self.params['substrate'] = {'so': popt[0], 'p': popt[1], 'q': popt[2]}
152
- y_pred = self.substrate(time, *popt, [biomass_params['xo'], biomass_params['xm'], biomass_params['um']])
153
- elif self.model_type == 'gompertz':
154
- p0 = [min(substrate), 0.01, 0.01]
155
- popt, _ = curve_fit(
156
- lambda t, so, p, q: self.substrate(t, so, p, q, [biomass_params['xm'], biomass_params['um'], biomass_params['lag']]),
157
- time, substrate, p0=p0, maxfev=self.maxfev
158
- )
159
- self.params['substrate'] = {'so': popt[0], 'p': popt[1], 'q': popt[2]}
160
- y_pred = self.substrate(time, *popt, [biomass_params['xm'], biomass_params['um'], biomass_params['lag']])
161
- elif self.model_type == 'moser':
162
- p0 = [min(substrate), 0.01, 0.01]
163
- popt, _ = curve_fit(
164
- lambda t, so, p, q: self.substrate(t, so, p, q, [biomass_params['Xm'], biomass_params['um'], biomass_params['Ks']]),
165
- time, substrate, p0=p0, maxfev=self.maxfev
166
- )
167
- self.params['substrate'] = {'so': popt[0], 'p': popt[1], 'q': popt[2]}
168
- y_pred = self.substrate(time, *popt, [biomass_params['Xm'], biomass_params['um'], biomass_params['Ks']])
169
- self.r2['substrate'] = 1 - (np.sum((substrate - y_pred) ** 2) / np.sum((substrate - np.mean(substrate)) ** 2))
170
- self.rmse['substrate'] = np.sqrt(mean_squared_error(substrate, y_pred))
171
- return y_pred
172
- except Exception as e:
173
- print(f"Error en fit_substrate_{self.model_type}: {e}")
174
  return None
175
 
176
- def fit_product(self, time, product, biomass_params):
 
177
  try:
178
- if self.model_type == 'logistic':
179
- p0 = [min(product), 0.01, 0.01]
180
- popt, _ = curve_fit(
181
- lambda t, po, alpha, beta: self.product(t, po, alpha, beta, [biomass_params['xo'], biomass_params['xm'], biomass_params['um']]),
182
- time, product, p0=p0, maxfev=self.maxfev
183
- )
184
- self.params['product'] = {'po': popt[0], 'alpha': popt[1], 'beta': popt[2]}
185
- y_pred = self.product(time, *popt, [biomass_params['xo'], biomass_params['xm'], biomass_params['um']])
186
- elif self.model_type == 'gompertz':
187
- p0 = [min(product), 0.01, 0.01]
188
- popt, _ = curve_fit(
189
- lambda t, po, alpha, beta: self.product(t, po, alpha, beta, [biomass_params['xm'], biomass_params['um'], biomass_params['lag']]),
190
- time, product, p0=p0, maxfev=self.maxfev
191
- )
192
- self.params['product'] = {'po': popt[0], 'alpha': popt[1], 'beta': popt[2]}
193
- y_pred = self.product(time, *popt, [biomass_params['xm'], biomass_params['um'], biomass_params['lag']])
194
- elif self.model_type == 'moser':
195
- p0 = [min(product), 0.01, 0.01]
196
- popt, _ = curve_fit(
197
- lambda t, po, alpha, beta: self.product(t, po, alpha, beta, [biomass_params['Xm'], biomass_params['um'], biomass_params['Ks']]),
198
- time, product, p0=p0, maxfev=self.maxfev
199
- )
200
- self.params['product'] = {'po': popt[0], 'alpha': popt[1], 'beta': popt[2]}
201
- y_pred = self.product(time, *popt, [biomass_params['Xm'], biomass_params['um'], biomass_params['Ks']])
202
- self.r2['product'] = 1 - (np.sum((product - y_pred) ** 2) / np.sum((product - np.mean(product)) ** 2))
203
- self.rmse['product'] = np.sqrt(mean_squared_error(product, y_pred))
204
  return y_pred
205
  except Exception as e:
206
- print(f"Error en fit_product_{self.model_type}: {e}")
 
207
  return None
208
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  def generate_fine_time_grid(self, time):
 
210
  time_fine = np.linspace(time.min(), time.max(), 500)
211
  return time_fine
212
 
213
- def system(self, y, t, biomass_params, substrate_params, product_params, model_type):
 
214
  X, S, P = y
215
 
216
- if model_type == 'logistic':
217
- dXdt = self.logistic_diff(X, t, biomass_params)
218
- elif model_type == 'gompertz':
219
- dXdt = self.gompertz_diff(X, t, biomass_params)
220
- elif model_type == 'moser':
221
- dXdt = self.moser_diff(X, t, biomass_params)
222
  else:
223
- dXdt = 0.0
224
-
225
- so, p, q = substrate_params
226
- po, alpha, beta = product_params
 
 
 
227
 
228
  dSdt = -p * dXdt - q * X
229
  dPdt = alpha * dXdt + beta * X
230
  return [dXdt, dSdt, dPdt]
231
 
232
  def get_initial_conditions(self, time, biomass, substrate, product):
233
- if 'biomass' in self.params:
 
 
234
  if self.model_type == 'logistic':
235
- xo = self.params['biomass']['xo']
236
- X0 = xo
237
  elif self.model_type == 'gompertz':
238
- xm = self.params['biomass']['xm']
239
- um = self.params['biomass']['um']
240
- lag = self.params['biomass']['lag']
241
- X0 = xm * np.exp(-np.exp((um * np.e / xm)*(lag - 0)+1))
242
  elif self.model_type == 'moser':
243
- Xm = self.params['biomass']['Xm']
244
- um = self.params['biomass']['um']
245
- Ks = self.params['biomass']['Ks']
246
- X0 = Xm*(1 - np.exp(-um*(0 - Ks)))
247
- else:
248
- X0 = biomass[0]
249
-
250
- if 'substrate' in self.params:
251
- so = self.params['substrate']['so']
252
- S0 = so
253
- else:
254
- S0 = substrate[0]
255
-
256
- if 'product' in self.params:
257
- po = self.params['product']['po']
258
- P0 = po
259
- else:
260
- P0 = product[0]
261
 
262
  return [X0, S0, P0]
263
 
 
264
  def solve_differential_equations(self, time, biomass, substrate, product):
265
- if 'biomass' not in self.params or not self.params['biomass']:
 
266
  print("No hay parámetros de biomasa, no se pueden resolver las EDO.")
267
  return None, None, None, time
268
 
269
- if self.model_type == 'logistic':
270
- biomass_params = [self.params['biomass']['xo'], self.params['biomass']['xm'], self.params['biomass']['um']]
271
- elif self.model_type == 'gompertz':
272
- biomass_params = [self.params['biomass']['xm'], self.params['biomass']['um'], self.params['biomass']['lag']]
273
- elif self.model_type == 'moser':
274
- biomass_params = [self.params['biomass']['Xm'], self.params['biomass']['um'], self.params['biomass']['Ks']]
275
- else:
276
- biomass_params = [0,0,0]
277
-
278
- if 'substrate' in self.params:
279
- substrate_params = [self.params['substrate']['so'], self.params['substrate']['p'], self.params['substrate']['q']]
280
- else:
281
- substrate_params = [0,0,0]
282
-
283
- if 'product' in self.params:
284
- product_params = [self.params['product']['po'], self.params['product']['alpha'], self.params['product']['beta']]
285
- else:
286
- product_params = [0,0,0]
287
-
288
  initial_conditions = self.get_initial_conditions(time, biomass, substrate, product)
289
  time_fine = self.generate_fine_time_grid(time)
290
- sol = odeint(self.system, initial_conditions, time_fine,
291
- args=(biomass_params, substrate_params, product_params, self.model_type))
292
-
293
- X = sol[:, 0]
294
- S = sol[:, 1]
295
- P = sol[:, 2]
 
 
 
296
 
297
- return X, S, P, time_fine
298
 
299
  def plot_results(self, time, biomass, substrate, product,
300
  y_pred_biomass, y_pred_substrate, y_pred_product,
301
  biomass_std=None, substrate_std=None, product_std=None,
302
  experiment_name='', legend_position='best', params_position='upper right',
303
- show_legend=True, show_params=True,
304
- style='whitegrid',
305
  line_color='#0000FF', point_color='#000000', line_style='-', marker_style='o',
306
- use_differential=False):
 
 
307
 
308
- if y_pred_biomass is None:
309
- print(f"No se pudo ajustar biomasa para {experiment_name} con {self.model_type}. Omitiendo figura.")
310
  return None
311
 
312
  sns.set_style(style)
313
-
314
- if use_differential and 'biomass' in self.params and self.params['biomass']:
315
- X, S, P, time_to_plot = self.solve_differential_equations(time, biomass, substrate, product)
316
- if X is not None:
317
- y_pred_biomass, y_pred_substrate, y_pred_product = X, S, P
318
- else:
319
- time_to_plot = time
320
- else:
321
- time_to_plot = time
322
-
 
 
 
 
323
  fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(10, 15))
324
- fig.suptitle(f'{experiment_name}', fontsize=16)
325
-
326
- plots = [
327
- (ax1, biomass, y_pred_biomass, biomass_std, 'Biomasa', 'Modelo', self.params.get('biomass', {}),
328
- self.r2.get('biomass', np.nan), self.rmse.get('biomass', np.nan)),
329
- (ax2, substrate, y_pred_substrate, substrate_std, 'Sustrato', 'Modelo', self.params.get('substrate', {}),
330
- self.r2.get('substrate', np.nan), self.rmse.get('substrate', np.nan)),
331
- (ax3, product, y_pred_product, product_std, 'Producto', 'Modelo', self.params.get('product', {}),
332
- self.r2.get('product', np.nan), self.rmse.get('product', np.nan))
 
 
 
333
  ]
334
 
335
- for idx, (ax, data, y_pred, data_std, ylabel, model_name, params, r2, rmse) in enumerate(plots):
336
- if data_std is not None:
337
  ax.errorbar(time, data, yerr=data_std, fmt=marker_style, color=point_color,
338
  label='Datos experimentales', capsize=5)
339
  else:
340
  ax.plot(time, data, marker=marker_style, linestyle='', color=point_color,
341
  label='Datos experimentales')
342
 
343
- if y_pred is not None:
344
- ax.plot(time_to_plot, y_pred, linestyle=line_style, color=line_color, label=model_name)
345
 
346
- ax.set_xlabel('Tiempo')
347
  ax.set_ylabel(ylabel)
348
  if show_legend:
349
  ax.legend(loc=legend_position)
350
- ax.set_title(f'{ylabel}')
351
-
352
- if show_params and params and all(np.isfinite(list(map(float, params.values())))):
353
- param_text = '\n'.join([f"{k} = {v:.3f}" for k, v in params.items()])
354
- text = f"{param_text}\nR² = {r2:.3f}\nRMSE = {rmse:.3f}"
355
- if params_position == 'outside right':
356
- bbox_props = dict(boxstyle='round', facecolor='white', alpha=0.5)
 
 
 
 
 
 
 
 
 
 
357
  ax.annotate(text, xy=(1.05, 0.5), xycoords='axes fraction',
358
- verticalalignment='center', bbox=bbox_props)
359
  else:
360
- if params_position in ['upper right', 'lower right']:
361
- text_x = 0.95
362
- ha = 'right'
363
- else:
364
- text_x = 0.05
365
- ha = 'left'
366
-
367
- if params_position in ['upper right', 'upper left']:
368
- text_y = 0.95
369
- va = 'top'
370
- else:
371
- text_y = 0.05
372
- va = 'bottom'
373
-
374
  ax.text(text_x, text_y, text, transform=ax.transAxes,
375
- verticalalignment=va, horizontalalignment=ha,
376
- bbox={'boxstyle': 'round', 'facecolor':'white', 'alpha':0.5})
377
 
378
- plt.tight_layout(rect=[0, 0.03, 1, 0.95])
379
 
 
 
380
  buf = io.BytesIO()
381
  fig.savefig(buf, format='png')
382
  buf.seek(0)
383
  image = Image.open(buf).convert("RGB")
384
  plt.close(fig)
385
-
386
  return image
387
 
 
388
  def plot_combined_results(self, time, biomass, substrate, product,
389
  y_pred_biomass, y_pred_substrate, y_pred_product,
390
  biomass_std=None, substrate_std=None, product_std=None,
391
  experiment_name='', legend_position='best', params_position='upper right',
392
- show_legend=True, show_params=True,
393
- style='whitegrid',
394
  line_color='#0000FF', point_color='#000000', line_style='-', marker_style='o',
395
- use_differential=False):
396
-
397
- if y_pred_biomass is None:
398
- print(f"No se pudo ajustar biomasa para {experiment_name} con {self.model_type}. Omitiendo figura.")
 
 
399
  return None
400
 
401
  sns.set_style(style)
 
402
 
403
- if use_differential and 'biomass' in self.params and self.params['biomass']:
404
- X, S, P, time_to_plot = self.solve_differential_equations(time, biomass, substrate, product)
405
- if X is not None:
406
- y_pred_biomass, y_pred_substrate, y_pred_product = X, S, P
 
407
  else:
408
- time_to_plot = time
409
- else:
410
- time_to_plot = time
411
 
412
- fig, ax1 = plt.subplots(figsize=(10, 7))
413
- fig.suptitle(f'{experiment_name}', fontsize=16)
 
 
 
 
 
 
414
 
415
  colors = {'Biomasa': 'blue', 'Sustrato': 'green', 'Producto': 'red'}
416
 
417
- ax1.set_xlabel('Tiempo')
418
- ax1.set_ylabel('Biomasa', color=colors['Biomasa'])
419
- if biomass_std is not None:
 
420
  ax1.errorbar(time, biomass, yerr=biomass_std, fmt=marker_style, color=colors['Biomasa'],
421
- label='Biomasa (Datos)', capsize=5)
422
  else:
423
  ax1.plot(time, biomass, marker=marker_style, linestyle='', color=colors['Biomasa'],
424
- label='Biomasa (Datos)')
425
- ax1.plot(time_to_plot, y_pred_biomass, linestyle=line_style, color=colors['Biomasa'],
426
- label='Biomasa (Modelo)')
 
427
  ax1.tick_params(axis='y', labelcolor=colors['Biomasa'])
428
 
 
429
  ax2 = ax1.twinx()
430
- ax2.set_ylabel('Sustrato', color=colors['Sustrato'])
431
- if substrate_std is not None:
432
- ax2.errorbar(time, substrate, yerr=substrate_std, fmt=marker_style, color=colors['Sustrato'],
433
- label='Sustrato (Datos)', capsize=5)
434
- else:
435
- ax2.plot(time, substrate, marker=marker_style, linestyle='', color=colors['Sustrato'],
436
- label='Sustrato (Datos)')
437
- if y_pred_substrate is not None:
 
438
  ax2.plot(time_to_plot, y_pred_substrate, linestyle=line_style, color=colors['Sustrato'],
439
- label='Sustrato (Modelo)')
440
  ax2.tick_params(axis='y', labelcolor=colors['Sustrato'])
441
 
 
442
  ax3 = ax1.twinx()
443
- ax3.spines["right"].set_position(("axes", 1.2))
444
  ax3.set_frame_on(True)
445
  ax3.patch.set_visible(False)
446
- for sp in ax3.spines.values():
447
- sp.set_visible(True)
448
-
449
- ax3.set_ylabel('Producto', color=colors['Producto'])
450
- if product_std is not None:
451
- ax3.errorbar(time, product, yerr=product_std, fmt=marker_style, color=colors['Producto'],
452
- label='Producto (Datos)', capsize=5)
453
- else:
454
- ax3.plot(time, product, marker=marker_style, linestyle='', color=colors['Producto'],
455
- label='Producto (Datos)')
456
- if y_pred_product is not None:
457
  ax3.plot(time_to_plot, y_pred_product, linestyle=line_style, color=colors['Producto'],
458
- label='Producto (Modelo)')
459
  ax3.tick_params(axis='y', labelcolor=colors['Producto'])
460
 
461
- lines_labels = [ax.get_legend_handles_labels() for ax in [ax1, ax2, ax3]]
462
- lines, labels = [sum(lol, []) for lol in zip(*lines_labels)]
463
  if show_legend:
464
- ax1.legend(lines, labels, loc=legend_position)
 
 
 
 
 
 
 
 
 
 
 
465
 
466
  if show_params:
467
- param_text_biomass = ''
468
- if 'biomass' in self.params:
469
- param_text_biomass = '\n'.join([f"{k} = {v:.3f}" for k, v in self.params['biomass'].items()])
470
- text_biomass = f"Biomasa:\n{param_text_biomass}\nR² = {self.r2.get('biomass', np.nan):.3f}\nRMSE = {self.rmse.get('biomass', np.nan):.3f}"
471
-
472
- param_text_substrate = ''
473
- if 'substrate' in self.params:
474
- param_text_substrate = '\n'.join([f"{k} = {v:.3f}" for k, v in self.params['substrate'].items()])
475
- text_substrate = f"Sustrato:\n{param_text_substrate}\nR² = {self.r2.get('substrate', np.nan):.3f}\nRMSE = {self.rmse.get('substrate', np.nan):.3f}"
476
-
477
- param_text_product = ''
478
- if 'product' in self.params:
479
- param_text_product = '\n'.join([f"{k} = {v:.3f}" for k, v in self.params['product'].items()])
480
- text_product = f"Producto:\n{param_text_product}\nR² = {self.r2.get('product', np.nan):.3f}\nRMSE = {self.rmse.get('product', np.nan):.3f}"
481
-
482
- total_text = f"{text_biomass}\n\n{text_substrate}\n\n{text_product}"
483
-
484
- if params_position == 'outside right':
485
- bbox_props = dict(boxstyle='round', facecolor='white', alpha=0.5)
486
- ax3.annotate(total_text, xy=(1.2, 0.5), xycoords='axes fraction',
487
- verticalalignment='center', bbox=bbox_props)
488
- else:
489
- if params_position in ['upper right', 'lower right']:
490
- text_x = 0.95
491
- ha = 'right'
492
- else:
493
- text_x = 0.05
494
- ha = 'left'
495
 
496
- if params_position in ['upper right', 'upper left']:
497
- text_y = 0.95
498
- va = 'top'
 
 
 
499
  else:
500
- text_y = 0.05
501
- va = 'bottom'
502
-
503
- ax1.text(text_x, text_y, total_text, transform=ax1.transAxes,
504
- verticalalignment=va, horizontalalignment=ha,
505
- bbox={'boxstyle':'round', 'facecolor':'white', 'alpha':0.5})
506
-
507
- plt.tight_layout(rect=[0, 0.03, 1, 0.95])
508
 
 
 
509
  buf = io.BytesIO()
510
  fig.savefig(buf, format='png')
511
  buf.seek(0)
512
  image = Image.open(buf).convert("RGB")
513
  plt.close(fig)
514
-
515
  return image
516
 
517
- def process_all_data(file, legend_position, params_position, model_types, experiment_names, lower_bounds, upper_bounds,
518
- mode='independent', style='whitegrid', line_color='#0000FF', point_color='#000000',
519
- line_style='-', marker_style='o', show_legend=True, show_params=True, use_differential=False, maxfev_val=50000):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
520
 
521
  try:
522
  xls = pd.ExcelFile(file.name)
523
  except Exception as e:
524
- print(f"Error al leer el archivo Excel: {e}")
525
- return [], pd.DataFrame()
526
 
527
  sheet_names = xls.sheet_names
528
- figures = []
529
- comparison_data = []
530
- experiment_counter = 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
531
 
532
  for sheet_name in sheet_names:
533
  try:
534
- df = pd.read_excel(file.name, sheet_name=sheet_name, header=[0, 1])
 
 
 
 
 
 
 
535
  except Exception as e:
536
  print(f"Error al leer la hoja '{sheet_name}': {e}")
537
  continue
538
 
539
- model_dummy = BioprocessModel()
540
- model_dummy.process_data(df)
541
- time = model_dummy.time
 
542
 
543
- if mode == 'independent':
544
- num_experiments = len(df.columns.levels[0])
545
- for idx in range(num_experiments):
546
- col = df.columns.levels[0][idx]
547
  try:
548
- time_exp = df[(col, 'Tiempo')].dropna().values
549
- biomass = df[(col, 'Biomasa')].dropna().values
550
- substrate = df[(col, 'Sustrato')].dropna().values
551
- product = df[(col, 'Producto')].dropna().values
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
  except KeyError as e:
553
- print(f"Error al procesar el experimento '{col}': {e}")
554
  continue
 
 
 
 
555
 
556
- biomass_std = None
557
- substrate_std = None
558
- product_std = None
559
- if biomass.ndim > 1:
560
- biomass_std = np.std(biomass, axis=0, ddof=1)
561
- biomass = np.mean(biomass, axis=0)
562
- if substrate.ndim > 1:
563
- substrate_std = np.std(substrate, axis=0, ddof=1)
564
- substrate = np.mean(substrate, axis=0)
565
- if product.ndim > 1:
566
- product_std = np.std(product, axis=0, ddof=1)
567
- product = np.mean(product, axis=0)
568
-
569
- experiment_name = (experiment_names[experiment_counter] if experiment_counter < len(experiment_names)
570
- else f"Tratamiento {experiment_counter + 1}")
571
-
572
- for model_type in model_types:
573
- model = BioprocessModel(model_type=model_type, maxfev=maxfev_val)
574
- model.fit_model()
575
-
576
- y_pred_biomass = model.fit_biomass(time_exp, biomass)
577
- if y_pred_biomass is None:
578
- comparison_data.append({
579
- 'Experimento': experiment_name,
580
- 'Modelo': model_type.capitalize(),
581
- 'R² Biomasa': np.nan,
582
- 'RMSE Biomasa': np.nan,
583
- 'R² Sustrato': np.nan,
584
- 'RMSE Sustrato': np.nan,
585
- 'R² Producto': np.nan,
586
- 'RMSE Producto': np.nan
587
- })
588
- continue
589
- else:
590
- if 'biomass' in model.params and model.params['biomass']:
591
- y_pred_substrate = model.fit_substrate(time_exp, substrate, model.params['biomass'])
592
- y_pred_product = model.fit_product(time_exp, product, model.params['biomass'])
593
- else:
594
- y_pred_substrate = None
595
- y_pred_product = None
596
-
597
- comparison_data.append({
598
- 'Experimento': experiment_name,
599
- 'Modelo': model_type.capitalize(),
600
- 'R² Biomasa': model.r2.get('biomass', np.nan),
601
- 'RMSE Biomasa': model.rmse.get('biomass', np.nan),
602
- 'R² Sustrato': model.r2.get('substrate', np.nan),
603
- 'RMSE Sustrato': model.rmse.get('substrate', np.nan),
604
- 'R² Producto': model.r2.get('product', np.nan),
605
- 'RMSE Producto': model.rmse.get('product', np.nan)
606
- })
607
-
608
- if mode == 'combinado':
609
- fig = model.plot_combined_results(time_exp, biomass, substrate, product,
610
- y_pred_biomass, y_pred_substrate, y_pred_product,
611
- biomass_std, substrate_std, product_std,
612
- experiment_name,
613
- legend_position, params_position,
614
- show_legend, show_params,
615
- style,
616
- line_color, point_color, line_style, marker_style,
617
- use_differential)
618
- else:
619
- fig = model.plot_results(time_exp, biomass, substrate, product,
620
- y_pred_biomass, y_pred_substrate, y_pred_product,
621
- biomass_std, substrate_std, product_std,
622
- experiment_name,
623
- legend_position, params_position,
624
- show_legend, show_params,
625
- style,
626
- line_color, point_color, line_style, marker_style,
627
- use_differential)
628
- if fig is not None:
629
- figures.append(fig)
630
-
631
- experiment_counter += 1
632
-
633
- elif mode in ['average', 'combinado']:
634
  try:
635
- time_exp = df[(df.columns.levels[0][0], 'Tiempo')].dropna().values
636
- biomass = model_dummy.dataxp[-1]
637
- substrate = model_dummy.datasp[-1]
638
- product = model_dummy.datapp[-1]
639
- except IndexError as e:
640
- print(f"Error al obtener los datos promedio de la hoja '{sheet_name}': {e}")
641
- continue
642
 
643
- biomass_std = model_dummy.datax_std[-1]
644
- substrate_std = model_dummy.datas_std[-1]
645
- product_std = model_dummy.datap_std[-1]
646
-
647
- experiment_name = (experiment_names[experiment_counter] if experiment_counter < len(experiment_names)
648
- else f"Tratamiento {experiment_counter + 1}")
649
-
650
- for model_type in model_types:
651
- model = BioprocessModel(model_type=model_type, maxfev=maxfev_val)
652
- model.fit_model()
653
-
654
- y_pred_biomass = model.fit_biomass(time_exp, biomass)
655
- if y_pred_biomass is None:
656
- comparison_data.append({
657
- 'Experimento': experiment_name,
658
- 'Modelo': model_type.capitalize(),
659
- 'R² Biomasa': np.nan,
660
- 'RMSE Biomasa': np.nan,
661
- 'R² Sustrato': np.nan,
662
- 'RMSE Sustrato': np.nan,
663
- 'R² Producto': np.nan,
664
- 'RMSE Producto': np.nan
665
- })
666
- continue
667
- else:
668
- if 'biomass' in model.params and model.params['biomass']:
669
- y_pred_substrate = model.fit_substrate(time_exp, substrate, model.params['biomass'])
670
- y_pred_product = model.fit_product(time_exp, product, model.params['biomass'])
671
- else:
672
- y_pred_substrate = None
673
- y_pred_product = None
674
-
675
- comparison_data.append({
676
- 'Experimento': experiment_name,
677
- 'Modelo': model_type.capitalize(),
678
- 'R² Biomasa': model.r2.get('biomass', np.nan),
679
- 'RMSE Biomasa': model.rmse.get('biomass', np.nan),
680
- 'R² Sustrato': model.r2.get('substrate', np.nan),
681
- 'RMSE Sustrato': model.rmse.get('substrate', np.nan),
682
- 'R² Producto': model.r2.get('product', np.nan),
683
- 'RMSE Producto': model.rmse.get('product', np.nan)
684
- })
685
-
686
- if mode == 'combinado':
687
- fig = model.plot_combined_results(time_exp, biomass, substrate, product,
688
- y_pred_biomass, y_pred_substrate, y_pred_product,
689
- biomass_std, substrate_std, product_std,
690
- experiment_name,
691
- legend_position, params_position,
692
- show_legend, show_params,
693
- style,
694
- line_color, point_color, line_style, marker_style,
695
- use_differential)
696
- else:
697
- fig = model.plot_results(time_exp, biomass, substrate, product,
698
- y_pred_biomass, y_pred_substrate, y_pred_product,
699
- biomass_std, substrate_std, product_std,
700
- experiment_name,
701
- legend_position, params_position,
702
- show_legend, show_params,
703
- style,
704
- line_color, point_color, line_style, marker_style,
705
- use_differential)
706
- if fig is not None:
707
- figures.append(fig)
708
 
709
- experiment_counter += 1
 
 
710
 
711
- comparison_df = pd.DataFrame(comparison_data)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
712
 
 
713
  if not comparison_df.empty:
714
  comparison_df_sorted = comparison_df.sort_values(
715
  by=['R² Biomasa', 'R² Sustrato', 'R² Producto', 'RMSE Biomasa', 'RMSE Sustrato', 'RMSE Producto'],
716
  ascending=[False, False, False, True, True, True]
717
  ).reset_index(drop=True)
718
  else:
719
- comparison_df_sorted = comparison_df
 
 
 
 
 
720
 
721
- return figures, comparison_df_sorted
722
 
723
  def create_interface():
724
- with gr.Blocks() as demo:
725
  gr.Markdown("# Modelos de Bioproceso: Logístico, Gompertz, Moser y Luedeking-Piret")
726
-
727
  gr.Markdown(r"""
728
- ## Ecuaciones Diferenciales Utilizadas
729
-
730
- **Biomasa:**
731
-
732
- - Logístico:
733
- $$
734
- \frac{dX}{dt} = \mu_m X\left(1 - \frac{X}{X_m}\right)
735
- $$
736
-
737
- - Gompertz:
738
- $$
739
- X(t) = X_m \exp\left(-\exp\left(\left(\frac{\mu_m e}{X_m}\right)(\text{lag}-t)+1\right)\right)
740
- $$
741
-
742
- Ecuación diferencial:
743
- $$
744
- \frac{dX}{dt} = X(t)\left(\frac{\mu_m e}{X_m}\right)\exp\left(\left(\frac{\mu_m e}{X_m}\right)(\text{lag}-t)+1\right)
745
- $$
746
-
747
- - Moser (simplificado):
748
- $$
749
- X(t)=X_m(1-e^{-\mu_m(t-K_s)})
750
- $$
751
-
752
- $$
753
- \frac{dX}{dt}=\mu_m(X_m - X)
754
- $$
755
-
756
- **Sustrato y Producto (Luedeking-Piret):**
757
- $$
758
- \frac{dS}{dt} = -p \frac{dX}{dt} - q X
759
- $$
760
-
761
- $$
762
- \frac{dP}{dt} = \alpha \frac{dX}{dt} + \beta X
763
- $$
764
- """)
765
-
766
- file_input = gr.File(label="Subir archivo Excel")
767
-
768
- with gr.Row():
769
- with gr.Column():
770
- legend_position = gr.Radio(
771
- choices=["upper left", "upper right", "lower left", "lower right", "best"],
772
- label="Posición de la leyenda",
773
- value="best"
774
- )
775
- show_legend = gr.Checkbox(label="Mostrar Leyenda", value=True)
776
-
777
- with gr.Column():
778
- params_positions = ["upper left", "upper right", "lower left", "lower right", "outside right"]
779
- params_position = gr.Radio(
780
- choices=params_positions,
781
- label="Posición de los parámetros",
782
- value="upper right"
783
  )
784
- show_params = gr.Checkbox(label="Mostrar Parámetros", value=True)
785
-
786
- model_types = gr.CheckboxGroup(
787
- choices=["logistic", "gompertz", "moser"],
788
- label="Tipo(s) de Modelo",
789
- value=["logistic"]
790
- )
791
- mode = gr.Radio(["independent", "average", "combinado"], label="Modo de Análisis", value="independent")
792
- use_differential = gr.Checkbox(label="Usar ecuaciones diferenciales para graficar", value=False)
793
-
794
- experiment_names = gr.Textbox(
795
- label="Nombres de los experimentos (uno por línea)",
796
- placeholder="Experimento 1\nExperimento 2\n...",
797
- lines=5
798
- )
799
-
800
- with gr.Row():
801
- with gr.Column():
802
- lower_bounds = gr.Textbox(
803
- label="Lower Bounds (uno por línea, formato: param1,param2,param3)",
804
- placeholder="0,0,0\n0,0,0\n...",
805
- lines=5
806
  )
807
-
808
- with gr.Column():
809
- upper_bounds = gr.Textbox(
810
- label="Upper Bounds (uno por línea, formato: param1,param2,param3)",
811
- placeholder="inf,inf,inf\ninf,inf,inf\n...",
812
- lines=5
 
813
  )
814
-
815
- styles = ['white', 'dark', 'whitegrid', 'darkgrid', 'ticks']
816
- style_dropdown = gr.Dropdown(choices=styles, label="Selecciona el estilo de gráfico", value='whitegrid')
817
-
818
- line_color_picker = gr.ColorPicker(label="Color de la línea", value='#0000FF')
819
- point_color_picker = gr.ColorPicker(label="Color de los puntos", value='#000000')
820
-
821
- line_style_options = ['-', '--', '-.', ':']
822
- line_style_dropdown = gr.Dropdown(choices=line_style_options, label="Estilo de línea", value='-')
823
-
824
- marker_style_options = ['o', 's', '^', 'v', 'D', 'x', '+', '*']
825
- marker_style_dropdown = gr.Dropdown(choices=marker_style_options, label="Estilo de punto", value='o')
826
-
827
- maxfev_input = gr.Number(label="maxfev (Máx. evaluaciones para el ajuste)", value=50000)
828
-
829
- simulate_btn = gr.Button("Simular")
830
-
831
- output_gallery = gr.Gallery(label="Resultados", columns=2, height='auto')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
832
  output_table = gr.Dataframe(
833
  label="Tabla Comparativa de Modelos",
834
  headers=["Experimento", "Modelo", "R² Biomasa", "RMSE Biomasa",
835
  "R² Sustrato", "RMSE Sustrato", "R² Producto", "RMSE Producto"],
836
- interactive=False
 
837
  )
838
-
839
- state_df = gr.State()
840
-
841
- def process_and_plot(file, legend_position, params_position, model_types, mode, experiment_names,
842
- lower_bounds, upper_bounds, style,
843
- line_color, point_color, line_style, marker_style,
844
- show_legend, show_params, use_differential, maxfev_input):
845
-
846
- experiment_names_list = experiment_names.strip().split('\n') if experiment_names.strip() else []
847
- lower_bounds_list = []
848
- if lower_bounds.strip():
849
- for lb in lower_bounds.strip().split('\n'):
850
- lb_values = []
851
- for val in lb.split(','):
852
- val = val.strip().lower()
853
- if val in ['inf', 'infty', 'infinity']:
854
- lb_values.append(-np.inf)
855
- else:
856
- try:
857
- lb_values.append(float(val))
858
- except ValueError:
859
- lb_values.append(0.0)
860
- lower_bounds_list.append(tuple(lb_values))
861
- upper_bounds_list = []
862
- if upper_bounds.strip():
863
- for ub in upper_bounds.strip().split('\n'):
864
- ub_values = []
865
- for val in ub.split(','):
866
- val = val.strip().lower()
867
- if val in ['inf', 'infty', 'infinity']:
868
- ub_values.append(np.inf)
869
- else:
870
- try:
871
- ub_values.append(float(val))
872
- except ValueError:
873
- ub_values.append(np.inf)
874
- upper_bounds_list.append(tuple(ub_values))
875
-
876
- figures, comparison_df = process_all_data(file, legend_position, params_position, model_types, experiment_names_list,
877
- lower_bounds_list, upper_bounds_list, mode, style,
878
- line_color, point_color, line_style, marker_style,
879
- show_legend, show_params, use_differential, maxfev_val=int(maxfev_input))
880
-
881
- return figures, comparison_df, comparison_df
882
-
883
- simulate_output = simulate_btn.click(
884
- fn=process_and_plot,
885
- inputs=[file_input,
886
- legend_position,
887
- params_position,
888
- model_types,
889
- mode,
890
- experiment_names,
891
- lower_bounds,
892
- upper_bounds,
893
- style_dropdown,
894
- line_color_picker,
895
- point_color_picker,
896
- line_style_dropdown,
897
- marker_style_dropdown,
898
- show_legend,
899
- show_params,
900
- use_differential,
901
- maxfev_input],
902
- outputs=[output_gallery, output_table, state_df]
903
  )
904
 
905
- def export_excel(df):
906
- if df.empty:
907
- return None
 
 
 
 
 
 
 
 
908
  with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
909
- df.to_excel(tmp.name, index=False)
910
  return tmp.name
911
 
912
  export_btn = gr.Button("Exportar Tabla a Excel")
913
- file_output = gr.File()
914
 
915
  export_btn.click(
916
  fn=export_excel,
917
- inputs=state_df,
918
- outputs=file_output
919
  )
920
-
921
  return demo
922
 
923
- demo = create_interface()
924
- demo.launch(share=True)
 
 
1
+ # import os # No parece usarse directamente, se puede quitar si no hay un uso oculto
2
+ # !pip install gradio seaborn scipy scikit-learn openpyxl pydantic==1.10.0 -q # Ejecutar en el entorno
3
 
4
+ from pydantic import BaseModel # ConfigDict ya no es necesario en Pydantic V2 si solo usas arbitrary_types_allowed
5
  import numpy as np
6
  import pandas as pd
7
  import matplotlib.pyplot as plt
 
14
  from PIL import Image
15
  import tempfile
16
 
17
+ # --- Constantes para nombres de columnas y etiquetas ---
18
+ # ### NUEVO ###
19
+ COL_TIME = 'Tiempo'
20
+ COL_BIOMASS = 'Biomasa'
21
+ COL_SUBSTRATE = 'Sustrato'
22
+ COL_PRODUCT = 'Producto'
23
+
24
+ LABEL_TIME = 'Tiempo'
25
+ LABEL_BIOMASS = 'Biomasa'
26
+ LABEL_SUBSTRATE = 'Sustrato'
27
+ LABEL_PRODUCT = 'Producto'
28
+ # --- Fin Constantes ---
29
+
30
+ class YourModel(BaseModel): # Esto parece ser un vestigio, no se usa. Se puede quitar si es así.
31
  class Config:
32
  arbitrary_types_allowed = True
33
 
 
49
  self.biomass_diff = None
50
  self.model_type = model_type
51
  self.maxfev = maxfev
52
+ self.time = np.array([]) # Inicializar time
53
 
54
  @staticmethod
55
  def logistic(time, xo, xm, um):
56
+ # Evitar division por cero o log de negativo si xm es muy pequeño o xo/xm >= 1
57
+ denominator = (1 - (xo / xm) * (1 - np.exp(um * time)))
58
+ # Añadir un pequeño epsilon para evitar división por cero si es necesario
59
+ denominator = np.where(np.abs(denominator) < 1e-9, np.sign(denominator) * 1e-9 if np.any(denominator) else 1e-9, denominator)
60
+ return (xo * np.exp(um * time)) / denominator
61
 
62
  @staticmethod
63
  def gompertz(time, xm, um, lag):
64
  return xm * np.exp(-np.exp((um * np.e / xm) * (lag - time) + 1))
65
 
66
  @staticmethod
67
+ def moser(time, Xm, um, Ks): # Modelo simplificado, no es el Moser clásico con dependencia de S
68
  return Xm * (1 - np.exp(-um * (time - Ks)))
69
 
70
  @staticmethod
 
78
  return X * (um * np.e / xm) * np.exp((um * np.e / xm) * (lag - t) + 1)
79
 
80
  @staticmethod
81
+ def moser_diff(X, t, params): # Diferencial del Moser simplificado usado
82
  Xm, um, Ks = params
83
+ return um * (Xm - X) # Asumiendo X(0) es tal que la forma integrada tiene sentido
 
 
 
 
 
 
84
 
85
+ def _get_biomass_model_params_as_list(self):
86
+ """Helper para obtener los parámetros de biomasa como lista para los modelos."""
87
+ # ### NUEVO ### (Helper interno)
88
+ if 'biomass' not in self.params or not self.params['biomass']:
89
+ return None
90
+ if self.model_type == 'logistic':
91
+ return [self.params['biomass']['xo'], self.params['biomass']['xm'], self.params['biomass']['um']]
92
+ elif self.model_type == 'gompertz':
93
+ return [self.params['biomass']['xm'], self.params['biomass']['um'], self.params['biomass']['lag']]
94
+ elif self.model_type == 'moser':
95
+ return [self.params['biomass']['Xm'], self.params['biomass']['um'], self.params['biomass']['Ks']]
96
+ return None
97
+
98
+ def substrate(self, time, so, p, q, biomass_params_list):
99
+ # ### MODIFICADO ###: Recibe directamente la lista de parámetros
100
+ if biomass_params_list is None: return np.full_like(time, so) # O manejar error
101
+ X_t = self.biomass_model(time, *biomass_params_list)
102
+ # dXdt = np.gradient(X_t, time) # np.gradient puede ser ruidoso
103
+ # Usar la forma diferencial si está disponible y es más estable
104
+ # Para el ajuste, usamos la forma integrada de X_t
105
+ integral_X = np.cumsum(X_t) * np.gradient(time) # Aproximación de la integral
106
+ return so - p * (X_t - biomass_params_list[0]) - q * integral_X
107
+
108
+ def product(self, time, po, alpha, beta, biomass_params_list):
109
+ # ### MODIFICADO ###: Recibe directamente la lista de parámetros
110
+ if biomass_params_list is None: return np.full_like(time, po) # O manejar error
111
+ X_t = self.biomass_model(time, *biomass_params_list)
112
+ integral_X = np.cumsum(X_t) * np.gradient(time) # Aproximación de la integral
113
+ return po + alpha * (X_t - biomass_params_list[0]) + beta * integral_X
114
 
115
  def process_data(self, df):
116
+ # ### MODIFICADO ###: Usa constantes
117
+ biomass_cols = [col for col in df.columns if col[1] == COL_BIOMASS]
118
+ substrate_cols = [col for col in df.columns if col[1] == COL_SUBSTRATE]
119
+ product_cols = [col for col in df.columns if col[1] == COL_PRODUCT]
120
+
121
+ time_col_tuple = [col for col in df.columns if col[1] == COL_TIME]
122
+ if not time_col_tuple:
123
+ raise ValueError(f"No se encontró la columna de '{COL_TIME}' en los datos.")
124
+ time_col = time_col_tuple[0]
125
  time = df[time_col].values
126
 
127
+ if biomass_cols:
128
+ data_biomass = np.array([df[col].values for col in biomass_cols])
129
+ self.datax.append(data_biomass)
130
+ self.dataxp.append(np.mean(data_biomass, axis=0))
131
+ self.datax_std.append(np.std(data_biomass, axis=0, ddof=1))
132
+ else: # Manejar el caso de que no haya datos de biomasa
133
+ self.dataxp.append(np.zeros_like(time))
134
+ self.datax_std.append(np.zeros_like(time))
135
+
136
+
137
+ if substrate_cols:
138
+ data_substrate = np.array([df[col].values for col in substrate_cols])
139
+ self.datas.append(data_substrate)
140
+ self.datasp.append(np.mean(data_substrate, axis=0))
141
+ self.datas_std.append(np.std(data_substrate, axis=0, ddof=1))
142
+ else:
143
+ self.datasp.append(np.zeros_like(time))
144
+ self.datas_std.append(np.zeros_like(time))
145
+
146
+ if product_cols:
147
+ data_product = np.array([df[col].values for col in product_cols])
148
+ self.datap.append(data_product)
149
+ self.datapp.append(np.mean(data_product, axis=0))
150
+ self.datap_std.append(np.std(data_product, axis=0, ddof=1))
151
+ else:
152
+ self.datapp.append(np.zeros_like(time))
153
+ self.datap_std.append(np.zeros_like(time))
154
 
155
  self.time = time
156
 
157
+
158
  def fit_model(self):
159
  if self.model_type == 'logistic':
160
  self.biomass_model = self.logistic
 
165
  elif self.model_type == 'moser':
166
  self.biomass_model = self.moser
167
  self.biomass_diff = self.moser_diff
168
+ else:
169
+ raise ValueError(f"Tipo de modelo desconocido: {self.model_type}")
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
 
172
+ def fit_biomass(self, time, biomass, bounds=None):
173
+ # ### MODIFICADO ###: Acepta bounds
174
+ p0 = None
175
+ fit_func = None
176
+ param_names = []
177
+
178
+ if self.model_type == 'logistic':
179
+ p0 = [max(1e-6,min(biomass)), max(biomass)*1.5 if max(biomass)>0 else 1.0, 0.1]
180
+ fit_func = self.logistic
181
+ param_names = ['xo', 'xm', 'um']
182
+ elif self.model_type == 'gompertz':
183
+ # Estimación de lag: tiempo hasta alcanzar ~10% de Xmax o donde la pendiente es máxima
184
+ grad_b = np.gradient(biomass)
185
+ lag_guess = time[np.argmax(grad_b)] if len(time) > 1 and np.any(grad_b > 1e-3) else time[0]
186
+ p0 = [max(biomass) if max(biomass)>0 else 1.0, 0.1, lag_guess]
187
+ fit_func = self.gompertz
188
+ param_names = ['xm', 'um', 'lag']
189
+ elif self.model_type == 'moser':
190
+ p0 = [max(biomass) if max(biomass)>0 else 1.0, 0.1, time[0]] # Ks como tiempo inicial
191
+ fit_func = self.moser
192
+ param_names = ['Xm', 'um', 'Ks']
193
+
194
+ if fit_func is None:
195
+ print(f"Modelo de biomasa no configurado para {self.model_type}")
196
+ return None
197
+
198
+ try:
199
+ # Asegurar que p0 esté dentro de los bounds si se proveen
200
+ if bounds:
201
+ p0_bounded = []
202
+ for i, val in enumerate(p0):
203
+ low = bounds[0][i] if bounds[0] and i < len(bounds[0]) else -np.inf
204
+ high = bounds[1][i] if bounds[1] and i < len(bounds[1]) else np.inf
205
+ p0_bounded.append(np.clip(val, low, high))
206
+ p0 = p0_bounded
207
+
208
+ popt, _ = curve_fit(fit_func, time, biomass, p0=p0, maxfev=self.maxfev, bounds=bounds or (-np.inf, np.inf))
209
+ self.params['biomass'] = dict(zip(param_names, popt))
210
+ y_pred = fit_func(time, *popt)
211
+
212
+ # Evitar R2 nan o inf si biomasa es constante
213
+ if np.sum((biomass - np.mean(biomass)) ** 2) < 1e-9: # Si la varianza es casi cero
214
+ self.r2['biomass'] = 1.0 if np.sum((biomass - y_pred) ** 2) < 1e-9 else 0.0
215
+ else:
216
+ self.r2['biomass'] = 1 - (np.sum((biomass - y_pred) ** 2) / np.sum((biomass - np.mean(biomass)) ** 2))
217
  self.rmse['biomass'] = np.sqrt(mean_squared_error(biomass, y_pred))
218
  return y_pred
219
  except Exception as e:
220
  print(f"Error en fit_biomass_{self.model_type}: {e}")
221
+ self.params['biomass'] = {} # Evitar errores posteriores
222
  return None
223
 
224
+ def _fit_consumption_production(self, time, data, fit_type, p0_values, param_names):
225
+ # ### NUEVO ### (Helper interno para sustrato y producto)
226
+ biomass_params_list = self._get_biomass_model_params_as_list()
227
+ if biomass_params_list is None:
228
+ print(f"Parámetros de biomasa no disponibles para ajustar {fit_type}.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  return None
230
 
231
+ model_func = self.substrate if fit_type == 'substrate' else self.product
232
+
233
  try:
234
+ popt, _ = curve_fit(
235
+ lambda t, *params_fit: model_func(t, *params_fit, biomass_params_list),
236
+ time, data, p0=p0_values, maxfev=self.maxfev
237
+ )
238
+ self.params[fit_type] = dict(zip(param_names, popt))
239
+ y_pred = model_func(time, *popt, biomass_params_list)
240
+
241
+ if np.sum((data - np.mean(data)) ** 2) < 1e-9: # Si la varianza es casi cero
242
+ self.r2[fit_type] = 1.0 if np.sum((data - y_pred) ** 2) < 1e-9 else 0.0
243
+ else:
244
+ self.r2[fit_type] = 1 - (np.sum((data - y_pred) ** 2) / np.sum((data - np.mean(data)) ** 2))
245
+ self.rmse[fit_type] = np.sqrt(mean_squared_error(data, y_pred))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  return y_pred
247
  except Exception as e:
248
+ print(f"Error en fit_{fit_type}_{self.model_type}: {e}")
249
+ self.params[fit_type] = {}
250
  return None
251
 
252
+ def fit_substrate(self, time, substrate):
253
+ # ### MODIFICADO ###: Usa helper
254
+ p0 = [max(1e-6, min(substrate)) if len(substrate)>0 else 1.0, 0.01, 0.01] # so, p, q
255
+ param_names = ['so', 'p', 'q']
256
+ return self._fit_consumption_production(time, substrate, 'substrate', p0, param_names)
257
+
258
+ def fit_product(self, time, product):
259
+ # ### MODIFICADO ###: Usa helper
260
+ p0 = [max(1e-6, min(product)) if len(product)>0 else 0.0, 0.01, 0.01] # po, alpha, beta
261
+ param_names = ['po', 'alpha', 'beta']
262
+ return self._fit_consumption_production(time, product, 'product', p0, param_names)
263
+
264
+
265
  def generate_fine_time_grid(self, time):
266
+ if len(time) < 2: return time # Evitar error si time no es suficiente
267
  time_fine = np.linspace(time.min(), time.max(), 500)
268
  return time_fine
269
 
270
+ def system(self, y, t, biomass_params_list, substrate_params_dict, product_params_dict):
271
+ # ### MODIFICADO ### para mayor claridad
272
  X, S, P = y
273
 
274
+ if self.model_type == 'logistic':
275
+ dXdt = self.logistic_diff(X, t, biomass_params_list)
276
+ elif self.model_type == 'gompertz':
277
+ dXdt = self.gompertz_diff(X, t, biomass_params_list)
278
+ elif self.model_type == 'moser':
279
+ dXdt = self.moser_diff(X, t, biomass_params_list)
280
  else:
281
+ dXdt = 0.0 # Fallback, debería lanzar error o ser manejado
282
+
283
+ # Usar .get con default para evitar KeyError si los params no están ajustados
284
+ p = substrate_params_dict.get('p', 0)
285
+ q = substrate_params_dict.get('q', 0)
286
+ alpha = product_params_dict.get('alpha', 0)
287
+ beta = product_params_dict.get('beta', 0)
288
 
289
  dSdt = -p * dXdt - q * X
290
  dPdt = alpha * dXdt + beta * X
291
  return [dXdt, dSdt, dPdt]
292
 
293
  def get_initial_conditions(self, time, biomass, substrate, product):
294
+ X0, S0, P0 = biomass[0], substrate[0], product[0] # Default a los primeros datos
295
+
296
+ if 'biomass' in self.params and self.params['biomass']:
297
  if self.model_type == 'logistic':
298
+ X0 = self.params['biomass']['xo']
 
299
  elif self.model_type == 'gompertz':
300
+ # Gompertz en t=0
301
+ xm, um, lag = self.params['biomass']['xm'], self.params['biomass']['um'], self.params['biomass']['lag']
302
+ X0 = xm * np.exp(-np.exp((um * np.e / xm)*(lag - time.min())+1)) # Usar time.min()
 
303
  elif self.model_type == 'moser':
304
+ Xm, um, Ks = self.params['biomass']['Xm'], self.params['biomass']['um'], self.params['biomass']['Ks']
305
+ X0 = Xm*(1 - np.exp(-um*(time.min() - Ks))) # Usar time.min()
306
+
307
+ if 'substrate' in self.params and self.params['substrate']:
308
+ S0 = self.params['substrate']['so']
309
+
310
+ if 'product' in self.params and self.params['product']:
311
+ P0 = self.params['product']['po']
 
 
 
 
 
 
 
 
 
 
312
 
313
  return [X0, S0, P0]
314
 
315
+
316
  def solve_differential_equations(self, time, biomass, substrate, product):
317
+ biomass_params_list = self._get_biomass_model_params_as_list()
318
+ if biomass_params_list is None:
319
  print("No hay parámetros de biomasa, no se pueden resolver las EDO.")
320
  return None, None, None, time
321
 
322
+ # Usar .get con default para evitar KeyError si no se ajustaron
323
+ substrate_params_dict = self.params.get('substrate', {})
324
+ product_params_dict = self.params.get('product', {})
325
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  initial_conditions = self.get_initial_conditions(time, biomass, substrate, product)
327
  time_fine = self.generate_fine_time_grid(time)
328
+
329
+ try:
330
+ sol = odeint(self.system, initial_conditions, time_fine,
331
+ args=(biomass_params_list, substrate_params_dict, product_params_dict))
332
+ X, S, P = sol[:, 0], sol[:, 1], sol[:, 2]
333
+ return X, S, P, time_fine
334
+ except Exception as e:
335
+ print(f"Error al resolver EDOs: {e}")
336
+ return None, None, None, time_fine
337
 
 
338
 
339
  def plot_results(self, time, biomass, substrate, product,
340
  y_pred_biomass, y_pred_substrate, y_pred_product,
341
  biomass_std=None, substrate_std=None, product_std=None,
342
  experiment_name='', legend_position='best', params_position='upper right',
343
+ show_legend=True, show_params=True, style='whitegrid',
 
344
  line_color='#0000FF', point_color='#000000', line_style='-', marker_style='o',
345
+ use_differential=False,
346
+ # ### NUEVO ###: Parámetros para unidades
347
+ time_unit='', biomass_unit='', substrate_unit='', product_unit=''):
348
 
349
+ if y_pred_biomass is None and not use_differential: # Si no hay ajuste y no se usan EDOs
350
+ print(f"No se pudo ajustar biomasa para {experiment_name} con {self.model_type} y no se usan EDOs. Omitiendo figura.")
351
  return None
352
 
353
  sns.set_style(style)
354
+ time_to_plot = time # Por defecto
355
+
356
+ if use_differential:
357
+ # Forzar la resolución de EDOs aquí si se quiere usar para graficar siempre
358
+ # aunque no haya ajuste previo de sustrato/producto.
359
+ # Los parámetros para sustrato/producto podrían ser cero si no se ajustaron.
360
+ X_ode, S_ode, P_ode, time_fine_ode = self.solve_differential_equations(time, biomass, substrate, product)
361
+ if X_ode is not None:
362
+ y_pred_biomass, y_pred_substrate, y_pred_product = X_ode, S_ode, P_ode
363
+ time_to_plot = time_fine_ode
364
+ else: # Fallback si EDOs fallan
365
+ print(f"Fallo al resolver EDOs para {experiment_name}, usando ajustes si existen.")
366
+ if y_pred_biomass is None: return None # No hay nada que graficar
367
+
368
  fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(10, 15))
369
+ fig.suptitle(f'{experiment_name} (Modelo: {self.model_type.capitalize()})', fontsize=16)
370
+
371
+ # ### MODIFICADO ###: Construcción de etiquetas de ejes con unidades
372
+ xlabel_full = f'{LABEL_TIME} ({time_unit})' if time_unit else LABEL_TIME
373
+ ylabel_biomass_full = f'{LABEL_BIOMASS} ({biomass_unit})' if biomass_unit else LABEL_BIOMASS
374
+ ylabel_substrate_full = f'{LABEL_SUBSTRATE} ({substrate_unit})' if substrate_unit else LABEL_SUBSTRATE
375
+ ylabel_product_full = f'{LABEL_PRODUCT} ({product_unit})' if product_unit else LABEL_PRODUCT
376
+
377
+ plots_config = [
378
+ (ax1, biomass, y_pred_biomass, biomass_std, ylabel_biomass_full, 'biomass'),
379
+ (ax2, substrate, y_pred_substrate, substrate_std, ylabel_substrate_full, 'substrate'),
380
+ (ax3, product, y_pred_product, product_std, ylabel_product_full, 'product')
381
  ]
382
 
383
+ for ax, data, y_pred, data_std, ylabel, param_key in plots_config:
384
+ if data_std is not None and len(data_std) == len(time):
385
  ax.errorbar(time, data, yerr=data_std, fmt=marker_style, color=point_color,
386
  label='Datos experimentales', capsize=5)
387
  else:
388
  ax.plot(time, data, marker=marker_style, linestyle='', color=point_color,
389
  label='Datos experimentales')
390
 
391
+ if y_pred is not None and len(y_pred) == len(time_to_plot):
392
+ ax.plot(time_to_plot, y_pred, linestyle=line_style, color=line_color, label='Modelo')
393
 
394
+ ax.set_xlabel(xlabel_full) # ### MODIFICADO ###
395
  ax.set_ylabel(ylabel)
396
  if show_legend:
397
  ax.legend(loc=legend_position)
398
+ ax.set_title(f'{ylabel.split(" (")[0]}') # Título sin unidad
399
+
400
+ current_params = self.params.get(param_key, {})
401
+ r2 = self.r2.get(param_key, np.nan)
402
+ rmse = self.rmse.get(param_key, np.nan)
403
+
404
+ if show_params and current_params: # Solo mostrar si hay params
405
+ # Filtrar NaNs o Infs de los parámetros para el texto
406
+ valid_params = {k: v for k, v in current_params.items() if np.isfinite(v)}
407
+ param_text = '\n'.join([f"{k} = {v:.3g}" for k, v in valid_params.items()]) # Usar .3g para mejor formato
408
+ text = f"{param_text}\nR² = {r2:.3f}\nRMSE = {rmse:.3g}"
409
+
410
+ # Lógica de posición del texto (simplificada de tu código original)
411
+ text_x, ha = (0.95, 'right') if 'right' in params_position else (0.05, 'left')
412
+ text_y, va = (0.95, 'top') if 'upper' in params_position else (0.05, 'bottom')
413
+ if params_position == 'outside right': # Manejo especial para outside right
414
+ fig.subplots_adjust(right=0.75) # Hacer espacio
415
  ax.annotate(text, xy=(1.05, 0.5), xycoords='axes fraction',
416
+ verticalalignment='center', bbox=dict(boxstyle='round', facecolor='white', alpha=0.7))
417
  else:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  ax.text(text_x, text_y, text, transform=ax.transAxes,
419
+ verticalalignment=va, horizontalalignment=ha,
420
+ bbox={'boxstyle': 'round', 'facecolor':'white', 'alpha':0.7})
421
 
 
422
 
423
+ plt.tight_layout(rect=[0, 0.03, 1, 0.95])
424
+
425
  buf = io.BytesIO()
426
  fig.savefig(buf, format='png')
427
  buf.seek(0)
428
  image = Image.open(buf).convert("RGB")
429
  plt.close(fig)
 
430
  return image
431
 
432
+
433
  def plot_combined_results(self, time, biomass, substrate, product,
434
  y_pred_biomass, y_pred_substrate, y_pred_product,
435
  biomass_std=None, substrate_std=None, product_std=None,
436
  experiment_name='', legend_position='best', params_position='upper right',
437
+ show_legend=True, show_params=True, style='whitegrid',
 
438
  line_color='#0000FF', point_color='#000000', line_style='-', marker_style='o',
439
+ use_differential=False,
440
+ # ### NUEVO ###: Parámetros para unidades
441
+ time_unit='', biomass_unit='', substrate_unit='', product_unit=''):
442
+
443
+ if y_pred_biomass is None and not use_differential:
444
+ print(f"No se pudo ajustar biomasa para {experiment_name} con {self.model_type} y no se usan EDOs. Omitiendo figura combinada.")
445
  return None
446
 
447
  sns.set_style(style)
448
+ time_to_plot = time
449
 
450
+ if use_differential:
451
+ X_ode, S_ode, P_ode, time_fine_ode = self.solve_differential_equations(time, biomass, substrate, product)
452
+ if X_ode is not None:
453
+ y_pred_biomass, y_pred_substrate, y_pred_product = X_ode, S_ode, P_ode
454
+ time_to_plot = time_fine_ode
455
  else:
456
+ print(f"Fallo al resolver EDOs para {experiment_name} (combinado), usando ajustes si existen.")
457
+ if y_pred_biomass is None: return None
 
458
 
459
+ fig, ax1 = plt.subplots(figsize=(12, 7)) # Ajustar tamaño para acomodar leyenda de params si es 'outside right'
460
+ fig.suptitle(f'{experiment_name} (Modelo: {self.model_type.capitalize()})', fontsize=16)
461
+
462
+ # ### MODIFICADO ###: Construcción de etiquetas de ejes con unidades
463
+ xlabel_full = f'{LABEL_TIME} ({time_unit})' if time_unit else LABEL_TIME
464
+ ylabel_biomass_full = f'{LABEL_BIOMASS} ({biomass_unit})' if biomass_unit else LABEL_BIOMASS
465
+ ylabel_substrate_full = f'{LABEL_SUBSTRATE} ({substrate_unit})' if substrate_unit else LABEL_SUBSTRATE
466
+ ylabel_product_full = f'{LABEL_PRODUCT} ({product_unit})' if product_unit else LABEL_PRODUCT
467
 
468
  colors = {'Biomasa': 'blue', 'Sustrato': 'green', 'Producto': 'red'}
469
 
470
+ # Biomasa (ax1)
471
+ ax1.set_xlabel(xlabel_full)
472
+ ax1.set_ylabel(ylabel_biomass_full, color=colors['Biomasa'])
473
+ if biomass_std is not None and len(biomass_std) == len(time):
474
  ax1.errorbar(time, biomass, yerr=biomass_std, fmt=marker_style, color=colors['Biomasa'],
475
+ label=f'{LABEL_BIOMASS} (Datos)', capsize=5)
476
  else:
477
  ax1.plot(time, biomass, marker=marker_style, linestyle='', color=colors['Biomasa'],
478
+ label=f'{LABEL_BIOMASS} (Datos)')
479
+ if y_pred_biomass is not None and len(y_pred_biomass) == len(time_to_plot):
480
+ ax1.plot(time_to_plot, y_pred_biomass, linestyle=line_style, color=colors['Biomasa'],
481
+ label=f'{LABEL_BIOMASS} (Modelo)')
482
  ax1.tick_params(axis='y', labelcolor=colors['Biomasa'])
483
 
484
+ # Sustrato (ax2)
485
  ax2 = ax1.twinx()
486
+ ax2.set_ylabel(ylabel_substrate_full, color=colors['Sustrato'])
487
+ if substrate is not None: # Solo graficar si hay datos de sustrato
488
+ if substrate_std is not None and len(substrate_std) == len(time):
489
+ ax2.errorbar(time, substrate, yerr=substrate_std, fmt=marker_style, color=colors['Sustrato'],
490
+ label=f'{LABEL_SUBSTRATE} (Datos)', capsize=5)
491
+ else:
492
+ ax2.plot(time, substrate, marker=marker_style, linestyle='', color=colors['Sustrato'],
493
+ label=f'{LABEL_SUBSTRATE} (Datos)')
494
+ if y_pred_substrate is not None and len(y_pred_substrate) == len(time_to_plot):
495
  ax2.plot(time_to_plot, y_pred_substrate, linestyle=line_style, color=colors['Sustrato'],
496
+ label=f'{LABEL_SUBSTRATE} (Modelo)')
497
  ax2.tick_params(axis='y', labelcolor=colors['Sustrato'])
498
 
499
+ # Producto (ax3)
500
  ax3 = ax1.twinx()
501
+ ax3.spines["right"].set_position(("axes", 1.15)) # Ajustar posición para que no se solape con ax2
502
  ax3.set_frame_on(True)
503
  ax3.patch.set_visible(False)
504
+
505
+ ax3.set_ylabel(ylabel_product_full, color=colors['Producto'])
506
+ if product is not None: # Solo graficar si hay datos de producto
507
+ if product_std is not None and len(product_std) == len(time):
508
+ ax3.errorbar(time, product, yerr=product_std, fmt=marker_style, color=colors['Producto'],
509
+ label=f'{LABEL_PRODUCT} (Datos)', capsize=5)
510
+ else:
511
+ ax3.plot(time, product, marker=marker_style, linestyle='', color=colors['Producto'],
512
+ label=f'{LABEL_PRODUCT} (Datos)')
513
+ if y_pred_product is not None and len(y_pred_product) == len(time_to_plot):
 
514
  ax3.plot(time_to_plot, y_pred_product, linestyle=line_style, color=colors['Producto'],
515
+ label=f'{LABEL_PRODUCT} (Modelo)')
516
  ax3.tick_params(axis='y', labelcolor=colors['Producto'])
517
 
518
+ # Leyenda combinada
 
519
  if show_legend:
520
+ handles, labels = [], []
521
+ for ax in [ax1, ax2, ax3]:
522
+ h, l = ax.get_legend_handles_labels()
523
+ handles.extend(h)
524
+ labels.extend(l)
525
+ # Evitar duplicados en leyenda si los hay
526
+ unique_labels = {}
527
+ for h, l in zip(handles, labels):
528
+ if l not in unique_labels:
529
+ unique_labels[l] = h
530
+ ax1.legend(unique_labels.values(), unique_labels.keys(), loc=legend_position)
531
+
532
 
533
  if show_params:
534
+ texts = []
535
+ for param_key, param_label in [('biomass', LABEL_BIOMASS), ('substrate', LABEL_SUBSTRATE), ('product', LABEL_PRODUCT)]:
536
+ current_params = self.params.get(param_key, {})
537
+ r2 = self.r2.get(param_key, np.nan)
538
+ rmse = self.rmse.get(param_key, np.nan)
539
+ if current_params: # Solo agregar si hay params
540
+ valid_params = {k: v for k, v in current_params.items() if np.isfinite(v)}
541
+ param_text_ind = '\n'.join([f"{k} = {v:.3g}" for k, v in valid_params.items()])
542
+ texts.append(f"{param_label}:\n{param_text_ind}\nR² = {r2:.3f}\nRMSE = {rmse:.3g}")
543
+
544
+ total_text = "\n\n".join(texts)
545
+
546
+ if total_text: # Solo mostrar si hay algo que poner
547
+ text_x, ha = (0.95, 'right') if 'right' in params_position else (0.05, 'left')
548
+ text_y, va = (0.95, 'top') if 'upper' in params_position else (0.05, 'bottom')
 
 
 
 
 
 
 
 
 
 
 
 
 
549
 
550
+ if params_position == 'outside right':
551
+ fig.subplots_adjust(right=0.70) # Ajustar para hacer espacio al texto
552
+ # Usar ax3 para anotar fuera, ya que es el más a la derecha
553
+ ax3.annotate(total_text, xy=(1.25, 0.5), xycoords='axes fraction', # Aumentar xy[0]
554
+ fontsize=8, # Reducir un poco la fuente para que quepa mejor
555
+ verticalalignment='center', bbox=dict(boxstyle='round', facecolor='white', alpha=0.7))
556
  else:
557
+ ax1.text(text_x, text_y, total_text, transform=ax1.transAxes,
558
+ fontsize=8,
559
+ verticalalignment=va, horizontalalignment=ha,
560
+ bbox={'boxstyle':'round', 'facecolor':'white', 'alpha':0.7})
 
 
 
 
561
 
562
+ plt.tight_layout(rect=[0, 0.03, 1, 0.95]) # Ajustar rect si es necesario
563
+
564
  buf = io.BytesIO()
565
  fig.savefig(buf, format='png')
566
  buf.seek(0)
567
  image = Image.open(buf).convert("RGB")
568
  plt.close(fig)
 
569
  return image
570
 
571
+
572
+ # ### NUEVO ###: Helper function para `process_all_data`
573
+ def _process_and_plot_single_experiment(
574
+ time_exp, biomass, substrate, product, biomass_std, substrate_std, product_std,
575
+ experiment_name, model_type_str, maxfev_val,
576
+ legend_position, params_position, show_legend, show_params,
577
+ style, line_color, point_color, line_style, marker_style,
578
+ use_differential, plot_mode, bounds_biomass,
579
+ # Unidades
580
+ time_unit, biomass_unit, substrate_unit, product_unit):
581
+
582
+ model = BioprocessModel(model_type=model_type_str, maxfev=maxfev_val)
583
+ model.fit_model() # Prepara las funciones del modelo
584
+
585
+ y_pred_biomass = model.fit_biomass(time_exp, biomass, bounds=bounds_biomass)
586
+
587
+ current_comparison_data = {
588
+ 'Experimento': experiment_name,
589
+ 'Modelo': model_type_str.capitalize(),
590
+ 'R² Biomasa': np.nan, 'RMSE Biomasa': np.nan,
591
+ 'R² Sustrato': np.nan, 'RMSE Sustrato': np.nan,
592
+ 'R² Producto': np.nan, 'RMSE Producto': np.nan
593
+ }
594
+
595
+ y_pred_substrate, y_pred_product = None, None
596
+ if y_pred_biomass is not None and 'biomass' in model.params and model.params['biomass']:
597
+ current_comparison_data.update({
598
+ 'R² Biomasa': model.r2.get('biomass', np.nan),
599
+ 'RMSE Biomasa': model.rmse.get('biomass', np.nan)
600
+ })
601
+ if substrate is not None and len(substrate) > 0:
602
+ y_pred_substrate = model.fit_substrate(time_exp, substrate)
603
+ if y_pred_substrate is not None:
604
+ current_comparison_data.update({
605
+ 'R² Sustrato': model.r2.get('substrate', np.nan),
606
+ 'RMSE Sustrato': model.rmse.get('substrate', np.nan)
607
+ })
608
+ if product is not None and len(product) > 0:
609
+ y_pred_product = model.fit_product(time_exp, product)
610
+ if y_pred_product is not None:
611
+ current_comparison_data.update({
612
+ 'R² Producto': model.r2.get('product', np.nan),
613
+ 'RMSE Producto': model.rmse.get('product', np.nan)
614
+ })
615
+ else: # Falló el ajuste de biomasa
616
+ print(f"No se pudo ajustar biomasa para {experiment_name} con {model_type_str}. No se ajustará sustrato/producto.")
617
+ # Los NaNs ya están en current_comparison_data
618
+
619
+ fig = None
620
+ plot_args = (time_exp, biomass, substrate, product,
621
+ y_pred_biomass, y_pred_substrate, y_pred_product,
622
+ biomass_std, substrate_std, product_std,
623
+ experiment_name, legend_position, params_position,
624
+ show_legend, show_params, style,
625
+ line_color, point_color, line_style, marker_style,
626
+ use_differential,
627
+ time_unit, biomass_unit, substrate_unit, product_unit) # Pasar unidades
628
+
629
+ if plot_mode == 'combinado':
630
+ fig = model.plot_combined_results(*plot_args)
631
+ else: # 'independent' o 'average' (que usan plot_results)
632
+ fig = model.plot_results(*plot_args)
633
+
634
+ return fig, current_comparison_data
635
+
636
+
637
+ def process_all_data(file, legend_position, params_position, model_types_selected, analysis_mode, experiment_names,
638
+ # ### MODIFICADO ###: bounds ahora son específicos
639
+ lower_bounds_biomass_str, upper_bounds_biomass_str,
640
+ style_plot, line_color_plot, point_color_plot, line_style_plot, marker_style_plot,
641
+ show_legend_plot, show_params_plot, use_differential_eqs, maxfev_val,
642
+ # ### NUEVO ###: Unidades para ejes
643
+ time_unit_str, biomass_unit_str, substrate_unit_str, product_unit_str):
644
+
645
+ if file is None:
646
+ return [], pd.DataFrame(), "Por favor, sube un archivo Excel."
647
 
648
  try:
649
  xls = pd.ExcelFile(file.name)
650
  except Exception as e:
651
+ return [], pd.DataFrame(), f"Error al leer el archivo Excel: {e}"
 
652
 
653
  sheet_names = xls.sheet_names
654
+ figures_list = []
655
+ comparison_data_list = []
656
+ experiment_counter = 0 # Para asignar nombres de `experiment_names`
657
+
658
+ # Parsear bounds para biomasa (asumimos 3 parámetros para todos los modelos de biomasa)
659
+ # ### MODIFICADO ###: Procesamiento de bounds más específico
660
+ parsed_bounds_biomass = ([-np.inf]*3, [np.inf]*3) # Default: sin bounds
661
+ try:
662
+ if lower_bounds_biomass_str.strip():
663
+ lb = [float(x.strip()) for x in lower_bounds_biomass_str.split(',')]
664
+ if len(lb) == 3 : parsed_bounds_biomass = (lb, parsed_bounds_biomass[1])
665
+ if upper_bounds_biomass_str.strip():
666
+ ub = [float(x.strip()) for x in upper_bounds_biomass_str.split(',')]
667
+ if len(ub) == 3 : parsed_bounds_biomass = (parsed_bounds_biomass[0], ub)
668
+ except ValueError:
669
+ print("Advertencia: Bounds para biomasa no son válidos, se usarán bounds por defecto (-inf, inf). Formato: num,num,num")
670
+
671
 
672
  for sheet_name in sheet_names:
673
  try:
674
+ df = pd.read_excel(xls, sheet_name=sheet_name, header=[0, 1])
675
+ # Asegurar que las columnas de datos (Biomasa, Sustrato, Producto) sean numéricas
676
+ for col_level0 in df.columns.levels[0]:
677
+ for col_level1 in [COL_BIOMASS, COL_SUBSTRATE, COL_PRODUCT, COL_TIME]:
678
+ if (col_level0, col_level1) in df.columns:
679
+ df[(col_level0, col_level1)] = pd.to_numeric(df[(col_level0, col_level1)], errors='coerce')
680
+ df = df.dropna(how='all', subset=[(c[0], c[1]) for c in df.columns if c[1] in [COL_TIME, COL_BIOMASS]]) # Eliminar filas totalmente vacías en T y X
681
+
682
  except Exception as e:
683
  print(f"Error al leer la hoja '{sheet_name}': {e}")
684
  continue
685
 
686
+ if analysis_mode == 'independent':
687
+ # Asumimos que cada columna de nivel 0 es un experimento diferente
688
+ # y que 'Tiempo', 'Biomasa', etc., son subcolumnas (nivel 1)
689
+ unique_experiments_in_sheet = df.columns.levels[0]
690
 
691
+ for exp_col_name in unique_experiments_in_sheet:
 
 
 
692
  try:
693
+ # Extraer datos para este experimento específico
694
+ time_exp = df[(exp_col_name, COL_TIME)].dropna().values
695
+ # Si no hay tiempo, saltar este experimento
696
+ if len(time_exp) == 0: continue
697
+
698
+ biomass_exp = df[(exp_col_name, COL_BIOMASS)].dropna().values if (exp_col_name, COL_BIOMASS) in df else np.array([])
699
+ substrate_exp = df[(exp_col_name, COL_SUBSTRATE)].dropna().values if (exp_col_name, COL_SUBSTRATE) in df else np.array([])
700
+ product_exp = df[(exp_col_name, COL_PRODUCT)].dropna().values if (exp_col_name, COL_PRODUCT) in df else np.array([])
701
+
702
+ # Asegurar que todos tengan la misma longitud que time_exp o estén vacíos
703
+ # Esto es simplista; un preprocesamiento más robusto podría ser necesario
704
+ biomass_exp = biomass_exp[:len(time_exp)] if len(biomass_exp) >= len(time_exp) else np.pad(biomass_exp, (0, len(time_exp) - len(biomass_exp)), 'constant', constant_values=np.nan)
705
+ substrate_exp = substrate_exp[:len(time_exp)] if len(substrate_exp) >= len(time_exp) else np.pad(substrate_exp, (0, len(time_exp) - len(substrate_exp)), 'constant', constant_values=np.nan)
706
+ product_exp = product_exp[:len(time_exp)] if len(product_exp) >= len(time_exp) else np.pad(product_exp, (0, len(time_exp) - len(product_exp)), 'constant', constant_values=np.nan)
707
+
708
+
709
+ current_exp_name_label = (experiment_names[experiment_counter] if experiment_counter < len(experiment_names)
710
+ else f"{sheet_name} - {exp_col_name}")
711
+
712
+ for model_t in model_types_selected:
713
+ fig, comp_data = _process_and_plot_single_experiment(
714
+ time_exp, biomass_exp, substrate_exp, product_exp,
715
+ None, None, None, # No std dev para modo 'independent' por ahora
716
+ current_exp_name_label, model_t, int(maxfev_val),
717
+ legend_position, params_position, show_legend_plot, show_params_plot,
718
+ style_plot, line_color_plot, point_color_plot, line_style_plot, marker_style_plot,
719
+ use_differential_eqs, analysis_mode, parsed_bounds_biomass,
720
+ time_unit_str, biomass_unit_str, substrate_unit_str, product_unit_str
721
+ )
722
+ if fig: figures_list.append(fig)
723
+ comparison_data_list.append(comp_data)
724
+ experiment_counter += 1
725
  except KeyError as e:
726
+ print(f"Advertencia: Falta la columna {e} para el experimento '{exp_col_name}' en la hoja '{sheet_name}'. Saltando.")
727
  continue
728
+ except Exception as e_exp:
729
+ print(f"Error procesando experimento '{exp_col_name}' en hoja '{sheet_name}': {e_exp}")
730
+ continue
731
+
732
 
733
+ elif analysis_mode in ['average', 'combinado']:
734
+ # Para 'average' y 'combinado', se promedian las réplicas dentro de una hoja
735
+ model_data_loader = BioprocessModel() # Usar una instancia para procesar datos de la hoja
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
736
  try:
737
+ model_data_loader.process_data(df)
738
+ except ValueError as ve: # Capturar error de columna de tiempo faltante
739
+ print(f"Error en la hoja '{sheet_name}': {ve}. Saltando esta hoja.")
740
+ continue
 
 
 
741
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
742
 
743
+ if len(model_data_loader.time) == 0:
744
+ print(f"No se encontraron datos de tiempo válidos en la hoja '{sheet_name}'. Saltando.")
745
+ continue
746
 
747
+ time_avg = model_data_loader.time
748
+ biomass_avg = model_data_loader.dataxp[-1] if model_data_loader.dataxp else np.array([])
749
+ substrate_avg = model_data_loader.datasp[-1] if model_data_loader.datasp else np.array([])
750
+ product_avg = model_data_loader.datapp[-1] if model_data_loader.datapp else np.array([])
751
+
752
+ biomass_std_avg = model_data_loader.datax_std[-1] if model_data_loader.datax_std and len(model_data_loader.datax_std[-1]) == len(time_avg) else None
753
+ substrate_std_avg = model_data_loader.datas_std[-1] if model_data_loader.datas_std and len(model_data_loader.datas_std[-1]) == len(time_avg) else None
754
+ product_std_avg = model_data_loader.datap_std[-1] if model_data_loader.datap_std and len(model_data_loader.datap_std[-1]) == len(time_avg) else None
755
+
756
+ current_exp_name_label = (experiment_names[experiment_counter] if experiment_counter < len(experiment_names)
757
+ else f"{sheet_name} (Promedio)")
758
+
759
+ for model_t in model_types_selected:
760
+ fig, comp_data = _process_and_plot_single_experiment(
761
+ time_avg, biomass_avg, substrate_avg, product_avg,
762
+ biomass_std_avg, substrate_std_avg, product_std_avg,
763
+ current_exp_name_label, model_t, int(maxfev_val),
764
+ legend_position, params_position, show_legend_plot, show_params_plot,
765
+ style_plot, line_color_plot, point_color_plot, line_style_plot, marker_style_plot,
766
+ use_differential_eqs, analysis_mode, parsed_bounds_biomass, # plot_mode es analysis_mode aquí
767
+ time_unit_str, biomass_unit_str, substrate_unit_str, product_unit_str
768
+ )
769
+ if fig: figures_list.append(fig)
770
+ comparison_data_list.append(comp_data)
771
+ experiment_counter += 1
772
 
773
+ comparison_df = pd.DataFrame(comparison_data_list)
774
  if not comparison_df.empty:
775
  comparison_df_sorted = comparison_df.sort_values(
776
  by=['R² Biomasa', 'R² Sustrato', 'R² Producto', 'RMSE Biomasa', 'RMSE Sustrato', 'RMSE Producto'],
777
  ascending=[False, False, False, True, True, True]
778
  ).reset_index(drop=True)
779
  else:
780
+ comparison_df_sorted = pd.DataFrame(columns=[ # DataFrame vacío con columnas esperadas
781
+ 'Experimento', 'Modelo', 'R² Biomasa', 'RMSE Biomasa',
782
+ 'R² Sustrato', 'RMSE Sustrato', 'R² Producto', 'RMSE Producto'
783
+ ])
784
+
785
+ return figures_list, comparison_df_sorted, "Proceso completado." # Añadir mensaje de estado
786
 
 
787
 
788
  def create_interface():
789
+ with gr.Blocks(theme=gr.themes.Soft()) as demo: # Usar un tema
790
  gr.Markdown("# Modelos de Bioproceso: Logístico, Gompertz, Moser y Luedeking-Piret")
 
791
  gr.Markdown(r"""
792
+ ## Ecuaciones ... (sin cambios)
793
+ """) # Tu markdown de ecuaciones aquí
794
+
795
+ with gr.Tabs():
796
+ with gr.TabItem("Configuración Principal"):
797
+ file_input = gr.File(label="Subir archivo Excel (.xlsx)")
798
+ experiment_names = gr.Textbox(
799
+ label="Nombres de los experimentos (uno por línea, opcional)",
800
+ placeholder="Tratamiento A\nTratamiento B\n...\nSi se deja vacío, se usarán nombres de hoja/columna.",
801
+ lines=3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
802
  )
803
+ model_types = gr.CheckboxGroup(
804
+ choices=["logistic", "gompertz", "moser"],
805
+ label="Tipo(s) de Modelo de Biomasa",
806
+ value=["logistic"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
807
  )
808
+ analysis_mode = gr.Radio(
809
+ choices=[
810
+ ("Procesar cada réplica/columna independientemente", "independent"),
811
+ ("Promediar réplicas por hoja (gráficos separados)", "average"),
812
+ ("Promediar réplicas por hoja (gráfico combinado)", "combinado")
813
+ ],
814
+ label="Modo de Análisis de Datos del Excel", value="independent"
815
  )
816
+ use_differential = gr.Checkbox(label="Usar EDOs para predecir y graficar curvas (en lugar de las formas integradas ajustadas)", value=False)
817
+ maxfev_input = gr.Number(label="maxfev (Máx. iteraciones para ajuste de curvas)", value=50000, precision=0)
818
+
819
+ with gr.Accordion("Bounds para Parámetros de Biomasa (opcional)", open=False):
820
+ gr.Markdown("Especificar bounds como `valor1,valor2,valor3`. Los parámetros son (X0, Xm, um) para Logístico, (Xm, um, lag) para Gompertz, (Xm, um, Ks) para Moser.")
821
+ lower_bounds_biomass = gr.Textbox(
822
+ label="Lower Bounds Biomasa (ej: 0.01,1,0.01)",
823
+ placeholder="Dejar vacío para -infinito para todos"
824
+ )
825
+ upper_bounds_biomass = gr.Textbox(
826
+ label="Upper Bounds Biomasa (ej: 1,10,1)",
827
+ placeholder="Dejar vacío para +infinito para todos"
828
+ )
829
+
830
+ with gr.TabItem("Personalización de Gráficos"):
831
+ with gr.Row():
832
+ show_legend = gr.Checkbox(label="Mostrar Leyenda", value=True)
833
+ legend_position = gr.Dropdown(
834
+ choices=["best", "upper left", "upper right", "lower left", "lower right", "center left", "center right", "lower center", "upper center", "center"],
835
+ label="Posición Leyenda", value="best"
836
+ )
837
+ with gr.Row():
838
+ show_params = gr.Checkbox(label="Mostrar Parámetros/Estadísticas", value=True)
839
+ params_position = gr.Dropdown(
840
+ choices=["upper left", "upper right", "lower left", "lower right", "outside right"],
841
+ label="Posición Parámetros", value="upper right"
842
+ )
843
+ with gr.Row():
844
+ style_dropdown = gr.Dropdown(choices=['whitegrid', 'darkgrid', 'white', 'dark', 'ticks'], label="Estilo Seaborn", value='whitegrid')
845
+ line_style_dropdown = gr.Dropdown(choices=['-', '--', '-.', ':'], label="Estilo de Línea Modelo", value='-')
846
+ marker_style_dropdown = gr.Dropdown(choices=['o', 's', '^', 'v', 'D', 'x', '+', '*'], label="Estilo de Punto Datos", value='o')
847
+ with gr.Row():
848
+ line_color_picker = gr.ColorPicker(label="Color Línea Modelo", value='#0000FF')
849
+ point_color_picker = gr.ColorPicker(label="Color Puntos Datos", value='#000000')
850
+
851
+ gr.Markdown("### Unidades para los Ejes (opcional)")
852
+ with gr.Row():
853
+ time_unit_input = gr.Textbox(label="Unidad de Tiempo", placeholder="ej: h, días")
854
+ biomass_unit_input = gr.Textbox(label="Unidad de Biomasa", placeholder="ej: g/L, UFC/mL")
855
+ with gr.Row():
856
+ substrate_unit_input = gr.Textbox(label="Unidad de Sustrato", placeholder="ej: g/L, %")
857
+ product_unit_input = gr.Textbox(label="Unidad de Producto", placeholder="ej: g/L, UI/mL")
858
+
859
+ simulate_btn = gr.Button("Generar Modelos y Gráficos", variant="primary")
860
+
861
+ status_message = gr.Textbox(label="Estado", interactive=False) # Para mostrar mensajes de error o éxito
862
+
863
+ output_gallery = gr.Gallery(label="Resultados Gráficos", columns=[1,2], height='auto', object_fit="contain")
864
  output_table = gr.Dataframe(
865
  label="Tabla Comparativa de Modelos",
866
  headers=["Experimento", "Modelo", "R² Biomasa", "RMSE Biomasa",
867
  "R² Sustrato", "RMSE Sustrato", "R² Producto", "RMSE Producto"],
868
+ interactive=False,
869
+ wrap=True
870
  )
871
+
872
+ state_df_for_export = gr.State() # Para guardar el DataFrame para exportar
873
+
874
+ def run_simulation_wrapper(
875
+ file, exp_names_str, models_sel, mode_sel, use_diff_eq, maxfev,
876
+ lb_biomass_str, ub_biomass_str, # Bounds
877
+ show_leg, leg_pos, show_par, par_pos, # Leyenda y params
878
+ style_sel, lstyle_sel, mstyle_sel, lcolor, pcolor, # Estilos
879
+ # Unidades
880
+ t_unit, b_unit, s_unit, p_unit):
881
+
882
+ exp_names_list = [name.strip() for name in exp_names_str.strip().split('\n') if name.strip()]
883
+
884
+ figures, comparison_df, message = process_all_data(
885
+ file, leg_pos, par_pos, models_sel, mode_sel, exp_names_list,
886
+ lb_biomass_str, ub_biomass_str, # Pasamos bounds como strings
887
+ style_sel, lcolor, pcolor, lstyle_sel, mstyle_sel,
888
+ show_leg, show_par, use_diff_eq, maxfev,
889
+ # Unidades
890
+ t_unit, b_unit, s_unit, p_unit
891
+ )
892
+ return figures, comparison_df, comparison_df, message # Devolver mensaje para status_message
893
+
894
+ simulate_btn.click(
895
+ fn=run_simulation_wrapper,
896
+ inputs=[
897
+ file_input, experiment_names, model_types, analysis_mode, use_differential, maxfev_input,
898
+ lower_bounds_biomass, upper_bounds_biomass, # Bounds
899
+ show_legend, legend_position, show_params, params_position, # Leyenda y params
900
+ style_dropdown, line_style_dropdown, marker_style_dropdown, line_color_picker, point_color_picker, # Estilos
901
+ # Unidades
902
+ time_unit_input, biomass_unit_input, substrate_unit_input, product_unit_input
903
+ ],
904
+ outputs=[output_gallery, output_table, state_df_for_export, status_message] # Añadido status_message
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
905
  )
906
 
907
+ def export_excel(df_to_export):
908
+ if df_to_export is None or df_to_export.empty:
909
+ # Devolver un archivo temporal vacío o un mensaje
910
+ # Para Gradio >3.0, puedes devolver None y se mostrará "No file"
911
+ # o un gr.Warning("No hay datos para exportar.")
912
+ # Por ahora, devolvemos un archivo con un nombre que indique que está vacío.
913
+ with tempfile.NamedTemporaryFile(prefix="no_data_", suffix=".xlsx", delete=False) as tmp:
914
+ # Opcional: escribir un mensaje en el Excel
915
+ pd.DataFrame({"Mensaje": ["No hay datos para exportar."]}).to_excel(tmp.name, index=False)
916
+ return tmp.name
917
+
918
  with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
919
+ df_to_export.to_excel(tmp.name, index=False)
920
  return tmp.name
921
 
922
  export_btn = gr.Button("Exportar Tabla a Excel")
923
+ file_output_excel = gr.File(label="Descargar Tabla Excel")
924
 
925
  export_btn.click(
926
  fn=export_excel,
927
+ inputs=state_df_for_export,
928
+ outputs=file_output_excel
929
  )
 
930
  return demo
931
 
932
+ if __name__ == '__main__':
933
+ app_interface = create_interface()
934
+ app_interface.launch(share=True, debug=True) # debug=True es útil durante el desarrollo