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
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
MODEL_VERSION = "engin-2026.08.25-1"
|
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_DETAIL = "Szczegóły cylindra"
|
||||||
|
VIEW_MODEL = "Model"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -115,7 +118,7 @@ def _render_header(live_inference: bool) -> None:
|
|||||||
<div>
|
<div>
|
||||||
<div class="eyebrow">AESTEEL · DIESEL INJECTION DIAGNOSTICS</div>
|
<div class="eyebrow">AESTEEL · DIESEL INJECTION DIAGNOSTICS</div>
|
||||||
<h1>ENGIN Diagnostic Console</h1>
|
<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>
|
||||||
<div class="runtime-badge">● {mode}<br><span>CPU · LEAKAGE-SAFE</span></div>
|
<div class="runtime-badge">● {mode}<br><span>CPU · LEAKAGE-SAFE</span></div>
|
||||||
</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[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")
|
st.markdown("### Mapa cylindrów")
|
||||||
columns = st.columns(4)
|
columns = st.columns(4)
|
||||||
selected_cylinder = int(st.session_state[session_key])
|
selected_cylinder = int(st.session_state[session_key])
|
||||||
@ -162,7 +166,7 @@ def _render_cylinder_grid(analysis, session_key: str) -> None:
|
|||||||
width="stretch",
|
width="stretch",
|
||||||
type="primary" if cylinder == selected_cylinder else "secondary",
|
type="primary" if cylinder == selected_cylinder else "secondary",
|
||||||
on_click=_select_cylinder,
|
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",
|
width="stretch",
|
||||||
height=315,
|
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)
|
explanation = explain_cylinder(analysis, cylinder)
|
||||||
row = analysis.diagnostics[analysis.diagnostics["cylinder"].eq(cylinder)].iloc[0]
|
row = analysis.diagnostics[analysis.diagnostics["cylinder"].eq(cylinder)].iloc[0]
|
||||||
color = LABEL_COLORS[explanation.label]
|
color = LABEL_COLORS[explanation.label]
|
||||||
@ -209,14 +217,17 @@ def _render_cylinder_detail(analysis, cylinder: int) -> None:
|
|||||||
""",
|
""",
|
||||||
unsafe_allow_html=True,
|
unsafe_allow_html=True,
|
||||||
)
|
)
|
||||||
spectrum_col, deviation_col = st.columns([1.25, 1.0], gap="large")
|
st.markdown("#### Widma porównawcze")
|
||||||
with spectrum_col:
|
|
||||||
st.plotly_chart(
|
st.plotly_chart(
|
||||||
cylinder_spectrum(analysis, cylinder),
|
cylinder_spectrum(analysis, cylinder, comparison_cylinders),
|
||||||
width="stretch",
|
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(
|
st.plotly_chart(
|
||||||
deviation_chart(analysis, cylinder),
|
deviation_chart(analysis, cylinder),
|
||||||
width="stretch",
|
width="stretch",
|
||||||
@ -225,32 +236,28 @@ def _render_cylinder_detail(analysis, cylinder: int) -> None:
|
|||||||
|
|
||||||
why, next_step = st.columns(2, gap="large")
|
why, next_step = st.columns(2, gap="large")
|
||||||
with why:
|
with why:
|
||||||
st.markdown("#### Dlaczego taka diagnoza?")
|
st.markdown("#### Uzasadnienie")
|
||||||
st.write(explanation.reason)
|
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:
|
with next_step:
|
||||||
st.markdown("#### Rekomendowany następny krok")
|
st.markdown("#### Następny krok")
|
||||||
st.write(explanation.recommendation)
|
st.write(explanation.recommendation)
|
||||||
source_label = "Reguła OOD" if explanation.decision_source == "ood_override" else "Klasyfikator spektralny"
|
source_label = (
|
||||||
st.caption(f"Źródło decyzji: {source_label}")
|
"Reguła OOD"
|
||||||
st.caption("Score modelu służy do porównania predykcji; nie jest kalibrowanym prawdopodobieństwem awarii.")
|
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:
|
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, c2, c3, c4 = st.columns(4)
|
||||||
c1.metric("Grouped Macro F1", "0.981")
|
c1.metric("Grouped Macro F1", "0.981")
|
||||||
c2.metric("Severity accuracy", "0.930")
|
c2.metric("Severity accuracy", "0.930")
|
||||||
c3.metric("Walidacyjne ML", "33.65 / 40")
|
c3.metric("Walidacyjne ML", "33.65 / 40")
|
||||||
c4.metric("Bramka jakości", "CI GREEN")
|
c4.metric("Testy", "38 / 38")
|
||||||
st.markdown(
|
st.caption(
|
||||||
"""
|
"Grouped CV po engine_id · 5 seedów · Macro F1 przy 5% braków: 0.983 · CPU."
|
||||||
- **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.
|
|
||||||
"""
|
|
||||||
)
|
)
|
||||||
with st.expander("Pokaż dane diagnostyczne"):
|
with st.expander("Pokaż dane diagnostyczne"):
|
||||||
st.dataframe(result.diagnostics, width="stretch", hide_index=True)
|
st.dataframe(result.diagnostics, width="stretch", hide_index=True)
|
||||||
@ -277,7 +284,7 @@ def render_app(dependencies: AppDependencies | None = None) -> None:
|
|||||||
payload: bytes | None
|
payload: bytes | None
|
||||||
if source == "Dane demonstracyjne":
|
if source == "Dane demonstracyjne":
|
||||||
payload = deps.demo_payload
|
payload = deps.demo_payload
|
||||||
st.success("Załadowano bezpieczny zestaw demonstracyjny")
|
st.success("Demo załadowane")
|
||||||
else:
|
else:
|
||||||
uploaded = st.file_uploader("Plik pomiarowy CSV", type=["csv"])
|
uploaded = st.file_uploader("Plik pomiarowy CSV", type=["csv"])
|
||||||
payload = uploaded.getvalue() if uploaded is not None else None
|
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()
|
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
|
||||||
_render_cylinder_grid(analysis, session_key)
|
|
||||||
|
|
||||||
overview_tab, detail_tab, technical_tab = st.tabs(
|
view_key = f"active_view_{analysis.engine_id}"
|
||||||
["Przegląd silnika", "Szczegóły cylindra", "Walidacja i model"]
|
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)
|
_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(
|
selected_from_box = st.selectbox(
|
||||||
"Cylinder do analizy",
|
"Cylinder główny",
|
||||||
available,
|
available,
|
||||||
format_func=lambda value: f"Cylinder {value:02d}",
|
format_func=lambda value: f"Cylinder {value:02d}",
|
||||||
key=session_key,
|
key=session_key,
|
||||||
)
|
)
|
||||||
_render_cylinder_detail(analysis, int(selected_from_box))
|
comparison_options = [
|
||||||
with technical_tab:
|
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)
|
_render_technical(result)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -64,9 +64,9 @@
|
|||||||
|
|
||||||
.diagnosis-card {
|
.diagnosis-card {
|
||||||
--diagnosis-color: var(--cyan);
|
--diagnosis-color: var(--cyan);
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, .8fr) minmax(0, 1.6fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
margin: .5rem 0 1rem;
|
margin: .5rem 0 1rem;
|
||||||
@ -77,7 +77,11 @@
|
|||||||
.diagnosis-card h2 { margin: .08rem 0; color: var(--text); }
|
.diagnosis-card h2 { margin: .08rem 0; color: var(--text); }
|
||||||
.diagnosis-card p { margin: 0; color: var(--muted); }
|
.diagnosis-card p { margin: 0; color: var(--muted); }
|
||||||
.diagnosis-kicker { color: var(--diagnosis-color); font: 800 .68rem ui-monospace, monospace; letter-spacing: .12em; }
|
.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; }
|
.diagnosis-metrics strong { display: block; margin-top: .2rem; color: var(--text); font-size: 1.05rem; }
|
||||||
|
|
||||||
[data-testid="stButton"] button {
|
[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="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="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) {
|
@media (max-width: 1000px) {
|
||||||
.product-header, .diagnosis-card { flex-direction: column; }
|
.product-header { flex-direction: column; }
|
||||||
|
.diagnosis-card { grid-template-columns: 1fr; }
|
||||||
.runtime-badge { width: 100%; }
|
.runtime-badge { width: 100%; }
|
||||||
.status-strip { grid-template-columns: repeat(2, 1fr); }
|
.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) {
|
@media (max-width: 560px) {
|
||||||
.status-strip { grid-template-columns: 1fr; }
|
.status-strip { grid-template-columns: 1fr; }
|
||||||
.status-strip > div { border-right: 0; border-bottom: 1px solid var(--line); }
|
.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 nie ma internetu: aplikacja nie potrzebuje internetu.
|
||||||
- Jeśli model nie wystartuje: automatycznie działa fallback na zapisanych predykcjach demo.
|
- 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.
|
- 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 __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
|
|
||||||
@ -12,12 +14,19 @@ PLOT_BG = "#101820"
|
|||||||
GRID = "rgba(148, 163, 184, 0.14)"
|
GRID = "rgba(148, 163, 184, 0.14)"
|
||||||
TEXT = "#dce8ee"
|
TEXT = "#dce8ee"
|
||||||
MUTED = "#8fa6b2"
|
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(
|
fig.update_layout(
|
||||||
height=height,
|
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)",
|
paper_bgcolor="rgba(0,0,0,0)",
|
||||||
plot_bgcolor=PLOT_BG,
|
plot_bgcolor=PLOT_BG,
|
||||||
font=dict(color=TEXT, family="Inter, system-ui, sans-serif"),
|
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>",
|
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_yaxes(autorange="reversed", title="Cylinder")
|
||||||
fig.update_xaxes(title="Częstotliwość [kHz]", dtick=2)
|
fig.update_xaxes(title="Częstotliwość [kHz]", dtick=2)
|
||||||
return _base_layout(fig, height=420)
|
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)
|
positions = np.flatnonzero(analysis.measurements["cylinder"].to_numpy() == cylinder)
|
||||||
if len(positions) != 1:
|
if len(positions) != 1:
|
||||||
raise KeyError(f"Unknown cylinder={cylinder}")
|
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]
|
row = analysis.diagnostics.iloc[position]
|
||||||
color = LABEL_COLORS[str(row["label"])]
|
primary_color = LABEL_COLORS[str(row["label"])]
|
||||||
frequency = np.arange(len(FREQ_COLS))
|
frequency = np.arange(len(FREQ_COLS))
|
||||||
fig = go.Figure()
|
fig = go.Figure()
|
||||||
fig.add_trace(
|
fig.add_trace(
|
||||||
@ -68,20 +91,33 @@ def cylinder_spectrum(analysis: EngineAnalysis, cylinder: int) -> go.Figure:
|
|||||||
x=frequency,
|
x=frequency,
|
||||||
y=analysis.reference[position],
|
y=analysis.reference[position],
|
||||||
mode="lines",
|
mode="lines",
|
||||||
name="Mediana pozostałych cylindrów",
|
name=f"Referencja C{cylinder:02d}",
|
||||||
line=dict(color="#7f95a1", width=2, dash="dash"),
|
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(
|
fig.add_trace(
|
||||||
go.Scatter(
|
go.Scatter(
|
||||||
x=frequency,
|
x=frequency,
|
||||||
y=analysis.spectra[position],
|
y=analysis.spectra[selected_position],
|
||||||
mode="lines+markers",
|
mode="lines+markers",
|
||||||
name=f"Cylinder {cylinder}",
|
name=f"C{selected_cylinder:02d}",
|
||||||
line=dict(color=color, width=3),
|
line=dict(
|
||||||
marker=dict(size=6),
|
color=color,
|
||||||
|
width=3 if selected_cylinder == cylinder else 2,
|
||||||
|
),
|
||||||
|
marker=dict(size=6 if selected_cylinder == cylinder else 4),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
top = {
|
top = {
|
||||||
int(value)
|
int(value)
|
||||||
for value in str(row["top_anomalous_frequencies_khz"]).split("|")
|
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(
|
fig.add_vrect(
|
||||||
x0=value - 0.35,
|
x0=value - 0.35,
|
||||||
x1=value + 0.35,
|
x1=value + 0.35,
|
||||||
fillcolor=color,
|
fillcolor=primary_color,
|
||||||
opacity=0.10,
|
opacity=0.10,
|
||||||
line_width=0,
|
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_xaxes(title="Częstotliwość [kHz]", dtick=1)
|
||||||
fig.update_yaxes(title="Amplituda [mV]")
|
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:
|
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.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_xaxes(title="Częstotliwość [kHz]", dtick=2)
|
||||||
fig.update_yaxes(title="Różnica [mV]")
|
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()
|
app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run()
|
||||||
self.assertEqual(len(app.exception), 0)
|
self.assertEqual(len(app.exception), 0)
|
||||||
self.assertGreaterEqual(len(app.button), 8)
|
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.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:
|
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()
|
||||||
@ -26,9 +30,14 @@ class StreamlitSmokeTests(unittest.TestCase):
|
|||||||
app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run()
|
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 = next(button for button in app.button if "C03" in button.label)
|
||||||
target.click().run()
|
target.click().run()
|
||||||
|
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 do analizy"
|
selector for selector in app.selectbox if selector.label == "Cylinder główny"
|
||||||
)
|
)
|
||||||
self.assertEqual(detail_selector.value, 3)
|
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)
|
rendered = "\n".join(markdown.value for markdown in app.markdown)
|
||||||
self.assertIn("CYLINDER 03", rendered)
|
self.assertIn("CYLINDER 03", rendered)
|
||||||
|
|||||||
@ -62,6 +62,11 @@ class ExplainabilityTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_all_chart_factories_return_populated_figures(self) -> None:
|
def test_all_chart_factories_return_populated_figures(self) -> None:
|
||||||
cylinder = int(self.analysis.measurements["cylinder"].iloc[0])
|
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(engine_heatmap(self.analysis).data), 1)
|
||||||
self.assertEqual(len(cylinder_spectrum(self.analysis, cylinder).data), 2)
|
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)
|
self.assertEqual(len(deviation_chart(self.analysis, cylinder).data), 1)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user