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"""
- ● {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),