All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 2m58s
359 lines
14 KiB
Python
359 lines
14 KiB
Python
"""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
|
|
|
|
|
|
@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"<style>{css_path.read_text(encoding='utf-8')}</style>", 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"""
|
|
<div class="product-header">
|
|
<div>
|
|
<div class="eyebrow">AESTEEL · DIESEL INJECTION DIAGNOSTICS</div>
|
|
<h1>ENGIN Diagnostic Console</h1>
|
|
<p>Akustyczna diagnostyka każdego cylindra — typ usterki, nasilenie i uzasadnienie.</p>
|
|
</div>
|
|
<div class="runtime-badge">● {mode}<br><span>CPU · LEAKAGE-SAFE</span></div>
|
|
</div>
|
|
""",
|
|
unsafe_allow_html=True,
|
|
)
|
|
|
|
|
|
def _render_summary(summary) -> None:
|
|
st.markdown(
|
|
f"""
|
|
<div class="status-strip status-{html.escape(summary.status_tone)}">
|
|
<div><span>STATUS SILNIKA</span><strong>{html.escape(summary.status)}</strong></div>
|
|
<div><span>NAJWYŻSZE NASILENIE</span><strong>{html.escape(summary.highest_severity_display)}</strong></div>
|
|
<div><span>WYMAGA UWAGI</span><strong>{summary.attention} / {summary.cylinders}</strong></div>
|
|
<div><span>ŚR. SCORE MODELU</span><strong>{_format_confidence(summary.mean_confidence)}</strong></div>
|
|
</div>
|
|
""",
|
|
unsafe_allow_html=True,
|
|
)
|
|
|
|
|
|
def _select_cylinder(session_key: str, cylinder: int) -> None:
|
|
st.session_state[session_key] = cylinder
|
|
|
|
|
|
def _render_cylinder_grid(analysis, session_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, 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("Kolejność: severity, odchylenie widma, następnie score modelu. Score nie jest skalibrowanym prawdopodobieństwem.")
|
|
|
|
|
|
def _render_cylinder_detail(analysis, cylinder: 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"""
|
|
<div class="diagnosis-card" style="--diagnosis-color:{color}">
|
|
<div>
|
|
<span class="diagnosis-kicker">CYLINDER {explanation.cylinder:02d}</span>
|
|
<h2>{html.escape(explanation.label_display)}</h2>
|
|
<p>{html.escape(explanation.severity_display)}</p>
|
|
</div>
|
|
<div class="diagnosis-metrics">
|
|
<div><span>Score label</span><strong>{_format_confidence(explanation.confidence)}</strong></div>
|
|
<div><span>Score severity</span><strong>{_format_confidence(severity_confidence)}</strong></div>
|
|
<div><span>Średnie odchylenie</span><strong>{explanation.anomaly_score:.1f} mV</strong></div>
|
|
</div>
|
|
</div>
|
|
""",
|
|
unsafe_allow_html=True,
|
|
)
|
|
spectrum_col, deviation_col = st.columns([1.25, 1.0], gap="large")
|
|
with spectrum_col:
|
|
st.plotly_chart(
|
|
cylinder_spectrum(analysis, cylinder),
|
|
width="stretch",
|
|
key=f"spectrum_{analysis.engine_id}_{cylinder}",
|
|
)
|
|
with deviation_col:
|
|
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("#### Dlaczego taka diagnoza?")
|
|
st.write(explanation.reason)
|
|
frequencies = " · ".join(f"{value} kHz" for value in explanation.top_frequencies)
|
|
st.markdown(f"**Najbardziej anomalne pasma:** `{frequencies}`")
|
|
with next_step:
|
|
st.markdown("#### Rekomendowany 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 decyzji: {source_label}")
|
|
st.caption("Score modelu służy do porównania predykcji; nie jest kalibrowanym prawdopodobieństwem awarii.")
|
|
|
|
|
|
def _render_technical(result: DiagnosisResult) -> None:
|
|
st.markdown("### Kontrola jakości i architektura")
|
|
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("Bramka jakości", "CI GREEN")
|
|
st.markdown(
|
|
"""
|
|
- **Zero leakage:** każdy fold zawiera kompletne, wcześniej niewidziane silniki.
|
|
- **Odporność na braki:** wynik sprawdzony przy dokładnie 5% zamaskowanych komórek widma.
|
|
- **CPU-first:** Logistic Regression + Extra Trees, bez GPU i z deterministycznymi seedami.
|
|
- **Explainability:** każda diagnoza korzysta z referencji pozostałych cylindrów tego samego silnika.
|
|
"""
|
|
)
|
|
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("Załadowano bezpieczny zestaw demonstracyjny")
|
|
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
|
|
_render_cylinder_grid(analysis, session_key)
|
|
|
|
overview_tab, detail_tab, technical_tab = st.tabs(
|
|
["Przegląd silnika", "Szczegóły cylindra", "Walidacja i model"]
|
|
)
|
|
with overview_tab:
|
|
_render_engine_overview(analysis)
|
|
with detail_tab:
|
|
selected_from_box = st.selectbox(
|
|
"Cylinder do analizy",
|
|
available,
|
|
format_func=lambda value: f"Cylinder {value:02d}",
|
|
key=session_key,
|
|
)
|
|
_render_cylinder_detail(analysis, int(selected_from_box))
|
|
with technical_tab:
|
|
_render_technical(result)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
render_app()
|