Sampling, Nyquist and aliasing: raw.resample() versus naive decimation on a 500 Hz recording

nb-1-1-resampling Level 1 · Signal Fundamentals ~3 min Used in L1.1 · Sampling, Nyquist and aliasing

Downloads from ds-iowapd, 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-1-resampling · Sampling, Nyquist and aliasing (L1.1)

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

What you will do

  1. Load a 500 Hz recording that carries a 60 Hz mains line and its harmonics below the 250 Hz Nyquist frequency (ds-iowapd), and read its spectrum.
  2. Predict where every component lands when the sampling rate drops to 100 Hz: f_alias = |f − round(f / fs) · fs|.
  3. Decimate the recording to 100 Hz two ways — raw.resample() (anti-alias filter, then decimate) and naive decimation (x[::5], no filter) — with a labelled synthetic 70 Hz marker added, and watch the marker fold to 30 Hz and the real line and harmonics fold onto 20 and 40 Hz.
  4. Measure what the anti-alias filter costs (the roll-off below the new Nyquist) and what naive decimation costs (folded noise raises the floor).
  5. Counter-example: ds-arithmetic is nominally 500 Hz but hardware low-passed near 30 Hz — the sampling rate says nothing about usable bandwidth.

Data

  • ds-iowapd — Iowa Parkinson's disease resting EEG, "Rest eyes open" (Anjum et al., 2024, npj Parkinson's Disease, DOI 10.1038/s41531-023-00602-0); OpenNeuro ds004584 v1.0.0, DOI 10.18112/openneuro.ds004584.v1.0.0, CC0. From the catalog: 64-channel Brain Vision actiCAP at 500 Hz, 0.1 Hz online high-pass, Pz online reference, eyes-open rest of ~3 min, 60 Hz mains (the authors removed 60, 180 and 200 Hz components in their own analysis). One subject's EEGLAB .set + .fdt pair (~30 MB) is downloaded from the public OpenNeuro bucket. Subject sub-007 is used because its recording shows the 60 Hz line and the 120, 180 and 240 Hz harmonics clearly in the channel mean; sub-001 (the subject behind the site's sampling widget) carries a weaker line and no visible harmonics, which was checked on the first minute of each of sub-001 … sub-008 before choosing.
  • ds-arithmetic — EEG During Mental Arithmetic Tasks, EEGMAT (Zyma et al., 2019, Data 4(1):14, DOI 10.3390/data4010014); PhysioNet v1.0.0, DOI 10.13026/C2JQ1P, ODC-By 1.0. From the catalog: Neurocom 23-channel system, 19 scalp electrodes (10-20), linked-ear reference, nominal 500 Hz, a 50 Hz hardware notch and a filter reported with a "30 Hz cut-off" (read as a low-pass near 30 Hz), ICA applied upstream. One EDF (Subject00_1.edf, the background recording before the task, ~4 MB).

ds-eegbci (160 Hz) cannot host this demonstration: its Nyquist frequency is 80 Hz, so no harmonic of 60 Hz exists in the file. ds-lemon raw (2500 Hz) is the spec's alternative and is not used here.

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. A 500 Hz recording, read before it is plotted

helpers_l1.load_iowapd downloads the subject's .set/.fdt pair and the BIDS sidecars, reads them with mne.io.read_raw_eeglab, and marks any flat channel bad. The sidecar states the sampling rate, the mains frequency and the online reference; the header reports no filtering (MNE then shows a low-pass equal to the Nyquist frequency). The reference channel Pz is not in the file (63 data channels), so there is no flat channel to mark.

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

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"])
KEYS = ["description", "sfreq_hz", "nyquist_hz", "n_channels", "duration_s", "highpass_hz_in_header",
        "lowpass_hz_in_header", "montage_attached", "bads", "amplitude_uV"]
rep = helpers.first_look_report(raw_pd, DATASET)
display(HTML(helpers.report_html({k: rep[k] for k in KEYS}, f"First look: {DATASET} {SUBJECT}")))
print("BIDS sidecar:", {k: sidecar.get(k) for k in ("SamplingFrequency", "PowerLineFrequency", "EEGReference",
                                                    "EEGChannelCount", "RecordingDuration", "SoftwareFilters")})
