Filters

The filters package provides digital signal processing filters specifically designed for HD-sEMG signal processing. All filters are implemented with a focus on phase preservation and numerical stability.

Bandpass Filter

The package provides a zero-phase Butterworth bandpass filter implementation that is particularly suited for EMG signal processing. It is an exact replica of Ton van den Bogert's MATLAB bandpassfilter (reference): the requested order is halved, the halved order is used in the pre-warping constant, and the filter is then applied forwards and backwards so the effective order is the requested one again.

API Reference

def bandpass_filter(data: np.ndarray, N: int, fcl: float, fch: float, fs: float) -> np.ndarray:
    """Apply a zero-phase Butterworth bandpass filter."""

Parameters

  • data (np.ndarray):

    • Signal samples to be filtered
    • Shape: (n_samples,), or (n_channels, n_samples) — filtering runs along the last axis, so each channel row is filtered independently
    • Type: float
  • N (int):

    • Total filter order, must be even and >= 2
    • Halved internally, then doubled again by the forward/backward application
    • Typical values: 2-8
  • fcl (float):

    • Lower cutoff frequency in Hz
    • Typical EMG values: 10-30 Hz
  • fch (float):

    • Upper cutoff frequency in Hz
    • Typical EMG values: 400-500 Hz
  • fs (float):

    • Sampling frequency in Hz
    • Because of the pre-warping, the Nyquist criterion alone is not sufficient: fs must satisfy fs > 2*fch/beta with beta = (sqrt(2)-1)**(1/N). For N=2 that is fs > 3.11*fch, not fs > 2*fch

Returns

  • filtered_data (np.ndarray):
    • Filtered signal
    • Same shape as input data
    • Zero-phase (no temporal shifting)

Raises

  • ValueError:
    • If N is not an even integer >= 2
    • If the cutoffs do not satisfy 0 < fcl < fch
    • If fs is too small for the requested cutoffs (see above). MATLAB errors on exactly the same inputs; before 2026-08 this function clipped the normalised cutoffs instead and returned a finite but wrong result

Implementation Details

The filter uses: - Butterworth filter design for maximally flat frequency response - Transfer-function (b, a) form with filtfilt, using MATLAB's default padding (padtype='odd', padlen=3*(max(len(a),len(b))-1)) rather than SciPy's defaults, which measurably diverge from MATLAB on short signals - Zero-phase filtering via forward-backward application (filtfilt) - Pre-warped cutoff scaling so the double-pass response has its -3 dB points at the requested frequencies

Example Usage

import numpy as np
from hdsemg_shared.filters.bandpass import bandpass_filter

# Generate sample EMG data
fs = 2000  # Sample rate: 2kHz
t = np.linspace(0, 1, fs)  # 1 second of data
emg = np.random.randn(fs)  # Simulated noise-like EMG

# Apply bandpass filter (20-450 Hz, 4th order)
filtered_emg = bandpass_filter(
    data=emg,
    N=4,        # total order, must be even
    fcl=20,     # Remove low-frequency drift
    fch=450,    # Remove high-frequency noise
    fs=fs
)

Best Practices

  1. Filter Order Selection:
  2. Start with N=4 for most applications
  3. N must be even (it is halved internally for the bi-directional pass)
  4. Increase N if you need sharper cutoffs
  5. Decrease N if you observe ringing artifacts

  6. Frequency Selection:

  7. For surface EMG: fcl=20Hz, fch=450Hz is typical
  8. Adjust based on your specific application and noise conditions
  9. Ensure fs > 2*fch/(sqrt(2)-1)**(1/N), which is stricter than the plain Nyquist criterion fch < fs/2 — the function raises otherwise

  10. Edge Effects:

  11. The filter may introduce edge effects at the start/end of the signal
  12. Consider padding your signal or discarding edge regions in critical analyses

Common Use Cases

  • Removing power line interference and baseline drift
  • Isolating the main EMG frequency band (20-450 Hz)
  • Pre-processing before amplitude analysis or feature extraction

Bandpass Filter with Exact Corners

bandpass_filter_exact_corners(data, N, fcl, fch, fs) band-passes each channel with its realised -3 dB points landing exactly on fcl and fch.

Why both band-passes exist

bandpass_filter is an exact replica of Ton van den Bogert's MATLAB original, whose beta pre-warp is derived for a single corner. A band-pass Butterworth has no such closed form, so dividing both corners by beta < 1 pushes both up by 1/beta = 1.554. Measured at fs = 2048 Hz, N = 2:

