|
|
|
import os |
|
import sys |
|
import subprocess |
|
|
|
os.system("pip install --upgrade gradio") |
|
|
|
|
|
import os |
|
import io |
|
import tempfile |
|
import traceback |
|
import zipfile |
|
from typing import List, Tuple, Dict, Any, Optional, Union |
|
from abc import ABC, abstractmethod |
|
from unittest.mock import MagicMock |
|
from dataclasses import dataclass |
|
from enum import Enum |
|
import json |
|
import base64 |
|
|
|
from PIL import Image |
|
import gradio as gr |
|
import plotly.graph_objects as go |
|
from plotly.subplots import make_subplots |
|
import plotly.io as pio |
|
import numpy as np |
|
import pandas as pd |
|
import matplotlib.pyplot as plt |
|
import seaborn as sns |
|
from scipy.integrate import odeint |
|
from scipy.optimize import curve_fit, differential_evolution |
|
from sklearn.metrics import mean_squared_error, r2_score |
|
from docx import Document |
|
from docx.shared import Inches |
|
from fpdf import FPDF |
|
from fpdf.enums import XPos, YPos |
|
from fastapi import FastAPI |
|
import uvicorn |
|
|
|
|
|
class Language(Enum): |
|
ES = "Español" |
|
EN = "English" |
|
PT = "Português" |
|
FR = "Français" |
|
DE = "Deutsch" |
|
ZH = "中文" |
|
JA = "日本語" |
|
|
|
TRANSLATIONS = { |
|
Language.ES: { |
|
"title": "🔬 Analizador de Cinéticas de Bioprocesos", |
|
"subtitle": "Análisis avanzado de modelos matemáticos biotecnológicos", |
|
"welcome": "Bienvenido al Analizador de Cinéticas", |
|
"upload": "Sube tu archivo Excel (.xlsx)", |
|
"select_models": "Modelos a Probar", |
|
"analysis_mode": "Modo de Análisis", |
|
"analyze": "Analizar y Graficar", |
|
"results": "Resultados", |
|
"download": "Descargar", |
|
"biomass": "Biomasa", |
|
"substrate": "Sustrato", |
|
"product": "Producto", |
|
"time": "Tiempo", |
|
"parameters": "Parámetros", |
|
"model_comparison": "Comparación de Modelos", |
|
"dark_mode": "Modo Oscuro", |
|
"light_mode": "Modo Claro", |
|
"language": "Idioma", |
|
"theory": "Teoría y Modelos", |
|
"guide": "Guía de Uso", |
|
"api_docs": "Documentación API", |
|
"individual": "Individual", |
|
"average": "Promedio", |
|
"combined": "Combinado", |
|
"config": "Configuración" |
|
}, |
|
Language.EN: { |
|
"title": "🔬 Bioprocess Kinetics Analyzer", |
|
"subtitle": "Advanced analysis of biotechnological mathematical models", |
|
"welcome": "Welcome to the Kinetics Analyzer", |
|
"upload": "Upload your Excel file (.xlsx)", |
|
"select_models": "Models to Test", |
|
"analysis_mode": "Analysis Mode", |
|
"analyze": "Analyze and Plot", |
|
"results": "Results", |
|
"download": "Download", |
|
"biomass": "Biomass", |
|
"substrate": "Substrate", |
|
"product": "Product", |
|
"time": "Time", |
|
"parameters": "Parameters", |
|
"model_comparison": "Model Comparison", |
|
"dark_mode": "Dark Mode", |
|
"light_mode": "Light Mode", |
|
"language": "Language", |
|
"theory": "Theory and Models", |
|
"guide": "User Guide", |
|
"api_docs": "API Documentation", |
|
"individual": "Individual", |
|
"average": "Average", |
|
"combined": "Combined", |
|
"config": "Configuration" |
|
}, |
|
} |
|
|
|
|
|
C_TIME = 'tiempo' |
|
C_BIOMASS = 'biomass' |
|
C_SUBSTRATE = 'substrate' |
|
C_PRODUCT = 'product' |
|
COMPONENTS = [C_BIOMASS, C_SUBSTRATE, C_PRODUCT] |
|
|
|
|
|
THEMES = { |
|
"light": gr.themes.Soft( |
|
primary_hue="blue", |
|
secondary_hue="sky", |
|
neutral_hue="gray", |
|
font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "sans-serif"] |
|
), |
|
"dark": gr.themes.Base( |
|
primary_hue="blue", |
|
secondary_hue="cyan", |
|
neutral_hue="slate", |
|
font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "sans-serif"] |
|
).set( |
|
body_background_fill="*neutral_950", |
|
body_background_fill_dark="*neutral_950", |
|
button_primary_background_fill="*primary_600", |
|
button_primary_background_fill_hover="*primary_700", |
|
) |
|
} |
|
|
|
|
|
|
|
class KineticModel(ABC): |
|
def __init__(self, name: str, display_name: str, param_names: List[str], |
|
description: str = "", equation: str = "", reference: str = ""): |
|
self.name = name |
|
self.display_name = display_name |
|
self.param_names = param_names |
|
self.num_params = len(param_names) |
|
self.description = description |
|
self.equation = equation |
|
self.reference = reference |
|
|
|
@abstractmethod |
|
def model_function(self, t: np.ndarray, *params: float) -> np.ndarray: |
|
pass |
|
|
|
def diff_function(self, X: float, t: float, params: List[float]) -> float: |
|
return 0.0 |
|
|
|
@abstractmethod |
|
def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]: |
|
pass |
|
|
|
@abstractmethod |
|
def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]: |
|
pass |
|
|
|
|
|
class LogisticModel(KineticModel): |
|
def __init__(self): |
|
super().__init__( |
|
"logistic", |
|
"Logístico", |
|
["X0", "Xm", "μm"], |
|
"Modelo de crecimiento logístico clásico para poblaciones limitadas", |
|
r"X(t) = \frac{X_0 X_m e^{\mu_m t}}{X_m - X_0 + X_0 e^{\mu_m t}}", |
|
"Verhulst (1838)" |
|
) |
|
|
|
def model_function(self, t: np.ndarray, *params: float) -> np.ndarray: |
|
X0, Xm, um = params |
|
if Xm <= 0 or X0 <= 0 or Xm < X0: |
|
return np.full_like(t, np.nan) |
|
exp_arg = np.clip(um * t, -700, 700) |
|
term_exp = np.exp(exp_arg) |
|
denominator = Xm - X0 + X0 * term_exp |
|
denominator = np.where(denominator == 0, 1e-9, denominator) |
|
return (X0 * term_exp * Xm) / denominator |
|
|
|
def diff_function(self, X: float, t: float, params: List[float]) -> float: |
|
_, Xm, um = params |
|
return um * X * (1 - X / Xm) if Xm > 0 else 0.0 |
|
|
|
def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]: |
|
return [ |
|
biomass[0] if len(biomass) > 0 and biomass[0] > 1e-6 else 1e-3, |
|
max(biomass) if len(biomass) > 0 else 1.0, |
|
0.1 |
|
] |
|
|
|
def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]: |
|
initial_biomass = biomass[0] if len(biomass) > 0 else 1e-9 |
|
max_biomass = max(biomass) if len(biomass) > 0 else 1.0 |
|
return ([1e-9, initial_biomass, 1e-9], [max_biomass * 1.2, max_biomass * 5, np.inf]) |
|
|
|
|
|
class GompertzModel(KineticModel): |
|
def __init__(self): |
|
super().__init__( |
|
"gompertz", |
|
"Gompertz", |
|
["Xm", "μm", "λ"], |
|
"Modelo de crecimiento asimétrico con fase lag", |
|
r"X(t) = X_m \exp\left(-\exp\left(\frac{\mu_m e}{X_m}(\lambda-t)+1\right)\right)", |
|
"Gompertz (1825)" |
|
) |
|
|
|
def model_function(self, t: np.ndarray, *params: float) -> np.ndarray: |
|
Xm, um, lag = params |
|
if Xm <= 0 or um <= 0: |
|
return np.full_like(t, np.nan) |
|
exp_term = (um * np.e / Xm) * (lag - t) + 1 |
|
exp_term_clipped = np.clip(exp_term, -700, 700) |
|
return Xm * np.exp(-np.exp(exp_term_clipped)) |
|
|
|
def diff_function(self, X: float, t: float, params: List[float]) -> float: |
|
Xm, um, lag = params |
|
k_val = um * np.e / Xm |
|
u_val = k_val * (lag - t) + 1 |
|
u_val_clipped = np.clip(u_val, -np.inf, 700) |
|
return X * k_val * np.exp(u_val_clipped) if Xm > 0 and X > 0 else 0.0 |
|
|
|
def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]: |
|
return [ |
|
max(biomass) if len(biomass) > 0 else 1.0, |
|
0.1, |
|
time[np.argmax(np.gradient(biomass))] if len(biomass) > 1 else 0 |
|
] |
|
|
|
def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]: |
|
initial_biomass = min(biomass) if len(biomass) > 0 else 1e-9 |
|
max_biomass = max(biomass) if len(biomass) > 0 else 1.0 |
|
return ([max(1e-9, initial_biomass), 1e-9, 0], [max_biomass * 5, np.inf, max(time) if len(time) > 0 else 1]) |
|
|
|
|
|
class MoserModel(KineticModel): |
|
def __init__(self): |
|
super().__init__( |
|
"moser", |
|
"Moser", |
|
["Xm", "μm", "Ks"], |
|
"Modelo exponencial simple de Moser", |
|
r"X(t) = X_m (1 - e^{-\mu_m (t - K_s)})", |
|
"Moser (1958)" |
|
) |
|
|
|
def model_function(self, t: np.ndarray, *params: float) -> np.ndarray: |
|
Xm, um, Ks = params |
|
return Xm * (1 - np.exp(-um * (t - Ks))) if Xm > 0 and um > 0 else np.full_like(t, np.nan) |
|
|
|
def diff_function(self, X: float, t: float, params: List[float]) -> float: |
|
Xm, um, _ = params |
|
return um * (Xm - X) if Xm > 0 else 0.0 |
|
|
|
def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]: |
|
return [max(biomass) if len(biomass) > 0 else 1.0, 0.1, 0] |
|
|
|
def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]: |
|
initial_biomass = min(biomass) if len(biomass) > 0 else 1e-9 |
|
max_biomass = max(biomass) if len(biomass) > 0 else 1.0 |
|
return ([max(1e-9, initial_biomass), 1e-9, -np.inf], [max_biomass * 5, np.inf, np.inf]) |
|
|
|
|
|
class BaranyiModel(KineticModel): |
|
def __init__(self): |
|
super().__init__( |
|
"baranyi", |
|
"Baranyi", |
|
["X0", "Xm", "μm", "λ"], |
|
"Modelo de Baranyi con fase lag explícita", |
|
r"X(t) = X_m / [1 + ((X_m/X_0) - 1) \exp(-\mu_m A(t))]", |
|
"Baranyi & Roberts (1994)" |
|
) |
|
|
|
def model_function(self, t: np.ndarray, *params: float) -> np.ndarray: |
|
X0, Xm, um, lag = params |
|
if X0 <= 0 or Xm <= X0 or um <= 0 or lag < 0: |
|
return np.full_like(t, np.nan) |
|
A_t = t + (1 / um) * np.log(np.exp(-um * t) + np.exp(-um * lag) - np.exp(-um * (t + lag))) |
|
exp_um_At = np.exp(np.clip(um * A_t, -700, 700)) |
|
numerator = Xm |
|
denominator = 1 + ((Xm / X0) - 1) * (1 / exp_um_At) |
|
return numerator / np.where(denominator == 0, 1e-9, denominator) |
|
|
|
def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]: |
|
return [ |
|
biomass[0] if len(biomass) > 0 and biomass[0] > 1e-6 else 1e-3, |
|
max(biomass) if len(biomass) > 0 else 1.0, |
|
0.1, |
|
time[np.argmax(np.gradient(biomass))] if len(biomass) > 1 else 0.0 |
|
] |
|
|
|
def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]: |
|
initial_biomass = biomass[0] if len(biomass) > 0 else 1e-9 |
|
max_biomass = max(biomass) if len(biomass) > 0 else 1.0 |
|
return ([1e-9, max(1e-9, initial_biomass), 1e-9, 0], [max_biomass * 1.2, max_biomass * 10, np.inf, max(time) if len(time) > 0 else 1]) |
|
|
|
|
|
class MonodModel(KineticModel): |
|
def __init__(self): |
|
super().__init__( |
|
"monod", |
|
"Monod", |
|
["μmax", "Ks", "Y", "m"], |
|
"Modelo de Monod con mantenimiento celular", |
|
r"\mu = \frac{\mu_{max} \cdot S}{K_s + S} - m", |
|
"Monod (1949)" |
|
) |
|
|
|
def model_function(self, t: np.ndarray, *params: float) -> np.ndarray: |
|
|
|
μmax, Ks, Y, m = params |
|
|
|
return np.full_like(t, np.nan) |
|
|
|
def diff_function(self, X: float, t: float, params: List[float]) -> float: |
|
μmax, Ks, Y, m = params |
|
S = 10.0 |
|
μ = (μmax * S / (Ks + S)) - m |
|
return μ * X |
|
|
|
def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]: |
|
return [0.5, 0.1, 0.5, 0.01] |
|
|
|
def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]: |
|
return ([0.01, 0.001, 0.1, 0.0], [2.0, 5.0, 1.0, 0.1]) |
|
|
|
|
|
class ContoisModel(KineticModel): |
|
def __init__(self): |
|
super().__init__( |
|
"contois", |
|
"Contois", |
|
["μmax", "Ksx", "Y", "m"], |
|
"Modelo de Contois para alta densidad celular", |
|
r"\mu = \frac{\mu_{max} \cdot S}{K_{sx} \cdot X + S} - m", |
|
"Contois (1959)" |
|
) |
|
|
|
def model_function(self, t: np.ndarray, *params: float) -> np.ndarray: |
|
return np.full_like(t, np.nan) |
|
|
|
def diff_function(self, X: float, t: float, params: List[float]) -> float: |
|
μmax, Ksx, Y, m = params |
|
S = 10.0 |
|
μ = (μmax * S / (Ksx * X + S)) - m |
|
return μ * X |
|
|
|
def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]: |
|
return [0.5, 0.5, 0.5, 0.01] |
|
|
|
def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]: |
|
return ([0.01, 0.01, 0.1, 0.0], [2.0, 10.0, 1.0, 0.1]) |
|
|
|
|
|
class AndrewsModel(KineticModel): |
|
def __init__(self): |
|
super().__init__( |
|
"andrews", |
|
"Andrews (Haldane)", |
|
["μmax", "Ks", "Ki", "Y", "m"], |
|
"Modelo de inhibición por sustrato", |
|
r"\mu = \frac{\mu_{max} \cdot S}{K_s + S + \frac{S^2}{K_i}} - m", |
|
"Andrews (1968)" |
|
) |
|
|
|
def model_function(self, t: np.ndarray, *params: float) -> np.ndarray: |
|
return np.full_like(t, np.nan) |
|
|
|
def diff_function(self, X: float, t: float, params: List[float]) -> float: |
|
μmax, Ks, Ki, Y, m = params |
|
S = 10.0 |
|
μ = (μmax * S / (Ks + S + S**2/Ki)) - m |
|
return μ * X |
|
|
|
def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]: |
|
return [0.5, 0.1, 50.0, 0.5, 0.01] |
|
|
|
def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]: |
|
return ([0.01, 0.001, 1.0, 0.1, 0.0], [2.0, 5.0, 200.0, 1.0, 0.1]) |
|
|
|
|
|
class TessierModel(KineticModel): |
|
def __init__(self): |
|
super().__init__( |
|
"tessier", |
|
"Tessier", |
|
["μmax", "Ks", "X0"], |
|
"Modelo exponencial de Tessier", |
|
r"\mu = \mu_{max} \cdot (1 - e^{-S/K_s})", |
|
"Tessier (1942)" |
|
) |
|
|
|
def model_function(self, t: np.ndarray, *params: float) -> np.ndarray: |
|
μmax, Ks, X0 = params |
|
|
|
return X0 * np.exp(μmax * t * 0.5) |
|
|
|
def diff_function(self, X: float, t: float, params: List[float]) -> float: |
|
μmax, Ks, X0 = params |
|
return μmax * X * 0.5 |
|
|
|
def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]: |
|
return [0.5, 1.0, biomass[0] if len(biomass) > 0 else 0.1] |
|
|
|
def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]: |
|
return ([0.01, 0.1, 1e-9], [2.0, 10.0, 1.0]) |
|
|
|
|
|
class RichardsModel(KineticModel): |
|
def __init__(self): |
|
super().__init__( |
|
"richards", |
|
"Richards", |
|
["A", "μm", "λ", "ν", "X0"], |
|
"Modelo generalizado de Richards", |
|
r"X(t) = A \cdot [1 + \nu \cdot e^{-\mu_m(t-\lambda)}]^{-1/\nu}", |
|
"Richards (1959)" |
|
) |
|
|
|
def model_function(self, t: np.ndarray, *params: float) -> np.ndarray: |
|
A, μm, λ, ν, X0 = params |
|
if A <= 0 or μm <= 0 or ν <= 0: |
|
return np.full_like(t, np.nan) |
|
exp_term = np.exp(-μm * (t - λ)) |
|
return A * (1 + ν * exp_term) ** (-1/ν) |
|
|
|
def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]: |
|
return [ |
|
max(biomass) if len(biomass) > 0 else 1.0, |
|
0.5, |
|
time[len(time)//4] if len(time) > 0 else 1.0, |
|
1.0, |
|
biomass[0] if len(biomass) > 0 else 0.1 |
|
] |
|
|
|
def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]: |
|
max_biomass = max(biomass) if len(biomass) > 0 else 10.0 |
|
max_time = max(time) if len(time) > 0 else 100.0 |
|
return ( |
|
[0.1, 0.01, 0.0, 0.1, 1e-9], |
|
[max_biomass * 2, 5.0, max_time, 10.0, max_biomass] |
|
) |
|
|
|
|
|
class StannardModel(KineticModel): |
|
def __init__(self): |
|
super().__init__( |
|
"stannard", |
|
"Stannard", |
|
["Xm", "μm", "λ", "α"], |
|
"Modelo de Stannard modificado", |
|
r"X(t) = X_m \cdot [1 - e^{-\mu_m(t-\lambda)^\alpha}]", |
|
"Stannard et al. (1985)" |
|
) |
|
|
|
def model_function(self, t: np.ndarray, *params: float) -> np.ndarray: |
|
Xm, μm, λ, α = params |
|
if Xm <= 0 or μm <= 0 or α <= 0: |
|
return np.full_like(t, np.nan) |
|
t_shifted = np.maximum(t - λ, 0) |
|
return Xm * (1 - np.exp(-μm * t_shifted ** α)) |
|
|
|
def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]: |
|
return [ |
|
max(biomass) if len(biomass) > 0 else 1.0, |
|
0.5, |
|
0.0, |
|
1.0 |
|
] |
|
|
|
def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]: |
|
max_biomass = max(biomass) if len(biomass) > 0 else 10.0 |
|
max_time = max(time) if len(time) > 0 else 100.0 |
|
return ([0.1, 0.01, -max_time/10, 0.1], [max_biomass * 2, 5.0, max_time/2, 3.0]) |
|
|
|
|
|
class HuangModel(KineticModel): |
|
def __init__(self): |
|
super().__init__( |
|
"huang", |
|
"Huang", |
|
["Xm", "μm", "λ", "n", "m"], |
|
"Modelo de Huang para fase lag variable", |
|
r"X(t) = X_m \cdot \frac{1}{1 + e^{-\mu_m(t-\lambda-m/n)}}", |
|
"Huang (2008)" |
|
) |
|
|
|
def model_function(self, t: np.ndarray, *params: float) -> np.ndarray: |
|
Xm, μm, λ, n, m = params |
|
if Xm <= 0 or μm <= 0 or n <= 0: |
|
return np.full_like(t, np.nan) |
|
return Xm / (1 + np.exp(-μm * (t - λ - m/n))) |
|
|
|
def get_initial_params(self, time: np.ndarray, biomass: np.ndarray) -> List[float]: |
|
return [ |
|
max(biomass) if len(biomass) > 0 else 1.0, |
|
0.5, |
|
time[len(time)//4] if len(time) > 0 else 1.0, |
|
1.0, |
|
0.5 |
|
] |
|
|
|
def get_param_bounds(self, time: np.ndarray, biomass: np.ndarray) -> Tuple[List[float], List[float]]: |
|
max_biomass = max(biomass) if len(biomass) > 0 else 10.0 |
|
max_time = max(time) if len(time) > 0 else 100.0 |
|
return ( |
|
[0.1, 0.01, 0.0, 0.1, 0.0], |
|
[max_biomass * 2, 5.0, max_time/2, 10.0, 5.0] |
|
) |
|
|
|
|
|
AVAILABLE_MODELS: Dict[str, KineticModel] = { |
|
model.name: model for model in [ |
|
LogisticModel(), |
|
GompertzModel(), |
|
MoserModel(), |
|
BaranyiModel(), |
|
MonodModel(), |
|
ContoisModel(), |
|
AndrewsModel(), |
|
TessierModel(), |
|
RichardsModel(), |
|
StannardModel(), |
|
HuangModel() |
|
] |
|
} |
|
|
|
|
|
class BioprocessFitter: |
|
def __init__(self, kinetic_model: KineticModel, maxfev: int = 50000, |
|
use_differential_evolution: bool = False): |
|
self.model = kinetic_model |
|
self.maxfev = maxfev |
|
self.use_differential_evolution = use_differential_evolution |
|
self.params: Dict[str, Dict[str, float]] = {c: {} for c in COMPONENTS} |
|
self.r2: Dict[str, float] = {} |
|
self.rmse: Dict[str, float] = {} |
|
self.mae: Dict[str, float] = {} |
|
self.aic: Dict[str, float] = {} |
|
self.bic: Dict[str, float] = {} |
|
self.data_time: Optional[np.ndarray] = None |
|
self.data_means: Dict[str, Optional[np.ndarray]] = {c: None for c in COMPONENTS} |
|
self.data_stds: Dict[str, Optional[np.ndarray]] = {c: None for c in COMPONENTS} |
|
self.raw_data: Dict[str, List[np.ndarray]] = {c: [] for c in COMPONENTS} |
|
|
|
def _get_biomass_at_t(self, t: np.ndarray, p: List[float]) -> np.ndarray: |
|
return self.model.model_function(t, *p) |
|
|
|
def _get_initial_biomass(self, p: List[float]) -> float: |
|
if not p: return 0.0 |
|
if any(k in self.model.param_names for k in ["Xo", "X0"]): |
|
try: |
|
idx = self.model.param_names.index("Xo") if "Xo" in self.model.param_names else self.model.param_names.index("X0") |
|
return p[idx] |
|
except (ValueError, IndexError): pass |
|
return float(self.model.model_function(np.array([0]), *p)[0]) |
|
|
|
def _calc_integral(self, t: np.ndarray, p: List[float]) -> Tuple[np.ndarray, np.ndarray]: |
|
X_t = self._get_biomass_at_t(t, p) |
|
if np.any(np.isnan(X_t)): return np.full_like(t, np.nan), np.full_like(t, np.nan) |
|
integral_X = np.zeros_like(X_t) |
|
if len(t) > 1: |
|
dt = np.diff(t, prepend=t[0] - (t[1] - t[0] if len(t) > 1 else 1)) |
|
integral_X = np.cumsum(X_t * dt) |
|
return integral_X, X_t |
|
|
|
def substrate(self, t: np.ndarray, so: float, p_c: float, q: float, bio_p: List[float]) -> np.ndarray: |
|
integral, X_t = self._calc_integral(t, bio_p) |
|
X0 = self._get_initial_biomass(bio_p) |
|
return so - p_c * (X_t - X0) - q * integral |
|
|
|
def product(self, t: np.ndarray, po: float, alpha: float, beta: float, bio_p: List[float]) -> np.ndarray: |
|
integral, X_t = self._calc_integral(t, bio_p) |
|
X0 = self._get_initial_biomass(bio_p) |
|
return po + alpha * (X_t - X0) + beta * integral |
|
|
|
def process_data_from_df(self, df: pd.DataFrame) -> None: |
|
try: |
|
time_col = [c for c in df.columns if c[1].strip().lower() == C_TIME][0] |
|
self.data_time = df[time_col].dropna().to_numpy() |
|
min_len = len(self.data_time) |
|
|
|
def extract(name: str) -> Tuple[np.ndarray, np.ndarray, List[np.ndarray]]: |
|
cols = [c for c in df.columns if c[1].strip().lower() == name.lower()] |
|
if not cols: return np.array([]), np.array([]), [] |
|
reps = [df[c].dropna().values[:min_len] for c in cols] |
|
reps = [r for r in reps if len(r) == min_len] |
|
if not reps: return np.array([]), np.array([]), [] |
|
arr = np.array(reps) |
|
mean = np.mean(arr, axis=0) |
|
std = np.std(arr, axis=0, ddof=1) if arr.shape[0] > 1 else np.zeros_like(mean) |
|
return mean, std, reps |
|
|
|
|
|
for comp, name in [(C_BIOMASS, 'Biomasa'), (C_SUBSTRATE, 'Sustrato'), (C_PRODUCT, 'Producto')]: |
|
mean, std, reps = extract(name) |
|
self.data_means[comp] = mean |
|
self.data_stds[comp] = std |
|
self.raw_data[comp] = reps |
|
|
|
except (IndexError, KeyError) as e: |
|
raise ValueError(f"Estructura de DataFrame inválida. Error: {e}") |
|
|
|
def _calculate_metrics(self, y_true: np.ndarray, y_pred: np.ndarray, |
|
n_params: int) -> Dict[str, float]: |
|
"""Calcula métricas adicionales de bondad de ajuste""" |
|
n = len(y_true) |
|
residuals = y_true - y_pred |
|
ss_res = np.sum(residuals**2) |
|
ss_tot = np.sum((y_true - np.mean(y_true))**2) |
|
|
|
r2 = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0 |
|
rmse = np.sqrt(ss_res / n) |
|
mae = np.mean(np.abs(residuals)) |
|
|
|
|
|
if n > n_params + 1: |
|
aic = n * np.log(ss_res/n) + 2 * n_params |
|
bic = n * np.log(ss_res/n) + n_params * np.log(n) |
|
else: |
|
aic = bic = np.inf |
|
|
|
return { |
|
'r2': r2, |
|
'rmse': rmse, |
|
'mae': mae, |
|
'aic': aic, |
|
'bic': bic |
|
} |
|
|
|
def _fit_component_de(self, func, t, data, bounds, *args): |
|
"""Ajuste usando evolución diferencial para optimización global""" |
|
def objective(params): |
|
try: |
|
pred = func(t, *params, *args) |
|
if np.any(np.isnan(pred)): |
|
return 1e10 |
|
return np.sum((data - pred)**2) |
|
except: |
|
return 1e10 |
|
|
|
result = differential_evolution(objective, bounds=list(zip(*bounds)), |
|
maxiter=1000, seed=42) |
|
if result.success: |
|
popt = result.x |
|
pred = func(t, *popt, *args) |
|
metrics = self._calculate_metrics(data, pred, len(popt)) |
|
return list(popt), metrics |
|
return None, {'r2': np.nan, 'rmse': np.nan, 'mae': np.nan, |
|
'aic': np.nan, 'bic': np.nan} |
|
|
|
def _fit_component(self, func, t, data, p0, bounds, sigma=None, *args): |
|
try: |
|
if self.use_differential_evolution: |
|
return self._fit_component_de(func, t, data, bounds, *args) |
|
|
|
if sigma is not None: |
|
sigma = np.where(sigma == 0, 1e-9, sigma) |
|
|
|
popt, _ = curve_fit(func, t, data, p0, bounds=bounds, |
|
maxfev=self.maxfev, ftol=1e-9, xtol=1e-9, |
|
sigma=sigma, absolute_sigma=bool(sigma is not None)) |
|
|
|
pred = func(t, *popt, *args) |
|
if np.any(np.isnan(pred)): |
|
return None, {'r2': np.nan, 'rmse': np.nan, 'mae': np.nan, |
|
'aic': np.nan, 'bic': np.nan} |
|
|
|
metrics = self._calculate_metrics(data, pred, len(popt)) |
|
return list(popt), metrics |
|
|
|
except (RuntimeError, ValueError): |
|
return None, {'r2': np.nan, 'rmse': np.nan, 'mae': np.nan, |
|
'aic': np.nan, 'bic': np.nan} |
|
|
|
def fit_all_models(self) -> None: |
|
t, bio_m, bio_s = self.data_time, self.data_means[C_BIOMASS], self.data_stds[C_BIOMASS] |
|
if t is None or bio_m is None or len(bio_m) == 0: return |
|
popt_bio = self._fit_biomass_model(t, bio_m, bio_s) |
|
if popt_bio: |
|
bio_p = list(self.params[C_BIOMASS].values()) |
|
if self.data_means[C_SUBSTRATE] is not None and len(self.data_means[C_SUBSTRATE]) > 0: |
|
self._fit_substrate_model(t, self.data_means[C_SUBSTRATE], self.data_stds[C_SUBSTRATE], bio_p) |
|
if self.data_means[C_PRODUCT] is not None and len(self.data_means[C_PRODUCT]) > 0: |
|
self._fit_product_model(t, self.data_means[C_PRODUCT], self.data_stds[C_PRODUCT], bio_p) |
|
|
|
def _fit_biomass_model(self, t, data, std): |
|
p0, bounds = self.model.get_initial_params(t, data), self.model.get_param_bounds(t, data) |
|
popt, metrics = self._fit_component(self.model.model_function, t, data, p0, bounds, std) |
|
if popt: |
|
self.params[C_BIOMASS] = dict(zip(self.model.param_names, popt)) |
|
self.r2[C_BIOMASS] = metrics['r2'] |
|
self.rmse[C_BIOMASS] = metrics['rmse'] |
|
self.mae[C_BIOMASS] = metrics['mae'] |
|
self.aic[C_BIOMASS] = metrics['aic'] |
|
self.bic[C_BIOMASS] = metrics['bic'] |
|
return popt |
|
|
|
def _fit_substrate_model(self, t, data, std, bio_p): |
|
p0, b = [data[0], 0.1, 0.01], ([0, -np.inf, -np.inf], [np.inf, np.inf, np.inf]) |
|
popt, metrics = self._fit_component(lambda t, so, p, q: self.substrate(t, so, p, q, bio_p), t, data, p0, b, std) |
|
if popt: |
|
self.params[C_SUBSTRATE] = {'So': popt[0], 'p': popt[1], 'q': popt[2]} |
|
self.r2[C_SUBSTRATE] = metrics['r2'] |
|
self.rmse[C_SUBSTRATE] = metrics['rmse'] |
|
self.mae[C_SUBSTRATE] = metrics['mae'] |
|
self.aic[C_SUBSTRATE] = metrics['aic'] |
|
self.bic[C_SUBSTRATE] = metrics['bic'] |
|
|
|
def _fit_product_model(self, t, data, std, bio_p): |
|
p0, b = [data[0] if len(data)>0 else 0, 0.1, 0.01], ([0, -np.inf, -np.inf], [np.inf, np.inf, np.inf]) |
|
popt, metrics = self._fit_component(lambda t, po, a, b: self.product(t, po, a, b, bio_p), t, data, p0, b, std) |
|
if popt: |
|
self.params[C_PRODUCT] = {'Po': popt[0], 'alpha': popt[1], 'beta': popt[2]} |
|
self.r2[C_PRODUCT] = metrics['r2'] |
|
self.rmse[C_PRODUCT] = metrics['rmse'] |
|
self.mae[C_PRODUCT] = metrics['mae'] |
|
self.aic[C_PRODUCT] = metrics['aic'] |
|
self.bic[C_PRODUCT] = metrics['bic'] |
|
|
|
def system_ode(self, y, t, bio_p, sub_p, prod_p): |
|
X, _, _ = y |
|
dXdt = self.model.diff_function(X, t, bio_p) |
|
return [dXdt, -sub_p.get('p',0)*dXdt - sub_p.get('q',0)*X, prod_p.get('alpha',0)*dXdt + prod_p.get('beta',0)*X] |
|
|
|
def solve_odes(self, t_fine): |
|
p = self.params |
|
bio_d, sub_d, prod_d = p[C_BIOMASS], p[C_SUBSTRATE], p[C_PRODUCT] |
|
if not bio_d: return None, None, None |
|
try: |
|
bio_p = list(bio_d.values()) |
|
y0 = [self._get_initial_biomass(bio_p), sub_d.get('So',0), prod_d.get('Po',0)] |
|
sol = odeint(self.system_ode, y0, t_fine, args=(bio_p, sub_d, prod_d)) |
|
return sol[:, 0], sol[:, 1], sol[:, 2] |
|
except: |
|
return None, None, None |
|
|
|
def _generate_fine_time_grid(self, t_exp): |
|
return np.linspace(min(t_exp), max(t_exp), 500) if t_exp is not None and len(t_exp) > 1 else np.array([]) |
|
|
|
def get_model_curves_for_plot(self, t_fine, use_diff): |
|
if use_diff and self.model.diff_function(1, 1, [1]*self.model.num_params) != 0: |
|
return self.solve_odes(t_fine) |
|
X, S, P = None, None, None |
|
if self.params[C_BIOMASS]: |
|
bio_p = list(self.params[C_BIOMASS].values()) |
|
X = self.model.model_function(t_fine, *bio_p) |
|
if self.params[C_SUBSTRATE]: |
|
S = self.substrate(t_fine, *list(self.params[C_SUBSTRATE].values()), bio_p) |
|
if self.params[C_PRODUCT]: |
|
P = self.product(t_fine, *list(self.params[C_PRODUCT].values()), bio_p) |
|
return X, S, P |
|
|
|
def plot_individual_or_combined(self, cfg, mode): |
|
"""Crea gráficos individuales o combinados con Matplotlib/Seaborn""" |
|
t_exp, t_fine = cfg['time_exp'], self._generate_fine_time_grid(cfg['time_exp']) |
|
X_m, S_m, P_m = self.get_model_curves_for_plot(t_fine, cfg.get('use_differential', False)) |
|
|
|
sns.set_style(cfg.get('style', 'whitegrid')) |
|
|
|
if mode == 'average': |
|
fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(10, 15), sharex=True) |
|
fig.suptitle(f"Análisis: {cfg.get('exp_name', '')} ({self.model.display_name})", fontsize=16) |
|
axes = [ax1, ax2, ax3] |
|
else: |
|
fig, ax1 = plt.subplots(figsize=(12, 8)) |
|
fig.suptitle(f"Análisis: {cfg.get('exp_name', '')} ({self.model.display_name})", fontsize=16) |
|
ax2 = ax1.twinx() |
|
ax3 = ax1.twinx() |
|
ax3.spines["right"].set_position(("axes", 1.18)) |
|
axes = [ax1, ax2, ax3] |
|
|
|
data_map = {C_BIOMASS: X_m, C_SUBSTRATE: S_m, C_PRODUCT: P_m} |
|
comb_styles = { |
|
C_BIOMASS: {'c': '#0072B2', 'mc': '#56B4E9', 'm': 'o', 'ls': '-'}, |
|
C_SUBSTRATE: {'c': '#009E73', 'mc': '#34E499', 'm': 's', 'ls': '--'}, |
|
C_PRODUCT: {'c': '#D55E00', 'mc': '#F0E442', 'm': '^', 'ls': '-.'} |
|
} |
|
|
|
for ax, comp in zip(axes, COMPONENTS): |
|
ylabel = cfg.get('axis_labels', {}).get(f'{comp}_label', comp.capitalize()) |
|
data = cfg.get(f'{comp}_exp') |
|
std = cfg.get(f'{comp}_std') |
|
model_data = data_map.get(comp) |
|
|
|
if mode == 'combined': |
|
s = comb_styles[comp] |
|
pc, lc, ms, ls = s['c'], s['mc'], s['m'], s['ls'] |
|
else: |
|
pc = cfg.get(f'{comp}_point_color') |
|
lc = cfg.get(f'{comp}_line_color') |
|
ms = cfg.get(f'{comp}_marker_style') |
|
ls = cfg.get(f'{comp}_line_style') |
|
|
|
ax_c = pc if mode == 'combined' else 'black' |
|
ax.set_ylabel(ylabel, color=ax_c) |
|
ax.tick_params(axis='y', labelcolor=ax_c) |
|
|
|
if data is not None and len(data) > 0: |
|
if cfg.get('show_error_bars') and std is not None and np.any(std > 0): |
|
ax.errorbar(t_exp, data, yerr=std, fmt=ms, color=pc, |
|
label=f'{comp.capitalize()} (Datos)', |
|
capsize=cfg.get('error_cap_size', 3), |
|
elinewidth=cfg.get('error_line_width', 1)) |
|
else: |
|
ax.plot(t_exp, data, ls='', marker=ms, color=pc, |
|
label=f'{comp.capitalize()} (Datos)') |
|
|
|
if model_data is not None and len(model_data) > 0: |
|
ax.plot(t_fine, model_data, ls=ls, color=lc, |
|
label=f'{comp.capitalize()} (Modelo)') |
|
|
|
if mode == 'average' and cfg.get('show_legend', True): |
|
ax.legend(loc=cfg.get('legend_pos', 'best')) |
|
|
|
if mode == 'average' and cfg.get('show_params', True) and self.params[comp]: |
|
decs = cfg.get('decimal_places', 3) |
|
p_txt = '\n'.join([f"{k}={format_number(v, decs)}" for k, v in self.params[comp].items()]) |
|
full_txt = f"{p_txt}\nR²={format_number(self.r2.get(comp, 0), 3)}, RMSE={format_number(self.rmse.get(comp, 0), 3)}" |
|
pos_x, ha = (0.95, 'right') if 'right' in cfg.get('params_pos', 'upper right') else (0.05, 'left') |
|
ax.text(pos_x, 0.95, full_txt, transform=ax.transAxes, va='top', ha=ha, |
|
bbox=dict(boxstyle='round,pad=0.4', fc='wheat', alpha=0.7)) |
|
|
|
if mode == 'combined' and cfg.get('show_legend', True): |
|
h1, l1 = axes[0].get_legend_handles_labels() |
|
h2, l2 = axes[1].get_legend_handles_labels() |
|
h3, l3 = axes[2].get_legend_handles_labels() |
|
axes[0].legend(handles=h1+h2+h3, labels=l1+l2+l3, loc=cfg.get('legend_pos', 'best')) |
|
|
|
axes[-1].set_xlabel(cfg.get('axis_labels', {}).get('x_label', 'Tiempo')) |
|
plt.tight_layout() |
|
|
|
if mode == 'combined': |
|
fig.subplots_adjust(right=0.8) |
|
|
|
return fig |
|
|
|
|
|
|
|
def format_number(value: Any, decimals: int) -> str: |
|
"""Formatea un número para su visualización""" |
|
if not isinstance(value, (int, float, np.number)) or pd.isna(value): |
|
return "" if pd.isna(value) else str(value) |
|
|
|
decimals = int(decimals) |
|
|
|
if decimals == 0: |
|
if 0 < abs(value) < 1: |
|
return f"{value:.2e}" |
|
else: |
|
return str(int(round(value, 0))) |
|
|
|
return str(round(value, decimals)) |
|
|
|
|
|
|
|
def plot_model_comparison_matplotlib(plot_config: Dict, models_results: List[Dict]) -> plt.Figure: |
|
"""Crea un gráfico de comparación de modelos estático usando Matplotlib/Seaborn""" |
|
time_exp = plot_config['time_exp'] |
|
time_fine = BioprocessFitter(list(AVAILABLE_MODELS.values())[0])._generate_fine_time_grid(time_exp) |
|
num_models = len(models_results) |
|
|
|
palettes = { |
|
C_BIOMASS: sns.color_palette("Blues", num_models), |
|
C_SUBSTRATE: sns.color_palette("Greens", num_models), |
|
C_PRODUCT: sns.color_palette("Reds", num_models) |
|
} |
|
line_styles = ['-', '--', '-.', ':'] |
|
|
|
sns.set_style(plot_config.get('style', 'whitegrid')) |
|
fig, ax1 = plt.subplots(figsize=(12, 8)) |
|
|
|
|
|
ax1.set_xlabel(plot_config['axis_labels']['x_label']) |
|
ax1.set_ylabel(plot_config['axis_labels']['biomass_label'], color="navy", fontsize=12) |
|
ax1.tick_params(axis='y', labelcolor="navy") |
|
ax2 = ax1.twinx() |
|
ax3 = ax1.twinx() |
|
ax3.spines["right"].set_position(("axes", 1.22)) |
|
ax2.set_ylabel(plot_config['axis_labels']['substrate_label'], color="darkgreen", fontsize=12) |
|
ax2.tick_params(axis='y', labelcolor="darkgreen") |
|
ax3.set_ylabel(plot_config['axis_labels']['product_label'], color="darkred", fontsize=12) |
|
ax3.tick_params(axis='y', labelcolor="darkred") |
|
|
|
|
|
data_markers = {C_BIOMASS: 'o', C_SUBSTRATE: 's', C_PRODUCT: '^'} |
|
for ax, key, color, face in [(ax1, C_BIOMASS, 'navy', 'skyblue'), |
|
(ax2, C_SUBSTRATE, 'darkgreen', 'lightgreen'), |
|
(ax3, C_PRODUCT, 'darkred', 'lightcoral')]: |
|
data_exp = plot_config.get(f'{key}_exp') |
|
data_std = plot_config.get(f'{key}_std') |
|
if data_exp is not None: |
|
if plot_config.get('show_error_bars') and data_std is not None and np.any(data_std > 0): |
|
ax.errorbar(time_exp, data_exp, yerr=data_std, fmt=data_markers[key], |
|
color=color, label=f'{key.capitalize()} (Datos)', zorder=10, |
|
markersize=8, markerfacecolor=face, markeredgecolor=color, |
|
capsize=plot_config.get('error_cap_size', 3), |
|
elinewidth=plot_config.get('error_line_width', 1)) |
|
else: |
|
ax.plot(time_exp, data_exp, ls='', marker=data_markers[key], |
|
label=f'{key.capitalize()} (Datos)', zorder=10, ms=8, |
|
mfc=face, mec=color, mew=1.5) |
|
|
|
|
|
for i, res in enumerate(models_results): |
|
ls = line_styles[i % len(line_styles)] |
|
model_info = AVAILABLE_MODELS.get(res["name"], MagicMock(display_name=res["name"])) |
|
model_display_name = model_info.display_name |
|
for key_short, ax, name_long in [('X', ax1, C_BIOMASS), ('S', ax2, C_SUBSTRATE), ('P', ax3, C_PRODUCT)]: |
|
if res.get(key_short) is not None: |
|
ax.plot(time_fine, res[key_short], color=palettes[name_long][i], ls=ls, |
|
label=f'{name_long.capitalize()} ({model_display_name})', alpha=0.9) |
|
|
|
fig.subplots_adjust(left=0.3, right=0.78, top=0.92, |
|
bottom=0.35 if plot_config.get('show_params') else 0.1) |
|
|
|
if plot_config.get('show_legend'): |
|
h1, l1 = ax1.get_legend_handles_labels() |
|
h2, l2 = ax2.get_legend_handles_labels() |
|
h3, l3 = ax3.get_legend_handles_labels() |
|
fig.legend(h1 + h2 + h3, l1 + l2 + l3, loc='center left', |
|
bbox_to_anchor=(0.0, 0.5), fancybox=True, shadow=True, fontsize='small') |
|
|
|
if plot_config.get('show_params'): |
|
total_width = 0.95 |
|
box_width = total_width / num_models |
|
start_pos = (1.0 - total_width) / 2 |
|
for i, res in enumerate(models_results): |
|
model_info = AVAILABLE_MODELS.get(res["name"], MagicMock(display_name=res["name"])) |
|
text = f"**{model_info.display_name}**\n" + _generate_model_param_text(res, plot_config.get('decimal_places', 3)) |
|
fig.text(start_pos + i * box_width, 0.01, text, transform=fig.transFigure, |
|
fontsize=7.5, va='bottom', ha='left', |
|
bbox=dict(boxstyle='round,pad=0.4', fc='ivory', ec='gray', alpha=0.9)) |
|
|
|
fig.suptitle(f"Comparación de Modelos: {plot_config.get('exp_name', '')}", fontsize=16) |
|
return fig |
|
|
|
def plot_model_comparison_plotly(plot_config: Dict, models_results: List[Dict]) -> go.Figure: |
|
"""Crea un gráfico de comparación de modelos interactivo usando Plotly""" |
|
fig = go.Figure() |
|
time_exp = plot_config['time_exp'] |
|
time_fine = BioprocessFitter(list(AVAILABLE_MODELS.values())[0])._generate_fine_time_grid(time_exp) |
|
num_models = len(models_results) |
|
|
|
palettes = { |
|
C_BIOMASS: sns.color_palette("Blues", n_colors=num_models).as_hex(), |
|
C_SUBSTRATE: sns.color_palette("Greens", n_colors=num_models).as_hex(), |
|
C_PRODUCT: sns.color_palette("Reds", n_colors=num_models).as_hex() |
|
} |
|
line_styles = ['solid', 'dash', 'dot', 'dashdot'] |
|
data_markers = {C_BIOMASS: 'circle-open', C_SUBSTRATE: 'square-open', C_PRODUCT: 'diamond-open'} |
|
|
|
for key, y_axis, color in [(C_BIOMASS, 'y1', 'navy'), |
|
(C_SUBSTRATE, 'y2', 'darkgreen'), |
|
(C_PRODUCT, 'y3', 'darkred')]: |
|
data_exp = plot_config.get(f'{key}_exp') |
|
data_std = plot_config.get(f'{key}_std') |
|
if data_exp is not None: |
|
error_y_config = dict(type='data', array=data_std, visible=True) if plot_config.get('show_error_bars') and data_std is not None and np.any(data_std > 0) else None |
|
fig.add_trace(go.Scatter( |
|
x=time_exp, y=data_exp, mode='markers', |
|
name=f'{key.capitalize()} (Datos)', |
|
marker=dict(color=color, size=10, symbol=data_markers[key], line=dict(width=2)), |
|
error_y=error_y_config, yaxis=y_axis, legendgroup="data")) |
|
|
|
for i, res in enumerate(models_results): |
|
ls = line_styles[i % len(line_styles)] |
|
model_display_name = AVAILABLE_MODELS.get(res["name"], MagicMock(display_name=res["name"])).display_name |
|
if res.get('X') is not None: |
|
fig.add_trace(go.Scatter(x=time_fine, y=res['X'], mode='lines', |
|
name=f'Biomasa ({model_display_name})', |
|
line=dict(color=palettes[C_BIOMASS][i], dash=ls), |
|
legendgroup=res["name"])) |
|
if res.get('S') is not None: |
|
fig.add_trace(go.Scatter(x=time_fine, y=res['S'], mode='lines', |
|
name=f'Sustrato ({model_display_name})', |
|
line=dict(color=palettes[C_SUBSTRATE][i], dash=ls), |
|
yaxis='y2', legendgroup=res["name"])) |
|
if res.get('P') is not None: |
|
fig.add_trace(go.Scatter(x=time_fine, y=res['P'], mode='lines', |
|
name=f'Producto ({model_display_name})', |
|
line=dict(color=palettes[C_PRODUCT][i], dash=ls), |
|
yaxis='y3', legendgroup=res["name"])) |
|
|
|
if plot_config.get('show_params'): |
|
x_positions = np.linspace(0, 1, num_models * 2 + 1)[1::2] |
|
for i, res in enumerate(models_results): |
|
model_display_name = AVAILABLE_MODELS.get(res["name"], MagicMock(display_name=res["name"])).display_name |
|
text = f"<b>{model_display_name}</b><br>" + _generate_model_param_text(res, plot_config.get('decimal_places', 3)).replace('\n', '<br>') |
|
fig.add_annotation(text=text, align='left', showarrow=False, xref='paper', |
|
yref='paper', x=x_positions[i], y=-0.35, bordercolor='gray', |
|
borderwidth=1, bgcolor='ivory', opacity=0.9) |
|
|
|
fig.update_layout( |
|
title=f"Comparación de Modelos (Interactivo): {plot_config.get('exp_name', '')}", |
|
xaxis=dict(domain=[0.18, 0.82]), |
|
yaxis=dict(title=plot_config['axis_labels']['biomass_label'], titlefont=dict(color='navy'), |
|
tickfont=dict(color='navy')), |
|
yaxis2=dict(title=plot_config['axis_labels']['substrate_label'], titlefont=dict(color='darkgreen'), |
|
tickfont=dict(color='darkgreen'), overlaying='y', side='right'), |
|
yaxis3=dict(title=plot_config['axis_labels']['product_label'], titlefont=dict(color='darkred'), |
|
tickfont=dict(color='darkred'), overlaying='y', side='right', position=0.85), |
|
legend=dict(traceorder="grouped", yanchor="middle", y=0.5, xanchor="right", x=-0.15), |
|
margin=dict(l=200, r=150, b=250 if plot_config.get('show_params') else 80, t=80), |
|
template="plotly_white" if plot_config.get('theme', 'light') == 'light' else "plotly_dark", |
|
showlegend=plot_config.get('show_legend', True) |
|
) |
|
return fig |
|
|
|
def _generate_model_param_text(result: Dict, decimals: int) -> str: |
|
"""Genera el texto formateado de los parámetros para las cajas de anotación""" |
|
text = "" |
|
for comp in COMPONENTS: |
|
if params := result.get('params', {}).get(comp): |
|
p_str = ', '.join([f"{k}={format_number(v, decimals)}" for k, v in params.items()]) |
|
r2 = result.get('r2', {}).get(comp, 0) |
|
rmse = result.get('rmse', {}).get(comp, 0) |
|
text += f"{comp[:4].capitalize()}: {p_str}\n(R²={format_number(r2, 3)}, RMSE={format_number(rmse, 3)})\n" |
|
return text.strip() |
|
|
|
|
|
|
|
def create_zip_file(image_list: List[Any]) -> Optional[str]: |
|
"""Crea un archivo ZIP con todas las imágenes""" |
|
if not image_list: |
|
gr.Warning("No hay gráficos para descargar.") |
|
return None |
|
try: |
|
zip_buffer = io.BytesIO() |
|
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: |
|
for i, fig in enumerate(image_list): |
|
buf = io.BytesIO() |
|
if isinstance(fig, go.Figure): |
|
buf.write(fig.to_image(format="png", scale=2, engine="kaleido")) |
|
elif isinstance(fig, plt.Figure): |
|
fig.savefig(buf, format='png', dpi=200, bbox_inches='tight') |
|
plt.close(fig) |
|
elif isinstance(fig, Image.Image): |
|
fig.save(buf, 'PNG') |
|
else: |
|
continue |
|
buf.seek(0) |
|
zf.writestr(f"grafico_{i+1}.png", buf.read()) |
|
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp: |
|
tmp.write(zip_buffer.getvalue()) |
|
return tmp.name |
|
except Exception as e: |
|
traceback.print_exc() |
|
gr.Error(f"Error al crear el archivo ZIP: {e}") |
|
return None |
|
|
|
def create_word_report(image_list: List[Any], table_df: pd.DataFrame, decimals: int) -> Optional[str]: |
|
"""Crea un reporte en Word con imágenes y tablas""" |
|
if not image_list and (table_df is None or table_df.empty): |
|
gr.Warning("No hay datos ni gráficos para crear el reporte.") |
|
return None |
|
try: |
|
doc = Document() |
|
doc.add_heading('Reporte de Análisis de Cinéticas', 0) |
|
|
|
|
|
doc.add_heading('Resumen Ejecutivo', level=1) |
|
doc.add_paragraph(f'Fecha del análisis: {pd.Timestamp.now().strftime("%Y-%m-%d %H:%M")}') |
|
doc.add_paragraph(f'Total de experimentos analizados: {len(table_df["Experimento"].unique()) if table_df is not None and not table_df.empty else 0}') |
|
doc.add_paragraph(f'Modelos utilizados: {", ".join(table_df["Modelo"].unique()) if table_df is not None and not table_df.empty else "N/A"}') |
|
|
|
if table_df is not None and not table_df.empty: |
|
doc.add_heading('Tabla de Resultados', level=1) |
|
table = doc.add_table(rows=1, cols=len(table_df.columns), style='Table Grid') |
|
for i, col in enumerate(table_df.columns): |
|
table.cell(0, i).text = str(col) |
|
for _, row in table_df.iterrows(): |
|
cells = table.add_row().cells |
|
for i, val in enumerate(row): |
|
cells[i].text = str(format_number(val, decimals)) |
|
|
|
if image_list: |
|
doc.add_page_break() |
|
doc.add_heading('Gráficos Generados', level=1) |
|
for i, fig in enumerate(image_list): |
|
buf = io.BytesIO() |
|
if isinstance(fig, go.Figure): |
|
buf.write(fig.to_image(format="png", scale=2, engine="kaleido")) |
|
elif isinstance(fig, plt.Figure): |
|
fig.savefig(buf, format='png', dpi=200, bbox_inches='tight') |
|
plt.close(fig) |
|
elif isinstance(fig, Image.Image): |
|
fig.save(buf, 'PNG') |
|
else: |
|
continue |
|
buf.seek(0) |
|
doc.add_paragraph(f'Gráfico {i+1}', style='Heading 3') |
|
doc.add_picture(buf, width=Inches(6.0)) |
|
doc.add_paragraph('') |
|
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".docx") as tmp: |
|
doc.save(tmp.name) |
|
return tmp.name |
|
except Exception as e: |
|
traceback.print_exc() |
|
gr.Error(f"Error al crear el reporte de Word: {e}") |
|
return None |
|
|
|
def create_pdf_report(image_list: List[Any], table_df: pd.DataFrame, decimals: int) -> Optional[str]: |
|
"""Crea un reporte en PDF con imágenes y tablas""" |
|
if not image_list and (table_df is None or table_df.empty): |
|
gr.Warning("No hay datos ni gráficos para crear el reporte.") |
|
return None |
|
try: |
|
pdf = FPDF() |
|
pdf.set_auto_page_break(auto=True, margin=15) |
|
pdf.add_page() |
|
pdf.set_font("Helvetica", 'B', 16) |
|
pdf.cell(0, 10, 'Reporte de Análisis de Cinéticas', new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='C') |
|
|
|
|
|
pdf.ln(10) |
|
pdf.set_font("Helvetica", '', 10) |
|
pdf.cell(0, 10, f'Fecha del análisis: {pd.Timestamp.now().strftime("%Y-%m-%d %H:%M")}', |
|
new_x=XPos.LMARGIN, new_y=YPos.NEXT) |
|
|
|
if table_df is not None and not table_df.empty: |
|
pdf.ln(10) |
|
pdf.set_font("Helvetica", 'B', 12) |
|
pdf.cell(0, 10, 'Tabla de Resultados', new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L') |
|
pdf.set_font("Helvetica", 'B', 8) |
|
|
|
effective_page_width = pdf.w - 2 * pdf.l_margin |
|
num_cols = len(table_df.columns) |
|
col_width = effective_page_width / num_cols if num_cols > 0 else 0 |
|
|
|
if num_cols > 15: |
|
pdf.set_font_size(6) |
|
elif num_cols > 10: |
|
pdf.set_font_size(7) |
|
|
|
for col in table_df.columns: |
|
pdf.cell(col_width, 10, str(col), border=1, align='C') |
|
pdf.ln() |
|
|
|
pdf.set_font("Helvetica", '', 7) |
|
if num_cols > 15: |
|
pdf.set_font_size(5) |
|
elif num_cols > 10: |
|
pdf.set_font_size(6) |
|
|
|
for _, row in table_df.iterrows(): |
|
for val in row: |
|
pdf.cell(col_width, 10, str(format_number(val, decimals)), border=1, align='R') |
|
pdf.ln() |
|
|
|
if image_list: |
|
for i, fig in enumerate(image_list): |
|
pdf.add_page() |
|
pdf.set_font("Helvetica", 'B', 12) |
|
pdf.cell(0, 10, f'Gráfico {i+1}', new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L') |
|
pdf.ln(5) |
|
|
|
buf = io.BytesIO() |
|
if isinstance(fig, go.Figure): |
|
buf.write(fig.to_image(format="png", scale=2, engine="kaleido")) |
|
elif isinstance(fig, plt.Figure): |
|
fig.savefig(buf, format='png', dpi=200, bbox_inches='tight') |
|
plt.close(fig) |
|
elif isinstance(fig, Image.Image): |
|
fig.save(buf, 'PNG') |
|
else: |
|
continue |
|
|
|
buf.seek(0) |
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp_img: |
|
tmp_img.write(buf.read()) |
|
pdf.image(tmp_img.name, x=None, y=None, w=pdf.w - 20) |
|
os.remove(tmp_img.name) |
|
|
|
pdf_bytes = pdf.output() |
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp: |
|
tmp.write(pdf_bytes) |
|
return tmp.name |
|
except Exception as e: |
|
traceback.print_exc() |
|
gr.Error(f"Error al crear el reporte PDF: {e}") |
|
return None |
|
|
|
|
|
|
|
def run_analysis(file, model_names, mode, engine, exp_names, settings): |
|
"""Ejecuta el análisis completo con todos los modos""" |
|
if not file: |
|
return [], pd.DataFrame(), "Error: Sube un archivo Excel.", pd.DataFrame() |
|
if not model_names: |
|
return [], pd.DataFrame(), "Error: Selecciona un modelo.", pd.DataFrame() |
|
|
|
try: |
|
xls = pd.ExcelFile(file.name) |
|
except Exception as e: |
|
return [], pd.DataFrame(), f"Error al leer archivo: {e}", pd.DataFrame() |
|
|
|
figs = [] |
|
results_data = [] |
|
msgs = [] |
|
|
|
exp_list = [n.strip() for n in exp_names.split('\n') if n.strip()] |
|
|
|
for i, sheet in enumerate(xls.sheet_names): |
|
exp_name = exp_list[i] if i < len(exp_list) else f"Hoja '{sheet}'" |
|
|
|
try: |
|
df = pd.read_excel(xls, sheet_name=sheet, header=[0,1]) |
|
reader = BioprocessFitter(list(AVAILABLE_MODELS.values())[0]) |
|
reader.process_data_from_df(df) |
|
|
|
if reader.data_time is None: |
|
msgs.append(f"WARN: Sin datos de tiempo en '{sheet}'.") |
|
continue |
|
|
|
cfg = settings.copy() |
|
cfg.update({'exp_name': exp_name, 'time_exp': reader.data_time}) |
|
|
|
for c in COMPONENTS: |
|
cfg[f'{c}_exp'] = reader.data_means[c] |
|
cfg[f'{c}_std'] = reader.data_stds[c] |
|
|
|
t_fine = reader._generate_fine_time_grid(reader.data_time) |
|
plot_results = [] |
|
|
|
for m_name in model_names: |
|
if m_name not in AVAILABLE_MODELS: |
|
msgs.append(f"WARN: Modelo '{m_name}' no disponible.") |
|
continue |
|
|
|
fitter = BioprocessFitter(AVAILABLE_MODELS[m_name], maxfev=int(settings.get('maxfev', 50000))) |
|
fitter.data_time = reader.data_time |
|
fitter.data_means = reader.data_means |
|
fitter.data_stds = reader.data_stds |
|
fitter.raw_data = reader.raw_data |
|
fitter.fit_all_models() |
|
|
|
|
|
row = {'Experimento': exp_name, 'Modelo': fitter.model.display_name} |
|
for c in COMPONENTS: |
|
if fitter.params[c]: |
|
row.update({f'{c.capitalize()}_{k}': v for k, v in fitter.params[c].items()}) |
|
row[f'R2_{c.capitalize()}'] = fitter.r2.get(c) |
|
row[f'RMSE_{c.capitalize()}'] = fitter.rmse.get(c) |
|
row[f'MAE_{c.capitalize()}'] = fitter.mae.get(c) |
|
row[f'AIC_{c.capitalize()}'] = fitter.aic.get(c) |
|
row[f'BIC_{c.capitalize()}'] = fitter.bic.get(c) |
|
results_data.append(row) |
|
|
|
|
|
if mode in ["average", "combined"]: |
|
if hasattr(fitter, 'plot_individual_or_combined'): |
|
figs.append(fitter.plot_individual_or_combined(cfg, mode)) |
|
elif mode == "individual": |
|
|
|
for rep_idx, rep_data in enumerate(fitter.raw_data[C_BIOMASS]): |
|
cfg_rep = cfg.copy() |
|
cfg_rep['exp_name'] = f"{exp_name} - Réplica {rep_idx + 1}" |
|
for c in COMPONENTS: |
|
if len(fitter.raw_data[c]) > rep_idx: |
|
cfg_rep[f'{c}_exp'] = fitter.raw_data[c][rep_idx] |
|
cfg_rep[f'{c}_std'] = None |
|
figs.append(fitter.plot_individual_or_combined(cfg_rep, "average")) |
|
else: |
|
|
|
X, S, P = fitter.get_model_curves_for_plot(t_fine, settings.get('use_differential', False)) |
|
plot_results.append({ |
|
'name': m_name, |
|
'X': X, |
|
'S': S, |
|
'P': P, |
|
'params': fitter.params, |
|
'r2': fitter.r2, |
|
'rmse': fitter.rmse |
|
}) |
|
|
|
if mode == "model_comparison" and plot_results: |
|
plot_func = plot_model_comparison_plotly if engine == 'Plotly (Interactivo)' else plot_model_comparison_matplotlib |
|
figs.append(plot_func(cfg, plot_results)) |
|
|
|
except Exception as e: |
|
msgs.append(f"ERROR en '{sheet}': {e}") |
|
traceback.print_exc() |
|
|
|
msg = "Análisis completado." + ("\n" + "\n".join(msgs) if msgs else "") |
|
df_res = pd.DataFrame(results_data).dropna(axis=1, how='all') |
|
|
|
if not df_res.empty: |
|
|
|
id_c = ['Experimento', 'Modelo'] |
|
p_c = sorted([c for c in df_res.columns if '_' in c and not any(m in c for m in ['R2', 'RMSE', 'MAE', 'AIC', 'BIC'])]) |
|
m_c = sorted([c for c in df_res.columns if any(m in c for m in ['R2', 'RMSE', 'MAE', 'AIC', 'BIC'])]) |
|
df_res = df_res[[c for c in id_c + p_c + m_c if c in df_res.columns]] |
|
|
|
|
|
df_ui = df_res.copy() |
|
for c in df_ui.select_dtypes(include=np.number).columns: |
|
df_ui[c] = df_ui[c].apply(lambda x: format_number(x, settings.get('decimal_places', 3)) if pd.notna(x) else '') |
|
else: |
|
df_ui = pd.DataFrame() |
|
|
|
return figs, df_ui, msg, df_res |
|
|
|
|
|
|
|
app = FastAPI(title="Bioprocess Kinetics API", version="2.0") |
|
|
|
@app.get("/") |
|
def read_root(): |
|
return {"message": "Bioprocess Kinetics Analysis API", "version": "2.0"} |
|
|
|
@app.post("/api/analyze") |
|
async def analyze_data( |
|
data: Dict[str, List[float]], |
|
models: List[str], |
|
options: Optional[Dict[str, Any]] = None |
|
): |
|
"""Endpoint para análisis de datos cinéticos""" |
|
try: |
|
results = {} |
|
|
|
for model_name in models: |
|
if model_name not in AVAILABLE_MODELS: |
|
continue |
|
|
|
model = AVAILABLE_MODELS[model_name] |
|
fitter = BioprocessFitter(model) |
|
|
|
|
|
fitter.data_time = np.array(data['time']) |
|
fitter.data_means[C_BIOMASS] = np.array(data.get('biomass', [])) |
|
fitter.data_means[C_SUBSTRATE] = np.array(data.get('substrate', [])) |
|
fitter.data_means[C_PRODUCT] = np.array(data.get('product', [])) |
|
|
|
|
|
fitter.fit_all_models() |
|
|
|
results[model_name] = { |
|
'parameters': fitter.params, |
|
'metrics': { |
|
'r2': fitter.r2, |
|
'rmse': fitter.rmse, |
|
'mae': fitter.mae, |
|
'aic': fitter.aic, |
|
'bic': fitter.bic |
|
} |
|
} |
|
|
|
return {"status": "success", "results": results} |
|
|
|
except Exception as e: |
|
return {"status": "error", "message": str(e)} |
|
|
|
@app.get("/api/models") |
|
def get_available_models(): |
|
"""Retorna lista de modelos disponibles con su información""" |
|
models_info = {} |
|
for name, model in AVAILABLE_MODELS.items(): |
|
models_info[name] = { |
|
"display_name": model.display_name, |
|
"parameters": model.param_names, |
|
"description": model.description, |
|
"equation": model.equation, |
|
"reference": model.reference, |
|
"num_params": model.num_params |
|
} |
|
return {"models": models_info} |
|
|
|
@app.post("/api/predict") |
|
async def predict_kinetics( |
|
model_name: str, |
|
parameters: Dict[str, float], |
|
time_points: List[float] |
|
): |
|
"""Predice valores usando un modelo y parámetros específicos""" |
|
if model_name not in AVAILABLE_MODELS: |
|
return {"status": "error", "message": f"Model {model_name} not found"} |
|
|
|
try: |
|
model = AVAILABLE_MODELS[model_name] |
|
time_array = np.array(time_points) |
|
params = [parameters[name] for name in model.param_names] |
|
|
|
predictions = model.model_function(time_array, *params) |
|
|
|
return { |
|
"status": "success", |
|
"predictions": predictions.tolist(), |
|
"time_points": time_points |
|
} |
|
except Exception as e: |
|
return {"status": "error", "message": str(e)} |
|
|
|
|
|
|
|
def create_gradio_interface() -> gr.Blocks: |
|
"""Crea la interfaz completa con todas las funcionalidades""" |
|
|
|
def change_language(lang_key: str) -> Dict: |
|
"""Cambia el idioma de la interfaz""" |
|
lang = Language[lang_key] |
|
trans = TRANSLATIONS.get(lang, TRANSLATIONS[Language.ES]) |
|
return trans["title"], trans["subtitle"] |
|
|
|
|
|
MODEL_CHOICES = [(model.display_name, model.name) for model in AVAILABLE_MODELS.values()] |
|
DEFAULT_MODELS = [m.name for m in list(AVAILABLE_MODELS.values())[:4]] |
|
|
|
with gr.Blocks(theme=THEMES["light"], css=""" |
|
.gradio-container {font-family: 'Inter', sans-serif;} |
|
.theory-box {background-color: #f0f9ff; padding: 20px; border-radius: 10px; margin: 10px 0;} |
|
.dark .theory-box {background-color: #1e293b;} |
|
.model-card {border: 1px solid #e5e7eb; padding: 15px; border-radius: 8px; margin: 10px 0;} |
|
.dark .model-card {border-color: #374151;} |
|
""") as demo: |
|
|
|
|
|
current_theme = gr.State("light") |
|
current_language = gr.State("ES") |
|
|
|
|
|
with gr.Row(): |
|
with gr.Column(scale=8): |
|
title_text = gr.Markdown("# 🔬 Analizador de Cinéticas de Bioprocesos") |
|
subtitle_text = gr.Markdown("Análisis avanzado de modelos matemáticos biotecnológicos") |
|
with gr.Column(scale=2): |
|
with gr.Row(): |
|
theme_toggle = gr.Checkbox(label="🌙 Modo Oscuro", value=False) |
|
language_select = gr.Dropdown( |
|
choices=[(lang.value, lang.name) for lang in Language], |
|
value="ES", |
|
label="🌐 Idioma" |
|
) |
|
|
|
with gr.Tabs() as tabs: |
|
|
|
with gr.TabItem("1. Guía y Formato de Datos"): |
|
with gr.Row(): |
|
with gr.Column(scale=2): |
|
gr.Markdown(""" |
|
### Bienvenido al Analizador de Cinéticas |
|
Esta herramienta te permite ajustar modelos matemáticos a tus datos de crecimiento microbiano. |
|
|
|
**Pasos a seguir:** |
|
1. Prepara tu archivo Excel según el formato especificado a la derecha. |
|
2. Ve a la pestaña **"2. Configuración y Ejecución"**. |
|
3. Sube tu archivo y selecciona los modelos cinéticos que deseas probar. |
|
4. Ajusta las opciones de visualización y análisis según tus preferencias. |
|
5. Haz clic en **"Analizar y Graficar"**. |
|
6. Explora los resultados en la pestaña **"3. Resultados"**. |
|
|
|
### Modos de Análisis |
|
- **Individual**: Un gráfico por cada réplica |
|
- **Promedio**: Promedio de réplicas con barras de error |
|
- **Combinado**: Todos los componentes en un solo gráfico |
|
- **Comparación**: Comparación de múltiples modelos |
|
""") |
|
with gr.Column(scale=3): |
|
gr.Markdown("### Formato del Archivo Excel") |
|
gr.Markdown("Usa una **cabecera de dos niveles** para tus datos.") |
|
df_ejemplo = pd.DataFrame({ |
|
('Rep1', 'Tiempo'): [0, 2, 4, 6], |
|
('Rep1', 'Biomasa'): [0.1, 0.5, 2.5, 5.0], |
|
('Rep1', 'Sustrato'): [10.0, 9.5, 7.0, 2.0], |
|
('Rep1', 'Producto'): [0.0, 0.1, 0.5, 1.2], |
|
('Rep2', 'Tiempo'): [0, 2, 4, 6], |
|
('Rep2', 'Biomasa'): [0.12, 0.48, 2.6, 5.2], |
|
('Rep2', 'Sustrato'): [10.2, 9.6, 7.1, 2.1], |
|
('Rep2', 'Producto'): [0.0, 0.12, 0.48, 1.1], |
|
}) |
|
gr.DataFrame(df_ejemplo, interactive=False, label="Ejemplo de Formato") |
|
|
|
|
|
with gr.TabItem("2. Configuración y Ejecución"): |
|
with gr.Row(): |
|
with gr.Column(scale=1): |
|
file_input = gr.File(label="Sube tu archivo Excel (.xlsx)", file_types=['.xlsx']) |
|
exp_names_input = gr.Textbox( |
|
label="Nombres de Experimentos (opcional)", |
|
placeholder="Nombre Hoja 1\nNombre Hoja 2\n...", |
|
lines=3, |
|
info="Un nombre por línea, en el mismo orden que las hojas del Excel." |
|
) |
|
model_selection_input = gr.CheckboxGroup( |
|
choices=MODEL_CHOICES, |
|
label="Modelos a Probar", |
|
value=DEFAULT_MODELS |
|
) |
|
analysis_mode_input = gr.Radio( |
|
["individual", "average", "combined", "model_comparison"], |
|
label="Modo de Análisis", |
|
value="average", |
|
info="Individual: por réplica. Average: promedio. Combined: 3 ejes. Comparación: todos los modelos." |
|
) |
|
plotting_engine_input = gr.Radio( |
|
["Seaborn (Estático)", "Plotly (Interactivo)"], |
|
label="Motor Gráfico (en modo Comparación)", |
|
value="Plotly (Interactivo)" |
|
) |
|
|
|
with gr.Column(scale=2): |
|
with gr.Accordion("Opciones Generales de Análisis", open=True): |
|
decimal_places_input = gr.Slider(0, 10, value=3, step=1, label="Precisión Decimal") |
|
show_params_input = gr.Checkbox(label="Mostrar Parámetros en Gráfico", value=True) |
|
show_legend_input = gr.Checkbox(label="Mostrar Leyenda en Gráfico", value=True) |
|
use_differential_input = gr.Checkbox(label="Usar EDO para graficar", value=False) |
|
maxfev_input = gr.Number(label="Iteraciones Máximas de Ajuste", value=50000) |
|
|
|
with gr.Accordion("Etiquetas de los Ejes", open=True): |
|
with gr.Row(): |
|
xlabel_input = gr.Textbox(label="Etiqueta Eje X", value="Tiempo (h)") |
|
with gr.Row(): |
|
ylabel_biomass_input = gr.Textbox(label="Etiqueta Biomasa", value="Biomasa (g/L)") |
|
ylabel_substrate_input = gr.Textbox(label="Etiqueta Sustrato", value="Sustrato (g/L)") |
|
ylabel_product_input = gr.Textbox(label="Etiqueta Producto", value="Producto (g/L)") |
|
|
|
with gr.Accordion("Opciones de Estilo", open=False): |
|
style_input = gr.Dropdown( |
|
['whitegrid', 'darkgrid', 'white', 'dark', 'ticks'], |
|
label="Estilo General (Matplotlib)", |
|
value='whitegrid' |
|
) |
|
with gr.Row(): |
|
with gr.Column(): |
|
gr.Markdown("**Biomasa**") |
|
biomass_point_color_input = gr.ColorPicker(label="Color Puntos", value='#0072B2') |
|
biomass_line_color_input = gr.ColorPicker(label="Color Línea", value='#56B4E9') |
|
biomass_marker_style_input = gr.Dropdown( |
|
['o', 's', '^', 'D', 'p', '*', 'X'], |
|
label="Marcador", |
|
value='o' |
|
) |
|
biomass_line_style_input = gr.Dropdown( |
|
['-', '--', '-.', ':'], |
|
label="Estilo Línea", |
|
value='-' |
|
) |
|
with gr.Column(): |
|
gr.Markdown("**Sustrato**") |
|
substrate_point_color_input = gr.ColorPicker(label="Color Puntos", value='#009E73') |
|
substrate_line_color_input = gr.ColorPicker(label="Color Línea", value='#34E499') |
|
substrate_marker_style_input = gr.Dropdown( |
|
['o', 's', '^', 'D', 'p', '*', 'X'], |
|
label="Marcador", |
|
value='s' |
|
) |
|
substrate_line_style_input = gr.Dropdown( |
|
['-', '--', '-.', ':'], |
|
label="Estilo Línea", |
|
value='--' |
|
) |
|
with gr.Column(): |
|
gr.Markdown("**Producto**") |
|
product_point_color_input = gr.ColorPicker(label="Color Puntos", value='#D55E00') |
|
product_line_color_input = gr.ColorPicker(label="Color Línea", value='#F0E442') |
|
product_marker_style_input = gr.Dropdown( |
|
['o', 's', '^', 'D', 'p', '*', 'X'], |
|
label="Marcador", |
|
value='^' |
|
) |
|
product_line_style_input = gr.Dropdown( |
|
['-', '--', '-.', ':'], |
|
label="Estilo Línea", |
|
value='-.' |
|
) |
|
|
|
with gr.Row(): |
|
legend_pos_input = gr.Radio( |
|
["best", "upper right", "upper left", "lower left", "lower right", "center"], |
|
label="Posición Leyenda", |
|
value="best" |
|
) |
|
params_pos_input = gr.Radio( |
|
["upper right", "upper left", "lower right", "lower left"], |
|
label="Posición Parámetros", |
|
value="upper right" |
|
) |
|
|
|
with gr.Accordion("Opciones de Barra de Error", open=False): |
|
show_error_bars_input = gr.Checkbox(label="Mostrar barras de error", value=True) |
|
error_cap_size_input = gr.Slider(1, 10, 3, step=1, label="Tamaño Tapa Error") |
|
error_line_width_input = gr.Slider(0.5, 5, 1.0, step=0.5, label="Grosor Línea Error") |
|
|
|
simulate_btn = gr.Button("Analizar y Graficar", variant="primary") |
|
|
|
|
|
with gr.TabItem("3. Resultados"): |
|
status_output = gr.Textbox(label="Estado del Análisis", interactive=False, lines=2) |
|
gallery_output = gr.Gallery( |
|
label="Gráficos Generados", |
|
columns=2, |
|
height=600, |
|
object_fit="contain", |
|
preview=True |
|
) |
|
|
|
with gr.Accordion("Descargar Reportes y Gráficos", open=True): |
|
with gr.Row(): |
|
zip_btn = gr.Button("📦 Descargar Gráficos (.zip)") |
|
word_btn = gr.Button("📄 Descargar Reporte (.docx)") |
|
pdf_btn = gr.Button("📄 Descargar Reporte (.pdf)") |
|
download_output = gr.File(label="Archivo de Descarga", interactive=False) |
|
|
|
gr.Markdown("### Tabla de Resultados Numéricos") |
|
table_output = gr.DataFrame(wrap=True) |
|
|
|
with gr.Row(): |
|
excel_btn = gr.Button("📊 Descargar Tabla (.xlsx)") |
|
csv_btn = gr.Button("📊 Descargar Tabla (.csv)") |
|
download_table_output = gr.File(label="Descargar Tabla", interactive=False) |
|
|
|
|
|
df_for_export = gr.State(pd.DataFrame()) |
|
figures_for_export = gr.State([]) |
|
|
|
|
|
|
|
def simulation_wrapper(file, models, mode, engine, names, use_diff, s_par, s_leg, maxfev, |
|
decimals, x_label, bio_label, sub_label, prod_label, style, s_err, |
|
cap, lw, l_pos, p_pos, bio_pc, bio_lc, bio_ms, bio_ls, sub_pc, |
|
sub_lc, sub_ms, sub_ls, prod_pc, prod_lc, prod_ms, prod_ls): |
|
try: |
|
def rgba_to_hex(rgba_string: str) -> str: |
|
if not isinstance(rgba_string, str) or rgba_string.startswith('#'): |
|
return rgba_string |
|
try: |
|
parts = rgba_string.lower().replace('rgba', '').replace('rgb', '').replace('(', '').replace(')', '') |
|
r, g, b, *_ = map(float, parts.split(',')) |
|
return f'#{int(r):02x}{int(g):02x}{int(b):02x}' |
|
except (ValueError, TypeError): |
|
return "#000000" |
|
|
|
plot_settings = { |
|
'decimal_places': int(decimals), |
|
'use_differential': use_diff, |
|
'style': style, |
|
'show_legend': s_leg, |
|
'show_params': s_par, |
|
'maxfev': int(maxfev), |
|
'axis_labels': { |
|
'x_label': x_label, |
|
'biomass_label': bio_label, |
|
'substrate_label': sub_label, |
|
'product_label': prod_label |
|
}, |
|
'legend_pos': l_pos, |
|
'params_pos': p_pos, |
|
'show_error_bars': s_err, |
|
'error_cap_size': cap, |
|
'error_line_width': lw, |
|
f'{C_BIOMASS}_point_color': rgba_to_hex(bio_pc), |
|
f'{C_BIOMASS}_line_color': rgba_to_hex(bio_lc), |
|
f'{C_BIOMASS}_marker_style': bio_ms, |
|
f'{C_BIOMASS}_line_style': bio_ls, |
|
f'{C_SUBSTRATE}_point_color': rgba_to_hex(sub_pc), |
|
f'{C_SUBSTRATE}_line_color': rgba_to_hex(sub_lc), |
|
f'{C_SUBSTRATE}_marker_style': sub_ms, |
|
f'{C_SUBSTRATE}_line_style': sub_ls, |
|
f'{C_PRODUCT}_point_color': rgba_to_hex(prod_pc), |
|
f'{C_PRODUCT}_line_color': rgba_to_hex(prod_lc), |
|
f'{C_PRODUCT}_marker_style': prod_ms, |
|
f'{C_PRODUCT}_line_style': prod_ls, |
|
} |
|
|
|
figures, df_ui, msg, df_export = run_analysis(file, models, mode, engine, names, plot_settings) |
|
|
|
|
|
image_list = [] |
|
for fig in figures: |
|
buf = io.BytesIO() |
|
if isinstance(fig, go.Figure): |
|
buf.write(fig.to_image(format="png", scale=2, engine="kaleido")) |
|
elif isinstance(fig, plt.Figure): |
|
fig.savefig(buf, format='png', bbox_inches='tight', dpi=150) |
|
plt.close(fig) |
|
buf.seek(0) |
|
image_list.append(Image.open(buf).convert("RGB")) |
|
|
|
return image_list, df_ui, msg, df_export, figures |
|
|
|
except Exception as e: |
|
print(f"--- ERROR CAPTURADO EN WRAPPER ---\n{traceback.format_exc()}") |
|
return [], pd.DataFrame(), f"Error Crítico: {e}", pd.DataFrame(), [] |
|
|
|
all_inputs = [ |
|
file_input, model_selection_input, analysis_mode_input, plotting_engine_input, exp_names_input, |
|
use_differential_input, show_params_input, show_legend_input, maxfev_input, decimal_places_input, |
|
xlabel_input, ylabel_biomass_input, ylabel_substrate_input, ylabel_product_input, |
|
style_input, show_error_bars_input, error_cap_size_input, error_line_width_input, |
|
legend_pos_input, params_pos_input, |
|
biomass_point_color_input, biomass_line_color_input, biomass_marker_style_input, biomass_line_style_input, |
|
substrate_point_color_input, substrate_line_color_input, substrate_marker_style_input, substrate_line_style_input, |
|
product_point_color_input, product_line_color_input, product_marker_style_input, product_line_style_input |
|
] |
|
|
|
all_outputs = [gallery_output, table_output, status_output, df_for_export, figures_for_export] |
|
|
|
simulate_btn.click(fn=simulation_wrapper, inputs=all_inputs, outputs=all_outputs) |
|
|
|
|
|
zip_btn.click(fn=create_zip_file, inputs=[figures_for_export], outputs=[download_output]) |
|
word_btn.click( |
|
fn=create_word_report, |
|
inputs=[figures_for_export, df_for_export, decimal_places_input], |
|
outputs=[download_output] |
|
) |
|
pdf_btn.click( |
|
fn=create_pdf_report, |
|
inputs=[figures_for_export, df_for_export, decimal_places_input], |
|
outputs=[download_output] |
|
) |
|
|
|
def export_table_to_file(df: pd.DataFrame, file_format: str) -> Optional[str]: |
|
if df is None or df.empty: |
|
gr.Warning("No hay datos para exportar.") |
|
return None |
|
suffix = ".xlsx" if file_format == "excel" else ".csv" |
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: |
|
if file_format == "excel": |
|
df.to_excel(tmp.name, index=False) |
|
else: |
|
df.to_csv(tmp.name, index=False, encoding='utf-8-sig') |
|
return tmp.name |
|
|
|
excel_btn.click( |
|
fn=lambda df: export_table_to_file(df, "excel"), |
|
inputs=[df_for_export], |
|
outputs=[download_table_output] |
|
) |
|
csv_btn.click( |
|
fn=lambda df: export_table_to_file(df, "csv"), |
|
inputs=[df_for_export], |
|
outputs=[download_table_output] |
|
) |
|
|
|
|
|
language_select.change( |
|
fn=change_language, |
|
inputs=[language_select], |
|
outputs=[title_text, subtitle_text] |
|
) |
|
|
|
|
|
def apply_theme(is_dark): |
|
return gr.Info("Tema cambiado. Los nuevos gráficos usarán el tema seleccionado.") |
|
|
|
theme_toggle.change( |
|
fn=apply_theme, |
|
inputs=[theme_toggle], |
|
outputs=[] |
|
) |
|
|
|
return demo |
|
|
|
|
|
|
|
if __name__ == '__main__': |
|
|
|
gradio_app = create_gradio_interface() |
|
gradio_app.launch(share=True, debug=True) |