Line noise: notch, spectral fit and a spatial (ZapLine-style) method at 60 Hz and 50 Hz, and a hardware notch hole

nb-1-6-line-noise Level 1 · Signal Fundamentals ~4 min Used in L1.6 · Line noise

Downloads from ds-iowapd, ds-eegbci, ds-dortmund, ds-arithmetic when you run it.

Download the notebook (.ipynb) Outputs below are the ones stored when it was executed — you do not need to run anything to read it.

nb-1-6-line-noise · Line noise (L1.6)

Lesson L1.6 · Level 1 · Status draft — for expert review; uncertain points carry TODO(confirm).

What you will do

  1. Find the mains line and every harmonic below the Nyquist frequency in a 500 Hz recording with 60 Hz mains (ds-iowapd).
  2. Remove them three ways — an FIR notch (raw.notch_filter, MNE defaults), a spectral fit (method='spectrum_fit': sinusoids fitted per window and subtracted) and a minimal spatial, ZapLine-style projection (helpers_l1.zapline_like) — and compare them with one residual-power metric and one collateral-damage metric.
  3. See why the same request is different at 160 Hz (ds-eegbci: 60 Hz is in band, its harmonics are above the 80 Hz Nyquist frequency), on a 50 Hz recording (ds-dortmund, 1000 Hz), and on a recording whose hardware already carved a notch hole (ds-arithmetic).
  4. Report the residual power ratio for the L1.6 exercise, with the method stated.