requested realised by bandpass_filter realised by bandpass_filter_exact_corners
15–450 Hz 35.3–574.9 Hz 15.0–450.0 Hz
30–450 Hz 68.8–582.3 Hz 30.0–450.0 Hz

bandpass_filter is deliberately not changed — every current caller depends on those numbers, and it is what reproduces MATLAB output. Use the exact-corner variant when the documented cut-offs must be the real ones.

from hdsemg_shared.filters.bandpass import bandpass_filter_exact_corners

filtered = bandpass_filter_exact_corners(emg, N=2, fcl=15.0, fch=450.0, fs=2048.0)

The solved upper corner sits above fch for a two-pass filter, so keep fch well below fs/2.

Lowpass Filter

lowpass_filter(data, N, fc, fs) — the same pre-warp and the same bi-directional application as bandpass_filter, for a single corner. That is the case the pre-warp is exact for, so the realised -3 dB point lands on fc and no exact-corner variant is needed (verified: 15.000 Hz for N = 2 and N = 4).

from hdsemg_shared.filters.lowpass import lowpass_filter

envelope = lowpass_filter(np.abs(filtered), N=2, fc=15.0, fs=2048.0)

Smoothing

moving_average(data, fs, window_s=None, fc=None, kernel='bidirectional') is the second way to smooth an envelope: a zero-phase boxcar instead of a Butterworth. Given the same fc the two attenuate a sine at fc identically, so the choice changes kernel shape, never bandwidth.

  • 'bidirectional' (default) — two boxcar passes, i.e. a triangular kernel.
  • 'rectangular' — one plain boxcar. With a window as long as the signal every sample returns the plain unweighted mean over the whole signal, which the triangular kernel does not. Use it wherever exact agreement with an epoch mean is the point.

window_length_for_cutoff(fc, fs, kernel) returns the window a given fc implies — roughly 0.319 * fs / fc for the default kernel, so 44 samples at fs = 2048 Hz, fc = 15 Hz.

Padding

Filtering a finite signal disturbs both ends, and filtfilt pads by only 3 samples. reflect_pad(data, pad) / trim_pad(data, pad) extend each channel with an even reflection of its own leading and trailing samples and remove it again afterwards. pad_samples(n_samples, fs, pad_s) gives the sample count both agree on.

Measured over 300 white-noise realisations, bias of the leading 64 samples:

padding band-pass lowpass
none +8.54 % -0.66 %
odd reflection (what filtfilt does) +9.16 % -0.84 %
even reflection (here) -0.28 % -0.01 %

Odd reflection does not fix the band-pass at all, so this is not a redundant second pad. global_amplitude applies it through its pad_s argument.

Future Extensions

The filters package is designed to be extensible. Planned or possible additions include:

  • Notch filters for power line interference
  • Adaptive filters for specific noise types
  • Wavelet-based filtering approaches

Source Code

The implementations can be found in src/hdsemg_shared/filters/ — bandpass.py, lowpass.py, smoothing.py and padding.py.

API Dokumentation

BANDPASS: zero-phase Butterworth band-pass, in two variants

bandpass_filter ... exact replica of Ton van den Bogert's MATLAB bandpassfilter. Its pre-warp is derived for a SINGLE corner, so both band-pass corners land ~1.554x high bandpass_filter_exact_corners ... solves for the corners numerically, so the realised -3 dB points land on the requested ones

Both filter a vector, or each row of a channels-by-samples matrix, on its own. Keep bandpass_filter for MATLAB agreement, use the exact-corner variant when the documented cut-offs must be the real ones.

CREDIT Ton van den Bogert, https://biomch-l.isbweb.org/archive/index.php/t-26625.html

bandpass_filter(data, N, fcl, fch, fs)

Exact Python replica of Ton van den Bogert's MATLAB bandpassfilter. @credit Ton van den Bogert, https://biomch-l.isbweb.org/archive/index.php/t-26625.html


Parameters

data : 1-D ndarray Signal to be filtered. N : int Total filter order requested by the user (must be even). Internally the Butterworth prototype is designed with order N/2 and then applied forwards & backwards (filtfilt) which doubles the effective order. fcl, fch : float Lower and higher cut-off frequencies [Hz]. fs : float Sampling frequency [Hz]. Must satisfy fs > 2fch/beta with beta = (sqrt(2)-1)*(1/N), i.e. the normalised cut-offs handed to butter() must stay below 1, otherwise a ValueError is raised (see the guard below). MATLAB errors on the same inputs; earlier versions of this function clipped instead and returned a finite but wrong result.

Returns

fdata : ndarray Zero-phase band-pass filtered signal (same length as data).