facts = helpers_l1.DATASETS_L1[DATASET]
print(f"Catalog: {facts['sfreq']} Hz, {facts['n_channels']} channels, {facts['mains_hz']} Hz mains; {facts['hardware_filters']}; "
      f"license {facts['license']}")
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
montage_attachedTrue
bads
amplitude_uVmedian_channel_std: 143.5
max_channel_std: 281.9
max_abs: 930.4
flattest_channel: P2
noisiest_channel: TP9
BIDS sidecar: {'SamplingFrequency': 500, 'PowerLineFrequency': 60, 'EEGReference': 'Pz', 'EEGChannelCount': 63, 'RecordingDuration': 239.82, 'SoftwareFilters': 'n/a'}
Catalog: 500 Hz, 64 channels, 60 Hz mains; 0.1 Hz online high-pass; Pz online reference (flat); license CC0
In [3]:
f_pd, psd_pd, ch_pd = helpers_l1.welch_raw(raw_pd, seg_s=4.0)          # Welch, 4-s Hann segments, 50 % overlap, uV^2/Hz
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))]                                      # the channel with the strongest 60 Hz line (a stated rule)
peaks = helpers_l1.line_peaks(f_pd, psd_pd, fmin=5, min_prominence_db=6)
print(f"sharp peaks (>= 6 dB over their surroundings) in the channel-mean PSD: "
      + ", ".join(f"{p['freq_hz']:.1f} Hz (+{p['prominence_db']:.0f} dB)" for p in peaks))
print(f"strongest 60 Hz line: {CH} (+{prom60.max():.0f} dB over its flanks); median channel +{np.median(prom60):.0f} dB")
HARMONICS = [60, 120, 180, 240]
ax = helpers_l1.plot_spectra({f"mean of {len(ch_pd)} channels": (f_pd, psd_pd),
                              f"{CH} (strongest 60 Hz line)": (f_pd, psd_pd[ch_pd.index(CH)], dict(lw=0.7, alpha=0.8))},
                             title=f"{DATASET} {SUBJECT}: PSD up to the {SF / 2:g} Hz Nyquist frequency; dotted = 60 Hz and harmonics",
                             lines=HARMONICS)
plt.show()   # render the static figure(s) of this cell inline
sharp peaks (>= 6 dB over their surroundings) in the channel-mean PSD: 60.0 Hz (+22 dB), 120.0 Hz (+7 dB), 180.0 Hz (+17 dB), 240.0 Hz (+6 dB)
strongest 60 Hz line: AFz (+39 dB over its flanks); median channel +17 dB
Figure 1 of notebook nb-1-1-resampling, an output plot. The text around it states what it shows and the units of every axis.

Four sharp peaks sit at 60, 120, 180 and 240 Hz — the mains frequency and every harmonic below the 250 Hz Nyquist frequency. Everything in this figure is real content of the file: sampled at 500 Hz, the recording can represent frequencies up to 250 Hz, and the amplifier passed them (the catalog documents only a 0.1 Hz online high-pass).

2. Where does a component land after resampling?

A sinusoid at f sampled at fs is indistinguishable from one at |f − k · fs| for any integer k; the copy that lands inside 0 … fs/2 is the alias, f_alias = |f − round(f / fs) · fs|. If the recording is decimated to 100 Hz without removing everything above 50 Hz first, each of the four lines — and a 70 Hz component, the marker used below — appears somewhere below 50 Hz.

In [4]:
FS_NEW = 100.0
print(f"decimating {SF:g} -> {FS_NEW:g} Hz (new Nyquist {FS_NEW / 2:g} Hz): f_alias = |f - round(f / fs) * fs|")
for f in (60, 70, 120, 180, 240):
    fa = helpers_l1.alias_frequency(f, FS_NEW)
    print(f"  {f:4d} Hz -> {fa:5.1f} Hz" + ("   (within the new band: unchanged)" if f <= FS_NEW / 2 else ""))
