C2MV commited on
Commit
b1db100
·
verified ·
1 Parent(s): 7f6867f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +140 -255
app.py CHANGED
@@ -1,27 +1,21 @@
1
  # --- INSTALACIÓN DE DEPENDENCIAS ADICIONALES ---
2
- import os
3
- import sys
4
- import subprocess
5
-
6
  os.system("pip install --upgrade gradio")
7
 
8
  # --- IMPORTACIONES ---
9
  import os
10
  import io
 
 
11
  import tempfile
12
  import traceback
13
  import zipfile
14
  from typing import List, Tuple, Dict, Any, Optional, Union
15
  from abc import ABC, abstractmethod
16
- from unittest.mock import MagicMock
17
  from dataclasses import dataclass
18
  from enum import Enum
19
- import json
20
 
21
- from PIL import Image
22
- import gradio as gr
23
- import plotly.graph_objects as go
24
- from plotly.subplots import make_subplots
25
  import numpy as np
26
  import pandas as pd
27
  import matplotlib.pyplot as plt
@@ -29,13 +23,21 @@ import seaborn as sns
29
  from scipy.integrate import odeint
30
  from scipy.optimize import curve_fit, differential_evolution
31
  from sklearn.metrics import mean_squared_error, r2_score
 
 
 
 
 
 
32
  from docx import Document
33
  from docx.shared import Inches
34
  from fpdf import FPDF
35
  from fpdf.enums import XPos, YPos
 
36
  from fastapi import FastAPI
37
  import uvicorn
38
 
 
39
  # --- SISTEMA DE INTERNACIONALIZACIÓN ---
40
  class Language(Enum):
41
  ES = "Español"
@@ -58,7 +60,7 @@ TRANSLATIONS = {
58
  "results": "Resultados",
59
  "download": "Descargar",
60
  "biomass": "Biomasa",
61
- "substrate": "Sustrato",
62
  "product": "Producto",
63
  "time": "Tiempo",
64
  "parameters": "Parámetros",
@@ -87,12 +89,13 @@ TRANSLATIONS = {
87
  "parameters": "Parameters",
88
  "model_comparison": "Model Comparison",
89
  "dark_mode": "Dark Mode",
90
- "light_mode": "Light Mode",
91
  "language": "Language",
92
  "theory": "Theory and Models",
93
  "guide": "User Guide",
94
  "api_docs": "API Documentation"
95
  },
 
96
  }
97
 
98
  # --- CONSTANTES MEJORADAS ---
@@ -127,9 +130,8 @@ THEMES = {
127
  }
128
 
129
  # --- MODELOS CINÉTICOS COMPLETOS ---
130
-
131
  class KineticModel(ABC):
132
- def __init__(self, name: str, display_name: str, param_names: List[str],
133
  description: str = "", equation: str = "", reference: str = ""):
134
  self.name = name
135
  self.display_name = display_name
@@ -140,53 +142,53 @@ class KineticModel(ABC):
140
  self.reference = reference
141
 
142
  @abstractmethod
143
- def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
144
  pass
145
-
146
- def diff_function(self, X: float, t: float, params: List[float]) -> float:
147
  return 0.0
148
-
149
  @abstractmethod
150
- def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
151
  pass
152
-
153
  @abstractmethod
154
- def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
155
  pass
156
 
157
  # Modelo Logístico
158
  class LogisticModel(KineticModel):
159
  def __init__(self):
160
  super().__init__(
161
- "logistic",
162
- "Logístico",
163
  ["X0", "Xm", "μm"],
164
  "Modelo de crecimiento logístico clásico para poblaciones limitadas",
165
  r"X(t) = \frac{X_0 X_m e^{\mu_m t}}{X_m - X_0 + X_0 e^{\mu_m t}}",
166
  "Verhulst (1838)"
167
  )
168
-
169
  def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
170
  X0, Xm, um = params
171
- if Xm <= 0 or X0 <= 0 or Xm < X0:
172
  return np.full_like(t, np.nan)
173
  exp_arg = np.clip(um * t, -700, 700)
174
  term_exp = np.exp(exp_arg)
175
  denominator = Xm - X0 + X0 * term_exp
176
  denominator = np.where(denominator == 0, 1e-9, denominator)
177
  return (X0 * term_exp * Xm) / denominator
178
-
179
  def diff_function(self, X: float, t: float, params: List[float]) -> float:
180
  _, Xm, um = params
181
  return um * X * (1 - X / Xm) if Xm > 0 else 0.0
182
-
183
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
184
  return [
185
  biomass[0] if len(biomass) > 0 and biomass[0] > 1e-6 else 1e-3,
186
  max(biomass) if len(biomass) > 0 else 1.0,
187
  0.1
188
  ]
189
-
190
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
191
  initial_biomass = biomass[0] if len(biomass) > 0 else 1e-9
192
  max_biomass = max(biomass) if len(biomass) > 0 else 1.0
@@ -196,14 +198,14 @@ class LogisticModel(KineticModel):
196
  class GompertzModel(KineticModel):
197
  def __init__(self):
198
  super().__init__(
199
- "gompertz",
200
- "Gompertz",
201
  ["Xm", "μm", "λ"],
202
  "Modelo de crecimiento asimétrico con fase lag",
203
  r"X(t) = X_m \exp\left(-\exp\left(\frac{\mu_m e}{X_m}(\lambda-t)+1\right)\right)",
204
  "Gompertz (1825)"
205
  )
206
-
207
  def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
208
  Xm, um, lag = params
209
  if Xm <= 0 or um <= 0:
@@ -211,21 +213,21 @@ class GompertzModel(KineticModel):
211
  exp_term = (um * np.e / Xm) * (lag - t) + 1
212
  exp_term_clipped = np.clip(exp_term, -700, 700)
213
  return Xm * np.exp(-np.exp(exp_term_clipped))
214
-
215
  def diff_function(self, X: float, t: float, params: List[float]) -> float:
216
  Xm, um, lag = params
217
  k_val = um * np.e / Xm
218
  u_val = k_val * (lag - t) + 1
219
  u_val_clipped = np.clip(u_val, -np.inf, 700)
220
  return X * k_val * np.exp(u_val_clipped) if Xm > 0 and X > 0 else 0.0
221
-
222
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
223
  return [
224
  max(biomass) if len(biomass) > 0 else 1.0,
225
  0.1,
226
  time[np.argmax(np.gradient(biomass))] if len(biomass) > 1 else 0
227
  ]
228
-
229
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
230
  initial_biomass = min(biomass) if len(biomass) > 0 else 1e-9
231
  max_biomass = max(biomass) if len(biomass) > 0 else 1.0
@@ -235,25 +237,25 @@ class GompertzModel(KineticModel):
235
  class MoserModel(KineticModel):
236
  def __init__(self):
237
  super().__init__(
238
- "moser",
239
- "Moser",
240
  ["Xm", "μm", "Ks"],
241
  "Modelo exponencial simple de Moser",
242
  r"X(t) = X_m (1 - e^{-\mu_m (t - K_s)})",
243
  "Moser (1958)"
244
  )
245
-
246
  def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
247
  Xm, um, Ks = params
248
  return Xm * (1 - np.exp(-um * (t - Ks))) if Xm > 0 and um > 0 else np.full_like(t, np.nan)
249
-
250
  def diff_function(self, X: float, t: float, params: List[float]) -> float:
251
  Xm, um, _ = params
252
  return um * (Xm - X) if Xm > 0 else 0.0
253
-
254
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
255
  return [max(biomass) if len(biomass) > 0 else 1.0, 0.1, 0]
256
-
257
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
258
  initial_biomass = min(biomass) if len(biomass) > 0 else 1e-9
259
  max_biomass = max(biomass) if len(biomass) > 0 else 1.0
@@ -263,14 +265,14 @@ class MoserModel(KineticModel):
263
  class BaranyiModel(KineticModel):
264
  def __init__(self):
265
  super().__init__(
266
- "baranyi",
267
- "Baranyi",
268
  ["X0", "Xm", "μm", "λ"],
269
  "Modelo de Baranyi con fase lag explícita",
270
  r"X(t) = X_m / [1 + ((X_m/X_0) - 1) \exp(-\mu_m A(t))]",
271
  "Baranyi & Roberts (1994)"
272
  )
273
-
274
  def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
275
  X0, Xm, um, lag = params
276
  if X0 <= 0 or Xm <= X0 or um <= 0 or lag < 0:
@@ -280,7 +282,7 @@ class BaranyiModel(KineticModel):
280
  numerator = Xm
281
  denominator = 1 + ((Xm / X0) - 1) * (1 / exp_um_At)
282
  return numerator / np.where(denominator == 0, 1e-9, denominator)
283
-
284
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
285
  return [
286
  biomass[0] if len(biomass) > 0 and biomass[0] > 1e-6 else 1e-3,
@@ -288,7 +290,7 @@ class BaranyiModel(KineticModel):
288
  0.1,
289
  time[np.argmax(np.gradient(biomass))] if len(biomass) > 1 else 0.0
290
  ]
291
-
292
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
293
  initial_biomass = biomass[0] if len(biomass) > 0 else 1e-9
294
  max_biomass = max(biomass) if len(biomass) > 0 else 1.0
@@ -305,22 +307,22 @@ class MonodModel(KineticModel):
305
  r"\mu = \frac{\mu_{max} \cdot S}{K_s + S} - m",
306
  "Monod (1949)"
307
  )
308
-
309
  def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
310
  # Implementación simplificada para ajuste
311
  μmax, Ks, Y, m = params
312
  # Este es un modelo más complejo que requiere integración numérica
313
  return np.full_like(t, np.nan) # Se usa solo con EDO
314
-
315
  def diff_function(self, X: float, t: float, params: List[float]) -> float:
316
  μmax, Ks, Y, m = params
317
  S = 10.0 # Valor placeholder, necesita integrarse con sustrato
318
  μ = (μmax * S / (Ks + S)) - m
319
  return μ * X
320
-
321
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
322
  return [0.5, 0.1, 0.5, 0.01]
323
-
324
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
325
  return ([0.01, 0.001, 0.1, 0.0], [2.0, 5.0, 1.0, 0.1])
326
 
@@ -335,19 +337,19 @@ class ContoisModel(KineticModel):
335
  r"\mu = \frac{\mu_{max} \cdot S}{K_{sx} \cdot X + S} - m",
336
  "Contois (1959)"
337
  )
338
-
339
  def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
340
  return np.full_like(t, np.nan) # Requiere EDO
341
-
342
  def diff_function(self, X: float, t: float, params: List[float]) -> float:
343
  μmax, Ksx, Y, m = params
344
  S = 10.0 # Placeholder
345
  μ = (μmax * S / (Ksx * X + S)) - m
346
  return μ * X
347
-
348
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
349
  return [0.5, 0.5, 0.5, 0.01]
350
-
351
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
352
  return ([0.01, 0.01, 0.1, 0.0], [2.0, 10.0, 1.0, 0.1])
353
 
@@ -362,19 +364,19 @@ class AndrewsModel(KineticModel):
362
  r"\mu = \frac{\mu_{max} \cdot S}{K_s + S + \frac{S^2}{K_i}} - m",
363
  "Andrews (1968)"
364
  )
365
-
366
  def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
367
  return np.full_like(t, np.nan)
368
-
369
  def diff_function(self, X: float, t: float, params: List[float]) -> float:
370
  μmax, Ks, Ki, Y, m = params
371
  S = 10.0 # Placeholder
372
  μ = (μmax * S / (Ks + S + S**2/Ki)) - m
373
  return μ * X
374
-
375
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
376
  return [0.5, 0.1, 50.0, 0.5, 0.01]