Raises

ValueError If N is not an even integer >= 2, if the cut-offs do not satisfy 0 < fcl < fch, or if fs is too small for the requested cut-offs.

Source code in hdsemg_shared/filters/bandpass.py
def bandpass_filter(data: np.ndarray,
                    N: int,
                    fcl: float,
                    fch: float,
                    fs: float) -> np.ndarray:
    """
    Exact Python replica of Ton van den Bogert's MATLAB `bandpassfilter`.
    @credit Ton van den Bogert, https://biomch-l.isbweb.org/archive/index.php/t-26625.html

    -------------------------------------------------------------------
    Parameters
    ----------
    data : 1-D ndarray
        Signal to be filtered.
    N : int
        *Total* filter order requested by the user (must be even).
        Internally the Butterworth prototype is designed with order N/2 and then
        applied forwards & backwards (filtfilt) which doubles the effective order.
    fcl, fch : float
        Lower and higher cut-off frequencies [Hz].
    fs : float
        Sampling frequency [Hz].
        Must satisfy fs > 2*fch/beta with beta = (sqrt(2)-1)**(1/N), i.e. the
        normalised cut-offs handed to butter() must stay below 1, otherwise a
        ValueError is raised (see the guard below). MATLAB errors on the same
        inputs; earlier versions of this function clipped instead and returned
        a finite but wrong result.

    Returns
    -------
    fdata : ndarray
        Zero-phase band-pass filtered signal (same length as `data`).

    Raises
    ------
    ValueError
        If N is not an even integer >= 2, if the cut-offs do not satisfy
        0 < fcl < fch, or if `fs` is too small for the requested cut-offs.
    """

    # ------------- 1. argument checks  ----------
    if N < 2 or N % 2:
        raise ValueError("N must be an even integer ≥ 2 (bi-directional filtering).")
    if fs <= 0 or fcl <= 0 or fch <= 0 or fcl >= fch:
        raise ValueError("Cut-off frequencies must satisfy 0 < fcl < fch < fs/2.")
    # ------------------------------------------------------------------------

    # ------------- 2. translate MATLAB design rule --------------------------
    # In Ton's routine, N is halved BEFORE the pre-warp constant is computed
    # (`N = N/2;` precedes the Wn line in the original MATLAB), so the
    # exponent below uses halfN, not the original N. Using the original N
    # here previously shifted both cutoffs (e.g. for N=2: beta=0.8022,
    # cutoffs off by ~20%, instead of the correct beta=0.6436) -- confirmed
    # against real MATLAB output on gait EMG data.
    halfN = N // 2  # order actually given to butter()
    beta = (np.sqrt(2) - 1) ** (1 / (2 * halfN))  # pre-warping constant (uses HALVED order)
    Wn = (2.0 * np.asarray([fcl, fch])) / (fs * beta)  # normalised (0–1)
    # ------------------------------------------------------------------------

    # ------------- 2b. sampling-frequency guard -----------------------------
    # MATLAB errors out when the sampling frequency is too small for the
    # requested cut-off. Its own guard,
    #     fs < 2 * (-1 + sqrt(2))^(-(1/2)/N) * fc
    # is evaluated with the UN-halved N (it precedes `N = N/2;`), which makes
    # it weaker than the filter actually needs: for N=2, fc=6 Hz it passes
    # from fs=14.96 Hz on, while Wn stays below 1 only from fs=18.65 Hz on --
    # in that gap MATLAB passes its own guard and then butter() itself errors.
    # Testing Wn < 1 directly therefore rejects exactly the inputs MATLAB
    # rejects, in one check instead of two.
    # Wn used to be clipped into (1e-6, 0.999) here, which silently returned a
    # finite but WRONG result for an under-sampled input instead of raising.
    if Wn[0] <= 0:
        raise ValueError(
            f"fcl={fcl} Hz underflows to a normalised cut-off of {Wn[0]} at "
            f"fs={fs} Hz; the filter is not representable."
        )
    if Wn[0] >= 1.0:
        raise ValueError(
            f"Your sampling frequency is too small for the specified lower "
            f"cut-off frequency: fs={fs} Hz, fcl={fcl} Hz and N={N} give a "
            f"normalised cut-off of {Wn[0]:.6g} >= 1. Need fs > "
            f"{2.0 * fcl / beta:.6g} Hz."
        )
    if Wn[1] >= 1.0:
        raise ValueError(
            f"Your sampling frequency is too small for the specified higher "
            f"cut-off frequency: fs={fs} Hz, fch={fch} Hz and N={N} give a "
            f"normalised cut-off of {Wn[1]:.6g} >= 1. Need fs > "
            f"{2.0 * fch / beta:.6g} Hz."
        )
    # ------------------------------------------------------------------------

    # ------------- 3. design filter & apply zero-phase ----------------------
    # Classical [b,a] + filtfilt with MATLAB's default padding
    # (padtype='odd', padlen=3*(max(len(a),len(b))-1)) rather than
    # scipy's sosfiltfilt defaults, which use a different pad length and
    # measurably diverge from MATLAB on short signals (e.g. epoch-windowed
    # EMG). Confirmed to match real MATLAB output to ~1e-13 relative error.
    b, a = butter(halfN, Wn, btype='bandpass')
    padlen = 3 * (max(len(a), len(b)) - 1)
    fdata = filtfilt(b, a, data, axis=-1, padtype='odd', padlen=padlen)
    return fdata

