Skip to content

_fir_filter.py

Self-contained FIR filter design and out-of-core FIR filtering.

Vendored from ghostipy (Apache-2.0, https://github.com/kemerelab/ghostipy), covering the FIR subset the spyglass LFP pipeline needs. A copy of the Apache License 2.0 ships with spyglass at spyglass/common/licenses/ghostipy-Apache-2.0.txt; the modifications made here are listed under "Intentional divergences from upstream" below.

Vendored from upstream (upstream name -> name here):

  • estimate_taps -> estimate_taps, FIR tap-count estimate
  • firdesign -> firdesign, Type I design with spline transition bands (L2), and its _firspline low-pass prototype helper
  • group_delay -> group_delay, integer group delay of a Type I FIR
  • filter_data_fir -> filter_data_fir, the mode='full' entry point
  • osconvolve -> _osconvolve, the overlap-save engine beneath it, which streams into a (possibly on-disk) preallocated output array with decimation and per-dimension index restrictions. Its argument validation and output-shape planning were split out into _plan_osconvolve, which has no upstream counterpart.

common_filter.py calls estimate_taps, firdesign and filter_data_fir; group_delay is vendored and exported for completeness but spyglass uses its own FirFilterParameters.calc_filter_delay instead.

Call signatures keep upstream ghostipy's parameter order and defaults, with clearer parameter names (e.g. fs -> sampling_freq, tw -> transition_width, p -> spline_power, b -> filter_coeffs) and added type annotations; numeric output matches upstream to floating-point round-off (measured: firdesign coefficients bitwise identical, filtered output within 4.4e-16 on spyglass's LFP calls).

Intentional divergences from upstream:

  • FFT backend: pyfftw replaced with scipy.fft (workers= for multithreading), using the real transform (rfft/irfft) for real input and the full complex transform otherwise. This removed the M1-Mac / conda-forge install friction the spyglass setup notes used to document, and is the only change that touches the numeric path for valid input (round-off only).
  • Block selection at boundaries: when the requested (exclusive) output stop lands exactly on an overlap-save block boundary, the trailing empty block is no longer processed. Upstream still read and FFT'd it only to write zero samples; skipping it removes a wasted transform and a read one block past the needed data (which could fail on a strict lazy/on-disk signal). Output is identical.
  • Overlap placement bug fix: upstream positioned a block's leading M-1 overlap samples from the LENGTH of the (clipped) read, which is only correct when the read reaches the block start. For a signal shorter than M-1 filtered with an nfft tight enough to need several blocks, the read is clipped at both ends and upstream shifted those samples, returning a wrong convolution (e.g. [2, 4, 4, 4, 10] instead of [2, 4, 6, 8, 10]). The position is now derived from where the read actually starts. Unreachable at spyglass's default nfft (>= 10x the kernel), so LFP output is unaffected.
  • Fail-loud / fail-closed hardening that affects only invalid inputs or genuine errors (never the valid path spyglass exercises): the M-1 overlap read no longer swallows exceptions and silently zero-fills; input_index_bounds / output_index_bounds treat the stop as exclusive and validate by range rather than probing the array; estimate_taps rejects non-positive sampling_freq, transition_width, passband_deviation, and stopband_deviation, and deviations so loose the tap estimate would be < 1; firdesign/_firspline require an integer numtaps >= 1 and at least two ordered band_edges; the spline power spline_power must be

    0; decimation_factor must be an integer >= 1; nfft must be an integer >= the kernel length; input_dim_restrictions entries must be 1-D integer index arrays restricting at most one non-filtered axis; output_offset must be an integer >= 0 that fits within outarray; complex input no longer raises UnboundLocalError.

  • Provably empty block reads are skipped rather than issued: the leading overlap read when a block starts at sample 0 (nothing precedes it), and the main read when a trailing block starts at or past the end of the data. Upstream issued both unconditionally and hid the fallout in the blanket except above. h5py rejects an empty slice combined with a fancy index of 16 or more elements ("Dataspaces don't have hyperslab selections"), which is an ordinary LFP configuration. The block buffer is already zeroed, so skipping is also the correct fill.
  • input_dim_restrictions may select in any order, including duplicates, and rows are returned in the order requested. An unsorted selection is READ as sorted unique indices -- all h5py accepts -- then gathered back. Upstream passed the array straight through, which worked only for a sorted selection on an on-disk signal.
  • outarray's real/complex check reads its dtype instead of a slice of its contents, so no data is read from a (possibly on-disk) output array.
  • verbose is gone from both entry points; progress goes to a module-level logging logger instead of print.