print(f"textbook case: 140 Hz sampled at 200 Hz -> {helpers_l1.alias_frequency(140, 200):g} Hz")

f_true = np.linspace(0, SF / 2, 2001)
fig, ax = plt.subplots(figsize=(9, 3.6))
ax.plot(f_true, helpers_l1.alias_frequency(f_true, FS_NEW), "k", lw=1)
for f, c in zip((60, 70, 120, 180, 240), ("tab:orange", "tab:blue", "tab:orange", "tab:orange", "tab:orange")):
    ax.plot(f, helpers_l1.alias_frequency(f, FS_NEW), "o", color=c)
    ax.annotate(f"{f} -> {helpers_l1.alias_frequency(f, FS_NEW):g} Hz", (f, helpers_l1.alias_frequency(f, FS_NEW)),
                textcoords="offset points", xytext=(4, 6), fontsize=8, color=c)
ax.set(xlabel="True frequency (Hz)", ylabel="Apparent frequency (Hz)",
       title=f"Folding at fs = {FS_NEW:g} Hz: apparent frequency of each true frequency (Hz); orange = real lines, blue = the 70 Hz marker")
ax.grid(alpha=0.3)
plt.show()   # render the static figure(s) of this cell inline
decimating 500 -> 100 Hz (new Nyquist 50 Hz): f_alias = |f - round(f / fs) * fs|
    60 Hz ->  40.0 Hz
    70 Hz ->  30.0 Hz
   120 Hz ->  20.0 Hz
   180 Hz ->  20.0 Hz
   240 Hz ->  40.0 Hz
textbook case: 140 Hz sampled at 200 Hz -> 60 Hz
Figure 2 of notebook nb-1-1-resampling, an output plot. The text around it states what it shows and the units of every axis.

3. The wagon wheel: a 70 Hz tone sampled at 100 Hz

Before the real data, the mechanism on a pure tone. Ten samples per 100 ms cannot follow a 70 Hz oscillation; a 30 Hz sinusoid passes through exactly the same samples. Note the sign: sin(2π · 70 · n/100) = sin(2π · 0.7 n) = −sin(2π · 0.3 n), so the alias of a component that folds down once has its phase reversed as well.

In [5]:
TONE_HZ = 70.0
t_fine = np.arange(0, 0.2, 1 / 5000)            # a dense grid standing in for the continuous signal
t_s = np.arange(0, 0.2, 1 / FS_NEW)             # the samples at 100 Hz
tone = lambda t: np.sin(2 * np.pi * TONE_HZ * t)
fig, ax = plt.subplots(figsize=(10, 3.4))
ax.plot(t_fine * 1000, tone(t_fine), color="0.6", lw=1, label=f"{TONE_HZ:g} Hz tone (continuous)")
ax.plot(t_s * 1000, tone(t_s), "o", color="tab:blue", ms=6, label=f"samples at {FS_NEW:g} Hz")
ax.plot(t_fine * 1000, -np.sin(2 * np.pi * helpers_l1.alias_frequency(TONE_HZ, FS_NEW) * t_fine), "--", color="tab:orange", lw=1.2,
        label=f"{helpers_l1.alias_frequency(TONE_HZ, FS_NEW):g} Hz sinusoid (sign reversed) through the same samples")
ax.set(xlabel="Time (ms)", ylabel="Amplitude (a.u.)", title=f"A {TONE_HZ:g} Hz tone sampled at {FS_NEW:g} Hz is a {helpers_l1.alias_frequency(TONE_HZ, FS_NEW):g} Hz tone (amplitude in arbitrary units)")
ax.grid(alpha=0.3)
ax.legend(fontsize=8, loc="upper right")
plt.show()   # render the static figure(s) of this cell inline
Figure 3 of notebook nb-1-1-resampling, an output plot. The text around it states what it shows and the units of every axis.

4. Real data, two ways down to 100 Hz

The channel with the strongest line, plus a synthetic 70 Hz marker (20 µV, labelled as such everywhere) added so that a component with a known alias is present. Three versions at 100 Hz:

  • naive decimation — keep every fifth sample, no filter (helpers_l1.naive_decimate);
  • raw.resample(100) — MNE's resampler, which low-passes below the new Nyquist frequency before decimating (the lesson's API);
  • scipy.signal.decimate — an anti-alias filter (here a zero-phase FIR) followed by decimation, for comparison.

