ui: align cylinder controls with service workflow
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 43s

This commit is contained in:
Jakub Famulski 2 2026-08-25 15:52:36 +02:00
parent dab5264ed7
commit 9032c49993
7 changed files with 156 additions and 109 deletions

View File

@ -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.

151
app.py
View File

@ -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,25 +145,14 @@ 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"""
"""
<div class="product-header">
<div>
<div class="eyebrow">AESTEEL · DIAGNOSTYKA WTRYSKU DIESEL</div>
<h1>Konsola diagnostyczna ENGIN</h1>
<p>Diagnoza cylindra, nasilenie i następny krok.</p>
</div>
<div class="runtime-badge runtime-{tone}"> {mode}<br><span>{verification}</span></div>
</div>
""",
unsafe_allow_html=True,
)
@ -182,7 +165,7 @@ def _render_summary(summary) -> None:
<div><span>STATUS SILNIKA</span><strong>{html.escape(summary.status)}</strong></div>
<div><span>NAJWYŻSZE NASILENIE</span><strong>{html.escape(summary.highest_severity_display)}</strong></div>
<div><span>WYMAGA UWAGI</span><strong>{summary.attention} / {summary.cylinders}</strong></div>
<div><span>ŚR. WYNIK MODELU</span><strong>{_format_confidence(summary.mean_confidence)}</strong></div>
<div><span>PIERWSZY DO KONTROLI</span><strong>C{summary.top_cylinder:02d}</strong></div>
</div>
""",
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(
'<div class="overview-panel-title">Mapa odchyleń</div>',
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(
'<div class="overview-panel-title">Priorytet kontroli</div>',
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"""
<div class="diagnosis-card" style="--diagnosis-color:{color}">
@ -233,9 +223,9 @@ def _render_cylinder_detail(
<p>{html.escape(explanation.severity_display)}</p>
</div>
<div class="diagnosis-metrics">
<div><span>Wynik diagnozy</span><strong>{_format_confidence(explanation.confidence)}</strong></div>
<div><span>Wynik oceny nasilenia</span><strong>{_format_confidence(severity_confidence)}</strong></div>
<div><span>Priorytet</span><strong>{html.escape(priority)}</strong></div>
<div><span>Średnie odchylenie</span><strong>{explanation.anomaly_score:.1f} mV</strong></div>
<div><span>Główne pasma</span><strong>{html.escape(top_bands)}</strong></div>
</div>
</div>
""",
@ -267,15 +257,74 @@ 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"
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),
)
st.caption(
f"Źródło: {source_label} · wynik modelu nie jest prawdopodobieństwem."
)
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:
st.markdown("### Walidacja i model")
@ -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,27 +435,10 @@ 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",
selected_from_box, additional_cylinders = _render_cylinder_selectors(
analysis,
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.",
session_key,
)
comparison_cylinders = [
int(selected_from_box),

View File

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

View File

@ -12,13 +12,13 @@
Komunikat: „Nie zmuszamy mechanika do czytania 21 wykresów. Najpierw widzi najważniejszy cylinder i pilność.”
## 1:152:20 — explainability
## 1:152: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:053:40 — własny plik i wartość

View File

@ -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ść 5099%.
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?

View File

@ -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 0100 był arbitralny | liczba została usunięta; UI pokazuje najwyższe rzeczywiste severity i porządkowy triage | testy explainability + smoke UI |

View File

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