Design details (spline-transition FIR) follow Burrus et al., 1992.

group_delay(filter_coeffs)

Group delay of a linear-phase (Type I) FIR filter.

Parameters:

Name Type Description Default
filter_coeffs (ndarray, shape(N))

The filter coefficients. N must be odd.

required

Returns:

Type Description
int

The group delay in samples, (N - 1) // 2.

Raises:

Type Description
ValueError

If filter_coeffs has an even number of coefficients, for which the group delay is not an integer.

Source code in src/spyglass/common/_fir_filter.py
def group_delay(filter_coeffs: np.ndarray) -> int:
    """Group delay of a linear-phase (Type I) FIR filter.

    Parameters
    ----------
    filter_coeffs : numpy.ndarray, shape (N,)
        The filter coefficients. ``N`` must be odd.

    Returns
    -------
    int
        The group delay in samples, ``(N - 1) // 2``.

    Raises
    ------
    ValueError
        If ``filter_coeffs`` has an even number of coefficients, for which the
        group delay is not an integer.
    """
    numtaps = len(filter_coeffs)
    if not numtaps & 1:
        raise ValueError(
            f"There are {numtaps} filter coefficients (an even number), so the "
            "group delay cannot be converted to an integer value"
        )
    return (numtaps - 1) // 2

estimate_taps(sampling_freq, transition_width, *, passband_deviation=0.001, stopband_deviation=1e-06)

Estimate the number of taps for a Type I FIR filter.

Parameters:

Name Type Description Default
sampling_freq float

Sampling rate in Hz.

required
transition_width float

Transition bandwidth in Hz.

required
passband_deviation float

Passband deviation. Default is 0.1% (1e-3).

0.001
stopband_deviation float

Minimum stopband attenuation. Default is 120 dB (1e-6).

1e-06

Returns:

Type Description
int

Number of taps (always odd).

Raises:

Type Description
ValueError

If sampling_freq, transition_width, passband_deviation, or stopband_deviation is non-finite or non-positive, or if the deviations are so loose that the estimated tap count is < 1 (i.e. 10 * passband_deviation * stopband_deviation >= 1).

References

https://dsp.stackexchange.com/questions/31066

Source code in src/spyglass/common/_fir_filter.py
def estimate_taps(
    sampling_freq: float,
    transition_width: float,
    *,
    passband_deviation: float = 1e-3,
    stopband_deviation: float = 1e-6,
) -> int:
    """Estimate the number of taps for a Type I FIR filter.

    Parameters
    ----------
    sampling_freq : float
        Sampling rate in Hz.
    transition_width : float
        Transition bandwidth in Hz.
    passband_deviation : float, optional
        Passband deviation. Default is 0.1% (1e-3).
    stopband_deviation : float, optional
        Minimum stopband attenuation. Default is 120 dB (1e-6).

    Returns
    -------
    int
        Number of taps (always odd).

    Raises
    ------
    ValueError
        If ``sampling_freq``, ``transition_width``, ``passband_deviation``, or
        ``stopband_deviation`` is non-finite or non-positive, or if the
        deviations are so loose that the estimated tap count is < 1 (i.e.
        ``10 * passband_deviation * stopband_deviation >= 1``).

    References
    ----------
    https://dsp.stackexchange.com/questions/31066
    """
    _assert_finite_positive(sampling_freq, "sampling_freq")
    _assert_finite_positive(transition_width, "transition_width")
    if (
        not np.isfinite(passband_deviation)
        or not np.isfinite(stopband_deviation)
        or passband_deviation <= 0
        or stopband_deviation <= 0
    ):
        raise ValueError(
            "passband/stopband deviations must be finite and positive but got "
            f"passband_deviation={passband_deviation}, "
            f"stopband_deviation={stopband_deviation}"
        )

    deviation_product = 10 * passband_deviation * stopband_deviation
    numtaps = int(
        np.ceil(
            2
            / 3
            * np.log10(1 / deviation_product)
            * sampling_freq
            / transition_width
        )
    )
    if numtaps < 1:
        raise ValueError(
            f"computed numtaps={numtaps} < 1; the deviations "
            f"passband_deviation={passband_deviation}, "
            f"stopband_deviation={stopband_deviation} are too loose "
            "(10 * passband_deviation * stopband_deviation must be < 1)"
        )
    if not numtaps & 1:
        numtaps += 1
    return numtaps

