Bessel Filter Design Principles and Python Implementation

A comprehensive guide to Bessel (Thomson-Bessel) filters covering mathematical foundations, maximally flat group delay derivation, comparison with Butterworth/Chebyshev/Elliptic filters, and Python implementation using SciPy for lowpass, highpass, and bandpass designs.

Introduction

The Bessel filter is an IIR filter with maximally flat group delay in the passband. Proposed by W. E. Thomson in 1952, it is also known as the Thomson-Bessel filter.

Comparing group delay flatness among common IIR filters (same order):

\[\text{Bessel} > \text{Butterworth} > \text{Chebyshev} > \text{Elliptic}\]

Because the Bessel filter maintains nearly constant group delay across the passband, all frequency components experience the same time delay through the filter. This preserves the shape of pulse waveforms. This article covers the mathematical background and practical design using SciPy.

What Is Group Delay?

Group delay \(\tau(\omega)\) is the negative derivative of the filter’s phase response \(\phi(\omega)\) with respect to frequency:

\[\tau(\omega) = -\frac{d\phi(\omega)}{d\omega} \tag{1}\]

Constant group delay means the filter has a linear phase response. All frequency components are delayed by the same amount of time, so the output waveform retains its shape.

Bessel filters are chosen for applications where waveform shape preservation matters: audio processing, pulse transmission, and precision measurement instruments.

Mathematical Background

Bessel Polynomials

The Bessel filter transfer function is based on Bessel polynomials \(B_n(s)\), defined by the recurrence:

\[B_0(s) = 1 \tag{2}\]\[B_1(s) = s + 1 \tag{3}\]\[B_n(s) = (2n-1)B_{n-1}(s) + s^2 B_{n-2}(s) \tag{4}\]

Low-order Bessel polynomials:

Order \(n\)\(B_n(s)\)
1\(s + 1\)
2\(s^2 + 3s + 3\)
3\(s^3 + 6s^2 + 15s + 15\)
4\(s^4 + 10s^3 + 45s^2 + 105s + 105\)

Transfer Function

The \(n\)-th order Bessel lowpass filter transfer function is:

\[H_n(s) = \frac{B_n(0)}{B_n(s/\omega_0)} = \frac{(2n-1)!!}{B_n(s/\omega_0)} \tag{5}\]

where \(\omega_0\) is the normalization frequency and \((2n-1)!! = 1 \cdot 3 \cdot 5 \cdots (2n-1)\) is the double factorial.

Maximally Flat Group Delay

Taylor-expanding the group delay \(\tau(\omega)\) around \(\omega=0\), the first \(2n-1\) derivatives vanish:

\[\tau(\omega) = \tau_0 \left[ 1 + O(\omega^{2n}) \right] \tag{6}\]

This is the meaning of “maximally flat group delay.” Higher order \(n\) extends the flat region to higher frequencies.

Comparison with Other IIR Filters

FilterAmplitude FlatnessTransition SteepnessGroup DelayPrimary Use
BesselPoorestGentlestMaximally FlatWaveform preservation
ButterworthMaximally FlatModerateGoodGeneral-purpose lowpass
Chebyshev IEquirippleSteeper than ButterNonlinearSharp cutoff required
EllipticEquirippleSteepestPoorestMinimum-order design

Bessel filters trade amplitude performance (cutoff sharpness) for optimal phase/group delay characteristics. The gentle transition band means higher orders are needed to meet the same attenuation spec compared to other filters.

Implementation with SciPy

SciPy provides scipy.signal.bessel() for Bessel filter design. By default, group delay is normalized at \(\omega=0\) (norm='delay').

Basic Lowpass Filter Design

import numpy as np
import matplotlib.pyplot as plt
from scipy import signal

# ===== Filter Parameters =====
fs = 1000      # Sampling frequency [Hz]
fc = 100       # Cutoff frequency [Hz]
orders = [2, 4, 6]

fig, axes = plt.subplots(3, 1, figsize=(10, 10))

for N in orders:
    # Bessel lowpass filter (group delay normalization)
    sos = signal.bessel(N, fc, btype='low', fs=fs, norm='delay', output='sos')
    w, h = signal.sosfreqz(sos, worN=4096, fs=fs)
    w_gd, gd = signal.group_delay(
        signal.bessel(N, fc, btype='low', fs=fs, norm='delay'),
        w=4096, fs=fs
    )

    axes[0].plot(w, 20 * np.log10(np.abs(h) + 1e-12), label=f'N={N}')
    axes[1].plot(w, np.degrees(np.unwrap(np.angle(h))), label=f'N={N}')
    axes[2].plot(w_gd, gd, label=f'N={N}')

axes[0].set_xlim(0, 500); axes[0].set_ylim(-60, 5)
axes[0].set_xlabel('Frequency [Hz]'); axes[0].set_ylabel('Magnitude [dB]')
axes[0].set_title('Bessel Filter - Magnitude Response')
axes[0].axvline(fc, color='gray', linestyle='--', alpha=0.5, label=f'fc={fc}Hz')
axes[0].legend(); axes[0].grid(True, alpha=0.3)

axes[1].set_xlim(0, 500)
axes[1].set_xlabel('Frequency [Hz]'); axes[1].set_ylabel('Phase [degrees]')
axes[1].set_title('Bessel Filter - Phase Response')
axes[1].legend(); axes[1].grid(True, alpha=0.3)

