From 3993a4a796f027624ae30245220f22bcac30c218 Mon Sep 17 00:00:00 2001 From: bresch Date: Thu, 9 Jul 2026 17:04:29 +0200 Subject: [PATCH 1/7] feat(filters): GUI to design and visualize series filter chains Add a PyQt5 tool to build a chain of digital filters linked in series and view the combined magnitude/phase/group-delay response. - filter_library.py: pure numpy/scipy core with a data-driven registry of the 11 filter types, Filter and FilterChain (series convolution), and response helpers. No GUI dependency so it can be reused in the control loop of other tools (e.g. autotune). - filter_response_canvas.py: reusable 3-plot Bode canvas with a shift+click/drag red cursor reading out values at a frequency. - filter_edit_dialog.py: add/edit popup with live preview. - filter_chain_widget.py: embeddable table (show/edit/remove) + plot. - filter_designer.py: standalone app entry point. --- filters/filter_chain_widget.py | 197 +++++++++++++++ filters/filter_chain_widget_helpers.py | 29 +++ filters/filter_designer.py | 45 ++++ filters/filter_edit_dialog.py | 130 ++++++++++ filters/filter_library.py | 328 +++++++++++++++++++++++++ filters/filter_response_canvas.py | 231 +++++++++++++++++ 6 files changed, 960 insertions(+) create mode 100644 filters/filter_chain_widget.py create mode 100644 filters/filter_chain_widget_helpers.py create mode 100644 filters/filter_designer.py create mode 100644 filters/filter_edit_dialog.py create mode 100644 filters/filter_library.py create mode 100644 filters/filter_response_canvas.py diff --git a/filters/filter_chain_widget.py b/filters/filter_chain_widget.py new file mode 100644 index 0000000..0040ee8 --- /dev/null +++ b/filters/filter_chain_widget.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +File: filter_chain_widget.py +Author: Mathieu Bresciani +Description: + Embeddable QWidget presenting a chain of filters linked in series: + - a global sampling-frequency field, + - an "Add filter" button, + - a table with, per row: a "show" checkbox (overlay that filter's + individual response), a summary, and Edit / Remove buttons, + - the combined response (always drawn bold) plus every checked filter. + + It is a plain QWidget (not a window) so it can be embedded elsewhere, + e.g. as a panel/tab of the autotune tool. It emits ``changed`` whenever + the chain or fs is modified, and exposes ``chain`` (a FilterChain). +""" + +from filter_chain_widget_helpers import make_button_cell, make_checkbox_cell +from filter_edit_dialog import FilterEditDialog +from filter_library import FilterChain +from filter_response_canvas import FilterResponseCanvas, Trace +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtWidgets import ( + QAbstractItemView, + QDoubleSpinBox, + QHBoxLayout, + QHeaderView, + QLabel, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +COL_SHOW, COL_SUMMARY, COL_EDIT, COL_REMOVE = range(4) + +# Combined trace is always black; individual filters cycle through this palette +# by their position in the chain (red is reserved for the cursor). +COMBINED_COLOR = "black" +FILTER_COLORS = [ + "#1f77b4", + "#ff7f0e", + "#2ca02c", + "#9467bd", + "#8c564b", + "#e377c2", + "#17becf", + "#bcbd22", +] + + +class FilterChainWidget(QWidget): + """Table of series filters plus their combined frequency response.""" + + changed = pyqtSignal() + + def __init__(self, parent=None, fs=1000.0, chain: FilterChain = None): + super().__init__(parent) + self.chain = chain if chain is not None else FilterChain() + self._show_flags = [False] * len(self.chain) + + # --- top row: fs + add --- + self.spin_fs = QDoubleSpinBox() + self.spin_fs.setRange(1.0, 1e7) + self.spin_fs.setDecimals(1) + self.spin_fs.setValue(fs) + self.spin_fs.setSuffix(" Hz") + self.spin_fs.valueChanged.connect(self._on_fs_changed) + + self.btn_add = QPushButton("+ Add filter") + self.btn_add.clicked.connect(self._on_add) + + top = QHBoxLayout() + top.addWidget(QLabel("Sampling freq:")) + top.addWidget(self.spin_fs) + top.addStretch() + top.addWidget(self.btn_add) + + # --- table --- + self.table = QTableWidget(0, 4) + self.table.setHorizontalHeaderLabels(["Show", "Filter", "", ""]) + self.table.verticalHeader().setVisible(False) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.setSelectionMode(QAbstractItemView.NoSelection) + header = self.table.horizontalHeader() + header.setSectionResizeMode(COL_SUMMARY, QHeaderView.Stretch) + header.setSectionResizeMode(COL_SHOW, QHeaderView.ResizeToContents) + # ResizeToContents measures cell *items*, not embedded widgets, so it + # clips the buttons. Give the button columns fixed widths sized to the + # widest button label instead. + for col in (COL_EDIT, COL_REMOVE): + header.setSectionResizeMode(col, QHeaderView.Fixed) + self.table.setColumnWidth(COL_EDIT, make_button_cell("Edit").minimumWidth() + 8) + self.table.setColumnWidth( + COL_REMOVE, make_button_cell("Remove").minimumWidth() + 8 + ) + self.table.setMaximumHeight(220) + + # --- plot --- + self.canvas = FilterResponseCanvas(figsize=(6, 6)) + + layout = QVBoxLayout(self) + layout.addLayout(top) + layout.addWidget(self.table) + layout.addWidget(self.canvas, 1) + + self._rebuild_table() + self._replot() + + # ------------------------------------------------------------------ + @property + def fs(self): + return self.spin_fs.value() + + # --- table construction ------------------------------------------- + def _rebuild_table(self): + self.table.setRowCount(0) + for row, flt in enumerate(self.chain): + self.table.insertRow(row) + + show = make_checkbox_cell(self._show_flags[row]) + show.toggled.connect( + lambda checked, r=row: self._on_show_toggled(r, checked) + ) + self.table.setCellWidget(row, COL_SHOW, self._center(show)) + + self.table.setItem(row, COL_SUMMARY, QTableWidgetItem(flt.summary())) + + edit = make_button_cell("Edit") + edit.clicked.connect(lambda _, r=row: self._on_edit(r)) + self.table.setCellWidget(row, COL_EDIT, edit) + + remove = make_button_cell("Remove", danger=True) + remove.clicked.connect(lambda _, r=row: self._on_remove(r)) + self.table.setCellWidget(row, COL_REMOVE, remove) + + @staticmethod + def _center(widget): + wrap = QWidget() + lay = QHBoxLayout(wrap) + lay.setContentsMargins(0, 0, 0, 0) + lay.setAlignment(Qt.AlignCenter) + lay.addWidget(widget) + return wrap + + # --- callbacks ----------------------------------------------------- + def _on_fs_changed(self, *_): + self._replot() + self.changed.emit() + + def _on_add(self): + dlg = FilterEditDialog(self, fs=self.fs) + if dlg.exec_() == FilterEditDialog.Accepted and dlg.result_filter: + self.chain.add(dlg.result_filter) + self._show_flags.append(False) + self._rebuild_table() + self._replot() + self.changed.emit() + + def _on_edit(self, row): + dlg = FilterEditDialog(self, fs=self.fs, flt=self.chain[row].copy()) + if dlg.exec_() == FilterEditDialog.Accepted and dlg.result_filter: + self.chain.replace(row, dlg.result_filter) + self._rebuild_table() + self._replot() + self.changed.emit() + + def _on_remove(self, row): + self.chain.remove(row) + del self._show_flags[row] + self._rebuild_table() + self._replot() + self.changed.emit() + + def _on_show_toggled(self, row, checked): + self._show_flags[row] = checked + self._replot() + + # --- plotting ------------------------------------------------------ + def _replot(self): + traces = [] + for row, flt in enumerate(self.chain): + if self._show_flags[row]: + b, a = flt.coefficients(self.fs) + # Colour is tied to the filter's position in the chain so it + # stays stable regardless of which filters are shown/hidden. + color = FILTER_COLORS[row % len(FILTER_COLORS)] + traces.append(Trace(b, a, label=flt.summary(), bold=False, color=color)) + if len(self.chain) > 0: + b, a = self.chain.coefficients(self.fs) + traces.append( + Trace(b, a, label="Combined", bold=True, color=COMBINED_COLOR) + ) + self.canvas.plot(traces, self.fs) diff --git a/filters/filter_chain_widget_helpers.py b/filters/filter_chain_widget_helpers.py new file mode 100644 index 0000000..b4cf945 --- /dev/null +++ b/filters/filter_chain_widget_helpers.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +File: filter_chain_widget_helpers.py +Author: Mathieu Bresciani +Description: + Small widget factory helpers for the filter chain table. +""" + +from PyQt5.QtWidgets import QCheckBox, QPushButton + + +def make_checkbox_cell(checked=False): + box = QCheckBox() + box.setChecked(checked) + box.setToolTip("Overlay this filter's individual response") + return box + + +def make_button_cell(text, danger=False): + btn = QPushButton(text) + if danger: + btn.setStyleSheet("color: white; background-color: #c0392b;") + # Reserve room for the label up front: ResizeToContents otherwise measures + # the column before the embedded button has laid out and clips the text. + text_width = btn.fontMetrics().horizontalAdvance(text) + btn.setMinimumWidth(text_width + 24) + return btn diff --git a/filters/filter_designer.py b/filters/filter_designer.py new file mode 100644 index 0000000..7efbb9a --- /dev/null +++ b/filters/filter_designer.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +File: filter_designer.py +Author: Mathieu Bresciani +Description: + Standalone GUI to design a chain of digital filters linked in series and + visualize the combined frequency response (magnitude, phase, group delay). + + Add filters with the "Add filter" button (a popup previews the filter as + you tune it), then edit/remove each one from the table. Tick a row's "Show" + box to overlay that individual filter on top of the combined response. + + The heavy lifting lives in filter_library.py (pure numpy/scipy) so the same + filters can be reused in the control loop of other tools. + +Usage: + python filter_designer.py +""" + +import sys + +from filter_chain_widget import FilterChainWidget +from PyQt5.QtWidgets import QApplication, QMainWindow + + +class FilterDesigner(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("Digital filter designer") + self.widget = FilterChainWidget(fs=1000.0) + self.setCentralWidget(self.widget) + self.resize(1000, 800) + + +def main(): + app = QApplication(sys.argv) + win = FilterDesigner() + win.show() + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main() diff --git a/filters/filter_edit_dialog.py b/filters/filter_edit_dialog.py new file mode 100644 index 0000000..814cf3e --- /dev/null +++ b/filters/filter_edit_dialog.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +File: filter_edit_dialog.py +Author: Mathieu Bresciani +Description: + Modal dialog to add or edit a single filter. A combo box selects the + filter type; parameter fields are rebuilt from the type's ParamSpec list. + An embedded FilterResponseCanvas previews the filter live as parameters + change. +""" + +from filter_library import FILTER_TYPE_IDS, FILTER_TYPES, Filter +from filter_response_canvas import FilterResponseCanvas, Trace +from PyQt5.QtWidgets import ( + QComboBox, + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QVBoxLayout, +) + + +class FilterEditDialog(QDialog): + """Add (``flt=None``) or edit an existing filter. + + After ``exec_()`` returns ``QDialog.Accepted``, read ``self.result_filter``. + """ + + def __init__(self, parent=None, fs=1000.0, flt: Filter = None): + super().__init__(parent) + self.setWindowTitle("Add filter" if flt is None else "Edit filter") + self.fs = fs + self.result_filter = None + self._param_widgets = {} # key -> QDoubleSpinBox + + # --- filter type combo --- + self.combo_type = QComboBox() + for tid in FILTER_TYPE_IDS: + self.combo_type.addItem(FILTER_TYPES[tid].name, tid) + + # --- dynamic parameter form --- + self.param_group = QGroupBox("Parameters") + self.param_form = QFormLayout(self.param_group) + + # --- preview --- + self.canvas = FilterResponseCanvas(figsize=(5, 6)) + + # --- buttons --- + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self._on_accept) + buttons.rejected.connect(self.reject) + + left = QVBoxLayout() + type_row = QHBoxLayout() + type_row.addWidget(QLabel("Filter type:")) + type_row.addWidget(self.combo_type, 1) + left.addLayout(type_row) + left.addWidget(self.param_group) + left.addStretch() + left.addWidget(buttons) + + right = QVBoxLayout() + right.addWidget(QLabel("Preview")) + right.addWidget(self.canvas, 1) + + main = QHBoxLayout(self) + main.addLayout(left) + main.addLayout(right, 1) + + # Preselect type / params when editing. + if flt is not None: + index = self.combo_type.findData(flt.type_id) + if index >= 0: + self.combo_type.setCurrentIndex(index) + self.combo_type.currentIndexChanged.connect(self._rebuild_params) + self._rebuild_params(preset_params=flt.params if flt else None) + + # ------------------------------------------------------------------ + def _current_type(self): + return FILTER_TYPES[self.combo_type.currentData()] + + def _rebuild_params(self, *_, preset_params=None): + # Drop existing rows. + while self.param_form.rowCount(): + self.param_form.removeRow(0) + self._param_widgets.clear() + + for spec in self._current_type().params: + spin = QDoubleSpinBox() + spin.setRange(spec.minimum, spec.maximum) + spin.setDecimals(spec.decimals) + spin.setSingleStep(max(spec.minimum, 1.0)) + value = ( + preset_params.get(spec.key, spec.default) + if preset_params + else spec.default + ) + spin.setValue(value) + if spec.unit: + spin.setSuffix(f" {spec.unit}") + spin.valueChanged.connect(self._update_preview) + self._param_widgets[spec.key] = spin + self.param_form.addRow(spec.label + ":", spin) + + self._update_preview() + + def _collect_params(self): + return {key: w.value() for key, w in self._param_widgets.items()} + + def _build_filter(self): + return Filter(self.combo_type.currentData(), self._collect_params()) + + def _update_preview(self, *_): + try: + flt = self._build_filter() + b, a = flt.coefficients(self.fs) + self.canvas.plot([Trace(b, a, bold=True)], self.fs) + except (ValueError, ZeroDivisionError, FloatingPointError): + # Invalid parameter combination mid-edit; skip this redraw. + self.canvas.clear() + + def _on_accept(self): + self.result_filter = self._build_filter() + self.accept() diff --git a/filters/filter_library.py b/filters/filter_library.py new file mode 100644 index 0000000..63fda5f --- /dev/null +++ b/filters/filter_library.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +File: filter_library.py +Author: Mathieu Bresciani +Email: brescianimathieu@gmail.com +Github: https://github.com/bresch +Description: + Pure (numpy + scipy only) library of digital filters and a chain that + links them in series. No GUI dependency so it can be reused directly in + the control loop of other tools (e.g. autotune). + + A filter type is described in a data-driven registry (``FILTER_TYPES``): + a display name, a list of parameters and a function turning + ``(params, fs) -> (b, a)``. This keeps the UI generic: it can build the + parameter fields and summaries from the registry alone. +""" + +import warnings +from dataclasses import dataclass, field +from typing import Callable, Dict, List, Tuple + +import numpy as np +from scipy import signal + +Coefficients = Tuple[np.ndarray, np.ndarray] + + +# --------------------------------------------------------------------------- +# Parameter / type description +# --------------------------------------------------------------------------- +@dataclass(frozen=True) +class ParamSpec: + """Description of a single tunable filter parameter.""" + + key: str + label: str + default: float + unit: str = "" + minimum: float = 0.0 + maximum: float = 1e9 + decimals: int = 3 + + +@dataclass(frozen=True) +class FilterType: + """A kind of filter: how to build its coefficients and describe it.""" + + type_id: str + name: str + params: Tuple[ParamSpec, ...] + func: Callable[[Dict[str, float], float], Coefficients] + + def coefficients(self, params: Dict[str, float], fs: float) -> Coefficients: + b, a = self.func(params, fs) + return np.asarray(b, dtype=float), np.asarray(a, dtype=float) + + def summary(self, params: Dict[str, float]) -> str: + parts = [] + for spec in self.params: + value = params.get(spec.key, spec.default) + unit = f" {spec.unit}" if spec.unit else "" + parts.append(f"{spec.label.lower()}: {_fmt(value)}{unit}") + return f"{self.name} — " + ", ".join(parts) + + +def _fmt(value: float) -> str: + """Compact number formatting (drops trailing zeros).""" + return f"{value:g}" + + +# --------------------------------------------------------------------------- +# Coefficient functions (ported from digital_filter_compare.py) +# Each takes a params dict and the sampling frequency fs. +# --------------------------------------------------------------------------- +def _lpf1_butter(p, fs): + fc = p["fc"] + gamma = np.tan(np.pi * fc / fs) + d = gamma + 1.0 + b = [gamma / d, gamma / d] + a = [1.0, (gamma - 1.0) / d] + return b, a + + +def _lpf2_butter(p, fs): + fc = p["fc"] + gamma = np.tan(np.pi * fc / fs) + gamma2 = gamma**2 + d = gamma2 + np.sqrt(2.0) * gamma + 1.0 + b = np.array([gamma2, 2.0 * gamma2, gamma2]) / d + a = np.array([d, 2.0 * (gamma2 - 1.0), gamma2 - np.sqrt(2.0) * gamma + 1.0]) / d + return b, a + + +def _lpf2_px4(p, fs): + fc = p["fc"] + fr = fs / fc + ohm = np.tan(np.pi / fr) + c = 1.0 + 2.0 * np.cos(np.pi / 4.0) * ohm + ohm**2 + b0 = ohm**2 / c + b = [b0, 2.0 * b0, b0] + a = [ + 1.0, + 2.0 * (ohm**2 - 1.0) / c, + (1.0 - 2.0 * np.cos(np.pi / 4.0) * ohm + ohm**2) / c, + ] + return b, a + + +def _lpf1_alpha(p, fs): + fc = p["fc"] + dt = 1.0 / fs + tau = 1.0 / (2.0 * np.pi * fc) + alpha = dt / (tau + dt) + b = [alpha] + a = [1.0, alpha - 1.0] + return b, a + + +def _lpf2_damped(p, fs): + fc = p["fc"] + zeta = p["zeta"] + t = 1.0 / fs + wn = 2.0 * np.pi * fc + k = wn / np.tan(wn * t / 2.0) + k2 = k**2 + a1a = 2.0 * zeta * wn + a2a = wn**2 + d = k2 + a1a * k + a2a + b = np.array([a2a, 2.0 * a2a, a2a]) / d + a = np.array([d, 2.0 * a2a - 2.0 * k2, k2 - a1a * k + a2a]) / d + return b, a + + +def _lpf2_crit_damped(p, fs): + fc = p["fc"] + wn = 2.0 * np.pi * fc + k = 2.0 * fs + k2 = k**2 + a1a = 2.0 * wn + a2a = wn**2 + d = k2 + a1a * k + a2a + b = np.array([a2a, 2.0 * a2a, a2a]) / d + a = np.array([d, 2.0 * a2a - 2.0 * k2, k2 - a1a * k + a2a]) / d + return b, a + + +def _hpf1_alpha(p, fs): + fc = p["fc"] + alpha = fs / (2.0 * np.pi * fc + fs) + b = [1.0, -1.0] + a = [1.0 / alpha, -1.0] + return b, a + + +def _hpf1_butter(p, fs): + fc = p["fc"] + gamma = np.tan(np.pi * fc / fs) + b = [1.0, -1.0] + a = [gamma + 1.0, gamma - 1.0] + return b, a + + +def _hpf2_butter(p, fs): + fc = p["fc"] + gamma = np.tan(np.pi * fc / fs) + gamma2 = gamma**2 + d = gamma2 + np.sqrt(2.0) * gamma + 1.0 + b = np.array([1.0, -2.0, 1.0]) / d + a = np.array([d, 2.0 * (gamma2 - 1.0), gamma2 - np.sqrt(2.0) * gamma + 1.0]) / d + return b, a + + +def _notch2(p, fs): + fc = p["fc"] + bw = p["bw"] + alpha = np.tan(np.pi * bw / fs) + beta = -np.cos(2.0 * np.pi * fc / fs) + d = alpha + 1.0 + b = np.array([1.0, 2.0 * beta, 1.0]) / d + a = np.array([d, 2.0 * beta, 1.0 - alpha]) / d + return b, a + + +def _bandstop2_butter(p, fs): + fc = p["fc"] + bw = p["bw"] + gamma = np.tan(np.pi * fc / fs) + gamma2 = gamma**2 + d = (1.0 + gamma2) * fc + gamma * bw + b0 = fc * (gamma2 + 1.0) + b1 = 2.0 * fc * (gamma2 - 1.0) + b = np.array([b0, b1, b0]) / d + a = np.array([d, b1, (1.0 + gamma2) * fc - gamma * bw]) / d + return b, a + + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- +_FC = ParamSpec("fc", "Cutoff freq", 20.0, "Hz", minimum=0.01, maximum=1e6) +_FC_NOTCH = ParamSpec("fc", "Center freq", 80.0, "Hz", minimum=0.01, maximum=1e6) +_BW = ParamSpec("bw", "Bandwidth", 30.0, "Hz", minimum=0.01, maximum=1e6) +_ZETA = ParamSpec("zeta", "Damping", 1.0, "", minimum=0.01, maximum=10.0) + +FILTER_TYPES: Dict[str, FilterType] = { + ft.type_id: ft + for ft in ( + FilterType("lpf1_butter", "Butterworth LPF 1st order", (_FC,), _lpf1_butter), + FilterType("lpf2_butter", "Butterworth LPF 2nd order", (_FC,), _lpf2_butter), + FilterType("lpf2_px4", "PX4 LPF2p (2nd order)", (_FC,), _lpf2_px4), + FilterType("lpf1_alpha", "LPF 1st order (alpha)", (_FC,), _lpf1_alpha), + FilterType("lpf2_damped", "LPF 2nd order (damped)", (_FC, _ZETA), _lpf2_damped), + FilterType( + "lpf2_crit", "LPF 2nd order (critically damped)", (_FC,), _lpf2_crit_damped + ), + FilterType("hpf1_alpha", "HPF 1st order (alpha)", (_FC,), _hpf1_alpha), + FilterType("hpf1_butter", "Butterworth HPF 1st order", (_FC,), _hpf1_butter), + FilterType("hpf2_butter", "Butterworth HPF 2nd order", (_FC,), _hpf2_butter), + FilterType("notch2", "Notch 2nd order", (_FC_NOTCH, _BW), _notch2), + FilterType( + "bandstop2_butter", + "Butterworth band-stop 2nd order", + (_FC_NOTCH, _BW), + _bandstop2_butter, + ), + ) +} + +# Order used by the UI combo box. +FILTER_TYPE_IDS: List[str] = list(FILTER_TYPES.keys()) + + +# --------------------------------------------------------------------------- +# Filter instance and chain +# --------------------------------------------------------------------------- +@dataclass +class Filter: + """A concrete filter: a type id plus its parameter values.""" + + type_id: str + params: Dict[str, float] = field(default_factory=dict) + + def __post_init__(self): + if self.type_id not in FILTER_TYPES: + raise KeyError(f"Unknown filter type '{self.type_id}'") + # Fill in defaults for any missing parameter. + merged = {spec.key: spec.default for spec in self.type.params} + merged.update(self.params) + self.params = merged + + @property + def type(self) -> FilterType: + return FILTER_TYPES[self.type_id] + + def coefficients(self, fs: float) -> Coefficients: + return self.type.coefficients(self.params, fs) + + def summary(self) -> str: + return self.type.summary(self.params) + + def copy(self) -> "Filter": + return Filter(self.type_id, dict(self.params)) + + +class FilterChain: + """An ordered list of filters linked in series.""" + + def __init__(self, filters: List[Filter] = None): + self.filters: List[Filter] = list(filters) if filters else [] + + # list-like helpers ----------------------------------------------------- + def __len__(self): + return len(self.filters) + + def __iter__(self): + return iter(self.filters) + + def __getitem__(self, index): + return self.filters[index] + + def add(self, flt: Filter): + self.filters.append(flt) + + def replace(self, index: int, flt: Filter): + self.filters[index] = flt + + def remove(self, index: int): + del self.filters[index] + + # math ------------------------------------------------------------------- + def coefficients(self, fs: float) -> Coefficients: + """Series combination: convolve all numerators and denominators.""" + b_total = np.array([1.0]) + a_total = np.array([1.0]) + for flt in self.filters: + b, a = flt.coefficients(fs) + b_total = np.convolve(b_total, b) + a_total = np.convolve(a_total, a) + return b_total, a_total + + +# --------------------------------------------------------------------------- +# Response helpers (shared by the UI and any analysis code) +# --------------------------------------------------------------------------- +def frequency_response(b, a, fs, n=2048): + """Return (freq_hz, magnitude_db, phase_deg).""" + w, h = signal.freqz(b, a, worN=n, fs=fs) + mag_db = 20.0 * np.log10(np.abs(h) + 1e-12) + phase_deg = np.rad2deg(np.unwrap(np.angle(h))) + return w, mag_db, phase_deg + + +def group_delay_ms(b, a, fs, n=2048): + """Return (freq_hz, group_delay_ms).""" + with warnings.catch_warnings(): + # High-pass / band-stop chains are near-singular at DC (0 Hz), which we + # discard when plotting on a log axis anyway. + warnings.filterwarnings("ignore", message=".*singularity may be present.*") + w, gd = signal.group_delay((b, a), w=n, fs=fs) + return w, gd / fs * 1e3 + + +def step_response(b, a, fs): + """Return (time_s, response).""" + t, y = signal.dstep((b, a, 1.0 / fs)) + return t, np.squeeze(y) diff --git a/filters/filter_response_canvas.py b/filters/filter_response_canvas.py new file mode 100644 index 0000000..42156b6 --- /dev/null +++ b/filters/filter_response_canvas.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +File: filter_response_canvas.py +Author: Mathieu Bresciani +Description: + Reusable Qt widget showing the Bode-style response (magnitude, phase and + group delay) of one or more filters. Used by both the add/edit preview + dialog and the main chain window, and embeddable in other tools. + + Shift + left-click places a vertical red cursor across all three axes. It + reads the magnitude / phase / group-delay values off the combined (bold) + trace at that frequency, annotates each plot, and labels the exact cursor + frequency under the bottom (group-delay) x-axis. The cursor persists across + redraws (e.g. when a filter parameter changes). +""" + +import numpy as np +from filter_library import frequency_response, group_delay_ms +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure +from matplotlib.transforms import blended_transform_factory +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QVBoxLayout, QWidget + +CURSOR_COLOR = "red" + + +class Trace: + """One curve to draw: coefficients (b, a) plus how to style it.""" + + def __init__(self, b, a, label="", bold=False, color=None): + self.b = b + self.a = a + self.label = label + self.bold = bold + self.color = color + + +class FilterResponseCanvas(QWidget): + """Three stacked, x-shared log-frequency axes: magnitude / phase / delay.""" + + def __init__(self, parent=None, figsize=(6, 6)): + super().__init__(parent) + self.figure = Figure(figsize=figsize, constrained_layout=True) + self.canvas = FigureCanvas(self.figure) + self.ax_mag = self.figure.add_subplot(3, 1, 1) + self.ax_phase = self.figure.add_subplot(3, 1, 2, sharex=self.ax_mag) + self.ax_gd = self.figure.add_subplot(3, 1, 3, sharex=self.ax_mag) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.canvas) + + # Cursor state. + self._fs = 0.0 + self._cursor_freq = None # None => no cursor + self._cursor_artists = [] + self._primary = None # lookup arrays of the trace the cursor reads + self._dragging = False + + self.canvas.mpl_connect("button_press_event", self._on_press) + self.canvas.mpl_connect("motion_notify_event", self._on_motion) + self.canvas.mpl_connect("button_release_event", self._on_release) + + # ------------------------------------------------------------------ + # Drawing + # ------------------------------------------------------------------ + def plot(self, traces, fs): + """Draw the given traces. ``traces`` is an iterable of ``Trace``.""" + self._fs = fs + # Axes are about to be cleared: their old cursor artists die with them. + self._cursor_artists = [] + self._primary = None + + for ax in (self.ax_mag, self.ax_phase, self.ax_gd): + # Reset to linear first: clearing a log axis warns while it + # momentarily resets the limits to the invalid (0, 1). + ax.set_xscale("linear") + ax.clear() + + any_labelled = False + for tr in traces: + lw = 2.4 if tr.bold else 1.2 + alpha = 1.0 if tr.bold else 0.7 + w, mag, phase = frequency_response(tr.b, tr.a, fs) + wg, gd = group_delay_ms(tr.b, tr.a, fs) + # Drop the DC (0 Hz) bin: it cannot be shown on a log axis. + s = slice(1, None) + (line,) = self.ax_mag.semilogx( + w[s], + mag[s], + linewidth=lw, + alpha=alpha, + color=tr.color, + label=tr.label or None, + ) + color = line.get_color() + self.ax_phase.semilogx( + w[s], phase[s], linewidth=lw, alpha=alpha, color=color + ) + self.ax_gd.semilogx(wg[s], gd[s], linewidth=lw, alpha=alpha, color=color) + any_labelled = any_labelled or bool(tr.label) + + # The cursor reads off the bold (combined) trace, or the first one. + if self._primary is None or tr.bold: + self._primary = {"w": w, "mag": mag, "phase": phase, "wg": wg, "gd": gd} + + self.ax_mag.set_ylabel("Amplitude (dB)") + self.ax_phase.set_ylabel("Phase (deg)") + self.ax_gd.set_ylabel("Group delay (ms)") + self.ax_gd.set_xlabel("Frequency (Hz)") + for ax in (self.ax_mag, self.ax_phase, self.ax_gd): + ax.grid(True, which="both", alpha=0.3) + if fs > 2.0: + self.ax_mag.set_xlim(left=1.0, right=fs / 2.0) + if any_labelled: + self.ax_mag.legend(fontsize=8, loc="lower left") + + self._draw_cursor() + + def clear(self): + self._cursor_artists = [] + self._primary = None + for ax in (self.ax_mag, self.ax_phase, self.ax_gd): + ax.set_xscale("linear") + ax.clear() + self.canvas.draw_idle() + + # ------------------------------------------------------------------ + # Cursor + # ------------------------------------------------------------------ + def _on_press(self, event): + if event.button != 1 or event.inaxes is None or event.xdata is None: + return + if not self._is_shift(event): + return + self._dragging = True + self._set_cursor(event.xdata) + + def _on_motion(self, event): + # Keep tracking while the button is held (shift may be released mid-drag). + if not self._dragging or event.inaxes is None or event.xdata is None: + return + self._set_cursor(event.xdata) + + def _on_release(self, event): + if event.button == 1: + self._dragging = False + + def _set_cursor(self, xdata): + freq = float(xdata) + if freq <= 0.0: + return + self._cursor_freq = freq + self._draw_cursor() + + @staticmethod + def _is_shift(event): + # Prefer the Qt event (reliable across backends); fall back to mpl key. + gui = getattr(event, "guiEvent", None) + if gui is not None: + try: + return bool(gui.modifiers() & Qt.ShiftModifier) + except (AttributeError, TypeError): + pass + return event.key in ("shift", "shift+shift") + + def _remove_cursor_artists(self): + for art in self._cursor_artists: + try: + art.remove() + except (ValueError, NotImplementedError): + pass + self._cursor_artists = [] + + def _draw_cursor(self): + self._remove_cursor_artists() + freq = self._cursor_freq + if freq is None or self._primary is None: + self.canvas.draw_idle() + return + + p = self._primary + mag = float(np.interp(freq, p["w"], p["mag"])) + phase = float(np.interp(freq, p["w"], p["phase"])) + gd = float(np.interp(freq, p["wg"], p["gd"])) + + readouts = [ + (self.ax_mag, mag, f"{mag:.2f} dB"), + (self.ax_phase, phase, f"{phase:.1f}°"), + (self.ax_gd, gd, f"{gd:.2f} ms"), + ] + for ax, yval, text in readouts: + self._cursor_artists.append( + ax.axvline(freq, color=CURSOR_COLOR, linewidth=1.0, alpha=0.9) + ) + self._cursor_artists.append( + ax.plot([freq], [yval], "o", color=CURSOR_COLOR, markersize=4)[0] + ) + self._cursor_artists.append( + ax.annotate( + text, + xy=(freq, yval), + xytext=(5, 5), + textcoords="offset points", + fontsize=8, + color=CURSOR_COLOR, + ha="left", + va="bottom", + ) + ) + + # Exact frequency, labelled under the bottom (group-delay) x-axis. + trans = blended_transform_factory(self.ax_gd.transData, self.ax_gd.transAxes) + self._cursor_artists.append( + self.ax_gd.annotate( + f"{freq:.4g} Hz", + xy=(freq, 0.0), + xycoords=trans, + xytext=(0, -18), + textcoords="offset points", + fontsize=8, + color=CURSOR_COLOR, + ha="center", + va="top", + ) + ) + + self.canvas.draw_idle() From 35fd3e738237a810961db08586d95817fe22d8dd Mon Sep 17 00:00:00 2001 From: bresch Date: Thu, 9 Jul 2026 17:28:03 +0200 Subject: [PATCH 2/7] feat(filters): left-panel layout with compact, auto-sized table - Move the table into a left panel (fs on top, table, Add button below), beside the plots. - Two-line filter rows: type name then parameters; shorten labels to f_c and BW. - Compact icon buttons (gear / cross) with columns and table sized to fit their contents exactly, no empty frame. --- filters/filter_chain_widget.py | 78 +++++++++++++++++++------- filters/filter_chain_widget_helpers.py | 9 ++- filters/filter_library.py | 23 ++++++-- 3 files changed, 81 insertions(+), 29 deletions(-) diff --git a/filters/filter_chain_widget.py b/filters/filter_chain_widget.py index 0040ee8..b89de0a 100644 --- a/filters/filter_chain_widget.py +++ b/filters/filter_chain_widget.py @@ -37,6 +37,9 @@ COL_SHOW, COL_SUMMARY, COL_EDIT, COL_REMOVE = range(4) +EDIT_LABEL = "⚙" +REMOVE_LABEL = "✕" + # Combined trace is always black; individual filters cycle through this palette # by their position in the chain (red is reserved for the cursor). COMBINED_COLOR = "black" @@ -73,11 +76,10 @@ def __init__(self, parent=None, fs=1000.0, chain: FilterChain = None): self.btn_add = QPushButton("+ Add filter") self.btn_add.clicked.connect(self._on_add) - top = QHBoxLayout() - top.addWidget(QLabel("Sampling freq:")) - top.addWidget(self.spin_fs) - top.addStretch() - top.addWidget(self.btn_add) + fs_row = QHBoxLayout() + fs_row.addWidget(QLabel("Sampling freq:")) + fs_row.addWidget(self.spin_fs) + fs_row.addStretch() # --- table --- self.table = QTableWidget(0, 4) @@ -85,27 +87,41 @@ def __init__(self, parent=None, fs=1000.0, chain: FilterChain = None): self.table.verticalHeader().setVisible(False) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) self.table.setSelectionMode(QAbstractItemView.NoSelection) + self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.table.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) header = self.table.horizontalHeader() - header.setSectionResizeMode(COL_SUMMARY, QHeaderView.Stretch) - header.setSectionResizeMode(COL_SHOW, QHeaderView.ResizeToContents) - # ResizeToContents measures cell *items*, not embedded widgets, so it - # clips the buttons. Give the button columns fixed widths sized to the - # widest button label instead. - for col in (COL_EDIT, COL_REMOVE): + header.setStretchLastSection(False) + # Default minimum (~36px) would keep the compact icon columns too wide. + header.setMinimumSectionSize(10) + # Summary column follows its text; the others are fixed. ResizeToContents + # measures cell *items* (the summary text) but not embedded widgets, so + # the widget columns get fixed widths sized to their content instead. + header.setSectionResizeMode(COL_SUMMARY, QHeaderView.ResizeToContents) + for col in (COL_SHOW, COL_EDIT, COL_REMOVE): header.setSectionResizeMode(col, QHeaderView.Fixed) - self.table.setColumnWidth(COL_EDIT, make_button_cell("Edit").minimumWidth() + 8) self.table.setColumnWidth( - COL_REMOVE, make_button_cell("Remove").minimumWidth() + 8 + COL_SHOW, self.table.fontMetrics().horizontalAdvance("Show") + 16 + ) + self.table.setColumnWidth( + COL_EDIT, make_button_cell(EDIT_LABEL, compact=True).width() + 2 ) - self.table.setMaximumHeight(220) + self.table.setColumnWidth( + COL_REMOVE, make_button_cell(REMOVE_LABEL, compact=True).width() + 2 + ) + + # --- left panel: fs on top, table, then add button --- + left = QVBoxLayout() + left.addLayout(fs_row) + left.addWidget(self.table) + left.addWidget(self.btn_add) + left.addStretch(1) # --- plot --- self.canvas = FilterResponseCanvas(figsize=(6, 6)) - layout = QVBoxLayout(self) - layout.addLayout(top) - layout.addWidget(self.table) - layout.addWidget(self.canvas, 1) + main = QHBoxLayout(self) + main.addLayout(left) + main.addWidget(self.canvas, 1) self._rebuild_table() self._replot() @@ -127,16 +143,23 @@ def _rebuild_table(self): ) self.table.setCellWidget(row, COL_SHOW, self._center(show)) - self.table.setItem(row, COL_SUMMARY, QTableWidgetItem(flt.summary())) + # Type name on the first line, parameters on the second. + self.table.setItem( + row, COL_SUMMARY, QTableWidgetItem(f"{flt.name}\n{flt.params_text()}") + ) - edit = make_button_cell("Edit") + edit = make_button_cell(EDIT_LABEL, compact=True) + edit.setToolTip("Edit this filter") edit.clicked.connect(lambda _, r=row: self._on_edit(r)) self.table.setCellWidget(row, COL_EDIT, edit) - remove = make_button_cell("Remove", danger=True) + remove = make_button_cell(REMOVE_LABEL, danger=True, compact=True) + remove.setToolTip("Remove this filter") remove.clicked.connect(lambda _, r=row: self._on_remove(r)) self.table.setCellWidget(row, COL_REMOVE, remove) + self._fit_table_size() + @staticmethod def _center(widget): wrap = QWidget() @@ -146,6 +169,19 @@ def _center(widget): lay.addWidget(widget) return wrap + def _fit_table_size(self): + """Size the table to exactly fit its columns and rows (no empty frame).""" + self.table.resizeColumnToContents(COL_SUMMARY) + self.table.resizeRowsToContents() + frame = 2 * self.table.frameWidth() + + width = sum(self.table.columnWidth(c) for c in range(self.table.columnCount())) + self.table.setFixedWidth(width + frame) + + height = self.table.horizontalHeader().height() + height += sum(self.table.rowHeight(r) for r in range(self.table.rowCount())) + self.table.setFixedHeight(height + frame) + # --- callbacks ----------------------------------------------------- def _on_fs_changed(self, *_): self._replot() diff --git a/filters/filter_chain_widget_helpers.py b/filters/filter_chain_widget_helpers.py index b4cf945..f3db502 100644 --- a/filters/filter_chain_widget_helpers.py +++ b/filters/filter_chain_widget_helpers.py @@ -18,12 +18,17 @@ def make_checkbox_cell(checked=False): return box -def make_button_cell(text, danger=False): +def make_button_cell(text, danger=False, compact=False): btn = QPushButton(text) if danger: btn.setStyleSheet("color: white; background-color: #c0392b;") # Reserve room for the label up front: ResizeToContents otherwise measures # the column before the embedded button has laid out and clips the text. + # Icon buttons (a single glyph) get tight padding so they stay small. text_width = btn.fontMetrics().horizontalAdvance(text) - btn.setMinimumWidth(text_width + 24) + padding = 10 if compact else 24 + if compact: + btn.setFixedWidth(text_width + padding) + else: + btn.setMinimumWidth(text_width + padding) return btn diff --git a/filters/filter_library.py b/filters/filter_library.py index 63fda5f..16217d1 100644 --- a/filters/filter_library.py +++ b/filters/filter_library.py @@ -56,13 +56,17 @@ def coefficients(self, params: Dict[str, float], fs: float) -> Coefficients: b, a = self.func(params, fs) return np.asarray(b, dtype=float), np.asarray(a, dtype=float) - def summary(self, params: Dict[str, float]) -> str: + def params_text(self, params: Dict[str, float]) -> str: parts = [] for spec in self.params: value = params.get(spec.key, spec.default) unit = f" {spec.unit}" if spec.unit else "" - parts.append(f"{spec.label.lower()}: {_fmt(value)}{unit}") - return f"{self.name} — " + ", ".join(parts) + parts.append(f"{spec.label}: {_fmt(value)}{unit}") + return ", ".join(parts) + + def summary(self, params: Dict[str, float]) -> str: + text = self.params_text(params) + return f"{self.name} — {text}" if text else self.name def _fmt(value: float) -> str: @@ -199,9 +203,9 @@ def _bandstop2_butter(p, fs): # --------------------------------------------------------------------------- # Registry # --------------------------------------------------------------------------- -_FC = ParamSpec("fc", "Cutoff freq", 20.0, "Hz", minimum=0.01, maximum=1e6) -_FC_NOTCH = ParamSpec("fc", "Center freq", 80.0, "Hz", minimum=0.01, maximum=1e6) -_BW = ParamSpec("bw", "Bandwidth", 30.0, "Hz", minimum=0.01, maximum=1e6) +_FC = ParamSpec("fc", "f_c", 20.0, "Hz", minimum=0.01, maximum=1e6) +_FC_NOTCH = ParamSpec("fc", "f_c", 80.0, "Hz", minimum=0.01, maximum=1e6) +_BW = ParamSpec("bw", "BW", 30.0, "Hz", minimum=0.01, maximum=1e6) _ZETA = ParamSpec("zeta", "Damping", 1.0, "", minimum=0.01, maximum=10.0) FILTER_TYPES: Dict[str, FilterType] = { @@ -254,9 +258,16 @@ def __post_init__(self): def type(self) -> FilterType: return FILTER_TYPES[self.type_id] + @property + def name(self) -> str: + return self.type.name + def coefficients(self, fs: float) -> Coefficients: return self.type.coefficients(self.params, fs) + def params_text(self) -> str: + return self.type.params_text(self.params) + def summary(self) -> str: return self.type.summary(self.params) From 932ee7bcc2a3fe263bece46892e207dda2dd70f7 Mon Sep 17 00:00:00 2001 From: bresch Date: Fri, 10 Jul 2026 08:15:47 +0200 Subject: [PATCH 3/7] fix(filter): change default sampling freq to 800Hz --- filters/filter_designer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/filters/filter_designer.py b/filters/filter_designer.py index 7efbb9a..081de94 100644 --- a/filters/filter_designer.py +++ b/filters/filter_designer.py @@ -29,7 +29,7 @@ class FilterDesigner(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Digital filter designer") - self.widget = FilterChainWidget(fs=1000.0) + self.widget = FilterChainWidget(fs=800.0) self.setCentralWidget(self.widget) self.resize(1000, 800) From 26c2ce3db039d7f9d04461a9770b925082028281 Mon Sep 17 00:00:00 2001 From: bresch Date: Fri, 10 Jul 2026 08:23:49 +0200 Subject: [PATCH 4/7] feat(filters): place response cursor on any left-click drag Drop the shift requirement so the frequency cursor is set and dragged with a plain left-click; shift+click still works. --- filters/filter_response_canvas.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/filters/filter_response_canvas.py b/filters/filter_response_canvas.py index 42156b6..387b6a1 100644 --- a/filters/filter_response_canvas.py +++ b/filters/filter_response_canvas.py @@ -21,7 +21,6 @@ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure from matplotlib.transforms import blended_transform_factory -from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QVBoxLayout, QWidget CURSOR_COLOR = "red" @@ -132,15 +131,15 @@ def clear(self): # Cursor # ------------------------------------------------------------------ def _on_press(self, event): + # Any left-click (with or without shift) places the cursor and starts + # a drag. if event.button != 1 or event.inaxes is None or event.xdata is None: return - if not self._is_shift(event): - return self._dragging = True self._set_cursor(event.xdata) def _on_motion(self, event): - # Keep tracking while the button is held (shift may be released mid-drag). + # Keep tracking while the button is held. if not self._dragging or event.inaxes is None or event.xdata is None: return self._set_cursor(event.xdata) @@ -156,17 +155,6 @@ def _set_cursor(self, xdata): self._cursor_freq = freq self._draw_cursor() - @staticmethod - def _is_shift(event): - # Prefer the Qt event (reliable across backends); fall back to mpl key. - gui = getattr(event, "guiEvent", None) - if gui is not None: - try: - return bool(gui.modifiers() & Qt.ShiftModifier) - except (AttributeError, TypeError): - pass - return event.key in ("shift", "shift+shift") - def _remove_cursor_artists(self): for art in self._cursor_artists: try: From ad5fa89e94fc9be73d96cbf329f7852e40cb4ede Mon Sep 17 00:00:00 2001 From: bresch Date: Fri, 10 Jul 2026 08:33:38 +0200 Subject: [PATCH 5/7] test(filters): unit tests for the filter designer + CI workflow - test_filter_library.py: pure core (coefficients, series convolution, summaries, response helpers). - test_filter_dialogs.py: add/edit dialog behaviour, headless. - test_filter_chain_widget.py: table, add/edit/remove (stubbed dialog), overlay toggling and stable trace colours. - filters_tests.yml: run the suite on PRs and master with Qt offscreen. --- .github/workflows/filters_tests.yml | 33 ++++++ filters/test_filter_chain_widget.py | 166 ++++++++++++++++++++++++++++ filters/test_filter_dialogs.py | 73 ++++++++++++ filters/test_filter_library.py | 127 +++++++++++++++++++++ 4 files changed, 399 insertions(+) create mode 100644 .github/workflows/filters_tests.yml create mode 100644 filters/test_filter_chain_widget.py create mode 100644 filters/test_filter_dialogs.py create mode 100644 filters/test_filter_library.py diff --git a/.github/workflows/filters_tests.yml b/.github/workflows/filters_tests.yml new file mode 100644 index 0000000..a5d9969 --- /dev/null +++ b/.github/workflows/filters_tests.yml @@ -0,0 +1,33 @@ +name: filters_tests +on: + pull_request: + push: + branches: [master] + +jobs: + filters_tests: + runs-on: ubuntu-latest + defaults: + run: + working-directory: filters + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + + # Qt's "offscreen" platform plugin still needs these shared libraries. + - name: Install Qt offscreen system libraries + run: | + sudo apt-get update + sudo apt-get install -y \ + libegl1 libgl1 libxkbcommon0 libdbus-1-3 + + - name: Install dependencies + run: pip install numpy scipy matplotlib pyqt5 pytest + + - name: Run tests + env: + QT_QPA_PLATFORM: offscreen + run: pytest -v diff --git a/filters/test_filter_chain_widget.py b/filters/test_filter_chain_widget.py new file mode 100644 index 0000000..9227efb --- /dev/null +++ b/filters/test_filter_chain_widget.py @@ -0,0 +1,166 @@ +"""Functional tests for the filter chain widget (table + plot). + +Driven headlessly (no real window). The add/edit dialog is stubbed so the +tests never open a modal. Run with: + QT_QPA_PLATFORM=offscreen pytest test_filter_chain_widget.py +The offscreen platform is also set automatically below as a fallback. +""" + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import filter_chain_widget # noqa: E402 +import pytest # noqa: E402 +from filter_chain_widget import ( # noqa: E402 + COL_EDIT, + COL_SUMMARY, + COMBINED_COLOR, + FilterChainWidget, +) +from filter_library import Filter, FilterChain # noqa: E402 +from PyQt5.QtWidgets import QApplication, QDialog # noqa: E402 + +FS = 800.0 + + +@pytest.fixture(scope="session") +def qapp(): + app = QApplication.instance() or QApplication([]) + yield app + + +@pytest.fixture +def stub_dialog(monkeypatch): + """Replace FilterEditDialog with one that returns a preset filter. + + Usage: call ``stub_dialog(some_filter)`` to make the next Add/Edit accept + with that filter; pass ``None`` to simulate the user cancelling. + """ + + def _install(result_filter, accepted=True): + class FakeDialog: + Accepted = QDialog.Accepted + + def __init__(self, *a, **k): + self.result_filter = result_filter + + def exec_(self): + return QDialog.Accepted if accepted else QDialog.Rejected + + monkeypatch.setattr(filter_chain_widget, "FilterEditDialog", FakeDialog) + + return _install + + +def _line_colors(widget): + """Map trace label -> color for the magnitude axis.""" + return { + line.get_label(): line.get_color() + for line in widget.canvas.ax_mag.get_lines() + if line.get_label() and not line.get_label().startswith("_") + } + + +def _make(*filters): + return FilterChainWidget(fs=FS, chain=FilterChain(list(filters))) + + +# --- table content ---------------------------------------------------------- +def test_rows_show_two_line_summary(qapp): + w = _make(Filter("lpf2_damped", {"fc": 20.0, "zeta": 0.7})) + text = w.table.item(0, COL_SUMMARY).text() + assert "\n" in text + name, params = text.split("\n", 1) + assert name == "LPF 2nd order (damped)" + assert params == "f_c: 20 Hz, Damping: 0.7" + + +def test_icon_button_labels(qapp): + w = _make(Filter("lpf1_butter", {"fc": 20.0})) + assert w.table.cellWidget(0, COL_EDIT).text() == filter_chain_widget.EDIT_LABEL + + +def test_table_fits_content(qapp): + w = _make(Filter("lpf1_butter"), Filter("notch2")) + header = w.table.horizontalHeader().height() + rows = sum(w.table.rowHeight(r) for r in range(w.table.rowCount())) + # Height hugs header + rows (allow for the frame border). + assert abs(w.table.height() - (header + rows)) <= 4 + + +# --- add / edit / remove ---------------------------------------------------- +def test_add_filter_via_dialog(qapp, stub_dialog): + w = _make() + stub_dialog(Filter("lpf2_butter", {"fc": 30.0})) + w._on_add() + assert len(w.chain) == 1 + assert w.chain[0].type_id == "lpf2_butter" + assert w.table.rowCount() == 1 + + +def test_add_cancelled_changes_nothing(qapp, stub_dialog): + w = _make(Filter("lpf1_butter")) + stub_dialog(None, accepted=False) + w._on_add() + assert len(w.chain) == 1 + + +def test_edit_filter_via_dialog(qapp, stub_dialog): + w = _make(Filter("lpf1_butter", {"fc": 10.0})) + stub_dialog(Filter("notch2", {"fc": 50.0, "bw": 5.0})) + w._on_edit(0) + assert w.chain[0].type_id == "notch2" + assert "Notch" in w.table.item(0, COL_SUMMARY).text() + + +def test_remove_filter(qapp): + w = _make(Filter("lpf1_butter"), Filter("notch2")) + w._on_remove(0) + assert len(w.chain) == 1 + assert w.chain[0].type_id == "notch2" + assert w.table.rowCount() == 1 + assert len(w._show_flags) == 1 + + +# --- plotting --------------------------------------------------------------- +def test_combined_trace_always_present_and_black(qapp): + w = _make(Filter("lpf2_butter", {"fc": 20.0})) + colors = _line_colors(w) + assert colors.get("Combined") == COMBINED_COLOR + + +def test_show_toggle_overlays_individual_filter(qapp): + w = _make(Filter("lpf2_butter", {"fc": 20.0})) + assert "Combined" in _line_colors(w) + assert len(_line_colors(w)) == 1 # only combined until a row is shown + + w._on_show_toggled(0, True) + labels = _line_colors(w) + assert len(labels) == 2 # combined + the shown filter + + +def test_colors_stable_across_show_toggles(qapp): + w = _make( + Filter("lpf2_butter", {"fc": 20.0}), + Filter("notch2", {"fc": 80.0, "bw": 30.0}), + Filter("hpf1_butter", {"fc": 5.0}), + ) + for r in range(3): + w._on_show_toggled(r, True) + all_shown = _line_colors(w) + + w._on_show_toggled(1, False) # hide the middle filter + reduced = _line_colors(w) + + common = set(all_shown) & set(reduced) + assert common # sanity + assert all(all_shown[label] == reduced[label] for label in common) + + +def test_fs_change_triggers_signal(qapp): + w = _make(Filter("lpf1_butter")) + fired = [] + w.changed.connect(lambda: fired.append(True)) + w.spin_fs.setValue(1000.0) + assert fired diff --git a/filters/test_filter_dialogs.py b/filters/test_filter_dialogs.py new file mode 100644 index 0000000..741595a --- /dev/null +++ b/filters/test_filter_dialogs.py @@ -0,0 +1,73 @@ +"""Functional tests for the add/edit filter dialog. + +These drive FilterEditDialog headlessly (no real window is shown). Run with: + QT_QPA_PLATFORM=offscreen pytest test_filter_dialogs.py +The offscreen platform is also set automatically below as a fallback. +""" + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest # noqa: E402 +from filter_edit_dialog import FilterEditDialog # noqa: E402 +from filter_library import Filter # noqa: E402 +from PyQt5.QtWidgets import QApplication # noqa: E402 + +FS = 800.0 + + +@pytest.fixture(scope="session") +def qapp(): + app = QApplication.instance() or QApplication([]) + yield app + + +def _param_keys(dialog): + return set(dialog._param_widgets.keys()) + + +def test_add_mode_defaults(qapp): + d = FilterEditDialog(fs=FS) + assert d.windowTitle() == "Add filter" + # First registry entry is preselected and its params are shown. + assert d.combo_type.currentData() == "lpf1_butter" + assert _param_keys(d) == {"fc"} + assert d.result_filter is None + + +def test_type_switch_rebuilds_params(qapp): + d = FilterEditDialog(fs=FS) + d.combo_type.setCurrentIndex(d.combo_type.findData("notch2")) + assert _param_keys(d) == {"fc", "bw"} + d.combo_type.setCurrentIndex(d.combo_type.findData("lpf2_damped")) + assert _param_keys(d) == {"fc", "zeta"} + + +def test_edit_mode_prefills(qapp): + flt = Filter("lpf2_damped", {"fc": 15.0, "zeta": 0.7}) + d = FilterEditDialog(fs=FS, flt=flt) + assert d.windowTitle() == "Edit filter" + assert d.combo_type.currentData() == "lpf2_damped" + assert d._param_widgets["fc"].value() == pytest.approx(15.0) + assert d._param_widgets["zeta"].value() == pytest.approx(0.7) + + +def test_accept_builds_filter_from_widgets(qapp): + d = FilterEditDialog(fs=FS) + d.combo_type.setCurrentIndex(d.combo_type.findData("notch2")) + d._param_widgets["fc"].setValue(120.0) + d._param_widgets["bw"].setValue(10.0) + + d._on_accept() + + assert d.result() == FilterEditDialog.Accepted + assert d.result_filter.type_id == "notch2" + assert d.result_filter.params == {"fc": 120.0, "bw": 10.0} + + +def test_edit_then_change_params(qapp): + d = FilterEditDialog(fs=FS, flt=Filter("lpf2_butter", {"fc": 20.0})) + d._param_widgets["fc"].setValue(50.0) + d._on_accept() + assert d.result_filter.params == {"fc": 50.0} diff --git a/filters/test_filter_library.py b/filters/test_filter_library.py new file mode 100644 index 0000000..9ae0baa --- /dev/null +++ b/filters/test_filter_library.py @@ -0,0 +1,127 @@ +"""Unit tests for the pure filter core (no GUI). + +Run with: + pytest test_filter_library.py +""" + +import numpy as np +import pytest +from filter_library import ( + FILTER_TYPE_IDS, + FILTER_TYPES, + Filter, + FilterChain, + frequency_response, + group_delay_ms, + step_response, +) + +FS = 1000.0 + + +def _mag_db_at(b, a, freq, fs=FS): + w, mag, _ = frequency_response(b, a, fs) + return float(np.interp(freq, w, mag)) + + +# --- registry / coefficients ------------------------------------------------ +def test_every_type_builds_finite_coefficients(): + for tid in FILTER_TYPE_IDS: + b, a = Filter(tid).coefficients(FS) + assert b.size >= 1 and a.size >= 1 + assert np.all(np.isfinite(b)) and np.all(np.isfinite(a)) + + +def test_lowpass_types_pass_dc(): + for tid in ("lpf1_butter", "lpf2_butter", "lpf2_px4", "lpf1_alpha", "lpf2_crit"): + b, a = Filter(tid, {"fc": 20.0}).coefficients(FS) + assert _mag_db_at(b, a, 0.0) == pytest.approx(0.0, abs=0.1) + + +def test_highpass_types_block_dc(): + for tid in ("hpf1_alpha", "hpf1_butter", "hpf2_butter"): + b, a = Filter(tid, {"fc": 10.0}).coefficients(FS) + assert _mag_db_at(b, a, 0.0) < -40.0 + + +def test_notch_attenuates_center_frequency(): + b, a = Filter("notch2", {"fc": 80.0, "bw": 30.0}).coefficients(FS) + assert _mag_db_at(b, a, 80.0) < -20.0 + assert _mag_db_at(b, a, 0.0) == pytest.approx(0.0, abs=0.5) + + +# --- chain ------------------------------------------------------------------ +def test_empty_chain_is_passthrough(): + b, a = FilterChain().coefficients(FS) + assert list(b) == [1.0] + assert list(a) == [1.0] + + +def test_chain_is_series_convolution(): + f1 = Filter("lpf2_butter", {"fc": 20.0}) + f2 = Filter("notch2", {"fc": 80.0, "bw": 30.0}) + b1, a1 = f1.coefficients(FS) + b2, a2 = f2.coefficients(FS) + + b, a = FilterChain([f1, f2]).coefficients(FS) + + assert b == pytest.approx(np.convolve(b1, b2)) + assert a == pytest.approx(np.convolve(a1, a2)) + + +def test_chain_list_operations(): + chain = FilterChain([Filter("lpf1_butter", {"fc": 10.0})]) + chain.add(Filter("lpf1_butter", {"fc": 20.0})) + assert len(chain) == 2 + chain.replace(0, Filter("notch2", {"fc": 50.0, "bw": 5.0})) + assert chain[0].type_id == "notch2" + chain.remove(1) + assert len(chain) == 1 + + +# --- Filter instance -------------------------------------------------------- +def test_defaults_filled_for_missing_params(): + f = Filter("lpf2_damped") # no params passed + assert set(f.params) == {"fc", "zeta"} + assert f.params["zeta"] == FILTER_TYPES["lpf2_damped"].params[1].default + + +def test_unknown_type_raises(): + with pytest.raises(KeyError): + Filter("does_not_exist") + + +def test_copy_is_independent(): + f = Filter("lpf2_damped", {"fc": 15.0, "zeta": 0.7}) + g = f.copy() + g.params["fc"] = 99.0 + assert f.params["fc"] == 15.0 + + +# --- summary / labels ------------------------------------------------------- +def test_params_text_uses_short_labels(): + assert Filter("lpf1_butter", {"fc": 20.0}).params_text() == "f_c: 20 Hz" + text = Filter("notch2", {"fc": 80.0, "bw": 30.0}).params_text() + assert text == "f_c: 80 Hz, BW: 30 Hz" + + +def test_summary_joins_name_and_params(): + f = Filter("lpf2_damped", {"fc": 20.0, "zeta": 0.7}) + assert f.summary() == f"{f.name} — f_c: 20 Hz, Damping: 0.7" + + +# --- response helpers ------------------------------------------------------- +def test_response_helpers_shapes(): + b, a = Filter("lpf2_butter", {"fc": 20.0}).coefficients(FS) + w, mag, phase = frequency_response(b, a, FS) + assert w.shape == mag.shape == phase.shape + wg, gd = group_delay_ms(b, a, FS) + assert wg.shape == gd.shape + t, y = step_response(b, a, FS) + assert np.squeeze(y).shape[0] == t.shape[0] + + +def test_group_delay_no_warning_for_highpass(recwarn): + b, a = Filter("hpf2_butter", {"fc": 5.0}).coefficients(FS) + group_delay_ms(b, a, FS) + assert not [w for w in recwarn.list if "singularity" in str(w.message)] From d59c03ae8be7c6bf5292128f765f28199437de38 Mon Sep 17 00:00:00 2001 From: bresch Date: Fri, 10 Jul 2026 08:41:44 +0200 Subject: [PATCH 6/7] feat(filters): show individual filter responses by default Default the per-row 'show' checkboxes to enabled so every filter's response is overlaid on the combined trace from the start. --- filters/filter_chain_widget.py | 4 ++-- filters/test_filter_chain_widget.py | 13 +++++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/filters/filter_chain_widget.py b/filters/filter_chain_widget.py index b89de0a..92bc81a 100644 --- a/filters/filter_chain_widget.py +++ b/filters/filter_chain_widget.py @@ -63,7 +63,7 @@ class FilterChainWidget(QWidget): def __init__(self, parent=None, fs=1000.0, chain: FilterChain = None): super().__init__(parent) self.chain = chain if chain is not None else FilterChain() - self._show_flags = [False] * len(self.chain) + self._show_flags = [True] * len(self.chain) # --- top row: fs + add --- self.spin_fs = QDoubleSpinBox() @@ -191,7 +191,7 @@ def _on_add(self): dlg = FilterEditDialog(self, fs=self.fs) if dlg.exec_() == FilterEditDialog.Accepted and dlg.result_filter: self.chain.add(dlg.result_filter) - self._show_flags.append(False) + self._show_flags.append(True) self._rebuild_table() self._replot() self.changed.emit() diff --git a/filters/test_filter_chain_widget.py b/filters/test_filter_chain_widget.py index 9227efb..a2e2099 100644 --- a/filters/test_filter_chain_widget.py +++ b/filters/test_filter_chain_widget.py @@ -132,12 +132,17 @@ def test_combined_trace_always_present_and_black(qapp): def test_show_toggle_overlays_individual_filter(qapp): w = _make(Filter("lpf2_butter", {"fc": 20.0})) + # Rows are shown by default: combined + the one filter. assert "Combined" in _line_colors(w) - assert len(_line_colors(w)) == 1 # only combined until a row is shown + assert len(_line_colors(w)) == 2 - w._on_show_toggled(0, True) - labels = _line_colors(w) - assert len(labels) == 2 # combined + the shown filter + w._on_show_toggled(0, False) + assert len(_line_colors(w)) == 1 # only combined once the row is hidden + + +def test_rows_shown_by_default(qapp): + w = _make(Filter("lpf1_butter"), Filter("notch2")) + assert w._show_flags == [True, True] def test_colors_stable_across_show_toggles(qapp): From 2be721fb2b14aa2c30da04d482051033aecfb315 Mon Sep 17 00:00:00 2001 From: bresch Date: Fri, 10 Jul 2026 08:54:53 +0200 Subject: [PATCH 7/7] feat(filters): replace Show column with an Enable (EN) column Toggling a row's EN box now temporarily removes that filter from the chain: it no longer contributes to the combined response and its trace is hidden, while staying in the table so it can be re-enabled. Add enabled_chain() exposing just the active filters, and emit changed on toggle. Broaden the group-delay singularity warning suppression. --- filters/filter_chain_widget.py | 54 ++++++++++++++++++----------- filters/filter_library.py | 10 +++--- filters/test_filter_chain_widget.py | 35 +++++++++++++------ 3 files changed, 63 insertions(+), 36 deletions(-) diff --git a/filters/filter_chain_widget.py b/filters/filter_chain_widget.py index 92bc81a..68a48b9 100644 --- a/filters/filter_chain_widget.py +++ b/filters/filter_chain_widget.py @@ -35,7 +35,7 @@ QWidget, ) -COL_SHOW, COL_SUMMARY, COL_EDIT, COL_REMOVE = range(4) +COL_ENABLE, COL_SUMMARY, COL_EDIT, COL_REMOVE = range(4) EDIT_LABEL = "⚙" REMOVE_LABEL = "✕" @@ -63,7 +63,7 @@ class FilterChainWidget(QWidget): def __init__(self, parent=None, fs=1000.0, chain: FilterChain = None): super().__init__(parent) self.chain = chain if chain is not None else FilterChain() - self._show_flags = [True] * len(self.chain) + self._enabled = [True] * len(self.chain) # --- top row: fs + add --- self.spin_fs = QDoubleSpinBox() @@ -83,7 +83,7 @@ def __init__(self, parent=None, fs=1000.0, chain: FilterChain = None): # --- table --- self.table = QTableWidget(0, 4) - self.table.setHorizontalHeaderLabels(["Show", "Filter", "", ""]) + self.table.setHorizontalHeaderLabels(["EN", "Filter", "", ""]) self.table.verticalHeader().setVisible(False) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) self.table.setSelectionMode(QAbstractItemView.NoSelection) @@ -97,10 +97,10 @@ def __init__(self, parent=None, fs=1000.0, chain: FilterChain = None): # measures cell *items* (the summary text) but not embedded widgets, so # the widget columns get fixed widths sized to their content instead. header.setSectionResizeMode(COL_SUMMARY, QHeaderView.ResizeToContents) - for col in (COL_SHOW, COL_EDIT, COL_REMOVE): + for col in (COL_ENABLE, COL_EDIT, COL_REMOVE): header.setSectionResizeMode(col, QHeaderView.Fixed) self.table.setColumnWidth( - COL_SHOW, self.table.fontMetrics().horizontalAdvance("Show") + 16 + COL_ENABLE, self.table.fontMetrics().horizontalAdvance("EN") + 16 ) self.table.setColumnWidth( COL_EDIT, make_button_cell(EDIT_LABEL, compact=True).width() + 2 @@ -137,11 +137,12 @@ def _rebuild_table(self): for row, flt in enumerate(self.chain): self.table.insertRow(row) - show = make_checkbox_cell(self._show_flags[row]) - show.toggled.connect( - lambda checked, r=row: self._on_show_toggled(r, checked) + enable = make_checkbox_cell(self._enabled[row]) + enable.setToolTip("Enable this filter in the chain") + enable.toggled.connect( + lambda checked, r=row: self._on_enabled_toggled(r, checked) ) - self.table.setCellWidget(row, COL_SHOW, self._center(show)) + self.table.setCellWidget(row, COL_ENABLE, self._center(enable)) # Type name on the first line, parameters on the second. self.table.setItem( @@ -191,7 +192,7 @@ def _on_add(self): dlg = FilterEditDialog(self, fs=self.fs) if dlg.exec_() == FilterEditDialog.Accepted and dlg.result_filter: self.chain.add(dlg.result_filter) - self._show_flags.append(True) + self._enabled.append(True) self._rebuild_table() self._replot() self.changed.emit() @@ -206,27 +207,38 @@ def _on_edit(self, row): def _on_remove(self, row): self.chain.remove(row) - del self._show_flags[row] + del self._enabled[row] self._rebuild_table() self._replot() self.changed.emit() - def _on_show_toggled(self, row, checked): - self._show_flags[row] = checked + def _on_enabled_toggled(self, row, checked): + self._enabled[row] = checked self._replot() + self.changed.emit() + + def enabled_chain(self): + """The chain restricted to the filters currently enabled.""" + return FilterChain( + [flt for row, flt in enumerate(self.chain) if self._enabled[row]] + ) # --- plotting ------------------------------------------------------ def _replot(self): traces = [] for row, flt in enumerate(self.chain): - if self._show_flags[row]: - b, a = flt.coefficients(self.fs) - # Colour is tied to the filter's position in the chain so it - # stays stable regardless of which filters are shown/hidden. - color = FILTER_COLORS[row % len(FILTER_COLORS)] - traces.append(Trace(b, a, label=flt.summary(), bold=False, color=color)) - if len(self.chain) > 0: - b, a = self.chain.coefficients(self.fs) + # Disabled filters are removed from the chain and hidden entirely. + if not self._enabled[row]: + continue + b, a = flt.coefficients(self.fs) + # Colour is tied to the filter's position in the chain so it + # stays stable regardless of which filters are enabled. + color = FILTER_COLORS[row % len(FILTER_COLORS)] + traces.append(Trace(b, a, label=flt.summary(), bold=False, color=color)) + + enabled = self.enabled_chain() + if len(enabled) > 0: + b, a = enabled.coefficients(self.fs) traces.append( Trace(b, a, label="Combined", bold=True, color=COMBINED_COLOR) ) diff --git a/filters/filter_library.py b/filters/filter_library.py index 16217d1..66c9354 100644 --- a/filters/filter_library.py +++ b/filters/filter_library.py @@ -325,10 +325,12 @@ def frequency_response(b, a, fs, n=2048): def group_delay_ms(b, a, fs, n=2048): """Return (freq_hz, group_delay_ms).""" - with warnings.catch_warnings(): - # High-pass / band-stop chains are near-singular at DC (0 Hz), which we - # discard when plotting on a log axis anyway. - warnings.filterwarnings("ignore", message=".*singularity may be present.*") + with warnings.catch_warnings(), np.errstate(divide="ignore", invalid="ignore"): + # High-pass / band-stop chains are singular at DC (0 Hz), which we + # discard when plotting on a log axis anyway. scipy phrases this either + # as "singularity may be present" or "group delay is singular", plus a + # numpy divide warning (silenced via errstate). + warnings.filterwarnings("ignore", message=".*singular.*") w, gd = signal.group_delay((b, a), w=n, fs=fs) return w, gd / fs * 1e3 diff --git a/filters/test_filter_chain_widget.py b/filters/test_filter_chain_widget.py index a2e2099..48da4ea 100644 --- a/filters/test_filter_chain_widget.py +++ b/filters/test_filter_chain_widget.py @@ -120,7 +120,7 @@ def test_remove_filter(qapp): assert len(w.chain) == 1 assert w.chain[0].type_id == "notch2" assert w.table.rowCount() == 1 - assert len(w._show_flags) == 1 + assert len(w._enabled) == 1 # --- plotting --------------------------------------------------------------- @@ -130,32 +130,45 @@ def test_combined_trace_always_present_and_black(qapp): assert colors.get("Combined") == COMBINED_COLOR -def test_show_toggle_overlays_individual_filter(qapp): +def test_disable_removes_filter_from_graphs(qapp): w = _make(Filter("lpf2_butter", {"fc": 20.0})) - # Rows are shown by default: combined + the one filter. + # Enabled by default: combined + the one filter. assert "Combined" in _line_colors(w) assert len(_line_colors(w)) == 2 - w._on_show_toggled(0, False) - assert len(_line_colors(w)) == 1 # only combined once the row is hidden + w._on_enabled_toggled(0, False) + # Disabling the only filter leaves no combined trace and no overlay. + assert len(_line_colors(w)) == 0 -def test_rows_shown_by_default(qapp): +def test_disable_excludes_filter_from_combined(qapp): + lpf = Filter("lpf2_butter", {"fc": 20.0}) + notch = Filter("notch2", {"fc": 80.0, "bw": 30.0}) + w = _make(lpf, notch) + + w._on_enabled_toggled(1, False) # disable the notch + + # The combined chain now equals just the enabled (low-pass) filter. + b, a = w.enabled_chain().coefficients(w.fs) + b_lpf, a_lpf = FilterChain([lpf]).coefficients(w.fs) + assert list(b) == list(b_lpf) + assert list(a) == list(a_lpf) + + +def test_rows_enabled_by_default(qapp): w = _make(Filter("lpf1_butter"), Filter("notch2")) - assert w._show_flags == [True, True] + assert w._enabled == [True, True] -def test_colors_stable_across_show_toggles(qapp): +def test_colors_stable_across_enable_toggles(qapp): w = _make( Filter("lpf2_butter", {"fc": 20.0}), Filter("notch2", {"fc": 80.0, "bw": 30.0}), Filter("hpf1_butter", {"fc": 5.0}), ) - for r in range(3): - w._on_show_toggled(r, True) all_shown = _line_colors(w) - w._on_show_toggled(1, False) # hide the middle filter + w._on_enabled_toggled(1, False) # disable the middle filter reduced = _line_colors(w) common = set(all_shown) & set(reduced)