Pipeline order and reproducibility: run_subject driven by one configuration file, the run log, and a per-subject QC report

nb-2-8-pipeline Level 2 · Preprocessing as a Pipeline ~3 min Used in L2.8 · Pipeline order and reproducibility

Downloads from ds-erpcore 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-2-8-pipeline · Pipeline order and reproducibility (L2.8)

Lesson L2.8 · Level 2 · Status draft — for expert review; uncertain points carry TODO(confirm).

Everything in Level 2 becomes one function here: run_subject(config, subject) executing the canonical order

load → montage → bad-channel detection → filter → interpolate → re-reference → ICA → epoch → reject

driven entirely by a configuration file, logging every step, and emitting a per-subject QC HTML report.

  1. Which implementation is running — the repository's pipelines/eegpipe package if it is there, and the same documented interface in notebooks/_shared/helpers_l2.py if it is not.
  2. The configuration: one YAML file, validated, with a hash.
  3. One subject end to end, with the run log: what each step did, how long it took, and the resolved parameter values rather than the requested ones.
  4. The QC report, rendered inline.
  5. Two runs differ only by their configuration — one parameter changed, and the consequences read off the two logs.
  6. A failure is a row, not a stopped cohort.

Where the code lives. The Phase 2 build contract reserves pipelines/eegpipe for this package — config.py, steps/ (one module per step), run.py with run_subject(config, subject) -> RunResult, qc.py (the HTML report), cli.py and tests/. The package is imported without being installed, by walking up from the working directory to the repository's pipelines/ folder — the same trick that finds _shared/, and no absolute path anywhere.

Data. ds-erpcore P3 (CC BY-SA 4.0 per data/directory.yaml, contested at source; open; per-subject downloadable): sub-001 for the worked run (~56 MB on an empty cache; already-cached subjects are re-used). TODO(confirm): the author mirrors the ERP CORE entry into the catalog registry and signs off the dataset page (§10.11 item 8).

In [1]:
# Setup: dependencies, the shared helpers, non-interactive plotting.
import importlib.util
import subprocess
import sys
import warnings
from pathlib import Path

# 1. Dependencies are pinned in notebooks/requirements.txt.  Nothing is installed
#    when the pinned stack is already present (local runs, CI); a fresh Colab or
#    Binder kernel installs it once.  On Colab, run from a clone of the repository
#    so that notebooks/_shared/ is available (repository URL: TODO(confirm), spec
#    section 13 item 3).
_needed = ('mne', 'scipy', 'matplotlib', 'pooch', 'pyprep', 'autoreject', 'yaml')
_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"]
    if "pyprep" in _missing:
        _cmd += ["pyprep>=0.9"]
    if "autoreject" in _missing:
        _cmd += ["autoreject>=0.5"]
    if "yaml" in _missing:
        _cmd += ["pyyaml>=6"]
    subprocess.check_call(_cmd)

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

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

# Warnings are worth reading, so they are not silenced -- but their default format prints the
# absolute path of the file that raised them, which is nobody else's business and would put this
# machine's directory layout into the saved outputs.  Only the class and the message are shown.
warnings.formatwarning = lambda message, category, *a, **k: f"{category.__name__}: {message}\n"

mne.viz.set_browser_backend("matplotlib", verbose=False)
mne.set_log_level("WARNING")
plt.rcParams["figure.dpi"] = 72
print(f"MNE {mne.__version__}; helpers imported from notebooks/_shared")
print("ERP CORE downloads (~56 MB per subject) are cached under data/downloads/erpcore/ in a repository "
      "clone, otherwise under MNE's data directory; nothing is re-fetched.")
MNE 1.10.2; helpers imported from notebooks/_shared
ERP CORE downloads (~56 MB per subject) are cached under data/downloads/erpcore/ in a repository clone, otherwise under MNE's data directory; nothing is re-fetched.

1. Which implementation is running

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

_pipelines = next((d / "pipelines" for d in (Path.cwd(), *Path.cwd().parents)
                   if (d / "pipelines" / "eegpipe" / "__init__.py").exists()), None)
EEGPIPE = None
if _pipelines is not None:
    sys.path.insert(0, str(_pipelines))
    try:
        import eegpipe as EEGPIPE
    except Exception as exc:
        print(f"pipelines/eegpipe found but not importable: {type(exc).__name__}: {exc}")

if EEGPIPE is not None:
    print(f"implementation: pipelines/eegpipe {EEGPIPE.__version__} (the package the Phase 2 contract reserves)")
    print(f"  canonical order: {' -> '.join(EEGPIPE.CANONICAL_ORDER)}")
    print(f"  step modules:    {', '.join(EEGPIPE.STEPS)}")
    run_one = EEGPIPE.run_subject
else:
    print("implementation: helpers_l2.run_subject -- the same documented interface "
          "(site/CONTRACTS.md, Phase 2 addendum), used because pipelines/eegpipe is not importable here")
    run_one = l2.run_subject
print()
print("Documented interface (site/CONTRACTS.md, Phase 2 addendum):")
print("  config.py   dataclass schema + YAML load/validate")
print("  steps/      load, montage, bad_channels, filter, interpolate, reference, ica, epoch, reject")
print("  run.py      run_subject(config, subject) -> RunResult")
print("  qc.py       HTML report: bad-channel table, ICA components removed with reasons, percent data")
print("              rejected per condition, filter settings, run log with versions and seed")
print("  cli.py      python -m eegpipe run --config ...")
print("  tests/      pytest, including a fast synthetic test that downloads nothing")
implementation: pipelines/eegpipe 0.2.0 (the package the Phase 2 contract reserves)
  canonical order: load -> montage -> bad_channels -> filter -> interpolate -> reference -> ica -> epoch -> reject
  step modules:    load, montage, bad_channels, filter, interpolate, reference, ica, epoch, reject

Documented interface (site/CONTRACTS.md, Phase 2 addendum):
  config.py   dataclass schema + YAML load/validate
  steps/      load, montage, bad_channels, filter, interpolate, reference, ica, epoch, reject
  run.py      run_subject(config, subject) -> RunResult
  qc.py       HTML report: bad-channel table, ICA components removed with reasons, percent data
              rejected per condition, filter settings, run log with versions and seed
  cli.py      python -m eegpipe run --config ...
  tests/      pytest, including a fast synthetic test that downloads nothing
In [3]:
# Both implementations are read through one small adapter, so every table below is written once.
def norm(res):
    """One shape for eegpipe.RunResult and for helpers_l2.run_subject's dict."""
    if hasattr(res, "status"):                                    # eegpipe.RunResult
        bads = {ch: v.get("criteria", []) for ch, v in (res.bad_channels or {}).items()}
        removed = [{"component": e["index"], "class": e["label"],
                    "probability": e.get("score", float("nan")), "evidence": e["reason"]}
                   for e in res.ica.get("excluded", [])]
        per = (res.rejection or {}).get("per_condition", {})
        table = [{"condition": c, "n_presented": v["n_before"], "n_rejected": v["n_rejected"],
                  "n_kept": v["n_kept"], "percent_rejected": v["percent_rejected"]} for c, v in per.items()]
        table.append({"condition": "all", "n_presented": res.rejection.get("n_epochs_before", 0),
                      "n_rejected": res.rejection.get("n_epochs_before", 0) - res.rejection.get("n_epochs_after", 0),
                      "n_kept": res.rejection.get("n_epochs_after", 0),
                      "percent_rejected": res.rejection.get("percent_rejected", float("nan"))})
        return {"subject": res.subject, "ok": res.status == "ok", "error": res.error,
                "steps": [{"name": s["name"], "duration_s": s["duration_s"], "params": s.get("params", {}),
                           "notes": s.get("notes", "")} for s in res.steps],
                "bads": bads, "interpolated": list(res.interpolated or []), "rank": res.rank,
                "rank_why": res.ica.get("rank_why", ""), "filter": res.filter or {},
                "ica": {"n_components": res.ica.get("n_components"), "method": res.ica.get("method"),
                        "seed": res.ica.get("seed"), "fitted_on": res.ica.get("fitted_on"),
                        "rank_after": res.ica.get("rank_after"), "removed": removed,
                        "kept_by_cap": res.ica.get("kept_by_max_remove", []),
                        "converged": res.ica.get("converged")},
                "rejection": {"criterion": res.rejection.get("criterion", {}), "table": table,
                              "imbalance_pp": res.rejection.get("condition_imbalance_pp", float("nan"))},
                "flags": list(res.flags or []), "outputs": dict(res.outputs or {}),
                "config_hash": res.config_hash, "seed": res.seed, "versions": res.versions,
                "duration_s": res.duration_s}
    d = res                                                        # helpers_l2.run_subject
    det = d.get("bad_channels", {}).get("detection", {})
    dec = d.get("bad_channels", {}).get("decision", {})
    return {"subject": d["subject"], "ok": d["ok"], "error": d["error"],
            "steps": d["log"].steps, "bads": det.get("by_channel", {}),
            "interpolated": dec.get("bads", []), "rank": d.get("rank", {}).get("rank"),
            "rank_why": d.get("rank", {}).get("arithmetic", ""), "filter": d.get("filter_resolved", {}),
            "ica": {"n_components": d.get("ica", {}).get("log", {}).get("n_components"),
                    "method": d.get("ica", {}).get("log", {}).get("method"),
                    "seed": d.get("ica", {}).get("log", {}).get("seed"),
                    "fitted_on": d.get("ica", {}).get("log", {}).get("fit_copy"),
                    "rank_after": d.get("ica", {}).get("rank_after"),
                    "removed": d.get("ica", {}).get("removed", []), "kept_by_cap": [], "converged": None},
            "rejection": {"criterion": {"peak_to_peak_uv": d.get("rejection", {}).get("threshold_uv")},
                          "table": d.get("rejection", {}).get("table", []),
                          "imbalance_pp": float("nan")},
            "flags": [], "outputs": {}, "config_hash": d.get("config_hash"), "seed": d["config"].get("seed"),
            "versions": d.get("versions", {}), "duration_s": d.get("duration_s", float("nan"))}

2. The configuration

One file holds every parameter: the subject list and the exclusions with their reasons, the montage, the filter cutoffs and their justification, the detection thresholds, the reference, the ICA method and rank policy, the rejection criteria, the seed. Nothing is typed at a prompt; nothing is commented out to switch behaviour; no parameter appears twice.

The practical test the lesson gives is: can you reproduce last month's result by checking out the configuration file? The hash below is what a run log records so that question has an answer.

Two overrides are applied here, both of them arguments rather than edits: the output goes to a temporary directory, and ICLabel is switched on, because mne-icalabel is installed in this environment and the lesson wants the classifier's second opinion (L2.6).

In [4]:
_out = tempfile.TemporaryDirectory(prefix="nb-2-8-")
OUT = Path(_out.name)

if EEGPIPE is not None:
    CONFIG_PATH = _pipelines / "configs" / "erpcore-p3.yaml"
    OVERRIDES = {"output.dir": str(OUT), "steps.ica.iclabel.enabled": True,
                 "subjects.include": ["sub-001"]}
    SHOWN_OVERRIDES = {**OVERRIDES, "output.dir": "<a temporary directory>"}
    config = EEGPIPE.load_config(CONFIG_PATH, overrides=OVERRIDES)
    text = CONFIG_PATH.read_text(encoding="utf-8")
    print(f"configuration file: {CONFIG_PATH.relative_to(_pipelines.parent)}  "
          f"({len(text.splitlines())} lines, {len(text) / 1000:.1f} kB)")
    print(f"overrides applied as arguments, not edits: {SHOWN_OVERRIDES}")
    print(f"name: {config.name!r}; dataset {config.dataset.id} ({config.dataset.paradigm}); "
          f"seed {config.seed}")
    print()
    print("".join(l for l in text.splitlines(keepends=True)
                  if not l.startswith("#"))[:2600].rstrip() + "\n    ...")
else:
    config = l2.PipelineConfig(subjects=("sub-001",), seed=l2.SEED)
    print(config.to_yaml())
print()
print("The configuration's hash is recorded by every run (printed with the run log in section 3).")
configuration file: pipelines/configs/erpcore-p3.yaml  (170 lines, 6.2 kB)
overrides applied as arguments, not edits: {'output.dir': '<a temporary directory>', 'steps.ica.iclabel.enabled': True, 'subjects.include': ['sub-001']}
name: 'erpcore-p3'; dataset ds-erpcore (P3); seed 20260917


name: erpcore-p3
description: >
  Capstone C2 on ERP CORE P3: raw -> cleaned continuous data + epochs + a QC report per
  subject, in the canonical order of lesson L2.8, driven entirely by this file.

seed: 20260917

dataset:
  id: ds-erpcore
  paradigm: P3
  path: null               # a folder holding the subject's .set/.fdt files, if you have one
  cache_dir: null          # null: $EEG_PIPELINE_CACHE, else the fetcher's own cache

subjects:
  # The documented subset for C2 is 10-20 subjects (spec section 11); the cohort is 40.
  include: [sub-001, sub-002, sub-003, sub-004, sub-005,
            sub-006, sub-007, sub-008, sub-009, sub-010]
  exclude: {}              # subject: "the reason", which the report then shows
  limit: null

output:
  dir: pipelines/runs/erpcore-p3
  overwrite: true
  write_raw: true
  write_epochs: true
  write_log: true
  write_qc: true
  figures: true

versions:
  on_mismatch: warn
  mne: "1.10.2"
  numpy: ">=2.1,<3"
  scipy: ">=1.15,<2"

