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 @@ 🔬 Módulo Simulações 🧠 Redes Neurais - 📐 NSGA-II + 📐 NSGA-II / NSGA-III ⚙️ Tuning de HPs + + + + + +
-
🔒 Restrições (penalidade)
+
🔒 Restrições nativas (n_ieq_constr=5)
+

Declaradas nativamente no pymoo, em unidades físicas:

+

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
    -
  1. Detecção automática dos arquivos CSV nas fontes acima.
  2. -
  3. Validação de colunas (12 features, 8 targets) e remoção de NaN/Inf por mediana da coluna.
  4. -
  5. Divisão 70/15/15 (treino/validação/teste) com seed=42.
  6. -
  7. Ajuste do StandardScaler exclusivamente no conjunto de treino.
  8. +
  9. Detecção automática dos arquivos CSV (layout flat ou aninhado).
  10. +
  11. 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).
  12. +
  13. Divisão 70/15/15 treino/validação/teste com random_state igual ao --seed informado (propagado para Python, NumPy e TensorFlow).
  14. +
  15. Ajuste do StandardScaler X e Y exclusivamente no conjunto de treino.
  16. Carregamento dos hiperparâmetros de training/best_hps_{ARCH}_values.json.
  17. -
  18. Treinamento com EarlyStopping (patience=20, monitor=val_loss).
  19. -
  20. Salva CSTR_{ARCH}_Surrogate.keras, scalerX.pkl, scalerY.pkl e data_meta.json.
  21. -
  22. Registra MAE de validação e teste em training_results.csv para ranking.
  23. +
  24. Treinamento com três callbacks: +
      +
    • EarlyStoppingpatience=20, restore_best_weights
    • +
    • ReduceLROnPlateaufactor=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)
    • +
    +
  25. +
  26. Cálculo de métricas globais (MAE, RMSE, R²) e per-target — 8 MAEs separados revelam falhas ocultas em alvos específicos.
  27. +
  28. Persistência dual: CSTR_{ARCH}_Surrogate.keras, scalerX.pkl (legado) + scalerX.json (SHA-256), scalerY.pkl + scalerY.json, data_meta.json.
  29. +
  30. 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.
  31. +
  32. 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étricaFórmulaPor target?Em models.json
MAE∑|y_true − y_pred| / N
RMSE√(∑(y_true − y_pred)² / N)
1 − SS_res / SS_tot
MAE globalMédia das 8 MAEs✗ (agregada)
+
+
@@ -1040,7 +1115,7 @@

Arquiteturas dos Modelos Substitutos


@@ -1200,7 +1275,575 @@

Busca Automática de Hiperparâmetros

- + +
+ +

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:

