C2MV commited on
Commit
bae8c21
·
verified ·
1 Parent(s): 88d0923

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +462 -118
app.py CHANGED
@@ -98,7 +98,6 @@ TRANSLATIONS = {
98
  "guide": "User Guide",
99
  "api_docs": "API Documentation"
100
  },
101
- # Agregar más traducciones según necesidad
102
  }
103
 
104
  # --- CONSTANTES MEJORADAS ---
@@ -109,7 +108,7 @@ C_PRODUCT = 'product'
109
  C_OXYGEN = 'oxygen'
110
  C_CO2 = 'co2'
111
  C_PH = 'ph'
112
- COMPONENTS = [C_BIOMASS, C_SUBSTRATE, C_PRODUCT, C_OXYGEN, C_CO2, C_PH]
113
 
114
  # --- SISTEMA DE TEMAS ---
115
  THEMES = {
@@ -132,7 +131,7 @@ THEMES = {
132
  )
133
  }
134
 
135
- # --- MODELOS CINÉTICOS AMPLIADOS ---
136
 
137
  class KineticModel(ABC):
138
  def __init__(self, name: str, display_name: str, param_names: List[str],
@@ -160,7 +159,7 @@ class KineticModel(ABC):
160
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
161
  pass
162
 
163
- # Modelos existentes mejorados
164
  class LogisticModel(KineticModel):
165
  def __init__(self):
166
  super().__init__(
@@ -178,9 +177,9 @@ class LogisticModel(KineticModel):
178
  return np.full_like(t, np.nan)
179
  exp_arg = np.clip(um * t, -700, 700)
180
  term_exp = np.exp(exp_arg)
181
- denominator = 1 - (X0 / Xm) * (1 - term_exp)
182
  denominator = np.where(denominator == 0, 1e-9, denominator)
183
- return (X0 * term_exp * Xm) / (Xm - X0 + X0 * term_exp)
184
 
185
  def diff_function(self, X: float, t: float, params: List[float]) -> float:
186
  _, Xm, um = params
@@ -198,7 +197,109 @@ class LogisticModel(KineticModel):
198
  max_biomass = max(biomass) if len(biomass) > 0 else 1.0
199
  return ([1e-9, initial_biomass, 1e-9], [max_biomass * 1.2, max_biomass * 5, np.inf])
200
 
201
- # Nuevos modelos con 3-5 parámetros
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  class MonodModel(KineticModel):
203
  def __init__(self):
204
  super().__init__(
@@ -228,6 +329,7 @@ class MonodModel(KineticModel):
228
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
229
  return ([0.01, 0.001, 0.1, 0.0], [2.0, 5.0, 1.0, 0.1])
230
 
 
231
  class ContoisModel(KineticModel):
232
  def __init__(self):
233
  super().__init__(
@@ -254,6 +356,7 @@ class ContoisModel(KineticModel):
254
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
255
  return ([0.01, 0.01, 0.1, 0.0], [2.0, 10.0, 1.0, 0.1])
256
 
 
257
  class AndrewsModel(KineticModel):
258
  def __init__(self):
259
  super().__init__(
@@ -280,6 +383,7 @@ class AndrewsModel(KineticModel):
280
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
281
  return ([0.01, 0.001, 1.0, 0.1, 0.0], [2.0, 5.0, 200.0, 1.0, 0.1])
282
 
 
283
  class TessierModel(KineticModel):
284
  def __init__(self):
285
  super().__init__(
@@ -296,12 +400,17 @@ class TessierModel(KineticModel):
296
  # Implementación simplificada
297
  return X0 * np.exp(μmax * t * 0.5) # Aproximación
298
 
 
 
 
 
299
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
300
  return [0.5, 1.0, biomass[0] if len(biomass) > 0 else 0.1]
301
 
302
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
303
  return ([0.01, 0.1, 1e-9], [2.0, 10.0, 1.0])
304
 
 
305
  class RichardsModel(KineticModel):
306
  def __init__(self):
307
  super().__init__(
@@ -337,6 +446,7 @@ class RichardsModel(KineticModel):
337
  [max_biomass * 2, 5.0, max_time, 10.0, max_biomass]
338
  )
339
 
 
340
  class StannardModel(KineticModel):
341
  def __init__(self):
342
  super().__init__(
@@ -368,6 +478,7 @@ class StannardModel(KineticModel):
368
  max_time = max(time) if len(time) > 0 else 100.0
369
  return ([0.1, 0.01, -max_time/10, 0.1], [max_biomass * 2, 5.0, max_time/2, 3.0])
370
 
 
371
  class HuangModel(KineticModel):
372
  def __init__(self):
373
  super().__init__(
@@ -435,6 +546,60 @@ class BioprocessFitter:
435
  self.data_time: Optional[np.ndarray] = None
436
  self.data_means: Dict[str, Optional[np.ndarray]] = {c: None for c in COMPONENTS}
437
  self.data_stds: Dict[str, Optional[np.ndarray]] = {c: None for c in COMPONENTS}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
 
439
  def _calculate_metrics(self, y_true: np.ndarray, y_pred: np.ndarray,
440
  n_params: int) -> Dict[str, float]:
@@ -508,15 +673,106 @@ class BioprocessFitter:
508
  return None, {'r2': np.nan, 'rmse': np.nan, 'mae': np.nan,
509
  'aic': np.nan, 'bic': np.nan}
510
 
511
- # El resto de los métodos permanecen similares con ajustes menores...
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
512
 
513
  # --- FUNCIONES DE PLOTEO MEJORADAS CON PLOTLY ---
514
 
515
  def create_interactive_plot(plot_config: Dict, models_results: List[Dict],
516
  selected_component: str = "all") -> go.Figure:
517
- """
518
- Crea un gráfico interactivo mejorado con Plotly
519
- """
520
  time_exp = plot_config['time_exp']
521
  time_fine = np.linspace(min(time_exp), max(time_exp), 500)
522
 
@@ -572,8 +828,7 @@ def create_interactive_plot(plot_config: Dict, models_results: List[Dict],
572
  color = colors[i % len(colors)]
573
  model_name = AVAILABLE_MODELS[res["name"]].display_name
574
 
575
- for comp, row, key in zip(components_to_plot, rows,
576
- ['X', 'S', 'P']):
577
  if res.get(key) is not None:
578
  trace = go.Scatter(
579
  x=time_fine,
@@ -591,9 +846,12 @@ def create_interactive_plot(plot_config: Dict, models_results: List[Dict],
591
  fig.add_trace(trace)
592
 
593
  # Actualizar diseño
 
 
 
594
  fig.update_layout(
595
  title=f"Análisis de Cinéticas: {plot_config.get('exp_name', '')}",
596
- template="plotly_white" if plot_config.get('theme') == 'light' else "plotly_dark",
597
  hovermode='x unified',
598
  legend=dict(
599
  orientation="v",
@@ -650,6 +908,96 @@ def create_interactive_plot(plot_config: Dict, models_results: List[Dict],
650
 
651
  return fig
652
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
653
  # --- API ENDPOINTS PARA AGENTES DE IA ---
654
 
655
  app = FastAPI(title="Bioprocess Kinetics API", version="2.0")
@@ -664,14 +1012,7 @@ async def analyze_data(
664
  models: List[str],
665
  options: Optional[Dict[str, Any]] = None
666
  ):
667
- """
668
- Endpoint para análisis de datos cinéticos
669
-
670
- Parameters:
671
- - data: Diccionario con 'time', 'biomass', 'substrate', 'product'
672
- - models: Lista de nombres de modelos a ajustar
673
- - options: Opciones adicionales de análisis
674
- """
675
  try:
676
  results = {}
677
 
@@ -757,19 +1098,7 @@ def create_gradio_interface() -> gr.Blocks:
757
  lang = Language[lang_key]
758
  trans = TRANSLATIONS.get(lang, TRANSLATIONS[Language.ES])
759
 
760
- return {
761
- title_text: trans["title"],
762
- subtitle_text: trans["subtitle"],
763
- upload_label: trans["upload"],
764
- models_label: trans["select_models"],
765
- analyze_button: trans["analyze"],
766
- # ... actualizar todos los componentes
767
- }
768
-
769
- def toggle_theme(is_dark: bool) -> gr.Blocks:
770
- """Cambia entre tema claro y oscuro"""
771
- theme = THEMES["dark"] if is_dark else THEMES["light"]
772
- return gr.Blocks(theme=theme)
773
 
774
  # Obtener opciones de modelo
775
  MODEL_CHOICES = [(model.display_name, model.name) for model in AVAILABLE_MODELS.values()]
@@ -906,91 +1235,78 @@ def create_gradio_interface() -> gr.Blocks:
906
  api_docs_button = gr.Button("📖 Ver Documentación API")
907
 
908
  download_file = gr.File(label="Archivo descargado")
909
-
910
- # --- TAB 4: API ---
911
- with gr.TabItem("🔌 API"):
912
- gr.Markdown("""
913
- ## Documentación de la API
914
-
915
- La API REST permite integrar el análisis de cinéticas en aplicaciones externas
916
- y agentes de IA.
917
-
918
- ### Endpoints disponibles:
919
-
920
- #### 1. `GET /api/models`
921
- Retorna la lista de modelos disponibles con su información.
922
-
923
- ```python
924
- import requests
925
- response = requests.get("http://localhost:8000/api/models")
926
- models = response.json()
927
- ```
928
 
929
- #### 2. `POST /api/analyze`
930
- Analiza datos con los modelos especificados.
931
-
932
- ```python
933
- data = {
934
- "data": {
935
- "time": [0, 1, 2, 3, 4],
936
- "biomass": [0.1, 0.3, 0.8, 1.5, 2.0],
937
- "substrate": [10, 8, 5, 2, 0.5]
938
- },
939
- "models": ["logistic", "gompertz"],
940
- "options": {"maxfev": 50000}
941
- }
942
- response = requests.post("http://localhost:8000/api/analyze", json=data)
943
- results = response.json()
944
- ```
945
-
946
- #### 3. `POST /api/predict`
947
- Predice valores usando un modelo y parámetros específicos.
948
-
949
- ```python
950
- data = {
951
- "model_name": "logistic",
952
- "parameters": {"X0": 0.1, "Xm": 10.0, "μm": 0.5},
953
- "time_points": [0, 1, 2, 3, 4, 5]
954
- }
955
- response = requests.post("http://localhost:8000/api/predict", json=data)
956
- predictions = response.json()
957
- ```
958
-
959
- ### Iniciar servidor API:
960
- ```bash
961
- uvicorn script_name:app --reload --port 8000
962
- ```
963
- """)
964
-
965
- # Botón para copiar comando
966
- gr.Textbox(
967
- value="uvicorn bioprocess_analyzer:app --reload --port 8000",
968
- label="Comando para iniciar API",
969
- interactive=False
970
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
971
 
972
  # --- EVENTOS ---
973
 
974
- def run_analysis_wrapper(file, models, component, use_de, maxfev, exp_names):
975
  """Wrapper para ejecutar el análisis"""
976
  try:
977
- # Aquí iría la lógica de análisis adaptada
978
- # Por brevedad, retorno valores de ejemplo
979
- fig = create_interactive_plot(
980
- {"time_exp": np.linspace(0, 10, 20)},
981
- [{"name": m, "X": np.random.rand(500)*10} for m in models[:2]],
982
- component
983
- )
984
-
985
- df = pd.DataFrame({
986
- "Modelo": ["Logístico", "Gompertz"],
987
- "R²": [0.95, 0.93],
988
- "RMSE": [0.12, 0.15]
989
- })
990
-
991
- return fig, df, "Análisis completado exitosamente"
992
-
993
  except Exception as e:
 
994
  return None, pd.DataFrame(), f"Error: {str(e)}"
995
 
996
  analyze_button.click(
@@ -1001,7 +1317,8 @@ def create_gradio_interface() -> gr.Blocks:
1001
  component_selector,
1002
  use_de_input,
1003
  maxfev_input,
1004
- exp_names_input
 
1005
  ],
1006
  outputs=[plot_output, results_table, status_output]
1007
  )
@@ -1010,20 +1327,47 @@ def create_gradio_interface() -> gr.Blocks:
1010
  language_select.change(
1011
  fn=change_language,
1012
  inputs=[language_select],
1013
- outputs=[title_text, subtitle_text] # Agregar todos los componentes
1014
  )
1015
 
1016
  # Cambio de tema
1017
  def apply_theme(is_dark):
1018
- # Aquí se aplicaría el cambio de tema
1019
- # Por limitaciones de Gradio, esto requeriría recargar la interfaz
1020
- return gr.Info("Tema cambiado. Recarga la página para ver los cambios.")
1021
 
1022
  theme_toggle.change(
1023
  fn=apply_theme,
1024
  inputs=[theme_toggle],
1025
  outputs=[]
1026
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1027
 
1028
  return demo
1029
 
 
98
  "guide": "User Guide",
99
  "api_docs": "API Documentation"
100
  },
 
101
  }
102
 
103
  # --- CONSTANTES MEJORADAS ---
 
108
  C_OXYGEN = 'oxygen'
109
  C_CO2 = 'co2'
110
  C_PH = 'ph'
111
+ COMPONENTS = [C_BIOMASS, C_SUBSTRATE, C_PRODUCT]
112
 
113
  # --- SISTEMA DE TEMAS ---
114
  THEMES = {
 
131
  )
132
  }
133
 
134
+ # --- MODELOS CINÉTICOS COMPLETOS ---
135
 
136
  class KineticModel(ABC):
137
  def __init__(self, name: str, display_name: str, param_names: List[str],
 
159
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
160
  pass
161
 
162
+ # Modelo Logístico
163
  class LogisticModel(KineticModel):
164
  def __init__(self):
165
  super().__init__(
 
177
  return np.full_like(t, np.nan)
178
  exp_arg = np.clip(um * t, -700, 700)
179
  term_exp = np.exp(exp_arg)
180
+ denominator = Xm - X0 + X0 * term_exp
181
  denominator = np.where(denominator == 0, 1e-9, denominator)
182
+ return (X0 * term_exp * Xm) / denominator
183
 
184
  def diff_function(self, X: float, t: float, params: List[float]) -> float:
185
  _, Xm, um = params
 
197
  max_biomass = max(biomass) if len(biomass) > 0 else 1.0
198
  return ([1e-9, initial_biomass, 1e-9], [max_biomass * 1.2, max_biomass * 5, np.inf])
199
 
200
+ # Modelo Gompertz
201
+ class GompertzModel(KineticModel):
202
+ def __init__(self):
203
+ super().__init__(
204
+ "gompertz",
205
+ "Gompertz",
206
+ ["Xm", "μm", "λ"],
207
+ "Modelo de crecimiento asimétrico con fase lag",
208
+ r"X(t) = X_m \exp\left(-\exp\left(\frac{\mu_m e}{X_m}(\lambda-t)+1\right)\right)",
209
+ "Gompertz (1825)"
210
+ )
211
+
212
+ def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
213
+ Xm, um, lag = params
214
+ if Xm <= 0 or um <= 0:
215
+ return np.full_like(t, np.nan)
216
+ exp_term = (um * np.e / Xm) * (lag - t) + 1
217
+ exp_term_clipped = np.clip(exp_term, -700, 700)
218
+ return Xm * np.exp(-np.exp(exp_term_clipped))
219
+
220
+ def diff_function(self, X: float, t: float, params: List[float]) -> float:
221
+ Xm, um, lag = params
222
+ k_val = um * np.e / Xm
223
+ u_val = k_val * (lag - t) + 1
224
+ u_val_clipped = np.clip(u_val, -np.inf, 700)
225
+ return X * k_val * np.exp(u_val_clipped) if Xm > 0 and X > 0 else 0.0
226
+
227
+ def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
228
+ return [
229
+ max(biomass) if len(biomass) > 0 else 1.0,
230
+ 0.1,
231
+ time[np.argmax(np.gradient(biomass))] if len(biomass) > 1 else 0
232
+ ]
233
+
234
+ def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
235
+ initial_biomass = min(biomass) if len(biomass) > 0 else 1e-9
236
+ max_biomass = max(biomass) if len(biomass) > 0 else 1.0
237
+ return ([max(1e-9, initial_biomass), 1e-9, 0], [max_biomass * 5, np.inf, max(time) if len(time) > 0 else 1])
238
+
239
+ # Modelo Moser
240
+ class MoserModel(KineticModel):
241
+ def __init__(self):
242
+ super().__init__(
243
+ "moser",
244
+ "Moser",
245
+ ["Xm", "μm", "Ks"],
246
+ "Modelo exponencial simple de Moser",
247
+ r"X(t) = X_m (1 - e^{-\mu_m (t - K_s)})",
248
+ "Moser (1958)"
249
+ )
250
+
251
+ def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
252
+ Xm, um, Ks = params
253
+ return Xm * (1 - np.exp(-um * (t - Ks))) if Xm > 0 and um > 0 else np.full_like(t, np.nan)
254
+
255
+ def diff_function(self, X: float, t: float, params: List[float]) -> float:
256
+ Xm, um, _ = params
257
+ return um * (Xm - X) if Xm > 0 else 0.0
258
+
259
+ def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
260
+ return [max(biomass) if len(biomass) > 0 else 1.0, 0.1, 0]
261
+
262
+ def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
263
+ initial_biomass = min(biomass) if len(biomass) > 0 else 1e-9
264
+ max_biomass = max(biomass) if len(biomass) > 0 else 1.0
265
+ return ([max(1e-9, initial_biomass), 1e-9, -np.inf], [max_biomass * 5, np.inf, np.inf])
266
+
267
+ # Modelo Baranyi
268
+ class BaranyiModel(KineticModel):
269
+ def __init__(self):
270
+ super().__init__(
271
+ "baranyi",
272
+ "Baranyi",
273
+ ["X0", "Xm", "μm", "λ"],
274
+ "Modelo de Baranyi con fase lag explícita",
275
+ r"X(t) = X_m / [1 + ((X_m/X_0) - 1) \exp(-\mu_m A(t))]",
276
+ "Baranyi & Roberts (1994)"
277
+ )
278
+
279
+ def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
280
+ X0, Xm, um, lag = params
281
+ if X0 <= 0 or Xm <= X0 or um <= 0 or lag < 0:
282
+ return np.full_like(t, np.nan)
283
+ A_t = t + (1 / um) * np.log(np.exp(-um * t) + np.exp(-um * lag) - np.exp(-um * (t + lag)))
284
+ exp_um_At = np.exp(np.clip(um * A_t, -700, 700))
285
+ numerator = Xm
286
+ denominator = 1 + ((Xm / X0) - 1) * (1 / exp_um_At)
287
+ return numerator / np.where(denominator == 0, 1e-9, denominator)
288
+
289
+ def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
290
+ return [
291
+ biomass[0] if len(biomass) > 0 and biomass[0] > 1e-6 else 1e-3,
292
+ max(biomass) if len(biomass) > 0 else 1.0,
293
+ 0.1,
294
+ time[np.argmax(np.gradient(biomass))] if len(biomass) > 1 else 0.0
295
+ ]
296
+
297
+ def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
298
+ initial_biomass = biomass[0] if len(biomass) > 0 else 1e-9
299
+ max_biomass = max(biomass) if len(biomass) > 0 else 1.0
300
+ return ([1e-9, max(1e-9, initial_biomass), 1e-9, 0], [max_biomass * 1.2, max_biomass * 10, np.inf, max(time) if len(time) > 0 else 1])
301
+
302
+ # Modelo Monod
303
  class MonodModel(KineticModel):
304
  def __init__(self):
305
  super().__init__(
 
329
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
330
  return ([0.01, 0.001, 0.1, 0.0], [2.0, 5.0, 1.0, 0.1])
331
 
332
+ # Modelo Contois
333
  class ContoisModel(KineticModel):
334
  def __init__(self):
335
  super().__init__(
 
356
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
357
  return ([0.01, 0.01, 0.1, 0.0], [2.0, 10.0, 1.0, 0.1])
358
 
359
+ # Modelo Andrews
360
  class AndrewsModel(KineticModel):
361
  def __init__(self):
362
  super().__init__(
 
383
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
384
  return ([0.01, 0.001, 1.0, 0.1, 0.0], [2.0, 5.0, 200.0, 1.0, 0.1])
385
 
386
+ # Modelo Tessier
387
  class TessierModel(KineticModel):
388
  def __init__(self):
389
  super().__init__(
 
400
  # Implementación simplificada
401
  return X0 * np.exp(μmax * t * 0.5) # Aproximación
402
 
403
+ def diff_function(self, X: float, t: float, params: List[float]) -> float:
404
+ μmax, Ks, X0 = params
405
+ return μmax * X * 0.5 # Simplificado
406
+
407
  def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
408
  return [0.5, 1.0, biomass[0] if len(biomass) > 0 else 0.1]
409
 
410
  def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
411
  return ([0.01, 0.1, 1e-9], [2.0, 10.0, 1.0])
412
 
413
+ # Modelo Richards
414
  class RichardsModel(KineticModel):
415
  def __init__(self):
416
  super().__init__(
 
446
  [max_biomass * 2, 5.0, max_time, 10.0, max_biomass]
447
  )
448
 
449
+ # Modelo Stannard
450
  class StannardModel(KineticModel):
451
  def __init__(self):
452
  super().__init__(
 
478
  max_time = max(time) if len(time) > 0 else 100.0
479
  return ([0.1, 0.01, -max_time/10, 0.1], [max_biomass * 2, 5.0, max_time/2, 3.0])
480
 
481
+ # Modelo Huang
482
  class HuangModel(KineticModel):
483
  def __init__(self):
484
  super().__init__(
 
546
  self.data_time: Optional[np.ndarray] = None
547
  self.data_means: Dict[str, Optional[np.ndarray]] = {c: None for c in COMPONENTS}
548
  self.data_stds: Dict[str, Optional[np.ndarray]] = {c: None for c in COMPONENTS}
549
+
550
+ def _get_biomass_at_t(self, t: np.ndarray, p: List[float]) -> np.ndarray:
551
+ return self.model.model_function(t, *p)
552
+
553
+ def _get_initial_biomass(self, p: List[float]) -> float:
554
+ if not p: return 0.0
555
+ if any(k in self.model.param_names for k in ["Xo", "X0"]):
556
+ try:
557
+ idx = self.model.param_names.index("Xo") if "Xo" in self.model.param_names else self.model.param_names.index("X0")
558
+ return p[idx]
559
+ except (ValueError, IndexError): pass
560
+ return float(self.model.model_function(np.array([0]), *p)[0])
561
+
562
+ def _calc_integral(self, t: np.ndarray, p: List[float]) -> Tuple[np.ndarray, np.ndarray]:
563
+ X_t = self._get_biomass_at_t(t, p)
564
+ if np.any(np.isnan(X_t)): return np.full_like(t, np.nan), np.full_like(t, np.nan)
565
+ integral_X = np.zeros_like(X_t)
566
+ if len(t) > 1:
567
+ dt = np.diff(t, prepend=t[0] - (t[1] - t[0] if len(t) > 1 else 1))
568
+ integral_X = np.cumsum(X_t * dt)
569
+ return integral_X, X_t
570
+
571
+ def substrate(self, t: np.ndarray, so: float, p_c: float, q: 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 so - p_c * (X_t - X0) - q * integral
575
+
576
+ def product(self, t: np.ndarray, po: float, alpha: float, beta: float, bio_p: List[float]) -> np.ndarray:
577
+ integral, X_t = self._calc_integral(t, bio_p)
578
+ X0 = self._get_initial_biomass(bio_p)
579
+ return po + alpha * (X_t - X0) + beta * integral
580
+
581
+ def process_data_from_df(self, df: pd.DataFrame) -> None:
582
+ try:
583
+ time_col = [c for c in df.columns if c[1].strip().lower() == C_TIME][0]
584
+ self.data_time = df[time_col].dropna().to_numpy()
585
+ min_len = len(self.data_time)
586
+
587
+ def extract(name: str) -> Tuple[np.ndarray, np.ndarray]:
588
+ cols = [c for c in df.columns if c[1].strip().lower() == name.lower()]
589
+ if not cols: return np.array([]), np.array([])
590
+ reps = [df[c].dropna().values[:min_len] for c in cols]
591
+ reps = [r for r in reps if len(r) == min_len]
592
+ if not reps: return np.array([]), np.array([])
593
+ arr = np.array(reps)
594
+ mean = np.mean(arr, axis=0)
595
+ std = np.std(arr, axis=0, ddof=1) if arr.shape[0] > 1 else np.zeros_like(mean)
596
+ return mean, std
597
+
598
+ self.data_means[C_BIOMASS], self.data_stds[C_BIOMASS] = extract('Biomasa')
599
+ self.data_means[C_SUBSTRATE], self.data_stds[C_SUBSTRATE] = extract('Sustrato')
600
+ self.data_means[C_PRODUCT], self.data_stds[C_PRODUCT] = extract('Producto')
601
+ except (IndexError, KeyError) as e:
602
+ raise ValueError(f"Estructura de DataFrame inválida. Error: {e}")
603
 
604
  def _calculate_metrics(self, y_true: np.ndarray, y_pred: np.ndarray,
605
  n_params: int) -> Dict[str, float]:
 
673
  return None, {'r2': np.nan, 'rmse': np.nan, 'mae': np.nan,
674
  'aic': np.nan, 'bic': np.nan}
675
 
676
+ def fit_all_models(self) -> None:
677
+ t, bio_m, bio_s = self.data_time, self.data_means[C_BIOMASS], self.data_stds[C_BIOMASS]
678
+ if t is None or bio_m is None or len(bio_m) == 0: return
679
+ popt_bio = self._fit_biomass_model(t, bio_m, bio_s)
680
+ if popt_bio:
681
+ bio_p = list(self.params[C_BIOMASS].values())
682
+ if self.data_means[C_SUBSTRATE] is not None and len(self.data_means[C_SUBSTRATE]) > 0:
683
+ self._fit_substrate_model(t, self.data_means[C_SUBSTRATE], self.data_stds[C_SUBSTRATE], bio_p)
684
+ if self.data_means[C_PRODUCT] is not None and len(self.data_means[C_PRODUCT]) > 0:
685
+ self._fit_product_model(t, self.data_means[C_PRODUCT], self.data_stds[C_PRODUCT], bio_p)
686
+
687
+ def _fit_biomass_model(self, t, data, std):
688
+ p0, bounds = self.model.get_initial_params(t, data), self.model.get_param_bounds(t, data)
689
+ popt, metrics = self._fit_component(self.model.model_function, t, data, p0, bounds, std)
690
+ if popt:
691
+ self.params[C_BIOMASS] = dict(zip(self.model.param_names, popt))
692
+ self.r2[C_BIOMASS] = metrics['r2']
693
+ self.rmse[C_BIOMASS] = metrics['rmse']
694
+ self.mae[C_BIOMASS] = metrics['mae']
695
+ self.aic[C_BIOMASS] = metrics['aic']
696
+ self.bic[C_BIOMASS] = metrics['bic']
697
+ return popt
698
+
699
+ def _fit_substrate_model(self, t, data, std, bio_p):
700
+ p0, b = [data[0], 0.1, 0.01], ([0, -np.inf, -np.inf], [np.inf, np.inf, np.inf])
701
+ popt, metrics = self._fit_component(lambda t, so, p, q: self.substrate(t, so, p, q, bio_p), t, data, p0, b, std)
702
+ if popt:
703
+ self.params[C_SUBSTRATE] = {'So': popt[0], 'p': popt[1], 'q': popt[2]}
704
+ self.r2[C_SUBSTRATE] = metrics['r2']
705
+ self.rmse[C_SUBSTRATE] = metrics['rmse']
706
+ self.mae[C_SUBSTRATE] = metrics['mae']
707
+ self.aic[C_SUBSTRATE] = metrics['aic']
708
+ self.bic[C_SUBSTRATE] = metrics['bic']
709
+
710
+ def _fit_product_model(self, t, data, std, bio_p):
711
+ 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])
712
+ popt, metrics = self._fit_component(lambda t, po, a, b: self.product(t, po, a, b, bio_p), t, data, p0, b, std)
713
+ if popt:
714
+ self.params[C_PRODUCT] = {'Po': popt[0], 'alpha': popt[1], 'beta': popt[2]}
715
+ self.r2[C_PRODUCT] = metrics['r2']
716
+ self.rmse[C_PRODUCT] = metrics['rmse']
717
+ self.mae[C_PRODUCT] = metrics['mae']
718
+ self.aic[C_PRODUCT] = metrics['aic']
719
+ self.bic[C_PRODUCT] = metrics['bic']
720
+
721
+ def system_ode(self, y, t, bio_p, sub_p, prod_p):
722
+ X, _, _ = y
723
+ dXdt = self.model.diff_function(X, t, bio_p)
724
+ 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]
725
+
726
+ def solve_odes(self, t_fine):
727
+ p = self.params
728
+ bio_d, sub_d, prod_d = p[C_BIOMASS], p[C_SUBSTRATE], p[C_PRODUCT]
729
+ if not bio_d: return None, None, None
730
+ try:
731
+ bio_p = list(bio_d.values())
732
+ y0 = [self._get_initial_biomass(bio_p), sub_d.get('So',0), prod_d.get('Po',0)]
733
+ sol = odeint(self.system_ode, y0, t_fine, args=(bio_p, sub_d, prod_d))
734
+ return sol[:, 0], sol[:, 1], sol[:, 2]
735
+ except:
736
+ return None, None, None
737
+
738
+ def _generate_fine_time_grid(self, t_exp):
739
+ return np.linspace(min(t_exp), max(t_exp), 500) if t_exp is not None and len(t_exp) > 1 else np.array([])
740
+
741
+ def get_model_curves_for_plot(self, t_fine, use_diff):
742
+ if use_diff and self.model.diff_function(1, 1, [1]*self.model.num_params) != 0:
743
+ return self.solve_odes(t_fine)
744
+ X, S, P = None, None, None
745
+ if self.params[C_BIOMASS]:
746
+ bio_p = list(self.params[C_BIOMASS].values())
747
+ X = self.model.model_function(t_fine, *bio_p)
748
+ if self.params[C_SUBSTRATE]:
749
+ S = self.substrate(t_fine, *list(self.params[C_SUBSTRATE].values()), bio_p)
750
+ if self.params[C_PRODUCT]:
751
+ P = self.product(t_fine, *list(self.params[C_PRODUCT].values()), bio_p)
752
+ return X, S, P
753
+
754
+ # --- FUNCIONES AUXILIARES ---
755
+
756
+ def format_number(value: Any, decimals: int) -> str:
757
+ """Formatea un número para su visualización"""
758
+ if not isinstance(value, (int, float, np.number)) or pd.isna(value):
759
+ return "" if pd.isna(value) else str(value)
760
+
761
+ decimals = int(decimals)
762
+
763
+ if decimals == 0:
764
+ if 0 < abs(value) < 1:
765
+ return f"{value:.2e}"
766
+ else:
767
+ return str(int(round(value, 0)))
768
+
769
+ return str(round(value, decimals))
770
 
771
  # --- FUNCIONES DE PLOTEO MEJORADAS CON PLOTLY ---
772
 
773
  def create_interactive_plot(plot_config: Dict, models_results: List[Dict],
774
  selected_component: str = "all") -> go.Figure:
775
+ """Crea un gráfico interactivo mejorado con Plotly"""
 
 
776
  time_exp = plot_config['time_exp']
777
  time_fine = np.linspace(min(time_exp), max(time_exp), 500)
778
 
 
828
  color = colors[i % len(colors)]
829
  model_name = AVAILABLE_MODELS[res["name"]].display_name
830
 
831
+ for comp, row, key in zip(components_to_plot, rows, ['X', 'S', 'P']):
 
832
  if res.get(key) is not None:
833
  trace = go.Scatter(
834
  x=time_fine,
 
846
  fig.add_trace(trace)
847
 
848
  # Actualizar diseño
849
+ theme = plot_config.get('theme', 'light')
850
+ template = "plotly_white" if theme == 'light' else "plotly_dark"
851
+
852
  fig.update_layout(
853
  title=f"Análisis de Cinéticas: {plot_config.get('exp_name', '')}",
854
+ template=template,
855
  hovermode='x unified',
856
  legend=dict(
857
  orientation="v",
 
908
 
909
  return fig
910
 
911
+ # --- FUNCIÓN PRINCIPAL DE ANÁLISIS ---
912
+ def run_analysis(file, model_names, component, use_de, maxfev, exp_names, theme='light'):
913
+ if not file: return None, pd.DataFrame(), "Error: Sube un archivo Excel."
914
+ if not model_names: return None, pd.DataFrame(), "Error: Selecciona un modelo."
915
+
916
+ try:
917
+ xls = pd.ExcelFile(file.name)
918
+ except Exception as e:
919
+ return None, pd.DataFrame(), f"Error al leer archivo: {e}"
920
+
921
+ results_data, msgs = [], []
922
+ models_results = []
923
+
924
+ exp_list = [n.strip() for n in exp_names.split('\n') if n.strip()] if exp_names else []
925
+
926
+ for i, sheet in enumerate(xls.sheet_names):
927
+ exp_name = exp_list[i] if i < len(exp_list) else f"Hoja '{sheet}'"
928
+ try:
929
+ df = pd.read_excel(xls, sheet_name=sheet, header=[0,1])
930
+ reader = BioprocessFitter(list(AVAILABLE_MODELS.values())[0])
931
+ reader.process_data_from_df(df)
932
+
933
+ if reader.data_time is None:
934
+ msgs.append(f"WARN: Sin datos de tiempo en '{sheet}'.")
935
+ continue
936
+
937
+ plot_config = {
938
+ 'exp_name': exp_name,
939
+ 'time_exp': reader.data_time,
940
+ 'theme': theme
941
+ }
942
+
943
+ for c in COMPONENTS:
944
+ plot_config[f'{c}_exp'] = reader.data_means[c]
945
+ plot_config[f'{c}_std'] = reader.data_stds[c]
946
+
947
+ t_fine = reader._generate_fine_time_grid(reader.data_time)
948
+
949
+ for m_name in model_names:
950
+ if m_name not in AVAILABLE_MODELS:
951
+ msgs.append(f"WARN: Modelo '{m_name}' no disponible.")
952
+ continue
953
+
954
+ fitter = BioprocessFitter(
955
+ AVAILABLE_MODELS[m_name],
956
+ maxfev=int(maxfev),
957
+ use_differential_evolution=use_de
958
+ )
959
+ fitter.data_time = reader.data_time
960
+ fitter.data_means = reader.data_means
961
+ fitter.data_stds = reader.data_stds
962
+ fitter.fit_all_models()
963
+
964
+ row = {'Experimento': exp_name, 'Modelo': fitter.model.display_name}
965
+ for c in COMPONENTS:
966
+ if fitter.params[c]:
967
+ row.update({f'{c.capitalize()}_{k}': v for k, v in fitter.params[c].items()})
968
+ row[f'R2_{c.capitalize()}'] = fitter.r2.get(c)
969
+ row[f'RMSE_{c.capitalize()}'] = fitter.rmse.get(c)
970
+ row[f'MAE_{c.capitalize()}'] = fitter.mae.get(c)
971
+ row[f'AIC_{c.capitalize()}'] = fitter.aic.get(c)
972
+ row[f'BIC_{c.capitalize()}'] = fitter.bic.get(c)
973
+
974
+ results_data.append(row)
975
+
976
+ X, S, P = fitter.get_model_curves_for_plot(t_fine, False)
977
+ models_results.append({
978
+ 'name': m_name,
979
+ 'X': X,
980
+ 'S': S,
981
+ 'P': P,
982
+ 'params': fitter.params,
983
+ 'r2': fitter.r2,
984
+ 'rmse': fitter.rmse
985
+ })
986
+
987
+ except Exception as e:
988
+ msgs.append(f"ERROR en '{sheet}': {e}")
989
+ traceback.print_exc()
990
+
991
+ msg = "Análisis completado." + ("\n" + "\n".join(msgs) if msgs else "")
992
+ df_res = pd.DataFrame(results_data).dropna(axis=1, how='all')
993
+
994
+ # Crear gráfico interactivo
995
+ fig = None
996
+ if models_results and reader.data_time is not None:
997
+ fig = create_interactive_plot(plot_config, models_results, component)
998
+
999
+ return fig, df_res, msg
1000
+
1001
  # --- API ENDPOINTS PARA AGENTES DE IA ---
1002
 
1003
  app = FastAPI(title="Bioprocess Kinetics API", version="2.0")
 
1012
  models: List[str],
1013
  options: Optional[Dict[str, Any]] = None
1014
  ):
1015
+ """Endpoint para análisis de datos cinéticos"""
 
 
 
 
 
 
 
1016
  try:
1017
  results = {}
1018
 
 
1098
  lang = Language[lang_key]
1099
  trans = TRANSLATIONS.get(lang, TRANSLATIONS[Language.ES])
1100
 
1101
+ return trans["title"], trans["subtitle"]
 
 
 
 
 
 
 
 
 
 
 
 
1102
 
1103
  # Obtener opciones de modelo
1104
  MODEL_CHOICES = [(model.display_name, model.name) for model in AVAILABLE_MODELS.values()]
 
1235
  api_docs_button = gr.Button("📖 Ver Documentación API")
1236
 
1237
  download_file = gr.File(label="Archivo descargado")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1238
 
1239
+ # --- TAB 4: API ---
1240
+ with gr.TabItem("🔌 API"):
1241
+ gr.Markdown("""
1242
+ ## Documentación de la API
1243
+
1244
+ La API REST permite integrar el análisis de cinéticas en aplicaciones externas
1245
+ y agentes de IA.
1246
+
1247
+ ### Endpoints disponibles:
1248
+
1249
+ #### 1. `GET /api/models`
1250
+ Retorna la lista de modelos disponibles con su información.
1251
+
1252
+ ```python
1253
+ import requests
1254
+ response = requests.get("http://localhost:8000/api/models")
1255
+ models = response.json()
1256
+ ```
1257
+
1258
+ #### 2. `POST /api/analyze`
1259
+ Analiza datos con los modelos especificados.
1260
+
1261
+ ```python
1262
+ data = {
1263
+ "data": {
1264
+ "time": [0, 1, 2, 3, 4],
1265
+ "biomass": [0.1, 0.3, 0.8, 1.5, 2.0],
1266
+ "substrate": [10, 8, 5, 2, 0.5]
1267
+ },
1268
+ "models": ["logistic", "gompertz"],
1269
+ "options": {"maxfev": 50000}
1270
+ }
1271
+ response = requests.post("http://localhost:8000/api/analyze", json=data)
1272
+ results = response.json()
1273
+ ```
1274
+
1275
+ #### 3. `POST /api/predict`
1276
+ Predice valores usando un modelo y parámetros específicos.
1277
+
1278
+ ```python
1279
+ data = {
1280
+ "model_name": "logistic",
1281
+ "parameters": {"X0": 0.1, "Xm": 10.0, "μm": 0.5},
1282
+ "time_points": [0, 1, 2, 3, 4, 5]
1283
+ }
1284
+ response = requests.post("http://localhost:8000/api/predict", json=data)
1285
+ predictions = response.json()
1286
+ ```
1287
+
1288
+ ### Iniciar servidor API:
1289
+ ```bash
1290
+ uvicorn script_name:app --reload --port 8000
1291
+ ```
1292
+ """)
1293
+
1294
+ # Botón para copiar comando
1295
+ gr.Textbox(
1296
+ value="uvicorn bioprocess_analyzer:app --reload --port 8000",
1297
+ label="Comando para iniciar API",
1298
+ interactive=False
1299
+ )
1300
 
1301
  # --- EVENTOS ---
1302
 
1303
+ def run_analysis_wrapper(file, models, component, use_de, maxfev, exp_names, theme):
1304
  """Wrapper para ejecutar el análisis"""
1305
  try:
1306
+ return run_analysis(file, models, component, use_de, maxfev, exp_names,
1307
+ 'dark' if theme else 'light')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1308
  except Exception as e:
1309
+ print(f"--- ERROR EN ANÁLISIS ---\n{traceback.format_exc()}")
1310
  return None, pd.DataFrame(), f"Error: {str(e)}"
1311
 
1312
  analyze_button.click(
 
1317
  component_selector,
1318
  use_de_input,
1319
  maxfev_input,
1320
+ exp_names_input,
1321
+ theme_toggle
1322
  ],
1323
  outputs=[plot_output, results_table, status_output]
1324
  )
 
1327
  language_select.change(
1328
  fn=change_language,
1329
  inputs=[language_select],
1330
+ outputs=[title_text, subtitle_text]
1331
  )
1332
 
1333
  # Cambio de tema
1334
  def apply_theme(is_dark):
1335
+ return gr.Info("Tema cambiado. Los gráficos nuevos usarán el tema seleccionado.")
 
 
1336
 
1337
  theme_toggle.change(
1338
  fn=apply_theme,
1339
  inputs=[theme_toggle],
1340
  outputs=[]
1341
  )
1342
+
1343
+ # Funciones de descarga
1344
+ def download_results_excel(df):
1345
+ if df is None or df.empty:
1346
+ gr.Warning("No hay datos para descargar")
1347
+ return None
1348
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as tmp:
1349
+ df.to_excel(tmp.name, index=False)
1350
+ return tmp.name
1351
+
1352
+ def download_results_json(df):
1353
+ if df is None or df.empty:
1354
+ gr.Warning("No hay datos para descargar")
1355
+ return None
1356
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".json") as tmp:
1357
+ df.to_json(tmp.name, orient='records', indent=2)
1358
+ return tmp.name
1359
+
1360
+ download_excel.click(
1361
+ fn=download_results_excel,
1362
+ inputs=[results_table],
1363
+ outputs=[download_file]
1364
+ )
1365
+
1366
+ download_json.click(
1367
+ fn=download_results_json,
1368
+ inputs=[results_table],
1369
+ outputs=[download_file]
1370
+ )
1371
 
1372
  return demo
1373