Data hygiene and metadata: the first-look checklist as code, an HTML report, and two planted problems

nb-0-6-first-look-report Level 0 · Read the Raw Signal ~2 min Used in L0.6 · Data hygiene and metadata

Downloads from ds-eegbci 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-0-6-first-look-report · Data hygiene and metadata (L0.6)

Lesson L0.6 Data hygiene and metadata · Level 0 · Status draft — drafted for expert review; every scientific statement below is a draft and uncertain points carry TODO(confirm).

What you will do

  1. Run the first-look checklist as code — sampling rate, channel count, duration, event counts and timing, header filters, montage, flat channels, DC offsets, the spectrum's line noise and alpha peak, and whether each channel's data agree with its label — through helpers.first_look_checks.
  2. Produce the reusable HTML summary, helpers.first_look_html, and a QC log with provenance.
  3. Adopt EEG-BIDS naming: what the names and sidecars are, shown on the real files of a BIDS dataset.
  4. Exercise: run the report on a deliberately corrupted copy of ds-eegbci S001 R01 and find its two planted problems (a mislabelled channel and a wrong sampling rate in the header). The copy is a labelled synthetic derivative written to a temporary folder; the answer is printed in the last cell.
  5. Second file, real: ds-eegbci S088 R03, one of the subjects the catalog documents as defective — the checklist should catch it.

Data ds-eegbci — EEG Motor Movement/Imagery Dataset (EEGMMIDB), Schalk et al. (2004), PhysioNet v1.0.0, DOI 10.13026/C28G6P, ODC-By 1.0. From the catalog: 64 channels (10-10, Sharbrough labels), 160 Hz, no hardware filters, 60 Hz mains; S088, S089, S092 and S100 carry inconsistent/overlapping event timestamps (annotation and sampling-rate defects, a community-documented caveat not stated on the PhysioNet page), and S038/S104 are also often dropped. Files used: S001 R01 (1.2 MB) and S088 R03 (2 MB). The BIDS naming section lists — without downloading anything — the file names of the dataset's BIDS mirror (OpenNeuro ds004362, catalog) and of ds-iowapd (OpenNeuro ds004584, CC0).

