C2MV commited on
Commit
71f4ff3
·
verified ·
1 Parent(s): 8d399f5

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +857 -258
app.py CHANGED
@@ -1,280 +1,879 @@
1
- # app.py - Versión simplificada para Hugging Face Spaces
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import gradio as gr
3
- import pandas as pd
4
- import numpy as np
5
  import plotly.graph_objects as go
6
  from plotly.subplots import make_subplots
7
- import tempfile
8
- import traceback
9
- from typing import List, Dict, Any, Optional, Tuple
 
 
10
  from scipy.optimize import curve_fit, differential_evolution
11
  from sklearn.metrics import mean_squared_error, r2_score
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
- # Configuración básica
14
- print("Iniciando aplicación...")
15
 
16
- # --- MODELOS BÁSICOS ---
17
- class LogisticModel:
 
 
 
 
 
 
 
 
18
  def __init__(self):
19
- self.name = "logistic"
20
- self.display_name = "Logístico"
21
- self.param_names = ["X0", "Xm", "μm"]
22
-
23
- def model_function(self, t, X0, Xm, um):
24
- try:
25
- if Xm <= 0 or X0 <= 0 or Xm < X0:
26
- return np.full_like(t, np.nan)
27
- exp_arg = np.clip(um * t, -700, 700)
28
- term_exp = np.exp(exp_arg)
29
- denominator = Xm - X0 + X0 * term_exp
30
- denominator = np.where(denominator == 0, 1e-9, denominator)
31
- return (X0 * term_exp * Xm) / denominator
32
- except:
33
  return np.full_like(t, np.nan)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
- class GompertzModel:
 
36
  def __init__(self):
37
- self.name = "gompertz"
38
- self.display_name = "Gompertz"
39
- self.param_names = ["Xm", "μm", "λ"]
40
-
41
- def model_function(self, t, Xm, um, lag):
42
- try:
43
- if Xm <= 0 or um <= 0:
44
- return np.full_like(t, np.nan)
45
- exp_term = (um * np.e / Xm) * (lag - t) + 1
46
- exp_term_clipped = np.clip(exp_term, -700, 700)
47
- return Xm * np.exp(-np.exp(exp_term_clipped))
48
- except:
49
  return np.full_like(t, np.nan)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
- # Modelos disponibles
52
- MODELS = {
53
- "logistic": LogisticModel(),
54
- "gompertz": GompertzModel()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  }
56
 
