ui: streamline cylinder diagnosis views
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 43s
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 43s
This commit is contained in:
parent
5ebfdc389a
commit
82ab439452
112
app.py
112
app.py
@ -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,14 +217,17 @@ 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.markdown("#### Widma porównawcze")
|
||||
st.plotly_chart(
|
||||
cylinder_spectrum(analysis, cylinder),
|
||||
cylinder_spectrum(analysis, cylinder, comparison_cylinders),
|
||||
width="stretch",
|
||||
key=f"spectrum_{analysis.engine_id}_{cylinder}",
|
||||
key=(
|
||||
f"spectrum_{analysis.engine_id}_"
|
||||
+ "_".join(str(value) for value in comparison_cylinders)
|
||||
),
|
||||
)
|
||||
with deviation_col:
|
||||
|
||||
st.markdown("#### Odchylenie cylindra głównego")
|
||||
st.plotly_chart(
|
||||
deviation_chart(analysis, cylinder),
|
||||
width="stretch",
|
||||
@ -225,32 +236,28 @@ def _render_cylinder_detail(analysis, cylinder: int) -> None:
|
||||
|
||||
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:
|
||||
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 do analizy",
|
||||
"Cylinder główny",
|
||||
available,
|
||||
format_func=lambda value: f"Cylinder {value:02d}",
|
||||
key=session_key,
|
||||
)
|
||||
_render_cylinder_detail(analysis, int(selected_from_box))
|
||||
with technical_tab:
|
||||
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,
|
||||
)
|
||||
elif view == VIEW_MODEL:
|
||||
_render_technical(result)
|
||||
|
||||
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"),
|
||||
)
|
||||
)
|
||||
|
||||
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[position],
|
||||
y=analysis.spectra[selected_position],
|
||||
mode="lines+markers",
|
||||
name=f"Cylinder {cylinder}",
|
||||
line=dict(color=color, width=3),
|
||||
marker=dict(size=6),
|
||||
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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user