C2MV commited on
Commit
b9b8eda
·
verified ·
1 Parent(s): e4816bb

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +406 -290
app.py CHANGED
@@ -1,7 +1,5 @@
1
  import os
2
- # It's generally better to manage dependencies outside the script (e.g., in a requirements.txt or by installing manually)
3
- # If you must do it in the script, ensure it's what you intend.
4
- os.system("pip install --upgrade gradio") # Commenting out for typical execution, assuming Gradio is installed.
5
 
6
  from pydantic import BaseModel
7
  import numpy as np
@@ -27,76 +25,87 @@ class BioprocessModel:
27
  self.params = {}
28
  self.r2 = {}
29
  self.rmse = {}
30
- self.datax = []
31
  self.datas = []
32
  self.datap = []
33
- self.dataxp = []
34
  self.datasp = []
35
  self.datapp = []
36
- self.datax_std = []
37
  self.datas_std = []
38
  self.datap_std = []
39
  self.biomass_model = None
40
  self.biomass_diff = None
41
  self.model_type = model_type
42
  self.maxfev = maxfev
43
- self.time = None
44
 
45
  @staticmethod
46
  def logistic(time, xo, xm, um):
47
- if xm == 0 or (xo / xm >= 1 and np.any(um * time > 0)):
 
 
48
  return np.full_like(time, np.nan)
49
  term_exp = np.exp(um * time)
50
  denominator = (xm - xo + xo * term_exp)
51
- denominator = np.where(denominator == 0, 1e-9, denominator)
52
  return (xo * xm * term_exp) / denominator
53
 
 
54
  @staticmethod
55
  def gompertz(time, xm, um, lag):
56
- if xm <= 0 or um <=0 :
57
  return np.full_like(time, np.nan)
58
  exp_term = (um * np.e / xm) * (lag - time) + 1
59
- exp_term_clipped = np.clip(exp_term, -np.inf, 700)
60
  return xm * np.exp(-np.exp(exp_term_clipped))
61
 
62
  @staticmethod
63
  def moser(time, Xm, um, Ks):
64
  if Xm <=0 or um <=0: return np.full_like(time, np.nan)
 
65
  return Xm * (1 - np.exp(-um * (time - Ks)))
66
 
67
  @staticmethod
68
  def baranyi(time, X0, Xm, um, lag):
69
- if X0 <= 0 or Xm <= X0 or um <= 0 or lag < 0:
70
  return np.full_like(time, np.nan)
71
- # h0 = um # Not used in this simplified A(t)
72
  log_arg_A = np.exp(-um * time) + np.exp(-um * lag) - np.exp(-um * (time + lag))
73
- log_arg_A = np.where(log_arg_A <= 1e-9, 1e-9, log_arg_A)
74
  A_t = time + (1 / um) * np.log(log_arg_A)
 
75
  exp_um_At = np.exp(um * A_t)
76
- exp_um_At_clipped = np.clip(exp_um_At, -np.inf, 700)
 
77
  numerator = Xm * exp_um_At_clipped
78
  denominator = (Xm / X0 - 1) + exp_um_At_clipped
79
- denominator = np.where(denominator == 0, 1e-9, denominator)
 
80
  return numerator / denominator
81
 
82
  @staticmethod
83
  def logistic_diff(X, t, params):
 
 
84
  _, xm, um = params
85
  if xm == 0: return 0
86
  return um * X * (1 - X / xm)
87
 
88
  @staticmethod
89
  def gompertz_diff(X, t, params):
 
90
  xm, um, lag = params
91
- if xm == 0 or X <= 1e-9 : return 0 # Avoid issues with X=0 or Xm=0
92
  k_val = um * np.e / xm
93
  u_val = k_val * (lag - t) + 1
94
- u_val_clipped = np.clip(u_val, -np.inf, 700)
95
  return X * k_val * np.exp(u_val_clipped)
96
 
97
  @staticmethod
98
  def moser_diff(X, t, params):
99
- Xm, um, _ = params
 
100
  return um * (Xm - X)
101
 
102
  def substrate(self, time, so, p, q, biomass_params_list):
@@ -104,16 +113,29 @@ class BioprocessModel:
104
  return np.full_like(time, np.nan)
105
  X_t = self.biomass_model(time, *biomass_params_list)
106
  if np.any(np.isnan(X_t)): return np.full_like(time, np.nan)
 
107
  integral_X = np.zeros_like(X_t)
108
  if len(time) > 1:
109
- dt = np.diff(time, prepend=time[0] - (time[1]-time[0] if len(time)>1 else 1))
110
- integral_X = np.cumsum(X_t * dt)
111
- X0_calc = 0
 
 
 
 
 
 
 
 
 
 
 
112
  if self.model_type in ['logistic', 'baranyi']: X0_calc = biomass_params_list[0]
113
- elif self.model_type == 'gompertz': X0_calc = self.gompertz(0, *biomass_params_list)
114
- elif self.model_type == 'moser': X0_calc = self.moser(0, *biomass_params_list)
115
- else: X0_calc = X_t[0] if len(X_t)>0 else 0
116
- X0 = X0_calc if not np.isnan(X0_calc) else (biomass_params_list[0] if biomass_params_list else 0)
 
117
  return so - p * (X_t - X0) - q * integral_X
118
 
119
  def product(self, time, po, alpha, beta, biomass_params_list):
@@ -121,88 +143,116 @@ class BioprocessModel:
121
  return np.full_like(time, np.nan)
122
  X_t = self.biomass_model(time, *biomass_params_list)
123
  if np.any(np.isnan(X_t)): return np.full_like(time, np.nan)
 
124
  integral_X = np.zeros_like(X_t)
125
  if len(time) > 1:
126
- dt = np.diff(time, prepend=time[0] - (time[1]-time[0] if len(time)>1 else 1))
127
- integral_X = np.cumsum(X_t * dt)
128
- X0_calc = 0
 
 
 
 
 
 
 
 
 
129
  if self.model_type in ['logistic', 'baranyi']: X0_calc = biomass_params_list[0]
130
  elif self.model_type == 'gompertz': X0_calc = self.gompertz(0, *biomass_params_list)
131
  elif self.model_type == 'moser': X0_calc = self.moser(0, *biomass_params_list)
132
- else: X0_calc = X_t[0] if len(X_t)>0 else 0
133
- X0 = X0_calc if not np.isnan(X0_calc) else (biomass_params_list[0] if biomass_params_list else 0)
 
134
  return po + alpha * (X_t - X0) + beta * integral_X
135
 
136
  def process_data(self, df):
 
137
  biomass_cols = [col for col in df.columns if isinstance(col, tuple) and len(col) > 1 and col[1] == 'Biomasa']
138
  substrate_cols = [col for col in df.columns if isinstance(col, tuple) and len(col) > 1 and col[1] == 'Sustrato']
139
  product_cols = [col for col in df.columns if isinstance(col, tuple) and len(col) > 1 and col[1] == 'Producto']
140
-
141
  time_col_candidates = [col for col in df.columns if isinstance(col, tuple) and len(col) > 1 and col[1] == 'Tiempo']
 
142
  if not time_col_candidates:
143
- raise ValueError("La columna ('*', 'Tiempo') no se encuentra en el DataFrame.")
144
- time_col_tuple = time_col_candidates[0]
145
-
146
- time_data = df[time_col_tuple]
147
- if isinstance(time_data, pd.DataFrame):
148
- time = time_data.iloc[:,0].dropna().values
149
- else:
150
- time = time_data.dropna().values
151
-
152
- def process_component(cols, data_list, data_p_list, data_std_list):
153
- if len(cols) > 0:
154
- component_df = df[cols]
155
- # component_df_cleaned = component_df.dropna(how='all') # Not strictly needed if individual series are handled
156
- data_replicates = [component_df[col].dropna().values for col in component_df.columns]
157
- if not data_replicates:
158
- data_list.append(np.array([])); data_p_list.append(np.array([])); data_std_list.append(np.array([]))
159
- return
160
-
161
- # Align replicates to the minimum length among them before averaging
162
- min_len_among_replicates = min(len(r) for r in data_replicates) if data_replicates else 0
163
- aligned_replicates_for_avg = [rep[:min_len_among_replicates] for rep in data_replicates]
164
-
165
- if aligned_replicates_for_avg:
166
- data_np = np.array(aligned_replicates_for_avg)
167
- data_list.append(data_np) # Store all replicates
168
- # Average and std are based on these aligned replicates
169
- avg_data = np.mean(data_np, axis=0)
170
- std_data = np.std(data_np, axis=0, ddof=1 if data_np.shape[0] > 1 else 0)
171
- data_p_list.append(avg_data)
172
- data_std_list.append(std_data)
173
- else: # Should not happen if data_replicates was not empty
174
- data_list.append(np.array([])); data_p_list.append(np.array([])); data_std_list.append(np.array([]))
175
- else:
176
- data_list.append(np.array([])); data_p_list.append(np.array([])); data_std_list.append(np.array([]))
177
-
178
- process_component(biomass_cols, self.datax, self.dataxp, self.datax_std)
179
- process_component(substrate_cols, self.datas, self.datasp, self.datas_std)
180
- process_component(product_cols, self.datap, self.datapp, self.datap_std)
181
-
182
- # Final alignment of averaged data (xp, sp, pp) and time
183
- min_valid_len = len(time)
184
- if self.dataxp and len(self.dataxp[-1]) > 0: min_valid_len = min(min_valid_len, len(self.dataxp[-1]))
185
- if self.datasp and len(self.datasp[-1]) > 0: min_valid_len = min(min_valid_len, len(self.datasp[-1]))
186
- if self.datapp and len(self.datapp[-1]) > 0: min_valid_len = min(min_valid_len, len(self.datapp[-1]))
187
-
188
- self.time = time[:min_valid_len]
189
- if self.dataxp and len(self.dataxp[-1]) > 0:
190
- self.dataxp[-1] = self.dataxp[-1][:min_valid_len]
191
- if self.datax_std and self.datax_std[-1] is not None and len(self.datax_std[-1]) > 0:
192
- self.datax_std[-1] = self.datax_std[-1][:min_valid_len]
193
-
194
- if self.datasp and len(self.datasp[-1]) > 0:
195
- self.datasp[-1] = self.datasp[-1][:min_valid_len]
196
- if self.datas_std and self.datas_std[-1] is not None and len(self.datas_std[-1]) > 0:
197
- self.datas_std[-1] = self.datas_std[-1][:min_valid_len]
198
-
199
- if self.datapp and len(self.datapp[-1]) > 0:
200
- self.datapp[-1] = self.datapp[-1][:min_valid_len]
201
- if self.datap_std and self.datap_std[-1] is not None and len(self.datap_std[-1]) > 0:
202
- self.datap_std[-1] = self.datap_std[-1][:min_valid_len]
203
-
204
-
205
- def fit_model(self):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  if self.model_type == 'logistic':
207
  self.biomass_model = self.logistic; self.biomass_diff = self.logistic_diff
208
  elif self.model_type == 'gompertz':
@@ -210,71 +260,113 @@ class BioprocessModel:
210
  elif self.model_type == 'moser':
211
  self.biomass_model = self.moser; self.biomass_diff = self.moser_diff
212
  elif self.model_type == 'baranyi':
213
- self.biomass_model = self.baranyi; self.biomass_diff = None
214
  else: raise ValueError(f"Modelo de biomasa desconocido: {self.model_type}")
215
 
216
  def fit_biomass(self, time, biomass):
217
  time = np.asarray(time, dtype=float); biomass = np.asarray(biomass, dtype=float)
 
 
 
 
 
218
  if len(time) != len(biomass):
 
219
  min_len = min(len(time), len(biomass)); time = time[:min_len]; biomass = biomass[:min_len]
220
- if min_len < 3 : self.r2['biomass'] = np.nan; self.rmse['biomass'] = np.nan; return None
221
- valid_indices = ~np.isnan(time) & ~np.isnan(biomass)
 
 
 
222
  time = time[valid_indices]; biomass = biomass[valid_indices]
 
223
  num_params = 4 if self.model_type == 'baranyi' else 3
224
  if len(time) < num_params:
225
- print(f"No hay suficientes datos válidos ({len(time)}) para {self.model_type} ({num_params} params).")
226
  self.r2['biomass'] = np.nan; self.rmse['biomass'] = np.nan; return None
227
- if len(biomass) == 0 or biomass[0] <= 1e-9: # Initial biomass must be positive
228
- print(f"Biomasa inicial no válida (<=1e-9) o datos de biomasa vacíos para {self.model_type}.")
 
229
  self.r2['biomass'] = np.nan; self.rmse['biomass'] = np.nan; return None
 
 
 
 
 
230
  try:
231
  popt, y_pred = None, None
 
 
 
232
  if self.model_type == 'logistic':
233
- xo_g, xm_g, um_g = biomass[0] if biomass[0]>1e-6 else 1e-3, max(biomass)*1.1 if max(biomass)>biomass[0] else biomass[0]*2, 0.1
234
- if xm_g <= xo_g: xm_g = xo_g + 1e-3
235
- p0=[xo_g, xm_g, um_g]; b=([1e-9, biomass[0]+1e-9 if biomass[0]>1e-9 else 1e-9, 1e-9],[max(biomass)*0.999 if max(biomass)>0 else 1,np.inf,np.inf])
236
- p0[0]=np.clip(p0[0],b[0][0],b[1][0]); p0[1]=np.clip(p0[1],max(b[0][1],p0[0]+1e-9),b[1][1])
237
- popt,_=curve_fit(self.logistic,time,biomass,p0=p0,maxfev=self.maxfev,bounds=b,ftol=1e-9,xtol=1e-9,method='trf')
 
 
 
238
  self.params['biomass']={'Xo':popt[0],'Xm':popt[1],'um':popt[2]}; y_pred=self.logistic(time,*popt)
 
239
  elif self.model_type == 'gompertz':
240
- xm_g,um_g=max(biomass) if max(biomass)>0 else 1.0,0.1
241
- lag_idx=np.where(biomass > (min(biomass)+0.1*(max(biomass)-min(biomass))))[0]
 
242
  lag_g=time[lag_idx[0]] if len(lag_idx)>0 and time[lag_idx[0]]>=0 else (time[0] if len(time)>0 and time[0]>=0 else 0)
243
- p0=[xm_g,um_g,lag_g]; b=([min(biomass) if min(biomass)>1e-9 else 1e-9,1e-9,0],[np.inf,np.inf,max(time)*1.1 if len(time)>0 and max(time)>0 else 100])
244
- popt,_=curve_fit(self.gompertz,time,biomass,p0=p0,maxfev=self.maxfev,bounds=b,ftol=1e-9,xtol=1e-9,method='trf')
 
245
  self.params['biomass']={'Xm':popt[0],'um':popt[1],'lag':popt[2]}; y_pred=self.gompertz(time,*popt)
 
246
  elif self.model_type == 'moser':
247
- Xm_g,um_g,Ks_g=max(biomass) if max(biomass)>0 else 1.0,0.1,time[0] if len(time)>0 else 0
248
- p0=[Xm_g,um_g,Ks_g]; b=([min(biomass) if min(biomass)>1e-9 else 1e-9,1e-9,-max(abs(time))*0.5 if len(time)>0 else -100],[np.inf,np.inf,max(abs(time))*1.5 if len(time)>0 else 100])
249
- popt,_=curve_fit(self.moser,time,biomass,p0=p0,maxfev=self.maxfev,bounds=b,ftol=1e-9,xtol=1e-9,method='trf')
 
250
  self.params['biomass']={'Xm':popt[0],'um':popt[1],'Ks':popt[2]}; y_pred=self.moser(time,*popt)
 
251
  elif self.model_type == 'baranyi':
