What survives at fourteen channels: three device classes measured with one estimator, the hardware bandwidth ceiling behind a nominal sampling rate, the vendor-derived columns that are not signal, and a controlled degradation of one recording from 64 electrodes to four

nb-7-5-low-channel Level 7 · Applied Electives ~5 min Used in L7.5 · Mobile and consumer EEG

Downloads from ds-eegbci, ds-brain-invaders, ds-areeg 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-7-5-low-channel · What survives at fourteen channels (L7.5)

Lesson L7.5 · Level 7 · Status draft — for expert review; uncertain points carry TODO(confirm).

Three device classes, four analyses, one question: which analyses survive when the montage shrinks, and which stop being possible at all? The treatment is comparative and product-neutral (spec §0.6, §10.6): device classes are characterised from facts the directory records — electrode type, channel count, hardware bandwidth, online filters, reference — and nothing here ranks a product, recommends one, or uses an in-house recording.

Two things make this harder than "run the same code three times", and both are the lesson:

  1. A device class and a paradigm are different constraints. A 14-channel headset cannot give you a 64-channel topography; it also cannot give you a P300 if the study that used it never ran an oddball. The first limit is the hardware, the second is the recording, and conflating them makes a headset look worse (or better) than it is. Section 2 separates them before anything is computed.
  2. Only a controlled degradation isolates the channel count. Comparing three datasets compares three cohorts, three paradigms, three amplifiers, three references and three sampling rates at once. Section 8 therefore takes one recording and removes channels from it, so the only thing that changes is the montage.

Data — three class-A datasets, and one that is named but never loaded.

  • ds-eegbci — EEG Motor Movement/Imagery Database (EEGMMIDB), Schalk, McFarland, Hinterberger, Birbaumer & Wolpaw (2004), BCI2000, IEEE TBME 51(6), DOI 10.1109/TBME.2004.827072; PhysioNet DOI 10.13026/C28G6P. Licence ODC-By 1.0, access: open. The 64-channel gel research cap.
  • ds-brain-invaders — Brain Invaders bi2014a, Korczowski, Cederhout, Andreev, Cattan, Rodrigues, Gubert & Congedo (2019), GIPSA-lab; DOI 10.5281/zenodo.3266223. Licence CC BY 4.0, access: open. The 16-channel dry research headset.
  • ds-areeg — ArEEG_Words, Darwish, Al Malah, Al Jallad & Ghneim (2024), arXiv:2411.18888; Mendeley Data DOI 10.17632/7m472ykkx7.1. Licence CC BY 4.0, access: open. The 14-channel consumer saline headset.
  • ds-mpeng — MultiPENG, Rashed, Shirmohammadi & Hefeeda (2025), IEEE Data Descriptions 2, 17–25, DOI 10.1109/IEEEDATA.2025.3553097; data on Kaggle, DOI 10.34740/kaggle/ds/6552328. Its data licence is CC BY-NC 4.0 — non-commercial, downstream of participant consent under a named research-ethics protocol — so under spec §10.7 it is class C: this notebook does not download it, compute on it, or ship anything derived from it. It is named, described and linked, and its recorded facts are used as facts. See site/notes/data-p4-licences.md §4.

What the lesson's exercise needs (ex-7-5, multiple-select: which of five analyses remain feasible at 4 channels) is printed by the last cell, with the number behind each verdict.

In [1]:
# Setup: dependencies, the shared helpers, non-interactive plotting.
import importlib.util
import io
import re
import subprocess
import sys
import time
import warnings
from concurrent.futures import ThreadPoolExecutor
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 kernel installs it once.  On Colab,
#    run from a clone of the repository so that notebooks/_shared/ is importable
#    (repository URL: TODO(confirm), spec section 13 item 3).
_needed = ("mne", "scipy", "matplotlib", "pandas", "pooch", "moabb")
_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", "moabb==1.7.2"]
    subprocess.check_call(_cmd)

# 2. Shared helpers, located relative to the working directory -- notebooks/L7/ or notebooks/ --
#    never through an absolute path.  helpers_l7.py belongs to track notebooks-L7a; this notebook
#    imports it when it is present and works without it (nothing below depends on it).
_shared = next((d / "_shared" for d in (Path.cwd(), *Path.cwd().parents)
                if (d / "_shared" / "helpers_l4.py").exists()), None)
if _shared is None:
    raise FileNotFoundError("start the kernel in notebooks/L7/ (or notebooks/) so that "
                            "_shared/helpers_l4.py is found")
sys.path.insert(0, str(_shared))
import helpers
import helpers_l1 as L1
import helpers_l4 as L4
import helpers_l5 as L5

HAVE_L7 = importlib.util.find_spec("helpers_l7") is not None
if HAVE_L7:
    import helpers_l7 as L7

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import mne
from scipy import signal as sps

mne.set_log_level("WARNING")
plt.rcParams["figure.dpi"] = 72

# 3. Quiet the downloader.  pooch, which MNE and moabb use to fetch datasets, logs
#    "Downloading file '...' from '...' to '<cache directory>'" at INFO, and that last field is an
#    ABSOLUTE PATH from whichever machine executed the notebook.  A stored notebook may not contain
#    one (scripts/scrub-notebooks.py is a CI gate) and re-executing would put them straight back, so
#    the message is suppressed at the source rather than cleaned up afterwards.  Nothing is hidden:
#    every cell below prints the file NAMES it fetched and the free disk before and after.
try:
    import pooch

    pooch.get_logger().setLevel("WARNING")
except Exception:  # pooch absent or its API moved: the scrub script is the backstop
    pass

SEED = 20260918
rng = np.random.default_rng(SEED)

# How much of each dataset is read.  FULL_RUN is the thorough local run; the stored run is the
# documented subset.  None of the conclusions below depends on the subset size, and section 3
# prints the spread across subjects so a reader can see how stable each number is.
FULL_RUN = False
N_EEGBCI = 5 if not FULL_RUN else 10          # ds-eegbci subjects for the resting spectra
N_AREEG = 3 if not FULL_RUN else 10           # ds-areeg participants
AREEG_TRIALS = None                            # None = every recording that participant has

print(f"MNE {mne.__version__}; helpers imported from notebooks/_shared "
      f"(helpers_l7 present: {HAVE_L7})")
print(f"FULL_RUN = {FULL_RUN}: {N_EEGBCI} ds-eegbci subjects, {N_AREEG} ds-areeg participants, "
      f"ds-brain-invaders subject 1")
MNE 1.10.2; helpers imported from notebooks/_shared (helpers_l7 present: True)
FULL_RUN = False: 5 ds-eegbci subjects, 3 ds-areeg participants, ds-brain-invaders subject 1

1 · The three device classes, as the directory records them

