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/filter_chain_widget.py b/filters/filter_chain_widget.py new file mode 100644 index 0000000..68a48b9 --- /dev/null +++ b/filters/filter_chain_widget.py @@ -0,0 +1,245 @@ +#!/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_ENABLE, 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" +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._enabled = [True] * 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) + + fs_row = QHBoxLayout() + fs_row.addWidget(QLabel("Sampling freq:")) + fs_row.addWidget(self.spin_fs) + fs_row.addStretch() + + # --- table --- + self.table = QTableWidget(0, 4) + self.table.setHorizontalHeaderLabels(["EN", "Filter", "", ""]) + 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.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_ENABLE, COL_EDIT, COL_REMOVE): + header.setSectionResizeMode(col, QHeaderView.Fixed) + self.table.setColumnWidth( + COL_ENABLE, self.table.fontMetrics().horizontalAdvance("EN") + 16 + ) + self.table.setColumnWidth( + COL_EDIT, make_button_cell(EDIT_LABEL, compact=True).width() + 2 + ) + 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)) + + main = QHBoxLayout(self) + main.addLayout(left) + main.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) + + 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_ENABLE, self._center(enable)) + + # 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_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_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() + lay = QHBoxLayout(wrap) + lay.setContentsMargins(0, 0, 0, 0) + lay.setAlignment(Qt.AlignCenter) + 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() + 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._enabled.append(True) + 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._enabled[row] + self._rebuild_table() + self._replot() + self.changed.emit() + + 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): + # 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) + ) + 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..f3db502 --- /dev/null +++ b/filters/filter_chain_widget_helpers.py @@ -0,0 +1,34 @@ +#!/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, 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) + 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_designer.py b/filters/filter_designer.py new file mode 100644 index 0000000..081de94 --- /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=800.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..66c9354 --- /dev/null +++ b/filters/filter_library.py @@ -0,0 +1,341 @@ +#!/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 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}: {_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: + """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", "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] = { + 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] + + @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) + + 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(), 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 + + +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..387b6a1 --- /dev/null +++ b/filters/filter_response_canvas.py @@ -0,0 +1,219 @@ +#!/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.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): + # 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 + self._dragging = True + self._set_cursor(event.xdata) + + def _on_motion(self, event): + # 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) + + 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() + + 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() diff --git a/filters/test_filter_chain_widget.py b/filters/test_filter_chain_widget.py new file mode 100644 index 0000000..48da4ea --- /dev/null +++ b/filters/test_filter_chain_widget.py @@ -0,0 +1,184 @@ +"""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._enabled) == 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_disable_removes_filter_from_graphs(qapp): + w = _make(Filter("lpf2_butter", {"fc": 20.0})) + # Enabled by default: combined + the one filter. + assert "Combined" in _line_colors(w) + assert len(_line_colors(w)) == 2 + + 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_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._enabled == [True, True] + + +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}), + ) + all_shown = _line_colors(w) + + w._on_enabled_toggled(1, False) # disable 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)]