252
- X0_g,Xm_g,um_g=biomass[0] if biomass[0]>1e-6 else 1e-3,max(biomass) if max(biomass)>biomass[0] else biomass[0]*2,0.1
253
- if Xm_g<=X0_g: Xm_g=X0_g+1e-3
254
- lag_idx_b=np.where(biomass > (X0_g+0.1*(Xm_g-X0_g)))[0]
255
  lag_g_b=time[lag_idx_b[0]] if len(lag_idx_b)>0 and time[lag_idx_b[0]]>=0 else (time[0] if len(time)>0 and time[0]>=0 else 0)
256
- p0=[X0_g,Xm_g,um_g,lag_g_b]; b=([1e-9,biomass[0]+1e-9 if biomass[0]>1e-9 else 1e-9,1e-9,0],[max(biomass)*0.999 if max(biomass)>0 else 1,np.inf,np.inf,max(time)*1.1 if len(time)>0 and max(time)>0 else 100])
257
- p0[0]=np.clip(p0[0],b[0][0],b[1][0]); p0[1]=np.clip(p0[1],max(b[0][1],p0[0]+1e-9),b[1][1]); p0[3]=np.clip(p0[3],b[0][3],b[1][3])
258
- popt,_=curve_fit(self.baranyi,time,biomass,p0=p0,maxfev=self.maxfev,bounds=b,ftol=1e-9,xtol=1e-9,method='trf')
 
 
 
259
  self.params['biomass']={'X0':popt[0],'Xm':popt[1],'um':popt[2],'lag':popt[3]}; y_pred=self.baranyi(time,*popt)
260
- else: return None
 
 
 
261
  if y_pred is None or np.any(np.isnan(y_pred)) or np.any(np.isinf(y_pred)):
 
262
  self.r2['biomass']=np.nan; self.rmse['biomass']=np.nan; self.params['biomass']={}; return None
 
263
  ss_res=np.sum((biomass-y_pred)**2); ss_tot=np.sum((biomass-np.mean(biomass))**2)
264
- self.r2['biomass']=1-(ss_res/ss_tot) if ss_tot!=0 else (1.0 if ss_res<1e-9 else 0.0)
 
265
  self.rmse['biomass']=np.sqrt(mean_squared_error(biomass,y_pred)); return y_pred
266
- except RuntimeError as e: print(f"RuntimeError fit_biomass {self.model_type}: {e}"); self.params['biomass']={}; self.r2['biomass']=np.nan; self.rmse['biomass']=np.nan; return None
267
- except Exception as e: print(f"Error fit_biomass {self.model_type}: {e}"); self.params['biomass']={}; self.r2['biomass']=np.nan; self.rmse['biomass']=np.nan; return None
268
 
269
- def fit_substrate(self, time, substrate, biomass_params_dict):
270
- if not biomass_params_dict or not self.params.get('biomass'): self.r2['substrate']=np.nan; self.rmse['substrate']=np.nan; return None
 
 
 
 
 
271
  time=np.asarray(time,dtype=float); substrate=np.asarray(substrate,dtype=float)
272
  valid_idx=~np.isnan(time)&~np.isnan(substrate); time=time[valid_idx]; substrate=substrate[valid_idx]
273
- if len(time)<3: self.r2['substrate']=np.nan; self.rmse['substrate']=np.nan; return None
 
274
  try:
275
  biomass_vals=list(biomass_params_dict.values())
276
- p0=[substrate[0] if len(substrate)>0 else 1.0,0.1,0.01]; b=([0,0,-np.inf],[np.inf,np.inf,np.inf])
277
- popt,_=curve_fit(lambda t,so,p,q:self.substrate(t,so,p,q,biomass_vals),time,substrate,p0=p0,maxfev=self.maxfev,bounds=b,ftol=1e-9,xtol=1e-9,method='trf')
 
278
  self.params['substrate']={'so':popt[0],'p':popt[1],'q':popt[2]}; y_pred=self.substrate(time,*popt,biomass_vals)
279
  if np.any(np.isnan(y_pred)) or np.any(np.isinf(y_pred)): self.r2['substrate']=np.nan; self.rmse['substrate']=np.nan; self.params['substrate']={}; return None
280
  ss_res=np.sum((substrate-y_pred)**2); ss_tot=np.sum((substrate-np.mean(substrate))**2)
@@ -283,15 +375,19 @@ class BioprocessModel:
283
  except RuntimeError as e: print(f"RuntimeError fit_substrate {self.model_type}: {e}"); self.params['substrate']={}; self.r2['substrate']=np.nan; self.rmse['substrate']=np.nan; return None
284
  except Exception as e: print(f"Error fit_substrate {self.model_type}: {e}"); self.params['substrate']={}; self.r2['substrate']=np.nan; self.rmse['substrate']=np.nan; return None
285
 
286
- def fit_product(self, time, product, biomass_params_dict):
287
- if not biomass_params_dict or not self.params.get('biomass'): self.r2['product']=np.nan; self.rmse['product']=np.nan; return None
 
 
288
  time=np.asarray(time,dtype=float); product=np.asarray(product,dtype=float)
289
  valid_idx=~np.isnan(time)&~np.isnan(product); time=time[valid_idx]; product=product[valid_idx]
290
- if len(time)<3: self.r2['product']=np.nan; self.rmse['product']=np.nan; return None
 
291
  try:
292
  biomass_vals=list(biomass_params_dict.values())
293
- p0=[product[0] if len(product)>0 else 0.0,0.1,0.01]; b=([0,0,-np.inf],[np.inf,np.inf,np.inf])
294
- popt,_=curve_fit(lambda t,po,a,b_par:self.product(t,po,a,b_par,biomass_vals),time,product,p0=p0,maxfev=self.maxfev,bounds=b,ftol=1e-9,xtol=1e-9,method='trf')
 
295
  self.params['product']={'po':popt[0],'alpha':popt[1],'beta':popt[2]}; y_pred=self.product(time,*popt,biomass_vals)
296
  if np.any(np.isnan(y_pred)) or np.any(np.isinf(y_pred)): self.r2['product']=np.nan; self.rmse['product']=np.nan; self.params['product']={}; return None
297
  ss_res=np.sum((product-y_pred)**2); ss_tot=np.sum((product-np.mean(product))**2)
@@ -300,65 +396,92 @@ class BioprocessModel:
300
  except RuntimeError as e: print(f"RuntimeError fit_product {self.model_type}: {e}"); self.params['product']={}; self.r2['product']=np.nan; self.rmse['product']=np.nan; return None
301
  except Exception as e: print(f"Error fit_product {self.model_type}: {e}"); self.params['product']={}; self.r2['product']=np.nan; self.rmse['product']=np.nan; return None
302
 
303
- def generate_fine_time_grid(self, time):
304
- if time is None or len(time)<2: return np.array([0]) if (time is None or len(time)==0) else np.array(time)
305
- t_min,t_max=np.min(time),np.max(time)
306
- return np.array([t_min]) if t_min==t_max else np.linspace(t_min,t_max,500)
307
 
308
  def system(self, y, t, biomass_params_ode, substrate_params, product_params, model_type_ode):
309
- X,S,P=y; X=max(X,0); dXdt=0.0
 
310
  if model_type_ode=='logistic': dXdt=self.logistic_diff(X,t,biomass_params_ode)
311
  elif model_type_ode=='gompertz': dXdt=self.gompertz_diff(X,t,biomass_params_ode)
312
  elif model_type_ode=='moser': dXdt=self.moser_diff(X,t,biomass_params_ode)
313
- else: dXdt=0.0 # Should be caught before if model has no diff eq
314
- p_val=substrate_params[1] if len(substrate_params)>1 else 0
315
- q_val=substrate_params[2] if len(substrate_params)>2 else 0
316
- dSdt=-p_val*dXdt-q_val*X; dSdt=0 if S<=0 and dSdt<0 else dSdt # Prevent S < 0
317
- alpha_val=product_params[1] if len(product_params)>1 else 0
318
- beta_val=product_params[2] if len(product_params)>2 else 0
319
- dPdt=alpha_val*dXdt+beta_val*X; return [dXdt,dSdt,dPdt]
320
-
321
- def get_initial_conditions(self, time, biomass, substrate, product):
322
- X0e=biomass[0] if biomass is not None and len(biomass)>0 and np.isfinite(biomass[0]) else 0.0
323
- S0e=substrate[0] if substrate is not None and len(substrate)>0 and np.isfinite(substrate[0]) else 0.0
324
- P0e=product[0] if product is not None and len(product)>0 and np.isfinite(product[0]) else 0.0
325
- X0=X0e
 
 
 
 
 
 
326
  if 'biomass' in self.params and self.params['biomass']:
327
- if self.model_type in ['logistic','baranyi']: X0=self.params['biomass'].get('Xo',self.params['biomass'].get('X0',X0e))
328
- elif self.model_type=='gompertz' and self.biomass_model and all(k in self.params['biomass'] for k in ['Xm','um','lag']):
329
- X0c=self.biomass_model(0,self.params['biomass']['Xm'],self.params['biomass']['um'],self.params['biomass']['lag']); X0=X0c if np.isfinite(X0c) else X0e
330
- elif self.model_type=='moser' and self.biomass_model and all(k in self.params['biomass'] for k in ['Xm','um','Ks']):
331
- X0c=self.biomass_model(0,self.params['biomass']['Xm'],self.params['biomass']['um'],self.params['biomass']['Ks']); X0=X0c if np.isfinite(X0c) else X0e
332
- S0=self.params.get('substrate',{}).get('so',S0e); P0=self.params.get('product',{}).get('po',P0e)
333
- return [max(X0 if np.isfinite(X0) else 0.0,1e-9), S0 if np.isfinite(S0) else 0.0, P0 if np.isfinite(P0) else 0.0] # Ensure X0 > 0 for ODE
 
 
 
 
 
 
 
 
 
 
 
 
334
 
335
  def solve_differential_equations(self, time, biomass, substrate, product):
336
- if self.biomass_diff is None: return None,None,None,time
337
- if 'biomass' not in self.params or not self.params['biomass']: return None,None,None,time
338
- if time is None or len(time)==0: return None,None,None,np.array([])
339
 
340
- biomass_p_ode=[]
341
- if self.model_type=='logistic': biomass_p_ode=[self.params['biomass']['Xo'],self.params['biomass']['Xm'],self.params['biomass']['um']]
342
  elif self.model_type=='gompertz': biomass_p_ode=[self.params['biomass']['Xm'],self.params['biomass']['um'],self.params['biomass']['lag']]
343
  elif self.model_type=='moser': biomass_p_ode=[self.params['biomass']['Xm'],self.params['biomass']['um'],self.params['biomass']['Ks']]
344
- else: return None,None,None,time # Should not happen if self.biomass_diff is set
345
 
346
  substrate_p=[self.params.get('substrate',{}).get('so',0), self.params.get('substrate',{}).get('p',0), self.params.get('substrate',{}).get('q',0)]
347
  product_p=[self.params.get('product',{}).get('po',0), self.params.get('product',{}).get('alpha',0), self.params.get('product',{}).get('beta',0)]
348
 
349
- init_cond=self.get_initial_conditions(time,biomass,substrate,product)
350
  time_f=self.generate_fine_time_grid(time)
351
- if len(time_f)==0: return None,None,None,time
352
-
353
- hmax_val=(time_f[-1]-time_f[0])/100.0 if len(time_f)>1 else 0.0 # Heuristic for hmax
354
  try:
355
  sol=odeint(self.system,init_cond,time_f,args=(biomass_p_ode,substrate_p,product_p,self.model_type),rtol=1e-6,atol=1e-6,hmax=hmax_val)
356
  except Exception as e_ode:
357
- print(f"Error ODE ({self.model_type}): {e_ode}. Trying lsoda.")
358
  try: sol=odeint(self.system,init_cond,time_f,args=(biomass_p_ode,substrate_p,product_p,self.model_type),rtol=1e-6,atol=1e-6,method='lsoda',hmax=hmax_val)