firdesign(numtaps, band_edges, desired, *, sampling_freq=1, spline_power=None)

Design an arbitrary Type I FIR filter with spline transition bands.

Optimized for an L2 error norm.

Parameters:

Name Type Description Default
numtaps int

Number of filter coefficients (must be a positive odd integer).

required
band_edges (array_like, shape(2 * n_bands))

Critical frequencies of the filter in Hz, an even-length, strictly increasing sequence. Do not include 0 or the Nyquist frequency.

required
desired (array_like, shape(2 * n_bands))

Magnitude response at each band edge; each value must be 0 or 1. The values must alternate between transition bands (the two edges of a transition band differ) and flat bands (the two edges match).

required
sampling_freq float

Sampling rate in Hz. Default is 1 Hz.

1
spline_power float

Power for the spline transition-band functions. Default follows Burrus et al., 1992.

None

Returns:

Type Description
(ndarray, shape(numtaps))

The filter coefficients.

Raises:

Type Description
ValueError

If numtaps is not a positive odd integer; if sampling_freq is non-finite or non-positive; if band_edges is empty, has an odd length, differs in length from desired, has a non-positive first edge, has a last edge >= the Nyquist frequency, or is not strictly increasing; or if desired contains values other than 0/1 or does not follow the required transition/flat-band alternation.