edfio writes the corrupted copy as EDF (the format whose header literally stores a sampling rate per signal); when it is not installed the copy is written as FIF instead and the notebook says so.

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", "edfio")
_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/L0/ (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

1. The checklist as code

helpers.first_look_checks returns one row per item with a status (ok, warn, fail, info), the measured value, and a note that says what the item is for. Expectations come from the catalog facts in helpers.DATASETS (sampling rate, channel count, mains frequency), so the same call works for any dataset the catalog documents. Underneath are small functions you can call on their own; a few are shown after the table.

In [2]:
DATASET, SUBJECT, RUN = "ds-eegbci", "S001", "R01"
raw = helpers.load_spine(DATASET, SUBJECT, RUN)


def print_checks(rows):
    for r in rows:
        print(f"[{r['status'].upper():4s}] {r['item']:30s} {r['value']}" + (f"\n       {r['note']}" if r["note"] else ""))


checks = helpers.first_look_checks(raw, DATASET)
print_checks(checks)
[OK  ] sampling rate                  160 Hz (Nyquist 80 Hz)
       matches the documented 160 Hz
[OK  ] channel count                  64 EEG of 64 ({'eeg': 64})
       matches the documented 64
[INFO] duration                       61.0 s = 9760 samples / 160 Hz
       compare with the documented run length
[OK  ] annotations                    1 (T0: 1)
[INFO] header filters                 high-pass 0 Hz, low-pass 80 Hz
       0 Hz / Nyquist means the header records no filter; the acquisition documentation may still document one
[OK  ] montage                        64 of 64 EEG channels have positions
[OK  ] flat channels                  none (smallest std 23.8 uV on T10)
[OK  ] DC offsets                     largest |mean| 10.7 uV on AF7
[OK  ] amplitude outliers             channel std within 0.25-4 x the median (52.4 uV)
[OK  ] line noise                     60.00 Hz, 14.1 dB above the floor
       at the mains frequency (60 Hz documented); a notch or a low-pass decides later, not now
[INFO] posterior alpha                peak 12.50 Hz, 4.8 dB above the 1/f trend
       mean PSD of O1, Oz, O2, PO3, POz, PO4, PO7, PO8, P3, Pz, P4
[OK  ] channel-position consistency   every channel's correlations fall off with distance (lowest rho +0.56, T10)
In [3]:
print("flat_channels (std < 0.5 uV):", helpers.flat_channels(raw) or "none")
dc = helpers.dc_offsets(raw)
print("dc_offsets, three largest |mean| (uV):", sorted(((round(v, 1), k) for k, v in dc.items()), key=lambda t: -abs(t[0]))[:3])
print("line_noise_peak:", helpers.line_noise_peak(raw, mains_hz=60))
print("alpha_peak:", helpers.alpha_peak(raw))
print("event_timing_check:", helpers.event_timing_check(raw))
cons = helpers.channel_position_consistency(raw)
print("channel_position_consistency, the three lowest rho:", [(c["name"], round(c["rho"], 2), c["best_match"]) for c in cons[:3]])
flat_channels (std < 0.5 uV): none
dc_offsets, three largest |mean| (uV): [(-10.7, 'AF7'), (-9.1, 'AF3'), (-8.8, 'Fp1')]
line_noise_peak: {'freq_hz': 60.0, 'db_above_floor': 14.13770929627096, 'mains_hz': 60.0, 'db_above_floor_at_mains': 14.13770929627096}
alpha_peak: {'freq_hz': 12.5, 'db_above_trend': 4.847594095436893, 'channels': ['O1', 'Oz', 'O2', 'PO3', 'POz', 'PO4', 'PO7', 'PO8', 'P3', 'Pz', 'P4']}
event_timing_check: {'sfreq_hz': 160.0, 'n_samples': 9760, 'duration_s': 61.0, 'n_annotations': 1, 'counts': {'T0': 1}, 'segment_markers': 0, 'spans_beyond_end': 0}
channel_position_consistency, the three lowest rho: [('T10', 0.56, 'TP8'), ('T8', 0.62, 'FT8'), ('C6', 0.69, 'C4')]

2. The reusable report: helpers.first_look_html

One call renders the metadata table (helpers.first_look_report), the checklist with a colour per status, the Welch PSD of a few channels (dB re 1 µV²/Hz) with the per-channel standard deviations (µV), and optional EEG-BIDS notes, as one HTML fragment. It displays inline, and the same string can be written to a file next to a QC log: a JSON record of what was checked, on which file, with what result — provenance you will want when the analysis is questioned months later. Both files go to a temporary folder here that is deleted at the end of the cell.

In [4]:
import hashlib
import json
import tempfile
from datetime import date
from IPython.display import HTML, display

from mne.datasets import eegbci

root = helpers.data_dir()
edf_path = Path(eegbci.load_data(1, [1], update_path=False, verbose=False, **({"path": str(root)} if root else {}))[0])
bids_notes = [
    "EEG-BIDS names a recording sub-<label>[_ses-<label>]_task-<label>[_run-<index>]_eeg.<ext> and describes it in sidecars: "
    "_eeg.json (acquisition metadata), _channels.tsv (names, types, units), _electrodes.tsv (positions), _events.tsv (events).",
    f"This file is not BIDS-named ({edf_path.name}); the catalog lists a BIDS mirror of the dataset (OpenNeuro ds004362) -- see section 3.",
]
html = helpers.first_look_html(raw, DATASET, title=f"First look: {DATASET} {SUBJECT} {RUN}", bids_notes=bids_notes)
display(HTML(html))

qc_log = {
    "date": date.today().isoformat(),
    "dataset": DATASET,
    "citation": helpers.DATASETS[DATASET]["citation"],
    "doi": helpers.DATASETS[DATASET]["doi"],
    "license": helpers.DATASETS[DATASET]["license"],
    "file": edf_path.name,
    "sha256_16": hashlib.sha256(edf_path.read_bytes()).hexdigest()[:16],
    "software": f"MNE {mne.__version__}",
    "checks": checks,
    "decision": "TODO: your judgment after looking at the traces (nb-0-4) and the artifact atlas (nb-0-5)",
}
with tempfile.TemporaryDirectory() as tmp:
    (Path(tmp) / "first-look-S001R01.html").write_text(html, encoding="utf-8")
    (Path(tmp) / "qc-log-S001R01.json").write_text(json.dumps(qc_log, indent=1), encoding="utf-8")
    print("written to a temporary folder:", ", ".join(f"{p.name} ({p.stat().st_size / 1e3:.0f} kB)" for p in sorted(Path(tmp).iterdir())))

First look: ds-eegbci S001 R01

Metadata (read before any plot)

descriptionds-eegbci S001 R01 (Baseline: eyes open (~1 min)); excluded by default: S088, S089, S092, S100 and S038, S104
sfreq_hz160
nyquist_hz80
n_channels64
channel_typeseeg: 64
duration_s61
n_samples9760
highpass_hz_in_header0
lowpass_hz_in_header80
montage_attachedTrue
bads
annotation_countsT0: 1
amplitude_uVmedian_channel_std: 52.45
max_channel_std: 110.3
max_abs: 597
flattest_channel: T10
noisiest_channel: Fp1

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)
PSD and channel amplitudes

