diff --git a/app.py b/app.py index 6a92774..ef6d4ba 100644 --- a/app.py +++ b/app.py @@ -39,6 +39,27 @@ MODEL_ARTIFACT_DIR = BASE_DIR / "artifacts" / MODEL_VERSION VIEW_OVERVIEW = "Przegląd" VIEW_DETAIL = "Szczegóły cylindra" VIEW_MODEL = "Model" +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) @@ -83,7 +104,7 @@ def build_dependencies() -> AppDependencies: "Artefakt modelu nie został załadowany. Aplikacja działa w bezpiecznym " "trybie demonstracyjnym na zapisanych predykcjach." ), - model_version="demo-precomputed", + model_version="tryb-demonstracyjny", ) @@ -110,17 +131,40 @@ def _format_confidence(value: float | int | None) -> str: return f"{100 * float(value):.0f}%" +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(live_inference: bool) -> None: - mode = "LIVE INFERENCE" if live_inference else "DEMO FALLBACK" + mode = "MODEL AKTYWNY" if live_inference else "TRYB DEMONSTRACYJNY" st.markdown( f"""
-
AESTEEL · DIESEL INJECTION DIAGNOSTICS
-

ENGIN Diagnostic Console

+
AESTEEL · DIAGNOSTYKA WTRYSKU DIESEL
+

Konsola diagnostyczna ENGIN

Diagnoza cylindra, nasilenie i następny krok.

-
● {mode}
CPU · LEAKAGE-SAFE
+
● {mode}
CPU · DANE LOKALNE
""", unsafe_allow_html=True, @@ -134,7 +178,7 @@ def _render_summary(summary) -> None:
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)}
+
ŚR. WYNIK MODELU{_format_confidence(summary.mean_confidence)}
""", unsafe_allow_html=True, @@ -171,24 +215,29 @@ def _render_cylinder_grid(analysis, session_key: str, view_key: str) -> None: def _render_engine_overview(analysis) -> None: - left, right = st.columns([1.45, 1.0], gap="large") + left, right = st.columns(2, gap="large") with left: - st.plotly_chart(engine_heatmap(analysis), width="stretch", key=f"heatmap_{analysis.engine_id}") + st.markdown("### Mapa odchyleń") + st.plotly_chart( + engine_heatmap(analysis), + width="stretch", + key=f"heatmap_{analysis.engine_id}", + config=PLOTLY_CONFIG, + ) 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["Wynik modelu"] = ranking["label_confidence"].map(_format_confidence) ranking["Priorytet"] = ranking["priority_display"] st.dataframe( - ranking[["Cylinder", "Diagnoza", "Nasilenie", "Score modelu", "Priorytet"]], + ranking[["Cylinder", "Diagnoza", "Nasilenie", "Wynik modelu", "Priorytet"]], hide_index=True, width="stretch", - height=315, + height=360, ) - st.caption("Priorytet: nasilenie → odchylenie → score modelu.") def _render_cylinder_detail( @@ -209,8 +258,8 @@ def _render_cylinder_detail(

{html.escape(explanation.severity_display)}

-
Score label{_format_confidence(explanation.confidence)}
-
Score severity{_format_confidence(severity_confidence)}
+
Wynik diagnozy{_format_confidence(explanation.confidence)}
+
Wynik oceny nasilenia{_format_confidence(severity_confidence)}
Średnie odchylenie{explanation.anomaly_score:.1f} mV
@@ -225,6 +274,7 @@ def _render_cylinder_detail( f"spectrum_{analysis.engine_id}_" + "_".join(str(value) for value in comparison_cylinders) ), + config=PLOTLY_CONFIG, ) st.markdown("#### Odchylenie cylindra głównego") @@ -232,6 +282,7 @@ def _render_cylinder_detail( deviation_chart(analysis, cylinder), width="stretch", key=f"deviation_{analysis.engine_id}_{cylinder}", + config=PLOTLY_CONFIG, ) why, next_step = st.columns(2, gap="large") @@ -242,30 +293,37 @@ def _render_cylinder_detail( st.markdown("#### Następny krok") st.write(explanation.recommendation) source_label = ( - "Reguła OOD" + "Reguła anomalii" if explanation.decision_source == "ood_override" else "Klasyfikator spektralny" ) - st.caption(f"Źródło: {source_label} · score nie jest prawdopodobieństwem.") + st.caption( + f"Źródło: {source_label} · wynik modelu 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") + 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( - "Grouped CV po engine_id · 5 seedów · Macro F1 przy 5% braków: 0.983 · CPU." + "Walidacja grupowa według silników · średnia z 5 uruchomień · " + "makro F1 przy 5% braków: 0.983 · CPU." ) with st.expander("Pokaż dane diagnostyczne"): - st.dataframe(result.diagnostics, width="stretch", hide_index=True) + 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="ENGIN Diagnostic Console", + page_title="Konsola diagnostyczna ENGIN", page_icon="⚙️", layout="wide", initial_sidebar_state="expanded", @@ -284,7 +342,7 @@ def render_app(dependencies: AppDependencies | None = None) -> None: payload: bytes | None if source == "Dane demonstracyjne": payload = deps.demo_payload - st.success("Demo załadowane") + 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 @@ -316,14 +374,14 @@ def render_app(dependencies: AppDependencies | None = None) -> None: with st.sidebar: engine_id = st.selectbox("Aktywny silnik", result.engine_ids) st.download_button( - "Pobierz predictions.csv", + "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 diagnostykę", + "Pobierz dane diagnostyczne (CSV)", data=result.diagnostics.to_csv(index=False).encode("utf-8"), file_name="prediction_diagnostics.csv", mime="text/csv", @@ -331,7 +389,8 @@ def render_app(dependencies: AppDependencies | None = None) -> None: ) st.divider() st.caption( - f"Model {deps.model_version} · CPU inference · brak połączeń zewnętrznych" + f"Wersja modelu: {deps.model_version} · wnioskowanie na CPU · " + "bez połączeń zewnętrznych" ) analysis = analyze_engine(result, str(engine_id)) diff --git a/assets/app.css b/assets/app.css index 4307f41..df6231f 100644 --- a/assets/app.css +++ b/assets/app.css @@ -98,6 +98,22 @@ [data-testid="stMetric"] { padding: .7rem; border: 1px solid var(--line); background: var(--panel); } [data-testid="stDataFrame"], [data-testid="stPlotlyChart"] { border: 1px solid var(--line); overflow: hidden; } +/* Streamlit 1.62 has no interface locale setting, so localize its uploader chrome. */ +[data-testid="stFileUploaderDropzone"] button [data-testid="stMarkdownContainer"] { display: none; } +[data-testid="stFileUploaderDropzone"] button::after { content: "Wybierz plik"; } +[data-testid="stFileUploaderDropzoneInstructions"] > div { display: none; } +[data-testid="stFileUploaderDropzoneInstructions"]::after { + content: "Maks. 10 MB na plik · CSV"; + color: var(--muted); + font-size: .875rem; + white-space: nowrap; +} +[data-testid="stFileUploaderDropzone"] > div:not([data-testid]) > span { font-size: 0; } +[data-testid="stFileUploaderDropzone"] > div:not([data-testid]) > span::after { + content: "Upuść plik tutaj"; + font-size: .875rem; +} + @media (max-width: 1000px) { .product-header { flex-direction: column; } .diagnosis-card { grid-template-columns: 1fr; } diff --git a/engin/charts.py b/engin/charts.py index ee4fb98..3f2afb0 100644 --- a/engin/charts.py +++ b/engin/charts.py @@ -57,10 +57,9 @@ def engine_heatmap(analysis: EngineAnalysis) -> go.Figure: hovertemplate="Cylinder %{y}
%{x} kHz
Odchylenie %{z:.1f} mV", ) ) - 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) + return _base_layout(fig, height=360, margin_top=18, margin_bottom=42) def _cylinder_position(analysis: EngineAnalysis, cylinder: int) -> int: diff --git a/tests/test_app_smoke.py b/tests/test_app_smoke.py index 3b3b452..3cd15af 100644 --- a/tests/test_app_smoke.py +++ b/tests/test_app_smoke.py @@ -19,6 +19,31 @@ class StreamlitSmokeTests(unittest.TestCase): self.assertFalse( any(selector.label == "Cylinder główny" for selector in app.selectbox) ) + rendered = "\n".join(markdown.value for markdown in app.markdown) + self.assertIn("Konsola diagnostyczna ENGIN", rendered) + for english_fragment in ( + "Diagnostic Console", + "LIVE INFERENCE", + "DEMO FALLBACK", + "LEAKAGE-SAFE", + "Grouped Macro F1", + "Severity accuracy", + ): + self.assertNotIn(english_fragment, rendered) + + app.segmented_control[0].set_value("Model").run() + self.assertEqual( + [metric.label for metric in app.metric], + [ + "Makro F1 (grupowe)", + "Trafność nasilenia", + "Punkty walidacyjne", + "Testy", + ], + ) + diagnostic_columns = set(app.dataframe[0].value.columns) + self.assertIn("Źródło decyzji", diagnostic_columns) + self.assertNotIn("decision_source", diagnostic_columns) def test_upload_mode_has_safe_empty_state(self) -> None: app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run() diff --git a/tests/test_explainability.py b/tests/test_explainability.py index 40e12b2..9791fab 100644 --- a/tests/test_explainability.py +++ b/tests/test_explainability.py @@ -63,7 +63,9 @@ 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) + heatmap = engine_heatmap(self.analysis) + self.assertEqual(len(heatmap.data), 1) + self.assertEqual(heatmap.layout.height, 360) self.assertEqual(len(cylinder_spectrum(self.analysis, cylinder).data), 2) self.assertEqual( len(cylinder_spectrum(self.analysis, cylinder, compared).data),