359
- except Exception as e_lsoda: print(f"Error ODE lsoda ({self.model_type}): {e_lsoda}"); return None,None,None,time_f
360
- return sol[:,0],sol[:,1],sol[:,2],time_f
361
-
 
 
 
 
 
 
 
 
362
  def plot_results(self, time, biomass, substrate, product,
363
  y_pred_biomass_fit, y_pred_substrate_fit, y_pred_product_fit,
364
  biomass_std=None, substrate_std=None, product_std=None,
@@ -370,11 +493,12 @@ class BioprocessModel:
370
 
371
  y_pred_b,y_pred_s,y_pred_p = y_pred_biomass_fit,y_pred_substrate_fit,y_pred_product_fit
372
  time_curves = self.generate_fine_time_grid(time)
373
- X_ode_success = False # Flag to indicate if ODE solution was used for title
374
 
375
  if y_pred_biomass_fit is None and not (use_differential and self.biomass_diff is not None and 'biomass' in self.params and self.params['biomass']):
376
- print(f"No biomass fit for {experiment_name}, {self.model_type}. Skipping plot.")
377
- return None
 
378
 
379
  can_use_ode = use_differential and self.biomass_diff is not None and 'biomass' in self.params and self.params['biomass']
380
 
@@ -386,19 +510,21 @@ class BioprocessModel:
386
  if X_ode_res is not None:
387
  y_pred_b,y_pred_s,y_pred_p,time_curves = X_ode_res,S_ode_res,P_ode_res,time_fine_ode
388
  X_ode_success = True
389
- else: # ODE failed, use curve_fit on fine grid
390
- print(f"ODE failed for {experiment_name}, {self.model_type}. Using curve_fit results.")
391
- if y_pred_biomass_fit is not None and self.biomass_model and 'biomass' in self.params and self.params['biomass']:
 
392
  b_params=list(self.params['biomass'].values())
393
- y_pred_b=self.biomass_model(time_curves,*b_params)
394
  if y_pred_substrate_fit is not None and 'substrate' in self.params and self.params.get('substrate'):
395
  s_params=list(self.params['substrate'].values()); y_pred_s=self.substrate(time_curves,*s_params,b_params)
396
  else: y_pred_s=np.full_like(time_curves,np.nan)
397
  if y_pred_product_fit is not None and 'product' in self.params and self.params.get('product'):
398
  p_params=list(self.params['product'].values()); y_pred_p=self.product(time_curves,*p_params,b_params)
399
  else: y_pred_p=np.full_like(time_curves,np.nan)
400
- else: y_pred_b,y_pred_s,y_pred_p = (np.full_like(time_curves,np.nan) for _ in range(3))
401
- else: # Not using ODE, use curve_fit on fine grid
 
402
  if y_pred_biomass_fit is not None and self.biomass_model and 'biomass' in self.params and self.params['biomass']:
403
  b_params=list(self.params['biomass'].values()); y_pred_b=self.biomass_model(time_curves,*b_params)
404
  if y_pred_substrate_fit is not None and 'substrate' in self.params and self.params.get('substrate'):
@@ -407,39 +533,43 @@ class BioprocessModel:
407
  if y_pred_product_fit is not None and 'product' in self.params and self.params.get('product'):
408
  p_params=list(self.params['product'].values()); y_pred_p=self.product(time_curves,*p_params,b_params)
409
  else: y_pred_p=np.full_like(time_curves,np.nan)
410
- else: y_pred_b,y_pred_s,y_pred_p = (np.full_like(time_curves,np.nan) for _ in range(3))
 
 
411
 
412
  fig,(ax1,ax2,ax3)=plt.subplots(3,1,figsize=(10,15),sharex=True)
413
- title_suffix = " - EDO" if X_ode_success else (" - Ajuste Directo" if not use_differential or self.biomass_diff is None else " - EDO (falló, usando ajuste)")
414
  fig.suptitle(f'{experiment_name} ({self.model_type.capitalize()}){title_suffix}',fontsize=16)
415
 
416
  plot_cfg=[(ax1,biomass,y_pred_b,biomass_std,axis_labels['biomass_label'],'Modelo Biomasa',self.params.get('biomass',{}),self.r2.get('biomass',np.nan),self.rmse.get('biomass',np.nan)),
417
  (ax2,substrate,y_pred_s,substrate_std,axis_labels['substrate_label'],'Modelo Sustrato',self.params.get('substrate',{}),self.r2.get('substrate',np.nan),self.rmse.get('substrate',np.nan)),
418
  (ax3,product,y_pred_p,product_std,axis_labels['product_label'],'Modelo Producto',self.params.get('product',{}),self.r2.get('product',np.nan),self.rmse.get('product',np.nan))]
419
 
420
- for i,(ax,data,y_pred_curve,std,ylab,leg_lab,p_dict,r2,rmse) in enumerate(plot_cfg):
 
421
  if data is not None and len(data)>0 and not np.all(np.isnan(data)):
422
  if show_error_bars and std is not None and len(std)==len(data) and not np.all(np.isnan(std)):
423
  ax.errorbar(time,data,yerr=std,fmt=marker_style,color=point_color,label='Datos experimentales',capsize=error_cap_size,elinewidth=error_line_width,markeredgewidth=1,markersize=5)
424
  else: ax.plot(time,data,marker=marker_style,linestyle='',color=point_color,label='Datos experimentales',markersize=5)
425
- else: ax.text(0.5,0.5,'No hay datos experimentales.',transform=ax.transAxes,ha='center',va='center',color='gray')
426
 
 
427
  if y_pred_curve is not None and len(y_pred_curve)>0 and not np.all(np.isnan(y_pred_curve)):
428
  ax.plot(time_curves,y_pred_curve,linestyle=line_style,color=line_color,label=leg_lab)
429
- elif i == 0 and y_pred_biomass_fit is None: # Biomass model failed at curve_fit stage
430
  ax.text(0.5, 0.6, 'Modelo de biomasa no ajustado.', transform=ax.transAxes, ha='center', va='center', color='red', fontsize=9)
431
- elif i > 0 and (y_pred_biomass_fit is None or not self.params.get('biomass')): # S/P model depends on biomass
432
  ax.text(0.5, 0.4, 'No ajustado (depende de biomasa).', transform=ax.transAxes, ha='center', va='center', color='orange', fontsize=9)
433
- elif y_pred_curve is None or np.all(np.isnan(y_pred_curve)): # Specific S/P model failed (e.g., NaN from its own fit or ODE)
434
  ax.text(0.5, 0.4, 'Modelo no ajustado o resultado inválido.', transform=ax.transAxes, ha='center', va='center', color='orange', fontsize=9)
435
 
436
  ax.set_ylabel(ylab); ax.set_title(ylab)
437
  if show_legend: ax.legend(loc=legend_position)
438
  if show_params and p_dict and any(np.isfinite(v) for v in p_dict.values()):
439
  p_txt='\n'.join([f"{k}={v:.3g}" if np.isfinite(v) else f"{k}=N/A" for k,v in p_dict.items()])
440
- txt=f"{p_txt}\nR²={r2:.3f if np.isfinite(r2) else 'N/A'}\nRMSE={rmse:.3f if np.isfinite(rmse) else 'N/A'}"
441
  if params_position=='outside right':
442
- fig.subplots_adjust(right=0.70) # Make more space
443
  ax.annotate(txt,xy=(1.05,0.5),xycoords='axes fraction',xytext=(10,0),textcoords='offset points',va='center',ha='left',bbox={'boxstyle':'round,pad=0.3','facecolor':'wheat','alpha':0.7}, fontsize=8)
444
  else:
445
  tx,ha=(0.95,'right') if 'right' in params_position else (0.05,'left')
@@ -449,7 +579,7 @@ class BioprocessModel:
449
 
450
  ax3.set_xlabel(axis_labels['x_label'])
451
  plt.tight_layout(rect=[0,0.03,1,0.95]);
452
- if params_position == 'outside right': fig.subplots_adjust(right=0.70) # Re-apply after tight_layout
453
 
454
  buf=io.BytesIO(); fig.savefig(buf,format='png',bbox_inches='tight'); buf.seek(0)
455
  image=Image.open(buf).convert("RGB"); plt.close(fig); return image
@@ -459,7 +589,7 @@ class BioprocessModel:
459
  biomass_std=None, substrate_std=None, product_std=None,
460
  experiment_name='', legend_position='best', params_position='upper right',
461
  show_legend=True, show_params=True, style='whitegrid',
462
- line_color='#0072B2', point_color='#D55E00', line_style='-', marker_style='o', # These are defaults, overridden by palette
463
  use_differential=False, axis_labels=None,
464
  show_error_bars=True, error_cap_size=3, error_line_width=1):
465
 
@@ -467,7 +597,10 @@ class BioprocessModel:
467
  time_curves = self.generate_fine_time_grid(time)
468
  X_ode_success = False
469
 
470
- if y_pred_biomass_fit is None and not (use_differential and self.biomass_diff is not None and 'biomass' in self.params and self.params['biomass']): return None
 
 
 
471
  can_use_ode = use_differential and self.biomass_diff is not None and 'biomass' in self.params and self.params['biomass']
472
  if axis_labels is None: axis_labels = {'x_label':'Tiempo','biomass_label':'Biomasa','substrate_label':'Sustrato','product_label':'Producto'}
473
  sns.set_style(style)
@@ -478,7 +611,7 @@ class BioprocessModel:
478
  if X_ode_res is not None:
479
  y_pred_b,y_pred_s,y_pred_p,time_curves = X_ode_res,S_ode_res,P_ode_res,time_fine_ode
480
  X_ode_success = True
481
- else: # Fallback
482
  if y_pred_biomass_fit is not None and self.biomass_model and 'biomass' in self.params and self.params['biomass']:
483
  b_params=list(self.params['biomass'].values()); y_pred_b=self.biomass_model(time_curves,*b_params)
484
  if y_pred_substrate_fit is not None and 'substrate' in self.params and self.params.get('substrate'): s_params=list(self.params['substrate'].values()); y_pred_s=self.substrate(time_curves,*s_params,b_params)
@@ -496,7 +629,7 @@ class BioprocessModel:
496
  else: y_pred_b,y_pred_s,y_pred_p = (np.full_like(time_curves,np.nan) for _ in range(3))
497
 
498
  fig,ax1=plt.subplots(figsize=(12,7))
499
- title_suffix = " - EDO" if X_ode_success else (" - Ajuste Directo" if not use_differential or self.biomass_diff is None else " - EDO (falló, usando ajuste)")
500
  fig.suptitle(f'{experiment_name} ({self.model_type.capitalize()}){title_suffix}',fontsize=16)
501
 
502
  colors = sns.color_palette("tab10", 3)
@@ -534,17 +667,17 @@ class BioprocessModel:
534
 
535
  if show_params:
536
  all_param_text = []
537
- for cat_label, p_dict, r2, rmse in [
538
  (axis_labels['biomass_label'], self.params.get('biomass',{}), self.r2.get('biomass',np.nan), self.rmse.get('biomass',np.nan)),
539
  (axis_labels['substrate_label'], self.params.get('substrate',{}), self.r2.get('substrate',np.nan), self.rmse.get('substrate',np.nan)),
540
  (axis_labels['product_label'], self.params.get('product',{}), self.r2.get('product',np.nan), self.rmse.get('product',np.nan))]:
541
  if p_dict and any(np.isfinite(v) for v in p_dict.values()):
542
  p_list = [f" {k}={v:.3g}" if np.isfinite(v) else f" {k}=N/A" for k,v in p_dict.items()]
543
- all_param_text.append(f"{cat_label}:\n" + "\n".join(p_list) + f"\n R²={r2:.3f if np.isfinite(r2) else 'N/A'}\n RMSE={rmse:.3f if np.isfinite(rmse) else 'N/A'}")
544
  total_text = "\n\n".join(all_param_text)
545
  if total_text:
546
  if params_position=='outside right':
547
- fig.subplots_adjust(right=0.65) # Make more space
548
  fig.text(0.67,0.5,total_text,transform=fig.transFigure,va='center',ha='left',bbox=dict(boxstyle='round,pad=0.3',facecolor='wheat',alpha=0.7),fontsize=7)
549
  else:
550
  tx,ha=(0.95,'right') if 'right' in params_position else (0.05,'left')
@@ -557,9 +690,10 @@ class BioprocessModel:
557
  buf=io.BytesIO(); fig.savefig(buf,format='png',bbox_inches='tight'); buf.seek(0)
558
  image=Image.open(buf).convert("RGB"); plt.close(fig); return image
559
 
 
560
  def sanitize_filename(name, max_length=100):
561
- name = str(name) # Ensure it's a string
562
- name = re.sub(r'[^\w\s-]', '', name).strip()
563
  name = re.sub(r'[-\s]+', '_', name)
564
  return name[:max_length]
565
 
