diff --git a/README.md b/README.md index 4965f07..c1ead6c 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,13 @@ rollbacku znajduje się w `docs/PRODUCTION_RUNBOOK.md`. ## Co zawiera produkt -- mapa 8-, 12- i 16-cylindrowych silników z diagnozą każdego cylindra, +- przegląd silników 8-, 12- i 16-cylindrowych z diagnozą każdego cylindra, - typ usterki: `ok`, `zakoksowany`, `lejacy`, `pompa`, `iglica` lub `unknown`, - nasilenie: `male`, `srednie`, `duze`; dla `ok` i `unknown` zawsze `nie_dotyczy`, -- status silnika, najwyższe rzeczywiste severity i kolejka cylindrów do kontroli, -- heatmapa odchyleń całego silnika, +- status silnika, najwyższe rzeczywiste nasilenie i kolejka cylindrów do kontroli, +- mapa odchyleń całego silnika ze stałą, porównywalną skalą, - porównanie widma cylindra z medianą pozostałych cylindrów tej samej jednostki, -- trzy najbardziej anomalne pasma, niekalibrowany score modelu oraz rekomendowany następny krok, +- priorytet kontroli, trzy najbardziej anomalne pasma oraz rekomendowany następny krok, - pobieranie `predictions.csv` i rozszerzonej diagnostyki, - ścisła walidacja CSV i bezpieczne komunikaty błędów, - lokalne działanie na CPU, bez wysyłania danych do zewnętrznych usług. diff --git a/app.py b/app.py index d37f8da..791fd65 100644 --- a/app.py +++ b/app.py @@ -7,7 +7,6 @@ import logging from dataclasses import dataclass from pathlib import Path -import numpy as np import pandas as pd import streamlit as st @@ -36,6 +35,7 @@ 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", @@ -122,12 +122,6 @@ def _render_error(error: UserFacingError) -> None: 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 _diagnostics_for_display(frame: pd.DataFrame) -> pd.DataFrame: display = frame.copy() for column in ("label", "raw_model_label"): @@ -151,24 +145,13 @@ def _diagnostics_for_display(frame: pd.DataFrame) -> pd.DataFrame: return display.rename(columns=DIAGNOSTIC_COLUMN_DISPLAY) -def _render_header(live_inference: bool) -> None: - if live_inference: - mode = "MODEL GOTOWY" - verification = "ARTEFAKT ZWERYFIKOWANY" - tone = "verified" - else: - mode = "TRYB DEMONSTRACYJNY" - verification = "ZAPISANE WYNIKI" - tone = "fallback" +def _render_header() -> None: st.markdown( - f""" + """
-
-
AESTEEL · DIAGNOSTYKA WTRYSKU DIESEL
-

Konsola diagnostyczna ENGIN

-

Diagnoza cylindra, nasilenie i następny krok.

-
-
● {mode}
{verification}
+
AESTEEL · DIAGNOSTYKA WTRYSKU DIESEL
+

Konsola diagnostyczna ENGIN

+

Diagnoza cylindra, nasilenie i następny krok.

