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"""
+ """
""",
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)