ui: balance and localize application views
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 32s
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 32s
This commit is contained in:
parent
82ab439452
commit
7159c01123
111
app.py
111
app.py
@ -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))
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user