Notch Filter Design and Python Implementation: Removing Power Line Noise

A practical guide to notch (band-reject) filter design for removing 50/60 Hz power line noise, with IIR notch filter theory, pole-zero analysis, and Python implementation using scipy.signal.iirnotch.

Introduction

When measuring signals with electrocardiograms (ECG), electroencephalograms (EEG), accelerometers, or audio equipment, power line noise at 50 Hz (Japan, Europe) or 60 Hz (US) frequently contaminates the data. This noise is concentrated at specific frequencies, so a simple lowpass filter often cannot solve the problem. For example, the R-wave in an ECG contains high-frequency components, and applying a lowpass filter to remove noise would also degrade the signal itself.

A notch filter selectively removes only a narrow frequency band while passing all other frequency components unchanged. In this article, we explain the theoretical background of IIR notch filters and demonstrate practical power line noise removal with Python using SciPy.

For filter fundamentals, see FIR vs IIR Filters. For frequency analysis basics, see FFT Algorithm and Python Implementation.

Basic Concepts of Notch Filters

A notch filter is an extreme case of a band-reject filter that removes only a very narrow frequency band. Its frequency response has a sharp “notch” at the target frequency while maintaining nearly flat gain elsewhere.

The two key design parameters are:

  • Notch frequency \(f_0\): the center frequency to reject (e.g., 50 Hz, 60 Hz)
  • Quality factor \(Q\): controls the sharpness of the notch

\(Q\) is defined as the ratio of the notch frequency to the bandwidth:

\[Q = \frac{f_0}{\Delta f} \tag{1}\]

where \(\Delta f\) is the \(-3\) dB bandwidth. A larger \(Q\) produces a narrower notch, enabling precise removal of only the target frequency.

IIR Notch Filter Transfer Function

The transfer function of a 2nd-order IIR notch filter is expressed as:

\[H(z) = \frac{1 - 2\cos(\omega_0)z^{-1} + z^{-2}}{1 - 2r\cos(\omega_0)z^{-1} + r^2 z^{-2}} \tag{2}\]

where \(\omega_0 = 2\pi f_0 / f_s\) is the normalized angular frequency and \(r\) is the pole radius (\(0 < r < 1\)).

Role of the Numerator (Zeros)

The numerator zeros are placed on the unit circle at \(z = e^{\pm j\omega_0}\). Since the zeros lie exactly on the unit circle, the transfer function gain at frequency \(\omega_0\) becomes exactly zero. This is the source of the notch filter’s perfect rejection characteristic.

Role of the Denominator (Poles)

The denominator poles are located at \(z = r \cdot e^{\pm j\omega_0}\), placed inside the unit circle at radius \(r < 1\). The poles are at the same angle as the zeros but at a smaller radius. The closer \(r\) is to 1, the closer the poles are to the zeros, resulting in a narrower notch (higher \(Q\)).

Pole-Zero Placement and Quality Factor

The relationship between the pole radius \(r\) and the quality factor \(Q\) is approximately:

\[Q \approx \frac{\omega_0}{2(1-r)} \tag{3}\]

Therefore, the bandwidth is:

\[\Delta f = \frac{f_0}{Q} \approx \frac{2(1-r) \cdot f_s}{2\pi} \tag{4}\]

The following code visualizes the pole-zero placement.

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

# --- Parameters ---
fs = 1000       # Sampling frequency [Hz]
f0 = 50         # Notch frequency [Hz]
Q = 30          # Quality factor

# --- Design notch filter ---
b, a = signal.iirnotch(f0, Q, fs)

# --- Compute zeros and poles ---
zeros, poles, _ = signal.tf2zpk(b, a)

# --- Pole-zero plot ---
fig, ax = plt.subplots(figsize=(6, 6))

# Draw unit circle
theta = np.linspace(0, 2 * np.pi, 200)
ax.plot(np.cos(theta), np.sin(theta), 'k-', linewidth=0.5)

# Plot zeros and poles
ax.scatter(zeros.real, zeros.imag, marker='o', s=100,
           facecolors='none', edgecolors='blue', linewidths=2, label='Zeros')
ax.scatter(poles.real, poles.imag, marker='x', s=100,
           color='red', linewidths=2, label='Poles')

ax.set_xlabel('Real')
ax.set_ylabel('Imaginary')
ax.set_title(f'Pole-Zero Plot (f0={f0} Hz, Q={Q})')
ax.set_aspect('equal')
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

