Source code for spudtr.filters

"""FIR filter wrappers and utility functions. 

All filter functions require explicit parameters except
``check_filter_params()`` and ``show_filter()`` which will,
respectively, return and display suggested defaults for ``window``,
``width_hz`` (transition band) and ``ripple_db`` if these are not
specified.

"""

import warnings
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np

from scipy import signal, fftpack

import logging as LOGGER
from scipy.signal import kaiserord, firwin, freqz, lfilter


FTYPES = ["lowpass", "highpass", "bandpass", "bandstop"]
WINDOWS = ["kaiser", "hamming", "hann", "blackman"]


# ------------------------------------------------------------
# "private"-ish functions


def _trans_bwidth_ripple(ftype=None, cutoff_hz=None, sfreq=None, window=None):

    """
    Calculate reasonable default transition width and ripple dB

    Parameters
    ----------

    ftype : string
        filter type, e.g., 'lowpass' , 'highpass', 'bandpass', 'bandstop'
    cutoff_hz : float or 1D array_like
        cutoff frequency in Hz
    sfreq : float
        sampling frequency per second, e.g., 250.0, 500.0
    window : string
        window type for firwin, e.g., 'kaiser','hamming','hann','blackman'

    Returns
    -------
    width_hz : float
        transition band width start to stop in Hz
    ripple_db : float
        attenuation in the stop band, in dB
    """
    for kwarg in [ftype, cutoff_hz, sfreq, window]:
        assert kwarg is not None

    if ftype.lower() == "lowpass":
        width_hz = min(max(cutoff_hz * 0.25, 2), cutoff_hz)
    elif ftype.lower() == "highpass":
        width_hz = min(max(cutoff_hz * 0.25, 2.0), sfreq / 2.0 - cutoff_hz)
    elif ftype.lower() == "bandpass" or ftype.lower() == "bandstop":
        l = min(max(cutoff_hz[0] * 0.25, 2), cutoff_hz[0])
        h = min(max(cutoff_hz[1] * 0.25, 2.0), sfreq / 2.0 - cutoff_hz[1])
        width_hz = (l + h) / 2

    if window.lower() == "kaiser" or window.lower() == "hamming":
        ripple_db = 53
    elif window.lower() == "hann":
        ripple_db = 44
    elif window.lower() == "blackman":
        ripple_db = 74

    return width_hz, ripple_db


def _suggest_epoch_length(sfreq=None, width_hz=None, ripple_db=None):

    """
    Parameters
    ----------
    sfreq : float
        sampling frequency, i.e. 250.0
    width_hz : float
        width of transition region in Hz
    ripple_db : float
        ripple in dB

    Examples
    --------
    >>> sfreq = 250
    >>> ripple_db = 60
    >>> width_hz = 4
    >>> suggest_epoch_length(sfreq, ripple_db, width_hz)
    your epoch length should be  230  points, or  0.92  seconds at least.
    """

    for kwarg in [sfreq, width_hz, ripple_db]:
        assert kwarg is not None

    # Nyquist frequency
    nyq_rate = sfreq / 2.0

    # transition band width in normalizied frequency
    width = width_hz / nyq_rate

    # order and Kaiser parameter for the FIR filter.
    # The parameters returned by this function are generally used for the window method with firwin.
    N, beta = kaiserord(ripple_db, width)

    N = N + 2

    print(
        "your epoch length should be ",
        N,
        " points, or ",
        N / sfreq,
        " seconds at least. ",
    )
    return N


