All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 43s
459 lines
16 KiB
Python
459 lines
16 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 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,
|
|
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"
|
|
NO_COMPARISON = 0
|
|
PLOTLY_CONFIG = {"displayModeBar": False, "displaylogo": False}
|
|
DIAGNOSTIC_COLUMN_DISPLAY = {
|
|
"engine_id": "Silnik",
|
|
"cylinder": "Cylinder",
|
|
"label": "Diagnoza",
|
|
"severity": "Nasilenie",
|
|
"raw_model_label": "Surowa diagnoza modelu",
|
|
"decision_source": "Źródło decyzji",
|
|
"label_confidence": "Wynik diagnozy",
|
|
"raw_model_confidence": "Surowy wynik diagnozy",
|
|
"label_margin": "Margines decyzji",
|
|
"severity_confidence": "Wynik oceny nasilenia",
|
|
"anomaly_score_mean_abs_mv": "Średnie odchylenie bezwzględne [mV]",
|
|
"anomaly_ratio_to_engine_median": "Odchylenie względem mediany silnika",
|
|
"ood_absolute_threshold_mv": "Bezwzględny próg anomalii [mV]",
|
|
"ood_ratio_threshold": "Względny próg anomalii",
|
|
"top_anomalous_frequencies_khz": "Anomalne częstotliwości [kHz]",
|
|
"missing_spectral_cells": "Brakujące pomiary widma",
|
|
"model_version": "Wersja modelu",
|
|
"model_artifact_sha256": "Suma SHA-256 artefaktu",
|
|
}
|
|
|
|
|
|
@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="tryb-demonstracyjny",
|
|
)
|
|
|
|
|
|
@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 _diagnostics_for_display(frame: pd.DataFrame) -> pd.DataFrame:
|
|
display = frame.copy()
|
|
for column in ("label", "raw_model_label"):
|
|
if column in display:
|
|
display[column] = display[column].map(LABEL_DISPLAY).fillna(display[column])
|
|
if "severity" in display:
|
|
display["severity"] = (
|
|
display["severity"].map(SEVERITY_DISPLAY).fillna(display["severity"])
|
|
)
|
|
if "decision_source" in display:
|
|
display["decision_source"] = display["decision_source"].replace(
|
|
{
|
|
"classifier": "Klasyfikator spektralny",
|
|
"ood_override": "Reguła anomalii",
|
|
}
|
|
)
|
|
if "model_version" in display:
|
|
display["model_version"] = display["model_version"].replace(
|
|
{"demo-precomputed": "tryb demonstracyjny"}
|
|
)
|
|
return display.rename(columns=DIAGNOSTIC_COLUMN_DISPLAY)
|
|
|
|
|
|
def _render_header() -> None:
|
|
st.markdown(
|
|
"""
|
|
<div class="product-header">
|
|
<div class="eyebrow">AESTEEL · DIAGNOSTYKA WTRYSKU DIESEL</div>
|
|
<h1>Konsola diagnostyczna ENGIN</h1>
|
|
<p>Diagnoza cylindra, nasilenie i następny krok.</p>
|
|
</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>PIERWSZY DO KONTROLI</span><strong>C{summary.top_cylinder:02d}</strong></div>
|
|
</div>
|
|
""",
|
|
unsafe_allow_html=True,
|
|
)
|
|
|
|
|
|
def _render_engine_overview(analysis) -> None:
|
|
left, right = st.columns(2, gap="large", vertical_alignment="top")
|
|
with left:
|
|
st.markdown(
|
|
'<div class="overview-panel-title">Mapa odchyleń</div>',
|
|
unsafe_allow_html=True,
|
|
)
|
|
st.plotly_chart(
|
|
engine_heatmap(analysis),
|
|
width="stretch",
|
|
key=f"heatmap_{analysis.engine_id}",
|
|
config=PLOTLY_CONFIG,
|
|
)
|
|
with right:
|
|
st.markdown(
|
|
'<div class="overview-panel-title">Priorytet kontroli</div>',
|
|
unsafe_allow_html=True,
|
|
)
|
|
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["Priorytet"] = ranking["priority_display"]
|
|
st.dataframe(
|
|
ranking[["Cylinder", "Diagnoza", "Nasilenie", "Priorytet"]],
|
|
hide_index=True,
|
|
width="stretch",
|
|
height=360,
|
|
)
|
|
|
|
|
|
def _render_cylinder_detail(
|
|
analysis,
|
|
cylinder: int,
|
|
comparison_cylinders: list[int],
|
|
) -> None:
|
|
explanation = explain_cylinder(analysis, cylinder)
|
|
color = LABEL_COLORS[explanation.label]
|
|
priority = rank_cylinders(analysis).loc[
|
|
lambda frame: frame["cylinder"].eq(cylinder), "priority_display"
|
|
].iloc[0]
|
|
top_bands = ", ".join(f"{value} kHz" for value in explanation.top_frequencies)
|
|
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>Priorytet</span><strong>{html.escape(priority)}</strong></div>
|
|
<div><span>Średnie odchylenie</span><strong>{explanation.anomaly_score:.1f} mV</strong></div>
|
|
<div><span>Główne pasma</span><strong>{html.escape(top_bands)}</strong></div>
|
|
</div>
|
|
</div>
|
|
""",
|
|
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)
|
|
),
|
|
config=PLOTLY_CONFIG,
|
|
)
|
|
|
|
st.markdown("#### Odchylenie cylindra głównego")
|
|
st.plotly_chart(
|
|
deviation_chart(analysis, cylinder),
|
|
width="stretch",
|
|
key=f"deviation_{analysis.engine_id}_{cylinder}",
|
|
config=PLOTLY_CONFIG,
|
|
)
|
|
|
|
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)
|
|
|
|
|
|
def _store_selected_cylinder(state_key: str, widget_key: str) -> None:
|
|
st.session_state[state_key] = int(st.session_state[widget_key])
|
|
|
|
|
|
def _comparison_label(value: int) -> str:
|
|
if value == NO_COMPARISON:
|
|
return "Bez porównania"
|
|
return f"Cylinder {value:02d}"
|
|
|
|
|
|
def _render_cylinder_selectors(
|
|
analysis,
|
|
available: list[int],
|
|
selected_state_key: str,
|
|
) -> tuple[int, list[int]]:
|
|
selector_key = f"cylinder_selector_{analysis.engine_id}"
|
|
selected = int(st.session_state[selected_state_key])
|
|
if selector_key not in st.session_state or st.session_state[selector_key] not in available:
|
|
st.session_state[selector_key] = selected
|
|
|
|
with st.container(border=True):
|
|
st.markdown("### Wybór cylindra")
|
|
columns = st.columns([1.35, 1.0, 1.0, 1.0], gap="medium")
|
|
with columns[0]:
|
|
primary = int(
|
|
st.selectbox(
|
|
"Cylinder do analizy",
|
|
available,
|
|
format_func=lambda value: f"Cylinder {value:02d}",
|
|
key=selector_key,
|
|
on_change=_store_selected_cylinder,
|
|
args=(selected_state_key, selector_key),
|
|
)
|
|
)
|
|
|
|
comparisons: list[int] = []
|
|
for slot, column in enumerate(columns[1:], start=1):
|
|
comparison_key = f"comparison_slot_{analysis.engine_id}_{slot}"
|
|
options = [
|
|
NO_COMPARISON,
|
|
*(
|
|
cylinder
|
|
for cylinder in available
|
|
if cylinder != primary and cylinder not in comparisons
|
|
),
|
|
]
|
|
if (
|
|
comparison_key not in st.session_state
|
|
or st.session_state[comparison_key] not in options
|
|
):
|
|
st.session_state[comparison_key] = NO_COMPARISON
|
|
with column:
|
|
comparison = int(
|
|
st.selectbox(
|
|
f"Porównanie {slot}",
|
|
options,
|
|
format_func=_comparison_label,
|
|
key=comparison_key,
|
|
)
|
|
)
|
|
if comparison != NO_COMPARISON:
|
|
comparisons.append(comparison)
|
|
|
|
st.session_state[selected_state_key] = primary
|
|
return primary, comparisons
|
|
|
|
|
|
def _render_technical(result: DiagnosisResult) -> None:
|
|
st.markdown("### Walidacja i model")
|
|
c1, c2, c3, c4 = st.columns(4)
|
|
c1.metric("Makro F1 (grupowe)", "0.981")
|
|
c2.metric("Trafność nasilenia", "0.930")
|
|
c3.metric("Punkty walidacyjne", "33.65 / 40")
|
|
c4.metric("Testy", "38 / 38")
|
|
st.caption(
|
|
"Walidacja grupowa według silników · średnia z 5 uruchomień · "
|
|
"makro F1 przy 5% braków: 0.983 · wnioskowanie lokalne na CPU."
|
|
)
|
|
st.markdown("#### Dane diagnostyczne")
|
|
st.dataframe(
|
|
_diagnostics_for_display(result.diagnostics),
|
|
width="stretch",
|
|
hide_index=True,
|
|
)
|
|
|
|
|
|
def render_app(dependencies: AppDependencies | None = None) -> None:
|
|
st.set_page_config(
|
|
page_title="Konsola diagnostyczna ENGIN",
|
|
page_icon="⚙️",
|
|
layout="wide",
|
|
initial_sidebar_state="expanded",
|
|
)
|
|
_load_css()
|
|
deps = dependencies or build_dependencies()
|
|
_render_header()
|
|
|
|
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("Dane demonstracyjne 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 wyniki (CSV)",
|
|
data=result.submission.to_csv(index=False).encode("utf-8"),
|
|
file_name="predictions.csv",
|
|
mime="text/csv",
|
|
width="stretch",
|
|
)
|
|
st.download_button(
|
|
"Pobierz dane diagnostyczne (CSV)",
|
|
data=result.diagnostics.to_csv(index=False).encode("utf-8"),
|
|
file_name="prediction_diagnostics.csv",
|
|
mime="text/csv",
|
|
width="stretch",
|
|
)
|
|
analysis = analyze_engine(result, str(engine_id))
|
|
summary = summarize_engine(analysis)
|
|
_render_summary(summary)
|
|
|
|
session_key = f"active_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],
|
|
required=True,
|
|
key=view_key,
|
|
label_visibility="collapsed",
|
|
width="stretch",
|
|
)
|
|
|
|
if view == VIEW_OVERVIEW:
|
|
_render_engine_overview(analysis)
|
|
elif view == VIEW_DETAIL:
|
|
selected_from_box, additional_cylinders = _render_cylinder_selectors(
|
|
analysis,
|
|
available,
|
|
session_key,
|
|
)
|
|
comparison_cylinders = [
|
|
int(selected_from_box),
|
|
*(int(value) for value in additional_cylinders),
|
|
]
|
|
_render_cylinder_detail(
|
|
analysis,
|
|
int(selected_from_box),
|
|
comparison_cylinders,
|
|
)
|
|
|
|
with st.expander("Informacje techniczne", expanded=False):
|
|
_render_technical(result)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
render_app()
|