# Display pole radius
print(f"Pole radius r = {np.abs(poles[0]):.6f}")
print(f"Zero radius = {np.abs(zeros[0]):.6f}")

In this plot, the zeros (circles) lie on the unit circle while the poles (crosses) are inside it. Both are at nearly the same angle, which ensures the gain remains close to 1 (0 dB) at frequencies away from the notch.

Designing with scipy.signal.iirnotch

SciPy’s scipy.signal.iirnotch provides a concise way to design notch filters.

from scipy.signal import iirnotch

# Parameters
f0 = 50   # Notch frequency [Hz]
Q = 30    # Quality factor
fs = 1000 # Sampling frequency [Hz]

# Compute filter coefficients
b, a = iirnotch(f0, Q, fs)
  • f0: frequency to reject [Hz]
  • Q: quality factor (larger = narrower notch)
  • fs: sampling frequency [Hz]
  • Returns: numerator coefficients b, denominator coefficients a (2nd-order IIR filter)

The following code compares the frequency response for different \(Q\) values.

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

fs = 1000
f0 = 50

fig, ax = plt.subplots(figsize=(10, 6))

for Q in [5, 15, 30, 60]:
    b, a = signal.iirnotch(f0, Q, fs)
    w, h = signal.freqz(b, a, worN=4096, fs=fs)
    ax.plot(w, 20 * np.log10(np.maximum(np.abs(h), 1e-12)),
            label=f'Q = {Q}')

ax.set_xlabel('Frequency [Hz]')
ax.set_ylabel('Magnitude [dB]')
ax.set_title('Notch Filter Frequency Response (f0 = 50 Hz)')
ax.set_xlim(0, 200)
ax.set_ylim(-60, 5)
ax.axvline(f0, color='gray', linestyle='--', alpha=0.5, label=f'f0 = {f0} Hz')
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

A larger \(Q\) produces a narrower and sharper notch, enabling precise removal of only the target frequency.

Practical Implementation: Power Line Noise Removal

Here is a complete code example for removing power line noise. In addition to the 50 Hz fundamental, harmonics at 100 Hz and 150 Hz are also removed using cascaded notch filters.

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

# --- Generate test signal ---
np.random.seed(42)
fs = 1000           # Sampling frequency [Hz]
T = 2.0             # Signal duration [s]
t = np.arange(0, T, 1/fs)
N = len(t)

# Useful signal: 10 Hz and 30 Hz sine waves
useful_signal = np.sin(2 * np.pi * 10 * t) + 0.5 * np.sin(2 * np.pi * 30 * t)

# Power line noise: 50 Hz fundamental + 100 Hz, 150 Hz harmonics
power_noise = (0.8 * np.sin(2 * np.pi * 50 * t)
               + 0.4 * np.sin(2 * np.pi * 100 * t)
               + 0.2 * np.sin(2 * np.pi * 150 * t))

# Random noise
random_noise = 0.3 * np.random.randn(N)

# Observed signal
observed = useful_signal + power_noise + random_noise

# --- Cascaded notch filter design ---
Q = 30
notch_freqs = [50, 100, 150]  # Fundamental + harmonics

# Combine filter coefficients in SOS form
sos_list = []
for f_notch in notch_freqs:
    b, a = signal.iirnotch(f_notch, Q, fs)
    sos = signal.tf2sos(b, a)
    sos_list.append(sos[0])

sos_cascade = np.array(sos_list)

# --- Apply filter (zero-phase filtering) ---
filtered = signal.sosfiltfilt(sos_cascade, observed)

# --- Compute frequency spectrum ---
freqs = np.fft.rfftfreq(N, 1/fs)
spectrum_before = np.abs(np.fft.rfft(observed)) / N
spectrum_after = np.abs(np.fft.rfft(filtered)) / N

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

# (a) Time domain: before filtering
axes[0].plot(t, observed, alpha=0.7, label='Observed')
axes[0].plot(t, useful_signal, 'k--', alpha=0.5, label='True signal')
axes[0].set_xlabel('Time [s]')
axes[0].set_ylabel('Amplitude')
axes[0].set_title('Before Notch Filtering')
axes[0].set_xlim(0, 0.5)
axes[0].legend()
axes[0].grid(True, alpha=0.3)

# (b) Time domain: after filtering
axes[1].plot(t, filtered, alpha=0.7, label='Filtered')
axes[1].plot(t, useful_signal, 'k--', alpha=0.5, label='True signal')
axes[1].set_xlabel('Time [s]')
axes[1].set_ylabel('Amplitude')
axes[1].set_title('After Notch Filtering (50, 100, 150 Hz)')
axes[1].set_xlim(0, 0.5)
axes[1].legend()
axes[1].grid(True, alpha=0.3)