def _mfreqz(b=None, a=1, cutoff_hz=None, sfreq=None, width_hz=None):

    """ Plot the frequency and phase response of a digital filter.

    Parameters
    ----------
    b : array_like
        numerator of a linear filter
    a : array_like
        denominator of a linear filter
    cutoff_hz : float or 1D array_like
        cutoff frequency in Hz
    sfreq : float
        sampling frequency, e.g., 250.0, 500.0
    width_hz : float
        transition band width start to stop in Hz

    Returns
    -------
    fig : `~.figure.Figure`
    """

    for kwarg in [b, cutoff_hz, sfreq, width_hz, a]:
        assert kwarg is not None

    w, h = signal.freqz(b, a)
    h_dB = 20 * np.log10(abs(h))

    fig, (ax_freq, ax_freq_phase) = plt.subplots(2, 1)
    # make a little extra space between the subplots
    fig.subplots_adjust(hspace=0.6)

    # frequency response plot
    nyq_rate = sfreq / 2.0
    ax_freq.plot((w / np.pi) * nyq_rate, abs(h))
    cutoff_hz = np.atleast_1d(cutoff_hz)
    lstyle = {"linestyle": "--", "lw": 1, "color": "r"}
    if cutoff_hz.size == 1:
        ax_freq.axvline(cutoff_hz + width_hz / 2, **lstyle)
        ax_freq.axvline(cutoff_hz - width_hz / 2, **lstyle)
    else:
        ax_freq.axvline(cutoff_hz[0] + width_hz / 2, **lstyle)
        ax_freq.axvline(cutoff_hz[0] - width_hz / 2, **lstyle)
        ax_freq.axvline(cutoff_hz[1] + width_hz / 2, **lstyle)
        ax_freq.axvline(cutoff_hz[1] - width_hz / 2, **lstyle)

    ax_freq.set_ylabel("Gain")
    ax_freq.set_xlabel("Frequency (Hz)")
    ax_freq.set_title(r"Frequency Response")

    # ax_freq.set_xlim(0, nyq_rate)
    ax_freq.set_xlim(-10, nyq_rate / 2)
    ax_freq.grid(linestyle="--")

    # frequency-phase plot
    ax_freq_phase.plot(w / max(w), h_dB, "b")
    ax_freq_phase.set_ylim(-150, 5)
    ax_freq_phase.set_ylabel("Magnitude (db)", color="b")
    ax_freq_phase.set_xlabel(r"Normalized Frequency (x$\pi$rad/sample)")
    ax_freq_phase.set_title(r"Frequency and Phase response")
    ax_freq_phaseb = ax_freq_phase.twinx()
    h_Phase = np.unwrap(np.arctan2(np.imag(h), np.real(h)))
    ax_freq_phaseb.plot(w / max(w), h_Phase, "g")
    ax_freq_phaseb.set_ylabel("Phase (radians)", color="g")
    ax_freq_phase.grid(linestyle="--")

    return fig


def _impz(b=None, a=1):

    """ Plot step and impulse response.

    Parameters
    ----------
    b : array_like
        numerator of a linear filter
    a : array_like
        denominator of a linear filter

    Returns
    -------
    fig : `~.figure.Figure`
    """

    for kwarg in [b, a]:
        assert kwarg is not None

    l = len(b)
    impulse = np.repeat(0.0, l)
    impulse[0] = 1.0
    x = np.arange(0, l)
    response = signal.lfilter(b, a, impulse)
    fig, (ax1, ax2) = plt.subplots(2, 1)
    # make a little extra space between the subplots
    fig.subplots_adjust(hspace=0.6)

    ax1.stem(x, response, use_line_collection=True)
    ax1.set_ylabel("Amplitude")
    ax1.set_xlabel(r"n (samples)")
    ax1.set_title(r"Impulse response")

    step = np.cumsum(response)
    ax2.stem(x, step, use_line_collection=True)
    ax2.set_ylabel("Amplitude")
    ax2.set_xlabel(r"n (samples)")
    ax2.set_title(r"Step response")
    return fig