@@ -586,7 +720,7 @@ def process_all_data(file, legend_position, params_position, model_types_selecte
586
  for sheet_name_idx, sheet_name in enumerate(sheet_names):
587
  current_sheet_name_base = (experiment_names_list[sheet_name_idx]
588
  if sheet_name_idx < len(experiment_names_list) and experiment_names_list[sheet_name_idx]
589
- else f"Hoja_{sanitize_filename(sheet_name, 15)}") # Sanitize sheet name for use in exp name
590
  try:
591
  df = pd.read_excel(xls, sheet_name=sheet_name, header=[0, 1])
592
  if df.empty: all_plot_messages.append(f"Hoja '{sheet_name}' vacía."); continue
@@ -602,70 +736,76 @@ def process_all_data(file, legend_position, params_position, model_types_selecte
602
  if mode == 'independent':
603
  unique_exp_groups = df.columns.get_level_values(0).unique()
604
  for exp_group_idx, exp_group_name in enumerate(unique_exp_groups):
605
- # Sanitize exp_group_name for use in experiment identifier
606
  sanitized_exp_group_name = sanitize_filename(exp_group_name, 20)
607
  current_experiment_name = f"{current_sheet_name_base}_{sanitized_exp_group_name}"
608
 
609
  exp_df_slice_multi = df[exp_group_name]
610
  try:
611
- time_exp_series_candidates = [col for col in exp_df_slice_multi.columns if col[0] == 'Tiempo'] if isinstance(exp_df_slice_multi.columns, pd.MultiIndex) else \
612
- [('Tiempo',)] if 'Tiempo' in exp_df_slice_multi.columns else []
613
-
614
- if not time_exp_series_candidates:
615
- all_plot_messages.append(f"No se encontró 'Tiempo' en {current_experiment_name}"); continue
616
 
617
- time_exp_col_name = 'Tiempo' # Simplified assumption for slice
618
- if isinstance(exp_df_slice_multi[time_exp_col_name], pd.DataFrame):
619
- time_exp = exp_df_slice_multi[time_exp_col_name].iloc[:,0].dropna().astype(float).values
620
- else:
621
- time_exp = exp_df_slice_multi[time_exp_col_name].dropna().astype(float).values
622
-
623
-
624
- def get_comp_data_independent(component_name):
625
- if component_name in exp_df_slice_multi:
626
- s_df=exp_df_slice_multi[component_name]
627
- if isinstance(s_df,pd.DataFrame): # Has replicates (e.g., R1, R2 columns)
628
- return s_df.mean(axis=1).dropna().astype(float).values,s_df.std(axis=1,ddof=1).dropna().astype(float).values
629
- return s_df.dropna().astype(float).values,None # Single series
 
 
 
 
630
  return np.array([]),None
631
 
632
  biomass_exp,biomass_std_exp=get_comp_data_independent('Biomasa')
633
  substrate_exp,substrate_std_exp=get_comp_data_independent('Sustrato')
634
  product_exp,product_std_exp=get_comp_data_independent('Producto')
635
 
 
636
  min_len=len(time_exp)
637
  if len(biomass_exp)>0:min_len=min(min_len,len(biomass_exp))
638
- else: all_plot_messages.append(f"Sin datos de biomasa para {current_experiment_name}."); # continue or allow processing without biomass
639
  if len(substrate_exp)>0:min_len=min(min_len,len(substrate_exp))
640
  if len(product_exp)>0:min_len=min(min_len,len(product_exp))
641
 
642
  time_exp=time_exp[:min_len]
643
  if len(biomass_exp)>0:biomass_exp=biomass_exp[:min_len]
 
644
  if biomass_std_exp is not None and len(biomass_std_exp)>0:biomass_std_exp=biomass_std_exp[:min_len]
 
645
  if len(substrate_exp)>0:substrate_exp=substrate_exp[:min_len]
 
646
  if substrate_std_exp is not None and len(substrate_std_exp)>0:substrate_std_exp=substrate_std_exp[:min_len]
 
647
  if len(product_exp)>0:product_exp=product_exp[:min_len]
 
648
  if product_std_exp is not None and len(product_std_exp)>0:product_std_exp=product_std_exp[:min_len]
649
 
650
-
651
  if len(time_exp)==0: all_plot_messages.append(f"Sin datos de tiempo para {current_experiment_name}."); continue
652
- if len(biomass_exp)==0: # Critical for most models
653
  all_plot_messages.append(f"Sin datos de biomasa para {current_experiment_name}, no se puede ajustar el modelo de biomasa.")
654
  for mt_ in model_types_selected: comparison_data.append({'Experimento':current_experiment_name,'Modelo':mt_.capitalize(),'R² Biomasa':np.nan,'RMSE Biomasa':np.nan})
655
  continue
656
-
657
  except KeyError as e_key: all_plot_messages.append(f"Falta columna {e_key} en '{current_experiment_name}'."); continue
658
  except Exception as e_data: all_plot_messages.append(f"Error procesando datos para '{current_experiment_name}': {e_data}."); continue
659
 
660
  for model_type_iter in model_types_selected:
661
  model_instance = BioprocessModel(model_type=model_type_iter, maxfev=maxfev_val)
662
  model_instance.fit_model()
663
- y_pred_biomass = model_instance.fit_biomass(time_exp, biomass_exp)
664
  y_pred_substrate, y_pred_product = None, None
665
  if y_pred_biomass is not None and model_instance.params.get('biomass'):
666
  if len(substrate_exp)>0: y_pred_substrate = model_instance.fit_substrate(time_exp, substrate_exp, model_instance.params['biomass'])
667
  if len(product_exp)>0: y_pred_product = model_instance.fit_product(time_exp, product_exp, model_instance.params['biomass'])
668
- else: all_plot_messages.append(f"Ajuste de biomasa falló para {current_experiment_name}, modelo {model_type_iter}.")
 
669
 
670
  all_parameters_collected.setdefault(current_experiment_name, {})[model_type_iter] = model_instance.params
671
  comparison_data.append({'Experimento':current_experiment_name,'Modelo':model_type_iter.capitalize(),
@@ -688,11 +828,24 @@ def process_all_data(file, legend_position, params_position, model_types_selecte
688
  substrate_std_avg = model_dummy_for_sheet.datas_std[-1] if model_dummy_for_sheet.datas_std and model_dummy_for_sheet.datas_std[-1] is not None and len(model_dummy_for_sheet.datas_std[-1]) == len(substrate_avg) else None
689
  product_std_avg = model_dummy_for_sheet.datap_std[-1] if model_dummy_for_sheet.datap_std and model_dummy_for_sheet.datap_std[-1] is not None and len(model_dummy_for_sheet.datap_std[-1]) == len(product_avg) else None
690
 
691
- if time_avg is None or len(time_avg)==0: all_plot_messages.append(f"Sin datos de tiempo promedio para '{current_sheet_name_base}'."); continue
 
 
 
 
692
  if len(biomass_avg)==0:
693
- all_plot_messages.append(f"Sin datos de biomasa promedio para '{current_sheet_name_base}'.")
694
  for mt_ in model_types_selected: comparison_data.append({'Experimento':current_experiment_name,'Modelo':mt_.capitalize(),'R² Biomasa':np.nan,'RMSE Biomasa':np.nan})
695
- continue
 
 
 
 
 
 
 
 
 
696
 
697
  for model_type_iter in model_types_selected:
698
  model_instance = BioprocessModel(model_type=model_type_iter, maxfev=maxfev_val)
@@ -702,7 +855,8 @@ def process_all_data(file, legend_position, params_position, model_types_selecte
702
  if y_pred_biomass is not None and model_instance.params.get('biomass'):
703
  if len(substrate_avg)>0: y_pred_substrate = model_instance.fit_substrate(time_avg, substrate_avg, model_instance.params['biomass'])
704
  if len(product_avg)>0: y_pred_product = model_instance.fit_product(time_avg, product_avg, model_instance.params['biomass'])
705
- else: all_plot_messages.append(f"Ajuste biomasa promedio falló: {current_experiment_name}, {model_type_iter}.")
 
706
 
707
  all_parameters_collected.setdefault(current_experiment_name, {})[model_type_iter] = model_instance.params
708
  comparison_data.append({'Experimento':current_experiment_name,'Modelo':model_type_iter.capitalize(),
@@ -721,17 +875,21 @@ def process_all_data(file, legend_position, params_position, model_types_selecte
721
  cols_to_sort = ['R² Biomasa', 'R² Sustrato', 'R² Producto', 'RMSE Biomasa', 'RMSE Sustrato', 'RMSE Producto']
722
  existing_cols_to_sort = [col for col in cols_to_sort if col in comparison_df.columns]
723
  ascending_map = {'R²': False, 'RMSE': True}
724
- sort_ascending = [True, True] + [ascending_map[col.split(' ')[0]] for col in existing_cols_to_sort]
725
  comparison_df_sorted = comparison_df.sort_values(by=['Experimento','Modelo']+existing_cols_to_sort,ascending=sort_ascending).reset_index(drop=True)
726
  else: comparison_df_sorted = pd.DataFrame(columns=['Experimento','Modelo','R² Biomasa','RMSE Biomasa','R² Sustrato','RMSE Sustrato','R² Producto','RMSE Producto'])
727
 
728
  final_message = "Procesamiento completado."
729
- if all_plot_messages: final_message += " Mensajes:\n" + "\n".join(list(set(all_plot_messages))) # Unique messages
730
  if not figures_with_names and not comparison_df_sorted.empty: final_message += "\nNo se generaron gráficos, pero hay datos en la tabla."
731
- elif not figures_with_names and comparison_df_sorted.empty: final_message += "\nNo se generaron gráficos ni datos para la tabla."
 
732
 
733
  return figures_with_names, comparison_df_sorted, final_message, all_parameters_collected
734
 
 
 
 
735
 
736
  MODEL_CHOICES = [("Logistic (3-parám)","logistic"),("Gompertz (3-parám)","gompertz"),("Moser (3-parám)","moser"),("Baranyi (4-parám)","baranyi")]
737
 
@@ -744,73 +902,40 @@ def create_zip_of_images(figures_with_names_list, base_zip_filename="plots"):
744
  for item_idx, item in enumerate(figures_with_names_list):
745
  img_pil = item['image']
746
  img_name_suggestion = item['name']
747
-
748
- # Sanitize and ensure uniqueness for filename within zip
749
  base_name, ext = os.path.splitext(img_name_suggestion)
750
- if not ext: ext = ".png" # Default to png if no extension
751
-
752
- sanitized_base = sanitize_filename(base_name, max_length=80) # Shorter max for base before adding index
753
  img_name_in_zip = f"{sanitized_base}{ext}"
754
-
755
- # Ensure unique name within zip, in case sanitization leads to clashes
756
  count = 1
757
  original_sanitized_base = sanitized_base
758
  while img_name_in_zip in zf.namelist():
759
  img_name_in_zip = f"{original_sanitized_base}_{count}{ext}"
760
  count += 1
761
- if count > len(figures_with_names_list) + 5: # Safety break
762
- img_name_in_zip = f"{original_sanitized_base}_{item_idx}_{count}{ext}" # Add original index for more uniqueness
763
  break
764
-
765
-
766
  img_byte_arr = io.BytesIO()
767
  img_pil.save(img_byte_arr, format='PNG')
768
  img_byte_arr.seek(0)
769
  zf.writestr(img_name_in_zip, img_byte_arr.getvalue())
770
-
771
  zip_buffer.seek(0)
772
-
773
  try:
774
- # Gradio's gr.File component handles the temporary file lifecycle if given bytes or a BytesIO object directly.
775
- # However, to ensure it has a .zip extension for download, creating a named temp file is safer.
776
  with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp_zip_file:
777
  tmp_zip_file.write(zip_buffer.getvalue())
778
  return tmp_zip_file.name, "ZIP con imágenes generado exitosamente."
779
  except Exception as e:
780
  return None, f"Error creando archivo ZIP temporal: {str(e)}"
781
 
782
-
783
  def create_interface():
784
  with gr.Blocks(theme=gr.themes.Soft()) as demo:
785
  gr.Markdown("# Modelos Cinéticos de Bioprocesos")
786
-
787
  with gr.Tab("Teoría y Uso"):
788
  gr.Markdown(r"""
789
- Análisis y visualización de datos de bioprocesos utilizando modelos cinéticos como Logístico, Gompertz y Moser para el crecimiento de biomasa,
790
- y el modelo de Luedeking-Piret para el consumo de sustrato y la formación de producto.
791
- Nuevos modelos como Baranyi (4 parámetros) han sido añadidos.
792
-
793
- **Instrucciones de Uso:**
794
- 1. **Subir archivo Excel (.xlsx):**
795
- El archivo debe tener una estructura de MultiIndex en las columnas:
796
- - Nivel 0: Nombre del experimento/tratamiento (ej: "Control", "Tratamiento A").
797
- - Nivel 1: Tipo de dato ("Tiempo", "Biomasa", "Sustrato", "Producto").
798
- - Nivel 2 (Opcional): Identificador de réplica (ej: "R1", "R2"). Si se omite, se asume una sola serie.
799
- 2. **Seleccionar Modelo(s) de Biomasa.**
800
- 3. **Elegir Modo de Análisis.**
801
- 4. **Configurar Opciones.**
802
- 5. **Ejecutar:** Haz clic en "Simular y Graficar".
803
- 6. **Resultados:** Visualiza los gráficos y la tabla. Exporta la tabla (Excel/CSV), los parámetros del modelo (Excel) o todas las imágenes generadas (ZIP).
804
  """)
805
  gr.Markdown(r"""
806
- ## Modelos Matemáticos para Bioprocesos
807
- **1. Modelo Logístico (3p):** $ X(t) = \frac{X_0 X_m e^{\mu_m t}}{X_m - X_0 + X_0 e^{\mu_m t}} $
808
- **2. Modelo Gompertz (3p):** $ X(t) = X_m \exp\left(-\exp\left(\frac{\mu_m e}{X_m}(\lambda-t)+1\right)\right) $
809
- **3. Modelo de Moser (simplificado, 3p):** $ X(t)=X_m(1-e^{-\mu_m(t-K_s)}) $
810
- **4. Modelo de Baranyi (4p):** $ \ln X(t) = \ln X_0 + \mu_m A(t) - \ln\left(1 + \frac{e^{\mu_m A(t)}-1}{X_m/X_0}\right) $
811
- **Luedeking-Piret (Sustrato y Producto):** $ \frac{dS}{dt} = -p \frac{dX}{dt} - q X \quad ; \quad \frac{dP}{dt} = \alpha \frac{dX}{dt} + \beta X $
812
  """)
813
-
814
  with gr.Tab("Simulación"):
815
  with gr.Row():
816
  file_input = gr.File(label="Subir archivo Excel (.xlsx)", file_types=['.xlsx'])
@@ -845,29 +970,26 @@ def create_interface():
845
  with gr.Accordion("Configuración Avanzada de Ajuste (No implementado aún)", open=False):
846
  lower_bounds_str_ui = gr.Textbox(label="Lower Bounds (JSON, no usado)", lines=3)
847
  upper_bounds_str_ui = gr.Textbox(label="Upper Bounds (JSON, no usado)", lines=3)
848
-
849
  simulate_btn = gr.Button("Simular y Graficar", variant="primary")
850
 
851
  with gr.Tab("Resultados"):
852
- status_message_ui = gr.Textbox(label="Estado del Procesamiento", interactive=False, lines=2)
853
  output_gallery_ui = gr.Gallery(label="Resultados Gráficos", columns=[2,1], height=600, object_fit="contain", preview=True)
854
- output_table_ui = gr.Dataframe( # REMOVED height=400
855
  label="Tabla Comparativa de Modelos",
856
  headers=["Experimento","Modelo","R² Biomasa","RMSE Biomasa","R² Sustrato","RMSE Sustrato","R² Producto","RMSE Producto"],
857
  interactive=False,
858
  wrap=True
 
859
  )
860
-
861
  state_df_ui = gr.State(pd.DataFrame())
862
  state_params_ui = gr.State({})
863
  state_figures_ui = gr.State([])
864
-
865
  with gr.Row():
866
  export_excel_btn = gr.Button("Exportar Tabla a Excel")
867
  export_csv_btn = gr.Button("Exportar Tabla a CSV")
868
  export_params_btn = gr.Button("Exportar Parámetros a Excel")
869
  export_images_zip_btn = gr.Button("Descargar Imágenes (ZIP)")
870
-
871
  download_file_output_ui = gr.File(label="Descargar Tabla/Parámetros", interactive=False)
872
  download_zip_images_ui = gr.File(label="Descargar ZIP de Imágenes", interactive=False)
873
 
@@ -880,16 +1002,13 @@ def create_interface():
880
  if file is None: return [], pd.DataFrame(), "Error: Sube un archivo Excel.", pd.DataFrame(), {}, []
881
  axis_labels = {'x_label':x_label or 'Tiempo','biomass_label':biomass_label or 'Biomasa','substrate_label':substrate_label or 'Sustrato','product_label':product_label or 'Producto'}
882
  if not models_sel: return [], pd.DataFrame(), "Error: Selecciona un modelo.", pd.DataFrame(), {}, []
883
-
884
  figures_with_names, comparison_df, message, collected_params = process_all_data(
885
  file, legend_pos, params_pos, models_sel, exp_names, low_bounds_str, up_bounds_str,
886
  analysis_mode, plot_style, line_col, point_col, line_sty, marker_sty,
887
  show_leg, show_par, use_diff, int(maxfev), axis_labels,
888
- show_error_bars_arg, error_cap_size_arg, error_line_width_arg
889
- )
890
  pil_images_for_gallery = [item['image'] for item in figures_with_names] if figures_with_names else []
891
  return pil_images_for_gallery, comparison_df, message, comparison_df, collected_params, figures_with_names
892
-
893
  simulate_btn.click(
894
  fn=run_simulation_interface,
895
  inputs=[file_input, legend_position_ui, params_position_ui, model_types_selected_ui, mode, experiment_names_str_ui,
@@ -897,15 +1016,14 @@ def create_interface():
897
  line_style_dropdown_ui, marker_style_dropdown_ui, show_legend_ui, show_params_ui, use_differential_ui,
898
  maxfev_input_ui, x_axis_label_input_ui, biomass_axis_label_input_ui, substrate_axis_label_input_ui,
899
  product_axis_label_input_ui, show_error_bars_ui, error_cap_size_ui, error_line_width_ui],
900
- outputs=[output_gallery_ui, output_table_ui, status_message_ui, state_df_ui, state_params_ui, state_figures_ui]
901
- )
902
 
903
  def export_df_to_file(df_to_export, file_format="excel"):
904
  if df_to_export is None or df_to_export.empty:
905
  with tempfile.NamedTemporaryFile(suffix=".txt",delete=False,mode="w",encoding="utf-8") as tmp: tmp.write("No hay datos en la tabla para exportar."); return tmp.name,"No hay datos para exportar."
906
  try:
907
  suffix=".xlsx" if file_format=="excel" else ".csv"
908
- with tempfile.NamedTemporaryFile(suffix=suffix,delete=False,mode='w+b' if file_format=="excel" else 'w', encoding=None if file_format=="excel" else "utf-8-sig") as tmp_f: # utf-8-sig for CSV with BOM
909
  if file_format=="excel": df_to_export.to_excel(tmp_f.name,index=False)
910
  else: df_to_export.to_csv(tmp_f.name,index=False)
911
  return tmp_f.name,f"Tabla exportada a {suffix[1:]} exitosamente."
@@ -923,10 +1041,10 @@ def create_interface():
923
  for exp_name,models_data in params_state_dict.items():
924
  for model_type_name,all_params_for_model_type in models_data.items():
925
  for param_category,category_params in all_params_for_model_type.items():
926
- if category_params and isinstance(category_params,dict) and category_params: # Ensure params exist
927
  df_params=pd.DataFrame({'Parámetro':list(category_params.keys()),'Valor':list(category_params.values())})
928
  s_exp=sanitize_filename(exp_name,15); s_mod=sanitize_filename(model_type_name,10); s_cat=sanitize_filename(param_category,4)
929
- sheet_name=f"{s_exp}_{s_mod}_{s_cat}"[:31] # Excel sheet name limit
930
  orig_sn,c=sheet_name,1
931
  while sheet_name in writer.sheets: sheet_name=f"{orig_sn[:28]}_{c}"[:31]; c+=1;
932
  df_params.to_excel(writer,sheet_name=sheet_name,index=False)
@@ -950,7 +1068,5 @@ def create_interface():
950
  return demo
951
 
952
  if __name__ == '__main__':
953
- # Ensure Gradio is installed, or run: pip install gradio pandas matplotlib seaborn scikit-learn openpyxl Pillow
954
- # The os.system call for pip install is generally not recommended within scripts for production/distribution.
955
  demo_instance = create_interface()
956
- demo_instance.launch(share=False, debug=True)
 
1
  import os
2
+ # os.system("pip install --upgrade gradio") # Best to manage dependencies externally
 
 
3
 
4
  from pydantic import BaseModel
5
  import numpy as np
 
25
  self.params = {}
26
  self.r2 = {}
27
  self.rmse = {}
28
+ self.datax = [] # Stores list of 2D numpy arrays (replicates per experiment/sheet)
29
  self.datas = []
30
  self.datap = []
31
+ self.dataxp = [] # Stores list of 1D numpy arrays (averaged data per experiment/sheet)
32
  self.datasp = []
33
  self.datapp = []
34
+ self.datax_std = [] # Stores list of 1D numpy arrays (std dev of data per experiment/sheet)
35
  self.datas_std = []
36
  self.datap_std = []
37
  self.biomass_model = None
38
  self.biomass_diff = None
39
  self.model_type = model_type
40
  self.maxfev = maxfev
41
+ self.time = None # Stores the final time vector for the current processing context (sheet)
42
 
43
  @staticmethod
44
  def logistic(time, xo, xm, um):
45
+ if xm <= 0 or xo <= 0 or um <=0 or xm <= xo : # Added xm <= xo check
46
+ # xm must be > xo for growth. If xm=xo, implies X(t)=xo, which means um=0.
47
+ # If um > 0 and xm=xo, the denominator can be zero if xo*exp(um*t) term dominates.
48
  return np.full_like(time, np.nan)
49
  term_exp = np.exp(um * time)
50
  denominator = (xm - xo + xo * term_exp)
51
+ denominator = np.where(denominator == 0, 1e-9, denominator) # Avoid division by zero
52
  return (xo * xm * term_exp) / denominator
53
 
54
+
55
  @staticmethod
56
  def gompertz(time, xm, um, lag):
57
+ if xm <= 0 or um <=0 : # lag can be 0 or positive
58
  return np.full_like(time, np.nan)
59
  exp_term = (um * np.e / xm) * (lag - time) + 1
60
+ exp_term_clipped = np.clip(exp_term, -np.inf, 700) # Avoid overflow in np.exp(np.exp())
61
  return xm * np.exp(-np.exp(exp_term_clipped))
62
 
63
  @staticmethod
64
  def moser(time, Xm, um, Ks):
65
  if Xm <=0 or um <=0: return np.full_like(time, np.nan)
66
+ # Ks can be negative, allowing for an initial lag or even growth before t=0 if extrapolated
67
  return Xm * (1 - np.exp(-um * (time - Ks)))
68
 
69
  @staticmethod
70
  def baranyi(time, X0, Xm, um, lag):
71
+ if X0 <= 0 or Xm <= X0 or um <= 0 or lag < 0: # lag should be non-negative, Xm > X0
72
  return np.full_like(time, np.nan)
73
+
74
  log_arg_A = np.exp(-um * time) + np.exp(-um * lag) - np.exp(-um * (time + lag))
75
+ log_arg_A = np.where(log_arg_A <= 1e-9, 1e-9, log_arg_A) # Prevent log(0 or negative)
76
  A_t = time + (1 / um) * np.log(log_arg_A)
77
+
78
  exp_um_At = np.exp(um * A_t)
79
+ exp_um_At_clipped = np.clip(exp_um_At, -np.inf, 700) # Clamp large values
80
+
81
  numerator = Xm * exp_um_At_clipped
82
  denominator = (Xm / X0 - 1) + exp_um_At_clipped
83
+ denominator = np.where(denominator == 0, 1e-9, denominator) # Avoid division by zero
84
+
85
  return numerator / denominator
86
 
87
  @staticmethod
88
  def logistic_diff(X, t, params):
89
+ # params for logistic_diff: [xo_initial_condition, xm, um]
90
+ # xo is not used in diff eq itself, but xm and um are parameters of the DE.
91
  _, xm, um = params
92
  if xm == 0: return 0
93
  return um * X * (1 - X / xm)
94
 
95
  @staticmethod
96
  def gompertz_diff(X, t, params):
97
+ # params for gompertz_diff: [xm, um, lag]
98
  xm, um, lag = params
99
+ if xm == 0 or X <= 1e-9 : return 0 # Avoid log(0) or division by zero if Xm or X is zero
100
  k_val = um * np.e / xm
101
  u_val = k_val * (lag - t) + 1
102
+ u_val_clipped = np.clip(u_val, -np.inf, 700) # Avoid overflow in exp
103
  return X * k_val * np.exp(u_val_clipped)
104
 
105
  @staticmethod
106
  def moser_diff(X, t, params):
107
+ # params for moser_diff: [Xm, um, Ks]
108
+ Xm, um, _ = params # Ks is not directly in this simplified dX/dt
109
  return um * (Xm - X)
110
 
111
  def substrate(self, time, so, p, q, biomass_params_list):
 
113
  return np.full_like(time, np.nan)
114
  X_t = self.biomass_model(time, *biomass_params_list)
115
  if np.any(np.isnan(X_t)): return np.full_like(time, np.nan)
116
+
117
  integral_X = np.zeros_like(X_t)
118
  if len(time) > 1:
119
+ # Use trapz for better integral approximation if scipy is available, else simple cumsum
120
+ try:
121
+ from scipy.integrate import cumulative_trapezoid
122
+ if len(time) > 1 and len(X_t) > 1:
123
+ integral_X[1:] = cumulative_trapezoid(X_t, time, initial=0)
124
+ else: # Fallback for single point or if lengths mismatch unexpectedly
125
+ dt = np.diff(time, prepend=time[0] - (time[1]-time[0] if len(time)>1 else 1))
126
+ integral_X = np.cumsum(X_t * dt)
127
+ except ImportError:
128
+ dt = np.diff(time, prepend=time[0] - (time[1]-time[0] if len(time)>1 else 1))
129
+ integral_X = np.cumsum(X_t * dt)
130
+
131
+
132
+ X0_calc = 0.0
133
  if self.model_type in ['logistic', 'baranyi']: X0_calc = biomass_params_list[0]
134
+ elif self.model_type == 'gompertz': X0_calc = self.gompertz(0, *biomass_params_list) # X(t=0)
135
+ elif self.model_type == 'moser': X0_calc = self.moser(0, *biomass_params_list) # X(t=0)
136
+ else: X0_calc = X_t[0] if len(X_t)>0 else 0.0
137
+
138
+ X0 = X0_calc if not np.isnan(X0_calc) else (biomass_params_list[0] if biomass_params_list and len(biomass_params_list)>0 else 0.0)
139
  return so - p * (X_t - X0) - q * integral_X
140
 
141
  def product(self, time, po, alpha, beta, biomass_params_list):
 
143
  return np.full_like(time, np.nan)
144
  X_t = self.biomass_model(time, *biomass_params_list)
145
  if np.any(np.isnan(X_t)): return np.full_like(time, np.nan)
146
+
147
  integral_X = np.zeros_like(X_t)
148
  if len(time) > 1:
149
+ try:
150
+ from scipy.integrate import cumulative_trapezoid
151
+ if len(time) > 1 and len(X_t) > 1:
152
+ integral_X[1:] = cumulative_trapezoid(X_t, time, initial=0)
153
+ else:
154
+ dt = np.diff(time, prepend=time[0] - (time[1]-time[0] if len(time)>1 else 1))
155
+ integral_X = np.cumsum(X_t * dt)
156
+ except ImportError:
157
+ dt = np.diff(time, prepend=time[0] - (time[1]-time[0] if len(time)>1 else 1))
158
+ integral_X = np.cumsum(X_t * dt)
159
+
160
+ X0_calc = 0.0
161
  if self.model_type in ['logistic', 'baranyi']: X0_calc = biomass_params_list[0]
162
  elif self.model_type == 'gompertz': X0_calc = self.gompertz(0, *biomass_params_list)
163
  elif self.model_type == 'moser': X0_calc = self.moser(0, *biomass_params_list)
164
+ else: X0_calc = X_t[0] if len(X_t)>0 else 0.0
165
+
166
+ X0 = X0_calc if not np.isnan(X0_calc) else (biomass_params_list[0] if biomass_params_list and len(biomass_params_list)>0 else 0.0)
167
  return po + alpha * (X_t - X0) + beta * integral_X
168
 
169
  def process_data(self, df):
170
+ # Identify columns for Time, Biomass, Substrate, Product based on the second level of MultiIndex
171
  biomass_cols = [col for col in df.columns if isinstance(col, tuple) and len(col) > 1 and col[1] == 'Biomasa']
172
  substrate_cols = [col for col in df.columns if isinstance(col, tuple) and len(col) > 1 and col[1] == 'Sustrato']
173
  product_cols = [col for col in df.columns if isinstance(col, tuple) and len(col) > 1 and col[1] == 'Producto']
 
174
  time_col_candidates = [col for col in df.columns if isinstance(col, tuple) and len(col) > 1 and col[1] == 'Tiempo']
175
+
176
  if not time_col_candidates:
177
+ raise ValueError("La columna ('*', 'Tiempo', '*') no se encuentra en el DataFrame.")
178
+
179
+ # Use the time data from the first identified 'Tiempo' column as the reference time axis for the sheet
180
+ # This assumes that for 'average' or 'combinado' mode, all data on the sheet can be aligned to this time axis.
181
+ ref_time_col_tuple = time_col_candidates[0]
182
+ time_data_series_or_df = df[ref_time_col_tuple]
183
+
184
+ if isinstance(time_data_series_or_df, pd.DataFrame): # If 'Tiempo' itself has sub-columns (e.g., replicates of time, unusual)
185
+ time = time_data_series_or_df.iloc[:,0].dropna().values # Take the first sub-column
186
+ else: # Standard case: 'Tiempo' is a single series
187
+ time = time_data_series_or_df.dropna().values
188
+
189
+ if len(time) == 0:
190
+ raise ValueError("El vector de tiempo de referencia está vacío después de procesar NaNs.")
191
+
192
+ def process_component(component_cols, data_list_all_reps, data_list_avg, data_list_std, ref_time_vector):
193
+ if not component_cols: # No columns for this component
194
+ data_list_all_reps.append(np.array([])); data_list_avg.append(np.array([])); data_list_std.append(np.array([]))
195
+ return
196
+
197
+ all_component_series = []
198
+ for col_tuple in component_cols:
199
+ series = df[col_tuple].dropna()
200
+ if not series.empty:
201
+ # Interpolate each series to the reference time vector
202
+ # This handles cases where individual replicates have different time points or lengths
203
+ # We need to ensure that the series' index is compatible with ref_time_vector for interpolation
204
+ # Assuming series.index are time points corresponding to series.values
205
+
206
+ # For simplicity, if direct time mapping isn't available per replicate,
207
+ # we'll rely on aligning by length after dropna, as done previously.
208
+ # A more robust approach would involve having time columns for each replicate.
209
+ # Current structure: ('ExpA', 'Biomass', 'R1'), ('ExpA', 'Time', 'R1_Time') - this is not assumed by current code.
210
+ # It assumes ('ExpA', 'Time', 'SomeTimeName') is common or first one is taken.
211
+ all_component_series.append(series.values)
212
+
213
+ if not all_component_series: # All series were empty after dropna
214
+ data_list_all_reps.append(np.array([])); data_list_avg.append(np.array([])); data_list_std.append(np.array([]))
215
+ return
216
+
217
+ # Align all collected series to the minimum length among them
218
+ min_len = min(len(s) for s in all_component_series)
219
+ aligned_series_np = np.array([s[:min_len] for s in all_component_series])
220
+
221
+ data_list_all_reps.append(aligned_series_np) # Store all (aligned) replicates
222
+ data_list_avg.append(np.mean(aligned_series_np, axis=0))
223
+ data_list_std.append(np.std(aligned_series_np, axis=0, ddof=1 if aligned_series_np.shape[0] > 1 else 0))
224
+
225
+ process_component(biomass_cols, self.datax, self.dataxp, self.datax_std, time)
226
+ process_component(substrate_cols, self.datas, self.datasp, self.datas_std, time)
227
+ process_component(product_cols, self.datap, self.datapp, self.datap_std, time)
228
+
229
+ # Final alignment of averaged data (xp, sp, pp) with the reference time vector
230
+ # All averaged component data should now be conformable in length due to the alignment within process_component.
231
+ # Now, ensure they are all truncated to the length of the shortest *averaged* component series or the ref_time.
232
+
233
+ current_min_len = len(time)
234
+ if self.dataxp and len(self.dataxp[-1]) > 0: current_min_len = min(current_min_len, len(self.dataxp[-1]))
235
+ else: self.dataxp[-1] = np.array([]) # Ensure it's an empty array if no data
236
+
237
+ if self.datasp and len(self.datasp[-1]) > 0: current_min_len = min(current_min_len, len(self.datasp[-1]))
238
+ else: self.datasp[-1] = np.array([])
239
+
240
+ if self.datapp and len(self.datapp[-1]) > 0: current_min_len = min(current_min_len, len(self.datapp[-1]))
241
+ else: self.datapp[-1] = np.array([])
242
+
243
+ self.time = time[:current_min_len]
244
+
245
+ if len(self.dataxp[-1]) > current_min_len : self.dataxp[-1] = self.dataxp[-1][:current_min_len]
246
+ if self.datax_std and self.datax_std[-1] is not None and len(self.datax_std[-1]) > current_min_len: self.datax_std[-1] = self.datax_std[-1][:current_min_len]
247
+
248
+ if len(self.datasp[-1]) > current_min_len : self.datasp[-1] = self.datasp[-1][:current_min_len]
249
+ if self.datas_std and self.datas_std[-1] is not None and len(self.datas_std[-1]) > current_min_len: self.datas_std[-1] = self.datas_std[-1][:current_min_len]
250
+
251
+ if len(self.datapp[-1]) > current_min_len : self.datapp[-1] = self.datapp[-1][:current_min_len]
252
+ if self.datap_std and self.datap_std[-1] is not None and len(self.datap_std[-1]) > current_min_len: self.datap_std[-1] = self.datap_std[-1][:current_min_len]
253
+
254
+
255
+ def fit_model(self): # Sets self.biomass_model and self.biomass_diff
256
  if self.model_type == 'logistic':
257
  self.biomass_model = self.logistic; self.biomass_diff = self.logistic_diff
258
  elif self.model_type == 'gompertz':
 
260
  elif self.model_type == 'moser':
261
  self.biomass_model = self.moser; self.biomass_diff = self.moser_diff
262
  elif self.model_type == 'baranyi':
263
+ self.biomass_model = self.baranyi; self.biomass_diff = None # No ODE for Baranyi here
264
  else: raise ValueError(f"Modelo de biomasa desconocido: {self.model_type}")
265
 
266
  def fit_biomass(self, time, biomass):
267
  time = np.asarray(time, dtype=float); biomass = np.asarray(biomass, dtype=float)
268
+
269
+ # Ensure time and biomass are 1D and have the same length
270
+ if time.ndim > 1: time = time.flatten()
271
+ if biomass.ndim > 1: biomass = biomass.flatten()
272
+
273
  if len(time) != len(biomass):
274
+ print(f"Warning: Tiempo ({len(time)}) y biomasa ({len(biomass)}) tienen longitudes diferentes para {self.model_type}. Intentando alinear.")
275
  min_len = min(len(time), len(biomass)); time = time[:min_len]; biomass = biomass[:min_len]
276
+ if min_len < (4 if self.model_type == 'baranyi' else 3) :
277
+ print(f"No hay suficientes datos después de alinear para {self.model_type}.")
278
+ self.r2['biomass'] = np.nan; self.rmse['biomass'] = np.nan; return None
279
+
280
+ valid_indices = ~np.isnan(time) & ~np.isnan(biomass) & (biomass > 0) # Ensure biomass is positive for fitting log models
281
  time = time[valid_indices]; biomass = biomass[valid_indices]
282
+
283
  num_params = 4 if self.model_type == 'baranyi' else 3
284
  if len(time) < num_params:
285
+ print(f"No hay suficientes datos válidos y positivos ({len(time)}) para {self.model_type} ({num_params} params).")
286
  self.r2['biomass'] = np.nan; self.rmse['biomass'] = np.nan; return None
287
+
288
+ if len(biomass) == 0 : # Should be caught by len(time) < num_params if num_params > 0
289
+ print(f"Datos de biomasa vacíos para {self.model_type}.")
290
  self.r2['biomass'] = np.nan; self.rmse['biomass'] = np.nan; return None
291
+
292
+ # The check for biomass[0] <= 1e-9 is implicitly handled by (biomass > 0) in valid_indices
293
+ # If after that, len(time) is still too short, it's caught above.
294
+ # If biomass[0] was indeed the issue, it would be filtered out, potentially making the array too short.
295
+
296
  try:
297
  popt, y_pred = None, None
298
+ # Common settings for curve_fit
299
+ fit_kwargs = {'maxfev': self.maxfev, 'ftol': 1e-9, 'xtol': 1e-9, 'method': 'trf'}
300
+
301
  if self.model_type == 'logistic':
302
+ xo_g, xm_g, um_g = biomass[0], max(biomass)*1.1, 0.1
303
+ if xm_g <= xo_g: xm_g = xo_g * 1.5 if xo_g > 0 else 1.0 # Ensure Xm > Xo
304
+ p0=[xo_g, xm_g, um_g]
305
+ # Bounds: Xo > 0, Xm > Xo, um > 0
306
+ bounds=([1e-9, biomass[0]+1e-9, 1e-9],[max(biomass)*2, np.inf, np.inf]) # Looser Xm upper bound
307
+ p0[0]=np.clip(p0[0],bounds[0][0],bounds[1][0])
308
+ p0[1]=np.clip(p0[1],max(bounds[0][1],p0[0]+1e-9),bounds[1][1])
309
+ popt,_=curve_fit(self.logistic,time,biomass,p0=p0,bounds=bounds,**fit_kwargs)
310
  self.params['biomass']={'Xo':popt[0],'Xm':popt[1],'um':popt[2]}; y_pred=self.logistic(time,*popt)
311
+
312
  elif self.model_type == 'gompertz':
313
+ xm_g,um_g=max(biomass),0.1
314
+ # Simplified lag guess: time of first significant increase or first time point
315
+ lag_idx=np.where(biomass > biomass[0] * 1.1)[0] # 10% increase from initial
316
  lag_g=time[lag_idx[0]] if len(lag_idx)>0 and time[lag_idx[0]]>=0 else (time[0] if len(time)>0 and time[0]>=0 else 0)
317
+ p0=[xm_g,um_g,lag_g]
318
+ bounds=([biomass[0], 1e-9, 0],[np.inf, np.inf, max(time)*1.1 if len(time)>0 and max(time)>0 else 100])
319
+ popt,_=curve_fit(self.gompertz,time,biomass,p0=p0,bounds=bounds,**fit_kwargs)
320
  self.params['biomass']={'Xm':popt[0],'um':popt[1],'lag':popt[2]}; y_pred=self.gompertz(time,*popt)
321
+
322
  elif self.model_type == 'moser':
323
+ Xm_g,um_g,Ks_g=max(biomass),0.1,time[0] if len(time)>0 else 0
324
+ p0=[Xm_g,um_g,Ks_g]
325
+ bounds=([biomass[0],1e-9, -max(abs(time))*2 if len(time)>0 else -100],[np.inf,np.inf, max(abs(time))*2 if len(time)>0 else 100])
326
+ popt,_=curve_fit(self.moser,time,biomass,p0=p0,bounds=bounds,**fit_kwargs)
327
  self.params['biomass']={'Xm':popt[0],'um':popt[1],'Ks':popt[2]}; y_pred=self.moser(time,*popt)
328
+
329
  elif self.model_type == 'baranyi':
330
+ X0_g,Xm_g,um_g=biomass[0],max(biomass),0.1
331
+ if Xm_g<=X0_g: Xm_g=X0_g*1.5 if X0_g > 0 else 1.0
332
+ lag_idx_b=np.where(biomass > X0_g*1.1)[0]
333
  lag_g_b=time[lag_idx_b[0]] if len(lag_idx_b)>0 and time[lag_idx_b[0]]>=0 else (time[0] if len(time)>0 and time[0]>=0 else 0)
334
+ p0=[X0_g,Xm_g,um_g,lag_g_b]
335
+ bounds=([1e-9, biomass[0]+1e-9, 1e-9, 0],[max(biomass)*2, np.inf, np.inf, max(time)*1.1 if len(time)>0 and max(time)>0 else 100])
336
+ p0[0]=np.clip(p0[0],bounds[0][0],bounds[1][0])
337
+ p0[1]=np.clip(p0[1],max(bounds[0][1],p0[0]+1e-9),bounds[1][1])
338
+ p0[3]=np.clip(p0[3],bounds[0][3],bounds[1][3])
339
+ popt,_=curve_fit(self.baranyi,time,biomass,p0=p0,bounds=bounds,**fit_kwargs)
340
  self.params['biomass']={'X0':popt[0],'Xm':popt[1],'um':popt[2],'lag':popt[3]}; y_pred=self.baranyi(time,*popt)
341
+ else:
342
+ print(f"Modelo {self.model_type} no implementado para ajuste de biomasa.")
343
+ return None
344
+
345
  if y_pred is None or np.any(np.isnan(y_pred)) or np.any(np.isinf(y_pred)):
346
+ print(f"Predicción de biomasa contiene NaN/Inf para {self.model_type} después del ajuste. Ajuste fallido.")
347
  self.r2['biomass']=np.nan; self.rmse['biomass']=np.nan; self.params['biomass']={}; return None
348
+
349
  ss_res=np.sum((biomass-y_pred)**2); ss_tot=np.sum((biomass-np.mean(biomass))**2)
350
+ if ss_tot == 0: self.r2['biomass'] = 1.0 if ss_res < 1e-9 else 0.0
351
+ else: self.r2['biomass'] = 1 - (ss_res / ss_tot)
352
  self.rmse['biomass']=np.sqrt(mean_squared_error(biomass,y_pred)); return y_pred
 
 
353
 
354
+ except RuntimeError as e: print(f"RuntimeError en fit_biomass_{self.model_type}: {e}"); self.params['biomass']={}; self.r2['biomass']=np.nan; self.rmse['biomass']=np.nan; return None
355
+ except Exception as e: print(f"Error general en fit_biomass_{self.model_type}: {e}"); self.params['biomass']={}; self.r2['biomass']=np.nan; self.rmse['biomass']=np.nan; return None
356
+
357
+ def fit_substrate(self, time, substrate, biomass_params_dict): # Assumes biomass fit was successful
358
+ if not biomass_params_dict or not self.params.get('biomass'):
359
+ print(f"Parámetros de biomasa no disponibles para fit_substrate_{self.model_type}."); self.r2['substrate']=np.nan; self.rmse['substrate']=np.nan; return None
360
+
361
  time=np.asarray(time,dtype=float); substrate=np.asarray(substrate,dtype=float)
362
  valid_idx=~np.isnan(time)&~np.isnan(substrate); time=time[valid_idx]; substrate=substrate[valid_idx]
363
+ if len(time)<3: print(f"Datos insuficientes para fit_substrate_{self.model_type}."); self.r2['substrate']=np.nan; self.rmse['substrate']=np.nan; return None
364
+
365
  try:
366
  biomass_vals=list(biomass_params_dict.values())
367
+ p0=[substrate[0] if len(substrate)>0 else 1.0, 0.1, 0.01];
368
+ bounds=([0, -np.inf, -np.inf],[np.inf, np.inf, np.inf]) # p, q can be negative
369
+ popt,_=curve_fit(lambda t,so,p,q:self.substrate(t,so,p,q,biomass_vals),time,substrate,p0=p0,maxfev=self.maxfev,bounds=bounds,ftol=1e-9,xtol=1e-9,method='trf')
370
  self.params['substrate']={'so':popt[0],'p':popt[1],'q':popt[2]}; y_pred=self.substrate(time,*popt,biomass_vals)
371
  if np.any(np.isnan(y_pred)) or np.any(np.isinf(y_pred)): self.r2['substrate']=np.nan; self.rmse['substrate']=np.nan; self.params['substrate']={}; return None
372
  ss_res=np.sum((substrate-y_pred)**2); ss_tot=np.sum((substrate-np.mean(substrate))**2)
 
375
  except RuntimeError as e: print(f"RuntimeError fit_substrate {self.model_type}: {e}"); self.params['substrate']={}; self.r2['substrate']=np.nan; self.rmse['substrate']=np.nan; return None
376
  except Exception as e: print(f"Error fit_substrate {self.model_type}: {e}"); self.params['substrate']={}; self.r2['substrate']=np.nan; self.rmse['substrate']=np.nan; return None
377
 
378
+ def fit_product(self, time, product, biomass_params_dict): # Assumes biomass fit was successful
379
+ if not biomass_params_dict or not self.params.get('biomass'):
380
+ print(f"Parámetros de biomasa no disponibles para fit_product_{self.model_type}."); self.r2['product']=np.nan; self.rmse['product']=np.nan; return None
381
+
382
  time=np.asarray(time,dtype=float); product=np.asarray(product,dtype=float)
383
  valid_idx=~np.isnan(time)&~np.isnan(product); time=time[valid_idx]; product=product[valid_idx]
384
+ if len(time)<3: print(f"Datos insuficientes para fit_product_{self.model_type}."); self.r2['product']=np.nan; self.rmse['product']=np.nan; return None
385
+
386
  try:
387
  biomass_vals=list(biomass_params_dict.values())
388
+ p0=[product[0] if len(product)>0 else 0.0, 0.1, 0.01];
389
+ bounds=([0, -np.inf, -np.inf],[np.inf, np.inf, np.inf]) # alpha, beta can be negative
390
+ popt,_=curve_fit(lambda t,po,a,b_par:self.product(t,po,a,b_par,biomass_vals),time,product,p0=p0,maxfev=self.maxfev,bounds=bounds,ftol=1e-9,xtol=1e-9,method='trf')
391
  self.params['product']={'po':popt[0],'alpha':popt[1],'beta':popt[2]}; y_pred=self.product(time,*popt,biomass_vals)
392
  if np.any(np.isnan(y_pred)) or np.any(np.isinf(y_pred)): self.r2['product']=np.nan; self.rmse['product']=np.nan; self.params['product']={}; return None
393
  ss_res=np.sum((product-y_pred)**2); ss_tot=np.sum((product-np.mean(product))**2)
 
396
  except RuntimeError as e: print(f"RuntimeError fit_product {self.model_type}: {e}"); self.params['product']={}; self.r2['product']=np.nan; self.rmse['product']=np.nan; return None
397
  except Exception as e: print(f"Error fit_product {self.model_type}: {e}"); self.params['product']={}; self.r2['product']=np.nan; self.rmse['product']=np.nan; return None
398
 
399
+ def generate_fine_time_grid(self, time_vector):
400
+ if time_vector is None or len(time_vector)<2: return np.array([0.0]) if (time_vector is None or len(time_vector)==0) else np.array(time_vector)
401
+ t_min,t_max=np.min(time_vector),np.max(time_vector)
402
+ return np.array([t_min]) if t_min==t_max else np.linspace(t_min,t_max,300) # Reduced points for faster plotting
403
 
404
  def system(self, y, t, biomass_params_ode, substrate_params, product_params, model_type_ode):
405
+ X,S,P=y; X=max(X,0); dXdt=0.0 # Ensure X is non-negative
406
+
407
  if model_type_ode=='logistic': dXdt=self.logistic_diff(X,t,biomass_params_ode)
408
  elif model_type_ode=='gompertz': dXdt=self.gompertz_diff(X,t,biomass_params_ode)
409
  elif model_type_ode=='moser': dXdt=self.moser_diff(X,t,biomass_params_ode)
410
+ else: dXdt=0.0
411
+
412
+ p_val=substrate_params[1] if len(substrate_params)>1 else 0.0
413
+ q_val=substrate_params[2] if len(substrate_params)>2 else 0.0
414
+ dSdt=-p_val*dXdt-q_val*X
415
+ if S <= 1e-9 and dSdt < 0 : dSdt = 0 # Prevent S from becoming negative due to consumption
416
+
417
+ alpha_val=product_params[1] if len(product_params)>1 else 0.0
418
+ beta_val=product_params[2] if len(product_params)>2 else 0.0
419
+ dPdt=alpha_val*dXdt+beta_val*X
420
+ return [dXdt,dSdt,dPdt]
421
+
422
+ def get_initial_conditions(self, time, biomass, substrate, product): # For ODE solving
423
+ # Use experimental data for initial conditions if available and valid, otherwise use fitted parameters if available
424
+ X0_exp=biomass[0] if biomass is not None and len(biomass)>0 and np.isfinite(biomass[0]) else 1e-6 # Small default if no data
425
+ S0_exp=substrate[0] if substrate is not None and len(substrate)>0 and np.isfinite(substrate[0]) else 0.0
426
+ P0_exp=product[0] if product is not None and len(product)>0 and np.isfinite(product[0]) else 0.0
427
+
428
+ X0 = X0_exp # Default to experimental
429
  if 'biomass' in self.params and self.params['biomass']:
430
+ # For models with direct Xo parameter
431
+ if self.model_type in ['logistic', 'baranyi']:
432
+ X0 = self.params['biomass'].get('Xo', self.params['biomass'].get('X0', X0_exp)) # Handles 'Xo' or 'X0'
433
+ # For models where X(t=0) is calculated
434
+ elif self.model_type == 'gompertz' and self.biomass_model and all(k in self.params['biomass'] for k in ['Xm','um','lag']):
435
+ X0_calc = self.biomass_model(0, self.params['biomass']['Xm'],self.params['biomass']['um'],self.params['biomass']['lag'])
436
+ if np.isfinite(X0_calc): X0 = X0_calc
437
+ elif self.model_type == 'moser' and self.biomass_model and all(k in self.params['biomass'] for k in ['Xm','um','Ks']):
438
+ X0_calc = self.biomass_model(0, self.params['biomass']['Xm'],self.params['biomass']['um'],self.params['biomass']['Ks'])
439
+ if np.isfinite(X0_calc): X0 = X0_calc
440
+
441
+ S0 = self.params.get('substrate',{}).get('so', S0_exp)
442
+ P0 = self.params.get('product',{}).get('po', P0_exp)
443
+
444
+ # Ensure initial conditions are finite and X0 is positive for ODE solver
445
+ X0 = max(X0 if np.isfinite(X0) else 1e-6, 1e-9)
446
+ S0 = S0 if np.isfinite(S0) else 0.0
447
+ P0 = P0 if np.isfinite(P0) else 0.0
448
+ return [X0, S0, P0]
449
 
450
  def solve_differential_equations(self, time, biomass, substrate, product):
451
+ if self.biomass_diff is None: print(f"ODE no soportado para {self.model_type}."); return None,None,None,time
452
+ if 'biomass' not in self.params or not self.params['biomass']: print("Parámetros de biomasa no disponibles para EDO."); return None,None,None,time
453
+ if time is None or len(time)==0: print("Tiempo no válido para EDOs."); return None,None,None,np.array([])
454
 
455
+ biomass_p_ode=[] # Parameters for the dX/dt part of the system
456
+ if self.model_type=='logistic': biomass_p_ode=[self.params['biomass']['Xo'],self.params['biomass']['Xm'],self.params['biomass']['um']] # Xo is for IC, Xm, um for ODE
457
  elif self.model_type=='gompertz': biomass_p_ode=[self.params['biomass']['Xm'],self.params['biomass']['um'],self.params['biomass']['lag']]
458
  elif self.model_type=='moser': biomass_p_ode=[self.params['biomass']['Xm'],self.params['biomass']['um'],self.params['biomass']['Ks']]
459
+ else: print(f"Modelo {self.model_type} sin EDO definida en 'system'."); return None,None,None,time
460
 
461
  substrate_p=[self.params.get('substrate',{}).get('so',0), self.params.get('substrate',{}).get('p',0), self.params.get('substrate',{}).get('q',0)]
462
  product_p=[self.params.get('product',{}).get('po',0), self.params.get('product',{}).get('alpha',0), self.params.get('product',{}).get('beta',0)]
463
 
464
+ init_cond=self.get_initial_conditions(time,biomass,substrate,product) # Uses fitted Xo, So, Po if available
465
  time_f=self.generate_fine_time_grid(time)
466
+ if len(time_f)==0 or len(time_f) == 1 and time_f[0]==0 : print("Malla de tiempo fina no generada."); return None,None,None,time
467
+
468
+ hmax_val=(time_f[-1]-time_f[0])/200.0 if len(time_f)>1 and time_f[-1]>time_f[0] else 0.0
469
  try:
470
  sol=odeint(self.system,init_cond,time_f,args=(biomass_p_ode,substrate_p,product_p,self.model_type),rtol=1e-6,atol=1e-6,hmax=hmax_val)
471
  except Exception as e_ode:
472
+ print(f"Error resolviendo EDO ({self.model_type}, {self.params.get('biomass')}): {e_ode}. Intentando 'lsoda'.")
473
  try: sol=odeint(self.system,init_cond,time_f,args=(biomass_p_ode,substrate_p,product_p,self.model_type),rtol=1e-6,atol=1e-6,method='lsoda',hmax=hmax_val)
474
+ except Exception as e_lsoda: print(f"Error resolviendo EDO con lsoda ({self.model_type}): {e_lsoda}"); return None,None,None,time_f
475
+
476
+ # Ensure solutions are non-negative
477
+ solX = np.maximum(sol[:,0], 0)
478
+ solS = np.maximum(sol[:,1], 0)
479
+ solP = np.maximum(sol[:,2], 0)
480
+ return solX, solS, solP, time_f
481
+
482
+ # plot_results and plot_combined_results remain largely the same as previous version,
483
+ # ensure they handle None from y_pred_biomass_fit gracefully.
484
+ # (Code for plot_results and plot_combined_results is omitted here for brevity but assumed to be from the previous corrected version)
485
  def plot_results(self, time, biomass, substrate, product,
486
  y_pred_biomass_fit, y_pred_substrate_fit, y_pred_product_fit,
487
  biomass_std=None, substrate_std=None, product_std=None,
 
493
 
494
  y_pred_b,y_pred_s,y_pred_p = y_pred_biomass_fit,y_pred_substrate_fit,y_pred_product_fit
495
  time_curves = self.generate_fine_time_grid(time)
496
+ X_ode_success = False
497
 
498
  if y_pred_biomass_fit is None and not (use_differential and self.biomass_diff is not None and 'biomass' in self.params and self.params['biomass']):
499
+ # This case means biomass fitting itself failed. Plotting is not possible.
500
+ print(f"Ajuste de biomasa falló para {experiment_name}, modelo {self.model_type}. No se generará gráfico.")
501
+ return None # Critical: if biomass fit fails, no plot.
502
 
503
  can_use_ode = use_differential and self.biomass_diff is not None and 'biomass' in self.params and self.params['biomass']
504
 
 
510
  if X_ode_res is not None:
511
  y_pred_b,y_pred_s,y_pred_p,time_curves = X_ode_res,S_ode_res,P_ode_res,time_fine_ode
512
  X_ode_success = True
513
+ else:
514
+ print(f"Solución EDO falló para {experiment_name}, {self.model_type}. Usando resultados de ajuste directo si existen.")
515
+ # Fallback to curve_fit results on fine grid if ODE failed
516
+ if y_pred_biomass_fit is not None and self.biomass_model and 'biomass' in self.params and self.params['biomass']: # Check if biomass fit was successful
517
  b_params=list(self.params['biomass'].values())
518
+ y_pred_b=self.biomass_model(time_curves,*b_params) # Use original y_pred_biomass_fit for this
519
  if y_pred_substrate_fit is not None and 'substrate' in self.params and self.params.get('substrate'):
520
  s_params=list(self.params['substrate'].values()); y_pred_s=self.substrate(time_curves,*s_params,b_params)
521
  else: y_pred_s=np.full_like(time_curves,np.nan)
522
  if y_pred_product_fit is not None and 'product' in self.params and self.params.get('product'):
523
  p_params=list(self.params['product'].values()); y_pred_p=self.product(time_curves,*p_params,b_params)
524
  else: y_pred_p=np.full_like(time_curves,np.nan)
525
+ else: # Biomass fit itself failed, so predictions are NaN
526
+ y_pred_b,y_pred_s,y_pred_p = (np.full_like(time_curves,np.nan) for _ in range(3))
527
+ else: # Not using ODE, ensure curve_fit results are plotted on fine grid
528
  if y_pred_biomass_fit is not None and self.biomass_model and 'biomass' in self.params and self.params['biomass']:
529
  b_params=list(self.params['biomass'].values()); y_pred_b=self.biomass_model(time_curves,*b_params)
530
  if y_pred_substrate_fit is not None and 'substrate' in self.params and self.params.get('substrate'):
 
533
  if y_pred_product_fit is not None and 'product' in self.params and self.params.get('product'):
534
  p_params=list(self.params['product'].values()); y_pred_p=self.product(time_curves,*p_params,b_params)
535
  else: y_pred_p=np.full_like(time_curves,np.nan)
536
+ else: # Biomass fit failed
537
+ y_pred_b,y_pred_s,y_pred_p = (np.full_like(time_curves,np.nan) for _ in range(3))
538
+
539
 
540
  fig,(ax1,ax2,ax3)=plt.subplots(3,1,figsize=(10,15),sharex=True)
541
+ title_suffix = " - EDO" if X_ode_success else (" - Ajuste Directo" if not use_differential or self.biomass_diff is None or not self.params.get('biomass') else " - EDO (falló, usando ajuste)")
542
  fig.suptitle(f'{experiment_name} ({self.model_type.capitalize()}){title_suffix}',fontsize=16)
543
 
544
  plot_cfg=[(ax1,biomass,y_pred_b,biomass_std,axis_labels['biomass_label'],'Modelo Biomasa',self.params.get('biomass',{}),self.r2.get('biomass',np.nan),self.rmse.get('biomass',np.nan)),
545
  (ax2,substrate,y_pred_s,substrate_std,axis_labels['substrate_label'],'Modelo Sustrato',self.params.get('substrate',{}),self.r2.get('substrate',np.nan),self.rmse.get('substrate',np.nan)),
546
  (ax3,product,y_pred_p,product_std,axis_labels['product_label'],'Modelo Producto',self.params.get('product',{}),self.r2.get('product',np.nan),self.rmse.get('product',np.nan))]
547
 
548
+ for i,(ax,data,y_pred_curve,std,ylab,leg_lab,p_dict,r2_val,rmse_val) in enumerate(plot_cfg):
549
+ # Plot experimental data
550
  if data is not None and len(data)>0 and not np.all(np.isnan(data)):
551
  if show_error_bars and std is not None and len(std)==len(data) and not np.all(np.isnan(std)):
552
  ax.errorbar(time,data,yerr=std,fmt=marker_style,color=point_color,label='Datos experimentales',capsize=error_cap_size,elinewidth=error_line_width,markeredgewidth=1,markersize=5)
553
  else: ax.plot(time,data,marker=marker_style,linestyle='',color=point_color,label='Datos experimentales',markersize=5)
554
+ else: ax.text(0.5,0.5,'No hay datos experimentales.',transform=ax.transAxes,ha='center',va='center',color='gray', fontsize=9)
555
 
556
+ # Plot model curve
557
  if y_pred_curve is not None and len(y_pred_curve)>0 and not np.all(np.isnan(y_pred_curve)):
558
  ax.plot(time_curves,y_pred_curve,linestyle=line_style,color=line_color,label=leg_lab)
559
+ elif i == 0 and y_pred_biomass_fit is None:
560
  ax.text(0.5, 0.6, 'Modelo de biomasa no ajustado.', transform=ax.transAxes, ha='center', va='center', color='red', fontsize=9)
561
+ elif i > 0 and (y_pred_biomass_fit is None or not self.params.get('biomass')):
562
  ax.text(0.5, 0.4, 'No ajustado (depende de biomasa).', transform=ax.transAxes, ha='center', va='center', color='orange', fontsize=9)
563
+ elif y_pred_curve is None or np.all(np.isnan(y_pred_curve)):
564
  ax.text(0.5, 0.4, 'Modelo no ajustado o resultado inválido.', transform=ax.transAxes, ha='center', va='center', color='orange', fontsize=9)
565
 
566
  ax.set_ylabel(ylab); ax.set_title(ylab)
567
  if show_legend: ax.legend(loc=legend_position)
568
  if show_params and p_dict and any(np.isfinite(v) for v in p_dict.values()):
569
  p_txt='\n'.join([f"{k}={v:.3g}" if np.isfinite(v) else f"{k}=N/A" for k,v in p_dict.items()])
570
+ txt=f"{p_txt}\nR²={r2_val:.3f if np.isfinite(r2_val) else 'N/A'}\nRMSE={rmse_val:.3f if np.isfinite(rmse_val) else 'N/A'}"
571
  if params_position=='outside right':
572
+ fig.subplots_adjust(right=0.70)
573
  ax.annotate(txt,xy=(1.05,0.5),xycoords='axes fraction',xytext=(10,0),textcoords='offset points',va='center',ha='left',bbox={'boxstyle':'round,pad=0.3','facecolor':'wheat','alpha':0.7}, fontsize=8)
574
  else:
575
  tx,ha=(0.95,'right') if 'right' in params_position else (0.05,'left')
 
579
 
580
  ax3.set_xlabel(axis_labels['x_label'])
581
  plt.tight_layout(rect=[0,0.03,1,0.95]);
582
+ if params_position == 'outside right': fig.subplots_adjust(right=0.70)
583
 
584
  buf=io.BytesIO(); fig.savefig(buf,format='png',bbox_inches='tight'); buf.seek(0)
585
  image=Image.open(buf).convert("RGB"); plt.close(fig); return image
 
589
  biomass_std=None, substrate_std=None, product_std=None,
590
  experiment_name='', legend_position='best', params_position='upper right',
591
  show_legend=True, show_params=True, style='whitegrid',
592
+ line_color='#0072B2', point_color='#D55E00', line_style='-', marker_style='o',
593
  use_differential=False, axis_labels=None,
594
  show_error_bars=True, error_cap_size=3, error_line_width=1):
595
 
 
597
  time_curves = self.generate_fine_time_grid(time)
598
  X_ode_success = False
599
 
600
+ if y_pred_biomass_fit is None and not (use_differential and self.biomass_diff is not None and 'biomass' in self.params and self.params['biomass']):
601
+ print(f"Ajuste de biomasa falló para {experiment_name}, modelo {self.model_type} (combinado). No se generará gráfico.")
602
+ return None
603
+
604
  can_use_ode = use_differential and self.biomass_diff is not None and 'biomass' in self.params and self.params['biomass']
605
  if axis_labels is None: axis_labels = {'x_label':'Tiempo','biomass_label':'Biomasa','substrate_label':'Sustrato','product_label':'Producto'}
606
  sns.set_style(style)
 
611
  if X_ode_res is not None:
612
  y_pred_b,y_pred_s,y_pred_p,time_curves = X_ode_res,S_ode_res,P_ode_res,time_fine_ode
613
  X_ode_success = True
614
+ else:
615
  if y_pred_biomass_fit is not None and self.biomass_model and 'biomass' in self.params and self.params['biomass']:
616
  b_params=list(self.params['biomass'].values()); y_pred_b=self.biomass_model(time_curves,*b_params)
617
  if y_pred_substrate_fit is not None and 'substrate' in self.params and self.params.get('substrate'): s_params=list(self.params['substrate'].values()); y_pred_s=self.substrate(time_curves,*s_params,b_params)
 
629
  else: y_pred_b,y_pred_s,y_pred_p = (np.full_like(time_curves,np.nan) for _ in range(3))
630
 
631
  fig,ax1=plt.subplots(figsize=(12,7))
632
+ title_suffix = " - EDO" if X_ode_success else (" - Ajuste Directo" if not use_differential or self.biomass_diff is None or not self.params.get('biomass') else " - EDO (falló, usando ajuste)")
633
  fig.suptitle(f'{experiment_name} ({self.model_type.capitalize()}){title_suffix}',fontsize=16)
634
 
635
  colors = sns.color_palette("tab10", 3)
 
667
 
668
  if show_params:
669
  all_param_text = []
670
+ for cat_label, p_dict, r2_val, rmse_val in [
671
  (axis_labels['biomass_label'], self.params.get('biomass',{}), self.r2.get('biomass',np.nan), self.rmse.get('biomass',np.nan)),
672
  (axis_labels['substrate_label'], self.params.get('substrate',{}), self.r2.get('substrate',np.nan), self.rmse.get('substrate',np.nan)),
673
  (axis_labels['product_label'], self.params.get('product',{}), self.r2.get('product',np.nan), self.rmse.get('product',np.nan))]:
674
  if p_dict and any(np.isfinite(v) for v in p_dict.values()):
675
  p_list = [f" {k}={v:.3g}" if np.isfinite(v) else f" {k}=N/A" for k,v in p_dict.items()]
676
+ all_param_text.append(f"{cat_label}:\n" + "\n".join(p_list) + f"\n R²={r2_val:.3f if np.isfinite(r2_val) else 'N/A'}\n RMSE={rmse_val:.3f if np.isfinite(rmse_val) else 'N/A'}")
677
  total_text = "\n\n".join(all_param_text)
678
  if total_text:
679
  if params_position=='outside right':
680
+ fig.subplots_adjust(right=0.65)
681
  fig.text(0.67,0.5,total_text,transform=fig.transFigure,va='center',ha='left',bbox=dict(boxstyle='round,pad=0.3',facecolor='wheat',alpha=0.7),fontsize=7)
682
  else:
683
  tx,ha=(0.95,'right') if 'right' in params_position else (0.05,'left')
 
690
  buf=io.BytesIO(); fig.savefig(buf,format='png',bbox_inches='tight'); buf.seek(0)
691
  image=Image.open(buf).convert("RGB"); plt.close(fig); return image
692
 
693
+
694
  def sanitize_filename(name, max_length=100):
695
+ name = str(name)
696
+ name = re.sub(r'[^\w\s.-]', '', name).strip() # Allow dot for extension
697
  name = re.sub(r'[-\s]+', '_', name)
698
  return name[:max_length]
699
 
 
720
  for sheet_name_idx, sheet_name in enumerate(sheet_names):
721
  current_sheet_name_base = (experiment_names_list[sheet_name_idx]
722
  if sheet_name_idx < len(experiment_names_list) and experiment_names_list[sheet_name_idx]
723
+ else f"Hoja_{sanitize_filename(sheet_name, 15)}")
724
  try:
725
  df = pd.read_excel(xls, sheet_name=sheet_name, header=[0, 1])
726
  if df.empty: all_plot_messages.append(f"Hoja '{sheet_name}' vacía."); continue
 
736
  if mode == 'independent':
737
  unique_exp_groups = df.columns.get_level_values(0).unique()
738
  for exp_group_idx, exp_group_name in enumerate(unique_exp_groups):
 
739
  sanitized_exp_group_name = sanitize_filename(exp_group_name, 20)
740
  current_experiment_name = f"{current_sheet_name_base}_{sanitized_exp_group_name}"
741
 
742
  exp_df_slice_multi = df[exp_group_name]
743
  try:
744
+ # Extract Time for this specific experiment group
745
+ if 'Tiempo' not in exp_df_slice_multi.columns.get_level_values(0): # Check if 'Tiempo' is a primary key in the slice
746
+ all_plot_messages.append(f"No se encontró 'Tiempo' en el grupo '{exp_group_name}' de la hoja '{sheet_name}'.")
747
+ continue
 
748
 
749
+ time_data_for_exp = exp_df_slice_multi['Tiempo']
750
+ if isinstance(time_data_for_exp, pd.DataFrame): # Time itself has replicates (unusual)
751
+ time_exp = time_data_for_exp.iloc[:,0].dropna().astype(float).values
752
+ else: # Time is a single series
753
+ time_exp = time_data_for_exp.dropna().astype(float).values
754
+
755
+ def get_comp_data_independent(component_name_str):
756
+ if component_name_str in exp_df_slice_multi.columns.get_level_values(0):
757
+ comp_data = exp_df_slice_multi[component_name_str]
758
+ if isinstance(comp_data,pd.DataFrame): # Has replicates (e.g., R1, R2 columns under 'Biomasa')
759
+ # Ensure all replicate columns are numeric before mean/std
760
+ numeric_cols = [col for col in comp_data.columns if pd.api.types.is_numeric_dtype(comp_data[col])]
761
+ if not numeric_cols: return np.array([]), None
762
+ comp_data_numeric = comp_data[numeric_cols]
763
+ return comp_data_numeric.mean(axis=1).dropna().astype(float).values, comp_data_numeric.std(axis=1,ddof=1).dropna().astype(float).values
764
+ elif pd.api.types.is_numeric_dtype(comp_data):
765
+ return comp_data.dropna().astype(float).values,None # Single numeric series
766
  return np.array([]),None
767
 
768
  biomass_exp,biomass_std_exp=get_comp_data_independent('Biomasa')
769
  substrate_exp,substrate_std_exp=get_comp_data_independent('Sustrato')
770
  product_exp,product_std_exp=get_comp_data_independent('Producto')
771
 
772
+ # Align all data to the shortest length after NaNs and with time_exp
773
  min_len=len(time_exp)
774
  if len(biomass_exp)>0:min_len=min(min_len,len(biomass_exp))
 
775
  if len(substrate_exp)>0:min_len=min(min_len,len(substrate_exp))
776
  if len(product_exp)>0:min_len=min(min_len,len(product_exp))
777
 
778
  time_exp=time_exp[:min_len]
779
  if len(biomass_exp)>0:biomass_exp=biomass_exp[:min_len]
780
+ else: biomass_exp = np.array([]) # Ensure it's an array
781
  if biomass_std_exp is not None and len(biomass_std_exp)>0:biomass_std_exp=biomass_std_exp[:min_len]
782
+
783
  if len(substrate_exp)>0:substrate_exp=substrate_exp[:min_len]
784
+ else: substrate_exp = np.array([])
785
  if substrate_std_exp is not None and len(substrate_std_exp)>0:substrate_std_exp=substrate_std_exp[:min_len]
786
+
787
  if len(product_exp)>0:product_exp=product_exp[:min_len]
788
+ else: product_exp = np.array([])
789
  if product_std_exp is not None and len(product_std_exp)>0:product_std_exp=product_std_exp[:min_len]
790
 
 
791
  if len(time_exp)==0: all_plot_messages.append(f"Sin datos de tiempo para {current_experiment_name}."); continue
792
+ if len(biomass_exp)==0:
793
  all_plot_messages.append(f"Sin datos de biomasa para {current_experiment_name}, no se puede ajustar el modelo de biomasa.")
794
  for mt_ in model_types_selected: comparison_data.append({'Experimento':current_experiment_name,'Modelo':mt_.capitalize(),'R² Biomasa':np.nan,'RMSE Biomasa':np.nan})
795
  continue
 
796
  except KeyError as e_key: all_plot_messages.append(f"Falta columna {e_key} en '{current_experiment_name}'."); continue
797
  except Exception as e_data: all_plot_messages.append(f"Error procesando datos para '{current_experiment_name}': {e_data}."); continue
798
 
799
  for model_type_iter in model_types_selected:
800
  model_instance = BioprocessModel(model_type=model_type_iter, maxfev=maxfev_val)
801
  model_instance.fit_model()
802
+ y_pred_biomass = model_instance.fit_biomass(time_exp, biomass_exp) # This now gets filtered positive biomass
803
  y_pred_substrate, y_pred_product = None, None
804
  if y_pred_biomass is not None and model_instance.params.get('biomass'):
805
  if len(substrate_exp)>0: y_pred_substrate = model_instance.fit_substrate(time_exp, substrate_exp, model_instance.params['biomass'])
806
  if len(product_exp)>0: y_pred_product = model_instance.fit_product(time_exp, product_exp, model_instance.params['biomass'])
807
+ # else: # Message for failed biomass fit is now inside fit_biomass or due to insufficient data
808
+ # all_plot_messages.append(f"Ajuste de biomasa falló o datos insuficientes para {current_experiment_name}, modelo {model_type_iter}.")
809
 
810
  all_parameters_collected.setdefault(current_experiment_name, {})[model_type_iter] = model_instance.params
811
  comparison_data.append({'Experimento':current_experiment_name,'Modelo':model_type_iter.capitalize(),
 
828
  substrate_std_avg = model_dummy_for_sheet.datas_std[-1] if model_dummy_for_sheet.datas_std and model_dummy_for_sheet.datas_std[-1] is not None and len(model_dummy_for_sheet.datas_std[-1]) == len(substrate_avg) else None
829
  product_std_avg = model_dummy_for_sheet.datap_std[-1] if model_dummy_for_sheet.datap_std and model_dummy_for_sheet.datap_std[-1] is not None and len(model_dummy_for_sheet.datap_std[-1]) == len(product_avg) else None
830
 
831
+ if time_avg is None or len(time_avg)==0:
832
+ all_plot_messages.append(f"Sin datos de tiempo promedio para '{current_sheet_name_base}'. No se procesarán modelos para esta hoja.")
833
+ for mt_ in model_types_selected: comparison_data.append({'Experimento':current_experiment_name,'Modelo':mt_.capitalize(),'R² Biomasa':np.nan,'RMSE Biomasa':np.nan})
834
+ continue
835
+
836
  if len(biomass_avg)==0:
837
+ all_plot_messages.append(f"Sin datos de biomasa promedio para '{current_sheet_name_base}'. No se procesarán modelos de biomasa para esta hoja.")
838
  for mt_ in model_types_selected: comparison_data.append({'Experimento':current_experiment_name,'Modelo':mt_.capitalize(),'R² Biomasa':np.nan,'RMSE Biomasa':np.nan})
839
+ continue
840
+
841
+ # This explicit check for average mode helps clarify why fitting might be skipped for the whole sheet average.
842
+ # The fit_biomass function itself will also perform checks on the data it receives.
843
+ if biomass_avg[0] <= 1e-9: # Check if the very first point of the averaged biomass is problematic
844
+ all_plot_messages.append(f"Biomasa inicial promedio (valor={biomass_avg[0]:.2e}) para '{current_sheet_name_base}' es <= 1e-9. Los modelos de biomasa no se ajustarán para el promedio de esta hoja.")
845
+ for mt_ in model_types_selected:
846
+ comparison_data.append({'Experimento':current_experiment_name,'Modelo':mt_.capitalize(),'R² Biomasa':np.nan,'RMSE Biomasa':np.nan})
847
+ all_parameters_collected.setdefault(current_experiment_name, {})[mt_] = {'biomass': {}, 'substrate': {}, 'product': {}}
848
+ continue
849
 
850
  for model_type_iter in model_types_selected:
851
  model_instance = BioprocessModel(model_type=model_type_iter, maxfev=maxfev_val)
 
855
  if y_pred_biomass is not None and model_instance.params.get('biomass'):
856
  if len(substrate_avg)>0: y_pred_substrate = model_instance.fit_substrate(time_avg, substrate_avg, model_instance.params['biomass'])
857
  if len(product_avg)>0: y_pred_product = model_instance.fit_product(time_avg, product_avg, model_instance.params['biomass'])
858
+ # else: # Message now handled by fit_biomass or insufficient data checks
859
+ # all_plot_messages.append(f"Ajuste biomasa promedio falló o datos insuficientes: {current_experiment_name}, {model_type_iter}.")
860
 
861
  all_parameters_collected.setdefault(current_experiment_name, {})[model_type_iter] = model_instance.params
862
  comparison_data.append({'Experimento':current_experiment_name,'Modelo':model_type_iter.capitalize(),
 
875
  cols_to_sort = ['R² Biomasa', 'R² Sustrato', 'R² Producto', 'RMSE Biomasa', 'RMSE Sustrato', 'RMSE Producto']
876
  existing_cols_to_sort = [col for col in cols_to_sort if col in comparison_df.columns]
877
  ascending_map = {'R²': False, 'RMSE': True}
878
+ sort_ascending = [True, True] + [ascending_map[col.split(' ')[0]] for col in existing_cols_to_sort if col.split(' ')[0] in ascending_map]
879
  comparison_df_sorted = comparison_df.sort_values(by=['Experimento','Modelo']+existing_cols_to_sort,ascending=sort_ascending).reset_index(drop=True)
880
  else: comparison_df_sorted = pd.DataFrame(columns=['Experimento','Modelo','R² Biomasa','RMSE Biomasa','R² Sustrato','RMSE Sustrato','R² Producto','RMSE Producto'])
881
 
882
  final_message = "Procesamiento completado."
883
+ if all_plot_messages: final_message += " Mensajes:\n" + "\n".join(list(set(all_plot_messages)))
884
  if not figures_with_names and not comparison_df_sorted.empty: final_message += "\nNo se generaron gráficos, pero hay datos en la tabla."
885
+ elif not figures_with_names and comparison_df_sorted.empty and not all_plot_messages : final_message += "\nNo se generaron gráficos ni datos para la tabla (posiblemente no hay datos válidos en el archivo)."
886
+ elif not figures_with_names and comparison_df_sorted.empty and all_plot_messages : pass # Messages already cover issues
887
 
888
  return figures_with_names, comparison_df_sorted, final_message, all_parameters_collected
889
 
890
+ # ... (MODEL_CHOICES, create_zip_of_images, create_interface, and __main__ block remain the same as the previous corrected version)
891
+ # Ensure create_interface uses the corrected gr.Dataframe without 'height'
892
+ # The rest of the UI and helper functions (create_zip_of_images, export functions) are assumed to be correct from the prior version.
893
 
894
  MODEL_CHOICES = [("Logistic (3-parám)","logistic"),("Gompertz (3-parám)","gompertz"),("Moser (3-parám)","moser"),("Baranyi (4-parám)","baranyi")]
895
 
 
902
  for item_idx, item in enumerate(figures_with_names_list):
903
  img_pil = item['image']
904
  img_name_suggestion = item['name']
 
 
905
  base_name, ext = os.path.splitext(img_name_suggestion)
906
+ if not ext: ext = ".png"
907
+ sanitized_base = sanitize_filename(base_name, max_length=80)
 
908
  img_name_in_zip = f"{sanitized_base}{ext}"
 
 
909
  count = 1
910
  original_sanitized_base = sanitized_base
911
  while img_name_in_zip in zf.namelist():
912
  img_name_in_zip = f"{original_sanitized_base}_{count}{ext}"
913
  count += 1
914
+ if count > len(figures_with_names_list) + 5:
915
+ img_name_in_zip = f"{original_sanitized_base}_{item_idx}_{count}{ext}"
916
  break
 
 
917
  img_byte_arr = io.BytesIO()
918
  img_pil.save(img_byte_arr, format='PNG')
919
  img_byte_arr.seek(0)
920
  zf.writestr(img_name_in_zip, img_byte_arr.getvalue())
 
921
  zip_buffer.seek(0)
 
922
  try:
 
 
923
  with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp_zip_file:
924
  tmp_zip_file.write(zip_buffer.getvalue())
925
  return tmp_zip_file.name, "ZIP con imágenes generado exitosamente."
926
  except Exception as e:
927
  return None, f"Error creando archivo ZIP temporal: {str(e)}"
928
 
 
929
  def create_interface():
930
  with gr.Blocks(theme=gr.themes.Soft()) as demo:
931
  gr.Markdown("# Modelos Cinéticos de Bioprocesos")
 
932
  with gr.Tab("Teoría y Uso"):
933
  gr.Markdown(r"""
934
+ Análisis y visualización de datos de bioprocesos... (etc., same as before)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
935
  """)
936
  gr.Markdown(r"""
937
+ ## Modelos Matemáticos para Bioprocesos ... (etc., same as before)
 
 
 
 
 
938
  """)
 
939
  with gr.Tab("Simulación"):
940
  with gr.Row():
941
  file_input = gr.File(label="Subir archivo Excel (.xlsx)", file_types=['.xlsx'])
 
970
  with gr.Accordion("Configuración Avanzada de Ajuste (No implementado aún)", open=False):
971
  lower_bounds_str_ui = gr.Textbox(label="Lower Bounds (JSON, no usado)", lines=3)
972
  upper_bounds_str_ui = gr.Textbox(label="Upper Bounds (JSON, no usado)", lines=3)
 
973
  simulate_btn = gr.Button("Simular y Graficar", variant="primary")
974
 
975
  with gr.Tab("Resultados"):
976
+ status_message_ui = gr.Textbox(label="Estado del Procesamiento", interactive=False, lines=3, max_lines=10) # Increased lines for messages
977
  output_gallery_ui = gr.Gallery(label="Resultados Gráficos", columns=[2,1], height=600, object_fit="contain", preview=True)
978
+ output_table_ui = gr.Dataframe(
979
  label="Tabla Comparativa de Modelos",
980
  headers=["Experimento","Modelo","R² Biomasa","RMSE Biomasa","R² Sustrato","RMSE Sustrato","R² Producto","RMSE Producto"],
981
  interactive=False,
982
  wrap=True
983
+ # Removed height=400
984
  )
 
985
  state_df_ui = gr.State(pd.DataFrame())
986
  state_params_ui = gr.State({})
987
  state_figures_ui = gr.State([])
 
988
  with gr.Row():
989
  export_excel_btn = gr.Button("Exportar Tabla a Excel")
990
  export_csv_btn = gr.Button("Exportar Tabla a CSV")
991
  export_params_btn = gr.Button("Exportar Parámetros a Excel")
992
  export_images_zip_btn = gr.Button("Descargar Imágenes (ZIP)")
 
993
  download_file_output_ui = gr.File(label="Descargar Tabla/Parámetros", interactive=False)
994
  download_zip_images_ui = gr.File(label="Descargar ZIP de Imágenes", interactive=False)
995
 
 
1002
  if file is None: return [], pd.DataFrame(), "Error: Sube un archivo Excel.", pd.DataFrame(), {}, []
1003
  axis_labels = {'x_label':x_label or 'Tiempo','biomass_label':biomass_label or 'Biomasa','substrate_label':substrate_label or 'Sustrato','product_label':product_label or 'Producto'}
1004
  if not models_sel: return [], pd.DataFrame(), "Error: Selecciona un modelo.", pd.DataFrame(), {}, []
 
1005
  figures_with_names, comparison_df, message, collected_params = process_all_data(
1006
  file, legend_pos, params_pos, models_sel, exp_names, low_bounds_str, up_bounds_str,
1007
  analysis_mode, plot_style, line_col, point_col, line_sty, marker_sty,
1008
  show_leg, show_par, use_diff, int(maxfev), axis_labels,
1009
+ show_error_bars_arg, error_cap_size_arg, error_line_width_arg)
 
1010
  pil_images_for_gallery = [item['image'] for item in figures_with_names] if figures_with_names else []
1011
  return pil_images_for_gallery, comparison_df, message, comparison_df, collected_params, figures_with_names
 
1012
  simulate_btn.click(
1013
  fn=run_simulation_interface,
1014
  inputs=[file_input, legend_position_ui, params_position_ui, model_types_selected_ui, mode, experiment_names_str_ui,
 
1016
  line_style_dropdown_ui, marker_style_dropdown_ui, show_legend_ui, show_params_ui, use_differential_ui,
1017
  maxfev_input_ui, x_axis_label_input_ui, biomass_axis_label_input_ui, substrate_axis_label_input_ui,
1018
  product_axis_label_input_ui, show_error_bars_ui, error_cap_size_ui, error_line_width_ui],
1019
+ outputs=[output_gallery_ui, output_table_ui, status_message_ui, state_df_ui, state_params_ui, state_figures_ui])
 
1020
 
1021
  def export_df_to_file(df_to_export, file_format="excel"):
1022
  if df_to_export is None or df_to_export.empty:
1023
  with tempfile.NamedTemporaryFile(suffix=".txt",delete=False,mode="w",encoding="utf-8") as tmp: tmp.write("No hay datos en la tabla para exportar."); return tmp.name,"No hay datos para exportar."
1024
  try:
1025
  suffix=".xlsx" if file_format=="excel" else ".csv"
1026
+ with tempfile.NamedTemporaryFile(suffix=suffix,delete=False,mode='w+b' if file_format=="excel" else 'w', encoding=None if file_format=="excel" else "utf-8-sig") as tmp_f:
1027
  if file_format=="excel": df_to_export.to_excel(tmp_f.name,index=False)
1028
  else: df_to_export.to_csv(tmp_f.name,index=False)
1029
  return tmp_f.name,f"Tabla exportada a {suffix[1:]} exitosamente."
 
1041
  for exp_name,models_data in params_state_dict.items():
1042
  for model_type_name,all_params_for_model_type in models_data.items():
1043
  for param_category,category_params in all_params_for_model_type.items():
1044
+ if category_params and isinstance(category_params,dict) and category_params:
1045
  df_params=pd.DataFrame({'Parámetro':list(category_params.keys()),'Valor':list(category_params.values())})
1046
  s_exp=sanitize_filename(exp_name,15); s_mod=sanitize_filename(model_type_name,10); s_cat=sanitize_filename(param_category,4)
1047
+ sheet_name=f"{s_exp}_{s_mod}_{s_cat}"[:31]
1048
  orig_sn,c=sheet_name,1
1049
  while sheet_name in writer.sheets: sheet_name=f"{orig_sn[:28]}_{c}"[:31]; c+=1;
1050
  df_params.to_excel(writer,sheet_name=sheet_name,index=False)
 
1068
  return demo
1069
 
1070
  if __name__ == '__main__':
 
 
1071
  demo_instance = create_interface()
1072
+ demo_instance.launch(share=False, debug=True)