steps:
  load:
    preload: true
    # The file spells the frontal channels FP1/FP2; standard_1005 spells them Fp1/Fp2.
    rename: {FP1: Fp1, FP2: Fp2}
    # Prefix match: HEOG-left, HEOG-right, VEOG-lower are all typed as EOG.
    eog_channels: [VEOG, HEOG]
    misc_channels: []
    crop_s: null
    bad_segments_s: []

  montage:
    name: standard_1005
    match_case: true
    on_missing: warn

  bad_channels:
    enabled: true
    window_s: 5.0
    flat_uv: 1.0
    flat_fraction: 0.5
    deviation_z: 5.0
    correlation_threshold: 0.4
    correlation_fraction: 0.3
    hf_noise_z: 5.0
    manual: []
    keep_existing: true
    max_bad_fraction: 0.2      # 6 of 30 channels: above that, the subject needs a decision

  filter:
    l_freq: 0.1
    h_freq: 30.0
    method: fir
    phase: zero
    fir_design: firwin
    fir_window: hamming
    notch_freqs: []            # the 30 Hz low-pass is already far below the 60 Hz mains
    resample_hz: 256.0         # from 1024 Hz, after the low-pass; 4x the low-pass cutoff
    ica_l_freq: 1.0
    justification:
      highpass: >
        0.1 Hz: high enough to remove the drift of a 405-s recording, low enough not to
        distort the P3's late positivity (L2.4, pf-hp-cutoff-erp).
      lowpass: >
        30 Hz: the P3 is a slow component, and 30 Hz keeps the 60 Hz mains and the muscle
        band out without touching it. No notch is needed behind a 30 Hz low-pass.
      resample: >
        1024 Hz costs time and disk for no gain at these cutoffs; 256 Hz is still eight
        times the low-pass. Resampling happens before epoching so events are conve
    ...

The configuration's hash is recorded by every run (printed with the run log in section 3).

3. One subject, end to end

The run log is not a debug convenience, it is the evidence. Each step records its name, its parameters as used (after defaults are resolved), its duration, and what it did.

In [5]:
t0 = time.time()
raw_result = run_one(config, "sub-001") if EEGPIPE is None else run_one(config, "sub-001", progress=False)
R = norm(raw_result)
print(f"\nreturned in {time.time() - t0:.0f} s; ok = {R['ok']}; configuration hash {R['config_hash']}, "
      f"seed {R['seed']}")
print()
print("Run log:")
print(l2.fmt_table([{"step": s["name"], "duration (s)": s["duration_s"],
                     "notes": str(s.get("notes", ""))[:78]} for s in R["steps"]],
                   ["step", "duration (s)", "notes"], floatfmt="{:.2f}"))
print(f"  total {R['duration_s']:.1f} s")
if R["flags"]:
    print(f"\nflags raised by the run (recorded, not fatal): {R['flags']}")
  cached  sub-001_task-P3_events.tsv (0.0 MB)
  cached  sub-001_task-P3_eeg.json (0.0 MB)
  cached  sub-001_task-P3_channels.tsv (0.0 MB)
  cached  sub-001_task-P3_electrodes.tsv (0.0 MB)
  cached  sub-001_task-P3_coordsystem.json (0.0 MB)
  cached  sub-001_task-P3_eeg.set (4.0 MB)
  cached  sub-001_task-P3_eeg.fdt (63.1 MB)
  cached  sub-001_task-P3_events.tsv (0.0 MB)
  cached  sub-001_task-P3_eeg.json (0.0 MB)
  cached  sub-001_task-P3_channels.tsv (0.0 MB)
  cached  sub-001_task-P3_electrodes.tsv (0.0 MB)
  cached  sub-001_task-P3_coordsystem.json (0.0 MB)
RuntimeWarning: The provided Raw instance is not filtered between 1 and 100 Hz. ICLabel was designed to classify features extracted from an EEG dataset bandpass filtered between 1 and 100 Hz (see the 'filter()' method for Raw and Epochs instances).
returned in 15 s; ok = True; configuration hash 17f71c09f4cf, seed 20260917