EEG-BIDS naming notes

  • EEG-BIDS names a recording sub-<label>[_ses-<label>]_task-<label>[_run-<index>]_eeg.<ext> and describes it in sidecars: _eeg.json (acquisition metadata), _channels.tsv (names, types, units), _electrodes.tsv (positions), _events.tsv (events).
  • This file is not BIDS-named (S001R01.edf); the catalog lists a BIDS mirror of the dataset (OpenNeuro ds004362) -- see section 3.
written to a temporary folder: first-look-S001R01.html (74 kB), qc-log-S001R01.json (2 kB)

3. EEG-BIDS naming, on real files

The two listings below come from OpenNeuro's public bucket (one small index request each; nothing is downloaded): the BIDS mirror of ds-eegbci that the catalog lists (ds004362) and ds-iowapd (ds004584), the dataset of nb-0-2. Read the names against the pattern above: subject, task, run, then the suffix that says what the file is. If the listing cannot be reached the cell says so and moves on.

In [5]:
for ds_id, prefix, label in (("ds004362", "sub-001/eeg/", "ds-eegbci BIDS mirror (OpenNeuro ds004362, catalog)"),
                             ("ds004584", "sub-001/eeg/", "ds-iowapd (OpenNeuro ds004584, CC0)")):
    files = helpers.list_openneuro_files(ds_id, prefix)
    print(f"{label}: {prefix}")
    if not files:
        print("   (listing not reachable in this run)")
        continue
    for f in files[:7]:
        print(f"   {f['name']:52s} {f['size'] / 1e3:9.1f} kB")
    if len(files) > 7:
        print(f"   ... {len(files)} files in total")
ds-eegbci BIDS mirror (OpenNeuro ds004362, catalog): sub-001/eeg/
   sub-001_task-motion_run-10_channels.tsv                    0.8 kB
   sub-001_task-motion_run-10_coordsystem.json                0.1 kB
   sub-001_task-motion_run-10_eeg.json                        0.5 kB
   sub-001_task-motion_run-10_eeg.set                      5937.8 kB
   sub-001_task-motion_run-10_electrodes.tsv                  2.0 kB
   sub-001_task-motion_run-10_events.tsv                      1.0 kB
   sub-001_task-motion_run-11_channels.tsv                    0.8 kB
   ... 84 files in total
ds-iowapd (OpenNeuro ds004584, CC0): sub-001/eeg/
   sub-001_task-Rest_channels.tsv                             0.7 kB
   sub-001_task-Rest_coordsystem.json                         0.1 kB
   sub-001_task-Rest_eeg.fdt                              35489.2 kB
   sub-001_task-Rest_eeg.json                                 0.5 kB
   sub-001_task-Rest_eeg.set                                614.3 kB
   sub-001_task-Rest_electrodes.tsv                           2.0 kB
   sub-001_task-Rest_events.tsv                               0.1 kB

