diff --git a/app.py b/app.py
index 3e3b8e5..6a92774 100644
--- a/app.py
+++ b/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:
AESTEEL · DIESEL INJECTION DIAGNOSTICS
ENGIN Diagnostic Console
-
Akustyczna diagnostyka każdego cylindra — typ usterki, nasilenie i uzasadnienie.
+
Diagnoza cylindra, nasilenie i następny krok.
● {mode}
CPU · LEAKAGE-SAFE
@@ -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)
diff --git a/assets/app.css b/assets/app.css
index 09ca6a4..4307f41 100644
--- a/assets/app.css
+++ b/assets/app.css
@@ -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; }
}
diff --git a/docs/DEMO_SCENARIO.md b/docs/DEMO_SCENARIO.md
index a0fcebc..64b14d8 100644
--- a/docs/DEMO_SCENARIO.md
+++ b/docs/DEMO_SCENARIO.md
@@ -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.
diff --git a/engin/charts.py b/engin/charts.py
index d802457..ee4fb98 100644
--- a/engin/charts.py
+++ b/engin/charts.py
@@ -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}
%{x} kHz
Odchylenie %{z:.1f} mV",
)
)
- 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)
diff --git a/tests/test_app_smoke.py b/tests/test_app_smoke.py
index d45d526..3b3b452 100644
--- a/tests/test_app_smoke.py
+++ b/tests/test_app_smoke.py
@@ -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)
diff --git a/tests/test_explainability.py b/tests/test_explainability.py
index 875fa33..40e12b2 100644
--- a/tests/test_explainability.py
+++ b/tests/test_explainability.py
@@ -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)