ui: align cylinder controls with service workflow
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
dab5264ed7
commit
9032c49993
@ -39,13 +39,13 @@ rollbacku znajduje się w `docs/PRODUCTION_RUNBOOK.md`.
|
|||||||
|
|
||||||
## Co zawiera produkt
|
## Co zawiera produkt
|
||||||
|
|
||||||
- mapa 8-, 12- i 16-cylindrowych silników z diagnozą każdego cylindra,
|
- przegląd silników 8-, 12- i 16-cylindrowych z diagnozą każdego cylindra,
|
||||||
- typ usterki: `ok`, `zakoksowany`, `lejacy`, `pompa`, `iglica` lub `unknown`,
|
- typ usterki: `ok`, `zakoksowany`, `lejacy`, `pompa`, `iglica` lub `unknown`,
|
||||||
- nasilenie: `male`, `srednie`, `duze`; dla `ok` i `unknown` zawsze `nie_dotyczy`,
|
- nasilenie: `male`, `srednie`, `duze`; dla `ok` i `unknown` zawsze `nie_dotyczy`,
|
||||||
- status silnika, najwyższe rzeczywiste severity i kolejka cylindrów do kontroli,
|
- status silnika, najwyższe rzeczywiste nasilenie i kolejka cylindrów do kontroli,
|
||||||
- heatmapa odchyleń całego silnika,
|
- mapa odchyleń całego silnika ze stałą, porównywalną skalą,
|
||||||
- porównanie widma cylindra z medianą pozostałych cylindrów tej samej jednostki,
|
- porównanie widma cylindra z medianą pozostałych cylindrów tej samej jednostki,
|
||||||
- trzy najbardziej anomalne pasma, niekalibrowany score modelu oraz rekomendowany następny krok,
|
- priorytet kontroli, trzy najbardziej anomalne pasma oraz rekomendowany następny krok,
|
||||||
- pobieranie `predictions.csv` i rozszerzonej diagnostyki,
|
- pobieranie `predictions.csv` i rozszerzonej diagnostyki,
|
||||||
- ścisła walidacja CSV i bezpieczne komunikaty błędów,
|
- ścisła walidacja CSV i bezpieczne komunikaty błędów,
|
||||||
- lokalne działanie na CPU, bez wysyłania danych do zewnętrznych usług.
|
- lokalne działanie na CPU, bez wysyłania danych do zewnętrznych usług.
|
||||||
|
|||||||
165
app.py
165
app.py
@ -7,7 +7,6 @@ import logging
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
@ -36,6 +35,7 @@ MODEL_VERSION = "engin-2026.08.25-1"
|
|||||||
MODEL_ARTIFACT_DIR = BASE_DIR / "artifacts" / MODEL_VERSION
|
MODEL_ARTIFACT_DIR = BASE_DIR / "artifacts" / MODEL_VERSION
|
||||||
VIEW_OVERVIEW = "Przegląd"
|
VIEW_OVERVIEW = "Przegląd"
|
||||||
VIEW_DETAIL = "Szczegóły cylindra"
|
VIEW_DETAIL = "Szczegóły cylindra"
|
||||||
|
NO_COMPARISON = 0
|
||||||
PLOTLY_CONFIG = {"displayModeBar": False, "displaylogo": False}
|
PLOTLY_CONFIG = {"displayModeBar": False, "displaylogo": False}
|
||||||
DIAGNOSTIC_COLUMN_DISPLAY = {
|
DIAGNOSTIC_COLUMN_DISPLAY = {
|
||||||
"engine_id": "Silnik",
|
"engine_id": "Silnik",
|
||||||
@ -122,12 +122,6 @@ def _render_error(error: UserFacingError) -> None:
|
|||||||
st.caption(f"Kod błędu: `{error.code}`")
|
st.caption(f"Kod błędu: `{error.code}`")
|
||||||
|
|
||||||
|
|
||||||
def _format_confidence(value: float | int | None) -> str:
|
|
||||||
if value is None or pd.isna(value):
|
|
||||||
return "—"
|
|
||||||
return f"{100 * float(value):.0f}%"
|
|
||||||
|
|
||||||
|
|
||||||
def _diagnostics_for_display(frame: pd.DataFrame) -> pd.DataFrame:
|
def _diagnostics_for_display(frame: pd.DataFrame) -> pd.DataFrame:
|
||||||
display = frame.copy()
|
display = frame.copy()
|
||||||
for column in ("label", "raw_model_label"):
|
for column in ("label", "raw_model_label"):
|
||||||
@ -151,24 +145,13 @@ def _diagnostics_for_display(frame: pd.DataFrame) -> pd.DataFrame:
|
|||||||
return display.rename(columns=DIAGNOSTIC_COLUMN_DISPLAY)
|
return display.rename(columns=DIAGNOSTIC_COLUMN_DISPLAY)
|
||||||
|
|
||||||
|
|
||||||
def _render_header(live_inference: bool) -> None:
|
def _render_header() -> None:
|
||||||
if live_inference:
|
|
||||||
mode = "MODEL GOTOWY"
|
|
||||||
verification = "ARTEFAKT ZWERYFIKOWANY"
|
|
||||||
tone = "verified"
|
|
||||||
else:
|
|
||||||
mode = "TRYB DEMONSTRACYJNY"
|
|
||||||
verification = "ZAPISANE WYNIKI"
|
|
||||||
tone = "fallback"
|
|
||||||
st.markdown(
|
st.markdown(
|
||||||
f"""
|
"""
|
||||||
<div class="product-header">
|
<div class="product-header">
|
||||||
<div>
|
<div class="eyebrow">AESTEEL · DIAGNOSTYKA WTRYSKU DIESEL</div>
|
||||||
<div class="eyebrow">AESTEEL · DIAGNOSTYKA WTRYSKU DIESEL</div>
|
<h1>Konsola diagnostyczna ENGIN</h1>
|
||||||
<h1>Konsola diagnostyczna ENGIN</h1>
|
<p>Diagnoza cylindra, nasilenie i następny krok.</p>
|
||||||
<p>Diagnoza cylindra, nasilenie i następny krok.</p>
|
|
||||||
</div>
|
|
||||||
<div class="runtime-badge runtime-{tone}">● {mode}<br><span>{verification}</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
""",
|
""",
|
||||||
unsafe_allow_html=True,
|
unsafe_allow_html=True,
|
||||||
@ -182,7 +165,7 @@ def _render_summary(summary) -> None:
|
|||||||
<div><span>STATUS SILNIKA</span><strong>{html.escape(summary.status)}</strong></div>
|
<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>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>WYMAGA UWAGI</span><strong>{summary.attention} / {summary.cylinders}</strong></div>
|
||||||
<div><span>ŚR. WYNIK MODELU</span><strong>{_format_confidence(summary.mean_confidence)}</strong></div>
|
<div><span>PIERWSZY DO KONTROLI</span><strong>C{summary.top_cylinder:02d}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
""",
|
""",
|
||||||
unsafe_allow_html=True,
|
unsafe_allow_html=True,
|
||||||
@ -190,9 +173,12 @@ def _render_summary(summary) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _render_engine_overview(analysis) -> None:
|
def _render_engine_overview(analysis) -> None:
|
||||||
left, right = st.columns(2, gap="large")
|
left, right = st.columns(2, gap="large", vertical_alignment="top")
|
||||||
with left:
|
with left:
|
||||||
st.markdown("### Mapa odchyleń")
|
st.markdown(
|
||||||
|
'<div class="overview-panel-title">Mapa odchyleń</div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
st.plotly_chart(
|
st.plotly_chart(
|
||||||
engine_heatmap(analysis),
|
engine_heatmap(analysis),
|
||||||
width="stretch",
|
width="stretch",
|
||||||
@ -200,15 +186,17 @@ def _render_engine_overview(analysis) -> None:
|
|||||||
config=PLOTLY_CONFIG,
|
config=PLOTLY_CONFIG,
|
||||||
)
|
)
|
||||||
with right:
|
with right:
|
||||||
st.markdown("### Priorytet kontroli")
|
st.markdown(
|
||||||
|
'<div class="overview-panel-title">Priorytet kontroli</div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
ranking = rank_cylinders(analysis).head(6).copy()
|
ranking = rank_cylinders(analysis).head(6).copy()
|
||||||
ranking["Cylinder"] = ranking["cylinder"].map(lambda value: f"C{int(value):02d}")
|
ranking["Cylinder"] = ranking["cylinder"].map(lambda value: f"C{int(value):02d}")
|
||||||
ranking["Diagnoza"] = ranking["label_display"]
|
ranking["Diagnoza"] = ranking["label_display"]
|
||||||
ranking["Nasilenie"] = ranking["severity_display"]
|
ranking["Nasilenie"] = ranking["severity_display"]
|
||||||
ranking["Wynik modelu"] = ranking["label_confidence"].map(_format_confidence)
|
|
||||||
ranking["Priorytet"] = ranking["priority_display"]
|
ranking["Priorytet"] = ranking["priority_display"]
|
||||||
st.dataframe(
|
st.dataframe(
|
||||||
ranking[["Cylinder", "Diagnoza", "Nasilenie", "Wynik modelu", "Priorytet"]],
|
ranking[["Cylinder", "Diagnoza", "Nasilenie", "Priorytet"]],
|
||||||
hide_index=True,
|
hide_index=True,
|
||||||
width="stretch",
|
width="stretch",
|
||||||
height=360,
|
height=360,
|
||||||
@ -221,9 +209,11 @@ def _render_cylinder_detail(
|
|||||||
comparison_cylinders: list[int],
|
comparison_cylinders: list[int],
|
||||||
) -> None:
|
) -> None:
|
||||||
explanation = explain_cylinder(analysis, cylinder)
|
explanation = explain_cylinder(analysis, cylinder)
|
||||||
row = analysis.diagnostics[analysis.diagnostics["cylinder"].eq(cylinder)].iloc[0]
|
|
||||||
color = LABEL_COLORS[explanation.label]
|
color = LABEL_COLORS[explanation.label]
|
||||||
severity_confidence = row.get("severity_confidence", np.nan)
|
priority = rank_cylinders(analysis).loc[
|
||||||
|
lambda frame: frame["cylinder"].eq(cylinder), "priority_display"
|
||||||
|
].iloc[0]
|
||||||
|
top_bands = ", ".join(f"{value} kHz" for value in explanation.top_frequencies)
|
||||||
st.markdown(
|
st.markdown(
|
||||||
f"""
|
f"""
|
||||||
<div class="diagnosis-card" style="--diagnosis-color:{color}">
|
<div class="diagnosis-card" style="--diagnosis-color:{color}">
|
||||||
@ -233,9 +223,9 @@ def _render_cylinder_detail(
|
|||||||
<p>{html.escape(explanation.severity_display)}</p>
|
<p>{html.escape(explanation.severity_display)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="diagnosis-metrics">
|
<div class="diagnosis-metrics">
|
||||||
<div><span>Wynik diagnozy</span><strong>{_format_confidence(explanation.confidence)}</strong></div>
|
<div><span>Priorytet</span><strong>{html.escape(priority)}</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><span>Średnie odchylenie</span><strong>{explanation.anomaly_score:.1f} mV</strong></div>
|
||||||
|
<div><span>Główne pasma</span><strong>{html.escape(top_bands)}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
""",
|
""",
|
||||||
@ -267,14 +257,73 @@ def _render_cylinder_detail(
|
|||||||
with next_step:
|
with next_step:
|
||||||
st.markdown("#### Następny krok")
|
st.markdown("#### Następny krok")
|
||||||
st.write(explanation.recommendation)
|
st.write(explanation.recommendation)
|
||||||
source_label = (
|
|
||||||
"Reguła anomalii"
|
|
||||||
if explanation.decision_source == "ood_override"
|
def _store_selected_cylinder(state_key: str, widget_key: str) -> None:
|
||||||
else "Klasyfikator spektralny"
|
st.session_state[state_key] = int(st.session_state[widget_key])
|
||||||
)
|
|
||||||
st.caption(
|
|
||||||
f"Źródło: {source_label} · wynik modelu nie jest prawdopodobieństwem."
|
def _comparison_label(value: int) -> str:
|
||||||
)
|
if value == NO_COMPARISON:
|
||||||
|
return "Bez porównania"
|
||||||
|
return f"Cylinder {value:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_cylinder_selectors(
|
||||||
|
analysis,
|
||||||
|
available: list[int],
|
||||||
|
selected_state_key: str,
|
||||||
|
) -> tuple[int, list[int]]:
|
||||||
|
selector_key = f"cylinder_selector_{analysis.engine_id}"
|
||||||
|
selected = int(st.session_state[selected_state_key])
|
||||||
|
if selector_key not in st.session_state or st.session_state[selector_key] not in available:
|
||||||
|
st.session_state[selector_key] = selected
|
||||||
|
|
||||||
|
with st.container(border=True):
|
||||||
|
st.markdown("### Wybór cylindra")
|
||||||
|
columns = st.columns([1.35, 1.0, 1.0, 1.0], gap="medium")
|
||||||
|
with columns[0]:
|
||||||
|
primary = int(
|
||||||
|
st.selectbox(
|
||||||
|
"Cylinder do analizy",
|
||||||
|
available,
|
||||||
|
format_func=lambda value: f"Cylinder {value:02d}",
|
||||||
|
key=selector_key,
|
||||||
|
on_change=_store_selected_cylinder,
|
||||||
|
args=(selected_state_key, selector_key),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
comparisons: list[int] = []
|
||||||
|
for slot, column in enumerate(columns[1:], start=1):
|
||||||
|
comparison_key = f"comparison_slot_{analysis.engine_id}_{slot}"
|
||||||
|
options = [
|
||||||
|
NO_COMPARISON,
|
||||||
|
*(
|
||||||
|
cylinder
|
||||||
|
for cylinder in available
|
||||||
|
if cylinder != primary and cylinder not in comparisons
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if (
|
||||||
|
comparison_key not in st.session_state
|
||||||
|
or st.session_state[comparison_key] not in options
|
||||||
|
):
|
||||||
|
st.session_state[comparison_key] = NO_COMPARISON
|
||||||
|
with column:
|
||||||
|
comparison = int(
|
||||||
|
st.selectbox(
|
||||||
|
f"Porównanie {slot}",
|
||||||
|
options,
|
||||||
|
format_func=_comparison_label,
|
||||||
|
key=comparison_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if comparison != NO_COMPARISON:
|
||||||
|
comparisons.append(comparison)
|
||||||
|
|
||||||
|
st.session_state[selected_state_key] = primary
|
||||||
|
return primary, comparisons
|
||||||
|
|
||||||
|
|
||||||
def _render_technical(result: DiagnosisResult) -> None:
|
def _render_technical(result: DiagnosisResult) -> None:
|
||||||
@ -305,7 +354,7 @@ def render_app(dependencies: AppDependencies | None = None) -> None:
|
|||||||
)
|
)
|
||||||
_load_css()
|
_load_css()
|
||||||
deps = dependencies or build_dependencies()
|
deps = dependencies or build_dependencies()
|
||||||
_render_header(deps.live_inference)
|
_render_header()
|
||||||
|
|
||||||
with st.sidebar:
|
with st.sidebar:
|
||||||
st.markdown("## Centrum diagnostyczne")
|
st.markdown("## Centrum diagnostyczne")
|
||||||
@ -362,14 +411,11 @@ def render_app(dependencies: AppDependencies | None = None) -> None:
|
|||||||
mime="text/csv",
|
mime="text/csv",
|
||||||
width="stretch",
|
width="stretch",
|
||||||
)
|
)
|
||||||
st.divider()
|
|
||||||
st.caption(f"Wersja modelu: {deps.model_version}")
|
|
||||||
|
|
||||||
analysis = analyze_engine(result, str(engine_id))
|
analysis = analyze_engine(result, str(engine_id))
|
||||||
summary = summarize_engine(analysis)
|
summary = summarize_engine(analysis)
|
||||||
_render_summary(summary)
|
_render_summary(summary)
|
||||||
|
|
||||||
session_key = f"selected_cylinder_{analysis.engine_id}"
|
session_key = f"active_cylinder_{analysis.engine_id}"
|
||||||
available = analysis.measurements["cylinder"].astype(int).tolist()
|
available = analysis.measurements["cylinder"].astype(int).tolist()
|
||||||
if session_key not in st.session_state or st.session_state[session_key] not in available:
|
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
|
st.session_state[session_key] = summary.top_cylinder
|
||||||
@ -389,28 +435,11 @@ def render_app(dependencies: AppDependencies | None = None) -> None:
|
|||||||
if view == VIEW_OVERVIEW:
|
if view == VIEW_OVERVIEW:
|
||||||
_render_engine_overview(analysis)
|
_render_engine_overview(analysis)
|
||||||
elif view == VIEW_DETAIL:
|
elif view == VIEW_DETAIL:
|
||||||
selector_col, comparison_col = st.columns([1.0, 2.0], gap="large")
|
selected_from_box, additional_cylinders = _render_cylinder_selectors(
|
||||||
with selector_col:
|
analysis,
|
||||||
selected_from_box = st.selectbox(
|
available,
|
||||||
"Cylinder główny",
|
session_key,
|
||||||
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 = [
|
comparison_cylinders = [
|
||||||
int(selected_from_box),
|
int(selected_from_box),
|
||||||
*(int(value) for value in additional_cylinders),
|
*(int(value) for value in additional_cylinders),
|
||||||
|
|||||||
@ -24,10 +24,6 @@
|
|||||||
[data-testid="stMainBlockContainer"] { padding-top: 2.1rem; max-width: 1500px; }
|
[data-testid="stMainBlockContainer"] { padding-top: 2.1rem; max-width: 1500px; }
|
||||||
|
|
||||||
.product-header {
|
.product-header {
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 2rem;
|
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
padding-bottom: 1.25rem;
|
padding-bottom: 1.25rem;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
@ -36,23 +32,6 @@
|
|||||||
.product-header h1 { margin: .2rem 0 .25rem; color: #f3f8fa; font-size: 2rem; line-height: 1.12; letter-spacing: -.035em; }
|
.product-header h1 { margin: .2rem 0 .25rem; color: #f3f8fa; font-size: 2rem; line-height: 1.12; letter-spacing: -.035em; }
|
||||||
.product-header p { margin: 0; color: var(--muted); }
|
.product-header p { margin: 0; color: var(--muted); }
|
||||||
.eyebrow { color: var(--cyan); font-size: .7rem; font-weight: 800; letter-spacing: .17em; }
|
.eyebrow { color: var(--cyan); font-size: .7rem; font-weight: 800; letter-spacing: .17em; }
|
||||||
.runtime-badge {
|
|
||||||
min-width: 218px;
|
|
||||||
padding: .7rem .9rem;
|
|
||||||
border: 1px solid rgba(56, 217, 150, .32);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: rgba(56, 217, 150, .07);
|
|
||||||
color: var(--green);
|
|
||||||
font: 700 .72rem/1.35 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
||||||
letter-spacing: .06em;
|
|
||||||
}
|
|
||||||
.runtime-badge span { color: var(--muted); font-size: .64rem; }
|
|
||||||
.runtime-fallback {
|
|
||||||
border-color: rgba(245, 165, 36, .38);
|
|
||||||
background: rgba(245, 165, 36, .08);
|
|
||||||
color: var(--orange);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-strip {
|
.status-strip {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
@ -63,7 +42,7 @@
|
|||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.status-strip > div { padding: 1rem 1.2rem; border-right: 1px solid var(--line); }
|
.status-strip > div { padding: 1rem 1.2rem; border-right: 1px solid var(--line); text-align: center; }
|
||||||
.status-strip > div:last-child { border-right: 0; }
|
.status-strip > div:last-child { border-right: 0; }
|
||||||
.status-strip span, .diagnosis-metrics span { display: block; color: var(--muted); font-size: .66rem; font-weight: 800; letter-spacing: .11em; }
|
.status-strip span, .diagnosis-metrics span { display: block; color: var(--muted); font-size: .66rem; font-weight: 800; letter-spacing: .11em; }
|
||||||
.status-strip strong { display: block; margin-top: .2rem; font-size: 1.28rem; color: var(--text); }
|
.status-strip strong { display: block; margin-top: .2rem; font-size: 1.28rem; color: var(--text); }
|
||||||
@ -97,6 +76,24 @@
|
|||||||
}
|
}
|
||||||
.diagnosis-metrics strong { display: block; margin-top: .2rem; color: var(--text); font-size: 1.05rem; }
|
.diagnosis-metrics strong { display: block; margin-top: .2rem; color: var(--text); font-size: 1.05rem; }
|
||||||
|
|
||||||
|
.overview-panel-title {
|
||||||
|
display: flex;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="stVerticalBlockBorderWrapper"] {
|
||||||
|
border-color: rgba(40, 183, 217, .32) !important;
|
||||||
|
border-left: 4px solid var(--cyan) !important;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: linear-gradient(90deg, rgba(40, 183, 217, .06), rgba(16, 27, 34, .72));
|
||||||
|
}
|
||||||
|
|
||||||
[data-testid="stMetric"] {
|
[data-testid="stMetric"] {
|
||||||
min-height: 5.2rem;
|
min-height: 5.2rem;
|
||||||
padding: .75rem;
|
padding: .75rem;
|
||||||
@ -140,9 +137,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1000px) {
|
@media (max-width: 1000px) {
|
||||||
.product-header { flex-direction: column; }
|
|
||||||
.diagnosis-card { grid-template-columns: 1fr; }
|
.diagnosis-card { grid-template-columns: 1fr; }
|
||||||
.runtime-badge { width: 100%; }
|
|
||||||
.status-strip { grid-template-columns: repeat(2, 1fr); }
|
.status-strip { grid-template-columns: repeat(2, 1fr); }
|
||||||
.diagnosis-metrics { width: 100%; }
|
.diagnosis-metrics { width: 100%; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,13 +12,13 @@
|
|||||||
|
|
||||||
Komunikat: „Nie zmuszamy mechanika do czytania 21 wykresów. Najpierw widzi najważniejszy cylinder i pilność.”
|
Komunikat: „Nie zmuszamy mechanika do czytania 21 wykresów. Najpierw widzi najważniejszy cylinder i pilność.”
|
||||||
|
|
||||||
## 1:15–2:20 — explainability
|
## 1:15–2:20 — uzasadnienie diagnozy
|
||||||
|
|
||||||
1. Otwórz „Szczegóły cylindra”.
|
1. Otwórz „Szczegóły cylindra”.
|
||||||
2. Wybierz najwyżej sklasyfikowany cylinder.
|
2. Wybierz najwyżej sklasyfikowany cylinder.
|
||||||
3. Opcjonalnie dodaj drugi cylinder do porównania.
|
3. Opcjonalnie dodaj drugi cylinder do porównania.
|
||||||
4. Porównaj widmo z medianą pozostałych cylindrów tego samego silnika.
|
4. Porównaj widmo z medianą pozostałych cylindrów tego samego silnika.
|
||||||
5. Wskaż zaznaczone pasma, wynik modelu, nasilenie i rekomendowany następny krok.
|
5. Wskaż priorytet, zaznaczone pasma, nasilenie i rekomendowany następny krok.
|
||||||
|
|
||||||
Komunikat: „Referencją jest konkretny silnik, nie abstrakcyjna średnia całej floty. Dzięki temu wynik jest łatwiejszy do zweryfikowania przez człowieka.”
|
Komunikat: „Referencją jest konkretny silnik, nie abstrakcyjna średnia całej floty. Dzięki temu wynik jest łatwiejszy do zweryfikowania przez człowieka.”
|
||||||
|
|
||||||
@ -28,7 +28,7 @@ Komunikat: „Referencją jest konkretny silnik, nie abstrakcyjna średnia całe
|
|||||||
2. Pokaż grupowe makro F1 0.981, trafność nasilenia 0.930 i wynik przy 5% braków.
|
2. Pokaż grupowe makro F1 0.981, trafność nasilenia 0.930 i wynik przy 5% braków.
|
||||||
3. Powiedz, że cały silnik jest zawsze w jednym zbiorze walidacyjnym, a model działa na CPU.
|
3. Powiedz, że cały silnik jest zawsze w jednym zbiorze walidacyjnym, a model działa na CPU.
|
||||||
|
|
||||||
Komunikat: „Wynik nie powstał przez przeciek między cylindrami tego samego silnika. Pipeline jest też testowany na brakach odpowiadających realnym danym warsztatowym.”
|
Komunikat: „Wynik nie powstał przez przeciek między cylindrami tego samego silnika. Proces jest też testowany na brakach odpowiadających realnym danym warsztatowym.”
|
||||||
|
|
||||||
## 3:05–3:40 — własny plik i wartość
|
## 3:05–3:40 — własny plik i wartość
|
||||||
|
|
||||||
|
|||||||
@ -28,7 +28,7 @@ Nie. Status jest deterministyczną regułą biznesową opartą bezpośrednio na
|
|||||||
|
|
||||||
## Czy score modelu jest prawdopodobieństwem awarii?
|
## Czy score modelu jest prawdopodobieństwem awarii?
|
||||||
|
|
||||||
Nie. To niekalibrowany wynik `predict_proba`, używany wyłącznie do względnego porównania decyzji klasyfikatora. Interfejs nazywa go „score”, nie „pewnością”. Dla `unknown` utworzonego przez regułę OOD pozostaje pusty zamiast sztucznie generować wartość 50–99%.
|
Nie. To niekalibrowany wynik `predict_proba`, używany wyłącznie do diagnostyki technicznej. Główny widok mechanika go nie pokazuje; zamiast tego prezentuje diagnozę, nasilenie, priorytet i następny krok. Dla `unknown` utworzonego przez regułę OOD wartość pozostaje pusta.
|
||||||
|
|
||||||
## Co robi aplikacja przy błędnym pliku?
|
## Co robi aplikacja przy błędnym pliku?
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
| Uwaga z przeglądu | Zmiana | Dowód regresji |
|
| Uwaga z przeglądu | Zmiana | Dowód regresji |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Wybór cylindra niepotrzebnie obciążał przegląd silnika | wybór jest dostępny wyłącznie w widoku szczegółów; przegląd pokazuje tylko stan i priorytety | `test_cylinder_selector_exists_only_in_detail_view` |
|
| Wybór cylindra niepotrzebnie obciążał przegląd silnika | wybór jest dostępny wyłącznie w widoku szczegółów; przegląd pokazuje tylko stan i priorytety | `test_cylinder_selector_exists_only_in_detail_view` |
|
||||||
|
| Selektor i karta mogły po pierwszym wejściu wskazywać różne cylindry | trwały stan wyboru i stan widżetu mają osobne klucze; test porównuje wartość selektora z nagłówkiem karty | `test_cylinder_selector_exists_only_in_detail_view` |
|
||||||
|
| Wielokrotny wybór pokazywał angielskie i sprzeczne komunikaty | zastąpiono go trzema opcjonalnymi, polskimi polami porównawczymi bez możliwości duplikatów | `test_cylinder_selector_exists_only_in_detail_view` |
|
||||||
| Automatyczna skala mapy wyolbrzymiała różnice w zdrowych silnikach | mapa ma stałą skalę ±20 mV i opis diagnozy przy każdym cylindrze | `test_all_chart_factories_return_populated_figures` |
|
| Automatyczna skala mapy wyolbrzymiała różnice w zdrowych silnikach | mapa ma stałą skalę ±20 mV i opis diagnozy przy każdym cylindrze | `test_all_chart_factories_return_populated_figures` |
|
||||||
| Status `WYMAGA WERYFIKACJI` był nieosiągalny | status zależy bezpośrednio od label/severity; `unknown` bez nazwanej usterki ma dedykowany status | `test_unknown_only_engine_requires_verification` |
|
| Status `WYMAGA WERYFIKACJI` był nieosiągalny | status zależy bezpośrednio od label/severity; `unknown` bez nazwanej usterki ma dedykowany status | `test_unknown_only_engine_requires_verification` |
|
||||||
| Engine Health 0–100 był arbitralny | liczba została usunięta; UI pokazuje najwyższe rzeczywiste severity i porządkowy triage | testy explainability + smoke UI |
|
| Engine Health 0–100 był arbitralny | liczba została usunięta; UI pokazuje najwyższe rzeczywiste severity i porządkowy triage | testy explainability + smoke UI |
|
||||||
|
|||||||
@ -21,12 +21,12 @@ class StreamlitSmokeTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertFalse(any("C01" in button.label for button in app.button))
|
self.assertFalse(any("C01" in button.label for button in app.button))
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
any(selector.label == "Cylinder główny" for selector in app.selectbox)
|
any(selector.label == "Cylinder do analizy" for selector in app.selectbox)
|
||||||
)
|
)
|
||||||
rendered = "\n".join(markdown.value for markdown in app.markdown)
|
rendered = "\n".join(markdown.value for markdown in app.markdown)
|
||||||
self.assertIn("Konsola diagnostyczna ENGIN", rendered)
|
self.assertIn("Konsola diagnostyczna ENGIN", rendered)
|
||||||
self.assertIn("ARTEFAKT ZWERYFIKOWANY", rendered)
|
self.assertNotIn("MODEL GOTOWY", rendered)
|
||||||
self.assertIn("runtime-verified", rendered)
|
self.assertNotIn("ARTEFAKT ZWERYFIKOWANY", rendered)
|
||||||
self.assertNotIn("CPU · DANE LOKALNE", rendered)
|
self.assertNotIn("CPU · DANE LOKALNE", rendered)
|
||||||
for english_fragment in (
|
for english_fragment in (
|
||||||
"Diagnostic Console",
|
"Diagnostic Console",
|
||||||
@ -59,6 +59,13 @@ class StreamlitSmokeTests(unittest.TestCase):
|
|||||||
diagnostic_columns = set(diagnostic_table.value.columns)
|
diagnostic_columns = set(diagnostic_table.value.columns)
|
||||||
self.assertIn("Źródło decyzji", diagnostic_columns)
|
self.assertIn("Źródło decyzji", diagnostic_columns)
|
||||||
self.assertNotIn("decision_source", diagnostic_columns)
|
self.assertNotIn("decision_source", diagnostic_columns)
|
||||||
|
overview_table = next(
|
||||||
|
table for table in app.dataframe if "Priorytet" in table.value.columns
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
list(overview_table.value.columns),
|
||||||
|
["Cylinder", "Diagnoza", "Nasilenie", "Priorytet"],
|
||||||
|
)
|
||||||
|
|
||||||
def test_upload_mode_has_safe_empty_state(self) -> None:
|
def test_upload_mode_has_safe_empty_state(self) -> None:
|
||||||
app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run()
|
app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run()
|
||||||
@ -69,21 +76,35 @@ class StreamlitSmokeTests(unittest.TestCase):
|
|||||||
def test_cylinder_selector_exists_only_in_detail_view(self) -> None:
|
def test_cylinder_selector_exists_only_in_detail_view(self) -> None:
|
||||||
app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run()
|
app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run()
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
any(selector.label == "Cylinder główny" for selector in app.selectbox)
|
any(selector.label == "Cylinder do analizy" for selector in app.selectbox)
|
||||||
)
|
)
|
||||||
app.segmented_control[0].set_value("Szczegóły cylindra").run()
|
app.segmented_control[0].set_value("Szczegóły cylindra").run()
|
||||||
self.assertEqual(app.segmented_control[0].value, "Szczegóły cylindra")
|
self.assertEqual(app.segmented_control[0].value, "Szczegóły cylindra")
|
||||||
detail_selector = next(
|
detail_selector = next(
|
||||||
selector for selector in app.selectbox if selector.label == "Cylinder główny"
|
selector
|
||||||
|
for selector in app.selectbox
|
||||||
|
if selector.label == "Cylinder do analizy"
|
||||||
)
|
)
|
||||||
|
rendered = "\n".join(markdown.value for markdown in app.markdown)
|
||||||
|
self.assertIn(f"CYLINDER {int(detail_selector.value):02d}", rendered)
|
||||||
detail_selector.set_value(3).run()
|
detail_selector.set_value(3).run()
|
||||||
detail_selector = next(
|
detail_selector = next(
|
||||||
selector for selector in app.selectbox if selector.label == "Cylinder główny"
|
selector
|
||||||
|
for selector in app.selectbox
|
||||||
|
if selector.label == "Cylinder do analizy"
|
||||||
)
|
)
|
||||||
self.assertEqual(detail_selector.value, 3)
|
self.assertEqual(detail_selector.value, 3)
|
||||||
comparison = app.multiselect[0]
|
for slot, cylinder in enumerate((1, 2, 4), start=1):
|
||||||
self.assertEqual(comparison.label, "Porównaj z cylindrami")
|
comparison = next(
|
||||||
comparison.set_value([1, 2, 4]).run()
|
selector
|
||||||
|
for selector in app.selectbox
|
||||||
|
if selector.label == f"Porównanie {slot}"
|
||||||
|
)
|
||||||
|
comparison.set_value(cylinder).run()
|
||||||
self.assertEqual(len(app.exception), 0)
|
self.assertEqual(len(app.exception), 0)
|
||||||
|
self.assertEqual(len(app.multiselect), 0)
|
||||||
rendered = "\n".join(markdown.value for markdown in app.markdown)
|
rendered = "\n".join(markdown.value for markdown in app.markdown)
|
||||||
self.assertIn("CYLINDER 03", rendered)
|
self.assertIn("CYLINDER 03", rendered)
|
||||||
|
self.assertIn("### Wybór cylindra", rendered)
|
||||||
|
self.assertNotIn("Select all", rendered)
|
||||||
|
self.assertNotIn("You can select up to", rendered)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user