diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..b1bf8bb
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,29 @@
+.git
+.github
+.idea
+.vscode
+.venv
+venv
+env
+.pytest_cache
+.mypy_cache
+.ruff_cache
+htmlcov
+__pycache__
+*.pyc
+*.pyo
+*.pyd
+.DS_Store
+Thumbs.db
+Simulacoes
+Tuning_NeuralNetwork
+docs
+tests
+*.md
+!README.md
+*.log
+*.tmp
+build
+dist
+*.egg-info
+.claude
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..5552f6f
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,18 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_style = space
+indent_size = 4
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{yml,yaml,toml,json}]
+indent_size = 2
+
+[*.md]
+trim_trailing_whitespace = false
+
+[Makefile]
+indent_style = tab
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..d65ce14
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,87 @@
+name: CI
+
+on:
+ push:
+ branches: [master, main]
+ pull_request:
+ branches: [master, main]
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ lint:
+ name: Lint & Format
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ cache: pip
+ - name: Install ruff
+ run: pip install "ruff>=0.5"
+ - name: Ruff check
+ run: ruff check --output-format=github .
+ - name: Ruff format --check
+ run: ruff format --check .
+
+ typecheck:
+ name: Type Check (core + services)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ cache: pip
+ - name: Install
+ run: |
+ pip install -e .[dev]
+ - name: mypy
+ run: mypy
+
+ test:
+ name: Tests (Py ${{ matrix.python-version }} / ${{ matrix.os }})
+ needs: [lint]
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, windows-latest]
+ python-version: ["3.11", "3.12"]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: pip
+ - name: Install
+ run: |
+ pip install -e .[dev]
+ - name: Run tests (sem slow)
+ run: pytest -m "not slow" --cov=CSTRChemIA/core --cov=CSTRChemIA/services --cov-report=xml
+ - name: Upload coverage (Ubuntu 3.12 only)
+ if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
+ uses: codecov/codecov-action@v4
+ with:
+ files: ./coverage.xml
+ fail_ci_if_error: false
+
+ docker:
+ name: Docker build
+ needs: [lint]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: docker/setup-buildx-action@v3
+ - name: Build image (cache)
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: false
+ tags: cstrchemia:ci
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.gitignore b/.gitignore
index 87882e5..23c843c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,3 +31,26 @@ Tuning_NeuralNetwork/
# OS
.DS_Store
Thumbs.db
+
+# Build
+build/
+dist/
+*.egg-info/
+
+# Ferramentas
+.ruff_cache/
+.mypy_cache/
+.coverage
+htmlcov/
+coverage.xml
+.nox/
+.tox/
+
+# Docker
+*.local.env
+
+# Locks gerados
+requirements.lock.txt
+
+# Claude Code working directory
+.claude/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..b3d8036
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,30 @@
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v4.6.0
+ hooks:
+ - id: trailing-whitespace
+ exclude: \.md$
+ - id: end-of-file-fixer
+ - id: check-yaml
+ - id: check-toml
+ - id: check-added-large-files
+ args: ["--maxkb=2048"]
+ - id: check-merge-conflict
+ - id: debug-statements
+ - id: mixed-line-ending
+ args: ["--fix=lf"]
+
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.5.5
+ hooks:
+ - id: ruff
+ args: ["--fix", "--exit-non-zero-on-fix"]
+ - id: ruff-format
+
+ - repo: https://github.com/pre-commit/mirrors-mypy
+ rev: v1.10.0
+ hooks:
+ - id: mypy
+ additional_dependencies: ["numpy", "pandas"]
+ files: ^CSTRChemIA/(core|services)/
+ args: ["--config-file=pyproject.toml"]
diff --git a/CSTRChemIA/README.md b/CSTRChemIA/README.md
index e69de29..8edf901 100644
--- a/CSTRChemIA/README.md
+++ b/CSTRChemIA/README.md
@@ -0,0 +1,20 @@
+# CSTRChemIA (package)
+
+Veja o [README principal](../README.md) para visão geral, instalação,
+arquitetura e deployment.
+
+Este subdiretório contém o código do pacote Python. Estrutura:
+
+```
+core/ # Infra transversal (logging, settings, constants, ...)
+services/ # Camada de aplicação (fachadas)
+api/ # REST API FastAPI (opcional)
+apps/ # Callbacks e init do Dash
+layouts/ # Layouts das abas
+assets/ # CSS / logo
+surrogate_model/ # Modelos Keras + scalers
+optimization/ # NSGA-II
+training/ # Pipeline de treino
+simulation/ # Simulador CSTR (ODEs)
+main.py # Entry-point Dash (+ /healthz)
+```
diff --git a/CSTRChemIA/api/__init__.py b/CSTRChemIA/api/__init__.py
new file mode 100644
index 0000000..477b130
--- /dev/null
+++ b/CSTRChemIA/api/__init__.py
@@ -0,0 +1,10 @@
+"""
+api — REST API opcional (FastAPI) sobre os services.
+
+Sobe separado do Dash para permitir consumo programático dos modelos
+surrogate e do NSGA-II. Requer extras ``cstrchemia[api]``.
+"""
+
+from api.app import create_app
+
+__all__ = ["create_app"]
diff --git a/CSTRChemIA/api/app.py b/CSTRChemIA/api/app.py
new file mode 100644
index 0000000..00f42c5
--- /dev/null
+++ b/CSTRChemIA/api/app.py
@@ -0,0 +1,157 @@
+"""
+FastAPI app sobre os services do CSTRChemIA.
+
+Endpoints:
+ GET /health — status + modelos disponíveis
+ GET /models — lista de modelos com métricas
+ POST /predict — predição unitária
+ POST /optimize — dispara NSGA-II (async)
+ GET /jobs/{id} — status do job de otimização
+ GET / — redirect para /docs
+
+Uso em dev:
+ uvicorn CSTRChemIA.api.app:app --reload --port 8000
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+try:
+ from fastapi import FastAPI, HTTPException, status
+ from fastapi.responses import RedirectResponse
+except ImportError: # pragma: no cover
+ raise RuntimeError(
+ "FastAPI não instalado. Instale: pip install -e .[api]"
+ )
+
+from api.schemas import (
+ HealthResponse,
+ JobStatusResponse,
+ ModelListResponse,
+ OptimizeRequest,
+ PredictRequest,
+ PredictResponse,
+)
+from core.exceptions import (
+ CSTRChemIAError,
+ ModelNotFoundError,
+ SimulationError,
+)
+from core.logging import get_logger, setup_logging
+from services.model_registry import get_model_registry
+from services.optimization_service import OptimizationService
+from services.simulation_service import SimulationService
+
+log = get_logger(__name__)
+
+
+def create_app() -> FastAPI:
+ """Factory — usada em testes e no uvicorn."""
+ setup_logging()
+
+ app = FastAPI(
+ title="CSTRChemIA API",
+ description="REST API sobre o simulador/otimizador do CSTR realístico.",
+ version="0.2.0",
+ )
+
+ # Services compartilhados (singletons por processo)
+ sim_service: SimulationService | None = None
+ opt_service = OptimizationService()
+
+ def _get_sim() -> SimulationService:
+ nonlocal sim_service
+ if sim_service is None:
+ sim_service = SimulationService()
+ return sim_service
+
+ # ---------- endpoints ----------
+ @app.get("/", include_in_schema=False)
+ async def root() -> RedirectResponse:
+ return RedirectResponse(url="/docs")
+
+ @app.get("/health", response_model=HealthResponse, tags=["meta"])
+ async def health() -> HealthResponse:
+ reg = get_model_registry()
+ return HealthResponse(
+ status="ok",
+ version=app.version,
+ models_available=reg.available_architectures(),
+ )
+
+ @app.get("/models", response_model=ModelListResponse, tags=["models"])
+ async def list_models() -> ModelListResponse:
+ infos = get_model_registry().list_models()
+ return ModelListResponse(models=[
+ {
+ "name": i.name,
+ "architecture": i.architecture,
+ "mae": i.mae,
+ "rmse": i.rmse,
+ "r2": i.r2,
+ }
+ for i in infos
+ ])
+
+ @app.post("/predict", response_model=PredictResponse, tags=["inference"])
+ async def predict(req: PredictRequest) -> PredictResponse:
+ import time
+ start = time.perf_counter()
+ try:
+ out = _get_sim().predict_named(req.features, model_name=req.model_name)
+ except ModelNotFoundError as e:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
+ except (SimulationError, CSTRChemIAError) as e:
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
+ return PredictResponse(
+ targets=out,
+ model_name=req.model_name,
+ wall_time_s=time.perf_counter() - start,
+ )
+
+ @app.post("/optimize", response_model=JobStatusResponse,
+ status_code=status.HTTP_202_ACCEPTED, tags=["optimization"])
+ async def optimize(req: OptimizeRequest) -> JobStatusResponse:
+ try:
+ job_id = opt_service.submit(
+ T_max=req.T_max,
+ X_min=req.X_min,
+ cat_min=req.cat_min,
+ fouling_max=req.fouling_max,
+ cp0_min=req.cp0_min,
+ pop_size=req.pop_size,
+ n_gen=req.n_gen,
+ model_type=req.model_name,
+ crossover_prob=req.crossover_prob,
+ mutation_prob=req.mutation_prob,
+ )
+ except CSTRChemIAError as e:
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
+ job = opt_service.get_job(job_id)
+ assert job is not None
+ return _job_to_schema(job)
+
+ @app.get("/jobs/{job_id}", response_model=JobStatusResponse, tags=["optimization"])
+ async def get_job(job_id: str) -> JobStatusResponse:
+ job = opt_service.get_job(job_id)
+ if job is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "job não encontrado")
+ return _job_to_schema(job)
+
+ return app
+
+
+def _job_to_schema(job: Any) -> JobStatusResponse:
+ return JobStatusResponse(
+ job_id=job.job_id,
+ status=job.status,
+ progress={"gen": job.progress_gen, "feasible": job.progress_feasible,
+ "total_gens": job.total_gens},
+ started_at=job.started_at,
+ finished_at=job.finished_at,
+ error=job.error,
+ )
+
+
+app = create_app()
diff --git a/CSTRChemIA/api/schemas.py b/CSTRChemIA/api/schemas.py
new file mode 100644
index 0000000..b055a8f
--- /dev/null
+++ b/CSTRChemIA/api/schemas.py
@@ -0,0 +1,64 @@
+"""Pydantic schemas usados nos endpoints REST."""
+
+from __future__ import annotations
+
+from typing import Any
+
+try:
+ from pydantic import BaseModel, ConfigDict, Field
+except ImportError: # pragma: no cover
+ raise RuntimeError(
+ "pydantic não instalado. Instale extras: pip install -e .[api]"
+ )
+
+from core.constants import DEFAULT_FEATURES
+
+
+class HealthResponse(BaseModel):
+ status: str = "ok"
+ version: str = "0.2.0"
+ models_available: list[str] = Field(default_factory=list)
+
+
+class ModelListResponse(BaseModel):
+ models: list[dict[str, Any]]
+
+
+class PredictRequest(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ features: dict[str, float] = Field(
+ ...,
+ description=f"Dict com chaves: {DEFAULT_FEATURES}",
+ )
+ model_name: str = Field("MLP", description="MLP, RNN ou CNN")
+
+
+class PredictResponse(BaseModel):
+ targets: dict[str, float]
+ model_name: str
+ wall_time_s: float
+
+
+class OptimizeRequest(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ T_max: float = 365.0
+ X_min: float = 0.30
+ cat_min: float = 0.9995
+ fouling_max: float = 0.025
+ cp0_min: float = 0.0
+ pop_size: int = Field(80, ge=10, le=500)
+ n_gen: int = Field(50, ge=1, le=1000)
+ model_name: str = "MLP"
+ crossover_prob: float = Field(0.9, ge=0.0, le=1.0)
+ mutation_prob: float = Field(0.1, ge=0.0, le=1.0)
+
+
+class JobStatusResponse(BaseModel):
+ job_id: str
+ status: str
+ progress: dict[str, int] = Field(default_factory=dict)
+ started_at: float = 0.0
+ finished_at: float = 0.0
+ error: str | None = None
diff --git a/CSTRChemIA/apps/MAIN_callbacks.py b/CSTRChemIA/apps/MAIN_callbacks.py
index ec45851..637c3c7 100644
--- a/CSTRChemIA/apps/MAIN_callbacks.py
+++ b/CSTRChemIA/apps/MAIN_callbacks.py
@@ -26,13 +26,17 @@
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
-from dash import Input, Output, State, callback, ctx, no_update, html, dcc
+from dash import Input, Output, State, callback, ctx, no_update, html, dcc, dash_table
from dash.exceptions import PreventUpdate
import dash_bootstrap_components as dbc
from main import app
from apps.MAIN_styles_dict import *
from layouts.layout_TAB import *
+from core.logging import get_logger, safe_print
+from core.i18n import t as _t, get_lang, DEFAULT_LANG
+# Registra callbacks de troca de unidade (efeito no import).
+from apps import MAIN_unit_callbacks # noqa: F401
from surrogate_model.KerasPredict import (
PredictValues,
TARGET_COLS,
@@ -42,7 +46,12 @@
clear_cache as clear_model_cache,
)
from surrogate_model.model_ranking import get_sorted_models
-from optimization.CSTR_Optimization_Problem import run_cstr_optimization
+from optimization.CSTR_Optimization_Problem import (
+ run_cstr_optimization,
+ rebuild_optimization_figs,
+)
+
+log = get_logger(__name__)
# =============================================================================
# CONSTANTES
@@ -64,9 +73,60 @@
_VAR_LABELS = ['Vazão Feed', 'CA₀ Feed', 'CB₀ Feed', 'Tf Feed',
'T_amb Feed', 'T_obs', 'Válvula', 'w_j']
+_VAR_LABEL_KEYS = ['feat_flow', 'feat_ca0', 'feat_cb0', 'feat_tf',
+ 'feat_tamb', 'feat_tobs', 'feat_valve', 'feat_wj']
TARGET_LABELS = ['CA₀', 'CB₀', 'CP₀', 'CS₀', 'T₀', 'X', 'Fouling', 'Cat.Act.']
+# ── Mapeamentos para analytics / visualizações localizadas ───────────────────
+# Mantemos os nomes crus (coluna técnica) → chave i18n. Assim traduzimos só
+# para exibição, sem quebrar lógica baseada em nomes canônicos.
+_TGT_LABEL_KEYS = {
+ 'CA0': 'target_CA0',
+ 'CB0': 'target_CB0',
+ 'CP0': 'target_CP0',
+ 'CS0': 'target_CS0',
+ 'T0': 'target_T0',
+ 'X': 'target_X',
+ 'fouling': 'target_fouling',
+ 'cat_activity': 'target_cat_activity',
+}
+
+_FEAT_CONT_KEYS = {
+ 'vazao_feed': 'feat_flow',
+ 'CA0_feed': 'feat_ca0',
+ 'CB0_feed': 'feat_cb0',
+ 'Tf_feed': 'feat_tf',
+ 'T_amb_feed': 'feat_tamb',
+ 'T_obs': 'feat_tobs',
+ 'valve_pos': 'feat_valve',
+ 'w_j': 'feat_wj',
+}
+
+_FEAT_FLAG_KEYS = {
+ 'flag_valvula': 'flag_valve_failure',
+ 'flag_manutencao': 'flag_maintenance',
+ 'flag_sensor_T': 'flag_sensor_t_failure',
+ 'flag_comunicacao': 'flag_comm_failure',
+}
+
+
+def _var_labels_for(lang: str) -> list[str]:
+ """Retorna rótulos de atributos (features) traduzidos para ``lang``."""
+ return [_t(k, lang) for k in _VAR_LABEL_KEYS]
+
+
+def _tgt_label(raw_name: str, lang: str) -> str:
+ """Label localizada para um alvo dado seu nome canônico (e.g. 'fouling')."""
+ key = _TGT_LABEL_KEYS.get(raw_name)
+ return _t(key, lang) if key else raw_name
+
+
+def _feat_label(raw_name: str, lang: str) -> str:
+ """Label localizada para uma feature (contínua ou flag) pelo nome canônico."""
+ key = _FEAT_CONT_KEYS.get(raw_name) or _FEAT_FLAG_KEYS.get(raw_name)
+ return _t(key, lang) if key else raw_name
+
PALETTE = {
"primary": "#5913B8",
"secondary": "#9B6BE0",
@@ -90,13 +150,9 @@
)
-def _safe_print(*args, **kwargs):
- """print() que silencia OSError/ValueError no Windows quando stdout está quebrado."""
- try:
- kwargs.pop('flush', None) # flush=True pode falhar com OSError no Win/Py3.13
- print(*args, **kwargs)
- except (OSError, ValueError):
- pass
+# _safe_print foi movido para core.logging.safe_print (veja import acima).
+# Mantido aqui como alias para compatibilidade com callbacks existentes.
+_safe_print = safe_print
# =============================================================================
@@ -129,10 +185,11 @@ def _load_train_stats():
# =============================================================================
# HELPERS
# =============================================================================
-def _extrapolation_info(feat_8: np.ndarray):
+def _extrapolation_info(feat_8: np.ndarray, lang: str = DEFAULT_LANG):
z = np.abs((feat_8 - _TRAIN_MEAN) / (_TRAIN_STD + 1e-30))
max_z = float(z.max())
- alerts = [(lbl, float(zi)) for lbl, zi in zip(_VAR_LABELS, z) if zi > 3.0]
+ labels = _var_labels_for(lang)
+ alerts = [(lbl, float(zi)) for lbl, zi in zip(labels, z) if zi > 3.0]
return max_z, alerts
@@ -189,7 +246,9 @@ def _cross_model_agreement(feat: np.ndarray, primary_arch: str):
return cv, preds_x
-def _empty_figure(message="Aguardando inputs…") -> go.Figure:
+def _empty_figure(message: str | None = None, lang: str = DEFAULT_LANG) -> go.Figure:
+ if message is None:
+ message = _t("fig_empty_default", lang)
fig = go.Figure()
fig.add_annotation(text=message, xref="paper", yref="paper",
x=0.5, y=0.5, showarrow=False,
@@ -212,10 +271,17 @@ def _empty_figure(message="Aguardando inputs…") -> go.Figure:
}
_log_queue = queue.Queue()
+#
+# Aceita TANTO o formato do PrintEpochCallback (``Epoch 1: loss=0.123,
+# val_loss=0.456``) quanto o formato nativo do Keras
+# (``Epoch 1/50 ... loss: 0.123 ... val_loss: 0.456``). O lookbehind
+# ``(?%{x}
%{y:.4g}",
))
fig.update_layout(
- title=dict(text='Estado Predito do Reator',
+ title=dict(text=_t("cb_chart_state_predicted", lang),
font=dict(size=15, color=PALETTE["primary"]), x=0.5),
- yaxis_title='Valor', template='plotly_white',
+ yaxis_title=_t("axis_value", lang), template='plotly_white',
margin=dict(t=50, b=40, l=40, r=20),
plot_bgcolor='rgba(0,0,0,0)', paper_bgcolor='rgba(0,0,0,0)',
font=dict(family="Inter, system-ui, sans-serif", size=11),
@@ -419,12 +576,14 @@ def _build_bar_chart(CA0_p, CB0_p, CP0_p, CS0_p, T0_pred, X_conv, fouling, cat_a
return fig
-def _build_radar_chart(X_conv, cat_act, fouling, fv, fst, fsc):
+def _build_radar_chart(X_conv, cat_act, fouling, fv, fst, fsc, lang: str = DEFAULT_LANG):
def _norm(v, vmin, vmax):
return max(0.0, min(1.0, (v - vmin) / (vmax - vmin + 1e-9)))
- cats = ['Conversão X', 'Ativ. Catalítica', 'Anti-fouling',
- 'Válvula OK', 'Sensor T OK', 'Comunicação OK']
+ cats = [_t("kpi_conversion_x", lang), _t("kpi_cat_activity", lang),
+ _t("legend_anti_fouling", lang),
+ _t("legend_valve_ok", lang), _t("legend_sensor_t_ok", lang),
+ _t("legend_comm_ok", lang)]
vals = [
_norm(X_conv, 0, 1),
_norm(cat_act, 0, 1),
@@ -438,7 +597,7 @@ def _norm(v, vmin, vmax):
marker=dict(color=PALETTE["primary"], size=8),
line=dict(color=PALETTE["primary"], width=2),
fillcolor='rgba(89,19,184,0.25)',
- name='Estado',
+ name=_t("legend_state", lang),
hovertemplate="%{theta}
%{r:.2f}",
))
fig.update_layout(
@@ -448,7 +607,7 @@ def _norm(v, vmin, vmax):
angularaxis=dict(gridcolor='rgba(89,19,184,0.15)'),
bgcolor='rgba(0,0,0,0)',
),
- title=dict(text='Saúde Operacional',
+ title=dict(text=_t("cb_chart_operational_health", lang),
font=dict(size=15, color=PALETTE["primary"]), x=0.5),
template='plotly_white',
margin=dict(t=60, b=30, l=40, r=40),
@@ -459,7 +618,7 @@ def _norm(v, vmin, vmax):
return fig
-def _build_gauge_chart(X_conv):
+def _build_gauge_chart(X_conv, lang: str = DEFAULT_LANG):
fig = go.Figure(go.Indicator(
mode="gauge+number+delta",
value=X_conv * 100.0,
@@ -478,7 +637,8 @@ def _build_gauge_chart(X_conv):
'threshold': {'line': {'color': PALETTE["danger"], 'width': 3},
'thickness': 0.75, 'value': 30.0},
},
- title={'text': "Conversão X", 'font': {'size': 14, 'color': PALETTE["primary"]}},
+ title={'text': _t("cb_chart_conversion_x", lang),
+ 'font': {'size': 14, 'color': PALETTE["primary"]}},
))
fig.update_layout(
template='plotly_white',
@@ -489,10 +649,28 @@ def _build_gauge_chart(X_conv):
return fig
-def _build_donut_chart(CA0_p, CB0_p, CP0_p, CS0_p):
- species_vals = [max(v, 0) for v in [CA0_p, CB0_p, CP0_p, CS0_p]]
+def _build_donut_chart(CA0_p, CB0_p, CP0_p, CS0_p, units_store: dict | None = None,
+ lang: str = DEFAULT_LANG):
+ """Donut das 4 concentrações. Converte os valores para a unidade escolhida
+ e usa o rótulo legível no hover template."""
+ from core.units import (
+ VAR_CATEGORY as _VC, canonical_value_of as _cvof,
+ get_option as _gopt, label_for as _lfor,
+ )
+ store = units_store or {}
+ cat_id = "concentration"
+ # Todos os 4 usam a mesma unidade (a de CA0 por padrão).
+ unit = store.get("CA0", _cvof(cat_id))
+ try:
+ opt = _gopt(cat_id, unit)
+ except Exception:
+ opt = _gopt(cat_id, _cvof(cat_id))
+ unit = _cvof(cat_id)
+ unit_lbl = _lfor(cat_id, unit)
+ species_vals = [max(opt.to_display(float(v)), 0.0)
+ for v in [CA0_p, CB0_p, CP0_p, CS0_p]]
fig = go.Figure(go.Pie(
- labels=['CA₀', 'CB₀', 'CP₀ (Produto)', 'CS₀ (Subproduto)'],
+ labels=['CA₀', 'CB₀', _t("kpi_cp0_product", lang), _t("kpi_cs0_byproduct", lang)],
values=species_vals, hole=0.55,
marker=dict(
colors=[PALETTE["info"], PALETTE["secondary"],
@@ -500,21 +678,24 @@ def _build_donut_chart(CA0_p, CB0_p, CP0_p, CS0_p):
line=dict(color='#FFFFFF', width=2),
),
textinfo='label+percent', textfont=dict(size=11),
- hovertemplate="%{label}
%{value:.2f} mol/m³
%{percent}",
+ hovertemplate=(
+ "%{label}
%{value:.3g} " + unit_lbl
+ + "
%{percent}"
+ ),
))
fig.update_layout(
- title=dict(text='Distribuição das Espécies',
+ title=dict(text=_t("cb_chart_species_dist", lang),
font=dict(size=14, color=PALETTE["primary"]), x=0.5),
template='plotly_white',
margin=dict(t=50, b=10, l=10, r=10),
paper_bgcolor='rgba(0,0,0,0)', showlegend=False,
- annotations=[dict(text=f"Σ
{sum(species_vals):.0f}", x=0.5, y=0.5,
+ annotations=[dict(text=f"Σ
{sum(species_vals):.3g}", x=0.5, y=0.5,
font=dict(size=16, color=PALETTE["primary"]), showarrow=False)],
)
return fig
-def _build_sensitivity_chart(feat, model_type, tobs):
+def _build_sensitivity_chart(feat, model_type, tobs, lang: str = DEFAULT_LANG):
try:
lo_t, hi_t = INPUT_RANGES["input-tobs"]
sweep = np.linspace(lo_t, hi_t, 30)
@@ -539,17 +720,17 @@ def _build_sensitivity_chart(feat, model_type, tobs):
))
fig.add_vline(x=tobs, line_width=2, line_dash='dash',
line_color=PALETTE["success"],
- annotation_text="atual", annotation_position="top")
+ annotation_text=_t("marker_current", lang), annotation_position="top")
fig.update_layout(
- title=dict(text='Sensibilidade X / T₀ × T_obs',
+ title=dict(text=_t("cb_chart_sensitivity", lang),
font=dict(size=14, color=PALETTE["primary"]), x=0.5),
template='plotly_white',
margin=dict(t=50, b=40, l=50, r=50),
paper_bgcolor='rgba(0,0,0,0)',
xaxis=dict(title='T_obs (K)'),
- yaxis=dict(title='X (%)', titlefont=dict(color=PALETTE["primary"]),
+ yaxis=dict(title=dict(text='X (%)', font=dict(color=PALETTE["primary"])),
tickfont=dict(color=PALETTE["primary"])),
- yaxis2=dict(title='T₀ (K)', titlefont=dict(color=PALETTE["danger"]),
+ yaxis2=dict(title=dict(text='T₀ (K)', font=dict(color=PALETTE["danger"])),
tickfont=dict(color=PALETTE["danger"]),
overlaying='y', side='right'),
legend=dict(orientation='h', yanchor='bottom', y=1.02,
@@ -558,43 +739,51 @@ def _build_sensitivity_chart(feat, model_type, tobs):
)
return fig
except Exception as e:
- return _empty_figure(f"Sensibilidade indisponível: {e}")
+ return _empty_figure(f"{_t('msg_sensitivity_unavailable', lang)}: {e}", lang=lang)
-def _build_model_comparison_chart(feat, model_type):
+def _build_model_comparison_chart(feat, model_type, lang: str = DEFAULT_LANG):
"""Grouped bar: MLP vs RNN vs CNN para X, T₀, Cat.Act., Fouling (normalizado)."""
archs = ['MLP', 'RNN', 'CNN']
+ # Chaves internas (estáveis) → rótulos localizados (para exibição).
+ metric_keys = ['X', 'CAT', 'FOUL', 'T0']
+ metric_labels = [
+ _t("short_x_pct", lang),
+ _t("short_cat_act", lang),
+ _t("short_fouling_um", lang),
+ _t("short_t0_k", lang),
+ ]
+
results = {}
for arch in archs:
try:
p = PredictValues(feat, model_type=arch, Tensor=True)[0]
results[arch] = {
- 'X (%)': float(np.clip(p[IDX['X']], 0, 1)) * 100,
- 'T₀ (K)': float(p[IDX['T0']]),
- 'Cat.Act.': float(np.clip(p[IDX['cat_activity']], 0, 1)) * 100,
- 'Fouling (µm)': float(max(0, p[IDX['fouling_thickness']])) * 1e6,
+ 'X': float(np.clip(p[IDX['X']], 0, 1)) * 100,
+ 'CAT': float(np.clip(p[IDX['cat_activity']], 0, 1)) * 100,
+ 'FOUL': float(max(0, p[IDX['fouling_thickness']])) * 1e6,
+ 'T0': float(p[IDX['T0']]),
}
except Exception:
pass
if not results:
- return _empty_figure("Modelos indisponíveis para comparação")
+ return _empty_figure(_t("msg_models_unavailable", lang), lang=lang)
- metrics = ['X (%)', 'Cat.Act.', 'Fouling (µm)', 'T₀ (K)']
arch_cols = {
'MLP': PALETTE['primary'],
'RNN': PALETTE['success'],
'CNN': PALETTE['danger'],
}
- fig = make_subplots(rows=1, cols=len(metrics),
- subplot_titles=metrics,
+ fig = make_subplots(rows=1, cols=len(metric_keys),
+ subplot_titles=metric_labels,
horizontal_spacing=0.06)
- for col_i, metric in enumerate(metrics, 1):
+ for col_i, (mkey, mlabel) in enumerate(zip(metric_keys, metric_labels), 1):
for arch, color in arch_cols.items():
if arch not in results:
continue
- v = results[arch].get(metric, 0)
+ v = results[arch].get(mkey, 0)
marker_line = dict(color='white', width=1.5)
bar_style = dict(color=color, opacity=0.85, line=marker_line)
if arch == model_type:
@@ -608,32 +797,34 @@ def _build_model_comparison_chart(feat, model_type):
text=[f"{v:.2f}"],
textposition='outside',
textfont=dict(size=9),
- hovertemplate=f"{arch}
{metric}=%{{y:.3g}}",
+ hovertemplate=f"{arch}
{mlabel}=%{{y:.3g}}",
), row=1, col=col_i)
fig.update_layout(
- title=dict(text=f'Comparação MLP/RNN/CNN (selecionado: {model_type})',
- font=dict(color=PALETTE['primary'], size=14), x=0.5),
+ title=dict(text=_t("cb_chart_model_comp", lang).format(model=model_type),
+ font=dict(color=PALETTE['primary'], size=14),
+ x=0.5, y=0.97, yanchor='top'),
template='plotly_white',
- margin=dict(t=60, b=40, l=40, r=20),
+ margin=dict(t=55, b=70, l=40, r=20),
paper_bgcolor='rgba(0,0,0,0)',
barmode='group',
- legend=dict(orientation='h', yanchor='bottom', y=1.08,
+ legend=dict(orientation='h', yanchor='top', y=-0.08,
xanchor='center', x=0.5),
font=dict(family="Inter, system-ui, sans-serif", size=11),
- height=340,
+ height=360,
)
return fig
-def _build_feature_contribution_chart(feat, model_type):
+def _build_feature_contribution_chart(feat, model_type, lang: str = DEFAULT_LANG):
"""
Contribuição de cada feature a ΔX (variação ±1σ do treino).
Barras horizontais: positivo = aumenta X, negativo = diminui X.
"""
lo_hi = list(INPUT_RANGES.values())
+ var_labels = _var_labels_for(lang)
contributions = []
- for i, (label, std) in enumerate(zip(_VAR_LABELS, _TRAIN_STD)):
+ for i, (label, std) in enumerate(zip(var_labels, _TRAIN_STD)):
lo, hi = lo_hi[i]
fp, fm = feat.copy(), feat.copy()
fp[0, i] = np.clip(feat[0, i] + std, lo, hi)
@@ -661,7 +852,7 @@ def _build_feature_contribution_chart(feat, model_type):
))
fig.add_vline(x=0, line_width=1.5, line_color='#888')
fig.update_layout(
- title=dict(text=f'ΔX por Feature (±1σ) — {model_type}',
+ title=dict(text=f"{_t('cb_chart_delta_x_feature', lang)} — {model_type}",
font=dict(color=PALETTE['primary'], size=14), x=0.5),
xaxis_title='ΔX (%)',
template='plotly_white',
@@ -673,7 +864,8 @@ def _build_feature_contribution_chart(feat, model_type):
return fig
-def _build_operating_envelope_chart(feat, model_type, tobs_current, valve_current):
+def _build_operating_envelope_chart(feat, model_type, tobs_current, valve_current,
+ lang: str = DEFAULT_LANG):
"""
Heatmap 2D: T_obs × valve_pos → X (%).
Marca o ponto de operação atual com ★.
@@ -690,28 +882,30 @@ def _build_operating_envelope_chart(feat, model_type, tobs_current, valve_curren
X_grid = np.clip(preds[:, IDX['X']], 0, 1) * 100.0
X_grid = X_grid.reshape(n, n)
except Exception:
- return _empty_figure("Envelope operacional indisponível")
+ return _empty_figure(_t("msg_envelope_unavailable", lang), lang=lang)
+ valve_label = _t("axis_valve_position", lang)
+ tobs_label = _t("feat_tobs", lang)
fig = go.Figure(go.Heatmap(
x=tobs_range, y=valve_range, z=X_grid,
colorscale='RdYlGn',
colorbar=dict(title='X (%)', thickness=14),
- hovertemplate="T_obs=%{x:.1f} K
Válvula=%{y:.2f}
X=%{z:.2f}%",
+ hovertemplate=f"{tobs_label}=%{{x:.1f}} K
{valve_label}=%{{y:.2f}}
X=%{{z:.2f}}%",
))
# Ponto atual
fig.add_trace(go.Scatter(
x=[tobs_current], y=[valve_current], mode='markers+text',
marker=dict(symbol='star', size=16, color=PALETTE['gold'],
line=dict(color='#333', width=2)),
- text=["Atual"], textposition='top center',
+ text=[_t("marker_current", lang)], textposition='top center',
textfont=dict(size=10, color='#333'),
- name='Ponto atual',
- hovertemplate=f"Ponto Atual
T_obs={tobs_current:.1f} K
Válvula={valve_current:.2f}",
+ name=_t("marker_current_point", lang),
+ hovertemplate=f"{_t('marker_current_point', lang)}
{tobs_label}={tobs_current:.1f} K
{valve_label}={valve_current:.2f}",
))
fig.update_layout(
- title=dict(text=f'Envelope Operacional: X(%) — {model_type}',
+ title=dict(text=f"{_t('cb_chart_envelope', lang)} — {model_type}",
font=dict(color=PALETTE['primary'], size=14), x=0.5),
- xaxis_title='T_obs (K)', yaxis_title='Posição da Válvula',
+ xaxis_title=f"{tobs_label} (K)", yaxis_title=valve_label,
template='plotly_white',
margin=dict(t=55, b=45, l=55, r=30),
paper_bgcolor='rgba(0,0,0,0)',
@@ -721,13 +915,13 @@ def _build_operating_envelope_chart(feat, model_type, tobs_current, valve_curren
return fig
-def _build_history_chart(history: list):
+def _build_history_chart(history: list, lang: str = DEFAULT_LANG):
"""
Linha temporal das últimas 30 predições.
Exibe X (%), Cat.Act. (normalizado 0–100) e Fouling severity (0–100).
"""
if not history or len(history) < 2:
- return _empty_figure("Altere os inputs para acumular histórico de predições")
+ return _empty_figure(_t("msg_history_hint", lang), lang=lang)
idx_arr = list(range(len(history)))
x_vals = [h.get('X_pct', 0) for h in history]
@@ -738,44 +932,47 @@ def _build_history_chart(history: list):
fig = go.Figure()
fig.add_trace(go.Scatter(
x=idx_arr, y=x_vals, mode='lines+markers',
- name='X (%)', line=dict(color=PALETTE['primary'], width=2.5),
+ name=_t("trace_conversion_x_pct", lang),
+ line=dict(color=PALETTE['primary'], width=2.5),
marker=dict(size=6),
hovertemplate="Pred #%{x}
X=%{y:.2f}%",
))
fig.add_trace(go.Scatter(
x=idx_arr, y=cat_vals, mode='lines+markers',
- name='Cat.Act. (%)',
+ name=_t("trace_cat_act_pct", lang),
line=dict(color=PALETTE['success'], width=2, dash='dot'),
marker=dict(size=5),
hovertemplate="Pred #%{x}
Cat.Act.=%{y:.2f}%",
))
fig.add_trace(go.Scatter(
x=idx_arr, y=foul_vals, mode='lines+markers',
- name='Fouling (% escala)',
+ name=_t("trace_fouling_scale", lang),
line=dict(color=PALETTE['warning'], width=2, dash='dash'),
marker=dict(size=5),
hovertemplate="Pred #%{x}
Fouling=%{y:.2f}%",
))
fig.add_trace(go.Scatter(
x=idx_arr, y=t0_vals, mode='lines',
- name='T₀ (K)', yaxis='y2',
+ name=_t("trace_t0_k", lang), yaxis='y2',
line=dict(color=PALETTE['danger'], width=1.5, dash='longdash'),
hovertemplate="Pred #%{x}
T₀=%{y:.1f} K",
))
fig.update_layout(
- title=dict(text='Histórico de Predições (últimas 30)',
- font=dict(color=PALETTE['primary'], size=14), x=0.5),
- xaxis=dict(title='Nº da predição'),
- yaxis=dict(title='Escala %', titlefont=dict(color=PALETTE['primary'])),
- yaxis2=dict(title='T₀ (K)', titlefont=dict(color=PALETTE['danger']),
+ title=dict(text=_t("cb_chart_history", lang),
+ font=dict(color=PALETTE['primary'], size=14),
+ x=0.5, y=0.97, yanchor='top'),
+ xaxis=dict(title=_t("axis_prediction_idx", lang)),
+ yaxis=dict(title=dict(text=_t("axis_percent_scale", lang),
+ font=dict(color=PALETTE['primary']))),
+ yaxis2=dict(title=dict(text='T₀ (K)', font=dict(color=PALETTE['danger'])),
overlaying='y', side='right'),
template='plotly_white',
- margin=dict(t=55, b=45, l=55, r=55),
+ margin=dict(t=55, b=80, l=55, r=55),
paper_bgcolor='rgba(0,0,0,0)',
- legend=dict(orientation='h', yanchor='bottom', y=1.02,
+ legend=dict(orientation='h', yanchor='top', y=-0.16,
xanchor='center', x=0.5),
font=dict(family="Inter, system-ui, sans-serif", size=11),
- height=360,
+ height=380,
)
return fig
@@ -784,15 +981,12 @@ def _build_history_chart(history: list):
# CALLBACK PRINCIPAL DE SIMULAÇÃO (+4 novos gráficos, +histórico)
# =============================================================================
@callback(
- # KPIs
- Output("out-X", "children"),
- Output("out-T0", "children"),
- Output("out-CA0", "children"),
- Output("out-CB0", "children"),
- Output("out-CP0", "children"),
- Output("out-CS0", "children"),
- Output("out-fouling", "children"),
+ # KPI sem unidade (tratado inline). Os demais KPIs são renderizados a
+ # partir do ``sim-kpis-canonical-store`` pelos callbacks de unidade em
+ # :mod:`apps.MAIN_unit_callbacks`.
Output("out-catact", "children"),
+ # Store canônico que alimenta os KPIs com unidade.
+ Output("sim-kpis-canonical-store", "data"),
# Gráficos originais (5)
Output("sim-bar-chart", "figure"),
Output("sim-radar-chart", "figure"),
@@ -822,16 +1016,50 @@ def _build_history_chart(history: list):
Input("flag-sensor-t", "value"),
Input("flag-comunicacao","value"),
Input("sim-model-select","value"),
- # Estado do histórico
+ # units-store como Input (não State) — quando o usuário troca unidade,
+ # rerenderiza gráficos + KPIs sem exigir uma ação extra de input.
+ Input("units-store", "data"),
+ # Idioma ativo (para que mudar o dropdown rerenderize os gráficos).
+ Input("lang-selector", "value"),
+ # Estado do histórico.
State("sim-history-store", "data"),
)
def update_simulation(vazao, ca0, cb0, tf, tamb, tobs, valve, wj,
- flag_v, flag_m, flag_st, flag_sc, model_type, history_data):
-
- N_OUTPUTS = 19
+ flag_v, flag_m, flag_st, flag_sc, model_type,
+ units_store, lang_in, history_data):
+ lang = get_lang(lang_in)
+
+ # Saídas totais (na ordem dos Outputs acima):
+ # 1 out-catact
+ # 1 sim-kpis-canonical-store
+ # 5 gráficos originais
+ # 1 status-bar
+ # 4 gráficos novos
+ # 1 history-store
+ # = 13 saídas
+ N_OUTPUTS = 13
if None in [vazao, ca0, cb0, tf, tamb, tobs, valve, wj, model_type]:
return [no_update] * N_OUTPUTS
+ # ── Conversão display → canonical para as 7 variáveis com unidade ───────
+ # (valve permanece em sua escala 0–1; flags são 0/1.)
+ from core.units import convert as _u_convert, VAR_CATEGORY as _VC, canonical_value_of as _cvof
+ def _to_canon(var_id: str, v):
+ cat = _VC.get(var_id)
+ if cat is None or v is None:
+ return v
+ unit = (units_store or {}).get(var_id, _cvof(cat))
+ conv = _u_convert(v, cat, unit, _cvof(cat))
+ return conv if conv is not None else v
+
+ vazao = _to_canon("vazao", vazao)
+ ca0 = _to_canon("ca0feed", ca0)
+ cb0 = _to_canon("cb0feed", cb0)
+ tf = _to_canon("tf", tf)
+ tamb = _to_canon("tamb", tamb)
+ tobs = _to_canon("tobs", tobs)
+ wj = _to_canon("wj", wj)
+
def _flag(val):
return 1.0 if (isinstance(val, list) and val) or (not isinstance(val, list) and val) else 0.0
@@ -860,10 +1088,23 @@ def _flag(val):
fouling < -0.005 or cat_act < 0.5 or cat_act > 1.05):
warn = html.Div([
html.Span("⚠ ", className="me-1"),
- html.Span(f"Predição fora dos limites físicos – {model_type}"),
+ html.Span(f"{_t('msg_invalid_prediction', lang)} – {model_type}"),
], className="alert alert-warning py-1 px-2 mb-0 small")
- return (["—"] * 8 + [_empty_figure("Predição inválida")] * 5
- + [warn] + [_empty_figure()] * 4 + [history_data or []])
+ # 13 saídas: out-catact, canonical-store, 5 figs, status, 4 figs, hist
+ inv_msg = _t('msg_invalid_prediction', lang)
+ return (
+ "—",
+ {},
+ _empty_figure(inv_msg, lang=lang),
+ _empty_figure(inv_msg, lang=lang),
+ _empty_figure(inv_msg, lang=lang),
+ _empty_figure(inv_msg, lang=lang),
+ _empty_figure(inv_msg, lang=lang),
+ warn,
+ _empty_figure(lang=lang), _empty_figure(lang=lang),
+ _empty_figure(lang=lang), _empty_figure(lang=lang),
+ history_data or [],
+ )
# Clamp suave
X_conv = float(np.clip(X_conv, 0.0, 1.0))
@@ -875,16 +1116,21 @@ def _flag(val):
cat_act = float(np.clip(cat_act, 0.0, 1.0))
# ── Gráficos originais ──────────────────────────────────────────────
- bar_fig = _build_bar_chart(CA0_p, CB0_p, CP0_p, CS0_p, T0_pred, X_conv, fouling, cat_act)
- radar_fig = _build_radar_chart(X_conv, cat_act, fouling, fv, fst, fsc)
- gauge_fig = _build_gauge_chart(X_conv)
- donut_fig = _build_donut_chart(CA0_p, CB0_p, CP0_p, CS0_p)
- sens_fig = _build_sensitivity_chart(feat, model_type, tobs)
+ bar_fig = _build_bar_chart(CA0_p, CB0_p, CP0_p, CS0_p, T0_pred, X_conv,
+ fouling, cat_act, units_store=units_store,
+ lang=lang)
+ radar_fig = _build_radar_chart(X_conv, cat_act, fouling, fv, fst, fsc,
+ lang=lang)
+ gauge_fig = _build_gauge_chart(X_conv, lang=lang)
+ donut_fig = _build_donut_chart(CA0_p, CB0_p, CP0_p, CS0_p,
+ units_store=units_store, lang=lang)
+ sens_fig = _build_sensitivity_chart(feat, model_type, tobs, lang=lang)
# ── Novos gráficos ──────────────────────────────────────────────────
- comp_fig = _build_model_comparison_chart(feat, model_type)
- feat_fig = _build_feature_contribution_chart(feat, model_type)
- env_fig = _build_operating_envelope_chart(feat, model_type, tobs, valve)
+ comp_fig = _build_model_comparison_chart(feat, model_type, lang=lang)
+ feat_fig = _build_feature_contribution_chart(feat, model_type, lang=lang)
+ env_fig = _build_operating_envelope_chart(feat, model_type, tobs, valve,
+ lang=lang)
# Histórico
history_data = history_data or []
@@ -895,24 +1141,25 @@ def _flag(val):
'T0': T0_pred,
})
history_data = history_data[-30:]
- hist_fig = _build_history_chart(history_data)
+ hist_fig = _build_history_chart(history_data, lang=lang)
# ── Barra de status ─────────────────────────────────────────────────
hard_fail = (fv == 1 or fst == 1 or fsc == 1 or fm == 1)
if hard_fail or fouling > 0.0175 or cat_act < 0.99960 or T0_pred > 360.0:
- status_color, status_text = "danger", "✗ Atenção: condição crítica"
+ status_color, status_text = "danger", _t("status_critical", lang)
elif fouling > 0.015 or cat_act < 0.99975 or T0_pred > 345.0:
- status_color, status_text = "warning", "⚠ Operação degradada"
+ status_color, status_text = "warning", _t("status_operation_degraded", lang)
else:
- status_color, status_text = "success", "✓ Operação saudável"
+ status_color, status_text = "success", _t("status_operation_healthy", lang)
feat_8 = np.array([vazao, ca0, cb0, tf, tamb, tobs, valve, wj], dtype=float)
- max_z, extrap_alerts = _extrapolation_info(feat_8)
+ max_z, extrap_alerts = _extrapolation_info(feat_8, lang=lang)
extrap_badge = []
if max_z > 3.0:
var_names = ", ".join(f"{lbl} ({z:.0f}σ)" for lbl, z in extrap_alerts)
cls = "badge bg-danger mt-1 px-2 py-1" if max_z > 10 else "badge bg-info mt-1 px-2 py-1"
- prefix = "⚠ Extrapolação severa: " if max_z > 10 else "ℹ Fora da zona densa: "
+ prefix = (_t("status_extrapolation_severe", lang) + ": ") if max_z > 10 \
+ else (_t("status_extrapolation_outside", lang) + ": ")
extrap_badge = [html.Br(),
html.Span(f"{prefix}{var_names}", className=cls,
style={"fontSize": "0.75rem", "fontWeight": "normal"})]
@@ -924,17 +1171,17 @@ def _flag(val):
preds_str = " ".join(f"{a}: {v*100:.1f}%" for a, v in preds_all.items())
if cv_x > 80:
agreement_badge = [html.Br(),
- html.Span(f"⚠ Baixa concordância (CV={cv_x:.0f}%) — {preds_str}",
+ html.Span(_t("status_low_agreement", lang).format(cv=cv_x, preds=preds_str),
className="badge bg-danger mt-1 px-2 py-1",
style={"fontSize": "0.75rem", "fontWeight": "normal"})]
elif cv_x > 30:
agreement_badge = [html.Br(),
- html.Span(f"ℹ Concordância moderada (CV={cv_x:.0f}%) — {preds_str}",
+ html.Span(_t("status_moderate_agreement", lang).format(cv=cv_x, preds=preds_str),
className="badge bg-warning text-dark mt-1 px-2 py-1",
style={"fontSize": "0.75rem", "fontWeight": "normal"})]
else:
agreement_badge = [html.Br(),
- html.Span(f"✓ Alta concordância (CV={cv_x:.0f}%) — {preds_str}",
+ html.Span(_t("status_high_agreement", lang).format(cv=cv_x, preds=preds_str),
className="badge bg-success mt-1 px-2 py-1",
style={"fontSize": "0.75rem", "fontWeight": "normal"})]
except Exception:
@@ -943,16 +1190,28 @@ def _flag(val):
status_bar = html.Div([
html.Span(status_text,
className=f"badge bg-{status_color} fs-6 px-3 py-2"),
- html.Span(f" Modelo: {model_type}", className="ms-3 text-muted small"),
+ html.Span(f" {_t('status_model_label', lang)}: {model_type}",
+ className="ms-3 text-muted small"),
*extrap_badge,
*agreement_badge,
], className="text-center my-2")
+ # ── KPIs em unidade canônica ────────────────────────────────────────
+ # Os callbacks em ``apps.MAIN_unit_callbacks`` leem esse dict e
+ # convertem/formatam conforme a unidade escolhida pelo usuário.
+ canonical_kpis = {
+ "X": X_conv, # fração 0–1
+ "T0": T0_pred, # K
+ "CA0": CA0_p, # mol/m³
+ "CB0": CB0_p, # mol/m³
+ "CP0": CP0_p, # mol/m³
+ "CS0": CS0_p, # mol/m³
+ "fouling": fouling, # m (modelo)
+ }
+
return (
- f"{X_conv * 100:.2f}%", f"{T0_pred:.2f} K",
- f"{CA0_p:.2f}", f"{CB0_p:.2f}",
- f"{CP0_p:.2f}", f"{CS0_p:.2f}",
- f"{fouling*1e6:.2f} µm", f"{cat_act:.4f}",
+ f"{cat_act:.4f}",
+ canonical_kpis,
bar_fig, radar_fig, gauge_fig, donut_fig, sens_fig,
status_bar,
comp_fig, feat_fig, env_fig, hist_fig,
@@ -961,12 +1220,24 @@ def _flag(val):
except Exception as e:
_safe_print(f"[WARN] Predição falhou: {e}")
+ pred_failed = _t("msg_prediction_failed", lang)
warn = html.Div([
html.Span("✗ ", className="me-1"),
- html.Span(f"Predição falhou: {e}"),
+ html.Span(f"{pred_failed}: {e}"),
], className="alert alert-danger py-1 px-2 mb-0 small")
- return (["—"] * 8 + [_empty_figure("Erro")] * 5
- + [warn] + [_empty_figure()] * 4 + [history_data or []])
+ return (
+ "—",
+ {},
+ _empty_figure(pred_failed, lang=lang),
+ _empty_figure(pred_failed, lang=lang),
+ _empty_figure(pred_failed, lang=lang),
+ _empty_figure(pred_failed, lang=lang),
+ _empty_figure(pred_failed, lang=lang),
+ warn,
+ _empty_figure(lang=lang), _empty_figure(lang=lang),
+ _empty_figure(lang=lang), _empty_figure(lang=lang),
+ history_data or [],
+ )
# =============================================================================
@@ -976,23 +1247,27 @@ def _flag(val):
Output('training-log-output', 'children'),
Input('btn-run-training', 'n_clicks'),
State('training-model-select', 'value'),
+ State('lang-selector', 'value'),
prevent_initial_call=True,
)
-def start_training(n_clicks, model_type):
+def start_training(n_clicks, model_type, lang_in):
if n_clicks is None or _training_status['running']:
raise PreventUpdate
+ lang = get_lang(lang_in)
thread = threading.Thread(target=_run_training_script, args=(model_type,))
thread.daemon = True
thread.start()
- return "🟡 Treinamento iniciado…\n"
+ return _t("msg_training_started", lang) + "\n"
@callback(
Output('training-log-output', 'children', allow_duplicate=True),
Input('training-interval', 'n_intervals'),
+ State('lang-selector', 'value'),
prevent_initial_call=True,
)
-def update_training_logs(_):
+def update_training_logs(_, lang_in):
+ lang = get_lang(lang_in)
new_lines = []
while not _log_queue.empty():
try:
@@ -1012,9 +1287,9 @@ def update_training_logs(_):
_training_status['log_lines'].extend(new_lines)
if not _training_status['running'] and _training_status['returncode'] is not None:
- msg = ("✅ Treinamento concluído com sucesso!"
+ msg = (_t("msg_training_completed", lang)
if _training_status['returncode'] == 0
- else f"❌ Falha: {_training_status['error']}")
+ else _t("msg_training_failed", lang).format(err=_training_status['error']))
_training_status['log_lines'].append(f"\n{msg}")
_training_status['returncode'] = None
@@ -1025,25 +1300,31 @@ def update_training_logs(_):
Output('training-status-badge', 'children'),
Output('training-status-badge', 'className'),
Input('training-interval', 'n_intervals'),
+ Input('lang-selector', 'value'),
)
-def update_training_badge(_):
+def update_training_badge(_, lang_in):
+ lang = get_lang(lang_in)
if _training_status['running']:
- return "● Treinando…", "badge bg-warning text-dark fs-6 px-3 py-2 pulse"
+ return (_t("status_training_active", lang),
+ "badge bg-warning text-dark fs-6 px-3 py-2 pulse")
if _training_status['error']:
- return f"● Erro: {_training_status['error']}", "badge bg-danger fs-6 px-3 py-2"
+ return (_t("status_training_error", lang).format(error=_training_status['error']),
+ "badge bg-danger fs-6 px-3 py-2")
if _training_status['log_lines']:
- return "● Concluído", "badge bg-success fs-6 px-3 py-2"
- return "● Aguardando", "badge bg-secondary fs-6 px-3 py-2"
+ return _t("status_training_done", lang), "badge bg-success fs-6 px-3 py-2"
+ return _t("status_waiting", lang), "badge bg-secondary fs-6 px-3 py-2"
@callback(
Output('training-loss-chart', 'figure'),
Input('training-interval', 'n_intervals'),
+ Input('lang-selector', 'value'),
)
-def update_loss_chart(_):
+def update_loss_chart(_, lang_in):
+ lang = get_lang(lang_in)
lh = _training_status.get('loss_history', [])
if len(lh) < 2:
- return _empty_figure("Inicie o treinamento para ver as curvas de loss")
+ return _empty_figure(_t("msg_start_training_loss", lang), lang=lang)
epochs = [h['epoch'] for h in lh]
losses = [h['loss'] for h in lh]
@@ -1052,13 +1333,15 @@ def update_loss_chart(_):
fig = go.Figure()
fig.add_trace(go.Scatter(
x=epochs, y=losses, mode='lines+markers',
- name='Train Loss', line=dict(color=PALETTE['primary'], width=2.5),
+ name=_t("legend_train_loss", lang),
+ line=dict(color=PALETTE['primary'], width=2.5),
marker=dict(size=5),
hovertemplate="Epoch=%{x}
Loss=%{y:.5f}",
))
fig.add_trace(go.Scatter(
x=epochs, y=val_losses, mode='lines+markers',
- name='Val Loss', line=dict(color=PALETTE['danger'], width=2.5, dash='dot'),
+ name=_t("legend_val_loss", lang),
+ line=dict(color=PALETTE['danger'], width=2.5, dash='dot'),
marker=dict(size=5),
hovertemplate="Epoch=%{x}
Val Loss=%{y:.5f}",
))
@@ -1068,14 +1351,15 @@ def update_loss_chart(_):
best_vl = min(val_losses)
fig.add_annotation(
x=best_ep, y=best_vl,
- text=f"Best={best_vl:.5f}",
+ text=f"{_t('legend_best', lang)}={best_vl:.5f}",
showarrow=True, arrowhead=2,
font=dict(color=PALETTE['success'], size=10),
)
fig.update_layout(
- title=dict(text='Curva de Loss (Train vs Validation)',
+ title=dict(text=_t("cb_chart_loss_curve", lang),
font=dict(color=PALETTE['primary'], size=15), x=0.5),
- xaxis_title='Época', yaxis_title='Loss (MSE)',
+ xaxis_title=_t("axis_epoch", lang),
+ yaxis_title=_t("axis_loss_mse", lang),
template='plotly_white',
margin=dict(t=55, b=45, l=65, r=30),
paper_bgcolor='rgba(0,0,0,0)',
@@ -1092,101 +1376,37 @@ def update_loss_chart(_):
Output('training-results-table-container', 'children'),
Input('btn-reload-metrics', 'n_clicks'),
Input('training-interval', 'n_intervals'),
+ Input('lang-selector', 'value'),
prevent_initial_call=False,
)
-def update_training_metrics(n_clicks, _):
+def update_training_metrics(n_clicks, _, lang_in):
+ lang = get_lang(lang_in)
results_csv = os.path.join(_SURROGATE_DIR, 'training_results.csv')
if not os.path.exists(results_csv):
- empty = _empty_figure("Treine um modelo para ver as métricas")
- msg = html.P("Arquivo training_results.csv não encontrado.",
+ empty = _empty_figure(_t("msg_train_for_metrics", lang), lang=lang)
+ msg = html.P(_t("msg_results_not_yet", lang),
className="text-muted text-center small")
return empty, empty, msg
try:
df = pd.read_csv(results_csv)
except Exception as e:
- empty = _empty_figure(f"Erro ao ler resultados: {e}")
+ empty = _empty_figure(
+ _t("msg_error_reading_results", lang).format(err=e), lang=lang)
return empty, empty, html.P(str(e), className="text-danger small")
- # ── Gráfico 1: métricas por target ───────────────────────────────────────
- # Espera colunas: arch, target, R2, MAE, RMSE (ou similar)
- metric_fig = _empty_figure("Colunas esperadas: arch, target, R2, MAE, RMSE")
- radar_fig = _empty_figure("Colunas esperadas: arch, R2_mean, MAE_mean")
-
- arch_col = next((c for c in df.columns if c.lower() in ('arch', 'architecture', 'model')), None)
- r2_col = next((c for c in df.columns if 'r2' in c.lower() or 'r²' in c.lower()), None)
- mae_col = next((c for c in df.columns if 'mae' in c.lower()), None)
-
- if arch_col and r2_col:
- archs = df[arch_col].unique().tolist()
- colors = [PALETTE['primary'], PALETTE['success'], PALETTE['danger']]
- metric_fig = go.Figure()
- for i, arch in enumerate(archs):
- sub = df[df[arch_col] == arch]
- target_col = next((c for c in df.columns if 'target' in c.lower()), None)
- if target_col and r2_col:
- metric_fig.add_trace(go.Bar(
- x=sub[target_col].astype(str).tolist(),
- y=sub[r2_col].tolist(),
- name=arch,
- marker_color=colors[i % len(colors)],
- ))
- else:
- # Sem coluna target: mostrar R² geral por arquitetura
- metric_fig.add_trace(go.Bar(
- x=[arch], y=[float(sub[r2_col].mean())],
- name=arch, marker_color=colors[i % len(colors)],
- ))
- metric_fig.update_layout(
- title=dict(text='R² por Target e Arquitetura',
- font=dict(color=PALETTE['primary'], size=15), x=0.5),
- xaxis_title='Target', yaxis_title='R²',
- barmode='group', template='plotly_white',
- margin=dict(t=55, b=45, l=55, r=30),
- legend=dict(orientation='h', yanchor='bottom', y=1.02,
- xanchor='center', x=0.5),
- )
+ metric_fig, radar_fig = _build_training_metric_figures(df, lang=lang)
- # ── Radar por arquitetura ─────────────────────────────────────────
- metrics_for_radar = [r2_col]
- if mae_col:
- metrics_for_radar.append(mae_col)
- rmse_col = next((c for c in df.columns if 'rmse' in c.lower()), None)
- if rmse_col:
- metrics_for_radar.append(rmse_col)
-
- if len(metrics_for_radar) >= 2:
- radar_fig = go.Figure()
- for i, arch in enumerate(archs):
- sub = df[df[arch_col] == arch]
- vals = []
- for mc in metrics_for_radar:
- v = float(sub[mc].mean())
- # MAE/RMSE: inverter para "maior = melhor"
- if 'mae' in mc.lower() or 'rmse' in mc.lower():
- max_v = float(df[mc].max()) + 1e-9
- v = 1.0 - (v / max_v)
- vals.append(v)
- vals_c = vals + [vals[0]]
- cats_c = metrics_for_radar + [metrics_for_radar[0]]
- radar_fig.add_trace(go.Scatterpolar(
- r=vals_c, theta=cats_c, fill='toself',
- name=arch, opacity=0.7,
- line=dict(color=colors[i % len(colors)], width=2),
- ))
- radar_fig.update_layout(
- polar=dict(radialaxis=dict(visible=True, range=[0, 1])),
- title=dict(text='Radar de Desempenho Global',
- font=dict(color=PALETTE['primary'], size=14), x=0.5),
- template='plotly_white',
- margin=dict(t=60, b=30, l=40, r=40),
- showlegend=True,
- )
-
- # ── Tabela de resultados ─────────────────────────────────────────────────
+ # ── Tabela de resultados (sem colunas verbosas) ──────────────────────────
+ display_cols = [c for c in df.columns if c not in ('hyperparameters', 'model_path')]
+ df_disp = df[display_cols].copy()
+ # Formata numéricos
+ for c in df_disp.columns:
+ if pd.api.types.is_float_dtype(df_disp[c]):
+ df_disp[c] = df_disp[c].map(lambda v: f"{v:.4g}")
table = dash_table.DataTable(
- data=df.to_dict('records'),
- columns=[{'name': c, 'id': c} for c in df.columns],
+ data=df_disp.to_dict('records'),
+ columns=[{'name': c, 'id': c} for c in df_disp.columns],
sort_action='native', filter_action='native',
style_table={'overflowX': 'auto'},
style_cell={'textAlign': 'center', 'fontSize': 12,
@@ -1199,6 +1419,138 @@ def update_training_metrics(n_clicks, _):
return metric_fig, radar_fig, table
+def _build_training_metric_figures(df: "pd.DataFrame", lang: str = DEFAULT_LANG):
+ """
+ Constrói (metrics_fig, radar_fig) a partir de ``training_results.csv``.
+ Funciona com as colunas reais salvas por ``save_training_result``:
+ ``architecture``, ``mae_real``, ``val_loss_scaled``, ``epochs_trained``.
+
+ Também é robusto para CSVs futuros que tragam colunas ``r2`` / ``rmse`` /
+ ``target`` — usa o que estiver disponível.
+ """
+ if df is None or df.empty:
+ empty = _empty_figure(_t("msg_no_training_result", lang), lang=lang)
+ return empty, empty
+
+ # Resolve coluna de arquitetura
+ arch_col = next((c for c in df.columns
+ if c.lower() in ('arch', 'architecture', 'model')), None)
+ if not arch_col:
+ empty = _empty_figure(_t("msg_csv_missing_arch", lang), lang=lang)
+ return empty, empty
+
+ # Agrupa por arquitetura (pega o melhor registro = menor MAE)
+ mae_col = next((c for c in df.columns if c.lower() in ('mae', 'mae_real', 'mae_mean')), None)
+ vloss_col = next((c for c in df.columns if c.lower() in ('val_loss_scaled', 'val_loss')), None)
+ epochs_col = next((c for c in df.columns if 'epoch' in c.lower()), None)
+ r2_col = next((c for c in df.columns if c.lower() in ('r2', 'r²', 'r2_mean')), None)
+ rmse_col = next((c for c in df.columns if 'rmse' in c.lower()), None)
+
+ # Deduplica: para cada arquitetura, fica o menor MAE (ou última linha se sem MAE)
+ if mae_col:
+ df_best = df.sort_values(mae_col).groupby(arch_col, as_index=False).first()
+ else:
+ df_best = df.groupby(arch_col, as_index=False).last()
+ df_best = df_best.reset_index(drop=True)
+
+ archs = df_best[arch_col].astype(str).tolist()
+ color_map = {
+ 'MLP': PALETTE['primary'],
+ 'RNN': PALETTE['success'],
+ 'CNN': PALETTE['danger'],
+ }
+ colors = [color_map.get(a, PALETTE['info']) for a in archs]
+
+ # ── Figura 1 — barras agrupadas ─────────────────────────────────────────
+ metric_fig = go.Figure()
+ numeric_metrics = []
+ if mae_col:
+ numeric_metrics.append((_t("metric_mae_real", lang), mae_col, False))
+ if vloss_col:
+ numeric_metrics.append((_t("metric_val_loss_scaled", lang), vloss_col, False))
+ if r2_col:
+ numeric_metrics.append((_t("metric_r2", lang), r2_col, True))
+ if rmse_col:
+ numeric_metrics.append((_t("metric_rmse", lang), rmse_col, False))
+ if epochs_col:
+ numeric_metrics.append((_t("metric_epochs", lang), epochs_col, True))
+
+ if not numeric_metrics:
+ metric_fig = _empty_figure(_t("msg_csv_no_metric", lang), lang=lang)
+ else:
+ for i, arch in enumerate(archs):
+ row = df_best[df_best[arch_col] == arch].iloc[0]
+ ys = [float(row[col]) for _, col, _ in numeric_metrics]
+ metric_fig.add_trace(go.Bar(
+ x=[label for label, _, _ in numeric_metrics],
+ y=ys,
+ name=str(arch),
+ marker_color=colors[i],
+ text=[f"{v:.4g}" for v in ys],
+ textposition='outside',
+ hovertemplate=f"{arch}
%{{x}}=%{{y:.4g}}",
+ ))
+ metric_fig.update_layout(
+ title=dict(text=_t("cb_chart_metrics_arch", lang),
+ font=dict(color=PALETTE['primary'], size=15),
+ x=0.5, xanchor='center', y=0.97, yanchor='top'),
+ xaxis_title=_t("axis_metric", lang),
+ yaxis_title=_t("axis_value", lang),
+ barmode='group', template='plotly_white',
+ margin=dict(t=70, b=60, l=60, r=30),
+ paper_bgcolor='rgba(0,0,0,0)',
+ legend=dict(orientation='h', yanchor='top', y=-0.15,
+ xanchor='center', x=0.5),
+ )
+
+ # ── Figura 2 — radar (normalizado 0..1 onde 1 = melhor) ────────────────
+ if len(archs) < 2 or not numeric_metrics:
+ radar_fig = _empty_figure(_t("msg_need_two_archs", lang), lang=lang)
+ return metric_fig, radar_fig
+
+ # Normaliza: para métricas onde higher_is_better=True, usa v/max;
+ # para higher_is_better=False, usa 1 - v/max (inverte).
+ labels = [label for label, _, _ in numeric_metrics]
+ norm_matrix = []
+ for _, col, higher_better in numeric_metrics:
+ vals = df_best[col].astype(float).to_numpy()
+ vmax = float(np.nanmax(vals)) if vals.size else 1.0
+ vmax = vmax if vmax > 1e-12 else 1.0
+ norm = (vals / vmax) if higher_better else (1.0 - vals / vmax)
+ norm = np.clip(norm, 0.0, 1.0)
+ norm_matrix.append(norm)
+ norm_matrix = np.array(norm_matrix).T # shape (n_arch, n_metrics)
+
+ radar_fig = go.Figure()
+ for i, arch in enumerate(archs):
+ vals = list(norm_matrix[i]) + [norm_matrix[i][0]]
+ cats = labels + [labels[0]]
+ radar_fig.add_trace(go.Scatterpolar(
+ r=vals, theta=cats, fill='toself',
+ name=str(arch), opacity=0.55,
+ line=dict(color=colors[i], width=2.2),
+ marker=dict(size=6, color=colors[i]),
+ hovertemplate=f"{arch}
%{{theta}}: %{{r:.3f}}",
+ ))
+ radar_fig.update_layout(
+ polar=dict(
+ radialaxis=dict(visible=True, range=[0, 1],
+ tickfont=dict(size=9)),
+ angularaxis=dict(tickfont=dict(size=10, color=PALETTE['primary'])),
+ ),
+ title=dict(text=_t("cb_chart_perf_radar", lang),
+ font=dict(color=PALETTE['primary'], size=14),
+ x=0.5, xanchor='center', y=0.97, yanchor='top'),
+ template='plotly_white',
+ margin=dict(t=70, b=40, l=45, r=45),
+ paper_bgcolor='rgba(0,0,0,0)',
+ showlegend=True,
+ legend=dict(orientation='h', yanchor='top', y=-0.08,
+ xanchor='center', x=0.5),
+ )
+ return metric_fig, radar_fig
+
+
# =============================================================================
# CALLBACKS DE OTIMIZAÇÃO
# =============================================================================
@@ -1223,12 +1575,15 @@ def update_training_metrics(n_clicks, _):
State('opt-tobs-max', 'value'),
State('opt-wj-min', 'value'),
State('opt-wj-max', 'value'),
+ State('units-store', 'data'),
+ State('lang-selector', 'value'),
prevent_initial_call=True,
)
def start_optimization(n_clicks, t_max, x_min, cat_min, fouling_max, cp0_min,
pop_size, n_gen, model_type, crossover_prob, mutation_prob,
vazao_min, vazao_max, valve_min, valve_max,
- tobs_min, tobs_max, wj_min, wj_max):
+ tobs_min, tobs_max, wj_min, wj_max, units_store, lang_in):
+ lang = get_lang(lang_in)
global _optimization_status, _opt_queue
with _opt_lock:
if _optimization_status['running']:
@@ -1244,13 +1599,40 @@ def start_optimization(n_clicks, t_max, x_min, cat_min, fouling_max, cp0_min,
except queue.Empty:
break
+ # ── Conversão display → canonical para todas as variáveis com unidade ────
+ from core.units import (
+ convert as _u_convert,
+ VAR_CATEGORY as _VC,
+ canonical_value_of as _cvof,
+ )
+
+ def _tc(var_id, value, fallback):
+ if value is None:
+ return fallback
+ cat = _VC.get(var_id)
+ if cat is None:
+ return float(value)
+ unit = (units_store or {}).get(var_id, _cvof(cat))
+ conv = _u_convert(value, cat, unit, _cvof(cat))
+ return float(conv) if conv is not None else float(value)
+
+ t_max_c = _tc("t_max", t_max, 365.0)
+ fouling_max_c = _tc("fouling_max", fouling_max, 0.025)
+ cp0_min_c = _tc("cp0_min", cp0_min, 0.0)
+ vazao_min_c = _tc("vazao_min", vazao_min, 0.00050)
+ vazao_max_c = _tc("vazao_max", vazao_max, 0.00120)
+ tobs_min_c = _tc("tobs_min", tobs_min, 324.0)
+ tobs_max_c = _tc("tobs_max", tobs_max, 365.0)
+ wj_min_c = _tc("wj_min", wj_min, 0.00010)
+ wj_max_c = _tc("wj_max", wj_max, 0.00999)
+
xl_arr = np.array([
- float(vazao_min or 0.00050), 1090.0, 925.0, 342.0, 285.0,
- float(tobs_min or 324.0), float(valve_min or 0.01), float(wj_min or 0.00010),
+ vazao_min_c, 1090.0, 925.0, 342.0, 285.0,
+ tobs_min_c, float(valve_min or 0.01), wj_min_c,
])
xu_arr = np.array([
- float(vazao_max or 0.00120), 1310.0, 1085.0, 358.0, 311.0,
- float(tobs_max or 365.0), float(valve_max or 1.00), float(wj_max or 0.00999),
+ vazao_max_c, 1310.0, 1085.0, 358.0, 311.0,
+ tobs_max_c, float(valve_max or 1.00), wj_max_c,
])
for i in range(len(xl_arr)):
if xl_arr[i] >= xu_arr[i]:
@@ -1260,11 +1642,11 @@ def start_optimization(n_clicks, t_max, x_min, cat_min, fouling_max, cp0_min,
thread = threading.Thread(
target=_run_optimization_thread,
- args=(float(t_max or 365), float(x_min or 30) / 100.0,
- float(cat_min or 0.90), float(fouling_max or 0.025),
- float(cp0_min or 0.0), int(pop_size or 80), int(n_gen or 40),
+ args=(t_max_c, float(x_min or 30) / 100.0,
+ float(cat_min or 0.90), fouling_max_c,
+ cp0_min_c, int(pop_size or 80), int(n_gen or 40),
model_type, float(crossover_prob or 0.9), float(mutation_prob or 0.1),
- xl_arr, xu_arr),
+ xl_arr, xu_arr, lang),
)
thread.daemon = True
thread.start()
@@ -1276,18 +1658,26 @@ def start_optimization(n_clicks, t_max, x_min, cat_min, fouling_max, cp0_min,
Output('opt-progress-bar', 'value'),
Output('opt-progress-text', 'children'),
Input('opt-interval', 'n_intervals'),
+ Input('lang-selector', 'value'),
prevent_initial_call=True,
)
-def update_optimization_progress(_):
+def update_optimization_progress(_, lang_in):
global _optimization_status
+ lang = get_lang(lang_in)
while not _opt_queue.empty():
msg = _opt_queue.get_nowait()
if msg[0] == 'progress':
_, gen, feasible = msg
_optimization_status['progress'].update(gen=gen, feasible=feasible)
elif msg[0] == 'result':
- _, df, figs, metrics = msg
- _optimization_status.update(result_df=df, figs=figs, metrics=metrics)
+ # Compatível com formato antigo (4 campos) e novo (5 campos com lang)
+ if len(msg) >= 5:
+ _, df, figs, metrics, figs_lang = msg
+ else:
+ _, df, figs, metrics = msg
+ figs_lang = DEFAULT_LANG
+ _optimization_status.update(result_df=df, figs=figs, metrics=metrics,
+ figs_lang=figs_lang)
elif msg[0] == 'error':
_, err = msg
_optimization_status['error'] = err
@@ -1297,15 +1687,15 @@ def update_optimization_progress(_):
elapsed = (_optimization_status.get('metrics') or {}).get('elapsed_s', 0)
return (
html.Div([html.Span("✓ ", className="text-success"),
- html.Span(f"Otimização concluída — {n_sol} soluções em {elapsed:.1f}s")]),
+ html.Span(_t("opt_status_completed", lang).format(n=n_sol, elapsed=elapsed))]),
100,
- f"Geração final: {_optimization_status['progress']['gen']}",
+ _t("opt_generation_final", lang).format(gen=_optimization_status['progress']['gen']),
)
elif not _optimization_status['running'] and _optimization_status['error']:
return (
html.Div([html.Span("✗ ", className="text-danger"),
- html.Span(f"Erro: {_optimization_status['error']}")]),
- 0, "Falha",
+ html.Span(_t("opt_error_prefix", lang).format(err=_optimization_status['error']))]),
+ 0, _t("opt_status_failure", lang),
)
else:
gen = _optimization_status['progress']['gen']
@@ -1314,9 +1704,10 @@ def update_optimization_progress(_):
feasi = _optimization_status['progress']['feasible']
return (
html.Div([html.Span("● ", className="text-warning pulse"),
- html.Span(f"Otimizando... Geração {gen}/{total} | Factíveis: {feasi}")]),
+ html.Span(_t("opt_status_optimizing", lang).format(
+ gen=gen, total=total, feasible=feasi))]),
pct,
- f"Geração {gen} / {total}",
+ _t("opt_generation_progress", lang).format(gen=gen, total=total),
)
@@ -1335,32 +1726,48 @@ def update_optimization_progress(_):
Output('opt-hypervolume-graph', 'figure'),
Output('opt-decision-space-graph','figure'),
Input('opt-interval', 'n_intervals'),
+ Input('lang-selector', 'value'),
prevent_initial_call=True,
)
-def update_optimization_results(_):
+def update_optimization_results(_, lang_in):
global _optimization_status
+ lang = get_lang(lang_in)
if (not _optimization_status['running']
and _optimization_status['result_df'] is not None
and _optimization_status['figs'] is not None):
df = _optimization_status['result_df']
figs = _optimization_status['figs']
metrics = _optimization_status.get('metrics', {}) or {}
-
- kpi_children = _build_kpi_summary(metrics, df)
+ figs_lang = _optimization_status.get('figs_lang')
+
+ # Se o idioma da UI mudou após a otimização, reconstrói as figuras
+ # traduzidas a partir dos dados armazenados (sem re-executar NSGA-II).
+ if figs_lang != lang:
+ try:
+ history = (metrics.get('history') or [])
+ constraints = metrics.get('constraints')
+ figs = rebuild_optimization_figs(df, history, constraints, lang=lang)
+ _optimization_status['figs'] = figs
+ _optimization_status['figs_lang'] = lang
+ except Exception:
+ # Em caso de falha, mantém figuras anteriores (silenciosamente)
+ pass
+
+ kpi_children = _build_kpi_summary(metrics, df, lang=lang)
return (
df.to_dict('records'),
- figs.get('pareto', _empty_figure("Sem Pareto")),
- figs.get('parcoords', _empty_figure("Sem parallel coords")),
- figs.get('scatter3d', _empty_figure("Sem 3D")),
- figs.get('convergence', _empty_figure("Sem convergência")),
- figs.get('heatmap', _empty_figure("Sem heatmap")),
- figs.get('violin', _empty_figure("Sem distribuição")),
+ figs.get('pareto', _empty_figure(_t("fig_empty_pareto", lang), lang=lang)),
+ figs.get('parcoords', _empty_figure(_t("fig_empty_parcoords", lang), lang=lang)),
+ figs.get('scatter3d', _empty_figure(_t("fig_empty_3d", lang), lang=lang)),
+ figs.get('convergence', _empty_figure(_t("fig_empty_convergence", lang), lang=lang)),
+ figs.get('heatmap', _empty_figure(_t("fig_empty_heatmap", lang), lang=lang)),
+ figs.get('violin', _empty_figure(_t("fig_empty_distribution", lang), lang=lang)),
kpi_children,
- figs.get('topsis', _empty_figure("Sem TOPSIS")),
- figs.get('clusters', _empty_figure("Sem clusters")),
- figs.get('hypervolume', _empty_figure("Sem hypervolume")),
- figs.get('decision_space', _empty_figure("Sem espaço de decisão")),
+ figs.get('topsis', _empty_figure(_t("fig_empty_topsis", lang), lang=lang)),
+ figs.get('clusters', _empty_figure(_t("fig_empty_clusters", lang), lang=lang)),
+ figs.get('hypervolume', _empty_figure(_t("fig_empty_hypervolume", lang), lang=lang)),
+ figs.get('decision_space', _empty_figure(_t("fig_empty_decision_space", lang), lang=lang)),
)
return (no_update,) * 12
@@ -1378,9 +1785,9 @@ def export_optimization_csv(n_clicks):
return no_update
-def _build_kpi_summary(metrics: dict, df) -> list:
+def _build_kpi_summary(metrics: dict, df, lang: str = DEFAULT_LANG) -> list:
if not metrics or metrics.get('n_feasible', 0) == 0:
- return [html.P("Nenhuma solução factível encontrada. Relaxe as restrições.",
+ return [html.P(_t("msg_no_feasible", lang),
className="text-warning text-center")]
n = metrics['n_feasible']
@@ -1413,31 +1820,31 @@ def _kpi(title, value, color, icon):
best_detail = ""
if best_sol:
best_detail = (
- f"Vazão={best_sol.get('vazao_feed', 0):.4g} m³/s | "
- f"T_obs={best_sol.get('T_obs (K)', 0):.1f} K | "
- f"Válvula={best_sol.get('valve_pos', 0):.2f} | "
- f"T0={best_sol.get('T0 predita (K)', 0):.1f} K"
+ f"{_t('label_flow', lang)}={best_sol.get('vazao_feed', 0):.4g} m³/s | "
+ f"{_t('label_tobs', lang)}={best_sol.get('T_obs (K)', 0):.1f} K | "
+ f"{_t('label_valve', lang)}={best_sol.get('valve_pos', 0):.2f} | "
+ f"{_t('label_t0', lang)}={best_sol.get('T0 predita (K)', 0):.1f} K"
)
return [
dbc.Row([
- _kpi("Soluções Factíveis", str(n), PALETTE['primary'], "🎯"),
- _kpi("Melhor X", f"{best_x:.2f}%", PALETTE['success'], "📈"),
- _kpi("Min. Fouling", f"{min_f:.2e} m", PALETTE['info'], "🛡️"),
- _kpi("Melhor Cat.Act.", f"{best_c:.4f}", PALETTE['warning'], "⚗️"),
- _kpi("Melhor CP0", f"{best_cp0:.1f}", PALETTE['secondary'],"🧪"),
- _kpi("Tempo", f"{elapsed:.1f}s", PALETTE['muted'], "⏱️"),
+ _kpi(_t("kpi_feasible_solutions", lang), str(n), PALETTE['primary'], "🎯"),
+ _kpi(_t("kpi_best_x", lang), f"{best_x:.2f}%", PALETTE['success'], "📈"),
+ _kpi(_t("kpi_min_fouling", lang), f"{min_f:.2e} m", PALETTE['info'], "🛡️"),
+ _kpi(_t("kpi_best_cat_act", lang), f"{best_c:.4f}", PALETTE['warning'], "⚗️"),
+ _kpi(_t("kpi_best_cp0", lang), f"{best_cp0:.1f}", PALETTE['secondary'],"🧪"),
+ _kpi(_t("kpi_time", lang), f"{elapsed:.1f}s", PALETTE['muted'], "⏱️"),
], className="g-2 mb-2"),
dbc.Row([
- _kpi("Média X", f"{avg_x:.2f}%", PALETTE['success'], "📊"),
- _kpi("Média Fouling", f"{avg_f:.2e}", PALETTE['info'], "📉"),
- _kpi("Média T0", f"{avg_t0:.1f} K", PALETTE['danger'], "🌡️"),
- _kpi("Hypervolume", f"{hv:.4f}", PALETTE['primary'], "📐"),
- _kpi("Spread", f"{spread:.3f}", PALETTE['warning'], "↔️"),
+ _kpi(_t("kpi_avg_x", lang), f"{avg_x:.2f}%", PALETTE['success'], "📊"),
+ _kpi(_t("kpi_avg_fouling", lang), f"{avg_f:.2e}", PALETTE['info'], "📉"),
+ _kpi(_t("kpi_avg_t0", lang), f"{avg_t0:.1f} K", PALETTE['danger'], "🌡️"),
+ _kpi(_t("kpi_hypervolume", lang), f"{hv:.4f}", PALETTE['primary'], "📐"),
+ _kpi(_t("kpi_spread", lang), f"{spread:.3f}", PALETTE['warning'], "↔️"),
dbc.Col(md=2),
], className="g-2 mb-2"),
html.Div([
- html.Span("🏆 Melhor solução (maior X): ", className="fw-bold small"),
+ html.Span(_t("msg_best_solution", lang), className="fw-bold small"),
html.Span(best_detail, className="small text-muted"),
], className="text-center mt-1 p-2",
style={"backgroundColor": "#E8F8F0", "borderRadius": "6px",
@@ -1459,12 +1866,14 @@ def _kpi(title, value, color, icon):
Output('analytics-dataset-kpis', 'children'),
Output('analytics-stats-table', 'children'),
Input('btn-load-analytics', 'n_clicks'),
+ Input('lang-selector', 'value'),
prevent_initial_call=True,
)
-def load_analytics(n_clicks):
+def load_analytics(n_clicks, lang_in):
"""
Carrega estatísticas do dataset e métricas das arquiteturas de surrogate.
"""
+ lang = get_lang(lang_in)
# ── Carrega data_meta.json ────────────────────────────────────────────────
meta_path = os.path.join(_SURROGATE_DIR, 'data_meta.json')
meta = {}
@@ -1481,6 +1890,10 @@ def load_analytics(n_clicks):
]
tgt_names = ['CA0', 'CB0', 'CP0', 'CS0', 'T0', 'X', 'fouling', 'cat_activity']
+ # Rótulos localizados (para exibição nos gráficos/tabela).
+ feat_labels = [_feat_label(f, lang) for f in feat_names]
+ tgt_labels = [_tgt_label(t, lang) for t in tgt_names]
+
feat_mean = np.array(meta.get('scaler_X_mean', [0] * 12))
feat_std = np.array(meta.get('scaler_X_std', [1] * 12))
tgt_mean = np.array(meta.get('scaler_y_mean', [0] * 8))
@@ -1507,37 +1920,38 @@ def _ds_kpi(title, value, color, icon):
)
dataset_kpis = dbc.Row([
- _ds_kpi("Total amostras", str(n_samples) if n_samples else "—", PALETTE['primary'], "📦"),
- _ds_kpi("Treino", str(n_train) if n_train else "—", PALETTE['success'], "🎓"),
- _ds_kpi("Validação", str(n_val) if n_val else "—", PALETTE['warning'], "🔍"),
- _ds_kpi("Teste", str(n_test) if n_test else "—", PALETTE['danger'], "🧪"),
- _ds_kpi("Features", "12", PALETTE['info'], "📥"),
- _ds_kpi("Targets", "8", PALETTE['secondary'], "📤"),
+ _ds_kpi(_t("kpi_total_samples", lang), str(n_samples) if n_samples else "—", PALETTE['primary'], "📦"),
+ _ds_kpi(_t("kpi_train", lang), str(n_train) if n_train else "—", PALETTE['success'], "🎓"),
+ _ds_kpi(_t("kpi_validation", lang), str(n_val) if n_val else "—", PALETTE['warning'], "🔍"),
+ _ds_kpi(_t("kpi_test", lang), str(n_test) if n_test else "—", PALETTE['danger'], "🧪"),
+ _ds_kpi(_t("kpi_features", lang), "12", PALETTE['info'], "📥"),
+ _ds_kpi(_t("kpi_targets", lang), "8", PALETTE['secondary'], "📤"),
], className="g-2 mb-3")
# ── Distribuição das features (boxplot normalizado) ──────────────────────
# Simula 200 amostras a partir de mean ± std (distribuição aprox. normal)
rng = np.random.default_rng(seed=0)
n_sim = 300
- cont_names = feat_names[:8] # apenas features contínuas
- cont_mean = feat_mean[:8]
- cont_std = feat_std[:8]
+ cont_names = feat_names[:8] # nomes canônicos (p/ lógica)
+ cont_labels = feat_labels[:8] # rótulos localizados (p/ UI)
+ cont_mean = feat_mean[:8]
+ cont_std = feat_std[:8]
feat_dist_fig = go.Figure()
colors_feat = px.colors.qualitative.Vivid
- for i, fname in enumerate(cont_names):
+ for i, flabel in enumerate(cont_labels):
samples = rng.normal(0, 1, n_sim) # z-scores
feat_dist_fig.add_trace(go.Box(
- y=samples, name=fname,
+ y=samples, name=flabel,
boxpoints='outliers',
marker=dict(size=3, color=colors_feat[i % len(colors_feat)]),
line=dict(color=colors_feat[i % len(colors_feat)]),
opacity=0.8,
))
feat_dist_fig.update_layout(
- title=dict(text='Distribuição das Features (z-score)',
+ title=dict(text=_t("cb_chart_feat_dist", lang),
font=dict(color=PALETTE['primary'], size=14), x=0.5),
- yaxis_title='Z-score (σ)',
+ yaxis_title=_t("axis_z_score", lang),
template='plotly_white',
margin=dict(t=55, b=60, l=55, r=20),
paper_bgcolor='rgba(0,0,0,0)',
@@ -1549,19 +1963,19 @@ def _ds_kpi(title, value, color, icon):
# ── Distribuição dos targets (violin) ────────────────────────────────────
tgt_dist_fig = go.Figure()
colors_tgt = px.colors.qualitative.Safe
- for i, tname in enumerate(tgt_names):
+ for i, tlabel in enumerate(tgt_labels):
samples = rng.normal(0, 1, n_sim)
tgt_dist_fig.add_trace(go.Violin(
- y=samples, name=tname,
+ y=samples, name=tlabel,
box_visible=True, meanline_visible=True,
fillcolor=colors_tgt[i % len(colors_tgt)],
opacity=0.65,
line_color=colors_tgt[i % len(colors_tgt)],
))
tgt_dist_fig.update_layout(
- title=dict(text='Distribuição dos Targets (z-score)',
+ title=dict(text=_t("cb_chart_target_dist", lang),
font=dict(color=PALETTE['primary'], size=14), x=0.5),
- yaxis_title='Z-score (σ)',
+ yaxis_title=_t("axis_z_score", lang),
template='plotly_white',
margin=dict(t=55, b=60, l=55, r=20),
paper_bgcolor='rgba(0,0,0,0)',
@@ -1585,16 +1999,18 @@ def _ds_kpi(title, value, color, icon):
corr_fig = go.Figure(go.Heatmap(
z=corr_mat,
- x=tgt_names, y=cont_names[:8],
+ x=tgt_labels, y=cont_labels,
colorscale='RdBu', zmin=-1, zmax=1,
colorbar=dict(title='ρ', thickness=14),
- hovertemplate="Feature: %{y}
Target: %{x}
ρ=%{z:.2f}",
+ hovertemplate=(f"{_t('col_feature', lang)}: %{{y}}
"
+ f"{_t('col_target', lang)}: %{{x}}
"
+ "ρ=%{z:.2f}"),
text=[[f"{v:.2f}" for v in row] for row in corr_mat],
texttemplate="%{text}",
textfont=dict(size=9),
))
corr_fig.update_layout(
- title=dict(text='Matriz de Correlação (estimativa de domain knowledge)',
+ title=dict(text=_t("cb_chart_corr", lang),
font=dict(color=PALETTE['primary'], size=14), x=0.5),
template='plotly_white',
margin=dict(t=55, b=55, l=100, r=30),
@@ -1603,43 +2019,20 @@ def _ds_kpi(title, value, color, icon):
)
# ── Métricas por arquitetura (training_results.csv) ──────────────────────
+ # Reutiliza exatamente o mesmo construtor que a aba Training para
+ # garantir consistência visual e correção de bugs num único lugar.
results_csv = os.path.join(_SURROGATE_DIR, 'training_results.csv')
- metrics_fig = _empty_figure("Treine um modelo para ver métricas")
- arch_radar_fig = _empty_figure("Treine um modelo para ver radar")
-
+ metrics_fig = _empty_figure(_t("msg_train_for_metrics", lang), lang=lang)
+ arch_radar_fig = _empty_figure(_t("msg_need_two_archs", lang), lang=lang)
if os.path.exists(results_csv):
try:
df_res = pd.read_csv(results_csv)
- arch_col = next((c for c in df_res.columns
- if c.lower() in ('arch', 'architecture', 'model')), None)
- r2_col = next((c for c in df_res.columns if 'r2' in c.lower()), None)
- mae_col = next((c for c in df_res.columns if 'mae' in c.lower()), None)
-
- if arch_col and r2_col:
- archs = df_res[arch_col].unique().tolist()
- colors = [PALETTE['primary'], PALETTE['success'], PALETTE['danger']]
- metrics_fig = go.Figure()
- for i, arch in enumerate(archs):
- sub = df_res[df_res[arch_col] == arch]
- target_col = next((c for c in df_res.columns
- if 'target' in c.lower()), None)
- xs = sub[target_col].tolist() if target_col else [arch]
- ys = sub[r2_col].tolist()
- metrics_fig.add_trace(go.Bar(
- x=xs, y=ys, name=arch,
- marker_color=colors[i % len(colors)],
- ))
- metrics_fig.update_layout(
- title=dict(text='R² por Arquitetura',
- font=dict(color=PALETTE['primary'], size=14), x=0.5),
- xaxis_title='', yaxis_title='R²',
- barmode='group', template='plotly_white',
- margin=dict(t=55, b=45, l=55, r=30),
- legend=dict(orientation='h', yanchor='bottom', y=1.02,
- xanchor='center', x=0.5),
- )
- except Exception:
- pass
+ metrics_fig, arch_radar_fig = _build_training_metric_figures(df_res, lang=lang)
+ except Exception as e: # noqa: BLE001
+ metrics_fig = _empty_figure(
+ _t("msg_error_reading_results", lang).format(err=e), lang=lang)
+ arch_radar_fig = _empty_figure(
+ _t("msg_error_reading_results", lang).format(err=e), lang=lang)
# ── PCA scree ─────────────────────────────────────────────────────────────
# Variância explicada estimada para 12 features (valores típicos de dados CSTR)
@@ -1650,13 +2043,13 @@ def _ds_kpi(title, value, color, icon):
pca_fig.add_trace(go.Bar(
x=[f"PC{i+1}" for i in range(len(explained_var))],
y=explained_var * 100,
- name='Variância individual',
+ name=_t("legend_variance_individual", lang),
marker_color=PALETTE['secondary'],
))
pca_fig.add_trace(go.Scatter(
x=[f"PC{i+1}" for i in range(len(cumvar))],
y=cumvar * 100,
- mode='lines+markers', name='Variância acumulada',
+ mode='lines+markers', name=_t("legend_variance_cumulative", lang),
line=dict(color=PALETTE['danger'], width=2.5),
marker=dict(size=6), yaxis='y',
))
@@ -1664,10 +2057,10 @@ def _ds_kpi(title, value, color, icon):
annotation_text='90%', annotation_position='right',
annotation_font=dict(color=PALETTE['success'], size=9))
pca_fig.update_layout(
- title=dict(text='Variância Explicada — PCA das Features',
+ title=dict(text=_t("cb_chart_pca", lang),
font=dict(color=PALETTE['primary'], size=14), x=0.5),
- xaxis_title='Componente Principal',
- yaxis_title='Variância Explicada (%)',
+ xaxis_title=_t("axis_pca_component", lang),
+ yaxis_title=_t("axis_explained_var", lang),
template='plotly_white',
margin=dict(t=55, b=45, l=60, r=30),
paper_bgcolor='rgba(0,0,0,0)',
@@ -1680,19 +2073,19 @@ def _ds_kpi(title, value, color, icon):
error_fig = go.Figure()
tgt_errors_std = [12.5, 11.8, 8.2, 7.9, 0.42, 0.018, 2.1e-4, 4.2e-5]
tgt_units = ['mol/m³', 'mol/m³', 'mol/m³', 'mol/m³', 'K', '—', 'm', '—']
- for i, (tname, err_std, unit) in enumerate(zip(tgt_names, tgt_errors_std, tgt_units)):
+ for i, (tlabel, err_std, unit) in enumerate(zip(tgt_labels, tgt_errors_std, tgt_units)):
samples = rng.normal(0, err_std, 500)
error_fig.add_trace(go.Violin(
- y=samples, name=f"{tname} ({unit})",
+ y=samples, name=f"{tlabel} ({unit})",
box_visible=True, meanline_visible=True,
fillcolor=colors_tgt[i % len(colors_tgt)],
opacity=0.65,
line_color=colors_tgt[i % len(colors_tgt)],
))
error_fig.update_layout(
- title=dict(text='Distribuição do Erro de Predição por Target',
+ title=dict(text=_t("cb_chart_error_dist", lang),
font=dict(color=PALETTE['primary'], size=14), x=0.5),
- yaxis_title='Erro (unidade do target)',
+ yaxis_title=_t("axis_error_unit", lang),
template='plotly_white',
margin=dict(t=55, b=60, l=60, r=20),
paper_bgcolor='rgba(0,0,0,0)',
@@ -1701,20 +2094,28 @@ def _ds_kpi(title, value, color, icon):
)
# ── Tabela de estatísticas ────────────────────────────────────────────────
+ col_feature = _t("col_feature", lang)
+ col_mean = _t("col_mean", lang)
+ col_std = _t("col_std", lang)
+ col_min = _t("col_min_est", lang)
+ col_max = _t("col_max_est", lang)
+ col_type = _t("col_type", lang)
+ type_cont = _t("type_continuous", lang)
+ type_flag = _t("type_flag", lang)
stats_rows = []
- for i, fname in enumerate(cont_names):
+ for i, flabel in enumerate(cont_labels):
stats_rows.append({
- 'Feature': fname,
- 'Média': f"{cont_mean[i]:.4g}",
- 'Desvio Padrão': f"{cont_std[i]:.4g}",
- 'Min estimado': f"{cont_mean[i] - 3*cont_std[i]:.4g}",
- 'Max estimado': f"{cont_mean[i] + 3*cont_std[i]:.4g}",
- 'Tipo': 'Contínua',
+ col_feature: flabel,
+ col_mean: f"{cont_mean[i]:.4g}",
+ col_std: f"{cont_std[i]:.4g}",
+ col_min: f"{cont_mean[i] - 3*cont_std[i]:.4g}",
+ col_max: f"{cont_mean[i] + 3*cont_std[i]:.4g}",
+ col_type: type_cont,
})
- for fname in feat_names[8:]:
+ for flabel in feat_labels[8:]:
stats_rows.append({
- 'Feature': fname, 'Média': '0/1', 'Desvio Padrão': '—',
- 'Min estimado': '0', 'Max estimado': '1', 'Tipo': 'Flag',
+ col_feature: flabel, col_mean: '0/1', col_std: '—',
+ col_min: '0', col_max: '1', col_type: type_flag,
})
stats_table = dash_table.DataTable(
@@ -1727,10 +2128,344 @@ def _ds_kpi(title, value, color, icon):
'color': '#5913B8', 'border': '1px solid #D4C5F0'},
style_data={'border': '1px solid #EEE'},
style_data_conditional=[
- {'if': {'filter_query': "{Tipo} = 'Flag'"},
+ {'if': {'filter_query': f"{{{col_type}}} = '{type_flag}'"},
'backgroundColor': '#FFF9E6', 'color': '#7B6B00'},
],
)
return (feat_dist_fig, tgt_dist_fig, corr_fig, metrics_fig, arch_radar_fig,
pca_fig, error_fig, dataset_kpis, stats_table)
+
+
+# =============================================================================
+# GERENCIADOR DE ARQUIVOS DE DADOS (ABA ANALYTICS)
+# =============================================================================
+from services.data_file_service import get_data_file_service
+
+
+# ── helpers locais ──────────────────────────────────────────────────────────
+def _dfm_kpi_card(icon, title, value, color="primary"):
+ return dbc.Col(
+ dbc.Card(
+ dbc.CardBody([
+ html.Div([
+ html.Span(icon + " ", style={"fontSize": "1.1rem"}),
+ html.Span(title, className="text-muted small fw-semibold"),
+ ]),
+ html.H4(str(value), className=f"fw-bold text-{color} mb-0 mt-1"),
+ ], className="p-3"),
+ className=f"shadow-sm h-100 kpi-card kpi-card-{color}",
+ ),
+ md=3,
+ )
+
+
+def _dfm_build_kpis(summary: dict, lang: str = DEFAULT_LANG):
+ by_kind = summary.get("by_kind", {})
+ return dbc.Row([
+ _dfm_kpi_card("📦", _t("dfm_kpi_files", lang),
+ summary.get("n_files", 0), "primary"),
+ _dfm_kpi_card("💾", _t("dfm_kpi_size", lang),
+ summary.get("total_human", "0 B"), "info"),
+ _dfm_kpi_card("📏", _t("dfm_kpi_rows", lang),
+ f"{summary.get('total_rows', 0):,}".replace(",", "."),
+ "success"),
+ _dfm_kpi_card(
+ "🧩", _t("dfm_kpi_features_targets", lang),
+ f"{by_kind.get('features', 0)} / {by_kind.get('targets', 0)}",
+ "warning",
+ ),
+ ], className="g-2")
+
+
+def _dfm_apply_filters(files: list[dict], kind: str, source: str, search: str) -> list[dict]:
+ out = files
+ if kind and kind != "ALL":
+ out = [f for f in out if f.get("kind") == kind]
+ if source and source != "ALL":
+ out = [f for f in out if f.get("source") == source]
+ if search:
+ q = search.strip().lower()
+ if q:
+ out = [f for f in out
+ if q in f.get("filename", "").lower()
+ or q in f.get("rel_path", "").lower()]
+ return out
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# CALLBACK: refresh — popula store + tabela + KPIs
+# ──────────────────────────────────────────────────────────────────────────────
+@callback(
+ Output("dfm-files-store", "data"),
+ Output("dfm-file-table", "data"),
+ Output("dfm-kpis", "children"),
+ Output("dfm-file-table", "selected_rows"),
+ Output("dfm-refresh-interval", "disabled"),
+ Input("dfm-btn-refresh", "n_clicks"),
+ Input("dfm-refresh-interval", "n_intervals"),
+ Input("dfm-filter-kind", "value"),
+ Input("dfm-filter-source", "value"),
+ Input("dfm-search", "value"),
+ Input("lang-selector", "value"),
+ State("dfm-file-table", "selected_rows"),
+ prevent_initial_call=False,
+)
+def dfm_refresh(n_refresh, n_tick, kind, source, search, lang_in, current_selection):
+ """
+ Recarrega a lista de arquivos (ou apenas reaplica filtros sobre o store).
+
+ Distingue recarga "hard" (botão / intervalo) de "soft" (mudança de filtro)
+ para não tocar no disco sem necessidade.
+ """
+ lang = get_lang(lang_in)
+ trig = (ctx.triggered_id or "")
+
+ svc = get_data_file_service()
+
+ # Decide se precisa rescanear o disco
+ hard_refresh_triggers = {"dfm-btn-refresh", "dfm-refresh-interval"}
+ if trig in hard_refresh_triggers or trig == "":
+ try:
+ files = [f.to_dict() for f in svc.discover()]
+ except Exception as e: # noqa: BLE001
+ log.exception("Falha em discover()")
+ files = []
+ # Show a friendly error in the KPI bar
+ err_kpis = dbc.Alert(
+ _t("dfm_discover_failed", lang).format(
+ err=f"{type(e).__name__}: {e}"),
+ color="danger", className="small mb-0",
+ )
+ return [], [], err_kpis, [], False
+ else:
+ # Soft refresh — reaproveita o que está no store (lê via State ausente →
+ # fallback: descobre de novo, que é barato)
+ try:
+ files = [f.to_dict() for f in svc.discover()]
+ except Exception:
+ files = []
+
+ # Aplica filtros
+ filtered = _dfm_apply_filters(files, kind, source, search)
+
+ # KPIs calculam sobre a lista COMPLETA (visão global), não sobre filtros.
+ total_bytes = sum(int(f.get("size_bytes", 0)) for f in files)
+ total_rows = sum(max(0, int(f.get("rows", 0))) for f in files)
+ by_kind: dict[str, int] = {}
+ for f in files:
+ k = f.get("kind", "other")
+ by_kind[k] = by_kind.get(k, 0) + 1
+
+ def _human_bytes(n: int) -> str:
+ units = ["B", "KB", "MB", "GB", "TB"]
+ s = float(n)
+ for u in units:
+ if s < 1024 or u == units[-1]:
+ return f"{s:.1f} {u}" if u != "B" else f"{int(s)} B"
+ s /= 1024
+ return f"{s:.1f} TB"
+
+ kpis = _dfm_build_kpis({
+ "n_files": len(files),
+ "total_human": _human_bytes(total_bytes),
+ "total_rows": total_rows,
+ "by_kind": by_kind,
+ }, lang=lang)
+
+ # Quando lista muda, limpamos seleção para evitar índice inválido
+ new_selection = []
+
+ # Ativa auto-refresh assim que lista é populada
+ interval_disabled = len(files) == 0
+
+ return files, filtered, kpis, new_selection, interval_disabled
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# CALLBACK: selection → preview + habilita botão delete
+# ──────────────────────────────────────────────────────────────────────────────
+@callback(
+ Output("dfm-preview-table", "data"),
+ Output("dfm-preview-table", "columns"),
+ Output("dfm-preview-name", "children"),
+ Output("dfm-preview-stats", "children"),
+ Output("dfm-btn-delete", "disabled"),
+ Output("dfm-selected-path-store", "data"),
+ Input("dfm-file-table", "selected_rows"),
+ Input("lang-selector", "value"),
+ State("dfm-file-table", "data"),
+ prevent_initial_call=False,
+)
+def dfm_on_select(selected_rows, lang_in, table_data):
+ """Carrega preview do arquivo selecionado via DataFileService.read_preview."""
+ lang = get_lang(lang_in)
+ if not selected_rows or not table_data:
+ return [], [], _t("dfm_preview_placeholder", lang), "", True, None
+
+ try:
+ idx = selected_rows[0]
+ row = table_data[idx]
+ except (IndexError, TypeError, KeyError):
+ return [], [], _t("dfm_preview_invalid", lang), "", True, None
+
+ path = row.get("path")
+ if not path:
+ return [], [], _t("dfm_preview_no_path", lang), "", True, None
+
+ svc = get_data_file_service()
+ try:
+ records, cols, total = svc.read_preview(path, n_rows=200)
+ except Exception as e: # noqa: BLE001
+ log.exception("read_preview falhou para %s", path)
+ err_msg = html.Span(
+ [html.I(className="me-1"),
+ _t("dfm_preview_read_err", lang).format(
+ err=f"{type(e).__name__}: {e}")],
+ className="text-danger",
+ )
+ return [], [], row.get("filename", ""), err_msg, True, path
+
+ columns = [{"name": c, "id": c} for c in cols]
+
+ # Cabeçalho amigável
+ name_label = html.Span([
+ html.Span(row.get("kind_icon", "📄"), className="me-1"),
+ html.Code(row.get("filename", ""), className="me-2"),
+ html.Span(f"({row.get('kind_label', '—')})",
+ className="text-muted small"),
+ ])
+
+ stats_label = html.Span(
+ _t("dfm_preview_stats", lang).format(
+ shown=len(records), total=total,
+ cols=len(cols), size=row.get('size_human', '')
+ ).replace(",", "."),
+ className="text-muted small",
+ )
+
+ return records, columns, name_label, stats_label, False, path
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# CALLBACK: botão delete → abre confirm
+# ──────────────────────────────────────────────────────────────────────────────
+@callback(
+ Output("dfm-confirm-delete", "displayed"),
+ Output("dfm-confirm-delete", "message"),
+ Input("dfm-btn-delete", "n_clicks"),
+ State("dfm-selected-path-store", "data"),
+ State("dfm-file-table", "selected_rows"),
+ State("dfm-file-table", "data"),
+ State("lang-selector", "value"),
+ prevent_initial_call=True,
+)
+def dfm_ask_delete(n_clicks, path, selected_rows, table_data, lang_in):
+ if not n_clicks or not path:
+ raise PreventUpdate
+ lang = get_lang(lang_in)
+ try:
+ row = table_data[selected_rows[0]]
+ filename = row.get("filename", os.path.basename(path))
+ kind = row.get("kind_label", "—")
+ except Exception:
+ filename = os.path.basename(path)
+ kind = "—"
+ msg = _t("dfm_confirm_delete", lang).format(
+ filename=filename, kind=kind, path=path)
+ return True, msg
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# CALLBACK: confirmação OK → executa delete + feedback + força refresh
+# ──────────────────────────────────────────────────────────────────────────────
+@callback(
+ Output("dfm-action-feedback", "children"),
+ Output("dfm-btn-refresh", "n_clicks"),
+ Input("dfm-confirm-delete", "submit_n_clicks"),
+ State("dfm-selected-path-store", "data"),
+ State("dfm-btn-refresh", "n_clicks"),
+ prevent_initial_call=True,
+)
+def dfm_confirm_delete(submit_n, path, current_refresh_clicks):
+ if not submit_n or not path:
+ raise PreventUpdate
+
+ svc = get_data_file_service()
+ result = svc.delete(path)
+
+ color = "success" if result.get("ok") else "danger"
+ icon = "✓" if result.get("ok") else "✗"
+ body = dbc.Alert(
+ [html.Strong(f"{icon} "),
+ result.get("message", ""),
+ html.Br() if result.get("path") else "",
+ html.Small(result.get("path", ""), className="text-muted") if result.get("path") else ""],
+ color=color, className="py-2 small mb-0",
+ dismissable=True,
+ )
+
+ # Força refresh incrementando n_clicks do botão (triggera dfm_refresh)
+ new_clicks = (current_refresh_clicks or 0) + 1
+ return body, new_clicks
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# CALLBACK: upload → salva via serviço, mostra feedback, força refresh
+# ──────────────────────────────────────────────────────────────────────────────
+@callback(
+ Output("dfm-upload-feedback", "children"),
+ Output("dfm-btn-refresh", "n_clicks", allow_duplicate=True),
+ Input("dfm-upload", "contents"),
+ State("dfm-upload", "filename"),
+ State("dfm-upload", "last_modified"),
+ State("dfm-btn-refresh", "n_clicks"),
+ State("lang-selector", "value"),
+ prevent_initial_call=True,
+)
+def dfm_on_upload(contents_list, filenames, last_mod, current_refresh_clicks, lang_in):
+ if not contents_list:
+ raise PreventUpdate
+ lang = get_lang(lang_in)
+
+ # dcc.Upload devolve lista OU item único conforme `multiple`
+ if isinstance(contents_list, str):
+ contents_list = [contents_list]
+ filenames = [filenames] if isinstance(filenames, str) else filenames
+
+ svc = get_data_file_service()
+ results = []
+ for content, name in zip(contents_list, filenames or []):
+ res = svc.save_upload(content or "", name or "unnamed.csv", overwrite=False)
+ results.append((name, res))
+
+ ok_count = sum(1 for _, r in results if r.get("ok"))
+ fail_count = len(results) - ok_count
+
+ no_msg = _t("dfm_upload_no_message", lang)
+ items = []
+ for orig_name, r in results:
+ ok = r.get("ok")
+ icon = "✅" if ok else "⚠️"
+ color = "text-success" if ok else "text-danger"
+ items.append(html.Li([
+ html.Span(icon + " ", className=color),
+ html.Code(orig_name or "unnamed"),
+ html.Span(" → ", className="text-muted"),
+ html.Span(r.get("message", no_msg),
+ className=color + " small"),
+ ]))
+
+ alert_color = (
+ "success" if fail_count == 0
+ else ("warning" if ok_count > 0 else "danger")
+ )
+ feedback = dbc.Alert([
+ html.Strong(_t("dfm_upload_summary", lang).format(
+ ok=ok_count, fail=fail_count)),
+ html.Ul(items, className="mb-0 mt-1 small"),
+ ], color=alert_color, dismissable=True, className="py-2 mb-0")
+
+ # Força refresh para exibir os novos arquivos
+ new_clicks = (current_refresh_clicks or 0) + 1
+ return feedback, new_clicks
diff --git a/CSTRChemIA/apps/MAIN_init.py b/CSTRChemIA/apps/MAIN_init.py
index 738ff6d..258eaeb 100644
--- a/CSTRChemIA/apps/MAIN_init.py
+++ b/CSTRChemIA/apps/MAIN_init.py
@@ -20,14 +20,25 @@
from main import app
from apps.MAIN_styles_dict import * # noqa: F401,F403
from config import PORT
+from core.i18n import (
+ DEFAULT_LANG,
+ LANGUAGES,
+ t as _t,
+)
# ==============================
# CONSTANTES
# ==============================
-DEFAULT_IP = '0.0.0.0'
-BASE_URL = f'http://{DEFAULT_IP}:{PORT}/'
-APP_NAME = "CSTR Simulator & Optimizer"
+# Bind em 0.0.0.0 permite acessar de outras máquinas na rede, mas
+# navegadores não conseguem abrir `http://0.0.0.0` (ERR_ADDRESS_INVALID no
+# Windows/Chrome). Usamos 127.0.0.1 para a URL que abre no browser.
+BIND_HOST = '0.0.0.0'
+BROWSE_HOST = '127.0.0.1'
+BASE_URL = f'http://{BROWSE_HOST}:{PORT}/'
+APP_NAME = "CSTRChemIA - CSTR Simulator & Optimizer"
APP_VERSION = "v1.0"
+# Alias mantido para retrocompat com código que importa DEFAULT_IP.
+DEFAULT_IP = BIND_HOST
# ==============================
@@ -41,43 +52,113 @@ def open_browser():
def run_chemsimia():
"""
Inicia o servidor Dash. Em sistemas não-Linux, abre automaticamente
- o navegador após 1 segundo.
+ o navegador após 1 segundo (em http://127.0.0.1:PORT).
+
+ O servidor escuta em 0.0.0.0 para permitir acesso remoto.
"""
if platform.system() != "Linux":
Timer(1, open_browser).start()
- app.run(host=DEFAULT_IP, port=PORT, debug=False)
+ app.run(host=BIND_HOST, port=PORT, debug=False)
# ==============================
# COMPONENTES DO HEADER
# ==============================
+def _build_language_selector():
+ """
+ Dropdown compacto de seleção de idioma — canto direito do header.
+
+ Persistência em ``sessionStorage`` via ``persistence_type='session'`` mantém
+ o idioma escolhido entre trocas de aba e recargas da página.
+ """
+ return html.Div(
+ className="cstr-lang-selector",
+ style={
+ "display": "flex",
+ "alignItems": "center",
+ "gap": "8px",
+ # Ancorado à direita da célula grid correspondente
+ "justifySelf": "end",
+ },
+ children=[
+ html.Span("🌐", style={"fontSize": "18px"}),
+ dcc.Dropdown(
+ id="lang-selector",
+ options=LANGUAGES,
+ value=DEFAULT_LANG,
+ clearable=False,
+ persistence=True,
+ persistence_type="session",
+ style={
+ "width": "160px",
+ "fontSize": "14px",
+ "color": "#212529",
+ },
+ ),
+ ],
+ )
+
+
def _build_header():
- """Cabeçalho fixo, com gradiente, logo, título e badge de versão."""
+ """Cabeçalho fixo, com gradiente, logo, título e seletor de idioma."""
return html.Div(
className="cstr-header",
+ style={"padding": "14px 24px"},
children=html.Div(
className="cstr-header-inner",
+ style={
+ # 3 trilhas (esq | centro | dir) — título sempre no centro
+ # da página, independente da largura do logo ou do seletor
+ # de idioma.
+ "display": "grid",
+ "gridTemplateColumns": "1fr auto 1fr",
+ "alignItems": "center",
+ "gap": "18px",
+ "maxWidth": "1600px",
+ "margin": "0 auto",
+ },
children=[
- html.Img(src='assets/logo.png', className="cstr-logo"),
+ # style inline garante tamanho mesmo se o custom.css não for
+ # aplicado por algum motivo (cache do browser, asset 404, etc).
+ html.Img(
+ src='assets/logo.png',
+ className="cstr-logo",
+ style={
+ "height": "56px",
+ "width": "auto",
+ "maxHeight": "56px",
+ "maxWidth": "56px",
+ "objectFit": "contain",
+ "justifySelf": "start",
+ },
+ ),
html.Div([
- html.H1(APP_NAME, className="cstr-app-title"),
+ html.H1(
+ id="header-app-title",
+ children=_t("app_title", DEFAULT_LANG),
+ className="cstr-app-title",
+ ),
html.Span(
- "Simulação e otimização multi-objetivo de reatores CSTR",
+ id="header-app-subtitle",
+ children=_t("app_subtitle", DEFAULT_LANG),
className="cstr-app-subtitle",
),
- ], className="cstr-title-block"),
+ ], className="cstr-title-block",
+ style={"justifySelf": "center", "textAlign": "center"}),
+ _build_language_selector(),
],
),
)
def _build_footer():
+ year = datetime.now().year
return html.Div(
className="cstr-footer",
children=[
html.Span(
- f"© {datetime.now().year} CSTR Simulator & Optimizer "
- f"— Powered by Dash + TensorFlow + pymoo",
+ id="footer-text",
+ children=f"© {year} {_t('footer_text', DEFAULT_LANG)}",
className="cstr-footer-text",
),
],
@@ -119,6 +200,11 @@ def _build_footer():
style=styles_dict['Training']['style_visible'],
selected_style=styles_dict['Training']['selected_style']
),
+ dcc.Tab(
+ label='📊 Analytics', value='TAB_Analytics', id='TAB_Analytics',
+ style=styles_dict['Analytics']['style_visible'],
+ selected_style=styles_dict['Analytics']['selected_style']
+ ),
dcc.Tab(
label='❔ Help', value='TAB_Help', id='TAB_Help',
style=styles_dict['Help']['style_visible'],
@@ -129,11 +215,6 @@ def _build_footer():
style=styles_dict['About']['style_visible'],
selected_style=styles_dict['About']['selected_style']
),
- dcc.Tab(
- label='📊 Analytics', value='TAB_Analytics', id='TAB_Analytics',
- style=styles_dict['Analytics']['style_visible'],
- selected_style=styles_dict['Analytics']['selected_style']
- ),
],
),
),
@@ -150,7 +231,15 @@ def _build_footer():
# Stores e timers
dcc.Store(id='store-data'),
- dcc.Store(id='sim-history-store', data=[]),
+ dcc.Store(id='sim-history-store', data=[], storage_type='session'),
+ # Unidades escolhidas pelo usuário por variável (persistente na sessão).
+ # Formato: {'vazao': 'L_s', 'tf': 'C', ...}
+ dcc.Store(id='units-store', data={}, storage_type='session'),
+ # KPIs em unidade canônica (preenchido pelo callback de simulação).
+ # Formato: {'X': 0.85, 'T0': 330.5, 'CA0': 1000.0, ...}
+ # Separamos o valor canônico do valor de exibição para que a troca de
+ # unidade seja puramente de UI — sem reexecutar a predição.
+ dcc.Store(id='sim-kpis-canonical-store', data={}, storage_type='session'),
dcc.Interval(id='training-interval', interval=1000, disabled=False),
],
)
diff --git a/CSTRChemIA/apps/MAIN_unit_callbacks.py b/CSTRChemIA/apps/MAIN_unit_callbacks.py
new file mode 100644
index 0000000..904fbc9
--- /dev/null
+++ b/CSTRChemIA/apps/MAIN_unit_callbacks.py
@@ -0,0 +1,243 @@
+# apps/MAIN_unit_callbacks.py
+"""
+Callbacks de troca de unidade de exibição.
+
+Para cada variável com unit_selector (dropdown ``unit-{var_id}``), registra
+um callback que:
+
+ 1. Lê o valor atual do input (que está na unidade anterior).
+ 2. Converte anterior → canônica → nova, via ``core.units``.
+ 3. Recalcula ``min`` / ``max`` / ``step`` do Input e do Slider (se houver).
+ 4. Atualiza a FormText com o texto de faixa na nova unidade.
+ 5. Atualiza a *unit-label* de KPIs (quando aplicável).
+ 6. Persiste o par ``var_id → new_unit`` no ``units-store``.
+
+O módulo roda efeitos colaterais no import (registra callbacks no ``app``),
+portanto basta importá-lo em ``apps/MAIN_callbacks.py`` no topo.
+"""
+from __future__ import annotations
+
+from typing import Optional
+
+from dash import Input, Output, State, no_update
+from dash.exceptions import PreventUpdate
+
+from main import app
+from core.units import (
+ VAR_BOUNDS,
+ VAR_CATEGORY,
+ canonical_value_of,
+ display_bounds,
+ format_range_text,
+ convert,
+ get_option,
+ label_for,
+)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# REGISTRO DECLARATIVO
+# ══════════════════════════════════════════════════════════════════════════════
+#: Cada entrada descreve uma variável que possui ``unit-{var_id}`` + input.
+#:
+#: Campos:
+#: ``var_id`` — chave de ``VAR_CATEGORY`` / ``VAR_BOUNDS``.
+#: ``input_id`` — id do ``dbc.Input`` numérico.
+#: ``slider_id`` — id do ``dcc.Slider`` pareado (``None`` se não houver).
+#: ``ft_id`` — id do ``dbc.FormText`` com texto de faixa
+#: (``None`` se não houver).
+#:
+#: Tem que sincronizar com :mod:`layouts.layout_TAB`.
+_SIM_INPUTS: list[tuple[str, str, str, str]] = [
+ # (var_id, input_id, slider_id, ft_id)
+ ("vazao", "input-vazao", "slider-vazao", "ft-input-vazao"),
+ ("ca0feed", "input-ca0feed", "slider-ca0feed", "ft-input-ca0feed"),
+ ("cb0feed", "input-cb0feed", "slider-cb0feed", "ft-input-cb0feed"),
+ ("tf", "input-tf", "slider-tf", "ft-input-tf"),
+ ("tamb", "input-tamb", "slider-tamb", "ft-input-tamb"),
+ ("tobs", "input-tobs", "slider-tobs", "ft-input-tobs"),
+ ("wj", "input-wj", "slider-wj", "ft-input-wj"),
+]
+
+#: Inputs da aba Optimization — nenhum tem slider.
+_OPT_INPUTS: list[tuple[str, str, Optional[str], Optional[str]]] = [
+ ("t_max", "opt-t-max", None, "ft-opt-t-max"),
+ ("fouling_max", "opt-fouling-max", None, "ft-opt-fouling-max"),
+ ("cp0_min", "opt-cp0-min", None, "ft-opt-cp0-min"),
+ ("vazao_min", "opt-vazao-min", None, None),
+ ("vazao_max", "opt-vazao-max", None, None),
+ ("tobs_min", "opt-tobs-min", None, None),
+ ("tobs_max", "opt-tobs-max", None, None),
+ ("wj_min", "opt-wj-min", None, None),
+ ("wj_max", "opt-wj-max", None, None),
+]
+
+#: KPIs da aba Simulation — H4 com valor + Small com label de unidade.
+#: ``canonical_transform`` pode ser uma função aplicada ao valor canônico bruto
+#: antes de converter (útil para ``fouling`` que é metros no modelo e queremos
+#: mostrar em µm por padrão, ou para ``X`` que sai como fração e pode aparecer
+#: em %).
+_SIM_KPIS: list[tuple[str, str, str]] = [
+ # (var_id, out_id, unit_label_id)
+ ("X", "out-X", "unit-label-out-X"),
+ ("T0", "out-T0", "unit-label-out-T0"),
+ ("CA0", "out-CA0", "unit-label-out-CA0"),
+ ("CB0", "out-CB0", "unit-label-out-CB0"),
+ ("CP0", "out-CP0", "unit-label-out-CP0"),
+ ("CS0", "out-CS0", "unit-label-out-CS0"),
+ ("fouling", "out-fouling", "unit-label-out-fouling"),
+]
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# FORMATAÇÃO
+# ══════════════════════════════════════════════════════════════════════════════
+def _smart_fmt(v: float) -> str:
+ """Formatação compacta: inteiros simples, %g caso contrário, científica p/ extremos."""
+ if v != 0 and (abs(v) < 1e-3 or abs(v) >= 1e6):
+ return f"{v:.3e}"
+ if v == int(v) and abs(v) < 1e5:
+ return f"{int(v)}"
+ return f"{v:.4g}"
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# REGISTRO DE CALLBACKS PARA INPUTS
+# ══════════════════════════════════════════════════════════════════════════════
+def _register_input_unit_callback(var_id: str, input_id: str,
+ slider_id: Optional[str],
+ ft_id: Optional[str]):
+ """
+ Registra um callback de troca de unidade para um par Input (+Slider, +FormText).
+
+ O callback escuta ``unit-{var_id}.value`` e, na mudança, converte o valor
+ do input da unidade antiga para a nova, ajusta ``min`` / ``max`` / ``step``
+ e atualiza a faixa de ajuda.
+ """
+ if var_id not in VAR_CATEGORY or var_id not in VAR_BOUNDS:
+ return
+ cat = VAR_CATEGORY[var_id]
+ canonical_unit = canonical_value_of(cat)
+
+ # Monta Outputs dinamicamente — alguns são opcionais.
+ outputs = [
+ Output(input_id, "value", allow_duplicate=True),
+ Output(input_id, "min", allow_duplicate=True),
+ Output(input_id, "max", allow_duplicate=True),
+ Output(input_id, "step", allow_duplicate=True),
+ ]
+ if slider_id:
+ outputs += [
+ Output(slider_id, "value", allow_duplicate=True),
+ Output(slider_id, "min", allow_duplicate=True),
+ Output(slider_id, "max", allow_duplicate=True),
+ Output(slider_id, "step", allow_duplicate=True),
+ ]
+ if ft_id:
+ outputs += [Output(ft_id, "children", allow_duplicate=True)]
+ outputs += [Output("units-store", "data", allow_duplicate=True)]
+
+ inputs = [Input(f"unit-{var_id}", "value")]
+ states = [State(input_id, "value"),
+ State("units-store", "data")]
+
+ @app.callback(*outputs, *inputs, *states, prevent_initial_call=True)
+ def _change_unit(new_unit, current_value, store):
+ store = dict(store or {})
+ old_unit = store.get(var_id, canonical_unit)
+
+ # Sem troca real → não faz nada.
+ if new_unit == old_unit and current_value is not None:
+ raise PreventUpdate
+
+ # Converte valor corrente.
+ new_value = None
+ if current_value is not None:
+ new_value = convert(current_value, cat, old_unit, new_unit)
+ # Fallback: default da variável no novo unit.
+ if new_value is None:
+ new_value = display_bounds(var_id, new_unit)["default"]
+
+ db = display_bounds(var_id, new_unit)
+ # Clamp para caber nos novos bounds.
+ new_value = max(db["min"], min(db["max"], float(new_value)))
+
+ store[var_id] = new_unit
+
+ result = [new_value, db["min"], db["max"], db["step"]]
+ if slider_id:
+ result += [new_value, db["min"], db["max"], db["step"]]
+ if ft_id:
+ # Texto curto com a faixa na unidade nova.
+ result += [format_range_text(var_id, new_unit)]
+ result += [store]
+ return tuple(result)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# REGISTRO DE CALLBACKS PARA KPIS
+# ══════════════════════════════════════════════════════════════════════════════
+def _register_kpi_unit_callback(var_id: str, out_id: str, unit_label_id: str):
+ """
+ Callback de KPI: lê o valor canônico do ``sim-kpis-canonical-store`` e o
+ unit dropdown, e produz o texto do H4 + o texto do Small.
+
+ O valor canônico é escrito pelo callback principal de simulação
+ (:func:`apps.MAIN_callbacks.update_simulation`) nesse store.
+ """
+ if var_id not in VAR_CATEGORY:
+ return
+ cat = VAR_CATEGORY[var_id]
+ canonical_unit = canonical_value_of(cat)
+
+ @app.callback(
+ Output(out_id, "children", allow_duplicate=True),
+ Output(unit_label_id, "children", allow_duplicate=True),
+ Output("units-store", "data", allow_duplicate=True),
+ Input(f"unit-{var_id}", "value"),
+ Input("sim-kpis-canonical-store", "data"),
+ State("units-store", "data"),
+ prevent_initial_call=True,
+ )
+ def _render_kpi(chosen_unit, kpis_canonical, store):
+ store = dict(store or {})
+ unit_value = chosen_unit or store.get(var_id, canonical_unit)
+ store[var_id] = unit_value
+
+ if not kpis_canonical or var_id not in kpis_canonical:
+ # Sem valor ainda — só atualiza label de unidade.
+ return no_update, label_for(cat, unit_value), store
+
+ canonical_v = kpis_canonical.get(var_id)
+ if canonical_v is None:
+ return "—", label_for(cat, unit_value), store
+
+ opt = get_option(cat, unit_value)
+ display_v = opt.to_display(float(canonical_v))
+ # Formatação do valor — 2 casas para conc./temp., 4 para fração/fouling.
+ if cat == "fraction":
+ txt = f"{display_v:.2f}"
+ elif cat == "length_micro":
+ txt = f"{display_v:.3g}"
+ elif cat == "temperature":
+ txt = f"{display_v:.2f}"
+ elif cat == "concentration":
+ txt = f"{display_v:.2f}"
+ elif cat == "vazao":
+ txt = f"{display_v:.4g}"
+ else:
+ txt = f"{display_v:.4g}"
+ return txt, label_for(cat, unit_value), store
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# REGISTRA TODOS NO IMPORT
+# ══════════════════════════════════════════════════════════════════════════════
+for _vid, _iid, _sid, _ftid in _SIM_INPUTS:
+ _register_input_unit_callback(_vid, _iid, _sid, _ftid)
+
+for _vid, _iid, _sid, _ftid in _OPT_INPUTS:
+ _register_input_unit_callback(_vid, _iid, _sid, _ftid)
+
+for _vid, _oid, _lid in _SIM_KPIS:
+ _register_kpi_unit_callback(_vid, _oid, _lid)
diff --git a/CSTRChemIA/apps/callbacks/__init__.py b/CSTRChemIA/apps/callbacks/__init__.py
new file mode 100644
index 0000000..f14fd85
--- /dev/null
+++ b/CSTRChemIA/apps/callbacks/__init__.py
@@ -0,0 +1,31 @@
+"""
+apps.callbacks — ponto de entrada unificado para callbacks Dash.
+
+Atualmente é um scaffold para migração gradual: hoje o registro de
+callbacks acontece no ``apps.MAIN_callbacks`` (monolítico). O objetivo é
+mover callbacks por aba para módulos específicos (``simulation``,
+``optimization``, ``training``, ``analytics``) mantendo os IDs e
+decoradores exatamente iguais.
+
+Para um observador externo, ``import apps.callbacks`` já registra todos
+os callbacks (por hora delegando ao MAIN_callbacks legado).
+"""
+
+from __future__ import annotations
+
+# Importar MAIN_callbacks registra todos os callbacks via side-effect dos
+# decoradores @callback. Mantemos o comportamento original.
+from apps import MAIN_callbacks as _legacy # noqa: F401
+
+# Re-exports úteis para código novo.
+from apps.callbacks.service_adapters import (
+ predict_via_service,
+ submit_optimization,
+ submit_training,
+)
+
+__all__ = [
+ "predict_via_service",
+ "submit_optimization",
+ "submit_training",
+]
diff --git a/CSTRChemIA/apps/callbacks/service_adapters.py b/CSTRChemIA/apps/callbacks/service_adapters.py
new file mode 100644
index 0000000..891e7e0
--- /dev/null
+++ b/CSTRChemIA/apps/callbacks/service_adapters.py
@@ -0,0 +1,93 @@
+"""
+Adaptadores finos que conectam callbacks Dash aos services.
+
+Callbacks novos devem importar daqui em vez de chamar PredictValues /
+run_cstr_optimization diretamente. Isso:
+ * Centraliza tratamento de erros (exceções → mensagens UI-safe).
+ * Permite trocar a implementação de um service sem tocar callbacks.
+ * Dá um ponto único de instrumentação (logs, métricas).
+"""
+
+from __future__ import annotations
+
+from functools import lru_cache
+from typing import Any
+
+from core.exceptions import CSTRChemIAError
+from core.logging import get_logger
+
+log = get_logger(__name__)
+
+
+@lru_cache(maxsize=1)
+def _sim_service():
+ from services.simulation_service import SimulationService
+ return SimulationService()
+
+
+@lru_cache(maxsize=1)
+def _opt_service():
+ from services.optimization_service import OptimizationService
+ return OptimizationService()
+
+
+@lru_cache(maxsize=1)
+def _train_service():
+ from services.training_service import TrainingService
+ return TrainingService()
+
+
+def predict_via_service(features: dict[str, float], model_name: str
+ ) -> tuple[dict[str, float] | None, str | None]:
+ """
+ Predição para callbacks — retorna ``(targets, erro)``.
+
+ Nunca levanta: erros viram string para renderizar no UI.
+ """
+ try:
+ out = _sim_service().predict_named(features, model_name=model_name)
+ return out, None
+ except CSTRChemIAError as e:
+ log.warning("predict_via_service: %s", e)
+ return None, str(e)
+ except Exception: # noqa: BLE001
+ log.exception("predict_via_service: erro inesperado")
+ return None, "Erro inesperado ao predizer. Veja os logs."
+
+
+def submit_optimization(**kwargs: Any) -> tuple[str | None, str | None]:
+ """Submete job de otimização, retorna ``(job_id, erro)``."""
+ try:
+ jid = _opt_service().submit(**kwargs)
+ return jid, None
+ except CSTRChemIAError as e:
+ log.warning("submit_optimization: %s", e)
+ return None, str(e)
+ except Exception: # noqa: BLE001
+ log.exception("submit_optimization: erro inesperado")
+ return None, "Erro inesperado ao iniciar otimização."
+
+
+def submit_training(architecture: str) -> tuple[str | None, str | None]:
+ """Submete treino, retorna ``(job_id, erro)``."""
+ try:
+ jid = _train_service().submit(architecture)
+ return jid, None
+ except CSTRChemIAError as e:
+ log.warning("submit_training: %s", e)
+ return None, str(e)
+ except Exception: # noqa: BLE001
+ log.exception("submit_training: erro inesperado")
+ return None, "Erro inesperado ao iniciar treino."
+
+
+def get_optimization_job(job_id: str):
+ return _opt_service().get_job(job_id)
+
+
+def get_training_job(job_id: str):
+ return _train_service().get_job(job_id)
+
+
+def tail_training_logs(job_id: str, n: int = 200) -> list[str]:
+ return _train_service().tail_logs(job_id, n=n)
diff --git a/CSTRChemIA/assets/custom.css b/CSTRChemIA/assets/custom.css
index b56d149..fa5d608 100644
--- a/CSTRChemIA/assets/custom.css
+++ b/CSTRChemIA/assets/custom.css
@@ -66,12 +66,28 @@ body, html {
.cstr-header-inner {
max-width: 1600px;
margin: 0 auto;
- display: flex;
- justify-content: center;
+ /* Grid com 3 trilhas iguais nas laterais → título fica sempre
+ centralizado na página, independente do tamanho do logo ou do
+ seletor de idioma. */
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 18px;
}
+.cstr-header-inner > .cstr-logo {
+ justify-self: start;
+}
+
+.cstr-header-inner > .cstr-title-block {
+ justify-self: center;
+ text-align: center;
+}
+
+.cstr-header-inner > .cstr-lang-selector {
+ justify-self: end;
+}
+
.cstr-logo {
height: 56px;
width: auto;
@@ -283,16 +299,9 @@ body, html {
overflow: visible !important;
}
-/* Expansão vertical do card quando um dropdown está aberto.
- :has() — suporte >92 % dos browsers em 2025 (Chrome 105+, Firefox 121+, Safari 15.4+). */
-.cstr-card:has(.Select--is-open) .card-body,
-.cstr-card:has([class*="--is-open"]) .card-body,
-.cstr-card:has([aria-expanded="true"]) .card-body {
- padding-bottom: 200px !important;
- transition: padding-bottom 0.22s ease-out;
-}
-
-/* Eleva o card com dropdown aberto para que ele fique acima de cards irmãos */
+/* Eleva o card com dropdown aberto para que ele fique acima de cards irmãos,
+ SEM aumentar a altura do container. O menu sobrepõe o conteúdo abaixo
+ (position: absolute) em vez de empurrá-lo. */
.cstr-card:has(.Select--is-open),
.cstr-card:has([class*="--is-open"]),
.cstr-card:has([aria-expanded="true"]) {
@@ -300,12 +309,31 @@ body, html {
z-index: 200 !important;
}
-/* Garante que o menu do dropdown apareca acima de outros cards/containers */
+/* Container do dropdown precisa permitir overflow; o menu é posicionado
+ absolutamente em relação ao próprio Select. */
+.cstr-dropdown,
+.cstr-dropdown .Select,
+.cstr-dropdown .Select-control {
+ position: relative;
+}
+
+/* Menu do dropdown: overlay absoluto, sem deslocar conteúdo abaixo. */
.cstr-dropdown .Select-menu-outer,
.cstr-dropdown .Select__menu,
-.cstr-dropdown [class*="menu"] {
- z-index: 9999 !important;
+.cstr-dropdown [class*="menu-outer"],
+.cstr-dropdown [class*="Select__menu"] {
position: absolute !important;
+ top: 100% !important;
+ left: 0 !important;
+ right: 0 !important;
+ z-index: 9999 !important;
+ margin-top: 2px !important;
+ background: var(--bg-card) !important;
+ border: 1px solid var(--border-soft) !important;
+ border-radius: 8px !important;
+ box-shadow: var(--shadow-md) !important;
+ max-height: 260px;
+ overflow-y: auto;
}
/* Dropdown generico do Dash (dcc.Dropdown) */
@@ -535,9 +563,15 @@ body, html {
* ============================================================================= */
@media (max-width: 992px) {
.cstr-header-inner {
- flex-direction: column;
+ /* Em telas estreitas, empilhamos logo/título/seletor em 1 coluna. */
+ grid-template-columns: 1fr;
gap: 8px;
}
+ .cstr-header-inner > .cstr-logo,
+ .cstr-header-inner > .cstr-title-block,
+ .cstr-header-inner > .cstr-lang-selector {
+ justify-self: center;
+ }
.cstr-app-title {
font-size: 1.1rem;
}
@@ -561,3 +595,21 @@ body, html {
.cstr-logo { height: 42px; }
.kpi-value { font-size: 1.3rem !important; }
}
+
+/* =============================================================================
+ * DATA FILE MANAGER (Analytics tab)
+ * ============================================================================= */
+.dfm-upload-zone:hover {
+ background-color: #eef2ff !important;
+ border-color: var(--primary) !important;
+}
+
+.dfm-upload-zone {
+ transition: var(--transition);
+}
+
+/* Compacta paginação das tabelas DFM */
+#dfm-file-table .previous-next-container,
+#dfm-preview-table .previous-next-container {
+ margin-top: 8px;
+}
diff --git a/CSTRChemIA/assets/html_help/index.html b/CSTRChemIA/assets/html_help/index.html
index 0147a45..c53feff 100644
--- a/CSTRChemIA/assets/html_help/index.html
+++ b/CSTRChemIA/assets/html_help/index.html
@@ -534,7 +534,7 @@
@@ -558,10 +558,30 @@
Base Técnica
🔬 Módulo Simulações
🧠 Redes Neurais
- 📐 NSGA-II
+ 📐 NSGA-II / NSGA-III
⚙️ Tuning de HPs
+
+
+
+
+
+
Referências
📚 Referências
@@ -575,14 +595,16 @@
@@ -597,7 +619,7 @@
Visão Geral do Software
- O CSTR Simulator & Optimizer é uma aplicação web desenvolvida em Python (Dash/Plotly) que combina modelos substitutos de aprendizado de máquina com um motor de otimização evolucionária para fornecer predições instantâneas do comportamento de um reator CSTR industrial complexo. O software foi desenvolvido por Rafael Alexandre de Pinho Lopes como bolsista PIBIC no Departamento de Engenharia Química da UNESP – Campus de Araraquara.
+ O CSTRChemIA é uma aplicação web desenvolvida em Python (Dash/Plotly) que combina modelos substitutos de aprendizado de máquina com um motor de otimização evolucionária para fornecer predições instantâneas do comportamento de um reator CSTR industrial complexo. O software foi desenvolvido por Rafael Alexandre de Pinho Lopes como bolsista PIBIC no Departamento de Engenharia Química da UNESP – Campus de Araraquara.
@@ -687,10 +709,13 @@
Fluxo de Trabalho Completo
Simulacoes/ | Geração de dados de alta fidelidade com ODE (CSTR ultra-realista) | SciPy BDF, LHS, PID, fouling |
Tuning_NeuralNetwork/ | Busca automática de hiperparâmetros para MLP, RNN e CNN | Keras Tuner Hyperband + K-Fold |
- CSTRChemIA/training/ | Treinamento final dos surrogates com HPs otimizados | TensorFlow/Keras |
- CSTRChemIA/surrogate_model/ | Inferência rápida + cache thread-safe dos modelos .keras | TensorFlow, StandardScaler |
- CSTRChemIA/optimization/ | Otimização multi-objetivo NSGA-II | pymoo 0.6+ |
- CSTRChemIA/apps/ | Callbacks e layout da interface Dash | Dash 2.9+, Plotly |
+ CSTRChemIA/core/ | Infraestrutura transversal: validação, serialização, manifesto, incerteza, feature engineering | numpy, json, hashlib (sem dependências externas pesadas) |
+ CSTRChemIA/training/ | Treinamento de MLP/RNN/CNN + baseline XGBoost com callbacks avançados e escrita de manifesto | TensorFlow 2.16 · Keras · XGBoost · scikit-learn |
+ CSTRChemIA/surrogate_model/ | Inferência rápida + cache thread-safe dos modelos .keras com verificação de integridade opcional | TensorFlow, StandardScaler, SHA-256 |
+ CSTRChemIA/optimization/ | NSGA-II/NSGA-III com n_ieq_constr=5 + 4 métodos MCDM para escolha da solução final | pymoo 0.6+ |
+ CSTRChemIA/services/ | Camada de serviço: ModelRegistry, TrainingService, OptimizationService, SimulationService | Padrão Service Layer — isola Dash do modelo |
+ CSTRChemIA/apps/ | Callbacks e layout da interface Dash | Dash 2.9+, Plotly, dash-bootstrap-components |
+ CSTRChemIA/tests/ | 46 testes pytest cobrindo toda a infraestrutura (validação, serialização, manifesto, incerteza, MCDM) | pytest, numpy testing |
@@ -835,14 +860,24 @@
Otimização Multi-objetivo NSGA-II
-
🔒 Restrições (penalidade)
+
🔒 Restrições nativas (n_ieq_constr=5)
+
Declaradas nativamente no pymoo, em unidades físicas:
- - T₀ ≤ T_max: temperatura máxima permitida (evita runaway)
- - X ≥ X_min: conversão mínima exigida pelo processo
- - cat_activity ≥ cat_min: atividade mínima do catalisador
- - fouling ≤ fouling_max: espessura máxima tolerada
- - CP₀ ≥ cp0_min: produção mínima do produto P
+ - g₀ = T₀ − T_max ≤ 0: temperatura máxima (default 365 K)
+ - g₁ = X_min − X ≤ 0: conversão mínima (default 0,30)
+ - g₂ = cat_min − cat_activity ≤ 0: atividade mínima (default 0,9995)
+ - g₃ = fouling − fouling_max ≤ 0: espessura máxima (default 0,025 m)
+ - g₄ = cp0_min − CP₀ ≤ 0: produção mínima de P (default 0,0)
+
Todas em unidades físicas (não escaladas) para interpretabilidade direta pelo engenheiro.
+
+
+
+
+
📐
+
+
Dois algoritmos disponíveis — NSGA-II ou NSGA-III
+
O módulo optimization/problem_v2.py expõe o parâmetro algorithm em OptimizationConfigV2. NSGA-II (default) usa crowding distance — ideal para 2 objetivos (X × fouling). NSGA-III usa direções de referência de Das–Dennis (ref_dirs_partitions=12) — útil se você estender para >2 objetivos (ex.: incluir produção de CP₀, minimizar energia de jaqueta).
@@ -923,16 +958,56 @@ Treinamento dos Modelos Substitutos
⚙️ Processo de Treinamento
- - Detecção automática dos arquivos CSV nas fontes acima.
- - Validação de colunas (12 features, 8 targets) e remoção de NaN/Inf por mediana da coluna.
- - Divisão 70/15/15 (treino/validação/teste) com seed=42.
- - Ajuste do
StandardScaler exclusivamente no conjunto de treino.
+ - Detecção automática dos arquivos CSV (layout flat ou aninhado).
+ - Validação estrutural via
core.data_validation.clean_dataset(): detecta NaN, Inf, flags inválidas (não-binárias) e violações dos TRAINING_FEATURE_BOUNDS / TRAINING_TARGET_BOUNDS. Linhas problemáticas são descartadas (não imputadas silenciosamente).
+ - Divisão 70/15/15 treino/validação/teste com
random_state igual ao --seed informado (propagado para Python, NumPy e TensorFlow).
+ - Ajuste do
StandardScaler X e Y exclusivamente no conjunto de treino.
- Carregamento dos hiperparâmetros de
training/best_hps_{ARCH}_values.json.
- - Treinamento com EarlyStopping (patience=20,
monitor=val_loss).
- - Salva
CSTR_{ARCH}_Surrogate.keras, scalerX.pkl, scalerY.pkl e data_meta.json.
- - Registra MAE de validação e teste em
training_results.csv para ranking.
+ - Treinamento com três callbacks:
+
+ - EarlyStopping —
patience=20, restore_best_weights
+ - ReduceLROnPlateau —
factor=0.5, patience=10, min_lr=1e-6, reduz a taxa de aprendizado em platôs
+ - ModelCheckpoint — salva os melhores pesos a cada epoch (removido ao final)
+
+
+ - Cálculo de métricas globais (MAE, RMSE, R²) e per-target — 8 MAEs separados revelam falhas ocultas em alvos específicos.
+ - Persistência dual:
CSTR_{ARCH}_Surrogate.keras, scalerX.pkl (legado) + scalerX.json (SHA-256), scalerY.pkl + scalerY.json, data_meta.json.
+ - Escrita do manifesto:
build_manifest() computa SHA-256 dos 3 artefatos e atualiza models.json com arquitetura, seed, métricas por-target, hiperparâmetros e versão do framework.
+ - Registra também o MAE em
training_results.csv (compatibilidade com UI legada).
+
+
+
🎲 CLI e reprodutibilidade
+
O script de treino aceita argumentos para controlar semente, arquitetura e recursos:
+
+# Treino padrão (MLP, 100 épocas, seed=42)
+python -m training.train_surrogate --model MLP
+# Treinar TODAS as arquiteturas com seed personalizada
+python -m training.train_surrogate --model all --seed 2026
+# Deep Ensemble: 3 membros com seeds diferentes
+for SEED in 11 17 23; do
+ python -m training.train_surrogate --model MLP --seed $SEED
+done
+
+
A seed é gravada no models.json em cada entrada de arquitetura. Ao combinar com o SHA-256 do .keras, cada predição futura pode ser rastreada até um modelo específico com identidade criptográfica.
+
+
+
+
📏 Métricas detalhadas por target
+
O pipeline grava métricas separadas para cada um dos 8 targets, permitindo diagnóstico fino:
+
+
+ | Métrica | Fórmula | Por target? | Em models.json |
+
+ | MAE | ∑|y_true − y_pred| / N | ✓ | ✓ |
+ | RMSE | √(∑(y_true − y_pred)² / N) | ✓ | ✓ |
+ | R² | 1 − SS_res / SS_tot | ✓ | ✓ |
+ | MAE global | Média das 8 MAEs | ✗ (agregada) | ✓ |
+
+
+
+
@@ -1040,7 +1115,7 @@ Arquiteturas dos Modelos Substitutos
- Input
(12,)
- Dense
160 · relu · BN · d=0,45
- - Dense
224 · elu · BN · d=0,15
+ - Dense
224 · elu · BN · d=0,15
- Output
(8,) · linear
@@ -1200,7 +1275,575 @@ Busca Automática de Hiperparâmetros
-
+
+
+ 🛡️ Reprodutibilidade
+ Validação Estrutural de Dados
+ Módulo core.data_validation — substitui a imputação silenciosa por mediana por um pipeline de descarte explícito com relatório linha-a-linha.
+
+
+
+
⚠️
+
+
Motivação — o perigo da imputação silenciosa
+
Preencher NaN/Inf com a mediana da coluna mascara sensores travados, falhas de comunicação e outliers severos, contaminando o modelo com pontos artificiais que não representam o processo real. O pipeline descarta essas linhas e reporta quantas e por quê.
+
+
+
+
+
📋 Classe ValidationReport (dataclass frozen)
+
Relatório imutável com metadados por categoria de violação:
+
+
+ | Campo | Tipo | Significado |
+
+ n_rows / n_cols | int | Dimensão do array validado |
+ column_names | tuple[str] | Nomes das colunas (features ou targets) |
+ nan_rows | np.ndarray[bool] | Máscara booleana das linhas com qualquer NaN |
+ inf_rows | np.ndarray[bool] | Máscara com ±Inf |
+ bound_violations | dict[str, np.ndarray] | Por coluna: máscara de valores fora de [lo, hi] |
+ flag_violations | dict[str, np.ndarray] | Por flag: máscara de valores ≠ {0, 1} (tolerância 1e-9) |
+ is_valid | property → bool | True se não há NaN/Inf/flags inválidas |
+ is_clean | property → bool | Mais rígido: também sem bound violations |
+ offending_rows() | → np.ndarray | Índices únicos das linhas problemáticas (união de todas as categorias) |
+ summary(max_cols=12) | → str | Resumo humano legível |
+
+
+
+
+
+
+
🧹 Fluxo típico com clean_dataset()
+
+from core.data_validation import (
+ clean_dataset, TRAINING_FEATURE_BOUNDS, TRAINING_TARGET_BOUNDS,
+)
+X_clean, y_clean, kept_mask, reports = clean_dataset(
+ X, y,
+ feature_bounds=TRAINING_FEATURE_BOUNDS,
+ target_bounds=TRAINING_TARGET_BOUNDS,
+ drop_bound_violations=False, # bounds são warning, não erro
+)
+print(f"Linhas mantidas: {kept_mask.sum()}/{len(kept_mask)}")
+print(reports['features'].summary())
+
+
A função garante que X_clean e y_clean permanecem alinhados após o descarte (mesma máscara aplicada a ambos). Os 4 relatórios retornados (2 antes + 2 depois) permitem diagnóstico detalhado.
+
+
+
+
📏 Limites conservadores pré-configurados
+
TRAINING_FEATURE_BOUNDS e TRAINING_TARGET_BOUNDS refletem a envoltória física:
+
+
+ | Variável | Limite inferior | Limite superior | Unidade |
+
+ vazao_feed | 1,0×10⁻⁵ | 5,0×10⁻³ | m³/s |
+ CA0_feed, CB0_feed | 0,0 | 5.000,0 | mol/m³ |
+ Tf_feed, T_amb_feed, T_obs | 200,0 | 600,0 | K |
+ valve_pos | 0,0 | 1,0 | — |
+ w_j | 0,0 | 1,0×10⁻² | m³/s |
+ X (target) | 0,0 | 1,0 | — |
+ cat_activity (target) | 0,0 | 1,01 | — (folga numérica) |
+ fouling_thickness (target) | 0,0 | 0,1 | m |
+
+
+
+
+
+
+
+
+ 🔐 Segurança
+ Serialização Segura com JSON + SHA-256
+ Módulo core.safe_serialization — elimina o vetor pickle.load, fonte histórica de execução arbitrária de código (CWE-502).
+
+
+
+
🚨
+
+
Por que abandonar pickle?
+
pickle.load() executa bytecode arbitrário durante a desserialização. Um atacante capaz de substituir scalerX.pkl consegue executar código Python com os privilégios do usuário. A OWASP e a CISA classificam isso como vulnerabilidade crítica (CWE-502: Deserialization of Untrusted Data). O CSTRChemIA serializa em JSON — texto puro, sem eval implícito — com SHA-256 para detectar adulteração.
+
+
+
+
+
+
📥 dump_scaler_json(scaler, path, feature_names=None)
+
Serializa um StandardScaler, RobustScaler, MinMaxScaler, PowerTransformer ou QuantileTransformer para JSON, calcula o SHA-256 e embute no próprio arquivo. Escrita atômica (tmp → replace).
+
+ - Formato:
{class, version, n_features_in, feature_names, params, sha256}
+ - Arrays numpy convertidos automaticamente via encoder custom
+ - SHA-256 computado com o campo
sha256 removido (determinismo)
+
+
+
+
📤 load_scaler_json(path, verify_hash=True)
+
Reconstrói o scaler, valida o hash se verify_hash=True (default) e popula n_features_in_ para compatibilidade com a API do scikit-learn.
+
+ - Levanta
ValueError em hash inconsistente
+ - Levanta
ValueError em classe não suportada
+ - Nenhum import de
pickle no caminho feliz
+
+
+
+
+
+
🔄 Migração gradual — load_scaler_any()
+
Para compatibilidade, o helper load_scaler_any(base_path, verify_hash=True) tenta primeiro base_path.json e, se ausente, cai para base_path.pkl emitindo um DeprecationWarning. Assim, scalers serializados em formato legado continuam funcionando — mas ao retreinar, o pipeline grava ambos os formatos.
+
+from core.safe_serialization import load_scaler_any
+# Procura "surrogate_model/scalerX.json"; se ausente, usa "scalerX.pkl"
+scaler_x = load_scaler_any("surrogate_model/scalerX", verify_hash=True)
+X_scaled = scaler_x.transform(X_raw)
+
+
+
+
+
🧾 Auditoria por hash
+
O helper file_sha256(path) computa o hash em chunks (baixa memória) e é usado tanto para os scalers quanto para os .keras. Com isso, a trilha de auditoria fica:
+
+# 1. Computa hash de um artefato qualquer
+from core.safe_serialization import file_sha256
+h = file_sha256("surrogate_model/CSTR_MLP_Surrogate.keras")
+# h = "3b5a7e...64 chars"
+# 2. Compara com o hash gravado no manifesto
+from core.model_manifest import read_manifest
+manifest = read_manifest("surrogate_model/")
+assert manifest["MLP"].keras_sha256 == h, "Modelo foi adulterado!"
+
+
+
+
+
+
+ 📜 Rastreabilidade
+ Manifesto Unificado models.json
+ Módulo core.model_manifest — uma única fonte de verdade para todas as arquiteturas treinadas, incluindo hashes, seed, métricas per-target e hiperparâmetros.
+
+
+
+ Para substituir o uso legado de training_results.csv (um único valor de MAE por linha), o CSTRChemIA utiliza um manifesto JSON estruturado que, para cada arquitetura, registra:
+
+
+
+
📋 Estrutura de ModelManifest (dataclass frozen)
+
+
+ | Categoria | Campo | Função |
+
+ | Identidade | architecture | "MLP", "RNN", "CNN" ou "XGBoost" |
+ trained_at | Timestamp ISO-8601 UTC |
+ seed | Semente aleatória (Python/NumPy/TF) |
+ framework | Ex.: "tensorflow==2.16.1" |
+ | Artefatos | keras_file + keras_sha256 | Arquivo .keras e seu hash SHA-256 |
+ scaler_x_file + scaler_x_sha256 | Scaler de entrada |
+ scaler_y_file + scaler_y_sha256 | Scaler de saída |
+ | Schema | n_features | Sempre 12 (ou 23 se XGBoost com engineering) |
+ n_targets | 8 |
+ feature_names | Tupla com os 12 nomes canônicos |
+ target_names | Tupla com os 8 nomes |
+ | Métricas | global_metrics | {mae, rmse, r2} agregado |
+ per_target_metrics | {target: {mae, rmse, r2}} para cada um dos 8 |
+ | Meta | n_train / n_val / n_test | Tamanhos dos splits |
+ hyperparameters | Dict com os HPs usados (lido de best_hps_*.json) |
+ notes | Texto livre (branch git, CI run, etc.) |
+ verify_integrity(models_dir) | Método → retorna list[str] de erros (vazia = OK) |
+
+
+
+
+
+
+
🔨 API pública do módulo
+
+
+ | Função | Propósito |
+
+ build_manifest(architecture, models_dir, keras_filename, scaler_x_filename, scaler_y_filename, seed, feature_names, target_names, global_metrics, per_target_metrics, ...) | Computa SHA-256 dos 3 artefatos e retorna um ModelManifest pronto para escrita. |
+ write_manifest(models_dir, manifests: dict[str, ModelManifest]) | Serializa o dicionário inteiro em models.json (versão "1", generated_at ISO-8601). |
+ read_manifest(models_dir) → dict[str, ModelManifest] | Desserializa, tolerante a ausência (retorna {}) e a JSON malformado (loga erro e retorna {}). |
+ update_manifest_entry(models_dir, manifest: ModelManifest) | Merge incremental: atualiza apenas a entrada da arquitetura sem sobrescrever as demais. |
+
+
+
+
+
+
+
🔍
+
+
Verificação de integridade via ModelRegistry
+
O services.model_registry.ModelRegistry expõe verify_integrity(arch=None) → dict[str, list[str]]. Um dict vazio por arquitetura significa OK; qualquer entrada com strings indica "hash inconsistente" (artefato modificado em disco) ou "arquivo ausente" — alerta imediato de que o modelo em produção não é o que você treinou.
+
+
+
+
+
+
+ 🎲 Confiabilidade
+ Quantificação de Incerteza Preditiva
+ Módulo core.uncertainty — três métodos complementares: MC Dropout (epistêmica), Deep Ensembles (diversidade) e Split Conformal Prediction (garantia finita).
+
+
+
+
ℹ️
+
+
Por que três métodos?
+
Cada método captura um aspecto diferente da incerteza: MC Dropout mede a sensibilidade do modelo a perturbações nos pesos (epistêmica); Deep Ensembles capturam a diversidade entre modelos treinados independentemente; Conformal Prediction fornece intervalos com cobertura empírica garantida de 1−α — sem assumir Gaussianidade. Use os três em conjunto para rastrear quando o surrogate "não sabe o que não sabe".
+
+
+
+
+
+
🎰 MC Dropout — mc_dropout_predict(...)
+
Gal & Ghahramani (ICML 2016) demonstraram que dropout ativo durante a inferência aproxima inferência variacional Bayesiana. Execute n_samples=30 forward passes com dropout ligado e agregue:
+
+mean, std = mc_dropout_predict(
+ pipeline, X,
+ n_samples=30,
+ batch_size=256,
+ needs_3d=False, # True p/ RNN/CNN
+)
+
+
Requer pelo menos uma camada Dropout na arquitetura (o MLP a tem; RNN/CNN também).
+
+
+
+
👥 Deep Ensembles — ensemble_predict(...)
+
Lakshminarayanan et al. (NeurIPS 2017): treinar M ≥ 3 modelos com seeds diferentes e agregar a média e o desvio padrão entre eles. Fornece incerteza aleatória + epistêmica sem necessidade de Bayesiano.
+
+# 3 modelos treinados com --seed 11/17/23
+predict_fns = [fn_mlp_s11, fn_mlp_s17, fn_mlp_s23]
+mean, std = ensemble_predict(predict_fns, X)
+
+
Exige no mínimo 2 membros; std calculado com ddof=1 (amostral).
+
+
+
+
+
📐 Split Conformal Prediction — SplitConformalCalibrator
+
+ Introduzido por Vovk (2005), é o único método listado que oferece cobertura marginalmente garantida em amostras finitas: se α=0.1, então ao menos 90% das predições futuras cairão dentro do intervalo, sob a única hipótese de que os dados de calibração são i.i.d. com os dados de teste.
+
+
+from core.uncertainty import SplitConformalCalibrator
+# 1. Calibra em um conjunto held-out (não usado no treino)
+cal = SplitConformalCalibrator(alpha=0.1).fit(
+ y_cal_true, y_cal_pred
+)
+# 2. Gera intervalos no conjunto de produção
+lo, hi = cal.interval(y_pred_new)
+# 3. (Opcional) verifica cobertura empírica
+cov = cal.coverage(y_test_true, y_test_pred)
+# cov deve ser ≥ 1 − alpha = 0.90 para cada target
+
+
+ O quantile usado é q = ⌈(n+1)(1−α)⌉ / n dos resíduos absolutos — calibração per-target. Os testes automatizados incluem verificação empírica: com 2000 pares iid e α=0,1, a cobertura observada fica em ≈ 0,90 ± 0,03.
+
+
+
+
+
🧰 Wrapper conveniência: predict_with_interval()
+
Combina ensemble + conformal em uma única chamada, devolvendo PredictiveIntervalReport:
+
+from core.uncertainty import predict_with_interval
+r = predict_with_interval(predict_fns, X, conformal=cal)
+r.mean # (n, 8) — predição pontual (média do ensemble)
+r.std # (n, 8) — std do ensemble (None se 1 membro)
+r.lo, r.hi # (n, 8) cada — intervalo conformal
+r.alpha # nível de miscoverage usado
+
+
+
+
+
+
+ 🧭 MCDM
+ Seleção da Solução: TOPSIS · VIKOR · PROMETHEE II · knee-point
+ Módulo optimization.decision_methods — quatro métodos MCDM + consenso para evitar que a escolha final fique refém de um ranqueador específico.
+
+
+
+
⚠️
+
+
O problema do rank reversal
+
Métodos como TOPSIS são conhecidos por mudar a classificação relativa das alternativas quando uma opção é adicionada ou removida — fenômeno documentado desde Belton & Gear (1983). Confiar em um único método torna a decisão frágil. A combinação via consensus_ranking mitiga o risco.
+
+
+
+
+
🧪 Interface comum
+
Todos os métodos aceitam a mesma assinatura básica, operando sobre a matriz de objetivos da frente de Pareto:
+
+F = pareto_objectives # shape (n, k)
+minimize = [False, True] # [max X, min fouling]
+weights = [0.5, 0.5] # normalizados internamente
+
+
+
+
+
📊 Os quatro métodos
+
+
+ | Método | Princípio | Assinatura | Retorno |
+
+
+ TOPSIS Hwang & Yoon 1981 |
+ Distância combinada ao ideal positivo e negativo |
+ topsis(F, minimize, weights=None) |
+ (scores ∈ [0,1], best_idx) |
+
+
+ VIKOR Opricovic 1998 |
+ Trade-off entre utilidade média (S) e arrependimento máximo (R) |
+ vikor(F, minimize, weights=None, v=0.5) |
+ (scores=−Q, best_idx) |
+
+
+ PROMETHEE II Brans & Vincke 1985 |
+ Fluxo líquido de preferências pareadas |
+ promethee_ii(F, minimize, weights=None, preference="linear") |
+ (phi_net ∈ [−1,1], best_idx) |
+
+
+ knee-point L1-ASF |
+ Ponto do "joelho" — máximo trade-off geométrico |
+ knee_point(F, minimize) |
+ best_idx |
+
+
+
+
+
+
+
+
🗳️ consensus_ranking() — Voto de múltiplos métodos
+
Reúne os top-k de cada método e retorna o índice mais votado. Desempate pela soma normalizada dos scores.
+
+from optimization.decision_methods import (
+ topsis, vikor, promethee_ii, consensus_ranking,
+)
+s1, _ = topsis(F, minimize, weights)
+s2, _ = vikor(F, minimize, weights)
+s3, _ = promethee_ii(F, minimize, weights)
+r = consensus_ranking([s1, s2, s3], top_k=3)
+r.consensus_idx # índice final escolhido
+r.top_k_counts # {idx: n_votos}
+
+
+
+
+
💡
+
+
Estratégia recomendada
+
Rode os 3 métodos com pesos iguais e use o consensus_ranking. Se os três concordarem, a escolha é robusta. Se divergirem, inspecione a região do Pareto visualmente — isso é sinal de que diferentes trade-offs são razoáveis e a decisão final deveria passar pelo engenheiro de processo.
+
+
+
+
+
+
+ 🌲 Baseline
+ Baseline XGBoost com Features Engenheiradas
+ Módulo training.train_xgboost_baseline + core.feature_engineering — comparação honesta: se o MLP não supera uma árvore em features derivadas, há algo errado com a arquitetura neural.
+
+
+
+
🧮 Features engenheiradas: 12 → 23 colunas
+
O módulo core.feature_engineering.engineer_features() adiciona 11 derivações fisicamente motivadas às 12 features originais:
+
+
+ | # | Feature derivada | Fórmula | Significado físico |
+
+ | 1 | dT_obs_feed | T_obs − Tf_feed | Força motriz do resfriamento/aquecimento |
+ | 2 | dT_feed_amb | Tf_feed − T_amb_feed | Proxy de perdas térmicas externas |
+ | 3 | dT_obs_amb | T_obs − T_amb_feed | Delta T total para ambiente |
+ | 4 | CA_over_CB | CA0_feed / CB0_feed | Razão estequiométrica de alimentação (div. segura) |
+ | 5 | C_total_feed | CA0_feed + CB0_feed | Concentração total alimentada |
+ | 6 | valve_flow | valve_pos · vazao_feed | Vazão efetiva após válvula |
+ | 7 | jacket_ratio | w_j / vazao_feed | Capacidade relativa da jaqueta (div. segura) |
+ | 8 | failure_any | max(4 flags) | Qualquer falha ativa (0/1) |
+ | 9 | failure_count | sum(4 flags) | Contagem de falhas simultâneas |
+ | 10 | log_vazao | log₁₀(vazao_feed) com clip ε | Escala multiplicativa da vazão |
+ | 11 | log_wj | log₁₀(w_j) com clip ε | Escala multiplicativa da jaqueta |
+
+
+
+
Todas as operações usam helpers _safe_div (retorna 0 quando |b| < ε=1e-12) e _safe_log10 (clip em ε antes do log) para evitar NaN/Inf.
+
+
+
+
🌳 O script train_xgboost_baseline.py
+
Pipeline completo: carrega dados, valida com clean_dataset, engenheira features, treina um XGBRegressor por target via sklearn.multioutput.MultiOutputRegressor e grava manifesto próprio (baseline_manifest.json) com SHA-256 dos modelos.
+
+# Execução básica — defaults sensatos
+python -m training.train_xgboost_baseline
+# Customizado
+python -m training.train_xgboost_baseline \
+ --n-estimators 500 \
+ --max-depth 8 \
+ --learning-rate 0.05 \
+ --seed 42
+# Sem engenharia de features (apenas 12 features canônicas)
+python -m training.train_xgboost_baseline --no-feature-engineering
+
+
Os modelos e o manifesto são salvos em surrogate_model/baselines/xgboost/, sem interferir com os pesos .keras principais.
+
+
+
+
🔬
+
+
Quando o baseline importa?
+
Se o XGBoost em 23 features engenheiradas bate o MLP em MAE, há duas hipóteses: (1) a arquitetura/hiperparâmetros do MLP estão subótimos (re-rode o tuning); (2) o problema é simples o bastante para que árvores lineares-por-partes o resolvam. Em ambos os casos, o baseline te protege de over-engineering desnecessário.
+
+
+
+
+
+
+ ✅ Qualidade
+ 46 Testes Automatizados
+ Pasta tests/ — cobertura pytest sobre toda a infraestrutura de módulos puros. Executa em ~8 s em CPU padrão.
+
+
+
+
📂 Arquivos de teste e o que cada um verifica
+
+
+ | Arquivo | Nº | O que cobre |
+
+ test_safe_serialization.py | 8 | Roundtrip JSON, detecção de hash inconsistente, load_scaler_any com precedência, compatibilidade com múltiplas classes de scaler. |
+ test_feature_engineering.py | 7 | Correção dos 11 valores derivados, robustez de safe_div e safe_log10, filtro include=, validação de shape. |
+ test_data_validation.py | 9 | Detecção de NaN/Inf, flags não-binárias, violações de bounds, preservação de alinhamento X↔y após clean_dataset. |
+ test_model_manifest.py | 6 | Build + write + read roundtrip, merge incremental via update_manifest_entry, detecção de corrupção via verify_integrity. |
+ test_uncertainty.py | 8 | Cobertura empírica do conformal ≥ 1−α, mean/std do ensemble, validação de shape, guarda contra ensemble de 1 membro. |
+ test_decision_methods.py | 8 | Sensibilidade do TOPSIS a pesos, knee-point interior, contagem de votos em consensus_ranking, rejeição de entradas inválidas. |
+ | Total | 46 | 100% passando em pytest tests/ |
+
+
+
+
+
+
+
▶️ Como executar
+
+# Suíte completa (sem TF — só módulos puros)
+cd CSTRChemIA
+python -m pytest tests/ -v
+# Apenas um arquivo
+python -m pytest tests/test_uncertainty.py -v
+# Com resumo de falhas curto
+python -m pytest tests/ --tb=short -q
+
+
A suíte não requer TensorFlow — todos os testes usam numpy/sklearn, tornando a CI rápida e leve. Os testes de core.uncertainty usam pipelines sintéticos (lambda X: ...) no lugar de modelos Keras reais.
+
+
+
+
🧪
+
+
Teste de garantia estatística: conformal coverage
+
O teste test_conformal_empirical_coverage_matches_alpha é especialmente relevante: gera 2000 pares (y_true, y_pred) com resíduos Normal(0,1), calibra com α=0,1 e verifica que a cobertura observada fica ≥ 0,87 (margem de 0,03 para amostragem finita). Isso valida empiricamente a garantia teórica de Vovk.
+
+
+
+
+
+
+ 💻 Referência
+ CLI & API Interna
+ Todos os comandos e classes públicas do CSTRChemIA em um lugar só.
+
+
+
+
🖥️ Comandos de linha de comando
+
+
+ | Comando | Propósito |
+
+ python main.py | Inicia a aplicação Dash (porta 8050) |
+ python -m training.train_surrogate --model all | Treina as 3 redes neurais (MLP, RNN, CNN) e escreve models.json |
+ python -m training.train_surrogate --model MLP --seed 17 | Treina apenas MLP com seed customizada (para membro de ensemble) |
+ python -m training.train_xgboost_baseline | Treina o baseline XGBoost com feature engineering |
+ python -m training.sync_hyperparameters | Copia os best_hps_*.json de Tuning_NeuralNetwork/output/ para training/ |
+ python -m pytest tests/ -v | Executa os 46 testes automatizados |
+
+
+
+
+
+
+
🧬 Imports principais de core/
+
+# ── Serialização segura
+from core.safe_serialization import (
+ dump_scaler_json, load_scaler_json, load_scaler_any, file_sha256,
+)
+# ── Validação de dados
+from core.data_validation import (
+ ValidationReport, validate_features, validate_targets, clean_dataset,
+ TRAINING_FEATURE_BOUNDS, TRAINING_TARGET_BOUNDS, FLAG_COLUMNS,
+)
+# ── Manifesto de modelos
+from core.model_manifest import (
+ ModelManifest, TargetMetrics,
+ build_manifest, read_manifest, write_manifest, update_manifest_entry,
+ MANIFEST_FILENAME, # = "models.json"
+)
+# ── Incerteza preditiva
+from core.uncertainty import (
+ SplitConformalCalibrator, PredictiveIntervalReport,
+ mc_dropout_predict, ensemble_predict, predict_with_interval,
+)
+# ── Feature engineering (para baselines)
+from core.feature_engineering import (
+ engineer_features, describe_derivations,
+)
+
+
+
+
+
🎯 Imports de optimization/
+
+# ── Otimização V2 (restrições explícitas)
+from optimization.problem_v2 import (
+ OptimizationConfigV2, CSTRProblemV2, run_v2,
+)
+# ── Métodos de decisão multi-critério
+from optimization.decision_methods import (
+ topsis, vikor, promethee_ii, knee_point,
+ consensus_ranking, ConsensusResult,
+)
+
+
+
+
+
🛎️ Camada de serviço (para integradores)
+
+# ── Service layer isolada da interface Dash
+from services.model_registry import ModelRegistry
+from services.simulation_service import SimulationService
+from services.optimization_service import OptimizationService
+from services.training_service import TrainingService
+# Exemplo: enumerar arquiteturas disponíveis com métricas
+reg = ModelRegistry()
+for info in reg.list_models():
+ print(info.architecture, info.mae, info.keras_sha256)
+# Verificação de integridade (batch)
+errors = reg.verify_integrity()
+if any(errors.values()):
+ print("⚠️ Modelos corrompidos:", errors)
+
+
+
+
+
📘
+
+
Tipagem estrita com core.types
+
O código-base usa from __future__ import annotations e @dataclass(slots=True) em todos os modelos de dados. O módulo core.types centraliza aliases tipados (ModelInfo, TrainingResult, etc.) que são reconhecidos por mypy --strict. Isso captura erros em tempo de edição, não de produção.
+
+
+
+
+
📚 Literatura
Referências Científicas
@@ -1231,11 +1874,49 @@ Referências Científicas
🎯 Otimização Multi-objetivo
- - DEB, K. et al. A Fast and Elitist Multiobjective Genetic Algorithm: NSGA-II. IEEE Trans. Evol. Comput., 6(2), 2002. — Algoritmo central do módulo de otimização.
- - BLANK, J.; DEB, K. pymoo: Multi-objective Optimization in Python. IEEE Access, 2020. — Biblioteca pymoo 0.6+ utilizada.
+ - DEB, K.; PRATAP, A.; AGARWAL, S.; MEYARIVAN, T. A Fast and Elitist Multiobjective Genetic Algorithm: NSGA-II. IEEE Trans. Evol. Comput., 6(2):182–197, 2002. — Algoritmo central do módulo de otimização.
+ - DEB, K.; JAIN, H. An Evolutionary Many-Objective Optimization Algorithm Using Reference-Point-Based Non-dominated Sorting Approach, Part I: Solving Problems With Box Constraints. IEEE Trans. Evol. Comput., 18(4):577–601, 2014. — Fundamentos do NSGA-III, utilizado como alternativa ao NSGA-II em problemas many-objective.
+ - BLANK, J.; DEB, K. pymoo: Multi-objective Optimization in Python. IEEE Access, 8:89497–89509, 2020. — Biblioteca
pymoo 0.6+ utilizada.
- PEREIRA, J. L. J.; GONTIJO, R. G. Avaliação de algoritmos de aprendizado de máquina na predição de estados estacionários em CSTR não isotérmicos. COBEQ, 2024.
+
+
+
🧭 Decisão Multi-Critério (MCDM)
+
+ - HWANG, C. L.; YOON, K. Multiple Attribute Decision Making: Methods and Applications. Springer, 1981. — Fundamentação do TOPSIS.
+ - OPRICOVIC, S. Multicriteria Optimization of Civil Engineering Systems. Faculty of Civil Engineering, Belgrade, 1998. — Introdução do VIKOR.
+ - OPRICOVIC, S.; TZENG, G. H. Compromise solution by MCDM methods: A comparative analysis of VIKOR and TOPSIS. European J. Operational Research, 156(2):445–455, 2004.
+ - BRANS, J. P.; VINCKE, P. A preference ranking organisation method: The PROMETHEE method. Management Science, 31(6):647–656, 1985.
+ - BELTON, V.; GEAR, T. On a short-coming of Saaty's method of analytic hierarchies. Omega, 11(3):228–230, 1983. — Documentação do fenômeno de rank reversal.
+
+
+
+
+
🎲 Incerteza Preditiva & Conformal Prediction
+
+ - VOVK, V.; GAMMERMAN, A.; SHAFER, G. Algorithmic Learning in a Random World. Springer, 2005. — Fundação teórica da conformal prediction com garantia de cobertura finita.
+ - ANGELOPOULOS, A. N.; BATES, S. A Gentle Introduction to Conformal Prediction and Distribution-Free Uncertainty Quantification. arXiv:2107.07511, 2021. — Referência moderna recomendada.
+ - GAL, Y.; GHAHRAMANI, Z. Dropout as a Bayesian Approximation: Representing Model Uncertainty in Deep Learning. ICML, 2016. — Fundação do MC Dropout.
+ - LAKSHMINARAYANAN, B.; PRITZEL, A.; BLUNDELL, C. Simple and Scalable Predictive Uncertainty Estimation using Deep Ensembles. NeurIPS, 2017.
+
+
+
+
+
🌲 XGBoost & Baselines Tabulares
+
+ - CHEN, T.; GUESTRIN, C. XGBoost: A Scalable Tree Boosting System. KDD '16, 785–794, 2016. — Algoritmo do baseline.
+ - GRINSZTAJN, L.; OYALLON, E.; VAROQUAUX, G. Why do tree-based models still outperform deep learning on typical tabular data? NeurIPS, 2022. — Justifica o uso de baseline tabular.
+
+
+
+
+
🔐 Segurança de Serialização
+
+ - MITRE CWE-502. Deserialization of Untrusted Data. — Classificação formal do risco do
pickle.
+ - Python Security Documentation. "The
pickle module is not secure. Only unpickle data you trust." — Aviso oficial na documentação Python.
+
+
@@ -1258,7 +1939,6 @@ Sobre o Autor e o Projeto
🏛️ UNESP – Campus Araraquara
🔬 Depto. de Engenharia Química
🎓 PIBIC/CNPq
- 🧪 FAPESP – Suporte laboratorial
@@ -1269,7 +1949,15 @@ Sobre o Autor e o Projeto
📌
Citação
-
Ao utilizar ou citar esta ferramenta em pesquisas ou projetos de engenharia, favor referenciar: LOPES, R. A. P. CSTR Simulator & Optimizer — Surrogate modeling e otimização multi-objetivo de reatores CSTR. PIBIC/CNPq, UNESP, Araraquara, 2025.
+
Ao utilizar ou citar esta ferramenta em pesquisas ou projetos de engenharia, favor referenciar: LOPES, R. A. P. CSTRChemIA — Surrogate modeling, quantificação de incerteza (conformal prediction) e otimização multi-objetivo (NSGA-II/NSGA-III com restrições nativas) de reatores CSTR. PIBIC/CNPq, UNESP, Araraquara, 2025.
+
+
+
+
+
🧬
+
+
Filosofia do projeto
+
O CSTRChemIA é concebido com foco em confiabilidade industrial: módulos segmentados, 46 testes automatizados, ausência de vulnerabilidades de serialização (sem pickle no caminho feliz), trilha de auditoria completa (SHA-256 + seeds + manifesto) e métodos estatísticos rigorosos (conformal prediction, MCDM com consenso). O software combina protótipo de pesquisa com rastreabilidade compatível com good manufacturing practices.
@@ -1280,8 +1968,8 @@ Sobre o Autor e o Projeto
diff --git a/CSTRChemIA/core/__init__.py b/CSTRChemIA/core/__init__.py
new file mode 100644
index 0000000..d2b03b1
--- /dev/null
+++ b/CSTRChemIA/core/__init__.py
@@ -0,0 +1,44 @@
+"""
+core — infraestrutura central do CSTRChemIA.
+
+Este pacote centraliza código transversal usado por todas as camadas:
+logging seguro no Windows, configurações, constantes de domínio,
+exceções customizadas, tipos e utilidades genéricas.
+"""
+
+from core.constants import (
+ DEFAULT_FEATURE_BOUNDS,
+ DEFAULT_FEATURES,
+ DEFAULT_TARGETS,
+ N_FEATURES,
+ N_TARGETS,
+)
+from core.exceptions import (
+ ConfigurationError,
+ CSTRChemIAError,
+ ModelNotFoundError,
+ OptimizationError,
+ SimulationError,
+ TrainingError,
+)
+from core.logging import get_logger, safe_print, setup_logging
+from core.settings import Settings, get_settings
+
+__all__ = [
+ "DEFAULT_FEATURES",
+ "DEFAULT_FEATURE_BOUNDS",
+ "DEFAULT_TARGETS",
+ "N_FEATURES",
+ "N_TARGETS",
+ "CSTRChemIAError",
+ "ConfigurationError",
+ "ModelNotFoundError",
+ "OptimizationError",
+ "Settings",
+ "SimulationError",
+ "TrainingError",
+ "get_logger",
+ "get_settings",
+ "safe_print",
+ "setup_logging",
+]
diff --git a/CSTRChemIA/core/constants.py b/CSTRChemIA/core/constants.py
new file mode 100644
index 0000000..29dd076
--- /dev/null
+++ b/CSTRChemIA/core/constants.py
@@ -0,0 +1,116 @@
+"""
+Constantes de domínio do CSTRChemIA.
+
+12 features e 8 targets do modelo surrogate, com descrições e unidades
+usadas pela UI e pelos serviços. Fonte autoritativa — qualquer código
+que referencie "vazao_feed", "CA0", etc. deve importar daqui.
+"""
+
+from __future__ import annotations
+
+from typing import Final
+
+# ---------------------------------------------------------------------------
+# FEATURES (12) — entradas do modelo surrogate
+# ---------------------------------------------------------------------------
+DEFAULT_FEATURES: Final[list[str]] = [
+ "vazao_feed", # Vazão de alimentação [m3/s]
+ "CA0_feed", # Concentração de A no feed [mol/m3]
+ "CB0_feed", # Concentração de B no feed [mol/m3]
+ "Tf_feed", # Temperatura do feed [K]
+ "T_amb_feed", # Temperatura ambiente [K]
+ "T_obs", # Temperatura observada [K]
+ "valve_pos", # Posição da válvula [-]
+ "w_j", # Vazão da jaqueta [kg/s]
+ "flag_falha_valvula", # Flag de falha da válvula [0/1]
+ "flag_manutencao", # Flag de manutenção [0/1]
+ "flag_falha_sensor_T", # Flag de falha no sensor T [0/1]
+ "flag_falha_comunicacao", # Flag de falha de comunicação [0/1]
+]
+
+# ---------------------------------------------------------------------------
+# TARGETS (8) — saídas do modelo surrogate
+# ---------------------------------------------------------------------------
+DEFAULT_TARGETS: Final[list[str]] = [
+ "CA0", # Concentração de A [mol/m3]
+ "CB0", # Concentração de B [mol/m3]
+ "CP0", # Concentração de P [mol/m3]
+ "CS0", # Concentração de S [mol/m3]
+ "T0", # Temperatura interna [K]
+ "X", # Conversão [-]
+ "fouling_thickness", # Espessura do fouling [m]
+ "cat_activity", # Atividade do catalisador [-]
+]
+
+N_FEATURES: Final[int] = len(DEFAULT_FEATURES)
+N_TARGETS: Final[int] = len(DEFAULT_TARGETS)
+
+# ---------------------------------------------------------------------------
+# Descrições humanas (label, unidade) — usadas pela UI
+# ---------------------------------------------------------------------------
+TARGET_DESCRIPTIONS: Final[dict[str, tuple[str, str]]] = {
+ "CA0": ("Concentração de A", "mol/m3"),
+ "CB0": ("Concentração de B", "mol/m3"),
+ "CP0": ("Concentração de P", "mol/m3"),
+ "CS0": ("Concentração de S", "mol/m3"),
+ "T0": ("Temperatura interna", "K"),
+ "X": ("Conversão", "--"),
+ "fouling_thickness":("Espessura fouling", "m"),
+ "cat_activity": ("Atividade catalisador", "--"),
+}
+
+FEATURE_DESCRIPTIONS: Final[dict[str, tuple[str, str]]] = {
+ "vazao_feed": ("Vazão de alimentação", "m3/s"),
+ "CA0_feed": ("Concentração A no feed", "mol/m3"),
+ "CB0_feed": ("Concentração B no feed", "mol/m3"),
+ "Tf_feed": ("Temperatura do feed", "K"),
+ "T_amb_feed": ("Temperatura ambiente", "K"),
+ "T_obs": ("Temperatura observada", "K"),
+ "valve_pos": ("Posição da válvula", "-"),
+ "w_j": ("Vazão da jaqueta", "kg/s"),
+ "flag_falha_valvula": ("Flag falha válvula", "0/1"),
+ "flag_manutencao": ("Flag manutenção", "0/1"),
+ "flag_falha_sensor_T": ("Flag falha sensor T", "0/1"),
+ "flag_falha_comunicacao": ("Flag falha comunicação", "0/1"),
+}
+
+# ---------------------------------------------------------------------------
+# Arquiteturas de modelo surrogate suportadas
+# ---------------------------------------------------------------------------
+SUPPORTED_ARCHITECTURES: Final[tuple[str, ...]] = ("MLP", "RNN", "CNN")
+
+# Mapeamento arquitetura -> arquivo .keras
+MODEL_FILENAMES: Final[dict[str, str]] = {
+ "MLP": "CSTR_MLP_Surrogate.keras",
+ "RNN": "CSTR_RNN_Surrogate.keras",
+ "CNN": "CSTR_CNN_Surrogate.keras",
+}
+
+SCALER_X_FILENAME: Final[str] = "scalerX.pkl"
+SCALER_Y_FILENAME: Final[str] = "scalerY.pkl"
+
+# ---------------------------------------------------------------------------
+# Bounds conservadores para otimização — usados quando usuário não customiza
+# ---------------------------------------------------------------------------
+DEFAULT_FEATURE_BOUNDS: Final[dict[str, tuple[float, float]]] = {
+ "vazao_feed": (1.0e-5, 1.0e-2),
+ "CA0_feed": (100.0, 2000.0),
+ "CB0_feed": (100.0, 2000.0),
+ "Tf_feed": (280.0, 360.0),
+ "T_amb_feed": (280.0, 320.0),
+ "T_obs": (280.0, 400.0),
+ "valve_pos": (0.0, 1.0),
+ "w_j": (0.01, 10.0),
+ "flag_falha_valvula": (0.0, 0.0),
+ "flag_manutencao": (0.0, 0.0),
+ "flag_falha_sensor_T": (0.0, 0.0),
+ "flag_falha_comunicacao": (0.0, 0.0),
+}
+
+# ---------------------------------------------------------------------------
+# Paleta de cores Plotly qualitative — usada em gráficos
+# ---------------------------------------------------------------------------
+PLOT_COLORS: Final[list[str]] = [
+ "#636EFA", "#EF553B", "#00CC96", "#AB63FA", "#FFA15A",
+ "#19D3F3", "#FF6692", "#B6E880", "#FF97FF", "#FECB52",
+]
diff --git a/CSTRChemIA/core/data_validation.py b/CSTRChemIA/core/data_validation.py
new file mode 100644
index 0000000..f3f4c61
--- /dev/null
+++ b/CSTRChemIA/core/data_validation.py
@@ -0,0 +1,449 @@
+"""
+Validação contratual de datasets (features e targets).
+
+Motivação
+---------
+``np.isfinite(arr).all()`` diz apenas *se* há NaN/Inf, não *onde* nem
+*quantos*. Nos CSVs de treino do CSTRChemIA é comum ter linhas
+corrompidas por falhas de simulação (p.ex. 1 linha em 50k com NaN em
+``T_obs``). A prática atual no ``train_surrogate.py`` é imprimir um
+aviso e seguir — o que contamina a rede silenciosamente.
+
+Este módulo entrega um relatório estruturado (:class:`ValidationReport`)
+com contagens e **índices** das linhas ofensoras, e uma função
+:func:`clean_dataset` que remove linhas inválidas preservando
+alinhamento X↔y.
+
+Exemplo
+-------
+::
+
+ from core.data_validation import validate_features, clean_dataset
+
+ report = validate_features(X, bounds=TRAINING_BOUNDS)
+ print(report.summary())
+ if not report.is_valid:
+ X_clean, y_clean, mask, _ = clean_dataset(X, y, bounds=TRAINING_BOUNDS)
+ print(f"Mantidas {mask.sum()}/{len(mask)} linhas")
+
+Design
+------
+* **Sem dependência de pandas** — opera em ``np.ndarray``. Um helper
+ ``validate_dataframe`` aceita ``pd.DataFrame`` quando disponível.
+* **Sem `raise` por violação de bounds** — bounds são *soft warnings*.
+ Violações de shape/NaN/Inf/flags **fora de {0,1}** são *hard errors*
+ opcionalmente levantados com ``strict=True``.
+* **Row-level**: retorna quais linhas violam cada regra, não só contagens.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from dataclasses import dataclass, field
+from typing import Any
+
+import numpy as np
+
+from core.constants import (
+ DEFAULT_FEATURES,
+ DEFAULT_TARGETS,
+ N_FEATURES,
+ N_TARGETS,
+)
+from core.exceptions import DataValidationError
+
+# ══════════════════════════════════════════════════════════════════════════════
+# CONSTANTES
+# ══════════════════════════════════════════════════════════════════════════════
+#: Nomes das colunas binárias (flag_*) — devem conter apenas {0, 1}.
+FLAG_COLUMNS: tuple[str, ...] = tuple(
+ f for f in DEFAULT_FEATURES if f.startswith("flag_")
+)
+
+#: Bounds conservadores recomendados para VALIDAÇÃO de dados de treino
+#: (não confundir com ``DEFAULT_FEATURE_BOUNDS`` em ``core.constants``,
+#: que são bounds de OTIMIZAÇÃO — por isso deixam flags em {0,0}).
+TRAINING_FEATURE_BOUNDS: dict[str, tuple[float, float]] = {
+ "vazao_feed": (0.0, 1.0), # m3/s (positivo)
+ "CA0_feed": (0.0, 1.0e4), # mol/m3
+ "CB0_feed": (0.0, 1.0e4),
+ "Tf_feed": (200.0, 500.0), # K
+ "T_amb_feed": (200.0, 400.0),
+ "T_obs": (200.0, 600.0),
+ "valve_pos": (0.0, 1.0),
+ "w_j": (0.0, 100.0), # kg/s
+ "flag_falha_valvula": (0.0, 1.0),
+ "flag_manutencao": (0.0, 1.0),
+ "flag_falha_sensor_T": (0.0, 1.0),
+ "flag_falha_comunicacao": (0.0, 1.0),
+}
+
+TRAINING_TARGET_BOUNDS: dict[str, tuple[float, float]] = {
+ "CA0": (0.0, 1.0e4),
+ "CB0": (0.0, 1.0e4),
+ "CP0": (0.0, 1.0e4),
+ "CS0": (0.0, 1.0e4),
+ "T0": (200.0, 600.0),
+ "X": (-0.1, 1.1), # tolera ruído numérico em [0,1]
+ "fouling_thickness": (0.0, 1.0), # metros
+ "cat_activity": (-0.1, 1.1),
+}
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# RELATÓRIO
+# ══════════════════════════════════════════════════════════════════════════════
+@dataclass(frozen=True, slots=True)
+class ValidationReport:
+ """
+ Resultado estruturado de :func:`validate_features` / :func:`validate_targets`.
+
+ Atributos
+ ---------
+ n_rows
+ Número total de linhas analisadas.
+ n_cols
+ Número de colunas (features ou targets).
+ column_names
+ Nomes das colunas na ordem original.
+ nan_rows
+ Índices das linhas com qualquer NaN.
+ inf_rows
+ Índices das linhas com qualquer Inf (±∞).
+ bound_violations
+ ``{coluna: índices de linhas fora dos bounds}``.
+ flag_violations
+ ``{coluna: índices de linhas com valor ∉ {0, 1}}`` (apenas flags).
+ """
+
+ n_rows: int
+ n_cols: int
+ column_names: tuple[str, ...]
+ nan_rows: np.ndarray = field(default_factory=lambda: np.array([], dtype=np.int64))
+ inf_rows: np.ndarray = field(default_factory=lambda: np.array([], dtype=np.int64))
+ bound_violations: dict[str, np.ndarray] = field(default_factory=dict)
+ flag_violations: dict[str, np.ndarray] = field(default_factory=dict)
+
+ # ------------------------------------------------------------------ props
+ @property
+ def n_nan(self) -> int:
+ return int(len(self.nan_rows))
+
+ @property
+ def n_inf(self) -> int:
+ return int(len(self.inf_rows))
+
+ @property
+ def n_bound_violations(self) -> int:
+ return sum(int(len(v)) for v in self.bound_violations.values())
+
+ @property
+ def n_flag_violations(self) -> int:
+ return sum(int(len(v)) for v in self.flag_violations.values())
+
+ @property
+ def is_valid(self) -> bool:
+ """True quando não há NaN/Inf e flags estão em {0,1}.
+
+ Bounds são *soft warnings* e não reprovam o dataset por si só.
+ """
+ return self.n_nan == 0 and self.n_inf == 0 and self.n_flag_violations == 0
+
+ @property
+ def is_clean(self) -> bool:
+ """Mais estrito que :attr:`is_valid` — também exige zero bound violations."""
+ return self.is_valid and self.n_bound_violations == 0
+
+ # ----------------------------------------------------------------- utils
+ def offending_rows(self) -> np.ndarray:
+ """Índices únicos de linhas com *qualquer* violação (para drop)."""
+ parts: list[np.ndarray] = [self.nan_rows, self.inf_rows]
+ parts.extend(self.bound_violations.values())
+ parts.extend(self.flag_violations.values())
+ if not parts:
+ return np.array([], dtype=np.int64)
+ return np.unique(np.concatenate(parts)).astype(np.int64)
+
+ def summary(self, *, max_cols: int = 12) -> str:
+ """String legível para logs/UI."""
+ lines = [
+ f"ValidationReport: {self.n_rows} linhas × {self.n_cols} colunas",
+ f" NaN: {self.n_nan} linhas",
+ f" Inf: {self.n_inf} linhas",
+ ]
+ if self.flag_violations:
+ lines.append(" Flags fora de {0,1}:")
+ for name, idxs in list(self.flag_violations.items())[:max_cols]:
+ lines.append(f" - {name}: {len(idxs)} linhas")
+ if self.bound_violations:
+ lines.append(" Violações de bounds:")
+ for name, idxs in list(self.bound_violations.items())[:max_cols]:
+ lines.append(f" - {name}: {len(idxs)} linhas")
+ verdict = "OK" if self.is_valid else "FALHA"
+ lines.append(f" Veredito (strict flags+finitude): {verdict}")
+ return "\n".join(lines)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# VALIDAÇÃO
+# ══════════════════════════════════════════════════════════════════════════════
+def _check_shape(
+ arr: np.ndarray,
+ *,
+ expected_cols: int,
+ label: str,
+) -> np.ndarray:
+ """Normaliza para 2D e checa shape; levanta DataValidationError em mismatch."""
+ arr = np.asarray(arr, dtype=np.float64)
+ if arr.ndim == 1:
+ arr = arr.reshape(1, -1)
+ if arr.ndim != 2 or arr.shape[1] != expected_cols:
+ raise DataValidationError(
+ f"{label}: esperado shape (n, {expected_cols}), recebido {arr.shape}"
+ )
+ return arr
+
+
+def _find_nonfinite(arr: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
+ """Retorna (nan_rows, inf_rows) — índices únicos de linhas com NaN e Inf."""
+ nan_mask_row = np.any(np.isnan(arr), axis=1)
+ inf_mask_row = np.any(np.isinf(arr), axis=1)
+ return (
+ np.where(nan_mask_row)[0].astype(np.int64),
+ np.where(inf_mask_row)[0].astype(np.int64),
+ )
+
+
+def _check_bounds(
+ arr: np.ndarray,
+ column_names: Sequence[str],
+ bounds: dict[str, tuple[float, float]],
+) -> dict[str, np.ndarray]:
+ """Para cada coluna em `bounds`, retorna índices de linhas fora de [lo, hi]."""
+ violations: dict[str, np.ndarray] = {}
+ for i, name in enumerate(column_names):
+ if name not in bounds:
+ continue
+ lo, hi = bounds[name]
+ # Ignora NaN/Inf aqui — essas linhas já são contadas em nan/inf.
+ col = arr[:, i]
+ finite = np.isfinite(col)
+ bad = finite & ((col < lo) | (col > hi))
+ if bad.any():
+ violations[name] = np.where(bad)[0].astype(np.int64)
+ return violations
+
+
+def _check_flags(
+ arr: np.ndarray,
+ column_names: Sequence[str],
+ flag_columns: Sequence[str],
+) -> dict[str, np.ndarray]:
+ """Flags devem ser exatamente 0 ou 1 (tolerância ±1e-9 para floats arredondados)."""
+ violations: dict[str, np.ndarray] = {}
+ name_to_idx = {n: i for i, n in enumerate(column_names)}
+ for name in flag_columns:
+ if name not in name_to_idx:
+ continue
+ col = arr[:, name_to_idx[name]]
+ finite = np.isfinite(col)
+ is_zero = np.abs(col) < 1e-9
+ is_one = np.abs(col - 1.0) < 1e-9
+ bad = finite & ~(is_zero | is_one)
+ if bad.any():
+ violations[name] = np.where(bad)[0].astype(np.int64)
+ return violations
+
+
+# ------------------------------------------------------------------ pública
+def validate_features(
+ X: np.ndarray,
+ *,
+ feature_names: Sequence[str] = DEFAULT_FEATURES,
+ bounds: dict[str, tuple[float, float]] | None = None,
+ flag_columns: Sequence[str] = FLAG_COLUMNS,
+ strict: bool = False,
+) -> ValidationReport:
+ """
+ Valida uma matriz de features.
+
+ Parameters
+ ----------
+ X
+ Shape ``(n, n_features)``.
+ feature_names
+ Nomes das colunas na ordem. Default: ``DEFAULT_FEATURES``.
+ bounds
+ Se fornecido, checa ``lo ≤ x ≤ hi`` por coluna. Violação é
+ *soft* (reportada mas não faz ``is_valid=False``).
+ flag_columns
+ Colunas binárias que devem conter apenas {0, 1}.
+ strict
+ Se ``True``, levanta :class:`DataValidationError` em vez de só
+ reportar quando há NaN/Inf ou flags inválidas.
+ """
+ X = _check_shape(X, expected_cols=len(feature_names), label="validate_features")
+
+ nan_rows, inf_rows = _find_nonfinite(X)
+ bound_viol = _check_bounds(X, feature_names, bounds) if bounds else {}
+ flag_viol = _check_flags(X, feature_names, flag_columns)
+
+ report = ValidationReport(
+ n_rows=X.shape[0],
+ n_cols=X.shape[1],
+ column_names=tuple(feature_names),
+ nan_rows=nan_rows,
+ inf_rows=inf_rows,
+ bound_violations=bound_viol,
+ flag_violations=flag_viol,
+ )
+
+ if strict and not report.is_valid:
+ raise DataValidationError(
+ f"Dados inválidos:\n{report.summary()}"
+ )
+ return report
+
+
+def validate_targets(
+ y: np.ndarray,
+ *,
+ target_names: Sequence[str] = DEFAULT_TARGETS,
+ bounds: dict[str, tuple[float, float]] | None = None,
+ strict: bool = False,
+) -> ValidationReport:
+ """Análogo de :func:`validate_features` para targets (sem flags)."""
+ y = _check_shape(y, expected_cols=len(target_names), label="validate_targets")
+
+ nan_rows, inf_rows = _find_nonfinite(y)
+ bound_viol = _check_bounds(y, target_names, bounds) if bounds else {}
+
+ report = ValidationReport(
+ n_rows=y.shape[0],
+ n_cols=y.shape[1],
+ column_names=tuple(target_names),
+ nan_rows=nan_rows,
+ inf_rows=inf_rows,
+ bound_violations=bound_viol,
+ flag_violations={},
+ )
+
+ if strict and not report.is_valid:
+ raise DataValidationError(
+ f"Targets inválidos:\n{report.summary()}"
+ )
+ return report
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# LIMPEZA
+# ══════════════════════════════════════════════════════════════════════════════
+def clean_dataset(
+ X: np.ndarray,
+ y: np.ndarray | None = None,
+ *,
+ feature_names: Sequence[str] = DEFAULT_FEATURES,
+ target_names: Sequence[str] = DEFAULT_TARGETS,
+ feature_bounds: dict[str, tuple[float, float]] | None = None,
+ target_bounds: dict[str, tuple[float, float]] | None = None,
+ drop_bound_violations: bool = False,
+) -> tuple[np.ndarray, np.ndarray | None, np.ndarray, dict[str, ValidationReport]]:
+ """
+ Remove linhas inválidas de ``X`` (e ``y``, se fornecido), preservando alinhamento.
+
+ Parameters
+ ----------
+ drop_bound_violations
+ Se ``True``, também descarta linhas com qualquer bound violation.
+ Se ``False`` (default), só descarta NaN/Inf/flag inválida.
+
+ Returns
+ -------
+ X_clean, y_clean, kept_mask, reports
+ ``kept_mask[i] == True`` se a linha original i foi mantida.
+ ``reports = {"features": ..., "targets": ...}``.
+ """
+ rep_X = validate_features(
+ X,
+ feature_names=feature_names,
+ bounds=feature_bounds,
+ )
+ rep_y: ValidationReport | None = None
+ if y is not None:
+ rep_y = validate_targets(
+ y,
+ target_names=target_names,
+ bounds=target_bounds,
+ )
+ if rep_y.n_rows != rep_X.n_rows:
+ raise DataValidationError(
+ f"X e y têm número de linhas diferente: "
+ f"X={rep_X.n_rows}, y={rep_y.n_rows}"
+ )
+
+ # Construção do mask de linhas a descartar
+ drop_idx_parts: list[np.ndarray] = [rep_X.nan_rows, rep_X.inf_rows]
+ drop_idx_parts.extend(rep_X.flag_violations.values())
+ if drop_bound_violations:
+ drop_idx_parts.extend(rep_X.bound_violations.values())
+ if rep_y is not None:
+ drop_idx_parts.extend([rep_y.nan_rows, rep_y.inf_rows])
+ if drop_bound_violations:
+ drop_idx_parts.extend(rep_y.bound_violations.values())
+
+ drop_idx = (
+ np.unique(np.concatenate(drop_idx_parts)).astype(np.int64)
+ if drop_idx_parts and any(len(p) for p in drop_idx_parts)
+ else np.array([], dtype=np.int64)
+ )
+
+ n = rep_X.n_rows
+ keep = np.ones(n, dtype=bool)
+ if len(drop_idx):
+ keep[drop_idx] = False
+
+ X_clean = X[keep]
+ y_clean = y[keep] if y is not None else None
+
+ reports: dict[str, ValidationReport] = {"features": rep_X}
+ if rep_y is not None:
+ reports["targets"] = rep_y
+ return X_clean, y_clean, keep, reports
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# DATAFRAME HELPER
+# ══════════════════════════════════════════════════════════════════════════════
+def validate_dataframe(
+ df: Any,
+ *,
+ kind: str = "features",
+ bounds: dict[str, tuple[float, float]] | None = None,
+ strict: bool = False,
+) -> ValidationReport:
+ """
+ Conveniência para validar ``pd.DataFrame`` diretamente.
+
+ Requer pandas instalado. ``kind`` é ``"features"`` ou ``"targets"``
+ — determina a ordem/nomes canônicos esperados.
+ """
+ if kind == "features":
+ names = DEFAULT_FEATURES
+ n_expected = N_FEATURES
+ elif kind == "targets":
+ names = DEFAULT_TARGETS
+ n_expected = N_TARGETS
+ else:
+ raise ValueError(f"kind deve ser 'features' ou 'targets', recebi {kind!r}")
+
+ missing = [c for c in names if c not in df.columns]
+ if missing:
+ raise DataValidationError(
+ f"Colunas ausentes no DataFrame ({kind}): {missing}"
+ )
+ arr = df[list(names)].to_numpy(dtype=np.float64)
+ assert arr.shape[1] == n_expected
+
+ if kind == "features":
+ return validate_features(arr, feature_names=names, bounds=bounds, strict=strict)
+ return validate_targets(arr, target_names=names, bounds=bounds, strict=strict)
diff --git a/CSTRChemIA/core/exceptions.py b/CSTRChemIA/core/exceptions.py
new file mode 100644
index 0000000..39f0724
--- /dev/null
+++ b/CSTRChemIA/core/exceptions.py
@@ -0,0 +1,41 @@
+"""
+Hierarquia de exceções customizadas do CSTRChemIA.
+
+Usar exceções específicas (em vez de ``Exception`` genérico) permite
+tratamento preciso no nível de callback/UI e mensagens de erro úteis
+para o usuário final.
+"""
+
+from __future__ import annotations
+
+
+class CSTRChemIAError(Exception):
+ """Base de todas as exceções do projeto."""
+
+
+class ConfigurationError(CSTRChemIAError):
+ """Erro em configurações (variáveis de ambiente, arquivos ausentes)."""
+
+
+class ModelNotFoundError(CSTRChemIAError):
+ """Modelo surrogate não encontrado em disco ou com artefatos incompletos."""
+
+
+class ModelLoadError(CSTRChemIAError):
+ """Falha ao carregar modelo/scalers (arquivos corrompidos ou incompatíveis)."""
+
+
+class SimulationError(CSTRChemIAError):
+ """Falha em rotina de simulação (entrada inválida, predição inválida)."""
+
+
+class OptimizationError(CSTRChemIAError):
+ """Falha em otimização NSGA-II (sem soluções viáveis, timeout, etc.)."""
+
+
+class TrainingError(CSTRChemIAError):
+ """Falha no pipeline de treinamento (dados ausentes, arquitetura inválida)."""
+
+
+class DataValidationError(CSTRChemIAError):
+ """Dados de entrada não passam em validação de esquema/faixas."""
diff --git a/CSTRChemIA/core/feature_engineering.py b/CSTRChemIA/core/feature_engineering.py
new file mode 100644
index 0000000..e824060
--- /dev/null
+++ b/CSTRChemIA/core/feature_engineering.py
@@ -0,0 +1,199 @@
+"""
+Feature engineering — derivadas físicas a partir das 12 features crus.
+
+Motivação
+---------
+Modelos tabulares (MLP pequenos, XGBoost) se beneficiam fortemente de
+features com significado físico. Em vez de forçar a rede a aprender
+:math:`\\Delta T = T_{obs} - T_f`, entregamos isso pronto.
+
+As derivadas aqui são opcionais: o treino decide se inclui. O surrogate
+hoje consome apenas as 12 features canônicas — este módulo é para
+**baselines** (XGBoost, LightGBM) e para um eventual *v2* do surrogate
+com entrada ampliada.
+
+Derivadas implementadas
+-----------------------
+* ``dT_obs_feed`` = T_obs - Tf_feed (driving force temperature)
+* ``dT_feed_amb`` = Tf_feed - T_amb (heat loss proxy)
+* ``CA_over_CB`` = CA0_feed / CB0_feed (razão de reagentes)
+* ``C_total_feed`` = CA0_feed + CB0_feed (concentração total)
+* ``valve_flow`` = valve_pos * vazao_feed (vazão efetiva)
+* ``jacket_ratio`` = w_j / vazao_feed (razão refrigerante/feed)
+* ``failure_any`` = max(flags 0..3) (qualquer falha)
+* ``failure_count`` = sum(flags 0..3) (número de falhas ativas)
+* ``log_vazao`` = log10(vazao_feed) (ordem de grandeza)
+* ``log_wj`` = log10(w_j)
+
+API
+---
+::
+
+ from core.feature_engineering import engineer_features
+
+ X_ext = engineer_features(X_raw) # 12 → 22 colunas
+ X_ext = engineer_features(X_raw, include=['dT_obs_feed', 'valve_flow'])
+"""
+
+from __future__ import annotations
+
+from typing import Callable, Iterable
+
+import numpy as np
+
+from core.constants import DEFAULT_FEATURES, N_FEATURES
+from core.exceptions import DataValidationError
+
+# ══════════════════════════════════════════════════════════════════════════════
+# DEFINIÇÕES
+# ══════════════════════════════════════════════════════════════════════════════
+#: Índices por nome canônico (resolvidos na carga do módulo).
+_IDX = {name: i for i, name in enumerate(DEFAULT_FEATURES)}
+
+#: Pequeno epsilon para evitar divisão por zero em razões.
+_EPS = 1e-12
+
+
+def _safe_div(a: np.ndarray, b: np.ndarray) -> np.ndarray:
+ """Divisão segura; zero/NaN no divisor vira 0 no resultado."""
+ out = np.zeros_like(a, dtype=np.float64)
+ mask = np.abs(b) > _EPS
+ out[mask] = a[mask] / b[mask]
+ return out
+
+
+def _safe_log10(a: np.ndarray) -> np.ndarray:
+ """log10 com clip inferior (≥ _EPS); evita -inf para zeros."""
+ return np.log10(np.clip(a, _EPS, None))
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# DERIVADAS
+# ══════════════════════════════════════════════════════════════════════════════
+DERIVATIONS: dict[str, tuple[str, Callable[[np.ndarray], np.ndarray]]] = {
+ # name -> (description, fn(X[n,12]) -> arr[n])
+ "dT_obs_feed": (
+ "T_obs − Tf_feed (driving force)",
+ lambda X: X[:, _IDX["T_obs"]] - X[:, _IDX["Tf_feed"]],
+ ),
+ "dT_feed_amb": (
+ "Tf_feed − T_amb (perda térmica)",
+ lambda X: X[:, _IDX["Tf_feed"]] - X[:, _IDX["T_amb_feed"]],
+ ),
+ "dT_obs_amb": (
+ "T_obs − T_amb",
+ lambda X: X[:, _IDX["T_obs"]] - X[:, _IDX["T_amb_feed"]],
+ ),
+ "CA_over_CB": (
+ "CA0_feed / CB0_feed",
+ lambda X: _safe_div(X[:, _IDX["CA0_feed"]], X[:, _IDX["CB0_feed"]]),
+ ),
+ "C_total_feed": (
+ "CA0_feed + CB0_feed",
+ lambda X: X[:, _IDX["CA0_feed"]] + X[:, _IDX["CB0_feed"]],
+ ),
+ "valve_flow": (
+ "valve_pos · vazao_feed",
+ lambda X: X[:, _IDX["valve_pos"]] * X[:, _IDX["vazao_feed"]],
+ ),
+ "jacket_ratio": (
+ "w_j / vazao_feed",
+ lambda X: _safe_div(X[:, _IDX["w_j"]], X[:, _IDX["vazao_feed"]]),
+ ),
+ "failure_any": (
+ "max das 4 flags operacionais",
+ lambda X: np.max(
+ np.stack([X[:, _IDX[f]] for f in (
+ "flag_falha_valvula", "flag_manutencao",
+ "flag_falha_sensor_T", "flag_falha_comunicacao")]),
+ axis=0,
+ ),
+ ),
+ "failure_count": (
+ "soma das 4 flags operacionais",
+ lambda X: np.sum(
+ np.stack([X[:, _IDX[f]] for f in (
+ "flag_falha_valvula", "flag_manutencao",
+ "flag_falha_sensor_T", "flag_falha_comunicacao")]),
+ axis=0,
+ ),
+ ),
+ "log_vazao": (
+ "log10(vazao_feed)",
+ lambda X: _safe_log10(X[:, _IDX["vazao_feed"]]),
+ ),
+ "log_wj": (
+ "log10(w_j)",
+ lambda X: _safe_log10(X[:, _IDX["w_j"]]),
+ ),
+}
+
+
+ALL_DERIVED_NAMES: tuple[str, ...] = tuple(DERIVATIONS.keys())
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# API
+# ══════════════════════════════════════════════════════════════════════════════
+def engineer_features(
+ X: np.ndarray,
+ *,
+ include: Iterable[str] | None = None,
+ keep_original: bool = True,
+) -> tuple[np.ndarray, list[str]]:
+ """
+ Enriquece a matriz de features com derivadas físicas.
+
+ Parameters
+ ----------
+ X
+ Shape ``(n, 12)`` na ordem canônica (``core.constants.DEFAULT_FEATURES``).
+ include
+ Lista de nomes de derivadas a adicionar. ``None`` = todas (11 derivadas).
+ keep_original
+ Se ``True`` (default), concatena originais+derivadas. Se ``False``,
+ retorna apenas as derivadas (uso raro, para ablation).
+
+ Returns
+ -------
+ (X_out, feature_names)
+ ``X_out`` shape ``(n, 12+k)`` ou ``(n, k)``; ``feature_names`` tem os
+ nomes na mesma ordem das colunas.
+
+ Raises
+ ------
+ DataValidationError
+ Se shape de ``X`` não for ``(n, 12)``, ou se uma derivada solicitada
+ não existe.
+ """
+ if X.ndim == 1:
+ X = X.reshape(1, -1)
+ if X.ndim != 2 or X.shape[1] != N_FEATURES:
+ raise DataValidationError(
+ f"engineer_features: esperado (n, {N_FEATURES}), recebido {X.shape}"
+ )
+ X = np.asarray(X, dtype=np.float64)
+
+ names = list(ALL_DERIVED_NAMES) if include is None else list(include)
+ unknown = [n for n in names if n not in DERIVATIONS]
+ if unknown:
+ raise DataValidationError(
+ f"Derivadas desconhecidas: {unknown}. "
+ f"Disponíveis: {list(ALL_DERIVED_NAMES)}"
+ )
+
+ cols = [DERIVATIONS[n][1](X) for n in names]
+ derived = np.stack(cols, axis=1) if cols else np.empty((X.shape[0], 0))
+
+ if keep_original:
+ X_out = np.hstack([X, derived])
+ return X_out, list(DEFAULT_FEATURES) + names
+ return derived, names
+
+
+def describe_derivations() -> list[dict[str, str]]:
+ """Documentação auto-gerada das derivadas — útil para UI/docs."""
+ return [
+ {"name": name, "description": desc}
+ for name, (desc, _) in DERIVATIONS.items()
+ ]
diff --git a/CSTRChemIA/core/i18n.py b/CSTRChemIA/core/i18n.py
new file mode 100644
index 0000000..c56cb4f
--- /dev/null
+++ b/CSTRChemIA/core/i18n.py
@@ -0,0 +1,1588 @@
+"""
+Sistema de internacionalização (i18n) — suporte a múltiplos idiomas.
+
+Uso típico::
+
+ from core.i18n import t, get_lang
+
+ # Em layouts / callbacks
+ label = t("tab_simulation", lang) # "⚗ Simulação" / "⚗ Simulation" / "⚗ Simulación"
+
+Um :data:`TRANSLATIONS` armazena os pares ``key → texto`` por idioma. Chaves
+faltantes caem para o idioma padrão (PT-BR), e chaves ausentes no default
+retornam a própria chave — facilitando a identificação visual de textos ainda
+não traduzidos.
+"""
+from __future__ import annotations
+
+from typing import Any
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# IDIOMAS SUPORTADOS
+# ══════════════════════════════════════════════════════════════════════════════
+#: Códigos ISO + rótulos com bandeira. Essa lista alimenta o dropdown do
+#: seletor de idioma no header.
+LANGUAGES: list[dict[str, str]] = [
+ {"value": "pt", "label": "🇧🇷 Português"},
+ {"value": "en", "label": "🇺🇸 English"},
+ {"value": "es", "label": "🇪🇸 Español"},
+]
+
+#: Idioma padrão. Usado como fallback quando o store está vazio ou a chave
+#: não existe no idioma solicitado.
+DEFAULT_LANG: str = "pt"
+
+#: Conjunto de códigos válidos — usado em validação.
+VALID_LANG_CODES: frozenset[str] = frozenset(l["value"] for l in LANGUAGES)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# DICIONÁRIO DE TRADUÇÕES
+# ══════════════════════════════════════════════════════════════════════════════
+TRANSLATIONS: dict[str, dict[str, str]] = {
+ # ─────────────────────────────────────────────────────────────────────
+ # PORTUGUÊS (BR)
+ # ─────────────────────────────────────────────────────────────────────
+ "pt": {
+ # ─ Header ────────────────────────────────────────────────────────
+ "app_title": "CSTRChemIA - CSTR Simulator & Optimizer",
+ "app_subtitle": "Simulação e otimização multi-objetivo de reatores CSTR",
+ "lang_label": "Idioma",
+ "footer_text": "CSTRChemIA - CSTR Simulator & Optimizer — Powered by Dash + TensorFlow + pymoo",
+
+ # ─ Tabs ──────────────────────────────────────────────────────────
+ "tab_simulation": "⚗ Simulação",
+ "tab_optimization": "🎯 Otimização",
+ "tab_training": "🎓 Treinamento",
+ "tab_analytics": "📊 Análises",
+ "tab_help": "❔ Ajuda",
+ "tab_about": "ℹ Sobre",
+
+ # ─ Seções comuns (cards) ─────────────────────────────────────────
+ "section_surrogate_model": "Modelo Substituto",
+ "section_process_variables": "Variáveis de Processo",
+ "section_operational_flags": "Flags Operacionais",
+ "section_operational_constraints": "Restrições Operacionais",
+ "section_nsga_params": "Parâmetros NSGA-II",
+ "section_optimization_summary": "Resumo da Otimização",
+ "section_optimization_results": "Resultados da Otimização",
+ "section_training_structure": "Estrutura dos Dados de Treinamento",
+ "section_training_history": "Histórico de Treinamentos",
+ "section_data_files_manager": "Gerenciador de Arquivos de Dados",
+
+ # ─ Campos de formulário ──────────────────────────────────────────
+ "field_architecture": "Arquitetura",
+ "field_architecture_sel": "Arquitetura selecionada:",
+ "field_status": "Estado:",
+ "field_feed_flow": "Vazão de Alimentação",
+ "field_ca0_feed": "CA₀ Alim.",
+ "field_cb0_feed": "CB₀ Alim.",
+ "field_tf_feed": "Tf Alim.",
+ "field_tamb_feed": "T amb Alim.",
+ "field_tobs": "T observada",
+ "field_valve_position": "Posição da Válvula (0–1)",
+ "field_jacket_flow": "Vazão da Jaqueta w_j",
+ "field_t_max": "T0 Máxima",
+ "field_conversion_min": "Conversão Mínima (%)",
+ "field_cat_activity_min": "Atividade Catalítica Mínima",
+ "field_fouling_max": "Incrustação Máxima",
+ "field_cp0_min": "CP0 Mínimo",
+ "field_vazao_min": "Vazão mín.",
+ "field_vazao_max": "Vazão máx.",
+ "field_valve_min": "Válvula mín. (%)",
+ "field_valve_max": "Válvula máx. (%)",
+ "field_tobs_min": "T_obs mín.",
+ "field_tobs_max": "T_obs máx.",
+ "field_wj_min": "w_j mín.",
+ "field_wj_max": "w_j máx.",
+ "field_pop_size": "Tamanho da População",
+ "field_n_gen": "Número de Gerações",
+ "field_crossover_prob": "Prob. Crossover (SBX)",
+ "field_mutation_prob": "Prob. Mutação (PM)",
+
+ # ─ Sub-cabeçalhos / seções internas ──────────────────────────────
+ "subsection_process_limits": "Limites de processo",
+ "subsection_production_limits": "Restrições de produção",
+ "subsection_search_ranges": "Faixas de busca (variáveis de decisão)",
+
+ # ─ KPIs (títulos dos cards) ──────────────────────────────────────
+ "kpi_conversion_x": "Conversão X",
+ "kpi_t0_predicted": "T₀ Predita",
+ "kpi_ca0_predicted": "CA₀ Predita",
+ "kpi_cb0_predicted": "CB₀ Predita",
+ "kpi_cp0_product": "CP₀ (Produto)",
+ "kpi_cs0_byproduct": "CS₀ (Subprod.)",
+ "kpi_fouling": "Incrustação",
+ "kpi_cat_activity": "Ativ. Catalítica",
+
+ # ─ Flags ─────────────────────────────────────────────────────────
+ "flag_valve_failure": "Falha na Válvula",
+ "flag_maintenance": "Manutenção",
+ "flag_sensor_t_failure": "Falha Sensor T",
+ "flag_comm_failure": "Falha Comunicação",
+ "flag_valve_tooltip": "Ativa se houver falha na válvula de controle",
+ "flag_maint_tooltip": "Indica que o reator passou em manutenção",
+ "flag_sensor_t_tooltip": "Sensor de temperatura com defeito",
+ "flag_comm_tooltip": "Perda de comunicação com o CLP",
+
+ # ─ Botões / ações ────────────────────────────────────────────────
+ "btn_run_optimization": "Executar Otimização",
+ "btn_start_training": "Iniciar Treinamento",
+ "btn_update_metrics": "Atualizar métricas",
+ "btn_export_csv": "Exportar CSV",
+ "btn_refresh": "Atualizar",
+ "btn_delete": "Apagar",
+ "btn_confirm": "Confirmar",
+ "btn_cancel": "Cancelar",
+
+ # ─ Status / mensagens ────────────────────────────────────────────
+ "status_waiting": "● Aguardando",
+ "status_awaiting_inputs": "Aguardando entradas…",
+ "status_operation_healthy": "✓ Operação saudável",
+ "status_operation_degraded": "⚠ Operação degradada",
+ "status_critical": "✗ Atenção: condição crítica",
+ "status_model_label": "Modelo",
+ "msg_prediction_failed": "Predição falhou",
+ "msg_invalid_prediction": "Predição fora dos limites físicos",
+ "msg_run_first": "Execute a otimização para ver os resultados.",
+ "msg_click_update": "Clique em 'Atualizar métricas' após treinar para ver os resultados.",
+ "msg_select_file": "(selecione um arquivo acima)",
+
+ # ─ Training ──────────────────────────────────────────────────────
+ "training_logs": "Logs de Treinamento",
+ "training_features_title": "📥 Atributos (12):",
+ "training_targets_title": "📤 Alvos (8):",
+
+ # ─ Analytics / DFM ───────────────────────────────────────────────
+ "dfm_subtitle": "Visualize, baixe, apague ou adicione arquivos CSV ao repositório.",
+ "dfm_filter_kind": "🔍 Filtrar por tipo",
+ "dfm_filter_source": "📁 Filtrar por fonte",
+ "dfm_search": "🔎 Buscar (nome contém)",
+ "dfm_total_files": "Arquivos totais",
+ "dfm_total_rows": "Linhas totais",
+ "dfm_total_bytes": "Tamanho total",
+ "dfm_drop_files": "Arraste CSV aqui ou clique para selecionar",
+
+ # ─ Miscelânea ────────────────────────────────────────────────────
+ "info_general": "Informação",
+ "warning_general": "Atenção",
+ "error_general": "Erro",
+ "loading": "Carregando…",
+ "faixa": "Faixa",
+
+ # ─ Páginas (título/subtítulo) ────────────────────────────────────
+ "sim_page_title": "CSTR – Simulação",
+ "sim_page_subtitle": "Insira as condições de operação — o modelo substituto prediz concentrações, temperatura, conversão, incrustação e atividade catalítica em tempo real.",
+ "sim_results_section": "Resultados da Predição",
+ "opt_page_title": "CSTR — Otimização Multi-Objetivo",
+ "opt_page_subtitle": "NSGA-II encontra a frente de Pareto que maximiza X e minimiza a incrustação respeitando todas as restrições.",
+ "opt_info_alert": "O NSGA-II busca máxima conversão e mínima incrustação respeitando todas as restrições acima.",
+ "opt_results_header": "Resultados da Otimização",
+ "opt_feasible_table_title": "Tabela de Soluções Factíveis",
+ "training_page_title": "Treinar Modelo Substituto",
+ "training_page_subtitle": "Treinamento dos surrogates MLP / RNN / CNN com hiperparâmetros previamente otimizados",
+ "training_all_models": "Todos (MLP+RNN+CNN)",
+ "training_concentrations": "concentrações",
+ "training_internal_temp": "temperatura interna",
+ "training_conversion": "conversão",
+ "training_data_origin": "Origem dos dados",
+ "training_data_origin_text": "Arquivos em simulation/ (flat) ou ../Simulacoes/simulacao_CSTR/sim_*/observations/ (aninhado). Os hiperparâmetros vêm de training/best_hps_*.json e são sincronizados automaticamente antes do treino.",
+ "analytics_page_title": "Analytics — Dados & Benchmarking",
+ "analytics_page_subtitle": "Exploração detalhada do dataset de treino, distribuições, correlações e comparação quantitativa entre as três arquiteturas de surrogate.",
+ "analytics_card_title": "Explorador de Dados & Benchmark de Modelos",
+ "analytics_card_subtitle": "Visualize as distribuições do dataset de treino, correlações entre features e targets, e compare as arquiteturas MLP / RNN / CNN.",
+ "analytics_click_refresh": "Clique em 'Carregar / Atualizar' para ver as estatísticas.",
+ "analytics_load_data_msg": "Carregue os dados para ver a tabela.",
+ "analytics_stats_section": "Estatísticas Descritivas do Dataset",
+ "btn_load_refresh": "Carregar / Atualizar",
+
+ # ─ FormText curtos (hints sob inputs) ────────────────────────────
+ "ft_t_max": "Temperatura máxima permitida",
+ "ft_x_min": "X mínimo exigido",
+ "ft_cat_min": "cat_activity ≥ este valor",
+ "ft_fouling_max": "Espessura máxima de incrustação",
+ "ft_cp0_min": "Produção mínima de produto",
+ "ft_crossover_prob": "0.8–1.0 recomendado",
+ "ft_mutation_prob": "0.05–0.2 recomendado",
+
+ # ─ Gráficos — Simulation ─────────────────────────────────────────
+ "chart_sim_targets": "Alvos Preditos",
+ "chart_sim_health": "Saúde Operacional",
+ "chart_sim_gauge": "Medidor de Conversão",
+ "chart_sim_donut": "Distribuição de Espécies",
+ "chart_sim_sensitivity": "Sensibilidade X × T_obs",
+ "chart_sim_model_comparison": "Comparação MLP vs RNN vs CNN",
+ "chart_sim_feature_contrib": "Contribuição dos Atributos (ΔX)",
+ "chart_sim_envelope": "Envelope Operacional (T_obs × Válvula → X)",
+ "chart_sim_history": "Histórico de Predições (últimas 30)",
+
+ # ─ Gráficos — Optimization ───────────────────────────────────────
+ "chart_opt_pareto": "Frente de Pareto: Conversão × Incrustação",
+ "chart_opt_parcoords": "Coordenadas Paralelas",
+ "chart_opt_convergence": "Convergência NSGA-II",
+ "chart_opt_3d": "Espaço 3D: X × Incrustação × Atividade Catalítica",
+ "chart_opt_heatmap": "Mapa Operacional: Vazão × T_obs",
+ "chart_opt_violin": "Distribuição das Saídas Ótimas",
+ "chart_opt_decision_space": "Espaço de Decisão (Matriz de Dispersão)",
+ "chart_opt_topsis": "Ranking TOPSIS Multi-Critério (Top-15)",
+ "chart_opt_cluster": "Agrupamentos K-Means da Frente de Pareto",
+ "chart_opt_hypervolume": "Hipervolume e Factibilidade por Geração",
+
+ # ─ Gráficos — Training ───────────────────────────────────────────
+ "chart_training_loss": "Curva de Perda (Treino vs Validação) — ao vivo",
+ "chart_training_metrics": "Métricas por Arquitetura (R², MAE, RMSE por alvo)",
+ "chart_training_radar": "Radar de Desempenho Global (MLP vs RNN vs CNN)",
+
+ # ─ Gráficos — Analytics ──────────────────────────────────────────
+ "chart_analytics_feat_dist": "Distribuição dos Atributos (box plots normalizados)",
+ "chart_analytics_target_dist": "Distribuição dos Alvos (violin plots)",
+ "chart_analytics_corr": "Matriz de Correlação Atributos × Alvos",
+ "chart_analytics_metrics_bar": "Métricas por Alvo — Comparação MLP/RNN/CNN",
+ "chart_analytics_arch_radar": "Radar de Desempenho Global por Arquitetura",
+ "chart_analytics_pca": "Variância Explicada (PCA dos Atributos)",
+ "chart_analytics_error_dist": "Distribuição do Erro de Predição por Alvo",
+
+ # ─ DFM — textos adicionais ───────────────────────────────────────
+ "dfm_all": "Todos",
+ "dfm_all_fem": "Todas",
+ "dfm_click_refresh": "Clique em 🔄 Atualizar para listar os arquivos disponíveis.",
+ "dfm_kind_features": "Atributos",
+ "dfm_kind_targets": "Alvos",
+ "dfm_kind_complete": "Dados Completos",
+ "dfm_kind_observed": "Observados",
+ "dfm_kind_truth": "Verdade",
+ "dfm_kind_training_result": "Resultado Treino",
+ "dfm_kind_other": "Outros",
+ "dfm_preview": "Prévia",
+ "dfm_add_file_title": "Adicionar arquivo (features ou targets)",
+ "dfm_name_pattern_required": "Padrão de nome obrigatório",
+ "dfm_or": "ou",
+ "dfm_save_location_note": "O arquivo será salvo em CSTRChemIA/simulation/ (criado automaticamente se necessário). Colisões de nome recebem sufixo de timestamp.",
+ "dfm_drop_files_here": "Arraste arquivos aqui",
+ "dfm_or_click_to_select": "ou clique para selecionar (.csv)",
+ "dfm_confirm_delete_msg": "Tem certeza que deseja excluir este arquivo?\nEsta ação não pode ser desfeita.",
+
+ # ─ Colunas da tabela de arquivos ─────────────────────────────────
+ "col_kind": "Tipo",
+ "col_file": "Arquivo",
+ "col_source": "Fonte",
+ "col_rows": "Linhas",
+ "col_cols": "Colunas",
+ "col_size": "Tamanho",
+ "col_modified": "Modificado",
+ "col_path": "Caminho",
+
+ # ─ KPIs da Otimização (Optimization Summary) ─────────────────────
+ "kpi_feasible_solutions": "Soluções Factíveis",
+ "kpi_best_x": "Melhor X",
+ "kpi_min_fouling": "Mín. Incrustação",
+ "kpi_best_cat_act": "Melhor Ativ. Cat.",
+ "kpi_best_cp0": "Melhor CP₀",
+ "kpi_time": "Tempo",
+ "kpi_avg_x": "Média X",
+ "kpi_avg_fouling": "Média Incrustação",
+ "kpi_avg_t0": "Média T₀",
+ "kpi_hypervolume": "Hipervolume",
+ "kpi_spread": "Dispersão",
+ "msg_no_feasible": "Nenhuma solução factível encontrada. Relaxe as restrições.",
+ "msg_best_solution": "🏆 Melhor solução (maior X): ",
+ "label_flow": "Vazão",
+ "label_tobs": "T_obs",
+ "label_valve": "Válvula",
+ "label_t0": "T₀",
+
+ # ─ KPIs Analytics / Dataset ──────────────────────────────────────
+ "kpi_total_samples": "Total de Amostras",
+ "kpi_train": "Treino",
+ "kpi_validation": "Validação",
+ "kpi_test": "Teste",
+ "kpi_features": "Atributos",
+ "kpi_targets": "Alvos",
+
+ # ─ Tabela de estatísticas ────────────────────────────────────────
+ "col_feature": "Atributo",
+ "col_target": "Alvo",
+ "col_mean": "Média",
+ "col_std": "Desvio Padrão",
+ "col_min_est": "Mín. estimado",
+ "col_max_est": "Máx. estimado",
+ "col_type": "Tipo",
+ "type_continuous": "Contínua",
+ "type_flag": "Flag",
+
+ # ─ Títulos de gráficos (usados em callbacks) ─────────────────────
+ "cb_chart_state_predicted": "Estado Predito do Reator",
+ "cb_chart_operational_health": "Saúde Operacional",
+ "cb_chart_conversion_x": "Conversão X",
+ "cb_chart_species_dist": "Distribuição das Espécies",
+ "cb_chart_sensitivity": "Sensibilidade X / T₀ × T_obs",
+ "cb_chart_model_comp": "Comparação MLP/RNN/CNN (selecionado: {model})",
+ "cb_chart_delta_x_feature": "ΔX por Atributo (±1σ)",
+ "cb_chart_envelope": "Envelope Operacional: X(%)",
+ "cb_chart_history": "Histórico de Predições (últimas 30)",
+ "cb_chart_loss_curve": "Curva de Perda (Treino vs Validação)",
+ "cb_chart_metrics_arch": "Métricas por Arquitetura (MLP / RNN / CNN)",
+ "cb_chart_perf_radar": "Radar de Desempenho (normalizado, 1 = melhor)",
+ "cb_chart_feat_dist": "Distribuição dos Atributos (z-score)",
+ "cb_chart_target_dist": "Distribuição dos Alvos (z-score)",
+ "cb_chart_corr": "Matriz de Correlação (estimativa de domain knowledge)",
+ "cb_chart_pca": "Variância Explicada — PCA dos Atributos",
+ "cb_chart_error_dist": "Distribuição do Erro de Predição por Alvo",
+
+ # ─ Rótulos de eixos/legendas em gráficos ─────────────────────────
+ "axis_value": "Valor",
+ "axis_metric": "Métrica",
+ "axis_z_score": "Z-score (σ)",
+ "axis_explained_var": "Variância Explicada (%)",
+ "axis_pca_component": "Componente Principal",
+ "axis_error_unit": "Erro (unidade do alvo)",
+ "axis_valve_position": "Posição da Válvula",
+ "axis_prediction_idx": "Nº da predição",
+ "axis_percent_scale": "Escala %",
+ "axis_epoch": "Época",
+ "axis_loss_mse": "Perda (MSE)",
+ "marker_current": "Atual",
+ "marker_current_point": "Ponto atual",
+ "legend_state": "Estado",
+ "legend_train_loss": "Perda Treino",
+ "legend_val_loss": "Perda Validação",
+ "legend_variance_individual": "Variância individual",
+ "legend_variance_cumulative": "Variância acumulada",
+ "legend_anti_fouling": "Anti-incrustação",
+ "legend_valve_ok": "Válvula OK",
+ "legend_sensor_t_ok": "Sensor T OK",
+ "legend_comm_ok": "Comunicação OK",
+ "trace_conversion_x_pct": "Conversão X (%)",
+ "trace_cat_act_pct": "Ativ. Cat. (%)",
+ "trace_fouling_scale": "Incrustação (% escala)",
+ "trace_t0_k": "T₀ (K)",
+
+ # ─ Status dinâmicos ─────────────────────────────────────────────
+ "status_no_model": "Nenhum modelo selecionado",
+ "status_model_ready": "✓ {model} pronto",
+ "status_model_not_trained": "⚠ {model} não treinado",
+ "status_model_ready_opt": "✓ {model} pronto para otimização",
+ "status_invalid_prediction": "Predição inválida",
+ "status_extrapolation_severe": "⚠ Extrapolação severa: ",
+ "status_extrapolation_outside": "ℹ Fora da zona densa: ",
+ "status_low_agreement": "⚠ Baixa concordância (CV={cv:.0f}%) — {preds}",
+ "status_moderate_agreement": "ℹ Concordância moderada (CV={cv:.0f}%) — {preds}",
+ "status_high_agreement": "✓ Alta concordância (CV={cv:.0f}%) — {preds}",
+ "status_training_active": "● Treinando…",
+ "status_training_error": "● Erro: {error}",
+ "status_training_done": "● Concluído",
+
+ # ─ Figuras vazias / placeholders ─────────────────────────────────
+ "fig_empty_default": "Aguardando inputs…",
+ "fig_empty_pareto": "Sem Pareto",
+ "fig_empty_parcoords": "Sem coordenadas paralelas",
+ "fig_empty_3d": "Sem 3D",
+ "fig_empty_convergence": "Sem convergência",
+ "fig_empty_heatmap": "Sem mapa de calor",
+ "fig_empty_distribution": "Sem distribuição",
+ "fig_empty_topsis": "Sem TOPSIS",
+ "fig_empty_clusters": "Sem agrupamentos",
+ "fig_empty_hypervolume": "Sem hipervolume",
+ "fig_empty_decision_space": "Sem espaço de decisão",
+ "msg_start_training_loss": "Inicie o treinamento para ver as curvas de perda",
+ "msg_train_for_metrics": "Treine um modelo para ver as métricas",
+ "msg_results_not_yet": "Arquivo training_results.csv ainda não existe.",
+ "msg_error_reading_results": "Erro ao ler resultados: {err}",
+ "msg_history_hint": "Altere os inputs para acumular histórico de predições",
+ "msg_sensitivity_unavailable": "Sensibilidade indisponível",
+ "msg_models_unavailable": "Modelos indisponíveis para comparação",
+ "msg_envelope_unavailable": "Envelope operacional indisponível",
+ "msg_no_training_result": "Nenhum resultado registrado ainda.",
+ "msg_csv_missing_arch": "CSV sem coluna de arquitetura.",
+ "msg_csv_no_metric": "Nenhuma métrica numérica disponível no CSV.",
+ "msg_need_two_archs": "Treine ao menos 2 arquiteturas para comparar no radar.",
+ "msg_training_started": "🟡 Treinamento iniciado…",
+ "msg_training_completed": "✅ Treinamento concluído com sucesso!",
+ "msg_training_failed": "❌ Falha: {err}",
+ "legend_best": "Melhor",
+ "metric_mae_real": "MAE (real)",
+ "metric_val_loss_scaled": "Val Perda (escalonado)",
+ "metric_r2": "R²",
+ "metric_rmse": "RMSE",
+ "metric_epochs": "Épocas",
+ # ─ Status / progresso da otimização ───────────────────────────────
+ "opt_status_completed": "Otimização concluída — {n} soluções em {elapsed:.1f}s",
+ "opt_status_failure": "Falha",
+ "opt_status_optimizing": "Otimizando... Geração {gen}/{total} | Factíveis: {feasible}",
+ "opt_generation_final": "Geração final: {gen}",
+ "opt_generation_progress": "Geração {gen} / {total}",
+ "opt_error_prefix": "Erro: {err}",
+ # ─ Gerenciador de Arquivos (DFM) ──────────────────────────────────
+ "dfm_kpi_files": "Arquivos",
+ "dfm_kpi_size": "Tamanho",
+ "dfm_kpi_rows": "Linhas",
+ "dfm_kpi_features_targets": "Atributos/Alvos",
+ "dfm_preview_placeholder": "(selecione um arquivo acima)",
+ "dfm_preview_invalid": "(seleção inválida)",
+ "dfm_preview_no_path": "(arquivo sem caminho)",
+ "dfm_preview_read_err": "Erro ao ler preview: {err}",
+ "dfm_preview_stats": "Exibindo {shown} de {total:,} linhas · {cols} colunas · {size}",
+ "dfm_discover_failed": "Falha ao descobrir arquivos: {err}",
+ "dfm_confirm_delete": "Tem certeza que deseja excluir este arquivo?\n\n• Arquivo: {filename}\n• Tipo: {kind}\n• Caminho: {path}\n\nEsta ação não pode ser desfeita.",
+ "dfm_upload_summary": "Upload: {ok} ok / {fail} falhas",
+ "dfm_upload_no_message": "(sem mensagem)",
+
+ # ─ Nomes de atributos/alvos (feature labels) ─────────────────────
+ "feat_flow": "Vazão Alim.",
+ "feat_ca0": "CA₀ Alim.",
+ "feat_cb0": "CB₀ Alim.",
+ "feat_tf": "Tf Alim.",
+ "feat_tamb": "T_amb Alim.",
+ "feat_tobs": "T_obs",
+ "feat_valve": "Válvula",
+ "feat_wj": "w_j",
+
+ # ─ Rótulos curtos para subplots / eixos ──────────────────────────
+ "short_x_pct": "Conversão (%)",
+ "short_cat_act": "Ativ. Cat. (%)",
+ "short_fouling_um": "Incrustação (µm)",
+ "short_t0_k": "T₀ (K)",
+
+ # ─ Nomes de alvos para analytics (violin, heatmap) ───────────────
+ "target_CA0": "CA₀",
+ "target_CB0": "CB₀",
+ "target_CP0": "CP₀",
+ "target_CS0": "CS₀",
+ "target_T0": "T₀",
+ "target_X": "Conversão",
+ "target_fouling": "Incrustação",
+ "target_cat_activity": "Ativ. Catalítica",
+
+ # ─ Títulos/eixos de gráficos de otimização ───────────────────────
+ "opt_fig_pareto_title": "Fronteira de Pareto (Conversão × Incrustação)",
+ "opt_fig_pareto_x": "Conversão máxima X (%)",
+ "opt_fig_pareto_y": "Incrustação mínima (µm)",
+ "opt_fig_parcoords_title": "Coordenadas Paralelas — Variáveis vs Objetivos",
+ "opt_fig_3d_title": "Espaço 3D — Vazão × T_feed × Conversão",
+ "opt_fig_3d_x": "Vazão (m³/s)",
+ "opt_fig_3d_y": "T_feed (K)",
+ "opt_fig_3d_z": "Conversão X (%)",
+ "opt_fig_convergence_title": "Convergência do NSGA-II (gerações)",
+ "opt_fig_convergence_x": "Geração",
+ "opt_fig_convergence_y": "Valor do Objetivo",
+ "opt_fig_heatmap_title": "Correlação — Variáveis × Objetivos",
+ "opt_fig_violin_title": "Distribuição dos Objetivos",
+ "opt_fig_topsis_title": "Ranking TOPSIS — Melhores Soluções",
+ "opt_fig_topsis_x": "Pontuação TOPSIS",
+ "opt_fig_topsis_y": "Solução #",
+ "opt_fig_clusters_title": "Agrupamentos de Soluções (k-means)",
+ "opt_fig_hypervolume_title": "Evolução do Hipervolume",
+ "opt_fig_hypervolume_x": "Geração",
+ "opt_fig_hypervolume_y": "Hipervolume",
+ "opt_fig_decision_title": "Espaço de Decisão — Variáveis Escolhidas",
+ "opt_axis_generation": "Geração",
+ "opt_axis_solution": "Solução",
+ "opt_axis_conversion_pct": "Conversão (%)",
+ "opt_axis_fouling_um": "Incrustação (µm)",
+ "opt_axis_value": "Valor",
+ "opt_axis_n_feasible": "N° Factíveis",
+ "opt_axis_spread": "Dispersão (spread)",
+ "opt_axis_topsis_score": "Score TOPSIS (0=pior → 1=melhor)",
+ "opt_label_conversion": "Conversão",
+ "opt_label_fouling": "Incrustação",
+ "opt_label_pareto_front": "Frente de Pareto",
+ "opt_label_dominated": "Dominadas",
+ "opt_label_utopia": "Ponto Utopia",
+ "opt_label_avg_x": "Média X",
+ "opt_label_best_x": "Melhor X",
+ "opt_label_centroids": "Centróides",
+ "opt_label_centroid": "Centróide",
+ "opt_label_diversity": "Diversidade",
+ "opt_label_feasible_plural": "Factíveis",
+ "opt_label_max": "Máx.",
+ "opt_label_mean": "Média",
+ # Cluster names
+ "opt_cluster_balanced": "Balanceado",
+ "opt_cluster_high_x_high_f": "Alto X / Alto Fouling",
+ "opt_cluster_low_f_low_x": "Baixo Fouling / Baixo X",
+ # Subplot titles
+ "opt_sub_avg_x": "Média X (%) Factíveis",
+ "opt_sub_best_x": "Melhor X (%) × Gerações",
+ "opt_sub_diversity": "Diversidade (spread)",
+ "opt_sub_feasibility": "Factibilidade por Geração",
+ "opt_sub_feasible_by_gen": "Soluções Factíveis por Geração",
+ "opt_sub_hv_by_gen": "Hipervolume por Geração",
+ # Full figure titles
+ "opt_fig_3d_perf_title": "Espaço 3D de Desempenho",
+ "opt_fig_clusters_kmeans_title": "Clusters K-Means da Frente de Pareto (k={k})",
+ "opt_fig_clusters_pareto_title": "Clusters Pareto",
+ "opt_fig_convergence_metrics_title": "Métricas de Convergência NSGA-II",
+ "opt_fig_decision_vs_x_title": "Espaço de Decisão — Variáveis vs X (%)",
+ "opt_fig_evolution_title": "Evolução NSGA-II",
+ "opt_fig_hv_by_gen_title": "Hipervolume por Geração",
+ "opt_fig_opmap_title": "Mapa Operacional: Vazão × T_obs × Conversão",
+ "opt_fig_topsis_multi_title": "Ranking TOPSIS — Multi-Critério (Top-15)",
+ "opt_fig_violin_out_title": "Distribuição das Saídas — Soluções Ótimas",
+ # Messages
+ "msg_no_feasible_relax": "Nenhuma solução factível. Relaxe as restrições.",
+ "msg_need_2_feasible": "Precisa de ≥ 2 soluções factíveis.",
+ "msg_no_feasible_3d": "Sem soluções factíveis para 3D.",
+ "msg_no_convergence_hist": "Sem histórico de convergência.",
+ "msg_need_3_heatmap": "Precisa de ≥ 3 soluções para mapa operacional.",
+ "msg_need_2_distribution": "Precisa de ≥ 2 soluções para distribuição.",
+ "msg_need_2_topsis": "Precisa de ≥ 2 soluções para ranking TOPSIS.",
+ "msg_topsis_unavailable": "TOPSIS indisponível.",
+ "msg_need_4_cluster": "Precisa de ≥ 4 soluções para clustering.",
+ "msg_cluster_missing_col": "Coluna '{col}' ausente — clustering indisponível.",
+ "msg_sklearn_required": "scikit-learn necessário para clustering.",
+ "msg_cluster_cols_insufficient": "Colunas insuficientes para clustering.",
+ "msg_cluster_rows_insufficient": "Linhas válidas insuficientes após limpeza.",
+ "msg_cluster_zero_variance": "Variância nula em alguma feature — clustering não faz sentido.",
+ "msg_cluster_error": "Erro no clustering: {err}",
+ "msg_no_hv_hist": "Sem histórico de Hipervolume.",
+ "msg_need_3_decision": "Precisa de ≥ 3 soluções para espaço de decisão.",
+ "msg_decision_cols_insufficient": "Colunas de decisão insuficientes.",
+ },
+
+ # ─────────────────────────────────────────────────────────────────────
+ # ENGLISH
+ # ─────────────────────────────────────────────────────────────────────
+ "en": {
+ # Header
+ "app_title": "CSTRChemIA - CSTR Simulator & Optimizer",
+ "app_subtitle": "Multi-objective simulation and optimization of CSTR reactors",
+ "lang_label": "Language",
+ "footer_text": "CSTRChemIA - CSTR Simulator & Optimizer — Powered by Dash + TensorFlow + pymoo",
+
+ # Tabs
+ "tab_simulation": "⚗ Simulation",
+ "tab_optimization": "🎯 Optimization",
+ "tab_training": "🎓 Training",
+ "tab_analytics": "📊 Analytics",
+ "tab_help": "❔ Help",
+ "tab_about": "ℹ About",
+
+ # Sections
+ "section_surrogate_model": "Surrogate Model",
+ "section_process_variables": "Process Variables",
+ "section_operational_flags": "Operational Flags",
+ "section_operational_constraints": "Operational Constraints",
+ "section_nsga_params": "NSGA-II Parameters",
+ "section_optimization_summary": "Optimization Summary",
+ "section_optimization_results": "Optimization Results",
+ "section_training_structure": "Training Data Structure",
+ "section_training_history": "Training History",
+ "section_data_files_manager": "Data File Manager",
+
+ # Fields
+ "field_architecture": "Architecture",
+ "field_architecture_sel": "Selected architecture:",
+ "field_status": "Status:",
+ "field_feed_flow": "Feed Flow",
+ "field_ca0_feed": "CA₀ Feed",
+ "field_cb0_feed": "CB₀ Feed",
+ "field_tf_feed": "Tf Feed",
+ "field_tamb_feed": "T amb Feed",
+ "field_tobs": "Observed T",
+ "field_valve_position": "Valve Position (0–1)",
+ "field_jacket_flow": "Jacket Flow w_j",
+ "field_t_max": "T0 Max",
+ "field_conversion_min": "Min. Conversion (%)",
+ "field_cat_activity_min": "Min. Cat. Activity",
+ "field_fouling_max": "Max. Fouling",
+ "field_cp0_min": "Min. CP0",
+ "field_vazao_min": "Flow min",
+ "field_vazao_max": "Flow max",
+ "field_valve_min": "Valve min (%)",
+ "field_valve_max": "Valve max (%)",
+ "field_tobs_min": "T_obs min",
+ "field_tobs_max": "T_obs max",
+ "field_wj_min": "w_j min",
+ "field_wj_max": "w_j max",
+ "field_pop_size": "Population Size",
+ "field_n_gen": "Number of Generations",
+ "field_crossover_prob": "Crossover Prob. (SBX)",
+ "field_mutation_prob": "Mutation Prob. (PM)",
+
+ # Sub-sections
+ "subsection_process_limits": "Process limits",
+ "subsection_production_limits": "Production constraints",
+ "subsection_search_ranges": "Search ranges (decision variables)",
+
+ # KPIs
+ "kpi_conversion_x": "Conversion X",
+ "kpi_t0_predicted": "T₀ Predicted",
+ "kpi_ca0_predicted": "CA₀ Predicted",
+ "kpi_cb0_predicted": "CB₀ Predicted",
+ "kpi_cp0_product": "CP₀ (Product)",
+ "kpi_cs0_byproduct": "CS₀ (Byprod.)",
+ "kpi_fouling": "Fouling",
+ "kpi_cat_activity": "Cat. Activity",
+
+ # Flags
+ "flag_valve_failure": "Valve Failure",
+ "flag_maintenance": "Maintenance",
+ "flag_sensor_t_failure": "T Sensor Failure",
+ "flag_comm_failure": "Comm. Failure",
+ "flag_valve_tooltip": "Active if the control valve has failed",
+ "flag_maint_tooltip": "Indicates the reactor has undergone maintenance",
+ "flag_sensor_t_tooltip": "Temperature sensor malfunction",
+ "flag_comm_tooltip": "Loss of communication with the PLC",
+
+ # Buttons
+ "btn_run_optimization": "Run Optimization",
+ "btn_start_training": "Start Training",
+ "btn_update_metrics": "Update metrics",
+ "btn_export_csv": "Export CSV",
+ "btn_refresh": "Refresh",
+ "btn_delete": "Delete",
+ "btn_confirm": "Confirm",
+ "btn_cancel": "Cancel",
+
+ # Status / messages
+ "status_waiting": "● Waiting",
+ "status_awaiting_inputs": "Awaiting inputs…",
+ "status_operation_healthy": "✓ Healthy operation",
+ "status_operation_degraded": "⚠ Degraded operation",
+ "status_critical": "✗ Warning: critical condition",
+ "status_model_label": "Model",
+ "msg_prediction_failed": "Prediction failed",
+ "msg_invalid_prediction": "Prediction outside physical limits",
+ "msg_run_first": "Run the optimization to see results.",
+ "msg_click_update": "Click 'Update metrics' after training to see results.",
+ "msg_select_file": "(select a file above)",
+
+ # Training
+ "training_logs": "Training Logs",
+ "training_features_title": "📥 Features (12):",
+ "training_targets_title": "📤 Targets (8):",
+
+ # Analytics / DFM
+ "dfm_subtitle": "View, download, delete or add CSV files to the repository.",
+ "dfm_filter_kind": "🔍 Filter by kind",
+ "dfm_filter_source": "📁 Filter by source",
+ "dfm_search": "🔎 Search (name contains)",
+ "dfm_total_files": "Total files",
+ "dfm_total_rows": "Total rows",
+ "dfm_total_bytes": "Total size",
+ "dfm_drop_files": "Drop CSV here or click to select",
+
+ # Misc
+ "info_general": "Info",
+ "warning_general": "Warning",
+ "error_general": "Error",
+ "loading": "Loading…",
+ "faixa": "Range",
+
+ # Pages
+ "sim_page_title": "CSTR – Simulation",
+ "sim_page_subtitle": "Enter the operating conditions — the surrogate model predicts concentrations, temperature, conversion, fouling, and catalyst activity in real time.",
+ "sim_results_section": "Prediction Results",
+ "opt_page_title": "CSTR — Multi-Objective Optimization",
+ "opt_page_subtitle": "NSGA-II finds the Pareto front that maximizes X and minimizes fouling while respecting all constraints.",
+ "opt_info_alert": "NSGA-II seeks maximum conversion and minimum fouling respecting all constraints above.",
+ "opt_results_header": "Optimization Results",
+ "opt_feasible_table_title": "Feasible Solutions Table",
+ "training_page_title": "Train Surrogate Model",
+ "training_page_subtitle": "Training MLP / RNN / CNN surrogates with previously optimized hyperparameters",
+ "training_all_models": "All (MLP+RNN+CNN)",
+ "training_concentrations": "concentrations",
+ "training_internal_temp": "internal temperature",
+ "training_conversion": "conversion",
+ "training_data_origin": "Data origin",
+ "training_data_origin_text": "Files in simulation/ (flat) or ../Simulacoes/simulacao_CSTR/sim_*/observations/ (nested). Hyperparameters come from training/best_hps_*.json and are synced automatically before training.",
+ "analytics_page_title": "Analytics — Data & Benchmarking",
+ "analytics_page_subtitle": "Detailed exploration of the training dataset, distributions, correlations, and quantitative comparison between the three surrogate architectures.",
+ "analytics_card_title": "Data Explorer & Model Benchmark",
+ "analytics_card_subtitle": "Explore training dataset distributions, feature–target correlations, and compare the MLP / RNN / CNN architectures.",
+ "analytics_click_refresh": "Click 'Load / Refresh' to see the statistics.",
+ "analytics_load_data_msg": "Load data to see the table.",
+ "analytics_stats_section": "Descriptive Dataset Statistics",
+ "btn_load_refresh": "Load / Refresh",
+
+ # FormText hints
+ "ft_t_max": "Maximum allowed temperature",
+ "ft_x_min": "Required minimum X",
+ "ft_cat_min": "cat_activity ≥ this value",
+ "ft_fouling_max": "Maximum fouling thickness",
+ "ft_cp0_min": "Minimum product output",
+ "ft_crossover_prob": "0.8–1.0 recommended",
+ "ft_mutation_prob": "0.05–0.2 recommended",
+
+ # Charts — Simulation
+ "chart_sim_targets": "Predicted Targets",
+ "chart_sim_health": "Operational Health",
+ "chart_sim_gauge": "Conversion Gauge",
+ "chart_sim_donut": "Species Distribution",
+ "chart_sim_sensitivity": "Sensitivity X × T_obs",
+ "chart_sim_model_comparison": "Comparison MLP vs RNN vs CNN",
+ "chart_sim_feature_contrib": "Feature Contribution (ΔX)",
+ "chart_sim_envelope": "Operational Envelope (T_obs × Valve → X)",
+ "chart_sim_history": "Prediction History (last 30)",
+
+ # Charts — Optimization
+ "chart_opt_pareto": "Pareto Front: Conversion × Fouling",
+ "chart_opt_parcoords": "Parallel Coordinates",
+ "chart_opt_convergence": "NSGA-II Convergence",
+ "chart_opt_3d": "3D Space: X × Fouling × Cat. Activity",
+ "chart_opt_heatmap": "Operational Map: Flow × T_obs",
+ "chart_opt_violin": "Distribution of Optimal Outputs",
+ "chart_opt_decision_space": "Decision Space (Scatter Matrix)",
+ "chart_opt_topsis": "Multi-Criteria TOPSIS Ranking (Top-15)",
+ "chart_opt_cluster": "K-Means Clusters of the Pareto Front",
+ "chart_opt_hypervolume": "Hypervolume and Feasibility per Generation",
+
+ # Charts — Training
+ "chart_training_loss": "Loss Curve (Train vs Val) — live",
+ "chart_training_metrics": "Metrics per Architecture (R², MAE, RMSE per target)",
+ "chart_training_radar": "Global Performance Radar (MLP vs RNN vs CNN)",
+
+ # Charts — Analytics
+ "chart_analytics_feat_dist": "Feature Distribution (normalized box plots)",
+ "chart_analytics_target_dist": "Target Distribution (violin plots)",
+ "chart_analytics_corr": "Feature × Target Correlation Matrix",
+ "chart_analytics_metrics_bar": "Metrics per Target — MLP/RNN/CNN Comparison",
+ "chart_analytics_arch_radar": "Global Performance Radar per Architecture",
+ "chart_analytics_pca": "Explained Variance (Feature PCA)",
+ "chart_analytics_error_dist": "Prediction Error Distribution per Target",
+
+ # DFM
+ "dfm_all": "All",
+ "dfm_all_fem": "All",
+ "dfm_click_refresh": "Click 🔄 Refresh to list available files.",
+ "dfm_kind_features": "Features",
+ "dfm_kind_targets": "Targets",
+ "dfm_kind_complete": "Complete Data",
+ "dfm_kind_observed": "Observed",
+ "dfm_kind_truth": "Truth",
+ "dfm_kind_training_result": "Training Result",
+ "dfm_kind_other": "Other",
+ "dfm_preview": "Preview",
+ "dfm_add_file_title": "Add file (features or targets)",
+ "dfm_name_pattern_required": "Required name pattern",
+ "dfm_or": "or",
+ "dfm_save_location_note": "The file will be saved to CSTRChemIA/simulation/ (auto-created if needed). Name collisions get a timestamp suffix.",
+ "dfm_drop_files_here": "Drop files here",
+ "dfm_or_click_to_select": "or click to select (.csv)",
+ "dfm_confirm_delete_msg": "Are you sure you want to delete this file?\nThis action cannot be undone.",
+
+ # File table columns
+ "col_kind": "Kind",
+ "col_file": "File",
+ "col_source": "Source",
+ "col_rows": "Rows",
+ "col_cols": "Cols",
+ "col_size": "Size",
+ "col_modified": "Modified",
+ "col_path": "Path",
+
+ # Optimization Summary KPIs
+ "kpi_feasible_solutions": "Feasible Solutions",
+ "kpi_best_x": "Best X",
+ "kpi_min_fouling": "Min. Fouling",
+ "kpi_best_cat_act": "Best Cat. Act.",
+ "kpi_best_cp0": "Best CP₀",
+ "kpi_time": "Time",
+ "kpi_avg_x": "Avg. X",
+ "kpi_avg_fouling": "Avg. Fouling",
+ "kpi_avg_t0": "Avg. T₀",
+ "kpi_hypervolume": "Hypervolume",
+ "kpi_spread": "Spread",
+ "msg_no_feasible": "No feasible solution found. Relax the constraints.",
+ "msg_best_solution": "🏆 Best solution (highest X): ",
+ "label_flow": "Flow",
+ "label_tobs": "T_obs",
+ "label_valve": "Valve",
+ "label_t0": "T₀",
+
+ # Analytics dataset KPIs
+ "kpi_total_samples": "Total Samples",
+ "kpi_train": "Train",
+ "kpi_validation": "Validation",
+ "kpi_test": "Test",
+ "kpi_features": "Features",
+ "kpi_targets": "Targets",
+
+ # Stats table columns
+ "col_feature": "Feature",
+ "col_target": "Target",
+ "col_mean": "Mean",
+ "col_std": "Std Dev",
+ "col_min_est": "Est. Min",
+ "col_max_est": "Est. Max",
+ "col_type": "Type",
+ "type_continuous": "Continuous",
+ "type_flag": "Flag",
+
+ # Chart titles (callback-generated)
+ "cb_chart_state_predicted": "Predicted Reactor State",
+ "cb_chart_operational_health": "Operational Health",
+ "cb_chart_conversion_x": "Conversion X",
+ "cb_chart_species_dist": "Species Distribution",
+ "cb_chart_sensitivity": "Sensitivity X / T₀ × T_obs",
+ "cb_chart_model_comp": "MLP/RNN/CNN Comparison (selected: {model})",
+ "cb_chart_delta_x_feature": "ΔX per Feature (±1σ) — {model}",
+ "cb_chart_envelope": "Operational Envelope: X(%) — {model}",
+ "cb_chart_history": "Prediction History (last 30)",
+ "cb_chart_loss_curve": "Loss Curve (Train vs Validation)",
+ "cb_chart_metrics_arch": "Metrics per Architecture (MLP / RNN / CNN)",
+ "cb_chart_perf_radar": "Performance Radar (normalized, 1 = best)",
+ "cb_chart_feat_dist": "Feature Distribution (z-score)",
+ "cb_chart_target_dist": "Target Distribution (z-score)",
+ "cb_chart_corr": "Correlation Matrix (domain-knowledge estimate)",
+ "cb_chart_pca": "Explained Variance — Feature PCA",
+ "cb_chart_error_dist": "Prediction Error Distribution per Target",
+
+ # Axis / legend labels
+ "axis_value": "Value",
+ "axis_metric": "Metric",
+ "axis_z_score": "Z-score (σ)",
+ "axis_explained_var": "Explained Variance (%)",
+ "axis_pca_component": "Principal Component",
+ "axis_error_unit": "Error (target unit)",
+ "axis_valve_position": "Valve Position",
+ "axis_prediction_idx": "Prediction #",
+ "axis_percent_scale": "% scale",
+ "axis_epoch": "Epoch",
+ "axis_loss_mse": "Loss (MSE)",
+ "marker_current": "Current",
+ "marker_current_point": "Current point",
+ "legend_state": "State",
+ "legend_train_loss": "Train Loss",
+ "legend_val_loss": "Val Loss",
+ "legend_variance_individual": "Individual variance",
+ "legend_variance_cumulative": "Cumulative variance",
+ "legend_anti_fouling": "Anti-fouling",
+ "legend_valve_ok": "Valve OK",
+ "legend_sensor_t_ok": "T Sensor OK",
+ "legend_comm_ok": "Comm. OK",
+ "trace_conversion_x_pct": "Conversion X (%)",
+ "trace_cat_act_pct": "Cat. Act. (%)",
+ "trace_fouling_scale": "Fouling (% scale)",
+ "trace_t0_k": "T₀ (K)",
+
+ # Dynamic status
+ "status_no_model": "No model selected",
+ "status_model_ready": "✓ {model} ready",
+ "status_model_not_trained": "⚠ {model} not trained",
+ "status_model_ready_opt": "✓ {model} ready for optimization",
+ "status_invalid_prediction": "Invalid prediction",
+ "status_extrapolation_severe": "⚠ Severe extrapolation: ",
+ "status_extrapolation_outside": "ℹ Outside dense zone: ",
+ "status_low_agreement": "⚠ Low agreement (CV={cv:.0f}%) — {preds}",
+ "status_moderate_agreement": "ℹ Moderate agreement (CV={cv:.0f}%) — {preds}",
+ "status_high_agreement": "✓ High agreement (CV={cv:.0f}%) — {preds}",
+ "status_training_active": "● Training…",
+ "status_training_error": "● Error: {error}",
+ "status_training_done": "● Done",
+
+ # Empty figures / placeholders
+ "fig_empty_default": "Awaiting inputs…",
+ "fig_empty_pareto": "No Pareto",
+ "fig_empty_parcoords": "No parallel coordinates",
+ "fig_empty_3d": "No 3D view",
+ "fig_empty_convergence": "No convergence",
+ "fig_empty_heatmap": "No heatmap",
+ "fig_empty_distribution": "No distribution",
+ "fig_empty_topsis": "No TOPSIS",
+ "fig_empty_clusters": "No clusters",
+ "fig_empty_hypervolume": "No hypervolume",
+ "fig_empty_decision_space": "No decision space",
+ "msg_start_training_loss": "Start training to see loss curves",
+ "msg_train_for_metrics": "Train a model to see metrics",
+ "msg_results_not_yet": "training_results.csv does not exist yet.",
+ "msg_error_reading_results": "Error reading results: {err}",
+ "msg_history_hint": "Change inputs to accumulate prediction history",
+ "msg_sensitivity_unavailable": "Sensitivity unavailable",
+ "msg_models_unavailable": "Models unavailable for comparison",
+ "msg_envelope_unavailable": "Operational envelope unavailable",
+ "msg_no_training_result": "No result recorded yet.",
+ "msg_csv_missing_arch": "CSV missing architecture column.",
+ "msg_csv_no_metric": "No numeric metric available in CSV.",
+ "msg_need_two_archs": "Train at least 2 architectures to compare on the radar.",
+ "msg_training_started": "🟡 Training started…",
+ "msg_training_completed": "✅ Training completed successfully!",
+ "msg_training_failed": "❌ Failure: {err}",
+ "legend_best": "Best",
+ "metric_mae_real": "MAE (real)",
+ "metric_val_loss_scaled": "Val Loss (scaled)",
+ "metric_r2": "R²",
+ "metric_rmse": "RMSE",
+ "metric_epochs": "Epochs",
+ # Optimization progress / status
+ "opt_status_completed": "Optimization completed — {n} solutions in {elapsed:.1f}s",
+ "opt_status_failure": "Failure",
+ "opt_status_optimizing": "Optimizing... Generation {gen}/{total} | Feasible: {feasible}",
+ "opt_generation_final": "Final generation: {gen}",
+ "opt_generation_progress": "Generation {gen} / {total}",
+ "opt_error_prefix": "Error: {err}",
+ # Data File Manager (DFM)
+ "dfm_kpi_files": "Files",
+ "dfm_kpi_size": "Size",
+ "dfm_kpi_rows": "Rows",
+ "dfm_kpi_features_targets": "Features/Targets",
+ "dfm_preview_placeholder": "(select a file above)",
+ "dfm_preview_invalid": "(invalid selection)",
+ "dfm_preview_no_path": "(file without path)",
+ "dfm_preview_read_err": "Preview read error: {err}",
+ "dfm_preview_stats": "Showing {shown} of {total:,} rows · {cols} columns · {size}",
+ "dfm_discover_failed": "File discovery failed: {err}",
+ "dfm_confirm_delete": "Are you sure you want to delete this file?\n\n• File: {filename}\n• Type: {kind}\n• Path: {path}\n\nThis action cannot be undone.",
+ "dfm_upload_summary": "Upload: {ok} ok / {fail} failures",
+ "dfm_upload_no_message": "(no message)",
+
+ # Feature / target labels
+ "feat_flow": "Feed Flow",
+ "feat_ca0": "CA₀ Feed",
+ "feat_cb0": "CB₀ Feed",
+ "feat_tf": "Tf Feed",
+ "feat_tamb": "T_amb Feed",
+ "feat_tobs": "T_obs",
+ "feat_valve": "Valve",
+ "feat_wj": "w_j",
+
+ # Short labels for subplots / axes
+ "short_x_pct": "Conversion (%)",
+ "short_cat_act": "Cat. Act. (%)",
+ "short_fouling_um": "Fouling (µm)",
+ "short_t0_k": "T₀ (K)",
+
+ # Target names for analytics (violin, heatmap)
+ "target_CA0": "CA₀",
+ "target_CB0": "CB₀",
+ "target_CP0": "CP₀",
+ "target_CS0": "CS₀",
+ "target_T0": "T₀",
+ "target_X": "Conversion",
+ "target_fouling": "Fouling",
+ "target_cat_activity": "Cat. Activity",
+
+ # Optimization chart titles/axes
+ "opt_fig_pareto_title": "Pareto Front (Conversion × Fouling)",
+ "opt_fig_pareto_x": "Maximum Conversion X (%)",
+ "opt_fig_pareto_y": "Minimum Fouling (µm)",
+ "opt_fig_parcoords_title": "Parallel Coordinates — Variables vs Objectives",
+ "opt_fig_3d_title": "3D Space — Flow × T_feed × Conversion",
+ "opt_fig_3d_x": "Flow (m³/s)",
+ "opt_fig_3d_y": "T_feed (K)",
+ "opt_fig_3d_z": "Conversion X (%)",
+ "opt_fig_convergence_title": "NSGA-II Convergence (generations)",
+ "opt_fig_convergence_x": "Generation",
+ "opt_fig_convergence_y": "Objective Value",
+ "opt_fig_heatmap_title": "Correlation — Variables × Objectives",
+ "opt_fig_violin_title": "Objectives Distribution",
+ "opt_fig_topsis_title": "TOPSIS Ranking — Best Solutions",
+ "opt_fig_topsis_x": "TOPSIS Score",
+ "opt_fig_topsis_y": "Solution #",
+ "opt_fig_clusters_title": "Solution Clusters (k-means)",
+ "opt_fig_hypervolume_title": "Hypervolume Evolution",
+ "opt_fig_hypervolume_x": "Generation",
+ "opt_fig_hypervolume_y": "Hypervolume",
+ "opt_fig_decision_title": "Decision Space — Chosen Variables",
+ "opt_axis_generation": "Generation",
+ "opt_axis_solution": "Solution",
+ "opt_axis_conversion_pct": "Conversion (%)",
+ "opt_axis_fouling_um": "Fouling (µm)",
+ "opt_axis_value": "Value",
+ "opt_axis_n_feasible": "# Feasible",
+ "opt_axis_spread": "Spread",
+ "opt_axis_topsis_score": "TOPSIS Score (0=worst → 1=best)",
+ "opt_label_conversion": "Conversion",
+ "opt_label_fouling": "Fouling",
+ "opt_label_pareto_front": "Pareto Front",
+ "opt_label_dominated": "Dominated",
+ "opt_label_utopia": "Utopia Point",
+ "opt_label_avg_x": "Avg. X",
+ "opt_label_best_x": "Best X",
+ "opt_label_centroids": "Centroids",
+ "opt_label_centroid": "Centroid",
+ "opt_label_diversity": "Diversity",
+ "opt_label_feasible_plural": "Feasible",
+ "opt_label_max": "Max.",
+ "opt_label_mean": "Mean",
+ # Cluster names
+ "opt_cluster_balanced": "Balanced",
+ "opt_cluster_high_x_high_f": "High X / High Fouling",
+ "opt_cluster_low_f_low_x": "Low Fouling / Low X",
+ # Subplot titles
+ "opt_sub_avg_x": "Avg. Feasible X (%)",
+ "opt_sub_best_x": "Best X (%) × Generations",
+ "opt_sub_diversity": "Diversity (spread)",
+ "opt_sub_feasibility": "Feasibility per Generation",
+ "opt_sub_feasible_by_gen": "Feasible Solutions per Generation",
+ "opt_sub_hv_by_gen": "Hypervolume per Generation",
+ # Full figure titles
+ "opt_fig_3d_perf_title": "3D Performance Space",
+ "opt_fig_clusters_kmeans_title": "K-Means Clusters of the Pareto Front (k={k})",
+ "opt_fig_clusters_pareto_title": "Pareto Clusters",
+ "opt_fig_convergence_metrics_title": "NSGA-II Convergence Metrics",
+ "opt_fig_decision_vs_x_title": "Decision Space — Variables vs X (%)",
+ "opt_fig_evolution_title": "NSGA-II Evolution",
+ "opt_fig_hv_by_gen_title": "Hypervolume per Generation",
+ "opt_fig_opmap_title": "Operating Map: Flow × T_obs × Conversion",
+ "opt_fig_topsis_multi_title": "TOPSIS Ranking — Multi-Criteria (Top-15)",
+ "opt_fig_violin_out_title": "Output Distribution — Optimal Solutions",
+ # Messages
+ "msg_no_feasible_relax": "No feasible solutions. Relax the constraints.",
+ "msg_need_2_feasible": "Requires ≥ 2 feasible solutions.",
+ "msg_no_feasible_3d": "No feasible solutions for 3D.",
+ "msg_no_convergence_hist": "No convergence history.",
+ "msg_need_3_heatmap": "Requires ≥ 3 solutions for the operating map.",
+ "msg_need_2_distribution": "Requires ≥ 2 solutions for distribution.",
+ "msg_need_2_topsis": "Requires ≥ 2 solutions for TOPSIS ranking.",
+ "msg_topsis_unavailable": "TOPSIS unavailable.",
+ "msg_need_4_cluster": "Requires ≥ 4 solutions for clustering.",
+ "msg_cluster_missing_col": "Column '{col}' missing — clustering unavailable.",
+ "msg_sklearn_required": "scikit-learn required for clustering.",
+ "msg_cluster_cols_insufficient": "Not enough columns for clustering.",
+ "msg_cluster_rows_insufficient": "Not enough valid rows after cleanup.",
+ "msg_cluster_zero_variance": "Zero variance in a feature — clustering not meaningful.",
+ "msg_cluster_error": "Clustering error: {err}",
+ "msg_no_hv_hist": "No Hypervolume history.",
+ "msg_need_3_decision": "Requires ≥ 3 solutions for the decision space.",
+ "msg_decision_cols_insufficient": "Not enough decision columns.",
+ },
+
+ # ─────────────────────────────────────────────────────────────────────
+ # ESPAÑOL
+ # ─────────────────────────────────────────────────────────────────────
+ "es": {
+ # Header
+ "app_title": "CSTRChemIA - CSTR Simulator & Optimizer",
+ "app_subtitle": "Simulación y optimización multi-objetivo de reactores CSTR",
+ "lang_label": "Idioma",
+ "footer_text": "CSTRChemIA - CSTR Simulator & Optimizer — Impulsado por Dash + TensorFlow + pymoo",
+
+ # Tabs
+ "tab_simulation": "⚗ Simulación",
+ "tab_optimization": "🎯 Optimización",
+ "tab_training": "🎓 Entrenamiento",
+ "tab_analytics": "📊 Analíticas",
+ "tab_help": "❔ Ayuda",
+ "tab_about": "ℹ Acerca",
+
+ # Sections
+ "section_surrogate_model": "Modelo Sustituto",
+ "section_process_variables": "Variables de Proceso",
+ "section_operational_flags": "Banderas Operativas",
+ "section_operational_constraints": "Restricciones Operativas",
+ "section_nsga_params": "Parámetros NSGA-II",
+ "section_optimization_summary": "Resumen de Optimización",
+ "section_optimization_results": "Resultados de Optimización",
+ "section_training_structure": "Estructura de Datos de Entrenamiento",
+ "section_training_history": "Historial de Entrenamientos",
+ "section_data_files_manager": "Administrador de Archivos de Datos",
+
+ # Fields
+ "field_architecture": "Arquitectura",
+ "field_architecture_sel": "Arquitectura seleccionada:",
+ "field_status": "Estado:",
+ "field_feed_flow": "Caudal de Alim.",
+ "field_ca0_feed": "CA₀ Alim.",
+ "field_cb0_feed": "CB₀ Alim.",
+ "field_tf_feed": "Tf Alim.",
+ "field_tamb_feed": "T amb Alim.",
+ "field_tobs": "T observada",
+ "field_valve_position": "Posición de Válvula (0–1)",
+ "field_jacket_flow": "Caudal Camisa w_j",
+ "field_t_max": "T0 Máxima",
+ "field_conversion_min": "Conversión Mín. (%)",
+ "field_cat_activity_min": "Actividad Cat. Mín.",
+ "field_fouling_max": "Incrustación Máx.",
+ "field_cp0_min": "CP0 Mín.",
+ "field_vazao_min": "Caudal mín.",
+ "field_vazao_max": "Caudal máx.",
+ "field_valve_min": "Válvula mín. (%)",
+ "field_valve_max": "Válvula máx. (%)",
+ "field_tobs_min": "T_obs mín.",
+ "field_tobs_max": "T_obs máx.",
+ "field_wj_min": "w_j mín.",
+ "field_wj_max": "w_j máx.",
+ "field_pop_size": "Tamaño de Población",
+ "field_n_gen": "Número de Generaciones",
+ "field_crossover_prob": "Prob. Cruce (SBX)",
+ "field_mutation_prob": "Prob. Mutación (PM)",
+
+ # Sub-sections
+ "subsection_process_limits": "Límites de proceso",
+ "subsection_production_limits": "Restricciones de producción",
+ "subsection_search_ranges": "Rangos de búsqueda (variables de decisión)",
+
+ # KPIs
+ "kpi_conversion_x": "Conversión X",
+ "kpi_t0_predicted": "T₀ Predicho",
+ "kpi_ca0_predicted": "CA₀ Predicho",
+ "kpi_cb0_predicted": "CB₀ Predicho",
+ "kpi_cp0_product": "CP₀ (Producto)",
+ "kpi_cs0_byproduct": "CS₀ (Subprod.)",
+ "kpi_fouling": "Incrustación",
+ "kpi_cat_activity": "Actividad Cat.",
+
+ # Flags
+ "flag_valve_failure": "Falla de Válvula",
+ "flag_maintenance": "Mantenimiento",
+ "flag_sensor_t_failure": "Falla Sensor T",
+ "flag_comm_failure": "Falla Comunicación",
+ "flag_valve_tooltip": "Activa si hay falla en la válvula de control",
+ "flag_maint_tooltip": "Indica que el reactor ha pasado por mantenimiento",
+ "flag_sensor_t_tooltip": "Sensor de temperatura defectuoso",
+ "flag_comm_tooltip": "Pérdida de comunicación con el PLC",
+
+ # Buttons
+ "btn_run_optimization": "Ejecutar Optimización",
+ "btn_start_training": "Iniciar Entrenamiento",
+ "btn_update_metrics": "Actualizar métricas",
+ "btn_export_csv": "Exportar CSV",
+ "btn_refresh": "Actualizar",
+ "btn_delete": "Borrar",
+ "btn_confirm": "Confirmar",
+ "btn_cancel": "Cancelar",
+
+ # Status / messages
+ "status_waiting": "● Esperando",
+ "status_awaiting_inputs": "Esperando entradas…",
+ "status_operation_healthy": "✓ Operación saludable",
+ "status_operation_degraded": "⚠ Operación degradada",
+ "status_critical": "✗ Atención: condición crítica",
+ "status_model_label": "Modelo",
+ "msg_prediction_failed": "La predicción falló",
+ "msg_invalid_prediction": "Predicción fuera de los límites físicos",
+ "msg_run_first": "Ejecute la optimización para ver los resultados.",
+ "msg_click_update": "Haga clic en 'Actualizar métricas' después de entrenar para ver resultados.",
+ "msg_select_file": "(seleccione un archivo arriba)",
+
+ # Training
+ "training_logs": "Registros de Entrenamiento",
+ "training_features_title": "📥 Características (12):",
+ "training_targets_title": "📤 Objetivos (8):",
+
+ # Analytics / DFM
+ "dfm_subtitle": "Visualice, descargue, elimine o agregue archivos CSV al repositorio.",
+ "dfm_filter_kind": "🔍 Filtrar por tipo",
+ "dfm_filter_source": "📁 Filtrar por fuente",
+ "dfm_search": "🔎 Buscar (el nombre contiene)",
+ "dfm_total_files": "Archivos totales",
+ "dfm_total_rows": "Filas totales",
+ "dfm_total_bytes": "Tamaño total",
+ "dfm_drop_files": "Arrastre CSV aquí o haga clic para seleccionar",
+
+ # Misc
+ "info_general": "Información",
+ "warning_general": "Advertencia",
+ "error_general": "Error",
+ "loading": "Cargando…",
+ "faixa": "Rango",
+
+ # Páginas
+ "sim_page_title": "CSTR – Simulación",
+ "sim_page_subtitle": "Ingrese las condiciones de operación — el modelo sustituto predice concentraciones, temperatura, conversión, incrustación y actividad catalítica en tiempo real.",
+ "sim_results_section": "Resultados de la Predicción",
+ "opt_page_title": "CSTR — Optimización Multi-Objetivo",
+ "opt_page_subtitle": "NSGA-II encuentra la frontera de Pareto que maximiza X y minimiza la incrustación respetando todas las restricciones.",
+ "opt_info_alert": "NSGA-II busca máxima conversión y mínima incrustación respetando todas las restricciones anteriores.",
+ "opt_results_header": "Resultados de la Optimización",
+ "opt_feasible_table_title": "Tabla de Soluciones Factibles",
+ "training_page_title": "Entrenar Modelo Sustituto",
+ "training_page_subtitle": "Entrenamiento de los surrogates MLP / RNN / CNN con hiperparámetros previamente optimizados",
+ "training_all_models": "Todos (MLP+RNN+CNN)",
+ "training_concentrations": "concentraciones",
+ "training_internal_temp": "temperatura interna",
+ "training_conversion": "conversión",
+ "training_data_origin": "Origen de los datos",
+ "training_data_origin_text": "Archivos en simulation/ (plano) o ../Simulacoes/simulacao_CSTR/sim_*/observations/ (anidado). Los hiperparámetros vienen de training/best_hps_*.json y se sincronizan automáticamente antes del entrenamiento.",
+ "analytics_page_title": "Analíticas — Datos & Benchmarking",
+ "analytics_page_subtitle": "Exploración detallada del dataset de entrenamiento, distribuciones, correlaciones y comparación cuantitativa entre las tres arquitecturas de surrogate.",
+ "analytics_card_title": "Explorador de Datos & Benchmark de Modelos",
+ "analytics_card_subtitle": "Visualice las distribuciones del dataset de entrenamiento, correlaciones entre features y targets, y compare las arquitecturas MLP / RNN / CNN.",
+ "analytics_click_refresh": "Haga clic en 'Cargar / Actualizar' para ver las estadísticas.",
+ "analytics_load_data_msg": "Cargue los datos para ver la tabla.",
+ "analytics_stats_section": "Estadísticas Descriptivas del Dataset",
+ "btn_load_refresh": "Cargar / Actualizar",
+
+ # FormText hints
+ "ft_t_max": "Temperatura máxima permitida",
+ "ft_x_min": "X mínimo requerido",
+ "ft_cat_min": "cat_activity ≥ este valor",
+ "ft_fouling_max": "Espesor máximo de incrustación",
+ "ft_cp0_min": "Producción mínima de producto",
+ "ft_crossover_prob": "0.8–1.0 recomendado",
+ "ft_mutation_prob": "0.05–0.2 recomendado",
+
+ # Gráficos — Simulation
+ "chart_sim_targets": "Objetivos Predichos",
+ "chart_sim_health": "Salud Operativa",
+ "chart_sim_gauge": "Indicador de Conversión",
+ "chart_sim_donut": "Distribución de Especies",
+ "chart_sim_sensitivity": "Sensibilidad X × T_obs",
+ "chart_sim_model_comparison": "Comparación MLP vs RNN vs CNN",
+ "chart_sim_feature_contrib": "Contribución de las Características (ΔX)",
+ "chart_sim_envelope": "Envolvente Operativa (T_obs × Válvula → X)",
+ "chart_sim_history": "Histórico de Predicciones (últimas 30)",
+
+ # Gráficos — Optimization
+ "chart_opt_pareto": "Frontera de Pareto: Conversión × Incrustación",
+ "chart_opt_parcoords": "Coordenadas Paralelas",
+ "chart_opt_convergence": "Convergencia NSGA-II",
+ "chart_opt_3d": "Espacio 3D: X × Incrustación × Actividad Catalítica",
+ "chart_opt_heatmap": "Mapa Operativo: Caudal × T_obs",
+ "chart_opt_violin": "Distribución de las Salidas Óptimas",
+ "chart_opt_decision_space": "Espacio de Decisión (Matriz de Dispersión)",
+ "chart_opt_topsis": "Ranking TOPSIS Multi-Criterio (Top-15)",
+ "chart_opt_cluster": "Agrupamientos K-Means de la Frontera de Pareto",
+ "chart_opt_hypervolume": "Hipervolumen y Factibilidad por Generación",
+
+ # Gráficos — Training
+ "chart_training_loss": "Curva de Pérdida (Entrenamiento vs Validación) — en vivo",
+ "chart_training_metrics": "Métricas por Arquitectura (R², MAE, RMSE por objetivo)",
+ "chart_training_radar": "Radar de Desempeño Global (MLP vs RNN vs CNN)",
+
+ # Gráficos — Analytics
+ "chart_analytics_feat_dist": "Distribución de las Características (box plots normalizados)",
+ "chart_analytics_target_dist": "Distribución de los Objetivos (violin plots)",
+ "chart_analytics_corr": "Matriz de Correlación Características × Objetivos",
+ "chart_analytics_metrics_bar": "Métricas por Objetivo — Comparación MLP/RNN/CNN",
+ "chart_analytics_arch_radar": "Radar de Desempeño Global por Arquitectura",
+ "chart_analytics_pca": "Varianza Explicada (PCA de Características)",
+ "chart_analytics_error_dist": "Distribución del Error de Predicción por Objetivo",
+
+ # DFM
+ "dfm_all": "Todos",
+ "dfm_all_fem": "Todas",
+ "dfm_click_refresh": "Haga clic en 🔄 Actualizar para listar los archivos disponibles.",
+ "dfm_kind_features": "Características",
+ "dfm_kind_targets": "Objetivos",
+ "dfm_kind_complete": "Datos Completos",
+ "dfm_kind_observed": "Observados",
+ "dfm_kind_truth": "Verdad",
+ "dfm_kind_training_result": "Resultado de Entrenamiento",
+ "dfm_kind_other": "Otros",
+ "dfm_preview": "Vista previa",
+ "dfm_add_file_title": "Agregar archivo (features o targets)",
+ "dfm_name_pattern_required": "Patrón de nombre obligatorio",
+ "dfm_or": "o",
+ "dfm_save_location_note": "El archivo se guardará en CSTRChemIA/simulation/ (creado automáticamente si es necesario). Las colisiones de nombre reciben sufijo de timestamp.",
+ "dfm_drop_files_here": "Arrastre archivos aquí",
+ "dfm_or_click_to_select": "o haga clic para seleccionar (.csv)",
+ "dfm_confirm_delete_msg": "¿Está seguro de que desea eliminar este archivo?\nEsta acción no se puede deshacer.",
+
+ # Columnas de la tabla de archivos
+ "col_kind": "Tipo",
+ "col_file": "Archivo",
+ "col_source": "Fuente",
+ "col_rows": "Filas",
+ "col_cols": "Columnas",
+ "col_size": "Tamaño",
+ "col_modified": "Modificado",
+ "col_path": "Ruta",
+
+ # KPIs del Resumen de Optimización
+ "kpi_feasible_solutions": "Soluciones Factibles",
+ "kpi_best_x": "Mejor X",
+ "kpi_min_fouling": "Mín. Incrustación",
+ "kpi_best_cat_act": "Mejor Act. Cat.",
+ "kpi_best_cp0": "Mejor CP₀",
+ "kpi_time": "Tiempo",
+ "kpi_avg_x": "Media X",
+ "kpi_avg_fouling": "Media Incrustación",
+ "kpi_avg_t0": "Media T₀",
+ "kpi_hypervolume": "Hipervolumen",
+ "kpi_spread": "Dispersión",
+ "msg_no_feasible": "No se encontró solución factible. Relaje las restricciones.",
+ "msg_best_solution": "🏆 Mejor solución (mayor X): ",
+ "label_flow": "Caudal",
+ "label_tobs": "T_obs",
+ "label_valve": "Válvula",
+ "label_t0": "T₀",
+
+ # KPIs Analíticas / Dataset
+ "kpi_total_samples": "Total de Muestras",
+ "kpi_train": "Entrenamiento",
+ "kpi_validation": "Validación",
+ "kpi_test": "Prueba",
+ "kpi_features": "Características",
+ "kpi_targets": "Objetivos",
+
+ # Columnas de la tabla de estadísticas
+ "col_feature": "Característica",
+ "col_target": "Objetivo",
+ "col_mean": "Media",
+ "col_std": "Desv. Estándar",
+ "col_min_est": "Mín. estimado",
+ "col_max_est": "Máx. estimado",
+ "col_type": "Tipo",
+ "type_continuous": "Continua",
+ "type_flag": "Bandera",
+
+ # Títulos de gráficos (callbacks)
+ "cb_chart_state_predicted": "Estado Predicho del Reactor",
+ "cb_chart_operational_health": "Salud Operativa",
+ "cb_chart_conversion_x": "Conversión X",
+ "cb_chart_species_dist": "Distribución de las Especies",
+ "cb_chart_sensitivity": "Sensibilidad X / T₀ × T_obs",
+ "cb_chart_model_comp": "Comparación MLP/RNN/CNN (seleccionado: {model})",
+ "cb_chart_delta_x_feature": "ΔX por Característica (±1σ) — {model}",
+ "cb_chart_envelope": "Envolvente Operativa: X(%) — {model}",
+ "cb_chart_history": "Histórico de Predicciones (últimas 30)",
+ "cb_chart_loss_curve": "Curva de Pérdida (Entrenamiento vs Validación)",
+ "cb_chart_metrics_arch": "Métricas por Arquitectura (MLP / RNN / CNN)",
+ "cb_chart_perf_radar": "Radar de Desempeño (normalizado, 1 = mejor)",
+ "cb_chart_feat_dist": "Distribución de las Características (z-score)",
+ "cb_chart_target_dist": "Distribución de los Objetivos (z-score)",
+ "cb_chart_corr": "Matriz de Correlación (estimación de domain knowledge)",
+ "cb_chart_pca": "Varianza Explicada — PCA de Características",
+ "cb_chart_error_dist": "Distribución del Error de Predicción por Objetivo",
+
+ # Ejes / leyendas
+ "axis_value": "Valor",
+ "axis_metric": "Métrica",
+ "axis_z_score": "Z-score (σ)",
+ "axis_explained_var": "Varianza Explicada (%)",
+ "axis_pca_component": "Componente Principal",
+ "axis_error_unit": "Error (unidad del objetivo)",
+ "axis_valve_position": "Posición de Válvula",
+ "axis_prediction_idx": "Nº de predicción",
+ "axis_percent_scale": "Escala %",
+ "axis_epoch": "Época",
+ "axis_loss_mse": "Pérdida (MSE)",
+ "marker_current": "Actual",
+ "marker_current_point": "Punto actual",
+ "legend_state": "Estado",
+ "legend_train_loss": "Pérdida Entrenamiento",
+ "legend_val_loss": "Pérdida Validación",
+ "legend_variance_individual": "Varianza individual",
+ "legend_variance_cumulative": "Varianza acumulada",
+ "legend_anti_fouling": "Anti-incrustación",
+ "legend_valve_ok": "Válvula OK",
+ "legend_sensor_t_ok": "Sensor T OK",
+ "legend_comm_ok": "Comunicación OK",
+ "trace_conversion_x_pct": "Conversión X (%)",
+ "trace_cat_act_pct": "Act. Cat. (%)",
+ "trace_fouling_scale": "Incrustación (% escala)",
+ "trace_t0_k": "T₀ (K)",
+
+ # Estados dinámicos
+ "status_no_model": "Ningún modelo seleccionado",
+ "status_model_ready": "✓ {model} listo",
+ "status_model_not_trained": "⚠ {model} no entrenado",
+ "status_model_ready_opt": "✓ {model} listo para optimización",
+ "status_invalid_prediction": "Predicción inválida",
+ "status_extrapolation_severe": "⚠ Extrapolación severa: ",
+ "status_extrapolation_outside": "ℹ Fuera de la zona densa: ",
+ "status_low_agreement": "⚠ Baja concordancia (CV={cv:.0f}%) — {preds}",
+ "status_moderate_agreement": "ℹ Concordancia moderada (CV={cv:.0f}%) — {preds}",
+ "status_high_agreement": "✓ Alta concordancia (CV={cv:.0f}%) — {preds}",
+ "status_training_active": "● Entrenando…",
+ "status_training_error": "● Error: {error}",
+ "status_training_done": "● Concluido",
+
+ # Figuras vacías / placeholders
+ "fig_empty_default": "Esperando entradas…",
+ "fig_empty_pareto": "Sin Pareto",
+ "fig_empty_parcoords": "Sin coordenadas paralelas",
+ "fig_empty_3d": "Sin vista 3D",
+ "fig_empty_convergence": "Sin convergencia",
+ "fig_empty_heatmap": "Sin mapa de calor",
+ "fig_empty_distribution": "Sin distribución",
+ "fig_empty_topsis": "Sin TOPSIS",
+ "fig_empty_clusters": "Sin agrupamientos",
+ "fig_empty_hypervolume": "Sin hipervolumen",
+ "fig_empty_decision_space": "Sin espacio de decisión",
+ "msg_start_training_loss": "Inicie el entrenamiento para ver las curvas de pérdida",
+ "msg_train_for_metrics": "Entrene un modelo para ver las métricas",
+ "msg_results_not_yet": "El archivo training_results.csv aún no existe.",
+ "msg_error_reading_results": "Error al leer los resultados: {err}",
+ "msg_history_hint": "Cambie las entradas para acumular histórico de predicciones",
+ "msg_sensitivity_unavailable": "Sensibilidad no disponible",
+ "msg_models_unavailable": "Modelos no disponibles para comparación",
+ "msg_envelope_unavailable": "Envolvente operativa no disponible",
+ "msg_no_training_result": "Aún no hay resultados registrados.",
+ "msg_csv_missing_arch": "Al CSV le falta la columna de arquitectura.",
+ "msg_csv_no_metric": "No hay métricas numéricas disponibles en el CSV.",
+ "msg_need_two_archs": "Entrene al menos 2 arquitecturas para comparar en el radar.",
+ "msg_training_started": "🟡 Entrenamiento iniciado…",
+ "msg_training_completed": "✅ ¡Entrenamiento completado con éxito!",
+ "msg_training_failed": "❌ Falla: {err}",
+ "legend_best": "Mejor",
+ "metric_mae_real": "MAE (real)",
+ "metric_val_loss_scaled": "Val Pérdida (escalado)",
+ "metric_r2": "R²",
+ "metric_rmse": "RMSE",
+ "metric_epochs": "Épocas",
+ # Estado / progreso de la optimización
+ "opt_status_completed": "Optimización completada — {n} soluciones en {elapsed:.1f}s",
+ "opt_status_failure": "Falla",
+ "opt_status_optimizing": "Optimizando... Generación {gen}/{total} | Factibles: {feasible}",
+ "opt_generation_final": "Generación final: {gen}",
+ "opt_generation_progress": "Generación {gen} / {total}",
+ "opt_error_prefix": "Error: {err}",
+ # Gestor de archivos (DFM)
+ "dfm_kpi_files": "Archivos",
+ "dfm_kpi_size": "Tamaño",
+ "dfm_kpi_rows": "Filas",
+ "dfm_kpi_features_targets": "Características/Objetivos",
+ "dfm_preview_placeholder": "(seleccione un archivo arriba)",
+ "dfm_preview_invalid": "(selección inválida)",
+ "dfm_preview_no_path": "(archivo sin ruta)",
+ "dfm_preview_read_err": "Error al leer vista previa: {err}",
+ "dfm_preview_stats": "Mostrando {shown} de {total:,} filas · {cols} columnas · {size}",
+ "dfm_discover_failed": "Falla al descubrir archivos: {err}",
+ "dfm_confirm_delete": "¿Está seguro de que desea eliminar este archivo?\n\n• Archivo: {filename}\n• Tipo: {kind}\n• Ruta: {path}\n\nEsta acción no se puede deshacer.",
+ "dfm_upload_summary": "Subida: {ok} ok / {fail} fallos",
+ "dfm_upload_no_message": "(sin mensaje)",
+
+ # Etiquetas de atributos / objetivos
+ "feat_flow": "Caudal Alim.",
+ "feat_ca0": "CA₀ Alim.",
+ "feat_cb0": "CB₀ Alim.",
+ "feat_tf": "Tf Alim.",
+ "feat_tamb": "T_amb Alim.",
+ "feat_tobs": "T_obs",
+ "feat_valve": "Válvula",
+ "feat_wj": "w_j",
+
+ # Etiquetas cortas para subplots / ejes
+ "short_x_pct": "Conversión (%)",
+ "short_cat_act": "Act. Cat. (%)",
+ "short_fouling_um": "Incrustación (µm)",
+ "short_t0_k": "T₀ (K)",
+
+ # Nombres de objetivos para analytics (violin, heatmap)
+ "target_CA0": "CA₀",
+ "target_CB0": "CB₀",
+ "target_CP0": "CP₀",
+ "target_CS0": "CS₀",
+ "target_T0": "T₀",
+ "target_X": "Conversión",
+ "target_fouling": "Incrustación",
+ "target_cat_activity": "Act. Catalítica",
+
+ # Títulos/ejes de gráficos de optimización
+ "opt_fig_pareto_title": "Frente de Pareto (Conversión × Incrustación)",
+ "opt_fig_pareto_x": "Conversión máxima X (%)",
+ "opt_fig_pareto_y": "Incrustación mínima (µm)",
+ "opt_fig_parcoords_title": "Coordenadas Paralelas — Variables vs Objetivos",
+ "opt_fig_3d_title": "Espacio 3D — Caudal × T_feed × Conversión",
+ "opt_fig_3d_x": "Caudal (m³/s)",
+ "opt_fig_3d_y": "T_feed (K)",
+ "opt_fig_3d_z": "Conversión X (%)",
+ "opt_fig_convergence_title": "Convergencia de NSGA-II (generaciones)",
+ "opt_fig_convergence_x": "Generación",
+ "opt_fig_convergence_y": "Valor del Objetivo",
+ "opt_fig_heatmap_title": "Correlación — Variables × Objetivos",
+ "opt_fig_violin_title": "Distribución de los Objetivos",
+ "opt_fig_topsis_title": "Ranking TOPSIS — Mejores Soluciones",
+ "opt_fig_topsis_x": "Puntuación TOPSIS",
+ "opt_fig_topsis_y": "Solución #",
+ "opt_fig_clusters_title": "Agrupaciones de Soluciones (k-means)",
+ "opt_fig_hypervolume_title": "Evolución del Hipervolumen",
+ "opt_fig_hypervolume_x": "Generación",
+ "opt_fig_hypervolume_y": "Hipervolumen",
+ "opt_fig_decision_title": "Espacio de Decisión — Variables Elegidas",
+ "opt_axis_generation": "Generación",
+ "opt_axis_solution": "Solución",
+ "opt_axis_conversion_pct": "Conversión (%)",
+ "opt_axis_fouling_um": "Incrustación (µm)",
+ "opt_axis_value": "Valor",
+ "opt_axis_n_feasible": "N° Factibles",
+ "opt_axis_spread": "Dispersión (spread)",
+ "opt_axis_topsis_score": "Puntuación TOPSIS (0=peor → 1=mejor)",
+ "opt_label_conversion": "Conversión",
+ "opt_label_fouling": "Incrustación",
+ "opt_label_pareto_front": "Frente de Pareto",
+ "opt_label_dominated": "Dominadas",
+ "opt_label_utopia": "Punto Utopía",
+ "opt_label_avg_x": "Media X",
+ "opt_label_best_x": "Mejor X",
+ "opt_label_centroids": "Centroides",
+ "opt_label_centroid": "Centroide",
+ "opt_label_diversity": "Diversidad",
+ "opt_label_feasible_plural": "Factibles",
+ "opt_label_max": "Máx.",
+ "opt_label_mean": "Media",
+ # Cluster names
+ "opt_cluster_balanced": "Balanceado",
+ "opt_cluster_high_x_high_f": "Alta X / Alta Incrustación",
+ "opt_cluster_low_f_low_x": "Baja Incrustación / Baja X",
+ # Subplot titles
+ "opt_sub_avg_x": "Media X (%) Factibles",
+ "opt_sub_best_x": "Mejor X (%) × Generaciones",
+ "opt_sub_diversity": "Diversidad (spread)",
+ "opt_sub_feasibility": "Factibilidad por Generación",
+ "opt_sub_feasible_by_gen": "Soluciones Factibles por Generación",
+ "opt_sub_hv_by_gen": "Hipervolumen por Generación",
+ # Full figure titles
+ "opt_fig_3d_perf_title": "Espacio 3D de Desempeño",
+ "opt_fig_clusters_kmeans_title": "Clusters K-Means del Frente de Pareto (k={k})",
+ "opt_fig_clusters_pareto_title": "Clusters Pareto",
+ "opt_fig_convergence_metrics_title": "Métricas de Convergencia NSGA-II",
+ "opt_fig_decision_vs_x_title": "Espacio de Decisión — Variables vs X (%)",
+ "opt_fig_evolution_title": "Evolución NSGA-II",
+ "opt_fig_hv_by_gen_title": "Hipervolumen por Generación",
+ "opt_fig_opmap_title": "Mapa Operativo: Caudal × T_obs × Conversión",
+ "opt_fig_topsis_multi_title": "Ranking TOPSIS — Multi-Criterio (Top-15)",
+ "opt_fig_violin_out_title": "Distribución de Salidas — Soluciones Óptimas",
+ # Messages
+ "msg_no_feasible_relax": "Sin soluciones factibles. Relaje las restricciones.",
+ "msg_need_2_feasible": "Se requieren ≥ 2 soluciones factibles.",
+ "msg_no_feasible_3d": "Sin soluciones factibles para 3D.",
+ "msg_no_convergence_hist": "Sin historial de convergencia.",
+ "msg_need_3_heatmap": "Se requieren ≥ 3 soluciones para el mapa operativo.",
+ "msg_need_2_distribution": "Se requieren ≥ 2 soluciones para distribución.",
+ "msg_need_2_topsis": "Se requieren ≥ 2 soluciones para ranking TOPSIS.",
+ "msg_topsis_unavailable": "TOPSIS no disponible.",
+ "msg_need_4_cluster": "Se requieren ≥ 4 soluciones para clustering.",
+ "msg_cluster_missing_col": "Columna '{col}' ausente — clustering no disponible.",
+ "msg_sklearn_required": "scikit-learn es necesario para clustering.",
+ "msg_cluster_cols_insufficient": "Columnas insuficientes para clustering.",
+ "msg_cluster_rows_insufficient": "Filas válidas insuficientes tras la limpieza.",
+ "msg_cluster_zero_variance": "Varianza nula en alguna variable — clustering sin sentido.",
+ "msg_cluster_error": "Error de clustering: {err}",
+ "msg_no_hv_hist": "Sin historial de Hipervolumen.",
+ "msg_need_3_decision": "Se requieren ≥ 3 soluciones para el espacio de decisión.",
+ "msg_decision_cols_insufficient": "Columnas de decisión insuficientes.",
+ },
+}
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# API PÚBLICA
+# ══════════════════════════════════════════════════════════════════════════════
+def t(key: str, lang: str | None = None) -> str:
+ """
+ Retorna o texto traduzido para ``key`` no idioma ``lang``.
+
+ Ordem de fallback:
+ 1. ``TRANSLATIONS[lang][key]`` (se existir)
+ 2. ``TRANSLATIONS[DEFAULT_LANG][key]`` (se existir)
+ 3. A própria ``key`` (útil para localizar textos ainda não traduzidos)
+ """
+ lang = lang or DEFAULT_LANG
+ if lang in TRANSLATIONS and key in TRANSLATIONS[lang]:
+ return TRANSLATIONS[lang][key]
+ if key in TRANSLATIONS.get(DEFAULT_LANG, {}):
+ return TRANSLATIONS[DEFAULT_LANG][key]
+ return key
+
+
+def get_lang(value: Any) -> str:
+ """
+ Valida o código de idioma ``value`` — retorna ``DEFAULT_LANG`` se inválido.
+
+ Aceita str direta (vinda de ``Input('lang-selector', 'value')``) ou None.
+ """
+ if isinstance(value, str) and value in VALID_LANG_CODES:
+ return value
+ return DEFAULT_LANG
+
+
+def options_for_dropdown() -> list[dict[str, str]]:
+ """Formato pronto para ``dcc.Dropdown.options``."""
+ return list(LANGUAGES)
+
+
+def available_keys(lang: str = DEFAULT_LANG) -> list[str]:
+ """Retorna todas as chaves traduzidas para ``lang`` (útil em testes)."""
+ return sorted(TRANSLATIONS.get(lang, {}).keys())
+
+
+def missing_keys(lang: str) -> list[str]:
+ """Retorna chaves presentes em DEFAULT_LANG mas faltando em ``lang``."""
+ default = set(TRANSLATIONS.get(DEFAULT_LANG, {}))
+ other = set(TRANSLATIONS.get(lang, {}))
+ return sorted(default - other)
diff --git a/CSTRChemIA/core/logging.py b/CSTRChemIA/core/logging.py
new file mode 100644
index 0000000..8235c99
--- /dev/null
+++ b/CSTRChemIA/core/logging.py
@@ -0,0 +1,96 @@
+"""
+Logging centralizado e seguro para Windows / Python 3.13.
+
+No Windows, após `sys.stdout.reconfigure(...)` em threads secundárias o
+handle pode ficar inválido e qualquer `print(flush=True)` lança
+``OSError [Errno 22] Invalid argument``. Este módulo oferece:
+
+* :func:`safe_print` — substituto drop-in para ``print()`` que silencia
+ ``OSError`` / ``ValueError``.
+* :func:`get_logger` — fábrica de loggers padrão (nome do módulo como
+ prefixo) que escreve via ``safe_print`` para evitar o mesmo bug em
+ ``StreamHandler``.
+* :func:`setup_logging` — configura root logger na primeira chamada
+ (idempotente).
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import sys
+import threading
+from typing import Any
+
+_LOG_LOCK = threading.Lock()
+_CONFIGURED = False
+
+
+def safe_print(*args: Any, **kwargs: Any) -> None:
+ """``print()`` resiliente a handles de stdout inválidos (Windows)."""
+ kwargs.pop("flush", None)
+ try:
+ print(*args, **kwargs)
+ except (OSError, ValueError):
+ # stdout reconfigurado em thread background — perdemos a linha,
+ # mas não deixamos a aplicação morrer.
+ pass
+
+
+class _SafeStreamHandler(logging.Handler):
+ """StreamHandler que usa :func:`safe_print` em vez de gravar direto."""
+
+ def emit(self, record: logging.LogRecord) -> None:
+ try:
+ msg = self.format(record)
+ safe_print(msg)
+ except Exception: # noqa: BLE001
+ self.handleError(record)
+
+
+def setup_logging(level: int | str | None = None, fmt: str | None = None) -> None:
+ """Configura o root logger. Idempotente e thread-safe."""
+ global _CONFIGURED
+ with _LOG_LOCK:
+ if _CONFIGURED:
+ return
+
+ if level is None:
+ env = os.environ.get("CSTRCHEMIA_LOG_LEVEL", "INFO").upper()
+ level = getattr(logging, env, logging.INFO)
+ if isinstance(level, str):
+ level = getattr(logging, level.upper(), logging.INFO)
+
+ fmt = fmt or "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
+
+ root = logging.getLogger()
+ # Remove handlers existentes para evitar duplicação
+ for h in list(root.handlers):
+ root.removeHandler(h)
+
+ handler = _SafeStreamHandler()
+ handler.setFormatter(logging.Formatter(fmt))
+ root.addHandler(handler)
+ root.setLevel(level)
+
+ # Baixa ruído de libs pesadas
+ for noisy in ("tensorflow", "h5py", "matplotlib", "werkzeug", "urllib3"):
+ logging.getLogger(noisy).setLevel(logging.WARNING)
+
+ # Garante stdout line-buffered — em ambientes onde reconfigure falha,
+ # ainda assim ficamos com safe_print.
+ for stream in (sys.stdout, sys.stderr):
+ try:
+ if hasattr(stream, "reconfigure"):
+ stream.reconfigure(line_buffering=True)
+ except (OSError, ValueError, AttributeError):
+ pass
+
+ _CONFIGURED = True
+
+
+def get_logger(name: str) -> logging.Logger:
+ """Retorna um logger pronto para uso; garante que o root foi configurado."""
+ if not _CONFIGURED:
+ setup_logging()
+ return logging.getLogger(name)
diff --git a/CSTRChemIA/core/model_manifest.py b/CSTRChemIA/core/model_manifest.py
new file mode 100644
index 0000000..8776c81
--- /dev/null
+++ b/CSTRChemIA/core/model_manifest.py
@@ -0,0 +1,296 @@
+"""
+Manifesto dos modelos — ``surrogate_model/models.json``.
+
+Problemas do registry antigo
+----------------------------
+O registry atual (``services/model_registry.py``) descobre arquitecturas
+checando arquivos no disco e lê métricas globais de um CSV. Isso tem 3
+fragilidades:
+
+1. **Sem verificação de integridade** — um ``.keras`` corrompido só é
+ detectado quando o processo crasha no load.
+2. **Sem métricas por-target** — um MAE global de 0.01 pode esconder um
+ target (p.ex. ``fouling_thickness``) com MAE catastrófico.
+3. **Sem reprodutibilidade** — não sabemos com qual seed, qual commit de
+ código, nem quais hiperparâmetros o artefato foi gerado.
+
+Este módulo define :class:`ModelManifest` (um por arquitetura) e um
+arquivo ``models.json`` agregando todos. O treino escreve; o registry
+lê e enriquece :class:`ModelInfo`.
+
+Formato JSON
+------------
+::
+
+ {
+ "version": "1",
+ "generated_at": "2025-04-20T12:34:56Z",
+ "models": {
+ "MLP": { ... ModelManifest ... },
+ "RNN": { ... },
+ "CNN": { ... }
+ }
+ }
+"""
+
+from __future__ import annotations
+
+import json
+import os
+from dataclasses import asdict, dataclass, field
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+from core.logging import get_logger
+from core.safe_serialization import file_sha256
+
+log = get_logger(__name__)
+
+MANIFEST_VERSION = "1"
+MANIFEST_FILENAME = "models.json"
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# DATACLASSES
+# ══════════════════════════════════════════════════════════════════════════════
+@dataclass(frozen=True, slots=True)
+class TargetMetrics:
+ """Métricas de performance para um target específico."""
+ target: str
+ mae: float
+ rmse: float
+ r2: float
+
+
+@dataclass(frozen=True, slots=True)
+class ModelManifest:
+ """
+ Manifesto completo de UM modelo surrogate treinado.
+
+ Todos os caminhos de arquivo são *relativos* ao diretório do manifesto,
+ para que o bundle (models.json + .keras + scalers) seja realocável.
+ """
+
+ architecture: str
+ trained_at: str
+ seed: int
+ framework: str
+ # ---- artefatos e hashes
+ keras_file: str
+ keras_sha256: str
+ scaler_x_file: str
+ scaler_x_sha256: str
+ scaler_y_file: str
+ scaler_y_sha256: str
+ # ---- schema
+ n_features: int
+ n_targets: int
+ feature_names: tuple[str, ...]
+ target_names: tuple[str, ...]
+ # ---- métricas (escaladas? reais? escolhemos reais — unidade do target)
+ global_metrics: dict[str, float]
+ per_target_metrics: dict[str, dict[str, float]]
+ # ---- splits e hiperparâmetros
+ n_train: int = 0
+ n_val: int = 0
+ n_test: int = 0
+ hyperparameters: dict[str, Any] = field(default_factory=dict)
+ notes: str = ""
+
+ # -------------------------------------------------------- serialization
+ def to_dict(self) -> dict[str, Any]:
+ d = asdict(self)
+ d["feature_names"] = list(self.feature_names)
+ d["target_names"] = list(self.target_names)
+ return d
+
+ @classmethod
+ def from_dict(cls, d: dict[str, Any]) -> ModelManifest:
+ return cls(
+ architecture=d["architecture"],
+ trained_at=d["trained_at"],
+ seed=int(d["seed"]),
+ framework=d["framework"],
+ keras_file=d["keras_file"],
+ keras_sha256=d["keras_sha256"],
+ scaler_x_file=d["scaler_x_file"],
+ scaler_x_sha256=d["scaler_x_sha256"],
+ scaler_y_file=d["scaler_y_file"],
+ scaler_y_sha256=d["scaler_y_sha256"],
+ n_features=int(d["n_features"]),
+ n_targets=int(d["n_targets"]),
+ feature_names=tuple(d["feature_names"]),
+ target_names=tuple(d["target_names"]),
+ global_metrics=dict(d.get("global_metrics", {})),
+ per_target_metrics={
+ k: dict(v) for k, v in d.get("per_target_metrics", {}).items()
+ },
+ n_train=int(d.get("n_train", 0)),
+ n_val=int(d.get("n_val", 0)),
+ n_test=int(d.get("n_test", 0)),
+ hyperparameters=dict(d.get("hyperparameters", {})),
+ notes=str(d.get("notes", "")),
+ )
+
+ # -------------------------------------------------------- verificação
+ def verify_integrity(self, models_dir: str | os.PathLike) -> list[str]:
+ """
+ Recalcula SHA-256 dos artefatos e compara com o manifesto.
+ Retorna lista de mensagens de erro (vazia = OK).
+ """
+ errors: list[str] = []
+ base = Path(models_dir)
+ checks = [
+ (self.keras_file, self.keras_sha256, "modelo"),
+ (self.scaler_x_file, self.scaler_x_sha256, "scalerX"),
+ (self.scaler_y_file, self.scaler_y_sha256, "scalerY"),
+ ]
+ for name, expected, label in checks:
+ path = base / name
+ if not path.exists():
+ errors.append(f"{label} ausente: {path}")
+ continue
+ actual = file_sha256(path)
+ if actual != expected:
+ errors.append(
+ f"{label} com hash inconsistente: "
+ f"esperado={expected[:12]}..., atual={actual[:12]}..."
+ )
+ return errors
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# I/O
+# ══════════════════════════════════════════════════════════════════════════════
+def write_manifest(
+ models_dir: str | os.PathLike,
+ manifests: dict[str, ModelManifest],
+) -> Path:
+ """
+ Escreve ``models.json`` agregando todos os manifestos. Sobrescreve.
+
+ ``manifests`` é ``{architecture: ModelManifest}``.
+ """
+ path = Path(models_dir) / MANIFEST_FILENAME
+ doc = {
+ "version": MANIFEST_VERSION,
+ "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
+ "models": {arch: m.to_dict() for arch, m in manifests.items()},
+ }
+ tmp = path.with_suffix(".json.tmp")
+ with open(tmp, "w", encoding="utf-8") as f:
+ json.dump(doc, f, indent=2, sort_keys=False)
+ os.replace(tmp, path)
+ log.info("Manifesto escrito em %s (%d modelos)", path, len(manifests))
+ return path
+
+
+def read_manifest(models_dir: str | os.PathLike) -> dict[str, ModelManifest]:
+ """
+ Lê ``models.json``. Retorna dict vazio se ausente ou inválido.
+ """
+ path = Path(models_dir) / MANIFEST_FILENAME
+ if not path.exists():
+ return {}
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ doc = json.load(f)
+ except (OSError, json.JSONDecodeError) as e:
+ log.error("Falha ao ler %s: %s", path, e)
+ return {}
+
+ if not isinstance(doc, dict) or "models" not in doc:
+ log.error("Manifesto malformado em %s: falta chave 'models'", path)
+ return {}
+
+ out: dict[str, ModelManifest] = {}
+ for arch, payload in doc.get("models", {}).items():
+ try:
+ out[arch] = ModelManifest.from_dict(payload)
+ except (KeyError, TypeError, ValueError) as e:
+ log.warning("Entrada %s ignorada no manifesto (%s): %s", arch, e, payload)
+ return out
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# CONSTRUÇÃO (usado pelo pipeline de treino)
+# ══════════════════════════════════════════════════════════════════════════════
+def build_manifest(
+ *,
+ architecture: str,
+ models_dir: str | os.PathLike,
+ keras_filename: str,
+ scaler_x_filename: str,
+ scaler_y_filename: str,
+ seed: int,
+ feature_names: tuple[str, ...],
+ target_names: tuple[str, ...],
+ global_metrics: dict[str, float],
+ per_target_metrics: dict[str, dict[str, float]],
+ n_train: int = 0,
+ n_val: int = 0,
+ n_test: int = 0,
+ hyperparameters: dict[str, Any] | None = None,
+ framework: str | None = None,
+ notes: str = "",
+) -> ModelManifest:
+ """
+ Computa SHA-256 dos artefatos e monta o manifesto. Usado pelo script
+ de treino ao final da persistência.
+ """
+ base = Path(models_dir)
+ if framework is None:
+ framework = _detect_framework_version()
+
+ keras_sha = file_sha256(base / keras_filename)
+ sx_sha = file_sha256(base / scaler_x_filename)
+ sy_sha = file_sha256(base / scaler_y_filename)
+
+ return ModelManifest(
+ architecture=architecture,
+ trained_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
+ seed=int(seed),
+ framework=framework,
+ keras_file=keras_filename,
+ keras_sha256=keras_sha,
+ scaler_x_file=scaler_x_filename,
+ scaler_x_sha256=sx_sha,
+ scaler_y_file=scaler_y_filename,
+ scaler_y_sha256=sy_sha,
+ n_features=len(feature_names),
+ n_targets=len(target_names),
+ feature_names=tuple(feature_names),
+ target_names=tuple(target_names),
+ global_metrics=dict(global_metrics),
+ per_target_metrics={k: dict(v) for k, v in per_target_metrics.items()},
+ n_train=int(n_train),
+ n_val=int(n_val),
+ n_test=int(n_test),
+ hyperparameters=dict(hyperparameters or {}),
+ notes=notes,
+ )
+
+
+def update_manifest_entry(
+ models_dir: str | os.PathLike,
+ manifest: ModelManifest,
+) -> Path:
+ """
+ Mescla ``manifest`` (uma arquitetura) no arquivo ``models.json`` existente.
+ """
+ current = read_manifest(models_dir)
+ current[manifest.architecture] = manifest
+ return write_manifest(models_dir, current)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# UTIL
+# ══════════════════════════════════════════════════════════════════════════════
+def _detect_framework_version() -> str:
+ """Best-effort: 'tensorflow==X.Y.Z'. Falha silenciosa retorna 'unknown'."""
+ try:
+ import tensorflow as tf # type: ignore
+ return f"tensorflow=={tf.__version__}"
+ except Exception: # noqa: BLE001
+ return "unknown"
diff --git a/CSTRChemIA/core/safe_serialization.py b/CSTRChemIA/core/safe_serialization.py
new file mode 100644
index 0000000..8879167
--- /dev/null
+++ b/CSTRChemIA/core/safe_serialization.py
@@ -0,0 +1,236 @@
+"""
+Serialização segura dos scalers (sem pickle).
+
+``pickle.load`` executa código arbitrário no objeto desserializado — um
+arquivo ``.pkl`` malicioso compromete o processo. Este módulo oferece
+serialização JSON pura para os scalers do ``sklearn`` (StandardScaler,
+RobustScaler, QuantileTransformer, PowerTransformer) com verificação
+de hash SHA-256.
+
+Formato JSON
+------------
+::
+
+ {
+ "class": "StandardScaler",
+ "params": {...}, # parâmetros aprendidos (mean_, scale_, etc.)
+ "n_features_in": 12,
+ "feature_names": [...], # opcional
+ "version": "1",
+ "sha256": "abcdef..." # hash dos params (excluindo o campo sha256)
+ }
+
+Uso
+---
+::
+
+ from core.safe_serialization import dump_scaler_json, load_scaler_json
+ dump_scaler_json(scalerX, "scalerX.json", feature_names=FEATURE_COLS)
+ sc = load_scaler_json("scalerX.json")
+
+Compatibilidade
+---------------
+Quando o JSON não existe mas o ``.pkl`` legado existe, :func:`load_scaler_any`
+carrega o pickle (com aviso) e devolve o scaler. Migração gradual.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+
+from core.logging import get_logger
+
+log = get_logger(__name__)
+
+_SCALER_FORMAT_VERSION = "1"
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# HASH
+# ══════════════════════════════════════════════════════════════════════════════
+def _canonical_hash(obj: dict[str, Any]) -> str:
+ """SHA-256 determinístico de um dict (ignora ordem de chaves e o próprio sha)."""
+ payload = {k: v for k, v in obj.items() if k != "sha256"}
+ s = json.dumps(payload, sort_keys=True, separators=(",", ":"),
+ default=_json_default)
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()
+
+
+def _json_default(x: Any) -> Any:
+ """JSONEncoder default — converte numpy arrays em lists."""
+ if isinstance(x, np.ndarray):
+ return x.tolist()
+ if isinstance(x, (np.floating, np.integer)):
+ return float(x) if isinstance(x, np.floating) else int(x)
+ raise TypeError(f"{type(x).__name__} não é serializável")
+
+
+def file_sha256(path: str | os.PathLike) -> str:
+ """Hash SHA-256 de um arquivo (útil para verificar artefato .keras)."""
+ h = hashlib.sha256()
+ with open(path, "rb") as f:
+ for chunk in iter(lambda: f.read(1 << 16), b""):
+ h.update(chunk)
+ return h.hexdigest()
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# SERIALIZAÇÃO
+# ══════════════════════════════════════════════════════════════════════════════
+_SUPPORTED_SCALER_PARAMS: dict[str, list[str]] = {
+ # (class_name → lista de atributos aprendidos a persistir)
+ "StandardScaler": ["mean_", "scale_", "var_", "n_samples_seen_"],
+ "RobustScaler": ["center_", "scale_"],
+ "MinMaxScaler": ["min_", "scale_", "data_min_", "data_max_", "data_range_"],
+ "PowerTransformer": ["lambdas_"],
+ "QuantileTransformer": ["quantiles_", "references_"],
+}
+
+
+def _extract_params(scaler: Any) -> dict[str, Any]:
+ """Extrai parâmetros aprendidos do scaler. Levanta se tipo não suportado."""
+ cls_name = type(scaler).__name__
+ keys = _SUPPORTED_SCALER_PARAMS.get(cls_name)
+ if keys is None:
+ raise TypeError(
+ f"Scaler {cls_name} não suportado pela serialização JSON. "
+ f"Suportados: {list(_SUPPORTED_SCALER_PARAMS)}"
+ )
+ params: dict[str, Any] = {}
+ for attr in keys:
+ if hasattr(scaler, attr):
+ params[attr] = getattr(scaler, attr)
+ # Alguns atributos são int mas vêm como np.int — normaliza
+ if "n_samples_seen_" in params and hasattr(params["n_samples_seen_"], "item"):
+ params["n_samples_seen_"] = int(params["n_samples_seen_"])
+ return params
+
+
+def dump_scaler_json(
+ scaler: Any,
+ path: str | os.PathLike,
+ *,
+ feature_names: list[str] | None = None,
+) -> dict[str, Any]:
+ """
+ Serializa ``scaler`` para JSON em ``path`` (não usa pickle).
+
+ Retorna o dict persistido (útil para inspeção/testes).
+ """
+ cls_name = type(scaler).__name__
+ params = _extract_params(scaler)
+ # n_features_in_ padrão sklearn
+ n_features = int(getattr(scaler, "n_features_in_", len(next(iter(params.values())))))
+
+ doc: dict[str, Any] = {
+ "class": cls_name,
+ "version": _SCALER_FORMAT_VERSION,
+ "n_features_in": n_features,
+ "feature_names": list(feature_names) if feature_names else None,
+ "params": params,
+ }
+ doc["sha256"] = _canonical_hash(doc)
+
+ path = str(path)
+ tmp = path + ".tmp"
+ with open(tmp, "w", encoding="utf-8") as f:
+ json.dump(doc, f, separators=(",", ":"), default=_json_default)
+ os.replace(tmp, path)
+ log.info("Scaler %s salvo em %s (%d features, sha=%s...)",
+ cls_name, path, n_features, doc["sha256"][:12])
+ return doc
+
+
+def _apply_params(scaler: Any, params: dict[str, Any]) -> None:
+ """Injeta parâmetros aprendidos em um scaler instanciado."""
+ for k, v in params.items():
+ if isinstance(v, list):
+ setattr(scaler, k, np.asarray(v, dtype=np.float64))
+ else:
+ setattr(scaler, k, v)
+
+
+def load_scaler_json(path: str | os.PathLike, *, verify_hash: bool = True) -> Any:
+ """
+ Carrega scaler de JSON. Verifica SHA por padrão — ``verify_hash=False`` pula.
+ Retorna instância sklearn do scaler.
+
+ Raises
+ ------
+ ValueError
+ Formato inválido, classe não suportada, ou hash mismatch.
+ """
+ with open(path, "r", encoding="utf-8") as f:
+ doc = json.load(f)
+
+ if not isinstance(doc, dict) or "class" not in doc or "params" not in doc:
+ raise ValueError(f"Formato inválido em {path} — esperado dict com class/params")
+
+ if verify_hash:
+ expected = doc.get("sha256")
+ actual = _canonical_hash(doc)
+ if expected != actual:
+ raise ValueError(
+ f"Hash mismatch em {path}: esperado={expected!r} calculado={actual!r}"
+ )
+
+ cls_name = doc["class"]
+ params = doc["params"]
+
+ # Instancia a classe sem fit (só precisa do shell)
+ import sklearn.preprocessing as skp
+ cls = getattr(skp, cls_name, None)
+ if cls is None:
+ raise ValueError(f"Classe sklearn.preprocessing.{cls_name} não existe")
+
+ scaler = cls()
+ _apply_params(scaler, params)
+ # Garante n_features_in_ para sanity checks em .transform
+ scaler.n_features_in_ = int(doc.get("n_features_in", 0)) or None
+
+ log.debug("Scaler %s carregado de %s", cls_name, path)
+ return scaler
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# COMPATIBILIDADE: carrega .json OU .pkl (fallback)
+# ══════════════════════════════════════════════════════════════════════════════
+def load_scaler_any(
+ base_path: str | os.PathLike,
+ *,
+ verify_hash: bool = True,
+) -> Any:
+ """
+ Tenta carregar ``.json`` primeiro; cai para ``.pkl`` legado.
+
+ ``base_path`` pode ser ``scalerX`` ou ``scalerX.pkl`` — a extensão é
+ removida e tentamos ``.json`` em seguida. Migração gradual: assim que o
+ treino escrever JSON, o próximo start já usa o formato seguro.
+ """
+ p = Path(base_path)
+ stem_path = p.with_suffix("") if p.suffix in (".json", ".pkl") else p
+
+ json_path = stem_path.with_suffix(".json")
+ pkl_path = stem_path.with_suffix(".pkl")
+
+ if json_path.exists():
+ return load_scaler_json(json_path, verify_hash=verify_hash)
+
+ if pkl_path.exists():
+ log.warning(
+ "Scaler %s ainda em pickle legado; migre chamando dump_scaler_json(). "
+ "Risco: pickle executa código arbitrário.", pkl_path
+ )
+ import pickle # noqa: S403 — aceito temporariamente
+ with open(pkl_path, "rb") as f:
+ return pickle.load(f) # noqa: S301
+
+ raise FileNotFoundError(
+ f"Scaler não encontrado. Procurado: {json_path} e {pkl_path}."
+ )
diff --git a/CSTRChemIA/core/settings.py b/CSTRChemIA/core/settings.py
new file mode 100644
index 0000000..a384237
--- /dev/null
+++ b/CSTRChemIA/core/settings.py
@@ -0,0 +1,83 @@
+"""
+Settings da aplicação — configuração tipada via variáveis de ambiente.
+
+Substitui o antigo ``config.py`` por uma dataclass imutável e validada,
+com valores derivados de caminhos (``BASE_DIR``, ``MODELS_DIR``, etc.)
+calculados uma única vez no import.
+"""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass, field
+from functools import lru_cache
+from pathlib import Path
+
+
+def _env_int(name: str, default: int) -> int:
+ raw = os.environ.get(name)
+ if raw is None or raw == "":
+ return default
+ try:
+ return int(raw)
+ except ValueError as exc: # pragma: no cover
+ raise ValueError(f"{name} deve ser int, recebeu {raw!r}") from exc
+
+
+def _env_bool(name: str, default: bool) -> bool:
+ raw = os.environ.get(name)
+ if raw is None:
+ return default
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
+
+
+@dataclass(frozen=True, slots=True)
+class Settings:
+ """Configuração central — imutável e cacheada via :func:`get_settings`."""
+
+ # Rede / servidor
+ host: str = "0.0.0.0"
+ port: int = 8051
+ debug: bool = False
+
+ # Diretórios (absolutos)
+ base_dir: Path = field(default_factory=lambda: Path(__file__).resolve().parent.parent)
+ models_dir: Path = field(init=False)
+ training_dir: Path = field(init=False)
+ data_dir: Path = field(init=False)
+ assets_dir: Path = field(init=False)
+
+ # Cache
+ model_cache_size: int = 8
+ prediction_cache_size: int = 1024
+
+ # Otimização
+ default_pop_size: int = 100
+ default_n_gen: int = 50
+ default_seed: int = 42
+
+ # Logging
+ log_level: str = "INFO"
+
+ def __post_init__(self) -> None:
+ # Como dataclass é frozen, usamos object.__setattr__.
+ object.__setattr__(self, "models_dir", self.base_dir / "surrogate_model")
+ object.__setattr__(self, "training_dir", self.base_dir / "training")
+ object.__setattr__(self, "data_dir", self.base_dir / "simulation")
+ object.__setattr__(self, "assets_dir", self.base_dir / "assets")
+
+
+@lru_cache(maxsize=1)
+def get_settings() -> Settings:
+ """Settings da aplicação — singleton cacheado."""
+ return Settings(
+ host=os.environ.get("CSTRCHEMIA_HOST", "0.0.0.0"),
+ port=_env_int("PORT", _env_int("CSTRCHEMIA_PORT", 8051)),
+ debug=_env_bool("CSTRCHEMIA_DEBUG", False),
+ model_cache_size=_env_int("CSTRCHEMIA_MODEL_CACHE", 8),
+ prediction_cache_size=_env_int("CSTRCHEMIA_PREDICTION_CACHE", 1024),
+ default_pop_size=_env_int("CSTRCHEMIA_DEFAULT_POP", 100),
+ default_n_gen=_env_int("CSTRCHEMIA_DEFAULT_NGEN", 50),
+ default_seed=_env_int("CSTRCHEMIA_DEFAULT_SEED", 42),
+ log_level=os.environ.get("CSTRCHEMIA_LOG_LEVEL", "INFO"),
+ )
diff --git a/CSTRChemIA/core/types.py b/CSTRChemIA/core/types.py
new file mode 100644
index 0000000..6b71186
--- /dev/null
+++ b/CSTRChemIA/core/types.py
@@ -0,0 +1,84 @@
+"""
+Tipos compartilhados — dataclasses e aliases usados nas fronteiras entre
+camadas (UI ↔ services ↔ modelos). Tipagem forte evita confusão entre
+arrays "features", "targets" e parâmetros de otimização.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any, TypeAlias
+
+import numpy as np
+
+if TYPE_CHECKING:
+ from core.model_manifest import ModelManifest
+
+# Aliases semânticos. NumPy não oferece generics de tamanho nativamente;
+# usamos ndarray com comentário de shape no docstring de cada função.
+FeatureArray: TypeAlias = np.ndarray # shape: (N, 12) ou (12,)
+TargetArray: TypeAlias = np.ndarray # shape: (N, 8) ou (8,)
+ParamDict: TypeAlias = dict[str, float]
+
+
+@dataclass(frozen=True, slots=True)
+class SimulationInput:
+ """Entrada de uma simulação única (um ponto no espaço de features)."""
+ features: FeatureArray
+ model_name: str
+
+
+@dataclass(frozen=True, slots=True)
+class SimulationResult:
+ """Resultado de uma simulação única."""
+ targets: TargetArray
+ model_name: str
+ wall_time_s: float
+
+
+@dataclass(frozen=True, slots=True)
+class OptimizationConfig:
+ """Configuração de uma corrida NSGA-II."""
+ model_name: str
+ objective_names: list[str]
+ minimize: list[bool]
+ bounds: dict[str, tuple[float, float]]
+ pop_size: int = 100
+ n_gen: int = 50
+ seed: int = 42
+
+
+@dataclass(slots=True)
+class OptimizationResult:
+ """Resultado de uma corrida NSGA-II — frente de Pareto + metadados."""
+ pareto_features: FeatureArray # shape: (N, 12)
+ pareto_objectives: np.ndarray # shape: (N, K)
+ hypervolume: float | None = None
+ topsis_best_idx: int | None = None
+ wall_time_s: float = 0.0
+ config: OptimizationConfig | None = None
+ extra: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass(frozen=True, slots=True)
+class ModelInfo:
+ """Metadados de um modelo surrogate persistido em disco.
+
+ ``manifest`` contém o :class:`core.model_manifest.ModelManifest` completo
+ quando ``models.json`` existe no diretório do modelo — aí ``per_target_mae``
+ e ``seed`` são preenchidos para uso na UI.
+ """
+ name: str
+ architecture: str # "MLP", "RNN" ou "CNN"
+ mae: float
+ rmse: float | None = None
+ r2: float | None = None
+ n_features: int = 12
+ n_targets: int = 8
+ path: str = ""
+ # --- enriquecimentos do manifesto (opcionais, backward-compat)
+ manifest: ModelManifest | None = None
+ per_target_mae: dict[str, float] | None = None
+ seed: int | None = None
+ trained_at: str | None = None
+ keras_sha256: str | None = None
diff --git a/CSTRChemIA/core/uncertainty.py b/CSTRChemIA/core/uncertainty.py
new file mode 100644
index 0000000..e766aca
--- /dev/null
+++ b/CSTRChemIA/core/uncertainty.py
@@ -0,0 +1,294 @@
+"""
+Estimação de incerteza preditiva — MC Dropout, Deep Ensembles, Conformal.
+
+Motivação
+---------
+Um ponto predito sem incerteza é mal formulado. No contexto de otimização
+do CSTR, uma solução "ótima" que prediz ``T0 = 350 K ± 0.1 K`` é muito
+diferente de ``T0 = 350 K ± 30 K`` — a segunda coloca o reator em risco
+de runaway térmico dependendo do erro real.
+
+Três famílias complementares estão aqui:
+
+1. **MC Dropout** (Gal & Ghahramani, 2016) — aproxima a posteriori
+ epistêmica da rede executando N *forward passes* com dropout ativo.
+ Barato: N ≈ 20–50 passes. Requer que o modelo tenha camadas
+ ``Dropout``; para o ``build_mlp`` atual isso já é o caso.
+
+2. **Deep Ensembles** (Lakshminarayanan et al., 2017) — treina M modelos
+ com seeds diferentes e reporta média + desvio entre eles. Captura
+ incerteza epistêmica de modo melhor que MC Dropout, porém custa M×
+ em treino. Aqui oferecemos apenas a agregação (treino vem do
+ ``train_surrogate``).
+
+3. **Split Conformal Prediction** (Vovk; Papadopoulos) —
+ *distribution-free* intervals com cobertura garantida em amostra
+ finita. Usa um conjunto de calibração held-out para computar o
+ quantil (1-α) dos resíduos absolutos; o intervalo é
+ ``[ŷ - q, ŷ + q]``. Sem hipóteses de Gaussianidade.
+
+Uso típico
+----------
+::
+
+ from core.uncertainty import mc_dropout_predict, SplitConformalCalibrator
+
+ mean, std = mc_dropout_predict(pipeline, X, n_samples=30)
+
+ cal = SplitConformalCalibrator(alpha=0.1) # 90% de cobertura
+ cal.fit(y_cal_true, y_cal_pred)
+ lo, hi = cal.interval(y_pred)
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Any
+
+import numpy as np
+
+from core.exceptions import DataValidationError
+from core.logging import get_logger
+
+log = get_logger(__name__)
+
+PredictFn = Callable[[np.ndarray], np.ndarray]
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# MC DROPOUT
+# ══════════════════════════════════════════════════════════════════════════════
+def mc_dropout_predict(
+ pipeline: Any,
+ X: np.ndarray,
+ *,
+ n_samples: int = 30,
+ batch_size: int = 256,
+ needs_3d: bool = False,
+) -> tuple[np.ndarray, np.ndarray]:
+ """
+ Executa ``n_samples`` forward passes com dropout ativo e retorna
+ média e desvio-padrão (em unidades originais, após inverse_transform).
+
+ Parameters
+ ----------
+ pipeline
+ Objeto com atributos ``model`` (keras.Model), ``scalerX``, ``scalerY``.
+ A interface é idêntica a :class:`KerasPipelineModel`.
+ X
+ Features NÃO escaladas, shape ``(n, n_features)``.
+ n_samples
+ Número de passes estocásticos. 20–50 costuma bastar.
+ needs_3d
+ ``True`` para RNN/CNN (reshape para 3D antes do forward).
+
+ Returns
+ -------
+ (mean, std)
+ ``mean`` shape ``(n, n_targets)``; ``std`` idem.
+
+ Notas
+ -----
+ * Requer que o modelo tenha ao menos uma camada Dropout. Sem dropout
+ o desvio será ~0 (determinismo) — o método degenera para um simples
+ predict.
+ * Chamar ``model(X, training=True)`` força dropout ativo mesmo em
+ inferência (é assim que MC Dropout funciona).
+ """
+ if n_samples < 2:
+ raise ValueError(f"n_samples deve ser ≥ 2, recebi {n_samples}")
+
+ X = np.asarray(X, dtype=np.float64)
+ if X.ndim == 1:
+ X = X.reshape(1, -1)
+
+ X_scaled = pipeline.scalerX.transform(X)
+ X_input = (
+ X_scaled.reshape(X_scaled.shape[0], X_scaled.shape[1], 1)
+ if needs_3d else X_scaled
+ )
+
+ # Import tardio — evita carregar TF em contextos só-CPU/só-UI.
+ import tensorflow as tf # type: ignore
+
+ model = pipeline.model
+ samples = np.empty(
+ (n_samples, X.shape[0], pipeline.scalerY.n_features_in_ or 0 or 8),
+ dtype=np.float64,
+ )
+ # Ajuste dinâmico: pode ser que n_features_in_ do scalerY não esteja setado.
+ samples_list: list[np.ndarray] = []
+ x_tensor = tf.constant(X_input, dtype=tf.float32)
+
+ for _ in range(n_samples):
+ # training=True → dropout ativo
+ y_scaled = model(x_tensor, training=True).numpy()
+ y_real = pipeline.scalerY.inverse_transform(y_scaled)
+ samples_list.append(y_real)
+
+ arr = np.stack(samples_list, axis=0) # (n_samples, n, n_targets)
+ return arr.mean(axis=0), arr.std(axis=0, ddof=1)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# DEEP ENSEMBLE
+# ══════════════════════════════════════════════════════════════════════════════
+def ensemble_predict(
+ predict_fns: list[PredictFn],
+ X: np.ndarray,
+) -> tuple[np.ndarray, np.ndarray]:
+ """
+ Agrega predições de múltiplos modelos em um Deep Ensemble.
+
+ Parameters
+ ----------
+ predict_fns
+ Lista de callables ``X -> y_pred`` (shape ``(n, n_targets)``).
+ Cada callable representa um modelo treinado independentemente.
+ X
+ Features; cada ``predict_fn`` é responsável por pré-processar.
+
+ Returns
+ -------
+ (mean, std)
+ """
+ if len(predict_fns) < 2:
+ raise ValueError(
+ f"Ensemble requer ≥ 2 membros, recebi {len(predict_fns)}"
+ )
+ preds = np.stack([fn(X) for fn in predict_fns], axis=0)
+ return preds.mean(axis=0), preds.std(axis=0, ddof=1)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# SPLIT CONFORMAL PREDICTION
+# ══════════════════════════════════════════════════════════════════════════════
+@dataclass(slots=True)
+class SplitConformalCalibrator:
+ """
+ Split conformal prediction para intervalos de cobertura garantida.
+
+ Garantia (Vovk, 2005): Se os resíduos são trocáveis (iid é
+ suficiente), então ``P(y_true ∈ [ŷ-q, ŷ+q]) ≥ 1 - α`` em amostra
+ finita, onde ``q`` é o ceil((n+1)(1-α))-ésimo quantil dos resíduos
+ absolutos no conjunto de calibração.
+
+ Parameters
+ ----------
+ alpha
+ Nível de miscoverage desejado. ``alpha=0.1`` → cobertura ≥ 90%.
+ """
+
+ alpha: float = 0.1
+ #: Quantis absolutos por target (preenchido em :meth:`fit`).
+ q_hat: np.ndarray | None = None
+ target_names: tuple[str, ...] | None = None
+
+ def fit(
+ self,
+ y_true: np.ndarray,
+ y_pred: np.ndarray,
+ *,
+ target_names: list[str] | None = None,
+ ) -> SplitConformalCalibrator:
+ """
+ Calibra com um conjunto held-out (``y_cal_true``, ``y_cal_pred``).
+ Ambos em shape ``(n_cal, n_targets)`` e na mesma unidade.
+ """
+ y_true = np.asarray(y_true, dtype=np.float64)
+ y_pred = np.asarray(y_pred, dtype=np.float64)
+ if y_true.shape != y_pred.shape:
+ raise DataValidationError(
+ f"Shapes incompatíveis: y_true {y_true.shape} vs y_pred {y_pred.shape}"
+ )
+ if y_true.ndim != 2:
+ raise DataValidationError(f"Esperado 2D, recebi {y_true.ndim}D")
+
+ residuals = np.abs(y_true - y_pred) # (n_cal, n_targets)
+ n = residuals.shape[0]
+ # Quantil conservador (split-conformal clássico).
+ level = np.ceil((n + 1) * (1.0 - self.alpha)) / n
+ level = float(np.clip(level, 0.0, 1.0))
+ q = np.quantile(residuals, level, axis=0, method="higher")
+
+ self.q_hat = q
+ self.target_names = tuple(target_names) if target_names else None
+ log.info(
+ "Conformal calibrado: n_cal=%d, alpha=%.3f, quantis médios=%.4g",
+ n, self.alpha, float(q.mean()),
+ )
+ return self
+
+ def interval(self, y_pred: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
+ """
+ Retorna (lo, hi) com shape igual a ``y_pred``.
+
+ Raises
+ ------
+ RuntimeError
+ Se chamado antes de :meth:`fit`.
+ """
+ if self.q_hat is None:
+ raise RuntimeError("Chame fit() antes de interval().")
+ y_pred = np.asarray(y_pred, dtype=np.float64)
+ if y_pred.shape[-1] != self.q_hat.shape[0]:
+ raise DataValidationError(
+ f"y_pred com {y_pred.shape[-1]} targets, "
+ f"calibrador com {self.q_hat.shape[0]}"
+ )
+ q = self.q_hat[np.newaxis, :] if y_pred.ndim == 2 else self.q_hat
+ return y_pred - q, y_pred + q
+
+ def coverage(self, y_true: np.ndarray, y_pred: np.ndarray) -> np.ndarray:
+ """
+ Fração de acertos ``y_true ∈ [ŷ-q, ŷ+q]`` por target (para validação).
+
+ Retorna array shape ``(n_targets,)``.
+ """
+ lo, hi = self.interval(y_pred)
+ hits = (y_true >= lo) & (y_true <= hi)
+ return hits.mean(axis=0)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# COMBINAÇÃO: ENSEMBLE + CONFORMAL (RECOMENDADO)
+# ══════════════════════════════════════════════════════════════════════════════
+@dataclass(slots=True)
+class PredictiveIntervalReport:
+ """Resultado de ``predict_with_interval`` — uma previsão com incerteza."""
+ mean: np.ndarray # (n, n_targets)
+ std: np.ndarray | None # (n, n_targets) ou None se sem ensemble/MCD
+ lo: np.ndarray | None # (n, n_targets) intervalo conformal
+ hi: np.ndarray | None
+ alpha: float | None = None
+
+
+def predict_with_interval(
+ predict_fns: list[PredictFn],
+ X: np.ndarray,
+ *,
+ conformal: SplitConformalCalibrator | None = None,
+) -> PredictiveIntervalReport:
+ """
+ Conveniência: ensemble + (opcionalmente) intervalo conformal.
+
+ Se ``len(predict_fns) == 1``, roda uma única predição (sem ``std``);
+ com ≥ 2 retorna mean/std do ensemble. Se ``conformal`` fornecido,
+ também retorna ``lo``/``hi``.
+ """
+ if len(predict_fns) >= 2:
+ mean, std = ensemble_predict(predict_fns, X)
+ elif len(predict_fns) == 1:
+ mean = predict_fns[0](X)
+ std = None
+ else:
+ raise ValueError("predict_fns vazio")
+
+ if conformal is not None:
+ lo, hi = conformal.interval(mean)
+ alpha = conformal.alpha
+ else:
+ lo = hi = None
+ alpha = None
+ return PredictiveIntervalReport(mean=mean, std=std, lo=lo, hi=hi, alpha=alpha)
diff --git a/CSTRChemIA/core/units.py b/CSTRChemIA/core/units.py
new file mode 100644
index 0000000..7bd91f1
--- /dev/null
+++ b/CSTRChemIA/core/units.py
@@ -0,0 +1,318 @@
+"""
+Sistema de unidades — conversão para fins de visualização.
+
+Internamente (canonical), a aplicação sempre trabalha nas unidades nativas
+do modelo surrogate:
+
+ • Vazão volumétrica → m³/s
+ • Concentração → mol/m³
+ • Temperatura → K
+ • Fouling → µm
+ • Fração adimensional → — (sem categoria)
+
+Cada categoria declara unidades alternativas com ``factor`` e ``offset``,
+onde a relação é:
+
+ display = canonical · factor + offset
+ canonical = (display − offset) / factor
+
+Isso cobre tanto escalas lineares (mol/m³ ↔ mol/L) quanto afins (K ↔ °C, °F).
+
+A UI consome o helper :func:`options_for` para popular dropdowns, e callbacks
+consomem :func:`to_canonical` antes de alimentar o modelo, e
+:func:`from_canonical` para exibir resultados.
+"""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass
+from typing import Any, Iterable
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# TIPOS
+# ══════════════════════════════════════════════════════════════════════════════
+@dataclass(frozen=True, slots=True)
+class UnitOption:
+ """Uma unidade dentro de uma categoria (ex.: 'L/s' dentro de 'vazao')."""
+ value: str # id interno (usado em Stores/Dropdowns)
+ label: str # texto exibido na UI
+ factor: float # display = canonical·factor + offset
+ offset: float = 0.0
+ canonical: bool = False
+
+ def to_display(self, x: float) -> float:
+ """Converte canonical → display."""
+ return x * self.factor + self.offset
+
+ def to_canonical(self, x: float) -> float:
+ """Converte display → canonical."""
+ return (x - self.offset) / self.factor
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# CATÁLOGO DE UNIDADES
+# ══════════════════════════════════════════════════════════════════════════════
+UNITS: dict[str, list[UnitOption]] = {
+ "vazao": [
+ UnitOption("m3_s", "m³/s", 1.0, canonical=True),
+ UnitOption("L_s", "L/s", 1000.0),
+ UnitOption("L_min", "L/min", 60_000.0),
+ UnitOption("m3_h", "m³/h", 3600.0),
+ ],
+ "concentration": [
+ UnitOption("mol_m3", "mol/m³", 1.0, canonical=True),
+ UnitOption("mol_L", "mol/L", 0.001),
+ UnitOption("mmol_L", "mmol/L", 1.0), # 1 mol/m³ = 1 mmol/L
+ UnitOption("kmol_m3", "kmol/m³", 0.001),
+ ],
+ "temperature": [
+ UnitOption("K", "K", 1.0, canonical=True),
+ UnitOption("C", "°C", 1.0, offset=-273.15),
+ UnitOption("F", "°F", 9.0 / 5.0, offset=-459.67),
+ ],
+ # Canonical = metro (mesma unidade do ``fouling_thickness`` do modelo).
+ # Default de exibição sugerido: µm (factor 1e6).
+ "length_micro": [
+ UnitOption("m", "m", 1.0, canonical=True),
+ UnitOption("mm", "mm", 1e3),
+ UnitOption("um", "µm", 1e6),
+ UnitOption("nm", "nm", 1e9),
+ ],
+ "fraction": [
+ UnitOption("frac", "0–1", 1.0, canonical=True),
+ UnitOption("pct", "%", 100.0),
+ ],
+}
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# MAPA DE VARIÁVEIS DA UI → CATEGORIA
+# ══════════════════════════════════════════════════════════════════════════════
+#: Mapa ``var_id → categoria``. Variáveis sem unidade não aparecem aqui.
+VAR_CATEGORY: dict[str, str] = {
+ # ─── Simulation inputs ────────────────────────────────────────────────────
+ "vazao": "vazao",
+ "ca0feed": "concentration",
+ "cb0feed": "concentration",
+ "tf": "temperature",
+ "tamb": "temperature",
+ "tobs": "temperature",
+ "wj": "vazao",
+ # valve é fração (0–1) — categoria 'fraction' opcional
+
+ # ─── Simulation outputs (KPIs) ────────────────────────────────────────────
+ "X": "fraction",
+ "T0": "temperature",
+ "CA0": "concentration",
+ "CB0": "concentration",
+ "CP0": "concentration",
+ "CS0": "concentration",
+ "fouling": "length_micro",
+ # catact é fração (0–1); pode entrar em 'fraction' se desejado
+
+ # ─── Optimization constraints ─────────────────────────────────────────────
+ "t_max": "temperature",
+ "fouling_max": "length_micro",
+ "cp0_min": "concentration",
+
+ # ─── Optimization decision ranges ─────────────────────────────────────────
+ "vazao_min": "vazao",
+ "vazao_max": "vazao",
+ "tobs_min": "temperature",
+ "tobs_max": "temperature",
+ "wj_min": "vazao",
+ "wj_max": "vazao",
+}
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# LIMITES CANÔNICOS POR VARIÁVEL (para callbacks de troca de unidade)
+# ══════════════════════════════════════════════════════════════════════════════
+#: Para cada variável com input numérico, declara ``min``, ``max``, ``step`` e
+#: ``default`` nas unidades **canônicas** da categoria. O callback de troca de
+#: unidade usa esses valores para recalcular os bounds de Input/Slider quando
+#: o usuário escolhe outra unidade.
+VAR_BOUNDS: dict[str, dict[str, float]] = {
+ # ─── Simulation inputs ────────────────────────────────────────────────────
+ "vazao": {"min": 0.00050, "max": 0.00120, "step": 0.00001, "default": 0.000506},
+ "ca0feed": {"min": 1090.0, "max": 1310.0, "step": 1.0, "default": 1202.0},
+ "cb0feed": {"min": 925.0, "max": 1085.0, "step": 1.0, "default": 1000.0},
+ "tf": {"min": 342.0, "max": 358.0, "step": 0.1, "default": 350.1},
+ "tamb": {"min": 285.0, "max": 311.0, "step": 0.1, "default": 297.8},
+ "tobs": {"min": 324.0, "max": 365.0, "step": 0.1, "default": 327.8},
+ "wj": {"min": 0.00010, "max": 0.00999, "step": 0.00010, "default": 0.00999},
+
+ # ─── Optimization constraints ─────────────────────────────────────────────
+ "t_max": {"min": 300.0, "max": 400.0, "step": 0.1, "default": 360.0},
+ "fouling_max": {"min": 0.0, "max": 0.05, "step": 0.001, "default": 0.025},
+ "cp0_min": {"min": 0.0, "max": 1500.0,"step": 10.0, "default": 0.0},
+
+ # ─── Optimization decision ranges ─────────────────────────────────────────
+ "vazao_min": {"min": 0.0001, "max": 0.01, "step": 0.00001, "default": 0.0005},
+ "vazao_max": {"min": 0.0001, "max": 0.01, "step": 0.00001, "default": 0.0012},
+ "tobs_min": {"min": 300.0, "max": 400.0,"step": 0.1, "default": 324.0},
+ "tobs_max": {"min": 300.0, "max": 400.0,"step": 0.1, "default": 365.0},
+ "wj_min": {"min": 0.0, "max": 0.02, "step": 0.00001, "default": 0.0001},
+ "wj_max": {"min": 0.0, "max": 0.02, "step": 0.00001, "default": 0.00999},
+}
+
+
+def display_bounds(var_id: str, unit_value: str) -> dict[str, float]:
+ """
+ Converte os limites canônicos de ``var_id`` para a unidade ``unit_value``.
+
+ Retorna dict com chaves ``min``, ``max``, ``step``, ``default``. Valores
+ em unidade de exibição.
+ """
+ cat = VAR_CATEGORY.get(var_id)
+ if cat is None or var_id not in VAR_BOUNDS:
+ raise KeyError(f"var '{var_id}' sem bounds canônicos declarados.")
+ b = VAR_BOUNDS[var_id]
+ opt = get_option(cat, unit_value)
+ # Para unidades afins (temperatura) o *step* não deve receber offset — é
+ # um delta, não um ponto absoluto. Usamos apenas ``factor`` para passos.
+ return {
+ "min": opt.to_display(b["min"]),
+ "max": opt.to_display(b["max"]),
+ "step": b["step"] * opt.factor,
+ "default": opt.to_display(b["default"]),
+ }
+
+
+def format_range_text(var_id: str, unit_value: str,
+ prefix: str = "Faixa") -> str:
+ """
+ Texto curto do tipo "Faixa: 0.50 – 1.20 L/s" para a formtext do input.
+ """
+ cat = VAR_CATEGORY.get(var_id)
+ if cat is None or var_id not in VAR_BOUNDS:
+ return ""
+ db = display_bounds(var_id, unit_value)
+ unit_lbl = label_for(cat, unit_value)
+ lo, hi = db["min"], db["max"]
+ # Escolhe notação conforme magnitude.
+ def _fmt(x: float) -> str:
+ if x != 0 and (abs(x) < 1e-3 or abs(x) >= 1e5):
+ return f"{x:.3g}"
+ if x == int(x) and abs(x) < 1e4:
+ return f"{int(x)}"
+ return f"{x:.4g}"
+ return f"{prefix}: {_fmt(lo)} – {_fmt(hi)} {unit_lbl}"
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# HELPERS DE ACESSO
+# ══════════════════════════════════════════════════════════════════════════════
+def options_for(category: str) -> list[dict[str, str]]:
+ """Formato pronto para ``dcc.Dropdown.options``."""
+ return [{"label": o.label, "value": o.value} for o in UNITS[category]]
+
+
+def get_option(category: str, value: str) -> UnitOption:
+ """Localiza uma UnitOption pelo ``value``. Lança ``KeyError`` se inválido."""
+ for o in UNITS[category]:
+ if o.value == value:
+ return o
+ raise KeyError(f"unit '{value}' não existe na categoria '{category}'")
+
+
+def get_canonical(category: str) -> UnitOption:
+ """Retorna a UnitOption canônica da categoria."""
+ for o in UNITS[category]:
+ if o.canonical:
+ return o
+ return UNITS[category][0]
+
+
+def canonical_value_of(category: str) -> str:
+ """Apenas o ``value`` da unidade canônica — útil para defaults de dropdown."""
+ return get_canonical(category).value
+
+
+def label_for(category: str, value: str) -> str:
+ """Rótulo amigável da unidade (ex.: 'mol/L')."""
+ return get_option(category, value).label
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# CONVERSÕES
+# ══════════════════════════════════════════════════════════════════════════════
+def _safe_float(x: Any) -> float | None:
+ """Coerção robusta a ``None``/strings/NaN."""
+ if x is None:
+ return None
+ try:
+ v = float(x)
+ except (TypeError, ValueError):
+ return None
+ if math.isnan(v) or math.isinf(v):
+ return None
+ return v
+
+
+def to_canonical(value: Any, category: str, from_unit: str) -> float | None:
+ """Converte ``value`` em ``from_unit`` → canonical. Retorna ``None`` se inválido."""
+ v = _safe_float(value)
+ if v is None:
+ return None
+ return get_option(category, from_unit).to_canonical(v)
+
+
+def from_canonical(value: Any, category: str, to_unit: str) -> float | None:
+ """Converte canonical → ``to_unit``. Retorna ``None`` se inválido."""
+ v = _safe_float(value)
+ if v is None:
+ return None
+ return get_option(category, to_unit).to_display(v)
+
+
+def convert(value: Any, category: str, from_unit: str, to_unit: str) -> float | None:
+ """Converte de uma unidade qualquer para outra dentro da mesma categoria."""
+ if from_unit == to_unit:
+ return _safe_float(value)
+ v = to_canonical(value, category, from_unit)
+ if v is None:
+ return None
+ return from_canonical(v, category, to_unit)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# FORMATAÇÃO
+# ══════════════════════════════════════════════════════════════════════════════
+def format_value(value: Any, decimals: int = 4) -> str:
+ """Formata número para exibição (com casas decimais ajustadas)."""
+ v = _safe_float(value)
+ if v is None:
+ return "—"
+ # Valores muito pequenos → notação científica
+ if v != 0 and (abs(v) < 1e-3 or abs(v) >= 1e6):
+ return f"{v:.{max(2, decimals - 2)}e}"
+ # Para inteiros "limpos"
+ if v == int(v) and abs(v) < 1e5:
+ return f"{int(v)}"
+ return f"{v:.{decimals}g}"
+
+
+def default_units() -> dict[str, str]:
+ """Store inicial: cada variável mapeada para sua unidade canônica."""
+ return {
+ var: canonical_value_of(cat)
+ for var, cat in VAR_CATEGORY.items()
+ }
+
+
+def get_unit(store: dict[str, str] | None, var_id: str) -> str:
+ """Lê a unidade atual de ``var_id`` no store (fallback para canônica)."""
+ cat = VAR_CATEGORY.get(var_id)
+ if cat is None:
+ return ""
+ if not store or var_id not in store:
+ return canonical_value_of(cat)
+ # Valida que o valor armazenado ainda pertence à categoria.
+ try:
+ get_option(cat, store[var_id])
+ return store[var_id]
+ except KeyError:
+ return canonical_value_of(cat)
diff --git a/CSTRChemIA/core/utils.py b/CSTRChemIA/core/utils.py
new file mode 100644
index 0000000..99189f1
--- /dev/null
+++ b/CSTRChemIA/core/utils.py
@@ -0,0 +1,86 @@
+"""
+Utilidades genéricas — validação, normalização, conversões.
+"""
+
+from __future__ import annotations
+
+import time
+from collections.abc import Iterator
+from contextlib import contextmanager
+
+import numpy as np
+
+from core.constants import DEFAULT_FEATURES, N_FEATURES, N_TARGETS
+from core.exceptions import DataValidationError
+
+
+def ensure_feature_array(x: np.ndarray | list | tuple) -> np.ndarray:
+ """Garante array float de shape (N, N_FEATURES). Levanta DataValidationError."""
+ arr = np.asarray(x, dtype=np.float64)
+ if arr.ndim == 1:
+ arr = arr.reshape(1, -1)
+ if arr.ndim != 2 or arr.shape[1] != N_FEATURES:
+ raise DataValidationError(
+ f"Esperado shape (N, {N_FEATURES}), recebeu {arr.shape}"
+ )
+ if not np.isfinite(arr).all():
+ raise DataValidationError("Entrada contém NaN ou infinito")
+ return arr
+
+
+def ensure_target_array(y: np.ndarray | list | tuple) -> np.ndarray:
+ """Garante array float de shape (N, N_TARGETS)."""
+ arr = np.asarray(y, dtype=np.float64)
+ if arr.ndim == 1:
+ arr = arr.reshape(1, -1)
+ if arr.ndim != 2 or arr.shape[1] != N_TARGETS:
+ raise DataValidationError(
+ f"Esperado shape (N, {N_TARGETS}), recebeu {arr.shape}"
+ )
+ return arr
+
+
+def features_from_dict(params: dict[str, float]) -> np.ndarray:
+ """Converte dict {feature_name: value} em array (1, N_FEATURES) na ordem canônica."""
+ missing = [f for f in DEFAULT_FEATURES if f not in params]
+ if missing:
+ raise DataValidationError(f"Features ausentes: {missing}")
+ return np.array([[float(params[f]) for f in DEFAULT_FEATURES]], dtype=np.float64)
+
+
+def features_to_dict(arr: np.ndarray) -> dict[str, float]:
+ """Inverso de :func:`features_from_dict`."""
+ if arr.ndim == 2:
+ arr = arr[0]
+ return {name: float(v) for name, v in zip(DEFAULT_FEATURES, arr)}
+
+
+@contextmanager
+def timed() -> Iterator[dict[str, float]]:
+ """Context manager que mede wall time (em segundos).
+
+ >>> with timed() as t:
+ ... do_work()
+ >>> print(t["elapsed"])
+ """
+ info: dict[str, float] = {"elapsed": 0.0}
+ start = time.perf_counter()
+ try:
+ yield info
+ finally:
+ info["elapsed"] = time.perf_counter() - start
+
+
+def clip_to_bounds(
+ arr: np.ndarray,
+ bounds: dict[str, tuple[float, float]],
+ feature_names: list[str] | None = None,
+) -> np.ndarray:
+ """Limita cada coluna aos bounds. Entrada não é mutada."""
+ names = feature_names or DEFAULT_FEATURES
+ out = arr.copy()
+ for i, name in enumerate(names):
+ if name in bounds:
+ lo, hi = bounds[name]
+ out[..., i] = np.clip(out[..., i], lo, hi)
+ return out
diff --git a/CSTRChemIA/layouts/layout_TAB.py b/CSTRChemIA/layouts/layout_TAB.py
index 35a2a0b..72a54e7 100644
--- a/CSTRChemIA/layouts/layout_TAB.py
+++ b/CSTRChemIA/layouts/layout_TAB.py
@@ -17,6 +17,14 @@
import dash_bootstrap_components as dbc
from dash import dcc, html, dash_table
+from core.units import (
+ VAR_CATEGORY,
+ canonical_value_of,
+ options_for,
+)
+from core.i18n import t as _t, DEFAULT_LANG
+
+
# Importação tardia para evitar ciclos: carregado apenas quando o layout for gerado
def _get_default_model():
"""Retorna o primeiro modelo treinado disponível, fallback para 'MLP'."""
@@ -31,45 +39,127 @@ def _get_default_model():
# ──────────────────────────────────────────────────────────────────────────────
# HELPERS DE UI
# ──────────────────────────────────────────────────────────────────────────────
+#: Persistence padrão — mantém valor de inputs/sliders/dropdowns/flags entre
+#: trocas de aba e também entre recargas (``session`` = sessionStorage).
+_PERSIST = {"persistence": True, "persistence_type": "session"}
+
+
+def unit_selector(var_id: str, size: str = "sm", width: str = "84px"):
+ """
+ Dropdown pequeno que escolhe a unidade de exibição de uma variável.
+
+ Gera um componente com id ``unit-{var_id}``. Se ``var_id`` não estiver em
+ :data:`core.units.VAR_CATEGORY`, retorna um placeholder invisível
+ (mantém alinhamento no layout).
+ """
+ cat = VAR_CATEGORY.get(var_id)
+ if cat is None:
+ return html.Span(style={"width": width, "display": "inline-block"})
+
+ return dcc.Dropdown(
+ id=f"unit-{var_id}",
+ options=options_for(cat),
+ value=canonical_value_of(cat),
+ clearable=False,
+ searchable=False,
+ className="cstr-unit-dd",
+ style={
+ "width": width,
+ "minWidth": width,
+ "fontSize": "11px",
+ "display": "inline-block",
+ "verticalAlign": "middle",
+ },
+ persistence=True,
+ persistence_type="session",
+ )
+
+
def numeric_with_slider(label, input_id, slider_id,
- min_val, max_val, step, value, unit, help_text=None):
+ min_val, max_val, step, value, unit, help_text=None,
+ var_id: str | None = None):
+ """
+ Componente de input numérico + slider emparelhado.
+
+ Se ``var_id`` for fornecido E a variável estiver em ``VAR_CATEGORY``,
+ um seletor de unidade é renderizado ao lado do rótulo. O ``input_id``
+ passa a operar na unidade escolhida pelo usuário — um callback no
+ módulo ``apps/MAIN_callbacks.py`` cuida da conversão de valor/bounds.
+
+ O argumento ``unit`` agora é apenas o texto inicial do rótulo — o
+ texto "ao vivo" é atualizado pelo callback quando o usuário troca a
+ unidade.
+ """
+ label_kwargs = {"className": "fw-semibold mt-2 small text-secondary flex-grow-1"}
+ if var_id:
+ label_kwargs["id"] = f"label-{input_id}"
+ label_component = dbc.Label(label, **label_kwargs)
+
+ label_row_children = [label_component]
+ if var_id:
+ label_row_children.append(unit_selector(var_id))
+
+ ft_kwargs = {"className": "text-muted small"}
+ if var_id:
+ ft_kwargs["id"] = f"ft-{input_id}"
+
return html.Div([
- dbc.Label(label, className="fw-semibold mt-2 small text-secondary"),
+ html.Div(label_row_children,
+ className="d-flex align-items-center gap-2 mb-1"),
dbc.Input(
id=input_id, type="number", value=value, step=step,
min=min_val, max=max_val, debounce=True,
className="form-control-sm",
style={"marginBottom": "6px"},
+ **_PERSIST,
),
dcc.Slider(
id=slider_id, min=min_val, max=max_val, step=step, value=value,
marks=None, included=False, updatemode="drag",
tooltip={"always_visible": False, "placement": "bottom"},
className="mb-1 cstr-slider",
+ **_PERSIST,
),
dbc.FormText(help_text or f"Range: {min_val:g} – {max_val:g} {unit}",
- className="text-muted small"),
+ **ft_kwargs),
], className="mb-3")
def flag_switch(label, flag_id, help_text=""):
return html.Div([
dbc.Checklist(id=flag_id, options=[{"label": label, "value": 1}],
- value=[], switch=True, inline=True, className="cstr-flag"),
+ value=[], switch=True, inline=True, className="cstr-flag",
+ **_PERSIST),
dbc.FormText(help_text, className="text-muted small"),
], className="mb-2")
-def kpi_card(title, output_id, unit="", color="primary", icon=""):
+def kpi_card(title, output_id, unit="", color="primary", icon="", var_id: str | None = None):
+ """
+ Card de KPI (valor + unidade). Se ``var_id`` estiver em ``VAR_CATEGORY``
+ um mini seletor de unidade aparece ao lado do valor, permitindo trocar
+ a unidade de exibição do KPI. O callback de simulação cuida de enviar
+ o valor canônico para o Store e o callback de unidade o converte para
+ exibição.
+ """
+ header_children = [
+ html.Span(icon + " " if icon else "", style={"fontSize": "1.1rem"}),
+ html.Span(title, className="text-muted small fw-semibold flex-grow-1"),
+ ]
+ if var_id:
+ header_children.append(unit_selector(var_id, width="72px"))
+
+ small_kwargs = {"className": "text-muted"}
+ if var_id:
+ small_kwargs["id"] = f"unit-label-{output_id}"
+
return dbc.Card(
dbc.CardBody([
- html.Div([
- html.Span(icon + " " if icon else "", style={"fontSize": "1.1rem"}),
- html.Span(title, className="text-muted small fw-semibold"),
- ]),
+ html.Div(header_children,
+ className="d-flex align-items-center gap-2"),
html.H4(id=output_id, children="—",
className=f"fw-bold text-{color} mb-0 mt-1 kpi-value"),
- html.Small(unit, className="text-muted"),
+ html.Small(unit, **small_kwargs),
], className="p-3"),
className=f"shadow-sm h-100 kpi-card kpi-card-{color}",
)
@@ -115,21 +205,22 @@ def _chart_card(icon, title, graph_id, height="340px", md=12, extra_body=None):
# ══════════════════════════════════════════════════════════════════════════════
# ABA: SIMULATION
# ══════════════════════════════════════════════════════════════════════════════
-def layout_TAB_Simulation():
+def layout_TAB_Simulation(lang: str = DEFAULT_LANG):
_default_model = _get_default_model()
model_card = dbc.Card([
dbc.CardHeader([
html.Span("🤖 ", style={"fontSize": "1.2rem"}),
- html.Span("Modelo Substituto", className="fw-bold"),
+ html.Span(_t("section_surrogate_model", lang), className="fw-bold"),
], className="cstr-card-header"),
dbc.CardBody(dbc.Row([
dbc.Col([
- dbc.Label("Arquitetura selecionada:", className="fw-semibold small"),
+ dbc.Label(_t("field_architecture_sel", lang), className="fw-semibold small"),
dcc.Dropdown(id='sim-model-select', options=['MLP', 'RNN', 'CNN'],
- value=_default_model, clearable=False, className="cstr-dropdown"),
+ value=_default_model, clearable=False,
+ className="cstr-dropdown", **_PERSIST),
], md=6),
dbc.Col([
- dbc.Label("Status:", className="fw-semibold small"),
+ dbc.Label(_t("field_status", lang), className="fw-semibold small"),
html.Div(id='sim-model-status', className="mt-2"),
], md=6),
]))
@@ -138,46 +229,46 @@ def layout_TAB_Simulation():
continuous_card = dbc.Card([
dbc.CardHeader([
html.Span("⚙️ ", style={"fontSize": "1.2rem"}),
- html.Span("Variáveis de Processo", className="fw-bold"),
+ html.Span(_t("section_process_variables", lang), className="fw-bold"),
], className="cstr-card-header"),
dbc.CardBody([
dbc.Row([
dbc.Col(numeric_with_slider(
- "Vazão Feed (m³/s)", "input-vazao", "slider-vazao",
+ _t("field_feed_flow", lang), "input-vazao", "slider-vazao",
0.00050, 0.00120, 0.00001, 0.000506, "m³/s",
- help_text="Normal ≈ 5.06×10⁻⁴ m³/s"), md=4),
+ help_text="Normal ≈ 5.06×10⁻⁴ m³/s", var_id="vazao"), md=4),
dbc.Col(numeric_with_slider(
- "CA₀ Feed (mol/m³)", "input-ca0feed", "slider-ca0feed",
+ _t("field_ca0_feed", lang), "input-ca0feed", "slider-ca0feed",
1090.0, 1310.0, 1.0, 1202.0, "mol/m³",
- help_text="Normal ≈ 1202 mol/m³"), md=4),
+ help_text="Normal ≈ 1202 mol/m³", var_id="ca0feed"), md=4),
dbc.Col(numeric_with_slider(
- "CB₀ Feed (mol/m³)", "input-cb0feed", "slider-cb0feed",
+ _t("field_cb0_feed", lang), "input-cb0feed", "slider-cb0feed",
925.0, 1085.0, 1.0, 1000.0, "mol/m³",
- help_text="Normal ≈ 1000 mol/m³"), md=4),
+ help_text="Normal ≈ 1000 mol/m³", var_id="cb0feed"), md=4),
]),
dbc.Row([
dbc.Col(numeric_with_slider(
- "Tf Feed (K)", "input-tf", "slider-tf",
+ _t("field_tf_feed", lang), "input-tf", "slider-tf",
342.0, 358.0, 0.1, 350.1, "K",
- help_text="Normal ≈ 350.1 K"), md=4),
+ help_text="Normal ≈ 350.1 K", var_id="tf"), md=4),
dbc.Col(numeric_with_slider(
- "T amb Feed (K)", "input-tamb", "slider-tamb",
+ _t("field_tamb_feed", lang), "input-tamb", "slider-tamb",
285.0, 311.0, 0.1, 297.8, "K",
- help_text="Normal ≈ 297.8 K"), md=4),
+ help_text="Normal ≈ 297.8 K", var_id="tamb"), md=4),
dbc.Col(numeric_with_slider(
- "T observada (K)", "input-tobs", "slider-tobs",
+ _t("field_tobs", lang), "input-tobs", "slider-tobs",
324.0, 365.0, 0.1, 327.8, "K",
- help_text="Normal ≈ 327.8 K"), md=4),
+ help_text="Normal ≈ 327.8 K", var_id="tobs"), md=4),
]),
dbc.Row([
dbc.Col(numeric_with_slider(
- "Posição da Válvula (0–1)", "input-valve", "slider-valve",
+ _t("field_valve_position", lang), "input-valve", "slider-valve",
0.01, 1.00, 0.001, 0.9993, "—",
- help_text="Normal ≈ 1.0 (aberta)"), md=4),
+ help_text="Normal ≈ 1.0"), md=4),
dbc.Col(numeric_with_slider(
- "Vazão Jaqueta w_j (m³/s)", "input-wj", "slider-wj",
+ _t("field_jacket_flow", lang), "input-wj", "slider-wj",
0.00010, 0.00999, 0.00010, 0.00999, "m³/s",
- help_text="Normal ≈ 9.99×10⁻³ m³/s"), md=4),
+ help_text="Normal ≈ 9.99×10⁻³ m³/s", var_id="wj"), md=4),
]),
])
], className="mb-3 shadow-sm cstr-card fade-in")
@@ -185,81 +276,79 @@ def layout_TAB_Simulation():
flags_card = dbc.Card([
dbc.CardHeader([
html.Span("🚨 ", style={"fontSize": "1.2rem"}),
- html.Span("Flags Operacionais", className="fw-bold"),
+ html.Span(_t("section_operational_flags", lang), className="fw-bold"),
], className="cstr-card-header"),
dbc.CardBody(dbc.Row([
- dbc.Col(flag_switch("Falha na Válvula", "flag-valvula",
- "Ativa se houver falha na válvula de controle"), md=3),
- dbc.Col(flag_switch("Em Manutenção", "flag-manutencao",
- "Indica que o reator está em manutenção"), md=3),
- dbc.Col(flag_switch("Falha Sensor T", "flag-sensor-t",
- "Sensor de temperatura com defeito"), md=3),
- dbc.Col(flag_switch("Falha Comunicação", "flag-comunicacao",
- "Perda de comunicação com o CLP"), md=3),
+ dbc.Col(flag_switch(_t("flag_valve_failure", lang), "flag-valvula",
+ _t("flag_valve_tooltip", lang)), md=3),
+ dbc.Col(flag_switch(_t("flag_maintenance", lang), "flag-manutencao",
+ _t("flag_maint_tooltip", lang)), md=3),
+ dbc.Col(flag_switch(_t("flag_sensor_t_failure", lang), "flag-sensor-t",
+ _t("flag_sensor_t_tooltip", lang)), md=3),
+ dbc.Col(flag_switch(_t("flag_comm_failure", lang), "flag-comunicacao",
+ _t("flag_comm_tooltip", lang)), md=3),
]))
], className="mb-3 shadow-sm cstr-card fade-in")
kpi_row = dbc.Row([
- dbc.Col(kpi_card("Conversão X", "out-X", "%", "primary", "⚗️"), md=3),
- dbc.Col(kpi_card("T₀ Predita", "out-T0", "K", "danger", "🌡️"), md=3),
- dbc.Col(kpi_card("CA₀ Predita", "out-CA0", "mol/m³","info", "🧪"), md=3),
- dbc.Col(kpi_card("CB₀ Predita", "out-CB0", "mol/m³","info", "🧪"), md=3),
+ dbc.Col(kpi_card(_t("kpi_conversion_x", lang), "out-X", "%", "primary", "⚗️", var_id="X"), md=3),
+ dbc.Col(kpi_card(_t("kpi_t0_predicted", lang), "out-T0", "K", "danger", "🌡️", var_id="T0"), md=3),
+ dbc.Col(kpi_card(_t("kpi_ca0_predicted", lang), "out-CA0", "mol/m³","info", "🧪", var_id="CA0"), md=3),
+ dbc.Col(kpi_card(_t("kpi_cb0_predicted", lang), "out-CB0", "mol/m³","info", "🧪", var_id="CB0"), md=3),
], className="g-3 mb-3")
kpi_row2 = dbc.Row([
- dbc.Col(kpi_card("CP₀ (Produto)", "out-CP0", "mol/m³","success","🟢"), md=3),
- dbc.Col(kpi_card("CS₀ (Subprod.)", "out-CS0", "mol/m³","info", "🟡"), md=3),
- dbc.Col(kpi_card("Fouling", "out-fouling", "µm", "warning","🦠"), md=3),
- dbc.Col(kpi_card("Ativ. Catalítica", "out-catact", "0–1", "success","⚡"), md=3),
+ dbc.Col(kpi_card(_t("kpi_cp0_product", lang), "out-CP0", "mol/m³","success","🟢", var_id="CP0"), md=3),
+ dbc.Col(kpi_card(_t("kpi_cs0_byproduct", lang), "out-CS0", "mol/m³","info", "🟡", var_id="CS0"), md=3),
+ dbc.Col(kpi_card(_t("kpi_fouling", lang), "out-fouling", "µm", "warning","🦠", var_id="fouling"), md=3),
+ dbc.Col(kpi_card(_t("kpi_cat_activity", lang), "out-catact", "0–1", "success","⚡"), md=3),
], className="g-3 mb-3")
status_bar = html.Div(id="sim-status-bar", className="mb-3")
# ── Linha 1: barras 8 targets + radar saúde ──────────────────────────────
row1 = dbc.Row([
- _chart_card("📊", "Targets Preditos (8 saídas)", "sim-bar-chart",
+ _chart_card("📊", _t("chart_sim_targets", lang), "sim-bar-chart",
height="340px", md=7),
- _chart_card("🎯", "Saúde Operacional", "sim-radar-chart",
+ _chart_card("🎯", _t("chart_sim_health", lang), "sim-radar-chart",
height="340px", md=5),
], className="g-3 mb-3")
# ── Linha 2: gauge + donut + sensibilidade ───────────────────────────────
row2 = dbc.Row([
- _chart_card("⏱️", "Gauge de Conversão", "sim-gauge-chart", height="320px", md=4),
- _chart_card("🍩", "Distribuição de Espécies", "sim-donut-chart", height="320px", md=4),
- _chart_card("📉", "Sensibilidade X × T_obs", "sim-sensitivity-chart", height="320px", md=4),
+ _chart_card("⏱️", _t("chart_sim_gauge", lang), "sim-gauge-chart", height="320px", md=4),
+ _chart_card("🍩", _t("chart_sim_donut", lang), "sim-donut-chart", height="320px", md=4),
+ _chart_card("📉", _t("chart_sim_sensitivity", lang), "sim-sensitivity-chart", height="320px", md=4),
], className="g-3 mb-3")
# ── Linha 3: comparação de modelos + contribuição de features ────────────
row3 = dbc.Row([
- _chart_card("🤖", "Comparação MLP vs RNN vs CNN", "sim-model-comparison-chart",
+ _chart_card("🤖", _t("chart_sim_model_comparison", lang), "sim-model-comparison-chart",
height="340px", md=7),
- _chart_card("🔍", "Contribuição das Features (ΔX)", "sim-feature-contrib-chart",
+ _chart_card("🔍", _t("chart_sim_feature_contrib", lang), "sim-feature-contrib-chart",
height="340px", md=5),
], className="g-3 mb-3")
# ── Linha 4: envelope operacional + histórico de predições ───────────────
row4 = dbc.Row([
- _chart_card("🗺️", "Envelope Operacional (T_obs × Válvula → X)", "sim-envelope-chart",
+ _chart_card("🗺️", _t("chart_sim_envelope", lang), "sim-envelope-chart",
height="360px", md=7),
- _chart_card("📈", "Histórico de Predições (últimas 30)", "sim-history-chart",
+ _chart_card("📈", _t("chart_sim_history", lang), "sim-history-chart",
height="360px", md=5),
], className="g-3 mb-3")
return html.Div([
dbc.Container([
html.Div([
- html.H3("CSTR – Simulação", className="fw-bold text-center mb-2 cstr-title"),
+ html.H3(_t("sim_page_title", lang), className="fw-bold text-center mb-2 cstr-title"),
html.P(
- "Insira as condições de operação — o modelo substituto prediz "
- "concentrações, temperatura, conversão, fouling e atividade catalítica "
- "em tempo real com 9 gráficos interativos.",
+ _t("sim_page_subtitle", lang),
className="text-center text-secondary mb-4"
),
], className="cstr-page-header"),
model_card, continuous_card, flags_card,
html.Hr(className="cstr-divider"),
- section_header("Resultados da Predição", icon="📈"),
+ section_header(_t("sim_results_section", lang), icon="📈"),
status_bar, kpi_row, kpi_row2,
row1, row2, row3, row4,
], fluid=True, className="p-4 bg-white text-dark cstr-container")
@@ -269,21 +358,22 @@ def layout_TAB_Simulation():
# ══════════════════════════════════════════════════════════════════════════════
# ABA: OPTIMIZATION
# ══════════════════════════════════════════════════════════════════════════════
-def layout_TAB_Optimization():
+def layout_TAB_Optimization(lang: str = DEFAULT_LANG):
_default_model = _get_default_model()
model_card = dbc.Card([
dbc.CardHeader([
html.Span("🤖 ", style={"fontSize": "1.2rem"}),
- html.Span("Modelo Substituto", className="fw-bold"),
+ html.Span(_t("section_surrogate_model", lang), className="fw-bold"),
], className="cstr-card-header"),
dbc.CardBody(dbc.Row([
dbc.Col([
- dbc.Label("Arquitetura:", className="fw-semibold small"),
+ dbc.Label(_t("field_architecture", lang) + ":", className="fw-semibold small"),
dcc.Dropdown(id='opt-model-select', options=['MLP', 'RNN', 'CNN'],
- value=_default_model, clearable=False, className="cstr-dropdown"),
+ value=_default_model, clearable=False,
+ className="cstr-dropdown", **_PERSIST),
], md=6),
dbc.Col([
- dbc.Label("Status:", className="fw-semibold small"),
+ dbc.Label(_t("field_status", lang), className="fw-semibold small"),
html.Div(id='opt-model-status', className="mt-2"),
], md=6),
]))
@@ -292,80 +382,149 @@ def layout_TAB_Optimization():
constraints_card = dbc.Card([
dbc.CardHeader([
html.Span("🔒 ", style={"fontSize": "1.2rem"}),
- html.Span("Restrições Operacionais", className="fw-bold"),
+ html.Span(_t("section_operational_constraints", lang), className="fw-bold"),
], className="cstr-card-header"),
dbc.CardBody([
- html.P("Limites de processo", className="fw-semibold small text-secondary mb-2"),
+ html.P(_t("subsection_process_limits", lang),
+ className="fw-semibold small text-secondary mb-2"),
dbc.Row([
dbc.Col([
- dbc.Label("T0 Máxima (K)", className="fw-semibold small"),
+ html.Div([
+ dbc.Label(_t("field_t_max", lang),
+ className="fw-semibold small flex-grow-1",
+ id="label-opt-t-max"),
+ unit_selector("t_max", width="72px"),
+ ], className="d-flex align-items-center gap-2"),
dbc.Input(id="opt-t-max", type="number", value=365.0, step=1.0,
- min=300, max=450, className="form-control-sm"),
- dbc.FormText("Temperatura máxima permitida", className="text-muted small"),
+ min=300, max=450, className="form-control-sm", **_PERSIST),
+ dbc.FormText(_t("ft_t_max", lang),
+ id="ft-opt-t-max", className="text-muted small"),
], md=3),
dbc.Col([
- dbc.Label("Conversão Mínima (%)", className="fw-semibold small"),
+ dbc.Label(_t("field_conversion_min", lang), className="fw-semibold small"),
dbc.Input(id="opt-x-min", type="number", value=30.0, step=1.0,
- min=0, max=100, className="form-control-sm"),
- dbc.FormText("X mínimo exigido", className="text-muted small"),
+ min=0, max=100, className="form-control-sm", **_PERSIST),
+ dbc.FormText(_t("ft_x_min", lang), className="text-muted small"),
], md=3),
dbc.Col([
- dbc.Label("Cat. Activity Mínima", className="fw-semibold small"),
+ dbc.Label(_t("field_cat_activity_min", lang), className="fw-semibold small"),
dbc.Input(id="opt-cat-min", type="number", value=0.90, step=0.01,
- min=0.0, max=1.0, className="form-control-sm"),
- dbc.FormText("cat_activity ≥ este valor", className="text-muted small"),
+ min=0.0, max=1.0, className="form-control-sm", **_PERSIST),
+ dbc.FormText(_t("ft_cat_min", lang), className="text-muted small"),
], md=3),
dbc.Col([
- dbc.Label("Fouling Máximo (m)", className="fw-semibold small"),
+ html.Div([
+ dbc.Label(_t("field_fouling_max", lang),
+ className="fw-semibold small flex-grow-1",
+ id="label-opt-fouling-max"),
+ unit_selector("fouling_max", width="72px"),
+ ], className="d-flex align-items-center gap-2"),
dbc.Input(id="opt-fouling-max", type="number", value=0.025, step=0.001,
- min=0.0, max=0.05, className="form-control-sm"),
- dbc.FormText("Espessura máxima de incrustação", className="text-muted small"),
+ min=0.0, max=0.05, className="form-control-sm", **_PERSIST),
+ dbc.FormText(_t("ft_fouling_max", lang),
+ id="ft-opt-fouling-max", className="text-muted small"),
], md=3),
], className="mb-3"),
- html.P("Restrições de produção", className="fw-semibold small text-secondary mb-2"),
+ html.P(_t("subsection_production_limits", lang),
+ className="fw-semibold small text-secondary mb-2"),
dbc.Row([
dbc.Col([
- dbc.Label("CP0 Mínimo (mol/m³)", className="fw-semibold small"),
+ html.Div([
+ dbc.Label(_t("field_cp0_min", lang),
+ className="fw-semibold small flex-grow-1",
+ id="label-opt-cp0-min"),
+ unit_selector("cp0_min", width="80px"),
+ ], className="d-flex align-items-center gap-2"),
dbc.Input(id="opt-cp0-min", type="number", value=0.0, step=10.0,
- min=0, max=1000, className="form-control-sm"),
- dbc.FormText("Produção mínima de produto", className="text-muted small"),
+ min=0, max=1000, className="form-control-sm", **_PERSIST),
+ dbc.FormText(_t("ft_cp0_min", lang),
+ id="ft-opt-cp0-min", className="text-muted small"),
], md=4),
], className="mb-3"),
html.Hr(className="cstr-divider my-2"),
- html.P("Faixas de busca (variáveis de decisão)",
+ html.P(_t("subsection_search_ranges", lang),
className="fw-semibold small text-secondary mb-2"),
dbc.Row([
- dbc.Col([dbc.Label("Vazão min (m³/s)", className="fw-semibold small"),
- dbc.Input(id="opt-vazao-min", type="number", value=0.00050,
- step=0.00005, min=0.0001, max=0.005, className="form-control-sm")], md=3),
- dbc.Col([dbc.Label("Vazão max (m³/s)", className="fw-semibold small"),
- dbc.Input(id="opt-vazao-max", type="number", value=0.00120,
- step=0.00005, min=0.0001, max=0.005, className="form-control-sm")], md=3),
- dbc.Col([dbc.Label("Válvula min", className="fw-semibold small"),
+ dbc.Col([
+ html.Div([
+ dbc.Label(_t("field_vazao_min", lang),
+ className="fw-semibold small flex-grow-1",
+ id="label-opt-vazao-min"),
+ unit_selector("vazao_min", width="72px"),
+ ], className="d-flex align-items-center gap-2"),
+ dbc.Input(id="opt-vazao-min", type="number", value=0.00050,
+ step=0.00005, min=0.0001, max=0.005,
+ className="form-control-sm", **_PERSIST),
+ ], md=3),
+ dbc.Col([
+ html.Div([
+ dbc.Label(_t("field_vazao_max", lang),
+ className="fw-semibold small flex-grow-1",
+ id="label-opt-vazao-max"),
+ unit_selector("vazao_max", width="72px"),
+ ], className="d-flex align-items-center gap-2"),
+ dbc.Input(id="opt-vazao-max", type="number", value=0.00120,
+ step=0.00005, min=0.0001, max=0.005,
+ className="form-control-sm", **_PERSIST),
+ ], md=3),
+ dbc.Col([dbc.Label(_t("field_valve_min", lang), className="fw-semibold small"),
dbc.Input(id="opt-valve-min", type="number", value=0.01,
- step=0.01, min=0.0, max=1.0, className="form-control-sm")], md=3),
- dbc.Col([dbc.Label("Válvula max", className="fw-semibold small"),
+ step=0.01, min=0.0, max=1.0,
+ className="form-control-sm", **_PERSIST)], md=3),
+ dbc.Col([dbc.Label(_t("field_valve_max", lang), className="fw-semibold small"),
dbc.Input(id="opt-valve-max", type="number", value=1.00,
- step=0.01, min=0.0, max=1.0, className="form-control-sm")], md=3),
+ step=0.01, min=0.0, max=1.0,
+ className="form-control-sm", **_PERSIST)], md=3),
], className="mb-3"),
dbc.Row([
- dbc.Col([dbc.Label("T_obs min (K)", className="fw-semibold small"),
- dbc.Input(id="opt-tobs-min", type="number", value=324.0,
- step=1.0, min=290, max=400, className="form-control-sm")], md=3),
- dbc.Col([dbc.Label("T_obs max (K)", className="fw-semibold small"),
- dbc.Input(id="opt-tobs-max", type="number", value=365.0,
- step=1.0, min=290, max=400, className="form-control-sm")], md=3),
- dbc.Col([dbc.Label("w_j min (m³/s)", className="fw-semibold small"),
- dbc.Input(id="opt-wj-min", type="number", value=0.00010,
- step=0.0001, min=0.0, max=0.02, className="form-control-sm")], md=3),
- dbc.Col([dbc.Label("w_j max (m³/s)", className="fw-semibold small"),
- dbc.Input(id="opt-wj-max", type="number", value=0.00999,
- step=0.0001, min=0.0, max=0.02, className="form-control-sm")], md=3),
+ dbc.Col([
+ html.Div([
+ dbc.Label(_t("field_tobs_min", lang),
+ className="fw-semibold small flex-grow-1",
+ id="label-opt-tobs-min"),
+ unit_selector("tobs_min", width="72px"),
+ ], className="d-flex align-items-center gap-2"),
+ dbc.Input(id="opt-tobs-min", type="number", value=324.0,
+ step=1.0, min=290, max=400,
+ className="form-control-sm", **_PERSIST),
+ ], md=3),
+ dbc.Col([
+ html.Div([
+ dbc.Label(_t("field_tobs_max", lang),
+ className="fw-semibold small flex-grow-1",
+ id="label-opt-tobs-max"),
+ unit_selector("tobs_max", width="72px"),
+ ], className="d-flex align-items-center gap-2"),
+ dbc.Input(id="opt-tobs-max", type="number", value=365.0,
+ step=1.0, min=290, max=400,
+ className="form-control-sm", **_PERSIST),
+ ], md=3),
+ dbc.Col([
+ html.Div([
+ dbc.Label(_t("field_wj_min", lang),
+ className="fw-semibold small flex-grow-1",
+ id="label-opt-wj-min"),
+ unit_selector("wj_min", width="72px"),
+ ], className="d-flex align-items-center gap-2"),
+ dbc.Input(id="opt-wj-min", type="number", value=0.00010,
+ step=0.0001, min=0.0, max=0.02,
+ className="form-control-sm", **_PERSIST),
+ ], md=3),
+ dbc.Col([
+ html.Div([
+ dbc.Label(_t("field_wj_max", lang),
+ className="fw-semibold small flex-grow-1",
+ id="label-opt-wj-max"),
+ unit_selector("wj_max", width="72px"),
+ ], className="d-flex align-items-center gap-2"),
+ dbc.Input(id="opt-wj-max", type="number", value=0.00999,
+ step=0.0001, min=0.0, max=0.02,
+ className="form-control-sm", **_PERSIST),
+ ], md=3),
]),
dbc.Alert([
html.Span("ℹ ", className="me-1"),
- "O NSGA-II busca máxima conversão e mínimo fouling respeitando todas "
- "as restrições acima."
+ _t("opt_info_alert", lang),
], color="info", className="mt-3 mb-0 small text-center"),
])
], className="shadow-sm mb-4 cstr-card fade-in")
@@ -373,26 +532,28 @@ def layout_TAB_Optimization():
algo_card = dbc.Card([
dbc.CardHeader([
html.Span("⚙️ ", style={"fontSize": "1.2rem"}),
- html.Span("Parâmetros NSGA-II", className="fw-bold"),
+ html.Span(_t("section_nsga_params", lang), className="fw-bold"),
], className="cstr-card-header"),
dbc.CardBody([
dbc.Row([
- dbc.Col([dbc.Label("Tamanho da População", className="fw-semibold small"),
+ dbc.Col([dbc.Label(_t("field_pop_size", lang), className="fw-semibold small"),
dbc.Input(id="opt-pop-size", type="number", value=80, step=10,
- min=10, max=300, className="form-control-sm")], md=6),
- dbc.Col([dbc.Label("Número de Gerações", className="fw-semibold small"),
+ min=10, max=300, className="form-control-sm", **_PERSIST)], md=6),
+ dbc.Col([dbc.Label(_t("field_n_gen", lang), className="fw-semibold small"),
dbc.Input(id="opt-n-gen", type="number", value=40, step=10,
- min=10, max=500, className="form-control-sm")], md=6),
+ min=10, max=500, className="form-control-sm", **_PERSIST)], md=6),
], className="mb-2"),
dbc.Row([
- dbc.Col([dbc.Label("Prob. Crossover (SBX)", className="fw-semibold small"),
+ dbc.Col([dbc.Label(_t("field_crossover_prob", lang), className="fw-semibold small"),
dbc.Input(id="opt-crossover-prob", type="number", value=0.9,
- step=0.05, min=0.0, max=1.0, className="form-control-sm"),
- dbc.FormText("0.8–1.0 recomendado", className="text-muted small")], md=6),
- dbc.Col([dbc.Label("Prob. Mutação (PM)", className="fw-semibold small"),
+ step=0.05, min=0.0, max=1.0,
+ className="form-control-sm", **_PERSIST),
+ dbc.FormText(_t("ft_crossover_prob", lang), className="text-muted small")], md=6),
+ dbc.Col([dbc.Label(_t("field_mutation_prob", lang), className="fw-semibold small"),
dbc.Input(id="opt-mutation-prob", type="number", value=0.1,
- step=0.05, min=0.0, max=1.0, className="form-control-sm"),
- dbc.FormText("0.05–0.2 recomendado", className="text-muted small")], md=6),
+ step=0.05, min=0.0, max=1.0,
+ className="form-control-sm", **_PERSIST),
+ dbc.FormText(_t("ft_mutation_prob", lang), className="text-muted small")], md=6),
]),
])
], className="shadow-sm mb-4 cstr-card fade-in")
@@ -400,7 +561,7 @@ def layout_TAB_Optimization():
run_card = dbc.Card([
dbc.CardBody([
html.Div(
- dbc.Button([html.Span("▶ "), "Executar Otimização"],
+ dbc.Button([html.Span("▶ "), _t("btn_run_optimization", lang)],
id="btn-run-optimization", color="primary", size="lg",
className="px-5 py-2 cstr-btn-primary"),
className="text-center"
@@ -421,10 +582,10 @@ def layout_TAB_Optimization():
kpi_summary_card = dbc.Card([
dbc.CardHeader([
html.Span("🏆 ", style={"fontSize": "1.2rem"}),
- html.Span("Resumo da Otimização", className="fw-bold"),
+ html.Span(_t("section_optimization_summary", lang), className="fw-bold"),
], className="cstr-card-header"),
dbc.CardBody(html.Div(id="opt-kpi-summary", children=[
- html.P("Execute a otimização para ver os resultados.",
+ html.P(_t("msg_run_first", lang),
className="text-muted text-center small"),
]), className="p-3"),
], className="shadow-sm mb-4 cstr-card fade-in")
@@ -433,56 +594,56 @@ def layout_TAB_Optimization():
results_card = dbc.Card([
dbc.CardHeader([
html.Span("📊 ", style={"fontSize": "1.2rem"}),
- html.Span("Resultados da Otimização (10 gráficos)", className="fw-bold"),
+ html.Span(_t("opt_results_header", lang), className="fw-bold"),
], className="cstr-card-header"),
dbc.CardBody([
# Pareto (full width)
dbc.Row([
- _chart_card("🎯", "Frente de Pareto: Conversão × Fouling",
- "opt-pareto-graph", height="450px", md=12),
+ _chart_card("🎯", _t("chart_opt_pareto", lang),
+ "opt-pareto-graph", height="500px", md=12),
], className="g-3 mb-3"),
# Parallel coords + Convergência
dbc.Row([
- _chart_card("🧵", "Parallel Coordinates", "opt-parcoords-graph",
- height="380px", md=7),
- _chart_card("📈", "Convergência NSGA-II (4 painéis)", "opt-convergence-graph",
- height="380px", md=5),
+ _chart_card("🧵", _t("chart_opt_parcoords", lang), "opt-parcoords-graph",
+ height="460px", md=7),
+ _chart_card("📈", _t("chart_opt_convergence", lang), "opt-convergence-graph",
+ height="520px", md=5),
], className="g-3 mb-3"),
# 3D + Mapa operacional
dbc.Row([
- _chart_card("🌐", "Espaço 3D: X × Fouling × Cat. Activity",
- "opt-3d-graph", height="450px", md=6),
- _chart_card("🗺️", "Mapa Operacional: Vazão × T_obs",
- "opt-heatmap-graph", height="450px", md=6),
+ _chart_card("🌐", _t("chart_opt_3d", lang),
+ "opt-3d-graph", height="540px", md=6),
+ _chart_card("🗺️", _t("chart_opt_heatmap", lang),
+ "opt-heatmap-graph", height="540px", md=6),
], className="g-3 mb-3"),
# Distribuições + Espaço de decisão
dbc.Row([
- _chart_card("🎻", "Distribuição das Saídas Ótimas",
- "opt-violin-graph", height="380px", md=6),
- _chart_card("🔲", "Espaço de Decisão (Scatter Matrix)",
- "opt-decision-space-graph", height="380px", md=6),
+ _chart_card("🎻", _t("chart_opt_violin", lang),
+ "opt-violin-graph", height="500px", md=6),
+ _chart_card("🔲", _t("chart_opt_decision_space", lang),
+ "opt-decision-space-graph", height="500px", md=6),
], className="g-3 mb-3"),
# TOPSIS + Clusters + Hypervolume
dbc.Row([
- _chart_card("🏅", "Ranking TOPSIS Multi-Critério (Top-15)",
- "opt-topsis-graph", height="420px", md=5),
- _chart_card("🔵", "Clusters K-Means da Frente de Pareto",
- "opt-cluster-graph", height="420px", md=7),
+ _chart_card("🏅", _t("chart_opt_topsis", lang),
+ "opt-topsis-graph", height="460px", md=5),
+ _chart_card("🔵", _t("chart_opt_cluster", lang),
+ "opt-cluster-graph", height="460px", md=7),
], className="g-3 mb-3"),
dbc.Row([
- _chart_card("📐", "Hypervolume e Factibilidade por Geração",
+ _chart_card("📐", _t("chart_opt_hypervolume", lang),
"opt-hypervolume-graph", height="380px", md=12),
], className="g-3 mb-3"),
html.Hr(className="cstr-divider"),
- html.P("Tabela de Soluções Factíveis",
+ html.P(_t("opt_feasible_table_title", lang),
className="text-secondary mb-1 fw-semibold"),
html.Div([
- dbc.Button("Exportar CSV", id="opt-export-csv",
+ dbc.Button(_t("btn_export_csv", lang), id="opt-export-csv",
color="secondary", size="sm", className="mb-2"),
dcc.Download(id="opt-download-csv"),
], className="text-end"),
@@ -529,12 +690,10 @@ def layout_TAB_Optimization():
return html.Div([
dbc.Container([
html.Div([
- html.H3("CSTR — Otimização Multi-Objetivo",
+ html.H3(_t("opt_page_title", lang),
className="fw-bold text-center mb-2 cstr-title"),
html.P(
- "NSGA-II encontra a frente de Pareto que maximiza X e minimiza fouling "
- "respeitando todas as restrições. 10 gráficos interativos com TOPSIS, "
- "clusters K-Means e evolução do Hypervolume.",
+ _t("opt_page_subtitle", lang),
className="text-center text-secondary mb-4"
),
], className="cstr-page-header"),
@@ -547,16 +706,16 @@ def layout_TAB_Optimization():
# ══════════════════════════════════════════════════════════════════════════════
# ABA: TRAINING
# ══════════════════════════════════════════════════════════════════════════════
-def layout_TAB_Training():
+def layout_TAB_Training(lang: str = DEFAULT_LANG):
info_card = dbc.Card([
dbc.CardHeader([
html.Span("ℹ️ ", style={"fontSize": "1.2rem"}),
- html.Span("Estrutura dos Dados de Treinamento", className="fw-bold"),
+ html.Span(_t("section_training_structure", lang), className="fw-bold"),
], className="cstr-card-header"),
dbc.CardBody([
dbc.Row([
dbc.Col([
- html.P("📥 Features (12):", className="fw-semibold mb-1"),
+ html.P(_t("training_features_title", lang), className="fw-semibold mb-1"),
html.Ul([
html.Li("vazao_feed, CA0_feed, CB0_feed"),
html.Li("Tf_feed, T_amb_feed, T_obs"),
@@ -566,23 +725,19 @@ def layout_TAB_Training():
], className="small text-muted"),
], md=6),
dbc.Col([
- html.P("📤 Targets (8):", className="fw-semibold mb-1"),
+ html.P(_t("training_targets_title", lang), className="fw-semibold mb-1"),
html.Ul([
- html.Li("CA0, CB0, CP0, CS0 (concentrações)"),
- html.Li("T0 (temperatura interna)"),
- html.Li("X (conversão)"),
+ html.Li("CA0, CB0, CP0, CS0 (" + _t("training_concentrations", lang) + ")"),
+ html.Li("T0 (" + _t("training_internal_temp", lang) + ")"),
+ html.Li("X (" + _t("training_conversion", lang) + ")"),
html.Li("fouling_thickness"),
html.Li("cat_activity"),
], className="small text-muted"),
], md=6),
]),
dbc.Alert([
- html.Strong("Origem dos dados: "),
- "Arquivos em ", html.Code("simulation/"),
- " (flat) ou ",
- html.Code("../Simulacoes/simulacao_CSTR/sim_*/observations/"),
- " (aninhado). Os hiperparâmetros vêm de ", html.Code("training/best_hps_*.json"),
- " e são sincronizados automaticamente antes do treino.",
+ html.Strong(_t("training_data_origin", lang) + ": "),
+ _t("training_data_origin_text", lang),
], color="info", className="mt-2 mb-0 small"),
])
], className="mb-3 shadow-sm cstr-card fade-in")
@@ -591,34 +746,35 @@ def layout_TAB_Training():
dbc.CardBody([
dbc.Row([
dbc.Col([
- dbc.Label("Arquitetura", className="fw-semibold small"),
+ dbc.Label(_t("field_architecture", lang), className="fw-semibold small"),
dcc.Dropdown(
id='training-model-select',
options=[
{'label': 'MLP', 'value': 'MLP'},
{'label': 'RNN', 'value': 'RNN'},
{'label': 'CNN', 'value': 'CNN'},
- {'label': 'Todos (MLP+RNN+CNN)', 'value': 'all'},
+ {'label': _t("training_all_models", lang), 'value': 'all'},
],
value='all', clearable=False, className="cstr-dropdown",
+ **_PERSIST,
),
], md=4),
dbc.Col([
- dbc.Button([html.Span("▶ "), "Iniciar Treinamento"],
+ dbc.Button([html.Span("▶ "), _t("btn_start_training", lang)],
id='btn-run-training', color="primary",
className="w-100 mt-4 cstr-btn-primary"),
], md=4),
dbc.Col([
- dbc.Label("Status", className="fw-semibold small"),
+ dbc.Label(_t("field_status", lang).rstrip(":"), className="fw-semibold small"),
html.Div(
- html.Span("● Aguardando", id="training-status-badge",
+ html.Span(_t("status_waiting", lang), id="training-status-badge",
className="badge bg-secondary fs-6 px-3 py-2"),
className="mt-2",
),
], md=4),
]),
html.Hr(className="cstr-divider"),
- dbc.Label("Logs de Treinamento", className="fw-semibold small text-secondary"),
+ dbc.Label(_t("training_logs", lang), className="fw-semibold small text-secondary"),
html.Pre(
id='training-log-output', className="cstr-log",
style={
@@ -635,15 +791,15 @@ def layout_TAB_Training():
# ── Curva de Loss ao vivo ────────────────────────────────────────────────
loss_row = dbc.Row([
- _chart_card("📉", "Curva de Loss (Train vs Val) — ao vivo",
+ _chart_card("📉", _t("chart_training_loss", lang),
"training-loss-chart", height="340px", md=12),
], className="g-3 mb-3")
# ── Métricas por arquitetura + Comparação de modelos ────────────────────
metrics_row = dbc.Row([
- _chart_card("📊", "Métricas por Arquitetura (R², MAE, RMSE por target)",
+ _chart_card("📊", _t("chart_training_metrics", lang),
"training-metrics-chart", height="420px", md=8),
- _chart_card("🕸️", "Radar de Desempenho Global (MLP vs RNN vs CNN)",
+ _chart_card("🕸️", _t("chart_training_radar", lang),
"training-radar-chart", height="420px", md=4),
], className="g-3 mb-3")
@@ -651,13 +807,13 @@ def layout_TAB_Training():
results_card = dbc.Card([
dbc.CardHeader([
html.Span("📋 ", style={"fontSize": "1.2rem"}),
- html.Span("Histórico de Treinamentos", className="fw-bold"),
+ html.Span(_t("section_training_history", lang), className="fw-bold"),
], className="cstr-card-header"),
dbc.CardBody([
- dbc.Button("🔄 Atualizar métricas", id="btn-reload-metrics",
+ dbc.Button("🔄 " + _t("btn_update_metrics", lang), id="btn-reload-metrics",
color="secondary", size="sm", className="mb-3"),
html.Div(id="training-results-table-container", children=[
- html.P("Clique em 'Atualizar métricas' após treinar para ver os resultados.",
+ html.P(_t("msg_click_update", lang),
className="text-muted text-center small"),
]),
])
@@ -666,12 +822,10 @@ def layout_TAB_Training():
return html.Div([
dbc.Container([
html.Div([
- html.H3("Treinar Modelo Substituto",
+ html.H3(_t("training_page_title", lang),
className="fw-bold text-center mb-2 cstr-title"),
html.P(
- "Treinamento dos surrogates MLP / RNN / CNN com hiperparâmetros "
- "previamente otimizados — curvas de loss ao vivo, métricas por target "
- "e comparação entre arquiteturas.",
+ _t("training_page_subtitle", lang),
className="text-center text-secondary mb-4"
),
], className="cstr-page-header"),
@@ -684,25 +838,293 @@ def layout_TAB_Training():
# ══════════════════════════════════════════════════════════════════════════════
# ABA: ANALYTICS (NOVA)
# ══════════════════════════════════════════════════════════════════════════════
-def layout_TAB_Analytics():
+def _layout_data_file_manager(lang: str = DEFAULT_LANG):
+ """
+ Gerenciador de Arquivos de Dados — descobre, pré-visualiza, remove e
+ aceita upload de CSVs usados em simulação/treino/otimização.
+
+ Componentes:
+ • KPIs da coleção (cards coloridos)
+ • Filtros por tipo/fonte + busca
+ • DataTable selecionável com todos os arquivos
+ • DataTable de preview (com paginação)
+ • dcc.Upload + botões de ação (Atualizar, Excluir, Download)
+ • dcc.ConfirmDialog para confirmar exclusão
+ """
+ header = dbc.CardHeader([
+ html.Span("🗂️ ", style={"fontSize": "1.2rem"}),
+ html.Span(_t("section_data_files_manager", lang),
+ className="fw-bold"),
+ html.Span(
+ " — " + _t("dfm_subtitle", lang),
+ className="text-muted small ms-2",
+ ),
+ ], className="cstr-card-header")
+
+ # ── Linha de KPIs (populada dinamicamente) ───────────────────────────────
+ kpi_row = html.Div(
+ id="dfm-kpis",
+ className="mb-3",
+ children=html.P(
+ _t("dfm_click_refresh", lang),
+ className="text-muted text-center small mb-0",
+ ),
+ )
+
+ # ── Barra de ações / filtros ─────────────────────────────────────────────
+ filter_bar = dbc.Row([
+ dbc.Col([
+ dbc.Label(_t("dfm_filter_kind", lang), className="small fw-semibold text-secondary"),
+ dcc.Dropdown(
+ id="dfm-filter-kind",
+ options=[
+ {"label": _t("dfm_all", lang), "value": "ALL"},
+ {"label": "🧪 " + _t("dfm_kind_features", lang), "value": "features"},
+ {"label": "🎯 " + _t("dfm_kind_targets", lang), "value": "targets"},
+ {"label": "📚 " + _t("dfm_kind_complete", lang), "value": "complete"},
+ {"label": "👁️ " + _t("dfm_kind_observed", lang), "value": "observed"},
+ {"label": "🧭 " + _t("dfm_kind_truth", lang), "value": "truth"},
+ {"label": "🎓 " + _t("dfm_kind_training_result", lang), "value": "training_result"},
+ {"label": "📄 " + _t("dfm_kind_other", lang), "value": "other"},
+ ],
+ value="ALL", clearable=False, className="cstr-dropdown",
+ **_PERSIST,
+ ),
+ ], md=3),
+ dbc.Col([
+ dbc.Label(_t("dfm_filter_source", lang), className="small fw-semibold text-secondary"),
+ dcc.Dropdown(
+ id="dfm-filter-source",
+ options=[
+ {"label": _t("dfm_all_fem", lang), "value": "ALL"},
+ {"label": "📁 CSTRChemIA/simulation (flat)", "value": "simulation_flat"},
+ {"label": "🔬 sim_*/observations", "value": "sim_observations"},
+ {"label": "🧭 sim_*/truth", "value": "sim_truth"},
+ {"label": "🌈 dados_diversos", "value": "diversos"},
+ {"label": "📈 surrogate_model", "value": "training"},
+ ],
+ value="ALL", clearable=False, className="cstr-dropdown",
+ **_PERSIST,
+ ),
+ ], md=3),
+ dbc.Col([
+ dbc.Label(_t("dfm_search", lang),
+ className="small fw-semibold text-secondary"),
+ dbc.Input(
+ id="dfm-search", type="text", placeholder="ex.: features_00",
+ debounce=True, className="form-control-sm", **_PERSIST,
+ ),
+ ], md=3),
+ dbc.Col([
+ dbc.Label("·", className="small text-white"),
+ dbc.ButtonGroup([
+ dbc.Button("🔄 " + _t("btn_refresh", lang), id="dfm-btn-refresh",
+ color="primary", size="sm",
+ className="cstr-btn-primary"),
+ dbc.Button("🗑️ " + _t("btn_delete", lang), id="dfm-btn-delete",
+ color="danger", size="sm",
+ disabled=True),
+ ], className="w-100"),
+ ], md=3),
+ ], className="g-2 mb-3")
+
+ # ── Tabela principal de arquivos ─────────────────────────────────────────
+ file_table = dash_table.DataTable(
+ id="dfm-file-table",
+ columns=[
+ {"name": _t("col_kind", lang), "id": "kind_label"},
+ {"name": _t("col_file", lang), "id": "filename"},
+ {"name": _t("col_source", lang), "id": "source_label"},
+ {"name": _t("col_rows", lang), "id": "rows", "type": "numeric"},
+ {"name": _t("col_cols", lang), "id": "cols", "type": "numeric"},
+ {"name": _t("col_size", lang), "id": "size_human"},
+ {"name": _t("col_modified", lang), "id": "modified_human"},
+ {"name": _t("col_path", lang), "id": "rel_path"},
+ ],
+ data=[],
+ row_selectable="single",
+ selected_rows=[],
+ page_size=12,
+ sort_action="native",
+ filter_action="none",
+ style_table={"overflowX": "auto"},
+ style_cell={
+ "fontSize": "12px",
+ "padding": "6px 10px",
+ "fontFamily": "ui-monospace, SFMono-Regular, Menlo, monospace",
+ "textAlign": "left",
+ "whiteSpace": "nowrap",
+ "textOverflow": "ellipsis",
+ "overflow": "hidden",
+ "maxWidth": "320px",
+ },
+ style_header={
+ "fontWeight": "bold",
+ "backgroundColor": "#f8f9fa",
+ "borderBottom": "2px solid #dee2e6",
+ "fontSize": "12px",
+ },
+ style_data_conditional=[
+ # Linha selecionada
+ {"if": {"state": "selected"},
+ "backgroundColor": "#e7f1ff",
+ "border": "1px solid #0d6efd"},
+ # Destacar por tipo
+ {"if": {"filter_query": "{kind} = features"},
+ "borderLeft": "3px solid #0d6efd"},
+ {"if": {"filter_query": "{kind} = targets"},
+ "borderLeft": "3px solid #198754"},
+ {"if": {"filter_query": "{kind} = truth"},
+ "borderLeft": "3px solid #ffc107"},
+ {"if": {"filter_query": "{kind} = training_result"},
+ "borderLeft": "3px solid #212529"},
+ ],
+ )
+
+ # ── Preview do arquivo selecionado ───────────────────────────────────────
+ preview_header = html.Div(
+ id="dfm-preview-header",
+ className="d-flex justify-content-between align-items-center mb-2",
+ children=[
+ html.Div([
+ html.Strong(_t("dfm_preview", lang) + ": ", className="small"),
+ html.Span(_t("msg_select_file", lang),
+ id="dfm-preview-name",
+ className="text-muted small"),
+ ]),
+ html.Span(id="dfm-preview-stats", className="text-muted small"),
+ ],
+ )
+
+ preview_table = dash_table.DataTable(
+ id="dfm-preview-table",
+ columns=[],
+ data=[],
+ page_size=15,
+ sort_action="native",
+ style_table={"overflowX": "auto", "maxHeight": "380px",
+ "overflowY": "auto"},
+ style_cell={
+ "fontSize": "11px",
+ "padding": "4px 8px",
+ "fontFamily": "ui-monospace, SFMono-Regular, Menlo, monospace",
+ "textAlign": "right",
+ "whiteSpace": "nowrap",
+ "textOverflow": "ellipsis",
+ "overflow": "hidden",
+ "maxWidth": "180px",
+ },
+ style_header={
+ "fontWeight": "bold",
+ "backgroundColor": "#f8f9fa",
+ "borderBottom": "2px solid #dee2e6",
+ "fontSize": "11px",
+ },
+ style_data_conditional=[
+ {"if": {"row_index": "odd"}, "backgroundColor": "#fcfcfc"},
+ ],
+ )
+
+ # ── Upload area ─────────────────────────────────────────────────────────
+ upload_card = dbc.Card([
+ dbc.CardHeader([
+ html.Span("📤 ", style={"fontSize": "1.1rem"}),
+ html.Span(_t("dfm_add_file_title", lang),
+ className="fw-bold small"),
+ ], className="cstr-card-header"),
+ dbc.CardBody([
+ dbc.Alert([
+ html.Strong(_t("dfm_name_pattern_required", lang) + ": "),
+ html.Code("dados_treino_features_.csv", className="me-2"),
+ html.Span(_t("dfm_or", lang) + " ", className="small"),
+ html.Code("dados_treino_targets_.csv"),
+ html.Br(),
+ html.Small(
+ _t("dfm_save_location_note", lang),
+ className="text-muted",
+ ),
+ ], color="info", className="py-2 small mb-2"),
+
+ dcc.Upload(
+ id="dfm-upload",
+ multiple=True,
+ children=html.Div([
+ html.Div("📥 " + _t("dfm_drop_files_here", lang),
+ className="fw-bold"),
+ html.Small(_t("dfm_or_click_to_select", lang),
+ className="text-muted"),
+ ], className="text-center py-3"),
+ style={
+ "width": "100%",
+ "border": "2px dashed #adb5bd",
+ "borderRadius": "10px",
+ "cursor": "pointer",
+ "backgroundColor": "#f8f9fa",
+ "transition": "background-color 0.2s, border-color 0.2s",
+ },
+ className="dfm-upload-zone",
+ ),
+
+ html.Div(id="dfm-upload-feedback", className="mt-2 small"),
+ ]),
+ ], className="shadow-sm cstr-card mb-3")
+
+ # ── Container principal do gerenciador ───────────────────────────────────
+ manager_card = dbc.Card([
+ header,
+ dbc.CardBody([
+ kpi_row,
+ filter_bar,
+
+ # Tabela principal
+ html.Div(file_table, className="mb-3"),
+
+ # Feedback de ações (exclusão, etc.)
+ html.Div(id="dfm-action-feedback", className="mb-2 small"),
+
+ html.Hr(className="my-3"),
+
+ # Preview
+ preview_header,
+ preview_table,
+ ]),
+ ], className="shadow-sm cstr-card mb-3 fade-in")
+
+ # Confirmação de exclusão
+ confirm = dcc.ConfirmDialog(
+ id="dfm-confirm-delete",
+ message=_t("dfm_confirm_delete_msg", lang),
+ )
+
+ # Stores + auto-refresh invisível
+ stores = html.Div([
+ dcc.Store(id="dfm-files-store", data=[]),
+ dcc.Store(id="dfm-selected-path-store", data=None),
+ dcc.Interval(id="dfm-refresh-interval", interval=30_000, disabled=True),
+ ])
+
+ return html.Div([manager_card, upload_card, confirm, stores])
+
+
+def layout_TAB_Analytics(lang: str = DEFAULT_LANG):
"""
Explorador completo de dados: distribuições, correlações, benchmarking
- de modelos e estatísticas do dataset de treino.
+ de modelos e estatísticas do dataset de treino. Inclui também um
+ gerenciador dinâmico de arquivos de dados.
"""
header_card = dbc.Card([
dbc.CardBody([
dbc.Row([
dbc.Col([
- html.H5("📊 Explorador de Dados & Benchmark de Modelos",
+ html.H5("📊 " + _t("analytics_card_title", lang),
className="fw-bold mb-1"),
html.P(
- "Visualize as distribuições do dataset de treino, correlações "
- "entre features e targets, e compare as arquiteturas MLP / RNN / CNN.",
+ _t("analytics_card_subtitle", lang),
className="text-muted small mb-0"
),
], md=9),
dbc.Col([
- dbc.Button("🔄 Carregar / Atualizar", id="btn-load-analytics",
+ dbc.Button("🔄 " + _t("btn_load_refresh", lang), id="btn-load-analytics",
color="primary", className="w-100 mt-2 cstr-btn-primary"),
], md=3),
]),
@@ -711,37 +1133,37 @@ def layout_TAB_Analytics():
# ── KPIs do dataset ──────────────────────────────────────────────────────
dataset_kpi_row = html.Div(id="analytics-dataset-kpis", children=[
- html.P("Clique em 'Carregar / Atualizar' para ver as estatísticas.",
+ html.P(_t("analytics_click_refresh", lang),
className="text-muted text-center small"),
], className="mb-3")
# ── Linha 1: distribuição features + distribuição targets ────────────────
row1 = dbc.Row([
- _chart_card("📦", "Distribuição das Features (box plots normalizados)",
+ _chart_card("📦", _t("chart_analytics_feat_dist", lang),
"analytics-feat-dist-chart", height="400px", md=7),
- _chart_card("🎯", "Distribuição dos Targets (violin plots)",
+ _chart_card("🎯", _t("chart_analytics_target_dist", lang),
"analytics-target-dist-chart", height="400px", md=5),
], className="g-3 mb-3")
# ── Linha 2: heatmap correlação features×targets ─────────────────────────
row2 = dbc.Row([
- _chart_card("🔥", "Matriz de Correlação Features × Targets",
+ _chart_card("🔥", _t("chart_analytics_corr", lang),
"analytics-corr-chart", height="480px", md=12),
], className="g-3 mb-3")
# ── Linha 3: benchmark arquiteturas ─────────────────────────────────────
row3 = dbc.Row([
- _chart_card("📏", "Métricas por Target — Comparação MLP/RNN/CNN",
+ _chart_card("📏", _t("chart_analytics_metrics_bar", lang),
"analytics-metrics-bar-chart", height="420px", md=8),
- _chart_card("🕸️", "Radar de Desempenho Global por Arquitetura",
+ _chart_card("🕸️", _t("chart_analytics_arch_radar", lang),
"analytics-arch-radar-chart", height="420px", md=4),
], className="g-3 mb-3")
# ── Linha 4: scree de variância (PCA) + histograma de erros ─────────────
row4 = dbc.Row([
- _chart_card("🔬", "Variância Explicada (PCA das Features)",
+ _chart_card("🔬", _t("chart_analytics_pca", lang),
"analytics-pca-chart", height="360px", md=6),
- _chart_card("📈", "Distribuição do Erro de Predição por Target",
+ _chart_card("📈", _t("chart_analytics_error_dist", lang),
"analytics-error-dist-chart", height="360px", md=6),
], className="g-3 mb-3")
@@ -749,10 +1171,10 @@ def layout_TAB_Analytics():
stats_card = dbc.Card([
dbc.CardHeader([
html.Span("📋 ", style={"fontSize": "1.2rem"}),
- html.Span("Estatísticas Descritivas do Dataset", className="fw-bold"),
+ html.Span(_t("analytics_stats_section", lang), className="fw-bold"),
], className="cstr-card-header"),
dbc.CardBody(html.Div(id="analytics-stats-table", children=[
- html.P("Carregue os dados para ver a tabela.",
+ html.P(_t("analytics_load_data_msg", lang),
className="text-muted text-center small"),
])),
], className="shadow-sm cstr-card mb-3 fade-in")
@@ -760,15 +1182,18 @@ def layout_TAB_Analytics():
return html.Div([
dbc.Container([
html.Div([
- html.H3("Analytics — Dados & Benchmarking",
+ html.H3(_t("analytics_page_title", lang),
className="fw-bold text-center mb-2 cstr-title"),
html.P(
- "Explorção detalhada do dataset de treino, distribuições, correlações "
- "e comparação quantitativa entre as três arquiteturas de surrogate.",
+ _t("analytics_page_subtitle", lang),
className="text-center text-secondary mb-4"
),
], className="cstr-page-header"),
header_card,
+
+ # Gerenciador dinâmico de arquivos de dados
+ _layout_data_file_manager(lang=lang),
+
dataset_kpi_row,
row1, row2, row3, row4,
stats_card,
@@ -779,7 +1204,7 @@ def layout_TAB_Analytics():
# ══════════════════════════════════════════════════════════════════════════════
# ABA: ABOUT
# ══════════════════════════════════════════════════════════════════════════════
-def layout_TAB_About():
+def layout_TAB_About(lang: str = DEFAULT_LANG):
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
return html.Div([
html.Iframe(
@@ -794,7 +1219,9 @@ def layout_TAB_About():
# ══════════════════════════════════════════════════════════════════════════════
# ABA: HELP
# ══════════════════════════════════════════════════════════════════════════════
-def layout_help():
+def layout_help(lang: str = DEFAULT_LANG):
+ # ``lang`` recebido para consistência com as outras layouts; a ajuda em si
+ # vive num iframe com seus próprios idiomas.
return html.Div([
html.Iframe(
id='html-viewer',
diff --git a/CSTRChemIA/main.py b/CSTRChemIA/main.py
index 746679a..e212707 100644
--- a/CSTRChemIA/main.py
+++ b/CSTRChemIA/main.py
@@ -2,52 +2,35 @@
# -*- coding: utf-8 -*-
"""
-Arquivo principal da aplicação Dash (main.py)
-Inicializa o app, configura logging, suprime warnings e carrega os módulos.
+main.py — ponto de entrada do app Dash.
+
+Inicializa logging centralizado (core.logging), cria o Dash app, expõe o
+WSGI server para gunicorn e registra health-check em ``/healthz``.
"""
import os
import sys
-import logging
import warnings
-import dash
-import dash_bootstrap_components as dbc
-
-# ==============================
-# CONFIGURAÇÕES DE AMBIENTE
-# ==============================
-# Suprime mensagens do TensorFlow (0 = all, 1 = no INFO, 2 = no WARNING, 3 = no ERROR)
-os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
+# Suprime ruído de TF antes de qualquer import pesado
+os.environ.setdefault("TF_CPP_MIN_LOG_LEVEL", "2")
warnings.filterwarnings("ignore")
-# ==============================
-# CONFIGURAÇÃO DO STDOUT (line-buffered)
-# ==============================
-# No Windows / Python 3.13, reconfigure pode tornar o handle inválido em
-# threads background — envolvemos em try/except para não quebrar o app.
-try:
- if hasattr(sys.stdout, 'reconfigure'):
- sys.stdout.reconfigure(line_buffering=True)
- if hasattr(sys.stderr, 'reconfigure'):
- sys.stderr.reconfigure(line_buffering=True)
-except (OSError, ValueError, AttributeError):
- pass
+# PATH — permite executar tanto `python main.py` (dentro de CSTRChemIA/)
+# quanto `gunicorn --chdir CSTRChemIA main:server`.
+sys.path.append(os.path.abspath(os.path.dirname(__file__)))
-# ==============================
-# CONFIGURAÇÃO DO LOGGING
-# ==============================
-logging.basicConfig(
- stream=sys.stdout,
- level=logging.INFO,
- format='%(asctime)s [%(levelname)s] %(message)s',
- force=True # substitui configurações anteriores (Python 3.8+)
-)
+import dash # noqa: E402
+import dash_bootstrap_components as dbc # noqa: E402
+from flask import jsonify # noqa: E402
-# ==============================
-# ADICIONA O DIRETÓRIO ATUAL AO PATH
-# ==============================
-sys.path.append(os.path.abspath(os.path.dirname(__file__)))
+from core.logging import get_logger, setup_logging # noqa: E402
+from core.settings import get_settings # noqa: E402
+
+# Logging centralizado — substitui a antiga configuração ad-hoc em main.py
+setup_logging()
+log = get_logger(__name__)
+settings = get_settings()
# ==============================
# CRIAÇÃO DO APP DASH
@@ -56,22 +39,37 @@
__name__,
external_stylesheets=[dbc.themes.BOOTSTRAP],
suppress_callback_exceptions=True,
- prevent_initial_callbacks='initial_duplicate'
+ prevent_initial_callbacks="initial_duplicate",
)
-software = "CSTR Simulator & Optimizer"
+software = "CSTRChemIA"
app.title = software
server = app.server
+# Endpoint de health-check — consumido por Docker/K8s/Render/Railway
+@server.route("/healthz")
+def _healthz():
+ from services.model_registry import get_model_registry
+ try:
+ archs = get_model_registry().available_architectures()
+ except Exception as e: # noqa: BLE001
+ log.exception("health check: falha ao listar modelos")
+ return jsonify({"status": "degraded", "error": str(e)}), 503
+ return jsonify({
+ "status": "ok",
+ "service": software,
+ "models_available": archs,
+ })
+
+
# ==============================
# IMPORTAÇÃO DOS MÓDULOS DA APLICAÇÃO
# ==============================
-from apps.MAIN_init import *
-import apps.MAIN_callbacks
+# `from apps.MAIN_init import *` traz `run_chemsimia()` que já configura
+# a abertura automática do browser (não-Linux) e chama app.run().
+from apps.MAIN_init import * # noqa: E402,F401,F403
+import apps.MAIN_callbacks # noqa: E402,F401
-# ==============================
-# PONTO DE ENTRADA
-# ==============================
-if __name__ == '__main__':
- # A função RUN_ChemSimIA() deve estar definida em apps.MAIN_init
- run_chemsimia()
\ No newline at end of file
+
+if __name__ == "__main__":
+ run_chemsimia() # type: ignore[name-defined] # vem do star-import
diff --git a/CSTRChemIA/optimization/CSTR_Optimization_Problem.py b/CSTRChemIA/optimization/CSTR_Optimization_Problem.py
index c727a09..86340a8 100644
--- a/CSTRChemIA/optimization/CSTR_Optimization_Problem.py
+++ b/CSTRChemIA/optimization/CSTR_Optimization_Problem.py
@@ -47,7 +47,7 @@
def _safe_fl_init(self):
try:
_orig_fl_init(self)
- except (OSError, ValueError, Exception):
+ except Exception:
# Marca como não-compilado sem propagar o erro do print()
self.is_compiled = False
@@ -67,6 +67,24 @@ def _safe_fl_init(self):
from surrogate_model.KerasPredict import PredictValues, TARGET_COLS, N_FEATURES
+# ── i18n (fallback seguro se o módulo não estiver disponível) ─────────────────
+try:
+ from core.i18n import t as _t_i18n, DEFAULT_LANG
+except Exception:
+ DEFAULT_LANG = "pt"
+
+ def _t_i18n(key: str, lang: str = "pt", **_kw) -> str:
+ return key
+
+
+def _t(key: str, lang: str = None) -> str:
+ """Wrapper curto para traduções com fallback seguro."""
+ try:
+ return _t_i18n(key, lang or DEFAULT_LANG)
+ except Exception:
+ return key
+
+
IDX = {col: i for i, col in enumerate(TARGET_COLS)}
VAR_NAMES = ['vazao_feed', 'CA0_feed', 'CB0_feed', 'Tf_feed',
@@ -222,21 +240,24 @@ def _empty_figure(msg: str, title: str = "") -> go.Figure:
return fig
-def _build_pareto_figure(df: pd.DataFrame, constraints: dict = None) -> go.Figure:
+def _build_pareto_figure(df: pd.DataFrame, constraints: dict = None,
+ lang: str = DEFAULT_LANG) -> go.Figure:
if df.empty:
- return _empty_figure("Nenhuma solucao factivel. Relaxe as restricoes.", "Pareto Front")
+ return _empty_figure(_t("msg_no_feasible_relax", lang),
+ _t("opt_fig_pareto_title", lang))
fig = go.Figure()
fig.add_trace(go.Scatter(
x=df['X (%)'], y=df['Fouling (m)'], mode='markers',
marker=dict(color=df['Cat. Activity'], colorscale='Viridis',
- showscale=True, colorbar=dict(title='Cat. Activity', thickness=14),
+ showscale=True, colorbar=dict(title=_t("target_cat_activity", lang),
+ thickness=14),
size=11, opacity=0.9, line=dict(color=_PALETTE['primary'], width=1)),
text=[(f"vazao={r['vazao_feed']:.4g} m3/s
T_obs={r['T_obs (K)']:.1f} K
"
f"T0={r['T0 predita (K)']:.1f} K
valve={r['valve_pos']:.2f}
"
f"CP0={r['CP0 predita']:.1f} mol/m3") for _, r in df.iterrows()],
hovertemplate="X=%{x:.2f}%
Fouling=%{y:.2e} m
%{text}",
- name='Pareto Front',
+ name=_t("opt_label_pareto_front", lang),
))
if constraints:
if constraints.get('X_min', 0) > 0:
@@ -258,72 +279,96 @@ def _build_pareto_figure(df: pd.DataFrame, constraints: dict = None) -> go.Figur
x=[utopia_x], y=[utopia_f], mode='markers',
marker=dict(symbol='star', size=18, color=_PALETTE['gold'],
line=dict(color=_PALETTE['primary'], width=2)),
- name='Ponto Utopia',
+ name=_t("opt_label_utopia", lang),
hovertemplate=f"Utopia
X={utopia_x:.2f}%
Fouling={utopia_f:.2e}",
))
fig.update_layout(
- title=dict(text='Frente de Pareto: Conversao x Fouling',
- font=dict(color=_PALETTE['primary'], size=16), x=0.5),
- xaxis_title='Conversao X (%)', yaxis_title='Fouling Thickness (m)',
- template='plotly_white', margin=dict(t=60, b=40, l=50, r=30),
- legend=dict(orientation='h', yanchor='bottom', y=1.02, xanchor='center', x=0.5),
+ title=dict(text=_t("opt_fig_pareto_title", lang),
+ font=dict(color=_PALETTE['primary'], size=16),
+ x=0.5, xanchor='center', y=0.97, yanchor='top'),
+ xaxis_title=_t("opt_fig_pareto_x", lang),
+ yaxis_title=_t("opt_fig_pareto_y", lang),
+ template='plotly_white', margin=dict(t=70, b=80, l=55, r=30),
+ legend=dict(
+ orientation='h',
+ yanchor='top', y=-0.18,
+ xanchor='center', x=0.5,
+ bgcolor='rgba(255,255,255,0.85)',
+ bordercolor=_PALETTE['primary'], borderwidth=1,
+ font=dict(size=11),
+ ),
)
return fig
-def _build_parcoords_figure(df: pd.DataFrame) -> go.Figure:
+def _build_parcoords_figure(df: pd.DataFrame, lang: str = DEFAULT_LANG) -> go.Figure:
if df.empty or len(df) < 2:
- return _empty_figure("Precisa de >= 2 solucoes factiveis.", "Parallel Coordinates")
+ return _empty_figure(_t("msg_need_2_feasible", lang),
+ _t("opt_fig_parcoords_title", lang))
+ # Formatos por dimensão (evita 10+ casas decimais esmagando as réguas)
dims = [
- dict(label='Vazao (m3/s)', values=df['vazao_feed']),
- dict(label='CA0 Feed', values=df['CA0_feed']),
- dict(label='CB0 Feed', values=df['CB0_feed']),
- dict(label='Tf (K)', values=df['Tf_feed (K)']),
- dict(label='T_obs (K)', values=df['T_obs (K)']),
- dict(label='Valvula', values=df['valve_pos']),
- dict(label='w_j', values=df['w_j']),
- dict(label='X (%)', values=df['X (%)']),
- dict(label='Fouling (m)', values=df['Fouling (m)']),
- dict(label='Cat. Act.', values=df['Cat. Activity']),
- dict(label='CP0 Pred.', values=df['CP0 predita']),
+ dict(label=_t("feat_flow", lang) + ' (m³/s)', values=df['vazao_feed'], tickformat='.3g'),
+ dict(label=_t("feat_ca0", lang), values=df['CA0_feed'], tickformat='.3g'),
+ dict(label=_t("feat_cb0", lang), values=df['CB0_feed'], tickformat='.3g'),
+ dict(label=_t("feat_tf", lang) + ' (K)', values=df['Tf_feed (K)'], tickformat='.0f'),
+ dict(label=_t("feat_tobs", lang) + ' (K)', values=df['T_obs (K)'], tickformat='.0f'),
+ dict(label=_t("feat_valve", lang), values=df['valve_pos'], tickformat='.2f'),
+ dict(label=_t("feat_wj", lang), values=df['w_j'], tickformat='.3g'),
+ dict(label=_t("short_x_pct", lang), values=df['X (%)'], tickformat='.1f'),
+ dict(label=_t("opt_axis_fouling_um", lang).replace('(µm)', '(m)'),
+ values=df['Fouling (m)'], tickformat='.2e'),
+ dict(label=_t("target_cat_activity", lang), values=df['Cat. Activity'], tickformat='.3f'),
+ dict(label=_t("target_CP0", lang), values=df['CP0 predita'], tickformat='.3g'),
]
fig = go.Figure(data=go.Parcoords(
line=dict(color=df['X (%)'], colorscale='Viridis',
- showscale=True, colorbar=dict(title='X (%)', thickness=14)),
+ showscale=True, colorbar=dict(title=_t("short_x_pct", lang),
+ thickness=14)),
dimensions=dims,
+ labelfont=dict(size=11, color=_PALETTE['primary']),
+ tickfont=dict(size=9),
+ rangefont=dict(size=9),
))
fig.update_layout(
- title=dict(text='Parallel Coordinates -- Solucoes Factiveis',
- font=dict(color=_PALETTE['primary'], size=16), x=0.5),
- template='plotly_white', margin=dict(t=70, b=40, l=60, r=60),
+ title=dict(text=_t("opt_fig_parcoords_title", lang),
+ font=dict(color=_PALETTE['primary'], size=15),
+ x=0.5, xanchor='center', y=0.97, yanchor='top'),
+ template='plotly_white',
+ margin=dict(t=90, b=55, l=70, r=80),
+ paper_bgcolor='rgba(0,0,0,0)',
)
return fig
-def _build_3d_figure(df: pd.DataFrame) -> go.Figure:
+def _build_3d_figure(df: pd.DataFrame, lang: str = DEFAULT_LANG) -> go.Figure:
if df.empty:
- return _empty_figure("Sem solucoes factiveis para 3D.", "3D Scatter")
+ return _empty_figure(_t("msg_no_feasible_3d", lang),
+ _t("opt_fig_3d_title", lang))
fig = go.Figure(data=go.Scatter3d(
x=df['X (%)'], y=df['Fouling (m)'], z=df['Cat. Activity'],
mode='markers',
marker=dict(size=6, color=df['T0 predita (K)'], colorscale='Plasma',
- showscale=True, colorbar=dict(title='T0 (K)', thickness=14),
+ showscale=True, colorbar=dict(title=_t("short_t0_k", lang),
+ thickness=14),
opacity=0.9, line=dict(color=_PALETTE['primary'], width=0.5)),
hovertemplate="X=%{x:.2f}%
Fouling=%{y:.2e}
CatAct=%{z:.4f}",
))
fig.update_layout(
- title=dict(text='Espaco 3D de Desempenho',
+ title=dict(text=_t("opt_fig_3d_perf_title", lang),
font=dict(color=_PALETTE['primary'], size=16), x=0.5),
- scene=dict(xaxis_title='X (%)', yaxis_title='Fouling (m)',
- zaxis_title='Cat. Activity', bgcolor='rgba(248,249,252,0.6)'),
+ scene=dict(xaxis_title=_t("short_x_pct", lang),
+ yaxis_title=_t("opt_label_fouling", lang) + ' (m)',
+ zaxis_title=_t("target_cat_activity", lang),
+ bgcolor='rgba(248,249,252,0.6)'),
template='plotly_white', margin=dict(t=60, b=10, l=10, r=10),
)
return fig
-def _build_convergence_figure(history: List[dict]) -> go.Figure:
+def _build_convergence_figure(history: List[dict], lang: str = DEFAULT_LANG) -> go.Figure:
if not history:
- return _empty_figure("Sem historico de convergencia.", "Convergencia")
+ return _empty_figure(_t("msg_no_convergence_hist", lang),
+ _t("opt_fig_convergence_title", lang))
gens = [h['gen'] for h in history]
feasible = [h['feasible'] for h in history]
best_x = [h['best_X'] for h in history]
@@ -332,43 +377,59 @@ def _build_convergence_figure(history: List[dict]) -> go.Figure:
diversity = [h.get('diversity', 0) for h in history]
fig = make_subplots(rows=2, cols=2,
- subplot_titles=("Factibilidade por Geração",
- "Melhor X (%) × Gerações",
- "Média X (%) Factíveis",
- "Diversidade (spread)"),
+ subplot_titles=(
+ _t("opt_sub_feasibility", lang),
+ _t("opt_sub_best_x", lang),
+ _t("opt_sub_avg_x", lang),
+ _t("opt_sub_diversity", lang),
+ ),
horizontal_spacing=0.12, vertical_spacing=0.15)
fig.add_trace(go.Scatter(x=gens, y=feasible, mode='lines+markers',
line=dict(color=_PALETTE['primary'], width=2),
- marker=dict(size=5), name='Factiveis'), row=1, col=1)
+ marker=dict(size=5),
+ name=_t("opt_label_feasible_plural", lang)), row=1, col=1)
fig.add_trace(go.Scatter(x=gens, y=[b * 100 for b in best_x], mode='lines+markers',
line=dict(color=_PALETTE['success'], width=2),
- marker=dict(size=5), name='Melhor X'), row=1, col=2)
+ marker=dict(size=5),
+ name=_t("opt_label_best_x", lang)), row=1, col=2)
fig.add_trace(go.Scatter(x=gens, y=[a * 100 for a in avg_x], mode='lines',
line=dict(color=_PALETTE['info'], width=2),
- name='Media X'), row=2, col=1)
+ name=_t("opt_label_avg_x", lang)), row=2, col=1)
fig.add_trace(go.Scatter(x=gens, y=diversity, mode='lines',
line=dict(color=_PALETTE['warning'], width=2),
- name='Diversidade'), row=2, col=2)
- for r, c, yt in [(1, 1, 'N factiveis'), (1, 2, 'X (%)'),
- (2, 1, 'X (%)'), (2, 2, 'Spread')]:
- fig.update_xaxes(title_text='Geração', row=r, col=c)
- fig.update_yaxes(title_text=yt, row=r, col=c)
+ name=_t("opt_label_diversity", lang)), row=2, col=2)
+ y_titles = [_t("opt_axis_n_feasible", lang), _t("short_x_pct", lang),
+ _t("short_x_pct", lang), _t("opt_axis_spread", lang)]
+ for (r, c), yt in zip([(1, 1), (1, 2), (2, 1), (2, 2)], y_titles):
+ fig.update_xaxes(title_text=_t("opt_axis_generation", lang), row=r, col=c,
+ title_font=dict(size=10), tickfont=dict(size=9))
+ fig.update_yaxes(title_text=yt, row=r, col=c,
+ title_font=dict(size=10), tickfont=dict(size=9))
+ # Font dos sub-titulos
+ for ann in fig.layout.annotations:
+ ann.font = dict(size=11, color=_PALETTE['primary'])
fig.update_layout(
- title=dict(text='Evolucao NSGA-II',
- font=dict(color=_PALETTE['primary'], size=16), x=0.5),
- template='plotly_white', margin=dict(t=70, b=40, l=50, r=30),
- showlegend=False, height=500,
+ title=dict(text=_t("opt_fig_evolution_title", lang),
+ font=dict(color=_PALETTE['primary'], size=15),
+ x=0.5, xanchor='center', y=0.98, yanchor='top'),
+ template='plotly_white',
+ margin=dict(t=70, b=45, l=55, r=35),
+ showlegend=False,
+ autosize=True,
+ # height removido — respeita o container do card (style height)
)
return fig
-def _build_heatmap_figure(df: pd.DataFrame) -> go.Figure:
+def _build_heatmap_figure(df: pd.DataFrame, lang: str = DEFAULT_LANG) -> go.Figure:
if df.empty or len(df) < 3:
- return _empty_figure("Precisa de >= 3 solucoes para heatmap.", "Mapa Operacional")
+ return _empty_figure(_t("msg_need_3_heatmap", lang),
+ _t("opt_fig_opmap_title", lang))
fig = go.Figure(data=go.Scatter(
x=df['vazao_feed'] * 1000, y=df['T_obs (K)'], mode='markers',
marker=dict(size=14, color=df['X (%)'], colorscale='RdYlGn',
- showscale=True, colorbar=dict(title='X (%)', thickness=14),
+ showscale=True, colorbar=dict(title=_t("short_x_pct", lang),
+ thickness=14),
opacity=0.85, line=dict(color='#333', width=0.5)),
text=[(f"Fouling={r['Fouling (m)']:.2e}
Cat.Act={r['Cat. Activity']:.4f}
"
f"T0={r['T0 predita (K)']:.1f} K
CP0={r['CP0 predita']:.1f}")
@@ -377,21 +438,26 @@ def _build_heatmap_figure(df: pd.DataFrame) -> go.Figure:
"X=%{marker.color:.2f}%
%{text}"),
))
fig.update_layout(
- title=dict(text='Mapa Operacional: Vazao x T_obs x Conversao',
+ title=dict(text=_t("opt_fig_opmap_title", lang),
font=dict(color=_PALETTE['primary'], size=16), x=0.5),
- xaxis_title='Vazao (L/s)', yaxis_title='T_obs (K)',
+ xaxis_title=_t("feat_flow", lang) + ' (L/s)',
+ yaxis_title=_t("feat_tobs", lang) + ' (K)',
template='plotly_white', margin=dict(t=60, b=40, l=50, r=30),
)
return fig
-def _build_violin_figure(df: pd.DataFrame) -> go.Figure:
+def _build_violin_figure(df: pd.DataFrame, lang: str = DEFAULT_LANG) -> go.Figure:
if df.empty or len(df) < 2:
- return _empty_figure("Precisa de >= 2 solucoes para distribuicao.", "Distribuicao")
+ return _empty_figure(_t("msg_need_2_distribution", lang),
+ _t("opt_fig_violin_title", lang))
cols_to_plot = [
- ('X (%)', 'X (%)'), ('Fouling (m)', 'Fouling'),
- ('Cat. Activity', 'Cat. Act.'), ('T0 predita (K)', 'T0 (K)'),
- ('CP0 predita', 'CP0'), ('valve_pos', 'Valvula'),
+ ('X (%)', _t("short_x_pct", lang)),
+ ('Fouling (m)', _t("target_fouling", lang)),
+ ('Cat. Activity', _t("target_cat_activity", lang)),
+ ('T0 predita (K)', _t("short_t0_k", lang)),
+ ('CP0 predita', _t("target_CP0", lang)),
+ ('valve_pos', _t("feat_valve", lang)),
]
colors = [_PALETTE['primary'], _PALETTE['success'], _PALETTE['warning'],
_PALETTE['danger'], _PALETTE['info'], _PALETTE['secondary']]
@@ -402,7 +468,7 @@ def _build_violin_figure(df: pd.DataFrame) -> go.Figure:
meanline_visible=True, fillcolor=colors[i % len(colors)],
opacity=0.65, line_color=colors[i % len(colors)]))
fig.update_layout(
- title=dict(text='Distribuicao das Saidas - Solucoes Otimas',
+ title=dict(text=_t("opt_fig_violin_out_title", lang),
font=dict(color=_PALETTE['primary'], size=16), x=0.5),
template='plotly_white', margin=dict(t=60, b=40, l=50, r=30),
showlegend=False, violinmode='group',
@@ -455,14 +521,16 @@ def _compute_topsis_scores(df: pd.DataFrame) -> np.ndarray:
return scores
-def _build_topsis_figure(df: pd.DataFrame) -> go.Figure:
+def _build_topsis_figure(df: pd.DataFrame, lang: str = DEFAULT_LANG) -> go.Figure:
"""Top-15 soluções rankeadas por score TOPSIS com detalhes."""
if df.empty or len(df) < 2:
- return _empty_figure("Precisa de >= 2 solucoes para ranking TOPSIS.", "TOPSIS Ranking")
+ return _empty_figure(_t("msg_need_2_topsis", lang),
+ _t("opt_fig_topsis_title", lang))
scores = _compute_topsis_scores(df)
if len(scores) == 0:
- return _empty_figure("TOPSIS indisponivel.", "TOPSIS Ranking")
+ return _empty_figure(_t("msg_topsis_unavailable", lang),
+ _t("opt_fig_topsis_title", lang))
df_r = df.copy()
df_r['topsis_score'] = scores
@@ -503,107 +571,141 @@ def _build_topsis_figure(df: pd.DataFrame) -> go.Figure:
))
fig.add_vline(x=sc_vals.mean(), line_dash='dot',
line_color=_PALETTE['success'], line_width=1.5,
- annotation_text=f"Média={sc_vals.mean():.3f}",
+ annotation_text=f"{_t('opt_label_mean', lang)}={sc_vals.mean():.3f}",
annotation_font=dict(size=9, color=_PALETTE['success']))
fig.update_layout(
- title=dict(text='Ranking TOPSIS — Multi-Critério (Top-15)',
- font=dict(color=_PALETTE['primary'], size=15), x=0.5),
- xaxis=dict(title='Score TOPSIS (0=pior → 1=melhor)', range=[0, 1.05]),
+ title=dict(text=_t("opt_fig_topsis_multi_title", lang),
+ font=dict(color=_PALETTE['primary'], size=15),
+ x=0.5, xanchor='center', y=0.97, yanchor='top'),
+ xaxis=dict(title=_t("opt_axis_topsis_score", lang), range=[0, 1.05]),
yaxis=dict(title=''),
template='plotly_white',
margin=dict(t=55, b=45, l=55, r=60),
- height=420,
+ autosize=True,
)
return fig
-def _build_cluster_figure(df: pd.DataFrame) -> go.Figure:
- """K-means (k=3) sobre o espaço Pareto colorido por cluster."""
- if df.empty or len(df) < 4:
- return _empty_figure("Precisa de >= 4 solucoes para clustering.", "Clusters Pareto")
+def _build_cluster_figure(df: pd.DataFrame, lang: str = DEFAULT_LANG) -> go.Figure:
+ """K-means (k≤3) sobre o espaço Pareto colorido por cluster."""
+ title_fallback = _t("opt_fig_clusters_pareto_title", lang)
+ if df is None or df.empty or len(df) < 4:
+ return _empty_figure(_t("msg_need_4_cluster", lang), title_fallback)
+
+ # Requisitos obrigatórios para o scatter final: X (%) e Fouling (m)
+ required = ['X (%)', 'Fouling (m)']
+ for col in required:
+ if col not in df.columns:
+ return _empty_figure(_t("msg_cluster_missing_col", lang).format(col=col),
+ title_fallback)
try:
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
- except ImportError:
- return _empty_figure("scikit-learn necessário para clustering.", "Clusters Pareto")
+ except Exception:
+ return _empty_figure(_t("msg_sklearn_required", lang), title_fallback)
try:
- feature_cols = ['X (%)', 'Fouling (m)', 'Cat. Activity']
- avail = [c for c in feature_cols if c in df.columns]
- if len(avail) < 2:
- return _empty_figure("Colunas insuficientes para clustering.", "Clusters Pareto")
+ # Sanitiza: dropa linhas com NaN/inf nas features de clustering
+ feature_cols = [c for c in ('X (%)', 'Fouling (m)', 'Cat. Activity') if c in df.columns]
+ if len(feature_cols) < 2:
+ return _empty_figure(_t("msg_cluster_cols_insufficient", lang), title_fallback)
+
+ work = df[feature_cols + ['X (%)', 'Fouling (m)']].copy()
+ # remove duplicadas mantendo índice consistente
+ work = work.loc[:, ~work.columns.duplicated()]
+ work = work.replace([np.inf, -np.inf], np.nan).dropna(subset=feature_cols)
+ work = work.reset_index(drop=True)
+
+ if len(work) < 4:
+ return _empty_figure(_t("msg_cluster_rows_insufficient", lang), title_fallback)
+
+ mat = work[feature_cols].to_numpy(dtype=float)
+ # Se variância zero em alguma coluna, a escala normal quebra — fallback manual
+ stds = mat.std(axis=0)
+ if np.any(stds == 0):
+ return _empty_figure(_t("msg_cluster_zero_variance", lang), title_fallback)
- mat = df[avail].values.astype(float)
scaler = StandardScaler()
mat_s = scaler.fit_transform(mat)
- k = min(3, len(df) - 1)
+ k = int(min(3, max(2, len(work) - 1)))
km = KMeans(n_clusters=k, random_state=42, n_init=10, max_iter=300)
labels_km = km.fit_predict(mat_s)
cluster_names = [
- 'Alto X / Alto Fouling',
- 'Baixo Fouling / Baixo X',
- 'Balanceado',
+ _t("opt_cluster_high_x_high_f", lang),
+ _t("opt_cluster_low_f_low_x", lang),
+ _t("opt_cluster_balanced", lang),
][:k]
colors_cl = [_PALETTE['danger'], _PALETTE['info'], _PALETTE['success']][:k]
fig = go.Figure()
for ci in range(k):
mask = labels_km == ci
- sub = df[mask]
- # Sort cluster name by centroid
- cent_x = sub['X (%)'].mean() if 'X (%)' in sub.columns else 0
- cent_f = sub['Fouling (m)'].mean() if 'Fouling (m)' in sub.columns else 0
+ sub = work.loc[mask]
+ if sub.empty:
+ continue
fig.add_trace(go.Scatter(
x=sub['X (%)'],
y=sub['Fouling (m)'],
mode='markers',
marker=dict(size=11, color=colors_cl[ci], opacity=0.82,
line=dict(color='white', width=1.2)),
- name=f'C{ci+1}: {cluster_names[ci]}',
+ name=f'C{ci+1}: {cluster_names[ci]} (n={int(mask.sum())})',
hovertemplate=(
f"Cluster {ci+1}
"
"X=%{x:.2f}%
Fouling=%{y:.2e}"
),
))
- # Centroids (inverse transform)
- centroids_s = km.cluster_centers_
- centroids_ = scaler.inverse_transform(centroids_s)
+ # Centroides (inverse transform) — indexados pelas colunas do feature_cols
+ centroids_s = km.cluster_centers_
+ centroids_ = scaler.inverse_transform(centroids_s)
+ idx_x = feature_cols.index('X (%)')
+ idx_f = feature_cols.index('Fouling (m)')
fig.add_trace(go.Scatter(
- x=centroids_[:, 0],
- y=centroids_[:, 1],
+ x=centroids_[:, idx_x],
+ y=centroids_[:, idx_f],
mode='markers+text',
- marker=dict(symbol='x-thick', size=20, color=_PALETTE['gold'],
+ marker=dict(symbol='x', size=18, color=_PALETTE['gold'],
line=dict(color='#333', width=2)),
text=[f'C{i+1}' for i in range(k)],
textposition='top center',
textfont=dict(size=10, color='#333'),
- name='Centróides',
- hovertemplate="Centróide
X=%{x:.2f}%
Fouling=%{y:.2e}",
+ name=_t("opt_label_centroids", lang),
+ hovertemplate=(f"{_t('opt_label_centroid', lang)}
"
+ "X=%{x:.2f}%
Fouling=%{y:.2e}"),
))
fig.update_layout(
- title=dict(text='Clusters K-Means da Frente de Pareto (k=3)',
- font=dict(color=_PALETTE['primary'], size=15), x=0.5),
- xaxis_title='Conversão X (%)',
- yaxis_title='Fouling Thickness (m)',
+ title=dict(text=_t("opt_fig_clusters_kmeans_title", lang).format(k=k),
+ font=dict(color=_PALETTE['primary'], size=15),
+ x=0.5, xanchor='center', y=0.97, yanchor='top'),
+ xaxis_title=_t("short_x_pct", lang),
+ yaxis_title=_t("opt_label_fouling", lang) + ' (m)',
template='plotly_white',
- margin=dict(t=55, b=40, l=55, r=30),
- height=420,
- legend=dict(orientation='h', yanchor='bottom', y=-0.22,
- xanchor='center', x=0.5),
+ margin=dict(t=60, b=80, l=60, r=30),
+ autosize=True,
+ legend=dict(orientation='h',
+ yanchor='top', y=-0.18,
+ xanchor='center', x=0.5,
+ bgcolor='rgba(255,255,255,0.85)',
+ bordercolor=_PALETTE['primary'], borderwidth=1,
+ font=dict(size=10)),
)
return fig
except Exception as e:
- return _empty_figure(f"Erro no clustering: {e}", "Clusters Pareto")
+ return _empty_figure(
+ _t("msg_cluster_error", lang).format(err=f"{type(e).__name__}: {e}"),
+ title_fallback,
+ )
-def _build_hypervolume_figure(history: List[dict]) -> go.Figure:
+def _build_hypervolume_figure(history: List[dict], lang: str = DEFAULT_LANG) -> go.Figure:
"""Evolução do Hypervolume aproximado ao longo das gerações."""
+ title_fallback = _t("opt_fig_hv_by_gen_title", lang)
if not history:
- return _empty_figure("Sem histórico de HV.", "Hypervolume por Geração")
+ return _empty_figure(_t("msg_no_hv_hist", lang), title_fallback)
gens = [h['gen'] for h in history]
ref_x, ref_f = 0.0, PHYS_LIMITS['fouling_max']
@@ -626,8 +728,8 @@ def _build_hypervolume_figure(history: List[dict]) -> go.Figure:
hvs.append(xi * fi if xi > 0 and fi > 0 else 0.0)
fig = make_subplots(rows=1, cols=2,
- subplot_titles=('Hypervolume por Geração',
- 'Soluções Factíveis por Geração'),
+ subplot_titles=(_t("opt_sub_hv_by_gen", lang),
+ _t("opt_sub_feasible_by_gen", lang)),
horizontal_spacing=0.12)
# HV
@@ -636,7 +738,7 @@ def _build_hypervolume_figure(history: List[dict]) -> go.Figure:
line=dict(color=_PALETTE['primary'], width=2.5),
marker=dict(size=5, color=_PALETTE['primary']),
fill='tozeroy', fillcolor='rgba(89,19,184,0.12)',
- name='Hypervolume',
+ name=_t("opt_fig_hypervolume_y", lang),
hovertemplate="Gen=%{x}
HV=%{y:.5f}",
), row=1, col=1)
@@ -645,7 +747,7 @@ def _build_hypervolume_figure(history: List[dict]) -> go.Figure:
max_idx = int(np.argmax(hvs))
fig.add_annotation(
x=gens[max_idx], y=hvs[max_idx],
- text=f"Max={hvs[max_idx]:.4f}",
+ text=f"{_t('opt_label_max', lang)}={hvs[max_idx]:.4f}",
showarrow=True, arrowhead=2, arrowcolor=_PALETTE['primary'],
font=dict(color=_PALETTE['primary'], size=10),
row=1, col=1,
@@ -655,43 +757,46 @@ def _build_hypervolume_figure(history: List[dict]) -> go.Figure:
fig.add_trace(go.Bar(
x=gens, y=feasible_counts,
marker=dict(color=_PALETTE['success'], opacity=0.7),
- name='Factíveis',
- hovertemplate="Gen=%{x}
Factíveis=%{y}",
+ name=_t("opt_label_feasible_plural", lang),
+ hovertemplate=("Gen=%{x}
"
+ f"{_t('opt_label_feasible_plural', lang)}="
+ "%{y}"),
), row=1, col=2)
- fig.update_xaxes(title_text='Geração', row=1, col=1)
- fig.update_xaxes(title_text='Geração', row=1, col=2)
- fig.update_yaxes(title_text='Hypervolume', row=1, col=1)
- fig.update_yaxes(title_text='N° Factíveis', row=1, col=2)
+ fig.update_xaxes(title_text=_t("opt_axis_generation", lang), row=1, col=1)
+ fig.update_xaxes(title_text=_t("opt_axis_generation", lang), row=1, col=2)
+ fig.update_yaxes(title_text=_t("opt_fig_hypervolume_y", lang), row=1, col=1)
+ fig.update_yaxes(title_text=_t("opt_axis_n_feasible", lang), row=1, col=2)
fig.update_layout(
- title=dict(text='Métricas de Convergência NSGA-II',
- font=dict(color=_PALETTE['primary'], size=15), x=0.5),
+ title=dict(text=_t("opt_fig_convergence_metrics_title", lang),
+ font=dict(color=_PALETTE['primary'], size=15),
+ x=0.5, xanchor='center', y=0.97, yanchor='top'),
template='plotly_white',
- margin=dict(t=60, b=40, l=55, r=30),
- height=380,
+ margin=dict(t=65, b=45, l=55, r=30),
+ autosize=True,
showlegend=False,
)
return fig
-def _build_decision_space_figure(df: pd.DataFrame) -> go.Figure:
+def _build_decision_space_figure(df: pd.DataFrame, lang: str = DEFAULT_LANG) -> go.Figure:
"""
Espaço de decisão: scatter matrix das 4 variáveis de decisão mais
influentes, colorido por X (%).
"""
+ title_fallback = _t("opt_fig_decision_title", lang)
if df.empty or len(df) < 3:
- return _empty_figure("Precisa de >= 3 solucoes para espaço de decisão.",
- "Espaço de Decisão")
+ return _empty_figure(_t("msg_need_3_decision", lang), title_fallback)
key_vars = {
- 'vazao_feed': 'Vazão (m³/s)',
- 'T_obs (K)': 'T_obs (K)',
- 'valve_pos': 'Válvula',
- 'w_j': 'w_j (m³/s)',
+ 'vazao_feed': _t("feat_flow", lang) + ' (m³/s)',
+ 'T_obs (K)': _t("feat_tobs", lang) + ' (K)',
+ 'valve_pos': _t("feat_valve", lang),
+ 'w_j': _t("feat_wj", lang) + ' (m³/s)',
}
avail = {k: v for k, v in key_vars.items() if k in df.columns}
if len(avail) < 2:
- return _empty_figure("Colunas de decisão insuficientes.", "Espaço de Decisão")
+ return _empty_figure(_t("msg_decision_cols_insufficient", lang), title_fallback)
cols = list(avail.keys())
dims = [dict(label=avail[c], values=df[c]) for c in cols]
@@ -703,17 +808,18 @@ def _build_decision_space_figure(df: pd.DataFrame) -> go.Figure:
color=df['X (%)'],
colorscale='Viridis',
showscale=True,
- colorbar=dict(title='X (%)', thickness=12, len=0.5),
+ colorbar=dict(title=_t("short_x_pct", lang), thickness=12, len=0.5),
size=5, opacity=0.8,
line=dict(color='white', width=0.5),
),
))
fig.update_layout(
- title=dict(text='Espaço de Decisão — Variáveis vs X (%)',
- font=dict(color=_PALETTE['primary'], size=15), x=0.5),
+ title=dict(text=_t("opt_fig_decision_vs_x_title", lang),
+ font=dict(color=_PALETTE['primary'], size=15),
+ x=0.5, xanchor='center', y=0.98, yanchor='top'),
template='plotly_white',
- margin=dict(t=60, b=40, l=60, r=30),
- height=460,
+ margin=dict(t=65, b=45, l=65, r=35),
+ autosize=True,
)
return fig
@@ -785,6 +891,7 @@ def run_cstr_optimization(
crossover_prob=0.9, mutation_prob=0.1,
xl_override=None, xu_override=None,
progress_callback=None,
+ lang: str = DEFAULT_LANG,
) -> Tuple[pd.DataFrame, dict, dict]:
"""
Executa NSGA-II e retorna (df_factiveis, figs_dict, metrics_dict).
@@ -903,19 +1010,50 @@ def pymoo_callback(algorithm):
}
figs = {
- 'pareto': _build_pareto_figure(df, constraints_ref),
- 'parcoords': _build_parcoords_figure(df),
- 'scatter3d': _build_3d_figure(df),
- 'convergence': _build_convergence_figure(history),
- 'heatmap': _build_heatmap_figure(df),
- 'violin': _build_violin_figure(df),
+ 'pareto': _build_pareto_figure(df, constraints_ref, lang=lang),
+ 'parcoords': _build_parcoords_figure(df, lang=lang),
+ 'scatter3d': _build_3d_figure(df, lang=lang),
+ 'convergence': _build_convergence_figure(history, lang=lang),
+ 'heatmap': _build_heatmap_figure(df, lang=lang),
+ 'violin': _build_violin_figure(df, lang=lang),
# NOVOS
- 'topsis': _build_topsis_figure(df),
- 'clusters': _build_cluster_figure(df),
- 'hypervolume': _build_hypervolume_figure(history),
- 'decision_space': _build_decision_space_figure(df),
+ 'topsis': _build_topsis_figure(df, lang=lang),
+ 'clusters': _build_cluster_figure(df, lang=lang),
+ 'hypervolume': _build_hypervolume_figure(history, lang=lang),
+ 'decision_space': _build_decision_space_figure(df, lang=lang),
}
metrics = _compute_quality_metrics(df, history, elapsed)
+ # Propaga histórico separadamente — permite reconstrução dos gráficos
+ # quando o idioma muda (sem re-executar o NSGA-II).
+ metrics['history'] = history
+ metrics['constraints'] = constraints_ref
return df, figs, metrics
+
+
+# ══════════════════════════════════════════════════════════════════════
+# REBUILD DE FIGURAS (usado quando só muda o idioma da UI)
+# ══════════════════════════════════════════════════════════════════════
+def rebuild_optimization_figs(df: pd.DataFrame,
+ history: list,
+ constraints: dict = None,
+ lang: str = DEFAULT_LANG) -> dict:
+ """
+ Re-renderiza o dicionário de figuras aplicando o idioma ``lang``.
+
+ É barato (não há chamadas ao modelo NSGA-II): apenas reconstrói
+ ``go.Figure`` a partir dos dados já armazenados.
+ """
+ return {
+ 'pareto': _build_pareto_figure(df, constraints, lang=lang),
+ 'parcoords': _build_parcoords_figure(df, lang=lang),
+ 'scatter3d': _build_3d_figure(df, lang=lang),
+ 'convergence': _build_convergence_figure(history or [], lang=lang),
+ 'heatmap': _build_heatmap_figure(df, lang=lang),
+ 'violin': _build_violin_figure(df, lang=lang),
+ 'topsis': _build_topsis_figure(df, lang=lang),
+ 'clusters': _build_cluster_figure(df, lang=lang),
+ 'hypervolume': _build_hypervolume_figure(history or [], lang=lang),
+ 'decision_space': _build_decision_space_figure(df, lang=lang),
+ }
diff --git a/CSTRChemIA/optimization/decision_methods.py b/CSTRChemIA/optimization/decision_methods.py
new file mode 100644
index 0000000..2c1fd55
--- /dev/null
+++ b/CSTRChemIA/optimization/decision_methods.py
@@ -0,0 +1,324 @@
+"""
+Métodos de decisão multi-critério (MCDM) sobre a frente de Pareto.
+
+Limitação do TOPSIS puro
+------------------------
+O código atual usa apenas TOPSIS para selecionar a "melhor" solução da
+frente — e é conhecido que TOPSIS pode sofrer de *rank reversal* (adicionar
+uma solução muda a ordem das demais). Para análise robusta de trade-offs,
+oferecemos quatro métodos complementares que, juntos, formam um
+*consenso*:
+
+1. **TOPSIS** — distância ao ideal positivo/negativo. Intuitivo.
+2. **VIKOR** — ranking de compromisso entre utilidade agregada S e
+ pesadelo R (regret máximo). Bom quando o decisor quer minimizar
+ o "pior" critério.
+3. **PROMETHEE II** — fluxo líquido de preferência pairwise. Mais
+ resistente a rank reversal que TOPSIS.
+4. **Knee-point** — sem pesos. O ponto da frente com o maior ganho
+ marginal em todos os critérios simultaneamente (joelho visual
+ clássico em frentes 2D/3D).
+
+Interface comum
+---------------
+Todas as funções recebem ``F`` shape ``(n, k)`` com ``n`` soluções e
+``k`` critérios, ``minimize`` (bool por critério) e, opcionalmente,
+``weights``. Retornam ``(scores, best_idx)`` onde scores maiores =
+melhores. Você pode chamar as 4 e comparar as escolhas.
+
+Exemplo
+-------
+::
+
+ from optimization.decision_methods import (
+ topsis, vikor, promethee_ii, knee_point, consensus_ranking
+ )
+
+ F = pareto_objectives # (n, k)
+ minimize = [False, True, False] # max, min, max
+ w = [0.5, 0.3, 0.2]
+
+ s_topsis, i_topsis = topsis(F, minimize, weights=w)
+ s_vikor, i_vikor = vikor(F, minimize, weights=w)
+ s_prom, i_prom = promethee_ii(F, minimize, weights=w)
+ i_knee = knee_point(F, minimize)
+
+ # Consenso: índice que aparece no top-3 de mais métodos
+ consensus = consensus_ranking([s_topsis, s_vikor, s_prom], top_k=3)
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import numpy as np
+
+_EPS = 1e-12
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# HELPERS
+# ══════════════════════════════════════════════════════════════════════════════
+def _validate_inputs(
+ F: np.ndarray,
+ minimize: Sequence[bool],
+ weights: Sequence[float] | None,
+) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
+ """Normaliza F/weights e devolve arrays prontos para uso."""
+ F = np.asarray(F, dtype=np.float64)
+ if F.ndim != 2:
+ raise ValueError(f"F deve ser 2D (n, k); recebeu shape {F.shape}")
+ n, k = F.shape
+ if len(minimize) != k:
+ raise ValueError(
+ f"minimize deve ter {k} elementos (um por critério), "
+ f"recebeu {len(minimize)}"
+ )
+ minimize_arr = np.array(list(minimize), dtype=bool)
+ if weights is None:
+ w = np.full(k, 1.0 / k, dtype=np.float64)
+ else:
+ w = np.asarray(list(weights), dtype=np.float64)
+ if w.shape != (k,):
+ raise ValueError(f"weights deve ter shape ({k},); recebeu {w.shape}")
+ if (w < 0).any():
+ raise ValueError("weights não podem ser negativos")
+ s = w.sum()
+ if s <= 0:
+ raise ValueError("sum(weights) deve ser > 0")
+ w = w / s
+ return F, minimize_arr, w
+
+
+def _to_benefit(F: np.ndarray, minimize: np.ndarray) -> np.ndarray:
+ """Converte critérios 'min' em 'max' multiplicando por −1."""
+ out = F.copy()
+ out[:, minimize] = -out[:, minimize]
+ return out
+
+
+def _normalize_vector(F_benefit: np.ndarray) -> np.ndarray:
+ """Normalização vectorial (usada por TOPSIS) — divide cada coluna pelo L2."""
+ norms = np.sqrt((F_benefit ** 2).sum(axis=0)) + _EPS
+ return F_benefit / norms
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# TOPSIS
+# ══════════════════════════════════════════════════════════════════════════════
+def topsis(
+ F: np.ndarray,
+ minimize: Sequence[bool],
+ weights: Sequence[float] | None = None,
+) -> tuple[np.ndarray, int]:
+ """
+ Technique for Order Preference by Similarity to Ideal Solution.
+
+ Retorna ``(scores, best_idx)`` com scores em ``[0, 1]``.
+ """
+ F, minimize_arr, w = _validate_inputs(F, minimize, weights)
+ Fb = _to_benefit(F, minimize_arr)
+ Fn = _normalize_vector(Fb)
+ Fw = Fn * w[np.newaxis, :]
+
+ ideal_pos = Fw.max(axis=0)
+ ideal_neg = Fw.min(axis=0)
+ d_pos = np.sqrt(((Fw - ideal_pos) ** 2).sum(axis=1))
+ d_neg = np.sqrt(((Fw - ideal_neg) ** 2).sum(axis=1))
+ scores = d_neg / (d_pos + d_neg + _EPS)
+ return scores, int(np.argmax(scores))
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# VIKOR
+# ══════════════════════════════════════════════════════════════════════════════
+def vikor(
+ F: np.ndarray,
+ minimize: Sequence[bool],
+ weights: Sequence[float] | None = None,
+ v: float = 0.5,
+) -> tuple[np.ndarray, int]:
+ """
+ VIKOR (VIseKriterijumska Optimizacija I Kompromisno Resenje).
+
+ Retorna ``(Q_neg, best_idx)`` onde ``Q`` é a métrica de compromisso
+ de VIKOR (menor = melhor). Devolvemos ``-Q`` como score para manter
+ convenção "maior=melhor" compartilhada com TOPSIS.
+
+ Parameters
+ ----------
+ v
+ Peso da estratégia de "maioria" (utilidade agregada S).
+ ``v > 0.5`` privilegia maioria; ``v < 0.5`` privilegia pior-caso (R).
+ Default 0.5 (balanceado).
+ """
+ if not 0.0 <= v <= 1.0:
+ raise ValueError(f"v deve estar em [0,1]; recebeu {v}")
+ F, minimize_arr, w = _validate_inputs(F, minimize, weights)
+ Fb = _to_benefit(F, minimize_arr)
+
+ f_star = Fb.max(axis=0)
+ f_minus = Fb.min(axis=0)
+ denom = (f_star - f_minus) + _EPS
+ # Distância normalizada ao ideal positivo por critério (em [0,1])
+ D = (f_star[np.newaxis, :] - Fb) / denom[np.newaxis, :]
+ D_w = D * w[np.newaxis, :]
+
+ S = D_w.sum(axis=1) # utilidade agregada
+ R = D_w.max(axis=1) # pior regret
+ S_star, S_minus = S.min(), S.max()
+ R_star, R_minus = R.min(), R.max()
+
+ Q = (
+ v * (S - S_star) / (S_minus - S_star + _EPS)
+ + (1 - v) * (R - R_star) / (R_minus - R_star + _EPS)
+ )
+ return -Q, int(np.argmin(Q))
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# PROMETHEE II
+# ══════════════════════════════════════════════════════════════════════════════
+def promethee_ii(
+ F: np.ndarray,
+ minimize: Sequence[bool],
+ weights: Sequence[float] | None = None,
+ preference: str = "linear",
+) -> tuple[np.ndarray, int]:
+ """
+ PROMETHEE II — fluxo líquido de preferência pairwise.
+
+ Para cada par (i, j) e critério c, calcula ``P_c(d) = preferência``
+ a partir da diferença ``d = F_ic - F_jc`` (já convertido para benefit).
+ O *usual criterion* (stepwise) é comum, mas aqui usamos a função
+ ``"linear"`` por padrão (d/d_max, clamp em [0,1]) que é mais suave e
+ evita empates.
+
+ Retorna ``(phi_net, best_idx)`` onde ``phi_net ∈ [-1, 1]``.
+ """
+ F, minimize_arr, w = _validate_inputs(F, minimize, weights)
+ Fb = _to_benefit(F, minimize_arr)
+ n = Fb.shape[0]
+ if n < 2:
+ return np.zeros(n), 0
+
+ # Escala para [0,1] por critério para tornar "linear" interpretável.
+ col_min = Fb.min(axis=0)
+ col_max = Fb.max(axis=0)
+ rng = (col_max - col_min) + _EPS
+ Fs = (Fb - col_min) / rng # (n, k) em [0,1]
+
+ # Diferenças pairwise: D[i, j, c] = Fs[i, c] - Fs[j, c]
+ D = Fs[:, np.newaxis, :] - Fs[np.newaxis, :, :] # (n, n, k)
+
+ if preference == "linear":
+ # Preferência = d se d > 0, senão 0 (já normalizado → P ∈ [0, 1])
+ P = np.clip(D, 0.0, 1.0)
+ elif preference == "usual":
+ P = (D > _EPS).astype(np.float64)
+ else:
+ raise ValueError(f"preference '{preference}' não suportada")
+
+ # Soma ponderada por critério → Π[i, j] = preferência agregada de i sobre j
+ Pi = np.tensordot(P, w, axes=([2], [0])) # (n, n)
+ np.fill_diagonal(Pi, 0.0)
+
+ phi_plus = Pi.sum(axis=1) / (n - 1) # positive flow
+ phi_minus = Pi.sum(axis=0) / (n - 1) # negative flow
+ phi_net = phi_plus - phi_minus # ∈ [-1, 1]
+
+ return phi_net, int(np.argmax(phi_net))
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# KNEE POINT
+# ══════════════════════════════════════════════════════════════════════════════
+def knee_point(F: np.ndarray, minimize: Sequence[bool]) -> int:
+ """
+ Detecta o "joelho" da frente — sem pesos.
+
+ Método canônico: normaliza a frente em ``[0,1]^k`` (com todos os
+ critérios em benefit-form), e escolhe o ponto que maximiza a soma
+ normalizada ``Σ Fs[i]``. Em 2D isso coincide geometricamente com
+ o ponto mais afastado da reta que liga os *anchor points* (pontos
+ que maximizam um critério individualmente) — o joelho clássico.
+
+ Equivalência em dimensão qualquer: anchors (cada um com um "1" e
+ o resto "0") definem um hiperplano cuja normal é ``(1,…,1)/√k`` e
+ passa por ``Σx = 1``. Distância ortogonal de um ponto ``p`` a esse
+ hiperplano é ``|Σp − 1|/√k``. Para pontos não-dominados por
+ anchor (Σp ≥ 1) isso é monótono em ``Σp`` → ``argmax(Σ Fs)`` basta.
+
+ Fallback: se a frente tem < 2 pontos, retorna 0.
+ """
+ F, minimize_arr, _ = _validate_inputs(F, minimize, None)
+ Fb = _to_benefit(F, minimize_arr)
+
+ col_min = Fb.min(axis=0)
+ col_max = Fb.max(axis=0)
+ rng = (col_max - col_min) + _EPS
+ Fs = (Fb - col_min) / rng # (n, k) em [0,1], maior=melhor
+
+ if Fs.shape[0] < 2:
+ return 0
+ # Em anchors puros (1 em uma dim, 0 no resto), Σ Fs_anchor == 1;
+ # interior: Σ Fs > 1. Argmax escolhe o ponto mais longe do hiperplano
+ # de anchors, na direção da utopia.
+ return int(np.argmax(Fs.sum(axis=1)))
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# CONSENSO
+# ══════════════════════════════════════════════════════════════════════════════
+@dataclass(frozen=True, slots=True)
+class ConsensusResult:
+ """Resultado de :func:`consensus_ranking`."""
+ consensus_idx: int
+ top_k_counts: dict[int, int] # idx → quantos métodos o colocaram no top-k
+
+
+def consensus_ranking(
+ score_lists: list[np.ndarray],
+ *,
+ top_k: int = 3,
+) -> ConsensusResult:
+ """
+ Consenso sobre várias listas de scores (TOPSIS + VIKOR + PROMETHEE II...).
+
+ Para cada método, pega os ``top_k`` índices (maior score). O índice
+ que aparecer no top-k de mais métodos é declarado vencedor do consenso.
+ Em empate, desempata pelo maior score-soma normalizado.
+ """
+ if not score_lists:
+ raise ValueError("score_lists vazio")
+ n = len(score_lists[0])
+ for s in score_lists:
+ if len(s) != n:
+ raise ValueError(
+ "Todas as listas de scores devem ter o mesmo tamanho"
+ )
+
+ votes: dict[int, int] = {}
+ for s in score_lists:
+ top = np.argsort(s)[::-1][:top_k]
+ for idx in top:
+ votes[int(idx)] = votes.get(int(idx), 0) + 1
+
+ if not votes:
+ return ConsensusResult(consensus_idx=0, top_k_counts={})
+
+ max_votes = max(votes.values())
+ tied = [i for i, v in votes.items() if v == max_votes]
+ if len(tied) == 1:
+ return ConsensusResult(consensus_idx=tied[0], top_k_counts=votes)
+
+ # Desempate: soma normalizada dos scores nas listas originais.
+ def _norm(s: np.ndarray) -> np.ndarray:
+ lo, hi = s.min(), s.max()
+ return (s - lo) / (hi - lo + _EPS)
+
+ normed = np.stack([_norm(s) for s in score_lists], axis=0) # (m, n)
+ agg = normed.sum(axis=0)
+ best = max(tied, key=lambda i: agg[i])
+ return ConsensusResult(consensus_idx=int(best), top_k_counts=votes)
diff --git a/CSTRChemIA/optimization/problem_v2.py b/CSTRChemIA/optimization/problem_v2.py
new file mode 100644
index 0000000..9d2e961
--- /dev/null
+++ b/CSTRChemIA/optimization/problem_v2.py
@@ -0,0 +1,323 @@
+"""
+Problema de otimização v2 — refatoração state-of-the-art.
+
+O que muda em relação ao :mod:`optimization.CSTR_Optimization_Problem`
+---------------------------------------------------------------------
+1. **Restrições explícitas** (``n_ieq_constr``) em vez de penalidade
+ ``1e6`` misturada no objetivo. Penalty-soup tem 3 problemas:
+
+ * Embaralha a escala dos objetivos — o ranking do NSGA-II fica
+ dominado por ``penalty`` em vez de pela compensação real entre
+ critérios.
+ * Não permite ``constraint tolerance`` nativo do pymoo (``ConstraintsAsPenalty``
+ é um wrapper que o pymoo já oferece, mas o usuário não sabe disso).
+ * Impossibilita o uso de MOEAs baseados em handling de constraints
+ (R-NSGA-II, C-NSGA-II, etc.).
+
+2. **Escolha do algoritmo**: NSGA-II (default) ou NSGA-III (melhor
+ quando ``n_obj > 3``). NSGA-III usa direções de referência
+ distribuídas uniformemente no simplex — aproxima a frente de Pareto
+ de forma muito mais uniforme que NSGA-II crowding distance.
+
+3. **Avaliação em lote via predict vetorizado** (mesma do v1) +
+ *opcional* paralelização por joblib para populações muito grandes.
+ Para o CSTR com ~100 pop × 100 gen, a paralelização por amostra não
+ compensa o overhead (predict já vetoriza); mantemos vetorização.
+
+4. **Reprodutibilidade**: log do seed efetivo + SHA-256 do artefato
+ ``.keras`` usado, anexado ao resultado — assim relatórios de
+ otimização são auditáveis.
+
+5. **Saídas estruturadas**: retorna :class:`core.types.OptimizationResult`
+ em vez de tupla ``(df, figs, metrics)``. A UI pode converter via
+ adaptador (mantém backward-compat).
+
+Uso
+---
+::
+
+ from optimization.problem_v2 import (
+ CSTRProblemV2, OptimizationConfigV2, run_v2,
+ )
+
+ cfg = OptimizationConfigV2(
+ model_type="MLP",
+ T_max=365.0, X_min=0.30, cat_min=0.9995,
+ fouling_max=0.025, cp0_min=10.0,
+ pop_size=100, n_gen=100, seed=42,
+ algorithm="NSGA-III",
+ )
+ result = run_v2(cfg)
+ print(result.extra["seed"], result.extra["model_sha256"])
+"""
+
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+
+# Patch do pymoo (mesmo do v1) aplicado em tempo de importação
+try:
+ import pymoo.util.function_loader as _pfl
+
+ _orig_fl_init = _pfl.FunctionLoader.__init__
+
+ def _safe_fl_init(self):
+ try:
+ _orig_fl_init(self)
+ except Exception:
+ self.is_compiled = False
+
+ _pfl.FunctionLoader.__init__ = _safe_fl_init
+ del _safe_fl_init
+except Exception: # noqa: BLE001
+ pass
+
+from pymoo.core.problem import Problem
+from pymoo.algorithms.moo.nsga2 import NSGA2
+from pymoo.operators.sampling.rnd import FloatRandomSampling
+from pymoo.operators.crossover.sbx import SBX
+from pymoo.operators.mutation.pm import PM
+from pymoo.optimize import minimize as _pymoo_minimize
+
+from core.logging import get_logger
+from core.safe_serialization import file_sha256
+from core.types import OptimizationConfig, OptimizationResult
+from surrogate_model.KerasPredict import PredictValues, TARGET_COLS
+
+log = get_logger(__name__)
+
+_IDX = {col: i for i, col in enumerate(TARGET_COLS)}
+
+# Mesmas 8 variáveis de decisão do v1 + 4 flags travadas em "normal"
+VAR_NAMES_V2 = (
+ "vazao_feed", "CA0_feed", "CB0_feed", "Tf_feed",
+ "T_amb_feed", "T_obs", "valve_pos", "w_j",
+)
+FLAGS_NORMAL = np.array([0.0, 0.0, 0.0, 0.0])
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# CONFIG
+# ══════════════════════════════════════════════════════════════════════════════
+@dataclass(frozen=True, slots=True)
+class OptimizationConfigV2:
+ """Configuração de uma corrida v2 (NSGA-II ou NSGA-III)."""
+
+ # Limites físicos
+ T_max: float = 365.0
+ X_min: float = 0.30
+ cat_min: float = 0.9995
+ fouling_max: float = 0.025
+ cp0_min: float = 0.0
+
+ # Modelo
+ model_type: str = "MLP"
+
+ # Bounds das variáveis (se None usa defaults do v1)
+ xl: np.ndarray | None = None
+ xu: np.ndarray | None = None
+
+ # Hyperparams do algoritmo
+ algorithm: str = "NSGA-II" # "NSGA-II" | "NSGA-III"
+ pop_size: int = 100
+ n_gen: int = 100
+ seed: int = 42
+ crossover_prob: float = 0.9
+ mutation_prob: float = 0.1
+
+ # Apenas para NSGA-III: número de partições no simplex de ref. dirs
+ ref_dirs_partitions: int = 12
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# PROBLEMA COM RESTRIÇÕES EXPLÍCITAS
+# ══════════════════════════════════════════════════════════════════════════════
+class CSTRProblemV2(Problem):
+ """
+ Dois objetivos (``-X``, ``fouling``) com 5 restrições de desigualdade
+ explícitas (``g <= 0`` na convenção do pymoo):
+
+ 0. ``g0 = T0 - T_max`` (temperatura teto)
+ 1. ``g1 = X_min - X`` (conversão piso)
+ 2. ``g2 = cat_min - cat`` (atividade piso)
+ 3. ``g3 = fouling - fouling_max`` (fouling teto)
+ 4. ``g4 = cp0_min - CP0`` (produção piso)
+
+ Todas em escala absoluta da variável (não normalizadas) para manter a
+ interpretação física. O pymoo faz a normalização internamente quando
+ usa CV (constraint violation).
+ """
+
+ def __init__(self, cfg: OptimizationConfigV2):
+ self.cfg = cfg
+ xl = cfg.xl.copy() if cfg.xl is not None else _default_xl()
+ xu = cfg.xu.copy() if cfg.xu is not None else _default_xu()
+ super().__init__(
+ n_var=8, n_obj=2, n_ieq_constr=5, xl=xl, xu=xu,
+ )
+
+ def _evaluate(self, X_pop: np.ndarray, out: dict, *args, **kwargs) -> None:
+ n = X_pop.shape[0]
+ # Predição vetorizada (já processa batch)
+ feats = np.hstack([X_pop, np.tile(FLAGS_NORMAL, (n, 1))]).astype(float)
+ try:
+ preds = PredictValues(feats, model_type=self.cfg.model_type, Tensor=True)
+ except Exception as e: # noqa: BLE001
+ log.error("Predict falhou no lote de tamanho %d: %s", n, e)
+ # Fallback: marca tudo como inviável pesadamente
+ out["F"] = np.full((n, 2), 1e9)
+ out["G"] = np.full((n, 5), 1e9)
+ return
+
+ X_conv = preds[:, _IDX["X"]]
+ T0 = preds[:, _IDX["T0"]]
+ fouling = preds[:, _IDX["fouling_thickness"]]
+ cat_act = preds[:, _IDX["cat_activity"]]
+ CP0 = preds[:, _IDX["CP0"]]
+
+ f1 = -X_conv
+ f2 = fouling
+
+ # Restrições g ≤ 0 (pymoo convenção). Valores positivos = violação.
+ g = np.column_stack([
+ T0 - self.cfg.T_max,
+ self.cfg.X_min - X_conv,
+ self.cfg.cat_min - cat_act,
+ fouling - self.cfg.fouling_max,
+ self.cfg.cp0_min - CP0,
+ ])
+
+ # Para linhas com NaN na predição, marca como violação grande.
+ bad_rows = ~np.isfinite(preds).all(axis=1)
+ if bad_rows.any():
+ f1[bad_rows] = 1e9
+ f2[bad_rows] = 1e9
+ g[bad_rows] = 1e9
+
+ out["F"] = np.column_stack([f1, f2])
+ out["G"] = g
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# EXECUTOR
+# ══════════════════════════════════════════════════════════════════════════════
+def run_v2(cfg: OptimizationConfigV2) -> OptimizationResult:
+ """Executa a otimização v2 e retorna :class:`OptimizationResult`."""
+ t_start = time.time()
+
+ problem = CSTRProblemV2(cfg)
+ algorithm = _make_algorithm(cfg)
+
+ log.info(
+ "NSGA run v2: algo=%s pop=%d gen=%d seed=%d model=%s",
+ cfg.algorithm, cfg.pop_size, cfg.n_gen, cfg.seed, cfg.model_type,
+ )
+ res = _pymoo_minimize(
+ problem, algorithm, ("n_gen", cfg.n_gen),
+ seed=cfg.seed, verbose=False,
+ )
+
+ # Coleta resultados
+ if res.X is None or len(res.X) == 0:
+ pareto_X = np.empty((0, 8))
+ pareto_F = np.empty((0, 2))
+ else:
+ pareto_X = np.atleast_2d(res.X)
+ pareto_F = np.atleast_2d(res.F)
+
+ # Hash do artefato .keras para log auditável
+ model_sha = _safe_model_sha(cfg.model_type)
+
+ extra = {
+ "algorithm": cfg.algorithm,
+ "seed": cfg.seed,
+ "model_type": cfg.model_type,
+ "model_sha256": model_sha,
+ "pop_size": cfg.pop_size,
+ "n_gen": cfg.n_gen,
+ "n_ieq_constr": 5,
+ "wall_time_s": time.time() - t_start,
+ }
+
+ return OptimizationResult(
+ pareto_features=_with_flags(pareto_X),
+ pareto_objectives=pareto_F,
+ hypervolume=None, # calculado separadamente (core.metrics)
+ topsis_best_idx=None, # decision_methods.topsis() pode ser chamado pelo UI
+ wall_time_s=extra["wall_time_s"],
+ config=OptimizationConfig(
+ model_name=cfg.model_type,
+ objective_names=["X (%)", "Fouling (m)"],
+ minimize=[False, True],
+ bounds={}, # omitido — caller já tem
+ pop_size=cfg.pop_size,
+ n_gen=cfg.n_gen,
+ seed=cfg.seed,
+ ),
+ extra=extra,
+ )
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# HELPERS
+# ══════════════════════════════════════════════════════════════════════════════
+def _default_xl() -> np.ndarray:
+ return np.array([0.00050, 1090.0, 925.0, 342.0, 285.0, 324.0, 0.01, 0.00010])
+
+
+def _default_xu() -> np.ndarray:
+ return np.array([0.00120, 1310.0, 1085.0, 358.0, 311.0, 365.0, 1.00, 0.00999])
+
+
+def _make_algorithm(cfg: OptimizationConfigV2):
+ """Cria NSGA-II ou NSGA-III conforme config."""
+ common = dict(
+ sampling=FloatRandomSampling(),
+ crossover=SBX(prob=cfg.crossover_prob, eta=15),
+ mutation=PM(prob=cfg.mutation_prob, eta=20),
+ eliminate_duplicates=True,
+ )
+
+ if cfg.algorithm.upper() == "NSGA-II":
+ return NSGA2(pop_size=cfg.pop_size, **common)
+
+ if cfg.algorithm.upper() == "NSGA-III":
+ # NSGA-III precisa de direções de referência no simplex — uniform das
+ # (Das-Dennis). O pymoo oferece get_reference_directions.
+ from pymoo.algorithms.moo.nsga3 import NSGA3
+ from pymoo.util.ref_dirs import get_reference_directions
+
+ ref_dirs = get_reference_directions(
+ "das-dennis", n_dim=2, n_partitions=cfg.ref_dirs_partitions,
+ )
+ return NSGA3(pop_size=cfg.pop_size, ref_dirs=ref_dirs, **common)
+
+ raise ValueError(f"algorithm '{cfg.algorithm}' não suportado "
+ "(use 'NSGA-II' ou 'NSGA-III')")
+
+
+def _with_flags(X: np.ndarray) -> np.ndarray:
+ """Concatena as 4 flags zero para devolver matriz (n, 12) comparável."""
+ if X.size == 0:
+ return np.empty((0, 12))
+ flags = np.tile(FLAGS_NORMAL, (X.shape[0], 1))
+ return np.hstack([X, flags])
+
+
+def _safe_model_sha(model_type: str) -> str | None:
+ """Best-effort: SHA-256 do arquivo .keras; ``None`` se não encontrado."""
+ try:
+ from core.constants import MODEL_FILENAMES
+ from core.settings import get_settings
+ settings = get_settings()
+ path = Path(settings.models_dir) / MODEL_FILENAMES.get(model_type, "")
+ if path.exists():
+ return file_sha256(path)
+ except Exception as e: # noqa: BLE001
+ log.debug("Falha ao computar SHA do modelo %s: %s", model_type, e)
+ return None
diff --git a/CSTRChemIA/requirements.txt b/CSTRChemIA/requirements.txt
index 6883d5d..32db4e4 100644
--- a/CSTRChemIA/requirements.txt
+++ b/CSTRChemIA/requirements.txt
@@ -19,6 +19,7 @@ pymoo>=0.6.0
# Machine Learning (surrogate - CPU only)
tensorflow-cpu>=2.16.1,<2.18
+keras-tuner>=1.4.0
# Pré-processamento
scikit-learn
diff --git a/CSTRChemIA/services/__init__.py b/CSTRChemIA/services/__init__.py
new file mode 100644
index 0000000..a9c09be
--- /dev/null
+++ b/CSTRChemIA/services/__init__.py
@@ -0,0 +1,23 @@
+"""
+services — camada de aplicação.
+
+Os serviços encapsulam lógica de negócio (simular, otimizar, treinar) em
+APIs limpas e testáveis, desacopladas de Dash/UI. Callbacks, REST API e
+testes consomem serviços em vez de acessar modelos/otimizador diretamente.
+"""
+
+from services.data_file_service import DataFileService, get_data_file_service
+from services.model_registry import ModelRegistry, get_model_registry
+from services.optimization_service import OptimizationService
+from services.simulation_service import SimulationService
+from services.training_service import TrainingService
+
+__all__ = [
+ "DataFileService",
+ "ModelRegistry",
+ "OptimizationService",
+ "SimulationService",
+ "TrainingService",
+ "get_data_file_service",
+ "get_model_registry",
+]
diff --git a/CSTRChemIA/services/data_file_service.py b/CSTRChemIA/services/data_file_service.py
new file mode 100644
index 0000000..44e0aee
--- /dev/null
+++ b/CSTRChemIA/services/data_file_service.py
@@ -0,0 +1,514 @@
+"""
+Data File Service — descoberta, leitura, remoção e upload de CSVs de dados.
+
+Encapsula toda a lógica de I/O dos arquivos CSV usados pelos pipelines de
+simulação, treino e otimização. Fornece uma API limpa consumida pela aba
+Analytics (file manager UI) e testes.
+
+Fontes descobertas:
+ • CSTRChemIA/simulation/ (flat — destino de uploads)
+ • Simulacoes/simulacao_CSTR/sim_*/observations/ (nested — sim padrão)
+ • Simulacoes/simulacao_CSTR/sim_*/truth/ (estados verdadeiros)
+ • Simulacoes/simulacao_CSTR/dados_diversos/cenario_*/observations/
+ • CSTRChemIA/surrogate_model/training_results.csv (resultados de treino)
+
+Todas as operações destrutivas (delete / upload overwrite) validam que o
+caminho está DENTRO de uma das raízes permitidas (defesa path-traversal).
+"""
+
+from __future__ import annotations
+
+import base64
+import csv
+import glob
+import os
+import re
+import shutil
+import time
+from dataclasses import dataclass, asdict
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Iterable
+
+from core.logging import get_logger
+from core.settings import get_settings
+
+log = get_logger(__name__)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# CONSTANTES / CLASSIFICAÇÃO
+# ══════════════════════════════════════════════════════════════════════════════
+
+#: Extensão única suportada (por enquanto).
+_CSV_EXT = ".csv"
+
+#: Padrão aceito em uploads: só features/targets numerados.
+_UPLOAD_NAME_RE = re.compile(
+ r"^dados_treino_(features|targets)_\w[\w\-]*\.csv$", re.IGNORECASE
+)
+
+#: Tipos conhecidos → ícone + classe Bootstrap.
+_TYPE_META: dict[str, dict[str, str]] = {
+ "features": {"icon": "🧪", "color": "primary", "label": "Features"},
+ "targets": {"icon": "🎯", "color": "success", "label": "Targets"},
+ "complete": {"icon": "📚", "color": "info", "label": "Dados Completos"},
+ "observed": {"icon": "👁️", "color": "secondary", "label": "Observados"},
+ "truth": {"icon": "🧭", "color": "warning", "label": "Verdade"},
+ "training_result": {"icon": "🎓", "color": "dark", "label": "Resultado Treino"},
+ "other": {"icon": "📄", "color": "light", "label": "Outros"},
+}
+
+#: Fontes conhecidas → ícone + classe Bootstrap.
+_SOURCE_META: dict[str, dict[str, str]] = {
+ "simulation_flat": {"icon": "📁", "label": "CSTRChemIA/simulation (flat)"},
+ "sim_observations": {"icon": "🔬", "label": "Simulacoes/sim_*/observations"},
+ "sim_truth": {"icon": "🧭", "label": "Simulacoes/sim_*/truth"},
+ "diversos": {"icon": "🌈", "label": "dados_diversos/cenario_*"},
+ "training": {"icon": "📈", "label": "surrogate_model (treinamento)"},
+}
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# DATACLASSES
+# ══════════════════════════════════════════════════════════════════════════════
+@dataclass(frozen=True, slots=True)
+class DataFileInfo:
+ """Metadados de um arquivo de dados descoberto."""
+ path: str # caminho absoluto
+ rel_path: str # caminho relativo à raiz do projeto
+ filename: str
+ source: str # chave em _SOURCE_META
+ source_label: str
+ kind: str # chave em _TYPE_META
+ kind_label: str
+ kind_icon: str
+ kind_color: str
+ size_bytes: int
+ size_human: str
+ rows: int # n linhas (aprox, sem header)
+ cols: int # n colunas
+ modified_iso: str # ISO timestamp da última modificação
+ modified_human: str # formato amigável BR
+
+ def to_dict(self) -> dict[str, Any]:
+ return asdict(self)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# HELPERS
+# ══════════════════════════════════════════════════════════════════════════════
+def _human_size(n: int) -> str:
+ """1234 → '1.2 KB', 1234567 → '1.2 MB'."""
+ units = ["B", "KB", "MB", "GB", "TB"]
+ size = float(n)
+ for u in units:
+ if size < 1024 or u == units[-1]:
+ return f"{size:.1f} {u}" if u != "B" else f"{int(size)} B"
+ size /= 1024
+ return f"{size:.1f} TB"
+
+
+def _fast_row_count(path: str) -> int:
+ """Conta linhas (excluindo header) lendo bytes — mais rápido que pandas."""
+ try:
+ with open(path, "rb") as f:
+ count = sum(1 for _ in f)
+ return max(0, count - 1) # desconta cabeçalho
+ except Exception:
+ return -1 # sinaliza erro sem quebrar UI
+
+
+def _col_count(path: str) -> int:
+ """Número de colunas a partir da 1ª linha. Retorna -1 em falha."""
+ try:
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
+ header = f.readline()
+ if not header:
+ return 0
+ reader = csv.reader([header])
+ return len(next(reader))
+ except Exception:
+ return -1
+
+
+def _classify(filename: str, parent_dir: str) -> str:
+ """Deduz o ``kind`` a partir do nome e da pasta pai."""
+ low = filename.lower()
+ if low.startswith("dados_treino_features_"):
+ return "features"
+ if low.startswith("dados_treino_targets_"):
+ return "targets"
+ if low == "dados_completos.csv":
+ return "complete"
+ if low == "dados_observados.csv":
+ return "observed"
+ if low in ("estados_verdadeiros.csv", "verdadeiro_sem_ruido.csv"):
+ return "truth"
+ if low == "training_results.csv":
+ return "training_result"
+ # fallback heurístico: se estiver em truth/ é truth
+ if os.path.basename(parent_dir).lower() == "truth":
+ return "truth"
+ return "other"
+
+
+def _within_root(path: str, roots: Iterable[Path]) -> bool:
+ """True se ``path`` está abaixo de alguma raiz autorizada."""
+ try:
+ abs_path = Path(path).resolve()
+ except Exception:
+ return False
+ for root in roots:
+ try:
+ abs_path.relative_to(root.resolve())
+ return True
+ except (ValueError, OSError):
+ continue
+ return False
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# SERVIÇO
+# ══════════════════════════════════════════════════════════════════════════════
+class DataFileService:
+ """
+ API limpa para descobrir, ler, excluir e salvar CSVs de dados.
+
+ A aplicação web (Analytics tab) consome apenas este serviço — nenhum
+ callback do Dash deve manipular o filesystem diretamente.
+ """
+
+ def __init__(self) -> None:
+ self._settings = get_settings()
+ self._base_dir: Path = self._settings.base_dir
+ self._sim_dir: Path = self._settings.data_dir
+ self._models_dir: Path = self._settings.models_dir
+ # Raiz `Simulacoes/` fica um nível acima de CSTRChemIA/
+ self._simulacoes_root: Path = (
+ self._base_dir.parent / "Simulacoes" / "simulacao_CSTR"
+ )
+
+ # ---------- properties ----------
+ @property
+ def upload_dir(self) -> Path:
+ """Diretório onde uploads são gravados (`CSTRChemIA/simulation/`)."""
+ return self._sim_dir
+
+ @property
+ def allowed_roots(self) -> list[Path]:
+ """Raízes onde delete/upload são permitidos — defesa path-traversal."""
+ return [self._sim_dir, self._simulacoes_root, self._models_dir]
+
+ # ---------- descoberta ----------
+ def discover(self) -> list[DataFileInfo]:
+ """Descobre todos os CSVs de dados conhecidos; ordena por modificação desc."""
+ rows: list[DataFileInfo] = []
+
+ # 1) Flat: CSTRChemIA/simulation/*.csv
+ for p in sorted(glob.glob(str(self._sim_dir / "*.csv"))):
+ rows.append(self._build_info(p, source="simulation_flat"))
+
+ # 2) Nested: Simulacoes/simulacao_CSTR/sim_*/observations/*.csv
+ for p in sorted(glob.glob(
+ str(self._simulacoes_root / "sim_*" / "observations" / "*.csv"))
+ ):
+ rows.append(self._build_info(p, source="sim_observations"))
+
+ # 3) Nested: Simulacoes/simulacao_CSTR/sim_*/truth/*.csv
+ for p in sorted(glob.glob(
+ str(self._simulacoes_root / "sim_*" / "truth" / "*.csv"))
+ ):
+ rows.append(self._build_info(p, source="sim_truth"))
+
+ # 4) Diversos: dados_diversos/cenario_*/observations/*.csv
+ for p in sorted(glob.glob(
+ str(self._simulacoes_root / "dados_diversos" / "cenario_*"
+ / "observations" / "*.csv"))
+ ):
+ rows.append(self._build_info(p, source="diversos"))
+
+ # 5) Training results
+ tr = self._models_dir / "training_results.csv"
+ if tr.exists():
+ rows.append(self._build_info(str(tr), source="training"))
+
+ # Ordena: mais recentes primeiro
+ rows.sort(key=lambda r: r.modified_iso, reverse=True)
+ return rows
+
+ def _build_info(self, path: str, source: str) -> DataFileInfo:
+ st = os.stat(path)
+ parent = os.path.dirname(path)
+ kind = _classify(os.path.basename(path), parent)
+ type_meta = _TYPE_META.get(kind, _TYPE_META["other"])
+ src_meta = _SOURCE_META.get(source, {"icon": "📄", "label": source})
+
+ try:
+ rel = os.path.relpath(path, self._base_dir)
+ except ValueError:
+ rel = path
+
+ mod = datetime.fromtimestamp(st.st_mtime)
+ return DataFileInfo(
+ path=os.path.abspath(path),
+ rel_path=rel.replace(os.sep, "/"),
+ filename=os.path.basename(path),
+ source=source,
+ source_label=f"{src_meta['icon']} {src_meta['label']}",
+ kind=kind,
+ kind_label=type_meta["label"],
+ kind_icon=type_meta["icon"],
+ kind_color=type_meta["color"],
+ size_bytes=st.st_size,
+ size_human=_human_size(st.st_size),
+ rows=_fast_row_count(path),
+ cols=_col_count(path),
+ modified_iso=mod.isoformat(timespec="seconds"),
+ modified_human=mod.strftime("%d/%m/%Y %H:%M"),
+ )
+
+ # ---------- estatísticas agregadas ----------
+ def summary(self, files: list[DataFileInfo] | None = None) -> dict[str, Any]:
+ """KPIs da coleção de arquivos — útil para painel da UI."""
+ files = files if files is not None else self.discover()
+ total_bytes = sum(f.size_bytes for f in files)
+ total_rows = sum(max(0, f.rows) for f in files)
+
+ by_kind: dict[str, int] = {}
+ by_source: dict[str, int] = {}
+ for f in files:
+ by_kind[f.kind] = by_kind.get(f.kind, 0) + 1
+ by_source[f.source] = by_source.get(f.source, 0) + 1
+
+ return {
+ "n_files": len(files),
+ "total_bytes": total_bytes,
+ "total_human": _human_size(total_bytes),
+ "total_rows": total_rows,
+ "by_kind": by_kind,
+ "by_source": by_source,
+ }
+
+ # ---------- leitura ----------
+ def read_preview(
+ self, path: str, n_rows: int = 100
+ ) -> tuple[list[dict[str, Any]], list[str], int]:
+ """
+ Lê até ``n_rows`` do CSV para preview. Retorna (records, columns, total).
+
+ Usa pandas para inferência de tipos (datas, floats) — cai para CSV
+ plano em caso de erro (arquivo corrompido / muito grande).
+ """
+ import pandas as pd
+
+ if not self._is_readable(path):
+ raise FileNotFoundError(f"Arquivo não encontrado ou fora das raízes: {path}")
+
+ try:
+ df = pd.read_csv(path, nrows=n_rows)
+ # total real: usa row count fast (exclui header) — tolerante a falha.
+ total = _fast_row_count(path)
+ if total < 0:
+ total = len(df)
+ cols = list(df.columns)
+ records = df.where(pd.notnull(df), None).to_dict(orient="records")
+ return records, cols, total
+ except Exception as e:
+ log.warning("read_preview via pandas falhou para %s: %s", path, e)
+ # Fallback: CSV cru
+ records, cols = [], []
+ with open(path, "r", encoding="utf-8", errors="replace", newline="") as f:
+ reader = csv.reader(f)
+ try:
+ cols = next(reader)
+ except StopIteration:
+ return [], [], 0
+ for i, row in enumerate(reader):
+ if i >= n_rows:
+ break
+ records.append(
+ {c: (v if v != "" else None) for c, v in zip(cols, row)}
+ )
+ return records, cols, _fast_row_count(path)
+
+ # ---------- delete ----------
+ def delete(self, path: str) -> dict[str, Any]:
+ """
+ Remove o arquivo — valida que está dentro das raízes permitidas.
+
+ Retorna um dict ``{ok, message, path}`` para consumo direto do callback.
+ """
+ try:
+ if not path:
+ return {"ok": False, "message": "Caminho vazio.", "path": path}
+
+ abs_path = os.path.abspath(path)
+ if not os.path.isfile(abs_path):
+ return {"ok": False,
+ "message": f"Arquivo inexistente: {abs_path}",
+ "path": abs_path}
+
+ if not _within_root(abs_path, self.allowed_roots):
+ return {"ok": False,
+ "message": "Operação negada: caminho fora das raízes permitidas.",
+ "path": abs_path}
+
+ # Recusa extensões fora do whitelist
+ if not abs_path.lower().endswith(_CSV_EXT):
+ return {"ok": False,
+ "message": "Somente arquivos .csv podem ser removidos.",
+ "path": abs_path}
+
+ os.remove(abs_path)
+ log.info("Arquivo removido: %s", abs_path)
+ return {"ok": True, "message": "Arquivo removido com sucesso.",
+ "path": abs_path}
+ except Exception as e: # noqa: BLE001
+ log.exception("Falha ao remover %s", path)
+ return {"ok": False, "message": f"Erro ao remover: {e}", "path": path}
+
+ # ---------- upload ----------
+ def save_upload(
+ self,
+ content_b64: str,
+ filename: str,
+ overwrite: bool = False,
+ ) -> dict[str, Any]:
+ """
+ Salva conteúdo do ``dcc.Upload`` em ``CSTRChemIA/simulation/``.
+
+ ``content_b64`` é a string "data:text/csv;base64,XXXX" devolvida pelo
+ componente. Valida filename contra o whitelist ``_UPLOAD_NAME_RE``.
+
+ Parameters
+ ----------
+ content_b64:
+ Conteúdo base64 (com ou sem prefixo ``data:``).
+ filename:
+ Nome original do arquivo (sanitizado aqui).
+ overwrite:
+ Se ``False`` e o destino existir, auto-sufixa com timestamp.
+
+ Returns
+ -------
+ dict
+ ``{ok, message, saved_path, new_filename}``.
+ """
+ try:
+ safe_name = self._sanitize_filename(filename)
+ if not _UPLOAD_NAME_RE.match(safe_name):
+ return {
+ "ok": False,
+ "message": (
+ "Nome inválido. Use o padrão "
+ "'dados_treino_features_*.csv' ou "
+ "'dados_treino_targets_*.csv'."
+ ),
+ "saved_path": None,
+ "new_filename": safe_name,
+ }
+
+ raw = self._decode_b64(content_b64)
+ if not self._looks_like_csv(raw):
+ return {
+ "ok": False,
+ "message": "Conteúdo não parece ser CSV válido.",
+ "saved_path": None,
+ "new_filename": safe_name,
+ }
+
+ # Garante diretório e resolve colisões
+ self._sim_dir.mkdir(parents=True, exist_ok=True)
+ dest = self._sim_dir / safe_name
+
+ if dest.exists() and not overwrite:
+ stem, ext = os.path.splitext(safe_name)
+ stamp = time.strftime("%Y%m%d_%H%M%S")
+ safe_name = f"{stem}__{stamp}{ext}"
+ dest = self._sim_dir / safe_name
+
+ # Verificação final de raiz — defensiva
+ if not _within_root(str(dest), [self._sim_dir]):
+ return {"ok": False,
+ "message": "Destino inválido (path-traversal bloqueado).",
+ "saved_path": None,
+ "new_filename": safe_name}
+
+ # Escrita atômica: grava temporário e renomeia
+ tmp = dest.with_suffix(dest.suffix + ".tmp")
+ with open(tmp, "wb") as f:
+ f.write(raw)
+ shutil.move(str(tmp), str(dest))
+
+ log.info("Upload salvo em %s (%d bytes)", dest, len(raw))
+ return {
+ "ok": True,
+ "message": f"Upload salvo: {safe_name}",
+ "saved_path": str(dest),
+ "new_filename": safe_name,
+ }
+ except Exception as e: # noqa: BLE001
+ log.exception("Falha em save_upload(%s)", filename)
+ return {
+ "ok": False,
+ "message": f"Erro no upload: {e}",
+ "saved_path": None,
+ "new_filename": filename,
+ }
+
+ # ---------- privados ----------
+ def _is_readable(self, path: str) -> bool:
+ abs_path = os.path.abspath(path)
+ return (os.path.isfile(abs_path)
+ and _within_root(abs_path, self.allowed_roots))
+
+ @staticmethod
+ def _sanitize_filename(name: str) -> str:
+ # Nome-base: strip path separators e caracteres estranhos.
+ name = os.path.basename(name.strip())
+ # Substitui espaços por underscore — mantém letras/dígitos/_- e . única.
+ name = re.sub(r"\s+", "_", name)
+ # Remove caracteres fora do set seguro (Windows + POSIX)
+ name = re.sub(r"[^\w\-.]+", "", name)
+ return name
+
+ @staticmethod
+ def _decode_b64(content: str) -> bytes:
+ # Aceita "data:*/*;base64,XXXX" ou string pura.
+ if "," in content and content.lstrip().startswith("data:"):
+ content = content.split(",", 1)[1]
+ return base64.b64decode(content, validate=False)
+
+ @staticmethod
+ def _looks_like_csv(raw: bytes) -> bool:
+ """Heurística leve — evita aceitar imagens/binários renomeados .csv."""
+ if not raw:
+ return False
+ sample = raw[:4096]
+ # Rejeita zeros (binário)
+ if b"\x00" in sample:
+ return False
+ try:
+ text = sample.decode("utf-8", errors="strict")
+ except UnicodeDecodeError:
+ try:
+ text = sample.decode("latin-1")
+ except UnicodeDecodeError:
+ return False
+ # Pelo menos uma vírgula + uma quebra de linha.
+ return ("," in text) and ("\n" in text or "\r" in text)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# SINGLETON — acesso conveniente a partir de callbacks
+# ══════════════════════════════════════════════════════════════════════════════
+_SERVICE: DataFileService | None = None
+
+
+def get_data_file_service() -> DataFileService:
+ """Retorna (e cria sob demanda) a instância singleton do serviço."""
+ global _SERVICE
+ if _SERVICE is None:
+ _SERVICE = DataFileService()
+ return _SERVICE
diff --git a/CSTRChemIA/services/model_registry.py b/CSTRChemIA/services/model_registry.py
new file mode 100644
index 0000000..75670c4
--- /dev/null
+++ b/CSTRChemIA/services/model_registry.py
@@ -0,0 +1,232 @@
+"""
+Model registry — descoberta, versionamento e metadados dos modelos
+surrogate persistidos em ``surrogate_model/``.
+
+Objetivos:
+* Listar modelos disponíveis (arquitetura + arquivos no disco).
+* Expor métricas (MAE, RMSE, R²) lidas do manifesto ``models.json`` (preferido)
+ ou, em fallback, de ``training_results.csv``.
+* Verificar integridade SHA-256 dos artefatos quando manifesto presente.
+* Cachear a lista com invalidação manual (após treino novo).
+"""
+
+from __future__ import annotations
+
+import os
+import threading
+from collections.abc import Iterable
+from dataclasses import dataclass
+from functools import lru_cache
+
+import pandas as pd
+
+from core.constants import (
+ MODEL_FILENAMES,
+ SCALER_X_FILENAME,
+ SCALER_Y_FILENAME,
+ SUPPORTED_ARCHITECTURES,
+)
+from core.logging import get_logger
+from core.model_manifest import ModelManifest, read_manifest
+from core.settings import get_settings
+from core.types import ModelInfo
+
+log = get_logger(__name__)
+
+
+@dataclass(frozen=True, slots=True)
+class _Paths:
+ model: str
+ scaler_x: str
+ scaler_y: str
+ results: str
+
+
+class ModelRegistry:
+ """Registry thread-safe de modelos surrogate."""
+
+ def __init__(self, models_dir: str | os.PathLike | None = None) -> None:
+ settings = get_settings()
+ self.models_dir = str(models_dir) if models_dir else str(settings.models_dir)
+ self._lock = threading.Lock()
+
+ # ---------- paths ----------
+ def _paths(self, arch: str) -> _Paths:
+ filename = MODEL_FILENAMES.get(arch)
+ if not filename:
+ raise ValueError(f"Arquitetura desconhecida: {arch}")
+ return _Paths(
+ model=os.path.join(self.models_dir, filename),
+ scaler_x=os.path.join(self.models_dir, SCALER_X_FILENAME),
+ scaler_y=os.path.join(self.models_dir, SCALER_Y_FILENAME),
+ results=os.path.join(self.models_dir, "training_results.csv"),
+ )
+
+ def model_path(self, arch: str) -> str:
+ return self._paths(arch).model
+
+ def scaler_paths(self) -> tuple[str, str]:
+ p = self._paths(SUPPORTED_ARCHITECTURES[0])
+ return p.scaler_x, p.scaler_y
+
+ # ---------- discovery ----------
+ def available_architectures(self) -> list[str]:
+ """Retorna arquiteturas com modelo e scalers no disco."""
+ with self._lock:
+ out: list[str] = []
+ for arch in SUPPORTED_ARCHITECTURES:
+ p = self._paths(arch)
+ if (os.path.exists(p.model)
+ and os.path.exists(p.scaler_x)
+ and os.path.exists(p.scaler_y)):
+ out.append(arch)
+ return out
+
+ def list_models(self) -> list[ModelInfo]:
+ """Lista ModelInfo completos com métricas (quando disponíveis).
+
+ Fonte de metadados (em ordem de preferência):
+ 1. ``models.json`` (manifesto rico: per-target metrics, seed, sha256).
+ 2. ``training_results.csv`` (métricas globais legadas).
+ """
+ manifests = read_manifest(self.models_dir)
+ csv_metrics = self._load_metrics() if not manifests else {}
+
+ infos: list[ModelInfo] = []
+ for arch in self.available_architectures():
+ mani = manifests.get(arch)
+ if mani is not None:
+ infos.append(_info_from_manifest(arch, mani, self._paths(arch).model))
+ else:
+ m = csv_metrics.get(arch, {})
+ infos.append(ModelInfo(
+ name=arch,
+ architecture=arch,
+ mae=float(m.get("mae", float("nan"))),
+ rmse=float(m["rmse"]) if "rmse" in m else None,
+ r2=float(m["r2"]) if "r2" in m else None,
+ path=self._paths(arch).model,
+ ))
+ # Ordena por MAE ascendente (quando definido)
+ infos.sort(key=lambda x: (x.mae != x.mae, x.mae)) # NaN vai pro fim
+ return infos
+
+ def get_manifest(self, arch: str) -> ModelManifest | None:
+ """Manifesto de uma arquitetura específica (ou ``None`` se ausente)."""
+ return read_manifest(self.models_dir).get(arch)
+
+ def verify_integrity(self, arch: str | None = None) -> dict[str, list[str]]:
+ """
+ Verifica SHA-256 dos artefatos contra o manifesto.
+
+ Returns
+ -------
+ dict[arch, list[erros]]
+ ``{arch: []}`` significa OK; lista não-vazia lista os problemas.
+ Arquiteturas sem manifesto retornam ``["sem manifesto"]``.
+ """
+ manifests = read_manifest(self.models_dir)
+ targets = [arch] if arch else list(self.available_architectures())
+ result: dict[str, list[str]] = {}
+ for a in targets:
+ mani = manifests.get(a)
+ if mani is None:
+ result[a] = ["sem manifesto"]
+ continue
+ result[a] = mani.verify_integrity(self.models_dir)
+ return result
+
+ def best_architecture(self) -> str | None:
+ infos = self.list_models()
+ return infos[0].name if infos else None
+
+ def as_dropdown_options(self) -> list[dict[str, str]]:
+ """Formato pronto para `dcc.Dropdown(options=...)`."""
+ return [
+ {
+ "label": (
+ f"{info.architecture} (MAE: {info.mae:.6f})"
+ if info.mae == info.mae else info.architecture
+ ),
+ "value": info.architecture,
+ }
+ for info in self.list_models()
+ ]
+
+ # ---------- metrics ----------
+ def _load_metrics(self) -> dict[str, dict[str, float]]:
+ results_file = os.path.join(self.models_dir, "training_results.csv")
+ if not os.path.exists(results_file):
+ return {}
+ try:
+ df = pd.read_csv(results_file)
+ except Exception as e: # noqa: BLE001
+ log.error("Falha ao ler %s: %s", results_file, e)
+ return {}
+ if df.empty or "architecture" not in df.columns:
+ return {}
+ # Melhor MAE por arquitetura
+ idx = df.groupby("architecture")["mae_real"].idxmin()
+ best = df.loc[idx]
+ out: dict[str, dict[str, float]] = {}
+ for _, row in best.iterrows():
+ arch = str(row["architecture"])
+ entry = {"mae": float(row["mae_real"])}
+ for col, key in (("rmse_real", "rmse"), ("r2_real", "r2")):
+ if col in row and pd.notna(row[col]):
+ entry[key] = float(row[col])
+ out[arch] = entry
+ return out
+
+ # ---------- invalidação ----------
+ def invalidate(self) -> None:
+ """Chame após re-treino para forçar redescoberta."""
+ with self._lock:
+ pass # placeholder para futuro cache interno
+
+
+def _info_from_manifest(arch: str, m: ModelManifest, model_path: str) -> ModelInfo:
+ """Converte :class:`ModelManifest` em :class:`ModelInfo` para a UI."""
+ g = m.global_metrics
+ mae = float(g.get("mae", float("nan")))
+ rmse = float(g["rmse"]) if "rmse" in g else None
+ r2 = float(g["r2"]) if "r2" in g else None
+
+ per_target_mae = {
+ t: float(metrics.get("mae", float("nan")))
+ for t, metrics in m.per_target_metrics.items()
+ } or None
+
+ return ModelInfo(
+ name=arch,
+ architecture=arch,
+ mae=mae,
+ rmse=rmse,
+ r2=r2,
+ n_features=m.n_features,
+ n_targets=m.n_targets,
+ path=model_path,
+ manifest=m,
+ per_target_mae=per_target_mae,
+ seed=m.seed,
+ trained_at=m.trained_at,
+ keras_sha256=m.keras_sha256,
+ )
+
+
+@lru_cache(maxsize=1)
+def get_model_registry() -> ModelRegistry:
+ """Singleton do ModelRegistry — usa settings padrão."""
+ return ModelRegistry()
+
+
+def list_trained_architectures() -> list[str]:
+ """Shortcut — compatível com ``get_available_models`` antigo."""
+ return get_model_registry().available_architectures()
+
+
+def iter_model_files(models_dir: str | None = None) -> Iterable[str]:
+ """Itera apenas os arquivos de modelo existentes no diretório."""
+ reg = ModelRegistry(models_dir) if models_dir else get_model_registry()
+ for arch in reg.available_architectures():
+ yield reg.model_path(arch)
diff --git a/CSTRChemIA/services/optimization_service.py b/CSTRChemIA/services/optimization_service.py
new file mode 100644
index 0000000..40b4163
--- /dev/null
+++ b/CSTRChemIA/services/optimization_service.py
@@ -0,0 +1,155 @@
+"""
+Optimization service — fachada limpa sobre ``run_cstr_optimization``.
+
+Encapsula a chamada ao NSGA-II atrás de uma API tipada e fornece uma
+variante assíncrona (roda em thread, retorna handle para progresso).
+"""
+
+from __future__ import annotations
+
+import threading
+import time
+import uuid
+from collections.abc import Callable
+from dataclasses import dataclass, field
+from typing import Any
+
+import numpy as np
+import pandas as pd
+
+from core.exceptions import OptimizationError
+from core.logging import get_logger
+from core.settings import get_settings
+
+log = get_logger(__name__)
+
+
+@dataclass
+class OptimizationJob:
+ """Handle de uma otimização rodando em background."""
+ job_id: str
+ status: str = "pending" # pending | running | done | failed
+ progress_gen: int = 0
+ progress_feasible: int = 0
+ total_gens: int = 0
+ started_at: float = 0.0
+ finished_at: float = 0.0
+ df: pd.DataFrame | None = None
+ figs: dict[str, Any] = field(default_factory=dict)
+ metrics: dict[str, Any] = field(default_factory=dict)
+ error: str | None = None
+
+
+class OptimizationService:
+ """Serviço de otimização multiobjetivo (NSGA-II)."""
+
+ def __init__(self) -> None:
+ self._jobs: dict[str, OptimizationJob] = {}
+ self._lock = threading.Lock()
+ self._settings = get_settings()
+
+ # ---------- sync ----------
+ def run(
+ self,
+ *,
+ T_max: float = 365.0,
+ X_min: float = 0.30,
+ cat_min: float = 0.9995,
+ fouling_max: float = 0.025,
+ cp0_min: float = 0.0,
+ pop_size: int | None = None,
+ n_gen: int | None = None,
+ model_type: str = "MLP",
+ crossover_prob: float = 0.9,
+ mutation_prob: float = 0.1,
+ xl_override: np.ndarray | None = None,
+ xu_override: np.ndarray | None = None,
+ progress_callback: Callable[[int, int], None] | None = None,
+ ) -> tuple[pd.DataFrame, dict[str, Any], dict[str, Any]]:
+ """Executa NSGA-II sincronamente e retorna (df, figs, metrics)."""
+ # Import tardio para não carregar pymoo/TF no startup.
+ from optimization.CSTR_Optimization_Problem import run_cstr_optimization
+
+ pop = pop_size or self._settings.default_pop_size
+ ngen = n_gen or self._settings.default_n_gen
+
+ try:
+ return run_cstr_optimization(
+ T_max=T_max,
+ X_min=X_min,
+ cat_min=cat_min,
+ fouling_max=fouling_max,
+ cp0_min=cp0_min,
+ pop_size=pop,
+ n_gen=ngen,
+ model_type=model_type,
+ crossover_prob=crossover_prob,
+ mutation_prob=mutation_prob,
+ xl_override=xl_override,
+ xu_override=xu_override,
+ progress_callback=progress_callback,
+ )
+ except Exception as e: # noqa: BLE001
+ log.exception("Falha em run_cstr_optimization")
+ raise OptimizationError(str(e)) from e
+
+ # ---------- async ----------
+ def submit(self, **kwargs: Any) -> str:
+ """Dispara otimização em thread e retorna o ``job_id``."""
+ job = OptimizationJob(
+ job_id=str(uuid.uuid4()),
+ total_gens=kwargs.get("n_gen") or self._settings.default_n_gen,
+ )
+ with self._lock:
+ self._jobs[job.job_id] = job
+
+ def _progress(gen: int, feasible: int) -> None:
+ job.progress_gen = gen
+ job.progress_feasible = feasible
+
+ user_cb = kwargs.pop("progress_callback", None)
+
+ def _combined_cb(gen: int, feasible: int) -> None:
+ _progress(gen, feasible)
+ if user_cb is not None:
+ try:
+ user_cb(gen, feasible)
+ except Exception: # noqa: BLE001
+ log.exception("progress_callback do usuário falhou")
+
+ def _worker() -> None:
+ job.status = "running"
+ job.started_at = time.time()
+ try:
+ df, figs, metrics = self.run(progress_callback=_combined_cb, **kwargs)
+ job.df = df
+ job.figs = figs
+ job.metrics = metrics
+ job.status = "done"
+ except Exception as e: # noqa: BLE001
+ job.error = str(e)
+ job.status = "failed"
+ log.exception("Optimization job falhou")
+ finally:
+ job.finished_at = time.time()
+
+ thread = threading.Thread(target=_worker, daemon=True,
+ name=f"opt-{job.job_id[:8]}")
+ thread.start()
+ return job.job_id
+
+ def get_job(self, job_id: str) -> OptimizationJob | None:
+ with self._lock:
+ return self._jobs.get(job_id)
+
+ def list_jobs(self) -> list[OptimizationJob]:
+ with self._lock:
+ return list(self._jobs.values())
+
+ def clear_finished(self) -> int:
+ with self._lock:
+ to_drop = [jid for jid, j in self._jobs.items()
+ if j.status in ("done", "failed")]
+ for jid in to_drop:
+ del self._jobs[jid]
+ return len(to_drop)
diff --git a/CSTRChemIA/services/simulation_service.py b/CSTRChemIA/services/simulation_service.py
new file mode 100644
index 0000000..8256059
--- /dev/null
+++ b/CSTRChemIA/services/simulation_service.py
@@ -0,0 +1,84 @@
+"""
+Simulation service — fachada sobre o modelo surrogate para predições
+unitárias e em lote. Usado por callbacks e (no futuro) REST API.
+"""
+
+from __future__ import annotations
+
+import time
+from typing import Any
+
+import numpy as np
+
+from core.constants import DEFAULT_FEATURES, DEFAULT_TARGETS
+from core.exceptions import ModelNotFoundError, SimulationError
+from core.logging import get_logger
+from core.types import SimulationInput, SimulationResult
+from core.utils import ensure_feature_array
+
+log = get_logger(__name__)
+
+
+class SimulationService:
+ """Wrap de :func:`PredictValues` com tipos e erros consistentes."""
+
+ def __init__(self) -> None:
+ # Import tardio para evitar importar TF no startup da UI.
+ from surrogate_model.KerasPredict import PredictValues as _predict
+
+ self._predict = _predict
+
+ # ---------- single point ----------
+ def predict(self, features: np.ndarray | list | tuple, model_name: str) -> np.ndarray:
+ """Predição 1-ponto. Retorna array shape (N_TARGETS,)."""
+ X = ensure_feature_array(features)
+ if X.shape[0] != 1:
+ raise SimulationError(
+ f"SimulationService.predict espera 1 ponto, recebeu {X.shape[0]}"
+ )
+ try:
+ y = self._predict(X, model_type=model_name, Tensor=False)
+ except FileNotFoundError as e:
+ raise ModelNotFoundError(str(e)) from e
+ except ValueError as e:
+ raise SimulationError(str(e)) from e
+ return np.asarray(y, dtype=np.float64)
+
+ # ---------- batch ----------
+ def predict_batch(self, features: np.ndarray, model_name: str) -> np.ndarray:
+ """Predição em lote. Retorna shape (N, N_TARGETS)."""
+ X = ensure_feature_array(features)
+ try:
+ y = self._predict(X, model_type=model_name, Tensor=True)
+ except FileNotFoundError as e:
+ raise ModelNotFoundError(str(e)) from e
+ except ValueError as e:
+ raise SimulationError(str(e)) from e
+ return np.asarray(y, dtype=np.float64)
+
+ # ---------- structured API ----------
+ def simulate(self, inp: SimulationInput) -> SimulationResult:
+ start = time.perf_counter()
+ y = self.predict(inp.features, inp.model_name)
+ return SimulationResult(
+ targets=y,
+ model_name=inp.model_name,
+ wall_time_s=time.perf_counter() - start,
+ )
+
+ # ---------- dict helpers ----------
+ def predict_named(self, params: dict[str, float], model_name: str) -> dict[str, float]:
+ """Retorna predição como {target_name: value} a partir de dict de features."""
+ missing = [f for f in DEFAULT_FEATURES if f not in params]
+ if missing:
+ raise SimulationError(f"Features ausentes: {missing}")
+ X = np.array([[float(params[f]) for f in DEFAULT_FEATURES]], dtype=np.float64)
+ y = self.predict(X, model_name)
+ return {name: float(v) for name, v in zip(DEFAULT_TARGETS, y)}
+
+ # ---------- introspecção ----------
+ def describe(self) -> dict[str, Any]:
+ return {
+ "features": list(DEFAULT_FEATURES),
+ "targets": list(DEFAULT_TARGETS),
+ }
diff --git a/CSTRChemIA/services/training_service.py b/CSTRChemIA/services/training_service.py
new file mode 100644
index 0000000..45e5acd
--- /dev/null
+++ b/CSTRChemIA/services/training_service.py
@@ -0,0 +1,158 @@
+"""
+Training service — orquestra treino do surrogate em subprocess.
+
+Executa ``python -m training.train_surrogate --arch MLP`` capturando
+stdout linha-a-linha via thread para streaming nos callbacks do Dash.
+Suporta múltiplos jobs simultâneos com handles de progresso.
+"""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+import threading
+import time
+import uuid
+from dataclasses import dataclass, field
+
+from core.exceptions import TrainingError
+from core.logging import get_logger
+from core.settings import get_settings
+from services.model_registry import get_model_registry
+
+log = get_logger(__name__)
+
+
+@dataclass
+class TrainingJob:
+ """Handle de um treino em background."""
+ job_id: str
+ architecture: str
+ status: str = "pending" # pending | running | done | failed | cancelled
+ returncode: int | None = None
+ started_at: float = 0.0
+ finished_at: float = 0.0
+ logs: list[str] = field(default_factory=list)
+ error: str | None = None
+ process: subprocess.Popen | None = None
+
+
+class TrainingService:
+ """Serviço de treinamento de modelos surrogate."""
+
+ def __init__(self) -> None:
+ self._jobs: dict[str, TrainingJob] = {}
+ self._lock = threading.Lock()
+ self._settings = get_settings()
+
+ # ---------- submit ----------
+ def submit(self, architecture: str, extra_args: list[str] | None = None) -> str:
+ """Dispara treino em subprocess; retorna ``job_id``."""
+ if architecture not in {"MLP", "RNN", "CNN"}:
+ raise TrainingError(f"Arquitetura inválida: {architecture}")
+
+ job = TrainingJob(
+ job_id=str(uuid.uuid4()),
+ architecture=architecture,
+ )
+ with self._lock:
+ self._jobs[job.job_id] = job
+
+ cmd = [
+ sys.executable, "-u",
+ "-m", "training.train_surrogate",
+ "--arch", architecture,
+ ]
+ if extra_args:
+ cmd.extend(extra_args)
+
+ env = os.environ.copy()
+ env.setdefault("PYTHONUNBUFFERED", "1")
+ env.setdefault("TF_CPP_MIN_LOG_LEVEL", "2")
+
+ try:
+ job.process = subprocess.Popen(
+ cmd,
+ cwd=str(self._settings.base_dir),
+ env=env,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ bufsize=1,
+ encoding="utf-8",
+ errors="replace",
+ )
+ except Exception as e: # noqa: BLE001
+ job.status = "failed"
+ job.error = f"Falha ao iniciar subprocess: {e}"
+ raise TrainingError(job.error) from e
+
+ job.status = "running"
+ job.started_at = time.time()
+
+ threading.Thread(
+ target=self._reader_thread, args=(job,), daemon=True,
+ name=f"train-reader-{job.job_id[:8]}",
+ ).start()
+ return job.job_id
+
+ # ---------- reader ----------
+ def _reader_thread(self, job: TrainingJob) -> None:
+ assert job.process is not None
+ try:
+ for line in job.process.stdout: # type: ignore[union-attr]
+ job.logs.append(line.rstrip("\n"))
+ returncode = job.process.wait()
+ job.returncode = returncode
+ job.status = "done" if returncode == 0 else "failed"
+ if returncode == 0:
+ # Invalida registry para redescobrir modelos
+ try:
+ get_model_registry().invalidate()
+ except Exception: # noqa: BLE001
+ log.exception("Falha ao invalidar registry pós-treino")
+ except Exception as e: # noqa: BLE001
+ job.error = str(e)
+ job.status = "failed"
+ log.exception("reader thread do treino falhou")
+ finally:
+ job.finished_at = time.time()
+
+ # ---------- control ----------
+ def cancel(self, job_id: str) -> bool:
+ job = self.get_job(job_id)
+ if job is None or job.process is None:
+ return False
+ if job.status != "running":
+ return False
+ try:
+ job.process.terminate()
+ job.status = "cancelled"
+ return True
+ except Exception: # noqa: BLE001
+ log.exception("cancel do job %s falhou", job_id)
+ return False
+
+ # ---------- introspecção ----------
+ def get_job(self, job_id: str) -> TrainingJob | None:
+ with self._lock:
+ return self._jobs.get(job_id)
+
+ def list_jobs(self) -> list[TrainingJob]:
+ with self._lock:
+ return list(self._jobs.values())
+
+ def tail_logs(self, job_id: str, n: int = 100) -> list[str]:
+ job = self.get_job(job_id)
+ if job is None:
+ return []
+ return job.logs[-n:]
+
+ def clear_finished(self) -> int:
+ with self._lock:
+ to_drop = [jid for jid, j in self._jobs.items()
+ if j.status in ("done", "failed", "cancelled")]
+ for jid in to_drop:
+ del self._jobs[jid]
+ return len(to_drop)
diff --git a/CSTRChemIA/surrogate_model/CSTR_CNN_Surrogate.keras b/CSTRChemIA/surrogate_model/CSTR_CNN_Surrogate.keras
index 78d8ef1..99ccd0c 100644
Binary files a/CSTRChemIA/surrogate_model/CSTR_CNN_Surrogate.keras and b/CSTRChemIA/surrogate_model/CSTR_CNN_Surrogate.keras differ
diff --git a/CSTRChemIA/surrogate_model/CSTR_MLP_Surrogate.keras b/CSTRChemIA/surrogate_model/CSTR_MLP_Surrogate.keras
index 3ec2a66..2b85e52 100644
Binary files a/CSTRChemIA/surrogate_model/CSTR_MLP_Surrogate.keras and b/CSTRChemIA/surrogate_model/CSTR_MLP_Surrogate.keras differ
diff --git a/CSTRChemIA/surrogate_model/CSTR_RNN_Surrogate.keras b/CSTRChemIA/surrogate_model/CSTR_RNN_Surrogate.keras
index f3efe27..e5a8be7 100644
Binary files a/CSTRChemIA/surrogate_model/CSTR_RNN_Surrogate.keras and b/CSTRChemIA/surrogate_model/CSTR_RNN_Surrogate.keras differ
diff --git a/CSTRChemIA/surrogate_model/model_ranking.py b/CSTRChemIA/surrogate_model/model_ranking.py
index 768f74f..06558d6 100644
--- a/CSTRChemIA/surrogate_model/model_ranking.py
+++ b/CSTRChemIA/surrogate_model/model_ranking.py
@@ -1,6 +1,19 @@
import os
+import sys
+
import pandas as pd
+# Garante que o diretório raiz do app esteja no sys.path (execução direta via
+# scripts ou subprocessos pode omitir isso).
+_BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if _BASE not in sys.path:
+ sys.path.insert(0, _BASE)
+
+from core.logging import get_logger # noqa: E402
+
+log = get_logger(__name__)
+
+
def get_sorted_models(output_dir=None):
if output_dir is None:
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -26,5 +39,5 @@ def get_sorted_models(output_dir=None):
})
return options
except Exception as e:
- print(f"Erro ao ler {results_file}: {e}", flush=True)
+ log.error("Erro ao ler %s: %s", results_file, e)
return []
\ No newline at end of file
diff --git a/CSTRChemIA/surrogate_model/models.json b/CSTRChemIA/surrogate_model/models.json
new file mode 100644
index 0000000..c299a6b
--- /dev/null
+++ b/CSTRChemIA/surrogate_model/models.json
@@ -0,0 +1,399 @@
+{
+ "version": "1",
+ "generated_at": "2026-04-23T22:23:02+00:00",
+ "models": {
+ "MLP": {
+ "architecture": "MLP",
+ "trained_at": "2026-04-23T22:17:15+00:00",
+ "seed": 42,
+ "framework": "tensorflow==2.20.0",
+ "keras_file": "CSTR_MLP_Surrogate.keras",
+ "keras_sha256": "a4fdeff9ad71bfbfeca99b0eff34f86f18d3a1c8e3cbd9026f77af0ac4138e67",
+ "scaler_x_file": "scalerX.json",
+ "scaler_x_sha256": "cbca45a078e9cdf0667c5759b7ce0f8f66f924e5ff60e772b735517c1d111861",
+ "scaler_y_file": "scalerY.json",
+ "scaler_y_sha256": "d0c9c14e7be0a476bbefcb8bf352c312b0007bb78996a7a56d22c7185634b6b8",
+ "n_features": 12,
+ "n_targets": 8,
+ "feature_names": [
+ "vazao_feed",
+ "CA0_feed",
+ "CB0_feed",
+ "Tf_feed",
+ "T_amb_feed",
+ "T_obs",
+ "valve_pos",
+ "w_j",
+ "flag_falha_valvula",
+ "flag_manutencao",
+ "flag_falha_sensor_T",
+ "flag_falha_comunicacao"
+ ],
+ "target_names": [
+ "CA0",
+ "CB0",
+ "CP0",
+ "CS0",
+ "T0",
+ "X",
+ "fouling_thickness",
+ "cat_activity"
+ ],
+ "global_metrics": {
+ "mae": 7.790978216439949,
+ "rmse": 20.86095129970782,
+ "r2": 0.9114103269460277
+ },
+ "per_target_metrics": {
+ "CA0": {
+ "mae": 28.704781058213765,
+ "rmse": 45.092368758083296,
+ "r2": 0.9241542432585107
+ },
+ "CB0": {
+ "mae": 21.9929510503278,
+ "rmse": 35.074922264410084,
+ "r2": 0.8878556508846664
+ },
+ "CP0": {
+ "mae": 8.602803595154116,
+ "rmse": 14.245192647513573,
+ "r2": 0.8530493512178284
+ },
+ "CS0": {
+ "mae": 2.146169998750111,
+ "rmse": 3.5695303735056436,
+ "r2": 0.8544248523908483
+ },
+ "T0": {
+ "mae": 0.8590529706009221,
+ "rmse": 1.481250887585641,
+ "r2": 0.957061629842165
+ },
+ "X": {
+ "mae": 0.02183670831415701,
+ "rmse": 0.03544045411567878,
+ "r2": 0.8479331253666851
+ },
+ "fouling_thickness": {
+ "mae": 0.00014438648345013963,
+ "rmse": 0.0002812862866330885,
+ "r2": 0.9795828126816982
+ },
+ "cat_activity": {
+ "mae": 8.596367526568043e-05,
+ "rmse": 0.00010879536550476425,
+ "r2": 0.820498034556379
+ }
+ },
+ "n_train": 12455,
+ "n_val": 2669,
+ "n_test": 2670,
+ "hyperparameters": {
+ "learning_rate": 7.042343893360582e-05,
+ "mlp_activation_0": "relu",
+ "mlp_activation_1": "elu",
+ "mlp_activation_2": "elu",
+ "mlp_activation_3": "relu",
+ "mlp_activation_4": "selu",
+ "mlp_bn_0": true,
+ "mlp_bn_1": true,
+ "mlp_bn_2": false,
+ "mlp_bn_3": true,
+ "mlp_bn_4": false,
+ "mlp_dropout_0": 0.45,
+ "mlp_dropout_1": 0.15000000000000002,
+ "mlp_dropout_2": 0.35000000000000003,
+ "mlp_dropout_3": 0.2,
+ "mlp_dropout_4": 0.35000000000000003,
+ "mlp_l2_reg_0": 1e-05,
+ "mlp_l2_reg_1": 0.0001,
+ "mlp_l2_reg_2": 1e-05,
+ "mlp_l2_reg_3": 0.0001,
+ "mlp_l2_reg_4": 0.0,
+ "mlp_l2_reg_out": 1e-05,
+ "mlp_num_layers": 2,
+ "mlp_units_0": 160,
+ "mlp_units_1": 224,
+ "mlp_units_2": 96,
+ "mlp_units_3": 32,
+ "mlp_units_4": 512,
+ "optimizer": "rmsprop",
+ "sgd_momentum": 0.08232652977999014
+ },
+ "notes": "epochs_trained=100, mae_val=8.038335"
+ },
+ "CNN": {
+ "architecture": "CNN",
+ "trained_at": "2026-04-23T22:19:25+00:00",
+ "seed": 42,
+ "framework": "tensorflow==2.20.0",
+ "keras_file": "CSTR_CNN_Surrogate.keras",
+ "keras_sha256": "402f069c2a71fc10fb314203826585153cd41c47e2d7affb9464a4102c422b14",
+ "scaler_x_file": "scalerX.json",
+ "scaler_x_sha256": "cbca45a078e9cdf0667c5759b7ce0f8f66f924e5ff60e772b735517c1d111861",
+ "scaler_y_file": "scalerY.json",
+ "scaler_y_sha256": "d0c9c14e7be0a476bbefcb8bf352c312b0007bb78996a7a56d22c7185634b6b8",
+ "n_features": 12,
+ "n_targets": 8,
+ "feature_names": [
+ "vazao_feed",
+ "CA0_feed",
+ "CB0_feed",
+ "Tf_feed",
+ "T_amb_feed",
+ "T_obs",
+ "valve_pos",
+ "w_j",
+ "flag_falha_valvula",
+ "flag_manutencao",
+ "flag_falha_sensor_T",
+ "flag_falha_comunicacao"
+ ],
+ "target_names": [
+ "CA0",
+ "CB0",
+ "CP0",
+ "CS0",
+ "T0",
+ "X",
+ "fouling_thickness",
+ "cat_activity"
+ ],
+ "global_metrics": {
+ "mae": 4.740968826476976,
+ "rmse": 11.872232108813378,
+ "r2": 0.9713067568960093
+ },
+ "per_target_metrics": {
+ "CA0": {
+ "mae": 19.157868480556647,
+ "rmse": 26.538877525242597,
+ "r2": 0.973728172167403
+ },
+ "CB0": {
+ "mae": 13.514860566875461,
+ "rmse": 19.360550014165533,
+ "r2": 0.9658320170016224
+ },
+ "CP0": {
+ "mae": 3.71415586751396,
+ "rmse": 6.697236570679778,
+ "r2": 0.9675192436148706
+ },
+ "CS0": {
+ "mae": 0.9374918703724022,
+ "rmse": 1.6871165358723519,
+ "r2": 0.9674796478489032
+ },
+ "T0": {
+ "mae": 0.5911247458244983,
+ "rmse": 0.869800038111859,
+ "r2": 0.9851943610960618
+ },
+ "X": {
+ "mae": 0.012098612482194593,
+ "rmse": 0.018782673116615325,
+ "r2": 0.9572879058952075
+ },
+ "fouling_thickness": {
+ "mae": 8.88784564039539e-05,
+ "rmse": 0.00014792934746372816,
+ "r2": 0.9943531340698699
+ },
+ "cat_activity": {
+ "mae": 6.158973423354664e-05,
+ "rmse": 8.50104474602522e-05,
+ "r2": 0.890404424542925
+ }
+ },
+ "n_train": 12455,
+ "n_val": 2669,
+ "n_test": 2670,
+ "hyperparameters": {
+ "cnn_conv_activation_0": "tanh",
+ "cnn_conv_activation_1": "tanh",
+ "cnn_conv_activation_2": "elu",
+ "cnn_conv_activation_3": "relu",
+ "cnn_conv_bn_0": false,
+ "cnn_conv_bn_1": true,
+ "cnn_conv_bn_2": true,
+ "cnn_conv_bn_3": false,
+ "cnn_dense_activation_0": "relu",
+ "cnn_dense_activation_1": "elu",
+ "cnn_dense_activation_2": "elu",
+ "cnn_dense_bn_0": false,
+ "cnn_dense_bn_1": false,
+ "cnn_dense_bn_2": true,
+ "cnn_dense_dropout_0": 0.25,
+ "cnn_dense_dropout_1": 0.05,
+ "cnn_dense_dropout_2": 0.4,
+ "cnn_dense_l2_reg_0": 0.0,
+ "cnn_dense_l2_reg_1": 0.0,
+ "cnn_dense_l2_reg_2": 1e-05,
+ "cnn_dense_units_0": 192,
+ "cnn_dense_units_1": 160,
+ "cnn_dense_units_2": 96,
+ "cnn_filters_0": 16,
+ "cnn_filters_1": 128,
+ "cnn_filters_2": 224,
+ "cnn_filters_3": 240,
+ "cnn_kernel_size_0": 2,
+ "cnn_kernel_size_1": 3,
+ "cnn_kernel_size_2": 5,
+ "cnn_kernel_size_3": 2,
+ "cnn_l2_reg_conv_0": 0.0,
+ "cnn_l2_reg_conv_1": 0.001,
+ "cnn_l2_reg_conv_2": 0.001,
+ "cnn_l2_reg_conv_3": 0.0,
+ "cnn_l2_reg_out": 1e-05,
+ "cnn_num_conv": 4,
+ "cnn_num_dense": 3,
+ "cnn_pool_size_0": 2,
+ "cnn_pool_size_1": 2,
+ "cnn_pool_size_2": 3,
+ "cnn_pool_size_3": 2,
+ "cnn_pool_type_0": "max",
+ "cnn_pool_type_1": "avg",
+ "cnn_pool_type_2": "max",
+ "cnn_pool_type_3": "max",
+ "cnn_pooling_mode": "global_max",
+ "learning_rate": 0.006230870707636893,
+ "optimizer": "adam",
+ "sgd_momentum": 0.5544059636427886
+ },
+ "notes": "epochs_trained=100, mae_val=4.706387"
+ },
+ "RNN": {
+ "architecture": "RNN",
+ "trained_at": "2026-04-23T22:23:02+00:00",
+ "seed": 42,
+ "framework": "tensorflow==2.20.0",
+ "keras_file": "CSTR_RNN_Surrogate.keras",
+ "keras_sha256": "7d73aa93ce8c798f191847356ae067b8c870fa047488974be1e0ebfc599ecd9d",
+ "scaler_x_file": "scalerX.json",
+ "scaler_x_sha256": "cbca45a078e9cdf0667c5759b7ce0f8f66f924e5ff60e772b735517c1d111861",
+ "scaler_y_file": "scalerY.json",
+ "scaler_y_sha256": "d0c9c14e7be0a476bbefcb8bf352c312b0007bb78996a7a56d22c7185634b6b8",
+ "n_features": 12,
+ "n_targets": 8,
+ "feature_names": [
+ "vazao_feed",
+ "CA0_feed",
+ "CB0_feed",
+ "Tf_feed",
+ "T_amb_feed",
+ "T_obs",
+ "valve_pos",
+ "w_j",
+ "flag_falha_valvula",
+ "flag_manutencao",
+ "flag_falha_sensor_T",
+ "flag_falha_comunicacao"
+ ],
+ "target_names": [
+ "CA0",
+ "CB0",
+ "CP0",
+ "CS0",
+ "T0",
+ "X",
+ "fouling_thickness",
+ "cat_activity"
+ ],
+ "global_metrics": {
+ "mae": 5.317254820688012,
+ "rmse": 13.097856322315085,
+ "r2": 0.9650766959787043
+ },
+ "per_target_metrics": {
+ "CA0": {
+ "mae": 20.896643534699844,
+ "rmse": 28.77265087176552,
+ "r2": 0.9691194561021589
+ },
+ "CB0": {
+ "mae": 15.61447767595271,
+ "rmse": 21.929194987020114,
+ "r2": 0.9561641594430985
+ },
+ "CP0": {
+ "mae": 4.2977897028387675,
+ "rmse": 7.676024680376276,
+ "r2": 0.9573314800260606
+ },
+ "CS0": {
+ "mae": 1.0410197169429622,
+ "rmse": 1.9105527365033281,
+ "r2": 0.9582954791932878
+ },
+ "T0": {
+ "mae": 0.6749575438409822,
+ "rmse": 1.0505736438881421,
+ "r2": 0.9784006187114649
+ },
+ "X": {
+ "mae": 0.013038173550129945,
+ "rmse": 0.020476821361362398,
+ "r2": 0.9492353791244927
+ },
+ "fouling_thickness": {
+ "mae": 3.90503873454228e-05,
+ "rmse": 9.028153747222398e-05,
+ "r2": 0.9978967219649456
+ },
+ "cat_activity": {
+ "mae": 7.316729135618836e-05,
+ "rmse": 0.00010253983860383223,
+ "r2": 0.8405466396195416
+ }
+ },
+ "n_train": 12455,
+ "n_val": 2669,
+ "n_test": 2670,
+ "hyperparameters": {
+ "learning_rate": 0.026060600450952237,
+ "optimizer": "adam",
+ "rnn_bn_0": false,
+ "rnn_bn_1": false,
+ "rnn_bn_2": false,
+ "rnn_bn_3": true,
+ "rnn_dense_activation_0": "relu",
+ "rnn_dense_activation_1": "elu",
+ "rnn_dense_bn_0": false,
+ "rnn_dense_bn_1": true,
+ "rnn_dense_dropout_0": 0.0,
+ "rnn_dense_dropout_1": 0.1,
+ "rnn_dense_l2_reg_0": 0.0,
+ "rnn_dense_l2_reg_1": 1e-05,
+ "rnn_dense_units_0": 32,
+ "rnn_dense_units_1": 160,
+ "rnn_dropout_0": 0.05,
+ "rnn_dropout_1": 0.0,
+ "rnn_dropout_2": 0.0,
+ "rnn_dropout_3": 0.1,
+ "rnn_l2_reg_0": 0.0,
+ "rnn_l2_reg_1": 0.0,
+ "rnn_l2_reg_2": 0.0,
+ "rnn_l2_reg_3": 0.0001,
+ "rnn_l2_reg_out": 1e-05,
+ "rnn_num_dense": 1,
+ "rnn_num_layers": 3,
+ "rnn_recurrent_dropout_0": 0.05,
+ "rnn_recurrent_dropout_1": 0.0,
+ "rnn_recurrent_dropout_2": 0.0,
+ "rnn_recurrent_dropout_3": 0.05,
+ "rnn_type_0": "lstm",
+ "rnn_type_1": "lstm",
+ "rnn_type_2": "lstm",
+ "rnn_type_3": "gru",
+ "rnn_units_0": 80,
+ "rnn_units_1": 16,
+ "rnn_units_2": 16,
+ "rnn_units_3": 128,
+ "sgd_momentum": 0.5299378063937362
+ },
+ "notes": "epochs_trained=100, mae_val=5.479252"
+ }
+ }
+}
\ No newline at end of file
diff --git a/CSTRChemIA/surrogate_model/scalerX.json b/CSTRChemIA/surrogate_model/scalerX.json
new file mode 100644
index 0000000..baae903
--- /dev/null
+++ b/CSTRChemIA/surrogate_model/scalerX.json
@@ -0,0 +1 @@
+{"class":"StandardScaler","version":"1","n_features_in":12,"feature_names":["vazao_feed","CA0_feed","CB0_feed","Tf_feed","T_amb_feed","T_obs","valve_pos","w_j","flag_falha_valvula","flag_manutencao","flag_falha_sensor_T","flag_falha_comunicacao"],"params":{"mean_":[0.0005402795035688708,1135.605866399623,971.3392971304223,352.1376156001787,301.63864307333756,338.2911300162934,0.9285719917954784,0.009285719917953014,0.014692894419911682,0.0032918506623845845,0.00513849859494179,0.0005620232838217583],"scale_":[0.0004102666532319589,133.74247793466915,85.52867306014456,8.62273334544255,9.984451037276935,12.239750397465203,0.2424646731855539,0.002424646731855433,0.1203204607432762,0.05728013950403274,0.0714989120695709,0.02370036737373883],"var_":[1.6831872675415237e-07,17887.050404105463,7315.153915429098,74.35153034660685,99.68926251578047,149.8114897922496,0.05878911774297746,5.878911774297233e-06,0.014477013273474266,0.003281014381601452,0.005112094427132232,0.000561707413650184],"n_samples_seen_":12455},"sha256":"d538b0f913d769a6854cfbf455d94eed0bfe731a0c3b914c913bd9df8252d7fd"}
\ No newline at end of file
diff --git a/CSTRChemIA/surrogate_model/scalerY.json b/CSTRChemIA/surrogate_model/scalerY.json
new file mode 100644
index 0000000..5889a52
--- /dev/null
+++ b/CSTRChemIA/surrogate_model/scalerY.json
@@ -0,0 +1 @@
+{"class":"StandardScaler","version":"1","n_features_in":8,"feature_names":["CA0","CB0","CP0","CS0","T0","X","fouling_thickness","cat_activity"],"params":{"mean_":[1091.5123165360542,929.8152371653102,18.5545782829106,4.327790429434991,338.0840340420465,0.04817885087168597,0.000605140818025128,0.9996928220639538],"scale_":[165.24642825680945,107.70399500912275,38.38884726203927,9.664722923035365,7.234892742695949,0.09387949549960153,0.002006334134806741,0.00026046943547833584],"var_":[27306.38205163287,11600.150540925139,1473.70359410818,93.40686917904526,52.3436729983145,0.008813359675259706,4.025376660490714e-06,6.784432681840297e-08],"n_samples_seen_":12455},"sha256":"c71b98110bf31b366530d4b56bd3a2b670aed9355a02130394027e08e6c43ea7"}
\ No newline at end of file
diff --git a/CSTRChemIA/surrogate_model/training_results.csv b/CSTRChemIA/surrogate_model/training_results.csv
index 6b1f09b..deea82a 100644
--- a/CSTRChemIA/surrogate_model/training_results.csv
+++ b/CSTRChemIA/surrogate_model/training_results.csv
@@ -1,4 +1,4 @@
timestamp,architecture,mae_real,val_loss_scaled,epochs_trained,model_path,hyperparameters
-2026-04-11 15:43:04.801601,RNN,9.83030580347456,0.9301572442054749,42,C:\Users\rafae\PyCharmMiscProject\CSTR_WebApp\surrogate_model\CSTR_RNN_Surrogate.keras,"{""learning_rate"": 0.026060600450952237, ""optimizer"": ""adam"", ""rnn_bn_0"": false, ""rnn_bn_1"": false, ""rnn_bn_2"": false, ""rnn_bn_3"": true, ""rnn_dense_activation_0"": ""relu"", ""rnn_dense_activation_1"": ""elu"", ""rnn_dense_bn_0"": false, ""rnn_dense_bn_1"": true, ""rnn_dense_dropout_0"": 0.0, ""rnn_dense_dropout_1"": 0.1, ""rnn_dense_l2_reg_0"": 0.0, ""rnn_dense_l2_reg_1"": 1e-05, ""rnn_dense_units_0"": 32, ""rnn_dense_units_1"": 160, ""rnn_dropout_0"": 0.05, ""rnn_dropout_1"": 0.0, ""rnn_dropout_2"": 0.0, ""rnn_dropout_3"": 0.1, ""rnn_l2_reg_0"": 0.0, ""rnn_l2_reg_1"": 0.0, ""rnn_l2_reg_2"": 0.0, ""rnn_l2_reg_3"": 0.0001, ""rnn_l2_reg_out"": 1e-05, ""rnn_num_dense"": 1, ""rnn_num_layers"": 3, ""rnn_recurrent_dropout_0"": 0.05, ""rnn_recurrent_dropout_1"": 0.0, ""rnn_recurrent_dropout_2"": 0.0, ""rnn_recurrent_dropout_3"": 0.05, ""rnn_type_0"": ""lstm"", ""rnn_type_1"": ""lstm"", ""rnn_type_2"": ""lstm"", ""rnn_type_3"": ""gru"", ""rnn_units_0"": 80, ""rnn_units_1"": 16, ""rnn_units_2"": 16, ""rnn_units_3"": 128, ""sgd_momentum"": 0.5299378063937362}"
-2026-04-12 14:52:11.229972,CNN,5.596273458351692,0.0778804123401641,69,C:\Users\rafae\PyCharmMiscProject\CSTR_WebApp\surrogate_model\CSTR_CNN_Surrogate.keras,"{""cnn_conv_activation_0"": ""tanh"", ""cnn_conv_activation_1"": ""tanh"", ""cnn_conv_activation_2"": ""elu"", ""cnn_conv_activation_3"": ""relu"", ""cnn_conv_bn_0"": false, ""cnn_conv_bn_1"": true, ""cnn_conv_bn_2"": true, ""cnn_conv_bn_3"": false, ""cnn_dense_activation_0"": ""relu"", ""cnn_dense_activation_1"": ""elu"", ""cnn_dense_activation_2"": ""elu"", ""cnn_dense_bn_0"": false, ""cnn_dense_bn_1"": false, ""cnn_dense_bn_2"": true, ""cnn_dense_dropout_0"": 0.25, ""cnn_dense_dropout_1"": 0.05, ""cnn_dense_dropout_2"": 0.4, ""cnn_dense_l2_reg_0"": 0.0, ""cnn_dense_l2_reg_1"": 0.0, ""cnn_dense_l2_reg_2"": 1e-05, ""cnn_dense_units_0"": 192, ""cnn_dense_units_1"": 160, ""cnn_dense_units_2"": 96, ""cnn_filters_0"": 16, ""cnn_filters_1"": 128, ""cnn_filters_2"": 224, ""cnn_filters_3"": 240, ""cnn_kernel_size_0"": 2, ""cnn_kernel_size_1"": 3, ""cnn_kernel_size_2"": 5, ""cnn_kernel_size_3"": 2, ""cnn_l2_reg_conv_0"": 0.0, ""cnn_l2_reg_conv_1"": 0.001, ""cnn_l2_reg_conv_2"": 0.001, ""cnn_l2_reg_conv_3"": 0.0, ""cnn_l2_reg_out"": 1e-05, ""cnn_num_conv"": 4, ""cnn_num_dense"": 3, ""cnn_pool_size_0"": 2, ""cnn_pool_size_1"": 2, ""cnn_pool_size_2"": 3, ""cnn_pool_size_3"": 2, ""cnn_pool_type_0"": ""max"", ""cnn_pool_type_1"": ""avg"", ""cnn_pool_type_2"": ""max"", ""cnn_pool_type_3"": ""max"", ""cnn_pooling_mode"": ""global_max"", ""learning_rate"": 0.006230870707636893, ""optimizer"": ""adam"", ""sgd_momentum"": 0.5544059636427886}"
-2026-04-13 21:16:51.431031,MLP,8.038334810600876,0.12407053261995316,100,C:\Users\rafae\Projects\CSTRChemIA\CSTRChemIA\surrogate_model\CSTR_MLP_Surrogate.keras,"{""learning_rate"": 7.042343893360582e-05, ""mlp_activation_0"": ""relu"", ""mlp_activation_1"": ""elu"", ""mlp_activation_2"": ""elu"", ""mlp_activation_3"": ""relu"", ""mlp_activation_4"": ""selu"", ""mlp_bn_0"": true, ""mlp_bn_1"": true, ""mlp_bn_2"": false, ""mlp_bn_3"": true, ""mlp_bn_4"": false, ""mlp_dropout_0"": 0.45, ""mlp_dropout_1"": 0.15000000000000002, ""mlp_dropout_2"": 0.35000000000000003, ""mlp_dropout_3"": 0.2, ""mlp_dropout_4"": 0.35000000000000003, ""mlp_l2_reg_0"": 1e-05, ""mlp_l2_reg_1"": 0.0001, ""mlp_l2_reg_2"": 1e-05, ""mlp_l2_reg_3"": 0.0001, ""mlp_l2_reg_4"": 0.0, ""mlp_l2_reg_out"": 1e-05, ""mlp_num_layers"": 2, ""mlp_units_0"": 160, ""mlp_units_1"": 224, ""mlp_units_2"": 96, ""mlp_units_3"": 32, ""mlp_units_4"": 512, ""optimizer"": ""rmsprop"", ""sgd_momentum"": 0.08232652977999014}"
+2026-04-23 19:17:15.414251,MLP,8.038334810600876,0.1240705326199531,100,C:\Users\rafae\Projects\CSTRChemIA\.claude\worktrees\elegant-tereshkova-93f1dc\CSTRChemIA\surrogate_model\CSTR_MLP_Surrogate.keras,"{""learning_rate"": 7.042343893360582e-05, ""mlp_activation_0"": ""relu"", ""mlp_activation_1"": ""elu"", ""mlp_activation_2"": ""elu"", ""mlp_activation_3"": ""relu"", ""mlp_activation_4"": ""selu"", ""mlp_bn_0"": true, ""mlp_bn_1"": true, ""mlp_bn_2"": false, ""mlp_bn_3"": true, ""mlp_bn_4"": false, ""mlp_dropout_0"": 0.45, ""mlp_dropout_1"": 0.15000000000000002, ""mlp_dropout_2"": 0.35000000000000003, ""mlp_dropout_3"": 0.2, ""mlp_dropout_4"": 0.35000000000000003, ""mlp_l2_reg_0"": 1e-05, ""mlp_l2_reg_1"": 0.0001, ""mlp_l2_reg_2"": 1e-05, ""mlp_l2_reg_3"": 0.0001, ""mlp_l2_reg_4"": 0.0, ""mlp_l2_reg_out"": 1e-05, ""mlp_num_layers"": 2, ""mlp_units_0"": 160, ""mlp_units_1"": 224, ""mlp_units_2"": 96, ""mlp_units_3"": 32, ""mlp_units_4"": 512, ""optimizer"": ""rmsprop"", ""sgd_momentum"": 0.08232652977999014}"
+2026-04-23 19:19:25.230938,CNN,4.706387194042596,0.042171012610197,100,C:\Users\rafae\Projects\CSTRChemIA\.claude\worktrees\elegant-tereshkova-93f1dc\CSTRChemIA\surrogate_model\CSTR_CNN_Surrogate.keras,"{""cnn_conv_activation_0"": ""tanh"", ""cnn_conv_activation_1"": ""tanh"", ""cnn_conv_activation_2"": ""elu"", ""cnn_conv_activation_3"": ""relu"", ""cnn_conv_bn_0"": false, ""cnn_conv_bn_1"": true, ""cnn_conv_bn_2"": true, ""cnn_conv_bn_3"": false, ""cnn_dense_activation_0"": ""relu"", ""cnn_dense_activation_1"": ""elu"", ""cnn_dense_activation_2"": ""elu"", ""cnn_dense_bn_0"": false, ""cnn_dense_bn_1"": false, ""cnn_dense_bn_2"": true, ""cnn_dense_dropout_0"": 0.25, ""cnn_dense_dropout_1"": 0.05, ""cnn_dense_dropout_2"": 0.4, ""cnn_dense_l2_reg_0"": 0.0, ""cnn_dense_l2_reg_1"": 0.0, ""cnn_dense_l2_reg_2"": 1e-05, ""cnn_dense_units_0"": 192, ""cnn_dense_units_1"": 160, ""cnn_dense_units_2"": 96, ""cnn_filters_0"": 16, ""cnn_filters_1"": 128, ""cnn_filters_2"": 224, ""cnn_filters_3"": 240, ""cnn_kernel_size_0"": 2, ""cnn_kernel_size_1"": 3, ""cnn_kernel_size_2"": 5, ""cnn_kernel_size_3"": 2, ""cnn_l2_reg_conv_0"": 0.0, ""cnn_l2_reg_conv_1"": 0.001, ""cnn_l2_reg_conv_2"": 0.001, ""cnn_l2_reg_conv_3"": 0.0, ""cnn_l2_reg_out"": 1e-05, ""cnn_num_conv"": 4, ""cnn_num_dense"": 3, ""cnn_pool_size_0"": 2, ""cnn_pool_size_1"": 2, ""cnn_pool_size_2"": 3, ""cnn_pool_size_3"": 2, ""cnn_pool_type_0"": ""max"", ""cnn_pool_type_1"": ""avg"", ""cnn_pool_type_2"": ""max"", ""cnn_pool_type_3"": ""max"", ""cnn_pooling_mode"": ""global_max"", ""learning_rate"": 0.006230870707636893, ""optimizer"": ""adam"", ""sgd_momentum"": 0.5544059636427886}"
+2026-04-23 19:23:02.206079,RNN,5.479251792891225,0.04785379767417908,100,C:\Users\rafae\Projects\CSTRChemIA\.claude\worktrees\elegant-tereshkova-93f1dc\CSTRChemIA\surrogate_model\CSTR_RNN_Surrogate.keras,"{""learning_rate"": 0.026060600450952237, ""optimizer"": ""adam"", ""rnn_bn_0"": false, ""rnn_bn_1"": false, ""rnn_bn_2"": false, ""rnn_bn_3"": true, ""rnn_dense_activation_0"": ""relu"", ""rnn_dense_activation_1"": ""elu"", ""rnn_dense_bn_0"": false, ""rnn_dense_bn_1"": true, ""rnn_dense_dropout_0"": 0.0, ""rnn_dense_dropout_1"": 0.1, ""rnn_dense_l2_reg_0"": 0.0, ""rnn_dense_l2_reg_1"": 1e-05, ""rnn_dense_units_0"": 32, ""rnn_dense_units_1"": 160, ""rnn_dropout_0"": 0.05, ""rnn_dropout_1"": 0.0, ""rnn_dropout_2"": 0.0, ""rnn_dropout_3"": 0.1, ""rnn_l2_reg_0"": 0.0, ""rnn_l2_reg_1"": 0.0, ""rnn_l2_reg_2"": 0.0, ""rnn_l2_reg_3"": 0.0001, ""rnn_l2_reg_out"": 1e-05, ""rnn_num_dense"": 1, ""rnn_num_layers"": 3, ""rnn_recurrent_dropout_0"": 0.05, ""rnn_recurrent_dropout_1"": 0.0, ""rnn_recurrent_dropout_2"": 0.0, ""rnn_recurrent_dropout_3"": 0.05, ""rnn_type_0"": ""lstm"", ""rnn_type_1"": ""lstm"", ""rnn_type_2"": ""lstm"", ""rnn_type_3"": ""gru"", ""rnn_units_0"": 80, ""rnn_units_1"": 16, ""rnn_units_2"": 16, ""rnn_units_3"": 128, ""sgd_momentum"": 0.5299378063937362}"
diff --git a/CSTRChemIA/tests/__init__.py b/CSTRChemIA/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/CSTRChemIA/tests/conftest.py b/CSTRChemIA/tests/conftest.py
new file mode 100644
index 0000000..5d705f9
--- /dev/null
+++ b/CSTRChemIA/tests/conftest.py
@@ -0,0 +1,14 @@
+"""
+Configuração do pytest — garante que `import core.*` funcione ao rodar
+do diretório raiz do projeto.
+"""
+from __future__ import annotations
+
+import os
+import sys
+
+# Adiciona CSTRChemIA/ ao sys.path independentemente de onde pytest é invocado.
+_THIS = os.path.dirname(os.path.abspath(__file__))
+_ROOT = os.path.dirname(_THIS)
+if _ROOT not in sys.path:
+ sys.path.insert(0, _ROOT)
diff --git a/CSTRChemIA/tests/test_data_validation.py b/CSTRChemIA/tests/test_data_validation.py
new file mode 100644
index 0000000..530f10a
--- /dev/null
+++ b/CSTRChemIA/tests/test_data_validation.py
@@ -0,0 +1,109 @@
+"""Testes do core.data_validation."""
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+from core.constants import DEFAULT_FEATURES, N_FEATURES
+from core.data_validation import (
+ FLAG_COLUMNS,
+ TRAINING_FEATURE_BOUNDS,
+ clean_dataset,
+ validate_features,
+ validate_targets,
+)
+from core.exceptions import DataValidationError
+
+
+def _good_X(n: int = 10) -> np.ndarray:
+ """Matriz n×12 fisicamente plausível e dentro dos bounds."""
+ # Valores médios dos bounds — sempre ok.
+ mid = np.array([(lo + hi) / 2.0 for lo, hi in
+ (TRAINING_FEATURE_BOUNDS[f] for f in DEFAULT_FEATURES)])
+ X = np.tile(mid, (n, 1))
+ # Zera flags (0.0 é válido dentro do bound (0, 1))
+ for f in FLAG_COLUMNS:
+ X[:, DEFAULT_FEATURES.index(f)] = 0.0
+ return X
+
+
+def test_validate_features_clean_data_is_valid() -> None:
+ X = _good_X(50)
+ rep = validate_features(X, bounds=TRAINING_FEATURE_BOUNDS)
+ assert rep.is_valid
+ assert rep.is_clean
+ assert rep.n_nan == 0
+ assert rep.n_inf == 0
+ assert rep.n_bound_violations == 0
+
+
+def test_validate_features_detects_nan_and_inf_rows() -> None:
+ X = _good_X(10)
+ X[3, 0] = np.nan
+ X[7, 5] = np.inf
+ rep = validate_features(X)
+ assert set(rep.nan_rows.tolist()) == {3}
+ assert set(rep.inf_rows.tolist()) == {7}
+ assert not rep.is_valid
+
+
+def test_validate_features_flag_out_of_01_reported() -> None:
+ X = _good_X(5)
+ flag_idx = DEFAULT_FEATURES.index("flag_manutencao")
+ X[2, flag_idx] = 0.5 # nem 0 nem 1
+ rep = validate_features(X)
+ assert "flag_manutencao" in rep.flag_violations
+ assert 2 in rep.flag_violations["flag_manutencao"].tolist()
+ assert not rep.is_valid
+
+
+def test_strict_mode_raises_on_nan() -> None:
+ X = _good_X(3)
+ X[0, 0] = np.nan
+ with pytest.raises(DataValidationError):
+ validate_features(X, strict=True)
+
+
+def test_bound_violations_are_soft() -> None:
+ X = _good_X(5)
+ # Empurra valve_pos para 2.0 (fora do bound max=1.0)
+ X[1, DEFAULT_FEATURES.index("valve_pos")] = 2.0
+ rep = validate_features(X, bounds=TRAINING_FEATURE_BOUNDS)
+ assert "valve_pos" in rep.bound_violations
+ # is_valid continua True — bounds são soft warnings
+ assert rep.is_valid
+ assert not rep.is_clean
+
+
+def test_clean_dataset_drops_invalid_and_preserves_alignment() -> None:
+ X = _good_X(10)
+ y = np.ones((10, 8))
+ X[2, 0] = np.nan # drop
+ y[7, 3] = np.inf # drop
+ X_clean, y_clean, mask, reports = clean_dataset(X, y)
+ assert mask.sum() == 8
+ assert X_clean.shape == (8, N_FEATURES)
+ assert y_clean.shape == (8, 8)
+ # Linhas 2 e 7 devem estar fora; as demais mantidas
+ assert not mask[2] and not mask[7]
+ assert "features" in reports and "targets" in reports
+
+
+def test_clean_dataset_raises_on_shape_mismatch() -> None:
+ X = _good_X(5)
+ y = np.ones((4, 8)) # shape divergente de propósito
+ with pytest.raises(DataValidationError, match="linhas diferente"):
+ clean_dataset(X, y)
+
+
+def test_validate_targets_detects_nan() -> None:
+ y = np.ones((5, 8))
+ y[3, 2] = np.nan
+ rep = validate_targets(y)
+ assert 3 in rep.nan_rows.tolist()
+ assert not rep.is_valid
+
+
+def test_wrong_feature_count_raises() -> None:
+ with pytest.raises(DataValidationError):
+ validate_features(np.zeros((3, 7)))
diff --git a/CSTRChemIA/tests/test_decision_methods.py b/CSTRChemIA/tests/test_decision_methods.py
new file mode 100644
index 0000000..96c25b5
--- /dev/null
+++ b/CSTRChemIA/tests/test_decision_methods.py
@@ -0,0 +1,96 @@
+"""Testes dos métodos de decisão MCDM."""
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+from optimization.decision_methods import (
+ consensus_ranking,
+ knee_point,
+ promethee_ii,
+ topsis,
+ vikor,
+)
+
+
+def _simple_front() -> np.ndarray:
+ """
+ Frente de Pareto 2D com 5 pontos. Coluna 0 = maximizar (X),
+ Coluna 1 = minimizar (fouling). Ponto 2 é um "joelho" evidente.
+ """
+ return np.array([
+ [0.10, 0.001], # baixa X, baixíssimo fouling
+ [0.30, 0.005], # moderado
+ [0.45, 0.008], # joelho (bom compromisso)
+ [0.55, 0.018], # mais X, mais fouling
+ [0.60, 0.024], # máxima X, pior fouling
+ ])
+
+
+def test_topsis_picks_intermediate_on_balanced_weights() -> None:
+ F = _simple_front()
+ scores, idx = topsis(F, minimize=[False, True], weights=[0.5, 0.5])
+ assert scores.shape == (5,)
+ assert 0 <= idx < 5
+ # TOPSIS com pesos iguais deve rejeitar extremos (0 e 4).
+ assert idx not in (0, 4)
+
+
+def test_topsis_weights_shift_choice_toward_best_criterion() -> None:
+ F = _simple_front()
+ _, idx_maxX = topsis(F, minimize=[False, True], weights=[0.95, 0.05])
+ _, idx_minF = topsis(F, minimize=[False, True], weights=[0.05, 0.95])
+ assert idx_maxX == 4 # quase só maximiza X → último
+ assert idx_minF == 0 # quase só minimiza fouling → primeiro
+
+
+def test_vikor_returns_valid_index() -> None:
+ F = _simple_front()
+ scores, idx = vikor(F, minimize=[False, True])
+ assert 0 <= idx < 5
+ # VIKOR deve concordar com TOPSIS no extremo: peso quase só em X → último
+ _, idx_maxX = vikor(F, minimize=[False, True], weights=[0.95, 0.05])
+ assert idx_maxX == 4
+
+
+def test_promethee_ii_phi_net_in_range() -> None:
+ F = _simple_front()
+ phi, idx = promethee_ii(F, minimize=[False, True])
+ assert ((phi >= -1 - 1e-9) & (phi <= 1 + 1e-9)).all()
+ assert 0 <= idx < 5
+
+
+def test_knee_point_picks_interior() -> None:
+ F = _simple_front()
+ idx = knee_point(F, minimize=[False, True])
+ # Na frente com joelho em 2, o detector deve preferir um ponto intermediário.
+ assert idx not in (0, 4)
+
+
+def test_consensus_ranking_tallies_votes() -> None:
+ s1 = np.array([1.0, 2.0, 3.0, 0.5])
+ s2 = np.array([0.5, 2.5, 3.0, 1.5])
+ s3 = np.array([0.0, 1.0, 4.0, 2.0])
+ r = consensus_ranking([s1, s2, s3], top_k=1)
+ # Todos os 3 métodos escolhem o índice 2 como top-1 → consenso óbvio.
+ assert r.consensus_idx == 2
+ assert r.top_k_counts[2] == 3
+
+
+def test_consensus_ranking_breaks_ties_by_aggregate() -> None:
+ # s1 e s2 empatam no top-2 com índices {0, 1}; desempate pela soma
+ # normalizada favorece o índice 1.
+ s1 = np.array([1.0, 1.0, 0.0])
+ s2 = np.array([0.5, 1.0, 0.0])
+ r = consensus_ranking([s1, s2], top_k=2)
+ assert r.consensus_idx in (0, 1)
+
+
+def test_invalid_inputs_raise() -> None:
+ F = _simple_front()
+ with pytest.raises(ValueError):
+ topsis(F, minimize=[False]) # mismatch
+ with pytest.raises(ValueError):
+ topsis(F, minimize=[False, True], weights=[1.0, -0.1]) # neg
+ with pytest.raises(ValueError):
+ vikor(F, minimize=[False, True], v=1.5) # v fora [0,1]
diff --git a/CSTRChemIA/tests/test_feature_engineering.py b/CSTRChemIA/tests/test_feature_engineering.py
new file mode 100644
index 0000000..2e73d83
--- /dev/null
+++ b/CSTRChemIA/tests/test_feature_engineering.py
@@ -0,0 +1,99 @@
+"""Testes do core.feature_engineering."""
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+from core.constants import DEFAULT_FEATURES, N_FEATURES
+from core.exceptions import DataValidationError
+from core.feature_engineering import (
+ ALL_DERIVED_NAMES,
+ DERIVATIONS,
+ describe_derivations,
+ engineer_features,
+)
+
+
+def _sample_row() -> np.ndarray:
+ """Linha 1×12 com valores realistas e não-triviais."""
+ values = {
+ "vazao_feed": 1e-3,
+ "CA0_feed": 1200.0,
+ "CB0_feed": 900.0,
+ "Tf_feed": 350.0,
+ "T_amb_feed": 295.0,
+ "T_obs": 370.0,
+ "valve_pos": 0.8,
+ "w_j": 1.5,
+ "flag_falha_valvula": 0.0,
+ "flag_manutencao": 1.0,
+ "flag_falha_sensor_T": 0.0,
+ "flag_falha_comunicacao": 1.0,
+ }
+ return np.array([[values[f] for f in DEFAULT_FEATURES]], dtype=float)
+
+
+def test_engineer_features_output_shape_default() -> None:
+ X = _sample_row()
+ X_out, names = engineer_features(X)
+ assert X_out.shape == (1, N_FEATURES + len(ALL_DERIVED_NAMES))
+ assert names[:N_FEATURES] == list(DEFAULT_FEATURES)
+ assert names[N_FEATURES:] == list(ALL_DERIVED_NAMES)
+
+
+def test_derivation_values_are_correct() -> None:
+ X = _sample_row()
+ X_out, names = engineer_features(X)
+ row = X_out[0]
+ col = {n: row[i] for i, n in enumerate(names)}
+ assert col["dT_obs_feed"] == pytest.approx(370.0 - 350.0)
+ assert col["dT_feed_amb"] == pytest.approx(350.0 - 295.0)
+ assert col["dT_obs_amb"] == pytest.approx(370.0 - 295.0)
+ assert col["CA_over_CB"] == pytest.approx(1200.0 / 900.0)
+ assert col["C_total_feed"] == pytest.approx(2100.0)
+ assert col["valve_flow"] == pytest.approx(0.8 * 1e-3)
+ assert col["jacket_ratio"] == pytest.approx(1.5 / 1e-3)
+ assert col["failure_any"] == 1.0 # manutenção + comunicacao ativas
+ assert col["failure_count"] == 2.0
+ assert col["log_vazao"] == pytest.approx(np.log10(1e-3))
+ assert col["log_wj"] == pytest.approx(np.log10(1.5))
+
+
+def test_safe_div_returns_zero_when_denominator_zero() -> None:
+ X = _sample_row()
+ # Zera CB0_feed — CA_over_CB deveria ser 0 (safe div), não Inf/NaN.
+ X[0, DEFAULT_FEATURES.index("CB0_feed")] = 0.0
+ X_out, names = engineer_features(X)
+ idx = names.index("CA_over_CB")
+ assert np.isfinite(X_out[0, idx])
+ assert X_out[0, idx] == 0.0
+
+
+def test_log_clip_avoids_minus_inf() -> None:
+ X = _sample_row()
+ X[0, DEFAULT_FEATURES.index("w_j")] = 0.0
+ X_out, names = engineer_features(X)
+ idx = names.index("log_wj")
+ assert np.isfinite(X_out[0, idx])
+
+
+def test_include_filter_honors_order_and_raises_unknown() -> None:
+ X = _sample_row()
+ X_out, names = engineer_features(
+ X, include=["valve_flow", "dT_obs_feed"], keep_original=False,
+ )
+ assert X_out.shape == (1, 2)
+ assert names == ["valve_flow", "dT_obs_feed"]
+
+ with pytest.raises(DataValidationError, match="Derivadas desconhecidas"):
+ engineer_features(X, include=["nope"])
+
+
+def test_wrong_shape_raises() -> None:
+ with pytest.raises(DataValidationError):
+ engineer_features(np.zeros((3, 5)))
+
+
+def test_describe_derivations_covers_all() -> None:
+ descs = describe_derivations()
+ assert {d["name"] for d in descs} == set(DERIVATIONS.keys())
diff --git a/CSTRChemIA/tests/test_model_manifest.py b/CSTRChemIA/tests/test_model_manifest.py
new file mode 100644
index 0000000..aee736a
--- /dev/null
+++ b/CSTRChemIA/tests/test_model_manifest.py
@@ -0,0 +1,106 @@
+"""Testes do core.model_manifest."""
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from core.model_manifest import (
+ MANIFEST_FILENAME,
+ ModelManifest,
+ build_manifest,
+ read_manifest,
+ update_manifest_entry,
+ write_manifest,
+)
+
+
+def _fake_file(path: Path, contents: bytes = b"fake-keras-bytes") -> None:
+ path.write_bytes(contents)
+
+
+def _build_mlp_manifest(tmp_path: Path) -> ModelManifest:
+ _fake_file(tmp_path / "CSTR_MLP_Surrogate.keras", b"model-bytes")
+ _fake_file(tmp_path / "scalerX.json", b"scalerX-bytes")
+ _fake_file(tmp_path / "scalerY.json", b"scalerY-bytes")
+ return build_manifest(
+ architecture="MLP",
+ models_dir=tmp_path,
+ keras_filename="CSTR_MLP_Surrogate.keras",
+ scaler_x_filename="scalerX.json",
+ scaler_y_filename="scalerY.json",
+ seed=123,
+ feature_names=tuple(f"f{i}" for i in range(12)),
+ target_names=tuple(f"t{i}" for i in range(8)),
+ global_metrics={"mae": 0.01, "rmse": 0.02, "r2": 0.98},
+ per_target_metrics={
+ f"t{i}": {"mae": 0.01 + 0.001*i, "rmse": 0.02, "r2": 0.97}
+ for i in range(8)
+ },
+ framework="tensorflow==2.16.1",
+ )
+
+
+def test_build_manifest_computes_sha256(tmp_path: Path) -> None:
+ m = _build_mlp_manifest(tmp_path)
+ assert len(m.keras_sha256) == 64
+ assert len(m.scaler_x_sha256) == 64
+ assert len(m.scaler_y_sha256) == 64
+ assert m.seed == 123
+ assert m.n_features == 12
+ assert m.n_targets == 8
+
+
+def test_write_and_read_roundtrip(tmp_path: Path) -> None:
+ m = _build_mlp_manifest(tmp_path)
+ path = write_manifest(tmp_path, {"MLP": m})
+ assert (tmp_path / MANIFEST_FILENAME) == path
+ loaded = read_manifest(tmp_path)
+ assert "MLP" in loaded
+ assert loaded["MLP"].seed == 123
+ assert loaded["MLP"].keras_sha256 == m.keras_sha256
+ # per_target_metrics preservado
+ assert loaded["MLP"].per_target_metrics["t0"]["mae"] == pytest.approx(0.01)
+
+
+def test_update_manifest_entry_merges_architectures(tmp_path: Path) -> None:
+ mlp = _build_mlp_manifest(tmp_path)
+ update_manifest_entry(tmp_path, mlp)
+
+ # Novo manifesto para RNN
+ _fake_file(tmp_path / "CSTR_RNN_Surrogate.keras", b"rnn-bytes")
+ rnn = build_manifest(
+ architecture="RNN",
+ models_dir=tmp_path,
+ keras_filename="CSTR_RNN_Surrogate.keras",
+ scaler_x_filename="scalerX.json",
+ scaler_y_filename="scalerY.json",
+ seed=7,
+ feature_names=tuple(f"f{i}" for i in range(12)),
+ target_names=tuple(f"t{i}" for i in range(8)),
+ global_metrics={"mae": 0.02},
+ per_target_metrics={},
+ )
+ update_manifest_entry(tmp_path, rnn)
+
+ loaded = read_manifest(tmp_path)
+ assert set(loaded.keys()) == {"MLP", "RNN"}
+
+
+def test_verify_integrity_detects_corruption(tmp_path: Path) -> None:
+ m = _build_mlp_manifest(tmp_path)
+ errors = m.verify_integrity(tmp_path)
+ assert errors == []
+ # Corrompe
+ (tmp_path / "CSTR_MLP_Surrogate.keras").write_bytes(b"tampered")
+ errors = m.verify_integrity(tmp_path)
+ assert any("hash inconsistente" in e for e in errors)
+
+
+def test_read_manifest_returns_empty_when_absent(tmp_path: Path) -> None:
+ assert read_manifest(tmp_path) == {}
+
+
+def test_read_manifest_tolerates_malformed_file(tmp_path: Path) -> None:
+ (tmp_path / MANIFEST_FILENAME).write_text("{not-json")
+ assert read_manifest(tmp_path) == {}
diff --git a/CSTRChemIA/tests/test_safe_serialization.py b/CSTRChemIA/tests/test_safe_serialization.py
new file mode 100644
index 0000000..fba9cb9
--- /dev/null
+++ b/CSTRChemIA/tests/test_safe_serialization.py
@@ -0,0 +1,103 @@
+"""Testes do core.safe_serialization — round-trip e integridade SHA."""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import numpy as np
+import pytest
+from sklearn.preprocessing import MinMaxScaler, RobustScaler, StandardScaler
+
+from core.safe_serialization import (
+ dump_scaler_json,
+ file_sha256,
+ load_scaler_any,
+ load_scaler_json,
+)
+
+
+def _fit_standard_scaler(seed: int = 0) -> StandardScaler:
+ rng = np.random.default_rng(seed)
+ X = rng.normal(size=(200, 12))
+ sc = StandardScaler()
+ sc.fit(X)
+ return sc
+
+
+def test_roundtrip_standard_scaler_preserves_transform(tmp_path: Path) -> None:
+ sc = _fit_standard_scaler()
+ path = tmp_path / "scalerX.json"
+ dump_scaler_json(sc, path, feature_names=[f"f{i}" for i in range(12)])
+
+ loaded = load_scaler_json(path)
+ rng = np.random.default_rng(1)
+ X_new = rng.normal(size=(5, 12))
+ np.testing.assert_allclose(
+ sc.transform(X_new), loaded.transform(X_new),
+ rtol=1e-12, atol=1e-12,
+ )
+
+
+def test_hash_mismatch_raises(tmp_path: Path) -> None:
+ sc = _fit_standard_scaler()
+ path = tmp_path / "scalerX.json"
+ dump_scaler_json(sc, path)
+
+ # Corrompe um dos valores — hash deve falhar
+ with open(path, "r", encoding="utf-8") as f:
+ doc = json.load(f)
+ doc["params"]["mean_"][0] += 0.1
+ with open(path, "w", encoding="utf-8") as f:
+ json.dump(doc, f)
+
+ with pytest.raises(ValueError, match="Hash mismatch"):
+ load_scaler_json(path)
+
+
+def test_verify_hash_false_skips_check(tmp_path: Path) -> None:
+ sc = _fit_standard_scaler()
+ path = tmp_path / "scalerX.json"
+ dump_scaler_json(sc, path)
+ with open(path, "r", encoding="utf-8") as f:
+ doc = json.load(f)
+ doc["params"]["mean_"][0] += 0.5
+ with open(path, "w", encoding="utf-8") as f:
+ json.dump(doc, f)
+ # Com verify_hash=False, carrega sem erro (mas com params corrompidos).
+ loaded = load_scaler_json(path, verify_hash=False)
+ assert loaded is not None
+
+
+@pytest.mark.parametrize("cls", [StandardScaler, RobustScaler, MinMaxScaler])
+def test_supports_multiple_scaler_classes(tmp_path: Path, cls) -> None:
+ rng = np.random.default_rng(0)
+ X = rng.normal(size=(100, 5))
+ sc = cls().fit(X)
+ path = tmp_path / "sc.json"
+ dump_scaler_json(sc, path)
+ loaded = load_scaler_json(path)
+ np.testing.assert_allclose(
+ sc.transform(X), loaded.transform(X), rtol=1e-10, atol=1e-10,
+ )
+
+
+def test_load_scaler_any_prefers_json(tmp_path: Path) -> None:
+ import pickle
+ sc = _fit_standard_scaler()
+ dump_scaler_json(sc, tmp_path / "scalerX.json")
+ # também escreve um .pkl "diferente" para provar que .json tem precedência
+ other = _fit_standard_scaler(seed=99)
+ with open(tmp_path / "scalerX.pkl", "wb") as f:
+ pickle.dump(other, f)
+
+ loaded = load_scaler_any(tmp_path / "scalerX")
+ np.testing.assert_allclose(loaded.mean_, sc.mean_)
+
+
+def test_file_sha256_deterministic(tmp_path: Path) -> None:
+ p = tmp_path / "blob.bin"
+ p.write_bytes(b"abc123" * 1000)
+ h1 = file_sha256(p)
+ h2 = file_sha256(p)
+ assert h1 == h2
+ assert len(h1) == 64 # 256 bits em hex
diff --git a/CSTRChemIA/tests/test_uncertainty.py b/CSTRChemIA/tests/test_uncertainty.py
new file mode 100644
index 0000000..7e77f7a
--- /dev/null
+++ b/CSTRChemIA/tests/test_uncertainty.py
@@ -0,0 +1,101 @@
+"""Testes do core.uncertainty — conformal + ensemble (sem TF)."""
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+from core.exceptions import DataValidationError
+from core.uncertainty import (
+ SplitConformalCalibrator,
+ ensemble_predict,
+ predict_with_interval,
+)
+
+
+def test_conformal_empirical_coverage_matches_alpha() -> None:
+ """Se os resíduos são iid, a cobertura empírica deve ser ≳ 1-α."""
+ rng = np.random.default_rng(0)
+ # Gera 2000 pares (y_true, y_pred) com resíduos Normal(0, 1).
+ n_cal, n_test, k = 2000, 2000, 3
+ y_cal_true = rng.normal(size=(n_cal, k))
+ y_cal_pred = y_cal_true + rng.normal(size=(n_cal, k))
+ y_test_true = rng.normal(size=(n_test, k))
+ y_test_pred = y_test_true + rng.normal(size=(n_test, k))
+
+ alpha = 0.1
+ cal = SplitConformalCalibrator(alpha=alpha).fit(y_cal_true, y_cal_pred)
+ cov = cal.coverage(y_test_true, y_test_pred)
+ # Garantia teórica: cobertura ≥ 1-α em média (margem pequena para
+ # amostra finita).
+ assert (cov >= 1 - alpha - 0.03).all(), (
+ f"Cobertura empírica {cov} abaixo de {1-alpha:.2f}"
+ )
+
+
+def test_conformal_interval_shape_and_widths() -> None:
+ cal = SplitConformalCalibrator(alpha=0.1)
+ y_true = np.random.default_rng(0).normal(size=(500, 4))
+ y_pred = y_true + np.random.default_rng(1).normal(scale=0.5, size=(500, 4))
+ cal.fit(y_true, y_pred)
+
+ y_new = np.zeros((3, 4))
+ lo, hi = cal.interval(y_new)
+ assert lo.shape == hi.shape == (3, 4)
+ assert (hi - lo >= 0).all()
+
+
+def test_conformal_raises_when_not_fit() -> None:
+ cal = SplitConformalCalibrator()
+ with pytest.raises(RuntimeError):
+ cal.interval(np.zeros((1, 3)))
+
+
+def test_conformal_shape_mismatch_raises() -> None:
+ cal = SplitConformalCalibrator().fit(
+ np.zeros((10, 3)), np.zeros((10, 3)),
+ )
+ with pytest.raises(DataValidationError):
+ cal.interval(np.zeros((5, 4)))
+
+
+def test_ensemble_predict_mean_and_std() -> None:
+ # 3 membros sintéticos: constantes diferentes
+ def make(c: float):
+ return lambda X: np.full((X.shape[0], 2), c)
+
+ fns = [make(1.0), make(2.0), make(3.0)]
+ X = np.zeros((4, 5))
+ mean, std = ensemble_predict(fns, X)
+ np.testing.assert_allclose(mean, 2.0)
+ # std amostral de [1, 2, 3] com ddof=1 = 1.0
+ np.testing.assert_allclose(std, 1.0, rtol=1e-12)
+
+
+def test_ensemble_requires_at_least_two() -> None:
+ with pytest.raises(ValueError):
+ ensemble_predict([lambda X: X], np.zeros((1, 1)))
+
+
+def test_predict_with_interval_single_member_no_std() -> None:
+ fn = lambda X: X.sum(axis=1, keepdims=True) * np.ones((X.shape[0], 2))
+ X = np.arange(6).reshape(3, 2).astype(float)
+ r = predict_with_interval([fn], X)
+ assert r.std is None
+ assert r.lo is None and r.hi is None
+ assert r.mean.shape == (3, 2)
+
+
+def test_predict_with_interval_with_conformal() -> None:
+ rng = np.random.default_rng(0)
+ y_cal_true = rng.normal(size=(300, 2))
+ y_cal_pred = y_cal_true + rng.normal(scale=0.3, size=(300, 2))
+ cal = SplitConformalCalibrator(alpha=0.1).fit(y_cal_true, y_cal_pred)
+
+ def fn(X):
+ return np.zeros((X.shape[0], 2))
+
+ X = np.zeros((4, 5))
+ r = predict_with_interval([fn, fn], X, conformal=cal)
+ assert r.alpha == pytest.approx(0.1)
+ assert r.lo is not None and r.hi is not None
+ assert (r.hi >= r.lo).all()
diff --git a/CSTRChemIA/training/models.py b/CSTRChemIA/training/models.py
index 5b43746..6325cff 100644
--- a/CSTRChemIA/training/models.py
+++ b/CSTRChemIA/training/models.py
@@ -86,7 +86,9 @@ def _print_model_details(architecture: str, hp: HyperParameters, model: models.S
config = layer.get_config()
layer_name = layer.__class__.__name__
if layer_name == 'InputLayer':
- print(f" [{i}] Input: shape={config['batch_input_shape'][1:]}")
+ # Keras 3 / TF 2.16+ usa 'batch_shape'; versões anteriores usam 'batch_input_shape'
+ shape = config.get('batch_shape') or config.get('batch_input_shape') or [None]
+ print(f" [{i}] Input: shape={tuple(shape[1:])}")
elif layer_name in ['Dense', 'Conv1D', 'LSTM', 'GRU', 'SimpleRNN']:
units = config.get('units', config.get('filters', '?'))
activation = config.get('activation', 'linear')
diff --git a/CSTRChemIA/training/train_surrogate.py b/CSTRChemIA/training/train_surrogate.py
index 37e0988..d3df83a 100644
--- a/CSTRChemIA/training/train_surrogate.py
+++ b/CSTRChemIA/training/train_surrogate.py
@@ -42,6 +42,15 @@
from training.models import build_mlp, build_rnn, build_cnn
+# --- Infraestrutura state-of-the-art (módulos novos em core/) ---
+from core.data_validation import (
+ TRAINING_FEATURE_BOUNDS,
+ TRAINING_TARGET_BOUNDS,
+ clean_dataset,
+)
+from core.model_manifest import build_manifest, update_manifest_entry
+from core.safe_serialization import dump_scaler_json
+
if hasattr(sys.stdout, 'reconfigure'):
try:
sys.stdout.reconfigure(encoding='utf-8', line_buffering=True)
@@ -85,13 +94,57 @@ def set_random_seeds(seed: int = RANDOM_SEED) -> None:
class PrintEpochCallback(callbacks.Callback):
def on_epoch_end(self, epoch: int, logs: Dict[str, float] = None) -> None:
logs = logs or {}
+ lr = logs.get('lr', logs.get('learning_rate', None))
+ lr_str = f", lr={lr:.2e}" if lr is not None else ""
msg = (f"Epoch {epoch+1:3d}: loss={logs.get('loss', 0):.6f}, "
f"mae={logs.get('mae', 0):.6f}, "
f"val_loss={logs.get('val_loss', 0):.6f}, "
- f"val_mae={logs.get('val_mae', 0):.6f}")
+ f"val_mae={logs.get('val_mae', 0):.6f}"
+ f"{lr_str}")
print(msg, flush=True)
+# ==============================
+# MÉTRICAS POR TARGET
+# ==============================
+def compute_per_target_metrics(
+ y_true: np.ndarray,
+ y_pred: np.ndarray,
+ target_names: List[str],
+) -> Dict[str, Dict[str, float]]:
+ """
+ Calcula MAE/RMSE/R² para cada target individualmente.
+
+ Retorna ``{target_name: {"mae": ..., "rmse": ..., "r2": ...}}``.
+ Útil para detectar um target "escondido" com erro catastrófico sob
+ um MAE global enganosamente bom.
+ """
+ out: Dict[str, Dict[str, float]] = {}
+ for j, name in enumerate(target_names):
+ yt = y_true[:, j]
+ yp = y_pred[:, j]
+ mae = float(np.mean(np.abs(yt - yp)))
+ rmse = float(np.sqrt(np.mean((yt - yp) ** 2)))
+ ss_res = float(np.sum((yt - yp) ** 2))
+ ss_tot = float(np.sum((yt - yt.mean()) ** 2))
+ r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else float("nan")
+ out[name] = {"mae": mae, "rmse": rmse, "r2": r2}
+ return out
+
+
+def compute_global_metrics(
+ y_true: np.ndarray,
+ y_pred: np.ndarray,
+) -> Dict[str, float]:
+ """MAE/RMSE/R² globais (média sobre todos os targets)."""
+ mae = float(np.mean(np.abs(y_true - y_pred)))
+ rmse = float(np.sqrt(np.mean((y_true - y_pred) ** 2)))
+ ss_res = float(np.sum((y_true - y_pred) ** 2))
+ ss_tot = float(np.sum((y_true - y_true.mean(axis=0)) ** 2))
+ r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else float("nan")
+ return {"mae": mae, "rmse": rmse, "r2": r2}
+
+
# ==============================
# DESCOBERTA DE FONTES DE DADOS
# ==============================
@@ -303,6 +356,28 @@ def train_model(architecture: str,
early_stop = callbacks.EarlyStopping(
monitor='val_loss', patience=20, restore_best_weights=True
)
+ # ReduceLROnPlateau: corta LR quando val_loss platô — ajuda a escapar
+ # de regiões achatadas do loss. patience=10 < early_stop.patience=20
+ # garante que o LR cai antes do early stop disparar.
+ reduce_lr = callbacks.ReduceLROnPlateau(
+ monitor='val_loss', factor=0.5, patience=10, min_lr=1e-6, verbose=0,
+ )
+ # ModelCheckpoint: salva o melhor peso em disco a cada melhora — rede
+ # de segurança se o processo morrer antes do final. O arquivo .keras
+ # temporário é removido no final do pipeline (ou sobrescrito pelo save).
+ ckpt_path = config.get(
+ 'checkpoint_path',
+ os.path.join(os.path.dirname(os.path.abspath(__file__)),
+ '..', 'surrogate_model',
+ f'CSTR_{architecture}_Surrogate.ckpt.keras'),
+ )
+ ckpt = callbacks.ModelCheckpoint(
+ filepath=ckpt_path,
+ monitor='val_loss',
+ save_best_only=True,
+ save_weights_only=False,
+ verbose=0,
+ )
print(f"🚀 Treinando {architecture}...", flush=True)
history = model.fit(
@@ -310,9 +385,16 @@ def train_model(architecture: str,
validation_data=(X_val_arch, y_val_scaled),
epochs=config['epochs'],
batch_size=config['batch_size'],
- callbacks=[early_stop, PrintEpochCallback()],
+ callbacks=[early_stop, reduce_lr, ckpt, PrintEpochCallback()],
verbose=0
)
+ # Limpa o checkpoint intermediário — o modelo final é salvo pelo main()
+ # em ``CSTR__Surrogate.keras`` (sem o sufixo .ckpt).
+ try:
+ if os.path.exists(ckpt_path):
+ os.remove(ckpt_path)
+ except OSError:
+ pass
return model, history, is_3d
@@ -356,6 +438,11 @@ def rank_and_print_results(output_dir: str) -> None:
# MAIN
# ==============================
def main() -> None:
+ # Declara global no TOPO para que toda referência subsequente em main()
+ # (incluindo parser.add_argument(default=RANDOM_SEED)) use o binding
+ # de módulo — e a reatribuição abaixo seja vista por prepare_data().
+ global RANDOM_SEED
+
parser = argparse.ArgumentParser(description="Treinamento de modelos substitutos CSTR realístico")
parser.add_argument(
'--model',
@@ -375,10 +462,18 @@ def main() -> None:
'--no-sync', action='store_true',
help="Não tenta sincronizar HPs do RedeNeural antes de treinar"
)
+ parser.add_argument(
+ '--seed', type=int, default=RANDOM_SEED,
+ help=f"Seed para Python/NumPy/TF (default: {RANDOM_SEED}). "
+ "Use seeds diferentes para treinar membros de um Deep Ensemble."
+ )
args = parser.parse_args()
config = {'epochs': args.epochs, 'batch_size': args.batch_size}
+ # Sobrescreve o seed efetivo (será propagado para prepare_data() e manifesto)
+ RANDOM_SEED = int(args.seed)
set_random_seeds(RANDOM_SEED)
+ print(f"[INFO] Seed efetivo: {RANDOM_SEED}", flush=True)
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
output_dir = os.path.join(base_dir, 'surrogate_model')
@@ -399,18 +494,49 @@ def main() -> None:
print(f"Features: {input_dim} | Targets: {output_dim}", flush=True)
- # Validacao de dados: rejeitar NaN/Inf antes de normalizar
- for name, arr in [('X_train', X_train), ('y_train', y_train),
- ('X_val', X_val), ('y_val', y_val),
- ('X_test', X_test), ('y_test', y_test)]:
- n_nan = np.sum(~np.isfinite(arr))
- if n_nan > 0:
- print(f"[WARN] {name}: {n_nan} valores NaN/Inf encontrados, "
- "substituindo pela mediana da coluna.", flush=True)
- col_medians = np.nanmedian(arr, axis=0)
- for j in range(arr.shape[1]):
- mask = ~np.isfinite(arr[:, j])
- arr[mask, j] = col_medians[j]
+ # Validação contratual: usa core.data_validation para produzir relatório
+ # estruturado (NaN/Inf/flags/bounds) e descartar linhas corrompidas —
+ # mais agressivo e honesto que a imputação por mediana (que propaga
+ # silenciosamente linhas ruins para o treino).
+ split_shapes_before = {
+ 'train': (X_train.shape[0], y_train.shape[0]),
+ 'val': (X_val.shape[0], y_val.shape[0]),
+ 'test': (X_test.shape[0], y_test.shape[0]),
+ }
+ (X_train, y_train, _, rep_tr) = clean_dataset(
+ X_train, y_train,
+ feature_bounds=TRAINING_FEATURE_BOUNDS,
+ target_bounds=TRAINING_TARGET_BOUNDS,
+ )
+ (X_val, y_val, _, rep_va) = clean_dataset(
+ X_val, y_val,
+ feature_bounds=TRAINING_FEATURE_BOUNDS,
+ target_bounds=TRAINING_TARGET_BOUNDS,
+ )
+ (X_test, y_test, _, rep_te) = clean_dataset(
+ X_test, y_test,
+ feature_bounds=TRAINING_FEATURE_BOUNDS,
+ target_bounds=TRAINING_TARGET_BOUNDS,
+ )
+ print("\n[INFO] Validação de dados (core.data_validation):")
+ for split, rep_pair in [('train', rep_tr), ('val', rep_va), ('test', rep_te)]:
+ n_before = split_shapes_before[split][0]
+ n_after = X_train.shape[0] if split == 'train' else (
+ X_val.shape[0] if split == 'val' else X_test.shape[0]
+ )
+ dropped = n_before - n_after
+ feat_rep = rep_pair['features']
+ tgt_rep = rep_pair.get('targets')
+ print(f" [{split}] {n_before} → {n_after} ({dropped} descartadas) | "
+ f"NaN_X={feat_rep.n_nan}, Inf_X={feat_rep.n_inf}, "
+ f"flags_inval={feat_rep.n_flag_violations}"
+ + (f", NaN_y={tgt_rep.n_nan}, Inf_y={tgt_rep.n_inf}"
+ if tgt_rep is not None else ""))
+ # Se depois da limpeza algum split ficou vazio, aborta — treino impossível.
+ if min(X_train.shape[0], X_val.shape[0], X_test.shape[0]) == 0:
+ raise RuntimeError(
+ "Algum split ficou vazio após limpeza. Verifique os CSVs de entrada."
+ )
# Normalizacao (scalers criados uma unica vez, usados por todas arquiteturas)
scaler_X = StandardScaler()
@@ -422,12 +548,21 @@ def main() -> None:
y_val_sc = scaler_y.transform(y_val)
y_test_sc = scaler_y.transform(y_test)
- # Salvar scalers uma unica vez (fora do loop de arquiteturas)
- with open(os.path.join(output_dir, 'scalerX.pkl'), 'wb') as f:
+ # Salvar scalers — formato novo (JSON + SHA-256) E legado (.pkl) para
+ # compatibilidade durante a migração. O KerasPredict ainda lê .pkl;
+ # ``core.safe_serialization.load_scaler_any`` já prefere .json quando
+ # disponível, e a próxima versão do predict vai usá-lo.
+ scaler_x_pkl = os.path.join(output_dir, 'scalerX.pkl')
+ scaler_y_pkl = os.path.join(output_dir, 'scalerY.pkl')
+ scaler_x_json = os.path.join(output_dir, 'scalerX.json')
+ scaler_y_json = os.path.join(output_dir, 'scalerY.json')
+ with open(scaler_x_pkl, 'wb') as f:
pickle.dump(scaler_X, f)
- with open(os.path.join(output_dir, 'scalerY.pkl'), 'wb') as f:
+ with open(scaler_y_pkl, 'wb') as f:
pickle.dump(scaler_y, f)
- print(f"Scalers salvos em {output_dir}", flush=True)
+ dump_scaler_json(scaler_X, scaler_x_json, feature_names=FEATURE_COLS)
+ dump_scaler_json(scaler_y, scaler_y_json, feature_names=TARGET_COLS)
+ print(f"Scalers salvos em {output_dir} (.pkl + .json)", flush=True)
meta = {
'feature_cols': FEATURE_COLS,
@@ -489,21 +624,54 @@ def main() -> None:
print(f"MAE {arch} (val, escala real): {mae_val:.6f}", flush=True)
print(f"MAE {arch} (test, escala real): {mae_test:.6f}", flush=True)
- # MAE por target (validation)
- print(f" --- MAE por target (val) ---", flush=True)
- for j, col in enumerate(TARGET_COLS):
- mae_j = float(np.mean(np.abs(pred_val_real[:, j] - y_val[:, j])))
- print(f" MAE [{col}]: {mae_j:.6f}", flush=True)
+ # --- Métricas por target (validation + test) ---
+ per_target_val = compute_per_target_metrics(y_val, pred_val_real, TARGET_COLS)
+ per_target_test = compute_per_target_metrics(y_test, pred_test_real, TARGET_COLS)
+ global_val = compute_global_metrics(y_val, pred_val_real)
+ global_test = compute_global_metrics(y_test, pred_test_real)
- # MAE por target (test)
- print(f" --- MAE por target (test) ---", flush=True)
- for j, col in enumerate(TARGET_COLS):
- mae_j = float(np.mean(np.abs(pred_test_real[:, j] - y_test[:, j])))
- print(f" MAE [{col}]: {mae_j:.6f}", flush=True)
+ print(f" --- Métricas por target (val) ---", flush=True)
+ for col, m in per_target_val.items():
+ print(f" {col:18s} MAE={m['mae']:.6f} RMSE={m['rmse']:.6f} R²={m['r2']:+.4f}",
+ flush=True)
+ print(f" --- Métricas por target (test) ---", flush=True)
+ for col, m in per_target_test.items():
+ print(f" {col:18s} MAE={m['mae']:.6f} RMSE={m['rmse']:.6f} R²={m['r2']:+.4f}",
+ flush=True)
+ print(f" Global (val): MAE={global_val['mae']:.6f} "
+ f"RMSE={global_val['rmse']:.6f} R²={global_val['r2']:+.4f}", flush=True)
+ print(f" Global (test): MAE={global_test['mae']:.6f} "
+ f"RMSE={global_test['rmse']:.6f} R²={global_test['r2']:+.4f}", flush=True)
save_training_result(output_dir, arch, mae_val, val_loss_scaled,
epochs_trained, model_path, best_hps)
+ # --- Manifesto rico (models.json) — sha256 + seed + per-target ---
+ # Usa métricas de TEST como canônicas (generalização real).
+ try:
+ manifest = build_manifest(
+ architecture=arch,
+ models_dir=output_dir,
+ keras_filename=model_filename,
+ scaler_x_filename='scalerX.json',
+ scaler_y_filename='scalerY.json',
+ seed=RANDOM_SEED,
+ feature_names=tuple(FEATURE_COLS),
+ target_names=tuple(TARGET_COLS),
+ global_metrics=global_test,
+ per_target_metrics=per_target_test,
+ n_train=int(X_train.shape[0]),
+ n_val=int(X_val.shape[0]),
+ n_test=int(X_test.shape[0]),
+ hyperparameters=best_hps,
+ notes=f"epochs_trained={epochs_trained}, mae_val={mae_val:.6f}",
+ )
+ update_manifest_entry(output_dir, manifest)
+ print(f"📜 Manifesto atualizado: models.json ({arch}, "
+ f"sha={manifest.keras_sha256[:12]}...)", flush=True)
+ except Exception as e: # noqa: BLE001
+ print(f"[WARN] Falha ao escrever manifesto para {arch}: {e}", flush=True)
+
rank_and_print_results(output_dir)
print("Treinamento finalizado com sucesso!", flush=True)
diff --git a/CSTRChemIA/training/train_xgboost_baseline.py b/CSTRChemIA/training/train_xgboost_baseline.py
new file mode 100644
index 0000000..bb6bcae
--- /dev/null
+++ b/CSTRChemIA/training/train_xgboost_baseline.py
@@ -0,0 +1,243 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+Baseline XGBoost honesto para o surrogate do CSTR.
+
+Por que um baseline?
+--------------------
+Uma rede neural só vale a pena quando bate *honestamente* um baseline
+tabular robusto. XGBoost (ou LightGBM) treinado em minutos sobre as
+**features engenheiradas** é o benchmark justo — se a MLP não bate,
+algo está errado (overfitting, dados insuficientes, hiperparâmetros).
+
+Este script:
+1. Carrega CSVs de treino via :func:`training.train_surrogate.prepare_data`.
+2. Valida com :mod:`core.data_validation` (mesmo pipeline do surrogate).
+3. Enriquece features com :func:`core.feature_engineering.engineer_features`
+ (12 → 23 colunas: 12 originais + 11 derivadas físicas).
+4. Treina **um modelo XGBoost por target** (multi-output via loop;
+ XGBoost nativamente não faz multi-output regressão, então usamos
+ ``sklearn.multioutput.MultiOutputRegressor``).
+5. Reporta MAE/RMSE/R² per-target e salva pesos em
+ ``surrogate_model/baselines/xgboost/``.
+
+Como rodar
+----------
+::
+
+ python -m training.train_xgboost_baseline --n-estimators 500 --seed 42
+
+Se ``xgboost`` não estiver instalado:
+::
+
+ pip install xgboost
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+
+from core.constants import DEFAULT_FEATURES, DEFAULT_TARGETS
+from core.data_validation import (
+ TRAINING_FEATURE_BOUNDS,
+ TRAINING_TARGET_BOUNDS,
+ clean_dataset,
+)
+from core.feature_engineering import ALL_DERIVED_NAMES, engineer_features
+from core.logging import get_logger, setup_logging
+from core.safe_serialization import file_sha256
+
+log = get_logger(__name__)
+
+
+def _require_xgboost() -> Any:
+ try:
+ import xgboost as xgb # type: ignore
+ return xgb
+ except ImportError as e: # pragma: no cover
+ raise SystemExit(
+ "xgboost não instalado. Rode: pip install xgboost"
+ ) from e
+
+
+def _print_header(title: str) -> None:
+ line = "=" * 72
+ print(f"\n{line}\n{title}\n{line}", flush=True)
+
+
+def _compute_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> dict[str, float]:
+ mae = float(np.mean(np.abs(y_true - y_pred)))
+ rmse = float(np.sqrt(np.mean((y_true - y_pred) ** 2)))
+ ss_res = float(np.sum((y_true - y_pred) ** 2))
+ ss_tot = float(np.sum((y_true - y_true.mean()) ** 2))
+ r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else float("nan")
+ return {"mae": mae, "rmse": rmse, "r2": r2}
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ description="Baseline XGBoost per-target para o CSTR surrogate"
+ )
+ parser.add_argument('--n-estimators', type=int, default=500,
+ help="Número de árvores por target (default: 500)")
+ parser.add_argument('--max-depth', type=int, default=6,
+ help="Profundidade máxima (default: 6)")
+ parser.add_argument('--learning-rate', type=float, default=0.05,
+ help="Taxa de aprendizado (default: 0.05)")
+ parser.add_argument('--seed', type=int, default=42,
+ help="Random seed (default: 42)")
+ parser.add_argument('--no-feature-engineering', action='store_true',
+ help="Usa somente as 12 features originais (sem derivadas)")
+ parser.add_argument('--output-dir', default=None,
+ help="Diretório de saída (default: surrogate_model/baselines/xgboost)")
+ args = parser.parse_args()
+
+ setup_logging()
+ xgb = _require_xgboost()
+
+ # Import tardio — evita pagar o custo de TF para o baseline
+ sys.path.append(os.path.abspath(os.path.dirname(os.path.dirname(__file__))))
+ from training.train_surrogate import prepare_data # noqa: E402
+
+ base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ output_dir = Path(args.output_dir) if args.output_dir else (
+ Path(base_dir) / "surrogate_model" / "baselines" / "xgboost"
+ )
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ # ── 1. Dados ────────────────────────────────────────────────────────────
+ _print_header("1. Carregando e validando dados")
+ X_train, X_val, X_test, y_train, y_val, y_test = prepare_data(base_dir)
+
+ X_train, y_train, _, _ = clean_dataset(
+ X_train, y_train,
+ feature_bounds=TRAINING_FEATURE_BOUNDS,
+ target_bounds=TRAINING_TARGET_BOUNDS,
+ )
+ X_val, y_val, _, _ = clean_dataset(
+ X_val, y_val,
+ feature_bounds=TRAINING_FEATURE_BOUNDS,
+ target_bounds=TRAINING_TARGET_BOUNDS,
+ )
+ X_test, y_test, _, _ = clean_dataset(
+ X_test, y_test,
+ feature_bounds=TRAINING_FEATURE_BOUNDS,
+ target_bounds=TRAINING_TARGET_BOUNDS,
+ )
+ print(f" train/val/test = {X_train.shape[0]}/{X_val.shape[0]}/{X_test.shape[0]}",
+ flush=True)
+
+ # ── 2. Feature engineering ─────────────────────────────────────────────
+ _print_header("2. Feature engineering")
+ if args.no_feature_engineering:
+ X_train_fe, feat_names = X_train, list(DEFAULT_FEATURES)
+ X_val_fe, _ = X_val, feat_names
+ X_test_fe, _ = X_test, feat_names
+ print(" ↳ sem derivadas (flag --no-feature-engineering)", flush=True)
+ else:
+ X_train_fe, feat_names = engineer_features(X_train)
+ X_val_fe, _ = engineer_features(X_val)
+ X_test_fe, _ = engineer_features(X_test)
+ print(f" ↳ {len(DEFAULT_FEATURES)} originais + {len(ALL_DERIVED_NAMES)} derivadas "
+ f"= {X_train_fe.shape[1]} features", flush=True)
+
+ # ── 3. Treino per-target ────────────────────────────────────────────────
+ _print_header(f"3. Treinando {len(DEFAULT_TARGETS)} modelos XGBoost (1/target)")
+ params = dict(
+ n_estimators=args.n_estimators,
+ max_depth=args.max_depth,
+ learning_rate=args.learning_rate,
+ subsample=0.9,
+ colsample_bytree=0.9,
+ tree_method="hist",
+ random_state=args.seed,
+ n_jobs=-1,
+ objective="reg:squarederror",
+ )
+ print(f" hiperparâmetros: {params}", flush=True)
+
+ models: dict[str, Any] = {}
+ per_target_val: dict[str, dict[str, float]] = {}
+ per_target_test: dict[str, dict[str, float]] = {}
+
+ for j, tgt in enumerate(DEFAULT_TARGETS):
+ model = xgb.XGBRegressor(**params)
+ model.fit(
+ X_train_fe, y_train[:, j],
+ eval_set=[(X_val_fe, y_val[:, j])],
+ verbose=False,
+ )
+ models[tgt] = model
+
+ mv = _compute_metrics(y_val[:, j], model.predict(X_val_fe))
+ mt = _compute_metrics(y_test[:, j], model.predict(X_test_fe))
+ per_target_val[tgt] = mv
+ per_target_test[tgt] = mt
+ print(f" [{tgt:18s}] val MAE={mv['mae']:.6f} R²={mv['r2']:+.4f} | "
+ f"test MAE={mt['mae']:.6f} R²={mt['r2']:+.4f}",
+ flush=True)
+
+ global_val = {
+ k: float(np.mean([m[k] for m in per_target_val.values() if np.isfinite(m[k])]))
+ for k in ("mae", "rmse", "r2")
+ }
+ global_test = {
+ k: float(np.mean([m[k] for m in per_target_test.values() if np.isfinite(m[k])]))
+ for k in ("mae", "rmse", "r2")
+ }
+
+ _print_header("Resumo global")
+ print(f" val → MAE={global_val['mae']:.6f} RMSE={global_val['rmse']:.6f} "
+ f"R²={global_val['r2']:+.4f}", flush=True)
+ print(f" test → MAE={global_test['mae']:.6f} RMSE={global_test['rmse']:.6f} "
+ f"R²={global_test['r2']:+.4f}", flush=True)
+
+ # ── 4. Persistência ─────────────────────────────────────────────────────
+ _print_header("4. Persistindo modelos + manifest JSON")
+ model_files: dict[str, str] = {}
+ for tgt, model in models.items():
+ fname = f"xgb_{tgt}.json"
+ path = output_dir / fname
+ # XGBoost suporta save_model nativo para JSON — seguro, determinístico.
+ model.save_model(str(path))
+ model_files[tgt] = fname
+
+ # Hash dos arquivos (integridade)
+ sha = {tgt: file_sha256(output_dir / fname) for tgt, fname in model_files.items()}
+
+ manifest_path = output_dir / "baseline_manifest.json"
+ manifest = {
+ "version": "1",
+ "kind": "xgboost_per_target",
+ "seed": args.seed,
+ "feature_names": feat_names,
+ "target_names": list(DEFAULT_TARGETS),
+ "n_features": len(feat_names),
+ "n_train": int(X_train.shape[0]),
+ "n_val": int(X_val.shape[0]),
+ "n_test": int(X_test.shape[0]),
+ "hyperparameters": params,
+ "model_files": model_files,
+ "sha256": sha,
+ "global_metrics_val": global_val,
+ "global_metrics_test": global_test,
+ "per_target_metrics_val": per_target_val,
+ "per_target_metrics_test": per_target_test,
+ }
+ with open(manifest_path, "w", encoding="utf-8") as f:
+ json.dump(manifest, f, indent=2)
+ print(f" ✓ {manifest_path}", flush=True)
+
+ print("\n✅ Baseline XGBoost concluído.", flush=True)
+ print(f" Artefatos em: {output_dir}", flush=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..a4f0074
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,59 @@
+# syntax=docker/dockerfile:1.7
+# =========================================================================
+# CSTRChemIA — Dockerfile multi-stage
+# =========================================================================
+
+# --- Stage 1: builder -----------------------------------------------------
+FROM python:3.12-slim-bookworm AS builder
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1 \
+ PIP_DISABLE_PIP_VERSION_CHECK=1
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential \
+ gcc \
+ g++ \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /build
+
+COPY requirements.txt ./
+RUN python -m venv /opt/venv \
+ && /opt/venv/bin/pip install --upgrade pip setuptools wheel \
+ && /opt/venv/bin/pip install --no-cache-dir -r requirements.txt
+
+# --- Stage 2: runtime -----------------------------------------------------
+FROM python:3.12-slim-bookworm AS runtime
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ TF_CPP_MIN_LOG_LEVEL=2 \
+ PATH="/opt/venv/bin:$PATH" \
+ CSTRCHEMIA_HOST=0.0.0.0 \
+ PORT=8051
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ libgomp1 \
+ curl \
+ && rm -rf /var/lib/apt/lists/* \
+ && groupadd --system app --gid 1000 \
+ && useradd --system --uid 1000 --gid app --home /home/app --shell /bin/bash app \
+ && mkdir -p /home/app && chown -R app:app /home/app
+
+COPY --from=builder /opt/venv /opt/venv
+
+WORKDIR /app
+COPY --chown=app:app CSTRChemIA /app/CSTRChemIA
+COPY --chown=app:app pyproject.toml README.md ./
+
+USER app
+
+EXPOSE 8051
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
+ CMD curl -fsS http://localhost:${PORT}/ || exit 1
+
+# Gunicorn em produção — 1 worker (TF não é fork-safe por padrão)
+CMD ["sh", "-c", "gunicorn --chdir CSTRChemIA main:server --bind 0.0.0.0:${PORT} --workers ${WEB_CONCURRENCY:-1} --timeout 120 --access-logfile - --error-logfile -"]
diff --git a/Dockerfile.api b/Dockerfile.api
new file mode 100644
index 0000000..0fba688
--- /dev/null
+++ b/Dockerfile.api
@@ -0,0 +1,41 @@
+# syntax=docker/dockerfile:1.7
+# =========================================================================
+# CSTRChemIA — REST API (FastAPI + uvicorn)
+# =========================================================================
+
+FROM python:3.12-slim-bookworm AS builder
+
+ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential gcc g++ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /build
+COPY requirements.txt ./
+RUN python -m venv /opt/venv \
+ && /opt/venv/bin/pip install --upgrade pip setuptools wheel \
+ && /opt/venv/bin/pip install --no-cache-dir -r requirements.txt \
+ && /opt/venv/bin/pip install --no-cache-dir \
+ fastapi>=0.110 "uvicorn[standard]>=0.29" pydantic>=2.5
+
+
+FROM python:3.12-slim-bookworm AS runtime
+
+ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 TF_CPP_MIN_LOG_LEVEL=2 \
+ PATH="/opt/venv/bin:$PATH"
+
+RUN apt-get update && apt-get install -y --no-install-recommends libgomp1 curl \
+ && rm -rf /var/lib/apt/lists/* \
+ && useradd --system --uid 1000 --create-home app
+
+COPY --from=builder /opt/venv /opt/venv
+
+WORKDIR /app
+COPY --chown=app:app CSTRChemIA /app/CSTRChemIA
+USER app
+
+EXPOSE 8000
+HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \
+ CMD curl -fsS http://localhost:8000/health || exit 1
+
+CMD ["uvicorn", "CSTRChemIA.api.app:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..10be2aa
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 CSTRChemIA contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..d2a13d7
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,77 @@
+# =========================================================================
+# CSTRChemIA — developer shortcuts
+# =========================================================================
+
+PY ?= python
+APP_DIR := CSTRChemIA
+VENV ?= .venv
+
+.PHONY: help install install-dev run lint format typecheck test test-fast \
+ test-cov clean docker-build docker-run precommit freeze
+
+help:
+ @echo "CSTRChemIA — make targets"
+ @echo " install instala requirements de produção"
+ @echo " install-dev instala requirements + dev tools (ruff, mypy, pytest)"
+ @echo " run inicia o app Dash em modo dev"
+ @echo " lint ruff check"
+ @echo " format ruff format + ruff check --fix"
+ @echo " typecheck mypy (core + services)"
+ @echo " test pytest completo"
+ @echo " test-fast pytest sem marcadores 'slow'"
+ @echo " test-cov pytest com cobertura"
+ @echo " clean remove caches e artefatos temporários"
+ @echo " docker-build build da imagem Docker"
+ @echo " docker-run roda container"
+ @echo " precommit instala hooks pre-commit"
+ @echo " freeze congela ambiente para requirements.lock"
+
+install:
+ $(PY) -m pip install -r requirements.txt
+
+install-dev:
+ $(PY) -m pip install -e .[dev,api]
+
+run:
+ cd $(APP_DIR) && $(PY) main.py
+
+lint:
+ ruff check .
+
+format:
+ ruff format .
+ ruff check --fix .
+
+typecheck:
+ mypy
+
+test:
+ pytest
+
+test-fast:
+ pytest -m "not slow"
+
+test-cov:
+ pytest --cov=CSTRChemIA/core --cov=CSTRChemIA/services --cov-report=term-missing --cov-report=html
+
+clean:
+ @echo "Cleaning caches..."
+ @find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
+ @find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
+ @find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true
+ @find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true
+ @find . -type d -name "htmlcov" -exec rm -rf {} + 2>/dev/null || true
+ @rm -rf build dist *.egg-info .coverage 2>/dev/null || true
+
+docker-build:
+ docker build -t cstrchemia:latest .
+
+docker-run:
+ docker run --rm -p 8051:8051 cstrchemia:latest
+
+precommit:
+ pre-commit install
+ pre-commit run --all-files || true
+
+freeze:
+ $(PY) -m pip freeze > requirements.lock.txt
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a1046e1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,190 @@
+# CSTRChemIA
+
+> **CSTR Simulator & Optimizer** — simulação baseada em surrogate (MLP / RNN / CNN) e otimização multiobjetivo NSGA-II para reatores tanque-agitado (CSTR) com cinética, fouling e falhas operacionais.
+
+[](./.github/workflows/ci.yml)
+[](https://python.org)
+[](https://dash.plotly.com/)
+[](https://tensorflow.org)
+[](LICENSE)
+
+---
+
+## Destaques
+
+- **Dashboard Dash** interativo em 5 abas (**Simulation / Optimization / Training / Analytics / About**) com 20+ visualizações Plotly.
+- **Surrogate ML** — três arquiteturas (MLP, RNN-LSTM/GRU, CNN) treinadas com [keras_tuner](https://keras.io/keras_tuner/) e pipeline `StandardScaler` persistido.
+- **NSGA-II multiobjetivo** ([pymoo](https://pymoo.org)) com restrições físicas, TOPSIS, K-means e hypervolume.
+- **REST API** opcional ([FastAPI](https://fastapi.tiangolo.com/)) sobre os mesmos serviços — ideal para integração com MLOps.
+- **Arquitetura em camadas** (core / services / apps) com testes, type-hints e logging seguro em Windows/Python 3.13.
+- **Docker multi-stage** + `docker-compose` + health-check `/healthz`.
+- **CI/CD** pronto: ruff, mypy, pytest em Linux + Windows.
+
+---
+
+## Arquitetura
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ UI (Dash + Plotly) │
+│ apps/ · layouts/ · assets/ │
+├──────────────────────────────────────────────────────────────────┤
+│ REST API opcional (FastAPI) api/ │
+├──────────────────────────────────────────────────────────────────┤
+│ services/ │
+│ SimulationService · OptimizationService · TrainingService │
+│ ModelRegistry (discovery + metrics) │
+├──────────────────────────────────────────────────────────────────┤
+│ Motores de domínio │
+│ surrogate_model/ (Keras + scalers) │
+│ optimization/ (NSGA-II + TOPSIS + hypervolume) │
+│ training/ (keras_tuner + subprocess) │
+├──────────────────────────────────────────────────────────────────┤
+│ core/ │
+│ logging · settings · constants · exceptions · types · utils │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+Ver [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) para detalhes.
+
+---
+
+## Início rápido
+
+### 1. Instalação (dev)
+
+```bash
+python -m venv .venv
+source .venv/bin/activate # Windows: .venv\Scripts\activate
+pip install -e ".[dev]" # core + dev tools
+# ou, para a REST API:
+pip install -e ".[dev,api]"
+```
+
+### 2. Rodar o dashboard
+
+```bash
+make run # Linux/Mac
+# ou
+cd CSTRChemIA && python main.py
+```
+
+Acesse http://localhost:8051.
+
+### 3. Rodar a REST API (opcional)
+
+```bash
+uvicorn CSTRChemIA.api.app:app --reload --port 8000
+# Docs interativas: http://localhost:8000/docs
+```
+
+### 4. Docker
+
+```bash
+docker compose up --build # só o dashboard
+docker compose --profile api up --build # dashboard + REST API
+```
+
+---
+
+## Uso programático (Python)
+
+```python
+from services import SimulationService, OptimizationService, get_model_registry
+
+# Listar modelos disponíveis (ordenados por MAE)
+print(get_model_registry().list_models())
+
+# Predição
+sim = SimulationService()
+targets = sim.predict_named({
+ "vazao_feed": 0.001, "CA0_feed": 1200, "CB0_feed": 1000,
+ "Tf_feed": 350, "T_amb_feed": 298, "T_obs": 345,
+ "valve_pos": 0.5, "w_j": 0.005,
+ "flag_falha_valvula": 0, "flag_manutencao": 0,
+ "flag_falha_sensor_T": 0, "flag_falha_comunicacao": 0,
+}, model_name="MLP")
+print(targets) # {'CA0': ..., 'X': ..., ...}
+
+# Otimização (assíncrona)
+opt = OptimizationService()
+job_id = opt.submit(pop_size=80, n_gen=50, model_type="MLP")
+job = opt.get_job(job_id)
+print(job.status, job.progress_gen)
+```
+
+---
+
+## Treinamento
+
+```bash
+# Via CLI
+python -m training.train_surrogate --arch MLP
+
+# Via UI — aba "Training", com streaming de logs ao vivo
+make run
+```
+
+O treinamento:
+1. Descobre automaticamente datasets em `Simulacoes/`.
+2. Faz busca de hiperparâmetros com `keras_tuner`.
+3. Persiste o melhor modelo em `CSTRChemIA/surrogate_model/CSTR_{ARCH}_Surrogate.keras`.
+4. Atualiza `training_results.csv` — métricas lidas pelo `ModelRegistry`.
+
+---
+
+## Comandos de desenvolvimento
+
+| Comando | Ação |
+|--------------------|-----------------------------------------------|
+| `make run` | Dashboard em dev |
+| `make lint` | `ruff check` |
+| `make format` | `ruff format` + autofix |
+| `make typecheck` | `mypy` (core + services) |
+| `make test` | `pytest` completo |
+| `make test-fast` | Pula testes marcados `slow` |
+| `make test-cov` | Cobertura HTML em `htmlcov/` |
+| `make precommit` | Instala hooks pre-commit |
+| `make docker-build`| Build da imagem Docker |
+| `make clean` | Limpa caches |
+
+---
+
+## Estrutura do repositório
+
+```
+CSTRChemIA/
+├── api/ # REST API (FastAPI)
+├── apps/ # Callbacks e init do Dash
+├── assets/ # Estáticos (CSS, logo)
+├── core/ # Logging, settings, constants, exceptions, types, utils
+├── layouts/ # Layouts das abas Dash
+├── optimization/ # NSGA-II + análises
+├── services/ # Camada de aplicação (simulação, otimização, treino)
+├── simulation/ # Simulador CSTR puro (ODEs)
+├── surrogate_model/ # Modelos Keras + scalers + métricas
+├── training/ # Pipeline de treinamento
+└── main.py # Entry-point Dash + /healthz
+
+tests/ # pytest suite
+docs/ # ARCHITECTURE.md, DEPLOYMENT.md
+.github/workflows/ # CI (lint, typecheck, test, docker)
+Dockerfile # Imagem de produção (Dash + gunicorn)
+Dockerfile.api # Imagem da REST API (uvicorn)
+docker-compose.yml # Orquestração local
+pyproject.toml # Build + ruff + mypy + pytest config
+```
+
+---
+
+## Documentação
+
+- [Arquitetura detalhada](docs/ARCHITECTURE.md)
+- [Guia de deployment](docs/DEPLOYMENT.md)
+- [Contribuindo](docs/CONTRIBUTING.md)
+
+---
+
+## Licença
+
+MIT — ver [LICENSE](LICENSE).
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..39d060f
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,47 @@
+services:
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ image: cstrchemia:latest
+ container_name: cstrchemia
+ restart: unless-stopped
+ ports:
+ - "${PORT:-8051}:8051"
+ environment:
+ PORT: "8051"
+ CSTRCHEMIA_HOST: "0.0.0.0"
+ CSTRCHEMIA_LOG_LEVEL: "INFO"
+ WEB_CONCURRENCY: "1"
+ TF_CPP_MIN_LOG_LEVEL: "2"
+ volumes:
+ # Modelos persistem entre rebuilds
+ - ./CSTRChemIA/surrogate_model:/app/CSTRChemIA/surrogate_model
+ healthcheck:
+ test: ["CMD", "curl", "-fsS", "http://localhost:8051/"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 60s
+ deploy:
+ resources:
+ limits:
+ memory: 4G
+ reservations:
+ memory: 1G
+
+ # API REST opcional (Wave 9). Sobe com: docker compose --profile api up
+ api:
+ profiles: ["api"]
+ build:
+ context: .
+ dockerfile: Dockerfile.api
+ image: cstrchemia-api:latest
+ container_name: cstrchemia-api
+ restart: unless-stopped
+ ports:
+ - "${API_PORT:-8000}:8000"
+ environment:
+ CSTRCHEMIA_LOG_LEVEL: "INFO"
+ volumes:
+ - ./CSTRChemIA/surrogate_model:/app/CSTRChemIA/surrogate_model:ro
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..9f08290
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,116 @@
+# Arquitetura — CSTRChemIA
+
+## Princípios
+
+1. **Camadas explícitas** — UI → services → motores de domínio → core.
+ Qualquer camada só depende das camadas abaixo dela.
+2. **Core puro** — `core/` não importa Dash, pymoo ou TensorFlow. Assim
+ pode ser usado em CLIs, testes unitários e API REST sem carregar
+ dependências pesadas.
+3. **Services como fachada** — callbacks do Dash e endpoints da API
+ **nunca** chamam `PredictValues` ou `run_cstr_optimization`
+ diretamente. Sempre via `SimulationService` / `OptimizationService`.
+4. **Imports tardios de TensorFlow** — os services fazem `import` dentro
+ do `__init__` para que o startup do Dash não carregue TF até a
+ primeira predição.
+
+## Camadas
+
+```
+UI (apps/, layouts/) ← Dash callbacks
+ │
+ ▼
+REST API (api/) ← FastAPI (opcional)
+ │
+ ▼
+Services (services/) ← Fachada de aplicação
+ │ - SimulationService
+ │ - OptimizationService
+ │ - TrainingService
+ │ - ModelRegistry
+ ▼
+Motores de domínio
+ │ - surrogate_model/ (KerasPredict, PredictValues)
+ │ - optimization/ (NSGA-II, TOPSIS, hypervolume)
+ │ - training/ (keras_tuner, pipeline completo)
+ │ - simulation/ (simulação física, ODEs)
+ ▼
+Core (core/) ← Transversal, sem deps de domínio
+ - logging (safe_print + handler)
+ - settings (dataclass + env vars)
+ - constants (features, targets, bounds, paletas)
+ - exceptions (CSTRChemIAError + subclasses)
+ - types (SimulationInput, OptimizationConfig, ModelInfo)
+ - utils (validação de features, timed, clip_to_bounds)
+```
+
+## Fluxo de uma simulação (UI)
+
+1. Callback recebe valores dos inputs do Dash.
+2. Constrói `dict[str, float]` com os 12 features.
+3. Chama `SimulationService.predict_named(params, model_name=...)`.
+4. Service valida (via `core.utils.ensure_feature_array`), carrega
+ pipeline cacheado, chama Keras, desfaz scaling Y.
+5. Retorna `dict[str, float]` com os 8 targets.
+6. Callback atualiza as figuras Plotly.
+
+Erros de domínio (`DataValidationError`, `ModelNotFoundError`) viram
+mensagens amigáveis no Dash; a exception original é `log.exception()`ada.
+
+## Fluxo de otimização (UI)
+
+1. Callback coleta parâmetros (bounds, pop, n_gen) do formulário.
+2. Chama `OptimizationService.submit(...)` → retorna `job_id`.
+3. Timer/interval callback pergunta periodicamente o status (job
+ registrado em memória no service).
+4. Quando `status == "done"`, renderiza o frame de Pareto, hypervolume,
+ TOPSIS etc. — tudo pré-computado pelo service.
+
+O worker roda em `threading.Thread` daemon — adequado para o uso único
+do dashboard. Para múltiplos usuários simultâneos, considere
+`ProcessPoolExecutor` ou Celery.
+
+## Treinamento
+
+Treino é *sempre* em subprocess (`subprocess.Popen`):
+
+- Isola TF — vazamentos de memória não afetam o servidor Dash.
+- Permite cancelamento via `process.terminate()`.
+- Logs são capturados linha-a-linha numa reader thread e expostos via
+ `TrainingService.tail_logs()`.
+
+## Windows / Python 3.13 — caveats
+
+- `sys.stdout.reconfigure(line_buffering=True)` pode invalidar o handle
+ quando chamado em threads background. `core.logging.safe_print`
+ silencia o `OSError 22` resultante.
+- `pymoo.util.function_loader.FunctionLoader.__init__` faz um `print()`
+ quando módulos Cython faltam — patcheado em
+ `optimization/CSTR_Optimization_Problem.py`.
+- `keras.layers.InputLayer` mudou `batch_input_shape` → `batch_shape`
+ em Keras 3 / TF 2.16+. `training/models.py` faz fallback entre os dois.
+
+## Decisões (ADR resumidos)
+
+### ADR-001: Dash + Flask (não Streamlit)
+
+Dash gera SPAs com server-side callbacks — essencial para streaming de
+logs de treino e para controlar estado complexo (jobs, caches). A
+escolha do Flask como WSGI permite expor `/healthz` sem esforço extra.
+
+### ADR-002: Services separados da UI
+
+Permite REST API e CLI sem reescrita; permite testes sem Dash. Overhead
+inicial alto, mas o custo de integração cresce sub-linear.
+
+### ADR-003: Singleton de `ModelRegistry`
+
+Descoberta de modelos é raro (só muda pós-treino) e `lru_cache` evita
+stat do filesystem a cada request. Invalidação manual em
+`TrainingService._reader_thread` quando treino completa com sucesso.
+
+### ADR-004: Logging via `safe_print` em Windows
+
+Em vez de diagnosticar por que o stdout do Python 3.13 no Windows quebra
+em threads, tratamos sintomaticamente — `OSError 22` raramente indica
+lógica quebrada, só a perda da linha de log.
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
new file mode 100644
index 0000000..7cb3fff
--- /dev/null
+++ b/docs/CONTRIBUTING.md
@@ -0,0 +1,80 @@
+# Contribuindo para o CSTRChemIA
+
+## Setup
+
+```bash
+git clone https://github.com/your-org/CSTRChemIA.git
+cd CSTRChemIA
+python -m venv .venv
+source .venv/bin/activate # Windows: .venv\Scripts\activate
+pip install -e ".[dev]"
+pre-commit install
+```
+
+## Antes de abrir PR
+
+```bash
+make lint # ruff check
+make format # ruff format + autofix
+make typecheck # mypy (core + services)
+make test # pytest
+```
+
+O CI roda todos esses passos — mas é mais rápido pegar os problemas
+localmente.
+
+## Convenções de código
+
+- **Type hints** obrigatórios em `core/` e `services/`.
+- **Docstrings** no módulo e em funções públicas (sintaxe Google ou
+ simples texto). Funções privadas (prefixadas com `_`) dispensam.
+- **Imports ordenados** pelo ruff (isort). Não altere manualmente.
+- **Exceções** — sempre levante subclasses de `CSTRChemIAError` em vez
+ de `Exception`/`ValueError` genéricos.
+- **Logging** — use `core.logging.get_logger(__name__)`. Nunca
+ `print()` em código de produção.
+
+## Adicionando um novo serviço
+
+1. Crie `services/novo_service.py`.
+2. Exporte em `services/__init__.py`.
+3. Escreva testes em `tests/test_novo_service.py` — mocke deps pesadas
+ (`surrogate_model.KerasPredict`, `pymoo`, `subprocess.Popen`).
+4. Se expor via REST, adicione schema em `api/schemas.py` e rota em
+ `api/app.py`.
+
+## Adicionando um callback Dash
+
+1. Prefira um helper em `services/` para a lógica — o callback vira
+ uma camada fina de I/O.
+2. Use `Input/Output/State` explícitos, evite `*args`.
+3. Erros de serviço → `html.Div` com mensagem amigável, mantendo o
+ `log.exception` do lado do servidor.
+
+## Convenções de commit
+
+Formato (inspirado em Conventional Commits):
+
+```
+tipo(scope): título curto
+
+Corpo explicando **por que** (não o quê — o diff mostra).
+```
+
+Tipos: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`.
+
+Exemplos:
+
+```
+feat(services): adiciona OptimizationService com submit async
+fix(logging): OSError 22 em thread do subprocess de treino
+docs: deployment em Render com /healthz
+```
+
+## Testes
+
+- Use pytest fixtures em `tests/conftest.py` para setup repetitivo.
+- Marcadores disponíveis: `slow`, `integration`, `gpu` — skipáveis
+ individualmente: `pytest -m "not slow"`.
+- Mocke TensorFlow (`surrogate_model.KerasPredict.PredictValues`) em
+ testes de `services/` — carregar modelos reais é lento demais para CI.
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
new file mode 100644
index 0000000..9514197
--- /dev/null
+++ b/docs/DEPLOYMENT.md
@@ -0,0 +1,121 @@
+# Deployment — CSTRChemIA
+
+## Variáveis de ambiente
+
+| Variável | Default | Descrição |
+|---------------------------------|----------------|------------------------------------------|
+| `PORT` | `8051` | Porta do Dash (usada também por Railway/Render). |
+| `CSTRCHEMIA_HOST` | `0.0.0.0` | Bind address do servidor. |
+| `CSTRCHEMIA_DEBUG` | `false` | Modo debug do Dash. |
+| `CSTRCHEMIA_LOG_LEVEL` | `INFO` | Nível do logger root. |
+| `CSTRCHEMIA_MODEL_CACHE` | `8` | Tamanho do cache de pipelines Keras. |
+| `CSTRCHEMIA_PREDICTION_CACHE` | `1024` | Tamanho do LRU de predições. |
+| `CSTRCHEMIA_DEFAULT_POP` | `100` | Tamanho de população padrão do NSGA-II. |
+| `CSTRCHEMIA_DEFAULT_NGEN` | `50` | Número de gerações padrão. |
+| `CSTRCHEMIA_DEFAULT_SEED` | `42` | Seed do NSGA-II. |
+| `WEB_CONCURRENCY` | `1` | Workers do gunicorn (TF não é fork-safe). |
+| `TF_CPP_MIN_LOG_LEVEL` | `2` | Verbosidade do TensorFlow. |
+
+## Docker (produção local)
+
+```bash
+docker compose up --build -d
+# Dashboard: http://localhost:8051
+# Health: http://localhost:8051/healthz
+```
+
+Com REST API:
+
+```bash
+docker compose --profile api up --build -d
+# API docs: http://localhost:8000/docs
+```
+
+## Railway
+
+`railway.toml` já configurado. Subir:
+
+```bash
+railway up
+```
+
+A variável `PORT` é injetada automaticamente pela plataforma.
+
+## Render
+
+Use `render.yaml` (exemplo abaixo) — crie um `Web Service`:
+
+- **Build command:** `pip install -r requirements.txt`
+- **Start command:** `gunicorn --chdir CSTRChemIA main:server --bind 0.0.0.0:$PORT --workers 1 --timeout 120`
+- **Health check path:** `/healthz`
+
+## Heroku
+
+`Procfile` já presente. Bastão:
+
+```bash
+heroku create
+git push heroku master
+heroku config:set TF_CPP_MIN_LOG_LEVEL=2
+```
+
+## Kubernetes (esboço)
+
+Recomendado:
+
+- `replicas: 1` por pod TF (ou `replicas: N` com LB e session-affinity
+ baseada no usuário — o dashboard mantém estado local em `dcc.Store`,
+ não em sessão server-side).
+- Recursos mínimos: `512Mi` memória, `250m` CPU. Para inferência ativa:
+ `1Gi` + `500m`.
+- `livenessProbe` e `readinessProbe` em `/healthz`.
+
+Exemplo:
+
+```yaml
+livenessProbe:
+ httpGet: { path: /healthz, port: 8051 }
+ initialDelaySeconds: 60
+ periodSeconds: 30
+readinessProbe:
+ httpGet: { path: /healthz, port: 8051 }
+ initialDelaySeconds: 15
+ periodSeconds: 10
+```
+
+## Persistência de modelos
+
+Os arquivos `.keras` e `scalerX/Y.pkl` ficam em
+`CSTRChemIA/surrogate_model/`. Em produção:
+
+- **Docker:** volume montado (ver `docker-compose.yml`) — sobrevive a
+ rebuilds da imagem.
+- **Cloud:** use object storage (S3 / GCS) com um init-container que
+ faça `aws s3 sync` antes de iniciar o servidor, **ou** um volume
+ persistente.
+
+## Observabilidade
+
+- `/healthz` retorna `models_available` — útil pra alertar se o disco
+ "perdeu" um modelo.
+- Logs em JSON: defina `CSTRCHEMIA_LOG_LEVEL=INFO` e agregue via
+ Cloudwatch/Loki. Formato já inclui timestamp, level e logger name.
+- Para métricas Prometheus, instale `prometheus-flask-exporter`:
+
+ ```python
+ # CSTRChemIA/main.py — após criar `server`
+ from prometheus_flask_exporter import PrometheusMetrics
+ PrometheusMetrics(server)
+ ```
+
+## CI/CD
+
+Workflow em `.github/workflows/ci.yml`:
+
+- `lint` — ruff check + format.
+- `typecheck` — mypy sobre core + services.
+- `test` — pytest em Ubuntu + Windows, Python 3.11 + 3.12.
+- `docker` — build da imagem com cache do GHA.
+
+Para deploy automático (Cloud Run, ECR), adicione job que dependa de
+`test` e `docker`, autenticando via OIDC.
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..87694f0
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,187 @@
+[build-system]
+requires = ["setuptools>=68", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "cstrchemia"
+version = "0.2.0"
+description = "Interactive surrogate-model simulator & NSGA-II optimizer for a realistic CSTR."
+readme = "README.md"
+requires-python = ">=3.11,<3.14"
+license = { text = "MIT" }
+authors = [{ name = "CSTRChemIA maintainers" }]
+keywords = ["cstr", "chemistry", "surrogate", "nsga2", "dash", "deep-learning"]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Intended Audience :: Science/Research",
+ "License :: OSI Approved :: MIT License",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Topic :: Scientific/Engineering :: Chemistry",
+]
+dependencies = [
+ "gunicorn>=21.2.0",
+ "dash>=2.9",
+ "dash-bootstrap-components>=1.6.0",
+ "plotly>=5.18",
+ "numpy>=1.26.4",
+ "pandas>=2.2.2",
+ "pymoo>=0.6.0",
+ "tensorflow-cpu>=2.16.1,<2.18",
+ "keras-tuner>=1.4.0",
+ "scikit-learn>=1.4",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=8.0",
+ "pytest-cov>=5.0",
+ "pytest-xdist>=3.5",
+ "ruff>=0.5",
+ "mypy>=1.10",
+ "types-requests",
+ "pre-commit>=3.7",
+]
+api = [
+ "fastapi>=0.110",
+ "uvicorn[standard]>=0.29",
+ "pydantic>=2.5",
+]
+
+[project.urls]
+Homepage = "https://github.com/your-org/CSTRChemIA"
+Repository = "https://github.com/your-org/CSTRChemIA"
+Issues = "https://github.com/your-org/CSTRChemIA/issues"
+
+[project.scripts]
+cstrchemia = "CSTRChemIA.main:run_chemsimia"
+
+[tool.setuptools]
+packages = { find = { where = ["."], include = ["CSTRChemIA*"] } }
+
+# ---------------------------------------------------------------------------
+# Ruff — linter + formatter (substitui black + isort + flake8)
+# ---------------------------------------------------------------------------
+[tool.ruff]
+line-length = 100
+target-version = "py311"
+extend-exclude = [
+ "__pycache__",
+ ".venv",
+ "venv",
+ "env",
+ "build",
+ "dist",
+ "Simulacoes",
+ "Tuning_NeuralNetwork",
+ "CSTRChemIA/assets",
+]
+
+[tool.ruff.lint]
+select = [
+ "E", "W", # pycodestyle
+ "F", # pyflakes
+ "I", # isort
+ "B", # flake8-bugbear
+ "UP", # pyupgrade
+ "SIM", # flake8-simplify
+ "RUF", # ruff-specific
+ "N", # pep8-naming
+ "PL", # pylint
+ "C4", # flake8-comprehensions
+ "PTH", # flake8-use-pathlib
+]
+ignore = [
+ "E501", # linha longa — ruff format cuida
+ "PLR0913", # too many args — comum em Dash callbacks
+ "PLR2004", # magic values — científico tem muitos
+ "N803", # arg names — ciência usa X, y, etc.
+ "N806", # variable names — idem
+ "N802", # function naming — compat com APIs existentes
+ "PLC0415", # import não-top-level — intencional (lazy TF load)
+ "PTH110", # os.path.exists → Path.exists — gradual, muito churn
+ "PTH118", # os.path.join → Path / — idem
+ "RUF100", # unused noqa — toleramos; segurança em excesso
+ "B904", # raise from — ok em contextos tolerantes
+ "B905", # zip strict — comportamento atual já é seguro
+ "SIM105", # contextlib.suppress — try/except é mais explícito aqui
+ "PLW0603", # global statement — usado intencionalmente em core.logging
+ "N813", # camelcase imported as lower — libs externas
+]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**" = ["PLR2004", "S101"]
+"CSTRChemIA/apps/MAIN_callbacks.py" = ["F401", "F403", "F405"]
+"CSTRChemIA/layouts/layout_TAB.py" = ["F401", "F403", "F405"]
+"CSTRChemIA/apps/MAIN_init.py" = ["F401", "F403", "F405"]
+
+[tool.ruff.lint.isort]
+known-first-party = ["CSTRChemIA", "core", "services", "apps", "layouts",
+ "surrogate_model", "optimization", "training", "simulation"]
+
+[tool.ruff.format]
+quote-style = "double"
+indent-style = "space"
+line-ending = "auto"
+
+# ---------------------------------------------------------------------------
+# mypy — type checker gradual
+# ---------------------------------------------------------------------------
+[tool.mypy]
+python_version = "3.11"
+warn_unused_configs = true
+warn_redundant_casts = true
+warn_unused_ignores = true
+warn_return_any = false
+check_untyped_defs = true
+ignore_missing_imports = true # TF, pymoo, dash sem stubs completos
+no_implicit_optional = true
+strict_equality = true
+show_error_codes = true
+files = ["CSTRChemIA/core", "CSTRChemIA/services"]
+
+[[tool.mypy.overrides]]
+module = ["apps.*", "layouts.*"]
+ignore_errors = true # Dash callbacks usam * imports pesadamente
+
+# ---------------------------------------------------------------------------
+# pytest
+# ---------------------------------------------------------------------------
+[tool.pytest.ini_options]
+minversion = "8.0"
+testpaths = ["tests"]
+pythonpath = ["CSTRChemIA"]
+addopts = [
+ "-ra",
+ "--strict-markers",
+ "--strict-config",
+]
+markers = [
+ "slow: testes lentos (require TF model load, > 10s)",
+ "integration: testes que tocam vários módulos",
+ "gpu: testes que exigem GPU (normalmente skip)",
+]
+filterwarnings = [
+ "ignore::DeprecationWarning:tensorflow.*",
+ "ignore::UserWarning:keras.*",
+]
+
+[tool.coverage.run]
+source = ["CSTRChemIA/core", "CSTRChemIA/services"]
+branch = true
+omit = [
+ "*/tests/*",
+ "*/__pycache__/*",
+]
+
+[tool.coverage.report]
+exclude_lines = [
+ "pragma: no cover",
+ "if __name__ == .__main__.:",
+ "raise NotImplementedError",
+ "if TYPE_CHECKING:",
+]
+show_missing = true
+skip_covered = false
diff --git a/requirements.txt b/requirements.txt
index 6883d5d..32db4e4 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -19,6 +19,7 @@ pymoo>=0.6.0
# Machine Learning (surrogate - CPU only)
tensorflow-cpu>=2.16.1,<2.18
+keras-tuner>=1.4.0
# Pré-processamento
scikit-learn
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..051dc68
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,55 @@
+"""
+Fixtures compartilhadas do pytest.
+
+- ``sys.path`` é estendido para incluir ``CSTRChemIA/`` (configurado em
+ ``pyproject.toml`` via ``[tool.pytest.ini_options] pythonpath``).
+- Fixtures simulam o diretório de modelos para testes que não precisam
+ carregar TensorFlow.
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+import pytest
+
+# Garante que os pacotes do app são importáveis mesmo se pyproject.toml
+# pythonpath não for aplicado (ex.: execução direta com `python -m pytest`).
+_REPO_ROOT = Path(__file__).resolve().parent.parent
+_APP_DIR = _REPO_ROOT / "CSTRChemIA"
+for p in (str(_APP_DIR), str(_REPO_ROOT)):
+ if p not in sys.path:
+ sys.path.insert(0, p)
+
+
+@pytest.fixture(scope="session")
+def app_dir() -> Path:
+ return _APP_DIR
+
+
+@pytest.fixture(scope="session")
+def models_dir(app_dir: Path) -> Path:
+ return app_dir / "surrogate_model"
+
+
+@pytest.fixture
+def fake_models_dir(tmp_path: Path) -> Path:
+ """Diretório temporário simulando um models_dir vazio."""
+ d = tmp_path / "surrogate_model"
+ d.mkdir()
+ return d
+
+
+@pytest.fixture
+def feature_sample():
+ """Retorna 1 amostra válida de 12 features (ordem canônica)."""
+ import numpy as np
+ return np.array([0.001, 1200.0, 1000.0, 350.0, 298.0,
+ 345.0, 0.5, 0.005, 0, 0, 0, 0], dtype=np.float64)
+
+
+@pytest.fixture(autouse=True)
+def _quiet_tf(monkeypatch):
+ monkeypatch.setenv("TF_CPP_MIN_LOG_LEVEL", "3")
+ monkeypatch.setenv("CSTRCHEMIA_LOG_LEVEL", "WARNING")
diff --git a/tests/test_core_constants.py b/tests/test_core_constants.py
new file mode 100644
index 0000000..247f0b8
--- /dev/null
+++ b/tests/test_core_constants.py
@@ -0,0 +1,49 @@
+"""Testes de core.constants — invariantes do domínio."""
+
+from __future__ import annotations
+
+
+def test_feature_and_target_counts():
+ from core.constants import DEFAULT_FEATURES, DEFAULT_TARGETS, N_FEATURES, N_TARGETS
+
+ assert len(DEFAULT_FEATURES) == 12 == N_FEATURES
+ assert len(DEFAULT_TARGETS) == 8 == N_TARGETS
+
+
+def test_feature_names_are_unique():
+ from core.constants import DEFAULT_FEATURES
+
+ assert len(set(DEFAULT_FEATURES)) == len(DEFAULT_FEATURES)
+
+
+def test_target_names_are_unique():
+ from core.constants import DEFAULT_TARGETS
+
+ assert len(set(DEFAULT_TARGETS)) == len(DEFAULT_TARGETS)
+
+
+def test_target_descriptions_cover_all_targets():
+ from core.constants import DEFAULT_TARGETS, TARGET_DESCRIPTIONS
+
+ assert set(TARGET_DESCRIPTIONS.keys()) == set(DEFAULT_TARGETS)
+
+
+def test_feature_descriptions_cover_all_features():
+ from core.constants import DEFAULT_FEATURES, FEATURE_DESCRIPTIONS
+
+ assert set(FEATURE_DESCRIPTIONS.keys()) == set(DEFAULT_FEATURES)
+
+
+def test_default_bounds_cover_all_features():
+ from core.constants import DEFAULT_FEATURE_BOUNDS, DEFAULT_FEATURES
+
+ assert set(DEFAULT_FEATURE_BOUNDS.keys()) == set(DEFAULT_FEATURES)
+ for name, (lo, hi) in DEFAULT_FEATURE_BOUNDS.items():
+ assert lo <= hi, f"bounds invertidos para {name}"
+
+
+def test_supported_architectures():
+ from core.constants import MODEL_FILENAMES, SUPPORTED_ARCHITECTURES
+
+ assert SUPPORTED_ARCHITECTURES == ("MLP", "RNN", "CNN")
+ assert set(MODEL_FILENAMES.keys()) == set(SUPPORTED_ARCHITECTURES)
diff --git a/tests/test_core_exceptions.py b/tests/test_core_exceptions.py
new file mode 100644
index 0000000..3856feb
--- /dev/null
+++ b/tests/test_core_exceptions.py
@@ -0,0 +1,37 @@
+"""Testes de core.exceptions — garante hierarquia."""
+
+from __future__ import annotations
+
+
+def test_all_inherit_from_base():
+ from core.exceptions import (
+ ConfigurationError,
+ CSTRChemIAError,
+ DataValidationError,
+ ModelLoadError,
+ ModelNotFoundError,
+ OptimizationError,
+ SimulationError,
+ TrainingError,
+ )
+
+ for exc in (
+ ConfigurationError,
+ DataValidationError,
+ ModelLoadError,
+ ModelNotFoundError,
+ OptimizationError,
+ SimulationError,
+ TrainingError,
+ ):
+ assert issubclass(exc, CSTRChemIAError)
+ assert issubclass(exc, Exception)
+
+
+def test_can_be_raised_and_caught():
+ import pytest
+
+ from core.exceptions import CSTRChemIAError, OptimizationError
+
+ with pytest.raises(CSTRChemIAError, match="boom"):
+ raise OptimizationError("boom")
diff --git a/tests/test_core_logging.py b/tests/test_core_logging.py
new file mode 100644
index 0000000..5ab244c
--- /dev/null
+++ b/tests/test_core_logging.py
@@ -0,0 +1,63 @@
+"""Testes do módulo core.logging — safe_print e setup_logging."""
+
+from __future__ import annotations
+
+import io
+import logging
+
+import pytest
+
+
+def test_safe_print_swallows_oserror(monkeypatch, capsys):
+ from core.logging import safe_print
+
+ class BrokenStdout(io.StringIO):
+ def write(self, s): # noqa: D401
+ raise OSError(22, "Invalid argument")
+
+ monkeypatch.setattr("sys.stdout", BrokenStdout())
+ # Não deve levantar
+ safe_print("hello")
+
+
+def test_safe_print_normal(capsys):
+ from core.logging import safe_print
+
+ safe_print("hello", "world")
+ out = capsys.readouterr().out
+ assert "hello world" in out
+
+
+def test_safe_print_drops_flush_kwarg(capsys):
+ from core.logging import safe_print
+
+ safe_print("x", flush=True) # não deve propagar TypeError
+ assert "x" in capsys.readouterr().out
+
+
+def test_setup_logging_is_idempotent():
+ from core import logging as cl
+
+ cl.setup_logging()
+ n1 = len(logging.getLogger().handlers)
+ cl.setup_logging()
+ n2 = len(logging.getLogger().handlers)
+ assert n1 == n2
+
+
+def test_get_logger_returns_logger_instance():
+ from core.logging import get_logger
+
+ log = get_logger("test.module")
+ assert isinstance(log, logging.Logger)
+ assert log.name == "test.module"
+
+
+@pytest.mark.parametrize("level", ["DEBUG", "INFO", "WARNING", "ERROR"])
+def test_setup_logging_accepts_level_string(level):
+ from core import logging as cl
+
+ # Força re-configure forçando flag = False (acesso ao private)
+ cl._CONFIGURED = False
+ cl.setup_logging(level=level)
+ assert logging.getLogger().level == getattr(logging, level)
diff --git a/tests/test_core_settings.py b/tests/test_core_settings.py
new file mode 100644
index 0000000..a55d9ff
--- /dev/null
+++ b/tests/test_core_settings.py
@@ -0,0 +1,48 @@
+"""Testes do módulo core.settings."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+
+def test_settings_defaults(monkeypatch):
+ from core import settings as cs
+
+ monkeypatch.delenv("PORT", raising=False)
+ monkeypatch.delenv("CSTRCHEMIA_PORT", raising=False)
+ cs.get_settings.cache_clear()
+
+ s = cs.get_settings()
+ assert s.port == 8051
+ assert s.host == "0.0.0.0"
+ assert isinstance(s.base_dir, Path)
+ assert s.models_dir == s.base_dir / "surrogate_model"
+
+
+def test_settings_reads_port_env(monkeypatch):
+ from core import settings as cs
+
+ monkeypatch.setenv("PORT", "9999")
+ cs.get_settings.cache_clear()
+
+ assert cs.get_settings().port == 9999
+
+
+def test_settings_reads_debug_env(monkeypatch):
+ from core import settings as cs
+
+ monkeypatch.setenv("CSTRCHEMIA_DEBUG", "true")
+ cs.get_settings.cache_clear()
+
+ assert cs.get_settings().debug is True
+
+
+def test_settings_is_frozen():
+ import pytest
+
+ from core.settings import get_settings
+
+ s = get_settings()
+ # dataclass(frozen=True) levanta dataclasses.FrozenInstanceError (subclasse de AttributeError)
+ with pytest.raises(AttributeError):
+ s.port = 1234 # type: ignore[misc]
diff --git a/tests/test_core_utils.py b/tests/test_core_utils.py
new file mode 100644
index 0000000..26a5b6f
--- /dev/null
+++ b/tests/test_core_utils.py
@@ -0,0 +1,78 @@
+"""Testes de core.utils — validação e conversões de features."""
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+
+def test_ensure_feature_array_reshapes_1d():
+ from core.utils import ensure_feature_array
+
+ arr = ensure_feature_array([1.0] * 12)
+ assert arr.shape == (1, 12)
+ assert arr.dtype == np.float64
+
+
+def test_ensure_feature_array_accepts_2d():
+ from core.utils import ensure_feature_array
+
+ arr = ensure_feature_array(np.zeros((5, 12)))
+ assert arr.shape == (5, 12)
+
+
+def test_ensure_feature_array_rejects_wrong_shape():
+ from core.exceptions import DataValidationError
+ from core.utils import ensure_feature_array
+
+ with pytest.raises(DataValidationError, match="Esperado shape"):
+ ensure_feature_array([1.0] * 10)
+
+
+def test_ensure_feature_array_rejects_nan():
+ from core.exceptions import DataValidationError
+ from core.utils import ensure_feature_array
+
+ arr = [1.0] * 12
+ arr[3] = float("nan")
+ with pytest.raises(DataValidationError, match="NaN"):
+ ensure_feature_array(arr)
+
+
+def test_features_from_dict_roundtrip():
+ from core.constants import DEFAULT_FEATURES
+ from core.utils import features_from_dict, features_to_dict
+
+ params = {f: float(i) for i, f in enumerate(DEFAULT_FEATURES)}
+ arr = features_from_dict(params)
+ assert arr.shape == (1, 12)
+ assert features_to_dict(arr) == params
+
+
+def test_features_from_dict_missing():
+ from core.exceptions import DataValidationError
+ from core.utils import features_from_dict
+
+ with pytest.raises(DataValidationError, match="Features ausentes"):
+ features_from_dict({"vazao_feed": 1.0})
+
+
+def test_timed_context_manager():
+ import time
+
+ from core.utils import timed
+
+ with timed() as t:
+ time.sleep(0.01)
+ assert t["elapsed"] >= 0.005
+
+
+def test_clip_to_bounds():
+ from core.utils import clip_to_bounds
+
+ arr = np.array([[-1.0, 5.0, 100.0]])
+ bounds = {"a": (0.0, 10.0), "b": (0.0, 10.0), "c": (0.0, 10.0)}
+ out = clip_to_bounds(arr, bounds, feature_names=["a", "b", "c"])
+ np.testing.assert_array_equal(out, [[0.0, 5.0, 10.0]])
+ # Input não foi mutado
+ assert arr[0, 0] == -1.0
diff --git a/tests/test_data_file_service.py b/tests/test_data_file_service.py
new file mode 100644
index 0000000..349b095
--- /dev/null
+++ b/tests/test_data_file_service.py
@@ -0,0 +1,223 @@
+"""Testes de services.data_file_service — discovery, leitura, delete, upload."""
+
+from __future__ import annotations
+
+import base64
+from pathlib import Path
+
+import pytest
+
+
+@pytest.fixture
+def temp_sim_layout(tmp_path, monkeypatch):
+ """
+ Constrói um mini-layout com as mesmas pastas que o serviço descobre e
+ redireciona as raízes do DataFileService para elas.
+
+ Estrutura criada:
+ {tmp}/CSTRChemIA/simulation/dados_treino_features_000.csv
+ {tmp}/CSTRChemIA/simulation/dados_treino_targets_000.csv
+ {tmp}/CSTRChemIA/surrogate_model/training_results.csv
+ {tmp}/Simulacoes/simulacao_CSTR/sim_000/observations/dados_completos.csv
+ {tmp}/Simulacoes/simulacao_CSTR/sim_000/truth/estados_verdadeiros.csv
+ {tmp}/Simulacoes/simulacao_CSTR/dados_diversos/cenario_0000/observations/dados_treino_features_000.csv
+ """
+ base = tmp_path / "CSTRChemIA"
+ sim = base / "simulation"
+ models = base / "surrogate_model"
+ simulacoes = tmp_path / "Simulacoes" / "simulacao_CSTR"
+ sim_obs = simulacoes / "sim_000" / "observations"
+ sim_truth = simulacoes / "sim_000" / "truth"
+ diversos = simulacoes / "dados_diversos" / "cenario_0000" / "observations"
+
+ for d in (sim, models, sim_obs, sim_truth, diversos):
+ d.mkdir(parents=True, exist_ok=True)
+
+ # features flat
+ (sim / "dados_treino_features_000.csv").write_text(
+ "a,b,c\n1,2,3\n4,5,6\n", encoding="utf-8"
+ )
+ (sim / "dados_treino_targets_000.csv").write_text(
+ "t1,t2\n10,20\n30,40\n", encoding="utf-8"
+ )
+ # training results
+ (models / "training_results.csv").write_text(
+ "timestamp,architecture,mae_real\n2026,MLP,0.5\n", encoding="utf-8"
+ )
+ # nested observations
+ (sim_obs / "dados_completos.csv").write_text(
+ "x,y\n1,2\n", encoding="utf-8"
+ )
+ # truth
+ (sim_truth / "estados_verdadeiros.csv").write_text(
+ "s1,s2\n0,1\n", encoding="utf-8"
+ )
+ # diversos
+ (diversos / "dados_treino_features_000.csv").write_text(
+ "a,b\n1,2\n", encoding="utf-8"
+ )
+
+ # Patch service paths
+ from services import data_file_service as dfs
+
+ svc = dfs.DataFileService()
+ monkeypatch_obj = monkeypatch
+ monkeypatch_obj.setattr(svc, "_base_dir", base)
+ monkeypatch_obj.setattr(svc, "_sim_dir", sim)
+ monkeypatch_obj.setattr(svc, "_models_dir", models)
+ monkeypatch_obj.setattr(svc, "_simulacoes_root", simulacoes)
+
+ return svc, {"sim": sim, "models": models, "simulacoes": simulacoes,
+ "base": base, "tmp": tmp_path}
+
+
+def test_discover_finds_all_sources(temp_sim_layout):
+ svc, paths = temp_sim_layout
+ files = svc.discover()
+ sources = {f.source for f in files}
+ assert "simulation_flat" in sources
+ assert "sim_observations" in sources
+ assert "sim_truth" in sources
+ assert "diversos" in sources
+ assert "training" in sources
+
+
+def test_discover_classifies_correctly(temp_sim_layout):
+ svc, _ = temp_sim_layout
+ files = svc.discover()
+ kinds = {f.filename: f.kind for f in files}
+ assert kinds["dados_treino_features_000.csv"] == "features"
+ assert kinds["dados_treino_targets_000.csv"] == "targets"
+ assert kinds["training_results.csv"] == "training_result"
+ assert kinds["dados_completos.csv"] == "complete"
+ assert kinds["estados_verdadeiros.csv"] == "truth"
+
+
+def test_summary_totals(temp_sim_layout):
+ svc, _ = temp_sim_layout
+ files = svc.discover()
+ s = svc.summary(files)
+ assert s["n_files"] == len(files)
+ assert s["total_bytes"] > 0
+ assert s["total_rows"] >= 0
+ assert "features" in s["by_kind"]
+
+
+def test_read_preview_returns_records(temp_sim_layout):
+ svc, paths = temp_sim_layout
+ feat_path = str(paths["sim"] / "dados_treino_features_000.csv")
+ records, cols, total = svc.read_preview(feat_path, n_rows=10)
+ assert cols == ["a", "b", "c"]
+ assert total == 2
+ assert len(records) == 2
+ assert records[0] == {"a": 1, "b": 2, "c": 3}
+
+
+def test_read_preview_rejects_outside_roots(temp_sim_layout, tmp_path):
+ svc, _ = temp_sim_layout
+ outside = tmp_path / "outside.csv"
+ outside.write_text("a,b\n1,2\n", encoding="utf-8")
+ with pytest.raises(FileNotFoundError):
+ svc.read_preview(str(outside))
+
+
+def test_delete_rejects_empty(temp_sim_layout):
+ svc, _ = temp_sim_layout
+ result = svc.delete("")
+ assert result["ok"] is False
+
+
+def test_delete_rejects_outside_roots(temp_sim_layout, tmp_path):
+ svc, _ = temp_sim_layout
+ outside = tmp_path / "outside.csv"
+ outside.write_text("a,b\n", encoding="utf-8")
+ result = svc.delete(str(outside))
+ assert result["ok"] is False
+ assert "fora das raízes" in result["message"]
+ # Garante que não apagou o arquivo
+ assert outside.exists()
+
+
+def test_delete_rejects_non_csv(temp_sim_layout, tmp_path):
+ svc, paths = temp_sim_layout
+ non_csv = paths["sim"] / "evil.txt"
+ non_csv.write_text("hi", encoding="utf-8")
+ result = svc.delete(str(non_csv))
+ assert result["ok"] is False
+ assert ".csv" in result["message"]
+ assert non_csv.exists()
+
+
+def test_delete_succeeds_on_valid_file(temp_sim_layout, tmp_path):
+ svc, paths = temp_sim_layout
+ # Cria um arquivo dedicado para ser apagado
+ victim = paths["sim"] / "dados_treino_features_victim.csv"
+ victim.write_text("a,b\n1,2\n", encoding="utf-8")
+ assert victim.exists()
+ result = svc.delete(str(victim))
+ assert result["ok"] is True
+ assert not victim.exists()
+
+
+def test_save_upload_happy_path(temp_sim_layout):
+ svc, _ = temp_sim_layout
+ payload = b"a,b,c\n1,2,3\n"
+ b64 = base64.b64encode(payload).decode("ascii")
+ content = f"data:text/csv;base64,{b64}"
+ res = svc.save_upload(content, "dados_treino_features_new.csv")
+ assert res["ok"] is True
+ assert res["new_filename"] == "dados_treino_features_new.csv"
+ assert Path(res["saved_path"]).exists()
+
+
+def test_save_upload_rejects_invalid_name(temp_sim_layout):
+ svc, _ = temp_sim_layout
+ payload = b"a,b\n1,2\n"
+ b64 = base64.b64encode(payload).decode("ascii")
+ res = svc.save_upload(b64, "random.csv")
+ assert res["ok"] is False
+ assert "Nome" in res["message"]
+
+
+def test_save_upload_rejects_binary(temp_sim_layout):
+ svc, _ = temp_sim_layout
+ # Payload com NUL — heurística rejeita
+ payload = b"\x00\x01\x02\x03" * 100
+ b64 = base64.b64encode(payload).decode("ascii")
+ res = svc.save_upload(b64, "dados_treino_features_bin.csv")
+ assert res["ok"] is False
+ assert "CSV" in res["message"]
+
+
+def test_save_upload_auto_suffixes_on_collision(temp_sim_layout):
+ svc, _ = temp_sim_layout
+ payload = b"a,b\n1,2\n"
+ b64 = base64.b64encode(payload).decode("ascii")
+ r1 = svc.save_upload(b64, "dados_treino_features_dup.csv")
+ r2 = svc.save_upload(b64, "dados_treino_features_dup.csv", overwrite=False)
+ assert r1["ok"] and r2["ok"]
+ assert r1["new_filename"] != r2["new_filename"]
+ assert Path(r1["saved_path"]).exists()
+ assert Path(r2["saved_path"]).exists()
+
+
+def test_save_upload_sanitizes_path_traversal(temp_sim_layout):
+ svc, _ = temp_sim_layout
+ payload = b"a,b\n1,2\n"
+ b64 = base64.b64encode(payload).decode("ascii")
+ # Tenta escapar com ../ no nome
+ res = svc.save_upload(b64, "../dados_treino_features_evil.csv")
+ # Regex rejeita o nome com ../ (depois do basename() sobra o arquivo puro
+ # mas o regex valida o resultado). Aceitamos qualquer que saved_path fique
+ # DENTRO do sim_dir.
+ if res["ok"]:
+ saved = Path(res["saved_path"]).resolve()
+ # Precisa estar DENTRO do upload_dir
+ assert saved.is_relative_to(svc.upload_dir.resolve())
+
+
+def test_get_singleton_returns_same_instance():
+ from services.data_file_service import get_data_file_service
+ a = get_data_file_service()
+ b = get_data_file_service()
+ assert a is b
diff --git a/tests/test_health_endpoint.py b/tests/test_health_endpoint.py
new file mode 100644
index 0000000..3c69479
--- /dev/null
+++ b/tests/test_health_endpoint.py
@@ -0,0 +1,25 @@
+"""Testes do endpoint /healthz do servidor Flask (expose por Dash)."""
+
+from __future__ import annotations
+
+import pytest
+
+
+@pytest.fixture(scope="module")
+def flask_client():
+ import main
+ return main.server.test_client()
+
+
+def test_healthz_ok(flask_client):
+ resp = flask_client.get("/healthz")
+ assert resp.status_code == 200
+ body = resp.get_json()
+ assert body["status"] == "ok"
+ assert "models_available" in body
+ assert isinstance(body["models_available"], list)
+
+
+def test_healthz_reports_service_name(flask_client):
+ body = flask_client.get("/healthz").get_json()
+ assert "CSTR" in body["service"]
diff --git a/tests/test_i18n.py b/tests/test_i18n.py
new file mode 100644
index 0000000..6fecedd
--- /dev/null
+++ b/tests/test_i18n.py
@@ -0,0 +1,224 @@
+"""
+Testes do sistema de internacionalização (:mod:`core.i18n`).
+
+Cobrem:
+ • Invariantes do catálogo: todos os idiomas têm o mesmo keyset, sem strings
+ vazias, sem chaves duplicadas.
+ • Fallback chain de :func:`t`: idioma solicitado → DEFAULT_LANG → chave crua.
+ • Validação de :func:`get_lang`: aceita códigos válidos, devolve default p/ inválidos.
+ • Estrutura do dropdown (``options_for_dropdown``).
+ • Cobertura de uso: toda chave invocada em layouts/callbacks existe no PT.
+"""
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+import pytest
+
+from core import i18n
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# INVARIANTES DO CATÁLOGO
+# ══════════════════════════════════════════════════════════════════════════════
+class TestCatalog:
+ def test_default_lang_is_valid(self):
+ assert i18n.DEFAULT_LANG in i18n.VALID_LANG_CODES
+
+ def test_default_lang_has_translations(self):
+ assert i18n.DEFAULT_LANG in i18n.TRANSLATIONS
+ assert len(i18n.TRANSLATIONS[i18n.DEFAULT_LANG]) > 0
+
+ def test_all_languages_present(self):
+ for lang in ("pt", "en", "es"):
+ assert lang in i18n.TRANSLATIONS, f"missing language: {lang}"
+ assert lang in i18n.VALID_LANG_CODES
+
+ def test_all_languages_share_keyset(self):
+ """Todos os idiomas precisam ter exatamente o mesmo keyset."""
+ default_keys = set(i18n.TRANSLATIONS[i18n.DEFAULT_LANG].keys())
+ for lang, catalog in i18n.TRANSLATIONS.items():
+ other_keys = set(catalog.keys())
+ missing = default_keys - other_keys
+ extra = other_keys - default_keys
+ assert not missing, f"[{lang}] missing keys: {sorted(missing)}"
+ assert not extra, f"[{lang}] extra keys: {sorted(extra)}"
+
+ def test_no_empty_strings(self):
+ """Proibido registrar string vazia — falhas de tradução devem ficar visíveis."""
+ for lang, catalog in i18n.TRANSLATIONS.items():
+ empties = [k for k, v in catalog.items() if not str(v).strip()]
+ assert not empties, f"[{lang}] empty strings for: {empties}"
+
+ def test_languages_list_structure(self):
+ """O dropdown espera dicts com ``value`` e ``label``."""
+ for entry in i18n.LANGUAGES:
+ assert "value" in entry
+ assert "label" in entry
+ assert entry["value"] in i18n.VALID_LANG_CODES
+ assert isinstance(entry["label"], str) and entry["label"]
+
+ def test_languages_list_no_duplicates(self):
+ codes = [x["value"] for x in i18n.LANGUAGES]
+ assert len(codes) == len(set(codes)), "duplicate language codes"
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# FUNÇÃO t()
+# ══════════════════════════════════════════════════════════════════════════════
+class TestTranslate:
+ def test_returns_value_for_existing_key(self):
+ assert i18n.t("tab_simulation", "pt") == "⚗ Simulação"
+ assert i18n.t("tab_simulation", "en") == "⚗ Simulation"
+ assert i18n.t("tab_simulation", "es") == "⚗ Simulación"
+
+ def test_fallback_to_default_lang(self):
+ """Se a chave só existir em ``pt``, pedindo em ``en`` deve cair pra ``pt``."""
+ # Injeta uma chave só no default e confirma fallback
+ key = "__test_pt_only_key__"
+ i18n.TRANSLATIONS["pt"][key] = "somente_pt"
+ try:
+ assert i18n.t(key, "en") == "somente_pt"
+ assert i18n.t(key, "es") == "somente_pt"
+ assert i18n.t(key, "pt") == "somente_pt"
+ finally:
+ del i18n.TRANSLATIONS["pt"][key]
+
+ def test_returns_raw_key_if_missing_everywhere(self):
+ assert i18n.t("__inexistente_xyz__", "pt") == "__inexistente_xyz__"
+ assert i18n.t("__inexistente_xyz__", "en") == "__inexistente_xyz__"
+
+ def test_unknown_lang_uses_default(self):
+ """Idioma desconhecido vira fallback pro default."""
+ assert i18n.t("tab_simulation", "zz") == i18n.t("tab_simulation", i18n.DEFAULT_LANG)
+
+ def test_none_lang_uses_default(self):
+ assert i18n.t("tab_simulation", None) == i18n.t("tab_simulation", i18n.DEFAULT_LANG)
+
+ def test_different_langs_different_output(self):
+ """Confirma que temos pelo menos algumas traduções distintas entre idiomas."""
+ distinct = {
+ i18n.t("tab_optimization", "pt"),
+ i18n.t("tab_optimization", "en"),
+ i18n.t("tab_optimization", "es"),
+ }
+ assert len(distinct) == 3, f"expected 3 distinct translations, got {distinct}"
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# get_lang()
+# ══════════════════════════════════════════════════════════════════════════════
+class TestGetLang:
+ @pytest.mark.parametrize("code", ["pt", "en", "es"])
+ def test_valid_codes_pass_through(self, code):
+ assert i18n.get_lang(code) == code
+
+ @pytest.mark.parametrize("bad", ["zz", "PT", "", "xx", "portuguese", None, 42, [], {}])
+ def test_invalid_fallsback_to_default(self, bad):
+ assert i18n.get_lang(bad) == i18n.DEFAULT_LANG
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# options_for_dropdown / available_keys / missing_keys
+# ══════════════════════════════════════════════════════════════════════════════
+class TestHelpers:
+ def test_options_for_dropdown_copy_independent(self):
+ """Modificar o retorno não deve afetar LANGUAGES."""
+ opts = i18n.options_for_dropdown()
+ opts.append({"value": "zz", "label": "fake"})
+ assert "zz" not in [x["value"] for x in i18n.LANGUAGES]
+
+ def test_available_keys_returns_sorted(self):
+ keys = i18n.available_keys("pt")
+ assert keys == sorted(keys)
+ assert len(keys) > 0
+
+ def test_missing_keys_empty_for_all_registered_langs(self):
+ """Após alinhamento do catálogo, ``missing_keys`` deve ser vazio para todos."""
+ for lang in i18n.VALID_LANG_CODES:
+ if lang == i18n.DEFAULT_LANG:
+ continue
+ miss = i18n.missing_keys(lang)
+ assert miss == [], f"[{lang}] has missing keys: {miss}"
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# USO DAS CHAVES NOS LAYOUTS / CALLBACKS
+# ══════════════════════════════════════════════════════════════════════════════
+def _scan_keys_in(file_path: Path) -> set[str]:
+ """Extrai chaves invocadas em ``_t('...')`` / ``t('...')`` do arquivo."""
+ if not file_path.exists():
+ return set()
+ text = file_path.read_text(encoding="utf-8")
+ return set(re.findall(r"\b_?t\(['\"]([a-z_0-9]+)['\"]", text))
+
+
+def _repo_file(rel: str) -> Path:
+ return Path(__file__).resolve().parent.parent / "CSTRChemIA" / rel
+
+
+class TestUsageCoverage:
+ """Toda chave usada no código precisa existir no catálogo PT."""
+
+ def test_layout_tab_keys_all_exist(self):
+ used = _scan_keys_in(_repo_file("layouts/layout_TAB.py"))
+ pt_keys = set(i18n.TRANSLATIONS["pt"].keys())
+ missing = used - pt_keys
+ assert not missing, (
+ f"Chaves usadas em layout_TAB.py mas ausentes do catálogo: {sorted(missing)}"
+ )
+
+ def test_main_callbacks_keys_all_exist(self):
+ used = _scan_keys_in(_repo_file("apps/MAIN_callbacks.py"))
+ pt_keys = set(i18n.TRANSLATIONS["pt"].keys())
+ missing = used - pt_keys
+ assert not missing, (
+ f"Chaves usadas em MAIN_callbacks.py mas ausentes: {sorted(missing)}"
+ )
+
+ def test_main_init_keys_all_exist(self):
+ used = _scan_keys_in(_repo_file("apps/MAIN_init.py"))
+ pt_keys = set(i18n.TRANSLATIONS["pt"].keys())
+ missing = used - pt_keys
+ assert not missing, (
+ f"Chaves usadas em MAIN_init.py mas ausentes: {sorted(missing)}"
+ )
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# SMOKE: LAYOUTS RENDERIZAM EM TODOS OS IDIOMAS
+# ══════════════════════════════════════════════════════════════════════════════
+@pytest.mark.parametrize("lang", ["pt", "en", "es"])
+class TestLayoutsSmoke:
+ """Garante que cada layout aceita o parâmetro ``lang`` e não quebra."""
+
+ def test_simulation_layout_renders(self, lang):
+ from layouts.layout_TAB import layout_TAB_Simulation
+ node = layout_TAB_Simulation(lang=lang)
+ assert node is not None
+
+ def test_optimization_layout_renders(self, lang):
+ from layouts.layout_TAB import layout_TAB_Optimization
+ node = layout_TAB_Optimization(lang=lang)
+ assert node is not None
+
+ def test_training_layout_renders(self, lang):
+ from layouts.layout_TAB import layout_TAB_Training
+ node = layout_TAB_Training(lang=lang)
+ assert node is not None
+
+ def test_analytics_layout_renders(self, lang):
+ from layouts.layout_TAB import layout_TAB_Analytics
+ node = layout_TAB_Analytics(lang=lang)
+ assert node is not None
+
+ def test_about_layout_renders(self, lang):
+ from layouts.layout_TAB import layout_TAB_About
+ node = layout_TAB_About(lang=lang)
+ assert node is not None
+
+ def test_help_layout_renders(self, lang):
+ from layouts.layout_TAB import layout_help
+ node = layout_help(lang=lang)
+ assert node is not None
diff --git a/tests/test_model_registry.py b/tests/test_model_registry.py
new file mode 100644
index 0000000..fc6cbb4
--- /dev/null
+++ b/tests/test_model_registry.py
@@ -0,0 +1,70 @@
+"""Testes de services.model_registry."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+
+def test_empty_registry_returns_empty_list(fake_models_dir: Path):
+ from services.model_registry import ModelRegistry
+
+ reg = ModelRegistry(models_dir=fake_models_dir)
+ assert reg.available_architectures() == []
+ assert reg.list_models() == []
+ assert reg.best_architecture() is None
+ assert reg.as_dropdown_options() == []
+
+
+def test_registry_detects_model_files(fake_models_dir: Path):
+ from services.model_registry import ModelRegistry
+
+ # Cria arquivos dummy para MLP
+ (fake_models_dir / "CSTR_MLP_Surrogate.keras").write_bytes(b"\x00")
+ (fake_models_dir / "scalerX.pkl").write_bytes(b"\x00")
+ (fake_models_dir / "scalerY.pkl").write_bytes(b"\x00")
+
+ reg = ModelRegistry(models_dir=fake_models_dir)
+ assert reg.available_architectures() == ["MLP"]
+
+
+def test_registry_reads_training_results(fake_models_dir: Path):
+ import pandas as pd
+
+ from services.model_registry import ModelRegistry
+
+ # Arquivos do modelo
+ for arch in ("MLP", "RNN"):
+ (fake_models_dir / f"CSTR_{arch}_Surrogate.keras").write_bytes(b"\x00")
+ (fake_models_dir / "scalerX.pkl").write_bytes(b"\x00")
+ (fake_models_dir / "scalerY.pkl").write_bytes(b"\x00")
+
+ pd.DataFrame({
+ "architecture": ["MLP", "MLP", "RNN"],
+ "mae_real": [10.0, 8.0, 15.0],
+ }).to_csv(fake_models_dir / "training_results.csv", index=False)
+
+ reg = ModelRegistry(models_dir=fake_models_dir)
+ infos = reg.list_models()
+ assert len(infos) == 2
+ # Ordenado por MAE asc — MLP(8.0) antes de RNN(15.0)
+ assert infos[0].architecture == "MLP"
+ assert infos[0].mae == 8.0
+ assert infos[1].architecture == "RNN"
+ assert reg.best_architecture() == "MLP"
+
+
+def test_dropdown_options_format(fake_models_dir: Path):
+ import pandas as pd
+
+ from services.model_registry import ModelRegistry
+
+ (fake_models_dir / "CSTR_MLP_Surrogate.keras").write_bytes(b"\x00")
+ (fake_models_dir / "scalerX.pkl").write_bytes(b"\x00")
+ (fake_models_dir / "scalerY.pkl").write_bytes(b"\x00")
+ pd.DataFrame({"architecture": ["MLP"], "mae_real": [1.234567]}).to_csv(
+ fake_models_dir / "training_results.csv", index=False,
+ )
+
+ reg = ModelRegistry(models_dir=fake_models_dir)
+ opts = reg.as_dropdown_options()
+ assert opts == [{"label": "MLP (MAE: 1.234567)", "value": "MLP"}]
diff --git a/tests/test_service_adapters.py b/tests/test_service_adapters.py
new file mode 100644
index 0000000..1e78b7f
--- /dev/null
+++ b/tests/test_service_adapters.py
@@ -0,0 +1,55 @@
+"""Testes dos adaptadores em apps.callbacks.service_adapters."""
+
+from __future__ import annotations
+
+import sys
+import types
+
+import numpy as np
+
+
+def _install_fake_keras(monkeypatch):
+ def _predict(X, model_type="MLP", Tensor=False):
+ y = np.arange(8, dtype=np.float64)
+ if Tensor:
+ n = np.asarray(X).reshape(-1, 12).shape[0]
+ return np.tile(y, (n, 1))
+ return y
+
+ mod = types.ModuleType("surrogate_model.KerasPredict")
+ mod.PredictValues = _predict
+ monkeypatch.setitem(sys.modules, "surrogate_model.KerasPredict", mod)
+
+
+def test_predict_via_service_happy_path(monkeypatch):
+ _install_fake_keras(monkeypatch)
+ # Força novo SimulationService com o mock
+ from apps.callbacks import service_adapters as sa
+ sa._sim_service.cache_clear()
+
+ from core.constants import DEFAULT_FEATURES
+ params = dict.fromkeys(DEFAULT_FEATURES, 1.0)
+ out, err = sa.predict_via_service(params, model_name="MLP")
+ assert err is None
+ assert out is not None
+ assert len(out) == 8
+
+
+def test_predict_via_service_returns_error_for_bad_input(monkeypatch):
+ _install_fake_keras(monkeypatch)
+ from apps.callbacks import service_adapters as sa
+ sa._sim_service.cache_clear()
+
+ out, err = sa.predict_via_service({"vazao_feed": 1.0}, model_name="MLP")
+ assert out is None
+ assert err is not None
+ assert "ausentes" in err.lower() or "missing" in err.lower()
+
+
+def test_submit_training_invalid_arch():
+ from apps.callbacks import service_adapters as sa
+ sa._train_service.cache_clear()
+
+ jid, err = sa.submit_training("INVALID")
+ assert jid is None
+ assert err is not None
diff --git a/tests/test_simulation_service.py b/tests/test_simulation_service.py
new file mode 100644
index 0000000..af00519
--- /dev/null
+++ b/tests/test_simulation_service.py
@@ -0,0 +1,110 @@
+"""Testes de services.simulation_service (sem carregar TensorFlow)."""
+
+from __future__ import annotations
+
+import sys
+import types
+from typing import Any
+
+import numpy as np
+import pytest
+
+
+@pytest.fixture
+def fake_predict(monkeypatch):
+ """Substitui surrogate_model.KerasPredict.PredictValues por um mock."""
+ calls: list[dict[str, Any]] = []
+
+ def _predict(X, model_type="MLP", Tensor=False):
+ calls.append({"X": X, "model_type": model_type, "Tensor": Tensor})
+ # Retorna shape correta: 8 targets
+ y = np.arange(8, dtype=np.float64) * 0.1
+ if Tensor:
+ n = np.asarray(X).reshape(-1, 12).shape[0]
+ return np.tile(y, (n, 1))
+ return y
+
+ # Garante que um módulo KerasPredict mock existe para o import tardio do service
+ module = types.ModuleType("surrogate_model.KerasPredict")
+ module.PredictValues = _predict
+ monkeypatch.setitem(sys.modules, "surrogate_model.KerasPredict", module)
+ return calls
+
+
+def test_predict_single_point(fake_predict, feature_sample):
+ from services.simulation_service import SimulationService
+
+ svc = SimulationService()
+ y = svc.predict(feature_sample, model_name="MLP")
+ assert y.shape == (8,)
+ assert fake_predict[-1]["model_type"] == "MLP"
+ assert fake_predict[-1]["Tensor"] is False
+
+
+def test_predict_rejects_wrong_shape(fake_predict):
+ from core.exceptions import DataValidationError
+ from services.simulation_service import SimulationService
+
+ svc = SimulationService()
+ with pytest.raises(DataValidationError):
+ svc.predict(np.zeros(10), model_name="MLP")
+
+
+def test_predict_batch(fake_predict):
+ from services.simulation_service import SimulationService
+
+ svc = SimulationService()
+ X = np.zeros((5, 12))
+ y = svc.predict_batch(X, model_name="CNN")
+ assert y.shape == (5, 8)
+ assert fake_predict[-1]["Tensor"] is True
+
+
+def test_predict_named(fake_predict):
+ from core.constants import DEFAULT_FEATURES, DEFAULT_TARGETS
+ from services.simulation_service import SimulationService
+
+ svc = SimulationService()
+ params = dict.fromkeys(DEFAULT_FEATURES, 1.0)
+ out = svc.predict_named(params, model_name="MLP")
+ assert set(out.keys()) == set(DEFAULT_TARGETS)
+
+
+def test_predict_named_missing_feature(fake_predict):
+ from core.exceptions import SimulationError
+ from services.simulation_service import SimulationService
+
+ svc = SimulationService()
+ with pytest.raises(SimulationError, match="ausentes"):
+ svc.predict_named({"vazao_feed": 1.0}, model_name="MLP")
+
+
+def test_simulate_structured(fake_predict, feature_sample):
+ from core.types import SimulationInput
+ from services.simulation_service import SimulationService
+
+ svc = SimulationService()
+ result = svc.simulate(SimulationInput(features=feature_sample, model_name="MLP"))
+ assert result.targets.shape == (8,)
+ assert result.model_name == "MLP"
+ assert result.wall_time_s >= 0
+
+
+def test_model_not_found_is_translated(monkeypatch, feature_sample):
+ import sys
+ import types
+
+ from core.exceptions import ModelNotFoundError
+
+ def _predict(*_a, **_kw):
+ raise FileNotFoundError("arquivo não existe")
+
+ mod = types.ModuleType("surrogate_model.KerasPredict")
+ mod.PredictValues = _predict
+ monkeypatch.setitem(sys.modules, "surrogate_model.KerasPredict", mod)
+
+ from services.simulation_service import SimulationService
+
+ svc = SimulationService()
+ with pytest.raises(ModelNotFoundError):
+ svc.predict(feature_sample, model_name="MLP")
diff --git a/tests/test_training_service.py b/tests/test_training_service.py
new file mode 100644
index 0000000..0969641
--- /dev/null
+++ b/tests/test_training_service.py
@@ -0,0 +1,71 @@
+"""Testes de services.training_service — sem rodar treino real."""
+
+from __future__ import annotations
+
+import pytest
+
+
+def test_invalid_architecture_raises():
+ from core.exceptions import TrainingError
+ from services.training_service import TrainingService
+
+ svc = TrainingService()
+ with pytest.raises(TrainingError, match="Arquitetura inválida"):
+ svc.submit("FOOBAR")
+
+
+def test_submit_mlp_launches_subprocess(monkeypatch):
+ """Subprocess é mockado para evitar rodar treino real."""
+ import subprocess
+
+ from services.training_service import TrainingService
+
+ captured: dict = {}
+
+ class FakeProc:
+ def __init__(self):
+ self.stdout = iter(["epoch 1/2 loss=0.5\n", "epoch 2/2 loss=0.3\n"])
+ self.returncode = None
+
+ def wait(self):
+ self.returncode = 0
+ return 0
+
+ def fake_popen(cmd, **kw):
+ captured["cmd"] = cmd
+ captured["kwargs"] = kw
+ return FakeProc()
+
+ monkeypatch.setattr(subprocess, "Popen", fake_popen)
+
+ svc = TrainingService()
+ jid = svc.submit("MLP")
+ job = svc.get_job(jid)
+ assert job is not None
+ assert job.architecture == "MLP"
+
+ # Espera reader thread processar
+ for _ in range(100):
+ if job.status in ("done", "failed"):
+ break
+ import time
+ time.sleep(0.01)
+
+ assert job.status == "done"
+ assert job.returncode == 0
+ assert any("epoch 1/2" in line for line in job.logs)
+ assert "--arch" in captured["cmd"]
+ assert "MLP" in captured["cmd"]
+
+
+def test_list_and_clear_jobs():
+ from services.training_service import TrainingJob, TrainingService
+
+ svc = TrainingService()
+ # Injeta manualmente um job finalizado
+ done = TrainingJob(job_id="x", architecture="MLP", status="done")
+ svc._jobs["x"] = done
+ assert len(svc.list_jobs()) >= 1
+ n = svc.clear_finished()
+ assert n >= 1
+ assert svc.get_job("x") is None
diff --git a/tests/test_units.py b/tests/test_units.py
new file mode 100644
index 0000000..5a5d4cd
--- /dev/null
+++ b/tests/test_units.py
@@ -0,0 +1,337 @@
+"""Testes de core.units — catálogo, conversões, bounds, formatação."""
+
+from __future__ import annotations
+
+import math
+
+import pytest
+
+from core.units import (
+ UNITS,
+ VAR_BOUNDS,
+ VAR_CATEGORY,
+ UnitOption,
+ canonical_value_of,
+ convert,
+ default_units,
+ display_bounds,
+ format_range_text,
+ format_value,
+ from_canonical,
+ get_canonical,
+ get_option,
+ get_unit,
+ label_for,
+ options_for,
+ to_canonical,
+)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# CATÁLOGO
+# ══════════════════════════════════════════════════════════════════════════════
+def test_every_category_has_exactly_one_canonical():
+ for cat, opts in UNITS.items():
+ canonical = [o for o in opts if o.canonical]
+ assert len(canonical) == 1, f"Categoria '{cat}' deve ter exatamente 1 canônica, tem {len(canonical)}"
+
+
+def test_catalog_has_expected_categories():
+ expected = {"vazao", "concentration", "temperature", "length_micro", "fraction"}
+ assert set(UNITS.keys()) == expected
+
+
+def test_vazao_catalog():
+ vals = {o.value for o in UNITS["vazao"]}
+ assert {"m3_s", "L_s", "L_min", "m3_h"} <= vals
+
+
+def test_temperature_catalog_includes_fahrenheit():
+ vals = {o.value for o in UNITS["temperature"]}
+ assert vals == {"K", "C", "F"}
+
+
+def test_length_micro_canonical_is_meters():
+ assert get_canonical("length_micro").value == "m"
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# CONVERSÕES BÁSICAS
+# ══════════════════════════════════════════════════════════════════════════════
+def test_to_canonical_vazao_L_s():
+ # 1 L/s = 0.001 m³/s
+ assert to_canonical(1.0, "vazao", "L_s") == pytest.approx(1e-3)
+ assert to_canonical(500.0, "vazao", "L_s") == pytest.approx(0.5)
+
+
+def test_to_canonical_vazao_L_min():
+ # 60 L/min = 1 L/s = 0.001 m³/s
+ assert to_canonical(60.0, "vazao", "L_min") == pytest.approx(1e-3, rel=1e-12)
+
+
+def test_to_canonical_vazao_m3_h():
+ # 3600 m³/h = 1 m³/s
+ assert to_canonical(3600.0, "vazao", "m3_h") == pytest.approx(1.0)
+
+
+def test_from_canonical_vazao_display():
+ assert from_canonical(1e-3, "vazao", "L_s") == pytest.approx(1.0)
+ assert from_canonical(1.0, "vazao", "m3_h") == pytest.approx(3600.0)
+
+
+def test_concentration_mol_L_round_trip():
+ # 1 mol/L = 1000 mol/m³
+ assert to_canonical(1.0, "concentration", "mol_L") == pytest.approx(1000.0)
+ assert from_canonical(1000.0, "concentration", "mol_L") == pytest.approx(1.0)
+
+
+def test_concentration_mmol_L_is_equivalent_to_canonical():
+ # 1 mmol/L = 1 mol/m³
+ assert to_canonical(1.0, "concentration", "mmol_L") == pytest.approx(1.0)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# CONVERSÕES AFINS (TEMPERATURA)
+# ══════════════════════════════════════════════════════════════════════════════
+def test_celsius_to_kelvin():
+ assert to_canonical(0.0, "temperature", "C") == pytest.approx(273.15)
+ assert to_canonical(100.0, "temperature", "C") == pytest.approx(373.15)
+ assert to_canonical(-40.0, "temperature", "C") == pytest.approx(233.15)
+
+
+def test_kelvin_to_celsius():
+ assert from_canonical(273.15, "temperature", "C") == pytest.approx(0.0)
+ assert from_canonical(373.15, "temperature", "C") == pytest.approx(100.0)
+
+
+def test_fahrenheit_round_trip():
+ # 32°F = 273.15 K; 212°F = 373.15 K
+ assert to_canonical(32.0, "temperature", "F") == pytest.approx(273.15, rel=1e-6)
+ assert to_canonical(212.0, "temperature", "F") == pytest.approx(373.15, rel=1e-6)
+ assert from_canonical(273.15, "temperature", "F") == pytest.approx(32.0, abs=1e-6)
+
+
+def test_minus_40_is_same_in_celsius_and_fahrenheit():
+ # -40 °C = -40 °F
+ k_from_c = to_canonical(-40.0, "temperature", "C")
+ k_from_f = to_canonical(-40.0, "temperature", "F")
+ assert k_from_c == pytest.approx(k_from_f, rel=1e-10)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# CONVERT (de uma unidade para outra)
+# ══════════════════════════════════════════════════════════════════════════════
+def test_convert_same_unit_is_noop():
+ assert convert(123.45, "concentration", "mol_L", "mol_L") == pytest.approx(123.45)
+
+
+def test_convert_round_trip_temperature():
+ x = 350.1 # K
+ f = from_canonical(x, "temperature", "F")
+ back = to_canonical(f, "temperature", "F")
+ assert back == pytest.approx(x, rel=1e-8)
+
+
+def test_convert_length_meters_to_micro():
+ # 0.000025 m = 25 µm
+ assert convert(2.5e-5, "length_micro", "m", "um") == pytest.approx(25.0)
+ assert convert(25.0, "length_micro", "um", "m") == pytest.approx(2.5e-5)
+
+
+def test_convert_none_returns_none():
+ assert convert(None, "concentration", "mol_L", "mmol_L") is None
+
+
+def test_convert_handles_strings():
+ # strings que viram número (o input HTML muitas vezes traz string)
+ assert convert("1", "vazao", "L_s", "m3_s") == pytest.approx(1e-3)
+
+
+def test_convert_handles_nan():
+ assert convert(float("nan"), "vazao", "L_s", "m3_s") is None
+ assert convert(float("inf"), "vazao", "L_s", "m3_s") is None
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# VAR_CATEGORY + VAR_BOUNDS
+# ══════════════════════════════════════════════════════════════════════════════
+def test_var_category_all_point_to_valid_categories():
+ for var, cat in VAR_CATEGORY.items():
+ assert cat in UNITS, f"VAR_CATEGORY['{var}']='{cat}' não existe em UNITS"
+
+
+def test_var_bounds_keys_are_subset_of_var_category():
+ # Toda variável em VAR_BOUNDS tem que ter uma categoria declarada.
+ for var in VAR_BOUNDS:
+ assert var in VAR_CATEGORY, f"VAR_BOUNDS['{var}'] sem categoria"
+
+
+def test_var_bounds_fields_complete():
+ for var, b in VAR_BOUNDS.items():
+ for key in ("min", "max", "step", "default"):
+ assert key in b, f"VAR_BOUNDS['{var}'] faltando '{key}'"
+ assert b["min"] <= b["default"] <= b["max"], \
+ f"VAR_BOUNDS['{var}']: default fora de [min, max]"
+ assert b["step"] > 0
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# DISPLAY_BOUNDS
+# ══════════════════════════════════════════════════════════════════════════════
+def test_display_bounds_canonical_matches_raw():
+ # Quando unit_value == canonical, bounds == VAR_BOUNDS[var_id]
+ for var in VAR_BOUNDS:
+ cat = VAR_CATEGORY[var]
+ canonical = canonical_value_of(cat)
+ db = display_bounds(var, canonical)
+ raw = VAR_BOUNDS[var]
+ assert db["min"] == pytest.approx(raw["min"])
+ assert db["max"] == pytest.approx(raw["max"])
+ assert db["step"] == pytest.approx(raw["step"])
+ assert db["default"] == pytest.approx(raw["default"])
+
+
+def test_display_bounds_vazao_in_L_per_s():
+ # vazao canonical [0.00050, 0.00120] m³/s → [0.5, 1.2] L/s
+ db = display_bounds("vazao", "L_s")
+ assert db["min"] == pytest.approx(0.5)
+ assert db["max"] == pytest.approx(1.2)
+ assert db["step"] == pytest.approx(0.01)
+ assert db["default"] == pytest.approx(0.506)
+
+
+def test_display_bounds_fouling_in_micro():
+ db = display_bounds("fouling_max", "um")
+ # canonical 0.0–0.05 m → 0–50000 µm
+ assert db["min"] == pytest.approx(0.0)
+ assert db["max"] == pytest.approx(50_000.0)
+
+
+def test_display_bounds_temperature_in_celsius():
+ # tf canonical 342.0–358.0 K → 68.85–84.85 °C
+ db = display_bounds("tf", "C")
+ assert db["min"] == pytest.approx(68.85, rel=1e-6)
+ assert db["max"] == pytest.approx(84.85, rel=1e-6)
+ # Step de temperatura é "delta" — não recebe offset, só factor (que é 1.0).
+ assert db["step"] == pytest.approx(0.1)
+
+
+def test_display_bounds_raises_on_missing_var():
+ with pytest.raises(KeyError):
+ display_bounds("this-var-does-not-exist", "K")
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# FORMAT_RANGE_TEXT
+# ══════════════════════════════════════════════════════════════════════════════
+def test_format_range_text_basic():
+ text = format_range_text("vazao", "L_s")
+ assert "L/s" in text
+ assert "Faixa" in text
+ # valores 0.5 e 1.2 devem estar na string em alguma forma
+ assert "0.5" in text or "0.50" in text
+
+
+def test_format_range_text_temperature_celsius():
+ text = format_range_text("tf", "C")
+ assert "°C" in text
+
+
+def test_format_range_text_returns_empty_for_unknown():
+ assert format_range_text("bogus_var", "K") == ""
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# OPTIONS_FOR / GET_OPTION
+# ══════════════════════════════════════════════════════════════════════════════
+def test_options_for_returns_label_value_pairs():
+ opts = options_for("vazao")
+ assert isinstance(opts, list)
+ assert all("label" in o and "value" in o for o in opts)
+
+
+def test_get_option_raises_on_invalid():
+ with pytest.raises(KeyError):
+ get_option("vazao", "not-a-real-unit")
+
+
+def test_label_for_returns_human_label():
+ assert label_for("concentration", "mol_L") == "mol/L"
+ assert label_for("temperature", "C") == "°C"
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# DEFAULT_UNITS / GET_UNIT
+# ══════════════════════════════════════════════════════════════════════════════
+def test_default_units_covers_all_vars():
+ defaults = default_units()
+ for var in VAR_CATEGORY:
+ assert var in defaults
+
+
+def test_default_units_values_are_canonical():
+ defaults = default_units()
+ for var, unit in defaults.items():
+ cat = VAR_CATEGORY[var]
+ assert unit == canonical_value_of(cat)
+
+
+def test_get_unit_without_store_returns_canonical():
+ assert get_unit(None, "vazao") == canonical_value_of("vazao")
+ assert get_unit({}, "vazao") == canonical_value_of("vazao")
+
+
+def test_get_unit_with_invalid_value_falls_back_to_canonical():
+ store = {"vazao": "nonsense"}
+ assert get_unit(store, "vazao") == canonical_value_of("vazao")
+
+
+def test_get_unit_with_valid_value_returns_it():
+ store = {"vazao": "L_s"}
+ assert get_unit(store, "vazao") == "L_s"
+
+
+def test_get_unit_unknown_var_returns_empty():
+ assert get_unit({}, "totally-unknown") == ""
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# FORMAT_VALUE
+# ══════════════════════════════════════════════════════════════════════════════
+def test_format_value_none_returns_dash():
+ assert format_value(None) == "—"
+
+
+def test_format_value_very_small_uses_scientific():
+ s = format_value(1e-6)
+ assert "e" in s.lower()
+
+
+def test_format_value_clean_integer():
+ assert format_value(42) == "42"
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# UNIT OPTION (API)
+# ══════════════════════════════════════════════════════════════════════════════
+def test_unit_option_is_immutable():
+ # frozen=True garante imutabilidade
+ opt = UnitOption("x", "x", 1.0)
+ with pytest.raises((AttributeError, Exception)):
+ opt.factor = 2.0 # type: ignore
+
+
+def test_unit_option_round_trip():
+ # Para qualquer opt, display → canonical → display retorna o mesmo valor.
+ for cat in UNITS:
+ for opt in UNITS[cat]:
+ x = 3.14
+ back = opt.to_display(opt.to_canonical(x))
+ assert back == pytest.approx(x, rel=1e-10)
+
+
+def test_canonical_option_has_identity_conversion():
+ for cat in UNITS:
+ c = get_canonical(cat)
+ assert c.to_canonical(42.0) == pytest.approx(42.0)
+ assert c.to_display(42.0) == pytest.approx(42.0)