Source code in src/spyglass/common/_fir_filter.py
def firdesign(
    numtaps: int,
    band_edges: npt.ArrayLike,
    desired: npt.ArrayLike,
    *,
    sampling_freq: float = 1,
    spline_power: float | None = None,
) -> np.ndarray:
    """Design an arbitrary Type I FIR filter with spline transition bands.

    Optimized for an L2 error norm.

    Parameters
    ----------
    numtaps : int
        Number of filter coefficients (must be a positive odd integer).
    band_edges : array_like, shape (2 * n_bands,)
        Critical frequencies of the filter in Hz, an even-length,
        strictly increasing sequence. Do not include 0 or the Nyquist
        frequency.
    desired : array_like, shape (2 * n_bands,)
        Magnitude response at each band edge; each value must be 0 or 1. The
        values must alternate between transition bands (the two edges of a
        transition band differ) and flat bands (the two edges match).
    sampling_freq : float, optional
        Sampling rate in Hz. Default is 1 Hz.
    spline_power : float, optional
        Power for the spline transition-band functions. Default follows
        Burrus et al., 1992.

    Returns
    -------
    numpy.ndarray, shape (numtaps,)
        The filter coefficients.

    Raises
    ------
    ValueError
        If ``numtaps`` is not a positive odd integer; if ``sampling_freq`` is
        non-finite or non-positive; if ``band_edges`` is empty, has an odd
        length, differs in length from ``desired``, has a non-positive first
        edge, has a last
        edge >= the Nyquist frequency, or is not strictly increasing; or if
        ``desired`` contains values other than 0/1 or does not follow the
        required transition/flat-band alternation.
    """
    band_edges = np.array(band_edges)
    desired = np.array(desired)

    if not isinstance(numtaps, (int, np.integer)):
        raise ValueError(f"Got {numtaps} for 'numtaps' but must be an integer")
    numtaps = int(numtaps)
    if numtaps < 1:
        raise ValueError(
            f"Got {numtaps} for 'numtaps' but must be a positive odd value"
        )
    if not numtaps & 1:
        raise ValueError(
            f"Got {numtaps} for 'numtaps' but must be an odd value"
        )
    _assert_finite_positive(sampling_freq, "sampling_freq")
    if len(band_edges) == 0:
        raise ValueError("Must have at least two band edges")
    if len(band_edges) % 2 != 0:
        raise ValueError("Must have even number of band edges")
    if len(band_edges) != len(desired):
        raise ValueError("must have equal number of band edges and values")
    if not np.isin(desired, (0, 1)).all():
        raise ValueError("All values must be either 0 or 1")
    if not band_edges[0] > 0:
        raise ValueError("First band edge must be greater than 0")
    if not band_edges[-1] < sampling_freq / 2:
        raise ValueError(
            f"Last band edge must be less than {sampling_freq / 2}"
        )
    if not np.all(band_edges[:-1] < band_edges[1:]):
        raise ValueError(
            "'band_edges' must be a monotonically increasing sequence"
        )

    for edge_index, (desired_left, desired_right) in enumerate(
        zip(desired, desired[1:])
    ):
        edge_left = band_edges[edge_index]
        edge_right = band_edges[edge_index + 1]
        if edge_index % 2 == 0:
            if desired_left == desired_right:
                raise ValueError(
                    f"Got {desired_left} for band edge {edge_left} Hz and "
                    f"{desired_right} for band edge {edge_right} Hz but must be "
                    "different values"
                )
        else:
            if desired_left != desired_right:
                raise ValueError(
                    f"Got {desired_left} for band edge {edge_left} Hz and "
                    f"{desired_right} for band edge {edge_right} Hz but must be "
                    "the same values"
                )

    critical_points = band_edges.reshape((-1, 2))
    # low pass prototypes
    prototypes = np.zeros((len(critical_points), numtaps))
    for ind, (pass_freq, stop_freq) in enumerate(critical_points):
        prototypes[ind] = _firspline(
            numtaps,
            pass_freq,
            stop_freq,
            sampling_freq=sampling_freq,
            spline_power=spline_power,
        )

    # center impulse (identity filter), used to invert a lowpass into a highpass
    impulse = scipy.signal.unit_impulse(numtaps, "mid")

    if prototypes.shape[0] == 1:  # single band
        coeffs = prototypes[0]
        if desired[-1] == 1:  # high pass
            coeffs = impulse - prototypes[0]
    else:  # multi-band
        coeffs = np.zeros(numtaps)

        # Magnitude at 0 and Nyquist is the same
        if desired[0] == desired[-1]:
            for ii in range(0, prototypes.shape[0], 2):
                lowpass_low = prototypes[ii]
                lowpass_high = prototypes[ii + 1]
                coeffs += lowpass_high - lowpass_low

            # high pass at 0 and Nyquist, so invert
            if desired[-1] == 1:
                coeffs = impulse - coeffs
        else:
            if desired[0] == 0:
                special_band = impulse - prototypes[-1]
                prototypes = prototypes[:-1]
            else:
                special_band = prototypes[0]
                prototypes = prototypes[1:]

            for ii in range(0, prototypes.shape[0] - 1, 2):
                lowpass_low = prototypes[ii]
                lowpass_high = prototypes[ii + 1]
                coeffs += lowpass_high - lowpass_low

            coeffs += special_band

    return coeffs

describe_output(data, filter_coeffs, *, nfft=None, axis=-1, input_index_bounds=None, output_index_bounds=None, decimation_factor=None, input_dim_restrictions=None)

Shape and dtype :func:filter_data_fir would produce, without filtering.

Sizes the preallocated (possibly on-disk) array for the out-of-core streaming protocol described in :func:filter_data_fir. No data is read and no FFT runs -- this only validates the arguments and plans the output.

Every argument that affects the answer is accepted and validated exactly as :func:filter_data_fir validates it, so whatever this accepts, the matching filtering call accepts too. The arguments that cannot affect the answer (threads, outarray, output_offset) are deliberately absent rather than accepted and ignored.

Parameters:

Name Type Description Default
See
required

Returns:

Name Type Description
shape tuple of int

Shape the output would have, including the effect of decimation_factor and input_dim_restrictions.

