ui: streamline cylinder diagnosis views
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 43s

This commit is contained in:
Jakub Famulski 2 2026-08-25 13:55:56 +02:00
parent 5ebfdc389a
commit 82ab439452
6 changed files with 182 additions and 79 deletions

112
app.py
View File

@ -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)

View File

@ -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; }
} }

View File

@ -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.

View File

@ -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)

View File

@ -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)

View File

@ -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)