Data (facts from the site's dataset catalog)

  • ds-iowapd — Iowa Parkinson's disease resting EEG (Anjum et al., 2024, DOI 10.1038/s41531-023-00602-0); OpenNeuro ds004584 v1.0.0, DOI 10.18112/openneuro.ds004584.v1.0.0, CC0. 64-channel Brain Vision actiCAP, 500 Hz, 0.1 Hz online high-pass, Pz online reference, eyes-open rest, 60 Hz mains; the authors removed 60, 180 and 200 Hz components in their analysis. sub-007 (one .set/.fdt pair, ~30 MB) — chosen because its channel-mean spectrum shows 60, 120, 180 and 240 Hz clearly; sub-001 shows only a weak 60 Hz line (checked on the first minute of sub-001 … sub-008).
  • ds-eegbci — EEGMMIDB (Schalk et al., 2004), PhysioNet DOI 10.13026/C28G6P, ODC-By 1.0; 64 channels, 160 Hz, no hardware filters, 60 Hz mains. S001 R01 (eyes open, ~1 min).
  • ds-dortmund — Dortmund Vital Study resting EEG (Wascher et al., 2024, DOI 10.1038/s41597-024-03797-w); OpenNeuro ds005385 v1.0.3, DOI 10.18112/openneuro.ds005385.v1.0.3, CC0. BrainAmp DC, 64 channels, 1000 Hz, 250 Hz online low-pass, no online high-pass, FCz online reference; 50 Hz mains (spec §6). sub-001, session 1, eyes closed, before the cognitive battery (acq-pre; one EDF, ~24 MB). ds-srm (OpenNeuro ds003775, CC0, BioSemi at 1024 Hz, ~31 MB per subject) is the alternative 50 Hz source and is not downloaded here.
  • ds-arithmetic — EEGMAT (Zyma et al., 2019, DOI 10.3390/data4010014); PhysioNet DOI 10.13026/C2JQ1P, ODC-By 1.0; 19 channels, nominal 500 Hz, hardware 50 Hz notch and a ~30 Hz low-pass, ICA applied upstream. Subject00_1.edf (~4 MB).
In [1]:
# Setup: dependencies, the shared helpers, non-interactive plotting.
import importlib.util
import subprocess
import sys
import warnings
from pathlib import Path

# 1. Dependencies are pinned in notebooks/requirements.txt.  Nothing is installed
#    when the pinned stack is already present (local runs, CI); a fresh Colab or
#    Binder kernel installs it once.  On Colab, run from a clone of the repository
#    so that notebooks/_shared/ is available (repository URL: TODO(confirm), spec
#    section 13 item 3).
_needed = ("mne", "scipy", "matplotlib", "pooch")
_missing = [p for p in _needed if importlib.util.find_spec(p) is None]
if _missing:
    _req = next((d / "requirements.txt" for d in (Path.cwd(), *Path.cwd().parents)
                 if (d / "requirements.txt").exists()), None)
    _cmd = [sys.executable, "-m", "pip", "install", "-q"]
    _cmd += ["-r", str(_req)] if _req else ["mne==1.10.2", "pooch>=1.8"]
    subprocess.check_call(_cmd)

# 2. Shared helpers (notebooks/_shared/helpers.py and helpers_l1.py), located
#    relative to the working directory -- notebooks/<level>/ or notebooks/ --
#    never through an absolute path.
_shared = next((d / "_shared" for d in (Path.cwd(), *Path.cwd().parents)
                if (d / "_shared" / "helpers_l1.py").exists()), None)
if _shared is None:
    raise FileNotFoundError("start the kernel in notebooks/L1/ (or notebooks/) so that _shared/helpers_l1.py is found")
sys.path.insert(0, str(_shared))
import helpers
import helpers_l1

# 3. Plotting: Jupyter's default inline backend renders static PNGs through Agg
#    (no windows, nothing blocks); outside Jupyter the helpers select Agg.  Every
#    MNE figure is requested with show=False, and plt.show() renders each cell's
#    figures in place.
import matplotlib.pyplot as plt
import numpy as np
import mne
import pooch

mne.viz.set_browser_backend("matplotlib", verbose=False)
mne.set_log_level("WARNING")
pooch.get_logger().setLevel("WARNING")   # no download chatter (it would print local paths)
plt.rcParams["figure.dpi"] = 72
print(f"MNE {mne.__version__}; helpers and helpers_l1 imported from notebooks/_shared; downloads go to the course "
      "data directory (EEG_COURSE_DOWNLOADS or EEG_COURSE_DATA if set, else MNE's data directory under eeg-course/)")
MNE 1.10.2; helpers and helpers_l1 imported from notebooks/_shared; downloads go to the course data directory (EEG_COURSE_DOWNLOADS or EEG_COURSE_DATA if set, else MNE's data directory under eeg-course/)

1. Find the line and its harmonics (ds-iowapd, 500 Hz, 60 Hz mains)

The PSD is estimated with Welch's method (4-s Hann segments, 50 % overlap, over the whole recording; resolution 0.25 Hz) and averaged over channels. Sharp peaks are found algorithmically (helpers_l1.line_peaks: at least 6 dB above their surroundings and at most 3 Hz wide); the ones at multiples of 60 Hz are the harmonics we will remove. Line noise is not the same on every channel — the topography of the 60 Hz prominence shows where the pick-up is worst.

In [2]:
from IPython.display import HTML, display
from scipy import signal

DATASET, SUBJECT = "ds-iowapd", "sub-007"
raw_pd, pd_paths = helpers_l1.load_iowapd(SUBJECT, return_paths=True)
SF = raw_pd.info["sfreq"]
sidecar = helpers_l1.read_sidecar(pd_paths["eeg.json"])
rep = helpers.first_look_report(raw_pd, DATASET)
display(HTML(helpers.report_html({k: rep[k] for k in ("description", "sfreq_hz", "nyquist_hz", "n_channels", "duration_s",
                                                       "highpass_hz_in_header", "lowpass_hz_in_header", "bads", "amplitude_uV")},
                                 f"First look: {DATASET} {SUBJECT}")))
print("BIDS sidecar:", {k: sidecar.get(k) for k in ("PowerLineFrequency", "EEGReference", "SamplingFrequency", "SoftwareFilters")})

WELCH = dict(seg_s=4.0, overlap=0.5, window="hann")
f_pd, psd_pd, ch_pd = helpers_l1.welch_raw(raw_pd, **WELCH)
peaks = helpers_l1.line_peaks(f_pd, psd_pd, fmin=5, min_prominence_db=6)
print("sharp peaks in the channel-mean PSD: " + ", ".join(f"{p['freq_hz']:.2f} Hz (+{p['prominence_db']:.0f} dB, {p['width_hz']:.2f} Hz wide)" for p in peaks))
MAINS = int(sidecar.get("PowerLineFrequency", helpers_l1.DATASETS_L1[DATASET]["mains_hz"]))
candidates = [k * MAINS for k in range(1, int(SF / 2 // MAINS) + 1)]
LINES = [f for f in candidates if any(abs(p["freq_hz"] - f) < 1.0 for p in peaks)]
print(f"multiples of {MAINS} Hz below the {SF / 2:g} Hz Nyquist frequency: {candidates}; found as sharp peaks: {LINES}")
print(f"200 Hz (removed by the authors in their analysis): {helpers_l1.peak_prominence_db(f_pd, psd_pd, 200.0):+.1f} dB over its flanks here -- not a sharp peak in this subject")
prom60 = np.array([helpers_l1.peak_prominence_db(f_pd, p, 60.0) for p in psd_pd])
CH = ch_pd[int(np.argmax(prom60))]
print(f"60 Hz prominence per channel: median +{np.median(prom60):.0f} dB, strongest {CH} +{prom60.max():.0f} dB, weakest {ch_pd[int(np.argmin(prom60))]} +{prom60.min():.0f} dB")

fig = plt.figure(figsize=(13, 4))
ax1 = fig.add_axes([0.06, 0.15, 0.6, 0.75])
helpers_l1.plot_spectra({f"mean of {len(ch_pd)} channels": (f_pd, psd_pd), f"{CH}": (f_pd, psd_pd[ch_pd.index(CH)], dict(lw=0.7, alpha=0.8))},
                        ax=ax1, lines=LINES, title=f"{DATASET} {SUBJECT}: the line and its harmonics below Nyquist")
ax2 = fig.add_axes([0.7, 0.05, 0.28, 0.9])
helpers.plot_topomap_values(raw_pd, prom60, unit="dB over flanks", title="60 Hz prominence", ax=ax2)
plt.show()   # render the static figure(s) of this cell inline
cached: ds004584/sub-007/eeg/sub-007_task-Rest_eeg.set (0.5 MiB)
cached: ds004584/sub-007/eeg/sub-007_task-Rest_eeg.fdt (28.8 MiB)
cached: ds004584/sub-007/eeg/sub-007_task-Rest_eeg.json (0.0 MiB)
cached: ds004584/sub-007/eeg/sub-007_task-Rest_channels.tsv (0.0 MiB)

First look: ds-iowapd sub-007

descriptionds-iowapd sub-007 task-Rest (OpenNeuro ds004584 v1.0.0, CC0; 500 Hz, 60 Hz mains; 0.1 Hz online high-pass; Pz online reference (flat)); flat channels marked bad: none
sfreq_hz500
nyquist_hz250
n_channels63
duration_s239.8
highpass_hz_in_header0
lowpass_hz_in_header250
bads
amplitude_uVmedian_channel_std: 143.5
max_channel_std: 281.9
max_abs: 930.4
flattest_channel: P2
noisiest_channel: TP9
BIDS sidecar: {'PowerLineFrequency': 60, 'EEGReference': 'Pz', 'SamplingFrequency': 500, 'SoftwareFilters': 'n/a'}
sharp peaks in the channel-mean PSD: 60.00 Hz (+22 dB, 0.66 Hz wide), 120.00 Hz (+7 dB, 0.48 Hz wide), 180.00 Hz (+17 dB, 0.63 Hz wide), 240.00 Hz (+6 dB, 0.48 Hz wide)
multiples of 60 Hz below the 250 Hz Nyquist frequency: [60, 120, 180, 240]; found as sharp peaks: [60, 120, 180, 240]
200 Hz (removed by the authors in their analysis): +2.2 dB over its flanks here -- not a sharp peak in this subject
60 Hz prominence per channel: median +17 dB, strongest AFz +39 dB, weakest TP10 +0 dB
Figure 1 of notebook nb-1-6-line-noise, an output plot. The text around it states what it shows and the units of every axis.
In [3]:
def residual_table(rows, f, psd_before, lines, half_width=1.0):
    """Print residual-power and collateral metrics for {label: psd_after}."""
    print(f"{'method':46s} {'ratio':>7s} {'excess':>7s} {'flank':>6s} {'1-45 Hz':>8s}   per-line ratio")
    out = {}
    df = f[1] - f[0]
    bb = (f >= 1) & (f <= 45)
    p0 = psd_before.mean(axis=0)[bb].sum() * df
    for label, psd_after in rows.items():
        r = helpers_l1.residual_power_ratio(f, psd_before, psd_after, lines, half_width=half_width)
        r["broadband_ratio"] = psd_after.mean(axis=0)[bb].sum() * df / p0
        out[label] = r
        print(f"{label:46s} {r['ratio']:7.4f} {r['ratio_excess']:7.3f} {r['flank_ratio']:6.3f} {r['broadband_ratio']:8.4f}   "
              + " ".join(f"{v:.3f}" for v in r["ratio_per_line"]))
    return out


def trace_panel(ax, sig_uV, sf, t0, dur, title):
    n0, n1 = int(t0 * sf), int((t0 + dur) * sf)
    ax.plot(np.arange(n0, n1) / sf, sig_uV[n0:n1], "k", lw=0.8)
    ax.set(title=title, xlabel="Time (s)", ylabel="uV")
    ax.grid(alpha=0.3)


print("Metric definitions (used throughout):")
print("  ratio        = line-band power after / before, summed over +/-1 Hz around each line (1 = nothing removed, 0 = nothing left, EEG included)")
print("  excess       = the same for the power ABOVE the local background (median PSD 2-5 Hz on either side); 0 = exactly the background is left, < 0 = a hole")
print("  flank        = power 2-5 Hz on either side of each line, after / before (collateral damage next to the lines; 1 = none)")
print("  1-45 Hz      = broadband power after / before (collateral damage in the EEG band; 1 = none)")
Metric definitions (used throughout):
  ratio        = line-band power after / before, summed over +/-1 Hz around each line (1 = nothing removed, 0 = nothing left, EEG included)
  excess       = the same for the power ABOVE the local background (median PSD 2-5 Hz on either side); 0 = exactly the background is left, < 0 = a hole
  flank        = power 2-5 Hz on either side of each line, after / before (collateral damage next to the lines; 1 = none)
  1-45 Hz      = broadband power after / before (collateral damage in the EEG band; 1 = none)

2. Method 1 — FIR notch: raw.notch_filter(freqs)

MNE's default notch is a zero-phase FIR band-stop at each frequency: stop-band width notch_widths = freqs / 200 (0.3 Hz at 60 Hz, 1.2 Hz at 240 Hz), 1 Hz transition bands, 'auto' length. A notch is a filter like any other (L1.5): MNE sets its length from the transition bandwidth, so the impulse response lasts a good fraction of a second and rings around transients whatever the stop-band width; the width decides how much of the neighbouring spectrum goes with the line. The response is measured below by filtering an impulse; a deliberately wide notch (4 Hz) shows what "too wide" costs next to the line.

In [4]:
raw_fir = raw_pd.copy().notch_filter(LINES, picks="eeg", verbose=False)                         # MNE defaults
raw_wide = raw_pd.copy().notch_filter(LINES, picks="eeg", notch_widths=4.0, verbose=False)      # too wide, for contrast

imp = np.zeros(int(30 * SF))
imp[len(imp) // 2] = 1.0
h_notch = mne.filter.notch_filter(imp[None], SF, LINES, verbose=False)[0]          # effective (zero-phase) impulse response
h_wide = mne.filter.notch_filter(imp[None], SF, LINES, notch_widths=4.0, verbose=False)[0]
w, H = signal.freqz(h_notch, worN=1 << 17, fs=SF)
_, Hw = signal.freqz(h_wide, worN=1 << 17, fs=SF)
gain = 20 * np.log10(np.maximum(np.abs(H), 1e-12))
gain_w = 20 * np.log10(np.maximum(np.abs(Hw), 1e-12))
for lab, g in (("default", gain), ("4 Hz wide", gain_w)):
    m = (w > 55) & (w < 65)
    below3 = w[m][g[m] <= -3]
    print(f"notch {lab:9s}: gain at 60 Hz {g[np.argmin(np.abs(w - 60))]:.0f} dB; -3 dB width around 60 Hz {below3.max() - below3.min():.2f} Hz; "
          f"impulse response above 1 % of its peak spans {np.ptp(np.where(np.abs(h_notch if lab == 'default' else h_wide) > 0.01 * np.abs(h_notch if lab == 'default' else h_wide).max())[0]) / SF:.2f} s")

fig, axes = plt.subplots(1, 2, figsize=(12, 3.6))
axes[0].plot(w, gain, label="MNE default (notch_widths = f/200)")
axes[0].plot(w, gain_w, label="notch_widths = 4 Hz")
axes[0].set(xlim=(50, 70), ylim=(-70, 5), xlabel="Frequency (Hz)", ylabel="Gain (dB)", title="Notch at 60 Hz: magnitude response, zoom (dB)")
axes[0].grid(alpha=0.3); axes[0].legend(fontsize=8)
tt = (np.arange(len(imp)) - len(imp) // 2) / SF
axes[1].plot(tt, h_notch, lw=0.6, label="MNE default")
axes[1].plot(tt, h_wide, lw=0.6, label="4 Hz wide")
axes[1].set(xlim=(-4, 4), xlabel="Time (s)", ylabel="Impulse response (a.u.)", title="Notch impulse responses (zero-phase, centred on 0)")
axes[1].grid(alpha=0.3); axes[1].legend(fontsize=8)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
notch default  : gain at 60 Hz -57 dB; -3 dB width around 60 Hz 0.92 Hz; impulse response above 1 % of its peak spans 0.80 s
notch 4 Hz wide: gain at 60 Hz -61 dB; -3 dB width around 60 Hz 4.62 Hz; impulse response above 1 % of its peak spans 0.73 s
Figure 2 of notebook nb-1-6-line-noise, an output plot. The text around it states what it shows and the units of every axis.

3. Method 2 — spectral fit: raw.notch_filter(freqs, method='spectrum_fit')

Instead of carving a stop band, this method estimates, in each window (filter_length='10s'), which sinusoids are significant (a multitaper F-test at p_value=0.05), fits their amplitude and phase, and subtracts them. Only the deterministic line is taken out; the EEG under it stays, so no hole appears. With freqs=None the frequencies are detected automatically — convenient, and worth checking, because weak harmonics may not pass the test.

In [5]:
import time

t0 = time.time()
raw_sf = raw_pd.copy().notch_filter(LINES, method="spectrum_fit", picks="eeg", verbose=False)
t_sf = time.time() - t0
t0 = time.time()
raw_sfa = raw_pd.copy().notch_filter(None, method="spectrum_fit", picks="eeg", verbose=False)   # frequencies detected automatically
t_sfa = time.time() - t0
print(f"spectrum_fit on {len(ch_pd)} channels x {raw_pd.times[-1]:.0f} s: {t_sf:.1f} s with the frequencies given, {t_sfa:.1f} s with automatic detection")
spectrum_fit on 63 channels x 240 s: 3.2 s with the frequencies given, 1.9 s with automatic detection

4. Method 3 — a spatial, ZapLine-style projection

Line noise reaches every electrode with a fixed spatial pattern (or a few). ZapLine (de Cheveigné, 2020; decheveigne2020 in the reading list) exploits that: split the data into a smooth part — a moving average of fs / f_line samples, whose frequency response has zeros at the line and every harmonic — and a remainder that carries the line; find, by joint diagonalisation (DSS), the spatial components of the remainder with the largest share of their variance inside narrow bands around the harmonics; project those components out; add the smooth part back. helpers_l1.zapline_like is a minimal teaching implementation of that idea (it handles the non-integer period 500/60 by interpolating between two integer-length averages, and picks the number of components automatically: those whose score stands out from the rest and holds at least 10 % of the component's variance). TODO(confirm) against the reference implementation before use beyond this course.

In [6]:
x_pd = helpers_l1.to_uV(raw_pd)                                   # (n_channels, n_times) in uV, bads excluded
t0 = time.time()
x_zl, zl = helpers_l1.zapline_like(x_pd, SF, float(MAINS))
t_zl = time.time() - t0
print(f"ZapLine-style: period {zl['period_samples']:.2f} samples, {zl['n_harmonics']} harmonics below Nyquist in the bias bands, "
      f"{zl['n_components']} components; removed {zl['n_removed']} (scores: {', '.join(f'{s:.2f}' for s in zl['scores'][:zl['n_removed'] + 3])} ...); {t_zl:.1f} s")
raw_zl = raw_pd.copy()
raw_zl._data[mne.pick_types(raw_pd.info, eeg=True, exclude="bads")] = x_zl * 1e-6

fig, axes = plt.subplots(1, 2, figsize=(12, 3.6))
n_show = 15
colors = ["tab:orange" if i < zl["n_removed"] else "tab:blue" for i in range(n_show)]
axes[0].bar(np.arange(1, n_show + 1), zl["scores"][:n_show], color=colors)
axes[0].set(xlabel="Component (sorted)", ylabel="Share of variance in the line bands", title=f"ZapLine-style scores; orange = removed ({zl['n_removed']}) (fraction, 0-1)")
axes[0].grid(alpha=0.3, axis="y")
removed_rms = zl["removed"].std(axis=1)
helpers.plot_topomap_values(raw_pd, 20 * np.log10(removed_rms / removed_rms.max()), unit="dB re max", title="Removed line signal (RMS per channel)", ax=axes[1])
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
ZapLine-style: period 8.33 samples, 4 harmonics below Nyquist in the bias bands, 63 components; removed 4 (scores: 0.99, 0.94, 0.49, 0.25, 0.07, 0.04, 0.03 ...); 0.4 s
Figure 3 of notebook nb-1-6-line-noise, an output plot. The text around it states what it shows and the units of every axis.

5. Compare: residual line power and collateral damage

One table, two figures. ratio is the line-band power that remains (the exercise's metric); excess says whether what remains is the background (0), still some line (> 0) or a hole (< 0); flank and 1-45 Hz measure collateral damage next to the lines and across the EEG band. Then the spectra (full band and a zoom on 60 Hz) and the raw trace of the strongest channel over 0.3 s.

In [7]:
after = {}
for label, r in (("FIR notch, MNE defaults", raw_fir), ("FIR notch, 4 Hz wide", raw_wide),
                 ("spectrum_fit, frequencies given", raw_sf), ("spectrum_fit, automatic frequencies", raw_sfa)):
    after[label] = helpers_l1.welch_raw(r, **WELCH)[1]
after[f"ZapLine-style, {zl['n_removed']} components (auto)"] = helpers_l1.welch_psd(x_zl, SF, **WELCH)[1]
for n in (1, 2):
    x_n, _ = helpers_l1.zapline_like(x_pd, SF, float(MAINS), n_remove=n)
    after[f"ZapLine-style, {n} component{'s' if n > 1 else ''}"] = helpers_l1.welch_psd(x_n, SF, **WELCH)[1]
results_pd = residual_table(after, f_pd, psd_pd, LINES)

show = {"original": (f_pd, psd_pd)}
show.update({k: (f_pd, v) for k, v in after.items() if k in ("FIR notch, MNE defaults", "spectrum_fit, frequencies given") or k.startswith("ZapLine-style,") and "(auto)" in k})
fig, axes = plt.subplots(1, 2, figsize=(13, 4))
helpers_l1.plot_spectra(show, ax=axes[0], lines=LINES, title=f"{SUBJECT}, channel mean: three methods, full band")
helpers_l1.plot_spectra(show, ax=axes[1], xlim=(55, 65), title="Zoom on the 60 Hz line")
fig.tight_layout()

i_ch = ch_pd.index(CH)
traces = {"original": x_pd[i_ch], "FIR notch (defaults)": raw_fir.get_data(picks=CH)[0] * 1e6,
          "spectrum_fit": raw_sf.get_data(picks=CH)[0] * 1e6, "ZapLine-style": x_zl[i_ch]}
fig, axes = plt.subplots(len(traces), 1, figsize=(11, 8), sharex=True, sharey=True)
for ax, (label, y) in zip(axes, traces.items()):
    trace_panel(ax, y, SF, 100.0, 0.3, f"{CH}, {label} (uV)")
fig.suptitle(f"{SUBJECT} {CH}: 0.3 s of the raw trace before and after each method (uV)", y=1.0)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
method                                           ratio  excess  flank  1-45 Hz   per-line ratio
FIR notch, MNE defaults                         0.0327  -0.028  1.000   1.0000   0.024 0.294 0.036 0.129
FIR notch, 4 Hz wide                            0.0000  -0.061  0.865   1.0001   0.000 0.000 0.000 0.000
spectrum_fit, frequencies given                 0.0549  -0.005  1.000   1.0000   0.040 0.483 0.073 0.349
spectrum_fit, automatic frequencies             0.1520   0.099  1.000   1.0000   0.121 0.826 0.478 0.924
ZapLine-style, 4 components (auto)              0.0587   0.001  0.979   0.9942   0.038 0.628 0.111 0.677
ZapLine-style, 1 component                      0.1318   0.077  0.996   0.9985   0.101 0.736 0.513 0.853
ZapLine-style, 2 components                     0.0794   0.022  0.994   0.9979   0.043 0.720 0.640 0.864
Figure 4 of notebook nb-1-6-line-noise, an output plot. The text around it states what it shows and the units of every axis.
Figure 5 of notebook nb-1-6-line-noise, an output plot. The text around it states what it shows and the units of every axis.

How to read the table: the default notch leaves ~3 % of the line-band power and takes the background with it (negative excess: a hole), the wide notch removes everything within ±2 Hz and damages the flanks (flank well below 1), the spectral fit leaves the background almost exactly (excess near 0) but more of the weaker harmonics, and the spatial method removes the line's dominant patterns with the smallest change to the spectrum shape at a small cost in the flanks. The notch and the spectral fit leave the 1–45 Hz band untouched; the spatial method changes it by well under 1 %. Which is "best" depends on the question: a notch is the safest default when only the line frequency matters; a spectral fit when the EEG under the line matters (gamma analyses); a spatial method when the line is strong on many channels and its spatial pattern is stable.

6. ds-eegbci: 60 Hz mains at 160 Hz — the harmonics are above Nyquist

Sampled at 160 Hz, the file cannot contain 120 or 180 Hz. If the amplifier passed them (the catalog says no hardware filters; whether an anti-alias filter existed is TODO(confirm)), they would have folded to |120 − 160| = 40 Hz and |180 − 160| = 20 Hz at acquisition. The test below looks for sharp peaks there; then only the 60 Hz line is notched, because that is the only line the file can hold.

In [8]:
raw_bci = helpers.load_spine("ds-eegbci", "S001", "R01")     # eyes open, ~1 min, 160 Hz
SF_B = raw_bci.info["sfreq"]
f_b, psd_b, ch_b = helpers_l1.welch_raw(raw_bci, **WELCH)
peaks_b = helpers_l1.line_peaks(f_b, psd_b, fmin=5, min_prominence_db=4)
print(f"ds-eegbci S001 R01 ({SF_B:g} Hz, Nyquist {SF_B / 2:g} Hz): sharp peaks >= 4 dB: "
      + (", ".join(f"{p['freq_hz']:.2f} Hz (+{p['prominence_db']:.1f} dB)" for p in peaks_b) or "none"))
for f_true in (120, 180):
    fa = helpers_l1.alias_frequency(f_true, SF_B)
    print(f"  {f_true} Hz would alias to {fa:g} Hz: PSD there is {helpers_l1.peak_prominence_db(f_b, psd_b, fa):+.1f} dB over its flanks "
          f"-> {'a sharp peak' if any(abs(p['freq_hz'] - fa) < 1 for p in peaks_b) else 'no sharp peak'} (this subject)")
raw_bci_n = raw_bci.copy().notch_filter([60.0], picks="eeg", verbose=False)
res_bci = residual_table({"FIR notch at 60 Hz only (MNE defaults)": helpers_l1.welch_raw(raw_bci_n, **WELCH)[1]}, f_b, psd_b, [60.0])
ax = helpers_l1.plot_spectra({"S001 R01, channel mean": (f_b, psd_b), "after a 60 Hz notch": (f_b, helpers_l1.welch_raw(raw_bci_n, **WELCH)[1])},
                             lines=[20, 40, 60], title=f"ds-eegbci S001 R01 at {SF_B:g} Hz: 60 Hz is in band; dotted 20/40 Hz = where 180/120 Hz would have folded")
plt.show()   # render the static figure(s) of this cell inline
ds-eegbci S001 R01 (160 Hz, Nyquist 80 Hz): sharp peaks >= 4 dB: 60.00 Hz (+7.2 dB)
  120 Hz would alias to 40 Hz: PSD there is +0.2 dB over its flanks -> no sharp peak (this subject)
  180 Hz would alias to 20 Hz: PSD there is -0.4 dB over its flanks -> no sharp peak (this subject)
method                                           ratio  excess  flank  1-45 Hz   per-line ratio
FIR notch at 60 Hz only (MNE defaults)          0.3378  -0.448  1.000   1.0002   0.338
Figure 6 of notebook nb-1-6-line-noise, an output plot. The text around it states what it shows and the units of every axis.

"Remove 60 Hz and every harmonic below Nyquist" on ds-eegbci therefore means "remove 60 Hz": 120 and 180 Hz are not in the file. Notching at 120 or 180 Hz is impossible (MNE refuses frequencies above the Nyquist frequency), and notching at 20 or 40 Hz "just in case" would remove beta-band EEG for a residue that this subject does not show. Whether any aliased residue appears in other subjects is TODO(confirm) from the data.

7. A 50 Hz recording: ds-dortmund (1000 Hz, 250 Hz online low-pass)

Same procedure, different country: the harmonics are at multiples of 50 Hz, and the 250 Hz online low-pass (catalog) shapes what survives above it. Channels whose standard deviation exceeds five times the median are marked bad before averaging (a stated rule, not a reviewed label).

In [9]:
raw_de, de_paths = helpers_l1.load_dortmund("sub-001", "1", "EyesClosed", "pre", return_paths=True)
SF_D = raw_de.info["sfreq"]
sidecar_de = helpers_l1.read_sidecar(de_paths["eeg.json"])
print("BIDS sidecar:", {k: sidecar_de.get(k) for k in ("PowerLineFrequency", "EEGReference", "SamplingFrequency", "SoftwareFilters", "RecordingDuration")})
eeg_idx = mne.pick_types(raw_de.info, eeg=True)
sd = raw_de.get_data(picks=eeg_idx).std(axis=1) * 1e6
raw_de.info["bads"] = [raw_de.ch_names[i] for i, s in zip(eeg_idx, sd) if s > 5 * np.median(sd)]
print(f"{len(eeg_idx)} EEG channels; marked bad (SD > 5 x median of {np.median(sd):.0f} uV): {raw_de.info['bads'] or 'none'}")
f_d, psd_d, ch_d = helpers_l1.welch_raw(raw_de, **WELCH)
peaks_d = helpers_l1.line_peaks(f_d, psd_d, fmin=5, min_prominence_db=6)
print("sharp peaks in the channel mean: " + ", ".join(f"{p['freq_hz']:.1f} Hz (+{p['prominence_db']:.0f} dB)" for p in peaks_d))
MAINS_D = int(sidecar_de.get("PowerLineFrequency", 50))
cand_d = [k * MAINS_D for k in range(1, int(SF_D / 2 // MAINS_D) + 1)]
LINES_D = [f for f in cand_d if helpers_l1.peak_prominence_db(f_d, psd_d, f) >= 3.0]
print(f"multiples of {MAINS_D} Hz at least 3 dB above their flanks: {LINES_D}  (of {cand_d})")

raw_de_fir = raw_de.copy().notch_filter(LINES_D, picks="eeg", verbose=False)
x_de = helpers_l1.to_uV(raw_de)
x_de_zl, zl_de = helpers_l1.zapline_like(x_de, SF_D, float(MAINS_D))
print(f"ZapLine-style on ds-dortmund: period {zl_de['period_samples']:.0f} samples (an integer at 1000 Hz), removed {zl_de['n_removed']} components")
after_d = {"FIR notch, MNE defaults": helpers_l1.welch_raw(raw_de_fir, **WELCH)[1],
           f"ZapLine-style, {zl_de['n_removed']} components (auto)": helpers_l1.welch_psd(x_de_zl, SF_D, **WELCH)[1]}
results_de = residual_table(after_d, f_d, psd_d, LINES_D)
fig, axes = plt.subplots(1, 2, figsize=(13, 4))
helpers_l1.plot_spectra({"original": (f_d, psd_d), **{k: (f_d, v) for k, v in after_d.items()}}, ax=axes[0], lines=cand_d[:7],
                        title=f"ds-dortmund sub-001 EC pre, channel mean, {SF_D:g} Hz: 50 Hz and harmonics; 250 Hz online low-pass")
helpers_l1.plot_spectra({"original": (f_d, psd_d), **{k: (f_d, v) for k, v in after_d.items()}}, ax=axes[1], xlim=(45, 55), title="Zoom on 50 Hz")
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
cached: ds005385/sub-001/ses-1/eeg/sub-001_ses-1_task-EyesClosed_acq-pre_eeg.edf (22.8 MiB)
cached: ds005385/sub-001/ses-1/eeg/sub-001_ses-1_task-EyesClosed_acq-pre_eeg.json (0.0 MiB)
cached: ds005385/sub-001/ses-1/eeg/sub-001_ses-1_task-EyesClosed_acq-pre_channels.tsv (0.0 MiB)
BIDS sidecar: {'PowerLineFrequency': 50, 'EEGReference': 'FCz', 'SamplingFrequency': 1000, 'SoftwareFilters': 'n/a', 'RecordingDuration': 184}
64 EEG channels; marked bad (SD > 5 x median of 84 uV): ['F8', 'CP1']
sharp peaks in the channel mean: 10.0 Hz (+14 dB), 50.0 Hz (+27 dB), 100.0 Hz (+15 dB), 150.0 Hz (+24 dB), 250.0 Hz (+18 dB), 350.2 Hz (+14 dB)
multiples of 50 Hz at least 3 dB above their flanks: [50, 100, 150, 200, 250, 300, 350, 450]  (of [50, 100, 150, 200, 250, 300, 350, 400, 450, 500])
ZapLine-style on ds-dortmund: period 20 samples (an integer at 1000 Hz), removed 4 components
method                                           ratio  excess  flank  1-45 Hz   per-line ratio
FIR notch, MNE defaults                         0.0184  -0.003  1.000   1.0000   0.014 0.150 0.015 0.297 0.026 0.207 0.044 0.034
ZapLine-style, 4 components (auto)              0.0203   0.001  0.956   0.9560   0.012 0.173 0.026 0.817 0.109 0.872 0.232 1.370
Figure 7 of notebook nb-1-6-line-noise, an output plot. The text around it states what it shows and the units of every axis.

8. ds-arithmetic: the hardware already carved a hole

The catalog documents a hardware 50 Hz notch. In the spectrum that is a hole: the PSD at 50 Hz sits below its own flanks. There is no line to remove; a software notch on top only widens the hole (pf-notch-hole-in-band) — and the ~30 Hz roll-off means the whole region is 30–40 dB below the alpha band anyway.

In [10]:
raw_ar, ar_path = helpers_l1.load_eegmat("Subject00", "rest", return_paths=True)
f_a, psd_a, ch_a = helpers_l1.welch_raw(raw_ar, **WELCH)
print(f"ds-arithmetic Subject00 rest: 50 Hz is {helpers_l1.peak_prominence_db(f_a, psd_a, 50.0):+.1f} dB relative to its flanks (negative = a hole); "
      f"sharp peaks >= 6 dB above 15 Hz: {[round(p['freq_hz'], 1) for p in helpers_l1.line_peaks(f_a, psd_a, fmin=15, min_prominence_db=6)] or 'none'}")
raw_ar_n = raw_ar.copy().notch_filter([50.0], picks="eeg", verbose=False)
res_ar = residual_table({"software notch at 50 Hz on top of the hardware hole": helpers_l1.welch_raw(raw_ar_n, **WELCH)[1]}, f_a, psd_a, [50.0])
print("(excess is not meaningful here: the band held less power than its flanks before anything was done)")
ax = helpers_l1.plot_spectra({"as recorded (hardware notch)": (f_a, psd_a), "after a software notch at 50 Hz": (f_a, helpers_l1.welch_raw(raw_ar_n, **WELCH)[1])},
                             xlim=(30, 70), lines=[50], title="ds-arithmetic Subject00, channel mean: a hole, then a wider hole")
plt.show()   # render the static figure(s) of this cell inline
cached: eegmat/1.0.0/Subject00_1.edf (3.7 MiB)
ds-arithmetic Subject00 rest: 50 Hz is -13.5 dB relative to its flanks (negative = a hole); sharp peaks >= 6 dB above 15 Hz: none
method                                           ratio  excess  flank  1-45 Hz   per-line ratio
software notch at 50 Hz on top of the hardware hole  0.6017   1.015  1.000   1.0001   0.602
(excess is not meaningful here: the band held less power than its flanks before anything was done)
Figure 8 of notebook nb-1-6-line-noise, an output plot. The text around it states what it shows and the units of every axis.

9. Cleanup (optional)

The three downloads (about 60 MB: Iowa sub-007, Dortmund sub-001, EEGMAT Subject00) stay in the course data directory unless DELETE_DOWNLOADS = True.

In [11]:
DELETE_DOWNLOADS = False
if DELETE_DOWNLOADS:
    helpers_l1.cleanup(list(pd_paths.values()) + list(de_paths.values()) + [ar_path])

10. The numbers

The L1.6 exercise: on ds-iowapd, remove 60 Hz and every harmonic found below the 250 Hz Nyquist frequency and report the residual power ratio. The ratio depends on the method, so the method is stated with it; all three are printed and the FIR notch with MNE defaults is proposed as the answer key.

In [12]:
key = results_pd["FIR notch, MNE defaults"]
print("nb-1-6-line-noise -- L1.6 exercise numbers (draft; TODO(confirm) at author review)")
print(f"Data: {DATASET} {SUBJECT} (OpenNeuro ds004584 v1.0.0, CC0), {len(ch_pd)} channels, {raw_pd.times[-1]:.0f} s at {SF:g} Hz")
print(f"Lines found and removed: {LINES} Hz (sharp peaks at multiples of {MAINS} Hz below the {SF / 2:g} Hz Nyquist frequency)")
print(f"Metric: line-band power after / before, Welch {WELCH['seg_s']:g}-s {WELCH['window']} segments with {int(100 * WELCH['overlap'])} % overlap over the whole "
      f"recording, mean over the {len(ch_pd)} channels, +/- {key['half_width_hz']:g} Hz around each line")
print(f"  residual power ratio, FIR notch (raw.notch_filter, MNE defaults)      : {key['ratio']:.4f}   <-- proposed answer key")
print(f"  residual power ratio, spectrum_fit (frequencies given)              : {results_pd['spectrum_fit, frequencies given']['ratio']:.4f}")
zl_key = [k for k in results_pd if k.startswith("ZapLine-style,") and "(auto)" in k][0]
print(f"  residual power ratio, ZapLine-style ({zl['n_removed']} components)                : {results_pd[zl_key]['ratio']:.4f}")
print(f"  line-band power before: {key['band_power_before_uV2']:.2f} uV^2, of which background (flank median x width) {key['background_before_uV2']:.2f} uV^2; "
      f"the excess-over-background ratios are {key['ratio_excess']:+.3f} (notch), {results_pd['spectrum_fit, frequencies given']['ratio_excess']:+.3f} (spectrum_fit), "
      f"{results_pd[zl_key]['ratio_excess']:+.3f} (ZapLine-style)")
print(f"  suggested tolerance for the numeric exercise: answer {key['ratio']:.2f}, tolerance 0.03 (accepts any of the three methods)")
print(f"Second part (free-response): on ds-eegbci at {SF_B:g} Hz only 60 Hz is below the {SF_B / 2:g} Hz Nyquist frequency; 120 and 180 Hz are not in the file, "
      f"so 'every harmonic' cannot be removed at its true frequency. For information: a 60 Hz notch on S001 R01 leaves a ratio of "
      f"{res_bci['FIR notch at 60 Hz only (MNE defaults)']['ratio']:.4f} in the 60 Hz band.")
print(f"50 Hz example: ds-dortmund sub-001 EC pre, lines {LINES_D} Hz, FIR notch ratio {results_de['FIR notch, MNE defaults']['ratio']:.4f}")
nb-1-6-line-noise -- L1.6 exercise numbers (draft; TODO(confirm) at author review)
Data: ds-iowapd sub-007 (OpenNeuro ds004584 v1.0.0, CC0), 63 channels, 240 s at 500 Hz
Lines found and removed: [60, 120, 180, 240] Hz (sharp peaks at multiples of 60 Hz below the 250 Hz Nyquist frequency)
Metric: line-band power after / before, Welch 4-s hann segments with 50 % overlap over the whole recording, mean over the 63 channels, +/- 1 Hz around each line
  residual power ratio, FIR notch (raw.notch_filter, MNE defaults)      : 0.0327   <-- proposed answer key
  residual power ratio, spectrum_fit (frequencies given)              : 0.0549
  residual power ratio, ZapLine-style (4 components)                : 0.0587
  line-band power before: 18.14 uV^2, of which background (flank median x width) 1.07 uV^2; the excess-over-background ratios are -0.028 (notch), -0.005 (spectrum_fit), +0.001 (ZapLine-style)
  suggested tolerance for the numeric exercise: answer 0.03, tolerance 0.03 (accepts any of the three methods)
Second part (free-response): on ds-eegbci at 160 Hz only 60 Hz is below the 80 Hz Nyquist frequency; 120 and 180 Hz are not in the file, so 'every harmonic' cannot be removed at its true frequency. For information: a 60 Hz notch on S001 R01 leaves a ratio of 0.3378 in the 60 Hz band.
50 Hz example: ds-dortmund sub-001 EC pre, lines [50, 100, 150, 200, 250, 300, 350, 450] Hz, FIR notch ratio 0.0184