From dab5264ed723b46fb12b41f5f52fcd700a33e186 Mon Sep 17 00:00:00 2001 From: Jakub Famulski 2 Date: Tue, 25 Aug 2026 15:22:35 +0200 Subject: [PATCH] ui: focus mechanic workflow and clarify health map --- app.py | 68 +++++++++++------------------------- assets/app.css | 55 +++++++++++++++++++++-------- docs/DEMO_SCENARIO.md | 19 +++++----- docs/REVIEW_RESPONSE.md | 5 +-- engin/charts.py | 20 ++++++----- tests/test_app_smoke.py | 33 +++++++++++++---- tests/test_explainability.py | 4 +++ 7 files changed, 117 insertions(+), 87 deletions(-) diff --git a/app.py b/app.py index ef6d4ba..d37f8da 100644 --- a/app.py +++ b/app.py @@ -15,8 +15,6 @@ 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, ) @@ -38,7 +36,6 @@ 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" PLOTLY_CONFIG = {"displayModeBar": False, "displaylogo": False} DIAGNOSTIC_COLUMN_DISPLAY = { "engine_id": "Silnik", @@ -155,7 +152,14 @@ def _diagnostics_for_display(frame: pd.DataFrame) -> pd.DataFrame: def _render_header(live_inference: bool) -> None: - mode = "MODEL AKTYWNY" if live_inference else "TRYB DEMONSTRACYJNY" + if live_inference: + mode = "MODEL GOTOWY" + verification = "ARTEFAKT ZWERYFIKOWANY" + tone = "verified" + else: + mode = "TRYB DEMONSTRACYJNY" + verification = "ZAPISANE WYNIKI" + tone = "fallback" st.markdown( f"""
@@ -164,7 +168,7 @@ def _render_header(live_inference: bool) -> None:

Konsola diagnostyczna ENGIN

Diagnoza cylindra, nasilenie i następny krok.