+
+ + + + + + + + + + + + + + +
CampoTipoSignificado
n_rows / n_colsintDimensão do array validado
column_namestuple[str]Nomes das colunas (features ou targets)
nan_rowsnp.ndarray[bool]Máscara booleana das linhas com qualquer NaN
inf_rowsnp.ndarray[bool]Máscara com ±Inf
bound_violationsdict[str, np.ndarray]Por coluna: máscara de valores fora de [lo, hi]
flag_violationsdict[str, np.ndarray]Por flag: máscara de valores ≠ {0, 1} (tolerância 1e-9)
is_validproperty → boolTrue se não há NaN/Inf/flags inválidas
is_cleanproperty → boolMais 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)→ strResumo 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ávelLimite inferiorLimite superiorUnidade
vazao_feed1,0×10⁻⁵5,0×10⁻³m³/s
CA0_feed, CB0_feed0,05.000,0mol/m³
Tf_feed, T_amb_feed, T_obs200,0600,0K
valve_pos0,01,0
w_j0,01,0×10⁻²m³/s
X (target)0,01,0
cat_activity (target)0,01,01— (folga numérica)
fouling_thickness (target)0,00,1m
+
+
+
+ + +
+ +

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!" +
+
+
+ + +
+ +

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)
+
+ + + + + + + + + + + + + + + + + + + + + +
CategoriaCampoFunção
Identidadearchitecture"MLP", "RNN", "CNN" ou "XGBoost"
trained_atTimestamp ISO-8601 UTC
seedSemente aleatória (Python/NumPy/TF)
frameworkEx.: "tensorflow==2.16.1"
Artefatoskeras_file + keras_sha256Arquivo .keras e seu hash SHA-256
scaler_x_file + scaler_x_sha256Scaler de entrada
scaler_y_file + scaler_y_sha256Scaler de saída
Scheman_featuresSempre 12 (ou 23 se XGBoost com engineering)
n_targets8
feature_namesTupla com os 12 nomes canônicos
target_namesTupla com os 8 nomes
Métricasglobal_metrics{mae, rmse, r2} agregado
per_target_metrics{target: {mae, rmse, r2}} para cada um dos 8
Metan_train / n_val / n_testTamanhos dos splits
hyperparametersDict com os HPs usados (lido de best_hps_*.json)
notesTexto 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çãoPropó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.
+
+
+
+ + +
+ +

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 +
+
+
+ + +
+ +

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étodoPrincípioAssinaturaRetorno
TOPSIS
Hwang & Yoon 1981
Distância combinada ao ideal positivo e negativotopsis(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 pareadaspromethee_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étricoknee_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 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 derivadaFórmulaSignificado físico
1dT_obs_feedT_obs − Tf_feedForça motriz do resfriamento/aquecimento
2dT_feed_ambTf_feed − T_amb_feedProxy de perdas térmicas externas
3dT_obs_ambT_obs − T_amb_feedDelta T total para ambiente
4CA_over_CBCA0_feed / CB0_feedRazão estequiométrica de alimentação (div. segura)
5C_total_feedCA0_feed + CB0_feedConcentração total alimentada
6valve_flowvalve_pos · vazao_feedVazão efetiva após válvula
7jacket_ratiow_j / vazao_feedCapacidade relativa da jaqueta (div. segura)
8failure_anymax(4 flags)Qualquer falha ativa (0/1)
9failure_countsum(4 flags)Contagem de falhas simultâneas
10log_vazaolog₁₀(vazao_feed) com clip εEscala multiplicativa da vazão
11log_wjlog₁₀(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.
+
+
+
+ + +
+ +

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
+
+ + + + + + + + + + + +
ArquivoO que cobre
test_safe_serialization.py8Roundtrip JSON, detecção de hash inconsistente, load_scaler_any com precedência, compatibilidade com múltiplas classes de scaler.
test_feature_engineering.py7Correção dos 11 valores derivados, robustez de safe_div e safe_log10, filtro include=, validação de shape.
test_data_validation.py9Detecçã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.py6Build + write + read roundtrip, merge incremental via update_manifest_entry, detecção de corrupção via verify_integrity.
test_uncertainty.py8Cobertura empírica do conformal ≥ 1−α, mean/std do ensemble, validação de shape, guarda contra ensemble de 1 membro.
test_decision_methods.py8Sensibilidade do TOPSIS a pesos, knee-point interior, contagem de votos em consensus_ranking, rejeição de entradas inválidas.
Total46100% 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.
+
+
+
+ + +
+ +

CLI & API Interna

+

Todos os comandos e classes públicas do CSTRChemIA em um lugar só.

+
+ +
+
🖥️ Comandos de linha de comando
+
+ + + + + + + + + + +
ComandoPropósito
python main.pyInicia a aplicação Dash (porta 8050)
python -m training.train_surrogate --model allTreina as 3 redes neurais (MLP, RNN, CNN) e escreve models.json
python -m training.train_surrogate --model MLP --seed 17Treina apenas MLP com seed customizada (para membro de ensemble)
python -m training.train_xgboost_baselineTreina o baseline XGBoost com feature engineering
python -m training.sync_hyperparametersCopia os best_hps_*.json de Tuning_NeuralNetwork/output/ para training/
python -m pytest tests/ -vExecuta 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.
+
+
+
+ +

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. + +[![CI](https://img.shields.io/badge/CI-GitHub_Actions-2088FF?logo=github-actions&logoColor=white)](./.github/workflows/ci.yml) +[![Python](https://img.shields.io/badge/Python-3.11%20%7C%203.12%20%7C%203.13-3776AB?logo=python&logoColor=white)](https://python.org) +[![Dash](https://img.shields.io/badge/Dash-2.9+-019999?logo=plotly&logoColor=white)](https://dash.plotly.com/) +[![TensorFlow](https://img.shields.io/badge/TensorFlow-2.16+-FF6F00?logo=tensorflow&logoColor=white)](https://tensorflow.org) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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)