"""ENGIN industrial diagnostic console built on the tested application core.""" from __future__ import annotations import html import logging from dataclasses import dataclass from pathlib import Path import numpy as np import pandas as pd import streamlit as st from engin.charts import cylinder_spectrum, deviation_chart, engine_heatmap from engin.config import ( LABEL_COLORS, LABEL_DISPLAY, LABEL_ICONS, NOT_APPLICABLE, SEVERITY_DISPLAY, AppConfig, ) from engin.errors import UserFacingError from engin.explainability import ( analyze_engine, explain_cylinder, rank_cylinders, summarize_engine, ) from engin.io import PandasCsvReader from engin.model import PrecomputedPredictionModel, SklearnPredictionModel from engin.service import DiagnosisResult, DiagnosticService from engin.validation import SpectrumFrameValidator LOGGER = logging.getLogger("engin.app") BASE_DIR = Path(__file__).resolve().parent MODEL_VERSION = "engin-2026.08.25-1" MODEL_ARTIFACT_DIR = BASE_DIR / "artifacts" / MODEL_VERSION VIEW_OVERVIEW = "Przegląd" VIEW_DETAIL = "Szczegóły cylindra" VIEW_MODEL = "Model" @dataclass(frozen=True) class AppDependencies: service: DiagnosticService demo_payload: bytes live_inference: bool = True startup_warning: str | None = None model_version: str = "unknown" def _reader() -> PandasCsvReader: return PandasCsvReader(AppConfig().max_upload_bytes) @st.cache_resource(show_spinner="Ładowanie modelu diagnostycznego…") def build_dependencies() -> AppDependencies: reader = _reader() validator = SpectrumFrameValidator() demo_path = BASE_DIR / "test.csv" demo_payload = demo_path.read_bytes() try: model = SklearnPredictionModel.from_artifact( MODEL_ARTIFACT_DIR, expected_model_version=MODEL_VERSION, ) return AppDependencies( service=DiagnosticService(reader=reader, validator=validator, model=model), demo_payload=demo_payload, model_version=MODEL_VERSION, ) except Exception: LOGGER.exception("Live model initialization failed; enabling demo fallback") submission = pd.read_csv(BASE_DIR / "predictions.csv") diagnostics = pd.read_csv(BASE_DIR / "prediction_diagnostics.csv") fallback = PrecomputedPredictionModel(submission, diagnostics) return AppDependencies( service=DiagnosticService(reader=reader, validator=validator, model=fallback), demo_payload=demo_payload, live_inference=False, startup_warning=( "Artefakt modelu nie został załadowany. Aplikacja działa w bezpiecznym " "trybie demonstracyjnym na zapisanych predykcjach." ), model_version="demo-precomputed", ) @st.cache_data(show_spinner="Analiza widm i klasyfikacja cylindrów…") def diagnose_payload(_service: DiagnosticService, payload: bytes) -> DiagnosisResult: return _service.diagnose_bytes(payload) def _load_css() -> None: css_path = BASE_DIR / "assets" / "app.css" st.markdown(f"", unsafe_allow_html=True) def _render_error(error: UserFacingError) -> None: st.error(f"**{error.title}**\n\n{error.message}") if error.hint: st.info(error.hint) st.caption(f"Kod błędu: `{error.code}`") def _format_confidence(value: float | int | None) -> str: if value is None or pd.isna(value): return "—" return f"{100 * float(value):.0f}%" def _render_header(live_inference: bool) -> None: mode = "LIVE INFERENCE" if live_inference else "DEMO FALLBACK" st.markdown( f"""
AESTEEL · DIESEL INJECTION DIAGNOSTICS

ENGIN Diagnostic Console

Diagnoza cylindra, nasilenie i następny krok.

● {mode}
CPU · LEAKAGE-SAFE
""", unsafe_allow_html=True, ) def _render_summary(summary) -> None: st.markdown( f"""
STATUS SILNIKA{html.escape(summary.status)}
NAJWYŻSZE NASILENIE{html.escape(summary.highest_severity_display)}
WYMAGA UWAGI{summary.attention} / {summary.cylinders}
ŚR. SCORE MODELU{_format_confidence(summary.mean_confidence)}
""", unsafe_allow_html=True, ) def _select_cylinder(session_key: str, view_key: str, cylinder: int) -> None: st.session_state[session_key] = cylinder st.session_state[view_key] = VIEW_DETAIL def _render_cylinder_grid(analysis, session_key: str, view_key: str) -> None: st.markdown("### Mapa cylindrów") columns = st.columns(4) selected_cylinder = int(st.session_state[session_key]) for index, row in analysis.diagnostics.iterrows(): cylinder = int(row["cylinder"]) label = str(row["label"]) severity = str(row["severity"]) icon = LABEL_ICONS[label] short_label = LABEL_DISPLAY[label] if severity != NOT_APPLICABLE: short_label += f" · {SEVERITY_DISPLAY[severity]}" button_label = f"{icon} C{cylinder:02d}\n{short_label}" with columns[index % 4]: st.button( button_label, key=f"cylinder_{analysis.engine_id}_{cylinder}", width="stretch", type="primary" if cylinder == selected_cylinder else "secondary", on_click=_select_cylinder, args=(session_key, view_key, cylinder), ) def _render_engine_overview(analysis) -> None: left, right = st.columns([1.45, 1.0], gap="large") with left: st.plotly_chart(engine_heatmap(analysis), width="stretch", key=f"heatmap_{analysis.engine_id}") with right: st.markdown("### Priorytet kontroli") ranking = rank_cylinders(analysis).head(6).copy() ranking["Cylinder"] = ranking["cylinder"].map(lambda value: f"C{int(value):02d}") ranking["Diagnoza"] = ranking["label_display"] ranking["Nasilenie"] = ranking["severity_display"] ranking["Score modelu"] = ranking["label_confidence"].map(_format_confidence) ranking["Priorytet"] = ranking["priority_display"] st.dataframe( ranking[["Cylinder", "Diagnoza", "Nasilenie", "Score modelu", "Priorytet"]], hide_index=True, width="stretch", height=315, ) st.caption("Priorytet: nasilenie → odchylenie → score modelu.") def _render_cylinder_detail( analysis, cylinder: int, comparison_cylinders: list[int], ) -> None: explanation = explain_cylinder(analysis, cylinder) row = analysis.diagnostics[analysis.diagnostics["cylinder"].eq(cylinder)].iloc[0] color = LABEL_COLORS[explanation.label] severity_confidence = row.get("severity_confidence", np.nan) st.markdown( f"""
CYLINDER {explanation.cylinder:02d}

{html.escape(explanation.label_display)}

{html.escape(explanation.severity_display)}

Score label{_format_confidence(explanation.confidence)}
Score severity{_format_confidence(severity_confidence)}
Średnie odchylenie{explanation.anomaly_score:.1f} mV
""", unsafe_allow_html=True, ) st.markdown("#### Widma porównawcze") st.plotly_chart( cylinder_spectrum(analysis, cylinder, comparison_cylinders), width="stretch", key=( f"spectrum_{analysis.engine_id}_" + "_".join(str(value) for value in comparison_cylinders) ), ) st.markdown("#### Odchylenie cylindra głównego") st.plotly_chart( deviation_chart(analysis, cylinder), width="stretch", key=f"deviation_{analysis.engine_id}_{cylinder}", ) why, next_step = st.columns(2, gap="large") with why: st.markdown("#### Uzasadnienie") st.write(explanation.reason) with next_step: st.markdown("#### Następny krok") st.write(explanation.recommendation) source_label = ( "Reguła OOD" if explanation.decision_source == "ood_override" else "Klasyfikator spektralny" ) st.caption(f"Źródło: {source_label} · score nie jest prawdopodobieństwem.") def _render_technical(result: DiagnosisResult) -> None: st.markdown("### Walidacja i model") c1, c2, c3, c4 = st.columns(4) c1.metric("Grouped Macro F1", "0.981") c2.metric("Severity accuracy", "0.930") c3.metric("Walidacyjne ML", "33.65 / 40") c4.metric("Testy", "38 / 38") st.caption( "Grouped CV po engine_id · 5 seedów · Macro F1 przy 5% braków: 0.983 · CPU." ) with st.expander("Pokaż dane diagnostyczne"): st.dataframe(result.diagnostics, width="stretch", hide_index=True) def render_app(dependencies: AppDependencies | None = None) -> None: st.set_page_config( page_title="ENGIN Diagnostic Console", page_icon="⚙️", layout="wide", initial_sidebar_state="expanded", ) _load_css() deps = dependencies or build_dependencies() _render_header(deps.live_inference) with st.sidebar: st.markdown("## Centrum diagnostyczne") source = st.radio( "Źródło danych", ["Dane demonstracyjne", "Wgraj plik CSV"], captions=["50 silników testowych", "Własne kompletne silniki 8/12/16"], ) payload: bytes | None if source == "Dane demonstracyjne": payload = deps.demo_payload st.success("Demo załadowane") else: uploaded = st.file_uploader("Plik pomiarowy CSV", type=["csv"]) payload = uploaded.getvalue() if uploaded is not None else None if payload is None: st.info("Wgraj CSV, aby rozpocząć diagnozę.") st.caption("Wymagane: kompletne silniki oraz mV_0...mV_20.") if deps.startup_warning: st.warning(deps.startup_warning) if payload is None: st.markdown("### Oczekiwanie na dane") st.write("Po wgraniu pliku aplikacja zweryfikuje strukturę przed uruchomieniem modelu.") return try: result = diagnose_payload(deps.service, payload) except UserFacingError as exc: _render_error(exc) return except Exception: LOGGER.exception("Unexpected application failure") st.error("**Nieoczekiwany błąd aplikacji**\n\nDiagnoza została bezpiecznie przerwana; dane nie zostały zmodyfikowane.") st.info("Uruchom aplikację ponownie lub użyj zestawu demonstracyjnego.") return for warning in result.warnings: st.warning(warning) with st.sidebar: engine_id = st.selectbox("Aktywny silnik", result.engine_ids) st.download_button( "Pobierz predictions.csv", data=result.submission.to_csv(index=False).encode("utf-8"), file_name="predictions.csv", mime="text/csv", width="stretch", ) st.download_button( "Pobierz diagnostykę", data=result.diagnostics.to_csv(index=False).encode("utf-8"), file_name="prediction_diagnostics.csv", mime="text/csv", width="stretch", ) st.divider() st.caption( f"Model {deps.model_version} · CPU inference · brak połączeń zewnętrznych" ) analysis = analyze_engine(result, str(engine_id)) summary = summarize_engine(analysis) _render_summary(summary) session_key = f"selected_cylinder_{analysis.engine_id}" available = analysis.measurements["cylinder"].astype(int).tolist() if session_key not in st.session_state or st.session_state[session_key] not in available: st.session_state[session_key] = summary.top_cylinder view_key = f"active_view_{analysis.engine_id}" if view_key not in st.session_state: st.session_state[view_key] = VIEW_OVERVIEW view = st.segmented_control( "Widok", [VIEW_OVERVIEW, VIEW_DETAIL, VIEW_MODEL], required=True, key=view_key, label_visibility="collapsed", width="stretch", ) if view == VIEW_OVERVIEW: _render_cylinder_grid(analysis, session_key, view_key) _render_engine_overview(analysis) elif view == VIEW_DETAIL: selector_col, comparison_col = st.columns([1.0, 2.0], gap="large") with selector_col: selected_from_box = st.selectbox( "Cylinder główny", available, format_func=lambda value: f"Cylinder {value:02d}", key=session_key, ) comparison_options = [ cylinder for cylinder in available if cylinder != selected_from_box ] with comparison_col: additional_cylinders = st.multiselect( "Porównaj z cylindrami", comparison_options, max_selections=3, format_func=lambda value: f"Cylinder {value:02d}", key=( f"comparison_{analysis.engine_id}_{int(selected_from_box)}" ), help="Cylinder główny jest zawsze pokazany; można dodać trzy kolejne.", ) comparison_cylinders = [ int(selected_from_box), *(int(value) for value in additional_cylinders), ] _render_cylinder_detail( analysis, int(selected_from_box), comparison_cylinders, ) elif view == VIEW_MODEL: _render_technical(result) if __name__ == "__main__": render_app()