dtype str

'<f8' for real input, '<c16' for complex input.

Raises:

Type Description
(ValueError, IndexError)

On the same invalid arguments :func:filter_data_fir rejects.

Source code in src/spyglass/common/_fir_filter.py
def describe_output(
    data: _ReadableArray,
    filter_coeffs: npt.ArrayLike,
    *,
    nfft: int | None = None,
    axis: int = -1,
    input_index_bounds: Sequence[int] | None = None,
    output_index_bounds: Sequence[int] | None = None,
    decimation_factor: int | None = None,
    input_dim_restrictions: Sequence[npt.ArrayLike | None] | None = None,
) -> tuple[tuple[int, ...], str]:
    """Shape and dtype :func:`filter_data_fir` would produce, without filtering.

    Sizes the preallocated (possibly on-disk) array for the out-of-core
    streaming protocol described in :func:`filter_data_fir`. No data is read and
    no FFT runs -- this only validates the arguments and plans the output.

    Every argument that affects the answer is accepted and validated exactly as
    :func:`filter_data_fir` validates it, so whatever this accepts, the matching
    filtering call accepts too. The arguments that cannot affect the answer
    (``threads``, ``outarray``, ``output_offset``) are deliberately absent
    rather than accepted and ignored.

    Parameters
    ----------
    See :func:`filter_data_fir` for the shared parameters.

    Returns
    -------
    shape : tuple of int
        Shape the output would have, including the effect of
        ``decimation_factor`` and ``input_dim_restrictions``.
    dtype : str
        ``'<f8'`` for real input, ``'<c16'`` for complex input.

    Raises
    ------
    ValueError, IndexError
        On the same invalid arguments :func:`filter_data_fir` rejects.
    """
    plan = _plan_osconvolve(
        data,
        np.asarray(filter_coeffs),
        mode="full",
        nfft=nfft,
        threads=1,  # no FFT runs here, so this cannot affect the answer
        axis=axis,
        input_index_bounds=input_index_bounds,
        output_index_bounds=output_index_bounds,
        decimation_factor=decimation_factor,
        input_dim_restrictions=input_dim_restrictions,
    )
    logger.debug(
        "Output array should have shape %s and dtype %s",
        plan.expected_shape,
        plan.dtype,
    )
    return plan.expected_shape, plan.dtype

filter_data_fir(data, filter_coeffs, *, nfft=None, threads=cpu_count(), axis=-1, outarray=None, input_index_bounds=None, output_index_bounds=None, decimation_factor=None, input_dim_restrictions=None, output_offset=0)

filter_data_fir(
    data: _ReadableArray,
    filter_coeffs: npt.ArrayLike,
    *,
    outarray: None = None,
    **kwargs
) -> np.ndarray
filter_data_fir(
    data: _ReadableArray,
    filter_coeffs: npt.ArrayLike,
    *,
    outarray: _OutArrayT,
    **kwargs
) -> _OutArrayT

Apply an FIR filter to data via overlap-save FFT convolution.

This is the public entry point spyglass uses: a thin mode='full' wrapper over the general :func:_osconvolve engine, exposing only the parameters spyglass needs. Combined with output_index_bounds set to [group_delay, group_delay + N] the full-mode convolution yields the zero-phase, delay-compensated output that spyglass relies on.

Parameters:

Name Type Description Default
data ndarray or Dataset

The data to be filtered, shape (..., n_time, ...) with the filtered axis given by axis. Must expose .ndim/.shape/.dtype and support slice + integer-array indexing. It is NOT converted to an array, so an on-disk/lazy signal stays on disk. Real input yields a real result; complex input a complex result.

required
filter_coeffs (array_like, shape(M))

Filter coefficients (1-D). Converted to a NumPy array internally.

required
nfft int

FFT length along the filtered axis; must be an integer >= M. Default chosen automatically.

None
threads int

Number of FFT worker threads (>= 1). Default is the CPU count.

cpu_count()
axis int

Axis along which to filter. Default is -1.

-1
outarray ndarray or Dataset

Preallocated output (may be on disk; spyglass writes into an NWB dataset). Default allocates in memory. See Notes for the dtype contract.

