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 ## 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`, - typ usterki: `ok`, `zakoksowany`, `lejacy`, `pompa`, `iglica` lub `unknown`,
- nasilenie: `male`, `srednie`, `duze`; dla `ok` i `unknown` zawsze `nie_dotyczy`, - nasilenie: `male`, `srednie`, `duze`; dla `ok` i `unknown` zawsze `nie_dotyczy`,
- status silnika, najwyższe rzeczywiste severity i kolejka cylindrów do kontroli, - status silnika, najwyższe rzeczywiste nasilenie i kolejka cylindrów do kontroli,
- heatmapa odchyleń całego silnika, - mapa odchyleń całego silnika ze stałą, porównywalną skalą,
- porównanie widma cylindra z medianą pozostałych cylindrów tej samej jednostki, - 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, - pobieranie `predictions.csv` i rozszerzonej diagnostyki,
- ścisła walidacja CSV i bezpieczne komunikaty błędów, - ścisła walidacja CSV i bezpieczne komunikaty błędów,
- lokalne działanie na CPU, bez wysyłania danych do zewnętrznych usług. - lokalne działanie na CPU, bez wysyłania danych do zewnętrznych usług.

165
app.py
View File

@ -7,7 +7,6 @@ import logging
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
import numpy as np
import pandas as pd import pandas as pd
import streamlit as st import streamlit as st
@ -36,6 +35,7 @@ MODEL_VERSION = "engin-2026.08.25-1"
MODEL_ARTIFACT_DIR = BASE_DIR / "artifacts" / MODEL_VERSION MODEL_ARTIFACT_DIR = BASE_DIR / "artifacts" / MODEL_VERSION
VIEW_OVERVIEW = "Przegląd" VIEW_OVERVIEW = "Przegląd"
VIEW_DETAIL = "Szczegóły cylindra" VIEW_DETAIL = "Szczegóły cylindra"
NO_COMPARISON = 0
PLOTLY_CONFIG = {"displayModeBar": False, "displaylogo": False} PLOTLY_CONFIG = {"displayModeBar": False, "displaylogo": False}
DIAGNOSTIC_COLUMN_DISPLAY = { DIAGNOSTIC_COLUMN_DISPLAY = {
"engine_id": "Silnik", "engine_id": "Silnik",
@ -122,12 +122,6 @@ def _render_error(error: UserFacingError) -> None:
st.caption(f"Kod błędu: `{error.code}`") 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: def _diagnostics_for_display(frame: pd.DataFrame) -> pd.DataFrame:
display = frame.copy() display = frame.copy()
for column in ("label", "raw_model_label"): 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) return display.rename(columns=DIAGNOSTIC_COLUMN_DISPLAY)
def _render_header(live_inference: bool) -> None: def _render_header() -> None:
if live_inference:
mode = "MODEL GOTOWY"
verification = "ARTEFAKT ZWERYFIKOWANY"
tone = "verified"
else:
mode = "TRYB DEMONSTRACYJNY"
verification = "ZAPISANE WYNIKI"
tone = "fallback"
st.markdown( st.markdown(
f""" """
<div class="product-header"> <div class="product-header">
<div> <div class="eyebrow">AESTEEL · DIAGNOSTYKA WTRYSKU DIESEL</div>
<div class="eyebrow">AESTEEL · DIAGNOSTYKA WTRYSKU DIESEL</div> <h1>Konsola diagnostyczna ENGIN</h1>
<h1>Konsola diagnostyczna ENGIN</h1> <p>Diagnoza cylindra, nasilenie i następny krok.</p>
<p>Diagnoza cylindra, nasilenie i następny krok.</p>
</div>
<div class="runtime-badge runtime-{tone}"> {mode}<br><span>{verification}</span></div>
</div> </div>
""", """,
unsafe_allow_html=True, 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>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>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>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> </div>
""", """,
unsafe_allow_html=True, unsafe_allow_html=True,
@ -190,9 +173,12 @@ def _render_summary(summary) -> None:
def _render_engine_overview(analysis) -> 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: with left:
st.markdown("### Mapa odchyleń") st.markdown(
'<div class="overview-panel-title">Mapa odchyleń</div>',
unsafe_allow_html=True,
)
st.plotly_chart( st.plotly_chart(
engine_heatmap(analysis), engine_heatmap(analysis),
width="stretch", width="stretch",
@ -200,15 +186,17 @@ def _render_engine_overview(analysis) -> None:
config=PLOTLY_CONFIG, config=PLOTLY_CONFIG,
) )
with right: 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 = rank_cylinders(analysis).head(6).copy()
ranking["Cylinder"] = ranking["cylinder"].map(lambda value: f"C{int(value):02d}") ranking["Cylinder"] = ranking["cylinder"].map(lambda value: f"C{int(value):02d}")
ranking["Diagnoza"] = ranking["label_display"] ranking["Diagnoza"] = ranking["label_display"]
ranking["Nasilenie"] = ranking["severity_display"] ranking["Nasilenie"] = ranking["severity_display"]
ranking["Wynik modelu"] = ranking["label_confidence"].map(_format_confidence)
ranking["Priorytet"] = ranking["priority_display"] ranking["Priorytet"] = ranking["priority_display"]
st.dataframe( st.dataframe(
ranking[["Cylinder", "Diagnoza", "Nasilenie", "Wynik modelu", "Priorytet"]], ranking[["Cylinder", "Diagnoza", "Nasilenie", "Priorytet"]],
hide_index=True, hide_index=True,
width="stretch", width="stretch",
height=360, height=360,
@ -221,9 +209,11 @@ def _render_cylinder_detail(
comparison_cylinders: list[int], comparison_cylinders: list[int],
) -> None: ) -> None:
explanation = explain_cylinder(analysis, cylinder) explanation = explain_cylinder(analysis, cylinder)
row = analysis.diagnostics[analysis.diagnostics["cylinder"].eq(cylinder)].iloc[0]
color = LABEL_COLORS[explanation.label] 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( st.markdown(
f""" f"""
<div class="diagnosis-card" style="--diagnosis-color:{color}"> <div class="diagnosis-card" style="--diagnosis-color:{color}">
@ -233,9 +223,9 @@ def _render_cylinder_detail(
<p>{html.escape(explanation.severity_display)}</p> <p>{html.escape(explanation.severity_display)}</p>
</div> </div>
<div class="diagnosis-metrics"> <div class="diagnosis-metrics">
<div><span>Wynik diagnozy</span><strong>{_format_confidence(explanation.confidence)}</strong></div> <div><span>Priorytet</span><strong>{html.escape(priority)}</strong></div>
<div><span>Wynik oceny nasilenia</span><strong>{_format_confidence(severity_confidence)}</strong></div>
<div><span>Średnie odchylenie</span><strong>{explanation.anomaly_score:.1f} mV</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>
</div> </div>
""", """,
@ -267,14 +257,73 @@ def _render_cylinder_detail(
with next_step: with next_step:
st.markdown("#### Następny krok") st.markdown("#### Następny krok")
st.write(explanation.recommendation) st.write(explanation.recommendation)
source_label = (
"Reguła anomalii"
if explanation.decision_source == "ood_override" def _store_selected_cylinder(state_key: str, widget_key: str) -> None:
else "Klasyfikator spektralny" st.session_state[state_key] = int(st.session_state[widget_key])
)
st.caption(
f"Źródło: {source_label} · wynik modelu nie jest prawdopodobieństwem." 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: def _render_technical(result: DiagnosisResult) -> None:
@ -305,7 +354,7 @@ def render_app(dependencies: AppDependencies | None = None) -> None:
) )
_load_css() _load_css()
deps = dependencies or build_dependencies() deps = dependencies or build_dependencies()
_render_header(deps.live_inference) _render_header()
with st.sidebar: with st.sidebar:
st.markdown("## Centrum diagnostyczne") st.markdown("## Centrum diagnostyczne")
@ -362,14 +411,11 @@ def render_app(dependencies: AppDependencies | None = None) -> None:
mime="text/csv", mime="text/csv",
width="stretch", width="stretch",
) )
st.divider()
st.caption(f"Wersja modelu: {deps.model_version}")
analysis = analyze_engine(result, str(engine_id)) analysis = analyze_engine(result, str(engine_id))
summary = summarize_engine(analysis) summary = summarize_engine(analysis)
_render_summary(summary) _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() available = analysis.measurements["cylinder"].astype(int).tolist()
if session_key not in st.session_state or st.session_state[session_key] not in available: 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 st.session_state[session_key] = summary.top_cylinder
@ -389,28 +435,11 @@ def render_app(dependencies: AppDependencies | None = None) -> None:
if view == VIEW_OVERVIEW: if view == VIEW_OVERVIEW:
_render_engine_overview(analysis) _render_engine_overview(analysis)
elif view == VIEW_DETAIL: elif view == VIEW_DETAIL:
selector_col, comparison_col = st.columns([1.0, 2.0], gap="large") selected_from_box, additional_cylinders = _render_cylinder_selectors(
with selector_col: analysis,
selected_from_box = st.selectbox( available,
"Cylinder główny", session_key,
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.",
)
comparison_cylinders = [ comparison_cylinders = [
int(selected_from_box), int(selected_from_box),
*(int(value) for value in additional_cylinders), *(int(value) for value in additional_cylinders),

View File

@ -24,10 +24,6 @@
[data-testid="stMainBlockContainer"] { padding-top: 2.1rem; max-width: 1500px; } [data-testid="stMainBlockContainer"] { padding-top: 2.1rem; max-width: 1500px; }
.product-header { .product-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 2rem;
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
padding-bottom: 1.25rem; padding-bottom: 1.25rem;
border-bottom: 1px solid var(--line); 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 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); } .product-header p { margin: 0; color: var(--muted); }
.eyebrow { color: var(--cyan); font-size: .7rem; font-weight: 800; letter-spacing: .17em; } .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 { .status-strip {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
@ -63,7 +42,7 @@
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
overflow: hidden; 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 > 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 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); } .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; } .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"] { [data-testid="stMetric"] {
min-height: 5.2rem; min-height: 5.2rem;
padding: .75rem; padding: .75rem;
@ -140,9 +137,7 @@
} }
@media (max-width: 1000px) { @media (max-width: 1000px) {
.product-header { flex-direction: column; }
.diagnosis-card { grid-template-columns: 1fr; } .diagnosis-card { grid-template-columns: 1fr; }
.runtime-badge { width: 100%; }
.status-strip { grid-template-columns: repeat(2, 1fr); } .status-strip { grid-template-columns: repeat(2, 1fr); }
.diagnosis-metrics { width: 100%; } .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ść.” 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”. 1. Otwórz „Szczegóły cylindra”.
2. Wybierz najwyżej sklasyfikowany cylinder. 2. Wybierz najwyżej sklasyfikowany cylinder.
3. Opcjonalnie dodaj drugi cylinder do porównania. 3. Opcjonalnie dodaj drugi cylinder do porównania.
4. Porównaj widmo z medianą pozostałych cylindrów tego samego silnika. 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.” 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. 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. 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ść ## 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? ## 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? ## Co robi aplikacja przy błędnym pliku?

View File

@ -3,6 +3,8 @@
| Uwaga z przeglądu | Zmiana | Dowód regresji | | 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` | | 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` | | 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` | | 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 | | 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("C01" in button.label for button in app.button))
self.assertFalse( 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) rendered = "\n".join(markdown.value for markdown in app.markdown)
self.assertIn("Konsola diagnostyczna ENGIN", rendered) self.assertIn("Konsola diagnostyczna ENGIN", rendered)
self.assertIn("ARTEFAKT ZWERYFIKOWANY", rendered) self.assertNotIn("MODEL GOTOWY", rendered)
self.assertIn("runtime-verified", rendered) self.assertNotIn("ARTEFAKT ZWERYFIKOWANY", rendered)
self.assertNotIn("CPU · DANE LOKALNE", rendered) self.assertNotIn("CPU · DANE LOKALNE", rendered)
for english_fragment in ( for english_fragment in (
"Diagnostic Console", "Diagnostic Console",
@ -59,6 +59,13 @@ class StreamlitSmokeTests(unittest.TestCase):
diagnostic_columns = set(diagnostic_table.value.columns) diagnostic_columns = set(diagnostic_table.value.columns)
self.assertIn("Źródło decyzji", diagnostic_columns) self.assertIn("Źródło decyzji", diagnostic_columns)
self.assertNotIn("decision_source", 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: def test_upload_mode_has_safe_empty_state(self) -> None:
app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run() 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: def test_cylinder_selector_exists_only_in_detail_view(self) -> None:
app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run() app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run()
self.assertFalse( 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() app.segmented_control[0].set_value("Szczegóły cylindra").run()
self.assertEqual(app.segmented_control[0].value, "Szczegóły cylindra") self.assertEqual(app.segmented_control[0].value, "Szczegóły cylindra")
detail_selector = next( 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.set_value(3).run()
detail_selector = next( 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) self.assertEqual(detail_selector.value, 3)
comparison = app.multiselect[0] for slot, cylinder in enumerate((1, 2, 4), start=1):
self.assertEqual(comparison.label, "Porównaj z cylindrami") comparison = next(
comparison.set_value([1, 2, 4]).run() 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.exception), 0)
self.assertEqual(len(app.multiselect), 0)
rendered = "\n".join(markdown.value for markdown in app.markdown) rendered = "\n".join(markdown.value for markdown in app.markdown)
self.assertIn("CYLINDER 03", rendered) self.assertIn("CYLINDER 03", rendered)
self.assertIn("### Wybór cylindra", rendered)
self.assertNotIn("Select all", rendered)
self.assertNotIn("You can select up to", rendered)