bandpass_filter_exact_corners(data, N, fcl, fch, fs)

BANDPASS_FILTER_EXACT_CORNERS band-passes each channel with -3 dB points landing exactly on fcl and fch

emg = bandpass_filter_exact_corners(emg, 2, 15.0, 450.0, 2048.0)

INPUT data ... signal over time, numeric vector; or a matrix whose rows are the channels and whose columns are the samples over time, then each row is filtered on its own [xV] N ... filter order, number. Must be EVEN and >= 2 [] fcl ... lower cutoff frequency, the realised -3 dB point [Hz] fch ... higher cutoff frequency, the realised -3 dB point [Hz] fs ... sampling frequency [Hz]

OUTPUT out ... filtered signal, same shape as data [xV]

RAISES ValueError ... if N is not an even integer >= 2, or if the cutoffs do not satisfy 0 < fcl < fch < fs/2

WHY bandpass_filter is an exact replica of Ton van den Bogert's MATLAB original, whose beta pre-warp is derived for a SINGLE corner. A band-pass Butterworth has no such closed form, so dividing BOTH corners by beta < 1 pushes both UP by 1/beta = 1.554: at fs = 2048 Hz and N = 2 a nominal 15-450 Hz band actually lands at 35.3-574.9 Hz, and 30-450 Hz at 68.8-582.3 Hz. This function instead solves numerically for the corners to hand to butter(), same goal, no closed form.

INFO ... NOT a MATLAB match, use bandpass_filter for that. This is an addition of the Python implementation INFO ... the solved upper corner sits ABOVE fch for a two-pass filter, so it can still run into the Nyquist clip even when the requested fch does not. Keep fch well below fs/2

Source code in hdsemg_shared/filters/bandpass.py
def bandpass_filter_exact_corners(data, N, fcl, fch, fs):
    """
    BANDPASS_FILTER_EXACT_CORNERS band-passes each channel with -3 dB points
    landing exactly on fcl and fch

      emg = bandpass_filter_exact_corners(emg, 2, 15.0, 450.0, 2048.0)

      INPUT
        data ... signal over time, numeric vector; or a matrix whose rows are
                  the channels and whose columns are the samples over time,
                  then each row is filtered on its own [xV]
        N    ... filter order, number. Must be EVEN and >= 2 []
        fcl  ... lower cutoff frequency, the realised -3 dB point [Hz]
        fch  ... higher cutoff frequency, the realised -3 dB point [Hz]
        fs   ... sampling frequency [Hz]

      OUTPUT
        out  ... filtered signal, same shape as data [xV]

      RAISES
        ValueError ... if N is not an even integer >= 2, or if the cutoffs do
                        not satisfy 0 < fcl < fch < fs/2

      WHY
        bandpass_filter is an exact replica of Ton van den Bogert's MATLAB
        original, whose beta pre-warp is derived for a SINGLE corner. A
        band-pass Butterworth has no such closed form, so dividing BOTH
        corners by beta < 1 pushes both UP by 1/beta = 1.554: at fs = 2048 Hz
        and N = 2 a nominal 15-450 Hz band actually lands at 35.3-574.9 Hz,
        and 30-450 Hz at 68.8-582.3 Hz. This function instead solves
        numerically for the corners to hand to butter(), same goal, no closed
        form.

      *INFO* ... NOT a MATLAB match, use bandpass_filter for that. This is an
                  addition of the Python implementation
      *INFO* ... the solved upper corner sits ABOVE fch for a two-pass
                  filter, so it can still run into the Nyquist clip even when
                  the requested fch does not. Keep fch well below fs/2
    """
    # Check the arguments
    if N < 2 or N % 2:
        raise ValueError("N must be an even integer >= 2 (bi-directional filtering).")
    if fs <= 0 or fcl <= 0 or fch <= 0 or fcl >= fch or fch >= fs / 2:
        raise ValueError("Cut-off frequencies must satisfy 0 < fcl < fch < fs/2.")

    halfN = N // 2
    target = 1 / np.sqrt(2)

    # Solve for the butter() corners whose TWO-PASS response is -3 dB at fcl/fch
    def residual(corners):
        lo = np.clip(corners[0], 1e-3, fs / 2 - 1e-3)
        hi = np.clip(corners[1], lo + 1e-3, fs / 2 - 1e-3)
        Wn = np.clip((2.0 * np.array([lo, hi])) / fs, 1e-6, 0.999)
        b, a = butter(halfN, Wn, btype='bandpass')
        _, h = freqz(b, a, worN=2 * np.pi * np.array([fcl, fch]) / fs)
        return np.abs(h) ** 2 - target

    lo, hi = fsolve(residual, x0=[fcl, fch], xtol=1e-12)
    Wn = np.clip((2.0 * np.array([lo, hi])) / fs, 1e-6, 0.999)

    # Design and apply zero-phase, MATLAB's padding
    b, a = butter(halfN, Wn, btype='bandpass')
    padlen = 3 * (max(len(a), len(b)) - 1)
    return filtfilt(b, a, data, axis=-1, padtype='odd', padlen=padlen)

