ui: focus mechanic workflow and clarify health map
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 36s

This commit is contained in:
Jakub Famulski 2 2026-08-25 15:22:35 +02:00
parent 7159c01123
commit dab5264ed7
7 changed files with 117 additions and 87 deletions

68
app.py
View File

@ -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"""
<div class="product-header">
@ -164,7 +168,7 @@ def _render_header(live_inference: bool) -> None:
<h1>Konsola diagnostyczna ENGIN</h1>
<p>Diagnoza cylindra, nasilenie i następny krok.</p>
</div>
<div class="runtime-badge"> {mode}<br><span>CPU · DANE LOKALNE</span></div>
<div class="runtime-badge runtime-{tone}"> {mode}<br><span>{verification}</span></div>
</div>
""",
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)

View File

@ -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; }
}

View File

@ -7,8 +7,8 @@
## 0:351: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:203: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:053: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.

View File

@ -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 0100 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 5099% | 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`.

View File

@ -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}<br>%{x} kHz<br>Odchylenie %{z:.1f} mV<extra></extra>",
hovertemplate="%{y}<br>%{x} kHz<br>Odchylenie %{z:.1f} mV<extra></extra>",
)
)
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)

View File

@ -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")

View File

@ -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),