# (c) Frequency domain: before vs after
axes[2].plot(freqs, 20 * np.log10(np.maximum(spectrum_before, 1e-12)),
             alpha=0.7, label='Before')
axes[2].plot(freqs, 20 * np.log10(np.maximum(spectrum_after, 1e-12)),
             alpha=0.7, label='After')
for f_notch in notch_freqs:
    axes[2].axvline(f_notch, color='red', linestyle='--', alpha=0.3)
axes[2].set_xlabel('Frequency [Hz]')
axes[2].set_ylabel('Magnitude [dB]')
axes[2].set_title('Frequency Spectrum: Before vs After')
axes[2].set_xlim(0, 200)
axes[2].legend()
axes[2].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

In the time domain, the filtered signal closely matches the original useful signal (10 Hz + 30 Hz). In the frequency domain, the peaks at 50 Hz, 100 Hz, and 150 Hz are removed while the 10 Hz and 30 Hz components are preserved.

Design Considerations

Choosing the Quality Factor Q

  • Q too large: The notch becomes extremely narrow and cannot accommodate slight frequency variations. Transient ringing also becomes more pronounced.
  • Q too small: The notch becomes too wide, removing useful signal components near the target frequency.

In practice, \(Q = 20 \sim 50\) is suitable for power line noise removal.

Zero-Phase vs Causal Filtering

  • Offline processing: Use scipy.signal.sosfiltfilt (zero-phase). Forward and reverse filtering eliminates phase distortion.
  • Real-time processing: Use scipy.signal.sosfilt (causal filter). Phase delay occurs since future data cannot be referenced, but sequential processing is possible.

Stability

An IIR notch filter is always stable when the pole radius \(r < 1\). Filters designed with scipy.signal.iirnotch automatically satisfy this condition, so stability does not need to be verified separately.

Notch Filter vs Bandstop Filter

A notch filter is a special case of a band-reject filter, but the two serve different purposes.

PropertyNotch FilterBandstop Filter
Rejection bandVery narrow (single frequency)Relatively wide band
OrderAchievable with 2nd orderMay require higher order
Use casePower line noise, specific interferersRemoving an entire frequency band
Designiirnotchbutter + btype='bandstop'

The following code compares the frequency responses of both approaches.

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

fs = 1000
f0 = 50

# Notch filter (Q=30)
b_notch, a_notch = signal.iirnotch(f0, Q=30, fs=fs)
w_notch, h_notch = signal.freqz(b_notch, a_notch, worN=4096, fs=fs)

# Bandstop filter (Butterworth 4th order, 40-60 Hz rejection)
sos_bs = signal.butter(4, [40, 60], btype='bandstop', fs=fs, output='sos')
w_bs, h_bs = signal.sosfreqz(sos_bs, worN=4096, fs=fs)

fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(w_notch, 20 * np.log10(np.maximum(np.abs(h_notch), 1e-12)),
        label='Notch (Q=30)')
ax.plot(w_bs, 20 * np.log10(np.maximum(np.abs(h_bs), 1e-12)),
        label='Bandstop Butterworth (40-60 Hz)')
ax.set_xlabel('Frequency [Hz]')
ax.set_ylabel('Magnitude [dB]')
ax.set_title('Notch Filter vs Bandstop Filter')
ax.set_xlim(0, 200)
ax.set_ylim(-60, 5)
ax.axvline(f0, color='gray', linestyle='--', alpha=0.5)
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

The notch filter removes only 50 Hz precisely, while the bandstop filter suppresses the entire 40-60 Hz band. Use a notch filter when the frequency to remove is well-defined, and a bandstop filter when a broader band needs to be suppressed.

Summary

  • A notch filter is a special case of a band-reject filter that removes only a specific frequency, ideal for power line noise removal
  • The 2nd-order IIR transfer function uses zeros on the unit circle to achieve perfect rejection at the notch frequency, and poles inside the unit circle to control the notch width
  • The quality factor \(Q\) determines notch sharpness; \(Q = 20 \sim 50\) is practical for power line noise removal
  • scipy.signal.iirnotch provides a concise design interface, and cascaded connections handle harmonics (100 Hz, 150 Hz, etc.)
  • Use sosfiltfilt (zero-phase) for offline processing and sosfilt (causal) for real-time processing

References