4. Exercise: a corrupted copy with two planted problems

The cell below builds a copy of S001 R01 with two deliberate defects, writes it as EDF (or FIF) into a temporary folder, reads it back as if it were a file you had been handed, and shows only its info summary. The construction is deterministic (no random numbers are involved) and the two defects are named in the last cell of the notebook — try to find them from the report first.

In [6]:
def make_corrupted_copy(raw_in, folder):
    """A labelled synthetic derivative of raw_in with two planted problems; returns the path of the written file."""
    data = raw_in.get_data().copy()
    names = list(raw_in.ch_names)
    # planted problem 1: the data of two channels are swapped under their labels (a mislabelled channel pair)
    i, j = names.index("Fp1"), names.index("O1")
    data[[i, j]] = data[[j, i]]
    # planted problem 2: the header claims 200 Hz for data sampled at 160 Hz
    info = mne.create_info(names, 200.0, "eeg")
    bad = mne.io.RawArray(data, info, verbose=False)
    bad.set_annotations(mne.Annotations([0.0], [bad.times[-1]], ["T0"]))
    bad.info["description"] = "SYNTHETIC derivative of ds-eegbci S001 R01 with two planted problems (L0.6 exercise)"
    try:
        import edfio  # noqa: F401  (MNE's EDF export needs it)
        out = Path(folder) / "S001R01-corrupted.edf"
        with warnings.catch_warnings():
            warnings.simplefilter("ignore")       # EDF export pads the last data record; not our concern here
            bad.export(out, fmt="edf", overwrite=True, verbose=False)
    except ImportError:
        out = Path(folder) / "S001R01-corrupted_raw.fif"
        bad.save(out, overwrite=True, verbose=False)
        print("edfio is not installed: the copy is written as FIF instead of EDF")
    return out


tmpdir = tempfile.TemporaryDirectory()             # deleted in the last cell of the notebook
bad_path = make_corrupted_copy(raw, tmpdir.name)
if bad_path.suffix == ".edf":
    handed = mne.io.read_raw_edf(bad_path, preload=True, verbose=False)
else:
    handed = mne.io.read_raw_fif(bad_path, preload=True, verbose=False)
handed.set_montage("standard_1005", on_missing="raise", verbose=False)
print(f"{bad_path.name}: {bad_path.stat().st_size / 1e6:.1f} MB")
print(handed)
print(f"sfreq {handed.info['sfreq']:g} Hz | {handed.info['nchan']} channels | {handed.times[-1] + 1 / handed.info['sfreq']:.1f} s | "
      f"annotations {helpers.event_timing_check(handed)['counts']}")
S001R01-corrupted.edf: 1.3 MB
<RawEDF | S001R01-corrupted.edf, 64 x 9800 (49.0 s), ~4.9 MiB, data loaded>
sfreq 200 Hz | 64 channels | 49.0 s | annotations {'T0': 1, 'BAD_ACQ_SKIP': 1}
In [7]:
html_bad = helpers.first_look_html(handed, DATASET, title=f"First look: {bad_path.name} (a file you were handed)")
display(HTML(html_bad))
checks_bad = helpers.first_look_checks(handed, DATASET)
print("rows that are not OK:")
print_checks([r for r in checks_bad if r["status"] in ("warn", "fail")])

First look: S001R01-corrupted.edf (a file you were handed)

Metadata (read before any plot)

descriptionNone
sfreq_hz200
nyquist_hz100
n_channels64
channel_typeseeg: 64
duration_s49
n_samples9800
highpass_hz_in_header0
lowpass_hz_in_header100
montage_attachedTrue
bads
annotation_countsT0: 1
BAD_ACQ_SKIP: 1
amplitude_uVmedian_channel_std: 52.34
max_channel_std: 110.1
max_abs: 597
flattest_channel: T10
noisiest_channel: O1