377
-
378
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
379
  return ([0.01, 0.001, 1.0, 0.1, 0.0], [2.0, 5.0, 200.0, 1.0, 0.1])
380
 
@@ -389,19 +391,19 @@ class TessierModel(KineticModel):
389
  r"\mu = \mu_{max} \cdot (1 - e^{-S/K_s})",
390
  "Tessier (1942)"
391
  )
392
-
393
  def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
394
  μmax, Ks, X0 = params
395
  # Implementación simplificada
396
  return X0 * np.exp(μmax * t * 0.5) # Aproximación
397
-
398
  def diff_function(self, X: float, t: float, params: List[float]) -> float:
399
  μmax, Ks, X0 = params
400
  return μmax * X * 0.5 # Simplificado
401
-
402
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
403
  return [0.5, 1.0, biomass[0] if len(biomass) > 0 else 0.1]
404
-
405
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
406
  return ([0.01, 0.1, 1e-9], [2.0, 10.0, 1.0])
407
 
@@ -413,17 +415,17 @@ class RichardsModel(KineticModel):
413
  "Richards",
414
  ["A", "μm", "λ", "ν", "X0"],
415
  "Modelo generalizado de Richards",
416
- r"X(t) = A \cdot [1 + \nu \cdot e^{-\mu_m(t-\lambda)}]^{-1/\nu}",
417
  "Richards (1959)"
418
  )
419
-
420
  def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
421
  A, μm, λ, ν, X0 = params
422
  if A <= 0 or μm <= 0 or ν <= 0:
423
  return np.full_like(t, np.nan)
424
  exp_term = np.exp(-μm * (t - λ))
425
  return A * (1 + ν * exp_term) ** (-1/ν)
426
-
427
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
428
  return [
429
  max(biomass) if len(biomass) > 0 else 1.0,
@@ -432,7 +434,7 @@ class RichardsModel(KineticModel):
432
  1.0,
433
  biomass[0] if len(biomass) > 0 else 0.1
434
  ]
435
-
436
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
437
  max_biomass = max(biomass) if len(biomass) > 0 else 10.0
438
  max_time = max(time) if len(time) > 0 else 100.0
@@ -452,14 +454,14 @@ class StannardModel(KineticModel):
452
  r"X(t) = X_m \cdot [1 - e^{-\mu_m(t-\lambda)^\alpha}]",
453
  "Stannard et al. (1985)"
454
  )
455
-
456
  def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
457
  Xm, μm, λ, α = params
458
  if Xm <= 0 or μm <= 0 or α <= 0:
459
  return np.full_like(t, np.nan)
460
  t_shifted = np.maximum(t - λ, 0)
461
  return Xm * (1 - np.exp(-μm * t_shifted ** α))
462
-
463
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
464
  return [
465
  max(biomass) if len(biomass) > 0 else 1.0,
@@ -467,7 +469,7 @@ class StannardModel(KineticModel):
467
  0.0,
468
  1.0
469
  ]
470
-
471
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
472
  max_biomass = max(biomass) if len(biomass) > 0 else 10.0
473
  max_time = max(time) if len(time) > 0 else 100.0
@@ -484,13 +486,13 @@ class HuangModel(KineticModel):
484
  r"X(t) = X_m \cdot \frac{1}{1 + e^{-\mu_m(t-\lambda-m/n)}}",
485
  "Huang (2008)"
486
  )
487
-
488
  def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
489
  Xm, μm, λ, n, m = params
490
  if Xm <= 0 or μm <= 0 or n <= 0:
491
  return np.full_like(t, np.nan)
492
  return Xm / (1 + np.exp(-μm * (t - λ - m/n)))
493
-
494
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
495
  return [
496
  max(biomass) if len(biomass) > 0 else 1.0,
@@ -499,7 +501,7 @@ class HuangModel(KineticModel):
499
  1.0,
500
  0.5
501
  ]
502
-
503
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
504
  max_biomass = max(biomass) if len(biomass) > 0 else 10.0
505
  max_time = max(time) if len(time) > 0 else 100.0
@@ -511,9 +513,9 @@ class HuangModel(KineticModel):
511
  # --- REGISTRO ACTUALIZADO DE MODELOS ---
512
  AVAILABLE_MODELS: Dict[str, KineticModel] = {
513
  model.name: model for model in [
514
- LogisticModel(),
515
- GompertzModel(),
516
- MoserModel(),
517
  BaranyiModel(),
518
  MonodModel(),
519
  ContoisModel(),
@@ -527,7 +529,7 @@ AVAILABLE_MODELS: Dict[str, KineticModel] = {
527
 
528
  # --- CLASE MEJORADA DE AJUSTE ---
529
  class BioprocessFitter:
530
- def __init__(self, kinetic_model: KineticModel, maxfev: int = 50000,
531
  use_differential_evolution: bool = False):
532
  self.model = kinetic_model
533
  self.maxfev = maxfev
@@ -542,9 +544,9 @@ class BioprocessFitter:
542
  self.data_means: Dict[str, Optional[np.ndarray]] = {c: None for c in COMPONENTS}
543
  self.data_stds: Dict[str, Optional[np.ndarray]] = {c: None for c in COMPONENTS}
544
 
545
- def _get_biomass_at_t(self, t: np.ndarray, p: List[float]) -> np.ndarray:
546
  return self.model.model_function(t, *p)
547
-
548
  def _get_initial_biomass(self, p: List[float]) -> float:
549
  if not p: return 0.0
550
  if any(k in self.model.param_names for k in ["Xo", "X0"]):
@@ -553,7 +555,7 @@ class BioprocessFitter:
553
  return p[idx]
554
  except (ValueError, IndexError): pass
555
  return float(self.model.model_function(np.array([0]), *p)[0])
556
-
557
  def _calc_integral(self, t: np.ndarray, p: List[float]) -> Tuple[np.ndarray, np.ndarray]:
558
  X_t = self._get_biomass_at_t(t, p)
559
  if np.any(np.isnan(X_t)): return np.full_like(t, np.nan), np.full_like(t, np.nan)
@@ -562,23 +564,22 @@ class BioprocessFitter:
562
  dt = np.diff(t, prepend=t[0] - (t[1] - t[0] if len(t) > 1 else 1))
563
  integral_X = np.cumsum(X_t * dt)
564
  return integral_X, X_t
565
-
566
  def substrate(self, t: np.ndarray, so: float, p_c: float, q: float, bio_p: List[float]) -> np.ndarray:
567
  integral, X_t = self._calc_integral(t, bio_p)
568
  X0 = self._get_initial_biomass(bio_p)
569
  return so - p_c * (X_t - X0) - q * integral
570
-
571
  def product(self, t: np.ndarray, po: float, alpha: float, beta: float, bio_p: List[float]) -> np.ndarray:
572
  integral, X_t = self._calc_integral(t, bio_p)
573
  X0 = self._get_initial_biomass(bio_p)
574
  return po + alpha * (X_t - X0) + beta * integral
575
-
576
  def process_data_from_df(self, df: pd.DataFrame) -> None:
577
  try:
578
  time_col = [c for c in df.columns if c[1].strip().lower() == C_TIME][0]
579
  self.data_time = df[time_col].dropna().to_numpy()
580
  min_len = len(self.data_time)
581
-
582
  def extract(name: str) -> Tuple[np.ndarray, np.ndarray]:
583
  cols = [c for c in df.columns if c[1].strip().lower() == name.lower()]
584
  if not cols: return np.array([]), np.array([])
@@ -589,32 +590,28 @@ class BioprocessFitter:
589
  mean = np.mean(arr, axis=0)
590
  std = np.std(arr, axis=0, ddof=1) if arr.shape[0] > 1 else np.zeros_like(mean)
591
  return mean, std
592
-
593
  self.data_means[C_BIOMASS], self.data_stds[C_BIOMASS] = extract('Biomasa')
594
  self.data_means[C_SUBSTRATE], self.data_stds[C_SUBSTRATE] = extract('Sustrato')
595
  self.data_means[C_PRODUCT], self.data_stds[C_PRODUCT] = extract('Producto')
596
  except (IndexError, KeyError) as e:
597
  raise ValueError(f"Estructura de DataFrame inválida. Error: {e}")
598
-
599
- def _calculate_metrics(self, y_true: np.ndarray, y_pred: np.ndarray,
600
  n_params: int) -> Dict[str, float]:
601
  """Calcula métricas adicionales de bondad de ajuste"""
602
  n = len(y_true)
603
  residuals = y_true - y_pred
604
  ss_res = np.sum(residuals**2)
605
  ss_tot = np.sum((y_true - np.mean(y_true))**2)
606
-
607
  r2 = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0
608
  rmse = np.sqrt(ss_res / n)
609
  mae = np.mean(np.abs(residuals))
610
-
611
  # AIC y BIC
612
  if n > n_params + 1:
613
  aic = n * np.log(ss_res/n) + 2 * n_params
614
  bic = n * np.log(ss_res/n) + n_params * np.log(n)
615
  else:
616
  aic = bic = np.inf
617
-
618
  return {
619
  'r2': r2,
620
  'rmse': rmse,
@@ -622,7 +619,7 @@ class BioprocessFitter:
622
  'aic': aic,
623
  'bic': bic
624
  }
625
-
626
  def _fit_component_de(self, func, t, data, bounds, *args):
627
  """Ajuste usando evolución diferencial para optimización global"""
628
  def objective(params):
@@ -633,56 +630,50 @@ class BioprocessFitter:
633
  return np.sum((data - pred)**2)
634
  except:
635
  return 1e10
636
-
637
- result = differential_evolution(objective, bounds=list(zip(*bounds)),
638
  maxiter=1000, seed=42)
639
  if result.success:
640
  popt = result.x
641
  pred = func(t, *popt, *args)
642
  metrics = self._calculate_metrics(data, pred, len(popt))
643
  return list(popt), metrics
644
- return None, {'r2': np.nan, 'rmse': np.nan, 'mae': np.nan,
645
  'aic': np.nan, 'bic': np.nan}
646
-
647
  def _fit_component(self, func, t, data, p0, bounds, sigma=None, *args):
648
  try:
649
  if self.use_differential_evolution:
650
  return self._fit_component_de(func, t, data, bounds, *args)
651
-
652
  if sigma is not None:
653
  sigma = np.where(sigma == 0, 1e-9, sigma)
654
-
655
- popt, _ = curve_fit(func, t, data, p0, bounds=bounds,
656
  maxfev=self.maxfev, ftol=1e-9, xtol=1e-9,
657
  sigma=sigma, absolute_sigma=bool(sigma is not None))
658
-
659
  pred = func(t, *popt, *args)
660
  if np.any(np.isnan(pred)):
661
  return None, {'r2': np.nan, 'rmse': np.nan, 'mae': np.nan,
662
  'aic': np.nan, 'bic': np.nan}
663
-
664
  metrics = self._calculate_metrics(data, pred, len(popt))
665
  return list(popt), metrics
666
-
667
  except (RuntimeError, ValueError):
668
  return None, {'r2': np.nan, 'rmse': np.nan, 'mae': np.nan,
669
  'aic': np.nan, 'bic': np.nan}
670
-
671
  def fit_all_models(self) -> None:
672
  t, bio_m, bio_s = self.data_time, self.data_means[C_BIOMASS], self.data_stds[C_BIOMASS]
673
  if t is None or bio_m is None or len(bio_m) == 0: return
674
  popt_bio = self._fit_biomass_model(t, bio_m, bio_s)
675
  if popt_bio:
676
  bio_p = list(self.params[C_BIOMASS].values())
677
- if self.data_means[C_SUBSTRATE] is not None and len(self.data_means[C_SUBSTRATE]) > 0:
678
  self._fit_substrate_model(t, self.data_means[C_SUBSTRATE], self.data_stds[C_SUBSTRATE], bio_p)
679
- if self.data_means[C_PRODUCT] is not None and len(self.data_means[C_PRODUCT]) > 0:
680
  self._fit_product_model(t, self.data_means[C_PRODUCT], self.data_stds[C_PRODUCT], bio_p)
681
-
682
  def _fit_biomass_model(self, t, data, std):
683
  p0, bounds = self.model.get_initial_params(t, data), self.model.get_param_bounds(t, data)
684
  popt, metrics = self._fit_component(self.model.model_function, t, data, p0, bounds, std)
685
- if popt:
686
  self.params[C_BIOMASS] = dict(zip(self.model.param_names, popt))
687
  self.r2[C_BIOMASS] = metrics['r2']
688
  self.rmse[C_BIOMASS] = metrics['rmse']
@@ -690,34 +681,34 @@ class BioprocessFitter:
690
  self.aic[C_BIOMASS] = metrics['aic']
691
  self.bic[C_BIOMASS] = metrics['bic']
692
  return popt
693
-
694
  def _fit_substrate_model(self, t, data, std, bio_p):
695
  p0, b = [data[0], 0.1, 0.01], ([0, -np.inf, -np.inf], [np.inf, np.inf, np.inf])
696
  popt, metrics = self._fit_component(lambda t, so, p, q: self.substrate(t, so, p, q, bio_p), t, data, p0, b, std)
697
- if popt:
698
  self.params[C_SUBSTRATE] = {'So': popt[0], 'p': popt[1], 'q': popt[2]}
699
  self.r2[C_SUBSTRATE] = metrics['r2']
700
  self.rmse[C_SUBSTRATE] = metrics['rmse']
701
  self.mae[C_SUBSTRATE] = metrics['mae']
702
  self.aic[C_SUBSTRATE] = metrics['aic']
703
  self.bic[C_SUBSTRATE] = metrics['bic']
704
-
705
  def _fit_product_model(self, t, data, std, bio_p):
706
  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])
707
  popt, metrics = self._fit_component(lambda t, po, a, b: self.product(t, po, a, b, bio_p), t, data, p0, b, std)
708
- if popt:
709
  self.params[C_PRODUCT] = {'Po': popt[0], 'alpha': popt[1], 'beta': popt[2]}
710
  self.r2[C_PRODUCT] = metrics['r2']
711
  self.rmse[C_PRODUCT] = metrics['rmse']
712
  self.mae[C_PRODUCT] = metrics['mae']
713
  self.aic[C_PRODUCT] = metrics['aic']
714
  self.bic[C_PRODUCT] = metrics['bic']
715
-
716
  def system_ode(self, y, t, bio_p, sub_p, prod_p):
717
  X, _, _ = y
718
  dXdt = self.model.diff_function(X, t, bio_p)
719
  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]