""", unsafe_allow_html=True, @@ -182,7 +165,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. WYNIK MODELU{_format_confidence(summary.mean_confidence)}
+
PIERWSZY DO KONTROLIC{summary.top_cylinder:02d}
""", unsafe_allow_html=True, @@ -190,9 +173,12 @@ def _render_summary(summary) -> None: def _render_engine_overview(analysis) -> None: - left, right = st.columns(2, gap="large") + left, right = st.columns(2, gap="large", vertical_alignment="top") with left: - st.markdown("### Mapa odchyleń") + st.markdown( + '
Mapa odchyleń
', + unsafe_allow_html=True, + ) st.plotly_chart( engine_heatmap(analysis), width="stretch", @@ -200,15 +186,17 @@ def _render_engine_overview(analysis) -> None: config=PLOTLY_CONFIG, ) with right: - st.markdown("### Priorytet kontroli") + st.markdown( + '
Priorytet kontroli
', + 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["Wynik modelu"] = ranking["label_confidence"].map(_format_confidence) ranking["Priorytet"] = ranking["priority_display"] st.dataframe( - ranking[["Cylinder", "Diagnoza", "Nasilenie", "Wynik modelu", "Priorytet"]], + ranking[["Cylinder", "Diagnoza", "Nasilenie", "Priorytet"]], hide_index=True, width="stretch", height=360, @@ -221,9 +209,11 @@ def _render_cylinder_detail( 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] - severity_confidence = row.get("severity_confidence", np.nan) + 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"""
@@ -233,9 +223,9 @@ def _render_cylinder_detail(

{html.escape(explanation.severity_display)}

-
Wynik diagnozy{_format_confidence(explanation.confidence)}
-
Wynik oceny nasilenia{_format_confidence(severity_confidence)}
+
Priorytet{html.escape(priority)}
Średnie odchylenie{explanation.anomaly_score:.1f} mV
+
Główne pasma{html.escape(top_bands)}
""", @@ -267,14 +257,73 @@ def _render_cylinder_detail( with next_step: st.markdown("#### Następny krok") st.write(explanation.recommendation) - source_label = ( - "Reguła anomalii" - if explanation.decision_source == "ood_override" - else "Klasyfikator spektralny" - ) - st.caption( - f"Źródło: {source_label} · wynik modelu nie jest prawdopodobieństwem." - ) + + +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: @@ -305,7 +354,7 @@ def render_app(dependencies: AppDependencies | None = None) -> None: ) _load_css() deps = dependencies or build_dependencies() - _render_header(deps.live_inference) + _render_header() with st.sidebar: st.markdown("## Centrum diagnostyczne") @@ -362,14 +411,11 @@ def render_app(dependencies: AppDependencies | None = None) -> None: mime="text/csv", width="stretch", ) - st.divider() - st.caption(f"Wersja modelu: {deps.model_version}") - analysis = analyze_engine(result, str(engine_id)) summary = summarize_engine(analysis) _render_summary(summary) - session_key = f"selected_cylinder_{analysis.engine_id}" + 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 @@ -389,28 +435,11 @@ def render_app(dependencies: AppDependencies | None = None) -> None: if view == VIEW_OVERVIEW: _render_engine_overview(analysis) 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.", - ) + 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), diff --git a/assets/app.css b/assets/app.css index 7606c17..7d46ffc 100644 --- a/assets/app.css +++ b/assets/app.css @@ -24,10 +24,6 @@ [data-testid="stMainBlockContainer"] { padding-top: 2.1rem; max-width: 1500px; } .product-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 2rem; margin-bottom: 1.25rem; padding-bottom: 1.25rem; border-bottom: 1px solid var(--line); @@ -36,23 +32,6 @@ .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: 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); @@ -63,7 +42,7 @@ margin-bottom: 1.5rem; overflow: hidden; } -.status-strip > div { padding: 1rem 1.2rem; border-right: 1px solid var(--line); } +.status-strip > div { padding: 1rem 1.2rem; border-right: 1px solid var(--line); text-align: center; } .status-strip > div:last-child { border-right: 0; } .status-strip span, .diagnosis-metrics span { display: block; color: var(--muted); font-size: .66rem; font-weight: 800; letter-spacing: .11em; } .status-strip strong { display: block; margin-top: .2rem; font-size: 1.28rem; color: var(--text); } @@ -97,6 +76,24 @@ } .diagnosis-metrics strong { display: block; margin-top: .2rem; color: var(--text); font-size: 1.05rem; } +.overview-panel-title { + display: flex; + min-height: 2.75rem; + align-items: center; + justify-content: center; + color: var(--text); + font-size: 1.15rem; + font-weight: 700; + text-align: center; +} + +[data-testid="stVerticalBlockBorderWrapper"] { + border-color: rgba(40, 183, 217, .32) !important; + border-left: 4px solid var(--cyan) !important; + border-radius: var(--radius); + background: linear-gradient(90deg, rgba(40, 183, 217, .06), rgba(16, 27, 34, .72)); +} + [data-testid="stMetric"] { min-height: 5.2rem; padding: .75rem; @@ -140,9 +137,7 @@ } @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%; } } diff --git a/docs/DEMO_SCENARIO.md b/docs/DEMO_SCENARIO.md index 57546bd..eaf70db 100644 --- a/docs/DEMO_SCENARIO.md +++ b/docs/DEMO_SCENARIO.md @@ -12,13 +12,13 @@ Komunikat: „Nie zmuszamy mechanika do czytania 21 wykresów. Najpierw widzi najważniejszy cylinder i pilność.” -## 1:15–2:20 — explainability +## 1:15–2:20 — uzasadnienie diagnozy 1. Otwórz „Szczegóły cylindra”. 2. Wybierz najwyżej sklasyfikowany cylinder. 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. +5. Wskaż priorytet, zaznaczone pasma, 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.” @@ -28,7 +28,7 @@ Komunikat: „Referencją jest konkretny silnik, nie abstrakcyjna średnia całe 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.” +Komunikat: „Wynik nie powstał przez przeciek między cylindrami tego samego silnika. Proces jest też testowany na brakach odpowiadających realnym danym warsztatowym.” ## 3:05–3:40 — własny plik i wartość diff --git a/docs/JURY_QA.md b/docs/JURY_QA.md index 58043d0..cef61c2 100644 --- a/docs/JURY_QA.md +++ b/docs/JURY_QA.md @@ -28,7 +28,7 @@ Nie. Status jest deterministyczną regułą biznesową opartą bezpośrednio na ## Czy score modelu jest prawdopodobieństwem awarii? -Nie. To niekalibrowany wynik `predict_proba`, używany wyłącznie do względnego porównania decyzji klasyfikatora. Interfejs nazywa go „score”, nie „pewnością”. Dla `unknown` utworzonego przez regułę OOD pozostaje pusty zamiast sztucznie generować wartość 50–99%. +Nie. To niekalibrowany wynik `predict_proba`, używany wyłącznie do diagnostyki technicznej. Główny widok mechanika go nie pokazuje; zamiast tego prezentuje diagnozę, nasilenie, priorytet i następny krok. Dla `unknown` utworzonego przez regułę OOD wartość pozostaje pusta. ## Co robi aplikacja przy błędnym pliku? diff --git a/docs/REVIEW_RESPONSE.md b/docs/REVIEW_RESPONSE.md index 6ec17cf..f39b9e1 100644 --- a/docs/REVIEW_RESPONSE.md +++ b/docs/REVIEW_RESPONSE.md @@ -3,6 +3,8 @@ | Uwaga z przeglądu | Zmiana | Dowód regresji | | --- | --- | --- | | 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` | +| Selektor i karta mogły po pierwszym wejściu wskazywać różne cylindry | trwały stan wyboru i stan widżetu mają osobne klucze; test porównuje wartość selektora z nagłówkiem karty | `test_cylinder_selector_exists_only_in_detail_view` | +| Wielokrotny wybór pokazywał angielskie i sprzeczne komunikaty | zastąpiono go trzema opcjonalnymi, polskimi polami porównawczymi bez możliwości duplikatów | `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 | diff --git a/tests/test_app_smoke.py b/tests/test_app_smoke.py index f61473a..5f814fe 100644 --- a/tests/test_app_smoke.py +++ b/tests/test_app_smoke.py @@ -21,12 +21,12 @@ class StreamlitSmokeTests(unittest.TestCase): ) 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) + any(selector.label == "Cylinder do analizy" 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("MODEL GOTOWY", rendered) + self.assertNotIn("ARTEFAKT ZWERYFIKOWANY", rendered) self.assertNotIn("CPU · DANE LOKALNE", rendered) for english_fragment in ( "Diagnostic Console", @@ -59,6 +59,13 @@ class StreamlitSmokeTests(unittest.TestCase): diagnostic_columns = set(diagnostic_table.value.columns) self.assertIn("Źródło decyzji", diagnostic_columns) self.assertNotIn("decision_source", diagnostic_columns) + overview_table = next( + table for table in app.dataframe if "Priorytet" in table.value.columns + ) + self.assertEqual( + list(overview_table.value.columns), + ["Cylinder", "Diagnoza", "Nasilenie", "Priorytet"], + ) def test_upload_mode_has_safe_empty_state(self) -> None: app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run() @@ -69,21 +76,35 @@ class StreamlitSmokeTests(unittest.TestCase): def test_cylinder_selector_exists_only_in_detail_view(self) -> None: app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run() self.assertFalse( - any(selector.label == "Cylinder główny" for selector in app.selectbox) + any(selector.label == "Cylinder do analizy" 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" + selector + for selector in app.selectbox + if selector.label == "Cylinder do analizy" ) + rendered = "\n".join(markdown.value for markdown in app.markdown) + self.assertIn(f"CYLINDER {int(detail_selector.value):02d}", rendered) detail_selector.set_value(3).run() detail_selector = next( - selector for selector in app.selectbox if selector.label == "Cylinder główny" + selector + for selector in app.selectbox + if selector.label == "Cylinder do analizy" ) 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() + for slot, cylinder in enumerate((1, 2, 4), start=1): + comparison = next( + selector + for selector in app.selectbox + if selector.label == f"Porównanie {slot}" + ) + comparison.set_value(cylinder).run() self.assertEqual(len(app.exception), 0) + self.assertEqual(len(app.multiselect), 0) rendered = "\n".join(markdown.value for markdown in app.markdown) self.assertIn("CYLINDER 03", rendered) + self.assertIn("### Wybór cylindra", rendered) + self.assertNotIn("Select all", rendered) + self.assertNotIn("You can select up to", rendered)