The spectra below 50 Hz tell them apart: naive decimation creates peaks at 20, 30 and 40 Hz that do not exist in the original.

In [6]:
from scipy import signal

TONE_UV = 20.0
Q = int(round(SF / FS_NEW))                                   # decimation factor 5
x = raw_pd.get_data(picks=CH)[0] * 1e6                         # uV
t = raw_pd.times
x_test = x + TONE_UV * np.sin(2 * np.pi * TONE_HZ * t)         # SYNTHETIC marker added to the real channel
raw_test = mne.io.RawArray(x_test[None] * 1e-6, mne.create_info([f"{CH}+70Hz"], SF, "eeg"), verbose=False)

versions = {
    f"original, {SF:g} Hz": (SF, x_test),
    "naive decimation x[::5] (no filter)": (FS_NEW, helpers_l1.naive_decimate(x_test, Q)),
    "raw.resample(100) (MNE: anti-alias low-pass, then decimate)": (FS_NEW, raw_test.copy().resample(FS_NEW, verbose=False).get_data()[0] * 1e6),
    "scipy.signal.decimate (FIR anti-alias, zero-phase)": (FS_NEW, signal.decimate(x_test, Q, ftype="fir", zero_phase=True)),
}
spectra = {}
for label, (fs, y) in versions.items():
    fr, p = helpers_l1.welch_psd(y, fs, seg_s=4.0)
    spectra[label] = (fr, p)
    pk = helpers_l1.line_peaks(fr, p, fmin=5, fmax=min(fs / 2, 50), min_prominence_db=6)
    print(f"{label:60s} sharp peaks 5-50 Hz: " + (", ".join(f"{q['freq_hz']:.2f} Hz (+{q['prominence_db']:.0f} dB)" for q in pk) or "none"))

fig, axes = plt.subplots(2, 1, figsize=(11, 8))
helpers_l1.plot_spectra({k: spectra[k] for k in list(spectra)[:1]}, ax=axes[0], lines=HARMONICS + [TONE_HZ],
                        title=f"{SUBJECT} {CH} + synthetic {TONE_HZ:g} Hz marker at {SF:g} Hz, 0-250 Hz")
helpers_l1.plot_spectra(spectra, ax=axes[1], xlim=(0, 50), lines=[20, 30, 40],
                        title=f"The same channel after decimation to {FS_NEW:g} Hz (dotted: predicted aliases 20, 30, 40 Hz)")
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
original, 500 Hz                                             sharp peaks 5-50 Hz: none
naive decimation x[::5] (no filter)                          sharp peaks 5-50 Hz: 20.00 Hz (+13 dB), 30.00 Hz (+30 dB), 40.00 Hz (+36 dB)
raw.resample(100) (MNE: anti-alias low-pass, then decimate)  sharp peaks 5-50 Hz: none
scipy.signal.decimate (FIR anti-alias, zero-phase)           sharp peaks 5-50 Hz: none
Figure 4 of notebook nb-1-1-resampling, an output plot. The text around it states what it shows and the units of every axis.

Read the lower panel against the alias table of section 2: after naive decimation the 60 Hz line and the 240 Hz harmonic pile up at 40 Hz, the 120 and 180 Hz harmonics at 20 Hz, and the 70 Hz marker at 30 Hz — inside the beta/gamma range, where nothing of the kind existed. Both anti-aliased versions follow the original below about 45 Hz and carry none of the folded peaks. Aliasing is not visible in a raw trace and cannot be undone afterwards: once folded, a 30 Hz alias is a 30 Hz signal.

5. What each route costs

Anti-alias filtering removes everything above the new Nyquist frequency and a transition band just below it; naive decimation keeps the whole band but folds the out-of-band content into it. Both effects are easiest to measure on white noise: after a proper anti-alias filter the noise floor stays where it was and rolls off near 50 Hz; after naive decimation the floor rises by the decimation factor (five times, about 7 dB) because five bands of noise now share one.