LOWPASS: zero-phase Butterworth lowpass, MATLAB-compatible

Companion of filters.bandpass.bandpass_filter, same pre-warp, same bi-directional application. A SINGLE corner is the case Ton van den Bogert's pre-warp is exact for, so the realised -3 dB point lands on fc and there is no exact-corner variant to add here (verified: 15.000 Hz realised for N = 2 and N = 4).

PORT of SP_AM_CV_HDsEMG_Analysis.m, local function lowpassfilter (line 1567), (c) H Penasso 20.02.2019. CREDIT Ton van den Bogert, https://biomch-l.isbweb.org/archive/index.php/t-26625.html

Ported to Python for hdsemg-shared by Claude Opus 5, 2026-08-19.

lowpass_filter(data, N, fc, fs)

LOWPASS_FILTER applies a zero-phase Butterworth lowpass to each channel

env = lowpass_filter(rectified, 2, 15.0, 2048.0)

INPUT data ... signal over time, numeric vector; or a matrix whose rows are the channels and whose columns are the samples over time, then each row is filtered on its own [xV] N ... filter order, number. Must be EVEN and >= 2: it is halved internally and doubled again by the bi-directional (forwards and backwards) filtering [] fc ... cutoff frequency, the realised -3 dB point of the whole two-pass filter [Hz] fs ... sampling frequency [Hz]

OUTPUT out ... filtered signal, same shape as data [xV]

RAISES ValueError ... if N is not an even integer >= 2, if fs or fc are not positive, or if fs is too small for fc

INFO ... MATLAB's own guard uses the UN-halved order and is therefore weaker than the filter needs; butter() then errors instead. Testing the normalised cutoff against 1 rejects exactly the same inputs in one check INFO ... padding follows MATLAB (padtype 'odd', padlen 3*(max(len(a),len(b))-1)), NOT scipy's defaults. For the edge bias that still leaves, see filters.padding