720
-
721
  def solve_odes(self, t_fine):
722
  p = self.params
723
  bio_d, sub_d, prod_d = p[C_BIOMASS], p[C_SUBSTRATE], p[C_PRODUCT]
@@ -729,10 +720,10 @@ class BioprocessFitter:
729
  return sol[:, 0], sol[:, 1], sol[:, 2]
730
  except:
731
  return None, None, None
732
-
733
  def _generate_fine_time_grid(self, t_exp):
734
  return np.linspace(min(t_exp), max(t_exp), 500) if t_exp is not None and len(t_exp) > 1 else np.array([])
735
-
736
  def get_model_curves_for_plot(self, t_fine, use_diff):
737
  if use_diff and self.model.diff_function(1, 1, [1]*self.model.num_params) != 0:
738
  return self.solve_odes(t_fine)
@@ -747,30 +738,24 @@ class BioprocessFitter:
747
  return X, S, P
748
 
749
  # --- FUNCIONES AUXILIARES ---
750
-
751
  def format_number(value: Any, decimals: int) -> str:
752
  """Formatea un número para su visualización"""
753
  if not isinstance(value, (int, float, np.number)) or pd.isna(value):
754
  return "" if pd.isna(value) else str(value)
755
-
756
  decimals = int(decimals)
757
-
758
  if decimals == 0:
759
  if 0 < abs(value) < 1:
760
  return f"{value:.2e}"
761
  else:
762
  return str(int(round(value, 0)))
763
-
764
  return str(round(value, decimals))
765
 
766
  # --- FUNCIONES DE PLOTEO MEJORADAS CON PLOTLY ---
767
-
768
- def create_interactive_plot(plot_config: Dict, models_results: List[Dict],
769
  selected_component: str = "all") -> go.Figure:
770
  """Crea un gráfico interactivo mejorado con Plotly"""
771
  time_exp = plot_config['time_exp']
772
  time_fine = np.linspace(min(time_exp), max(time_exp), 500)
773
-
774
  # Configuración de subplots si se muestran todos los componentes
775
  if selected_component == "all":