axes[2].set_xlim(0, fs / 2); axes[2].set_ylim(0, 0.02)
axes[2].set_xlabel('Frequency [Hz]'); axes[2].set_ylabel('Group Delay [s]')
axes[2].set_title('Bessel Filter - Group Delay')
axes[2].legend(); axes[2].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

Group Delay Comparison with Other Filters

import numpy as np
import matplotlib.pyplot as plt
from scipy import signal

fs = 1000; fc = 100; N = 4

fig, axes = plt.subplots(2, 1, figsize=(10, 8))

filters = {
    'Bessel': signal.bessel(N, fc, btype='low', fs=fs, norm='delay'),
    'Butterworth': signal.butter(N, fc, btype='low', fs=fs),
    'Chebyshev I (rp=1dB)': signal.cheby1(N, 1.0, fc, btype='low', fs=fs),
    'Elliptic (rp=1,rs=60)': signal.ellip(N, 1.0, 60.0, fc, btype='low', fs=fs),
}

colors = ['royalblue', 'tomato', 'green', 'purple']

for (name, (b, a)), color in zip(filters.items(), colors):
    w, h = signal.freqz(b, a, worN=4096, fs=fs)
    w_gd, gd = signal.group_delay((b, a), w=4096, fs=fs)
    axes[0].plot(w, 20 * np.log10(np.abs(h) + 1e-12), label=name, color=color)
    axes[1].plot(w_gd, gd, label=name, color=color)

axes[0].set_xlim(0, 500); axes[0].set_ylim(-80, 5)
axes[0].set_xlabel('Frequency [Hz]'); axes[0].set_ylabel('Magnitude [dB]')
axes[0].set_title(f'Magnitude Response Comparison (N={N})')
axes[0].axvline(fc, color='gray', linestyle='--', alpha=0.5); axes[0].legend(); axes[0].grid(True, alpha=0.3)

axes[1].set_xlim(0, 500); axes[1].set_ylim(0, 0.025)
axes[1].set_xlabel('Frequency [Hz]'); axes[1].set_ylabel('Group Delay [s]')
axes[1].set_title(f'Group Delay Comparison (N={N})')
axes[1].axvline(fc, color='gray', linestyle='--', alpha=0.5); axes[1].legend(); axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

Pulse Waveform Preservation Example

The following code demonstrates the Bessel filter’s key advantage — preserving rectangular pulse shapes:

import numpy as np
import matplotlib.pyplot as plt
from scipy import signal

fs = 1000; fc = 100; N = 4
t = np.linspace(0, 0.1, int(fs * 0.1), endpoint=False)

# Rectangular pulse (5ms width)
x = np.zeros(len(t))
x[(t >= 0.02) & (t < 0.025)] = 1.0

filters = {
    'Bessel': signal.bessel(N, fc, btype='low', fs=fs, norm='delay', output='sos'),
    'Butterworth': signal.butter(N, fc, btype='low', fs=fs, output='sos'),
    'Chebyshev I': signal.cheby1(N, 1.0, fc, btype='low', fs=fs, output='sos'),
    'Elliptic': signal.ellip(N, 1.0, 60.0, fc, btype='low', fs=fs, output='sos'),
}

fig, axes = plt.subplots(len(filters) + 1, 1, figsize=(10, 14), sharex=True)
axes[0].plot(t * 1000, x, 'k', label='Input pulse')
axes[0].set_title('Input Signal (Rectangular Pulse)'); axes[0].legend(); axes[0].grid(True, alpha=0.3)

for idx, (name, sos) in enumerate(filters.items()):
    y = signal.sosfilt(sos, x)
    axes[idx + 1].plot(t * 1000, y, label=name)
    axes[idx + 1].plot(t * 1000, x, 'k--', alpha=0.3, label='Original pulse')
    axes[idx + 1].set_title(f'{name} (N={N})')
    axes[idx + 1].legend(); axes[idx + 1].grid(True, alpha=0.3)

axes[-1].set_xlabel('Time [ms]')
plt.tight_layout()
plt.show()

Running this code confirms that the Bessel filter preserves waveform shape best, while the elliptic filter produces significant ringing.

The norm Parameter

scipy.signal.bessel() accepts a norm parameter for different normalization strategies:

norm valueMeaningUse Case
'delay'Group delay at \(\omega=0\) equals \(1/\omega_c\)Group delay comparison (default)
'critical'-3dB cutoff is at the specified frequencyAmplitude comparison with others
'mag'Passband edge gain matches specified valueSpecific gain requirements

To compare with other filters at the same -3dB cutoff, use norm='critical':

from scipy import signal

fs = 1000; fc = 100; N = 4

# -3dB cutoff at fc=100 Hz
sos_bessel = signal.bessel(N, fc, btype='low', fs=fs, norm='critical', output='sos')
sos_butter = signal.butter(N, fc, btype='low', fs=fs, output='sos')

When to Choose a Bessel Filter

ScenarioRecommended Filter
Preserving pulse waveform shapeBessel filter
Minimizing phase distortion in audio signalsBessel filter
Maximizing transition band steepnessElliptic filter
Maximally flat amplitude response in passbandButterworth filter
Minimizing filter order for given specElliptic filter
Sharp cutoff with some ripple toleranceChebyshev filter

References

  • Thomson, W. E. (1952). Delay networks having maximally flat frequency characteristics. Proceedings of the IEE - Part III: Radio and Communication Engineering, 96(44), 487–490.
  • Proakis, J. G., & Manolakis, D. G. (2007). Digital Signal Processing (4th ed.). Pearson.
  • SciPy scipy.signal.bessel documentation