None
input_index_bounds sequence of 2 int

[start, stop) indices along axis defining WHICH OUTPUT the convolution is computed for (stop exclusive). This is not a promise that only that input range is read: the filter still draws its support from the neighbouring samples of the full array, so with [start, stop) the result at the window edges depends on data outside the window. To filter a window in isolation instead, slice the array first and pass bounds relative to the slice -- the two give different edge samples.

None
output_index_bounds sequence of 2 int

[start, stop) indices of the full-convolution output to keep (stop exclusive).

None
decimation_factor int

Integer decimation factor (>= 1). Default None (no decimation).

None
input_dim_restrictions sequence

One entry per dimension of data. The entry for axis must be None; at most one other entry may be set, and it must be a 1-D, in-range array of integer indices selecting which elements of that (non-filtered) axis to keep -- e.g. a subset of electrodes. Any order is accepted, including duplicates, and rows are returned in the order given (an unsorted selection is read in sorted order, which is all h5py accepts, then gathered back). Slices/masks and restricting more than one axis are not supported (they raise).

None
output_offset int

Offset (>= 0) into outarray along axis at which to start writing. Default 0.

0

Returns:

Type Description
numpy.ndarray or the ``outarray`` type

The filtered (and optionally decimated) data. When an outarray was supplied, the SAME object is returned, written in place -- so an h5py Dataset in gives that Dataset back, not a numpy array. Otherwise a newly allocated numpy array is returned. Use :func:describe_output to size that array without filtering.

Raises:

Type Description
ValueError

On invalid arguments -- e.g. threads < 1, a non-1-D kernel, a non-integer or out-of-range nfft/decimation_factor/output_offset, bounds that are not strictly increasing, output_index_bounds out of range, or unsupported input_dim_restrictions.

IndexError

If input_index_bounds or a restriction array is out of range. Note that out-of-range bounds raise IndexError for the INPUT and ValueError for the OUTPUT.

TypeError

If the result is complex but a real-dtype outarray was supplied.

Notes

Output dtype: real input yields float64 ('<f8'), complex input yields complex128 ('<c16'). If you supply your own outarray, its dtype is used as-is and the result is cast into it -- assigning the float result into an integer array truncates silently, so match the dtype from :func:describe_output (a lower-precision float such as float32 is fine).

Out-of-core streaming protocol (how spyglass filters data larger than RAM): call :func:describe_output once per interval to get each interval's output length, preallocate a single (possibly on-disk) array sized to their sum, then call this function per interval with that array as outarray and the running cumulative length as output_offset.

The input is assumed finite: a NaN/inf in any block spreads across that whole block's output via the FFT.

