diff --git a/app.py b/app.py index 3e3b8e5..6a92774 100644 --- a/app.py +++ b/app.py @@ -36,6 +36,9 @@ 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) @@ -115,7 +118,7 @@ def _render_header(live_inference: bool) -> None:
AESTEEL · DIESEL INJECTION DIAGNOSTICS

ENGIN Diagnostic Console

-

Akustyczna diagnostyka każdego cylindra — typ usterki, nasilenie i uzasadnienie.

+

Diagnoza cylindra, nasilenie i następny krok.

● {mode}
CPU · LEAKAGE-SAFE
@@ -138,11 +141,12 @@ def _render_summary(summary) -> None: ) -def _select_cylinder(session_key: str, cylinder: int) -> None: +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) -> None: +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]) @@ -162,7 +166,7 @@ def _render_cylinder_grid(analysis, session_key: str) -> None: width="stretch", type="primary" if cylinder == selected_cylinder else "secondary", on_click=_select_cylinder, - args=(session_key, cylinder), + args=(session_key, view_key, cylinder), ) @@ -184,10 +188,14 @@ def _render_engine_overview(analysis) -> None: width="stretch", height=315, ) - st.caption("Kolejność: severity, odchylenie widma, następnie score modelu. Score nie jest skalibrowanym prawdopodobieństwem.") + st.caption("Priorytet: nasilenie → odchylenie → score modelu.") -def _render_cylinder_detail(analysis, cylinder: int) -> None: +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] @@ -209,48 +217,47 @@ def _render_cylinder_detail(analysis, cylinder: int) -> None: """, 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}", - ) + 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("#### Dlaczego taka diagnoza?") + st.markdown("#### Uzasadnienie") 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.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 decyzji: {source_label}") - st.caption("Score modelu służy do porównania predykcji; nie jest kalibrowanym prawdopodobieństwem awarii.") + 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("### Kontrola jakości i architektura") + 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("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. - """ + 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) @@ -277,7 +284,7 @@ def render_app(dependencies: AppDependencies | None = None) -> None: payload: bytes | None if source == "Dane demonstracyjne": payload = deps.demo_payload - st.success("Załadowano bezpieczny zestaw demonstracyjny") + 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 @@ -335,22 +342,55 @@ def render_app(dependencies: AppDependencies | None = None) -> None: 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"] + 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", ) - with overview_tab: + + if view == VIEW_OVERVIEW: + _render_cylinder_grid(analysis, session_key, view_key) _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, + 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, ) - _render_cylinder_detail(analysis, int(selected_from_box)) - with technical_tab: + elif view == VIEW_MODEL: _render_technical(result) diff --git a/assets/app.css b/assets/app.css index 09ca6a4..4307f41 100644 --- a/assets/app.css +++ b/assets/app.css @@ -64,9 +64,9 @@ .diagnosis-card { --diagnosis-color: var(--cyan); - display: flex; + display: grid; + grid-template-columns: minmax(220px, .8fr) minmax(0, 1.6fr); align-items: center; - justify-content: space-between; gap: 2rem; padding: 1rem 1.25rem; margin: .5rem 0 1rem; @@ -77,7 +77,11 @@ .diagnosis-card h2 { margin: .08rem 0; color: var(--text); } .diagnosis-card p { margin: 0; color: var(--muted); } .diagnosis-kicker { color: var(--diagnosis-color); font: 800 .68rem ui-monospace, monospace; letter-spacing: .12em; } -.diagnosis-metrics { display: flex; gap: 2rem; } +.diagnosis-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(110px, 1fr)); + gap: 1rem; +} .diagnosis-metrics strong { display: block; margin-top: .2rem; color: var(--text); font-size: 1.05rem; } [data-testid="stButton"] button { @@ -92,16 +96,18 @@ [data-testid="stButton"] button[kind="primary"] { border-color: var(--cyan); background: rgba(40, 183, 217, .13); } [data-testid="stMetric"] { padding: .7rem; border: 1px solid var(--line); background: var(--panel); } -[data-testid="stDataFrame"], [data-testid="stPlotlyChart"] { border: 1px solid var(--line); } +[data-testid="stDataFrame"], [data-testid="stPlotlyChart"] { border: 1px solid var(--line); overflow: hidden; } -@media (max-width: 900px) { - .product-header, .diagnosis-card { flex-direction: column; } +@media (max-width: 1000px) { + .product-header { flex-direction: column; } + .diagnosis-card { grid-template-columns: 1fr; } .runtime-badge { width: 100%; } .status-strip { grid-template-columns: repeat(2, 1fr); } - .diagnosis-metrics { width: 100%; flex-wrap: wrap; gap: 1rem 1.6rem; } + .diagnosis-metrics { width: 100%; } } @media (max-width: 560px) { .status-strip { grid-template-columns: 1fr; } .status-strip > div { border-right: 0; border-bottom: 1px solid var(--line); } + .diagnosis-metrics { grid-template-columns: 1fr; } } diff --git a/docs/DEMO_SCENARIO.md b/docs/DEMO_SCENARIO.md index a0fcebc..64b14d8 100644 --- a/docs/DEMO_SCENARIO.md +++ b/docs/DEMO_SCENARIO.md @@ -41,4 +41,4 @@ Zamknięcie: „ENGIN daje trzy rzeczy naraz: konkursowo skuteczny model, weryfi - Jeśli nie ma internetu: aplikacja nie potrzebuje internetu. - Jeśli model nie wystartuje: automatycznie działa fallback na zapisanych predykcjach demo. - Jeśli brakuje czasu: pokaż ekran główny, jeden cylinder i metryki — około 90 sekund. -- Przed wystąpieniem uruchom `python -m unittest discover -v` i zachowaj terminal z wynikiem 32/32. +- Przed wystąpieniem uruchom `python -m unittest discover -v` i zachowaj terminal z wynikiem 38/38. diff --git a/engin/charts.py b/engin/charts.py index d802457..ee4fb98 100644 --- a/engin/charts.py +++ b/engin/charts.py @@ -2,6 +2,8 @@ from __future__ import annotations +from collections.abc import Sequence + import numpy as np import plotly.graph_objects as go @@ -12,12 +14,19 @@ PLOT_BG = "#101820" GRID = "rgba(148, 163, 184, 0.14)" TEXT = "#dce8ee" MUTED = "#8fa6b2" +COMPARISON_COLORS = ("#28b7d9", "#f5a524", "#38d996", "#d56cff", "#2f78ed") -def _base_layout(fig: go.Figure, *, height: int) -> go.Figure: +def _base_layout( + fig: go.Figure, + *, + height: int, + margin_top: int = 42, + margin_bottom: int = 28, +) -> go.Figure: fig.update_layout( height=height, - margin=dict(l=24, r=24, t=42, b=28), + margin=dict(l=24, r=24, t=margin_top, b=margin_bottom), paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG, font=dict(color=TEXT, family="Inter, system-ui, sans-serif"), @@ -48,19 +57,33 @@ def engine_heatmap(analysis: EngineAnalysis) -> go.Figure: hovertemplate="Cylinder %{y}
%{x} kHz
Odchylenie %{z:.1f} mV", ) ) - fig.update_layout(title="Engine Diagnostic Fingerprint") + fig.update_layout(title="Mapa odchyleń") fig.update_yaxes(autorange="reversed", title="Cylinder") fig.update_xaxes(title="Częstotliwość [kHz]", dtick=2) return _base_layout(fig, height=420) -def cylinder_spectrum(analysis: EngineAnalysis, cylinder: int) -> go.Figure: +def _cylinder_position(analysis: EngineAnalysis, cylinder: int) -> int: positions = np.flatnonzero(analysis.measurements["cylinder"].to_numpy() == cylinder) if len(positions) != 1: raise KeyError(f"Unknown cylinder={cylinder}") - position = int(positions[0]) + return int(positions[0]) + + +def cylinder_spectrum( + analysis: EngineAnalysis, + cylinder: int, + comparison_cylinders: Sequence[int] | None = None, +) -> go.Figure: + selected = list( + dict.fromkeys([cylinder, *(comparison_cylinders or ())]) + ) + if len(selected) > 4: + raise ValueError("At most four cylinders can be compared.") + + position = _cylinder_position(analysis, cylinder) row = analysis.diagnostics.iloc[position] - color = LABEL_COLORS[str(row["label"])] + primary_color = LABEL_COLORS[str(row["label"])] frequency = np.arange(len(FREQ_COLS)) fig = go.Figure() fig.add_trace( @@ -68,20 +91,33 @@ def cylinder_spectrum(analysis: EngineAnalysis, cylinder: int) -> go.Figure: x=frequency, y=analysis.reference[position], mode="lines", - name="Mediana pozostałych cylindrów", + name=f"Referencja C{cylinder:02d}", line=dict(color="#7f95a1", width=2, dash="dash"), ) ) - fig.add_trace( - go.Scatter( - x=frequency, - y=analysis.spectra[position], - mode="lines+markers", - name=f"Cylinder {cylinder}", - line=dict(color=color, width=3), - marker=dict(size=6), - ) + + extra_colors = iter( + color + for color in COMPARISON_COLORS + if color.lower() != primary_color.lower() ) + for selected_cylinder in selected: + selected_position = _cylinder_position(analysis, selected_cylinder) + color = primary_color if selected_cylinder == cylinder else next(extra_colors) + fig.add_trace( + go.Scatter( + x=frequency, + y=analysis.spectra[selected_position], + mode="lines+markers", + name=f"C{selected_cylinder:02d}", + line=dict( + color=color, + width=3 if selected_cylinder == cylinder else 2, + ), + marker=dict(size=6 if selected_cylinder == cylinder else 4), + ) + ) + top = { int(value) for value in str(row["top_anomalous_frequencies_khz"]).split("|") @@ -91,14 +127,22 @@ def cylinder_spectrum(analysis: EngineAnalysis, cylinder: int) -> go.Figure: fig.add_vrect( x0=value - 0.35, x1=value + 0.35, - fillcolor=color, + fillcolor=primary_color, opacity=0.10, line_width=0, ) - fig.update_layout(title="Widmo cylindra vs referencja", legend=dict(orientation="h", y=1.14)) + fig.update_layout( + legend=dict( + orientation="h", + x=0, + xanchor="left", + y=1.04, + yanchor="bottom", + ) + ) fig.update_xaxes(title="Częstotliwość [kHz]", dtick=1) fig.update_yaxes(title="Amplituda [mV]") - return _base_layout(fig, height=410) + return _base_layout(fig, height=440, margin_top=76, margin_bottom=42) def deviation_chart(analysis: EngineAnalysis, cylinder: int) -> go.Figure: @@ -116,7 +160,6 @@ def deviation_chart(analysis: EngineAnalysis, cylinder: int) -> go.Figure: ) ) fig.add_hline(y=0, line_color="#6e8794", line_width=1) - fig.update_layout(title="Odchylenie od referencji") fig.update_xaxes(title="Częstotliwość [kHz]", dtick=2) fig.update_yaxes(title="Różnica [mV]") - return _base_layout(fig, height=320) + return _base_layout(fig, height=340, margin_top=24, margin_bottom=42) diff --git a/tests/test_app_smoke.py b/tests/test_app_smoke.py index d45d526..3b3b452 100644 --- a/tests/test_app_smoke.py +++ b/tests/test_app_smoke.py @@ -13,8 +13,12 @@ class StreamlitSmokeTests(unittest.TestCase): app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run() self.assertEqual(len(app.exception), 0) self.assertGreaterEqual(len(app.button), 8) - self.assertGreaterEqual(len(app.selectbox), 2) + self.assertGreaterEqual(len(app.selectbox), 1) self.assertEqual(app.radio[0].value, "Dane demonstracyjne") + self.assertEqual(app.segmented_control[0].value, "Przegląd") + self.assertFalse( + any(selector.label == "Cylinder główny" for selector in app.selectbox) + ) def test_upload_mode_has_safe_empty_state(self) -> None: app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run() @@ -26,9 +30,14 @@ class StreamlitSmokeTests(unittest.TestCase): app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run() target = next(button for button in app.button if "C03" in button.label) target.click().run() + self.assertEqual(app.segmented_control[0].value, "Szczegóły cylindra") detail_selector = next( - selector for selector in app.selectbox if selector.label == "Cylinder do analizy" + selector for selector in app.selectbox if selector.label == "Cylinder główny" ) self.assertEqual(detail_selector.value, 3) + comparison = app.multiselect[0] + self.assertEqual(comparison.label, "Porównaj z cylindrami") + comparison.set_value([1, 2, 4]).run() + self.assertEqual(len(app.exception), 0) rendered = "\n".join(markdown.value for markdown in app.markdown) self.assertIn("CYLINDER 03", rendered) diff --git a/tests/test_explainability.py b/tests/test_explainability.py index 875fa33..40e12b2 100644 --- a/tests/test_explainability.py +++ b/tests/test_explainability.py @@ -62,6 +62,11 @@ class ExplainabilityTests(unittest.TestCase): def test_all_chart_factories_return_populated_figures(self) -> None: cylinder = int(self.analysis.measurements["cylinder"].iloc[0]) + compared = self.analysis.measurements["cylinder"].astype(int).head(4).tolist() self.assertEqual(len(engine_heatmap(self.analysis).data), 1) self.assertEqual(len(cylinder_spectrum(self.analysis, cylinder).data), 2) + self.assertEqual( + len(cylinder_spectrum(self.analysis, cylinder, compared).data), + 5, + ) self.assertEqual(len(deviation_chart(self.analysis, cylinder).data), 1)