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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +777 -382
app.py CHANGED
@@ -17,11 +17,13 @@ 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
@@ -68,7 +70,11 @@ TRANSLATIONS = {
68
  "language": "Idioma",
69
  "theory": "Teoría y Modelos",
70
  "guide": "Guía de Uso",
71
- "api_docs": "Documentación API"
 
 
 
 
72
  },
73
  Language.EN: {
74
  "title": "🔬 Bioprocess Kinetics Analyzer",
@@ -91,7 +97,11 @@ TRANSLATIONS = {
91
  "language": "Language",
92
  "theory": "Theory and Models",
93
  "guide": "User Guide",
94
- "api_docs": "API Documentation"
 
 
 
 
95
  },
96
  }
97
 
@@ -100,9 +110,6 @@ C_TIME = 'tiempo'
100
  C_BIOMASS = 'biomass'
101
  C_SUBSTRATE = 'substrate'
102
  C_PRODUCT = 'product'
103
- C_OXYGEN = 'oxygen'
104
- C_CO2 = 'co2'
105
- C_PH = 'ph'
106
  COMPONENTS = [C_BIOMASS, C_SUBSTRATE, C_PRODUCT]
107
 
108
  # --- SISTEMA DE TEMAS ---
@@ -541,6 +548,7 @@ class BioprocessFitter:
541
  self.data_time: Optional[np.ndarray] = None
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)
@@ -579,20 +587,24 @@ class BioprocessFitter:
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([])
585
  reps = [df[c].dropna().values[:min_len] for c in cols]
586
  reps = [r for r in reps if len(r) == min_len]
587
- if not reps: return np.array([]), np.array([])
588
  arr = np.array(reps)
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
 
@@ -745,6 +757,90 @@ class BioprocessFitter:
745
  if self.params[C_PRODUCT]:
746
  P = self.product(t_fine, *list(self.params[C_PRODUCT].values()), bio_p)
747
  return X, S, P
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
748
 
749
  # --- FUNCIONES AUXILIARES ---
750
 
