Capstone C0: one-page recording reports with montage, events, an artifact timeline, the first-look PSD and a go/no-go template

nb-c0-recording-report Level 0 · Read the Raw Signal capstone ~3 min Used in C0 · Capstone — Recording report

Downloads from ds-eegbci, ds-lemon 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-c0-recording-report · Capstone C0 — Recording report

Capstone C0 · Level 0 · Status draft — drafted for expert review; every scientific statement below is a draft and uncertain points carry TODO(confirm).

The brief (spec C0). For one raw file from each spine dataset, produce a one-page report: montage, sampling, duration, event table, artifact timeline by type, first-look PSD, and a go/no-go recommendation with reasons. Deliverable: this notebook and the rendered HTML it writes next to itself (nb-c0-recording-report.html). Rubric: all metadata correct; at least five artifact types located with timestamps; the PSD interpreted (alpha peak, line noise) in words.

What this notebook does. It assembles the report from the Level 0 building blocks — the first-look checklist (nb-0-6), the artifact detectors of the atlas (nb-0-5), the montage and event tools (nb-0-2, nb-0-3) — as a function you can run on any recording, and fills in a go/no-go template from the numbers. Every artifact label is label_source: algorithmic (a threshold rule, not a reviewed label; TODO(confirm)), and the recommendation is a template: the last section of each report is yours to rewrite after you have looked at the traces.

Subset and full cohort (spec §11). By default the notebook runs on the documented subset — one file per spine dataset — so that it finishes in a few minutes on a fresh kernel. FULL_COHORT = True runs the documented larger sets for local use (see the configuration cell); it is not needed for the capstone.

Data

  • ds-eegbci — EEGMMIDB, Schalk et al. (2004), PhysioNet v1.0.0, DOI 10.13026/C28G6P, ODC-By 1.0; catalog: 64 channels (10-10), 160 Hz, no hardware filters, 60 Hz mains, recording reference not documented (TODO(confirm)). Subset: S001 R01 (eyes open, ~1 min; 1.2 MB).
  • ds-lemon — LEMON, Babayan et al. (2019), DOI 10.1038/sdata.2018.308; CC BY 4.0 per the descriptor (exact terms TODO(confirm)); catalog: 62 channels (61 EEG + VEOG), FCz online reference, 2500 Hz, 0.015–1000 Hz, no notch, 50 Hz mains, 16 alternating 1-min EC/EO blocks. Subset: sub-010002, the first 5 minutes of the data file (about 93 MB) fetched from the public S3 mirror as described in nb-0-3 (helpers.fetch_lemon_raw); skipped with a note when it cannot be fetched.
  • ds-erpcore — not in the site's dataset catalog (TODO(confirm), spec §13 item 22): its report is a stub.
In [1]:
# Setup: dependencies, the shared helpers, non-interactive plotting.
import importlib.util
import subprocess
import sys
import warnings
from pathlib import Path

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

# 2. Shared helpers (notebooks/_shared/helpers.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.py").exists()), None)
if _shared is None:
    raise FileNotFoundError("start the kernel in notebooks/capstones/ (or notebooks/) so that _shared/helpers.py is found")
sys.path.insert(0, str(_shared))
import helpers

# 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

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; "
      "downloads go to MNE's default data directory unless EEG_COURSE_DATA is set")
MNE 1.10.2; helpers imported from notebooks/_shared; downloads go to MNE's default data directory unless EEG_COURSE_DATA is set

0. Configuration: the subset, and the FULL_COHORT switch

In [2]:
FULL_COHORT = False          # False: the documented subset (one file per spine dataset). True: the sets below, local runs only.
LEMON_MAX_MINUTES = 5        # the ds-lemon data file is fetched as a prefix of this many minutes (None = whole recording, 302 MB)
LEMON_DELETE_AFTER_USE = FULL_COHORT   # in a full-cohort run, delete each LEMON subject's files after its report (disk)

SUBSET = {
    "ds-eegbci": [("S001", "R01")],
    "ds-lemon": ["sub-010002"],
    "ds-erpcore": ["TODO(confirm)"],
}
# Documented full-cohort sets (spec 11: 10-20 subjects; none of the eegbci subjects below is in the catalog's exclusion
# list; the LEMON ids are the first ten initial ids of the mirror's name_match.csv, each fetched as a 5-min prefix).
FULL = {
    "ds-eegbci": [(f"S{i:03d}", "R01") for i in range(1, 13)],
    "ds-lemon": [f"sub-0100{i:02d}" for i in range(2, 12)],
    "ds-erpcore": ["TODO(confirm)"],
}
PLAN = FULL if FULL_COHORT else SUBSET

