ui: balance and localize application views
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 32s

This commit is contained in:
Jakub Famulski 2 2026-08-25 15:01:49 +02:00
parent 82ab439452
commit 7159c01123
5 changed files with 130 additions and 29 deletions

111
app.py
View File

@ -39,6 +39,27 @@ MODEL_ARTIFACT_DIR = BASE_DIR / "artifacts" / MODEL_VERSION
VIEW_OVERVIEW = "Przegląd"
VIEW_DETAIL = "Szczegóły cylindra"
VIEW_MODEL = "Model"
PLOTLY_CONFIG = {"displayModeBar": False, "displaylogo": False}
DIAGNOSTIC_COLUMN_DISPLAY = {
"engine_id": "Silnik",
"cylinder": "Cylinder",
"label": "Diagnoza",
"severity": "Nasilenie",
"raw_model_label": "Surowa diagnoza modelu",
"decision_source": "Źródło decyzji",
"label_confidence": "Wynik diagnozy",
"raw_model_confidence": "Surowy wynik diagnozy",
"label_margin": "Margines decyzji",
"severity_confidence": "Wynik oceny nasilenia",
"anomaly_score_mean_abs_mv": "Średnie odchylenie bezwzględne [mV]",
"anomaly_ratio_to_engine_median": "Odchylenie względem mediany silnika",
"ood_absolute_threshold_mv": "Bezwzględny próg anomalii [mV]",
"ood_ratio_threshold": "Względny próg anomalii",
"top_anomalous_frequencies_khz": "Anomalne częstotliwości [kHz]",
"missing_spectral_cells": "Brakujące pomiary widma",
"model_version": "Wersja modelu",
"model_artifact_sha256": "Suma SHA-256 artefaktu",
}
@dataclass(frozen=True)
@ -83,7 +104,7 @@ def build_dependencies() -> AppDependencies:
"Artefakt modelu nie został załadowany. Aplikacja działa w bezpiecznym "
"trybie demonstracyjnym na zapisanych predykcjach."
),
model_version="demo-precomputed",
model_version="tryb-demonstracyjny",
)
@ -110,17 +131,40 @@ def _format_confidence(value: float | int | None) -> str:
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"):
if column in display:
display[column] = display[column].map(LABEL_DISPLAY).fillna(display[column])
if "severity" in display:
display["severity"] = (
display["severity"].map(SEVERITY_DISPLAY).fillna(display["severity"])
)
if "decision_source" in display:
display["decision_source"] = display["decision_source"].replace(
{
"classifier": "Klasyfikator spektralny",
"ood_override": "Reguła anomalii",
}
)
if "model_version" in display:
display["model_version"] = display["model_version"].replace(
{"demo-precomputed": "tryb demonstracyjny"}
)
return display.rename(columns=DIAGNOSTIC_COLUMN_DISPLAY)
def _render_header(live_inference: bool) -> None:
mode = "LIVE INFERENCE" if live_inference else "DEMO FALLBACK"
mode = "MODEL AKTYWNY" if live_inference else "TRYB DEMONSTRACYJNY"
st.markdown(
f"""
<div class="product-header">
<div>
<div class="eyebrow">AESTEEL · DIESEL INJECTION DIAGNOSTICS</div>
<h1>ENGIN Diagnostic Console</h1>
<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"> {mode}<br><span>CPU · LEAKAGE-SAFE</span></div>
<div class="runtime-badge"> {mode}<br><span>CPU · DANE LOKALNE</span></div>
</div>
""",
unsafe_allow_html=True,
@ -134,7 +178,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. SCORE MODELU</span><strong>{_format_confidence(summary.mean_confidence)}</strong></div>
<div><span>ŚR. WYNIK MODELU</span><strong>{_format_confidence(summary.mean_confidence)}</strong></div>
</div>
""",
unsafe_allow_html=True,
@ -171,24 +215,29 @@ def _render_cylinder_grid(analysis, session_key: str, view_key: str) -> None:
def _render_engine_overview(analysis) -> None:
left, right = st.columns([1.45, 1.0], gap="large")
left, right = st.columns(2, gap="large")
with left:
st.plotly_chart(engine_heatmap(analysis), width="stretch", key=f"heatmap_{analysis.engine_id}")
st.markdown("### Mapa odchyleń")
st.plotly_chart(
engine_heatmap(analysis),
width="stretch",
key=f"heatmap_{analysis.engine_id}",
config=PLOTLY_CONFIG,
)
with right:
st.markdown("### Priorytet kontroli")
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["Score modelu"] = ranking["label_confidence"].map(_format_confidence)
ranking["Wynik modelu"] = ranking["label_confidence"].map(_format_confidence)
ranking["Priorytet"] = ranking["priority_display"]
st.dataframe(
ranking[["Cylinder", "Diagnoza", "Nasilenie", "Score modelu", "Priorytet"]],
ranking[["Cylinder", "Diagnoza", "Nasilenie", "Wynik modelu", "Priorytet"]],
hide_index=True,
width="stretch",
height=315,
height=360,
)
st.caption("Priorytet: nasilenie → odchylenie → score modelu.")
def _render_cylinder_detail(
@ -209,8 +258,8 @@ def _render_cylinder_detail(
<p>{html.escape(explanation.severity_display)}</p>
</div>
<div class="diagnosis-metrics">
<div><span>Score label</span><strong>{_format_confidence(explanation.confidence)}</strong></div>
<div><span>Score severity</span><strong>{_format_confidence(severity_confidence)}</strong></div>
<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>Średnie odchylenie</span><strong>{explanation.anomaly_score:.1f} mV</strong></div>
</div>
</div>
@ -225,6 +274,7 @@ def _render_cylinder_detail(
f"spectrum_{analysis.engine_id}_"
+ "_".join(str(value) for value in comparison_cylinders)
),
config=PLOTLY_CONFIG,
)
st.markdown("#### Odchylenie cylindra głównego")
@ -232,6 +282,7 @@ def _render_cylinder_detail(
deviation_chart(analysis, cylinder),
width="stretch",
key=f"deviation_{analysis.engine_id}_{cylinder}",
config=PLOTLY_CONFIG,
)
why, next_step = st.columns(2, gap="large")
@ -242,30 +293,37 @@ def _render_cylinder_detail(
st.markdown("#### Następny krok")
st.write(explanation.recommendation)
source_label = (
"Reguła OOD"
"Reguła anomalii"
if explanation.decision_source == "ood_override"
else "Klasyfikator spektralny"
)
st.caption(f"Źródło: {source_label} · score nie jest prawdopodobieństwem.")
st.caption(
f"Źródło: {source_label} · wynik modelu nie jest prawdopodobieństwem."
)
def _render_technical(result: DiagnosisResult) -> None:
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")
c1.metric("Makro F1 (grupowe)", "0.981")
c2.metric("Trafność nasilenia", "0.930")
c3.metric("Punkty walidacyjne", "33.65 / 40")
c4.metric("Testy", "38 / 38")
st.caption(
"Grouped CV po engine_id · 5 seedów · Macro F1 przy 5% braków: 0.983 · CPU."
"Walidacja grupowa według silników · średnia z 5 uruchomień · "
"makro F1 przy 5% braków: 0.983 · CPU."
)
with st.expander("Pokaż dane diagnostyczne"):
st.dataframe(result.diagnostics, width="stretch", hide_index=True)
st.dataframe(
_diagnostics_for_display(result.diagnostics),
width="stretch",
hide_index=True,
)
def render_app(dependencies: AppDependencies | None = None) -> None:
st.set_page_config(
page_title="ENGIN Diagnostic Console",
page_title="Konsola diagnostyczna ENGIN",
page_icon="⚙️",
layout="wide",
initial_sidebar_state="expanded",
@ -284,7 +342,7 @@ def render_app(dependencies: AppDependencies | None = None) -> None:
payload: bytes | None
if source == "Dane demonstracyjne":
payload = deps.demo_payload
st.success("Demo załadowane")
st.success("Dane demonstracyjne załadowane")
else:
uploaded = st.file_uploader("Plik pomiarowy CSV", type=["csv"])
payload = uploaded.getvalue() if uploaded is not None else None
@ -316,14 +374,14 @@ def render_app(dependencies: AppDependencies | None = None) -> None:
with st.sidebar:
engine_id = st.selectbox("Aktywny silnik", result.engine_ids)
st.download_button(
"Pobierz predictions.csv",
"Pobierz wyniki (CSV)",
data=result.submission.to_csv(index=False).encode("utf-8"),
file_name="predictions.csv",
mime="text/csv",
width="stretch",
)
st.download_button(
"Pobierz diagnostykę",
"Pobierz dane diagnostyczne (CSV)",
data=result.diagnostics.to_csv(index=False).encode("utf-8"),
file_name="prediction_diagnostics.csv",
mime="text/csv",
@ -331,7 +389,8 @@ def render_app(dependencies: AppDependencies | None = None) -> None:
)
st.divider()
st.caption(
f"Model {deps.model_version} · CPU inference · brak połączeń zewnętrznych"
f"Wersja modelu: {deps.model_version} · wnioskowanie na CPU · "
"bez połączeń zewnętrznych"
)
analysis = analyze_engine(result, str(engine_id))

View File

@ -98,6 +98,22 @@
[data-testid="stMetric"] { padding: .7rem; border: 1px solid var(--line); background: var(--panel); }
[data-testid="stDataFrame"], [data-testid="stPlotlyChart"] { border: 1px solid var(--line); overflow: hidden; }
/* Streamlit 1.62 has no interface locale setting, so localize its uploader chrome. */
[data-testid="stFileUploaderDropzone"] button [data-testid="stMarkdownContainer"] { display: none; }
[data-testid="stFileUploaderDropzone"] button::after { content: "Wybierz plik"; }
[data-testid="stFileUploaderDropzoneInstructions"] > div { display: none; }
[data-testid="stFileUploaderDropzoneInstructions"]::after {
content: "Maks. 10 MB na plik · CSV";
color: var(--muted);
font-size: .875rem;
white-space: nowrap;
}
[data-testid="stFileUploaderDropzone"] > div:not([data-testid]) > span { font-size: 0; }
[data-testid="stFileUploaderDropzone"] > div:not([data-testid]) > span::after {
content: "Upuść plik tutaj";
font-size: .875rem;
}
@media (max-width: 1000px) {
.product-header { flex-direction: column; }
.diagnosis-card { grid-template-columns: 1fr; }

View File

@ -57,10 +57,9 @@ def engine_heatmap(analysis: EngineAnalysis) -> go.Figure:
hovertemplate="Cylinder %{y}<br>%{x} kHz<br>Odchylenie %{z:.1f} mV<extra></extra>",
)
)
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)
return _base_layout(fig, height=360, margin_top=18, margin_bottom=42)
def _cylinder_position(analysis: EngineAnalysis, cylinder: int) -> int:

View File

@ -19,6 +19,31 @@ class StreamlitSmokeTests(unittest.TestCase):
self.assertFalse(
any(selector.label == "Cylinder główny" for selector in app.selectbox)
)
rendered = "\n".join(markdown.value for markdown in app.markdown)
self.assertIn("Konsola diagnostyczna ENGIN", rendered)
for english_fragment in (
"Diagnostic Console",
"LIVE INFERENCE",
"DEMO FALLBACK",
"LEAKAGE-SAFE",
"Grouped Macro F1",
"Severity accuracy",
):
self.assertNotIn(english_fragment, rendered)
app.segmented_control[0].set_value("Model").run()
self.assertEqual(
[metric.label for metric in app.metric],
[
"Makro F1 (grupowe)",
"Trafność nasilenia",
"Punkty walidacyjne",
"Testy",
],
)
diagnostic_columns = set(app.dataframe[0].value.columns)
self.assertIn("Źródło decyzji", diagnostic_columns)
self.assertNotIn("decision_source", diagnostic_columns)
def test_upload_mode_has_safe_empty_state(self) -> None:
app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run()

View File

@ -63,7 +63,9 @@ 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)
heatmap = engine_heatmap(self.analysis)
self.assertEqual(len(heatmap.data), 1)
self.assertEqual(heatmap.layout.height, 360)
self.assertEqual(len(cylinder_spectrum(self.analysis, cylinder).data), 2)
self.assertEqual(
len(cylinder_spectrum(self.analysis, cylinder, compared).data),