ui: streamline cylinder diagnosis views
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 13:55:56 +02:00
parent 5ebfdc389a
commit 82ab439452
6 changed files with 182 additions and 79 deletions

136
app.py
View File

@ -36,6 +36,9 @@ LOGGER = logging.getLogger("engin.app")
BASE_DIR = Path(__file__).resolve().parent
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"
@dataclass(frozen=True)
@ -115,7 +118,7 @@ def _render_header(live_inference: bool) -> None:
<div>
<div class="eyebrow">AESTEEL · DIESEL INJECTION DIAGNOSTICS</div>
<h1>ENGIN Diagnostic Console</h1>
<p>Akustyczna diagnostyka każdego cylindra typ usterki, nasilenie i uzasadnienie.</p>
<p>Diagnoza cylindra, nasilenie i następny krok.</p>
</div>
<div class="runtime-badge"> {mode}<br><span>CPU · LEAKAGE-SAFE</span></div>
</div>
@ -138,11 +141,12 @@ def _render_summary(summary) -> None:
)
def _select_cylinder(session_key: str, cylinder: int) -> 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) -> None:
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])
@ -162,7 +166,7 @@ def _render_cylinder_grid(analysis, session_key: str) -> None:
width="stretch",
type="primary" if cylinder == selected_cylinder else "secondary",
on_click=_select_cylinder,
args=(session_key, cylinder),
args=(session_key, view_key, cylinder),
)
@ -184,10 +188,14 @@ def _render_engine_overview(analysis) -> None:
width="stretch",
height=315,
)
st.caption("Kolejność: severity, odchylenie widma, następnie score modelu. Score nie jest skalibrowanym prawdopodobieństwem.")
st.caption("Priorytet: nasilenie → odchylenie → score modelu.")
def _render_cylinder_detail(analysis, cylinder: int) -> None:
def _render_cylinder_detail(
analysis,
cylinder: int,
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]
@ -209,48 +217,47 @@ def _render_cylinder_detail(analysis, cylinder: int) -> None:
""",
unsafe_allow_html=True,
)
spectrum_col, deviation_col = st.columns([1.25, 1.0], gap="large")
with spectrum_col:
st.plotly_chart(
cylinder_spectrum(analysis, cylinder),
width="stretch",
key=f"spectrum_{analysis.engine_id}_{cylinder}",
)
with deviation_col:
st.plotly_chart(
deviation_chart(analysis, cylinder),
width="stretch",
key=f"deviation_{analysis.engine_id}_{cylinder}",
)
st.markdown("#### Widma porównawcze")
st.plotly_chart(
cylinder_spectrum(analysis, cylinder, comparison_cylinders),
width="stretch",
key=(
f"spectrum_{analysis.engine_id}_"
+ "_".join(str(value) for value in comparison_cylinders)
),
)
st.markdown("#### Odchylenie cylindra głównego")
st.plotly_chart(
deviation_chart(analysis, cylinder),
width="stretch",
key=f"deviation_{analysis.engine_id}_{cylinder}",
)
why, next_step = st.columns(2, gap="large")
with why:
st.markdown("#### Dlaczego taka diagnoza?")
st.markdown("#### Uzasadnienie")
st.write(explanation.reason)
frequencies = " · ".join(f"{value} kHz" for value in explanation.top_frequencies)
st.markdown(f"**Najbardziej anomalne pasma:** `{frequencies}`")
with next_step:
st.markdown("#### Rekomendowany następny krok")
st.markdown("#### Następny krok")
st.write(explanation.recommendation)
source_label = "Reguła OOD" if explanation.decision_source == "ood_override" else "Klasyfikator spektralny"
st.caption(f"Źródło decyzji: {source_label}")
st.caption("Score modelu służy do porównania predykcji; nie jest kalibrowanym prawdopodobieństwem awarii.")
source_label = (
"Reguła OOD"
if explanation.decision_source == "ood_override"
else "Klasyfikator spektralny"
)
st.caption(f"Źródło: {source_label} · score nie jest prawdopodobieństwem.")
def _render_technical(result: DiagnosisResult) -> None:
st.markdown("### Kontrola jakości i architektura")
st.markdown("### Walidacja i model")
c1, c2, c3, c4 = st.columns(4)
c1.metric("Grouped Macro F1", "0.981")
c2.metric("Severity accuracy", "0.930")
c3.metric("Walidacyjne ML", "33.65 / 40")
c4.metric("Bramka jakości", "CI GREEN")
st.markdown(
"""
- **Zero leakage:** każdy fold zawiera kompletne, wcześniej niewidziane silniki.
- **Odporność na braki:** wynik sprawdzony przy dokładnie 5% zamaskowanych komórek widma.
- **CPU-first:** Logistic Regression + Extra Trees, bez GPU i z deterministycznymi seedami.
- **Explainability:** każda diagnoza korzysta z referencji pozostałych cylindrów tego samego silnika.
"""
c4.metric("Testy", "38 / 38")
st.caption(
"Grouped CV po engine_id · 5 seedów · Macro F1 przy 5% braków: 0.983 · CPU."
)
with st.expander("Pokaż dane diagnostyczne"):
st.dataframe(result.diagnostics, width="stretch", hide_index=True)
@ -277,7 +284,7 @@ def render_app(dependencies: AppDependencies | None = None) -> None:
payload: bytes | None
if source == "Dane demonstracyjne":
payload = deps.demo_payload
st.success("Załadowano bezpieczny zestaw demonstracyjny")
st.success("Demo załadowane")
else:
uploaded = st.file_uploader("Plik pomiarowy CSV", type=["csv"])
payload = uploaded.getvalue() if uploaded is not None else None
@ -335,22 +342,55 @@ def render_app(dependencies: AppDependencies | None = None) -> None:
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
_render_cylinder_grid(analysis, session_key)
overview_tab, detail_tab, technical_tab = st.tabs(
["Przegląd silnika", "Szczegóły cylindra", "Walidacja i model"]
view_key = f"active_view_{analysis.engine_id}"
if view_key not in st.session_state:
st.session_state[view_key] = VIEW_OVERVIEW
view = st.segmented_control(
"Widok",
[VIEW_OVERVIEW, VIEW_DETAIL, VIEW_MODEL],
required=True,
key=view_key,
label_visibility="collapsed",
width="stretch",
)
with overview_tab:
if view == VIEW_OVERVIEW:
_render_cylinder_grid(analysis, session_key, view_key)
_render_engine_overview(analysis)
with detail_tab:
selected_from_box = st.selectbox(
"Cylinder do analizy",
available,
format_func=lambda value: f"Cylinder {value:02d}",
key=session_key,
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.",
)
comparison_cylinders = [
int(selected_from_box),
*(int(value) for value in additional_cylinders),
]
_render_cylinder_detail(
analysis,
int(selected_from_box),
comparison_cylinders,
)
_render_cylinder_detail(analysis, int(selected_from_box))
with technical_tab:
elif view == VIEW_MODEL:
_render_technical(result)

View File

@ -64,9 +64,9 @@
.diagnosis-card {
--diagnosis-color: var(--cyan);
display: flex;
display: grid;
grid-template-columns: minmax(220px, .8fr) minmax(0, 1.6fr);
align-items: center;
justify-content: space-between;
gap: 2rem;
padding: 1rem 1.25rem;
margin: .5rem 0 1rem;
@ -77,7 +77,11 @@
.diagnosis-card h2 { margin: .08rem 0; color: var(--text); }
.diagnosis-card p { margin: 0; color: var(--muted); }
.diagnosis-kicker { color: var(--diagnosis-color); font: 800 .68rem ui-monospace, monospace; letter-spacing: .12em; }
.diagnosis-metrics { display: flex; gap: 2rem; }
.diagnosis-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(110px, 1fr));
gap: 1rem;
}
.diagnosis-metrics strong { display: block; margin-top: .2rem; color: var(--text); font-size: 1.05rem; }
[data-testid="stButton"] button {
@ -92,16 +96,18 @@
[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); }
[data-testid="stDataFrame"], [data-testid="stPlotlyChart"] { border: 1px solid var(--line); overflow: hidden; }
@media (max-width: 900px) {
.product-header, .diagnosis-card { flex-direction: column; }
@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%; flex-wrap: wrap; gap: 1rem 1.6rem; }
.diagnosis-metrics { width: 100%; }
}
@media (max-width: 560px) {
.status-strip { grid-template-columns: 1fr; }
.status-strip > div { border-right: 0; border-bottom: 1px solid var(--line); }
.diagnosis-metrics { grid-template-columns: 1fr; }
}

View File

@ -41,4 +41,4 @@ Zamknięcie: „ENGIN daje trzy rzeczy naraz: konkursowo skuteczny model, weryfi
- Jeśli nie ma internetu: aplikacja nie potrzebuje internetu.
- Jeśli model nie wystartuje: automatycznie działa fallback na zapisanych predykcjach demo.
- 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 32/32.
- Przed wystąpieniem uruchom `python -m unittest discover -v` i zachowaj terminal z wynikiem 38/38.

View File

@ -2,6 +2,8 @@
from __future__ import annotations
from collections.abc import Sequence
import numpy as np
import plotly.graph_objects as go
@ -12,12 +14,19 @@ PLOT_BG = "#101820"
GRID = "rgba(148, 163, 184, 0.14)"
TEXT = "#dce8ee"
MUTED = "#8fa6b2"
COMPARISON_COLORS = ("#28b7d9", "#f5a524", "#38d996", "#d56cff", "#2f78ed")
def _base_layout(fig: go.Figure, *, height: int) -> go.Figure:
def _base_layout(
fig: go.Figure,
*,
height: int,
margin_top: int = 42,
margin_bottom: int = 28,
) -> go.Figure:
fig.update_layout(
height=height,
margin=dict(l=24, r=24, t=42, b=28),
margin=dict(l=24, r=24, t=margin_top, b=margin_bottom),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor=PLOT_BG,
font=dict(color=TEXT, family="Inter, system-ui, sans-serif"),
@ -48,19 +57,33 @@ def engine_heatmap(analysis: EngineAnalysis) -> go.Figure:
hovertemplate="Cylinder %{y}<br>%{x} kHz<br>Odchylenie %{z:.1f} mV<extra></extra>",
)
)
fig.update_layout(title="Engine Diagnostic Fingerprint")
fig.update_layout(title="Mapa odchyleń")
fig.update_yaxes(autorange="reversed", title="Cylinder")
fig.update_xaxes(title="Częstotliwość [kHz]", dtick=2)
return _base_layout(fig, height=420)
def cylinder_spectrum(analysis: EngineAnalysis, cylinder: int) -> go.Figure:
def _cylinder_position(analysis: EngineAnalysis, cylinder: int) -> int:
positions = np.flatnonzero(analysis.measurements["cylinder"].to_numpy() == cylinder)
if len(positions) != 1:
raise KeyError(f"Unknown cylinder={cylinder}")
position = int(positions[0])
return int(positions[0])
def cylinder_spectrum(
analysis: EngineAnalysis,
cylinder: int,
comparison_cylinders: Sequence[int] | None = None,
) -> go.Figure:
selected = list(
dict.fromkeys([cylinder, *(comparison_cylinders or ())])
)
if len(selected) > 4:
raise ValueError("At most four cylinders can be compared.")
position = _cylinder_position(analysis, cylinder)
row = analysis.diagnostics.iloc[position]
color = LABEL_COLORS[str(row["label"])]
primary_color = LABEL_COLORS[str(row["label"])]
frequency = np.arange(len(FREQ_COLS))
fig = go.Figure()
fig.add_trace(
@ -68,20 +91,33 @@ def cylinder_spectrum(analysis: EngineAnalysis, cylinder: int) -> go.Figure:
x=frequency,
y=analysis.reference[position],
mode="lines",
name="Mediana pozostałych cylindrów",
name=f"Referencja C{cylinder:02d}",
line=dict(color="#7f95a1", width=2, dash="dash"),
)
)
fig.add_trace(
go.Scatter(
x=frequency,
y=analysis.spectra[position],
mode="lines+markers",
name=f"Cylinder {cylinder}",
line=dict(color=color, width=3),
marker=dict(size=6),
)
extra_colors = iter(
color
for color in COMPARISON_COLORS
if color.lower() != primary_color.lower()
)
for selected_cylinder in selected:
selected_position = _cylinder_position(analysis, selected_cylinder)
color = primary_color if selected_cylinder == cylinder else next(extra_colors)
fig.add_trace(
go.Scatter(
x=frequency,
y=analysis.spectra[selected_position],
mode="lines+markers",
name=f"C{selected_cylinder:02d}",
line=dict(
color=color,
width=3 if selected_cylinder == cylinder else 2,
),
marker=dict(size=6 if selected_cylinder == cylinder else 4),
)
)
top = {
int(value)
for value in str(row["top_anomalous_frequencies_khz"]).split("|")
@ -91,14 +127,22 @@ def cylinder_spectrum(analysis: EngineAnalysis, cylinder: int) -> go.Figure:
fig.add_vrect(
x0=value - 0.35,
x1=value + 0.35,
fillcolor=color,
fillcolor=primary_color,
opacity=0.10,
line_width=0,
)
fig.update_layout(title="Widmo cylindra vs referencja", legend=dict(orientation="h", y=1.14))
fig.update_layout(
legend=dict(
orientation="h",
x=0,
xanchor="left",
y=1.04,
yanchor="bottom",
)
)
fig.update_xaxes(title="Częstotliwość [kHz]", dtick=1)
fig.update_yaxes(title="Amplituda [mV]")
return _base_layout(fig, height=410)
return _base_layout(fig, height=440, margin_top=76, margin_bottom=42)
def deviation_chart(analysis: EngineAnalysis, cylinder: int) -> go.Figure:
@ -116,7 +160,6 @@ def deviation_chart(analysis: EngineAnalysis, cylinder: int) -> go.Figure:
)
)
fig.add_hline(y=0, line_color="#6e8794", line_width=1)
fig.update_layout(title="Odchylenie od referencji")
fig.update_xaxes(title="Częstotliwość [kHz]", dtick=2)
fig.update_yaxes(title="Różnica [mV]")
return _base_layout(fig, height=320)
return _base_layout(fig, height=340, margin_top=24, margin_bottom=42)

View File

@ -13,8 +13,12 @@ class StreamlitSmokeTests(unittest.TestCase):
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), 2)
self.assertGreaterEqual(len(app.selectbox), 1)
self.assertEqual(app.radio[0].value, "Dane demonstracyjne")
self.assertEqual(app.segmented_control[0].value, "Przegląd")
self.assertFalse(
any(selector.label == "Cylinder główny" for selector in app.selectbox)
)
def test_upload_mode_has_safe_empty_state(self) -> None:
app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run()
@ -26,9 +30,14 @@ class StreamlitSmokeTests(unittest.TestCase):
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.assertEqual(app.segmented_control[0].value, "Szczegóły cylindra")
detail_selector = next(
selector for selector in app.selectbox if selector.label == "Cylinder do analizy"
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")
comparison.set_value([1, 2, 4]).run()
self.assertEqual(len(app.exception), 0)
rendered = "\n".join(markdown.value for markdown in app.markdown)
self.assertIn("CYLINDER 03", rendered)

View File

@ -62,6 +62,11 @@ class ExplainabilityTests(unittest.TestCase):
def test_all_chart_factories_return_populated_figures(self) -> None:
cylinder = int(self.analysis.measurements["cylinder"].iloc[0])
compared = self.analysis.measurements["cylinder"].astype(int).head(4).tolist()
self.assertEqual(len(engine_heatmap(self.analysis).data), 1)
self.assertEqual(len(cylinder_spectrum(self.analysis, cylinder).data), 2)
self.assertEqual(
len(cylinder_spectrum(self.analysis, cylinder, compared).data),
5,
)
self.assertEqual(len(deviation_chart(self.analysis, cylinder).data), 1)