In [7]:
rng = np.random.default_rng(1)
noise = rng.standard_normal(int(60 * SF)) * 10.0      # 60 s of white noise, 10 uV RMS, at 500 Hz
raw_noise = mne.io.RawArray(noise[None] * 1e-6, mne.create_info(["noise"], SF, "eeg"), verbose=False)
noise_versions = {
    f"white noise at {SF:g} Hz": (SF, noise),
    "naive decimation": (FS_NEW, helpers_l1.naive_decimate(noise, Q)),
    "raw.resample(100)": (FS_NEW, raw_noise.copy().resample(FS_NEW, verbose=False).get_data()[0] * 1e6),
    "scipy.signal.decimate": (FS_NEW, signal.decimate(noise, Q, ftype="fir", zero_phase=True)),
}
ns = {k: helpers_l1.welch_psd(y, fs, seg_s=2.0) for k, (fs, y) in noise_versions.items()}
ref_db = 10 * np.log10(np.mean(ns[f"white noise at {SF:g} Hz"][1]))
for label, (fr, p) in ns.items():
    db = 10 * np.log10(p)
    m = (fr >= 5) & (fr <= 40)
    line = f"{label:28s} floor 5-40 Hz: {db[m].mean() - ref_db:+5.1f} dB re the original"
    if fr[-1] <= 60:
        hi = fr >= 30                                   # look for the roll-off above 30 Hz only
        first_below = lambda drop: (f"at {fr[hi][np.argmax(db[hi] < ref_db - drop)]:.1f} Hz" if np.any(db[hi] < ref_db - drop) else "not reached below 50 Hz")
        line += f"; roll-off: -3 dB {first_below(3)}, -10 dB {first_below(10)}"
    print(line)
ax = helpers_l1.plot_spectra(ns, xlim=(0, 50), title="White noise resampled three ways: anti-alias roll-off vs folded noise")
plt.show()   # render the static figure(s) of this cell inline
white noise at 500 Hz        floor 5-40 Hz:  -0.1 dB re the original
naive decimation             floor 5-40 Hz:  +7.0 dB re the original; roll-off: -3 dB not reached below 50 Hz, -10 dB not reached below 50 Hz
raw.resample(100)            floor 5-40 Hz:  -0.1 dB re the original; roll-off: -3 dB not reached below 50 Hz, -10 dB not reached below 50 Hz
scipy.signal.decimate        floor 5-40 Hz:  -0.1 dB re the original; roll-off: -3 dB at 48.0 Hz, -10 dB not reached below 50 Hz
Figure 5 of notebook nb-1-1-resampling, an output plot. The text around it states what it shows and the units of every axis.

Choosing a sampling rate for an analysis is choosing a Nyquist frequency with a safety margin: the transition band of the anti-alias filter sits just below fs/2, so the usable band ends a little lower. MNE's resample and SciPy's decimate differ in filter design and in the exact roll-off (printed above), not in principle. raw.resample() also adjusts events and annotations; a bare x[::5] adjusts nothing.

6. Counter-example: nominal 500 Hz, usable to about 30 Hz (ds-arithmetic)

ds-arithmetic is sampled at 500 Hz too. The catalog documents a hardware 50 Hz notch and a filter with a "30 Hz cut-off" (read as a low-pass), and the EDF header carries its own prefilter fields. Its spectrum, on the same axes as the Iowa recording, has nothing to fold.

In [8]:
raw_ar, ar_path = helpers_l1.load_eegmat("Subject00", "rest", return_paths=True)
rep_ar = helpers.first_look_report(raw_ar, "ds-arithmetic")
display(HTML(helpers.report_html({k: rep_ar[k] for k in ("description", "sfreq_hz", "nyquist_hz", "n_channels", "channel_types",
                                                          "duration_s", "highpass_hz_in_header", "lowpass_hz_in_header")},
                                 "First look: ds-arithmetic Subject00_1 (background EEG before the task)")))
print("Note: this file is", f"{rep_ar['duration_s']:.0f} s long; the catalog states that only artifact-free 60-s segments are released",
      "-- TODO(confirm) whether that statement applies to the task (_2) files only.")

