# models.py import numpy as np from scipy.optimize import curve_fit from sympy import symbols, sympify, lambdify import warnings from sklearn.metrics import mean_squared_error # Asegúrate que está importado class BioprocessModel: def __init__(self): self.params = {} # Almacenará los parámetros ajustados para cada tipo de modelo self.models = {} # Almacenará la configuración de cada tipo de modelo (ecuación, función, etc.) self.r2 = {} # Almacenará el R^2 para cada tipo de modelo self.rmse = {} # Almacenará el RMSE para cada tipo de modelo def set_model(self, model_type, equation_str, param_str): """ Configura un modelo (Biomasa, Sustrato, Producto) usando una ecuación simbólica. :param model_type: 'biomass', 'substrate', o 'product' :param equation_str: La ecuación como una cadena de texto (ej. "Xm * (1 - exp(-um * t))") Si la ecuación es para sustrato o producto y depende de la biomasa X(t), se debe usar 'X_val' en la ecuación para representar el valor de X(t). :param param_str: Cadena de parámetros separados por comas (ej. "Xm, um") """ equation_str = equation_str.strip() # Si el usuario escribe "Y = ...", tomar solo la parte derecha if '=' in equation_str: equation_str = equation_str.split('=', 1)[1].strip() params_list = [param.strip() for param in param_str.split(',')] self.models[model_type] = { 'equation_str': equation_str, 'params': params_list } # Símbolos para el tiempo y los parámetros del modelo actual t_sym = symbols('t') current_param_syms = symbols(params_list) # Argumentos para lambdify lambdify_args = [t_sym] + list(current_param_syms) # Si el modelo es sustrato o producto, puede depender de la biomasa (X_val) # y de los parámetros de biomasa ajustados. # La función `lambdify` solo manejará los parámetros directos de este modelo. # La dependencia de X(t) se resolverá en la función de ajuste. # Si la ecuación contiene 'X_val' (representando X(t) para modelos S y P) # lo añadimos como un símbolo que se sustituirá en la función de ajuste. all_symbols_in_eq = sympify(equation_str).free_symbols final_lambdify_args = list(lambdify_args) # Copia para modificar # Manejo de X_val para modelos S y P que dependen de biomasa # No es necesario añadir X_val a lambdify_args aquí si se maneja en fit_model_wrapper # func = lambdify(final_lambdify_args, sympify(equation_str), 'numpy') # Guardar los símbolos para uso posterior si es necesario self.models[model_type]['sympy_expr'] = sympify(equation_str) self.models[model_type]['param_symbols'] = current_param_syms self.models[model_type]['time_symbol'] = t_sym # La función compilada ('function') se creará dinámicamente en fit_model para manejar dependencias def fit_model(self, model_type, time, data, bounds, biomass_params_fitted=None): """ Ajusta el modelo configurado a los datos. :param model_type: 'biomass', 'substrate', o 'product' :param time: Array de datos de tiempo :param data: Array de datos observados :param bounds: Tupla (lower_bounds, upper_bounds) para los parámetros :param biomass_params_fitted: Dict de parámetros de biomasa ajustados (necesario para sustrato/producto) :return: (y_pred, popt) - Datos predichos y parámetros optimizados """ if model_type not in self.models: raise ValueError(f"Modelo para '{model_type}' no configurado. Llama a set_model primero.") model_config = self.models[model_type] equation_expr = model_config['sympy_expr'] current_param_names = model_config['params'] current_param_syms = model_config['param_symbols'] t_sym = model_config['time_symbol'] # Construir la función a ajustar con curve_fit # Esta función tomará `t` y los parámetros del modelo actual como `*args` def fit_model_wrapper(t_data, *current_p_values): # Crear un diccionario de sustitución para los parámetros del modelo actual subs_dict = {sym: val for sym, val in zip(current_param_syms, current_p_values)} # Si es sustrato o producto y depende de la biomasa (X_val) X_val_sym = symbols('X_val') # Símbolo para X(t) if model_type in ['substrate', 'product'] and X_val_sym in equation_expr.free_symbols: if biomass_params_fitted is None or 'biomass' not in self.models: raise ValueError("Parámetros de biomasa ajustados son necesarios para modelos de sustrato/producto que dependen de X(t).") # Calcular X(t) usando el modelo de biomasa ajustado y los t_data actuales biomass_model_config = self.models['biomass'] biomass_expr = biomass_model_config['sympy_expr'] biomass_p_syms = biomass_model_config['param_symbols'] # Crear dict de sustitución para parámetros de biomasa biomass_subs_dict = {sym: biomass_params_fitted[name] for sym, name in zip(biomass_p_syms, biomass_model_config['params'])} # Evaluar X(t) para cada punto en t_data X_t_values = np.array([ biomass_expr.subs(biomass_subs_dict).subs({biomass_model_config['time_symbol']: ti}).evalf() for ti in t_data ], dtype=float) # Añadir X_val (X(t)) al diccionario de sustituciones para la ecuación actual subs_dict[X_val_sym] = X_t_values # Evaluar la ecuación del modelo actual (sustrato, producto, o biomasa) # con los valores de sus parámetros y X(t) si es aplicable # Es importante que la ecuación use t_sym como variable independiente # y que lambdify se haga sobre (t_sym, *current_param_syms, X_val_sym_if_present) # Re-lambdify aquí para asegurar el scope correcto de X_t_values si se usa X_val_sym args_for_lambdify = [t_sym] + list(current_param_syms) final_expr = equation_expr # Expresión original # Si X_val está en la ecuación, NO lo incluimos en args_for_lambdify # sino que lo sustituimos ANTES de lambdify o lo pasamos como constante si es posible. # Lo más robusto es sustituir X_val en la expresión ANTES de lambdify if X_val_sym in equation_expr.free_symbols and X_val_sym in subs_dict: # Si X_val es un array (porque t_data es un array), no podemos sustituirlo directamente # en la expresión simbólica para crear una única función lambdify que tome `t`. # En su lugar, evaluamos la expresión punto por punto después de sustituir parámetros. # Esto es menos eficiente que un lambdify completo, pero más flexible. # Evaluación numérica punto por punto # No necesitamos lambdify si X_val es un array y se evalúa punto por punto # La función `fit_model_wrapper` ya está iterando sobre los `t_data` (implícitamente a través de numpy) # Entonces, si X_val es un array, la expresión `final_expr.subs(subs_dict)` debería funcionar si # las operaciones son compatibles con numpy arrays (sympy suele hacerlo). # # La forma en que se llama a esta función desde curve_fit es: `fit_model_wrapper(time, p1, p2, ...)` # donde `time` es el array completo de tiempos. # `X_t_values` es un array de la misma longitud que `time`. # Simpler lambdify for the current model parameters, handle X_val in wrapper func_for_current_model = lambdify(args_for_lambdify, equation_expr.subs({X_val_sym: X_val_sym}), 'numpy') # Keep X_val symbolic for now # Now call it, and if it needs X_val, it must be part of its signature or global # This part is tricky with sympy and curve_fit when there are interdependencies. # The lambda should capture X_t_values correctly if subs_dict[X_val_sym] is set. # Let's evaluate por punto para mayor robustez con X_val siendo un array y_calculated = np.empty_like(t_data, dtype=float) for i, ti in enumerate(t_data): point_subs = subs_dict.copy() point_subs[t_sym] = ti if X_val_sym in point_subs and isinstance(point_subs[X_val_sym], np.ndarray): # Si X_val es un array, tomar el valor correspondiente a ti point_subs[X_val_sym] = point_subs[X_val_sym][i] y_calculated[i] = equation_expr.subs(point_subs).evalf() return y_calculated else: # Modelo de Biomasa o modelo S/P que no usa X_val (raro) func = lambdify(args_for_lambdify, equation_expr, 'numpy') return func(t_data, *current_p_values) p0 = np.ones(len(current_param_names)) # Estimaciones iniciales lower_bounds, upper_bounds = bounds # Asegurar que los límites tengan la longitud correcta lower_bounds = np.array(lower_bounds if len(lower_bounds) == len(p0) else [-np.inf] * len(p0)) upper_bounds = np.array(upper_bounds if len(upper_bounds) == len(p0) else [np.inf] * len(p0)) # Ignorar advertencias de RuntimeWarning de operaciones inválidas (ej. división por cero durante la optimización) with warnings.catch_warnings(): warnings.simplefilter("ignore", RuntimeWarning) warnings.simplefilter("ignore", UserWarning) # Sympy lambdify warnings popt, pcov = curve_fit(fit_model_wrapper, time, data, p0=p0, bounds=(lower_bounds, upper_bounds), maxfev=100000) self.params[model_type] = dict(zip(current_param_names, popt)) # Calcular R^2 y RMSE usando los parámetros ajustados # Es importante volver a llamar a fit_model_wrapper para obtener y_pred con los popt, # ya que la función interna maneja las dependencias de X(t) y_pred = fit_model_wrapper(time, *popt) ss_res = np.sum((data - y_pred) ** 2) ss_tot = np.sum((data - np.mean(data)) ** 2) if ss_tot == 0: # Evitar división por cero si los datos son constantes self.r2[model_type] = 1.0 if ss_res < 1e-9 else 0.0 else: self.r2[model_type] = 1 - (ss_res / ss_tot) self.rmse[model_type] = np.sqrt(mean_squared_error(data, y_pred)) return y_pred, popt # Devolver y_pred y los parámetros optimizados