Run log:
step          duration (s)  notes                                                                         
------------  ------------  ------------------------------------------------------------------------------
load          0.30          {'dataset': 'ds-erpcore', 'subject': 'sub-001', 'source': 'ERP CORE (Compendiu
montage       0.01          {'montage': 'standard_1005', 'n_eeg': 30, 'n_positioned': 30, 'channels_withou
bad_channels  0.14          {'n_eeg': 30, 'n_windows': 93, 'window_s': 5.0, 'bads': [], 'bad_fraction': 0.
filter        1.20          {'sfreq_in_hz': 1024.0, 'ica_copy': {'l_freq_hz': 1.0, 'h_freq_hz': 30.0, 'fil
interpolate   0.00          {'reason': 'no bad channels to interpolate', 'bads_left_marked': [], 'rank': 3
reference     0.02          {'type': 'average', 'projection': False, 'note': 'the average reference is a s
ica           12.06         {'method': 'infomax', 'fit_params': {'extended': True}, 'seed': 20260917, 'n_c
epoch         0.05          {'source': 'annotations', 'events_in_recording': 402, 'event_codes_present': {
reject        0.04          {'method': 'threshold', 'n_epochs_before': 200, 'criterion': {'peak_to_peak_uv
  total 13.8 s

flags raised by the run (recorded, not fatal): ['ICA did not converge (500 iterations)']
In [6]:
print(f"Bad channels ({len(R['bads'])} flagged, {len(R['interpolated'])} interpolated):")
print(l2.fmt_table([{"channel": ch, "criteria": crit,
                     "decision": "interpolated" if ch in R["interpolated"] else "kept"}
                    for ch, crit in R["bads"].items()]
                   or [{"channel": "(none flagged)", "criteria": "-", "decision": "-"}],
                   ["channel", "criteria", "decision"]))
print()
print(f"Rank: {R['rank']}  ({R['rank_why']})")
print()
print("Filter as resolved -- the values actually used, not the values requested:")
f = R["filter"]
flat = {k: v for k, v in f.items() if not isinstance(v, dict)}
print(l2.fmt_table([{"field": k, "value": str(v)[:70]} for k, v in flat.items()], ["field", "value"]))
for sub in ("analysis", "ica_copy"):
    if isinstance(f.get(sub), dict):
        print(f"  {sub}: " + ", ".join(f"{k} {v}" for k, v in f[sub].items() if k != "why"))
        if f[sub].get("why"):
            print(f"      why: {f[sub]['why']}")
for k, v in (f.get("justification") or {}).items():
    print(f"  justification, {k}: {' '.join(str(v).split())}")
Bad channels (0 flagged, 0 interpolated):
channel         criteria  decision
--------------  --------  --------
(none flagged)  -         -       

Rank: 24  (rank carried through the pipeline: 30 EEG channels - 0 interpolated - reference cost = 29)

Filter as resolved -- the values actually used, not the values requested:
field             value                                                                 
----------------  ----------------------------------------------------------------------
sfreq_in_hz       1024.0                                                                
resampled_to_hz   256.0                                                                 
resample_note     after the low-pass so nothing above the new Nyquist can alias, and bef
info_highpass_hz  0.1                                                                   
info_lowpass_hz   30.0                                                                  
sfreq_out_hz      256.0                                                                 
edge_note         filtered on continuous data, so edge artifacts sit at the ends of the 
  analysis: l_freq_hz 0.1, h_freq_hz 30.0, method fir, phase zero, fir_design firwin, fir_window hamming, l_trans_bandwidth auto, h_trans_bandwidth auto, filter_length_samples 33793, filter_length_s 33.001
  ica_copy: l_freq_hz 1.0, h_freq_hz 30.0, filter_length_samples 3381, filter_length_s 3.3018
      why: ICA is fitted on a 1 Hz high-passed copy because slow drift dominates the variance and costs components; the unmixing is applied to the analysis data (L2.4)
  justification, highpass: 0.1 Hz: high enough to remove the drift of a 405-s recording, low enough not to distort the P3's late positivity (L2.4, pf-hp-cutoff-erp).
  justification, lowpass: 30 Hz: the P3 is a slow component, and 30 Hz keeps the 60 Hz mains and the muscle band out without touching it. No notch is needed behind a 30 Hz low-pass.
  justification, resample: 1024 Hz costs time and disk for no gain at these cutoffs; 256 Hz is still eight times the low-pass. Resampling happens before epoching so events are converted once.
  justification, ica_highpass: 1 Hz for the ICA fit only: drift dominates the variance and costs components; the unmixing is applied to the 0.1 Hz analysis data (L2.4, L2.6).
In [7]:
ica = R["ica"]
print(f"ICA: {ica['method']}, {ica['n_components']} components, seed {ica['seed']}, fitted on "
      f"{ica['fitted_on']}; converged: {ica['converged']}")
print(f"  components removed: {len(ica['removed'])}; rank after cleaning {ica['rank_after']}")
print(l2.fmt_table(ica["removed"] or [{"component": "-", "class": "-", "probability": float("nan"),
                                       "evidence": "nothing met the policy"}],
                   ["component", "class", "probability", "evidence"], floatfmt="{:.3f}"))
if ica["kept_by_cap"]:
    print(f"  kept only because the cap was reached (logged, not silently dropped): {ica['kept_by_cap']}")
print()
print(f"Rejection criterion (one criterion for every condition, fixed before the data were looked at): "
      f"{R['rejection']['criterion']}")
print(l2.fmt_table(R["rejection"]["table"],
                   ["condition", "n_presented", "n_rejected", "n_kept", "percent_rejected"], floatfmt="{:.2f}"))
print(f"  per-condition imbalance: {R['rejection']['imbalance_pp']:.2f} percentage points")
print()
print(f"Files written: " + ", ".join(f"{k} -> {Path(v).name}" for k, v in R["outputs"].items())
      if R["outputs"] else "Files written: none (this implementation returns objects rather than files)")
ICA: infomax, 29 components, seed 20260917, fitted on 1 Hz high-passed copy; converged: False
  components removed: 5; rank after cleaning 24
component  class            probability  evidence                                                                                                              
---------  ---------------  -----------  ----------------------------------------------------------------------------------------------------------------------
8          eye blink        0.971        ICLabel classified it eye blink with probability 0.97 (threshold 0.8)                                                 
3          muscle artifact  0.964        ICLabel classified it muscle artifact with probability 0.96 (threshold 0.8)                                           
27         muscle artifact  0.944        ICLabel classified it muscle artifact with probability 0.94 (threshold 0.8)                                           
10         eye blink        0.820        ICLabel classified it eye blink with probability 0.82 (threshold 0.8)                                                 
0          eye              0.664        |correlation| with HEOG_left, HEOG_right, VEOG_lower = 0.66, above the configured threshold 0.5 (measure: correlation)

Rejection criterion (one criterion for every condition, fixed before the data were looked at): {'peak_to_peak_uv': 100.0, 'flat_uv': 1.0, 'eog_peak_to_peak_uv': None}
condition  n_presented  n_rejected  n_kept  percent_rejected
---------  -----------  ----------  ------  ----------------
target     40           2           38      5.00            
standard   160          7           153     4.38            
all        200          9           191     4.50            
  per-condition imbalance: 0.62 percentage points

Files written: config -> resolved-config.yaml, raw -> sub-001_desc-clean_raw.fif, epochs -> sub-001_desc-clean_epo.fif, qc -> qc.html, log -> run-log.json

4. The QC report

One HTML page per subject, self-contained, readable by someone who did not write the pipeline: the numbers that would make you distrust the subject at the top, then the bad-channel table, the removed components with their evidence, the per-condition rejection, the filter settings with a sentence each, and the run log with versions and the seed.

In [8]:
qc_path = R["outputs"].get("qc")
if qc_path and Path(qc_path).exists():
    html = Path(qc_path).read_text(encoding="utf-8")
    print(f"{Path(qc_path).name}: {len(html) / 1000:.0f} kB of self-contained HTML "
          "(every figure embedded as a base64 PNG)")
else:
    html = l2.qc_report_html(raw_result, title="QC report -- sub-001 (ds-erpcore P3)")
    print(f"rendered by helpers_l2.qc_report_html: {len(html) / 1000:.0f} kB")
display(HTML(html))
qc.html: 556 kB of self-contained HTML (every figure embedded as a base64 PNG)
QC sub-001 — erpcore-p3

QC report — sub-001 ok

ds-erpcore · configuration erpcore-p3 (hash 17f71c09f4cf) · seed 20260917 · 2026-09-18T14:22:11+00:00 · 13.8 s

Read this first — what would make you distrust this subject

  • ICA did not converge (500 iterations)
0bad channels
0interpolated
24rank
5ICA removed
191epochs kept
4.5% rejected

Bad channels

30 EEG channels, 93 windows of 5.0 s. Criteria and thresholds are in the configuration below; detection ran before filtering and before re-referencing.

(nothing to show)

Rank after interpolation and re-referencing: 24 — 0 interpolated channel(s) (none) and the reference each cost one. This is the rank the ICA step was given.

Filter settings, as resolved

whathigh-pass (Hz)low-pass (Hz)methodphaselength (samples)low transitionhigh transition
analysis data0.1030.00firzero33793autoauto
ICA copy (fit only)1.0030.00firzero3381
  • Resampled to 256.0 Hz — after the low-pass so nothing above the new Nyquist can alias, and before epoching so events become sample indices only once
  • Recorded passband after filtering: 0.10–30.0 Hz
  • highpass: 0.1 Hz: high enough to remove the drift of a 405-s recording, low enough not to distort the P3's late positivity (L2.4, pf-hp-cutoff-erp).
  • lowpass: 30 Hz: the P3 is a slow component, and 30 Hz keeps the 60 Hz mains and the muscle band out without touching it. No notch is needed behind a 30 Hz low-pass.
  • resample: 1024 Hz costs time and disk for no gain at these cutoffs; 256 Hz is still eight times the low-pass. Resampling happens before epoching so events are converted once.
  • ica_highpass: 1 Hz for the ICA fit only: drift dominates the variance and costs components; the unmixing is applied to the 0.1 Hz analysis data (L2.4, L2.6).
  • ICA copy: ICA is fitted on a 1 Hz high-passed copy because slow drift dominates the variance and costs components; the unmixing is applied to the analysis data (L2.4)
  • filtered on continuous data, so edge artifacts sit at the ends of the recording rather than inside every epoch (L2.4)

ICA components removed

infomax, 29 components, fitted on 1 Hz high-passed copy, seed 20260917, 500 iterations, converged: no. Components requested: rank carried through the pipeline: 30 EEG channels - 0 interpolated - reference cost = 29.

componentclassscoreICLabelevidence
IC8eye blink0.97eye blink p=0.971ICLabel classified it eye blink with probability 0.97 (threshold 0.8)
IC3muscle artifact0.96muscle artifact p=0.964ICLabel classified it muscle artifact with probability 0.96 (threshold 0.8)
IC27muscle artifact0.94muscle artifact p=0.944ICLabel classified it muscle artifact with probability 0.94 (threshold 0.8)
IC10eye blink0.82eye blink p=0.82ICLabel classified it eye blink with probability 0.82 (threshold 0.8)
IC0eye0.66eye blink p=0.986|correlation| with HEOG_left, HEOG_right, VEOG_lower = 0.66, above the configured threshold 0.5 (measure: correlation)

5 component(s) removed; rank after cleaning 24.

Data rejected, per condition

conditionepochs beforekeptrejected% rejected
target403825.00
standard16015374.38
  • Method: threshold
  • Criterion (the same for every condition): peak-to-peak 100.0 µV, flat 1.0 µV
  • Overall: 4.5% of epochs rejected; per-condition spread 0.6 percentage points
  • Reasons recorded by MNE: PO8: 7, P8: 3, CPz: 2, P7: 2, PO7: 2, Fp1: 1, C3: 1, P4: 1

Figures

Channel-mean power spectral density (Welch, median average) before filtering and after the whole pipeline. The mains line and the drift should be gone; the alpha peak should not be.
Channel-mean power spectral density (Welch, median average) before filtering and after the whole pipeline. The mains line and the drift should be gone; the alpha peak should not be.
Continuous data around the largest deflection on Fp1 (a blink, if the recording has one), before and after the ICA unmixing was applied (µV).
Continuous data around the largest deflection on Fp1 (a blink, if the recording has one), before and after the ICA unmixing was applied (µV).
Topographies of the removed ICA components (IC8, IC3, IC27, IC10, IC0).
Topographies of the removed ICA components (IC8, IC3, IC27, IC10, IC0).
ERP per condition at Pz, after cleaning and rejection, in µV (positive up).
ERP per condition at Pz, after cleaning and rejection, in µV (positive up).

Run log

stepstatussecondswhat it recorded
loadok0.30{"dataset": "ds-erpcore", "duration_s": 467.0, "n_channels": 33, "sfreq_hz": 1024.0}
montageok0.01{"n_positioned": 30}
bad_channelsok0.14{"bads": []}
filterok1.20{"resampled_to_hz": 256.0, "sfreq_out_hz": 256.0}
interpolateskipped0.00{"reason": "no bad channels to interpolate"}
referenceok0.02{"n_channels": 33, "rank_after": 29, "type": "average"}
icaok12.06{"n_excluded": 5, "rank_after": 24}
epochok0.05{"n_epochs": 200, "n_epochs_per_condition": {"standard": 160, "target": 40}, "sfreq_hz": 256.0}
rejectok0.04{"percent_rejected": 4.5}
The full run log (JSON) — every parameter as resolved, every note
{
  "subject": "sub-001",
  "dataset": "ds-erpcore",
  "config_name": "erpcore-p3",
  "config_hash": "17f71c09f4cf",
  "seed": 20260917,
  "status": "ok",
  "error": null,
  "traceback": null,
  "started_at": "2026-09-18T14:22:11+00:00",
  "duration_s": 13.819,
  "versions": {
    "python": "3.13.5",
    "platform": "Darwin 25.3.0 (arm64)",
    "mne": "1.10.2",
    "numpy": "2.1.3",
    "scipy": "1.15.3",
    "matplotlib": "3.10.6",
    "scikit-learn": "1.6.1",
    "autoreject": "0.5.0",
    "mne-icalabel": "0.9.0",
    "python-picard": null,
    "PyYAML": "6.0.2",
    "pinned": {
      "mne": "1.10.2",
      "numpy": ">=2.1,<3",
      "scipy": ">=1.15,<2"
    },
    "on_mismatch": "warn"
  },
  "version_mismatches": [],
  "bad_channels": {},
  "interpolated": [],
  "rank": 24,
  "filter": {
    "sfreq_in_hz": 1024.0,
    "ica_copy": {
      "l_freq_hz": 1.0,
      "h_freq_hz": 30.0,
      "filter_length_samples": 3381,
      "filter_length_s": 3.3018,
      "why": "ICA is fitted on a 1 Hz high-passed copy because slow drift dominates the variance and costs components; the unmixing is applied to the analysis data (L2.4)"
    },
    "analysis": {
      "l_freq_hz": 0.1,
      "h_freq_hz": 30.0,
      "method": "fir",
      "phase": "zero",
      "fir_design": "firwin",
      "fir_window": "hamming",
      "l_trans_bandwidth": "auto",
      "h_trans_bandwidth": "auto",
      "filter_length_samples": 33793,
      "filter_length_s": 33.001
    },
    "resampled_to_hz": 256.0,
    "resample_note": "after the low-pass so nothing above the new Nyquist can alias, and before epoching so events become sample indices only once",
    "info_highpass_hz": 0.1,
    "info_lowpass_hz": 30.0,
    "sfreq_out_hz": 256.0,
    "justification": {
      "highpass": "0.1 Hz: high enough to remove the drift of a 405-s recording, low enough not to distort the P3's late positivity (L2.4, pf-hp-cutoff-erp).\n",
      "lowpass": "30 Hz: the P3 is a slow component, and 30 Hz keeps the 60 Hz mains and the muscle band out without touching it. No notch is needed behind a 30 Hz low-pass.\n",
      "resample": "1024 Hz costs time and disk for no gain at these cutoffs; 256 Hz is still eight times the low-pass. Resampling happens before epoching so events are converted once.\n",
      "ica_highpass": "1 Hz for the ICA fit only: drift dominates the variance and costs components; the unmixing is applied to the 0.1 Hz analysis data (L2.4, L2.6).\n"
    },
    "edge_note": "filtered on continuous data, so edge artifacts sit at the ends of the recording rather than inside every epoch (L2.4)"
  },
  "ica": {
    "method": "infomax",
    "fit_params": {
      "extended": true
    },
    "seed": 20260917,
    "n_components": 29,
    "n_components_requested": 29,
    "rank_why": "rank carried through the pipeline: 30 EEG channels - 0 interpolated - reference cost = 29",
    "fitted_on": "1 Hz high-passed copy",
    "n_iterations": 500,
    "converged": false,
    "excluded": [
      {
        "index": 8,
        "label": "eye blink",
        "score": 0.971,
        "reason": "ICLabel classified it eye blink with probability 0.97 (threshold 0.8)",
        "detector": "mne-icalabel",
        "iclabel": {
          "label": "eye blink",
          "probability": 0.971
        }
      },
      {
        "index": 3,
        "label": "muscle artifact",
        "score": 0.964,
        "reason": "ICLabel classified it muscle artifact with probability 0.96 (threshold 0.8)",
        "detector": "mne-icalabel",
        "iclabel": {
          "label": "muscle artifact",
          "probability": 0.964
        }
      },
      {
        "index": 27,
        "label": "muscle artifact",
        "score": 0.944,
        "reason": "ICLabel classified it muscle artifact with probability 0.94 (threshold 0.8)",
        "detector": "mne-icalabel",
        "iclabel": {
          "label": "muscle artifact",
          "probability": 0.944
        }
      },
      {
        "index": 10,
        "label": "eye blink",
        "score": 0.82,
        "reason": "ICLabel classified it eye blink with probability 0.82 (threshold 0.8)",
        "detector": "mne-icalabel",
        "iclabel": {
          "label": "eye blink",
          "probability": 0.82
        }
      },
      {
        "index": 0,
        "label": "eye",
        "score": 0.664,
        "reason": "|correlation| with HEOG_left, HEOG_right, VEOG_lower = 0.66, above the configured threshold 0.5 (measure: correlation)",
        "detector": "ICA.find_bads_eog(EOG channels in the recording)",
        "iclabel": {
          "label": "eye blink",
          "probability": 0.986
        }
      }
    ],
    "n_excluded": 5,
    "kept_by_max_remove": [],
    "applied": true,
    "rank_before": 29,
    "rank_after": 24,
    "eog": {
      "enabled": true,
      "channels": [
        "HEOG_left",
        "HEOG_right",
        "VEOG_lower"
      ],
      "provenance": "EOG channels in the recording"
    },
    "iclabel": {
      "enabled": true,
      "labels": [
        "eye blink",
        "other",
        "brain",
        "muscle artifact",
        "eye blink",
        "brain",
        "other",
        "brain",
        "eye blink",
        "brain",
        "eye blink",
        "muscle artifact",
        "eye blink",
        "brain",
        "brain",
        "brain",
        "brain",
        "brain",
        "brain",
        "other",
        "brain",
        "other",
        "other",
        "brain",
        "brain",
        "other",
        "other",
        "muscle artifact",
        "brain"
      ],
      "threshold": 0.8,
      "requirements": "extended infomax, 1-100 Hz, average reference (mne-icalabel's stated conditions)"
    }
  },
  "epoching": {
    "n_epochs_per_condition": {
      "target": 40,
      "standard": 160
    },
    "dropped_at_epoching": {},
    "conditions": {
      "target": {
        "requested": [
          11,
          22,
          33,
          44,
          55
        ],
        "codes": [
          1,
          9,
          15,
          21,
          27
        ],
        "not_found": [],
        "n_events": 40
      },
      "standard": {
        "requested": [
          12,
          13,
          14,
          15,
          21,
          23,
          24,
          25,
          31,
          32,
          34,
          35,
          41,
          42,
          43,
          45,
          51,
          52,
          53,
          54
        ],
        "codes": [
          2,
          3,
          4,
          5,
          8,
          10,
          11,
          12,
          13,
          14,
          16,
          17,
          18,
          19,
          20,
          22,
          23,
          24,
          25,
          26
        ],
        "not_found": [],
        "n_events": 160
      }
    }
  },
  "rejection": {
    "method": "threshold",
    "n_epochs_before": 200,
    "criterion": {
      "peak_to_peak_uv": 100.0,
      "flat_uv": 1.0,
      "eog_peak_to_peak_uv": null
    },
    "criterion_note": "one criterion for every condition, set before the data were looked at (pf-condition-biased-rejection)",
    "per_condition": {
      "target": {
        "n_before": 40,
        "n_kept": 38,
        "n_rejected": 2,
        "percent_rejected": 5.0
      },
      "standard": {
        "n_before": 160,
        "n_kept": 153,
        "n_rejected": 7,
        "percent_rejected": 4.38
      }
    },
    "n_epochs_after": 191,
    "percent_rejected": 4.5,
    "condition_imbalance_pp": 0.62,
    "drop_reasons": {
      "CPz": 2,
      "Fp1": 1,
      "P7": 2,
      "PO7": 2,
      "P8": 3,
      "PO8": 7,
      "C3": 1,
      "P4": 1,
      "Fp2": 1,
      "PO3": 1,
      "Oz": 1
    },
    "flags": []
  },
  "facts": {
    "dataset": "ds-erpcore",
    "subject": "sub-001",
    "paradigm": "P3",
    "name": "ERP CORE (Compendium of Open Resources and Experiments)",
    "license": "CC BY 4.0 (OSF node thsqg; TODO(confirm): the component's own LICENSE file says CC BY-SA 4.0 and its dataset_description.json says CC0 -- the author reconciles this, spec section 10.11 item 8)",
    "source": "https://osf.io/thsqg/",
    "source_doi": "TODO(confirm)",
    "citation": "Kappenman, Farrens, Zhang, Stewart & Luck (2021), ERP CORE",
    "sfreq_hz": 1024.0,
    "n_channels": 33,
    "n_channels_note": "30 EEG + 3 EOG, 10-20 placement (sub-002 P3 channels.tsv)",
    "reference": "CMS (Biosemi ActiveTwo online reference)",
    "hardware_filters": "none recorded (BIDS eeg.json SoftwareFilters: n/a)",
    "mains_hz": 60,
    "subjects": 40,
    "access": "open",
    "files": [
      "sub-001_task-P3_events.tsv",
      "sub-001_task-P3_eeg.json",
      "sub-001_task-P3_channels.tsv",
      "sub-001_task-P3_electrodes.tsv",
      "sub-001_task-P3_coordsystem.json",
      "sub-001_task-P3_eeg.set",
      "sub-001_task-P3_eeg.fdt"
    ],
    "source_kind": "data/scripts/fetch_erpcore.py",
    "sidecar": {
      "subject": "sub-001",
      "eeg_json": {
        "TaskName": "P3",
        "Manufacturer": "Biosemi",
        "ManufacturersModelName": "ActiveTwo",
        "EEGReference": "CMS",
        "SamplingFrequency": 1024,
        "PowerLineFrequency": 60,
        "SoftwareFilters": "n/a",
        "EEGPlacementScheme": "10-20",
        "RecordingType": "continuous",
        "EEGChannelCount": 30,
        "EOGChannelCount": 3,
        "RecordingDuration": 467,
        "TaskDescription": "The P3 was elicited in an active visual oddball task adapted from Luck et al. (2009). The letters A, B, C, D, and E were presented in random order (p = .2 for each letter). One letter was designated the target for a given block of trials, and the other 4 letters were non-targets. Thus, the probability of the target category was .2, but the same physical stimulus served as a target in some blocks and a nontarget in others. Participants responded whether the letter presented on each trial was the target or a non-target for that block."
      },
      "channels": [
        {
          "name": "FP1",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "F3",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "F7",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "FC3",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "C3",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "C5",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "P3",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "P7",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "P9",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "PO7",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "PO3",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "O1",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "Oz",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "Pz",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "CPz",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "FP2",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "Fz",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "F4",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "F8",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "FC4",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "FCz",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "Cz",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "C4",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "C6",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "P4",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "P8",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "P10",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "PO8",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "PO4",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "O2",
          "type": "EEG",
          "units": "microV"
        },
        {
          "name": "HEOG_left",
          "type": "EOG",
          "units": "microV"
        },
        {
          "name": "HEOG_right",
          "type": "EOG",
          "units": "microV"
        },
        {
          "name": "VEOG_lower",
          "type": "EOG",
          "units": "microV"
        }
      ],
      "eeg_channels": [
        "FP1",
        "F3",
        "F7",
        "FC3",
        "C3",
        "C5",
        "P3",
        "P7",
        "P9",
        "PO7",
        "PO3",
        "O1",
        "Oz",
        "Pz",
        "CPz",
        "FP2",
        "Fz",
        "F4",
        "F8",
        "FC4",
        "FCz",
        "Cz",
        "C4",
        "C6",
        "P4",
        "P8",
        "P10",
        "PO8",
        "PO4",
        "O2"
      ],
      "eog_channels": [
        "HEOG_left",
        "HEOG_right",
        "VEOG_lower"
      ]
    },
    "montage": "standard_1005"
  },
  "flags": [
    "ICA did not converge (500 iterations)"
  ],
  "outputs": {
    "config": ".../nb-2-8-o9hlqr4r/resolved-config.yaml",
    "raw": ".../sub-001/sub-001_desc-clean_raw.fif",
    "epochs": ".../sub-001/sub-001_desc-clean_epo.fif"
  },
  "steps": [
    {
      "name": "load",
      "params": {
        "preload": true,
        "eog_channels": [
          "VEOG",
          "HEOG"
        ],
        "misc_channels": [],
        "rename": {
          "FP1": "Fp1",
          "FP2": "Fp2"
        },
        "crop_s": null,
        "bad_segments_s": [],
        "dataset": {
          "id": "ds-erpcore",
          "paradigm": "P3",
          "runs": [],
          "path": null,
          "cache_dir": null,
          "synthetic": {}
        },
        "seed": 20260917
      },
      "duration_s": 0.2978,
      "notes": {
        "dataset": "ds-erpcore",
        "subject": "sub-001",
        "source": "ERP CORE (Compendium of Open Resources and Experiments)",
        "license": "CC BY 4.0 (OSF node thsqg; TODO(confirm): the component's own LICENSE file says CC BY-SA 4.0 and its dataset_description.json says CC0 -- the author reconciles this, spec section 10.11 item 8)",
        "files": [
          "sub-001_task-P3_events.tsv",
          "sub-001_task-P3_eeg.json",
          "sub-001_task-P3_channels.tsv",
          "sub-001_task-P3_electrodes.tsv",
          "sub-001_task-P3_coordsystem.json",
          "sub-001_task-P3_eeg.set",
          "sub-001_task-P3_eeg.fdt"
        ],
        "sfreq_hz": 1024.0,
        "duration_s": 467.0,
        "n_channels": 33,
        "channel_counts": {
          "eeg": 30,
          "eog": 3,
          "stim": 0
        },
        "channels_renamed": {
          "FP1": "Fp1",
          "FP2": "Fp2"
        },
        "channel_types_set": {
          "HEOG_left": "eog",
          "HEOG_right": "eog",
          "VEOG_lower": "eog"
        },
        "header_highpass_hz": 0.0,
        "header_lowpass_hz": 512.0,
        "bads_in_file": [],
        "n_annotations": 402,
        "annotation_descriptions": [
          "11",
          "12",
          "13",
          "14",
          "15",
          "201",
          "202",
          "21",
          "22",
          "23",
          "24",
          "25",
          "31",
          "32",
          "33",
          "34",
          "35",
          "41",
          "42",
          "43"
        ],
        "bad_segments_annotated": [],
        "cropped_to_s": null,
        "documented_defect": null,
        "rank_start": 30
      },
      "status": "ok",
      "error": null
    },
    {
      "name": "montage",
      "params": {
        "name": "standard_1005",
        "match_case": true,
        "on_missing": "warn"
      },
      "duration_s": 0.0055,
      "notes": {
        "montage": "standard_1005",
        "n_eeg": 30,
        "n_positioned": 30,
        "channels_without_position": [],
        "note": "every EEG channel has a position"
      },
      "status": "ok",
      "error": null
    },
    {
      "name": "bad_channels",
      "params": {
        "enabled": true,
        "window_s": 5.0,
        "flat_uv": 1.0,
        "flat_fraction": 0.5,
        "deviation_z": 5.0,
        "correlation_threshold": 0.4,
        "correlation_fraction": 0.3,
        "hf_noise_z": 5.0,
        "manual": [],
        "keep_existing": true,
        "max_bad_fraction": 0.2
      },
      "duration_s": 0.1357,
      "notes": {
        "n_eeg": 30,
        "n_windows": 93,
        "window_s": 5.0,
        "bads": [],
        "bad_fraction": 0.0,
        "criteria_fired": {},
        "per_channel": {
          "Fp1": {
            "criteria": [],
            "metrics": {
              "std_uv": 1598.951,
              "deviation_z": 1.52,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.0,
              "median_best_correlation": 0.85,
              "hf_ratio": 0.003,
              "hf_noise_z": -0.91
            }
          },
          "F3": {
            "criteria": [],
            "metrics": {
              "std_uv": 807.166,
              "deviation_z": 0.29,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.0,
              "median_best_correlation": 0.973,
              "hf_ratio": 0.004,
              "hf_noise_z": -0.67
            }
          },
          "F7": {
            "criteria": [],
            "metrics": {
              "std_uv": 462.048,
              "deviation_z": -0.72,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.0,
              "median_best_correlation": 0.863,
              "hf_ratio": 0.015,
              "hf_noise_z": 1.18
            }
          },
          "FC3": {
            "criteria": [],
            "metrics": {
              "std_uv": 739.151,
              "deviation_z": 0.13,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.0,
              "median_best_correlation": 0.976,
              "hf_ratio": 0.004,
              "hf_noise_z": -0.63
            }
          },
          "C3": {
            "criteria": [],
            "metrics": {
              "std_uv": 1260.008,
              "deviation_z": 1.09,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.0,
              "median_best_correlation": 0.945,
              "hf_ratio": 0.002,
              "hf_noise_z": -1.02
            }
          },
          "C5": {
            "criteria": [],
            "metrics": {
              "std_uv": 662.041,
              "deviation_z": -0.07,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.011,
              "median_best_correlation": 0.874,
              "hf_ratio": 0.009,
              "hf_noise_z": 0.15
            }
          },
          "P3": {
            "criteria": [],
            "metrics": {
              "std_uv": 554.609,
              "deviation_z": -0.39,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.011,
              "median_best_correlation": 0.781,
              "hf_ratio": 0.003,
              "hf_noise_z": -0.79
            }
          },
          "P7": {
            "criteria": [],
            "metrics": {
              "std_uv": 741.678,
              "deviation_z": 0.14,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.0,
              "median_best_correlation": 0.772,
              "hf_ratio": 0.007,
              "hf_noise_z": -0.12
            }
          },
          "P9": {
            "criteria": [],
            "metrics": {
              "std_uv": 157.485,
              "deviation_z": -2.65,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.0,
              "median_best_correlation": 0.764,
              "hf_ratio": 0.029,
              "hf_noise_z": 3.65
            }
          },
          "PO7": {
            "criteria": [],
            "metrics": {
              "std_uv": 306.236,
              "deviation_z": -1.46,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.022,
              "median_best_correlation": 0.705,
              "hf_ratio": 0.021,
              "hf_noise_z": 2.35
            }
          },
          "PO3": {
            "criteria": [],
            "metrics": {
              "std_uv": 679.767,
              "deviation_z": -0.02,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.032,
              "median_best_correlation": 0.751,
              "hf_ratio": 0.004,
              "hf_noise_z": -0.66
            }
          },
          "O1": {
            "criteria": [],
            "metrics": {
              "std_uv": 695.603,
              "deviation_z": 0.02,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.0,
              "median_best_correlation": 0.763,
              "hf_ratio": 0.009,
              "hf_noise_z": 0.19
            }
          },
          "Oz": {
            "criteria": [],
            "metrics": {
              "std_uv": 483.576,
              "deviation_z": -0.63,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.0,
              "median_best_correlation": 0.789,
              "hf_ratio": 0.009,
              "hf_noise_z": 0.17
            }
          },
          "Pz": {
            "criteria": [],
            "metrics": {
              "std_uv": 108.066,
              "deviation_z": -3.33,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.0,
              "median_best_correlation": 0.845,
              "hf_ratio": 0.012,
              "hf_noise_z": 0.78
            }
          },
          "CPz": {
            "criteria": [],
            "metrics": {
              "std_uv": 183.924,
              "deviation_z": -2.37,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.0,
              "median_best_correlation": 0.924,
              "hf_ratio": 0.009,
              "hf_noise_z": 0.17
            }
          },
          "Fp2": {
            "criteria": [],
            "metrics": {
              "std_uv": 1926.533,
              "deviation_z": 1.85,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.0,
              "median_best_correlation": 0.842,
              "hf_ratio": 0.003,
              "hf_noise_z": -0.82
            }
          },
          "Fz": {
            "criteria": [],
            "metrics": {
              "std_uv": 1274.242,
              "deviation_z": 1.11,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.0,
              "median_best_correlation": 0.962,
              "hf_ratio": 0.002,
              "hf_noise_z": -1.03
            }
          },
          "F4": {
            "criteria": [],
            "metrics": {
              "std_uv": 429.606,
              "deviation_z": -0.85,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.0,
              "median_best_correlation": 0.916,
              "hf_ratio": 0.014,
              "hf_noise_z": 1.12
            }
          },
          "F8": {
            "criteria": [],
            "metrics": {
              "std_uv": 945.054,
              "deviation_z": 0.57,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.086,
              "median_best_correlation": 0.662,
              "hf_ratio": 0.015,
              "hf_noise_z": 1.24
            }
          },
          "FC4": {
            "criteria": [],
            "metrics": {
              "std_uv": 847.671,
              "deviation_z": 0.38,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.011,
              "median_best_correlation": 0.883,
              "hf_ratio": 0.006,
              "hf_noise_z": -0.28
            }
          },
          "FCz": {
            "criteria": [],
            "metrics": {
              "std_uv": 490.412,
              "deviation_z": -0.61,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.0,
              "median_best_correlation": 0.968,
              "hf_ratio": 0.004,
              "hf_noise_z": -0.62
            }
          },
          "Cz": {
            "criteria": [],
            "metrics": {
              "std_uv": 106.081,
              "deviation_z": -3.36,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.0,
              "median_best_correlation": 0.947,
              "hf_ratio": 0.017,
              "hf_noise_z": 1.67
            }
          },
          "C4": {
            "criteria": [],
            "metrics": {
              "std_uv": 891.055,
              "deviation_z": 0.47,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.0,
              "median_best_correlation": 0.906,
              "hf_ratio": 0.004,
              "hf_noise_z": -0.64
            }
          },
          "C6": {
            "criteria": [],
            "metrics": {
              "std_uv": 1534.857,
              "deviation_z": 1.44,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.022,
              "median_best_correlation": 0.862,
              "hf_ratio": 0.005,
              "hf_noise_z": -0.51
            }
          },
          "P4": {
            "criteria": [],
            "metrics": {
              "std_uv": 715.976,
              "deviation_z": 0.07,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.0,
              "median_best_correlation": 0.854,
              "hf_ratio": 0.005,
              "hf_noise_z": -0.42
            }
          },
          "P8": {
            "criteria": [],
            "metrics": {
              "std_uv": 453.434,
              "deviation_z": -0.75,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.0,
              "median_best_correlation": 0.83,
              "hf_ratio": 0.01,
              "hf_noise_z": 0.42
            }
          },
          "P10": {
            "criteria": [],
            "metrics": {
              "std_uv": 1462.528,
              "deviation_z": 1.36,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.0,
              "median_best_correlation": 0.876,
              "hf_ratio": 0.002,
              "hf_noise_z": -0.93
            }
          },
          "PO8": {
            "criteria": [],
            "metrics": {
              "std_uv": 920.719,
              "deviation_z": 0.53,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.0,
              "median_best_correlation": 0.829,
              "hf_ratio": 0.009,
              "hf_noise_z": 0.12
            }
          },
          "PO4": {
            "criteria": [],
            "metrics": {
              "std_uv": 362.411,
              "deviation_z": -1.15,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.0,
              "median_best_correlation": 0.75,
              "hf_ratio": 0.014,
              "hf_noise_z": 1.13
            }
          },
          "O2": {
            "criteria": [],
            "metrics": {
              "std_uv": 489.419,
              "deviation_z": -0.61,
              "flat_fraction": 0.0,
              "low_correlation_fraction": 0.011,
              "median_best_correlation": 0.822,
              "hf_ratio": 0.012,
              "hf_noise_z": 0.68
            }
          }
        },
        "exceeds_max_bad_fraction": false,
        "flags": [],
        "note": "detection ran before filtering and before re-referencing (L2.8): a bad channel that joins an average reference contaminates every channel"
      },
      "status": "ok",
      "error": null
    },
    {
      "name": "filter",
      "params": {
        "l_freq": 0.1,
        "h_freq": 30.0,
        "method": "fir",
        "phase": "zero",
        "fir_design": "firwin",
        "fir_window": "hamming",
        "l_trans_bandwidth": "auto",
        "h_trans_bandwidth": "auto",
        "iir_params": {},
        "notch_freqs": [],
        "notch_widths": null,
        "resample_hz": 256.0,
        "ica_l_freq": 1.0,
        "ica_h_freq": null,
        "justification": {
          "highpass": "0.1 Hz: high enough to remove the drift of a 405-s recording, low enough not to distort the P3's late positivity (L2.4, pf-hp-cutoff-erp).\n",
          "lowpass": "30 Hz: the P3 is a slow component, and 30 Hz keeps the 60 Hz mains and the muscle band out without touching it. No notch is needed behind a 30 Hz low-pass.\n",
          "resample": "1024 Hz costs time and disk for no gain at these cutoffs; 256 Hz is still eight times the low-pass. Resampling happens before epoching so events are converted once.\n",
          "ica_highpass": "1 Hz for the ICA fit only: drift dominates the variance and costs components; the unmixing is applied to the 0.1 Hz analysis data (L2.4, L2.6).\n"
        }
      },
      "duration_s": 1.2026,
      "notes": {
        "sfreq_in_hz": 1024.0,
        "ica_copy": {
          "l_freq_hz": 1.0,
          "h_freq_hz": 30.0,
          "filter_length_samples": 3381,
          "filter_length_s": 3.3018,
          "why": "ICA is fitted on a 1 Hz high-passed copy because slow drift dominates the variance and costs components; the unmixing is applied to the analysis data (L2.4)"
        },
        "analysis": {
          "l_freq_hz": 0.1,
          "h_freq_hz": 30.0,
          "method": "fir",
          "phase": "zero",
          "fir_design": "firwin",
          "fir_window": "hamming",
          "l_trans_bandwidth": "auto",
          "h_trans_bandwidth": "auto",
          "filter_length_samples": 33793,
          "filter_length_s": 33.001
        },
        "resampled_to_hz": 256.0,
        "resample_note": "after the low-pass so nothing above the new Nyquist can alias, and before epoching so events become sample indices only once",
        "info_highpass_hz": 0.1,
        "info_lowpass_hz": 30.0,
        "sfreq_out_hz": 256.0,
        "justification": {
          "highpass": "0.1 Hz: high enough to remove the drift of a 405-s recording, low enough not to distort the P3's late positivity (L2.4, pf-hp-cutoff-erp).\n",
          "lowpass": "30 Hz: the P3 is a slow component, and 30 Hz keeps the 60 Hz mains and the muscle band out without touching it. No notch is needed behind a 30 Hz low-pass.\n",
          "resample": "1024 Hz costs time and disk for no gain at these cutoffs; 256 Hz is still eight times the low-pass. Resampling happens before epoching so events are converted once.\n",
          "ica_highpass": "1 Hz for the ICA fit only: drift dominates the variance and costs components; the unmixing is applied to the 0.1 Hz analysis data (L2.4, L2.6).\n"
        },
        "edge_note": "filtered on continuous data, so edge artifacts sit at the ends of the recording rather than inside every epoch (L2.4)"
      },
      "status": "ok",
      "error": null
    },
    {
      "name": "interpolate",
      "params": {
        "enabled": true,
        "method": "spline",
        "reset_bads": true,
        "exclude": [],
        "max_interpolate": 6
      },
      "duration_s": 0.0,
      "notes": {
        "reason": "no bad channels to interpolate",
        "bads_left_marked": [],
        "rank": 30
      },
      "status": "skipped",
      "error": null
    },
    {
      "name": "reference",
      "params": {
        "type": "average",
        "channels": [],
        "projection": false,
        "add_channels": []
      },
      "duration_s": 0.0203,
      "notes": {
        "type": "average",
        "projection": false,
        "note": "the average reference is a subtraction of the mean over channels, so the data lose one rank; the ICA step is told",
        "rank_before": 30,
        "rank_after": 29,
        "rank_cost": 1,
        "n_channels": 33
      },
      "status": "ok",
      "error": null
    },
    {
      "name": "ica",
      "params": {
        "enabled": true,
        "method": "infomax",
        "fit_params": {
          "extended": true
        },
        "n_components": "rank",
        "max_iter": 500,
        "decim": 3,
        "seed": 20260917,
        "reject_by_annotation": true,
        "fit_on": "ica_copy",
        "eog": {
          "enabled": true,
          "channels": [],
          "measure": "correlation",
          "threshold": 0.5
        },
        "muscle": {
          "enabled": false,
          "threshold": 0.8
        },
        "iclabel": {
          "enabled": true,
          "labels": [
            "eye blink",
            "muscle artifact",
            "heart beat",
            "line noise",
            "channel noise"
          ],
          "threshold": 0.8
        },
        "max_remove": 6,
        "apply": true
      },
      "duration_s": 12.0616,
      "notes": {
        "method": "infomax",
        "fit_params": {
          "extended": true
        },
        "seed": 20260917,
        "n_components": 29,
        "n_components_requested": 29,
        "rank_why": "rank carried through the pipeline: 30 EEG channels - 0 interpolated - reference cost = 29",
        "fitted_on": "1 Hz high-passed copy",
        "n_iterations": 500,
        "converged": false,
        "excluded": [
          {
            "index": 8,
            "label": "eye blink",
            "score": 0.971,
            "reason": "ICLabel classified it eye blink with probability 0.97 (threshold 0.8)",
            "detector": "mne-icalabel",
            "iclabel": {
              "label": "eye blink",
              "probability": 0.971
            }
          },
          {
            "index": 3,
            "label": "muscle artifact",
            "score": 0.964,
            "reason": "ICLabel classified it muscle artifact with probability 0.96 (threshold 0.8)",
            "detector": "mne-icalabel",
            "iclabel": {
              "label": "muscle artifact",
              "probability": 0.964
            }
          },
          {
            "index": 27,
            "label": "muscle artifact",
            "score": 0.944,
            "reason": "ICLabel classified it muscle artifact with probability 0.94 (threshold 0.8)",
            "detector": "mne-icalabel",
            "iclabel": {
              "label": "muscle artifact",
              "probability": 0.944
            }
          },
          {
            "index": 10,
            "label": "eye blink",
            "score": 0.82,
            "reason": "ICLabel classified it eye blink with probability 0.82 (threshold 0.8)",
            "detector": "mne-icalabel",
            "iclabel": {
              "label": "eye blink",
              "probability": 0.82
            }
          },
          {
            "index": 0,
            "label": "eye",
            "score": 0.664,
            "reason": "|correlation| with HEOG_left, HEOG_right, VEOG_lower = 0.66, above the configured threshold 0.5 (measure: correlation)",
            "detector": "ICA.find_bads_eog(EOG channels in the recording)",
            "iclabel": {
              "label": "eye blink",
              "probability": 0.986
            }
          }
        ],
        "n_excluded": 5,
        "kept_by_max_remove": [],
        "applied": true,
        "rank_before": 29,
        "rank_after": 24,
        "eog": {
          "enabled": true,
          "channels": [
            "HEOG_left",
            "HEOG_right",
            "VEOG_lower"
          ],
          "provenance": "EOG channels in the recording"
        },
        "iclabel": {
          "enabled": true,
          "labels": [
            "eye blink",
            "other",
            "brain",
            "muscle artifact",
            "eye blink",
            "brain",
            "other",
            "brain",
            "eye blink",
            "brain",
            "eye blink",
            "muscle artifact",
            "eye blink",
            "brain",
            "brain",
            "brain",
            "brain",
            "brain",
            "brain",
            "other",
            "brain",
            "other",
            "other",
            "brain",
            "brain",
            "other",
            "other",
            "muscle artifact",
            "brain"
          ],
          "threshold": 0.8,
          "requirements": "extended infomax, 1-100 Hz, average reference (mne-icalabel's stated conditions)"
        },
        "warning": "ICA did not converge in 500 iterations; the components are whatever the algorithm had reached (logged, not raised)"
      },
      "status": "ok",
      "error": null
    },
    {
      "name": "epoch",
      "params": {
        "enabled": true,
        "source": "annotations",
        "stim_channel": null,
        "conditions": {
          "target": [
            11,
            22,
            33,
            44,
            55
          ],
          "standard": [
            12,
            13,
            14,
            15,
            21,
            23,
            24,
            25,
            31,
            32,
            34,
            35,
            41,
            42,
            43,
            45,
            51,
            52,
            53,
            54
          ]
        },
        "tmin": -0.2,
        "tmax": 0.8,
        "baseline": [
          -0.2,
          0.0
        ],
        "decim": 1,
        "detrend": null,
        "reject_by_annotation": true,
        "event_repeated": "error",
        "on_missing": "warn",
        "shift_s": 0.0
      },
      "duration_s": 0.0535,
      "notes": {
        "source": "annotations",
        "events_in_recording": 402,
        "event_codes_present": {
          "11": 1,
          "12": 2,
          "13": 3,
          "14": 4,
          "15": 5,
          "201": 6,
          "202": 7,
          "21": 8,
          "22": 9,
          "23": 10,
          "24": 11,
          "25": 12,
          "31": 13,
          "32": 14,
          "33": 15,
          "34": 16,
          "35": 17,
          "41": 18,
          "42": 19,
          "43": 20,
          "44": 21,
          "45": 22,
          "51": 23,
          "52": 24,
          "53": 25,
          "54": 26,
          "55": 27
        },
        "conditions": {
          "target": {
            "requested": [
              11,
              22,
              33,
              44,
              55
            ],
            "codes": [
              1,
              9,
              15,
              21,
              27
            ],
            "not_found": [],
            "n_events": 40
          },
          "standard": {
            "requested": [
              12,
              13,
              14,
              15,
              21,
              23,
              24,
              25,
              31,
              32,
              34,
              35,
              41,
              42,
              43,
              45,
              51,
              52,
              53,
              54
            ],
            "codes": [
              2,
              3,
              4,
              5,
              8,
              10,
              11,
              12,
              13,
              14,
              16,
              17,
              18,
              19,
              20,
              22,
              23,
              24,
              25,
              26
            ],
            "not_found": [],
            "n_events": 160
          }
        },
        "n_epochs": 200,
        "n_epochs_per_condition": {
          "target": 40,
          "standard": 160
        },
        "dropped_at_epoching": {},
        "tmin_s": -0.2,
        "tmax_s": 0.8,
        "baseline_s": [
          -0.2,
          0.0
        ],
        "decim": 1,
        "sfreq_hz": 256.0,
        "trigger_shift_s": 0.0,
        "note": "epoching came after resampling and after every continuous-domain step (L2.8); epochs overlapping a BAD_ annotation were dropped here, before any rejection criterion was applied"
      },
      "status": "ok",
      "error": null
    },
    {
      "name": "reject",
      "params": {
        "method": "threshold",
        "ptp_uv": 100.0,
        "flat_uv": 1.0,
        "eog_ptp_uv": null,
        "max_rejected_fraction": 0.4,
        "condition_balance_pp": 10.0,
        "autoreject": {
          "n_interpolate": [
            1,
            4
          ],
          "consensus": [],
          "cv": 4,
          "n_jobs": 1
        }
      },
      "duration_s": 0.0375,
      "notes": {
        "method": "threshold",
        "n_epochs_before": 200,
        "criterion": {
          "peak_to_peak_uv": 100.0,
          "flat_uv": 1.0,
          "eog_peak_to_peak_uv": null
        },
        "criterion_note": "one criterion for every condition, set before the data were looked at (pf-condition-biased-rejection)",
        "per_condition": {
          "target": {
            "n_before": 40,
            "n_kept": 38,
            "n_rejected": 2,
            "percent_rejected": 5.0
          },
          "standard": {
            "n_before": 160,
            "n_kept": 153,
            "n_rejected": 7,
            "percent_rejected": 4.38
          }
        },
        "n_epochs_after": 191,
        "percent_rejected": 4.5,
        "condition_imbalance_pp": 0.62,
        "drop_reasons": {
          "CPz": 2,
          "Fp1": 1,
          "P7": 2,
          "PO7": 2,
          "P8": 3,
          "PO8": 7,
          "C3": 1,
          "P4": 1,
          "Fp2": 1,
          "PO3": 1,
          "Oz": 1
        },
        "flags": []
      },
      "status": "ok",
      "error": null
    }
  ],
  "config": {
    "name": "erpcore-p3",
    "description": "Capstone C2 on ERP CORE P3: raw -> cleaned continuous data + epochs + a QC report per subject, in the canonical order of lesson L2.8, driven entirely by this file.\n",
    "seed": 20260917,
    "dataset": {
      "id": "ds-erpcore",
      "paradigm": "P3",
      "runs": [],
      "path": null,
      "cache_dir": null,
      "synthetic": {}
    },
    "subjects": {
      "include": [
        "sub-001"
      ],
      "exclude": {},
      "limit": null
    },
    "output": {
      "dir": ".../T/nb-2-8-o9hlqr4r",
      "overwrite": true,
      "write_raw": true,
      "write_epochs": true,
      "write_log": true,
      "write_qc": true,
      "figures": true
    },
    "versions": {
      "on_mismatch": "warn",
      "pins": {
        "mne": "1.10.2",
        "numpy": ">=2.1,<3",
        "scipy": ">=1.15,<2"
      }
    },
    "steps": {
      "load": {
        "preload": true,
        "eog_channels": [
          "VEOG",
          "HEOG"
        ],
        "misc_channels": [],
        "rename": {
          "FP1": "Fp1",
          "FP2": "Fp2"
        },
        "crop_s": null,
        "bad_segments_s": []
      },
      "montage": {
        "name": "standard_1005",
        "match_case": true,
        "on_missing": "warn"
      },
      "bad_channels": {
        "enabled": true,
        "window_s": 5.0,
        "flat_uv": 1.0,
        "flat_fraction": 0.5,
        "deviation_z": 5.0,
        "correlation_threshold": 0.4,
        "correlation_fraction": 0.3,
        "hf_noise_z": 5.0,
        "manual": [],
        "keep_existing": true,
        "max_bad_fraction": 0.2
      },
      "filter": {
        "l_freq": 0.1,
        "h_freq": 30.0,
        "method": "fir",
        "phase": "zero",
        "fir_design": "firwin",
        "fir_window": "hamming",
        "l_trans_bandwidth": "auto",
        "h_trans_bandwidth": "auto",
        "iir_params": {},
        "notch_freqs": [],
        "notch_widths": null,
        "resample_hz": 256.0,
        "ica_l_freq": 1.0,
        "ica_h_freq": null,
        "justification": {
          "highpass": "0.1 Hz: high enough to remove the drift of a 405-s recording, low enough not to distort the P3's late positivity (L2.4, pf-hp-cutoff-erp).\n",
          "lowpass": "30 Hz: the P3 is a slow component, and 30 Hz keeps the 60 Hz mains and the muscle band out without touching it. No notch is needed behind a 30 Hz low-pass.\n",
          "resample": "1024 Hz costs time and disk for no gain at these cutoffs; 256 Hz is still eight times the low-pass. Resampling happens before epoching so events are converted once.\n",
          "ica_highpass": "1 Hz for the ICA fit only: drift dominates the variance and costs components; the unmixing is applied to the 0.1 Hz analysis data (L2.4, L2.6).\n"
        }
      },
      "interpolate": {
        "enabled": true,
        "method": "spline",
        "reset_bads": true,
        "exclude": [],
        "max_interpolate": 6
      },
      "reference": {
        "type": "average",
        "channels": [],
        "projection": false,
        "add_channels": []
      },
      "ica": {
        "enabled": true,
        "method": "infomax",
        "fit_params": {
          "extended": true
        },
        "n_components": "rank",
        "max_iter": 500,
        "decim": 3,
        "seed": null,
        "reject_by_annotation": true,
        "fit_on": "ica_copy",
        "eog": {
          "enabled": true,
          "channels": [],
          "measure": "correlation",
          "threshold": 0.5
        },
        "muscle": {
          "enabled": false,
          "threshold": 0.8
        },
        "iclabel": {
          "enabled": true,
          "labels": [
            "eye blink",
            "muscle artifact",
            "heart beat",
            "line noise",
            "channel noise"
          ],
          "threshold": 0.8
        },
        "max_remove": 6,
        "apply": true
      },
      "epoch": {
        "enabled": true,
        "source": "annotations",
        "stim_channel": null,
        "conditions": {
          "target": [
            11,
            22,
            33,
            44,
            55
          ],
          "standard": [
            12,
            13,
            14,
            15,
            21,
            23,
            24,
            25,
            31,
            32,
            34,
            35,
            41,
            42,
            43,
            45,
            51,
            52,
            53,
            54
          ]
        },
        "tmin": -0.2,
        "tmax": 0.8,
        "baseline": [
          -0.2,
          0.0
        ],
        "decim": 1,
        "detrend": null,
        "reject_by_annotation": true,
        "event_repeated": "error",
        "on_missing": "warn",
        "shift_s": 0.0
      },
      "reject": {
        "method": "threshold",
        "ptp_uv": 100.0,
        "flat_uv": 1.0,
        "eog_ptp_uv": null,
        "max_rejected_fraction": 0.4,
        "condition_balance_pp": 10.0,
        "autoreject": {
          "n_interpolate": [
            1,
            4
          ],
          "consensus": [],
          "cv": 4,
          "n_jobs": 1
        }
      }
    }
  }
}

Provenance, versions and seed

dataset factvalue
nameERP CORE (Compendium of Open Resources and Experiments)
licenseCC BY 4.0 (OSF node thsqg; TODO(confirm): the component's own LICENSE file says CC BY-SA 4.0 and its dataset_description.json says CC0 -- the author reconciles this, spec section 10.11 item 8)
sourcehttps://osf.io/thsqg/
source_doiTODO(confirm)
citationKappenman, Farrens, Zhang, Stewart & Luck (2021), ERP CORE
sfreq_hz1024.00
referenceCMS (Biosemi ActiveTwo online reference)
hardware_filtersnone recorded (BIDS eeg.json SoftwareFilters: n/a)
mains_hz60
filessub-001_task-P3_events.tsv, sub-001_task-P3_eeg.json, sub-001_task-P3_channels.tsv, sub-001_task-P3_electrodes.tsv, sub-001_task-P3_coordsystem.json, sub-001_task-P3_eeg.set, sub-001_task-P3_eeg.fdt

Seed 20260917, configuration hash 17f71c09f4cf.

packagein usepinned in the config
python3.13.5
platformDarwin 25.3.0 (arm64)
mne1.10.21.10.2
numpy2.1.3>=2.1,<3
scipy1.15.3>=1.15,<2
matplotlib3.10.6
scikit-learn1.6.1
autoreject0.5.0
mne-icalabel0.9.0
python-picardnot installed
PyYAML6.0.2
The resolved configuration (YAML) — this is what reproduces the run
name: erpcore-p3
description: 'Capstone C2 on ERP CORE P3: raw -> cleaned continuous data + epochs
  + a QC report per subject, in the canonical order of lesson L2.8, driven entirely
  by this file.

  '
seed: 20260917
dataset:
  id: ds-erpcore
  paradigm: P3
  runs: []
  path: null
  cache_dir: null
  synthetic: {}
subjects:
  include:
  - sub-001
  exclude: {}
  limit: null
output:
  dir: .../T/nb-2-8-o9hlqr4r
  overwrite: true
  write_raw: true
  write_epochs: true
  write_log: true
  write_qc: true
  figures: true
versions:
  on_mismatch: warn
  pins:
    mne: 1.10.2
    numpy: '>=2.1,<3'
    scipy: '>=1.15,<2'
steps:
  load:
    preload: true
    eog_channels:
    - VEOG
    - HEOG
    misc_channels: []
    rename:
      FP1: Fp1
      FP2: Fp2
    crop_s: null
    bad_segments_s: []
  montage:
    name: standard_1005
    match_case: true
    on_missing: warn
  bad_channels:
    enabled: true
    window_s: 5.0
    flat_uv: 1.0
    flat_fraction: 0.5
    deviation_z: 5.0
    correlation_threshold: 0.4
    correlation_fraction: 0.3
    hf_noise_z: 5.0
    manual: []
    keep_existing: true
    max_bad_fraction: 0.2
  filter:
    l_freq: 0.1
    h_freq: 30.0
    method: fir
    phase: zero
    fir_design: firwin
    fir_window: hamming
    l_trans_bandwidth: auto
    h_trans_bandwidth: auto
    iir_params: {}
    notch_freqs: []
    notch_widths: null
    resample_hz: 256.0
    ica_l_freq: 1.0
    ica_h_freq: null
    justification:
      highpass: '0.1 Hz: high enough to remove the drift of a 405-s recording, low
        enough not to distort the P3''s late positivity (L2.4, pf-hp-cutoff-erp).

        '
      lowpass: '30 Hz: the P3 is a slow component, and 30 Hz keeps the 60 Hz mains
        and the muscle band out without touching it. No notch is needed behind a 30
        Hz low-pass.

        '
      resample: '1024 Hz costs time and disk for no gain at these cutoffs; 256 Hz
        is still eight times the low-pass. Resampling happens before epoching so events
        are converted once.

        '
      ica_highpass: '1 Hz for the ICA fit only: drift dominates the variance and costs
        components; the unmixing is applied to the 0.1 Hz analysis data (L2.4, L2.6).

        '
  interpolate:
    enabled: true
    method: spline
    reset_bads: true
    exclude: []
    max_interpolate: 6
  reference:
    type: average
    channels: []
    projection: false
    add_channels: []
  ica:
    enabled: true
    method: infomax
    fit_params:
      extended: true
    n_components: rank
    max_iter: 500
    decim: 3
    seed: null
    reject_by_annotation: true
    fit_on: ica_copy
    eog:
      enabled: true
      channels: []
      measure: correlation
      threshold: 0.5
    muscle:
      enabled: false
      threshold: 0.8
    iclabel:
      enabled: true
      labels:
      - eye blink
      - muscle artifact
      - heart beat
      - line noise
      - channel noise
      threshold: 0.8
    max_remove: 6
    apply: true
  epoch:
    enabled: true
    source: annotations
    stim_channel: null
    conditions:
      target:
      - 11
      - 22
      - 33
      - 44
      - 55
      standard:
      - 12
      - 13
      - 14
      - 15
      - 21
      - 23
      - 24
      - 25
      - 31
      - 32
      - 34
      - 35
      - 41
      - 42
      - 43
      - 45
      - 51
      - 52
      - 53
      - 54
    tmin: -0.2
    tmax: 0.8
    baseline:
    - -0.2
    - 0.0
    decim: 1
    detrend: null
    reject_by_annotation: true
    event_repeated: error
    on_missing: warn
    shift_s: 0.0
  reject:
    method: threshold
    ptp_uv: 100.0
    flat_uv: 1.0
    eog_ptp_uv: null
    max_rejected_fraction: 0.4
    condition_balance_pp: 10.0
    autoreject:
      n_interpolate:
      - 1
      - 4
      consensus: []
      cv: 4
      n_jobs: 1

Generated by eegpipe (pipelines/eegpipe) for the course capstone C2. Cite a run by its configuration name and hash, the seed and the package versions above.

5. Two runs differ only by their configuration

One parameter is changed — the analysis high-pass moves from 0.1 Hz to 1 Hz, which L2.4 says costs a P3 a third of its amplitude — and nothing else. The two configuration hashes and the two run logs are the whole record of the difference. No file was edited to produce the second run.

In [9]:
if EEGPIPE is not None:
    config_b = EEGPIPE.load_config(CONFIG_PATH, overrides={**OVERRIDES, "steps.filter.l_freq": 1.0,
                                                           "output.dir": str(OUT / "run-b")})
    result_b = norm(EEGPIPE.run_subject(config_b, "sub-001"))
else:
    import dataclasses
    config_b = dataclasses.replace(config, l_freq=1.0)
    result_b = norm(l2.run_subject(config_b, "sub-001", verbose=False))

rows = []
for label, res in (("A: analysis high-pass 0.1 Hz", R), ("B: analysis high-pass 1.0 Hz", result_b)):
    fa = (res["filter"].get("analysis") or res["filter"])
    rows.append({"run": label, "config hash": res["config_hash"],
                 "high-pass (Hz)": fa.get("l_freq_hz", float("nan")),
                 "FIR length (s)": fa.get("filter_length_s", res["filter"].get("fir_length_s", float("nan"))),
                 "bads interpolated": res["interpolated"] or ["-"], "rank": res["rank"],
                 "ICA removed": len(res["ica"]["removed"]),
                 "% rejected (all)": [r for r in res["rejection"]["table"] if r["condition"] == "all"][0]["percent_rejected"],
                 "imbalance (pp)": res["rejection"]["imbalance_pp"],
                 "duration (s)": res["duration_s"]})
print(l2.fmt_table(rows, list(rows[0]), floatfmt="{:.2f}"))
print()
print("Everything a reader needs in order to know why the two runs differ is the pair of configuration "
      "hashes. That is what 'two runs differ only by their configuration' buys, and it is the test L2.8 "
      "gives: could you reproduce last month's result by checking out the configuration file?")
  cached  sub-001_task-P3_events.tsv (0.0 MB)
  cached  sub-001_task-P3_eeg.json (0.0 MB)
  cached  sub-001_task-P3_channels.tsv (0.0 MB)
  cached  sub-001_task-P3_electrodes.tsv (0.0 MB)
  cached  sub-001_task-P3_coordsystem.json (0.0 MB)
  cached  sub-001_task-P3_eeg.set (4.0 MB)
  cached  sub-001_task-P3_eeg.fdt (63.1 MB)
  cached  sub-001_task-P3_events.tsv (0.0 MB)
  cached  sub-001_task-P3_eeg.json (0.0 MB)
  cached  sub-001_task-P3_channels.tsv (0.0 MB)
  cached  sub-001_task-P3_electrodes.tsv (0.0 MB)
  cached  sub-001_task-P3_coordsystem.json (0.0 MB)
RuntimeWarning: The provided Raw instance is not filtered between 1 and 100 Hz. ICLabel was designed to classify features extracted from an EEG dataset bandpass filtered between 1 and 100 Hz (see the 'filter()' method for Raw and Epochs instances).
run                           config hash   high-pass (Hz)  FIR length (s)  bads interpolated  rank  ICA removed  % rejected (all)  imbalance (pp)  duration (s)
----------------------------  ------------  --------------  --------------  -----------------  ----  -----------  ----------------  --------------  ------------
A: analysis high-pass 0.1 Hz  17f71c09f4cf  0.10            33.00           -                  24    5            4.50              0.62            13.82       
B: analysis high-pass 1.0 Hz  bad18d091a36  1.00            3.30            -                  24    5            3.00              2.50            13.48       

Everything a reader needs in order to know why the two runs differ is the pair of configuration hashes. That is what 'two runs differ only by their configuration' buys, and it is the test L2.8 gives: could you reproduce last month's result by checking out the configuration file?
In [10]:
# The measurement itself, so that the difference is a number rather than a claim.
CH, WINDOW = l2.P3_CHANNEL, l2.P3_WINDOW
measure_rows = []


def epochs_of(res_obj):
    """The cleaned epochs, whichever implementation produced them."""
    if hasattr(res_obj, "status"):
        if getattr(res_obj, "epochs", None) is not None:
            return res_obj.epochs
        p = (res_obj.outputs or {}).get("epochs")
        return mne.read_epochs(p, verbose=False) if p and Path(p).exists() else None
    return res_obj.get("epochs")


ep_a = epochs_of(raw_result)
ep_b_src = (EEGPIPE.run_subject(config_b, "sub-001") if EEGPIPE is not None
            else l2.run_subject(config_b, "sub-001", verbose=False))
ep_b = epochs_of(ep_b_src)
fig, ax = plt.subplots(figsize=(9, 4.2))
for ep, label, color in ((ep_a, "A: high-pass 0.1 Hz", "tab:blue"), (ep_b, "B: high-pass 1.0 Hz", "tab:red")):
    if ep is None:
        continue
    d = l2.difference_wave(ep)
    amp = l2.mean_amplitude(d, CH, WINDOW)
    measure_rows.append({"run": label, f"{CH} mean (uV)": amp, "trials": int(d.nave)})
    ax.plot(d.times * 1000, d.data[d.ch_names.index(CH)] * 1e6, lw=1.6, color=color,
            label=f"{label}: {amp:+.2f} uV")
ax.axvspan(WINDOW[0] * 1000, WINDOW[1] * 1000, color="tab:orange", alpha=0.18, label="a-priori window")
ax.axhline(0, color="gray", lw=0.6); ax.axvline(0, color="gray", lw=0.6)
ax.set(xlabel="Time from stimulus (ms)", ylabel="Amplitude (uV)",
       title=f"sub-001: target minus standard at {CH} under the two configurations (uV, positive up)")
ax.legend(fontsize=8); ax.grid(alpha=0.3)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
print(l2.fmt_table(measure_rows, list(measure_rows[0]) if measure_rows else [], floatfmt="{:+.2f}"))
  cached  sub-001_task-P3_events.tsv (0.0 MB)
  cached  sub-001_task-P3_eeg.json (0.0 MB)
  cached  sub-001_task-P3_channels.tsv (0.0 MB)
  cached  sub-001_task-P3_electrodes.tsv (0.0 MB)
  cached  sub-001_task-P3_coordsystem.json (0.0 MB)
  cached  sub-001_task-P3_eeg.set (4.0 MB)
  cached  sub-001_task-P3_eeg.fdt (63.1 MB)
  cached  sub-001_task-P3_events.tsv (0.0 MB)
  cached  sub-001_task-P3_eeg.json (0.0 MB)
  cached  sub-001_task-P3_channels.tsv (0.0 MB)
  cached  sub-001_task-P3_electrodes.tsv (0.0 MB)
  cached  sub-001_task-P3_coordsystem.json (0.0 MB)
RuntimeWarning: The provided Raw instance is not filtered between 1 and 100 Hz. ICLabel was designed to classify features extracted from an EEG dataset bandpass filtered between 1 and 100 Hz (see the 'filter()' method for Raw and Epochs instances).
Figure 1 of notebook nb-2-8-pipeline, an output plot. The text around it states what it shows and the units of every axis.
run                  Pz mean (uV)  trials
-------------------  ------------  ------
A: high-pass 0.1 Hz  +2.88         30    
B: high-pass 1.0 Hz  +2.12         30    

6. A failure is a row, not a stopped cohort

A subject whose file is missing, whose events cannot be read, or whose ICA does not converge must appear in the report as exactly that. It must not raise out of the loop and end the run at subject 7 of 40.

In [11]:
bad = run_one(config, "sub-999") if EEGPIPE is None else run_one(config, "sub-999", progress=False)
B = norm(bad)
print(f"ok = {B['ok']}")
print(f"error recorded: {str(B['error'])[:200]}")
print(f"the run log still exists, with {len(B['steps'])} step(s):")
print(l2.fmt_table([{"step": s["name"], "duration (s)": s["duration_s"],
                     "notes": str(s.get("notes", ""))[:70]} for s in B["steps"]]
                   or [{"step": "(none completed)", "duration (s)": 0.0, "notes": ""}],
                   ["step", "duration (s)", "notes"], floatfmt="{:.2f}"))
qc_bad = B["outputs"].get("qc")
if qc_bad and Path(qc_bad).exists():
    print("\nand the QC report renders, saying what happened:")
    display(HTML(Path(qc_bad).read_text(encoding="utf-8")))
else:
    print("\nand the QC report renders, saying what happened:")
    display(HTML(l2.qc_report_html(bad, title="QC report -- sub-999 (failed)")
                 if EEGPIPE is None else f"<p><b>sub-999 failed:</b> {B['error']}</p>"))
ok = False
error recorded: load: ValueError: P3: no sub-999 (have 40 subjects)
the run log still exists, with 1 step(s):
step  duration (s)  notes                                                         
----  ------------  --------------------------------------------------------------
load  0.00          {'traceback': 'ValueError: P3: no sub-999 (have 40 subjects)'}

and the QC report renders, saying what happened:
QC sub-999 — erpcore-p3

QC report — sub-999 failed

ds-erpcore · configuration erpcore-p3 (hash 17f71c09f4cf) · seed 20260917 · 2026-09-18T14:22:53+00:00 · 0.0 s

Read this first — what would make you distrust this subject

  • load: ValueError: P3: no sub-999 (have 40 subjects)
  • step 'load' failed: ValueError: P3: no sub-999 (have 40 subjects)
0bad channels
0interpolated
rank
ICA removed
epochs kept
% rejected

Bad channels

— EEG channels, — windows of — s. Criteria and thresholds are in the configuration below; detection ran before filtering and before re-referencing.

(nothing to show)

Rank after interpolation and re-referencing: — 0 interpolated channel(s) (none) and the reference each cost one. This is the rank the ICA step was given.

Filter settings, as resolved

whathigh-pass (Hz)low-pass (Hz)methodphaselength (samples)low transitionhigh transition
analysis data
  • Recorded passband after filtering: —–— Hz

ICA components removed

ICA did not run.

Data rejected, per condition

(nothing to show)

  • Method:
  • Overall: —% of epochs rejected; per-condition spread — percentage points

Figures

figures need the live objects; this report was rendered from a stored run log

Run log

stepstatussecondswhat it recorded
loadfailed0.00
The full run log (JSON) — every parameter as resolved, every note
{
  "subject": "sub-999",
  "dataset": "ds-erpcore",
  "config_name": "erpcore-p3",
  "config_hash": "17f71c09f4cf",
  "seed": 20260917,
  "status": "failed",
  "error": "load: ValueError: P3: no sub-999 (have 40 subjects)",
  "traceback": "Traceback (most recent call last):\n  File \"pipelines/eegpipe/run.py\", line 258, in run_subject\n    data = STEPS[name].run(data, params, log)\n  File \"pipelines/eegpipe/steps/load.py\", line 34, in run\n    raw, facts = load_raw(dataset, subject, preload=bool(params.get(\"preload\", True)),\n                 ~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n                          seed=int(params.get(\"seed\", 0)))\n                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"pipelines/eegpipe/datasets.py\", line 270, in load_raw\n    return _load_erpcore(dataset, subject, preload)\n  File \"pipelines/eegpipe/datasets.py\", line 222, in _load_erpcore\n    paths = fetcher.fetch_subject(paradigm, subj)\n  File \"data/scripts/fetch_erpcore.py\", line 216, in fetch_subject\n    raise ValueError(f\"{paradigm}: no {sid} (have {len(index['subjects'])} subjects)\")\nValueError: P3: no sub-999 (have 40 subjects)\n",
  "started_at": "2026-09-18T14:22:53+00:00",
  "duration_s": 0.007,
  "versions": {
    "python": "3.13.5",
    "platform": "Darwin 25.3.0 (arm64)",
    "mne": "1.10.2",
    "numpy": "2.1.3",
    "scipy": "1.15.3",
    "matplotlib": "3.10.6",
    "scikit-learn": "1.6.1",
    "autoreject": "0.5.0",
    "mne-icalabel": "0.9.0",
    "python-picard": null,
    "PyYAML": "6.0.2",
    "pinned": {
      "mne": "1.10.2",
      "numpy": ">=2.1,<3",
      "scipy": ">=1.15,<2"
    },
    "on_mismatch": "warn"
  },
  "version_mismatches": [],
  "bad_channels": {},
  "interpolated": [],
  "rank": null,
  "filter": {},
  "ica": {},
  "epoching": {},
  "rejection": {},
  "facts": {},
  "flags": [
    "step 'load' failed: ValueError: P3: no sub-999 (have 40 subjects)"
  ],
  "outputs": {
    "config": ".../nb-2-8-o9hlqr4r/resolved-config.yaml"
  },
  "steps": [
    {
      "name": "load",
      "params": {
        "preload": true,
        "eog_channels": [
          "VEOG",
          "HEOG"
        ],
        "misc_channels": [],
        "rename": {
          "FP1": "Fp1",
          "FP2": "Fp2"
        },
        "crop_s": null,
        "bad_segments_s": [],
        "dataset": {
          "id": "ds-erpcore",
          "paradigm": "P3",
          "runs": [],
          "path": null,
          "cache_dir": null,
          "synthetic": {}
        },
        "seed": 20260917
      },
      "duration_s": 0.0026,
      "notes": {
        "traceback": "ValueError: P3: no sub-999 (have 40 subjects)"
      },
      "status": "failed",
      "error": "ValueError: P3: no sub-999 (have 40 subjects)"
    }
  ],
  "config": {
    "name": "erpcore-p3",
    "description": "Capstone C2 on ERP CORE P3: raw -> cleaned continuous data + epochs + a QC report per subject, in the canonical order of lesson L2.8, driven entirely by this file.\n",
    "seed": 20260917,
    "dataset": {
      "id": "ds-erpcore",
      "paradigm": "P3",
      "runs": [],
      "path": null,
      "cache_dir": null,
      "synthetic": {}
    },
    "subjects": {
      "include": [
        "sub-001"
      ],
      "exclude": {},
      "limit": null
    },
    "output": {
      "dir": ".../T/nb-2-8-o9hlqr4r",
      "overwrite": true,
      "write_raw": true,
      "write_epochs": true,
      "write_log": true,
      "write_qc": true,
      "figures": true
    },
    "versions": {
      "on_mismatch": "warn",
      "pins": {
        "mne": "1.10.2",
        "numpy": ">=2.1,<3",
        "scipy": ">=1.15,<2"
      }
    },
    "steps": {
      "load": {
        "preload": true,
        "eog_channels": [
          "VEOG",
          "HEOG"
        ],
        "misc_channels": [],
        "rename": {
          "FP1": "Fp1",
          "FP2": "Fp2"
        },
        "crop_s": null,
        "bad_segments_s": []
      },
      "montage": {
        "name": "standard_1005",
        "match_case": true,
        "on_missing": "warn"
      },
      "bad_channels": {
        "enabled": true,
        "window_s": 5.0,
        "flat_uv": 1.0,
        "flat_fraction": 0.5,
        "deviation_z": 5.0,
        "correlation_threshold": 0.4,
        "correlation_fraction": 0.3,
        "hf_noise_z": 5.0,
        "manual": [],
        "keep_existing": true,
        "max_bad_fraction": 0.2
      },
      "filter": {
        "l_freq": 0.1,
        "h_freq": 30.0,
        "method": "fir",
        "phase": "zero",
        "fir_design": "firwin",
        "fir_window": "hamming",
        "l_trans_bandwidth": "auto",
        "h_trans_bandwidth": "auto",
        "iir_params": {},
        "notch_freqs": [],
        "notch_widths": null,
        "resample_hz": 256.0,
        "ica_l_freq": 1.0,
        "ica_h_freq": null,
        "justification": {
          "highpass": "0.1 Hz: high enough to remove the drift of a 405-s recording, low enough not to distort the P3's late positivity (L2.4, pf-hp-cutoff-erp).\n",
          "lowpass": "30 Hz: the P3 is a slow component, and 30 Hz keeps the 60 Hz mains and the muscle band out without touching it. No notch is needed behind a 30 Hz low-pass.\n",
          "resample": "1024 Hz costs time and disk for no gain at these cutoffs; 256 Hz is still eight times the low-pass. Resampling happens before epoching so events are converted once.\n",
          "ica_highpass": "1 Hz for the ICA fit only: drift dominates the variance and costs components; the unmixing is applied to the 0.1 Hz analysis data (L2.4, L2.6).\n"
        }
      },
      "interpolate": {
        "enabled": true,
        "method": "spline",
        "reset_bads": true,
        "exclude": [],
        "max_interpolate": 6
      },
      "reference": {
        "type": "average",
        "channels": [],
        "projection": false,
        "add_channels": []
      },
      "ica": {
        "enabled": true,
        "method": "infomax",
        "fit_params": {
          "extended": true
        },
        "n_components": "rank",
        "max_iter": 500,
        "decim": 3,
        "seed": null,
        "reject_by_annotation": true,
        "fit_on": "ica_copy",
        "eog": {
          "enabled": true,
          "channels": [],
          "measure": "correlation",
          "threshold": 0.5
        },
        "muscle": {
          "enabled": false,
          "threshold": 0.8
        },
        "iclabel": {
          "enabled": true,
          "labels": [
            "eye blink",
            "muscle artifact",
            "heart beat",
            "line noise",
            "channel noise"
          ],
          "threshold": 0.8
        },
        "max_remove": 6,
        "apply": true
      },
      "epoch": {
        "enabled": true,
        "source": "annotations",
        "stim_channel": null,
        "conditions": {
          "target": [
            11,
            22,
            33,
            44,
            55
          ],
          "standard": [
            12,
            13,
            14,
            15,
            21,
            23,
            24,
            25,
            31,
            32,
            34,
            35,
            41,
            42,
            43,
            45,
            51,
            52,
            53,
            54
          ]
        },
        "tmin": -0.2,
        "tmax": 0.8,
        "baseline": [
          -0.2,
          0.0
        ],
        "decim": 1,
        "detrend": null,
        "reject_by_annotation": true,
        "event_repeated": "error",
        "on_missing": "warn",
        "shift_s": 0.0
      },
      "reject": {
        "method": "threshold",
        "ptp_uv": 100.0,
        "flat_uv": 1.0,
        "eog_ptp_uv": null,
        "max_rejected_fraction": 0.4,
        "condition_balance_pp": 10.0,
        "autoreject": {
          "n_interpolate": [
            1,
            4
          ],
          "consensus": [],
          "cv": 4,
          "n_jobs": 1
        }
      }
    }
  }
}

Provenance, versions and seed

(nothing to show)

Seed 20260917, configuration hash 17f71c09f4cf.

packagein usepinned in the config
python3.13.5
platformDarwin 25.3.0 (arm64)
mne1.10.21.10.2
numpy2.1.3>=2.1,<3
scipy1.15.3>=1.15,<2
matplotlib3.10.6
scikit-learn1.6.1
autoreject0.5.0
mne-icalabel0.9.0
python-picardnot installed
PyYAML6.0.2
The resolved configuration (YAML) — this is what reproduces the run
name: erpcore-p3
description: 'Capstone C2 on ERP CORE P3: raw -> cleaned continuous data + epochs
  + a QC report per subject, in the canonical order of lesson L2.8, driven entirely
  by this file.

  '
seed: 20260917
dataset:
  id: ds-erpcore
  paradigm: P3
  runs: []
  path: null
  cache_dir: null
  synthetic: {}
subjects:
  include:
  - sub-001
  exclude: {}
  limit: null
output:
  dir: .../T/nb-2-8-o9hlqr4r
  overwrite: true
  write_raw: true
  write_epochs: true
  write_log: true
  write_qc: true
  figures: true
versions:
  on_mismatch: warn
  pins:
    mne: 1.10.2
    numpy: '>=2.1,<3'
    scipy: '>=1.15,<2'
steps:
  load:
    preload: true
    eog_channels:
    - VEOG
    - HEOG
    misc_channels: []
    rename:
      FP1: Fp1
      FP2: Fp2
    crop_s: null
    bad_segments_s: []
  montage:
    name: standard_1005
    match_case: true
    on_missing: warn
  bad_channels:
    enabled: true
    window_s: 5.0
    flat_uv: 1.0
    flat_fraction: 0.5
    deviation_z: 5.0
    correlation_threshold: 0.4
    correlation_fraction: 0.3
    hf_noise_z: 5.0
    manual: []
    keep_existing: true
    max_bad_fraction: 0.2
  filter:
    l_freq: 0.1
    h_freq: 30.0
    method: fir
    phase: zero
    fir_design: firwin
    fir_window: hamming
    l_trans_bandwidth: auto
    h_trans_bandwidth: auto
    iir_params: {}
    notch_freqs: []
    notch_widths: null
    resample_hz: 256.0
    ica_l_freq: 1.0
    ica_h_freq: null
    justification:
      highpass: '0.1 Hz: high enough to remove the drift of a 405-s recording, low
        enough not to distort the P3''s late positivity (L2.4, pf-hp-cutoff-erp).

        '
      lowpass: '30 Hz: the P3 is a slow component, and 30 Hz keeps the 60 Hz mains
        and the muscle band out without touching it. No notch is needed behind a 30
        Hz low-pass.

        '
      resample: '1024 Hz costs time and disk for no gain at these cutoffs; 256 Hz
        is still eight times the low-pass. Resampling happens before epoching so events
        are converted once.

        '
      ica_highpass: '1 Hz for the ICA fit only: drift dominates the variance and costs
        components; the unmixing is applied to the 0.1 Hz analysis data (L2.4, L2.6).

        '
  interpolate:
    enabled: true
    method: spline
    reset_bads: true
    exclude: []
    max_interpolate: 6
  reference:
    type: average
    channels: []
    projection: false
    add_channels: []
  ica:
    enabled: true
    method: infomax
    fit_params:
      extended: true
    n_components: rank
    max_iter: 500
    decim: 3
    seed: null
    reject_by_annotation: true
    fit_on: ica_copy
    eog:
      enabled: true
      channels: []
      measure: correlation
      threshold: 0.5
    muscle:
      enabled: false
      threshold: 0.8
    iclabel:
      enabled: true
      labels:
      - eye blink
      - muscle artifact
      - heart beat
      - line noise
      - channel noise
      threshold: 0.8
    max_remove: 6
    apply: true
  epoch:
    enabled: true
    source: annotations
    stim_channel: null
    conditions:
      target:
      - 11
      - 22
      - 33
      - 44
      - 55
      standard:
      - 12
      - 13
      - 14
      - 15
      - 21
      - 23
      - 24
      - 25
      - 31
      - 32
      - 34
      - 35
      - 41
      - 42
      - 43
      - 45
      - 51
      - 52
      - 53
      - 54
    tmin: -0.2
    tmax: 0.8
    baseline:
    - -0.2
    - 0.0
    decim: 1
    detrend: null
    reject_by_annotation: true
    event_repeated: error
    on_missing: warn
    shift_s: 0.0
  reject:
    method: threshold
    ptp_uv: 100.0
    flat_uv: 1.0
    eog_ptp_uv: null
    max_rejected_fraction: 0.4
    condition_balance_pp: 10.0
    autoreject:
      n_interpolate:
      - 1
      - 4
      consensus: []
      cv: 4
      n_jobs: 1

Generated by eegpipe (pipelines/eegpipe) for the course capstone C2. Cite a run by its configuration name and hash, the seed and the package versions above.

7. The numbers

In [12]:
print("nb-2-8-pipeline -- L2.8 numbers (draft; TODO(confirm) at author review)")
print(f"Implementation: " + (f"pipelines/eegpipe {EEGPIPE.__version__}" if EEGPIPE is not None
                             else "helpers_l2.run_subject (the documented interface; pipelines/eegpipe absent)"))
print(f"Configuration: " + (str(CONFIG_PATH.relative_to(_pipelines.parent)) if EEGPIPE is not None
                            else "helpers_l2.PipelineConfig defaults")
      + f", hash {R['config_hash']}, seed {R['seed']}")
print(f"Data: ds-erpcore P3 sub-001 (CC BY-SA 4.0 per data/directory.yaml, contested at source, open, per-subject downloadable).")
print(f"Canonical order executed: "
      + (" -> ".join(EEGPIPE.CANONICAL_ORDER) if EEGPIPE is not None
         else "load -> montage -> bad-channel detection -> filter -> interpolate -> re-reference -> ICA -> "
              "epoch -> reject"))
print()
print("Run log (step, duration, notes):")
for s in R["steps"]:
    print(f"  {s['name']:<16s} {s['duration_s']:6.2f} s   {str(s.get('notes', ''))[:96]}")
print(f"  {'TOTAL':<16s} {R['duration_s']:6.2f} s")
print()
print(f"Bad channels: {R['interpolated'] or 'none'} interpolated of {len(R['bads'])} flagged "
      f"({', '.join(f'{ch}: {c}' for ch, c in R['bads'].items()) or 'nothing flagged'})")
print(f"Rank: {R['rank']} -- {R['rank_why']}; after ICA cleaning {R['ica']['rank_after']}")
fa = R["filter"].get("analysis") or R["filter"]
print(f"Filter as resolved (analysis branch): {fa.get('l_freq_hz')}-{fa.get('h_freq_hz')} Hz, "
      f"{fa.get('method', '?')}, phase {fa.get('phase', '?')}, "
      f"{fa.get('filter_length_samples', fa.get('fir_length_samples', '?'))} taps = "
      f"{fa.get('filter_length_s', fa.get('fir_length_s', float('nan'))):.1f} s")
if isinstance(R["filter"].get("ica_copy"), dict):
    ic = R["filter"]["ica_copy"]
    print(f"Filter as resolved (ICA branch):      {ic.get('l_freq_hz')}-{ic.get('h_freq_hz')} Hz, "
          f"{ic.get('filter_length_samples', '?')} taps = {ic.get('filter_length_s', float('nan')):.1f} s")
print(f"ICA: {R['ica']['method']}, {R['ica']['n_components']} components, seed {R['ica']['seed']}, "
      f"fitted on {R['ica']['fitted_on']}; {len(R['ica']['removed'])} removed:")
for r in R["ica"]["removed"]:
    print(f"    IC{r['component']:<3} {str(r['class']):16s} score {r['probability']:.3f}   {r['evidence'][:96]}")
print("Rejection:")
for r in R["rejection"]["table"]:
    print(f"    {str(r['condition']):9s} presented {r['n_presented']:3d}  rejected {r['n_rejected']:3d}  "
          f"kept {r['n_kept']:3d}  ({r['percent_rejected']:.2f} %)")
print(f"    per-condition imbalance {R['rejection']['imbalance_pp']:.2f} pp; criterion "
      f"{R['rejection']['criterion']}")
if measure_rows:
    print()
    print("Measured P3 under the two configurations (the only difference is the analysis high-pass):")
    for m in measure_rows:
        print(f"    {m['run']:26s} {CH} mean amplitude {m[f'{CH} mean (uV)']:+5.2f} uV "
              f"({m['trials']} trials)")
print()
print(f"Versions recorded in the run log: {R['versions']}")
print()
print("L2.8's exercises are an ordering exercise (ex-2-8-pipeline-order; key: load, montage, bad-channel "
      "detection, filter, interpolate, re-reference, ICA, epoch, reject) and a free response; this notebook "
      "produces no numeric key. The QC report in section 4 is the C2 deliverable in miniature.")
_out.cleanup()
print("\ntemporary output directory removed; the ERP CORE downloads stay in the cache.")
nb-2-8-pipeline -- L2.8 numbers (draft; TODO(confirm) at author review)
Implementation: pipelines/eegpipe 0.2.0
Configuration: pipelines/configs/erpcore-p3.yaml, hash 17f71c09f4cf, seed 20260917
Data: ds-erpcore P3 sub-001 (CC BY-SA 4.0 per data/directory.yaml, contested at source, open, per-subject downloadable).
Canonical order executed: load -> montage -> bad_channels -> filter -> interpolate -> reference -> ica -> epoch -> reject

Run log (step, duration, notes):
  load               0.30 s   {'dataset': 'ds-erpcore', 'subject': 'sub-001', 'source': 'ERP CORE (Compendium of Open Resource
  montage            0.01 s   {'montage': 'standard_1005', 'n_eeg': 30, 'n_positioned': 30, 'channels_without_position': [], '
  bad_channels       0.14 s   {'n_eeg': 30, 'n_windows': 93, 'window_s': 5.0, 'bads': [], 'bad_fraction': 0.0, 'criteria_fired
  filter             1.20 s   {'sfreq_in_hz': 1024.0, 'ica_copy': {'l_freq_hz': 1.0, 'h_freq_hz': 30.0, 'filter_length_samples
  interpolate        0.00 s   {'reason': 'no bad channels to interpolate', 'bads_left_marked': [], 'rank': 30}
  reference          0.02 s   {'type': 'average', 'projection': False, 'note': 'the average reference is a subtraction of the 
  ica               12.06 s   {'method': 'infomax', 'fit_params': {'extended': True}, 'seed': 20260917, 'n_components': 29, 'n
  epoch              0.05 s   {'source': 'annotations', 'events_in_recording': 402, 'event_codes_present': {'11': 1, '12': 2, 
  reject             0.04 s   {'method': 'threshold', 'n_epochs_before': 200, 'criterion': {'peak_to_peak_uv': 100.0, 'flat_uv
  TOTAL             13.82 s

Bad channels: none interpolated of 0 flagged (nothing flagged)
Rank: 24 -- rank carried through the pipeline: 30 EEG channels - 0 interpolated - reference cost = 29; after ICA cleaning 24
Filter as resolved (analysis branch): 0.1-30.0 Hz, fir, phase zero, 33793 taps = 33.0 s
Filter as resolved (ICA branch):      1.0-30.0 Hz, 3381 taps = 3.3 s
ICA: infomax, 29 components, seed 20260917, fitted on 1 Hz high-passed copy; 5 removed:
    IC8   eye blink        score 0.971   ICLabel classified it eye blink with probability 0.97 (threshold 0.8)
    IC3   muscle artifact  score 0.964   ICLabel classified it muscle artifact with probability 0.96 (threshold 0.8)
    IC27  muscle artifact  score 0.944   ICLabel classified it muscle artifact with probability 0.94 (threshold 0.8)
    IC10  eye blink        score 0.820   ICLabel classified it eye blink with probability 0.82 (threshold 0.8)
    IC0   eye              score 0.664   |correlation| with HEOG_left, HEOG_right, VEOG_lower = 0.66, above the configured threshold 0.5 
Rejection:
    target    presented  40  rejected   2  kept  38  (5.00 %)
    standard  presented 160  rejected   7  kept 153  (4.38 %)
    all       presented 200  rejected   9  kept 191  (4.50 %)
    per-condition imbalance 0.62 pp; criterion {'peak_to_peak_uv': 100.0, 'flat_uv': 1.0, 'eog_peak_to_peak_uv': None}

Measured P3 under the two configurations (the only difference is the analysis high-pass):
    A: high-pass 0.1 Hz        Pz mean amplitude +2.88 uV (30 trials)
    B: high-pass 1.0 Hz        Pz mean amplitude +2.12 uV (30 trials)

Versions recorded in the run log: {'python': '3.13.5', 'platform': 'Darwin 25.3.0 (arm64)', 'mne': '1.10.2', 'numpy': '2.1.3', 'scipy': '1.15.3', 'matplotlib': '3.10.6', 'scikit-learn': '1.6.1', 'autoreject': '0.5.0', 'mne-icalabel': '0.9.0', 'python-picard': None, 'PyYAML': '6.0.2', 'pinned': {'mne': '1.10.2', 'numpy': '>=2.1,<3', 'scipy': '>=1.15,<2'}, 'on_mismatch': 'warn'}

L2.8's exercises are an ordering exercise (ex-2-8-pipeline-order; key: load, montage, bad-channel detection, filter, interpolate, re-reference, ICA, epoch, reject) and a free response; this notebook produces no numeric key. The QC report in section 4 is the C2 deliverable in miniature.

temporary output directory removed; the ERP CORE downloads stay in the cache.