OUT_DIR = _shared.parent / "capstones"          # next to this notebook, found relative to the working directory
REPORT_NAME = "nb-c0-recording-report.html"
print("plan:", {k: (v if len(v) <= 3 else f"{len(v)} files") for k, v in PLAN.items()})
print(f"report file: {REPORT_NAME} (written into the capstones folder)")
plan: {'ds-eegbci': [('S001', 'R01')], 'ds-lemon': ['sub-010002'], 'ds-erpcore': ['TODO(confirm)']}
report file: nb-c0-recording-report.html (written into the capstones folder)

1. The report builder

recording_report(raw, dataset, label) computes everything and returns the numbers (a dict) and one HTML section:

  1. Montage — the sensor layout from the channel names (template 10-10 positions), with the documented reference stated.
  2. Sampling and duration — header values against the catalog, channel counts by type, header filters.
  3. Events — counts per description, the first rows, and the timing check (helpers.event_timing_check).
  4. Artifact timelinehelpers.detect_artifacts: blink, saccade, EMG, drift, electrode pop, noisy channel, flat channel, line noise, each a simple threshold rule (documented in the helper's docstring) — plotted as a timeline and tabulated with timestamps.
  5. First-look PSDhelpers.psd_summary: the posterior alpha peak and the mains line, in numbers and in words.
  6. Checklisthelpers.first_look_checks (nb-0-6).
  7. Go / no-go (template)recommend() applies three explicit rules: any failed checklist item → NO-GO until resolved; a noisy channel, two or more flat-channel events, or technical artifact coverage above 20 % of the recording (union of EMG/pop/flat spans) → CAUTION; otherwise GO. Ocular events (expected with the eyes open; L2.6 removes them), drift and line noise (filtering matters, L1.6/L2.4) are reported with their numbers but do not decide. The reasons are generated from the numbers and must be rewritten by you after viewing the traces.
In [3]:
from datetime import date
from html import escape
from IPython.display import HTML, display

TECHNICAL = ("emg", "pop", "flat")          # spans that count toward "artifact coverage" (noisy is a channel flag, not a span)
OCULAR = ("blink", "saccade")               # reported, expected in eyes-open data, handled later (L2.6), not a caution by themselves


def coverage_fraction(events, duration_s, labels=TECHNICAL):
    """Fraction of the recording covered by the union of the events with the given labels."""
    spans = sorted((e["onset_s"], e["onset_s"] + e["duration_s"]) for e in events if e["label"] in labels)
    covered, cur = 0.0, None
    for a, b in spans:
        if cur is None or a > cur[1]:
            if cur is not None:
                covered += cur[1] - cur[0]
            cur = [a, b]
        else:
            cur[1] = max(cur[1], b)
    if cur is not None:
        covered += cur[1] - cur[0]
    return covered / duration_s if duration_s else 0.0


def recommend(checks, events, duration_s):
    """The go/no-go template: a decision and generated reasons (to be rewritten after viewing the traces)."""
    fails = [c for c in checks if c["status"] == "fail"]
    warns = [c for c in checks if c["status"] == "warn"]
    counts = {lab: sum(e["label"] == lab for e in events) for lab in helpers.ARTIFACT_LABELS}
    cov = coverage_fraction(events, duration_s)
    ocular = coverage_fraction(events, duration_s, OCULAR)
    reasons = []
    if fails:
        decision = "NO-GO"
        reasons += [f"checklist FAIL -- {c['item']}: {c['value']}" for c in fails]
        reasons.append("resolve the metadata/integrity problems (or document them) before any analysis")
    else:
        flags = []
        if counts["noisy"]:
            flags.append(f"{counts['noisy']} noisy channel(s): {', '.join(e['channels'][0] for e in events if e['label'] == 'noisy')}"
                         " -- exclude or interpolate (L2.2) before anything else")
        if counts["flat"] >= 2:
            flags.append(f"{counts['flat']} flat-channel events")
        if cov > 0.20:
            flags.append(f"technical artifact coverage {100 * cov:.0f} % of the recording (EMG/pop/flat)")
        decision = "CAUTION" if flags else "GO"
        reasons += flags or [f"no failed checklist item; technical artifact coverage {100 * cov:.0f} % (EMG/pop/flat), no noisy channel"]
        reasons += [f"checklist WARN -- {c['item']}: {c['value']}" for c in warns]
        reasons.append(f"ocular events cover {100 * ocular:.0f} % ({counts['blink']} blinks, {counts['saccade']} saccades): expected with the eyes open, "
                       "to be handled by EOG regression or ICA (L2.6), not a reason to reject")
        reasons.append(f"{counts['emg']} EMG bursts, {counts['drift']} drift windows, {counts['pop']} pops, "
                       f"{counts['line-noise']} line-noise windows located (algorithmic); drift and line noise are filtering matters (L1.6, L2.4)")
    return {"decision": decision, "reasons": reasons, "coverage": cov, "ocular": ocular, "counts": counts}


def recording_report(raw, dataset, label):
    """One-page report (HTML section) + the numbers behind it."""
    cat = helpers.DATASETS.get(dataset, {})
    mains = cat.get("mains_hz")
    sf, dur = float(raw.info["sfreq"]), raw.n_times / raw.info["sfreq"]
    meta = helpers.first_look_report(raw, dataset)
    checks = helpers.first_look_checks(raw, dataset)
    timing = helpers.event_timing_check(raw)
    events = helpers.detect_artifacts(raw, mains_hz=mains)
    psd = helpers.psd_summary(raw, mains_hz=mains)
    rec = recommend(checks, events, dur)
    types = raw.get_channel_types()
    tcount = ", ".join(f"{types.count(t)} {t}" for t in sorted(set(types)))

    # figures
    fig_m, ax = plt.subplots(figsize=(4.6, 4.6))
    raw.plot_sensors(show_names=True, axes=ax, show=False)
    ax.set_title(f"{label}: {len(mne.pick_types(raw.info, eeg=True))} EEG channels, template 10-10 positions", fontsize=9)
    fig_t, ax = plt.subplots(figsize=(11, 3.2))
    helpers.artifact_timeline(events, dur, ax=ax, title=f"{label}: artifact timeline (algorithmic labels; s)")
    picks = [c for c in ("Fp1", "Fz", "Cz", "Pz", "O1", "O2") if c in raw.ch_names]
    fig_p, ax = plt.subplots(figsize=(8, 3.4))
    helpers.plot_psd(raw, picks, fmin=0.5, fmax=min(sf / 2, 100), ax=ax, title=f"{label}: Welch PSD, 4-s segments (dB re 1 uV^2/Hz)")
    imgs = {k: helpers.fig_to_base64(f) for k, f in (("montage", fig_m), ("timeline", fig_t), ("psd", fig_p))}
    for f in (fig_m, fig_t, fig_p):
        plt.close(f)

    # tables
    ann = raw.annotations
    ev_rows = [(f"{o:.3f}", f"{d:.3f}", str(desc)) for o, d, desc in list(zip(ann.onset, ann.duration, ann.description))[:8]]
    count_rows = [(k, v) for k, v in timing["counts"].items()]
    art_rows = []
    for lab in helpers.ARTIFACT_LABELS:
        evs = [e for e in events if e["label"] == lab]
        if not evs:
            continue
        chans = sorted({c for e in evs for c in e["channels"]})
        art_rows.append((lab, len(evs), f"{sum(e['duration_s'] for e in evs):.1f}", ", ".join(f"{e['onset_s']:.1f}" for e in evs[:6]) + (" ..." if len(evs) > 6 else ""),
                         ", ".join(chans[:8]) + (" ..." if len(chans) > 8 else ""), evs[0]["detector"]))
    tl = timing.get("interval_s")
    timing_text = (f"{timing['n_samples']} samples / {sf:g} Hz = {dur:.1f} s; {timing['n_annotations']} annotations"
                   + (f"; onset intervals median {tl['median']:.3f} s (min {tl['min']:.3f}, max {tl['max']:.3f}); "
                      f"overlaps {timing.get('n_overlapping_spans', 0)}, gaps {timing.get('n_gaps_between_spans', 0)}, "
                      f"long gaps {timing.get('n_long_gaps', 0)}" if tl else ""))
    status_colour = {"ok": "#2a7", "warn": "#d90", "fail": "#c33", "info": "#888"}
    check_rows = "".join(f"<tr><td style='padding:2px 8px'>{escape(c['item'])}</td><td style='padding:2px 8px;color:{status_colour[c['status']]};font-weight:bold'>"
                         f"{c['status'].upper()}</td><td style='padding:2px 8px'>{escape(str(c['value']))}</td><td style='padding:2px 8px;color:#555'>{escape(c['note'])}</td></tr>" for c in checks)
    decision_colour = {"GO": "#2a7", "CAUTION": "#d90", "NO-GO": "#c33"}[rec["decision"]]
    html = f"""
<section style="font-family:sans-serif;max-width:64em;page-break-after:always;border-top:2px solid #999;padding-top:1em">
<h2>Recording report -- {escape(label)}</h2>
<p><b>{escape(cat.get('name', dataset))}</b> -- {escape(cat.get('citation', ''))}; DOI {escape(str(cat.get('doi', 'TODO(confirm)')))}; license {escape(str(cat.get('license', 'TODO(confirm)')))}.<br>
File: {escape(str(raw.filenames[0].name if raw.filenames and raw.filenames[0] else label))}; report generated by nb-c0-recording-report on {date.today().isoformat()} with MNE {mne.__version__}.
Artifact labels are <b>algorithmic</b> (threshold rules, not reviewed); the recommendation is a <b>template</b>.</p>
<h3>1. Montage</h3>
<img alt="sensor layout" src="data:image/png;base64,{imgs['montage']}" style="max-width:340px">
<p>Documented reference: {escape(str(cat.get('reference', 'not documented -- TODO(confirm)')))}. Montage: {escape(str(cat.get('montage', 'TODO(confirm)')))}.</p>
<h3>2. Sampling and duration</h3>
{helpers.html_table([
    ("sampling rate (header)", f"{sf:g} Hz (Nyquist {sf / 2:g} Hz)", f"documented: {cat.get('sfreq', 'TODO(confirm)')} Hz"),
    ("channels", f"{raw.info['nchan']} ({tcount})", f"documented: {cat.get('n_channels', 'TODO(confirm)')}"),
    ("duration", f"{dur:.1f} s ({dur / 60:.2f} min)", "documented run length: see the dataset page"),
    ("header filters", f"high-pass {meta['highpass_hz_in_header']:g} Hz, low-pass {meta['lowpass_hz_in_header']:g} Hz", f"documented: {cat.get('hardware_filters', 'TODO(confirm)')}"),
    ("mains", f"{cat.get('mains_hz', 'TODO(confirm)')} Hz (documented)", ""),
], header=("item", "value", "documentation"))}
<h3>3. Events</h3>
{helpers.html_table(count_rows, header=("description", "count"))}
<p>{escape(timing_text)}</p>
{helpers.html_table(ev_rows, header=("onset (s)", "duration (s)", "description")) if ev_rows else "<p>no annotations</p>"}
<h3>4. Artifact timeline (algorithmic)</h3>
<img alt="artifact timeline" src="data:image/png;base64,{imgs['timeline']}" style="max-width:100%">
{helpers.html_table(art_rows, header=("type", "n", "total s", "first onsets (s)", "channels", "detector")) if art_rows else "<p>nothing flagged</p>"}
<p>Coverage of the recording by technical spans (EMG/pop/flat): {100 * rec['coverage']:.0f} %; by ocular spans (blink/saccade): {100 * rec['ocular']:.0f} %.</p>
<h3>5. First-look PSD</h3>
<img alt="PSD" src="data:image/png;base64,{imgs['psd']}" style="max-width:100%">
<p>{escape(psd['alpha_text'])} {escape(psd['line_text'])}</p>
<h3>6. Checklist</h3>
<table style="font-size:0.9em;border-collapse:collapse"><tr><th style='text-align:left;padding:2px 8px'>item</th><th style='text-align:left;padding:2px 8px'>status</th><th style='text-align:left;padding:2px 8px'>value</th><th style='text-align:left;padding:2px 8px'>note</th></tr>{check_rows}</table>
<h3>7. Go / no-go (template -- rewrite after viewing the traces)</h3>
<p style="font-size:1.2em"><b style="color:{decision_colour}">{rec['decision']}</b></p>
<ul>{''.join(f'<li>{escape(r)}</li>' for r in rec['reasons'])}</ul>
<p style="color:#555">Template rules: any failed checklist item -> NO-GO; a noisy channel, two or more flat-channel events, or more than 20 % technical coverage (EMG/pop/flat) -> CAUTION; otherwise GO. Ocular events, drift and line noise are reported but do not decide. Your reasons should name what you saw in the traces (nb-0-4) and which atlas entries (nb-0-5) the flagged spans match.</p>
</section>"""
    summary = {"label": label, "dataset": dataset, "sfreq": sf, "n_channels": raw.info["nchan"], "duration_s": dur,
               "event_counts": timing["counts"], "artifact_counts": rec["counts"], "coverage": rec["coverage"], "ocular": rec["ocular"],
               "alpha": psd["alpha"], "line_noise": psd["line_noise"], "alpha_text": psd["alpha_text"],
               "line_text": psd["line_text"], "decision": rec["decision"], "reasons": rec["reasons"],
               "n_fail": sum(c["status"] == "fail" for c in checks), "n_warn": sum(c["status"] == "warn" for c in checks)}
    return summary, html


def stub_report(dataset, label, message):
    """A report section for a dataset that cannot be loaded yet."""
    return ({"label": label, "dataset": dataset, "decision": "TODO(confirm)", "reasons": [message]},
            f"<section style='font-family:sans-serif;max-width:64em;border-top:2px solid #999;padding-top:1em'>"
            f"<h2>Recording report -- {escape(label)}</h2><p>{escape(message)}</p></section>")

2. Run on the plan

Each report is displayed inline and appended to the HTML file. A LEMON subject that cannot be fetched is skipped with a note; the ERP CORE entry is a stub until the dataset enters the catalog.

In [4]:
import shutil

summaries, sections = [], []

for subject, run in PLAN["ds-eegbci"]:
    raw = helpers.load_spine("ds-eegbci", subject, run)
    s, h = recording_report(raw, "ds-eegbci", f"ds-eegbci {subject} {run} ({helpers.EEGBCI_RUNS[run]})")
    summaries.append(s)
    sections.append(h)
    display(HTML(h))

for subject in PLAN["ds-lemon"]:
    raw = helpers.load_lemon_raw(subject, max_minutes=LEMON_MAX_MINUTES, preload=True)
    if raw is None:
        s, h = stub_report("ds-lemon", f"ds-lemon {subject}", "Skipped in this run: the raw files could not be fetched from the public "
                           "mirror (see the message above); re-run with network access, or set LEMON_MAX_MINUTES lower.")
    else:
        s, h = recording_report(raw, "ds-lemon", f"ds-lemon {subject} (raw, first {LEMON_MAX_MINUTES} min)" if LEMON_MAX_MINUTES
                                else f"ds-lemon {subject} (raw)")
        if LEMON_DELETE_AFTER_USE:
            folder = helpers.download_root() / "MNE-lemon-data" / "EEG_Raw_BIDS_ID" / subject
            del raw
            shutil.rmtree(folder, ignore_errors=True)
            print(f"deleted the files of {subject} after use")
    summaries.append(s)
    sections.append(h)
    display(HTML(h))

s, h = stub_report("ds-erpcore", "ds-erpcore (TODO(confirm))",
                   "ERP CORE is not in the site's dataset catalog (spec 13.22): license and per-subject download are TODO(confirm); "
                   "no file is fetched and no report is produced until the author resolves it.")
summaries.append(s)
sections.append(h)
display(HTML(h))

OUT_DIR.mkdir(parents=True, exist_ok=True)
page = ("<!DOCTYPE html><html><head><meta charset='utf-8'><title>C0 recording reports</title></head><body>"
        f"<h1 style='font-family:sans-serif'>Capstone C0 -- recording reports ({date.today().isoformat()})</h1>"
        + "".join(sections) + "</body></html>")
(OUT_DIR / REPORT_NAME).write_text(page, encoding="utf-8")
print(f"\nwritten: {REPORT_NAME} ({(OUT_DIR / REPORT_NAME).stat().st_size / 1e3:.0f} kB, {len(sections)} sections) next to the notebook")

Recording report -- ds-eegbci S001 R01 (Baseline: eyes open (~1 min))

EEG Motor Movement/Imagery Dataset (EEGMMIDB) -- Schalk et al., 2004, IEEE TBME, DOI 10.1109/TBME.2004.827072; DOI 10.13026/C28G6P; license ODC-By 1.0.
File: S001R01.edf; report generated by nb-c0-recording-report on 2026-09-17 with MNE 1.10.2. Artifact labels are algorithmic (threshold rules, not reviewed); the recommendation is a template.

1. Montage

sensor layout

Documented reference: not documented -- TODO(confirm). Montage: international 10-10 (64 channels, Sharbrough labels).

2. Sampling and duration

itemvaluedocumentation
sampling rate (header)160 Hz (Nyquist 80 Hz)documented: 160 Hz
channels64 (64 eeg)documented: 64
duration61.0 s (1.02 min)documented run length: see the dataset page
header filtershigh-pass 0 Hz, low-pass 80 Hzdocumented: none (no online filtering or notch)
mains60 Hz (documented)

3. Events

descriptioncount
T01

9760 samples / 160 Hz = 61.0 s; 1 annotations

onset (s)duration (s)description
0.00060.200T0

4. Artifact timeline (algorithmic)

artifact timeline
typentotal sfirst onsets (s)channelsdetector
blink187.25.3, 9.5, 14.0, 15.2, 17.1, 24.1 ...Fp1, Fp2same-sign 0.5-15 Hz deflection on Fp1 and Fp2 > 75 uV
saccade4518.00.6, 1.6, 2.4, 5.5, 6.6, 7.3 ...F7, F8|F7 - F8| step > 50 uV (0.3-s means, 0.5-15 Hz)
drift29450.010.0, 10.0, 10.0, 20.0, 20.0, 20.0 ...AF3, AF4, AF7, AF8, AFz, C2, C5, F1 ...< 0.5 Hz peak-to-peak > 200 uV in a 10-s window
pop22.017.7, 41.6T8sample-to-sample jump > 100 uV confined to one channel
line-noise312.00.0, 12.0, 40.0most channels60 Hz line > 15 dB above the floor (90th percentile of channels) in a 4-s window

Coverage of the recording by technical spans (EMG/pop/flat): 3 %; by ocular spans (blink/saccade): 38 %.

5. First-look PSD

PSD

A posterior alpha peak at 12.5 Hz, 4.8 dB above the 1/f trend (mean PSD of O1, Oz, O2, PO3, POz, PO4, PO7, PO8, P3, Pz, P4). The 60 Hz mains line is moderate (14.1 dB above the local floor).

6. Checklist

itemstatusvaluenote
sampling rateOK160 Hz (Nyquist 80 Hz)matches the documented 160 Hz
channel countOK64 EEG of 64 ({'eeg': 64})matches the documented 64
durationINFO61.0 s = 9760 samples / 160 Hzcompare with the documented run length
annotationsOK1 (T0: 1)
header filtersINFOhigh-pass 0 Hz, low-pass 80 Hz0 Hz / Nyquist means the header records no filter; the acquisition documentation may still document one
montageOK64 of 64 EEG channels have positions
flat channelsOKnone (smallest std 23.8 uV on T10)
DC offsetsOKlargest |mean| 10.7 uV on AF7
amplitude outliersOKchannel std within 0.25-4 x the median (52.4 uV)
line noiseOK60.00 Hz, 14.1 dB above the floorat the mains frequency (60 Hz documented); a notch or a low-pass decides later, not now
posterior alphaINFOpeak 12.50 Hz, 4.8 dB above the 1/f trendmean PSD of O1, Oz, O2, PO3, POz, PO4, PO7, PO8, P3, Pz, P4
channel-position consistencyOKevery channel's correlations fall off with distance (lowest rho +0.56, T10)

7. Go / no-go (template -- rewrite after viewing the traces)

GO

  • no failed checklist item; technical artifact coverage 3 % (EMG/pop/flat), no noisy channel
  • ocular events cover 38 % (18 blinks, 45 saccades): expected with the eyes open, to be handled by EOG regression or ICA (L2.6), not a reason to reject
  • 0 EMG bursts, 29 drift windows, 2 pops, 3 line-noise windows located (algorithmic); drift and line noise are filtering matters (L1.6, L2.4)

Template rules: any failed checklist item -> NO-GO; a noisy channel, two or more flat-channel events, or more than 20 % technical coverage (EMG/pop/flat) -> CAUTION; otherwise GO. Ocular events, drift and line noise are reported but do not decide. Your reasons should name what you saw in the traces (nb-0-4) and which atlas entries (nb-0-5) the flagged spans match.

ds-lemon sub-010002: using the cached BrainVision files (sub-010002.eeg: 93 MB, first 5 min)

Recording report -- ds-lemon sub-010002 (raw, first 5 min)

LEMON -- MPI-Leipzig Mind-Brain-Body, resting-state EEG (Babayan et al., 2019) -- Babayan et al., 2019, Scientific Data, DOI 10.1038/sdata.2018.308; DOI 10.1038/sdata.2018.308; license CC BY 4.0 per the data descriptor; the NITRC page references a PDDL-style Open Data dedication; exact terms TODO(confirm).
File: sub-010002.eeg; report generated by nb-c0-recording-report on 2026-09-17 with MNE 1.10.2. Artifact labels are algorithmic (threshold rules, not reviewed); the recommendation is a template.

1. Montage

sensor layout

Documented reference: FCz (online reference; absent as a data channel). Montage: 62-channel actiCAP in a 10-10 layout: 61 scalp electrodes + VEOG; BrainAmp MR plus.

2. Sampling and duration

itemvaluedocumentation
sampling rate (header)2500 Hz (Nyquist 1250 Hz)documented: 2500 Hz
channels62 (61 eeg, 1 eog)documented: 62
duration300.0 s (5.00 min)documented run length: see the dataset page
header filtershigh-pass 0.0159155 Hz, low-pass 1000 Hzdocumented: raw band-pass 0.015-1000 Hz; hardware notch off; no notch at any stage
mains50 Hz (documented)

3. Events

descriptioncount
Comment/no USB Connection to actiCAP1
Stimulus/S 16
Stimulus/S21083
Stimulus/S20060

750000 samples / 2500 Hz = 300.0 s; 150 annotations; onset intervals median 2.000 s (min 0.009, max 4.353); overlaps 0, gaps 0, long gaps 1

onset (s)duration (s)description
0.0000.000Comment/no USB Connection to actiCAP
3.9920.000Stimulus/S 1
6.3750.000Stimulus/S 1
6.3850.000Stimulus/S210
8.3850.000Stimulus/S210
10.3850.000Stimulus/S210
12.3850.000Stimulus/S210
14.3850.000Stimulus/S210

4. Artifact timeline (algorithmic)

artifact timeline
typentotal sfirst onsets (s)channelsdetector
blink156.03.5, 5.1, 66.7, 69.9, 88.6, 101.9 ...Fp1, Fp2same-sign 0.5-15 Hz deflection on Fp1 and Fp2 > 75 uV
saccade41.65.0, 63.7, 129.1, 191.2F7, F8|F7 - F8| step > 50 uV (0.3-s means, 0.5-15 Hz)
emg11.065.0PO1025-72 Hz RMS > 4 x channel median and > 10 uV
drift28310.00.0, 0.0, 0.0, 0.0, 0.0, 0.0 ...AF3, AF4, AF7, AF8, AFz, Fp1, Fp2, PO10 ...< 0.5 Hz peak-to-peak > 200 uV in a 10-s window
pop22.0162.5, 232.9FC5sample-to-sample jump > 100 uV confined to one channel
noisy1300.00.0T7> 20 confined jumps > 100 uV per minute: a noisy channel, not isolated pops

Coverage of the recording by technical spans (EMG/pop/flat): 1 %; by ocular spans (blink/saccade): 2 %.

5. First-look PSD

PSD

A posterior alpha peak at 10.5 Hz, 4.5 dB above the 1/f trend (mean PSD of O1, Oz, O2, PO3, POz, PO4, PO7, PO8, P3, Pz, P4). The 50 Hz mains line is negligible (4.0 dB above the local floor).

6. Checklist

itemstatusvaluenote
sampling rateOK2500 Hz (Nyquist 1250 Hz)matches the documented 2500 Hz
channel countOK61 EEG of 62 ({'eeg': 61, 'eog': 1})matches the documented 62
durationINFO300.0 s = 750000 samples / 2500 Hzcompare with the documented run length
annotationsWARN150 (Comment/no USB Connection to actiCAP: 1, Stimulus/S 1: 6, Stimulus/S210: 83, Stimulus/S200: 60)1 onset intervals > 2 x median; onset intervals median 2.000 s (min 0.009, max 4.353); 150 zero-duration markers
header filtersINFOhigh-pass 0.0159155 Hz, low-pass 1000 Hz0 Hz / Nyquist means the header records no filter; the acquisition documentation may still document one
montageOK61 of 61 EEG channels have positions
flat channelsOKnone (smallest std 4.2 uV on FC2)
DC offsetsOKlargest |mean| 61.7 uV on FT7
amplitude outliersWARNlow: FC1 (5.0 uV), FC2 (4.2 uV); high: Fp1 (114 uV), Fp2 (102 uV)median channel std 24.5 uV; a channel far below its neighbours may sit next to the reference electrode or be bridged to it; far above: a poor contact, muscle, or a non-EEG signal
line noiseOKno sharp line above 10 dB (largest 9.9 dB at 150.00 Hz)
posterior alphaINFOpeak 10.50 Hz, 4.5 dB above the 1/f trendmean PSD of O1, Oz, O2, PO3, POz, PO4, PO7, PO8, P3, Pz, P4
channel-position consistencyOKevery channel's correlations fall off with distance (lowest rho +0.22, T7)

7. Go / no-go (template -- rewrite after viewing the traces)

CAUTION

  • 1 noisy channel(s): T7 -- exclude or interpolate (L2.2) before anything else
  • checklist WARN -- annotations: 150 (Comment/no USB Connection to actiCAP: 1, Stimulus/S 1: 6, Stimulus/S210: 83, Stimulus/S200: 60)
  • checklist WARN -- amplitude outliers: low: FC1 (5.0 uV), FC2 (4.2 uV); high: Fp1 (114 uV), Fp2 (102 uV)
  • ocular events cover 2 % (15 blinks, 4 saccades): expected with the eyes open, to be handled by EOG regression or ICA (L2.6), not a reason to reject
  • 1 EMG bursts, 28 drift windows, 2 pops, 0 line-noise windows located (algorithmic); drift and line noise are filtering matters (L1.6, L2.4)

Template rules: any failed checklist item -> NO-GO; a noisy channel, two or more flat-channel events, or more than 20 % technical coverage (EMG/pop/flat) -> CAUTION; otherwise GO. Ocular events, drift and line noise are reported but do not decide. Your reasons should name what you saw in the traces (nb-0-4) and which atlas entries (nb-0-5) the flagged spans match.

Recording report -- ds-erpcore (TODO(confirm))

ERP CORE is not in the site's dataset catalog (spec 13.22): license and per-subject download are TODO(confirm); no file is fetched and no report is produced until the author resolves it.

written: nb-c0-recording-report.html (278 kB, 3 sections) next to the notebook

3. Reading the reports

Check the metadata block against the dataset page first (rubric item 1): the header rate and the channel count must match the catalog, or the mismatch must be explained — for LEMON the 62nd channel is the VEOG, and the low-amplitude channels beside FCz are the signature of the online reference (nb-0-2 found the same beside Pz in ds-iowapd). Then read the timeline against the atlas (rubric item 2): blinks and saccades on the frontal channels of an eyes-open baseline are expected; drift windows on a recording without a hardware high-pass are expected; a noisy channel or flat events are what you would exclude or interpolate later. Finally the PSD sentences (rubric item 3): an alpha peak near 10 Hz on the posterior channels, and the mains line at 60 Hz (ds-eegbci) or 50 Hz (ds-lemon) — in the LEMON file the fundamental is weak and its 150 Hz harmonic is the sharpest line, which the report says in numbers.

4. The numbers

In [5]:
print("nb-c0-recording-report -- capstone C0 (subset run)" if not FULL_COHORT else "nb-c0-recording-report -- capstone C0 (FULL_COHORT run)")
for s in summaries:
    print(f"\n{s['label']}:")
    if "sfreq" not in s:
        print(f"  {s['decision']}: {s['reasons'][0]}")
        continue
    print(f"  {s['sfreq']:g} Hz | {s['n_channels']} channels | {s['duration_s']:.1f} s | events {s['event_counts']}")
    print(f"  artifacts (algorithmic): {s['artifact_counts']} | technical coverage {100 * s['coverage']:.0f} % | ocular coverage {100 * s['ocular']:.0f} %")
    print(f"  {s['alpha_text']}")
    print(f"  {s['line_text']}")
    print(f"  checklist: {s['n_fail']} fail, {s['n_warn']} warn | recommendation (template): {s['decision']} -- {s['reasons'][0]}")
print(f"\nrendered HTML: {REPORT_NAME} in the capstones folder; every label is label_source: algorithmic; TODO(confirm) at author review.")
nb-c0-recording-report -- capstone C0 (subset run)

ds-eegbci S001 R01 (Baseline: eyes open (~1 min)):
  160 Hz | 64 channels | 61.0 s | events {'T0': 1}
  artifacts (algorithmic): {'blink': 18, 'saccade': 45, 'emg': 0, 'drift': 29, 'pop': 2, 'noisy': 0, 'flat': 0, 'line-noise': 3} | technical coverage 3 % | ocular coverage 38 %
  A posterior alpha peak at 12.5 Hz, 4.8 dB above the 1/f trend (mean PSD of O1, Oz, O2, PO3, POz, PO4, PO7, PO8, P3, Pz, P4).
  The 60 Hz mains line is moderate (14.1 dB above the local floor).
  checklist: 0 fail, 0 warn | recommendation (template): GO -- no failed checklist item; technical artifact coverage 3 % (EMG/pop/flat), no noisy channel

ds-lemon sub-010002 (raw, first 5 min):
  2500 Hz | 62 channels | 300.0 s | events {'Comment/no USB Connection to actiCAP': 1, 'Stimulus/S  1': 6, 'Stimulus/S210': 83, 'Stimulus/S200': 60}
  artifacts (algorithmic): {'blink': 15, 'saccade': 4, 'emg': 1, 'drift': 28, 'pop': 2, 'noisy': 1, 'flat': 0, 'line-noise': 0} | technical coverage 1 % | ocular coverage 2 %
  A posterior alpha peak at 10.5 Hz, 4.5 dB above the 1/f trend (mean PSD of O1, Oz, O2, PO3, POz, PO4, PO7, PO8, P3, Pz, P4).
  The 50 Hz mains line is negligible (4.0 dB above the local floor).
  checklist: 0 fail, 2 warn | recommendation (template): CAUTION -- 1 noisy channel(s): T7 -- exclude or interpolate (L2.2) before anything else

ds-erpcore (TODO(confirm)):
  TODO(confirm): ERP CORE is not in the site's dataset catalog (spec 13.22): license and per-subject download are TODO(confirm); no file is fetched and no report is produced until the author resolves it.

rendered HTML: nb-c0-recording-report.html in the capstones folder; every label is label_source: algorithmic; TODO(confirm) at author review.