Source code in hdsemg_shared/filters/lowpass.py
def lowpass_filter(data, N, fc, fs):
    """
    LOWPASS_FILTER applies a zero-phase Butterworth lowpass to each channel

      env = lowpass_filter(rectified, 2, 15.0, 2048.0)

      INPUT
        data ... signal over time, numeric vector; or a matrix whose rows are
                  the channels and whose columns are the samples over time,
                  then each row is filtered on its own [xV]
        N    ... filter order, number. Must be EVEN and >= 2: it is halved
                  internally and doubled again by the bi-directional
                  (forwards and backwards) filtering []
        fc   ... cutoff frequency, the realised -3 dB point of the whole
                  two-pass filter [Hz]
        fs   ... sampling frequency [Hz]

      OUTPUT
        out  ... filtered signal, same shape as data [xV]

      RAISES
        ValueError ... if N is not an even integer >= 2, if fs or fc are not
                        positive, or if fs is too small for fc

      *INFO* ... MATLAB's own guard uses the UN-halved order and is therefore
                  weaker than the filter needs; butter() then errors instead.
                  Testing the normalised cutoff against 1 rejects exactly the
                  same inputs in one check
      *INFO* ... padding follows MATLAB (padtype 'odd', padlen
                  3*(max(len(a),len(b))-1)), NOT scipy's defaults. For the
                  edge bias that still leaves, see filters.padding
    """
    # Check the arguments
    if N < 2 or N % 2:
        raise ValueError("N must be an even integer >= 2 (bi-directional filtering).")
    if fs <= 0 or fc <= 0:
        raise ValueError("fs and fc must be positive.")

    # Pre-warp with the HALVED order, N is doubled again by filtfilt
    half_n = N // 2
    beta = (np.sqrt(2) - 1) ** (1 / (2 * half_n))
    Wn = float((2.0 * fc) / (fs * beta))

    # Guard the sampling frequency instead of clipping to a wrong-but-finite result
    if Wn <= 0:
        raise ValueError(
            f"fc={fc} Hz underflows to a normalised cut-off of {Wn} at "
            f"fs={fs} Hz; the filter is not representable."
        )
    if Wn >= 1.0:
        raise ValueError(
            f"Your sampling frequency is too small for the specified cut-off "
            f"frequency: fs={fs} Hz, fc={fc} Hz and N={N} give a normalised "
            f"cut-off of {Wn:.6g} >= 1. Need fs > {2.0 * fc / beta:.6g} Hz."
        )

    # Design and apply zero-phase
    b, a = butter(half_n, Wn, btype='low')
    padlen = 3 * (max(len(a), len(b)) - 1)
    return filtfilt(b, a, data, axis=-1, padtype='odd', padlen=padlen)

SMOOTHING: zero-phase moving average, as an alternative to a lowpass

A boxcar run forwards and backwards is the second way to smooth an envelope. Given the same fc it attenuates a sine at fc exactly as filters.lowpass.lowpass_filter does, so the two differ only in kernel shape and the choice never changes the bandwidth.

TWO KERNELS "bidirectional" ... two boxcar passes, i.e. a triangular kernel. The default, the smoother of the two "rectangular" ... one plain boxcar. With a window as long as the signal every sample returns the plain unweighted mean over the whole signal, which "bidirectional" does NOT - it weighs the middle of its window more heavily. Use "rectangular" wherever exact agreement with an epoch mean is the point

ADDITION of the Python implementation, no MATLAB equivalent.

(c) H Penasso. Written for hdsemg-shared by Claude Opus 5, 2026-08-19, from the MATLAB conventions of Global-HDsEMG-Analysis.

moving_average(data, fs, window_s=None, fc=None, kernel='bidirectional')

MOVING_AVERAGE smooths each channel with a zero-phase moving window

y = moving_average(squared, fs, fc = 15.0) y = moving_average(squared, fs, window_s = 3.0, kernel = 'rectangular')

INPUT data ... signal over time, numeric vector; or a matrix whose rows are the channels and whose columns are the samples over time, then each row is smoothed on its own [xV] fs ... sampling frequency [Hz] window_s ... equivalent rectangular width of the window, give this OR fc, not both [s] fc ... wanted -3 dB cutoff, give this OR window_s [Hz] kernel ... char, 'bidirectional' (default) or 'rectangular', see the module docstring, TWO KERNELS []

OUTPUT y ... smoothed signal, same shape as data. Exactly zero-phase, a symmetric input comes back symmetric [xV]

RAISES ValueError ... if neither or both of window_s and fc are given, if either is not positive, or if kernel is unknown

INFO ... the ends are divided by the kernel weight that actually overlapped them, so no taper towards zero is introduced

Source code in hdsemg_shared/filters/smoothing.py
def moving_average(data, fs, window_s = None, fc = None, kernel = 'bidirectional'):
    """
    MOVING_AVERAGE smooths each channel with a zero-phase moving window

      y = moving_average(squared, fs, fc = 15.0)
      y = moving_average(squared, fs, window_s = 3.0, kernel = 'rectangular')

      INPUT
        data     ... signal over time, numeric vector; or a matrix whose rows
                      are the channels and whose columns are the samples over
                      time, then each row is smoothed on its own [xV]
        fs       ... sampling frequency [Hz]
        window_s ... equivalent rectangular width of the window, give this OR
                      fc, not both [s]
        fc       ... wanted -3 dB cutoff, give this OR window_s [Hz]
        kernel   ... char, 'bidirectional' (default) or 'rectangular', see
                      the module docstring, TWO KERNELS []

      OUTPUT
        y        ... smoothed signal, same shape as data. Exactly zero-phase,
                      a symmetric input comes back symmetric [xV]

      RAISES
        ValueError ... if neither or both of window_s and fc are given, if
                        either is not positive, or if kernel is unknown

      *INFO* ... the ends are divided by the kernel weight that actually
                  overlapped them, so no taper towards zero is introduced
    """
    x = np.asarray(data, dtype=np.float64)
    h = _kernel(kernel, _resolve_window(fs, window_s, fc, kernel))
    if h.size == 1:
        return x.copy()

    # Convolve the signal and a matching all-ones signal with the same kernel,
    # then divide: at the ends only part of the kernel overlaps and the
    # denominator carries exactly that part
    num = convolve1d(x, h, axis=-1, mode='constant', cval=0.0)
    den = convolve1d(np.ones(x.shape[-1], dtype=np.float64), h, mode='constant', cval=0.0)
    return num / den