f_ar, psd_ar, ch_ar = helpers_l1.welch_raw(raw_ar, seg_s=4.0)
m_ar = psd_ar.mean(axis=0)
ref = 10 * np.log10(m_ar[np.argmin(np.abs(f_ar - 20))])
db_ar = 10 * np.log10(m_ar)
for drop in (3, 10, 20):
    idx = np.where((f_ar > 20) & (db_ar <= ref - drop))[0]
    print(f"ds-arithmetic channel mean: -{drop:2d} dB relative to 20 Hz first reached at {f_ar[idx[0]]:.2f} Hz")
print(f"50 Hz relative to its flanks: {helpers_l1.peak_prominence_db(f_ar, m_ar, 50.0):+.1f} dB (a hole, not a peak)")
print(f"ds-iowapd {SUBJECT} channel mean, for comparison: 60 Hz {helpers_l1.peak_prominence_db(f_pd, psd_pd, 60.0):+.1f} dB over its flanks")

ax = helpers_l1.plot_spectra({f"ds-iowapd {SUBJECT}, mean of {len(ch_pd)} channels": (f_pd, psd_pd),
                              f"ds-arithmetic Subject00 rest, mean of {len(ch_ar)} channels": (f_ar, psd_ar)},
                             lines=[30, 50], title="Two nominal 500 Hz recordings: content up to 250 Hz vs a ~30 Hz hardware ceiling with a 50 Hz notch hole")
plt.show()   # render the static figure(s) of this cell inline
cached: eegmat/1.0.0/Subject00_1.edf (3.7 MiB)

First look: ds-arithmetic Subject00_1 (background EEG before the task)

descriptionds-arithmetic Subject00_1 (rest; PhysioNet eegmat 1.0.0, ODC-By 1.0; nominal 500 Hz; 50 Hz hardware notch; a filter reported with a '30 Hz cut-off' (read as a low-pass near 30 Hz); ICA applied upstream)
sfreq_hz500
nyquist_hz250
n_channels21
channel_typesecg: 1
eeg: 19
misc: 1
duration_s182
highpass_hz_in_header0.5
lowpass_hz_in_header45
Note: this file is 182 s long; the catalog states that only artifact-free 60-s segments are released -- TODO(confirm) whether that statement applies to the task (_2) files only.
ds-arithmetic channel mean: - 3 dB relative to 20 Hz first reached at 24.75 Hz
ds-arithmetic channel mean: -10 dB relative to 20 Hz first reached at 32.25 Hz
ds-arithmetic channel mean: -20 dB relative to 20 Hz first reached at 45.25 Hz
50 Hz relative to its flanks: -13.5 dB (a hole, not a peak)
ds-iowapd sub-007 channel mean, for comparison: 60 Hz +21.8 dB over its flanks
Figure 6 of notebook nb-1-1-resampling, an output plot. The text around it states what it shows and the units of every axis.
In [9]:
# Decimating ds-arithmetic naively to 100 Hz folds nothing worth seeing: there is almost no power above 50 Hz to fold.
x_ar = raw_ar.get_data(picks="O1")[0] * 1e6
ar_versions = {f"O1 at {SF:g} Hz": helpers_l1.welch_psd(x_ar, SF, seg_s=4.0),
               "O1, naive decimation to 100 Hz": helpers_l1.welch_psd(helpers_l1.naive_decimate(x_ar, Q), FS_NEW, seg_s=4.0)}
for label, (fr, p) in ar_versions.items():
    pk = helpers_l1.line_peaks(fr, p, fmin=15, fmax=50, min_prominence_db=6)
    print(f"{label:34s} sharp peaks 15-50 Hz: " + (", ".join(f"{q['freq_hz']:.2f} Hz" for q in pk) or "none"))
above = m_ar[f_ar > 50].sum() / m_ar.sum()
print(f"fraction of the channel-mean power above 50 Hz in ds-arithmetic: {100 * above:.3f} % "
      f"(ds-iowapd {SUBJECT}: {100 * psd_pd.mean(axis=0)[f_pd > 50].sum() / psd_pd.mean(axis=0).sum():.1f} %)")