Every fact in the table below comes from data/directory.yaml (built from the site's dataset catalog), read live from the repository rather than typed here. That matters for this lesson in particular: consumer-device specifications are exactly the place where a remembered number goes wrong, and the licence review that closed ds-mpeng found a licence taken from a secondary route that was not the data's own.

In [2]:
# data/directory.yaml, found by walking up from the working directory (no absolute path).
# helpers_l5 reads the same file for its licence lines; this is the acquisition side of it, which
# no shared helper exposes yet (recorded as a request to helpers_l7 in site/notes/notebooks-L7b.md).
import yaml

_DIR_YAML = next((d / "data" / "directory.yaml" for d in (Path.cwd(), *Path.cwd().parents)
                  if (d / "data" / "directory.yaml").exists()), None)
if _DIR_YAML is None:
    raise FileNotFoundError("data/directory.yaml not found above the working directory")
DIRECTORY = {e["id"]: e for e in yaml.safe_load(_DIR_YAML.read_text())["datasets"]}
print(f"data/directory.yaml: {len(DIRECTORY)} dataset entries")

CLASSES = {
    "ds-eegbci": "64-ch gel research cap",
    "ds-brain-invaders": "16-ch dry research headset",
    "ds-areeg": "14-ch consumer saline headset",
    "ds-mpeng": "14-ch consumer saline headset (named only -- CC BY-NC)",
}
FIELDS = ("device_class", "device", "channels", "sfreq_hz", "reference", "online_filters",
          "mains_hz", "paradigms", "population")
rows = []
for ds, label in CLASSES.items():
    e = DIRECTORY[ds]
    row = {"dataset": ds, "class in this notebook": label}
    for f in FIELDS:
        v = e.get(f, e.get(f + "_note", "TODO(confirm)"))
        row[f] = ", ".join(map(str, v)) if isinstance(v, list) else str(v)
    rows.append(row)
facts = pd.DataFrame(rows).set_index("dataset")
for col in facts.columns:
    print(f"\n--- {col} ---")
    for ds in facts.index:
        print(f"   {ds:20s} {facts.loc[ds, col]}")
data/directory.yaml: 28 dataset entries

--- class in this notebook ---
   ds-eegbci            64-ch gel research cap
   ds-brain-invaders    16-ch dry research headset
   ds-areeg             14-ch consumer saline headset
   ds-mpeng             14-ch consumer saline headset (named only -- CC BY-NC)

--- device_class ---
   ds-eegbci            research-cap
   ds-brain-invaders    research-dry
   ds-areeg             consumer
   ds-mpeng             consumer

--- device ---
   ds-eegbci            BCI2000, 64-channel 10-10 cap
   ds-brain-invaders    g.tec g.USBamp with 16 g.Sahara active dry electrodes
   ds-areeg             14-channel wireless saline-electrode headset (Emotiv EPOC X)
   ds-mpeng             14-channel wireless saline-electrode headset (Emotiv EPOC X)

--- channels ---
   ds-eegbci            TODO(confirm)
   ds-brain-invaders    TODO(confirm)
   ds-areeg             TODO(confirm)
   ds-mpeng             TODO(confirm)

--- sfreq_hz ---
   ds-eegbci            TODO(confirm)
   ds-brain-invaders    TODO(confirm)
   ds-areeg             TODO(confirm)
   ds-mpeng             TODO(confirm)

--- reference ---
   ds-eegbci            TODO(confirm)
   ds-brain-invaders    right earlobe
   ds-areeg             CMS/DRL reference electrodes above the ears
   ds-mpeng             TODO(confirm)

--- online_filters ---
   ds-eegbci            none
   ds-brain-invaders    none
   ds-areeg             device factory front-end; not documented in the paper
   ds-mpeng             device hardware default (built-in band-pass; the paper does not document an online notch)

--- mains_hz ---
   ds-eegbci            60
   ds-brain-invaders    50
   ds-areeg             TODO(confirm)
   ds-mpeng             60

--- paradigms ---
   ds-eegbci            rest-eo, rest-ec, motor-execution, motor-imagery
   ds-brain-invaders    p300-oddball-bci
   ds-areeg             imagined-speech
   ds-mpeng             video-game, rest-eo, rest-ec

--- population ---
   ds-eegbci            109 volunteers; demographics undocumented
   ds-brain-invaders    64 healthy adults (of 71 recorded), 57 BCI-naive; demographics undocumented
   ds-areeg             22 Arabic-speaking adults 17–25 (17 M / 5 F)
   ds-mpeng             39 adults, mean 24.3 y (30 M / 9 F)
In [3]:
# Licences, from the same file, through the helper that formats them.
L5.print_licences("ds-eegbci", "ds-brain-invaders", "ds-areeg", "ds-mpeng", notes=True)
print()
for ds in ("ds-eegbci", "ds-brain-invaders", "ds-areeg", "ds-mpeng"):
    cav = DIRECTORY[ds].get("caveats", [])
    print(f"{ds} -- caveats the catalog records:")
    for c in cav:
        print(f"   - {c}")
    print()
  ds-eegbci — EEG Motor Movement/Imagery Dataset (EEGMMIDB): licence ODC-By-1.0, access open (data/directory.yaml)
      ODC-By 1.0 on PhysioNet; CC0 on the OpenNeuro BIDS mirror (ds004362)
  ds-brain-invaders — Brain Invaders bi2014a: licence CC-BY-4.0, access open (data/directory.yaml)
  ds-areeg — ArEEG_Words: licence CC-BY-4.0, access open (data/directory.yaml)
  ds-mpeng — MultiPENG (EEG stream): licence CC-BY-NC-4.0, license_status: contested, access open (data/directory.yaml)
      CONTESTED between this site's own catalog and the repository, and the repository is right. §13 item 24
      asked only whether a Kaggle account is needed; answering it meant reading the Kaggle record, and the
      licence there is not the one the catalog holds. Four statements, all read 2026-09-18. (1) The catalog
      registry records license.name CC-BY-4.0 with license.doi 10.1109/IEEEDATA.2025.3553097. That DOI is the
      IEEE Data Descriptions descriptor PAPER, and Crossref gives that paper's own licence as
      https://creativecommons.org/licenses/by/4.0/legalcode (Unpaywall likewise: is_oa true, oa_status hybrid,
      licence cc-by). So the recorded CC-BY-4.0 is the ARTICLE's licence, not the data's. (2) Kaggle's own
      record for the dataset, read from its public metadata endpoint, states licenseName:
      'Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)'. (3) The depositing authors' own
      description text on that same Kaggle page says it in their words and gives the reason: 'This dataset was
      collected as part of a research study approved by the Research Ethics Board (REB) of the University of
      Ottawa (Protocol #H-07-23-9439). All participants provided informed consent for their data to be shared
      for academic and non-commercial research purposes only. In accordance with the ethics approval and
      participant consent, this dataset is released under a CC BY-NC 4.0 license, which restricts usage to
      non-commercial applications while requiring appropriate attribution to the original authors.' It then
      requires that researchers 'Use the data solely for academic or non-commercial research purposes'. (4)
      SILENCES, recorded as silences: the DataCite record for the dataset DOI 10.34740/kaggle/ds/6552328 has
      an empty rightsList, and the authors' analysis repository on GitHub carries no LICENSE file. §10.7
      already decides this shape of conflict — 'where the article's own license differs from the data license
      (ds-emotions, ds-srm, ds-respect) ... the repository's data license governs' — so the governing licence
      is CC BY-NC 4.0 and this dataset is §10.7 class C (non-commercial), not class A. license.name is
      recorded here as CC-BY-NC-4.0, but the registry wins in build_dataset_pages.build_entry and still states
      CC-BY-4.0, so license_status: contested is set to close the gate now; snippets derives to 'no' by that
      route today and by the NC name once the registry is corrected. THE FIX BELONGS IN THE REGISTRY, not
      here: §10.11 item 1 back-fills license.name from prose and item 3 asks for exactly this kind of
      article-vs-data conflict to be recorded there. The author should set mpeng license.name to CC-BY-NC-4.0
      and keep 10.1109/IEEEDATA.2025.3553097 as the paper DOI rather than as a licence DOI. NOT ESTABLISHED:
      the descriptor paper's own wording about the data licence could not be read — IEEE Xplore served an HTTP
      202 interstitial to an anonymous reader — so whether the paper states CC BY-NC for the data as well is
      TODO(confirm).

ds-eegbci -- caveats the catalog records:
   - Recorded with no hardware filters: line noise and drift are all there.
   - Subjects S088, S089, S092 and S100 carry inconsistent event timestamps; S038 and S104 are also often dropped.
   - Channel names use Sharbrough-style labels that must be mapped for a montage.

ds-brain-invaders -- caveats the catalog records:
   - Dry electrodes, no online filter, 50 Hz mains.
   - 64 of 71 recorded subjects released.

ds-areeg -- caveats the catalog records:
   - Nominal 128 Hz output but ≈ 43 Hz hardware bandwidth and built-in 50/60 Hz notches — the Nyquist frequency is not the usable bandwidth.

ds-mpeng -- caveats the catalog records:
   - LICENCE: CC BY-NC 4.0 per the repository and per the depositing authors' own words, not the CC BY 4.0 the catalog registry records — the registry appears to have captured the descriptor article's licence instead of the data's. No asset derived from this dataset ships, and no notebook downloads it, until the author corrects the registry (§10.11 items 1 and 3). See license.note for all four statements.
   - Consent-limited, not merely licence-limited: the authors state that participants consented to sharing 'for academic and non-commercial research purposes only' under University of Ottawa REB protocol #H-07-23-9439. The NC term is downstream of the consent, so it is not the kind of restriction a licence decision can trade away.
   - 18.3 GB as a single bundle with no established per-file anonymous route, for a dataset whose EEG is a small fraction of the payload. Even with a permissive licence this would be a poor fit for a notebook on a small machine.
   - Only ~50% of samples meet the authors' quality criterion (vendor-computed EQ.OVERALL ≥ 75%) during intense play.
   - The CSV interleaves 14 EEG columns with contact-quality, "performance-metric" and band-power columns that are vendor-derived, not raw signal.
   - Nominal 128 Hz output but ≈ 43 Hz hardware bandwidth and built-in 50/60 Hz notches (the descriptor documents only the built-in band-pass; notch and ceiling are inferred from the device specification).

The one that is named and not loaded

ds-mpeng is a 14-channel consumer recording made during video-game play, and §6 originally listed it beside ds-areeg for this lesson. Its data licence is CC BY-NC 4.0 — the depositors state that participant consent under their research-ethics protocol permits academic and non-commercial use only — which is spec §10.7 class C. Class C is never downloaded by a notebook and ships nothing.

The lesson loses a dataset, not a teaching point. The three facts L7.5 wanted from it are facts about a device class and about vendor exports, and all three can be shown on ds-areeg, which is the same class of hardware under CC BY 4.0:

Recorded fact about ds-mpeng Where this notebook shows the same thing
≈ 43 Hz hardware bandwidth behind a nominal 128/256 Hz output Section 4, measured on ds-areeg
Built-in 50/60 Hz notches Section 4, measured on ds-areeg
CSV interleaves 14 EEG columns with contact-quality, band-power and "performance-metric" columns that are vendor-derived, not raw signal Section 5, on ds-areeg's CQ.*, EQ.* and MOT.* columns
Only ~50 % of samples meet the authors' quality criterion (vendor-computed EQ.OVERALL ≥ 75 %) during intense play Section 5 computes the same criterion on ds-areeg, whose task is not intense play — so the two numbers are a contrast, not a substitute

The §6 note "ds-mpeng if its 15-s eyes-open/closed baseline is present in the per-session exports — TODO(confirm)" is moot: the licence closes the question before the baseline one is reached.

2 · What each recording can answer, and why

Before any analysis: a capability matrix that separates what the hardware forbids from what this particular recording happens not to contain. Both stop an analysis; only the first is a property of the device class, and a comparison that blurs them is not product-neutral, it is just wrong.

In [4]:
ANALYSES = {
    "alpha peak / IAF": {
        "needs": "a posterior electrode, >= 30 s of a resting or low-demand condition, resolution well "
                 "below 1 Hz, usable bandwidth to ~15 Hz",
    },
    "aperiodic slope (1-40 Hz)": {
        "needs": "usable bandwidth across the whole fit range; a hardware roll-off inside the range "
                 "becomes part of the fitted slope",
    },
    "mu ERD lateralisation": {
        "needs": "C3 and C4 (or near), a motor execution/imagery paradigm with cues, and a reference "
                 "that does not itself carry the sensorimotor signal",
    },
    "P300 amplitude": {
        "needs": "a centro-parietal electrode (Pz/CPz), an oddball or comparable rare-event paradigm, "
                 "and enough trials for an average",
    },
    "scalp topography / source": {
        "needs": "enough electrodes with known positions to interpolate a field -- and an average "
                 "reference that approximates an inactive one",
    },
}
DEVICE_CHANNELS = {
    "ds-eegbci": None,           # filled from the loaded recordings below
    "ds-brain-invaders": None,
    "ds-areeg": ("AF3", "F7", "F3", "FC5", "T7", "P7", "O1", "O2", "P8", "T8", "FC6", "F4", "F8", "AF4"),
}
PARADIGM = {
    "ds-eegbci": "eyes-open/eyes-closed baselines + motor execution and imagery (cued)",
    "ds-brain-invaders": "visual P300 oddball (Brain Invaders game), target/non-target",
    "ds-areeg": "imagined speech of 16 Arabic words, 10 s per word with eyes closed, no cue-locked "
                "sensory or motor event",
}
for ds, p in PARADIGM.items():
    print(f"{ds:20s} paradigm: {p}")
print()
for a, d in ANALYSES.items():
    print(f"{a}\n   needs: {d['needs']}")
print()
print("The matrix is filled in section 9, after every entry in it has a measured number behind it.")
ds-eegbci            paradigm: eyes-open/eyes-closed baselines + motor execution and imagery (cued)
ds-brain-invaders    paradigm: visual P300 oddball (Brain Invaders game), target/non-target
ds-areeg             paradigm: imagined speech of 16 Arabic words, 10 s per word with eyes closed, no cue-locked sensory or motor event

alpha peak / IAF
   needs: a posterior electrode, >= 30 s of a resting or low-demand condition, resolution well below 1 Hz, usable bandwidth to ~15 Hz
aperiodic slope (1-40 Hz)
   needs: usable bandwidth across the whole fit range; a hardware roll-off inside the range becomes part of the fitted slope
mu ERD lateralisation
   needs: C3 and C4 (or near), a motor execution/imagery paradigm with cues, and a reference that does not itself carry the sensorimotor signal
P300 amplitude
   needs: a centro-parietal electrode (Pz/CPz), an oddball or comparable rare-event paradigm, and enough trials for an average
scalp topography / source
   needs: enough electrodes with known positions to interpolate a field -- and an average reference that approximates an inactive one

The matrix is filled in section 9, after every entry in it has a measured number behind it.

3 · One estimator, three devices

Comparing an alpha peak across devices means using one estimator, not three. The estimator here is the one helpers.alpha_peak implements: fit a straight line to log power against log frequency over 2–40 Hz excluding 5–15 Hz, then take the largest residual inside 7–13 Hz and report it only when it clears 3 dB above that line.

Detrending matters more than usual here, because the three amplifiers have visibly different aperiodic slopes and one of them has a hardware roll-off inside the fit range. A raw arg-max over the alpha band would be comparing peak heights on different backgrounds.

helpers.alpha_peak takes an mne.io.Raw. Two of the three recordings are continuous, but ds-areeg is 16 separate ~11.5-second exports per participant, so its spectrum has to be averaged over segments before the peak is found. The cell below therefore implements the same algorithm on a (freqs, psd) pair and checks it against helpers.alpha_peak on a continuous recording so that the two are known to agree before it is used on the one that needs it.

In [5]:
FIT_RANGE = (2.0, 40.0)
ALPHA_BAND = (7.0, 13.0)
MIN_DB = 3.0


def peak_above_trend(freqs, psd_uv2_hz, *, band=ALPHA_BAND, fit_range=FIT_RANGE, min_db=MIN_DB):
    """helpers.alpha_peak's algorithm on a spectrum instead of a Raw.

    Straight-line fit of 10*log10(PSD) against log10(f) over ``fit_range`` excluding 5-15 Hz, then the
    largest residual inside ``band``.  Returns frequency (Hz), height above the trend (dB), and the
    fitted slope, which is reported because a device's roll-off shows up in it.
    """
    freqs = np.asarray(freqs, float)
    psd = np.asarray(psd_uv2_hz, float)
    if psd.ndim > 1:
        psd = psd.mean(axis=0)
    keep_fit = (freqs >= fit_range[0]) & (freqs <= fit_range[1]) & (psd > 0)
    f, p = freqs[keep_fit], 10 * np.log10(psd[keep_fit])
    keep = (f < 5) | (f > 15)
    slope, icpt = np.polyfit(np.log10(f[keep]), p[keep], 1)
    resid = p - (slope * np.log10(f) + icpt)
    m = (f >= band[0]) & (f <= band[1])
    i = int(np.argmax(np.where(m, resid, -np.inf)))
    # A maximum sitting ON the search limit is the band's choice, not the data's: the residual is still
    # rising where the band stops.  A recording with no posterior rhythm does exactly that, and calling
    # the edge an "alpha peak" manufactures a value.  Reported as absent instead.  (nb-7-6 uses the same
    # guard; without it four eyes-open ds-iowapd recordings there all landed on 7.00 Hz and produced a
    # group difference built entirely on non-peaks.)
    idx = np.where(m)[0]
    at_edge = bool(i == idx[0] or i == idx[-1])
    ok = (resid[i] >= min_db) and not at_edge
    return {"freq_hz": float(f[i]) if ok else None, "at_band_edge": at_edge,
            "db_above_trend": float(resid[i]), "slope_db_per_decade": float(slope)}


def welch_uv2(x_uv, sfreq, *, seconds=2.0):
    """Welch PSD in uV^2/Hz with a Hann window of ``seconds`` and 50 % overlap."""
    nperseg = int(round(seconds * sfreq))
    nperseg = min(nperseg, np.asarray(x_uv).shape[-1])
    f, p = sps.welch(np.asarray(x_uv, float), fs=sfreq, window="hann", nperseg=nperseg,
                     noverlap=nperseg // 2, detrend="constant", scaling="density")
    return f, p


print(f"estimator: line fit over {FIT_RANGE[0]:g}-{FIT_RANGE[1]:g} Hz excluding 5-15 Hz; peak = largest "
      f"residual in {ALPHA_BAND[0]:g}-{ALPHA_BAND[1]:g} Hz, reported only above {MIN_DB:g} dB and only "
      f"when it is not on the band's own edge")
estimator: line fit over 2-40 Hz excluding 5-15 Hz; peak = largest residual in 7-13 Hz, reported only above 3 dB and only when it is not on the band's own edge
In [6]:
dl = L4.Downloads("nb-7-5").start()

POSTERIOR = ("O1", "Oz", "O2", "PO3", "POz", "PO4", "PO7", "PO8", "P3", "Pz", "P4", "P7", "P8")


def existing_then_add(paths, downloads):
    """Register for deletion ONLY the files this run created.

    Two Phase 3 tracks sharing one cache deleted each other's subjects (notebooks/README.md).  A file
    that was already on disk when this notebook started was not downloaded by it and is left alone.
    """
    new = [p for p in paths if Path(p).is_file() and Path(p).stat().st_mtime >= _T_START]
    downloads.add(*new)
    return new


_T_START = time.time()

# --- ds-eegbci: R02, the one-minute eyes-CLOSED baseline ------------------------------------------
t0 = time.time()
eegbci_psd, eegbci_rows = {}, []
subjects = [f"S{i:03d}" for i in range(1, N_EEGBCI + 1)]
for s in subjects:
    paths = L4.fetch_eegbci(s, (2,), verbose=False)
    existing_then_add(paths, dl)
    raw = helpers.load_spine("ds-eegbci", s, "R02")
    chs = [c for c in POSTERIOR if c in raw.ch_names]
    x = raw.get_data(picks=chs) * 1e6
    f, P = welch_uv2(x, raw.info["sfreq"])
    eegbci_psd[s] = (f, P.mean(0))
    pk = peak_above_trend(f, P.mean(0))
    ref = helpers.alpha_peak(raw, picks=POSTERIOR, band=ALPHA_BAND, fit_range=FIT_RANGE, min_db=MIN_DB)
    eegbci_rows.append({"subject": s, "n_eeg": len(mne.pick_types(raw.info, eeg=True)),
                        "duration_s": round(float(raw.times[-1]), 1),
                        "iaf_hz": pk["freq_hz"], "db": round(pk["db_above_trend"], 2),
                        "helpers.alpha_peak": ref["freq_hz"],
                        "agree": (pk["freq_hz"] == ref["freq_hz"])})
    DEVICE_CHANNELS["ds-eegbci"] = tuple(raw.copy().pick("eeg").ch_names)
    del raw
eegbci_tab = pd.DataFrame(eegbci_rows)
print(f"ds-eegbci: {len(eegbci_tab)} subjects, run R02 (eyes closed), {time.time() - t0:.0f} s")
print(eegbci_tab.to_string(index=False))
print(f"\nCROSS-CHECK: the notebook's spectrum-domain estimator agrees with helpers.alpha_peak on "
      f"{int(eegbci_tab.agree.sum())} of {len(eegbci_tab)} subjects "
      f"({'identical' if eegbci_tab.agree.all() else 'DISAGREEMENT -- see the rows above'}).")
free disk before nb-7-5: 1,900 MB
ds-eegbci: 5 subjects, run R02 (eyes closed), 1 s
subject  n_eeg  duration_s  iaf_hz    db  helpers.alpha_peak  agree
   S001     64        61.0    10.0 15.24                10.0   True
   S002     64        61.0    11.5 17.77                11.5   True
   S003     64        61.0    10.5 16.91                10.5   True
   S004     64        61.0    10.5 17.36                10.5   True
   S005     64        61.0    11.0  3.59                11.0   True

CROSS-CHECK: the notebook's spectrum-domain estimator agrees with helpers.alpha_peak on 5 of 5 subjects (identical).
In [7]:
# --- ds-brain-invaders: the 16-channel dry headset -------------------------------------------------
t0 = time.time()
raw_bi = helpers.load_spine("ds-brain-invaders", 1)
bi_channels = tuple(raw_bi.copy().pick("eeg").ch_names)
DEVICE_CHANNELS["ds-brain-invaders"] = bi_channels
chs = [c for c in POSTERIOR if c in raw_bi.ch_names]
x = raw_bi.get_data(picks=chs) * 1e6
f_bi, P_bi = welch_uv2(x, raw_bi.info["sfreq"])
P_bi = P_bi.mean(0)
pk_bi = peak_above_trend(f_bi, P_bi)
print(f"ds-brain-invaders subject 1: {len(bi_channels)} EEG channels at "
      f"{raw_bi.info['sfreq']:.0f} Hz, {raw_bi.times[-1]:.0f} s, loaded in {time.time() - t0:.0f} s")
print(f"   channels: {', '.join(bi_channels)}")
print(f"   posterior picks used: {', '.join(chs)}")
print(f"   alpha peak {pk_bi['freq_hz']} Hz at {pk_bi['db_above_trend']:.2f} dB above trend")
print()
print("CAVEAT, and it is not small: this recording is a P300 game, eyes open and visually engaged, not a")
print("resting baseline.  Alpha is suppressed by exactly that.  The number is comparable across the")
print("SPECTRAL ESTIMATOR, not across the STATE -- which is the confound section 8's degradation removes.")
ds-brain-invaders subject 1: 16 EEG channels at 512 Hz, 816 s, loaded in 1 s
   channels: Fp1, Fp2, F3, AFz, F4, T7, Cz, T8, P7, P3, Pz, P4, P8, O1, Oz, O2
   posterior picks used: O1, Oz, O2, P3, Pz, P4, P7, P8
   alpha peak None Hz at 0.43 dB above trend

CAVEAT, and it is not small: this recording is a P300 game, eyes open and visually engaged, not a
resting baseline.  Alpha is suppressed by exactly that.  The number is comparable across the
SPECTRAL ESTIMATOR, not across the STATE -- which is the confound section 8's degradation removes.
In [8]:
# --- ds-areeg: the 14-channel consumer headset ----------------------------------------------------
# One 32.2 MB zip on Mendeley Data is the whole dataset.  Rather than fetch it, the central directory
# is read over two HTTP range requests (about 125 kB) and only the members this notebook needs are
# pulled -- the same technique helpers_l4.aszed_index uses for ds-aszed, pointed at a different archive.
AREEG_URL = ("https://data.mendeley.com/public-files/datasets/7m472ykkx7/files/"
             "8b06aa16-8df4-436a-b95c-fc0f1d821be7/file_downloaded")
AREEG_ZIP_BYTES = 32_207_245           # Mendeley public API, record 7m472ykkx7 v1, read 2026-09-18
AREEG_CHANNELS = DEVICE_CHANNELS["ds-areeg"]
AREEG_SFREQ = 128.0

t0 = time.time()
areeg_ix = L4.aszed_index(AREEG_URL, size=AREEG_ZIP_BYTES, verbose=False)
members = [e for e in areeg_ix["entries"] if e["name"].lower().endswith(".csv")]
by_par = {}
for e in members:
    base = e["name"].split("/")[-1]
    m = re.match(r"par\.\s*(\d+)\s", base)
    by_par.setdefault(int(m.group(1)) if m else -1, []).append(e)
print(f"ds-areeg archive index: {len(areeg_ix['entries'])} members, {len(members)} CSV recordings, "
      f"read in {time.time() - t0:.1f} s over 2 range requests")
print(f"   participant ids parsed from filenames: "
      f"{sorted(k for k in by_par if k > 0)}")
print(f"   recordings per participant: "
      f"{ {k: len(v) for k, v in sorted(by_par.items()) if k > 0} }")
print(f"   filenames that do not match 'par.<N> ': {len(by_par.get(-1, []))}")
print()
print("TODO(confirm) -- a discrepancy with the descriptor, recorded rather than reconciled: the data")
print(f"   descriptor documents 22 participants and 352 recordings; this archive holds {len(members)} CSVs")
print(f"   whose filenames parse to participants 1-{max(k for k in by_par if k > 0)}, plus "
      f"{len(by_par.get(-1, []))} filenames that do not follow the pattern.  Filename-derived ids are not")
print("   a participant table, and this dataset ships no participants file (catalog: 'No formal")
print("   demographic/clinical metadata file beyond the in-paper participant table').")
ds-areeg archive index: 376 members, 359 CSV recordings, read in 2.7 s over 2 range requests
   participant ids parsed from filenames: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]
   recordings per participant: {1: 16, 2: 16, 3: 16, 4: 17, 5: 17, 6: 16, 7: 16, 8: 16, 9: 16, 10: 16, 11: 16, 12: 17, 13: 16, 14: 16, 15: 18, 16: 17, 17: 16, 18: 16, 19: 15, 20: 17, 21: 15, 22: 13, 23: 4}
   filenames that do not match 'par.<N> ': 1

TODO(confirm) -- a discrepancy with the descriptor, recorded rather than reconciled: the data
   descriptor documents 22 participants and 352 recordings; this archive holds 359 CSVs
   whose filenames parse to participants 1-23, plus 1 filenames that do not follow the pattern.  Filename-derived ids are not
   a participant table, and this dataset ships no participants file (catalog: 'No formal
   demographic/clinical metadata file beyond the in-paper participant table').
In [9]:
def areeg_load(entry):
    """One ArEEG CSV, inflated from the archive in memory.  Returns (metadata, DataFrame)."""
    txt = L4.aszed_extract(areeg_ix, entry["name"]).decode("utf-8", "replace")
    head, rest = txt.split("\n", 1)
    meta = {k.strip(): v.strip() for k, v in
            (kv.split(":", 1) for kv in head.split(",") if ":" in kv)}
    return meta, pd.read_csv(io.StringIO(rest))


def areeg_participant(par, *, max_trials=None):
    """Every recording of one ArEEG participant, fetched in parallel (the archive host's latency,
    not its bandwidth, is the cost: ~2 s per request, ~90 kB each)."""
    ents = sorted(by_par[par], key=lambda e: e["name"])[:max_trials]
    with ThreadPoolExecutor(max_workers=6) as ex:
        out = list(ex.map(areeg_load, ents))
    return ents, out


t0 = time.time()
areeg_par = sorted(k for k in by_par if k > 0)[:N_AREEG]
areeg_data, areeg_rows, areeg_bytes = {}, [], 0
for par in areeg_par:
    ents, loaded = areeg_participant(par, max_trials=AREEG_TRIALS)
    areeg_bytes += sum(e["compressed_bytes"] for e in ents)
    segs, quality = [], []
    for meta, df in loaded:
        X = df[[f"EEG.{c}" for c in AREEG_CHANNELS]].to_numpy(float)
        segs.append(X.T)                                    # (14, n_times), microvolts
        quality.append(df)
    areeg_data[par] = {"segments": segs, "frames": quality, "meta": loaded[0][0]}
    # Per-segment Welch, then average: the exports are separate recordings, so concatenating them
    # would put 15 discontinuities inside the windows and spread broadband power everywhere.
    Ps = [welch_uv2(s - s.mean(1, keepdims=True), AREEG_SFREQ)[1] for s in segs]
    f_ar = welch_uv2(segs[0], AREEG_SFREQ)[0]
    P = np.mean(Ps, axis=0)
    posterior = [AREEG_CHANNELS.index(c) for c in ("O1", "O2", "P7", "P8")]
    pk = peak_above_trend(f_ar, P[posterior])
    areeg_data[par]["freqs"], areeg_data[par]["psd"] = f_ar, P
    areeg_rows.append({"participant": f"par.{par}", "n_recordings": len(segs),
                       "total_s": round(sum(s.shape[1] for s in segs) / AREEG_SFREQ, 1),
                       "iaf_hz": pk["freq_hz"], "db": round(pk["db_above_trend"], 2),
                       "slope_dB_per_decade": round(pk["slope_db_per_decade"], 2)})
areeg_tab = pd.DataFrame(areeg_rows)
print(f"ds-areeg: {len(areeg_tab)} participants, {int(areeg_tab.n_recordings.sum())} recordings, "
      f"{areeg_bytes / 1e6:.1f} MB fetched of a {AREEG_ZIP_BYTES / 1e6:.1f} MB archive, "
      f"{time.time() - t0:.0f} s")
print(areeg_tab.to_string(index=False))
print()
print(f"header of the first recording: {areeg_data[areeg_par[0]]['meta']}")
print()
print("What the 10-s imagined-speech period is, for this purpose: eyes closed, no visual input, a")
print("light cognitive task.  It is not a resting baseline and this notebook does not call it one.")
print("TODO(confirm): each export runs ~11.5 s against a protocol whose imagine period is 10 s; which")
print("part of the trial the export covers is not stated in the file, so the whole export is used.")
ds-areeg: 3 participants, 48 recordings, 4.4 MB fetched of a 32.2 MB archive, 18 s
participant  n_recordings  total_s  iaf_hz    db  slope_dB_per_decade
      par.1            16    185.4    12.0 10.14               -12.78
      par.2            16    174.5    11.5  6.48               -16.66
      par.3            16    173.4    11.5  9.82                -7.04

header of the first recording: {'title': 'aya اختر', 'start timestamp': '1702803254.654390', 'stop timestamp': '1702803266.202539', 'headset type': 'EPOCX', 'headset serial': 'E50206EF', 'headset firmware': '720', 'channels': '67', 'sampling rate': 'eeg_128;mot_32', 'samples': '1480', 'version': '2.2'}

What the 10-s imagined-speech period is, for this purpose: eyes closed, no visual input, a
light cognitive task.  It is not a resting baseline and this notebook does not call it one.
TODO(confirm): each export runs ~11.5 s against a protocol whose imagine period is 10 s; which
part of the trial the export covers is not stated in the file, so the whole export is used.
In [10]:
fig, axes = plt.subplots(1, 2, figsize=(12.5, 4.4))

ax = axes[0]
f_e, P_e = eegbci_psd[subjects[0]]
ax.semilogy(f_e, P_e, lw=1.4, label=f"ds-eegbci {subjects[0]} R02, 64 ch @ 160 Hz (eyes closed)")
ax.semilogy(f_bi, P_bi, lw=1.4, label="ds-brain-invaders sub 1, 16 ch dry @ 512 Hz (P300 game)")
par0 = areeg_par[0]
posterior = [AREEG_CHANNELS.index(c) for c in ("O1", "O2", "P7", "P8")]
ax.semilogy(areeg_data[par0]["freqs"], areeg_data[par0]["psd"][posterior].mean(0), lw=1.4,
            label=f"ds-areeg par.{par0}, 14 ch consumer @ 128 Hz (imagined speech, eyes closed)")
ax.axvspan(43, 64, color="0.85", zorder=0)
ax.text(45, ax.get_ylim()[1] * 0.3, "above the consumer\nheadset's documented\n~43 Hz ceiling",
        fontsize=7.5, va="top")
ax.set(xlim=(1, 70), xlabel="Frequency (Hz)", ylabel="Power spectral density (µV²/Hz)",
       title="Posterior spectra, three device classes (µV²/Hz vs Hz)")
ax.legend(fontsize=7.4, loc="lower left")
ax.grid(alpha=0.3, which="both")

ax = axes[1]
groups = [("ds-eegbci\n64 ch gel", [r for r in eegbci_tab.iaf_hz if r]),
          ("ds-brain-invaders\n16 ch dry", [pk_bi["freq_hz"]] if pk_bi["freq_hz"] else []),
          ("ds-areeg\n14 ch consumer", [r for r in areeg_tab.iaf_hz if r])]
for i, (name, vals) in enumerate(groups):
    if vals:
        ax.scatter(np.full(len(vals), i) + rng.normal(0, 0.05, len(vals)), vals, s=44, alpha=0.85)
        ax.hlines(np.median(vals), i - 0.22, i + 0.22, color="k", lw=2.2)
    else:
        ax.text(i, 10, "no peak\nabove 3 dB", ha="center", fontsize=8)
ax.set_xticks(range(3), [g[0] for g in groups], fontsize=8)
ax.set(ylabel="Individual alpha frequency (Hz)", ylim=(6.5, 13.5),
       title="Alpha peak, one estimator, three classes (Hz)\nblack bar = median")
ax.grid(alpha=0.3, axis="y")
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
Figure 1 of notebook nb-7-5-low-channel, an output plot. The text around it states what it shows and the units of every axis.

4 · The ceiling the Nyquist frequency hides

pf-hardware-bandwidth-ceiling in one measurement. The directory records, for the consumer headsets, a nominal 128 or 256 Hz output but an effective hardware bandwidth of roughly 0.16–43 Hz with built-in mains notches. A learner reading only the export's sampling rate would put the usable ceiling at 64 Hz — the Nyquist frequency — and would analyse "gamma" in a band where the amplifier has already removed the signal.

The test does not need the manufacturer's number, and it should not use one: measure the local slope of each spectrum in sliding one-octave windows. EEG's own aperiodic background has an exponent of roughly 1–3, which is −10 to −30 dB per decade. An analog anti-alias or front-end filter is far steeper than that, so a sustained slope past −40 dB/decade — every window above some frequency, not one noisy window — is an amplifier, not a brain.

In [11]:
CEILING_SLOPE = -40.0        # dB per decade; steeper than any plausible aperiodic EEG background


def local_slopes(freqs, psd, *, width_oct=1.0, fmin=12.0, step=0.5):
    """Slope of log10 power against log10 frequency in sliding one-octave windows (dB/decade)."""
    freqs = np.asarray(freqs, float)
    psd = np.asarray(psd, float)
    out = []
    for fc in np.arange(fmin, freqs.max() / 2 ** (width_oct / 2) + 1e-9, step):
        lo, hi = fc / 2 ** (width_oct / 2), fc * 2 ** (width_oct / 2)
        m = (freqs >= lo) & (freqs <= hi) & (psd > 0)
        if m.sum() < 5:
            continue
        out.append((fc, float(np.polyfit(np.log10(freqs[m]), 10 * np.log10(psd[m]), 1)[0])))
    return np.asarray(out)


def sustained_ceiling(freqs, psd, *, threshold=CEILING_SLOPE, **kw):
    """Lowest frequency above which EVERY window is steeper than ``threshold``.

    One steep window is a notch or a noisy estimate; a ceiling is a slope that never comes back.
    Returns (frequency or None, the slope curve).
    """
    ls = local_slopes(freqs, psd, **kw)
    steep = (ls[:, 1] <= threshold).astype(int)
    sustained = np.flip(np.minimum.accumulate(np.flip(steep)))
    idx = np.where(sustained == 1)[0]
    return (float(ls[idx[0], 0]) if idx.size else None), ls


SPECTRA = {
    f"ds-eegbci {subjects[0]} R02 (64 ch, 160 Hz out)": eegbci_psd[subjects[0]],
    "ds-brain-invaders sub 1 (16 ch dry, 512 Hz out)": (f_bi, P_bi),
    f"ds-areeg par.{areeg_par[0]} (14 ch consumer, 128 Hz out)":
        (areeg_data[areeg_par[0]]["freqs"], areeg_data[areeg_par[0]]["psd"][posterior].mean(0)),
}
rows, slope_curves = [], {}
for label, (f, P) in SPECTRA.items():
    ceil, ls = sustained_ceiling(f, P)
    slope_curves[label] = ls
    at = {hz: (float(ls[np.argmin(np.abs(ls[:, 0] - hz)), 1]) if ls[:, 0].max() >= hz else np.nan)
          for hz in (20, 30, 40, 45)}
    rows.append({"recording": label, "Nyquist (Hz)": f.max(),
                 "sustained roll-off from (Hz)": ceil,
                 "slope @20 Hz": round(at[20], 1), "slope @30 Hz": round(at[30], 1),
                 "slope @40 Hz": round(at[40], 1), "slope @45 Hz": round(at[45], 1)})
roll = pd.DataFrame(rows)
print(f"Local spectral slope in sliding one-octave windows (dB per decade); a sustained slope past "
      f"{CEILING_SLOPE:g} dB/decade is the front end, not the brain:")
print(roll.to_string(index=False))
print()
print("ds-eegbci and ds-brain-invaders have NO sustained ceiling below their Nyquist frequencies, which is")
print("what the catalog says of both: no hardware filters on the first, no online filter on the second.")
print("Their slopes wander between about -10 and -50 dB/decade and always come back -- that is EEG plus")
print("muscle plus a mains line, not a filter.")
print()
ceil_ar = roll.iloc[2]["sustained roll-off from (Hz)"]
print(f"ds-areeg's slope leaves that range at {ceil_ar:g} Hz and never returns, reaching "
      f"{roll.iloc[2]['slope @45 Hz']:.0f} dB/decade by 45 Hz.")
print()
print("A DISAGREEMENT WITH A RECORDED FACT, reported rather than reconciled: data/directory.yaml records")
print(f"   'nominal 128 Hz output but ~= 43 Hz hardware bandwidth' for this device class, and the")
print(f"   sustained roll-off measured here begins at {ceil_ar:g} Hz -- lower than 43.  The two numbers are")
print("   not the same quantity: a manufacturer's 'bandwidth' figure is a single point on a response")
print("   (a -3 dB corner, usually) and this is the frequency past which the response is steepening")
print("   everywhere.  A corner at 43 Hz and a roll-off that has begun by 31 Hz are compatible.")
print("   TODO(confirm): which convention the 43 Hz figure uses.  What both agree on, and what the lesson")
print(f"   needs, is that the usable ceiling is FAR below the "
      f"{roll.iloc[2]['Nyquist (Hz)']:.0f} Hz Nyquist frequency the export's sampling rate advertises.")
Local spectral slope in sliding one-octave windows (dB per decade); a sustained slope past -40 dB/decade is the front end, not the brain:
                                      recording  Nyquist (Hz)  sustained roll-off from (Hz)  slope @20 Hz  slope @30 Hz  slope @40 Hz  slope @45 Hz
         ds-eegbci S001 R02 (64 ch, 160 Hz out)          80.0                           NaN         -34.4         -32.9         -24.4         -23.1
ds-brain-invaders sub 1 (16 ch dry, 512 Hz out)         256.0                           NaN          -8.2          16.2          88.9          30.8
    ds-areeg par.1 (14 ch consumer, 128 Hz out)          64.0                          31.0         -20.3         -37.4         -97.9        -143.6

ds-eegbci and ds-brain-invaders have NO sustained ceiling below their Nyquist frequencies, which is
what the catalog says of both: no hardware filters on the first, no online filter on the second.
Their slopes wander between about -10 and -50 dB/decade and always come back -- that is EEG plus
muscle plus a mains line, not a filter.

ds-areeg's slope leaves that range at 31 Hz and never returns, reaching -144 dB/decade by 45 Hz.

A DISAGREEMENT WITH A RECORDED FACT, reported rather than reconciled: data/directory.yaml records
   'nominal 128 Hz output but ~= 43 Hz hardware bandwidth' for this device class, and the
   sustained roll-off measured here begins at 31 Hz -- lower than 43.  The two numbers are
   not the same quantity: a manufacturer's 'bandwidth' figure is a single point on a response
   (a -3 dB corner, usually) and this is the frequency past which the response is steepening
   everywhere.  A corner at 43 Hz and a roll-off that has begun by 31 Hz are compatible.
   TODO(confirm): which convention the 43 Hz figure uses.  What both agree on, and what the lesson
   needs, is that the usable ceiling is FAR below the 64 Hz Nyquist frequency the export's sampling rate advertises.
In [12]:
fig, axes = plt.subplots(1, 2, figsize=(12.5, 4.3))
ax = axes[0]
for label, ls in slope_curves.items():
    ax.plot(ls[:, 0], ls[:, 1], lw=1.5, label=label)
ax.axhline(CEILING_SLOPE, color="0.4", lw=1.0, ls="--")
ax.text(13, CEILING_SLOPE + 4, f"{CEILING_SLOPE:g} dB/decade: steeper than any\naperiodic EEG background",
        fontsize=7.4, color="0.3")
ax.axvline(43, color="0.6", lw=1.0, ls=":")
ax.text(43.8, -175, "documented\n~43 Hz", fontsize=7.2, color="0.4")
ax.set(xlim=(12, 70), ylim=(-200, 60), xlabel="Frequency (Hz)",
       ylabel="Local spectral slope (dB per decade)",
       title="Where the amplifier takes over (dB/decade vs Hz)")
ax.legend(fontsize=7.2, loc="lower left")
ax.grid(alpha=0.3)

ax = axes[1]
f_ar = areeg_data[areeg_par[0]]["freqs"]
for par in areeg_par:
    ax.semilogy(f_ar, areeg_data[par]["psd"][posterior].mean(0), lw=1.3, label=f"par.{par}")
for hz in (50, 60):
    ax.axvline(hz, color="0.6", lw=0.9, ls=":")
ax.axvline(64, color="k", lw=1.0)
ax.text(63, 3e-3, "Nyquist of the 128 Hz export", fontsize=7.2, ha="right", rotation=90)
ax.set(xlim=(1, 64), xlabel="Frequency (Hz)", ylabel="Power spectral density (µV²/Hz)",
       title="ds-areeg posterior spectra to Nyquist (µV²/Hz vs Hz)")
ax.legend(fontsize=8)
ax.grid(alpha=0.3, which="both")
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
Figure 2 of notebook nb-7-5-low-channel, an output plot. The text around it states what it shows and the units of every axis.
In [13]:
# Is a mains notch visible, or has the roll-off already removed everything at 50 and 60 Hz?
print(f"ds-areeg, mains region.  Recording location: Syria (catalog); "
      f"data/directory.yaml records mains_hz: {DIRECTORY['ds-areeg'].get('mains_hz')}.")
print()
P_ar = np.mean([areeg_data[p]["psd"][posterior].mean(0) for p in areeg_par], axis=0)
tot = np.trapezoid(P_ar[(f_ar >= 1) & (f_ar <= 64)], f_ar[(f_ar >= 1) & (f_ar <= 64)])
for lo, hi in ((8, 12), (20, 25), (30, 35), (38, 43), (45, 50), (48, 52), (55, 62)):
    m = (f_ar >= lo) & (f_ar <= hi)
    band = np.trapezoid(P_ar[m], f_ar[m])
    print(f"   {lo:2d}-{hi:2d} Hz : {band:9.4f} uV^2 ({100 * band / tot:6.3f} % of 1-64 Hz)")
print()
print("A notch cannot be told apart from the roll-off here, and that is the honest reading: by 50 Hz the")
print("band-limited front end has already removed so much that there is nothing left for a notch to")
print("remove.  The catalog states the notches as a device specification; this recording neither")
print("confirms nor contradicts them, and TODO(confirm) is the right answer to 'can you see the notch'.")
ds-areeg, mains region.  Recording location: Syria (catalog); data/directory.yaml records mains_hz: TODO(confirm).

    8-12 Hz :   31.9966 uV^2 (16.865 % of 1-64 Hz)
   20-25 Hz :    6.0702 uV^2 ( 3.200 % of 1-64 Hz)
   30-35 Hz :    3.9069 uV^2 ( 2.059 % of 1-64 Hz)
   38-43 Hz :    1.4801 uV^2 ( 0.780 % of 1-64 Hz)
   45-50 Hz :    0.1373 uV^2 ( 0.072 % of 1-64 Hz)
   48-52 Hz :    0.0834 uV^2 ( 0.044 % of 1-64 Hz)
   55-62 Hz :    0.0049 uV^2 ( 0.003 % of 1-64 Hz)

A notch cannot be told apart from the roll-off here, and that is the honest reading: by 50 Hz the
band-limited front end has already removed so much that there is nothing left for a notch to
remove.  The catalog states the notches as a device specification; this recording neither
confirms nor contradicts them, and TODO(confirm) is the right answer to 'can you see the notch'.

5 · The columns that are not signal

A consumer export is not a raw file with extra metadata; it is a vendor product, and most of its columns are computed by the headset's firmware rather than measured at an electrode. ds-areeg's CSV has 67 columns and 14 of them are EEG.

Three families matter for anyone analysing this class of data:

  • CQ.*contact quality per electrode (and CQ.Overall): an impedance-like indicator, on the vendor's own scale.
  • EQ.*signal ("EEG") quality per electrode, plus EQ.OVERALL and EQ.SampleRateQuality. This is the family the catalog names for ds-mpeng: only about 50 % of samples meet the authors' quality criterion (vendor-computed EQ.OVERALL ≥ 75 %) during intense play.
  • MOT.* — a 9-axis motion block (quaternions, accelerometer, magnetometer) at a different sampling rate from the EEG.

A pipeline that reads "every numeric column" into a feature matrix trains on the vendor's opinion of the data alongside the data, and the quality columns correlate with the very artifacts a classifier should be ignoring.

In [14]:
frames = [df for par in areeg_par for df in areeg_data[par]["frames"]]
cols = frames[0].columns
fam = {"EEG (signal)": [c for c in cols if c.startswith("EEG.") and c.split(".", 1)[1] in AREEG_CHANNELS],
       "EEG (housekeeping)": [c for c in cols if c.startswith("EEG.")
                              and c.split(".", 1)[1] not in AREEG_CHANNELS],
       "CQ (contact quality)": [c for c in cols if c.startswith("CQ.")],
       "EQ (signal quality)": [c for c in cols if c.startswith("EQ.")],
       "MOT (motion)": [c for c in cols if c.startswith("MOT.")],
       "markers / timestamps": [c for c in cols if c.startswith(("Timestamp", "Original", "Marker"))]}
print(f"{len(cols)} columns in an ArEEG export:")
for k, v in fam.items():
    print(f"   {len(v):3d}  {k:22s} {', '.join(v[:6])}{' ...' if len(v) > 6 else ''}")
print(f"\n   {len(fam['EEG (signal)'])} of {len(cols)} columns "
      f"({100 * len(fam['EEG (signal)']) / len(cols):.0f} %) are electrode signal.")
print()
# The vendor criterion, computed.  EQ.OVERALL is written only when the firmware updates it.
eq = pd.concat([df["EQ.OVERALL"] for df in frames])
updates = eq.dropna()
print(f"EQ.OVERALL is present on {len(updates):,} of {len(eq):,} rows "
      f"({100 * len(updates) / len(eq):.1f} %) -- the firmware updates it about "
      f"{len(updates) / (len(eq) / 128.0):.1f} times a second, so it is a periodic report, not a "
      f"per-sample flag.")
print(f"   values seen: {sorted(updates.unique().tolist())}")
frac75 = float((updates >= 75).mean())
print(f"   fraction of quality updates meeting the ds-mpeng criterion (EQ.OVERALL >= 75 %): "
      f"{100 * frac75:.1f} %")
print()
print("Beside the recorded fact for ds-mpeng -- about 50 % of samples during intense video-game play --")
print(f"this imagined-speech task at {100 * frac75:.0f} % is the contrast the lesson wants: the same class")
print("of hardware and the same vendor criterion, with and without vigorous movement.  The two numbers")
print("are not measured the same way (samples there, firmware updates here) and are not interchangeable.")
68 columns in an ArEEG export:
    14  EEG (signal)           EEG.AF3, EEG.F7, EEG.F3, EEG.FC5, EEG.T7, EEG.P7 ...
     6  EEG (housekeeping)     EEG.Counter, EEG.Interpolated, EEG.RawCq, EEG.Battery, EEG.BatteryPercent, EEG.MarkerHardware
    15  CQ (contact quality)   CQ.AF3, CQ.F7, CQ.F3, CQ.FC5, CQ.T7, CQ.P7 ...
    16  EQ (signal quality)    EQ.SampleRateQuality, EQ.OVERALL, EQ.AF3, EQ.F7, EQ.F3, EQ.FC5 ...
    12  MOT (motion)           MOT.CounterMems, MOT.InterpolatedMems, MOT.Q0, MOT.Q1, MOT.Q2, MOT.Q3 ...
     5  markers / timestamps   Timestamp, OriginalTimestamp, MarkerIndex, MarkerType, MarkerValueInt

   14 of 68 columns (21 %) are electrode signal.

EQ.OVERALL is present on 1,068 of 68,259 rows (1.6 %) -- the firmware updates it about 2.0 times a second, so it is a periodic report, not a per-sample flag.
   values seen: [8.333333, 16.666666, 25.0, 33.333332, 41.666668, 50.0, 58.333332, 66.666664, 75.0, 83.333336, 91.666664, 100.0]
   fraction of quality updates meeting the ds-mpeng criterion (EQ.OVERALL >= 75 %): 71.8 %

Beside the recorded fact for ds-mpeng -- about 50 % of samples during intense video-game play --
this imagined-speech task at 72 % is the contrast the lesson wants: the same class
of hardware and the same vendor criterion, with and without vigorous movement.  The two numbers
are not measured the same way (samples there, firmware updates here) and are not interchangeable.
In [15]:
# Per-electrode quality, and the one that matters most for this analysis.
per_ch = pd.DataFrame({c: pd.concat([df[f"EQ.{c}"] for df in frames]).dropna()
                       for c in AREEG_CHANNELS}).mean().sort_values()
print("Mean EQ.* per electrode over every loaded recording (vendor scale, higher is better):")
for c, v in per_ch.items():
    flag = "  <-- posterior, and the worst-scored electrode in the set" if c == per_ch.index[0] and c in ("O1", "O2", "P7", "P8") else ""
    print(f"   {c:4s} {v:5.2f}{flag}")
print()
worst = per_ch.index[0]
print(f"The vendor's own column flags {worst} as the least trustworthy electrode of the fourteen.")
if worst in ("O1", "O2", "P7", "P8"):
    print("It is also one of the four posterior electrodes this notebook measures alpha on.  That is the")
    print("whole argument for reading the quality columns BEFORE choosing a channel, rather than after a")
    print("result disappoints -- and it is a decision the 64-channel cap does not have to make, because")
    print("it has eleven posterior electrodes and can drop one.")
print()
mot = [c for c in cols if c.startswith("MOT.Acc")]
m0 = frames[0]
print(f"Motion block: {', '.join(mot)} is present on {100 * (1 - m0[mot[0]].isna().mean()):.0f} % of rows "
      f"-- the header declares 'sampling rate: {areeg_data[areeg_par[0]]['meta'].get('sampling rate')}', so "
      f"the motion channels run at a quarter of the EEG rate and the column is padded with blanks.")
print("Reading this CSV with a naive 'drop rows with missing values' throws away three quarters of the EEG.")
Mean EQ.* per electrode over every loaded recording (vendor scale, higher is better):
   O1    2.80  <-- posterior, and the worst-scored electrode in the set
   T8    3.62
   P7    3.76
   T7    3.78
   F3    3.79
   P8    3.82
   FC5   3.82
   O2    3.92
   F7    3.92
   FC6   3.92
   F4    3.93
   AF4   3.93
   AF3   3.94
   F8    3.94

The vendor's own column flags O1 as the least trustworthy electrode of the fourteen.
It is also one of the four posterior electrodes this notebook measures alpha on.  That is the
whole argument for reading the quality columns BEFORE choosing a channel, rather than after a
result disappoints -- and it is a decision the 64-channel cap does not have to make, because
it has eleven posterior electrodes and can drop one.

Motion block: MOT.AccX, MOT.AccY, MOT.AccZ is present on 25 % of rows -- the header declares 'sampling rate: eeg_128;mot_32', so the motion channels run at a quarter of the EEG rate and the column is padded with blanks.
Reading this CSV with a naive 'drop rows with missing values' throws away three quarters of the EEG.

6 · Mu desynchronisation, where only one class can answer

Motor imagery needs C3 and C4 and a cued motor paradigm. Of the three recordings, only ds-eegbci has one — not because a 14-channel headset could not record C3 and C4 (it has no electrode there, which is a real hardware limit) and not because a dry 16-channel headset could not (it has Cz, T7 and T8 but no C3/C4), but because neither study ran a motor task. Section 9's matrix keeps those reasons apart.

The value below is a cross-check against a number this course already computed: nb-4-4-erd reports S001's right-fist imagery mu ERD at C3 as −47.87 % and at C4 as −10.46 % (band-first order, baseline −1.5…−0.5 s, active 0.5…3.5 s, 8–13 Hz, 64-channel average reference). Reproducing it here is what makes the degradation in section 8 interpretable: if the 64-channel number moves, the pipeline moved, not the montage.

nb-4-4 also records that S001 was selected as the subject with the largest mu ERD at C3 out of ten candidates, against a cohort median of −23.1 % (range −47.9 to +6.6). The degradation below is a within-subject comparison, so the selection does not bias it — but the absolute value is a selected one and is never quoted here without this sentence.

In [16]:
MI_SUBJECT = "S001"
t0 = time.time()
paths = L4.fetch_eegbci(MI_SUBJECT, L4.MI_RUNS, verbose=False)
existing_then_add(paths, dl)
epochs_mi, mi_info = L4.load_imagery_epochs(MI_SUBJECT, L4.MI_RUNS, reference="average")
print(f"ds-eegbci {MI_SUBJECT} runs {'+'.join(mi_info['runs'])}: {mi_info['n_eeg']} EEG channels at "
      f"{mi_info['sfreq']:.0f} Hz, epochs {mi_info['n_epochs']}, {time.time() - t0:.0f} s")
print(f"   conditions: T1 = left-fist imagery, T2 = right-fist imagery (the EDF's own annotations)")
print(f"   reference: {mi_info['reference']} over all {mi_info['n_eeg']} channels")

CH_MI = ("C3", "C4")
t0 = time.time()
tf = L4.morlet_power(epochs_mi["T2"], picks=CH_MI)
erd64 = {ch: L4.band_erd(tf["power"][:, i], tf["freqs"], tf["times"], L4.MU_BAND,
                         baseline=L4.TF_BASELINE, active=L4.TF_ACTIVE, order="band-first")
         for i, ch in enumerate(CH_MI)}
KEY_44 = {"C3": -47.87, "C4": -10.46}
print(f"\nmu ERD (8-13 Hz, band-first, baseline {L4.TF_BASELINE}, active {L4.TF_ACTIVE}), T2, "
      f"{mi_info['n_eeg']}-channel average reference, {time.time() - t0:.0f} s:")
for ch in CH_MI:
    d = erd64[ch] - KEY_44[ch]
    print(f"   {ch}: {erd64[ch]:+7.2f} %   nb-4-4 key {KEY_44[ch]:+7.2f} %   difference {d:+.3f} pp "
          f"{'-- reproduced' if abs(d) < 0.05 else '-- DISAGREEMENT, see the notes'}")
LAT64 = erd64["C3"] - erd64["C4"]
print(f"   lateralisation (C3 - C4) for right-fist imagery: {LAT64:+.2f} pp "
      f"(negative = more desynchronisation contralateral to the imagined hand, which is the expected sign)")
ds-eegbci S001 runs R04+R08+R12: 64 EEG channels at 160 Hz, epochs {'T0': 44, 'T1': 23, 'T2': 22}, 50 s
   conditions: T1 = left-fist imagery, T2 = right-fist imagery (the EDF's own annotations)
   reference: average over all 64 channels

mu ERD (8-13 Hz, band-first, baseline (-1.5, -0.5), active (0.5, 3.5)), T2, 64-channel average reference, 0 s:
   C3:  -47.87 %   nb-4-4 key  -47.87 %   difference +0.003 pp -- reproduced
   C4:  -10.46 %   nb-4-4 key  -10.46 %   difference -0.004 pp -- reproduced
   lateralisation (C3 - C4) for right-fist imagery: -37.40 pp (negative = more desynchronisation contralateral to the imagined hand, which is the expected sign)

7 · P300, where only the other class can answer

Symmetrically: ds-brain-invaders ran a visual oddball and has Pz; ds-eegbci ran no ERP paradigm and ds-areeg's imagined-speech protocol has no cue-locked sensory event to average to.

The pipeline is not invented here. nb-3-2-erp-core-p3 measured this same recording as its dry-electrode counterpoint to ERP CORE and reported +1.43 µV at Pz, 300–600 ms, target minus non-target — with the recording's own reference kept, a 0.1–40 Hz band-pass, a −200…0 ms baseline and a 150 µV peak-to-peak criterion that rejects 57 % of this subject's epochs against 6 % on the ERP CORE gel caps. Reproducing it is the cross-check; what happens when two of those decisions change is this section's actual lesson.

In [17]:
import helpers_l3 as L3

P3_CH, P3_WINDOW = L3.P3_CHANNEL, L3.P3_WINDOW
raw_bi_f = raw_bi.copy()
raw_bi_f.filter(L3.HP_HZ, L3.LP_HZ, picks="eeg", method="fir", fir_design="firwin", phase="zero",
                verbose=False)
events_bi = mne.find_events(raw_bi_f, stim_channel="STI 014", shortest_event=1, verbose=False)
ep_all = mne.Epochs(raw_bi_f, events_bi, helpers.BI2014A_EVENT_ID, tmin=L3.EPOCH_TMIN, tmax=L3.EPOCH_TMAX,
                    baseline=L3.EPOCH_BASELINE, picks="eeg", preload=True, verbose=False)
d_all = ep_all.get_data() * 1e6
ptp_all = (d_all.max(2) - d_all.min(2)).max(1)
keep_all = ptp_all <= L3.REJECT_PTP_UV
ep_clean = ep_all[np.where(keep_all)[0]]


def p300(epochs, *, ch=None, window=None):
    """Target minus non-target mean amplitude (uV) at one electrode, with its SME and trial counts."""
    ch = P3_CH if ch is None else ch
    window = P3_WINDOW if window is None else window
    if ch not in epochs.ch_names:
        return {"amp_uv": np.nan, "sme_uv": np.nan, "evoked": None,
                "n": {c: len(epochs[c]) for c in ("Target", "NonTarget")}}
    ev = {c: epochs[c].average() for c in ("Target", "NonTarget")}
    diff = mne.combine_evoked([ev["Target"], ev["NonTarget"]], weights=[1, -1])
    m = (diff.times >= window[0]) & (diff.times <= window[1])
    per = {c: epochs[c].get_data(picks=[ch])[:, 0, :][:, m].mean(1) * 1e6 for c in ev}
    return {"amp_uv": float(L3.mean_amplitude(diff, ch, window)),
            "sme_uv": float(np.sqrt(per["Target"].var(ddof=1) / len(per["Target"])
                                    + per["NonTarget"].var(ddof=1) / len(per["NonTarget"]))),
            "evoked": diff, "n": {c: len(per[c]) for c in per}}


p3_ref = p300(ep_clean)
diff_clean = mne.combine_evoked([ep_clean["Target"].average(), ep_clean["NonTarget"].average()],
                                weights=[1, -1])
KEY_32 = 1.43
print(f"ds-brain-invaders subject 1, the nb-3-2 pipeline: {L3.HP_HZ:g}-{L3.LP_HZ:g} Hz, epochs "
      f"{L3.EPOCH_TMIN:g}..{L3.EPOCH_TMAX:g} s, baseline {L3.EPOCH_BASELINE}, "
      f"{L3.REJECT_PTP_UV:g} uV peak-to-peak over all 16 channels, RECORDING REFERENCE KEPT")
print(f"   rejected {int((~keep_all).sum())} of {len(keep_all)} epochs ({100 * (~keep_all).mean():.0f} %) "
      f"-- kept {p3_ref['n']['Target']} Target / {p3_ref['n']['NonTarget']} NonTarget")
print(f"   P300 at {P3_CH}, {P3_WINDOW[0] * 1000:.0f}-{P3_WINDOW[1] * 1000:.0f} ms: "
      f"{p3_ref['amp_uv']:+.3f} uV   nb-3-2 key {KEY_32:+.2f} uV   "
      f"difference {p3_ref['amp_uv'] - KEY_32:+.3f} uV "
      f"{'-- reproduced' if abs(p3_ref['amp_uv'] - KEY_32) < 0.006 else '-- DISAGREEMENT, see the notes'}")
print(f"   nb-3-2 also records that Pz is not this subject's best site, and it is not: "
      f"P4 {L3.mean_amplitude(diff_clean, 'P4', P3_WINDOW):+.2f} uV, "
      f"Cz {L3.mean_amplitude(diff_clean, 'Cz', P3_WINDOW):+.2f} uV, "
      f"P3 {L3.mean_amplitude(diff_clean, 'P3', P3_WINDOW):+.2f} uV")
ds-brain-invaders subject 1, the nb-3-2 pipeline: 0.1-40 Hz, epochs -0.2..0.8 s, baseline (-0.2, 0.0), 150 uV peak-to-peak over all 16 channels, RECORDING REFERENCE KEPT
   rejected 679 of 1188 epochs (57 %) -- kept 84 Target / 425 NonTarget
   P300 at Pz, 300-600 ms: +1.431 uV   nb-3-2 key +1.43 uV   difference +0.001 uV -- reproduced
   nb-3-2 also records that Pz is not this subject's best site, and it is not: P4 +5.24 uV, Cz +4.99 uV, P3 +2.77 uV
In [18]:
# Two decisions, each of which flips the sign of this component on this recording.
def rereference(epochs):
    """Average over the channels that remain, applied to the data (not as a projection)."""
    d = epochs.get_data(copy=True)
    d = d - d.mean(axis=1, keepdims=True)
    return mne.EpochsArray(d, epochs.info.copy(), events=epochs.events, event_id=epochs.event_id,
                           tmin=epochs.tmin, verbose=False)


variants = [
    ("recording reference, 150 uV rejection  (nb-3-2)", p300(ep_clean)),
    ("recording reference, NO rejection", p300(ep_all)),
    ("16-channel average reference, 150 uV rejection", p300(rereference(ep_clean))),
    ("16-channel average reference, NO rejection", p300(rereference(ep_all))),
]
print(f"The same flashes, the same electrode, the same window -- {P3_CH}, "
      f"{P3_WINDOW[0] * 1000:.0f}-{P3_WINDOW[1] * 1000:.0f} ms, target minus non-target:\n")
print(f"{'pipeline':50s} {'P300 (uV)':>10s} {'SME (uV)':>9s} {'target n':>9s}")
for name, r in variants:
    print(f"{name:50s} {r['amp_uv']:+10.3f} {r['sme_uv']:9.3f} {r['n']['Target']:9d}")
print()
print("Two independent decisions each change the SIGN of a P300 on this recording, and neither is a")
print("mistake a reader would notice in a methods section:")
print("  - Artifact rejection.  57 % of these dry-electrode epochs exceed 150 uV against 6 % of ERP")
print("    CORE's gel epochs (nb-3-2).  Without rejection the average is dominated by what the dry")
print("    contacts did, not by what the brain did.")
print("  - The reference.  An average over 64 electrodes approximates an inactive reference; an average")
print("    over 16 that are mostly frontal and temporal does not, and a P300 is broad enough that much")
print("    of it lands in the reference and is subtracted from Pz.")
print()
print("This is the low-channel lesson in its sharpest form, and it is NOT a claim that dry electrodes")
print("are worse: it is that a pipeline transported unchanged from a 64-channel gel cap can invert a")
print("component on 16 dry ones, and the montage is the reason.  pf-reference-changes-everything.")
The same flashes, the same electrode, the same window -- Pz, 300-600 ms, target minus non-target:

pipeline                                            P300 (uV)  SME (uV)  target n
recording reference, 150 uV rejection  (nb-3-2)        +1.431     1.877        84
recording reference, NO rejection                      -1.243     2.365       198
16-channel average reference, 150 uV rejection         -1.174     1.532        84
16-channel average reference, NO rejection             -3.364     1.790       198

Two independent decisions each change the SIGN of a P300 on this recording, and neither is a
mistake a reader would notice in a methods section:
  - Artifact rejection.  57 % of these dry-electrode epochs exceed 150 uV against 6 % of ERP
    CORE's gel epochs (nb-3-2).  Without rejection the average is dominated by what the dry
    contacts did, not by what the brain did.
  - The reference.  An average over 64 electrodes approximates an inactive reference; an average
    over 16 that are mostly frontal and temporal does not, and a P300 is broad enough that much
    of it lands in the reference and is subtracted from Pz.

This is the low-channel lesson in its sharpest form, and it is NOT a claim that dry electrodes
are worse: it is that a pipeline transported unchanged from a 64-channel gel cap can invert a
component on 16 dry ones, and the montage is the reason.  pf-reference-changes-everything.

8 · The controlled degradation

Everything above compares three cohorts, three paradigms, three amplifiers, three references and three sampling rates at once. This section changes one thing.

Two four-channel montages are taken out of the 64-channel cap, and they are chosen to make a second point: which four electrodes matter as much as how many.

  • task-matched 4C3, C4, O1, O2: two sensorimotor sites and two posterior ones, the electrodes a designer would pick knowing the analysis.
  • consumer-shaped 4AF3, AF4, O1, O2: two frontal and two posterior, the shape a head-worn consumer band tends to have, with nothing over sensorimotor cortex.

The reference is re-derived at every step, because that is the mechanism section 7 just demonstrated.

In [19]:
SUBSETS = {
    "64 (full cap)": None,
    "19 (10-20 clinical)": ("Fp1", "Fp2", "F7", "F3", "Fz", "F4", "F8", "T7", "C3", "Cz", "C4", "T8",
                            "P7", "P3", "Pz", "P4", "P8", "O1", "O2"),
    "8 (task-matched)": ("F3", "F4", "C3", "Cz", "C4", "P3", "O1", "O2"),
    "4 (task-matched)": ("C3", "C4", "O1", "O2"),
    "4 (consumer-shaped)": ("AF3", "AF4", "O1", "O2"),
}

# The alpha peak is measured on the SAME eyes-closed baseline section 3 used (R02), so that the only
# thing changing here is the montage -- not the state, the run or the window.
raw_ec = helpers.load_spine("ds-eegbci", MI_SUBJECT, "R02")

rows = []
for name, chs in SUBSETS.items():
    # --- alpha peak on the eyes-closed baseline, reference re-derived over the retained channels ---
    r = raw_ec.copy()
    if chs is not None:
        r.pick([c for c in chs if c in r.ch_names])
    x = r.get_data(picks="eeg") * 1e6
    x = x - x.mean(axis=0, keepdims=True)
    post = [i for i, c in enumerate(r.copy().pick("eeg").ch_names) if c in POSTERIOR]
    f_d, P_d = welch_uv2(x[post] if post else x, r.info["sfreq"])
    pk = peak_above_trend(f_d, P_d)
    # --- mu ERD on the imagery epochs, reference re-derived the same way ---
    ep = epochs_mi.copy()
    if chs is not None:
        ep.pick([c for c in chs if c in ep.ch_names])
    ep_r = rereference(ep)
    row = {"montage": name, "n_channels": len(ep_r.ch_names), "posterior ch": len(post),
           "alpha peak (Hz)": pk["freq_hz"], "peak height (dB)": round(pk["db_above_trend"], 2)}
    if all(c in ep_r.ch_names for c in CH_MI):
        t = L4.morlet_power(ep_r["T2"], picks=CH_MI)
        e = {ch: L4.band_erd(t["power"][:, i], t["freqs"], t["times"], L4.MU_BAND,
                             baseline=L4.TF_BASELINE, active=L4.TF_ACTIVE, order="band-first")
             for i, ch in enumerate(CH_MI)}
        row |= {"mu ERD C3 (%)": round(e["C3"], 2), "mu ERD C4 (%)": round(e["C4"], 2),
                "lateralisation C3-C4 (pp)": round(e["C3"] - e["C4"], 2)}
    else:
        row |= {"mu ERD C3 (%)": np.nan, "mu ERD C4 (%)": np.nan,
                "lateralisation C3-C4 (pp)": np.nan}
    rows.append(row)
deg = pd.DataFrame(rows)
print(f"Controlled degradation -- ds-eegbci {MI_SUBJECT}, identical recordings, identical pipeline, "
      f"only the montage changes.")
print(f"   alpha peak: run R02 (eyes closed), the posterior electrodes that survive the subset")
print(f"   mu ERD    : runs {'+'.join(mi_info['runs'])}, condition T2, 8-13 Hz, band-first\n")
print(deg.to_string(index=False))
edge = [r["montage"] for _, r in deg.iterrows() if r["alpha peak (Hz)"] is None]
print(f"\nband-edge / threshold check: "
      f"{'every montage produced a real peak inside the band' if not edge else 'NO PEAK REPORTED for ' + ', '.join(edge) + ' -- either below ' + str(MIN_DB) + ' dB or on the band limit, and the estimator says absent rather than inventing a value'}")
Controlled degradation -- ds-eegbci S001, identical recordings, identical pipeline, only the montage changes.
   alpha peak: run R02 (eyes closed), the posterior electrodes that survive the subset
   mu ERD    : runs R04+R08+R12, condition T2, 8-13 Hz, band-first

            montage  n_channels  posterior ch  alpha peak (Hz)  peak height (dB)  mu ERD C3 (%)  mu ERD C4 (%)  lateralisation C3-C4 (pp)
      64 (full cap)          64            13             10.0             17.22         -47.87         -10.46                     -37.40
19 (10-20 clinical)          19             7             10.0             16.48         -47.31         -10.74                     -36.56
   8 (task-matched)           8             3             10.0             17.55         -46.05         -12.18                     -33.87
   4 (task-matched)           4             2             10.0             17.54         -37.97          -8.35                     -29.63
4 (consumer-shaped)           4             2             10.0             17.00            NaN            NaN                        NaN

band-edge / threshold check: every montage produced a real peak inside the band
In [20]:
base = deg.iloc[0]
print("What moved, and by how much, against the full cap:\n")
for _, r in deg.iloc[1:].iterrows():
    print(f"{r['montage']}:")
    if not np.isnan(r["mu ERD C3 (%)"]):
        print(f"   mu ERD C3   {r['mu ERD C3 (%)']:+7.2f} % vs {base['mu ERD C3 (%)']:+7.2f} %  "
              f"({r['mu ERD C3 (%)'] - base['mu ERD C3 (%)']:+.2f} pp, "
              f"{100 * abs(r['mu ERD C3 (%)'] - base['mu ERD C3 (%)']) / abs(base['mu ERD C3 (%)']):.0f} % "
              f"of the full-cap value)")
        print(f"   mu ERD C4   {r['mu ERD C4 (%)']:+7.2f} % vs {base['mu ERD C4 (%)']:+7.2f} %  "
              f"({r['mu ERD C4 (%)'] - base['mu ERD C4 (%)']:+.2f} pp)")
        print(f"   C3 - C4     {r['lateralisation C3-C4 (pp)']:+7.2f} pp vs "
              f"{base['lateralisation C3-C4 (pp)']:+7.2f} pp   "
              f"(sign {'PRESERVED' if np.sign(r['lateralisation C3-C4 (pp)']) == np.sign(base['lateralisation C3-C4 (pp)']) else 'FLIPPED'})")
    else:
        print("   mu ERD      NOT COMPUTABLE -- this montage has no C3/C4")
    if r["alpha peak (Hz)"] is not None and base["alpha peak (Hz)"] is not None:
        print(f"   alpha peak  {r['alpha peak (Hz)']:.2f} Hz vs {base['alpha peak (Hz)']:.2f} Hz  "
              f"({r['alpha peak (Hz)'] - base['alpha peak (Hz)']:+.2f} Hz) from "
              f"{r['posterior ch']} posterior electrode(s); height "
              f"{r['peak height (dB)']:.2f} dB vs {base['peak height (dB)']:.2f} dB")
    print()
print("The peak FREQUENCY is the robust one, and the reason is worth stating: a frequency does not care")
print("how many electrodes were averaged into the reference, as long as the rhythm is on one of them.")
print("Its HEIGHT above the aperiodic background does move, because that is an amplitude.")
print("The ERD percentages move too -- most at four channels, where C3 and C4 are half the reference and")
print("a quarter of each channel's own signal is subtracted from itself.  The lateralisation keeps its")
print("sign at every montage that has both electrodes, which is the useful part: a low-channel system")
print("can say WHICH side desynchronised and cannot say BY HOW MUCH on a research cap's scale.")
What moved, and by how much, against the full cap:

19 (10-20 clinical):
   mu ERD C3    -47.31 % vs  -47.87 %  (+0.56 pp, 1 % of the full-cap value)
   mu ERD C4    -10.74 % vs  -10.46 %  (-0.28 pp)
   C3 - C4      -36.56 pp vs  -37.40 pp   (sign PRESERVED)
   alpha peak  10.00 Hz vs 10.00 Hz  (+0.00 Hz) from 7 posterior electrode(s); height 16.48 dB vs 17.22 dB

8 (task-matched):
   mu ERD C3    -46.05 % vs  -47.87 %  (+1.82 pp, 4 % of the full-cap value)
   mu ERD C4    -12.18 % vs  -10.46 %  (-1.72 pp)
   C3 - C4      -33.87 pp vs  -37.40 pp   (sign PRESERVED)
   alpha peak  10.00 Hz vs 10.00 Hz  (+0.00 Hz) from 3 posterior electrode(s); height 17.55 dB vs 17.22 dB

4 (task-matched):
   mu ERD C3    -37.97 % vs  -47.87 %  (+9.90 pp, 21 % of the full-cap value)
   mu ERD C4     -8.35 % vs  -10.46 %  (+2.11 pp)
   C3 - C4      -29.63 pp vs  -37.40 pp   (sign PRESERVED)
   alpha peak  10.00 Hz vs 10.00 Hz  (+0.00 Hz) from 2 posterior electrode(s); height 17.54 dB vs 17.22 dB

4 (consumer-shaped):
   mu ERD      NOT COMPUTABLE -- this montage has no C3/C4
   alpha peak  10.00 Hz vs 10.00 Hz  (+0.00 Hz) from 2 posterior electrode(s); height 17.00 dB vs 17.22 dB

The peak FREQUENCY is the robust one, and the reason is worth stating: a frequency does not care
how many electrodes were averaged into the reference, as long as the rhythm is on one of them.
Its HEIGHT above the aperiodic background does move, because that is an amplitude.
The ERD percentages move too -- most at four channels, where C3 and C4 are half the reference and
a quarter of each channel's own signal is subtracted from itself.  The lateralisation keeps its
sign at every montage that has both electrodes, which is the useful part: a low-channel system
can say WHICH side desynchronised and cannot say BY HOW MUCH on a research cap's scale.
In [21]:
# The same degradation for the P300, on the 16-channel dry headset -- with both reference regimes,
# because section 7 showed they are not the same measurement.
P3_SUBSETS = {
    "16 (full headset)": None,
    "8 (midline + posterior)": ("Fp1", "Fp2", "Cz", "P3", "Pz", "P4", "O1", "O2"),
    "4 (centro-parietal)": ("Cz", "Pz", "O1", "O2"),
    "4 (frontal + posterior)": ("Fp1", "Fp2", "O1", "O2"),
}
rows, p3_curves = [], {}
for name, chs in P3_SUBSETS.items():
    keep = list(ep_all.ch_names) if chs is None else [c for c in chs if c in ep_all.ch_names]
    sub_all = ep_all.copy().pick(keep)
    d = sub_all.get_data() * 1e6
    keep_sub = (d.max(2) - d.min(2)).max(1) <= L3.REJECT_PTP_UV
    sub_clean = sub_all[np.where(keep_sub)[0]]
    r_rec, r_avg = p300(sub_clean), p300(rereference(sub_clean))
    if r_rec["evoked"] is not None:
        p3_curves[f"{name} ({r_rec['amp_uv']:+.2f} uV)"] = r_rec["evoked"]
    rows.append({"montage": name, "n_channels": len(keep), "Pz present": P3_CH in keep,
                 "epochs kept": int(keep_sub.sum()),
                 "recording ref (uV)": np.round(r_rec["amp_uv"], 3),
                 "average ref (uV)": np.round(r_avg["amp_uv"], 3),
                 "SME (uV)": np.round(r_rec["sme_uv"], 3)})
p3deg = pd.DataFrame(rows)
print("Controlled degradation -- ds-brain-invaders subject 1, identical flashes, only the montage changes.")
print("The 150 uV criterion is applied over the RETAINED channels, as a real pipeline would.\n")
print(p3deg.to_string(index=False))
print()
print("Three things are visible there, and NONE of them is the one a reader would predict.")
print()
print(f"  1. The trial set changes.  A 150 uV criterion evaluated over the whole montage rejects an")
print(f"     epoch because of the WORST electrode in it, so removing channels removes the reasons to")
print(f"     reject: {p3deg['epochs kept'].iloc[0]} epochs survive at 16 channels and "
      f"{p3deg['epochs kept'].iloc[-1]} at 4.  With the dry contacts' own artifacts back in the")
print(f"     average, the recording-reference measurement goes "
      f"{p3deg['recording ref (uV)'].iloc[0]:+.2f} -> "
      f"{p3deg['recording ref (uV)'].iloc[1]:+.2f} -> {p3deg['recording ref (uV)'].iloc[2]:+.2f} uV.")
print(f"     Electrode COUNT is not what moved that number; the rejection RULE is, and the rule was")
print(f"     never restated when the montage changed.  That is the whole failure, and it is silent.")
print(f"  2. The average-reference column moves for the separate reason section 7 gave, and stays")
print(f"     negative at every montage -- a 16-, 8- or 4-electrode average is not an inactive")
print(f"     reference and a broad positive component partly cancels itself.")
print(f"  3. The last row has no Pz.  It is not a smaller P300; there is no P300 to measure.  A montage")
print(f"     without a centro-parietal electrode does not measure a weak component, it measures another")
print(f"     part of the head.")
print()
print("The honest conclusion for L7.5 is therefore narrower than 'a P300 needs N channels'.  It is:")
print("a P300 needs ONE right electrode, an artifact rule written for the montage in front of you, and")
print("a reference you have thought about.  On this recording the second and third decide the answer's")
print("SIGN, and the channel count only decides them by accident.")
Controlled degradation -- ds-brain-invaders subject 1, identical flashes, only the montage changes.
The 150 uV criterion is applied over the RETAINED channels, as a real pipeline would.

                montage  n_channels  Pz present  epochs kept  recording ref (uV)  average ref (uV)  SME (uV)
      16 (full headset)          16        True          509               1.431            -1.174     1.877
8 (midline + posterior)           8        True          896              -0.032            -2.300     1.548
    4 (centro-parietal)           4        True          976               0.370            -2.360     1.711
4 (frontal + posterior)           4       False          979                 NaN               NaN       NaN

Three things are visible there, and NONE of them is the one a reader would predict.

  1. The trial set changes.  A 150 uV criterion evaluated over the whole montage rejects an
     epoch because of the WORST electrode in it, so removing channels removes the reasons to
     reject: 509 epochs survive at 16 channels and 979 at 4.  With the dry contacts' own artifacts back in the
     average, the recording-reference measurement goes +1.43 -> -0.03 -> +0.37 uV.
     Electrode COUNT is not what moved that number; the rejection RULE is, and the rule was
     never restated when the montage changed.  That is the whole failure, and it is silent.
  2. The average-reference column moves for the separate reason section 7 gave, and stays
     negative at every montage -- a 16-, 8- or 4-electrode average is not an inactive
     reference and a broad positive component partly cancels itself.
  3. The last row has no Pz.  It is not a smaller P300; there is no P300 to measure.  A montage
     without a centro-parietal electrode does not measure a weak component, it measures another
     part of the head.

The honest conclusion for L7.5 is therefore narrower than 'a P300 needs N channels'.  It is:
a P300 needs ONE right electrode, an artifact rule written for the montage in front of you, and
a reference you have thought about.  On this recording the second and third decide the answer's
SIGN, and the channel count only decides them by accident.
In [22]:
fig, axes = plt.subplots(1, 3, figsize=(14.5, 4.2))

ax = axes[0]
sub = deg.dropna(subset=["mu ERD C3 (%)"])
x = np.arange(len(sub))
ax.bar(x - 0.2, sub["mu ERD C3 (%)"], 0.4, label="C3 (contralateral)")
ax.bar(x + 0.2, sub["mu ERD C4 (%)"], 0.4, label="C4 (ipsilateral)")
ax.axhline(0, color="k", lw=0.8)
ax.set_xticks(x, sub["montage"], rotation=22, ha="right", fontsize=7.5)
ax.set(ylabel="Mu (8–13 Hz) ERD (% change from baseline)",
       title=f"Right-fist imagery, ds-eegbci {MI_SUBJECT}\n(negative = desynchronisation)")
ax.legend(fontsize=8)
ax.grid(alpha=0.3, axis="y")

ax = axes[1]
sub2 = deg.dropna(subset=["alpha peak (Hz)"])
ax.plot(range(len(sub2)), sub2["alpha peak (Hz)"], "o-", lw=1.6, label="peak frequency (Hz)")
ax.set_xticks(range(len(sub2)), sub2["montage"], rotation=22, ha="right", fontsize=7.5)
ax.set(ylabel="Alpha peak frequency (Hz)", ylim=(7, 13),
       title="Eyes-closed alpha peak, fewer channels")
ax2 = ax.twinx()
ax2.plot(range(len(sub2)), sub2["peak height (dB)"], "s--", color="tab:orange", lw=1.3,
         label="height above trend (dB)")
ax2.set_ylabel("Peak height above the aperiodic trend (dB)")
h1, l1 = ax.get_legend_handles_labels()
h2, l2 = ax2.get_legend_handles_labels()
ax.legend(h1 + h2, l1 + l2, fontsize=7.4, loc="lower left")
ax.grid(alpha=0.3)

ax = axes[2]
for label, ev in p3_curves.items():
    ax.plot(ev.times * 1000, ev.data[ev.ch_names.index(P3_CH)] * 1e6, lw=1.4, label=label)
ax.axvspan(P3_WINDOW[0] * 1000, P3_WINDOW[1] * 1000, color="0.88", zorder=0)
ax.axhline(0, color="k", lw=0.8)
ax.set(xlabel="Time from stimulus (ms)", ylabel="Amplitude, target − non-target (µV)",
       title=f"P300 at {P3_CH}, recording reference (µV)")
ax.legend(fontsize=7.4)
ax.grid(alpha=0.3)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
Figure 3 of notebook nb-7-5-low-channel, an output plot. The text around it states what it shows and the units of every axis.

9 · The matrix, filled in

Every cell now has a measurement or a stated reason behind it. The two reasons are kept apart, because only one of them is a property of the device class:

  • hardware — the electrode, the bandwidth or the reference is not there. Changing the study would not help.
  • paradigm — the hardware could do it; this recording did not run the task. A different study on the same headset could.
In [23]:
def cell(ok, why):
    return f"{'yes' if ok else 'no'} ({why})"


matrix = pd.DataFrame(index=list(ANALYSES), columns=[
    "ds-eegbci (64 gel)", "ds-brain-invaders (16 dry)", "ds-areeg (14 consumer)",
    "4 ch task-matched", "4 ch consumer-shaped"])

has = lambda ds, *c: all(x in DEVICE_CHANNELS[ds] for x in c)
d4t, d4c = SUBSETS["4 (task-matched)"], SUBSETS["4 (consumer-shaped)"]

matrix.loc["alpha peak / IAF"] = [
    cell(True, f"{eegbci_tab.iaf_hz.dropna().median():.1f} Hz median over {len(eegbci_tab)} subjects"),
    cell(pk_bi["freq_hz"] is not None,
         f"{pk_bi['freq_hz']} Hz" if pk_bi["freq_hz"] else
         f"no peak above {MIN_DB:g} dB ({pk_bi['db_above_trend']:.2f} dB) -- eyes-open task, not rest"),
    cell(areeg_tab.iaf_hz.notna().any(), f"{areeg_tab.iaf_hz.dropna().median():.1f} Hz median"),
    cell(True, f"{deg.set_index('montage').loc['4 (task-matched)', 'alpha peak (Hz)']} Hz, unchanged"),
    cell(True, f"{deg.set_index('montage').loc['4 (consumer-shaped)', 'alpha peak (Hz)']} Hz, unchanged"),
]
matrix.loc["aperiodic slope (1-40 Hz)"] = [
    cell(True, "no hardware filter at all"),
    cell(True, "no online filter"),
    cell(False, f"sustained hardware roll-off from {roll.iloc[2]['sustained roll-off from (Hz)']:g} Hz, "
                f"inside the 1-40 Hz fit range"),
    cell(True, "same amplifier as the full cap"),
    cell(True, "same amplifier as the full cap"),
]
matrix.loc["mu ERD lateralisation"] = [
    cell(True, f"C3-C4 {LAT64:+.1f} pp"),
    cell(False, "paradigm: no motor task; hardware: no C3/C4 either"),
    cell(False, "paradigm: no motor task; hardware: no central electrode"),
    cell(True, f"C3-C4 {deg.set_index('montage').loc['4 (task-matched)', 'lateralisation C3-C4 (pp)']:+.1f} pp"),
    cell(False, "hardware: no electrode over sensorimotor cortex"),
]
matrix.loc["P300 amplitude"] = [
    cell(False, "paradigm: no ERP task in this dataset"),
    cell(True, f"{p3_ref['amp_uv']:+.2f} uV at Pz (recording reference, 150 uV rejection)"),
    cell(False, "paradigm: no cue-locked event; hardware: no Pz"),
    cell(False, "hardware: no centro-parietal electrode in this subset"),
    cell(False, "hardware: no centro-parietal electrode in this subset"),
]
matrix.loc["scalp topography / source"] = [
    cell(True, "64 positions"),
    cell(False, "16 positions: a map, not a field"),
    cell(False, "14 positions, none central"),
    cell(False, "4 positions"),
    cell(False, "4 positions"),
]
pd.set_option("display.max_colwidth", 52)
print(matrix.to_string())
pd.reset_option("display.max_colwidth")
                                                   ds-eegbci (64 gel)                                     ds-brain-invaders (16 dry)                                                     ds-areeg (14 consumer)                                           4 ch task-matched                                        4 ch consumer-shaped
alpha peak / IAF                 yes (10.5 Hz median over 5 subjects)  no (no peak above 3 dB (0.43 dB) -- eyes-open task, not rest)                                                       yes (11.5 Hz median)                                    yes (10.0 Hz, unchanged)                                    yes (10.0 Hz, unchanged)
aperiodic slope (1-40 Hz)             yes (no hardware filter at all)                                         yes (no online filter)  no (sustained hardware roll-off from 31 Hz, inside the 1-40 Hz fit range)                        yes (same amplifier as the full cap)                        yes (same amplifier as the full cap)
mu ERD lateralisation                            yes (C3-C4 -37.4 pp)        no (paradigm: no motor task; hardware: no C3/C4 either)               no (paradigm: no motor task; hardware: no central electrode)                                        yes (C3-C4 -29.6 pp)        no (hardware: no electrode over sensorimotor cortex)
P300 amplitude             no (paradigm: no ERP task in this dataset)   yes (+1.43 uV at Pz (recording reference, 150 uV rejection))                        no (paradigm: no cue-locked event; hardware: no Pz)  no (hardware: no centro-parietal electrode in this subset)  no (hardware: no centro-parietal electrode in this subset)
scalp topography / source                          yes (64 positions)                          no (16 positions: a map, not a field)                                            no (14 positions, none central)                                            no (4 positions)                                            no (4 positions)

Reading the matrix without sliding into a ranking

Three statements this notebook supports, and one it does not:

  • Frequency-domain measurements that ask where the peak is survive channel loss. The alpha peak did not move across five montages of the same recording.
  • Amplitude-domain measurements that depend on a reference do not survive it unchanged. Every ERD percentage moved when the average reference was re-derived over fewer electrodes, and it moved most where the measured electrode was itself a large share of the reference.
  • Some analyses do not degrade, they stop. Removing Pz does not shrink the P300; it removes it.
  • Not supported: that any device class is better. A comparison of three datasets is a comparison of three studies. The only clean statement about channel count in this notebook is section 8's, and it is about one subject on one amplifier.
In [24]:
try:
    print("nb-7-5-low-channel -- L7.5 numbers (draft; TODO(confirm) at author review)")
    print()
    print(f"1. ALPHA PEAK / IAF, one estimator (line fit 2-40 Hz excluding 5-15 Hz, largest residual in "
          f"7-13 Hz above {MIN_DB:g} dB):")
    print(f"     ds-eegbci          {len(eegbci_tab)} subjects, R02 eyes closed: median "
          f"{eegbci_tab.iaf_hz.dropna().median():.2f} Hz, "
          f"range {eegbci_tab.iaf_hz.dropna().min():.2f}-{eegbci_tab.iaf_hz.dropna().max():.2f} Hz")
    print(f"     ds-brain-invaders  subject 1 (P300 game, eyes open): {pk_bi['freq_hz']} Hz "
          f"at {pk_bi['db_above_trend']:.2f} dB")
    print(f"     ds-areeg           {len(areeg_tab)} participants (imagined speech, eyes closed): median "
          f"{areeg_tab.iaf_hz.dropna().median():.2f} Hz, values "
          f"{[v for v in areeg_tab.iaf_hz]}")
    print(f"     cross-check: the notebook's estimator agrees with helpers.alpha_peak on "
          f"{int(eegbci_tab.agree.sum())}/{len(eegbci_tab)} ds-eegbci subjects")
    print()
    print("2. HARDWARE BANDWIDTH CEILING (sustained local slope past -40 dB/decade in 1-octave windows):")
    for _, r in roll.iterrows():
        print(f"     {r['recording']:46s} roll-off from "
              f"{str(r['sustained roll-off from (Hz)']):>6s} Hz  "
              f"(Nyquist {r['Nyquist (Hz)']:.0f} Hz; slope at 40 Hz {r['slope @40 Hz']:+7.1f} dB/decade)")
    print(f"     DISAGREEMENT with a recorded fact, reported not reconciled: data/directory.yaml records")
    print(f"     '~= 43 Hz hardware bandwidth' for this device class; the measured sustained roll-off "
          f"begins at {roll.iloc[2]['sustained roll-off from (Hz)']:g} Hz.  Compatible if 43 Hz is a "
          f"corner frequency -- TODO(confirm) which convention it uses.")
    print()
    print(f"3. VENDOR COLUMNS (ds-areeg, {len(frames)} recordings):")
    print(f"     {len(fam['EEG (signal)'])} of {len(cols)} columns are electrode signal "
          f"({100 * len(fam['EEG (signal)']) / len(cols):.0f} %)")
    print(f"     EQ.OVERALL present on {100 * len(updates) / len(eq):.1f} % of rows; "
          f"{100 * frac75:.1f} % of those updates meet the ds-mpeng criterion (>= 75 %)")
    print(f"     worst-scored electrode: {per_ch.index[0]} (mean EQ {per_ch.iloc[0]:.2f} vs "
          f"{per_ch.iloc[-1]:.2f} for the best)")
    print(f"     ds-mpeng's own recorded figure, NOT computed here (CC BY-NC, never downloaded): "
          f"~50 % of samples meet EQ.OVERALL >= 75 % during intense play")
    print()
    print(f"4. MU ERD, ds-eegbci {MI_SUBJECT}, T2 right-fist imagery, band-first, 8-13 Hz:")
    for ch in CH_MI:
        print(f"     {ch} at 64 ch: {erd64[ch]:+7.2f} %  (nb-4-4 key {KEY_44[ch]:+.2f} %, "
              f"difference {erd64[ch] - KEY_44[ch]:+.3f} pp)")
    print(f"     S001 was SELECTED for the largest C3 ERD of ten candidates (nb-4-4); the cohort median "
          f"is -23.1 % (range -47.9 to +6.6)")
    print()
    print(f"5. P300, ds-brain-invaders subject 1, {P3_CH}, "
          f"{P3_WINDOW[0] * 1000:.0f}-{P3_WINDOW[1] * 1000:.0f} ms, target - non-target:")
    print(f"     nb-3-2 pipeline (recording reference, 150 uV rejection): {p3_ref['amp_uv']:+.3f} uV "
          f"(nb-3-2 key {KEY_32:+.2f} uV, difference {p3_ref['amp_uv'] - KEY_32:+.3f} uV)")
    for name, r in variants:
        print(f"     {name:50s} {r['amp_uv']:+8.3f} uV")
    print("     -- both the rejection decision and the reference decision FLIP THE SIGN on this recording")
    print("     by montage, keeping the nb-3-2 pipeline:")
    for _, r in p3deg.iterrows():
        rec = f"{r['recording ref (uV)']:+8.3f}" if not np.isnan(r['recording ref (uV)']) else "   absent"
        avg = f"{r['average ref (uV)']:+8.3f}" if not np.isnan(r['average ref (uV)']) else "   absent"
        print(f"     {r['montage']:26s} recording ref {rec} uV   average ref {avg} uV   "
              f"{r['epochs kept']:4d} epochs kept")
    print("     the epochs-kept column is the finding: a whole-montage 150 uV rule rejects on the worst")
    print("     electrode, so removing channels changes the trial set and with it the measurement")
    print()
    print("6. ex-7-5 CONTROLLED DEGRADATION (ds-eegbci S001, identical data, only the montage changes):")
    for _, r in deg.iterrows():
        print(f"     {r['montage']:22s} n={r['n_channels']:3d}  alpha peak "
              f"{str(r['alpha peak (Hz)']):>6s} Hz   mu ERD C3 {str(r['mu ERD C3 (%)']):>8s} %   "
              f"C3-C4 {str(r['lateralisation C3-C4 (pp)']):>8s} pp")
    print()
    print("   ANSWER to 'which of five analyses remain feasible at 4 channels' (task-matched C3/C4/O1/O2):")
    a4 = deg.set_index("montage").loc["4 (task-matched)"]
    print(f"     alpha peak / IAF            FEASIBLE   ({a4['alpha peak (Hz)']} Hz, unchanged from 64 ch)")
    print(f"     aperiodic slope 1-40 Hz     FEASIBLE on this amplifier; NOT on a consumer front end "
          f"whose roll-off is inside the fit range")
    print(f"     mu ERD lateralisation       FEASIBLE ONLY IF C3 and C4 are two of the four "
          f"({a4['lateralisation C3-C4 (pp)']:+.2f} pp here; not computable on AF3/AF4/O1/O2)")
    print(f"     P300 amplitude              NOT FEASIBLE without a centro-parietal electrode "
          f"(and at 16 channels the REFERENCE, not the count, is what decides its sign)")
    print(f"     scalp topography / source   NOT FEASIBLE at 4 positions")
    print()
    print("   So the count is TWO unconditionally (alpha peak, aperiodic slope on a flat amplifier), a")
    print("   THIRD conditional on which four electrodes, and TWO that are not about channel count at")
    print("   all -- they need a particular electrode or a field.")
finally:
    freed = dl.finish()
nb-7-5-low-channel -- L7.5 numbers (draft; TODO(confirm) at author review)

1. ALPHA PEAK / IAF, one estimator (line fit 2-40 Hz excluding 5-15 Hz, largest residual in 7-13 Hz above 3 dB):
     ds-eegbci          5 subjects, R02 eyes closed: median 10.50 Hz, range 10.00-11.50 Hz
     ds-brain-invaders  subject 1 (P300 game, eyes open): None Hz at 0.43 dB
     ds-areeg           3 participants (imagined speech, eyes closed): median 11.50 Hz, values [12.0, 11.5, 11.5]
     cross-check: the notebook's estimator agrees with helpers.alpha_peak on 5/5 ds-eegbci subjects

2. HARDWARE BANDWIDTH CEILING (sustained local slope past -40 dB/decade in 1-octave windows):
     ds-eegbci S001 R02 (64 ch, 160 Hz out)         roll-off from    nan Hz  (Nyquist 80 Hz; slope at 40 Hz   -24.4 dB/decade)
     ds-brain-invaders sub 1 (16 ch dry, 512 Hz out) roll-off from    nan Hz  (Nyquist 256 Hz; slope at 40 Hz   +88.9 dB/decade)
     ds-areeg par.1 (14 ch consumer, 128 Hz out)    roll-off from   31.0 Hz  (Nyquist 64 Hz; slope at 40 Hz   -97.9 dB/decade)
     DISAGREEMENT with a recorded fact, reported not reconciled: data/directory.yaml records
     '~= 43 Hz hardware bandwidth' for this device class; the measured sustained roll-off begins at 31 Hz.  Compatible if 43 Hz is a corner frequency -- TODO(confirm) which convention it uses.

3. VENDOR COLUMNS (ds-areeg, 48 recordings):
     14 of 68 columns are electrode signal (21 %)
     EQ.OVERALL present on 1.6 % of rows; 71.8 % of those updates meet the ds-mpeng criterion (>= 75 %)
     worst-scored electrode: O1 (mean EQ 2.80 vs 3.94 for the best)
     ds-mpeng's own recorded figure, NOT computed here (CC BY-NC, never downloaded): ~50 % of samples meet EQ.OVERALL >= 75 % during intense play

4. MU ERD, ds-eegbci S001, T2 right-fist imagery, band-first, 8-13 Hz:
     C3 at 64 ch:  -47.87 %  (nb-4-4 key -47.87 %, difference +0.003 pp)
     C4 at 64 ch:  -10.46 %  (nb-4-4 key -10.46 %, difference -0.004 pp)
     S001 was SELECTED for the largest C3 ERD of ten candidates (nb-4-4); the cohort median is -23.1 % (range -47.9 to +6.6)

5. P300, ds-brain-invaders subject 1, Pz, 300-600 ms, target - non-target:
     nb-3-2 pipeline (recording reference, 150 uV rejection): +1.431 uV (nb-3-2 key +1.43 uV, difference +0.001 uV)
     recording reference, 150 uV rejection  (nb-3-2)      +1.431 uV
     recording reference, NO rejection                    -1.243 uV
     16-channel average reference, 150 uV rejection       -1.174 uV
     16-channel average reference, NO rejection           -3.364 uV
     -- both the rejection decision and the reference decision FLIP THE SIGN on this recording
     by montage, keeping the nb-3-2 pipeline:
     16 (full headset)          recording ref   +1.431 uV   average ref   -1.174 uV    509 epochs kept
     8 (midline + posterior)    recording ref   -0.032 uV   average ref   -2.300 uV    896 epochs kept
     4 (centro-parietal)        recording ref   +0.370 uV   average ref   -2.360 uV    976 epochs kept
     4 (frontal + posterior)    recording ref    absent uV   average ref    absent uV    979 epochs kept
     the epochs-kept column is the finding: a whole-montage 150 uV rule rejects on the worst
     electrode, so removing channels changes the trial set and with it the measurement

6. ex-7-5 CONTROLLED DEGRADATION (ds-eegbci S001, identical data, only the montage changes):
     64 (full cap)          n= 64  alpha peak   10.0 Hz   mu ERD C3   -47.87 %   C3-C4    -37.4 pp
     19 (10-20 clinical)    n= 19  alpha peak   10.0 Hz   mu ERD C3   -47.31 %   C3-C4   -36.56 pp
     8 (task-matched)       n=  8  alpha peak   10.0 Hz   mu ERD C3   -46.05 %   C3-C4   -33.87 pp
     4 (task-matched)       n=  4  alpha peak   10.0 Hz   mu ERD C3   -37.97 %   C3-C4   -29.63 pp
     4 (consumer-shaped)    n=  4  alpha peak   10.0 Hz   mu ERD C3      nan %   C3-C4      nan pp

   ANSWER to 'which of five analyses remain feasible at 4 channels' (task-matched C3/C4/O1/O2):
     alpha peak / IAF            FEASIBLE   (10.0 Hz, unchanged from 64 ch)
     aperiodic slope 1-40 Hz     FEASIBLE on this amplifier; NOT on a consumer front end whose roll-off is inside the fit range
     mu ERD lateralisation       FEASIBLE ONLY IF C3 and C4 are two of the four (-29.63 pp here; not computable on AF3/AF4/O1/O2)
     P300 amplitude              NOT FEASIBLE without a centro-parietal electrode (and at 16 channels the REFERENCE, not the count, is what decides its sign)
     scalp topography / source   NOT FEASIBLE at 4 positions

   So the count is TWO unconditionally (alpha peak, aperiodic slope on a flat amplifier), a
   THIRD conditional on which four electrodes, and TWO that are not about channel count at
   all -- they need a particular electrode or a field.
deleted 3 downloaded file(s), 7.4 MiB freed
free disk after nb-7-5: 1,896 MB (-4 MB against the start of the notebook; the volume is shared, so anything else running on it moves this number too)