Checklist

itemstatusvaluenote
sampling rateFAIL200 Hz in the headerdocumented: 160 Hz -- check the header and the spectrum
channel countOK64 EEG of 64 ({'eeg': 64})matches the documented 64
durationINFO49.0 s = 9800 samples / 200 Hzcompare with the documented run length
annotationsOK2 (T0: 1, BAD_ACQ_SKIP: 1)1 gaps between consecutive spans; onset intervals median 48.800 s (min 48.800, max 48.800)
header filtersINFOhigh-pass 0 Hz, low-pass 100 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.7 uV on T10)
DC offsetsOKlargest |mean| 10.7 uV on AF7
amplitude outliersOKchannel std within 0.25-4 x the median (52.3 uV)
line noiseFAIL75.00 Hz, 14.7 dB above the floora sharp line that is not at 60 Hz or a harmonic: if it is the 60 Hz mains, the true sampling rate is 200 x 60 / 75.00 = 160.0 Hz, not the header's 200 Hz
posterior alphaINFOno clear peak in 7-13 Hzlargest residual 1.0 dB; eyes open, or a shifted spectrum?
channel-position consistencyFAILO1 (rho -0.86, most similar to Fpz 20 cm away); Fp1 (rho -0.77, most similar to PO7 18 cm away)a channel whose data resemble channels far from its label: mislabelled, or swapped with another
PSD and channel amplitudes
rows that are not OK:
[FAIL] sampling rate                  200 Hz in the header
       documented: 160 Hz -- check the header and the spectrum
[FAIL] line noise                     75.00 Hz, 14.7 dB above the floor
       a sharp line that is not at 60 Hz or a harmonic: if it is the 60 Hz mains, the true sampling rate is 200 x 60 / 75.00 = 160.0 Hz, not the header's 200 Hz
[FAIL] channel-position consistency   O1 (rho -0.86, most similar to Fpz 20 cm away); Fp1 (rho -0.77, most similar to PO7 18 cm away)
       a channel whose data resemble channels far from its label: mislabelled, or swapped with another

Reading the flags

  • Sampling rate. The header says 200 Hz; the catalog says 160 Hz. A header alone could be right and the catalog wrong — so look at the spectrum: the sharpest line sits at 75 Hz, and no mains runs at 75 Hz. A 60 Hz line shows up at 75 Hz exactly when the samples are played back 200/160 = 1.25 times too fast; the true rate is therefore 200 × 60 / 75 = 160 Hz. The duration confirms it (about 49 s in the report for a run that should last about a minute), and so would an alpha rhythm sitting at 12–13 Hz instead of 10.
  • Channel-position consistency. The channel labelled O1 resembles the frontal-polar channels, and the one labelled Fp1 resembles the parieto-occipital ones: their labels are swapped. The traces below show it directly — the blinks are on the wrong side of the head.
In [8]:
fig, axes = plt.subplots(1, 2, figsize=(14, 4), gridspec_kw=dict(width_ratios=[1.6, 1]))
helpers.plot_traces(handed, ["Fp1", "Fp2", "O1", "O2"], t0=5, duration=10, spacing_uV=250, ax=axes[0],
                    title=f"{bad_path.name}: which channel carries the blinks?")
axes[0].set_xlabel("Time (s, at the header's 200 Hz)")
helpers.plot_psd(raw, ["Cz"], fmin=1, fmax=raw.info["sfreq"] / 2 - 1, ax=axes[1], label_prefix="original 160 Hz: ", title="")
helpers.plot_psd(handed, ["Cz"], fmin=1, fmax=handed.info["sfreq"] / 2 - 1, ax=axes[1], label_prefix="header 200 Hz: ",
                 title="Cz PSD, original vs corrupted header, each up to its own Nyquist (dB re 1 uV^2/Hz)")
for f, colour in ((60, "tab:blue"), (75, "tab:orange")):
    axes[1].axvline(f, color=colour, lw=0.8, ls="--")
axes[1].legend(fontsize=8)
for ax in axes:
    ax.title.set_fontsize(9)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