def _design_firwin_filter(
    ftype=None, cutoff_hz=None, sfreq=None, width_hz=None, ripple_db=None, window=None
):
    """calculate odd length, symmetric, linear phase FIR filter coefficients

    FIRLS at https://scipy-cookbook.readthedocs.io/items/FIRFilter.html

    Parameters
    ----------

    ftype : string
        filter type, one of 'lowpass' , 'highpass', 'bandpass', 'bandstop'

    cutoff_hz : float or 1D array_like
        cutoff frequency in Hz, e.g., 5.0, 30.0 for lowpass or
        highpass. 1D array_like, e.g. [10.0, 30.0] for bandpass or
        bandstop

    sfreq : float
        sampling frequency, e.g., 250.0, 500.0

    width_hz : float
        transition band width start to stop in Hz

    ripple_db : float
        attenuation in the stop band, in dB, e.g., 24.0, 60.0


    Returns
    -------
    taps : np.array
        coefficients of FIR filter.

    """

    for kwarg in [ftype, cutoff_hz, sfreq, width_hz, ripple_db, window]:
        assert kwarg is not None

    check_filter_params(
        ftype=ftype,
        cutoff_hz=cutoff_hz,
        sfreq=sfreq,
        width_hz=width_hz,
        ripple_db=ripple_db,
        window=window,
    )

    # Nyquist frequency
    nyq_rate = sfreq / 2.0

    # transition band width in normalizied frequency
    width = width_hz / nyq_rate

    # order and Kaiser parameter for the FIR filter.
    N, beta = kaiserord(ripple_db, width)

    if N % 2 == 0:
        N = N + 1  # enforce odd number of taps

    # create a FIR filter using firwin
    if ftype.lower() == "lowpass":
        if window.lower() == "kaiser":
            taps = firwin(
                N, cutoff_hz, window=("kaiser", beta), pass_zero="lowpass", fs=sfreq,
            )
        else:
            taps = firwin(N, cutoff_hz, window=window, pass_zero="lowpass", fs=sfreq)
    elif ftype.lower() == "highpass":
        if window.lower() == "kaiser":
            taps = firwin(
                N, cutoff_hz, window=("kaiser", beta), pass_zero="highpass", fs=sfreq,
            )
        else:
            taps = firwin(N, cutoff_hz, window=window, pass_zero="highpass", fs=sfreq)
    elif ftype.lower() == "bandpass":
        if window.lower() == "kaiser":
            taps = firwin(
                N, cutoff_hz, window=("kaiser", beta), pass_zero="bandpass", fs=sfreq,
            )
        else:
            taps = firwin(N, cutoff_hz, window=window, pass_zero="bandpass", fs=sfreq)
    elif ftype.lower() == "bandstop":
        if window.lower() == "kaiser":
            taps = firwin(
                N, cutoff_hz, window=("kaiser", beta), pass_zero="bandstop", fs=sfreq,
            )
        else:
            taps = firwin(N, cutoff_hz, window=window, pass_zero="bandstop", fs=sfreq)

    return taps


def _sins_test_data(
    freq_list, amplitude_list, sampling_freq=250, duration=1.5, show_plot=False
):
    """creat a noisy signal to test the filter

    Parameters
    ----------
    freq_list : float, list

    amplitude_list : float, list

    sampling_freq : float, optional
        sampling frequency, default is 250.0

    duration : float, optional
        signal duration, default is 1.5 seconds

    Returns
    -------
    t,x : float
        time and values of a noisy signal  

    Examples
    --------
    >>> freq_list = [10.0, 25.0, 45.0]
    >>> amplitude_list = [1.0, 0.2, 0.3]
    >>> t, y = _sins_test_data(freq_list, amplitude_list)

    """
    assert len(freq_list) == len(amplitude_list)

    t = np.arange(0.0, duration, 1 / sampling_freq)
    x_noise = 0.1 * np.sin(2 * np.pi * 60 * t) + 0.2 * np.random.normal(size=len(t))
    # x = x_noise
    x = 0.0
    for i in range(len(freq_list)):
        x += amplitude_list[i] * np.sin(2 * np.pi * freq_list[i] * t)

    if show_plot:
        fig, ax = plt.subplots(figsize=(18, 4))
        ax.plot(t, x)

    return t, x


def _apply_firwin_filter_data(data, taps):
    """apply and phase compensate the FIRLS filtering to each column

    Parameters
    ----------
    data : array

    taps : ndarray
        Coefficients of FIR filter.

    Returns
    -------
    filtered_data : filtered data (same size as data)
        filtered array.

    """

    N = len(taps)
    delay = int((len(taps) - 1) / 2)
    a = 1.0

    msg = f"""
    applying linear phase delay compensated filter.
    a: {a}, N: {N}, delay: {delay}
    taps:
    {taps}
    """
    data = np.asanyarray(data).astype("float64")

    # add pads
    yy = []
    b = data[0:delay][::-1]
    e = data[-delay:][::-1]
    yy = np.append(b, data)
    yy = np.append(yy, e)

    # forward pass
    filtered_data = lfilter(taps, a, yy)

    # roll the phase shift by delay back to 0
    filtered_data = np.roll(filtered_data, -delay)[delay:-delay]

    if not len(data) == len(filtered_data):
        raise ValueError(
            f"filter I/O length mismatch: input={len(data)} output={len(filtered_data)}"
        )

    return filtered_data


# ------------------------------------------------------------
# public functions