776
  fig = make_subplots(
@@ -785,23 +770,19 @@ def create_interactive_plot(plot_config: Dict, models_results: List[Dict],
785
  fig = go.Figure()
786
  components_to_plot = [selected_component]
787
  rows = [None]
788
-
789
  # Colores para diferentes modelos
790
- colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
791
  '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
792
-
793
  # Agregar datos experimentales
794
  for comp, row in zip(components_to_plot, rows):
795
  data_exp = plot_config.get(f'{comp}_exp')
796
  data_std = plot_config.get(f'{comp}_std')
797
-
798
  if data_exp is not None:
799
  error_y = dict(
800
  type='data',
801
  array=data_std,
802
  visible=True
803
  ) if data_std is not None and np.any(data_std > 0) else None
804
-
805
  trace = go.Scatter(
806
  x=time_exp,
807
  y=data_exp,
@@ -812,17 +793,14 @@ def create_interactive_plot(plot_config: Dict, models_results: List[Dict],
812
  legendgroup=comp,
813
  showlegend=True
814
  )
815
-
816
  if selected_component == "all":
817
  fig.add_trace(trace, row=row, col=1)
818
  else:
819
  fig.add_trace(trace)
820
-
821
  # Agregar curvas de modelos
822
  for i, res in enumerate(models_results):
823
  color = colors[i % len(colors)]
824
  model_name = AVAILABLE_MODELS[res["name"]].display_name
825
-
826
  for comp, row, key in zip(components_to_plot, rows, ['X', 'S', 'P']):
827
  if res.get(key) is not None:
828
  trace = go.Scatter(
@@ -834,16 +812,13 @@ def create_interactive_plot(plot_config: Dict, models_results: List[Dict],
834
  legendgroup=f'{res["name"]}_{comp}',
835
  showlegend=True
836
  )
837
-
838
  if selected_component == "all":
839
  fig.add_trace(trace, row=row, col=1)
840
  else:
841
  fig.add_trace(trace)
842
-
843
  # Actualizar diseño
844
  theme = plot_config.get('theme', 'light')
845
  template = "plotly_white" if theme == 'light' else "plotly_dark"
846
-
847
  fig.update_layout(
848
  title=f"Análisis de Cinéticas: {plot_config.get('exp_name', '')}",
849
  template=template,
@@ -857,7 +832,6 @@ def create_interactive_plot(plot_config: Dict, models_results: List[Dict],
857
  ),
858
  margin=dict(l=80, r=250, t=100, b=80)
859
  )
860
-
861
  # Actualizar ejes
862
  if selected_component == "all":
863
  fig.update_xaxes(title_text="Tiempo", row=3, col=1)
@@ -872,82 +846,45 @@ def create_interactive_plot(plot_config: Dict, models_results: List[Dict],
872
  C_PRODUCT: "Producto (g/L)"
873
  }
874
  fig.update_yaxes(title_text=labels.get(selected_component, "Valor"))
875
-
876
- # Agregar botones para cambiar entre modos de visualización
877
- fig.update_layout(
878
- updatemenus=[
879
- dict(
880
- type="dropdown",
881
- showactive=True,
882
- buttons=[
883
- dict(label="Todos los componentes",
884
- method="update",
885
- args=[{"visible": [True] * len(fig.data)}]),
886
- dict(label="Solo Biomasa",
887
- method="update",
888
- args=[{"visible": [i < len(fig.data)//3 for i in range(len(fig.data))]}]),
889
- dict(label="Solo Sustrato",
890
- method="update",
891
- args=[{"visible": [len(fig.data)//3 <= i < 2*len(fig.data)//3 for i in range(len(fig.data))]}]),
892
- dict(label="Solo Producto",
893
- method="update",
894
- args=[{"visible": [i >= 2*len(fig.data)//3 for i in range(len(fig.data))]}]),
895
- ],
896
- x=0.1,
897
- y=1.15,
898
- xanchor="left",
899
- yanchor="top"
900
- )
901
- ]
902
- )
903
-
904
  return fig
905
 
906
  # --- FUNCIÓN PRINCIPAL DE ANÁLISIS ---
907
  def run_analysis(file, model_names, component, use_de, maxfev, exp_names, theme='light'):
908
  if not file: return None, pd.DataFrame(), "Error: Sube un archivo Excel."
909
  if not model_names: return None, pd.DataFrame(), "Error: Selecciona un modelo."
910
-
911
- try:
912
  xls = pd.ExcelFile(file.name)
913
- except Exception as e:
914
  return None, pd.DataFrame(), f"Error al leer archivo: {e}"
915
-
916
  results_data, msgs = [], []
917
  models_results = []
918
-
919
  exp_list = [n.strip() for n in exp_names.split('\n') if n.strip()] if exp_names else []
920
-
921
  for i, sheet in enumerate(xls.sheet_names):
922
  exp_name = exp_list[i] if i < len(exp_list) else f"Hoja '{sheet}'"
923
  try:
924
  df = pd.read_excel(xls, sheet_name=sheet, header=[0,1])
925
  reader = BioprocessFitter(list(AVAILABLE_MODELS.values())[0])
926
  reader.process_data_from_df(df)
927
-
928
- if reader.data_time is None:
929
  msgs.append(f"WARN: Sin datos de tiempo en '{sheet}'.")
930
  continue
931
-
932
  plot_config = {
933
- 'exp_name': exp_name,
934
  'time_exp': reader.data_time,
935
  'theme': theme
936
  }
937
-
938
- for c in COMPONENTS:
939
  plot_config[f'{c}_exp'] = reader.data_means[c]
940
  plot_config[f'{c}_std'] = reader.data_stds[c]
941
-
942
  t_fine = reader._generate_fine_time_grid(reader.data_time)
943
-
944
  for m_name in model_names:
945
- if m_name not in AVAILABLE_MODELS:
946
  msgs.append(f"WARN: Modelo '{m_name}' no disponible.")
947
  continue
948
-
949
  fitter = BioprocessFitter(
950
- AVAILABLE_MODELS[m_name],
951
  maxfev=int(maxfev),
952
  use_differential_evolution=use_de
953
  )
@@ -955,46 +892,38 @@ def run_analysis(file, model_names, component, use_de, maxfev, exp_names, theme=
955
  fitter.data_means = reader.data_means
956
  fitter.data_stds = reader.data_stds
957
  fitter.fit_all_models()
958
-
959
  row = {'Experimento': exp_name, 'Modelo': fitter.model.display_name}
960
  for c in COMPONENTS:
961
- if fitter.params[c]:
962
  row.update({f'{c.capitalize()}_{k}': v for k, v in fitter.params[c].items()})
963
  row[f'R2_{c.capitalize()}'] = fitter.r2.get(c)
964
  row[f'RMSE_{c.capitalize()}'] = fitter.rmse.get(c)
965
  row[f'MAE_{c.capitalize()}'] = fitter.mae.get(c)
966
  row[f'AIC_{c.capitalize()}'] = fitter.aic.get(c)
967
  row[f'BIC_{c.capitalize()}'] = fitter.bic.get(c)
968
-
969
  results_data.append(row)
970
-
971
  X, S, P = fitter.get_model_curves_for_plot(t_fine, False)
972
  models_results.append({
973
- 'name': m_name,
974
- 'X': X,
975
- 'S': S,
976
- 'P': P,
977
- 'params': fitter.params,
978
- 'r2': fitter.r2,
979
  'rmse': fitter.rmse
980
  })
981
-
982
- except Exception as e:
983
  msgs.append(f"ERROR en '{sheet}': {e}")
984
  traceback.print_exc()
985
-
986
  msg = "Análisis completado." + ("\n" + "\n".join(msgs) if msgs else "")
987
  df_res = pd.DataFrame(results_data).dropna(axis=1, how='all')
988
-
989
  # Crear gráfico interactivo
990
  fig = None
991
  if models_results and reader.data_time is not None:
992
  fig = create_interactive_plot(plot_config, models_results, component)
993
-
994
  return fig, df_res, msg
995
 
996
  # --- API ENDPOINTS PARA AGENTES DE IA ---
997
-
998
  app = FastAPI(title="Bioprocess Kinetics API", version="2.0")
999
 
1000
  @app.get("/")
@@ -1010,23 +939,18 @@ async def analyze_data(
1010
  """Endpoint para análisis de datos cinéticos"""
1011
  try:
1012
  results = {}
1013
-
1014
  for model_name in models:
1015
  if model_name not in AVAILABLE_MODELS:
1016
  continue
1017
-
1018
  model = AVAILABLE_MODELS[model_name]
1019
  fitter = BioprocessFitter(model)
1020
-
1021
  # Configurar datos
1022
  fitter.data_time = np.array(data['time'])
1023
  fitter.data_means[C_BIOMASS] = np.array(data.get('biomass', []))
1024
  fitter.data_means[C_SUBSTRATE] = np.array(data.get('substrate', []))
1025
  fitter.data_means[C_PRODUCT] = np.array(data.get('product', []))
1026
-
1027
  # Ajustar modelo
1028
  fitter.fit_all_models()
1029
-
1030
  results[model_name] = {
1031
  'parameters': fitter.params,
1032
  'metrics': {
@@ -1037,9 +961,7 @@ async def analyze_data(
1037
  'bic': fitter.bic
1038
  }
1039
  }
1040
-
1041
  return {"status": "success", "results": results}
1042
-
1043
  except Exception as e:
1044
  return {"status": "error", "message": str(e)}
1045
 
@@ -1067,14 +989,11 @@ async def predict_kinetics(
1067
  """Predice valores usando un modelo y parámetros específicos"""
1068
  if model_name not in AVAILABLE_MODELS:
1069
  return {"status": "error", "message": f"Model {model_name} not found"}
1070
-
1071
  try:
1072
  model = AVAILABLE_MODELS[model_name]
1073
  time_array = np.array(time_points)
1074
  params = [parameters[name] for name in model.param_names]
1075
-
1076
  predictions = model.model_function(time_array, *params)
1077
-
1078
  return {
1079
  "status": "success",
1080
  "predictions": predictions.tolist(),
@@ -1084,21 +1003,16 @@ async def predict_kinetics(
1084
  return {"status": "error", "message": str(e)}
1085
 
1086
  # --- INTERFAZ GRADIO MEJORADA ---
1087
-
1088
  def create_gradio_interface() -> gr.Blocks:
1089
  """Crea la interfaz mejorada con soporte multiidioma y tema"""
1090
-
1091
  def change_language(lang_key: str) -> Dict:
1092
  """Cambia el idioma de la interfaz"""
1093
  lang = Language[lang_key]
1094
  trans = TRANSLATIONS.get(lang, TRANSLATIONS[Language.ES])
1095
-
1096
  return trans["title"], trans["subtitle"]
1097
-
1098
  # Obtener opciones de modelo
1099
  MODEL_CHOICES = [(model.display_name, model.name) for model in AVAILABLE_MODELS.values()]
1100
  DEFAULT_MODELS = [m.name for m in list(AVAILABLE_MODELS.values())[:4]]
1101
-
1102
  with gr.Blocks(theme=THEMES["light"], css="""
1103
  .gradio-container {font-family: 'Inter', sans-serif;}
1104
  .theory-box {background-color: #f0f9ff; padding: 20px; border-radius: 10px; margin: 10px 0;}
@@ -1106,11 +1020,9 @@ def create_gradio_interface() -> gr.Blocks:
1106
  .model-card {border: 1px solid #e5e7eb; padding: 15px; border-radius: 8px; margin: 10px 0;}
1107
  .dark .model-card {border-color: #374151;}
1108
  """) as demo:
1109
-
1110
  # Estado para tema e idioma
1111
  current_theme = gr.State("light")
1112
  current_language = gr.State("ES")
1113
-
1114
  # Header con controles de tema e idioma
1115
  with gr.Row():
1116
  with gr.Column(scale=8):
@@ -1124,23 +1036,19 @@ def create_gradio_interface() -> gr.Blocks:
1124
  value="ES",
1125
  label="🌐 Idioma"
1126
  )
1127
-
1128
  with gr.Tabs() as tabs:
1129
  # --- TAB 1: TEORÍA Y MODELOS ---
1130
  with gr.TabItem("📚 Teoría y Modelos"):
1131
  gr.Markdown("""
1132
  ## Introducción a los Modelos Cinéticos
1133
-
1134
  Los modelos cinéticos en biotecnología describen el comportamiento dinámico
1135
  de los microorganismos durante su crecimiento. Estos modelos son fundamentales
1136
  para:
1137
-
1138
  - **Optimización de procesos**: Determinar condiciones óptimas de operación
1139
  - **Escalamiento**: Predecir comportamiento a escala industrial
1140
  - **Control de procesos**: Diseñar estrategias de control efectivas
1141
  - **Análisis económico**: Evaluar viabilidad de procesos
1142
  """)
1143
-
1144
  # Cards para cada modelo
1145
  for model_name, model in AVAILABLE_MODELS.items():
1146
  with gr.Accordion(f"📊 {model.display_name}", open=False):
@@ -1148,11 +1056,8 @@ def create_gradio_interface() -> gr.Blocks:
1148
  with gr.Column(scale=3):
1149
  gr.Markdown(f"""
1150
  **Descripción**: {model.description}
1151
-
1152
  **Ecuación**: ${model.equation}$
1153
-
1154
  **Parámetros**: {', '.join(model.param_names)}
1155
-
1156
  **Referencia**: {model.reference}
1157
  """)
1158
  with gr.Column(scale=1):
@@ -1161,7 +1066,7 @@ def create_gradio_interface() -> gr.Blocks:
1161
  - Parámetros: {model.num_params}
1162
  - Complejidad: {'⭐' * min(model.num_params, 5)}
1163
  """)
1164
-
1165
  # --- TAB 2: ANÁLISIS ---
1166
  with gr.TabItem("🔬 Análisis"):
1167
  with gr.Row():
@@ -1170,31 +1075,26 @@ def create_gradio_interface() -> gr.Blocks:
1170
  label="📁 Sube tu archivo Excel (.xlsx)",
1171
  file_types=['.xlsx']
1172
  )
1173
-
1174
  exp_names_input = gr.Textbox(
1175
  label="🏷️ Nombres de Experimentos",
1176
  placeholder="Experimento 1\nExperimento 2\n...",
1177
  lines=3
1178
  )
1179
-
1180
  model_selection_input = gr.CheckboxGroup(
1181
  choices=MODEL_CHOICES,
1182
  label="📊 Modelos a Probar",
1183
  value=DEFAULT_MODELS
1184
  )
1185
-
1186
  with gr.Accordion("⚙️ Opciones Avanzadas", open=False):
1187
  use_de_input = gr.Checkbox(
1188
  label="Usar Evolución Diferencial",
1189
  value=False,
1190
  info="Optimización global más robusta pero más lenta"
1191
  )
1192
-
1193
  maxfev_input = gr.Number(
1194
  label="Iteraciones máximas",
1195
  value=50000
1196
  )
1197
-
1198
  with gr.Column(scale=2):
1199
  # Selector de componente para visualización
1200
  component_selector = gr.Dropdown(
@@ -1207,52 +1107,41 @@ def create_gradio_interface() -> gr.Blocks:
1207
  value="all",
1208
  label="📈 Componente a visualizar"
1209
  )
1210
-
1211
  plot_output = gr.Plot(label="Visualización Interactiva")
1212
-
1213
  analyze_button = gr.Button("🚀 Analizar y Graficar", variant="primary")
1214
-
1215
  # --- TAB 3: RESULTADOS ---
1216
  with gr.TabItem("📊 Resultados"):
1217
  status_output = gr.Textbox(
1218
  label="Estado del Análisis",
1219
  interactive=False
1220
  )
1221
-
1222
  results_table = gr.DataFrame(
1223
  label="Tabla de Resultados",
1224
  wrap=True
1225
  )
1226
-
1227
  with gr.Row():
1228
  download_excel = gr.Button("📥 Descargar Excel")
1229
  download_json = gr.Button("📥 Descargar JSON")
1230
  api_docs_button = gr.Button("📖 Ver Documentación API")
1231
-
1232
  download_file = gr.File(label="Archivo descargado")
1233
-
1234
  # --- TAB 4: API ---
1235
  with gr.TabItem("🔌 API"):
1236
  gr.Markdown("""
1237
  ## Documentación de la API
1238
-
1239
  La API REST permite integrar el análisis de cinéticas en aplicaciones externas
1240
  y agentes de IA.
1241
-
1242
  ### Endpoints disponibles:
1243
-
1244
  #### 1. `GET /api/models`
1245
  Retorna la lista de modelos disponibles con su información.
1246
-
1247
  ```python
1248
  import requests
1249
  response = requests.get("http://localhost:8000/api/models")
1250
  models = response.json()
1251
  ```
1252
-
1253
  #### 2. `POST /api/analyze`
1254
  Analiza datos con los modelos especificados.
1255
-
1256
  ```python
1257
  data = {
1258
  "data": {
@@ -1266,10 +1155,8 @@ def create_gradio_interface() -> gr.Blocks:
1266
  response = requests.post("http://localhost:8000/api/analyze", json=data)
1267
  results = response.json()
1268
  ```
1269
-
1270
  #### 3. `POST /api/predict`
1271
  Predice valores usando un modelo y parámetros específicos.
1272
-
1273
  ```python
1274
  data = {
1275
  "model_name": "logistic",
@@ -1279,31 +1166,28 @@ def create_gradio_interface() -> gr.Blocks:
1279
  response = requests.post("http://localhost:8000/api/predict", json=data)
1280
  predictions = response.json()
1281
  ```
1282
-
1283
  ### Iniciar servidor API:
1284
  ```bash
1285
- uvicorn script_name:app --reload --port 8000
1286
  ```
1287
  """)
1288
-
1289
  # Botón para copiar comando
1290
  gr.Textbox(
1291
  value="uvicorn bioprocess_analyzer:app --reload --port 8000",
1292
  label="Comando para iniciar API",
1293
  interactive=False
1294
  )
1295
-
1296
  # --- EVENTOS ---
1297
-
1298
  def run_analysis_wrapper(file, models, component, use_de, maxfev, exp_names, theme):
1299
  """Wrapper para ejecutar el análisis"""
1300
  try:
1301
- return run_analysis(file, models, component, use_de, maxfev, exp_names,
1302
  'dark' if theme else 'light')
1303
  except Exception as e:
1304
  print(f"--- ERROR EN ANÁLISIS ---\n{traceback.format_exc()}")
1305
  return None, pd.DataFrame(), f"Error: {str(e)}"
1306
-
1307
  analyze_button.click(
1308
  fn=run_analysis_wrapper,
1309
  inputs=[
@@ -1317,24 +1201,20 @@ def create_gradio_interface() -> gr.Blocks:
1317
  ],
1318
  outputs=[plot_output, results_table, status_output]
1319
  )
1320
-
1321
  # Cambio de idioma
1322
  language_select.change(
1323
  fn=change_language,
1324
  inputs=[language_select],
1325
  outputs=[title_text, subtitle_text]
1326
  )
1327
-
1328
  # Cambio de tema
1329
  def apply_theme(is_dark):
1330
  return gr.Info("Tema cambiado. Los gráficos nuevos usarán el tema seleccionado.")
1331
-
1332
  theme_toggle.change(
1333
  fn=apply_theme,
1334
  inputs=[theme_toggle],
1335
  outputs=[]
1336
  )
1337
-
1338
  # Funciones de descarga
1339
  def download_results_excel(df):
1340
  if df is None or df.empty:
@@ -1343,7 +1223,6 @@ def create_gradio_interface() -> gr.Blocks:
1343
  with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as tmp:
1344
  df.to_excel(tmp.name, index=False)
1345
  return tmp.name
1346
-
1347
  def download_results_json(df):
1348
  if df is None or df.empty:
1349
  gr.Warning("No hay datos para descargar")
@@ -1351,24 +1230,30 @@ def create_gradio_interface() -> gr.Blocks:
1351
  with tempfile.NamedTemporaryFile(delete=False, suffix=".json") as tmp:
1352
  df.to_json(tmp.name, orient='records', indent=2)
1353
  return tmp.name
1354
-
1355
  download_excel.click(
1356
  fn=download_results_excel,
1357
  inputs=[results_table],
1358
  outputs=[download_file]
1359
  )
1360
-
1361
  download_json.click(
1362
  fn=download_results_json,
1363
  inputs=[results_table],
1364
  outputs=[download_file]
1365
  )
1366
-
1367
  return demo
1368
 
1369
  # --- PUNTO DE ENTRADA ---
1370
-
1371
  if __name__ == '__main__':
1372
  # Lanzar aplicación Gradio
 
 
1373
  gradio_app = create_gradio_interface()
1374
- gradio_app.launch(share=True, debug=True)
 
 
 
 
 
 
 
 
 
1
  # --- INSTALACIÓN DE DEPENDENCIAS ADICIONALES ---
2
+ # Se recomienda ejecutar este comando manualmente si es necesario
 
 
 
3
  os.system("pip install --upgrade gradio")
4
 
5
  # --- IMPORTACIONES ---
6
  import os
7
  import io
8
+ import sys
9
+ import json
10
  import tempfile
11
  import traceback
12
  import zipfile
13
  from typing import List, Tuple, Dict, Any, Optional, Union
14
  from abc import ABC, abstractmethod
 
15
  from dataclasses import dataclass
16
  from enum import Enum
17
+ from unittest.mock import MagicMock
18
 
 
 
 
 
19
  import numpy as np
20
  import pandas as pd
21
  import matplotlib.pyplot as plt
 
23
  from scipy.integrate import odeint
24
  from scipy.optimize import curve_fit, differential_evolution
25
  from sklearn.metrics import mean_squared_error, r2_score
26
+
27
+ import gradio as gr
28
+ import plotly.graph_objects as go
29
+ from plotly.subplots import make_subplots
30
+
31
+ from PIL import Image
32
  from docx import Document
33
  from docx.shared import Inches
34
  from fpdf import FPDF
35
  from fpdf.enums import XPos, YPos
36
+
37
  from fastapi import FastAPI
38
  import uvicorn
39
 
40
+
41
  # --- SISTEMA DE INTERNACIONALIZACIÓN ---
42
  class Language(Enum):
43
  ES = "Español"
 
60
  "results": "Resultados",
61
  "download": "Descargar",
62
  "biomass": "Biomasa",
63
+ "substrate": "Sustrato",
64
  "product": "Producto",
65
  "time": "Tiempo",
66
  "parameters": "Parámetros",
 
89
  "parameters": "Parameters",
90
  "model_comparison": "Model Comparison",
91
  "dark_mode": "Dark Mode",
92
+ "light_mode": "Light Mode",
93
  "language": "Language",
94
  "theory": "Theory and Models",
95
  "guide": "User Guide",
96
  "api_docs": "API Documentation"
97
  },
98
+ # Se pueden agregar más idiomas aquí
99
  }
100
 
101
  # --- CONSTANTES MEJORADAS ---
 
130
  }
131
 
132
  # --- MODELOS CINÉTICOS COMPLETOS ---
 
133
  class KineticModel(ABC):
134
+ def __init__(self, name: str, display_name: str, param_names: List[str],
135
  description: str = "", equation: str = "", reference: str = ""):
136
  self.name = name
137
  self.display_name = display_name
 
142
  self.reference = reference
143
 
144
  @abstractmethod
145
+ def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
146
  pass
147
+
148
+ def diff_function(self, X: float, t: float, params: List[float]) -> float:
149
  return 0.0
150
+
151
  @abstractmethod
152
+ def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
153
  pass
154
+
155
  @abstractmethod
156
+ def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
157
  pass
158
 
159
  # Modelo Logístico
160
  class LogisticModel(KineticModel):
161
  def __init__(self):
162
  super().__init__(
163
+ "logistic",
164
+ "Logístico",
165
  ["X0", "Xm", "μm"],
166
  "Modelo de crecimiento logístico clásico para poblaciones limitadas",
167
  r"X(t) = \frac{X_0 X_m e^{\mu_m t}}{X_m - X_0 + X_0 e^{\mu_m t}}",
168
  "Verhulst (1838)"
169
  )
170
+
171
  def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
172
  X0, Xm, um = params
173
+ if Xm <= 0 or X0 <= 0 or Xm < X0:
174
  return np.full_like(t, np.nan)
175
  exp_arg = np.clip(um * t, -700, 700)
176
  term_exp = np.exp(exp_arg)
177
  denominator = Xm - X0 + X0 * term_exp
178
  denominator = np.where(denominator == 0, 1e-9, denominator)
179
  return (X0 * term_exp * Xm) / denominator
180
+
181
  def diff_function(self, X: float, t: float, params: List[float]) -> float:
182
  _, Xm, um = params
183
  return um * X * (1 - X / Xm) if Xm > 0 else 0.0
184
+
185
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
186
  return [
187
  biomass[0] if len(biomass) > 0 and biomass[0] > 1e-6 else 1e-3,
188
  max(biomass) if len(biomass) > 0 else 1.0,
189
  0.1
190
  ]
191
+
192
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
193
  initial_biomass = biomass[0] if len(biomass) > 0 else 1e-9
194
  max_biomass = max(biomass) if len(biomass) > 0 else 1.0
 
198
  class GompertzModel(KineticModel):
199
  def __init__(self):
200
  super().__init__(
201
+ "gompertz",
202
+ "Gompertz",
203
  ["Xm", "μm", "λ"],
204
  "Modelo de crecimiento asimétrico con fase lag",
205
  r"X(t) = X_m \exp\left(-\exp\left(\frac{\mu_m e}{X_m}(\lambda-t)+1\right)\right)",
206
  "Gompertz (1825)"
207
  )
208
+
209
  def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
210
  Xm, um, lag = params
211
  if Xm <= 0 or um <= 0:
 
213
  exp_term = (um * np.e / Xm) * (lag - t) + 1
214
  exp_term_clipped = np.clip(exp_term, -700, 700)
215
  return Xm * np.exp(-np.exp(exp_term_clipped))
216
+
217
  def diff_function(self, X: float, t: float, params: List[float]) -> float:
218
  Xm, um, lag = params
219
  k_val = um * np.e / Xm
220
  u_val = k_val * (lag - t) + 1
221
  u_val_clipped = np.clip(u_val, -np.inf, 700)
222
  return X * k_val * np.exp(u_val_clipped) if Xm > 0 and X > 0 else 0.0
223
+
224
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
225
  return [
226
  max(biomass) if len(biomass) > 0 else 1.0,
227
  0.1,
228
  time[np.argmax(np.gradient(biomass))] if len(biomass) > 1 else 0
229
  ]
230
+
231
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
232
  initial_biomass = min(biomass) if len(biomass) > 0 else 1e-9
233
  max_biomass = max(biomass) if len(biomass) > 0 else 1.0
 
237
  class MoserModel(KineticModel):
238
  def __init__(self):
239
  super().__init__(
240
+ "moser",
241
+ "Moser",
242
  ["Xm", "μm", "Ks"],
243
  "Modelo exponencial simple de Moser",
244
  r"X(t) = X_m (1 - e^{-\mu_m (t - K_s)})",
245
  "Moser (1958)"
246
  )
247
+
248
  def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
249
  Xm, um, Ks = params
250
  return Xm * (1 - np.exp(-um * (t - Ks))) if Xm > 0 and um > 0 else np.full_like(t, np.nan)
251
+
252
  def diff_function(self, X: float, t: float, params: List[float]) -> float:
253
  Xm, um, _ = params
254
  return um * (Xm - X) if Xm > 0 else 0.0
255
+
256
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
257
  return [max(biomass) if len(biomass) > 0 else 1.0, 0.1, 0]
258
+
259
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
260
  initial_biomass = min(biomass) if len(biomass) > 0 else 1e-9
261
  max_biomass = max(biomass) if len(biomass) > 0 else 1.0
 
265
  class BaranyiModel(KineticModel):
266
  def __init__(self):
267
  super().__init__(
268
+ "baranyi",
269
+ "Baranyi",
270
  ["X0", "Xm", "μm", "λ"],
271
  "Modelo de Baranyi con fase lag explícita",
272
  r"X(t) = X_m / [1 + ((X_m/X_0) - 1) \exp(-\mu_m A(t))]",
273
  "Baranyi & Roberts (1994)"
274
  )
275
+
276
  def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
277
  X0, Xm, um, lag = params
278
  if X0 <= 0 or Xm <= X0 or um <= 0 or lag < 0:
 
282
  numerator = Xm
283
  denominator = 1 + ((Xm / X0) - 1) * (1 / exp_um_At)
284
  return numerator / np.where(denominator == 0, 1e-9, denominator)
285
+
286
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
287
  return [
288
  biomass[0] if len(biomass) > 0 and biomass[0] > 1e-6 else 1e-3,
 
290
  0.1,
291
  time[np.argmax(np.gradient(biomass))] if len(biomass) > 1 else 0.0
292
  ]
293
+
294
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
295
  initial_biomass = biomass[0] if len(biomass) > 0 else 1e-9
296
  max_biomass = max(biomass) if len(biomass) > 0 else 1.0
 
307
  r"\mu = \frac{\mu_{max} \cdot S}{K_s + S} - m",
308
  "Monod (1949)"
309
  )
310
+
311
  def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
312
  # Implementación simplificada para ajuste
313
  μmax, Ks, Y, m = params
314
  # Este es un modelo más complejo que requiere integración numérica
315
  return np.full_like(t, np.nan) # Se usa solo con EDO
316
+
317
  def diff_function(self, X: float, t: float, params: List[float]) -> float:
318
  μmax, Ks, Y, m = params
319
  S = 10.0 # Valor placeholder, necesita integrarse con sustrato
320
  μ = (μmax * S / (Ks + S)) - m
321
  return μ * X
322
+
323
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
324
  return [0.5, 0.1, 0.5, 0.01]
325
+
326
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
327
  return ([0.01, 0.001, 0.1, 0.0], [2.0, 5.0, 1.0, 0.1])
328
 
 
337
  r"\mu = \frac{\mu_{max} \cdot S}{K_{sx} \cdot X + S} - m",
338
  "Contois (1959)"
339
  )
340
+
341
  def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
342
  return np.full_like(t, np.nan) # Requiere EDO
343
+
344
  def diff_function(self, X: float, t: float, params: List[float]) -> float:
345
  μmax, Ksx, Y, m = params
346
  S = 10.0 # Placeholder
347
  μ = (μmax * S / (Ksx * X + S)) - m
348
  return μ * X
349
+
350
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
351
  return [0.5, 0.5, 0.5, 0.01]
352
+
353
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
354
  return ([0.01, 0.01, 0.1, 0.0], [2.0, 10.0, 1.0, 0.1])
355
 
 
364
  r"\mu = \frac{\mu_{max} \cdot S}{K_s + S + \frac{S^2}{K_i}} - m",
365
  "Andrews (1968)"
366
  )
367
+
368
  def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
369
  return np.full_like(t, np.nan)
370
+
371
  def diff_function(self, X: float, t: float, params: List[float]) -> float:
372
  μmax, Ks, Ki, Y, m = params
373
  S = 10.0 # Placeholder
374
  μ = (μmax * S / (Ks + S + S**2/Ki)) - m
375
  return μ * X
376
+
377
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
378
  return [0.5, 0.1, 50.0, 0.5, 0.01]
379
+
380
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
381
  return ([0.01, 0.001, 1.0, 0.1, 0.0], [2.0, 5.0, 200.0, 1.0, 0.1])
382
 
 
391
  r"\mu = \mu_{max} \cdot (1 - e^{-S/K_s})",
392
  "Tessier (1942)"
393
  )
394
+
395
  def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
396
  μmax, Ks, X0 = params
397
  # Implementación simplificada
398
  return X0 * np.exp(μmax * t * 0.5) # Aproximación
399
+
400
  def diff_function(self, X: float, t: float, params: List[float]) -> float:
401
  μmax, Ks, X0 = params
402
  return μmax * X * 0.5 # Simplificado
403
+
404
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
405
  return [0.5, 1.0, biomass[0] if len(biomass) > 0 else 0.1]
406
+
407
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
408
  return ([0.01, 0.1, 1e-9], [2.0, 10.0, 1.0])
409
 
 
415
  "Richards",
416
  ["A", "μm", "λ", "ν", "X0"],
417
  "Modelo generalizado de Richards",
418
+ r"X(t) = A \cdot [1 + \nu \cdot e^{-\mu_m(t-\lambda)}]^{-1/\nu}", # Corregido el LaTeX
419
  "Richards (1959)"
420
  )
421
+
422
  def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
423
  A, μm, λ, ν, X0 = params
424
  if A <= 0 or μm <= 0 or ν <= 0:
425
  return np.full_like(t, np.nan)
426
  exp_term = np.exp(-μm * (t - λ))
427
  return A * (1 + ν * exp_term) ** (-1/ν)
428
+
429
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
430
  return [
431
  max(biomass) if len(biomass) > 0 else 1.0,
 
434
  1.0,
435
  biomass[0] if len(biomass) > 0 else 0.1
436
  ]
437
+
438
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
439
  max_biomass = max(biomass) if len(biomass) > 0 else 10.0
440
  max_time = max(time) if len(time) > 0 else 100.0
 
454
  r"X(t) = X_m \cdot [1 - e^{-\mu_m(t-\lambda)^\alpha}]",
455
  "Stannard et al. (1985)"
456
  )
457
+
458
  def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
459
  Xm, μm, λ, α = params
460
  if Xm <= 0 or μm <= 0 or α <= 0:
461
  return np.full_like(t, np.nan)
462
  t_shifted = np.maximum(t - λ, 0)
463
  return Xm * (1 - np.exp(-μm * t_shifted ** α))
464
+
465
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
466
  return [
467
  max(biomass) if len(biomass) > 0 else 1.0,
 
469
  0.0,
470
  1.0
471
  ]
472
+
473
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
474
  max_biomass = max(biomass) if len(biomass) > 0 else 10.0
475
  max_time = max(time) if len(time) > 0 else 100.0
 
486
  r"X(t) = X_m \cdot \frac{1}{1 + e^{-\mu_m(t-\lambda-m/n)}}",
487
  "Huang (2008)"
488
  )
489
+
490
  def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
491
  Xm, μm, λ, n, m = params
492
  if Xm <= 0 or μm <= 0 or n <= 0:
493
  return np.full_like(t, np.nan)
494
  return Xm / (1 + np.exp(-μm * (t - λ - m/n)))
495
+
496
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
497
  return [
498
  max(biomass) if len(biomass) > 0 else 1.0,
 
501
  1.0,
502
  0.5
503
  ]
504
+
505
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
506
  max_biomass = max(biomass) if len(biomass) > 0 else 10.0
507
  max_time = max(time) if len(time) > 0 else 100.0
 
513
  # --- REGISTRO ACTUALIZADO DE MODELOS ---
514
  AVAILABLE_MODELS: Dict[str, KineticModel] = {
515
  model.name: model for model in [
516
+ LogisticModel(),
517
+ GompertzModel(),
518
+ MoserModel(),
519
  BaranyiModel(),
520
  MonodModel(),
521
  ContoisModel(),
 
529
 
530
  # --- CLASE MEJORADA DE AJUSTE ---
531
  class BioprocessFitter:
532
+ def __init__(self, kinetic_model: KineticModel, maxfev: int = 50000,
533
  use_differential_evolution: bool = False):
534
  self.model = kinetic_model
535
  self.maxfev = maxfev
 
544
  self.data_means: Dict[str, Optional[np.ndarray]] = {c: None for c in COMPONENTS}
545
  self.data_stds: Dict[str, Optional[np.ndarray]] = {c: None for c in COMPONENTS}
546
 
547
+ def _get_biomass_at_t(self, t: np.ndarray, p: List[float]) -> np.ndarray:
548
  return self.model.model_function(t, *p)
549
+
550
  def _get_initial_biomass(self, p: List[float]) -> float:
551
  if not p: return 0.0
552
  if any(k in self.model.param_names for k in ["Xo", "X0"]):
 
555
  return p[idx]
556
  except (ValueError, IndexError): pass
557
  return float(self.model.model_function(np.array([0]), *p)[0])
558
+
559
  def _calc_integral(self, t: np.ndarray, p: List[float]) -> Tuple[np.ndarray, np.ndarray]:
560
  X_t = self._get_biomass_at_t(t, p)
561
  if np.any(np.isnan(X_t)): return np.full_like(t, np.nan), np.full_like(t, np.nan)
 
564
  dt = np.diff(t, prepend=t[0] - (t[1] - t[0] if len(t) > 1 else 1))
565
  integral_X = np.cumsum(X_t * dt)
566
  return integral_X, X_t
567
+
568
  def substrate(self, t: np.ndarray, so: float, p_c: float, q: float, bio_p: List[float]) -> np.ndarray:
569
  integral, X_t = self._calc_integral(t, bio_p)
570
  X0 = self._get_initial_biomass(bio_p)
571
  return so - p_c * (X_t - X0) - q * integral
572
+
573
  def product(self, t: np.ndarray, po: float, alpha: float, beta: float, bio_p: List[float]) -> np.ndarray:
574
  integral, X_t = self._calc_integral(t, bio_p)
575
  X0 = self._get_initial_biomass(bio_p)
576
  return po + alpha * (X_t - X0) + beta * integral
577
+
578
  def process_data_from_df(self, df: pd.DataFrame) -> None:
579
  try:
580
  time_col = [c for c in df.columns if c[1].strip().lower() == C_TIME][0]
581
  self.data_time = df[time_col].dropna().to_numpy()
582
  min_len = len(self.data_time)
 
583
  def extract(name: str) -> Tuple[np.ndarray, np.ndarray]:
584
  cols = [c for c in df.columns if c[1].strip().lower() == name.lower()]
585
  if not cols: return np.array([]), np.array([])
 
590
  mean = np.mean(arr, axis=0)
591
  std = np.std(arr, axis=0, ddof=1) if arr.shape[0] > 1 else np.zeros_like(mean)
592
  return mean, std
 
593
  self.data_means[C_BIOMASS], self.data_stds[C_BIOMASS] = extract('Biomasa')
594
  self.data_means[C_SUBSTRATE], self.data_stds[C_SUBSTRATE] = extract('Sustrato')
595
  self.data_means[C_PRODUCT], self.data_stds[C_PRODUCT] = extract('Producto')
596
  except (IndexError, KeyError) as e:
597
  raise ValueError(f"Estructura de DataFrame inválida. Error: {e}")
598
+
599
+ def _calculate_metrics(self, y_true: np.ndarray, y_pred: np.ndarray,
600
  n_params: int) -> Dict[str, float]:
601
  """Calcula métricas adicionales de bondad de ajuste"""
602
  n = len(y_true)
603
  residuals = y_true - y_pred
604
  ss_res = np.sum(residuals**2)
605
  ss_tot = np.sum((y_true - np.mean(y_true))**2)
 
606
  r2 = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0
607
  rmse = np.sqrt(ss_res / n)
608
  mae = np.mean(np.abs(residuals))
 
609
  # AIC y BIC
610
  if n > n_params + 1:
611
  aic = n * np.log(ss_res/n) + 2 * n_params
612
  bic = n * np.log(ss_res/n) + n_params * np.log(n)
613
  else:
614
  aic = bic = np.inf
 
615
  return {
616
  'r2': r2,
617
  'rmse': rmse,
 
619
  'aic': aic,
620
  'bic': bic
621
  }
622
+
623
  def _fit_component_de(self, func, t, data, bounds, *args):
624
  """Ajuste usando evolución diferencial para optimización global"""
625
  def objective(params):
 
630
  return np.sum((data - pred)**2)
631
  except:
632
  return 1e10
633
+ result = differential_evolution(objective, bounds=list(zip(*bounds)),
 
634
  maxiter=1000, seed=42)
635
  if result.success:
636
  popt = result.x
637
  pred = func(t, *popt, *args)
638
  metrics = self._calculate_metrics(data, pred, len(popt))
639
  return list(popt), metrics
640
+ return None, {'r2': np.nan, 'rmse': np.nan, 'mae': np.nan,
641
  'aic': np.nan, 'bic': np.nan}
642
+
643
  def _fit_component(self, func, t, data, p0, bounds, sigma=None, *args):
644
  try:
645
  if self.use_differential_evolution:
646
  return self._fit_component_de(func, t, data, bounds, *args)
 
647
  if sigma is not None:
648
  sigma = np.where(sigma == 0, 1e-9, sigma)
649
+ popt, _ = curve_fit(func, t, data, p0, bounds=bounds,
 
650
  maxfev=self.maxfev, ftol=1e-9, xtol=1e-9,
651
  sigma=sigma, absolute_sigma=bool(sigma is not None))
 
652
  pred = func(t, *popt, *args)
653
  if np.any(np.isnan(pred)):
654
  return None, {'r2': np.nan, 'rmse': np.nan, 'mae': np.nan,
655
  'aic': np.nan, 'bic': np.nan}
 
656
  metrics = self._calculate_metrics(data, pred, len(popt))
657
  return list(popt), metrics
 
658
  except (RuntimeError, ValueError):
659
  return None, {'r2': np.nan, 'rmse': np.nan, 'mae': np.nan,
660
  'aic': np.nan, 'bic': np.nan}
661
+
662
  def fit_all_models(self) -> None:
663
  t, bio_m, bio_s = self.data_time, self.data_means[C_BIOMASS], self.data_stds[C_BIOMASS]
664
  if t is None or bio_m is None or len(bio_m) == 0: return
665
  popt_bio = self._fit_biomass_model(t, bio_m, bio_s)
666
  if popt_bio:
667
  bio_p = list(self.params[C_BIOMASS].values())
668
+ if self.data_means[C_SUBSTRATE] is not None and len(self.data_means[C_SUBSTRATE]) > 0:
669
  self._fit_substrate_model(t, self.data_means[C_SUBSTRATE], self.data_stds[C_SUBSTRATE], bio_p)
670
+ if self.data_means[C_PRODUCT] is not None and len(self.data_means[C_PRODUCT]) > 0:
671
  self._fit_product_model(t, self.data_means[C_PRODUCT], self.data_stds[C_PRODUCT], bio_p)
672
+
673
  def _fit_biomass_model(self, t, data, std):
674
  p0, bounds = self.model.get_initial_params(t, data), self.model.get_param_bounds(t, data)
675
  popt, metrics = self._fit_component(self.model.model_function, t, data, p0, bounds, std)
676
+ if popt:
677
  self.params[C_BIOMASS] = dict(zip(self.model.param_names, popt))
678
  self.r2[C_BIOMASS] = metrics['r2']
679
  self.rmse[C_BIOMASS] = metrics['rmse']
 
681
  self.aic[C_BIOMASS] = metrics['aic']
682
  self.bic[C_BIOMASS] = metrics['bic']
683
  return popt
684
+
685
  def _fit_substrate_model(self, t, data, std, bio_p):
686
  p0, b = [data[0], 0.1, 0.01], ([0, -np.inf, -np.inf], [np.inf, np.inf, np.inf])
687
  popt, metrics = self._fit_component(lambda t, so, p, q: self.substrate(t, so, p, q, bio_p), t, data, p0, b, std)
688
+ if popt:
689
  self.params[C_SUBSTRATE] = {'So': popt[0], 'p': popt[1], 'q': popt[2]}
690
  self.r2[C_SUBSTRATE] = metrics['r2']
691
  self.rmse[C_SUBSTRATE] = metrics['rmse']
692
  self.mae[C_SUBSTRATE] = metrics['mae']
693
  self.aic[C_SUBSTRATE] = metrics['aic']
694
  self.bic[C_SUBSTRATE] = metrics['bic']
695
+
696
  def _fit_product_model(self, t, data, std, bio_p):
697
  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])
698
  popt, metrics = self._fit_component(lambda t, po, a, b: self.product(t, po, a, b, bio_p), t, data, p0, b, std)
699
+ if popt:
700
  self.params[C_PRODUCT] = {'Po': popt[0], 'alpha': popt[1], 'beta': popt[2]}
701
  self.r2[C_PRODUCT] = metrics['r2']
702
  self.rmse[C_PRODUCT] = metrics['rmse']
703
  self.mae[C_PRODUCT] = metrics['mae']
704
  self.aic[C_PRODUCT] = metrics['aic']
705
  self.bic[C_PRODUCT] = metrics['bic']
706
+
707
  def system_ode(self, y, t, bio_p, sub_p, prod_p):
708
  X, _, _ = y
709
  dXdt = self.model.diff_function(X, t, bio_p)
710
  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]
711
+
712
  def solve_odes(self, t_fine):
713
  p = self.params
714
  bio_d, sub_d, prod_d = p[C_BIOMASS], p[C_SUBSTRATE], p[C_PRODUCT]
 
720
  return sol[:, 0], sol[:, 1], sol[:, 2]
721
  except:
722
  return None, None, None
723
+
724
  def _generate_fine_time_grid(self, t_exp):
725
  return np.linspace(min(t_exp), max(t_exp), 500) if t_exp is not None and len(t_exp) > 1 else np.array([])
726
+
727
  def get_model_curves_for_plot(self, t_fine, use_diff):
728
  if use_diff and self.model.diff_function(1, 1, [1]*self.model.num_params) != 0:
729
  return self.solve_odes(t_fine)
 
738
  return X, S, P
739
 
740
  # --- FUNCIONES AUXILIARES ---
 
741
  def format_number(value: Any, decimals: int) -> str:
742
  """Formatea un número para su visualización"""
743
  if not isinstance(value, (int, float, np.number)) or pd.isna(value):
744
  return "" if pd.isna(value) else str(value)
 
745
  decimals = int(decimals)
 
746
  if decimals == 0:
747
  if 0 < abs(value) < 1:
748
  return f"{value:.2e}"
749
  else:
750
  return str(int(round(value, 0)))
 
751
  return str(round(value, decimals))
752
 
753
  # --- FUNCIONES DE PLOTEO MEJORADAS CON PLOTLY ---
754
+ def create_interactive_plot(plot_config: Dict, models_results: List[Dict],
 
755
  selected_component: str = "all") -> go.Figure:
756
  """Crea un gráfico interactivo mejorado con Plotly"""
757
  time_exp = plot_config['time_exp']
758
  time_fine = np.linspace(min(time_exp), max(time_exp), 500)
 
759
  # Configuración de subplots si se muestran todos los componentes
760
  if selected_component == "all":
761
  fig = make_subplots(
 
770
  fig = go.Figure()
771
  components_to_plot = [selected_component]
772
  rows = [None]
 
773
  # Colores para diferentes modelos
774
+ colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
775
  '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
 
776
  # Agregar datos experimentales
777
  for comp, row in zip(components_to_plot, rows):
778
  data_exp = plot_config.get(f'{comp}_exp')
779
  data_std = plot_config.get(f'{comp}_std')
 
780
  if data_exp is not None:
781
  error_y = dict(
782
  type='data',
783
  array=data_std,
784
  visible=True
785
  ) if data_std is not None and np.any(data_std > 0) else None
 
786
  trace = go.Scatter(
787
  x=time_exp,
788
  y=data_exp,
 
793
  legendgroup=comp,
794
  showlegend=True
795
  )
 
796
  if selected_component == "all":
797
  fig.add_trace(trace, row=row, col=1)
798
  else:
799
  fig.add_trace(trace)
 
800
  # Agregar curvas de modelos
801
  for i, res in enumerate(models_results):
802
  color = colors[i % len(colors)]
803
  model_name = AVAILABLE_MODELS[res["name"]].display_name
 
804
  for comp, row, key in zip(components_to_plot, rows, ['X', 'S', 'P']):
805
  if res.get(key) is not None:
806
  trace = go.Scatter(
 
812
  legendgroup=f'{res["name"]}_{comp}',
813
  showlegend=True
814
  )
 
815
  if selected_component == "all":
816
  fig.add_trace(trace, row=row, col=1)
817
  else:
818
  fig.add_trace(trace)
 
819
  # Actualizar diseño
820
  theme = plot_config.get('theme', 'light')
821
  template = "plotly_white" if theme == 'light' else "plotly_dark"
 
822
  fig.update_layout(
823
  title=f"Análisis de Cinéticas: {plot_config.get('exp_name', '')}",
824
  template=template,
 
832
  ),
833
  margin=dict(l=80, r=250, t=100, b=80)
834
  )
 
835
  # Actualizar ejes
836
  if selected_component == "all":
837
  fig.update_xaxes(title_text="Tiempo", row=3, col=1)
 
846
  C_PRODUCT: "Producto (g/L)"
847
  }
848
  fig.update_yaxes(title_text=labels.get(selected_component, "Valor"))
849
+ # Agregar botones para cambiar entre modos de visualización (opcional)
850
+ # Se eliminan por simplicidad, ya que el selector de componente hace algo similar
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
851
  return fig
852
 
853
  # --- FUNCIÓN PRINCIPAL DE ANÁLISIS ---
854
  def run_analysis(file, model_names, component, use_de, maxfev, exp_names, theme='light'):
855
  if not file: return None, pd.DataFrame(), "Error: Sube un archivo Excel."
856
  if not model_names: return None, pd.DataFrame(), "Error: Selecciona un modelo."
857
+ try:
 
858
  xls = pd.ExcelFile(file.name)
859
+ except Exception as e:
860
  return None, pd.DataFrame(), f"Error al leer archivo: {e}"
 
861
  results_data, msgs = [], []
862
  models_results = []
 
863
  exp_list = [n.strip() for n in exp_names.split('\n') if n.strip()] if exp_names else []
 
864
  for i, sheet in enumerate(xls.sheet_names):
865
  exp_name = exp_list[i] if i < len(exp_list) else f"Hoja '{sheet}'"
866
  try:
867
  df = pd.read_excel(xls, sheet_name=sheet, header=[0,1])
868
  reader = BioprocessFitter(list(AVAILABLE_MODELS.values())[0])
869
  reader.process_data_from_df(df)
870
+ if reader.data_time is None:
 
871
  msgs.append(f"WARN: Sin datos de tiempo en '{sheet}'.")
872
  continue
 
873
  plot_config = {
874
+ 'exp_name': exp_name,
875
  'time_exp': reader.data_time,
876
  'theme': theme
877
  }
878
+ for c in COMPONENTS:
 
879
  plot_config[f'{c}_exp'] = reader.data_means[c]
880
  plot_config[f'{c}_std'] = reader.data_stds[c]
 
881
  t_fine = reader._generate_fine_time_grid(reader.data_time)
 
882
  for m_name in model_names:
883
+ if m_name not in AVAILABLE_MODELS:
884
  msgs.append(f"WARN: Modelo '{m_name}' no disponible.")
885
  continue
 
886
  fitter = BioprocessFitter(
887
+ AVAILABLE_MODELS[m_name],
888
  maxfev=int(maxfev),
889
  use_differential_evolution=use_de
890
  )
 
892
  fitter.data_means = reader.data_means
893
  fitter.data_stds = reader.data_stds
894
  fitter.fit_all_models()
 
895
  row = {'Experimento': exp_name, 'Modelo': fitter.model.display_name}
896
  for c in COMPONENTS:
897
+ if fitter.params[c]:
898
  row.update({f'{c.capitalize()}_{k}': v for k, v in fitter.params[c].items()})
899
  row[f'R2_{c.capitalize()}'] = fitter.r2.get(c)
900
  row[f'RMSE_{c.capitalize()}'] = fitter.rmse.get(c)
901
  row[f'MAE_{c.capitalize()}'] = fitter.mae.get(c)
902
  row[f'AIC_{c.capitalize()}'] = fitter.aic.get(c)
903
  row[f'BIC_{c.capitalize()}'] = fitter.bic.get(c)
 
904
  results_data.append(row)
 
905
  X, S, P = fitter.get_model_curves_for_plot(t_fine, False)
906
  models_results.append({
907
+ 'name': m_name,
908
+ 'X': X,
909
+ 'S': S,
910
+ 'P': P,
911
+ 'params': fitter.params,
912
+ 'r2': fitter.r2,
913
  'rmse': fitter.rmse
914
  })
915
+ except Exception as e:
 
916
  msgs.append(f"ERROR en '{sheet}': {e}")
917
  traceback.print_exc()
 
918
  msg = "Análisis completado." + ("\n" + "\n".join(msgs) if msgs else "")
919
  df_res = pd.DataFrame(results_data).dropna(axis=1, how='all')
 
920
  # Crear gráfico interactivo
921
  fig = None
922
  if models_results and reader.data_time is not None:
923
  fig = create_interactive_plot(plot_config, models_results, component)
 
924
  return fig, df_res, msg
925
 
926
  # --- API ENDPOINTS PARA AGENTES DE IA ---
 
927
  app = FastAPI(title="Bioprocess Kinetics API", version="2.0")
928
 
929
  @app.get("/")
 
939
  """Endpoint para análisis de datos cinéticos"""
940
  try:
941
  results = {}
 
942
  for model_name in models:
943
  if model_name not in AVAILABLE_MODELS:
944
  continue
 
945
  model = AVAILABLE_MODELS[model_name]
946
  fitter = BioprocessFitter(model)
 
947
  # Configurar datos
948
  fitter.data_time = np.array(data['time'])
949
  fitter.data_means[C_BIOMASS] = np.array(data.get('biomass', []))
950
  fitter.data_means[C_SUBSTRATE] = np.array(data.get('substrate', []))
951
  fitter.data_means[C_PRODUCT] = np.array(data.get('product', []))
 
952
  # Ajustar modelo
953
  fitter.fit_all_models()
 
954
  results[model_name] = {
955
  'parameters': fitter.params,
956
  'metrics': {
 
961
  'bic': fitter.bic
962
  }
963
  }
 
964
  return {"status": "success", "results": results}
 
965
  except Exception as e:
966
  return {"status": "error", "message": str(e)}
967
 
 
989
  """Predice valores usando un modelo y parámetros específicos"""
990
  if model_name not in AVAILABLE_MODELS:
991
  return {"status": "error", "message": f"Model {model_name} not found"}
 
992
  try:
993
  model = AVAILABLE_MODELS[model_name]
994
  time_array = np.array(time_points)
995
  params = [parameters[name] for name in model.param_names]
 
996
  predictions = model.model_function(time_array, *params)
 
997
  return {
998
  "status": "success",
999
  "predictions": predictions.tolist(),
 
1003
  return {"status": "error", "message": str(e)}
1004
 
1005
  # --- INTERFAZ GRADIO MEJORADA ---
 
1006
  def create_gradio_interface() -> gr.Blocks:
1007
  """Crea la interfaz mejorada con soporte multiidioma y tema"""
 
1008
  def change_language(lang_key: str) -> Dict:
1009
  """Cambia el idioma de la interfaz"""
1010
  lang = Language[lang_key]
1011
  trans = TRANSLATIONS.get(lang, TRANSLATIONS[Language.ES])
 
1012
  return trans["title"], trans["subtitle"]
 
1013
  # Obtener opciones de modelo
1014
  MODEL_CHOICES = [(model.display_name, model.name) for model in AVAILABLE_MODELS.values()]
1015
  DEFAULT_MODELS = [m.name for m in list(AVAILABLE_MODELS.values())[:4]]
 
1016
  with gr.Blocks(theme=THEMES["light"], css="""
1017
  .gradio-container {font-family: 'Inter', sans-serif;}
1018
  .theory-box {background-color: #f0f9ff; padding: 20px; border-radius: 10px; margin: 10px 0;}
 
1020
  .model-card {border: 1px solid #e5e7eb; padding: 15px; border-radius: 8px; margin: 10px 0;}
1021
  .dark .model-card {border-color: #374151;}
1022
  """) as demo:
 
1023
  # Estado para tema e idioma
1024
  current_theme = gr.State("light")
1025
  current_language = gr.State("ES")
 
1026
  # Header con controles de tema e idioma
1027
  with gr.Row():
1028
  with gr.Column(scale=8):
 
1036
  value="ES",
1037
  label="🌐 Idioma"
1038
  )
 
1039
  with gr.Tabs() as tabs:
1040
  # --- TAB 1: TEORÍA Y MODELOS ---
1041
  with gr.TabItem("📚 Teoría y Modelos"):
1042
  gr.Markdown("""
1043
  ## Introducción a los Modelos Cinéticos
 
1044
  Los modelos cinéticos en biotecnología describen el comportamiento dinámico
1045
  de los microorganismos durante su crecimiento. Estos modelos son fundamentales
1046
  para:
 
1047
  - **Optimización de procesos**: Determinar condiciones óptimas de operación
1048
  - **Escalamiento**: Predecir comportamiento a escala industrial
1049
  - **Control de procesos**: Diseñar estrategias de control efectivas
1050
  - **Análisis económico**: Evaluar viabilidad de procesos
1051
  """)
 
1052
  # Cards para cada modelo
1053
  for model_name, model in AVAILABLE_MODELS.items():
1054
  with gr.Accordion(f"📊 {model.display_name}", open=False):
 
1056
  with gr.Column(scale=3):
1057
  gr.Markdown(f"""
1058
  **Descripción**: {model.description}
 
1059
  **Ecuación**: ${model.equation}$
 
1060
  **Parámetros**: {', '.join(model.param_names)}
 
1061
  **Referencia**: {model.reference}
1062
  """)
1063
  with gr.Column(scale=1):
 
1066
  - Parámetros: {model.num_params}
1067
  - Complejidad: {'⭐' * min(model.num_params, 5)}
1068
  """)
1069
+
1070
  # --- TAB 2: ANÁLISIS ---
1071
  with gr.TabItem("🔬 Análisis"):
1072
  with gr.Row():
 
1075
  label="📁 Sube tu archivo Excel (.xlsx)",
1076
  file_types=['.xlsx']
1077
  )
 
1078
  exp_names_input = gr.Textbox(
1079
  label="🏷️ Nombres de Experimentos",
1080
  placeholder="Experimento 1\nExperimento 2\n...",
1081
  lines=3
1082
  )
 
1083
  model_selection_input = gr.CheckboxGroup(
1084
  choices=MODEL_CHOICES,
1085
  label="📊 Modelos a Probar",
1086
  value=DEFAULT_MODELS
1087
  )
 
1088
  with gr.Accordion("⚙️ Opciones Avanzadas", open=False):
1089
  use_de_input = gr.Checkbox(
1090
  label="Usar Evolución Diferencial",
1091
  value=False,
1092
  info="Optimización global más robusta pero más lenta"
1093
  )
 
1094
  maxfev_input = gr.Number(
1095
  label="Iteraciones máximas",
1096
  value=50000
1097
  )
 
1098
  with gr.Column(scale=2):
1099
  # Selector de componente para visualización
1100
  component_selector = gr.Dropdown(
 
1107
  value="all",
1108
  label="📈 Componente a visualizar"
1109
  )
 
1110
  plot_output = gr.Plot(label="Visualización Interactiva")
 
1111
  analyze_button = gr.Button("🚀 Analizar y Graficar", variant="primary")
1112
+
1113
  # --- TAB 3: RESULTADOS ---
1114
  with gr.TabItem("📊 Resultados"):
1115
  status_output = gr.Textbox(
1116
  label="Estado del Análisis",
1117
  interactive=False
1118
  )
 
1119
  results_table = gr.DataFrame(
1120
  label="Tabla de Resultados",
1121
  wrap=True
1122
  )
 
1123
  with gr.Row():
1124
  download_excel = gr.Button("📥 Descargar Excel")
1125
  download_json = gr.Button("📥 Descargar JSON")
1126
  api_docs_button = gr.Button("📖 Ver Documentación API")
 
1127
  download_file = gr.File(label="Archivo descargado")
1128
+
1129
  # --- TAB 4: API ---
1130
  with gr.TabItem("🔌 API"):
1131
  gr.Markdown("""
1132
  ## Documentación de la API
 
1133
  La API REST permite integrar el análisis de cinéticas en aplicaciones externas
1134
  y agentes de IA.
 
1135
  ### Endpoints disponibles:
 
1136
  #### 1. `GET /api/models`
1137
  Retorna la lista de modelos disponibles con su información.
 
1138
  ```python
1139
  import requests
1140
  response = requests.get("http://localhost:8000/api/models")
1141
  models = response.json()
1142
  ```
 
1143
  #### 2. `POST /api/analyze`
1144
  Analiza datos con los modelos especificados.
 
1145
  ```python
1146
  data = {
1147
  "data": {
 
1155
  response = requests.post("http://localhost:8000/api/analyze", json=data)
1156
  results = response.json()
1157
  ```
 
1158
  #### 3. `POST /api/predict`
1159
  Predice valores usando un modelo y parámetros específicos.
 
1160
  ```python
1161
  data = {
1162
  "model_name": "logistic",
 
1166
  response = requests.post("http://localhost:8000/api/predict", json=data)
1167
  predictions = response.json()
1168
  ```
 
1169
  ### Iniciar servidor API:
1170
  ```bash
1171
+ uvicorn bioprocess_analyzer:app --reload --port 8000
1172
  ```
1173
  """)
 
1174
  # Botón para copiar comando
1175
  gr.Textbox(
1176
  value="uvicorn bioprocess_analyzer:app --reload --port 8000",
1177
  label="Comando para iniciar API",
1178
  interactive=False
1179
  )
1180
+
1181
  # --- EVENTOS ---
 
1182
  def run_analysis_wrapper(file, models, component, use_de, maxfev, exp_names, theme):
1183
  """Wrapper para ejecutar el análisis"""
1184
  try:
1185
+ return run_analysis(file, models, component, use_de, maxfev, exp_names,
1186
  'dark' if theme else 'light')
1187
  except Exception as e:
1188
  print(f"--- ERROR EN ANÁLISIS ---\n{traceback.format_exc()}")
1189
  return None, pd.DataFrame(), f"Error: {str(e)}"
1190
+
1191
  analyze_button.click(
1192
  fn=run_analysis_wrapper,
1193
  inputs=[
 
1201
  ],
1202
  outputs=[plot_output, results_table, status_output]
1203
  )
 
1204
  # Cambio de idioma
1205
  language_select.change(
1206
  fn=change_language,
1207
  inputs=[language_select],
1208
  outputs=[title_text, subtitle_text]
1209
  )
 
1210
  # Cambio de tema
1211
  def apply_theme(is_dark):
1212
  return gr.Info("Tema cambiado. Los gráficos nuevos usarán el tema seleccionado.")
 
1213
  theme_toggle.change(
1214
  fn=apply_theme,
1215
  inputs=[theme_toggle],
1216
  outputs=[]
1217
  )
 
1218
  # Funciones de descarga
1219
  def download_results_excel(df):
1220
  if df is None or df.empty:
 
1223
  with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as tmp:
1224
  df.to_excel(tmp.name, index=False)
1225
  return tmp.name
 
1226
  def download_results_json(df):
1227
  if df is None or df.empty:
1228
  gr.Warning("No hay datos para descargar")
 
1230
  with tempfile.NamedTemporaryFile(delete=False, suffix=".json") as tmp:
1231
  df.to_json(tmp.name, orient='records', indent=2)
1232
  return tmp.name
 
1233
  download_excel.click(
1234
  fn=download_results_excel,
1235
  inputs=[results_table],
1236
  outputs=[download_file]
1237
  )
 
1238
  download_json.click(
1239
  fn=download_results_json,
1240
  inputs=[results_table],
1241
  outputs=[download_file]
1242
  )
 
1243
  return demo
1244
 
1245
  # --- PUNTO DE ENTRADA ---
 
1246
  if __name__ == '__main__':
1247
  # Lanzar aplicación Gradio
1248
+ # Nota: share=True puede mostrar advertencias en algunos entornos.
1249
+ # Si no necesitas compartir públicamente, puedes usar share=False.
1250
  gradio_app = create_gradio_interface()
1251
+ # Opciones para lanzar:
1252
+ # Opción 1: Lanzamiento estándar local
1253
+ # gradio_app.launch(debug=True)
1254
+ # Opción 2: Lanzamiento local con share (puede mostrar advertencias)
1255
+ # gradio_app.launch(share=True, debug=True)
1256
+ # Opción 3: Lanzamiento en todas las interfaces (0.0.0.0) - útil para Docker/contenedores
1257
+ gradio_app.launch(share=False, debug=True, server_name="0.0.0.0", server_port=7860)
1258
+ # Opción 4: Solo servidor local (127.0.0.1)
1259
+ # gradio_app.launch(share=False, debug=True, server_name="127.0.0.1", server_port=7860)