-
● {mode}
CPU · DANE LOKALNE
+
● {mode}
{verification}
""", unsafe_allow_html=True, @@ -185,35 +189,6 @@ def _render_summary(summary) -> 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, view_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, view_key, cylinder), - ) - - def _render_engine_overview(analysis) -> None: left, right = st.columns(2, gap="large") with left: @@ -311,14 +286,14 @@ def _render_technical(result: DiagnosisResult) -> None: 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 · CPU." + "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, ) - with st.expander("Pokaż dane diagnostyczne"): - st.dataframe( - _diagnostics_for_display(result.diagnostics), - width="stretch", - hide_index=True, - ) def render_app(dependencies: AppDependencies | None = None) -> None: @@ -388,10 +363,7 @@ def render_app(dependencies: AppDependencies | None = None) -> None: width="stretch", ) st.divider() - st.caption( - f"Wersja modelu: {deps.model_version} · wnioskowanie na CPU · " - "bez połączeń zewnętrznych" - ) + st.caption(f"Wersja modelu: {deps.model_version}") analysis = analyze_engine(result, str(engine_id)) summary = summarize_engine(analysis) @@ -407,7 +379,7 @@ def render_app(dependencies: AppDependencies | None = None) -> None: st.session_state[view_key] = VIEW_OVERVIEW view = st.segmented_control( "Widok", - [VIEW_OVERVIEW, VIEW_DETAIL, VIEW_MODEL], + [VIEW_OVERVIEW, VIEW_DETAIL], required=True, key=view_key, label_visibility="collapsed", @@ -415,7 +387,6 @@ def render_app(dependencies: AppDependencies | None = None) -> None: ) if view == VIEW_OVERVIEW: - _render_cylinder_grid(analysis, session_key, view_key) _render_engine_overview(analysis) elif view == VIEW_DETAIL: selector_col, comparison_col = st.columns([1.0, 2.0], gap="large") @@ -449,7 +420,8 @@ def render_app(dependencies: AppDependencies | None = None) -> None: int(selected_from_box), comparison_cylinders, ) - elif view == VIEW_MODEL: + + with st.expander("Informacje techniczne", expanded=False): _render_technical(result) diff --git a/assets/app.css b/assets/app.css index df6231f..7606c17 100644 --- a/assets/app.css +++ b/assets/app.css @@ -9,6 +9,7 @@ --green: #38d996; --orange: #f5a524; --red: #ff6b57; + --radius: 6px; } [data-testid="stAppViewContainer"] { @@ -32,27 +33,35 @@ border-bottom: 1px solid var(--line); } -.product-header h1 { margin: .2rem 0 .25rem; color: #f3f8fa; font-size: 2rem; letter-spacing: -.035em; } +.product-header h1 { margin: .2rem 0 .25rem; color: #f3f8fa; font-size: 2rem; line-height: 1.12; letter-spacing: -.035em; } .product-header p { margin: 0; color: var(--muted); } .eyebrow { color: var(--cyan); font-size: .7rem; font-weight: 800; letter-spacing: .17em; } .runtime-badge { - min-width: 170px; + min-width: 218px; padding: .7rem .9rem; border: 1px solid rgba(56, 217, 150, .32); + border-radius: var(--radius); background: rgba(56, 217, 150, .07); color: var(--green); font: 700 .72rem/1.35 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .06em; } .runtime-badge span { color: var(--muted); font-size: .64rem; } +.runtime-fallback { + border-color: rgba(245, 165, 36, .38); + background: rgba(245, 165, 36, .08); + color: var(--orange); +} .status-strip { display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid var(--line); border-left: 4px solid var(--green); + border-radius: var(--radius); background: linear-gradient(90deg, rgba(56, 217, 150, .07), rgba(16, 27, 34, .92)); margin-bottom: 1.5rem; + overflow: hidden; } .status-strip > div { padding: 1rem 1.2rem; border-right: 1px solid var(--line); } .status-strip > div:last-child { border-right: 0; } @@ -60,7 +69,10 @@ .status-strip strong { display: block; margin-top: .2rem; font-size: 1.28rem; color: var(--text); } .status-warning { border-left-color: var(--orange); background: linear-gradient(90deg, rgba(245, 165, 36, .08), rgba(16, 27, 34, .92)); } .status-critical { border-left-color: var(--red); background: linear-gradient(90deg, rgba(255, 107, 87, .09), rgba(16, 27, 34, .92)); } -.status-unknown { border-left-color: #d56cff; } +.status-unknown { + border-left-color: #d56cff; + background: linear-gradient(90deg, rgba(213, 108, 255, .08), rgba(16, 27, 34, .92)); +} .diagnosis-card { --diagnosis-color: var(--cyan); @@ -73,6 +85,7 @@ background: linear-gradient(90deg, color-mix(in srgb, var(--diagnosis-color) 12%, transparent), var(--panel)); border: 1px solid var(--line); border-left: 4px solid var(--diagnosis-color); + border-radius: var(--radius); } .diagnosis-card h2 { margin: .08rem 0; color: var(--text); } .diagnosis-card p { margin: 0; color: var(--muted); } @@ -84,19 +97,31 @@ } .diagnosis-metrics strong { display: block; margin-top: .2rem; color: var(--text); font-size: 1.05rem; } -[data-testid="stButton"] button { - min-height: 4.2rem; - white-space: pre-line; - border-radius: 3px; - border-color: var(--line); +[data-testid="stMetric"] { + min-height: 5.2rem; + padding: .75rem; + border: 1px solid var(--line); + border-radius: var(--radius); background: var(--panel); - font-weight: 650; } -[data-testid="stButton"] button:hover { border-color: var(--cyan); color: #fff; } -[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); overflow: hidden; } +[data-testid="stDataFrame"], [data-testid="stPlotlyChart"] { + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel); + overflow: hidden; +} +[data-testid="stDownloadButton"] button, +[data-testid="stFileUploaderDropzone"] { + border-color: var(--line); + border-radius: var(--radius); +} +[data-testid="stDownloadButton"] button:hover { border-color: var(--cyan); } +[data-testid="stExpander"] details { + border-color: var(--line); + border-radius: var(--radius); + background: rgba(16, 27, 34, .58); +} +[data-baseweb="select"] > div { border-radius: var(--radius); } /* Streamlit 1.62 has no interface locale setting, so localize its uploader chrome. */ [data-testid="stFileUploaderDropzone"] button [data-testid="stMarkdownContainer"] { display: none; } @@ -123,7 +148,9 @@ } @media (max-width: 560px) { + .product-header h1 { font-size: 1.65rem; } .status-strip { grid-template-columns: 1fr; } .status-strip > div { border-right: 0; border-bottom: 1px solid var(--line); } + .status-strip > div:last-child { border-bottom: 0; } .diagnosis-metrics { grid-template-columns: 1fr; } } diff --git a/docs/DEMO_SCENARIO.md b/docs/DEMO_SCENARIO.md index 64b14d8..57546bd 100644 --- a/docs/DEMO_SCENARIO.md +++ b/docs/DEMO_SCENARIO.md @@ -7,8 +7,8 @@ ## 0:35–1:15 — ekran silnika 1. Uruchom `streamlit run app.py` i pozostaw „Dane demonstracyjne”. -2. Wskaż status silnika, najwyższe severity i liczbę cylindrów wymagających uwagi. -3. Pokaż mapę cylindrów oraz ranking kontroli. +2. Wskaż status silnika, najwyższe nasilenie i liczbę cylindrów wymagających uwagi. +3. Pokaż mapę odchyleń: diagnoza jest zapisana przy każdym cylindrze, a stała skala kolorów pozwala porównywać silniki. Komunikat: „Nie zmuszamy mechanika do czytania 21 wykresów. Najpierw widzi najważniejszy cylinder i pilność.” @@ -16,29 +16,30 @@ Komunikat: „Nie zmuszamy mechanika do czytania 21 wykresów. Najpierw widzi na 1. Otwórz „Szczegóły cylindra”. 2. Wybierz najwyżej sklasyfikowany cylinder. -3. Porównaj jego widmo z medianą pozostałych cylindrów tego samego silnika. -4. Wskaż zaznaczone pasma, score modelu, severity i rekomendowany następny krok. +3. Opcjonalnie dodaj drugi cylinder do porównania. +4. Porównaj widmo z medianą pozostałych cylindrów tego samego silnika. +5. Wskaż zaznaczone pasma, wynik modelu, nasilenie i rekomendowany następny krok. Komunikat: „Referencją jest konkretny silnik, nie abstrakcyjna średnia całej floty. Dzięki temu wynik jest łatwiejszy do zweryfikowania przez człowieka.” ## 2:20–3:05 — odporność -1. Przejdź do „Walidacja i model”. -2. Pokaż grouped Macro F1 0.981, severity 0.930 i wynik przy 5% braków. -3. Powiedz, że cały silnik jest zawsze w jednym foldzie i że inference działa na CPU. +1. Rozwiń „Informacje techniczne”. +2. Pokaż grupowe makro F1 0.981, trafność nasilenia 0.930 i wynik przy 5% braków. +3. Powiedz, że cały silnik jest zawsze w jednym zbiorze walidacyjnym, a model działa na CPU. Komunikat: „Wynik nie powstał przez przeciek między cylindrami tego samego silnika. Pipeline jest też testowany na brakach odpowiadających realnym danym warsztatowym.” ## 3:05–3:40 — własny plik i wartość 1. Pokaż opcję „Wgraj plik CSV”, ale nie przełączaj jej, jeśli czas jest napięty. -2. Pokaż przyciski pobrania submission oraz pełnej diagnostyki. +2. Pokaż przyciski pobrania wyników oraz pełnej diagnostyki. Zamknięcie: „ENGIN daje trzy rzeczy naraz: konkursowo skuteczny model, weryfikowalne uzasadnienie i prostą decyzję serwisową. Działa lokalnie na CPU i jest gotowy na podłączenie do warsztatowego procesu.” ## Plan awaryjny - Jeśli nie ma internetu: aplikacja nie potrzebuje internetu. -- Jeśli model nie wystartuje: automatycznie działa fallback na zapisanych predykcjach demo. +- Jeśli model nie wystartuje: automatycznie działa tryb awaryjny na zapisanych predykcjach demonstracyjnych. - 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 38/38. diff --git a/docs/REVIEW_RESPONSE.md b/docs/REVIEW_RESPONSE.md index 32391da..6ec17cf 100644 --- a/docs/REVIEW_RESPONSE.md +++ b/docs/REVIEW_RESPONSE.md @@ -2,7 +2,8 @@ | Uwaga z przeglądu | Zmiana | Dowód regresji | | --- | --- | --- | -| Mapa cylindrów i dropdown nadpisywały sobie stan | oba widgety korzystają z jednego klucza session state; kafelki ustawiają go callbackiem | `test_cylinder_grid_and_detail_selector_share_state` | +| Wybór cylindra niepotrzebnie obciążał przegląd silnika | wybór jest dostępny wyłącznie w widoku szczegółów; przegląd pokazuje tylko stan i priorytety | `test_cylinder_selector_exists_only_in_detail_view` | +| Automatyczna skala mapy wyolbrzymiała różnice w zdrowych silnikach | mapa ma stałą skalę ±20 mV i opis diagnozy przy każdym cylindrze | `test_all_chart_factories_return_populated_figures` | | Status `WYMAGA WERYFIKACJI` był nieosiągalny | status zależy bezpośrednio od label/severity; `unknown` bez nazwanej usterki ma dedykowany status | `test_unknown_only_engine_requires_verification` | | Engine Health 0–100 był arbitralny | liczba została usunięta; UI pokazuje najwyższe rzeczywiste severity i porządkowy triage | testy explainability + smoke UI | | OOD otrzymywał sztuczny confidence 50–99% | score OOD jest `NaN`; UI nazywa pozostałe wartości score modelu i wyjaśnia brak kalibracji | `test_diagnostics_are_complete_for_the_application` | @@ -15,4 +16,4 @@ Po zmianie OOD ponownie wykonano pięć seedów grouped CV dla clean i masked 5% - clean: Macro F1 `0.9811`, severity `0.9298`, Raw Score `0.9683`, - masked 5%: Macro F1 `0.9829`, severity `0.9333`, Raw Score `0.9705`. -Pełny pakiet po iteracji: `32/32` testów oraz poprawny healthcheck Streamlit `ok`. +Pełny pakiet po iteracji: `38/38` testów oraz poprawny test stanu Streamlit `ok`. diff --git a/engin/charts.py b/engin/charts.py index 3f2afb0..e622306 100644 --- a/engin/charts.py +++ b/engin/charts.py @@ -7,7 +7,7 @@ from collections.abc import Sequence import numpy as np import plotly.graph_objects as go -from .config import FREQ_COLS, LABEL_COLORS +from .config import FREQ_COLS, LABEL_COLORS, LABEL_DISPLAY, LABEL_ICONS from .explainability import EngineAnalysis PLOT_BG = "#101820" @@ -15,6 +15,7 @@ GRID = "rgba(148, 163, 184, 0.14)" TEXT = "#dce8ee" MUTED = "#8fa6b2" COMPARISON_COLORS = ("#28b7d9", "#f5a524", "#38d996", "#d56cff", "#2f78ed") +HEATMAP_DEVIATION_LIMIT_MV = 20.0 def _base_layout( @@ -38,15 +39,18 @@ def _base_layout( def engine_heatmap(analysis: EngineAnalysis) -> go.Figure: - cylinders = analysis.measurements["cylinder"].astype(int).tolist() - max_abs = max(float(np.percentile(analysis.absolute_deviation, 95)), 1.0) + row_labels = [ + f"{LABEL_ICONS[str(row.label)]} C{int(row.cylinder):02d} · " + f"{LABEL_DISPLAY[str(row.label)]}" + for row in analysis.diagnostics.itertuples() + ] fig = go.Figure( go.Heatmap( z=analysis.deviation, x=list(range(len(FREQ_COLS))), - y=[f"C{value:02d}" for value in cylinders], - zmin=-max_abs, - zmax=max_abs, + y=row_labels, + zmin=-HEATMAP_DEVIATION_LIMIT_MV, + zmax=HEATMAP_DEVIATION_LIMIT_MV, zmid=0, colorscale=[ [0.0, "#28b7d9"], @@ -54,10 +58,10 @@ def engine_heatmap(analysis: EngineAnalysis) -> go.Figure: [1.0, "#ff6b57"], ], colorbar=dict(title="Δ mV", thickness=12), - hovertemplate="Cylinder %{y}
%{x} kHz
Odchylenie %{z:.1f} mV", + hovertemplate="%{y}
%{x} kHz
Odchylenie %{z:.1f} mV", ) ) - fig.update_yaxes(autorange="reversed", title="Cylinder") + fig.update_yaxes(autorange="reversed", automargin=True, title=None) fig.update_xaxes(title="Częstotliwość [kHz]", dtick=2) return _base_layout(fig, height=360, margin_top=18, margin_bottom=42) diff --git a/tests/test_app_smoke.py b/tests/test_app_smoke.py index 3cd15af..f61473a 100644 --- a/tests/test_app_smoke.py +++ b/tests/test_app_smoke.py @@ -12,15 +12,22 @@ class StreamlitSmokeTests(unittest.TestCase): def test_default_demo_renders_without_exception(self) -> None: 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), 1) self.assertEqual(app.radio[0].value, "Dane demonstracyjne") self.assertEqual(app.segmented_control[0].value, "Przegląd") + self.assertEqual( + app.segmented_control[0].options, + ["Przegląd", "Szczegóły cylindra"], + ) + self.assertFalse(any("C01" in button.label for button in app.button)) 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) + self.assertIn("ARTEFAKT ZWERYFIKOWANY", rendered) + self.assertIn("runtime-verified", rendered) + self.assertNotIn("CPU · DANE LOKALNE", rendered) for english_fragment in ( "Diagnostic Console", "LIVE INFERENCE", @@ -31,7 +38,6 @@ class StreamlitSmokeTests(unittest.TestCase): ): self.assertNotIn(english_fragment, rendered) - app.segmented_control[0].set_value("Model").run() self.assertEqual( [metric.label for metric in app.metric], [ @@ -41,7 +47,16 @@ class StreamlitSmokeTests(unittest.TestCase): "Testy", ], ) - diagnostic_columns = set(app.dataframe[0].value.columns) + technical_expander = next( + expander for expander in app.expander if expander.label == "Informacje techniczne" + ) + self.assertFalse(technical_expander.proto.expanded) + diagnostic_table = next( + table + for table in app.dataframe + if "Źródło decyzji" in table.value.columns + ) + diagnostic_columns = set(diagnostic_table.value.columns) self.assertIn("Źródło decyzji", diagnostic_columns) self.assertNotIn("decision_source", diagnostic_columns) @@ -51,14 +66,20 @@ class StreamlitSmokeTests(unittest.TestCase): self.assertEqual(len(app.exception), 0) self.assertGreaterEqual(len(app.info), 1) - def test_cylinder_grid_and_detail_selector_share_state(self) -> None: + def test_cylinder_selector_exists_only_in_detail_view(self) -> None: 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.assertFalse( + any(selector.label == "Cylinder główny" for selector in app.selectbox) + ) + app.segmented_control[0].set_value("Szczegóły cylindra").run() self.assertEqual(app.segmented_control[0].value, "Szczegóły cylindra") detail_selector = next( selector for selector in app.selectbox if selector.label == "Cylinder główny" ) + detail_selector.set_value(3).run() + detail_selector = next( + 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") diff --git a/tests/test_explainability.py b/tests/test_explainability.py index 9791fab..5f68568 100644 --- a/tests/test_explainability.py +++ b/tests/test_explainability.py @@ -66,6 +66,10 @@ class ExplainabilityTests(unittest.TestCase): heatmap = engine_heatmap(self.analysis) self.assertEqual(len(heatmap.data), 1) self.assertEqual(heatmap.layout.height, 360) + self.assertEqual(heatmap.data[0].zmin, -20.0) + self.assertEqual(heatmap.data[0].zmax, 20.0) + self.assertTrue(any("Sprawny" in label for label in heatmap.data[0].y)) + self.assertTrue(any("·" in label for label in heatmap.data[0].y)) self.assertEqual(len(cylinder_spectrum(self.analysis, cylinder).data), 2) self.assertEqual( len(cylinder_spectrum(self.analysis, cylinder, compared).data),