57
- # --- FUNCIONES DE ANÁLISIS SIMPLIFICADAS ---
58
- def fit_model(model, time_data, biomass_data):
59
- """Ajusta un modelo a los datos"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  try:
61
- # Parámetros iniciales
62
- if model.name == "logistic":
63
- p0 = [biomass_data[0], max(biomass_data), 0.1]
64
- bounds = ([1e-9, biomass_data[0], 1e-9],
65
- [max(biomass_data)*2, max(biomass_data)*5, 10])
66
- else: # gompertz
67
- p0 = [max(biomass_data), 0.1, 0]
68
- bounds = ([biomass_data[0], 1e-9, 0],
69
- [max(biomass_data)*5, 10, max(time_data)])
70
-
71
- popt, _ = curve_fit(model.model_function, time_data, biomass_data,
72
- p0=p0, bounds=bounds, maxfev=10000)
73
-
74
- # Calcular métricas
75
- y_pred = model.model_function(time_data, *popt)
76
- r2 = r2_score(biomass_data, y_pred)
77
- rmse = np.sqrt(mean_squared_error(biomass_data, y_pred))
78
-
79
- return {
80
- 'success': True,
81
- 'parameters': dict(zip(model.param_names, popt)),
82
- 'r2': r2,
83
- 'rmse': rmse,
84
- 'predictions': y_pred
85
- }
86
  except Exception as e:
87
- return {
88
- 'success': False,
89
- 'error': str(e),
90
- 'parameters': {},
91
- 'r2': np.nan,
92
- 'rmse': np.nan,
93
- 'predictions': np.full_like(time_data, np.nan)
94
- }
95
-
96
- def process_excel_file(file_path):
97
- """Procesa archivo Excel y extrae datos"""
98
- try:
99
- # Leer archivo Excel
100
- xls = pd.ExcelFile(file_path)
101
- results = []
102
-
103
- for sheet_name in xls.sheet_names:
104
- df = pd.read_excel(xls, sheet_name=sheet_name, header=[0,1])
105
-
106
- # Buscar columnas de tiempo y biomasa
107
- time_col = None
108
- biomass_cols = []
109
-
110
- for col in df.columns:
111
- if 'tiempo' in str(col[1]).lower():
112
- time_col = col
113
- elif 'biomasa' in str(col[1]).lower():
114
- biomass_cols.append(col)
115
-
116
- if time_col is None or not biomass_cols:
117
  continue
118
-
119
- # Extraer datos
120
- time_data = df[time_col].dropna().values
121
- biomass_data = df[biomass_cols[0]].dropna().values
122
-
123
- # Asegurar mismo tamaño
124
- min_len = min(len(time_data), len(biomass_data))
125
- time_data = time_data[:min_len]
126
- biomass_data = biomass_data[:min_len]
127
-
128
- results.append({
129
- 'sheet': sheet_name,
130
- 'time': time_data,
131
- 'biomass': biomass_data
132
- })
133
-
134
- return results
135
- except Exception as e:
136
- print(f"Error procesando archivo: {e}")
137
- return []
138
-
139
- def analyze_data(file, selected_models):
140
- """Función principal de análisis"""
141
- if file is None:
142
- return None, "Error: No se ha subido ningún archivo"
143
-
144
- try:
145
- # Procesar archivo
146
- datasets = process_excel_file(file.name)
147
-
148
- if not datasets:
149
- return None, "Error: No se encontraron datos válidos en el archivo"
150
-
151
- all_results = []
152
-
153
- # Por cada dataset
154
- for dataset in datasets:
155
- sheet_name = dataset['sheet']
156
- time_data = dataset['time']
157
- biomass_data = dataset['biomass']
158
-
159
- # Por cada modelo seleccionado
160
- for model_name in selected_models:
161
- if model_name in MODELS:
162
- model = MODELS[model_name]
163
- result = fit_model(model, time_data, biomass_data)
164
-
165
- all_results.append({
166
- 'Experimento': sheet_name,
167
- 'Modelo': model.display_name,
168
- 'R²': result['r2'],
169
- 'RMSE': result['rmse'],
170
- **{f'Param_{k}': v for k, v in result['parameters'].items()}
171
- })
172
-
173
- # Crear DataFrame de resultados
174
- results_df = pd.DataFrame(all_results)
175
-
176
- # Crear gráfico simple
177
- fig = go.Figure()
178
-
179
- if datasets:
180
- dataset = datasets[0] # Usar primer dataset para el gráfico
181
-
182
- # Datos experimentales
183
- fig.add_trace(go.Scatter(
184
- x=dataset['time'],
185
- y=dataset['biomass'],
186
- mode='markers',
187
- name='Datos Experimentales',
188
- marker=dict(size=8)
189
- ))
190
-
191
- # Predicciones de modelos
192
- colors = ['red', 'blue', 'green', 'orange']
193
- for i, model_name in enumerate(selected_models):
194
- if model_name in MODELS:
195
- model = MODELS[model_name]
196
- result = fit_model(model, dataset['time'], dataset['biomass'])
197
- if result['success']:
198
- t_fine = np.linspace(min(dataset['time']), max(dataset['time']), 100)
199
- y_pred = model.model_function(t_fine, *result['parameters'].values())
200
-
201
- fig.add_trace(go.Scatter(
202
- x=t_fine,
203
- y=y_pred,
204
- mode='lines',
205
- name=f'{model.display_name} (R²={result["r2"]:.3f})',
206
- line=dict(color=colors[i % len(colors)])
207
- ))
208
-
209
- fig.update_layout(
210
- title='Análisis de Cinéticas de Crecimiento',
211
- xaxis_title='Tiempo',
212
- yaxis_title='Biomasa',
213
- template='plotly_white'
214
- )
215
-
216
- return fig, f"Análisis completado exitosamente. Procesados {len(datasets)} experimentos."
217
-
218
- except Exception as e:
219
- error_msg = f"Error en el análisis: {str(e)}"
220
- print(error_msg)
221
- print(traceback.format_exc())
222
- return None, error_msg
223
-
224
- # --- INTERFAZ GRADIO SIMPLIFICADA ---
225
- def create_interface():
226
- """Crear interfaz Gradio simplificada"""
227
-
228
- with gr.Blocks(title="Analizador de Cinéticas") as demo:
229
- gr.Markdown("# 🔬 Analizador de Cinéticas de Bioprocesos")
230
- gr.Markdown("Versión simplificada para análisis de modelos de crecimiento")
231
-
232
  with gr.Row():
233
- with gr.Column():
234
- file_input = gr.File(
235
- label="📁 Subir archivo Excel (.xlsx)",
236
- file_types=['.xlsx']
237
- )
238
-
239
- model_selection = gr.CheckboxGroup(
240
- choices=[("Logístico", "logistic"), ("Gompertz", "gompertz")],
241
- label="🔬 Seleccionar Modelos",
242
- value=["logistic"]
243
- )
244
-
245
- analyze_btn = gr.Button("🚀 Analizar", variant="primary")
246
-
247
- with gr.Column():
248
- plot_output = gr.Plot(label="📊 Resultados")
249
- status_output = gr.Textbox(label="📋 Estado", interactive=False)
250
-
251
- # Conectar eventos
252
- analyze_btn.click(
253
- fn=analyze_data,
254
- inputs=[file_input, model_selection],
255
- outputs=[plot_output, status_output]
256
- )
257
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  return demo
259
 
260
  # --- PUNTO DE ENTRADA ---
261
- if __name__ == "__main__":
262
- print("Creando interfaz...")
263
-
264
- try:
265
- demo = create_interface()
266
- print("Interfaz creada, lanzando aplicación...")
267
-
268
- # Configuración para Hugging Face Spaces
269
- demo.launch(
270
- server_name="0.0.0.0",
271
- server_port=7860,
272
- share=False, # No usar share en HF Spaces
273
- debug=False, # Desactivar debug en producción
274
- show_error=True,
275
- quiet=False
276
- )
277
-
278
- except Exception as e:
279
- print(f"Error al lanzar la aplicación: {e}")
280
- print(traceback.format_exc())
 
1
+ # --- INSTALACIÓN DE DEPENDENCIAS ADICIONALES ---
2
+ import os
3
+ import sys
4
+ import subprocess
5
+ os.system("pip install gradio==5.38.1")
6
+ import os
7
+ import io
8
+ import tempfile
9
+ import traceback
10
+ import zipfile
11
+ from typing import List, Tuple, Dict, Any, Optional, Union
12
+ from abc import ABC, abstractmethod
13
+ from unittest.mock import MagicMock
14
+ from dataclasses import dataclass
15
+ from enum import Enum
16
+ import json
17
+
18
+ from PIL import Image
19
  import gradio as gr
 
 
20
  import plotly.graph_objects as go
21
  from plotly.subplots import make_subplots
22
+ import numpy as np
23
+ import pandas as pd
24
+ import matplotlib.pyplot as plt
25
+ import seaborn as sns
26
+ from scipy.integrate import odeint
27
  from scipy.optimize import curve_fit, differential_evolution
28
  from sklearn.metrics import mean_squared_error, r2_score
29
+ from docx import Document
30
+ from docx.shared import Inches
31
+ from fpdf import FPDF
32
+ from fpdf.enums import XPos, YPos
33
+
34
+ # --- SISTEMA DE INTERNACIONALIZACIÓN ---
35
+ class Language(Enum):
36
+ ES = "Español"
37
+ EN = "English"
38
+ PT = "Português"
39
+ FR = "Français"
40
+ DE = "Deutsch"
41
+ ZH = "中文"
42
+ JA = "日本語"
43
+
44
+ TRANSLATIONS = {
45
+ Language.ES: {
46
+ "title": "🔬 Analizador de Cinéticas de Bioprocesos",
47
+ "subtitle": "Análisis avanzado de modelos matemáticos biotecnológicos",
48
+ "upload": "Sube tu archivo Excel (.xlsx)",
49
+ "select_models": "Modelos a Probar",
50
+ "analyze": "Analizar y Graficar",
51
+ "results": "Resultados",
52
+ "download": "Descargar",
53
+ "biomass": "Biomasa",
54
+ "substrate": "Sustrato",
55
+ "product": "Producto",
56
+ "time": "Tiempo",
57
+ "parameters": "Parámetros",
58
+ "model_comparison": "Comparación de Modelos",
59
+ "dark_mode": "Modo Oscuro",
60
+ "light_mode": "Modo Claro",
61
+ "language": "Idioma",
62
+ "theory": "Teoría y Modelos",
63
+ },
64
+ Language.EN: {
65
+ "title": "🔬 Bioprocess Kinetics Analyzer",
66
+ "subtitle": "Advanced analysis of biotechnological mathematical models",
67
+ "upload": "Upload your Excel file (.xlsx)",
68
+ "select_models": "Models to Test",
69
+ "analyze": "Analyze and Plot",
70
+ "results": "Results",
71
+ "download": "Download",
72
+ "biomass": "Biomass",
73
+ "substrate": "Substrate",
74
+ "product": "Product",
75
+ "time": "Time",
76
+ "parameters": "Parameters",
77
+ "model_comparison": "Model Comparison",
78
+ "dark_mode": "Dark Mode",
79
+ "light_mode": "Light Mode",
80
+ "language": "Language",
81
+ "theory": "Theory and Models",
82
+ },
83
+ }
84
+
85
+ # --- CONSTANTES MEJORADAS ---
86
+ C_TIME = 'tiempo'
87
+ C_BIOMASS = 'biomass'
88
+ C_SUBSTRATE = 'substrate'
89
+ C_PRODUCT = 'product'
90
+ COMPONENTS = [C_BIOMASS, C_SUBSTRATE, C_PRODUCT]
91
+
92
+ # --- SISTEMA DE TEMAS ---
93
+ THEMES = {
94
+ "light": gr.themes.Soft(
95
+ primary_hue="blue",
96
+ secondary_hue="sky",
97
+ neutral_hue="gray",
98
+ font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "sans-serif"]
99
+ ),
100
+ "dark": gr.themes.Base(
101
+ primary_hue="blue",
102
+ secondary_hue="cyan",
103
+ neutral_hue="slate",
104
+ font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "sans-serif"]
105
+ ).set(
106
+ body_background_fill="*neutral_950",
107
+ body_background_fill_dark="*neutral_950",
108
+ button_primary_background_fill="*primary_600",
109
+ button_primary_background_fill_hover="*primary_700",
110
+ )
111
+ }
112
+
113
+ # --- MODELOS CINÉTICOS COMPLETOS ---
114
+
115
+ class KineticModel(ABC):
116
+ def __init__(self, name: str, display_name: str, param_names: List[str],
117
+ description: str = "", equation: str = "", reference: str = ""):
118
+ self.name = name
119
+ self.display_name = display_name
120
+ self.param_names = param_names
121
+ self.num_params = len(param_names)
122
+ self.description = description
123
+ self.equation = equation
124
+ self.reference = reference
125
+
126
+ @abstractmethod
127
+ def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
128
+ pass
129
 
130
+ def diff_function(self, X: float, t: float, params: List[float]) -> float:
131
+ return 0.0
132
 
133
+ @abstractmethod
134
+ def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
135
+ pass
136
+
137
+ @abstractmethod
138
+ def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
139
+ pass
140
+
141
+ # Modelo Logístico
142
+ class LogisticModel(KineticModel):
143
  def __init__(self):
144
+ super().__init__(
145
+ "logistic",
146
+ "Logístico",
147
+ ["X0", "Xm", "μm"],
148
+ "Modelo de crecimiento logístico clásico para poblaciones limitadas",
149
+ r"X(t) = \frac{X_0 X_m e^{\mu_m t}}{X_m - X_0 + X_0 e^{\mu_m t}}",
150
+ "Verhulst (1838)"
151
+ )
152
+
153
+ def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
154
+ X0, Xm, um = params
155
+ if Xm <= 0 or X0 <= 0 or Xm < X0:
 
 
156
  return np.full_like(t, np.nan)
157
+ exp_arg = np.clip(um * t, -700, 700)
158
+ term_exp = np.exp(exp_arg)
159
+ denominator = Xm - X0 + X0 * term_exp
160
+ denominator = np.where(denominator == 0, 1e-9, denominator)
161
+ return (X0 * term_exp * Xm) / denominator
162
+
163
+ def diff_function(self, X: float, t: float, params: List[float]) -> float:
164
+ _, Xm, um = params
165
+ return um * X * (1 - X / Xm) if Xm > 0 else 0.0
166
+
167
+ def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
168
+ return [
169
+ biomass[0] if len(biomass) > 0 and biomass[0] > 1e-6 else 1e-3,
170
+ max(biomass) if len(biomass) > 0 else 1.0,
171
+ 0.1
172
+ ]
173
+
174
+ def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
175
+ initial_biomass = biomass[0] if len(biomass) > 0 else 1e-9
176
+ max_biomass = max(biomass) if len(biomass) > 0 else 1.0
177
+ return ([1e-9, initial_biomass, 1e-9], [max_biomass * 1.2, max_biomass * 5, np.inf])
178
 
179
+ # Modelo Gompertz
180
+ class GompertzModel(KineticModel):
181
  def __init__(self):
182
+ super().__init__(
183
+ "gompertz",
184
+ "Gompertz",
185
+ ["Xm", "μm", "λ"],
186
+ "Modelo de crecimiento asimétrico con fase lag",
187
+ r"X(t) = X_m \exp\left(-\exp\left(\frac{\mu_m e}{X_m}(\lambda-t)+1\right)\right)",
188
+ "Gompertz (1825)"
189
+ )
190
+
191
+ def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
192
+ Xm, um, lag = params
193
+ if Xm <= 0 or um <= 0:
194
  return np.full_like(t, np.nan)
195
+ exp_term = (um * np.e / Xm) * (lag - t) + 1
196
+ exp_term_clipped = np.clip(exp_term, -700, 700)
197
+ return Xm * np.exp(-np.exp(exp_term_clipped))
198
+
199
+ def diff_function(self, X: float, t: float, params: List[float]) -> float:
200
+ Xm, um, lag = params
201
+ k_val = um * np.e / Xm
202
+ u_val = k_val * (lag - t) + 1
203
+ u_val_clipped = np.clip(u_val, -np.inf, 700)
204
+ return X * k_val * np.exp(u_val_clipped) if Xm > 0 and X > 0 else 0.0
205
+
206
+ def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
207
+ return [
208
+ max(biomass) if len(biomass) > 0 else 1.0,
209
+ 0.1,
210
+ time[np.argmax(np.gradient(biomass))] if len(biomass) > 1 else 0
211
+ ]
212
+
213
+ def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
214
+ initial_biomass = min(biomass) if len(biomass) > 0 else 1e-9
215
+ max_biomass = max(biomass) if len(biomass) > 0 else 1.0
216
+ return ([max(1e-9, initial_biomass), 1e-9, 0], [max_biomass * 5, np.inf, max(time) if len(time) > 0 else 1])
217
+
218
+ # Modelo Moser
219
+ class MoserModel(KineticModel):
220
+ def __init__(self):
221
+ super().__init__(
222
+ "moser",
223
+ "Moser",
224
+ ["Xm", "μm", "Ks"],
225
+ "Modelo exponencial simple de Moser",
226
+ r"X(t) = X_m (1 - e^{-\mu_m (t - K_s)})",
227
+ "Moser (1958)"
228
+ )
229
+
230
+ def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
231
+ Xm, um, Ks = params
232
+ return Xm * (1 - np.exp(-um * (t - Ks))) if Xm > 0 and um > 0 else np.full_like(t, np.nan)
233
+
234
+ def diff_function(self, X: float, t: float, params: List[float]) -> float:
235
+ Xm, um, _ = params
236
+ return um * (Xm - X) if Xm > 0 else 0.0
237
+
238
+ def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
239
+ return [max(biomass) if len(biomass) > 0 else 1.0, 0.1, 0]
240
+
241
+ def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
242
+ initial_biomass = min(biomass) if len(biomass) > 0 else 1e-9
243
+ max_biomass = max(biomass) if len(biomass) > 0 else 1.0
244
+ return ([max(1e-9, initial_biomass), 1e-9, -np.inf], [max_biomass * 5, np.inf, np.inf])
245
+
246
+ # Modelo Baranyi
247
+ class BaranyiModel(KineticModel):
248
+ def __init__(self):
249
+ super().__init__(
250
+ "baranyi",
251
+ "Baranyi",
252
+ ["X0", "Xm", "μm", "λ"],
253
+ "Modelo de Baranyi con fase lag explícita",
254
+ r"X(t) = X_m / [1 + ((X_m/X_0) - 1) \exp(-\mu_m A(t))]",
255
+ "Baranyi & Roberts (1994)"
256
+ )
257
+
258
+ def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
259
+ X0, Xm, um, lag = params
260
+ if X0 <= 0 or Xm <= X0 or um <= 0 or lag < 0:
261
+ return np.full_like(t, np.nan)
262
+ A_t = t + (1 / um) * np.log(np.exp(-um * t) + np.exp(-um * lag) - np.exp(-um * (t + lag)))
263
+ exp_um_At = np.exp(np.clip(um * A_t, -700, 700))
264
+ numerator = Xm
265
+ denominator = 1 + ((Xm / X0) - 1) * (1 / exp_um_At)
266
+ return numerator / np.where(denominator == 0, 1e-9, denominator)
267
+
268
+ def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
269
+ return [
270
+ biomass[0] if len(biomass) > 0 and biomass[0] > 1e-6 else 1e-3,
271
+ max(biomass) if len(biomass) > 0 else 1.0,
272
+ 0.1,
273
+ time[np.argmax(np.gradient(biomass))] if len(biomass) > 1 else 0.0
274
+ ]
275
+
276
+ def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
277
+ initial_biomass = biomass[0] if len(biomass) > 0 else 1e-9
278
+ max_biomass = max(biomass) if len(biomass) > 0 else 1.0
279
+ 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])
280
+
281
+ # Modelo Monod
282
+ class MonodModel(KineticModel):
283
+ def __init__(self):
284
+ super().__init__(
285
+ "monod",
286
+ "Monod",
287
+ ["μmax", "Ks", "Y", "m"],
288
+ "Modelo de Monod con mantenimiento celular",
289
+ r"\mu = \frac{\mu_{max} \cdot S}{K_s + S} - m",
290
+ "Monod (1949)"
291
+ )
292
+
293
+ def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
294
+ return np.full_like(t, np.nan)
295
+
296
+ def diff_function(self, X: float, t: float, params: List[float]) -> float:
297
+ μmax, Ks, Y, m = params
298
+ S = 10.0
299
+ μ = (μmax * S / (Ks + S)) - m
300
+ return μ * X
301
+
302
+ def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
303
+ return [0.5, 0.1, 0.5, 0.01]
304
+
305
+ def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
306
+ return ([0.01, 0.001, 0.1, 0.0], [2.0, 5.0, 1.0, 0.1])
307
+
308
+ # Modelo Contois
309
+ class ContoisModel(KineticModel):
310
+ def __init__(self):
311
+ super().__init__(
312
+ "contois",
313
+ "Contois",
314
+ ["μmax", "Ksx", "Y", "m"],
315
+ "Modelo de Contois para alta densidad celular",
316
+ r"\mu = \frac{\mu_{max} \cdot S}{K_{sx} \cdot X + S} - m",
317
+ "Contois (1959)"
318
+ )
319
+
320
+ def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
321
+ return np.full_like(t, np.nan)
322
+
323
+ def diff_function(self, X: float, t: float, params: List[float]) -> float:
324
+ μmax, Ksx, Y, m = params
325
+ S = 10.0
326
+ μ = (μmax * S / (Ksx * X + S)) - m
327
+ return μ * X
328
+
329
+ def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
330
+ return [0.5, 0.5, 0.5, 0.01]
331
+
332
+ def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
333
+ return ([0.01, 0.01, 0.1, 0.0], [2.0, 10.0, 1.0, 0.1])
334
+
335
+ # Modelo Andrews
336
+ class AndrewsModel(KineticModel):
337
+ def __init__(self):
338
+ super().__init__(
339
+ "andrews",
340
+ "Andrews (Haldane)",
341
+ ["μmax", "Ks", "Ki", "Y", "m"],
342
+ "Modelo de inhibición por sustrato",
343
+ r"\mu = \frac{\mu_{max} \cdot S}{K_s + S + \frac{S^2}{K_i}} - m",
344
+ "Andrews (1968)"
345
+ )
346
+
347
+ def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
348
+ return np.full_like(t, np.nan)
349
+
350
+ def diff_function(self, X: float, t: float, params: List[float]) -> float:
351
+ μmax, Ks, Ki, Y, m = params
352
+ S = 10.0
353
+ μ = (μmax * S / (Ks + S + S**2/Ki)) - m
354
+ return μ * X
355
+
356
+ def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
357
+ return [0.5, 0.1, 50.0, 0.5, 0.01]
358
+
359
+ def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
360
+ return ([0.01, 0.001, 1.0, 0.1, 0.0], [2.0, 5.0, 200.0, 1.0, 0.1])
361
 
362
+ # Modelo Tessier
363
+ class TessierModel(KineticModel):
364
+ def __init__(self):
365
+ super().__init__(
366
+ "tessier",
367
+ "Tessier",
368
+ ["μmax", "Ks", "X0"],
369
+ "Modelo exponencial de Tessier",
370
+ r"\mu = \mu_{max} \cdot (1 - e^{-S/K_s})",
371
+ "Tessier (1942)"
372
+ )
373
+
374
+ def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
375
+ μmax, Ks, X0 = params
376
+ return X0 * np.exp(μmax * t * 0.5)
377
+
378
+ def diff_function(self, X: float, t: float, params: List[float]) -> float:
379
+ μmax, Ks, X0 = params
380
+ return μmax * X * 0.5
381
+
382
+ def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
383
+ return [0.5, 1.0, biomass[0] if len(biomass) > 0 else 0.1]
384
+
385
+ def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
386
+ return ([0.01, 0.1, 1e-9], [2.0, 10.0, 1.0])
387
+
388
+ # Modelo Richards
389
+ class RichardsModel(KineticModel):
390
+ def __init__(self):
391
+ super().__init__(
392
+ "richards",
393
+ "Richards",
394
+ ["A", "μm", "λ", "ν", "X0"],
395
+ "Modelo generalizado de Richards",
396
+ r"X(t) = A \cdot [1 + \nu \cdot e^{-\mu_m(t-\lambda)}]^{-1/\nu}",
397
+ "Richards (1959)"
398
+ )
399
+
400
+ def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
401
+ A, μm, λ, ν, X0 = params
402
+ if A <= 0 or μm <= 0 or ν <= 0:
403
+ return np.full_like(t, np.nan)
404
+ exp_term = np.exp(-μm * (t - λ))
405
+ return A * (1 + ν * exp_term) ** (-1/ν)
406
+
407
+ def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
408
+ return [
409
+ max(biomass) if len(biomass) > 0 else 1.0,
410
+ 0.5,
411
+ time[len(time)//4] if len(time) > 0 else 1.0,
412
+ 1.0,
413
+ biomass[0] if len(biomass) > 0 else 0.1
414
+ ]
415
+
416
+ def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
417
+ max_biomass = max(biomass) if len(biomass) > 0 else 10.0
418
+ max_time = max(time) if len(time) > 0 else 100.0
419
+ return (
420
+ [0.1, 0.01, 0.0, 0.1, 1e-9],
421
+ [max_biomass * 2, 5.0, max_time, 10.0, max_biomass]
422
+ )
423
+
424
+ # Modelo Stannard
425
+ class StannardModel(KineticModel):
426
+ def __init__(self):
427
+ super().__init__(
428
+ "stannard",
429
+ "Stannard",
430
+ ["Xm", "μm", "λ", "α"],
431
+ "Modelo de Stannard modificado",
432
+ r"X(t) = X_m \cdot [1 - e^{-\mu_m(t-\lambda)^\alpha}]",
433
+ "Stannard et al. (1985)"
434
+ )
435
+
436
+ def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
437
+ Xm, μm, λ, α = params
438
+ if Xm <= 0 or μm <= 0 or α <= 0:
439
+ return np.full_like(t, np.nan)
440
+ t_shifted = np.maximum(t - λ, 0)
441
+ return Xm * (1 - np.exp(-μm * t_shifted ** α))
442
+
443
+ def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
444
+ return [
445
+ max(biomass) if len(biomass) > 0 else 1.0,
446
+ 0.5,
447
+ 0.0,
448
+ 1.0
449
+ ]
450
+
451
+ def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
452
+ max_biomass = max(biomass) if len(biomass) > 0 else 10.0
453
+ max_time = max(time) if len(time) > 0 else 100.0
454
+ return ([0.1, 0.01, -max_time/10, 0.1], [max_biomass * 2, 5.0, max_time/2, 3.0])
455
+
456
+ # Modelo Huang
457
+ class HuangModel(KineticModel):
458
+ def __init__(self):
459
+ super().__init__(
460
+ "huang",
461
+ "Huang",
462
+ ["Xm", "μm", "λ", "n", "m"],
463
+ "Modelo de Huang para fase lag variable",
464
+ r"X(t) = X_m \cdot \frac{1}{1 + e^{-\mu_m(t-\lambda-m/n)}}",
465
+ "Huang (2008)"
466
+ )
467
+
468
+ def model_function(self, t: np.ndarray, *params: float) -> np.ndarray:
469
+ Xm, μm, λ, n, m = params
470
+ if Xm <= 0 or μm <= 0 or n <= 0:
471
+ return np.full_like(t, np.nan)
472
+ return Xm / (1 + np.exp(-μm * (t - λ - m/n)))
473
+
474
+ def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]:
475
+ return [
476
+ max(biomass) if len(biomass) > 0 else 1.0,
477
+ 0.5,
478
+ time[len(time)//4] if len(time) > 0 else 1.0,
479
+ 1.0,
480
+ 0.5
481
+ ]
482
+
483
+ def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]:
484
+ max_biomass = max(biomass) if len(biomass) > 0 else 10.0
485
+ max_time = max(time) if len(time) > 0 else 100.0
486
+ return (
487
+ [0.1, 0.01, 0.0, 0.1, 0.0],
488
+ [max_biomass * 2, 5.0, max_time/2, 10.0, 5.0]
489
+ )
490
+
491
+ # --- REGISTRO ACTUALIZADO DE MODELOS ---
492
+ AVAILABLE_MODELS: Dict[str, KineticModel] = {
493
+ model.name: model for model in [
494
+ LogisticModel(),
495
+ GompertzModel(),
496
+ MoserModel(),
497
+ BaranyiModel(),
498
+ MonodModel(),
499
+ ContoisModel(),
500
+ AndrewsModel(),
501
+ TessierModel(),
502
+ RichardsModel(),
503
+ StannardModel(),
504
+ HuangModel()
505
+ ]
506
  }
507
 
508
+ # --- CLASE MEJORADA DE AJUSTE ---
509
+ class BioprocessFitter:
510
+ def __init__(self, kinetic_model: KineticModel, maxfev: int = 50000,
511
+ use_differential_evolution: bool = False):
512
+ self.model = kinetic_model
513
+ self.maxfev = maxfev
514
+ self.use_differential_evolution = use_differential_evolution
515
+ self.params: Dict[str, Dict[str, float]] = {c: {} for c in COMPONENTS}
516
+ self.r2: Dict[str, float] = {}
517
+ self.rmse: Dict[str, float] = {}
518
+ self.mae: Dict[str, float] = {}
519
+ self.aic: Dict[str, float] = {}
520
+ self.bic: Dict[str, float] = {}
521
+ self.data_time: Optional[np.ndarray] = None
522
+ self.data_means: Dict[str, Optional[np.ndarray]] = {c: None for c in COMPONENTS}
523
+ self.data_stds: Dict[str, Optional[np.ndarray]] = {c: None for c in COMPONENTS}
524
+
525
+ def _get_biomass_at_t(self, t: np.ndarray, p: List[float]) -> np.ndarray:
526
+ return self.model.model_function(t, *p)
527
+
528
+ def _get_initial_biomass(self, p: List[float]) -> float:
529
+ if not p: return 0.0
530
+ if any(k in self.model.param_names for k in ["Xo", "X0"]):
531
+ try:
532
+ idx = self.model.param_names.index("Xo") if "Xo" in self.model.param_names else self.model.param_names.index("X0")
533
+ return p[idx]
534
+ except (ValueError, IndexError): pass
535
+ return float(self.model.model_function(np.array([0]), *p)[0])
536
+
537
+ def _calc_integral(self, t: np.ndarray, p: List[float]) -> Tuple[np.ndarray, np.ndarray]:
538
+ X_t = self._get_biomass_at_t(t, p)
539
+ if np.any(np.isnan(X_t)): return np.full_like(t, np.nan), np.full_like(t, np.nan)
540
+ integral_X = np.zeros_like(X_t)
541
+ if len(t) > 1:
542
+ dt = np.diff(t, prepend=t[0] - (t[1] - t[0] if len(t) > 1 else 1))
543
+ integral_X = np.cumsum(X_t * dt)
544
+ return integral_X, X_t
545
+
546
+ def substrate(self, t: np.ndarray, so: float, p_c: float, q: float, bio_p: List[float]) -> np.ndarray:
547
+ integral, X_t = self._calc_integral(t, bio_p)
548
+ X0 = self._get_initial_biomass(bio_p)
549
+ return so - p_c * (X_t - X0) - q * integral
550
+
551
+ def product(self, t: np.ndarray, po: float, alpha: float, beta: float, bio_p: List[float]) -> np.ndarray:
552
+ integral, X_t = self._calc_integral(t, bio_p)
553
+ X0 = self._get_initial_biomass(bio_p)
554
+ return po + alpha * (X_t - X0) + beta * integral
555
+
556
+ def process_data_from_df(self, df: pd.DataFrame) -> None:
557
+ try:
558
+ time_col = [c for c in df.columns if c[1].strip().lower() == C_TIME][0]
559
+ self.data_time = df[time_col].dropna().to_numpy()
560
+ min_len = len(self.data_time)
561
+
562
+ def extract(name: str) -> Tuple[np.ndarray, np.ndarray]:
563
+ cols = [c for c in df.columns if c[1].strip().lower() == name.lower()]
564
+ if not cols: return np.array([]), np.array([])
565
+ reps = [df[c].dropna().values[:min_len] for c in cols]
566
+ reps = [r for r in reps if len(r) == min_len]
567
+ if not reps: return np.array([]), np.array([])
568
+ arr = np.array(reps)
569
+ mean = np.mean(arr, axis=0)
570
+ std = np.std(arr, axis=0, ddof=1) if arr.shape[0] > 1 else np.zeros_like(mean)
571
+ return mean, std
572
+
573
+ self.data_means[C_BIOMASS], self.data_stds[C_BIOMASS] = extract('Biomasa')
574
+ self.data_means[C_SUBSTRATE], self.data_stds[C_SUBSTRATE] = extract('Sustrato')
575
+ self.data_means[C_PRODUCT], self.data_stds[C_PRODUCT] = extract('Producto')
576
+ except (IndexError, KeyError) as e:
577
+ raise ValueError(f"Estructura de DataFrame inválida. Error: {e}")
578
+
579
+ def _calculate_metrics(self, y_true: np.ndarray, y_pred: np.ndarray,
580
+ n_params: int) -> Dict[str, float]:
581
+ n = len(y_true)
582
+ residuals = y_true - y_pred
583
+ ss_res = np.sum(residuals**2)
584
+ ss_tot = np.sum((y_true - np.mean(y_true))**2)
585
+ r2 = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0
586
+ rmse = np.sqrt(ss_res / n)
587
+ mae = np.mean(np.abs(residuals))
588
+ if n > n_params + 1:
589
+ aic = n * np.log(ss_res/n) + 2 * n_params
590
+ bic = n * np.log(ss_res/n) + n_params * np.log(n)
591
+ else:
592
+ aic = bic = np.inf
593
+ return {'r2': r2, 'rmse': rmse, 'mae': mae, 'aic': aic, 'bic': bic}
594
+
595
+ def _fit_component_de(self, func, t, data, bounds, *args):
596
+ def objective(params):
597
+ try:
598
+ pred = func(t, *params, *args)
599
+ if np.any(np.isnan(pred)): return 1e10
600
+ return np.sum((data - pred)**2)
601
+ except:
602
+ return 1e10
603
+ result = differential_evolution(objective, bounds=list(zip(*bounds)), maxiter=1000, seed=42)
604
+ if result.success:
605
+ popt = result.x
606
+ pred = func(t, *popt, *args)
607
+ metrics = self._calculate_metrics(data, pred, len(popt))
608
+ return list(popt), metrics
609
+ return None, {'r2': np.nan, 'rmse': np.nan, 'mae': np.nan, 'aic': np.nan, 'bic': np.nan}
610
+
611
+ def _fit_component(self, func, t, data, p0, bounds, sigma=None, *args):
612
+ try:
613
+ if self.use_differential_evolution:
614
+ return self._fit_component_de(func, t, data, bounds, *args)
615
+ if sigma is not None:
616
+ sigma = np.where(sigma == 0, 1e-9, sigma)
617
+ popt, _ = curve_fit(func, t, data, p0, bounds=bounds, maxfev=self.maxfev, ftol=1e-9, xtol=1e-9, sigma=sigma, absolute_sigma=bool(sigma is not None))
618
+ pred = func(t, *popt, *args)
619
+ if np.any(np.isnan(pred)):
620
+ return None, {'r2': np.nan, 'rmse': np.nan, 'mae': np.nan, 'aic': np.nan, 'bic': np.nan}
621
+ metrics = self._calculate_metrics(data, pred, len(popt))
622
+ return list(popt), metrics
623
+ except (RuntimeError, ValueError):
624
+ return None, {'r2': np.nan, 'rmse': np.nan, 'mae': np.nan, 'aic': np.nan, 'bic': np.nan}
625
+
626
+ def fit_all_models(self) -> None:
627
+ t, bio_m, bio_s = self.data_time, self.data_means[C_BIOMASS], self.data_stds[C_BIOMASS]
628
+ if t is None or bio_m is None or len(bio_m) == 0: return
629
+ popt_bio = self._fit_biomass_model(t, bio_m, bio_s)
630
+ if popt_bio:
631
+ bio_p = list(self.params[C_BIOMASS].values())
632
+ if self.data_means[C_SUBSTRATE] is not None and len(self.data_means[C_SUBSTRATE]) > 0:
633
+ self._fit_substrate_model(t, self.data_means[C_SUBSTRATE], self.data_stds[C_SUBSTRATE], bio_p)
634
+ if self.data_means[C_PRODUCT] is not None and len(self.data_means[C_PRODUCT]) > 0:
635
+ self._fit_product_model(t, self.data_means[C_PRODUCT], self.data_stds[C_PRODUCT], bio_p)
636
+
637
+ def _fit_biomass_model(self, t, data, std):
638
+ p0, bounds = self.model.get_initial_params(t, data), self.model.get_param_bounds(t, data)
639
+ popt, metrics = self._fit_component(self.model.model_function, t, data, p0, bounds, std)
640
+ if popt:
641
+ self.params[C_BIOMASS] = dict(zip(self.model.param_names, popt))
642
+ self.r2[C_BIOMASS], self.rmse[C_BIOMASS], self.mae[C_BIOMASS], self.aic[C_BIOMASS], self.bic[C_BIOMASS] = metrics['r2'], metrics['rmse'], metrics['mae'], metrics['aic'], metrics['bic']
643
+ return popt
644
+
645
+ def _fit_substrate_model(self, t, data, std, bio_p):
646
+ p0, b = [data[0], 0.1, 0.01], ([0, -np.inf, -np.inf], [np.inf, np.inf, np.inf])
647
+ popt, metrics = self._fit_component(lambda t, so, p, q: self.substrate(t, so, p, q, bio_p), t, data, p0, b, std)
648
+ if popt:
649
+ self.params[C_SUBSTRATE] = {'So': popt[0], 'p': popt[1], 'q': popt[2]}
650
+ self.r2[C_SUBSTRATE], self.rmse[C_SUBSTRATE], self.mae[C_SUBSTRATE], self.aic[C_SUBSTRATE], self.bic[C_SUBSTRATE] = metrics['r2'], metrics['rmse'], metrics['mae'], metrics['aic'], metrics['bic']
651
+
652
+ def _fit_product_model(self, t, data, std, bio_p):
653
+ 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])
654
+ popt, metrics = self._fit_component(lambda t, po, a, b: self.product(t, po, a, b, bio_p), t, data, p0, b, std)
655
+ if popt:
656
+ self.params[C_PRODUCT] = {'Po': popt[0], 'alpha': popt[1], 'beta': popt[2]}
657
+ self.r2[C_PRODUCT], self.rmse[C_PRODUCT], self.mae[C_PRODUCT], self.aic[C_PRODUCT], self.bic[C_PRODUCT] = metrics['r2'], metrics['rmse'], metrics['mae'], metrics['aic'], metrics['bic']
658
+
659
+ def system_ode(self, y, t, bio_p, sub_p, prod_p):
660
+ X, _, _ = y
661
+ dXdt = self.model.diff_function(X, t, bio_p)
662
+ 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]
663
+
664
+ def solve_odes(self, t_fine):
665
+ p = self.params
666
+ bio_d, sub_d, prod_d = p[C_BIOMASS], p[C_SUBSTRATE], p[C_PRODUCT]
667
+ if not bio_d: return None, None, None
668
+ try:
669
+ bio_p = list(bio_d.values())
670
+ y0 = [self._get_initial_biomass(bio_p), sub_d.get('So',0), prod_d.get('Po',0)]
671
+ sol = odeint(self.system_ode, y0, t_fine, args=(bio_p, sub_d, prod_d))
672
+ return sol[:, 0], sol[:, 1], sol[:, 2]
673
+ except:
674
+ return None, None, None
675
+
676
+ def _generate_fine_time_grid(self, t_exp):
677
+ return np.linspace(min(t_exp), max(t_exp), 500) if t_exp is not None and len(t_exp) > 1 else np.array([])
678
+
679
+ def get_model_curves_for_plot(self, t_fine, use_diff):
680
+ if use_diff and self.model.diff_function(1, 1, [1]*self.model.num_params) != 0:
681
+ return self.solve_odes(t_fine)
682
+ X, S, P = None, None, None
683
+ if self.params[C_BIOMASS]:
684
+ bio_p = list(self.params[C_BIOMASS].values())
685
+ X = self.model.model_function(t_fine, *bio_p)
686
+ if self.params[C_SUBSTRATE]:
687
+ S = self.substrate(t_fine, *list(self.params[C_SUBSTRATE].values()), bio_p)
688
+ if self.params[C_PRODUCT]:
689
+ P = self.product(t_fine, *list(self.params[C_PRODUCT].values()), bio_p)
690
+ return X, S, P
691
+
692
+ # --- FUNCIONES AUXILIARES ---
693
+ def format_number(value: Any, decimals: int) -> str:
694
+ if not isinstance(value, (int, float, np.number)) or pd.isna(value):
695
+ return "" if pd.isna(value) else str(value)
696
+ decimals = int(decimals)
697
+ if decimals == 0:
698
+ if 0 < abs(value) < 1:
699
+ return f"{value:.2e}"
700
+ else:
701
+ return str(int(round(value, 0)))
702
+ return str(round(value, decimals))
703
+
704
+ # --- FUNCIONES DE PLOTEO MEJORADAS CON PLOTLY ---
705
+ def create_interactive_plot(plot_config: Dict, models_results: List[Dict],
706
+ selected_component: str = "all") -> go.Figure:
707
+ time_exp = plot_config['time_exp']
708
+ time_fine = np.linspace(min(time_exp), max(time_exp), 500)
709
+ if selected_component == "all":
710
+ fig = make_subplots(rows=3, cols=1, subplot_titles=('Biomasa', 'Sustrato', 'Producto'), vertical_spacing=0.08, shared_xaxes=True)
711
+ components_to_plot, rows = [C_BIOMASS, C_SUBSTRATE, C_PRODUCT], [1, 2, 3]
712
+ else:
713
+ fig, components_to_plot, rows = go.Figure(), [selected_component], [None]
714
+ colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
715
+ for comp, row in zip(components_to_plot, rows):
716
+ data_exp, data_std = plot_config.get(f'{comp}_exp'), plot_config.get(f'{comp}_std')
717
+ if data_exp is not None:
718
+ error_y = dict(type='data', array=data_std, visible=True) if data_std is not None and np.any(data_std > 0) else None
719
+ trace = go.Scatter(x=time_exp, y=data_exp, mode='markers', name=f'{comp.capitalize()} (Experimental)', marker=dict(size=10, symbol='circle'), error_y=error_y, legendgroup=comp, showlegend=True)
720
+ if selected_component == "all": fig.add_trace(trace, row=row, col=1)
721
+ else: fig.add_trace(trace)
722
+ for i, res in enumerate(models_results):
723
+ color, model_name = colors[i % len(colors)], AVAILABLE_MODELS[res["name"]].display_name
724
+ for comp, row, key in zip(components_to_plot, rows, ['X', 'S', 'P']):
725
+ if res.get(key) is not None:
726
+ trace = go.Scatter(x=time_fine, y=res[key], mode='lines', name=f'{model_name} - {comp.capitalize()}', line=dict(color=color, width=2), legendgroup=f'{res["name"]}_{comp}', showlegend=True)
727
+ if selected_component == "all": fig.add_trace(trace, row=row, col=1)
728
+ else: fig.add_trace(trace)
729
+ theme, template = plot_config.get('theme', 'light'), "plotly_white" if plot_config.get('theme', 'light') == 'light' else "plotly_dark"
730
+ fig.update_layout(title=f"Análisis de Cinéticas: {plot_config.get('exp_name', '')}", template=template, hovermode='x unified', legend=dict(orientation="v", yanchor="middle", y=0.5, xanchor="left", x=1.02), margin=dict(l=80, r=250, t=100, b=80))
731
+ if selected_component == "all":
732
+ fig.update_xaxes(title_text="Tiempo", row=3, col=1)
733
+ fig.update_yaxes(title_text="Biomasa (g/L)", row=1, col=1)
734
+ fig.update_yaxes(title_text="Sustrato (g/L)", row=2, col=1)
735
+ fig.update_yaxes(title_text="Producto (g/L)", row=3, col=1)
736
+ else:
737
+ fig.update_xaxes(title_text="Tiempo")
738
+ labels = {C_BIOMASS: "Biomasa (g/L)", C_SUBSTRATE: "Sustrato (g/L)", C_PRODUCT: "Producto (g/L)"}
739
+ fig.update_yaxes(title_text=labels.get(selected_component, "Valor"))
740
+ return fig
741
+
742
+ # --- FUNCIÓN PRINCIPAL DE ANÁLISIS ---
743
+ def run_analysis(file, model_names, component, use_de, maxfev, exp_names, theme='light'):
744
+ if not file: return None, pd.DataFrame(), "Error: Sube un archivo Excel."
745
+ if not model_names: return None, pd.DataFrame(), "Error: Selecciona un modelo."
746
  try:
747
+ xls = pd.ExcelFile(file.name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
748
  except Exception as e:
749
+ return None, pd.DataFrame(), f"Error al leer archivo: {e}"
750
+ results_data, msgs, models_results = [], [], []
751
+ exp_list = [n.strip() for n in exp_names.split('\n') if n.strip()] if exp_names else []
752
+ for i, sheet in enumerate(xls.sheet_names):
753
+ exp_name = exp_list[i] if i < len(exp_list) else f"Hoja '{sheet}'"
754
+ try:
755
+ df = pd.read_excel(xls, sheet_name=sheet, header=[0,1])
756
+ reader = BioprocessFitter(list(AVAILABLE_MODELS.values())[0])
757
+ reader.process_data_from_df(df)
758
+ if reader.data_time is None:
759
+ msgs.append(f"WARN: Sin datos de tiempo en '{sheet}'.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
760
  continue
761
+ plot_config = {'exp_name': exp_name, 'time_exp': reader.data_time, 'theme': theme}
762
+ for c in COMPONENTS:
763
+ plot_config[f'{c}_exp'], plot_config[f'{c}_std'] = reader.data_means[c], reader.data_stds[c]
764
+ t_fine = reader._generate_fine_time_grid(reader.data_time)
765
+ for m_name in model_names:
766
+ if m_name not in AVAILABLE_MODELS:
767
+ msgs.append(f"WARN: Modelo '{m_name}' no disponible.")
768
+ continue
769
+ fitter = BioprocessFitter(AVAILABLE_MODELS[m_name], maxfev=int(maxfev), use_differential_evolution=use_de)
770
+ fitter.data_time, fitter.data_means, fitter.data_stds = reader.data_time, reader.data_means, reader.data_stds
771
+ fitter.fit_all_models()
772
+ row = {'Experimento': exp_name, 'Modelo': fitter.model.display_name}
773
+ for c in COMPONENTS:
774
+ if fitter.params[c]:
775
+ row.update({f'{c.capitalize()}_{k}': v for k, v in fitter.params[c].items()})
776
+ row[f'R2_{c.capitalize()}'], row[f'RMSE_{c.capitalize()}'], row[f'MAE_{c.capitalize()}'], row[f'AIC_{c.capitalize()}'], row[f'BIC_{c.capitalize()}'] = fitter.r2.get(c), fitter.rmse.get(c), fitter.mae.get(c), fitter.aic.get(c), fitter.bic.get(c)
777
+ results_data.append(row)
778
+ X, S, P = fitter.get_model_curves_for_plot(t_fine, False)
779
+ models_results.append({'name': m_name, 'X': X, 'S': S, 'P': P, 'params': fitter.params, 'r2': fitter.r2, 'rmse': fitter.rmse})
780
+ except Exception as e:
781
+ msgs.append(f"ERROR en '{sheet}': {e}")
782
+ traceback.print_exc()
783
+ msg = "Análisis completado." + ("\n" + "\n".join(msgs) if msgs else "")
784
+ df_res = pd.DataFrame(results_data).dropna(axis=1, how='all')
785
+ fig = None
786
+ if models_results and reader.data_time is not None:
787
+ fig = create_interactive_plot(plot_config, models_results, component)
788
+ return fig, df_res, msg
789
+
790
+ # --- INTERFAZ GRADIO MEJORADA ---
791
+ def create_gradio_interface() -> gr.Blocks:
792
+ def change_language(lang_key: str) -> Dict:
793
+ lang = Language[lang_key]
794
+ trans = TRANSLATIONS.get(lang, TRANSLATIONS[Language.ES])
795
+ return trans["title"], trans["subtitle"]
796
+
797
+ MODEL_CHOICES = [(model.display_name, model.name) for model in AVAILABLE_MODELS.values()]
798
+ DEFAULT_MODELS = [m.name for m in list(AVAILABLE_MODELS.values())[:4]]
799
+
800
+ with gr.Blocks(theme=THEMES["light"], css="""
801
+ .gradio-container {font-family: 'Inter', sans-serif;}
802
+ .theory-box {background-color: #f0f9ff; padding: 20px; border-radius: 10px; margin: 10px 0;}
803
+ .dark .theory-box {background-color: #1e293b;}
804
+ .model-card {border: 1px solid #e5e7eb; padding: 15px; border-radius: 8px; margin: 10px 0;}
805
+ .dark .model-card {border-color: #374151;}
806
+ """) as demo:
807
+ current_theme = gr.State("light")
808
+ current_language = gr.State("ES")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
809
  with gr.Row():
810
+ with gr.Column(scale=8):
811
+ title_text = gr.Markdown("# 🔬 Analizador de Cinéticas de Bioprocesos")
812
+ subtitle_text = gr.Markdown("Análisis avanzado de modelos matemáticos biotecnológicos")
813
+ with gr.Column(scale=2):
814
+ with gr.Row():
815
+ theme_toggle = gr.Checkbox(label="🌙 Modo Oscuro", value=False)
816
+ language_select = gr.Dropdown(choices=[(lang.value, lang.name) for lang in Language], value="ES", label="🌐 Idioma")
817
+ with gr.Tabs() as tabs:
818
+ with gr.TabItem("📚 Teoría y Modelos"):
819
+ gr.Markdown("## Introducción a los Modelos Cinéticos\nLos modelos cinéticos en biotecnología describen el comportamiento dinámico de los microorganismos.")
820
+ for model_name, model in AVAILABLE_MODELS.items():
821
+ with gr.Accordion(f"📊 {model.display_name}", open=False):
822
+ with gr.Row():
823
+ with gr.Column(scale=3):
824
+ gr.Markdown(f"**Descripción**: {model.description}\n\n**Ecuación**: ${model.equation}$\n\n**Parámetros**: {', '.join(model.param_names)}\n\n**Referencia**: {model.reference}")
825
+ with gr.Column(scale=1):
826
+ gr.Markdown(f"**Características**:\n- Parámetros: {model.num_params}\n- Complejidad: {'⭐' * min(model.num_params, 5)}")
827
+ with gr.TabItem("🔬 Análisis"):
828
+ with gr.Row():
829
+ with gr.Column(scale=1):
830
+ file_input = gr.File(label="📁 Sube tu archivo Excel (.xlsx)", file_types=['.xlsx'])
831
+ exp_names_input = gr.Textbox(label="🏷️ Nombres de Experimentos", placeholder="Experimento 1\nExperimento 2\n...", lines=3)
832
+ model_selection_input = gr.CheckboxGroup(choices=MODEL_CHOICES, label="📊 Modelos a Probar", value=DEFAULT_MODELS)
833
+ with gr.Accordion("⚙️ Opciones Avanzadas", open=False):
834
+ use_de_input = gr.Checkbox(label="Usar Evolución Diferencial", value=False, info="Optimización global más robusta pero más lenta")
835
+ maxfev_input = gr.Number(label="Iteraciones máximas", value=50000)
836
+ with gr.Column(scale=2):
837
+ component_selector = gr.Dropdown(choices=[("Todos los componentes", "all"), ("Solo Biomasa", C_BIOMASS), ("Solo Sustrato", C_SUBSTRATE), ("Solo Producto", C_PRODUCT)], value="all", label="📈 Componente a visualizar")
838
+ plot_output = gr.Plot(label="Visualización Interactiva")
839
+ analyze_button = gr.Button("🚀 Analizar y Graficar", variant="primary")
840
+ with gr.TabItem("📊 Resultados"):
841
+ status_output = gr.Textbox(label="Estado del Análisis", interactive=False)
842
+ results_table = gr.DataFrame(label="Tabla de Resultados", wrap=True)
843
+ with gr.Row():
844
+ download_excel = gr.Button("📥 Descargar Excel")
845
+ download_json = gr.Button("📥 Descargar JSON")
846
+ download_file = gr.File(label="Archivo descargado")
847
+ def run_analysis_wrapper(file, models, component, use_de, maxfev, exp_names, theme):
848
+ try:
849
+ return run_analysis(file, models, component, use_de, maxfev, exp_names, 'dark' if theme else 'light')
850
+ except Exception as e:
851
+ print(f"--- ERROR EN ANÁLISIS ---\n{traceback.format_exc()}")
852
+ return None, pd.DataFrame(), f"Error: {str(e)}"
853
+ analyze_button.click(fn=run_analysis_wrapper, inputs=[file_input, model_selection_input, component_selector, use_de_input, maxfev_input, exp_names_input, theme_toggle], outputs=[plot_output, results_table, status_output])
854
+ language_select.change(fn=change_language, inputs=[language_select], outputs=[title_text, subtitle_text])
855
+ def apply_theme(is_dark):
856
+ return gr.Info("Tema cambiado. Los gráficos nuevos usarán el tema seleccionado.")
857
+ theme_toggle.change(fn=apply_theme, inputs=[theme_toggle], outputs=[])
858
+ def download_results_excel(df):
859
+ if df is None or df.empty:
860
+ gr.Warning("No hay datos para descargar")
861
+ return None
862
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as tmp:
863
+ df.to_excel(tmp.name, index=False)
864
+ return tmp.name
865
+ def download_results_json(df):
866
+ if df is None or df.empty:
867
+ gr.Warning("No hay datos para descargar")
868
+ return None
869
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".json") as tmp:
870
+ df.to_json(tmp.name, orient='records', indent=2)
871
+ return tmp.name
872
+ download_excel.click(fn=download_results_excel, inputs=[results_table], outputs=[download_file])
873
+ download_json.click(fn=download_results_json, inputs=[results_table], outputs=[download_file])
874
  return demo
875
 
876
  # --- PUNTO DE ENTRADA ---
877
+ if __name__ == '__main__':
878
+ gradio_app = create_gradio_interface()
879
+ gradio_app.launch()