Figure 1 of notebook nb-0-6-first-look-report, an output plot. The text around it states what it shows and the units of every axis.

5. A real defective file: S088 R03

helpers.load_spine refuses S088 on purpose (catalog caveat). To see the defect the file is read directly here with MNE's downloader — a deliberate bypass, for inspection only, never for analysis. Two items of the checklist react.

In [9]:
p88 = Path(eegbci.load_data(88, [3], update_path=False, verbose=False, **({"path": str(root)} if root else {}))[0])
raw88 = mne.io.read_raw_edf(p88, preload=True, verbose=False)
eegbci.standardize(raw88)
raw88.set_montage("standard_1005", on_missing="ignore", verbose=False)
print(f"{p88.name}: {raw88}")
checks88 = helpers.first_look_checks(raw88, DATASET)
print_checks([r for r in checks88 if r["status"] in ("warn", "fail")])
print("\nthe first eight annotations (onset, duration, end, in s):")
ann = raw88.annotations
for k in range(8):
    print(f"   {ann.description[k]:3s} {ann.onset[k]:8.3f} {ann.duration[k]:7.3f} {ann.onset[k] + ann.duration[k]:8.3f}")
t88 = helpers.event_timing_check(raw88)
raw_r03 = helpers.load_spine(DATASET, SUBJECT, "R03")
t01 = helpers.event_timing_check(raw_r03)
print(f"\nS088 R03: {t88['sfreq_hz']:g} Hz, {t88['n_annotations']} annotations, overlapping spans {t88['n_overlapping_spans']}, "
      f"gaps between spans {t88['n_gaps_between_spans']}")
print(f"S001 R03: {t01['sfreq_hz']:g} Hz, {t01['n_annotations']} annotations, overlapping spans {t01['n_overlapping_spans']}, "
      f"gaps between spans {t01['n_gaps_between_spans']}  (the same run of an unaffected subject)")
S088R03.edf: <RawEDF | S088R03.edf, 64 x 15872 (124.0 s), ~7.8 MiB, data loaded>
[FAIL] sampling rate                  128 Hz in the header
       documented: 160 Hz -- check the header and the spectrum
[FAIL] annotations                    38 (T0: 19, T1: 9, T2: 10)
       16 overlapping spans; 17 gaps between consecutive spans; 18 onset intervals > 2 x median; onset intervals median 1.400 s (min 1.375, max 5.125)

the first eight annotations (onset, duration, end, in s):
   T0     0.000   1.375    1.375
   T1     1.375   5.125    6.500
   T0     6.500   1.375    7.875
   T2     7.875   5.125   13.000
   T0    13.000   1.375   14.375
   T1    14.380   5.125   19.505
   T0    19.500   1.375   20.875
   T2    20.880   5.125   26.005

S088 R03: 128 Hz, 38 annotations, overlapping spans 16, gaps between spans 17
S001 R03: 160 Hz, 30 annotations, overlapping spans 0, gaps between spans 0  (the same run of an unaffected subject)

The catalog's caveat, in the file: R03 of S088 was stored at 128 Hz where every other run of the dataset is at 160 Hz, and its event spans overlap or leave gaps by a few milliseconds instead of tiling the run. Neither would be visible in a trace; both are visible to a checklist that is run every time, on every file — which is the habit this lesson is about.

6. The numbers

In [10]:
fails = {r["item"]: r["value"] for r in checks_bad if r["status"] == "fail"}
ln_bad = helpers.line_noise_peak(handed, mains_hz=60)
print(f"nb-0-6-first-look-report -- {DATASET} (EEGMMIDB; PhysioNet DOI 10.13026/C28G6P; ODC-By 1.0)")
print(f"baseline S001 R01: {raw.info['sfreq']:g} Hz | {len(mne.pick_types(raw.info, eeg=True))} EEG channels | "
      f"{raw.times[-1] + 1 / raw.info['sfreq']:.2f} s | flat channels: {len(helpers.flat_channels(raw))} | "
      f"largest |DC| {max(abs(v) for v in helpers.dc_offsets(raw).values()):.1f} uV | "
      f"line {helpers.line_noise_peak(raw, mains_hz=60)['freq_hz']:.2f} Hz | "
      f"checklist: {sum(r['status'] == 'ok' for r in checks)} ok, {sum(r['status'] == 'warn' for r in checks)} warn, "
      f"{sum(r['status'] == 'fail' for r in checks)} fail")