ax = helpers_l1.plot_spectra(ar_versions, xlim=(0, 50), title="ds-arithmetic O1: naive decimation to 100 Hz changes almost nothing below 50 Hz")
plt.show()   # render the static figure(s) of this cell inline
O1 at 500 Hz                       sharp peaks 15-50 Hz: none
O1, naive decimation to 100 Hz     sharp peaks 15-50 Hz: none
fraction of the channel-mean power above 50 Hz in ds-arithmetic: 0.006 % (ds-iowapd sub-007: 1.5 %)
Figure 7 of notebook nb-1-1-resampling, an output plot. The text around it states what it shows and the units of every axis.

The sampling rate of a file is a fact about the recorder; the usable bandwidth is a fact about the whole chain — hardware filters, electrodes, the amplifier's anti-alias filter, and any software filtering applied before release. Read the spectrum (and the documentation) before assuming that a 500 Hz file contains 250 Hz of signal, and before assuming that a low sampling rate was harmless: ds-eegbci at 160 Hz has no hardware filters at all (catalog), so whatever its amplifier did about content above 80 Hz is TODO(confirm).

7. Cleanup (optional)

The two downloads (about 35 MB) stay in the course data directory so that nb-1-6 can reuse the Iowa recording. Set DELETE_DOWNLOADS = True to remove them.

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

8. The numbers

In [11]:
fr_n, p_n = spectra["naive decimation x[::5] (no filter)"]
m30 = (fr_n >= 25) & (fr_n <= 35)
observed = float(fr_n[m30][np.argmax(p_n[m30])])
print("nb-1-1-resampling -- L1.1 numbers (draft; TODO(confirm) at author review)")
print(f"Data: {DATASET} {SUBJECT} (OpenNeuro ds004584 v1.0.0, CC0), channel {CH}, {SF:g} Hz, decimated to {FS_NEW:g} Hz; "
      f"synthetic {TONE_HZ:g} Hz marker of {TONE_UV:g} uV added and labelled as such")
print(f"alias frequency of the {TONE_HZ:g} Hz component when decimating {SF:g} -> {FS_NEW:g} Hz without an anti-alias filter: "
      f"{helpers_l1.alias_frequency(TONE_HZ, FS_NEW):g} Hz  (largest peak between 25 and 35 Hz in the decimated spectrum: {observed:.2f} Hz)")
print("real components that fold in the same step: "
      + ", ".join(f"{f} -> {helpers_l1.alias_frequency(f, FS_NEW):g} Hz" for f in HARMONICS))
print(f"textbook case (L1.1 exercise): a 140 Hz tone sampled at 200 Hz appears at {helpers_l1.alias_frequency(140, 200):g} Hz")
print(f"counter-example: ds-arithmetic Subject00 is nominally {raw_ar.info['sfreq']:g} Hz but its channel-mean PSD is 20 dB below its 20 Hz level by "
      f"{f_ar[np.where((f_ar > 20) & (db_ar <= ref - 20))[0][0]]:.1f} Hz and has a 50 Hz hardware notch hole")
nb-1-1-resampling -- L1.1 numbers (draft; TODO(confirm) at author review)
Data: ds-iowapd sub-007 (OpenNeuro ds004584 v1.0.0, CC0), channel AFz, 500 Hz, decimated to 100 Hz; synthetic 70 Hz marker of 20 uV added and labelled as such
alias frequency of the 70 Hz component when decimating 500 -> 100 Hz without an anti-alias filter: 30 Hz  (largest peak between 25 and 35 Hz in the decimated spectrum: 30.00 Hz)
real components that fold in the same step: 60 -> 40 Hz, 120 -> 20 Hz, 180 -> 20 Hz, 240 -> 40 Hz
textbook case (L1.1 exercise): a 140 Hz tone sampled at 200 Hz appears at 60 Hz
counter-example: ds-arithmetic Subject00 is nominally 500 Hz but its channel-mean PSD is 20 dB below its 20 Hz level by 45.2 Hz and has a 50 Hz hardware notch hole