window_length_for_cutoff(fc, fs, kernel='bidirectional')

WINDOW_LENGTH_FOR_CUTOFF returns the boxcar length whose response is -3 dB at fc

w = window_length_for_cutoff(15.0, 2048.0)

INPUT fc ... wanted -3 dB cutoff frequency [Hz] fs ... sampling frequency [Hz] kernel ... char, 'bidirectional' (default) or 'rectangular' []

OUTPUT w ... length of ONE boxcar pass, samples []

RAISES ValueError ... if fc is not below the Nyquist frequency, or if no window is long enough to reach fc

INFO ... -3 dB means the response of the WHOLE smoother, both passes included, has fallen to 1/sqrt(2). Give this function and lowpass_filter the same fc and both attenuate a sine at fc equally INFO ... rule of thumb w ~ 0.319 * fs / fc, so at fs = 2048 Hz and fc = 15 Hz that is 44 samples, a 31.9 ms equivalent window

Source code in hdsemg_shared/filters/smoothing.py
def window_length_for_cutoff(fc, fs, kernel = 'bidirectional'):
    """
    WINDOW_LENGTH_FOR_CUTOFF returns the boxcar length whose response is
    -3 dB at fc

      w = window_length_for_cutoff(15.0, 2048.0)

      INPUT
        fc     ... wanted -3 dB cutoff frequency [Hz]
        fs     ... sampling frequency [Hz]
        kernel ... char, 'bidirectional' (default) or 'rectangular' []

      OUTPUT
        w      ... length of ONE boxcar pass, samples []

      RAISES
        ValueError ... if fc is not below the Nyquist frequency, or if no
                        window is long enough to reach fc

      *INFO* ... -3 dB means the response of the WHOLE smoother, both passes
                  included, has fallen to 1/sqrt(2). Give this function and
                  lowpass_filter the same fc and both attenuate a sine at fc
                  equally
      *INFO* ... rule of thumb w ~ 0.319 * fs / fc, so at fs = 2048 Hz and
                  fc = 15 Hz that is 44 samples, a 31.9 ms equivalent window
    """
    if fs <= 0 or fc <= 0:
        raise ValueError("fs and fc must be positive.")
    if fc >= fs / 2:
        raise ValueError(f"fc={fc} Hz must be below the Nyquist frequency fs/2={fs / 2} Hz.")

    passes = _passes(kernel)
    target = 1.0 / np.sqrt(2.0)

    # The response falls monotonically in w until the kernel's first null,
    # which sits at w = fs/fc; bracket below it to stay on that branch
    w_hi = min(fs / fc, 1e6)
    if _response(fc, w_hi, fs, passes) > target:
        raise ValueError(f"No boxcar window reaches -3 dB at fc={fc} Hz for fs={fs} Hz.")

    w = brentq(lambda ww: _response(fc, ww, fs, passes) - target, 1.0 + 1e-9, w_hi, xtol=1e-10)
    return max(1, int(round(w)))

PADDING: edge guards for zero-phase filtering

Filtering a finite signal disturbs both of its ends. scipy's filtfilt pads by only 3 samples by default, far less than an EMG band-pass settles in, so each channel is padded with a time-flipped copy of its own leading and trailing samples, filtered, and the pad trimmed off again.

Measured over 300 white-noise realisations, bias of the leading 64 samples against the middle of the signal:

no pad                     band-pass  +8.54 %   lowpass  -0.66 %
odd reflection  (filtfilt) band-pass  +9.16 %   lowpass  -0.84 %
even reflection (here)     band-pass  -0.28 %   lowpass  -0.01 %

Even reflection is what works. Odd reflection - what filtfilt does by default - does not fix the band-pass at all, so this is not a redundant second pad.

ADDITION of the Python implementation, no MATLAB equivalent.