Source code in src/spyglass/common/_fir_filter.py
def filter_data_fir(
    data: _ReadableArray,
    filter_coeffs: npt.ArrayLike,
    *,
    nfft: int | None = None,
    threads: int = cpu_count(),
    axis: int = -1,
    outarray: _WritableArray | None = None,
    input_index_bounds: Sequence[int] | None = None,
    output_index_bounds: Sequence[int] | None = None,
    decimation_factor: int | None = None,
    input_dim_restrictions: Sequence[npt.ArrayLike | None] | None = None,
    output_offset: int = 0,
) -> _WritableArray:
    """Apply an FIR filter to data via overlap-save FFT convolution.

    This is the public entry point spyglass uses: a thin ``mode='full'`` wrapper
    over the general :func:`_osconvolve` engine, exposing only the parameters
    spyglass needs. Combined with ``output_index_bounds`` set to
    ``[group_delay, group_delay + N]`` the full-mode convolution yields the
    zero-phase, delay-compensated output that spyglass relies on.

    Parameters
    ----------
    data : numpy.ndarray or h5py.Dataset
        The data to be filtered, shape ``(..., n_time, ...)`` with the filtered
        axis given by ``axis``. Must expose ``.ndim``/``.shape``/``.dtype`` and
        support slice + integer-array indexing. It is NOT converted to an array,
        so an on-disk/lazy signal stays on disk. Real input yields a real
        result; complex input a complex result.
    filter_coeffs : array_like, shape (M,)
        Filter coefficients (1-D). Converted to a NumPy array internally.
    nfft : int, optional
        FFT length along the filtered axis; must be an integer >= ``M``.
        Default chosen automatically.
    threads : int, optional
        Number of FFT worker threads (>= 1). Default is the CPU count.
    axis : int, optional
        Axis along which to filter. Default is -1.
    outarray : numpy.ndarray or h5py.Dataset, optional
        Preallocated output (may be on disk; spyglass writes into an NWB
        dataset). Default allocates in memory. See Notes for the dtype
        contract.
    input_index_bounds : sequence of 2 int, optional
        ``[start, stop)`` indices along ``axis`` defining WHICH OUTPUT the
        convolution is computed for (stop exclusive). This is not a promise that
        only that input range is read: the filter still draws its support from
        the neighbouring samples of the full array, so with ``[start, stop)`` the
        result at the window edges depends on data outside the window. To filter
        a window in isolation instead, slice the array first and pass bounds
        relative to the slice -- the two give different edge samples.
    output_index_bounds : sequence of 2 int, optional
        ``[start, stop)`` indices of the full-convolution output to keep (stop
        exclusive).
    decimation_factor : int, optional
        Integer decimation factor (>= 1). Default None (no decimation).
    input_dim_restrictions : sequence, optional
        One entry per dimension of ``data``. The entry for ``axis`` must be
        None; at most one other entry may be set, and it must be a 1-D, in-range
        array of integer indices selecting which elements of that (non-filtered)
        axis to keep -- e.g. a subset of electrodes. Any order is accepted,
        including duplicates, and rows are returned in the order given (an
        unsorted selection is read in sorted order, which is all h5py accepts,
        then gathered back). Slices/masks and restricting more than one axis are
        not supported (they raise).
    output_offset : int, optional
        Offset (>= 0) into ``outarray`` along ``axis`` at which to start
        writing. Default 0.

    Returns
    -------
    numpy.ndarray or the ``outarray`` type
        The filtered (and optionally decimated) data. When an ``outarray`` was
        supplied, the SAME object is returned, written in place -- so an h5py
        ``Dataset`` in gives that ``Dataset`` back, not a numpy array.
        Otherwise a newly allocated numpy array is returned. Use
        :func:`describe_output` to size that array without filtering.

    Raises
    ------
    ValueError
        On invalid arguments -- e.g. ``threads < 1``, a non-1-D kernel, a
        non-integer or out-of-range
        ``nfft``/``decimation_factor``/``output_offset``, bounds that are not
        strictly increasing, ``output_index_bounds`` out of range, or unsupported
        ``input_dim_restrictions``.
    IndexError
        If ``input_index_bounds`` or a restriction array is out of range. Note
        that out-of-range bounds raise ``IndexError`` for the INPUT and
        ``ValueError`` for the OUTPUT.
    TypeError
        If the result is complex but a real-dtype ``outarray`` was supplied.

    Notes
    -----
    Output dtype: real input yields ``float64`` (``'<f8'``), complex input
    yields ``complex128`` (``'<c16'``). If you supply your own ``outarray``, its
    dtype is used as-is and the result is cast into it -- assigning the float
    result into an integer array truncates silently, so match the dtype from
    :func:`describe_output` (a lower-precision float such as ``float32`` is
    fine).

    Out-of-core streaming protocol (how spyglass filters data larger than RAM):
    call :func:`describe_output` once per interval to get each interval's output
    length, preallocate a single (possibly on-disk) array sized to their sum,
    then call this function per interval with that array as ``outarray`` and the
    running cumulative length as ``output_offset``.

    The input is assumed finite: a NaN/inf in any block spreads across that
    whole block's output via the FFT.
    """
    return _osconvolve(
        data,
        filter_coeffs,
        mode="full",
        nfft=nfft,
        threads=threads,
        axis=axis,
        outarray=outarray,
        input_index_bounds=input_index_bounds,
        output_index_bounds=output_index_bounds,
        decimation_factor=decimation_factor,
        input_dim_restrictions=input_dim_restrictions,
        output_offset=output_offset,
    )