[docs]def check_filter_params( ftype=None, cutoff_hz=None, sfreq=None, width_hz=None, ripple_db=None, window=None, allow_defaults=False, ): r"""type check FIR filter parameters and optionally provide defaults Values for `ftype`, `cutoff_hz`, and `sfreq` are obligatory. If `allow_defaults` is True, reasonable defaults are provided if any of window, width_hz and ripple_db are None. .. _filter_parameters_label: Parameters ---------- ftype : str {'lowpass' , 'highpass', 'bandpass', 'bandstop'} filter type cutoff_hz : float or 1D-array-like of floats, length 2 1/2 amplitude cutoff frequency in Hz sfreq : float sampling frequency, e.g., 250.0, 500.0 width_hz : float pass-to-stop transition band width (Hz), symmetric for bandpass, bandstop ripple_db : float ripple, in dB, e.g., 53.0, 60.0 window : str {'kaiser','hamming','hann','blackman'} window type for firwin allow_defaults : bool {False, True} If `True` this makes `width_hz`, `ripple_db`, `window` optional and fills in sensible defaults for any left unspecified by the user. Returns ------- dict ``params`` with key:val for all filter parameters specified, suitable for passing as ``**params`` to spudtr.filters FIR functions. """ def _test_numeric(_key, _val): """helper raises ValueError if _val is None or non-numeric""" try: if _val is None: raise Exception np.array(_val).astype(float) except: raise ValueError(f"{_key}={_val}, must be numeric") # ------------------------------------------------------------ # API obligatory args: ftype, cutoff_hz, sfreq # ------------------------------------------------------------ if ftype not in FTYPES: raise ValueError(f"ftype={ftype}, must be one of " + " ".join(FTYPES)) for _param in ["cutoff_hz", "sfreq"]: _test_numeric(_param, eval(_param)) # ------------------------------------------------------------ # API optional args: window, width_hz, ripple_db # ------------------------------------------------------------ if window is None and allow_defaults: window = "kaiser" warnings.warn(f"using default window='{window}'") if window not in WINDOWS: raise ValueError(f"window={window}, must be one of " + " ".join(WINDOWS)) # compute default cutoff_hz and ripple_db for this window, ftype, sfreq _width_hz, _ripple_db = _trans_bwidth_ripple( ftype=ftype, cutoff_hz=cutoff_hz, sfreq=sfreq, window=window ) if width_hz is None and allow_defaults: width_hz = _width_hz warnings.warn(f"using default width_hz={width_hz:0.3f}") if ripple_db is None and allow_defaults: ripple_db = _ripple_db warnings.warn(f"using default ripple_db={ripple_db:0.3f}") for _param in ["width_hz", "ripple_db"]: _test_numeric(_param, eval(_param)) # load up return dict _params = { "ftype": ftype, "cutoff_hz": cutoff_hz, "width_hz": width_hz, "ripple_db": ripple_db, "window": window, "sfreq": sfreq, } assert all([val is not None for val in _params.values()]) return _params
[docs]def show_filter( ftype=None, cutoff_hz=None, sfreq=None, width_hz=None, ripple_db=None, window=None, show_output=True, ): """Text summary and graphic display of filter attributes for the specified parameters. Figures are plotted for the transfer function, coefficients, and input-output performance on pure sine wave data with intervals of edge distortion highlighted. Parameters ---------- ftype : str {'lowpass' , 'highpass', 'bandpass', 'bandstop'} filter type cutoff_hz : float or 1D array-like, length=2 1/2 amplitude cutoff frequency (Hz) sfreq : float sampling frequency in samples per second width_hz : float, optional pass-to-stop transition band width (Hz) ripple_db : float, optional band ripple (dB) window : {'kaiser','hamming','hann','blackman'}, optional window type for firwin show_output : bool plot example filter input-output, default=True Returns ------- freq_phase : matplotlib.Figure plots frequency and phase response imp_resp: matplotlib.Figure plots impulse and step response s_edge : float number of seconds distorted at edge boundaries n_edge : int number of samples distorted at edge boundaries Notes ----- For more information on the filter parameters see :ref:`check_filter params() Parameters <filter_parameters_label>` Examples -------- >>> ftype = 'lowpass' >>> cutoff_hz = 10.0 >>> sfreq = 250 >>> width_hz = 5.0 >>> ripple_db = 53.0 >>> window = 'kaiser' >>> # fill in defaults for width_hz, ripple_db, window >>> show_filter( ftype=ftype, cutoff_hz=cutoff_hz, sfreq=sfreq ) >>> # with all explicit parameters >>> show_filter( ftype=ftype, cutoff_hz=cutoff_hz, sfreq=sfreq, width_hz=width_hz, ripple_db=ripple_db, window=window, ) """ _fp = check_filter_params( ftype=ftype, cutoff_hz=cutoff_hz, sfreq=sfreq, width_hz=width_hz, ripple_db=ripple_db, window=window, allow_defaults=True, ) taps = _design_firwin_filter(**_fp) # this many samples are lost to edge distortion (worst case) n_edge = int(np.floor(len(taps) / 2.0)) s_edge = n_edge / sfreq # promote scalar to iterable for printing _cutoff_hz = np.array(_fp["cutoff_hz"]).flatten() print(f"{_fp['ftype']} filter") print(f"sampling rate (samples / s): {_fp['sfreq']:0.3f}") print("1/2 amplitude cutoff (Hz): " + " ".join([f"{hz:0.3f}" for hz in _cutoff_hz])) print(f"transition width (Hz): {_fp['width_hz']:0.3f}") print(f"ripple (dB): {_fp['ripple_db']:0.3f}") print(f"window: {_fp['window']}") print(f"length (coefficients): {len(taps)}") print(f"delay (samples): {n_edge}") print( f"edge distortion: first and last {s_edge:.4f} seconds of the data" f"(= {n_edge} samples at {sfreq} samples / s)" ) freq_phase = _mfreqz( b=taps, a=1, cutoff_hz=_fp["cutoff_hz"], sfreq=_fp["sfreq"], width_hz=_fp["width_hz"], ) imp_step = _impz(b=taps, a=1) if show_output: io_fig, io_ax = filters_effect(**_fp) xdata_lims = np.array( [(l.get_xdata()[0], l.get_xdata()[-1]) for l in io_ax.get_lines()] ).max(axis=0) tmin, tmax = xdata_lims[0], xdata_lims[1] io_ax.axvspan(tmin, tmin + s_edge, color="gray", alpha=0.15) io_ax.axvspan(tmax, tmax - s_edge, color="gray", alpha=0.15) return freq_phase, imp_step, s_edge, n_edge
[docs]def fir_filter_dt( dt, col_names, ftype=None, cutoff_hz=None, sfreq=None, width_hz=None, ripple_db=None, window=None, ): """apply FIRLS filtering to columns of dataframe-like synchronized discrete time series Parameters ---------- dt : pd.DataFrame or structured numpy nd.array with named data types regularly sampled time-series data table: time (row) x data (columns) col_names: list of str column names to apply the transform key=val see :ref:`check_filter params() Parameters <filter_parameters_label>` Returns ------- pd.DataFrame or np.ndarray table-like copy with filtered data columns, the same size and object type as dt Notes ----- The input data is zero-padded by the length of the FIR filter delay and trimmed back to the original length. Examples -------- >>> ftype = "bandpass" >>> cutoff_hz = [18, 35] >>> width_hz = 5 >>> ripple_db = 60 >>> window = "kaiser" >>> sfreq = 250 >>> fir_filter_dt = epochs_filters( dt, col_names, ftype=ftype, window=window, cutoff_hz=cutoff_hz, width_hz=width_hz, ripple_db=ripple_db, sfreq=sfreq, trim_edges=False ) >>> params = dict(ftype="lowpass", cutoff_hz=10, width_hz=5, ripple_db=60, sfreq=250, window="hamming") >>> fir_filter_dt = epochs_filters(dt, col_names, **params) """ _fp = check_filter_params( ftype=ftype, cutoff_hz=cutoff_hz, sfreq=sfreq, width_hz=width_hz, ripple_db=ripple_db, window=window, ) taps = _design_firwin_filter(**_fp) # modicum of guarding if isinstance(dt, pd.DataFrame) or ( isinstance(dt, np.ndarray) and dt.dtype.names is not None ): pass else: raise TypeError("dt must be pandas.DataFrame or structured numpy.ndarray") filt_dt = dt.copy() for column in col_names: filt_dt[column] = _apply_firwin_filter_data(dt[column], taps) return filt_dt
[docs]def fir_filter_data( data, ftype=None, cutoff_hz=None, sfreq=None, width_hz=None, ripple_db=None, window=None, ): """ Finite Impulse Response filter Parameters ---------- data : 1-D array-like key=val see :ref:`check_filter params() Parameters <filter_parameters_label>` Returns ------- 1D array ``filtered_data`` filter output, same length as ``data`` Notes ----- The input data is zero-padded by the length of the FIR filter delay and trimmed back to the original length. """ _fp = check_filter_params( ftype=ftype, cutoff_hz=cutoff_hz, sfreq=sfreq, width_hz=width_hz, ripple_db=ripple_db, window=window, ) taps = _design_firwin_filter(**_fp) filt_data = _apply_firwin_filter_data(data, taps) return filt_data
[docs]def filters_effect( ftype=None, cutoff_hz=None, sfreq=None, width_hz=None, ripple_db=None, window=None, ): """ Generate example filter input-output plots for pure sinewave data. Parameters ---------- key=val see :ref:`check_filter params() Parameters <filter_parameters_label>` Returns ------- matplotlib.figure.Figure, matplotlib.axes.Axes ``fig``, ``ax`` of the example plot """ # test signal lower and upper bounds LO_HZ_LB = 0.2 HI_HZ_UB = sfreq / 2.0 _fp = check_filter_params( ftype=ftype, cutoff_hz=cutoff_hz, sfreq=sfreq, width_hz=width_hz, ripple_db=ripple_db, window=window, allow_defaults=False, ) if isinstance(cutoff_hz, list): lo_hz = cutoff_hz[0] - width_hz hi_hz = cutoff_hz[1] + width_hz else: lo_hz = cutoff_hz - width_hz hi_hz = cutoff_hz + width_hz mid_hz = np.mean([lo_hz, hi_hz]) # same as np.mean(cutoff_hz) # bound lo, hi, mid hz lo_hz = np.max([LO_HZ_LB, lo_hz]) hi_hz = np.min([HI_HZ_UB, hi_hz]) assert lo_hz < mid_hz and mid_hz < hi_hz # set y, y1 sine wave lo_hz, hi_hz, w/ mid_hz for band pass/stop if ftype.lower() == "lowpass": y_freqs = [lo_hz, hi_hz] y1_freqs = [lo_hz] # lo signal to pass elif ftype.lower() == "highpass": y_freqs = [lo_hz, hi_hz] y1_freqs = [hi_hz] # hi signal to pass elif ftype.lower() == "bandpass": y_freqs = [lo_hz, mid_hz, hi_hz] y1_freqs = [mid_hz] # in-band signal to pass elif ftype.lower() == "bandstop": y_freqs = [lo_hz, mid_hz, hi_hz] y1_freqs = [lo_hz, hi_hz] # out-of-band signals to pass _fparams = dict( ftype=ftype, cutoff_hz=cutoff_hz, sfreq=sfreq, width_hz=width_hz, ripple_db=ripple_db, window=window, ) # generate y, y1, and filter y y_amplitude_list = [1.0] * len(y_freqs) y1_amplitude_list = [1.0] * len(y1_freqs) # duration = 1/2 filter len + 3 cycles of low Hz + 1/2 filter len taps = _design_firwin_filter(**_fparams) duration = (3 * (1 / lo_hz)) + (len(taps) / sfreq) t, y = _sins_test_data(y_freqs, y_amplitude_list, sfreq, duration) t1, y1 = _sins_test_data(y1_freqs, y1_amplitude_list, sfreq, duration) y_filt = fir_filter_data(y, **_fparams) # apply the filter fig, ax = plt.subplots(figsize=(16, 4)) ax.plot(t, y, ".-", color="c", linestyle="-", label="input") ax.plot(t, y1, ".-", color="b", linestyle="-", label="ideal output") ax.plot( t, y_filt, ".-", color="r", linestyle="-", label="%s filter output" % ftype, ) # format for the title cutoff_hz_str = " ".join([f"{hz:.3f}" for hz in np.atleast_1d(cutoff_hz)]) ax.set_title( ( f"{ftype} filter cutoff={cutoff_hz_str} Hz, transition width={width_hz:.3f} Hz, " f"ripple={ripple_db:.3f}, dB window={window}" ), fontsize=20, ) ax.set_xlabel("Time", fontsize=20) ax.legend(fontsize=16, loc="upper left", bbox_to_anchor=(1.05, 1.0)) return fig, ax