@@ -763,235 +859,456 @@ def format_number(value: Any, decimals: int) -> str:
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(
777
- rows=3, cols=1,
778
- subplot_titles=('Biomasa', 'Sustrato', 'Producto'),
779
- vertical_spacing=0.08,
780
- shared_xaxes=True
781
- )
782
- components_to_plot = [C_BIOMASS, C_SUBSTRATE, C_PRODUCT]
783
- rows = [1, 2, 3]
784
- else:
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,
808
- mode='markers',
809
- name=f'{comp.capitalize()} (Experimental)',
810
- marker=dict(size=10, symbol='circle'),
811
- error_y=error_y,
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(
829
- x=time_fine,
830
- y=res[key],
831
- mode='lines',
832
- name=f'{model_name} - {comp.capitalize()}',
833
- line=dict(color=color, width=2),
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,
850
- hovermode='x unified',
851
- legend=dict(
852
- orientation="v",
853
- yanchor="middle",
854
- y=0.5,
855
- xanchor="left",
856
- x=1.02
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)
864
- fig.update_yaxes(title_text="Biomasa (g/L)", row=1, col=1)
865
- fig.update_yaxes(title_text="Sustrato (g/L)", row=2, col=1)
866
- fig.update_yaxes(title_text="Producto (g/L)", row=3, col=1)
867
- else:
868
- fig.update_xaxes(title_text="Tiempo")
869
- labels = {
870
- C_BIOMASS: "Biomasa (g/L)",
871
- C_SUBSTRATE: "Sustrato (g/L)",
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
- )
954
  fitter.data_time = reader.data_time
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
 
@@ -1083,16 +1400,15 @@ async def predict_kinetics(
1083
  except Exception as e:
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
@@ -1126,196 +1442,304 @@ def create_gradio_interface() -> gr.Blocks:
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):
1147
- with gr.Row():
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):
1159
- gr.Markdown(f"""
1160
- **Características**:
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():
1168
  with gr.Column(scale=1):
1169
- file_input = gr.File(
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(
1201
- choices=[
1202
- ("Todos los componentes", "all"),
1203
- ("Solo Biomasa", C_BIOMASS),
1204
- ("Solo Sustrato", C_SUBSTRATE),
1205
- ("Solo Producto", C_PRODUCT)
1206
- ],
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": {
1259
- "time": [0, 1, 2, 3, 4],
1260
- "biomass": [0.1, 0.3, 0.8, 1.5, 2.0],
1261
- "substrate": [10, 8, 5, 2, 0.5]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1262
  },
1263
- "models": ["logistic", "gompertz"],
1264
- "options": {"maxfev": 50000}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1265
  }
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",
1276
- "parameters": {"X0": 0.1, "Xm": 10.0, "μm": 0.5},
1277
- "time_points": [0, 1, 2, 3, 4, 5]
1278
- }
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=[
1310
- file_input,
1311
- model_selection_input,
1312
- component_selector,
1313
- use_de_input,
1314
- maxfev_input,
1315
- exp_names_input,
1316
- theme_toggle
1317
- ],
1318
- outputs=[plot_output, results_table, status_output]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1319
  )
1320
 
1321
  # Cambio de idioma
@@ -1327,46 +1751,17 @@ def create_gradio_interface() -> gr.Blocks:
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:
1341
- gr.Warning("No hay datos para descargar")
1342
- return None
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")
1350
- return None
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
 
17
  from dataclasses import dataclass
18
  from enum import Enum
19
  import json
20
+ import base64
21
 
22
  from PIL import Image
23
  import gradio as gr
24
  import plotly.graph_objects as go
25
  from plotly.subplots import make_subplots
26
+ import plotly.io as pio
27
  import numpy as np
28
  import pandas as pd
29
  import matplotlib.pyplot as plt
 
70
  "language": "Idioma",
71
  "theory": "Teoría y Modelos",
72
  "guide": "Guía de Uso",
73
+ "api_docs": "Documentación API",
74
+ "individual": "Individual",
75
+ "average": "Promedio",
76
+ "combined": "Combinado",
77
+ "config": "Configuración"
78
  },
79
  Language.EN: {
80
  "title": "🔬 Bioprocess Kinetics Analyzer",
 
97
  "language": "Language",
98
  "theory": "Theory and Models",
99
  "guide": "User Guide",
100
+ "api_docs": "API Documentation",
101
+ "individual": "Individual",
102
+ "average": "Average",
103
+ "combined": "Combined",
104
+ "config": "Configuration"
105
  },
106
  }
107
 
 
110
  C_BIOMASS = 'biomass'
111
  C_SUBSTRATE = 'substrate'
112
  C_PRODUCT = 'product'
 
 
 
113
  COMPONENTS = [C_BIOMASS, C_SUBSTRATE, C_PRODUCT]
114
 
115
  # --- SISTEMA DE TEMAS ---
 
548
  self.data_time: Optional[np.ndarray] = None
549
  self.data_means: Dict[str, Optional[np.ndarray]] = {c: None for c in COMPONENTS}
550
  self.data_stds: Dict[str, Optional[np.ndarray]] = {c: None for c in COMPONENTS}
551
+ self.raw_data: Dict[str, List[np.ndarray]] = {c: [] for c in COMPONENTS} # Para análisis individual
552
 
553
  def _get_biomass_at_t(self, t: np.ndarray, p: List[float]) -> np.ndarray:
554
  return self.model.model_function(t, *p)
 
587
  self.data_time = df[time_col].dropna().to_numpy()
588
  min_len = len(self.data_time)
589
 
590
+ def extract(name: str) -> Tuple[np.ndarray, np.ndarray, List[np.ndarray]]:
591
  cols = [c for c in df.columns if c[1].strip().lower() == name.lower()]
592
+ if not cols: return np.array([]), np.array([]), []
593
  reps = [df[c].dropna().values[:min_len] for c in cols]
594
  reps = [r for r in reps if len(r) == min_len]
595
+ if not reps: return np.array([]), np.array([]), []
596
  arr = np.array(reps)
597
  mean = np.mean(arr, axis=0)
598
  std = np.std(arr, axis=0, ddof=1) if arr.shape[0] > 1 else np.zeros_like(mean)
599
+ return mean, std, reps
600
 
601
+ # Extraer datos con réplicas individuales
602
+ for comp, name in [(C_BIOMASS, 'Biomasa'), (C_SUBSTRATE, 'Sustrato'), (C_PRODUCT, 'Producto')]:
603
+ mean, std, reps = extract(name)
604
+ self.data_means[comp] = mean
605
+ self.data_stds[comp] = std
606
+ self.raw_data[comp] = reps
607
+
608
  except (IndexError, KeyError) as e:
609
  raise ValueError(f"Estructura de DataFrame inválida. Error: {e}")
610
 
 
757
  if self.params[C_PRODUCT]:
758
  P = self.product(t_fine, *list(self.params[C_PRODUCT].values()), bio_p)
759
  return X, S, P
760
+
761
+ def plot_individual_or_combined(self, cfg, mode):
762
+ """Crea gráficos individuales o combinados con Matplotlib/Seaborn"""
763
+ t_exp, t_fine = cfg['time_exp'], self._generate_fine_time_grid(cfg['time_exp'])
764
+ X_m, S_m, P_m = self.get_model_curves_for_plot(t_fine, cfg.get('use_differential', False))
765
+
766
+ sns.set_style(cfg.get('style', 'whitegrid'))
767
+
768
+ if mode == 'average':
769
+ fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(10, 15), sharex=True)
770
+ fig.suptitle(f"Análisis: {cfg.get('exp_name', '')} ({self.model.display_name})", fontsize=16)
771
+ axes = [ax1, ax2, ax3]
772
+ else:
773
+ fig, ax1 = plt.subplots(figsize=(12, 8))
774
+ fig.suptitle(f"Análisis: {cfg.get('exp_name', '')} ({self.model.display_name})", fontsize=16)
775
+ ax2 = ax1.twinx()
776
+ ax3 = ax1.twinx()
777
+ ax3.spines["right"].set_position(("axes", 1.18))
778
+ axes = [ax1, ax2, ax3]
779
+
780
+ data_map = {C_BIOMASS: X_m, C_SUBSTRATE: S_m, C_PRODUCT: P_m}
781
+ comb_styles = {
782
+ C_BIOMASS: {'c': '#0072B2', 'mc': '#56B4E9', 'm': 'o', 'ls': '-'},
783
+ C_SUBSTRATE: {'c': '#009E73', 'mc': '#34E499', 'm': 's', 'ls': '--'},
784
+ C_PRODUCT: {'c': '#D55E00', 'mc': '#F0E442', 'm': '^', 'ls': '-.'}
785
+ }
786
+
787
+ for ax, comp in zip(axes, COMPONENTS):
788
+ ylabel = cfg.get('axis_labels', {}).get(f'{comp}_label', comp.capitalize())
789
+ data = cfg.get(f'{comp}_exp')
790
+ std = cfg.get(f'{comp}_std')
791
+ model_data = data_map.get(comp)
792
+
793
+ if mode == 'combined':
794
+ s = comb_styles[comp]
795
+ pc, lc, ms, ls = s['c'], s['mc'], s['m'], s['ls']
796
+ else:
797
+ pc = cfg.get(f'{comp}_point_color')
798
+ lc = cfg.get(f'{comp}_line_color')
799
+ ms = cfg.get(f'{comp}_marker_style')
800
+ ls = cfg.get(f'{comp}_line_style')
801
+
802
+ ax_c = pc if mode == 'combined' else 'black'
803
+ ax.set_ylabel(ylabel, color=ax_c)
804
+ ax.tick_params(axis='y', labelcolor=ax_c)
805
+
806
+ if data is not None and len(data) > 0:
807
+ if cfg.get('show_error_bars') and std is not None and np.any(std > 0):
808
+ ax.errorbar(t_exp, data, yerr=std, fmt=ms, color=pc,
809
+ label=f'{comp.capitalize()} (Datos)',
810
+ capsize=cfg.get('error_cap_size', 3),
811
+ elinewidth=cfg.get('error_line_width', 1))
812
+ else:
813
+ ax.plot(t_exp, data, ls='', marker=ms, color=pc,
814
+ label=f'{comp.capitalize()} (Datos)')
815
+
816
+ if model_data is not None and len(model_data) > 0:
817
+ ax.plot(t_fine, model_data, ls=ls, color=lc,
818
+ label=f'{comp.capitalize()} (Modelo)')
819
+
820
+ if mode == 'average' and cfg.get('show_legend', True):
821
+ ax.legend(loc=cfg.get('legend_pos', 'best'))
822
+
823
+ if mode == 'average' and cfg.get('show_params', True) and self.params[comp]:
824
+ decs = cfg.get('decimal_places', 3)
825
+ p_txt = '\n'.join([f"{k}={format_number(v, decs)}" for k, v in self.params[comp].items()])
826
+ full_txt = f"{p_txt}\nR²={format_number(self.r2.get(comp, 0), 3)}, RMSE={format_number(self.rmse.get(comp, 0), 3)}"
827
+ pos_x, ha = (0.95, 'right') if 'right' in cfg.get('params_pos', 'upper right') else (0.05, 'left')
828
+ ax.text(pos_x, 0.95, full_txt, transform=ax.transAxes, va='top', ha=ha,
829
+ bbox=dict(boxstyle='round,pad=0.4', fc='wheat', alpha=0.7))
830
+
831
+ if mode == 'combined' and cfg.get('show_legend', True):
832
+ h1, l1 = axes[0].get_legend_handles_labels()
833
+ h2, l2 = axes[1].get_legend_handles_labels()
834
+ h3, l3 = axes[2].get_legend_handles_labels()
835
+ axes[0].legend(handles=h1+h2+h3, labels=l1+l2+l3, loc=cfg.get('legend_pos', 'best'))
836
+
837
+ axes[-1].set_xlabel(cfg.get('axis_labels', {}).get('x_label', 'Tiempo'))
838
+ plt.tight_layout()
839
+
840
+ if mode == 'combined':
841
+ fig.subplots_adjust(right=0.8)
842
+
843
+ return fig
844
 
845
  # --- FUNCIONES AUXILIARES ---
846
 
 
859
 
860
  return str(round(value, decimals))
861
 
862
+ # --- FUNCIONES DE PLOTEO MEJORADAS ---
863
 
864
+ def plot_model_comparison_matplotlib(plot_config: Dict, models_results: List[Dict]) -> plt.Figure:
865
+ """Crea un gráfico de comparación de modelos estático usando Matplotlib/Seaborn"""
 
866
  time_exp = plot_config['time_exp']
867
+ time_fine = BioprocessFitter(list(AVAILABLE_MODELS.values())[0])._generate_fine_time_grid(time_exp)
868
+ num_models = len(models_results)
869
+
870
+ palettes = {
871
+ C_BIOMASS: sns.color_palette("Blues", num_models),
872
+ C_SUBSTRATE: sns.color_palette("Greens", num_models),
873
+ C_PRODUCT: sns.color_palette("Reds", num_models)
874
+ }
875
+ line_styles = ['-', '--', '-.', ':']
876
+
877
+ sns.set_style(plot_config.get('style', 'whitegrid'))
878
+ fig, ax1 = plt.subplots(figsize=(12, 8))
879
+
880
+ # Configuración de los 3 ejes Y
881
+ ax1.set_xlabel(plot_config['axis_labels']['x_label'])
882
+ ax1.set_ylabel(plot_config['axis_labels']['biomass_label'], color="navy", fontsize=12)
883
+ ax1.tick_params(axis='y', labelcolor="navy")
884
+ ax2 = ax1.twinx()
885
+ ax3 = ax1.twinx()
886
+ ax3.spines["right"].set_position(("axes", 1.22))
887
+ ax2.set_ylabel(plot_config['axis_labels']['substrate_label'], color="darkgreen", fontsize=12)
888
+ ax2.tick_params(axis='y', labelcolor="darkgreen")
889
+ ax3.set_ylabel(plot_config['axis_labels']['product_label'], color="darkred", fontsize=12)
890
+ ax3.tick_params(axis='y', labelcolor="darkred")
891
+
892
+ # Dibujar datos experimentales
893
+ data_markers = {C_BIOMASS: 'o', C_SUBSTRATE: 's', C_PRODUCT: '^'}
894
+ for ax, key, color, face in [(ax1, C_BIOMASS, 'navy', 'skyblue'),
895
+ (ax2, C_SUBSTRATE, 'darkgreen', 'lightgreen'),
896
+ (ax3, C_PRODUCT, 'darkred', 'lightcoral')]:
897
+ data_exp = plot_config.get(f'{key}_exp')
898
+ data_std = plot_config.get(f'{key}_std')
899
  if data_exp is not None:
900
+ if plot_config.get('show_error_bars') and data_std is not None and np.any(data_std > 0):
901
+ ax.errorbar(time_exp, data_exp, yerr=data_std, fmt=data_markers[key],
902
+ color=color, label=f'{key.capitalize()} (Datos)', zorder=10,
903
+ markersize=8, markerfacecolor=face, markeredgecolor=color,
904
+ capsize=plot_config.get('error_cap_size', 3),
905
+ elinewidth=plot_config.get('error_line_width', 1))
 
 
 
 
 
 
 
 
 
 
 
 
 
906
  else:
907
+ ax.plot(time_exp, data_exp, ls='', marker=data_markers[key],
908
+ label=f'{key.capitalize()} (Datos)', zorder=10, ms=8,
909
+ mfc=face, mec=color, mew=1.5)
910
+
911
+ # Dibujar curvas de los modelos
912
  for i, res in enumerate(models_results):
913
+ ls = line_styles[i % len(line_styles)]
914
+ model_info = AVAILABLE_MODELS.get(res["name"], MagicMock(display_name=res["name"]))
915
+ model_display_name = model_info.display_name
916
+ for key_short, ax, name_long in [('X', ax1, C_BIOMASS), ('S', ax2, C_SUBSTRATE), ('P', ax3, C_PRODUCT)]:
917
+ if res.get(key_short) is not None:
918
+ ax.plot(time_fine, res[key_short], color=palettes[name_long][i], ls=ls,
919
+ label=f'{name_long.capitalize()} ({model_display_name})', alpha=0.9)
920
+
921
+ fig.subplots_adjust(left=0.3, right=0.78, top=0.92,
922
+ bottom=0.35 if plot_config.get('show_params') else 0.1)
923
+
924
+ if plot_config.get('show_legend'):
925
+ h1, l1 = ax1.get_legend_handles_labels()
926
+ h2, l2 = ax2.get_legend_handles_labels()
927
+ h3, l3 = ax3.get_legend_handles_labels()
928
+ fig.legend(h1 + h2 + h3, l1 + l2 + l3, loc='center left',
929
+ bbox_to_anchor=(0.0, 0.5), fancybox=True, shadow=True, fontsize='small')
930
+
931
+ if plot_config.get('show_params'):
932
+ total_width = 0.95
933
+ box_width = total_width / num_models
934
+ start_pos = (1.0 - total_width) / 2
935
+ for i, res in enumerate(models_results):
936
+ model_info = AVAILABLE_MODELS.get(res["name"], MagicMock(display_name=res["name"]))
937
+ text = f"**{model_info.display_name}**\n" + _generate_model_param_text(res, plot_config.get('decimal_places', 3))
938
+ fig.text(start_pos + i * box_width, 0.01, text, transform=fig.transFigure,
939
+ fontsize=7.5, va='bottom', ha='left',
940
+ bbox=dict(boxstyle='round,pad=0.4', fc='ivory', ec='gray', alpha=0.9))
941
+
942
+ fig.suptitle(f"Comparación de Modelos: {plot_config.get('exp_name', '')}", fontsize=16)
943
+ return fig
944
+
945
+ def plot_model_comparison_plotly(plot_config: Dict, models_results: List[Dict]) -> go.Figure:
946
+ """Crea un gráfico de comparación de modelos interactivo usando Plotly"""
947
+ fig = go.Figure()
948
+ time_exp = plot_config['time_exp']
949
+ time_fine = BioprocessFitter(list(AVAILABLE_MODELS.values())[0])._generate_fine_time_grid(time_exp)
950
+ num_models = len(models_results)
951
+
952
+ palettes = {
953
+ C_BIOMASS: sns.color_palette("Blues", n_colors=num_models).as_hex(),
954
+ C_SUBSTRATE: sns.color_palette("Greens", n_colors=num_models).as_hex(),
955
+ C_PRODUCT: sns.color_palette("Reds", n_colors=num_models).as_hex()
956
+ }
957
+ line_styles = ['solid', 'dash', 'dot', 'dashdot']
958
+ data_markers = {C_BIOMASS: 'circle-open', C_SUBSTRATE: 'square-open', C_PRODUCT: 'diamond-open'}
959
+
960
+ for key, y_axis, color in [(C_BIOMASS, 'y1', 'navy'),
961
+ (C_SUBSTRATE, 'y2', 'darkgreen'),
962
+ (C_PRODUCT, 'y3', 'darkred')]:
963
+ data_exp = plot_config.get(f'{key}_exp')
964
+ data_std = plot_config.get(f'{key}_std')
965
+ if data_exp is not None:
966
+ error_y_config = dict(type='data', array=data_std, visible=True) if plot_config.get('show_error_bars') and data_std is not None and np.any(data_std > 0) else None
967
+ fig.add_trace(go.Scatter(
968
+ x=time_exp, y=data_exp, mode='markers',
969
+ name=f'{key.capitalize()} (Datos)',
970
+ marker=dict(color=color, size=10, symbol=data_markers[key], line=dict(width=2)),
971
+ error_y=error_y_config, yaxis=y_axis, legendgroup="data"))
972
+
973
+ for i, res in enumerate(models_results):
974
+ ls = line_styles[i % len(line_styles)]
975
+ model_display_name = AVAILABLE_MODELS.get(res["name"], MagicMock(display_name=res["name"])).display_name
976
+ if res.get('X') is not None:
977
+ fig.add_trace(go.Scatter(x=time_fine, y=res['X'], mode='lines',
978
+ name=f'Biomasa ({model_display_name})',
979
+ line=dict(color=palettes[C_BIOMASS][i], dash=ls),
980
+ legendgroup=res["name"]))
981
+ if res.get('S') is not None:
982
+ fig.add_trace(go.Scatter(x=time_fine, y=res['S'], mode='lines',
983
+ name=f'Sustrato ({model_display_name})',
984
+ line=dict(color=palettes[C_SUBSTRATE][i], dash=ls),
985
+ yaxis='y2', legendgroup=res["name"]))
986
+ if res.get('P') is not None:
987
+ fig.add_trace(go.Scatter(x=time_fine, y=res['P'], mode='lines',
988
+ name=f'Producto ({model_display_name})',
989
+ line=dict(color=palettes[C_PRODUCT][i], dash=ls),
990
+ yaxis='y3', legendgroup=res["name"]))
991
+
992
+ if plot_config.get('show_params'):
993
+ x_positions = np.linspace(0, 1, num_models * 2 + 1)[1::2]
994
+ for i, res in enumerate(models_results):
995
+ model_display_name = AVAILABLE_MODELS.get(res["name"], MagicMock(display_name=res["name"])).display_name
996
+ text = f"<b>{model_display_name}</b><br>" + _generate_model_param_text(res, plot_config.get('decimal_places', 3)).replace('\n', '<br>')
997
+ fig.add_annotation(text=text, align='left', showarrow=False, xref='paper',
998
+ yref='paper', x=x_positions[i], y=-0.35, bordercolor='gray',
999
+ borderwidth=1, bgcolor='ivory', opacity=0.9)
1000
+
1001
  fig.update_layout(
1002
+ title=f"Comparación de Modelos (Interactivo): {plot_config.get('exp_name', '')}",
1003
+ xaxis=dict(domain=[0.18, 0.82]),
1004
+ yaxis=dict(title=plot_config['axis_labels']['biomass_label'], titlefont=dict(color='navy'),
1005
+ tickfont=dict(color='navy')),
1006
+ yaxis2=dict(title=plot_config['axis_labels']['substrate_label'], titlefont=dict(color='darkgreen'),
1007
+ tickfont=dict(color='darkgreen'), overlaying='y', side='right'),
1008
+ yaxis3=dict(title=plot_config['axis_labels']['product_label'], titlefont=dict(color='darkred'),
1009
+ tickfont=dict(color='darkred'), overlaying='y', side='right', position=0.85),
1010
+ legend=dict(traceorder="grouped", yanchor="middle", y=0.5, xanchor="right", x=-0.15),
1011
+ margin=dict(l=200, r=150, b=250 if plot_config.get('show_params') else 80, t=80),
1012
+ template="plotly_white" if plot_config.get('theme', 'light') == 'light' else "plotly_dark",
1013
+ showlegend=plot_config.get('show_legend', True)
 
 
 
 
 
 
 
 
 
 
 
 
1014
  )
 
1015
  return fig
1016
 
1017
+ def _generate_model_param_text(result: Dict, decimals: int) -> str:
1018
+ """Genera el texto formateado de los parámetros para las cajas de anotación"""
1019
+ text = ""
1020
+ for comp in COMPONENTS:
1021
+ if params := result.get('params', {}).get(comp):
1022
+ p_str = ', '.join([f"{k}={format_number(v, decimals)}" for k, v in params.items()])
1023
+ r2 = result.get('r2', {}).get(comp, 0)
1024
+ rmse = result.get('rmse', {}).get(comp, 0)
1025
+ text += f"{comp[:4].capitalize()}: {p_str}\n(R²={format_number(r2, 3)}, RMSE={format_number(rmse, 3)})\n"
1026
+ return text.strip()
1027
+
1028
+ # --- FUNCIONES DE DESCARGA Y REPORTES ---
1029
+
1030
+ def create_zip_file(image_list: List[Any]) -> Optional[str]:
1031
+ """Crea un archivo ZIP con todas las imágenes"""
1032
+ if not image_list:
1033
+ gr.Warning("No hay gráficos para descargar.")
1034
+ return None
1035
+ try:
1036
+ zip_buffer = io.BytesIO()
1037
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
1038
+ for i, fig in enumerate(image_list):
1039
+ buf = io.BytesIO()
1040
+ if isinstance(fig, go.Figure):
1041
+ buf.write(fig.to_image(format="png", scale=2, engine="kaleido"))
1042
+ elif isinstance(fig, plt.Figure):
1043
+ fig.savefig(buf, format='png', dpi=200, bbox_inches='tight')
1044
+ plt.close(fig)
1045
+ elif isinstance(fig, Image.Image):
1046
+ fig.save(buf, 'PNG')
1047
+ else:
1048
+ continue
1049
+ buf.seek(0)
1050
+ zf.writestr(f"grafico_{i+1}.png", buf.read())
1051
+
1052
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp:
1053
+ tmp.write(zip_buffer.getvalue())
1054
+ return tmp.name
1055
+ except Exception as e:
1056
+ traceback.print_exc()
1057
+ gr.Error(f"Error al crear el archivo ZIP: {e}")
1058
+ return None
1059
+
1060
+ def create_word_report(image_list: List[Any], table_df: pd.DataFrame, decimals: int) -> Optional[str]:
1061
+ """Crea un reporte en Word con imágenes y tablas"""
1062
+ if not image_list and (table_df is None or table_df.empty):
1063
+ gr.Warning("No hay datos ni gráficos para crear el reporte.")
1064
+ return None
1065
+ try:
1066
+ doc = Document()
1067
+ doc.add_heading('Reporte de Análisis de Cinéticas', 0)
1068
+
1069
+ # Resumen ejecutivo
1070
+ doc.add_heading('Resumen Ejecutivo', level=1)
1071
+ doc.add_paragraph(f'Fecha del análisis: {pd.Timestamp.now().strftime("%Y-%m-%d %H:%M")}')
1072
+ doc.add_paragraph(f'Total de experimentos analizados: {len(table_df["Experimento"].unique()) if table_df is not None and not table_df.empty else 0}')
1073
+ doc.add_paragraph(f'Modelos utilizados: {", ".join(table_df["Modelo"].unique()) if table_df is not None and not table_df.empty else "N/A"}')
1074
+
1075
+ if table_df is not None and not table_df.empty:
1076
+ doc.add_heading('Tabla de Resultados', level=1)
1077
+ table = doc.add_table(rows=1, cols=len(table_df.columns), style='Table Grid')
1078
+ for i, col in enumerate(table_df.columns):
1079
+ table.cell(0, i).text = str(col)
1080
+ for _, row in table_df.iterrows():
1081
+ cells = table.add_row().cells
1082
+ for i, val in enumerate(row):
1083
+ cells[i].text = str(format_number(val, decimals))
1084
+
1085
+ if image_list:
1086
+ doc.add_page_break()
1087
+ doc.add_heading('Gráficos Generados', level=1)
1088
+ for i, fig in enumerate(image_list):
1089
+ buf = io.BytesIO()
1090
+ if isinstance(fig, go.Figure):
1091
+ buf.write(fig.to_image(format="png", scale=2, engine="kaleido"))
1092
+ elif isinstance(fig, plt.Figure):
1093
+ fig.savefig(buf, format='png', dpi=200, bbox_inches='tight')
1094
+ plt.close(fig)
1095
+ elif isinstance(fig, Image.Image):
1096
+ fig.save(buf, 'PNG')
1097
+ else:
1098
+ continue
1099
+ buf.seek(0)
1100
+ doc.add_paragraph(f'Gráfico {i+1}', style='Heading 3')
1101
+ doc.add_picture(buf, width=Inches(6.0))
1102
+ doc.add_paragraph('') # Espacio entre imágenes
1103
+
1104
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".docx") as tmp:
1105
+ doc.save(tmp.name)
1106
+ return tmp.name
1107
+ except Exception as e:
1108
+ traceback.print_exc()
1109
+ gr.Error(f"Error al crear el reporte de Word: {e}")
1110
+ return None
1111
+
1112
+ def create_pdf_report(image_list: List[Any], table_df: pd.DataFrame, decimals: int) -> Optional[str]:
1113
+ """Crea un reporte en PDF con imágenes y tablas"""
1114
+ if not image_list and (table_df is None or table_df.empty):
1115
+ gr.Warning("No hay datos ni gráficos para crear el reporte.")
1116
+ return None
1117
+ try:
1118
+ pdf = FPDF()
1119
+ pdf.set_auto_page_break(auto=True, margin=15)
1120
+ pdf.add_page()
1121
+ pdf.set_font("Helvetica", 'B', 16)
1122
+ pdf.cell(0, 10, 'Reporte de Análisis de Cinéticas', new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='C')
1123
+
1124
+ # Resumen ejecutivo
1125
+ pdf.ln(10)
1126
+ pdf.set_font("Helvetica", '', 10)
1127
+ pdf.cell(0, 10, f'Fecha del análisis: {pd.Timestamp.now().strftime("%Y-%m-%d %H:%M")}',
1128
+ new_x=XPos.LMARGIN, new_y=YPos.NEXT)
1129
+
1130
+ if table_df is not None and not table_df.empty:
1131
+ pdf.ln(10)
1132
+ pdf.set_font("Helvetica", 'B', 12)
1133
+ pdf.cell(0, 10, 'Tabla de Resultados', new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
1134
+ pdf.set_font("Helvetica", 'B', 8)
1135
+
1136
+ effective_page_width = pdf.w - 2 * pdf.l_margin
1137
+ num_cols = len(table_df.columns)
1138
+ col_width = effective_page_width / num_cols if num_cols > 0 else 0
1139
+
1140
+ if num_cols > 15:
1141
+ pdf.set_font_size(6)
1142
+ elif num_cols > 10:
1143
+ pdf.set_font_size(7)
1144
+
1145
+ for col in table_df.columns:
1146
+ pdf.cell(col_width, 10, str(col), border=1, align='C')
1147
+ pdf.ln()
1148
+
1149
+ pdf.set_font("Helvetica", '', 7)
1150
+ if num_cols > 15:
1151
+ pdf.set_font_size(5)
1152
+ elif num_cols > 10:
1153
+ pdf.set_font_size(6)
1154
+
1155
+ for _, row in table_df.iterrows():
1156
+ for val in row:
1157
+ pdf.cell(col_width, 10, str(format_number(val, decimals)), border=1, align='R')
1158
+ pdf.ln()
1159
+
1160
+ if image_list:
1161
+ for i, fig in enumerate(image_list):
1162
+ pdf.add_page()
1163
+ pdf.set_font("Helvetica", 'B', 12)
1164
+ pdf.cell(0, 10, f'Gráfico {i+1}', new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
1165
+ pdf.ln(5)
1166
+
1167
+ buf = io.BytesIO()
1168
+ if isinstance(fig, go.Figure):
1169
+ buf.write(fig.to_image(format="png", scale=2, engine="kaleido"))
1170
+ elif isinstance(fig, plt.Figure):
1171
+ fig.savefig(buf, format='png', dpi=200, bbox_inches='tight')
1172
+ plt.close(fig)
1173
+ elif isinstance(fig, Image.Image):
1174
+ fig.save(buf, 'PNG')
1175
+ else:
1176
+ continue
1177
+
1178
+ buf.seek(0)
1179
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp_img:
1180
+ tmp_img.write(buf.read())
1181
+ pdf.image(tmp_img.name, x=None, y=None, w=pdf.w - 20)
1182
+ os.remove(tmp_img.name)
1183
+
1184
+ pdf_bytes = pdf.output()
1185
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
1186
+ tmp.write(pdf_bytes)
1187
+ return tmp.name
1188
+ except Exception as e:
1189
+ traceback.print_exc()
1190
+ gr.Error(f"Error al crear el reporte PDF: {e}")
1191
+ return None
1192
+
1193
  # --- FUNCIÓN PRINCIPAL DE ANÁLISIS ---
1194
+
1195
+ def run_analysis(file, model_names, mode, engine, exp_names, settings):
1196
+ """Ejecuta el análisis completo con todos los modos"""
1197
+ if not file:
1198
+ return [], pd.DataFrame(), "Error: Sube un archivo Excel.", pd.DataFrame()
1199
+ if not model_names:
1200
+ return [], pd.DataFrame(), "Error: Selecciona un modelo.", pd.DataFrame()
1201
 
1202
+ try:
1203
  xls = pd.ExcelFile(file.name)
1204
+ except Exception as e:
1205
+ return [], pd.DataFrame(), f"Error al leer archivo: {e}", pd.DataFrame()
1206
 
1207
+ figs = []
1208
+ results_data = []
1209
+ msgs = []
1210
 
1211
+ exp_list = [n.strip() for n in exp_names.split('\n') if n.strip()]
1212
 
1213
  for i, sheet in enumerate(xls.sheet_names):
1214
  exp_name = exp_list[i] if i < len(exp_list) else f"Hoja '{sheet}'"
1215
+
1216
  try:
1217
  df = pd.read_excel(xls, sheet_name=sheet, header=[0,1])
1218
  reader = BioprocessFitter(list(AVAILABLE_MODELS.values())[0])
1219
  reader.process_data_from_df(df)
1220
 
1221
+ if reader.data_time is None:
1222
  msgs.append(f"WARN: Sin datos de tiempo en '{sheet}'.")
1223
  continue
1224
+
1225
+ cfg = settings.copy()
1226
+ cfg.update({'exp_name': exp_name, 'time_exp': reader.data_time})
1227
 
1228
+ for c in COMPONENTS:
1229
+ cfg[f'{c}_exp'] = reader.data_means[c]
1230
+ cfg[f'{c}_std'] = reader.data_stds[c]
 
 
 
 
 
 
1231
 
1232
  t_fine = reader._generate_fine_time_grid(reader.data_time)
1233
+ plot_results = []
1234
 
1235
  for m_name in model_names:
1236
+ if m_name not in AVAILABLE_MODELS:
1237
  msgs.append(f"WARN: Modelo '{m_name}' no disponible.")
1238
  continue
1239
 
1240
+ fitter = BioprocessFitter(AVAILABLE_MODELS[m_name], maxfev=int(settings.get('maxfev', 50000)))
 
 
 
 
1241
  fitter.data_time = reader.data_time
1242
  fitter.data_means = reader.data_means
1243
  fitter.data_stds = reader.data_stds
1244
+ fitter.raw_data = reader.raw_data
1245
  fitter.fit_all_models()
1246
 
1247
+ # Guardar resultados numéricos
1248
  row = {'Experimento': exp_name, 'Modelo': fitter.model.display_name}
1249
  for c in COMPONENTS:
1250
+ if fitter.params[c]:
1251
  row.update({f'{c.capitalize()}_{k}': v for k, v in fitter.params[c].items()})
1252
  row[f'R2_{c.capitalize()}'] = fitter.r2.get(c)
1253
  row[f'RMSE_{c.capitalize()}'] = fitter.rmse.get(c)
1254
  row[f'MAE_{c.capitalize()}'] = fitter.mae.get(c)
1255
  row[f'AIC_{c.capitalize()}'] = fitter.aic.get(c)
1256
  row[f'BIC_{c.capitalize()}'] = fitter.bic.get(c)
 
1257
  results_data.append(row)
1258
 
1259
+ # Generar gráficos según el modo
1260
+ if mode in ["average", "combined"]:
1261
+ if hasattr(fitter, 'plot_individual_or_combined'):
1262
+ figs.append(fitter.plot_individual_or_combined(cfg, mode))
1263
+ elif mode == "individual":
1264
+ # Crear gráficos para cada réplica
1265
+ for rep_idx, rep_data in enumerate(fitter.raw_data[C_BIOMASS]):
1266
+ cfg_rep = cfg.copy()
1267
+ cfg_rep['exp_name'] = f"{exp_name} - Réplica {rep_idx + 1}"
1268
+ for c in COMPONENTS:
1269
+ if len(fitter.raw_data[c]) > rep_idx:
1270
+ cfg_rep[f'{c}_exp'] = fitter.raw_data[c][rep_idx]
1271
+ cfg_rep[f'{c}_std'] = None # No hay std para réplicas individuales
1272
+ figs.append(fitter.plot_individual_or_combined(cfg_rep, "average"))
1273
+ else:
1274
+ # Modo comparación de modelos
1275
+ X, S, P = fitter.get_model_curves_for_plot(t_fine, settings.get('use_differential', False))
1276
+ plot_results.append({
1277
+ 'name': m_name,
1278
+ 'X': X,
1279
+ 'S': S,
1280
+ 'P': P,
1281
+ 'params': fitter.params,
1282
+ 'r2': fitter.r2,
1283
+ 'rmse': fitter.rmse
1284
+ })
1285
+
1286
+ if mode == "model_comparison" and plot_results:
1287
+ plot_func = plot_model_comparison_plotly if engine == 'Plotly (Interactivo)' else plot_model_comparison_matplotlib
1288
+ figs.append(plot_func(cfg, plot_results))
1289
 
1290
+ except Exception as e:
1291
  msgs.append(f"ERROR en '{sheet}': {e}")
1292
  traceback.print_exc()
1293
 
1294
  msg = "Análisis completado." + ("\n" + "\n".join(msgs) if msgs else "")
1295
  df_res = pd.DataFrame(results_data).dropna(axis=1, how='all')
1296
 
1297
+ if not df_res.empty:
1298
+ # Ordenar columnas
1299
+ id_c = ['Experimento', 'Modelo']
1300
+ p_c = sorted([c for c in df_res.columns if '_' in c and not any(m in c for m in ['R2', 'RMSE', 'MAE', 'AIC', 'BIC'])])
1301
+ m_c = sorted([c for c in df_res.columns if any(m in c for m in ['R2', 'RMSE', 'MAE', 'AIC', 'BIC'])])
1302
+ df_res = df_res[[c for c in id_c + p_c + m_c if c in df_res.columns]]
1303
+
1304
+ # Crear DataFrame formateado para UI
1305
+ df_ui = df_res.copy()
1306
+ for c in df_ui.select_dtypes(include=np.number).columns:
1307
+ df_ui[c] = df_ui[c].apply(lambda x: format_number(x, settings.get('decimal_places', 3)) if pd.notna(x) else '')
1308
+ else:
1309
+ df_ui = pd.DataFrame()
1310
 
1311
+ return figs, df_ui, msg, df_res
1312
 
1313
  # --- API ENDPOINTS PARA AGENTES DE IA ---
1314
 
 
1400
  except Exception as e:
1401
  return {"status": "error", "message": str(e)}
1402
 
1403
+ # --- INTERFAZ GRADIO COMPLETA ---
1404
 
1405
  def create_gradio_interface() -> gr.Blocks:
1406
+ """Crea la interfaz completa con todas las funcionalidades"""
1407
 
1408
  def change_language(lang_key: str) -> Dict:
1409
  """Cambia el idioma de la interfaz"""
1410
  lang = Language[lang_key]
1411
  trans = TRANSLATIONS.get(lang, TRANSLATIONS[Language.ES])
 
1412
  return trans["title"], trans["subtitle"]
1413
 
1414
  # Obtener opciones de modelo
 
1442
  )
1443
 
1444
  with gr.Tabs() as tabs:
1445
+ # --- TAB 1: GUÍA Y FORMATO ---
1446
+ with gr.TabItem("1. Guía y Formato de Datos"):
1447
+ with gr.Row():
1448
+ with gr.Column(scale=2):
1449
+ gr.Markdown("""
1450
+ ### Bienvenido al Analizador de Cinéticas
1451
+ Esta herramienta te permite ajustar modelos matemáticos a tus datos de crecimiento microbiano.
1452
+
1453
+ **Pasos a seguir:**
1454
+ 1. Prepara tu archivo Excel según el formato especificado a la derecha.
1455
+ 2. Ve a la pestaña **"2. Configuración y Ejecución"**.
1456
+ 3. Sube tu archivo y selecciona los modelos cinéticos que deseas probar.
1457
+ 4. Ajusta las opciones de visualización y análisis según tus preferencias.
1458
+ 5. Haz clic en **"Analizar y Graficar"**.
1459
+ 6. Explora los resultados en la pestaña **"3. Resultados"**.
1460
+
1461
+ ### Modos de Análisis
1462
+ - **Individual**: Un gráfico por cada réplica
1463
+ - **Promedio**: Promedio de réplicas con barras de error
1464
+ - **Combinado**: Todos los componentes en un solo gráfico
1465
+ - **Comparación**: Comparación de múltiples modelos
1466
+ """)
1467
+ with gr.Column(scale=3):
1468
+ gr.Markdown("### Formato del Archivo Excel")
1469
+ gr.Markdown("Usa una **cabecera de dos niveles** para tus datos.")
1470
+ df_ejemplo = pd.DataFrame({
1471
+ ('Rep1', 'Tiempo'): [0, 2, 4, 6],
1472
+ ('Rep1', 'Biomasa'): [0.1, 0.5, 2.5, 5.0],
1473
+ ('Rep1', 'Sustrato'): [10.0, 9.5, 7.0, 2.0],
1474
+ ('Rep1', 'Producto'): [0.0, 0.1, 0.5, 1.2],
1475
+ ('Rep2', 'Tiempo'): [0, 2, 4, 6],
1476
+ ('Rep2', 'Biomasa'): [0.12, 0.48, 2.6, 5.2],
1477
+ ('Rep2', 'Sustrato'): [10.2, 9.6, 7.1, 2.1],
1478
+ ('Rep2', 'Producto'): [0.0, 0.12, 0.48, 1.1],
1479
+ })
1480
+ gr.DataFrame(df_ejemplo, interactive=False, label="Ejemplo de Formato")
1481
 
1482
+ # --- TAB 2: CONFIGURACIÓN Y EJECUCIÓN ---
1483
+ with gr.TabItem("2. Configuración y Ejecución"):
1484
  with gr.Row():
1485
  with gr.Column(scale=1):
1486
+ file_input = gr.File(label="Sube tu archivo Excel (.xlsx)", file_types=['.xlsx'])
 
 
 
 
1487
  exp_names_input = gr.Textbox(
1488
+ label="Nombres de Experimentos (opcional)",
1489
+ placeholder="Nombre Hoja 1\nNombre Hoja 2\n...",
1490
+ lines=3,
1491
+ info="Un nombre por línea, en el mismo orden que las hojas del Excel."
1492
  )
 
1493
  model_selection_input = gr.CheckboxGroup(
1494
  choices=MODEL_CHOICES,
1495
+ label="Modelos a Probar",
1496
  value=DEFAULT_MODELS
1497
  )
1498
+ analysis_mode_input = gr.Radio(
1499
+ ["individual", "average", "combined", "model_comparison"],
1500
+ label="Modo de Análisis",
1501
+ value="average",
1502
+ info="Individual: por réplica. Average: promedio. Combined: 3 ejes. Comparación: todos los modelos."
1503
+ )
1504
+ plotting_engine_input = gr.Radio(
1505
+ ["Seaborn (Estático)", "Plotly (Interactivo)"],
1506
+ label="Motor Gráfico (en modo Comparación)",
1507
+ value="Plotly (Interactivo)"
1508
+ )
 
1509
 
1510
  with gr.Column(scale=2):
1511
+ with gr.Accordion("Opciones Generales de Análisis", open=True):
1512
+ decimal_places_input = gr.Slider(0, 10, value=3, step=1, label="Precisión Decimal")
1513
+ show_params_input = gr.Checkbox(label="Mostrar Parámetros en Gráfico", value=True)
1514
+ show_legend_input = gr.Checkbox(label="Mostrar Leyenda en Gráfico", value=True)
1515
+ use_differential_input = gr.Checkbox(label="Usar EDO para graficar", value=False)
1516
+ maxfev_input = gr.Number(label="Iteraciones Máximas de Ajuste", value=50000)
1517
+
1518
+ with gr.Accordion("Etiquetas de los Ejes", open=True):
1519
+ with gr.Row():
1520
+ xlabel_input = gr.Textbox(label="Etiqueta Eje X", value="Tiempo (h)")
1521
+ with gr.Row():
1522
+ ylabel_biomass_input = gr.Textbox(label="Etiqueta Biomasa", value="Biomasa (g/L)")
1523
+ ylabel_substrate_input = gr.Textbox(label="Etiqueta Sustrato", value="Sustrato (g/L)")
1524
+ ylabel_product_input = gr.Textbox(label="Etiqueta Producto", value="Producto (g/L)")
1525
 
1526
+ with gr.Accordion("Opciones de Estilo", open=False):
1527
+ style_input = gr.Dropdown(
1528
+ ['whitegrid', 'darkgrid', 'white', 'dark', 'ticks'],
1529
+ label="Estilo General (Matplotlib)",
1530
+ value='whitegrid'
1531
+ )
1532
+ with gr.Row():
1533
+ with gr.Column():
1534
+ gr.Markdown("**Biomasa**")
1535
+ biomass_point_color_input = gr.ColorPicker(label="Color Puntos", value='#0072B2')
1536
+ biomass_line_color_input = gr.ColorPicker(label="Color Línea", value='#56B4E9')
1537
+ biomass_marker_style_input = gr.Dropdown(
1538
+ ['o', 's', '^', 'D', 'p', '*', 'X'],
1539
+ label="Marcador",
1540
+ value='o'
1541
+ )
1542
+ biomass_line_style_input = gr.Dropdown(
1543
+ ['-', '--', '-.', ':'],
1544
+ label="Estilo Línea",
1545
+ value='-'
1546
+ )
1547
+ with gr.Column():
1548
+ gr.Markdown("**Sustrato**")
1549
+ substrate_point_color_input = gr.ColorPicker(label="Color Puntos", value='#009E73')
1550
+ substrate_line_color_input = gr.ColorPicker(label="Color Línea", value='#34E499')
1551
+ substrate_marker_style_input = gr.Dropdown(
1552
+ ['o', 's', '^', 'D', 'p', '*', 'X'],
1553
+ label="Marcador",
1554
+ value='s'
1555
+ )
1556
+ substrate_line_style_input = gr.Dropdown(
1557
+ ['-', '--', '-.', ':'],
1558
+ label="Estilo Línea",
1559
+ value='--'
1560
+ )
1561
+ with gr.Column():
1562
+ gr.Markdown("**Producto**")
1563
+ product_point_color_input = gr.ColorPicker(label="Color Puntos", value='#D55E00')
1564
+ product_line_color_input = gr.ColorPicker(label="Color Línea", value='#F0E442')
1565
+ product_marker_style_input = gr.Dropdown(
1566
+ ['o', 's', '^', 'D', 'p', '*', 'X'],
1567
+ label="Marcador",
1568
+ value='^'
1569
+ )
1570
+ product_line_style_input = gr.Dropdown(
1571
+ ['-', '--', '-.', ':'],
1572
+ label="Estilo Línea",
1573
+ value='-.'
1574
+ )
1575
+
1576
+ with gr.Row():
1577
+ legend_pos_input = gr.Radio(
1578
+ ["best", "upper right", "upper left", "lower left", "lower right", "center"],
1579
+ label="Posición Leyenda",
1580
+ value="best"
1581
+ )
1582
+ params_pos_input = gr.Radio(
1583
+ ["upper right", "upper left", "lower right", "lower left"],
1584
+ label="Posición Parámetros",
1585
+ value="upper right"
1586
+ )
1587
 
1588
+ with gr.Accordion("Opciones de Barra de Error", open=False):
1589
+ show_error_bars_input = gr.Checkbox(label="Mostrar barras de error", value=True)
1590
+ error_cap_size_input = gr.Slider(1, 10, 3, step=1, label="Tamaño Tapa Error")
1591
+ error_line_width_input = gr.Slider(0.5, 5, 1.0, step=0.5, label="Grosor Línea Error")
1592
+
1593
+ simulate_btn = gr.Button("Analizar y Graficar", variant="primary")
1594
+
1595
  # --- TAB 3: RESULTADOS ---
1596
+ with gr.TabItem("3. Resultados"):
1597
+ status_output = gr.Textbox(label="Estado del Análisis", interactive=False, lines=2)
1598
+ gallery_output = gr.Gallery(
1599
+ label="Gráficos Generados",
1600
+ columns=2,
1601
+ height=600,
1602
+ object_fit="contain",
1603
+ preview=True
 
1604
  )
1605
 
1606
+ with gr.Accordion("Descargar Reportes y Gráficos", open=True):
1607
+ with gr.Row():
1608
+ zip_btn = gr.Button("📦 Descargar Gráficos (.zip)")
1609
+ word_btn = gr.Button("📄 Descargar Reporte (.docx)")
1610
+ pdf_btn = gr.Button("📄 Descargar Reporte (.pdf)")
1611
+ download_output = gr.File(label="Archivo de Descarga", interactive=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
1612
 
1613
+ gr.Markdown("### Tabla de Resultados Numéricos")
1614
+ table_output = gr.DataFrame(wrap=True)
 
 
 
1615
 
1616
+ with gr.Row():
1617
+ excel_btn = gr.Button("📊 Descargar Tabla (.xlsx)")
1618
+ csv_btn = gr.Button("📊 Descargar Tabla (.csv)")
1619
+ download_table_output = gr.File(label="Descargar Tabla", interactive=False)
1620
 
1621
+ # Estados para almacenar datos
1622
+ df_for_export = gr.State(pd.DataFrame())
1623
+ figures_for_export = gr.State([])
1624
+
1625
+ # --- EVENTOS ---
1626
+
1627
+ def simulation_wrapper(file, models, mode, engine, names, use_diff, s_par, s_leg, maxfev,
1628
+ decimals, x_label, bio_label, sub_label, prod_label, style, s_err,
1629
+ cap, lw, l_pos, p_pos, bio_pc, bio_lc, bio_ms, bio_ls, sub_pc,
1630
+ sub_lc, sub_ms, sub_ls, prod_pc, prod_lc, prod_ms, prod_ls):
1631
+ try:
1632
+ def rgba_to_hex(rgba_string: str) -> str:
1633
+ if not isinstance(rgba_string, str) or rgba_string.startswith('#'):
1634
+ return rgba_string
1635
+ try:
1636
+ parts = rgba_string.lower().replace('rgba', '').replace('rgb', '').replace('(', '').replace(')', '')
1637
+ r, g, b, *_ = map(float, parts.split(','))
1638
+ return f'#{int(r):02x}{int(g):02x}{int(b):02x}'
1639
+ except (ValueError, TypeError):
1640
+ return "#000000"
1641
+
1642
+ plot_settings = {
1643
+ 'decimal_places': int(decimals),
1644
+ 'use_differential': use_diff,
1645
+ 'style': style,
1646
+ 'show_legend': s_leg,
1647
+ 'show_params': s_par,
1648
+ 'maxfev': int(maxfev),
1649
+ 'axis_labels': {
1650
+ 'x_label': x_label,
1651
+ 'biomass_label': bio_label,
1652
+ 'substrate_label': sub_label,
1653
+ 'product_label': prod_label
1654
  },
1655
+ 'legend_pos': l_pos,
1656
+ 'params_pos': p_pos,
1657
+ 'show_error_bars': s_err,
1658
+ 'error_cap_size': cap,
1659
+ 'error_line_width': lw,
1660
+ f'{C_BIOMASS}_point_color': rgba_to_hex(bio_pc),
1661
+ f'{C_BIOMASS}_line_color': rgba_to_hex(bio_lc),
1662
+ f'{C_BIOMASS}_marker_style': bio_ms,
1663
+ f'{C_BIOMASS}_line_style': bio_ls,
1664
+ f'{C_SUBSTRATE}_point_color': rgba_to_hex(sub_pc),
1665
+ f'{C_SUBSTRATE}_line_color': rgba_to_hex(sub_lc),
1666
+ f'{C_SUBSTRATE}_marker_style': sub_ms,
1667
+ f'{C_SUBSTRATE}_line_style': sub_ls,
1668
+ f'{C_PRODUCT}_point_color': rgba_to_hex(prod_pc),
1669
+ f'{C_PRODUCT}_line_color': rgba_to_hex(prod_lc),
1670
+ f'{C_PRODUCT}_marker_style': prod_ms,
1671
+ f'{C_PRODUCT}_line_style': prod_ls,
1672
  }
 
 
 
1673
 
1674
+ figures, df_ui, msg, df_export = run_analysis(file, models, mode, engine, names, plot_settings)
 
1675
 
1676
+ # Convertir figuras a imágenes para galería
1677
+ image_list = []
1678
+ for fig in figures:
1679
+ buf = io.BytesIO()
1680
+ if isinstance(fig, go.Figure):
1681
+ buf.write(fig.to_image(format="png", scale=2, engine="kaleido"))
1682
+ elif isinstance(fig, plt.Figure):
1683
+ fig.savefig(buf, format='png', bbox_inches='tight', dpi=150)
1684
+ plt.close(fig)
1685
+ buf.seek(0)
1686
+ image_list.append(Image.open(buf).convert("RGB"))
1687
 
1688
+ return image_list, df_ui, msg, df_export, figures
 
 
 
 
1689
 
1690
+ except Exception as e:
1691
+ print(f"--- ERROR CAPTURADO EN WRAPPER ---\n{traceback.format_exc()}")
1692
+ return [], pd.DataFrame(), f"Error Crítico: {e}", pd.DataFrame(), []
1693
+
1694
+ all_inputs = [
1695
+ file_input, model_selection_input, analysis_mode_input, plotting_engine_input, exp_names_input,
1696
+ use_differential_input, show_params_input, show_legend_input, maxfev_input, decimal_places_input,
1697
+ xlabel_input, ylabel_biomass_input, ylabel_substrate_input, ylabel_product_input,
1698
+ style_input, show_error_bars_input, error_cap_size_input, error_line_width_input,
1699
+ legend_pos_input, params_pos_input,
1700
+ biomass_point_color_input, biomass_line_color_input, biomass_marker_style_input, biomass_line_style_input,
1701
+ substrate_point_color_input, substrate_line_color_input, substrate_marker_style_input, substrate_line_style_input,
1702
+ product_point_color_input, product_line_color_input, product_marker_style_input, product_line_style_input
1703
+ ]
1704
 
1705
+ all_outputs = [gallery_output, table_output, status_output, df_for_export, figures_for_export]
1706
 
1707
+ simulate_btn.click(fn=simulation_wrapper, inputs=all_inputs, outputs=all_outputs)
 
 
 
 
 
 
 
1708
 
1709
+ # Funciones de descarga
1710
+ zip_btn.click(fn=create_zip_file, inputs=[figures_for_export], outputs=[download_output])
1711
+ word_btn.click(
1712
+ fn=create_word_report,
1713
+ inputs=[figures_for_export, df_for_export, decimal_places_input],
1714
+ outputs=[download_output]
1715
+ )
1716
+ pdf_btn.click(
1717
+ fn=create_pdf_report,
1718
+ inputs=[figures_for_export, df_for_export, decimal_places_input],
1719
+ outputs=[download_output]
1720
+ )
1721
+
1722
+ def export_table_to_file(df: pd.DataFrame, file_format: str) -> Optional[str]:
1723
+ if df is None or df.empty:
1724
+ gr.Warning("No hay datos para exportar.")
1725
+ return None
1726
+ suffix = ".xlsx" if file_format == "excel" else ".csv"
1727
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
1728
+ if file_format == "excel":
1729
+ df.to_excel(tmp.name, index=False)
1730
+ else:
1731
+ df.to_csv(tmp.name, index=False, encoding='utf-8-sig')
1732
+ return tmp.name
1733
+
1734
+ excel_btn.click(
1735
+ fn=lambda df: export_table_to_file(df, "excel"),
1736
+ inputs=[df_for_export],
1737
+ outputs=[download_table_output]
1738
+ )
1739
+ csv_btn.click(
1740
+ fn=lambda df: export_table_to_file(df, "csv"),
1741
+ inputs=[df_for_export],
1742
+ outputs=[download_table_output]
1743
  )
1744
 
1745
  # Cambio de idioma
 
1751
 
1752
  # Cambio de tema
1753
  def apply_theme(is_dark):
1754
+ return gr.Info("Tema cambiado. Los nuevos gráficos usarán el tema seleccionado.")
1755
 
1756
  theme_toggle.change(
1757
  fn=apply_theme,
1758
  inputs=[theme_toggle],
1759
  outputs=[]
1760
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1761
 
1762
  return demo
1763
 
1764
+ # --- PUNTO DE ENTRADA PRINCIPAL ---
1765
 
1766
  if __name__ == '__main__':
1767
  # Lanzar aplicación Gradio