print()
print(f"L0.6 exercise -- the two planted problems in {bad_path.name} (SYNTHETIC derivative of S001 R01; answer key):")
print(f"  1. mislabelled channel: the labels Fp1 and O1 are swapped -- the channel labelled O1 carries the frontal-polar "
      f"(blink) data and the channel labelled Fp1 the occipital data   <-- answer 1")
print(f"     tell-tale: channel-position consistency flags {', '.join(n for n in ('O1', 'Fp1') if n in fails.get('channel-position consistency', ''))}; "
      f"the blinks appear on 'O1' in the traces")
print(f"  2. wrong sampling rate in the header: the header says {handed.info['sfreq']:g} Hz; the data were sampled at "
      f"{raw.info['sfreq']:g} Hz   <-- answer 2")
print(f"     tell-tale: the sharpest spectral line is at {ln_bad['freq_hz']:.2f} Hz instead of 60 Hz "
      f"({handed.info['sfreq']:g} x 60 / {ln_bad['freq_hz']:.0f} = {handed.info['sfreq'] * 60 / ln_bad['freq_hz']:.0f} Hz), "
      f"the file lasts {handed.times[-1] + 1 / handed.info['sfreq']:.1f} s instead of about a minute, and the catalog documents 160 Hz")
print(f"  checklist on the corrupted copy: {sum(r['status'] == 'fail' for r in checks_bad)} FAIL rows "
      f"({', '.join(r['item'] for r in checks_bad if r['status'] == 'fail')})")
print()
print(f"second file, real -- S088 R03 (catalog caveat): sampling rate {t88['sfreq_hz']:g} Hz (documented 160 Hz); "
      f"{t88['n_annotations']} annotations with {t88['n_overlapping_spans']} overlapping spans and {t88['n_gaps_between_spans']} gaps; "
      f"S001 R03 for comparison: {t01['sfreq_hz']:g} Hz, 0 overlaps, 0 gaps")
print("TODO(confirm): draft values until the author reviews them.")
tmpdir.cleanup()                                   # remove the temporary folder with the corrupted copy
nb-0-6-first-look-report -- ds-eegbci (EEGMMIDB; PhysioNet DOI 10.13026/C28G6P; ODC-By 1.0)
baseline S001 R01: 160 Hz | 64 EEG channels | 61.00 s | flat channels: 0 | largest |DC| 10.7 uV | line 60.00 Hz | checklist: 9 ok, 0 warn, 0 fail

L0.6 exercise -- the two planted problems in S001R01-corrupted.edf (SYNTHETIC derivative of S001 R01; answer key):
  1. mislabelled channel: the labels Fp1 and O1 are swapped -- the channel labelled O1 carries the frontal-polar (blink) data and the channel labelled Fp1 the occipital data   <-- answer 1
     tell-tale: channel-position consistency flags O1, Fp1; the blinks appear on 'O1' in the traces
  2. wrong sampling rate in the header: the header says 200 Hz; the data were sampled at 160 Hz   <-- answer 2
     tell-tale: the sharpest spectral line is at 75.00 Hz instead of 60 Hz (200 x 60 / 75 = 160 Hz), the file lasts 49.0 s instead of about a minute, and the catalog documents 160 Hz
  checklist on the corrupted copy: 3 FAIL rows (sampling rate, line noise, channel-position consistency)

second file, real -- S088 R03 (catalog caveat): sampling rate 128 Hz (documented 160 Hz); 38 annotations with 16 overlapping spans and 17 gaps; S001 R03 for comparison: 160 Hz, 0 overlaps, 0 gaps
TODO(confirm): draft values until the author reviews them.