hackathon-ENGIN/app.py
Jakub Famulski 2 8e6dcb750d
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 2m58s
prod: load versioned model artifact at runtime
2026-08-25 13:06:14 +02:00

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()