(c) H Penasso. Written for hdsemg-shared by Claude Opus 5, 2026-08-19, from the MATLAB conventions of Global-HDsEMG-Analysis.

pad_samples(n_samples, fs, pad_s)

PAD_SAMPLES converts a padding duration into the sample count both reflect_pad and trim_pad will use

pad = pad_samples(emg.shape[-1], fs, 0.25)

INPUT n_samples ... length of the signal along time [] fs ... sampling frequency [Hz] pad_s ... wanted padding duration at each end, 0 disables padding [s]

OUTPUT pad ... number of samples, clipped to what the signal can supply, so reflect_pad and trim_pad stay consistent []

Source code in hdsemg_shared/filters/padding.py
def pad_samples(n_samples, fs, pad_s):
    """
    PAD_SAMPLES converts a padding duration into the sample count both
    reflect_pad and trim_pad will use

      pad = pad_samples(emg.shape[-1], fs, 0.25)

      INPUT
        n_samples ... length of the signal along time []
        fs        ... sampling frequency [Hz]
        pad_s     ... wanted padding duration at each end, 0 disables
                       padding [s]

      OUTPUT
        pad       ... number of samples, clipped to what the signal can
                       supply, so reflect_pad and trim_pad stay consistent []
    """
    if pad_s <= 0:
        return 0
    return int(min(round(pad_s * fs), max(n_samples - 1, 0)))

reflect_pad(data, pad)

REFLECT_PAD extends a signal at both ends with an even reflection of itself

padded = reflect_pad(emg, 512)

INPUT data ... signal over time, numeric vector; or a matrix whose rows are the channels and whose columns are the samples over time, then each row is padded on its own [xV] pad ... number of samples to add at EACH end. Clipped to what the signal can supply, ignored if <= 0 []

OUTPUT padded ... data with pad samples prepended and appended, same number of rows [xV]

INFO ... trim the same number of samples off both ends after filtering, see trim_pad INFO ... the reflection is EVEN (a mirror), not odd (a point reflection). Odd is what filtfilt does and it does not fix the band-pass edge bias

Source code in hdsemg_shared/filters/padding.py
def reflect_pad(data, pad):
    """
    REFLECT_PAD extends a signal at both ends with an even reflection of itself

      padded = reflect_pad(emg, 512)

      INPUT
        data   ... signal over time, numeric vector; or a matrix whose rows
                    are the channels and whose columns are the samples over
                    time, then each row is padded on its own [xV]
        pad    ... number of samples to add at EACH end. Clipped to what the
                    signal can supply, ignored if <= 0 []

      OUTPUT
        padded ... data with `pad` samples prepended and appended, same
                    number of rows [xV]

      *INFO* ... trim the same number of samples off both ends after
                  filtering, see trim_pad
      *INFO* ... the reflection is EVEN (a mirror), not odd (a point
                  reflection). Odd is what filtfilt does and it does not fix
                  the band-pass edge bias
    """
    x = np.asarray(data, dtype=np.float64)
    if pad <= 0:
        return x

    # Cannot reflect more signal than there is
    pad = min(int(pad), x.shape[-1] - 1)
    if pad <= 0:
        return x

    return np.concatenate([x[..., pad:0:-1], x, x[..., -2:-pad - 2:-1]], axis=-1)

trim_pad(data, pad)

TRIM_PAD removes the samples reflect_pad added at both ends

emg = trim_pad(filtered_padded, 512)

INPUT data ... padded signal over time, vector or channels-by-samples matrix [xV] pad ... number of samples reflect_pad added at EACH end. Must be the value reflect_pad actually used, see pad_samples []

OUTPUT out ... data with pad samples removed from both ends [xV]

INFO ... reflect_pad clips pad to the signal length, so ask pad_samples for the number both functions agree on

Source code in hdsemg_shared/filters/padding.py
def trim_pad(data, pad):
    """
    TRIM_PAD removes the samples reflect_pad added at both ends

      emg = trim_pad(filtered_padded, 512)

      INPUT
        data ... padded signal over time, vector or channels-by-samples
                  matrix [xV]
        pad  ... number of samples reflect_pad added at EACH end. Must be the
                  value reflect_pad actually used, see pad_samples []

      OUTPUT
        out  ... data with `pad` samples removed from both ends [xV]

      *INFO* ... reflect_pad clips `pad` to the signal length, so ask
                  pad_samples for the number both functions agree on
    """
    x = np.asarray(data, dtype=np.float64)
    if pad <= 0:
        return x
    return x[..., int(pad):x.shape[-1] - int(pad)]