Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: lint

on:
push:
branches: [main, develop]
branches: [main]
pull_request:
branches: [main, develop]
branches: [main]

permissions:
contents: read
Expand Down
85 changes: 85 additions & 0 deletions .github/workflows/matlab-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
name: matlab-tests

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read

# A single test job that installs MATLAB (matlab-actions/setup-matlab is
# free for public repos), clones EEGLAB plus the plugins needed for our
# Phase 1 tests, then runs tests/matlab/run_all_tests.m against the
# real-data fixture under tests/fixtures/bids_mini/. The fixture itself
# is committed to the repo (tracked, ~2-3 MB) so CI never reaches for the
# /Volumes path that only exists on the maintainer's machine.

jobs:
matlab-tests:
name: matlab unit + smoke tests
runs-on: ubuntu-latest
timeout-minutes: 30

env:
# Pin EEGLAB to a tagged release so CI is reproducible across runs.
# 2026.0.0 is the latest stable as of 2026-05-12. The release bundles
# the plugins we need for phase 1 as git submodules: EEG-BIDS,
# clean_rawdata, dipfit, firfilt, ICLabel. Biosig (for BDF I/O) is
# not bundled and is auto-installed by EEGLAB's plugin manager on
# first call to pop_biosig; we trigger that explicitly below.
EEGLAB_TAG: "2026.0.0"
EEGLAB_DIR: ${{ github.workspace }}/.eeglab
HBN_BIDS_ROOT: ${{ github.workspace }}/tests/fixtures/bids_mini

steps:
- uses: actions/checkout@v4

# EEGLAB plus its bundled submodule plugins is >500 MB and slow to
# clone fresh on every run. Cache it keyed on the pinned tag so a
# version bump invalidates cleanly.
- name: Cache EEGLAB + bundled plugins
id: cache-eeglab
uses: actions/cache@v4
with:
path: ${{ env.EEGLAB_DIR }}
key: eeglab-${{ env.EEGLAB_TAG }}-${{ runner.os }}-v1

- name: Clone EEGLAB (cache miss)
if: steps.cache-eeglab.outputs.cache-hit != 'true'
run: |
set -euo pipefail
git clone --depth 1 --branch "$EEGLAB_TAG" --recurse-submodules \
https://github.com/sccn/eeglab.git "$EEGLAB_DIR"

- uses: matlab-actions/setup-matlab@v2
with:
release: R2024a

- name: Run MATLAB tests against the fixture
uses: matlab-actions/run-command@v2
with:
command: |
addpath('${{ env.EEGLAB_DIR }}');
evalc('eeglab nogui');
% Biosig is required for BDF I/O and is not bundled with EEGLAB.
% plugin_askinstall runs headless and pulls the plugin into
% EEGLAB_DIR/plugins/, where eeglab's path setup finds it on the
% next call. The Biosig install is what enables pop_biosig and
% pop_writeeeg.
try
plugin_askinstall('Biosig', [], true);
catch ME
warning('hbn:ci:biosig_install', ...
'Biosig auto-install failed: %s', ME.message);
end
evalc('eeglab nogui'); % re-init to pick up newly installed plugins
addpath(genpath(fullfile(pwd, 'src', 'matlab')));
addpath(genpath(fullfile(pwd, 'tests', 'matlab')));
results = run_all_tests();
disp(results);
if any(~results.passed)
error('hbn:ci:test_failures', ...
'One or more MATLAB tests failed; see table above.');
end
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ derivatives/**/_bids_import_scratch/
!derivatives/**/params.json
!derivatives/**/README.md

# Allow-list: track the real-EEG test fixture so CI can run MATLAB tests
# without the maintainer's /Volumes mount. The fixture is small (~2.3 MB,
# 60 s of one HBN R3 subject; license CC-BY-SA 4.0 inherited).
!tests/fixtures/
!tests/fixtures/**/
!tests/fixtures/**/*.bdf
!tests/fixtures/**/*.tsv
!tests/fixtures/**/*.json
!tests/fixtures/**/*.md
!tests/fixtures/**/README

# MATLAB scratch
*.asv
*.m~
Expand Down Expand Up @@ -54,3 +65,4 @@ __pycache__/
# .claude/ is tracked so collaborators see project agents, commands,
# and epic-dev state. Only the per-user permission file is private.
.claude/settings.local.json
logs/
15 changes: 12 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ repos:
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: \.(set|fdt|bdf|edf|mat)$
# tests/fixtures/ mirrors upstream HBN sidecars byte-for-byte.
exclude: \.(set|fdt|bdf|edf|mat)$|^tests/fixtures/
- id: end-of-file-fixer
exclude: \.(set|fdt|bdf|edf|mat|tsv)$
# tests/fixtures/ mirrors upstream HBN sidecars byte-for-byte;
# do not rewrite their final newlines.
exclude: \.(set|fdt|bdf|edf|mat|tsv)$|^tests/fixtures/
- id: check-yaml
- id: check-json
- id: check-merge-conflict
Expand All @@ -15,6 +18,10 @@ repos:
args: [--maxkb=30000]
- id: mixed-line-ending
args: [--fix=lf]
# Skip binary EEG formats; the hook treats unknown files as text
# and would corrupt BDF/EDF/SET/FDT/MAT by rewriting bytes that
# happen to match CRLF/CR patterns.
exclude: \.(set|fdt|bdf|edf|mat)$

- repo: https://github.com/crate-ci/typos
rev: v1.29.4
Expand All @@ -23,7 +30,9 @@ repos:
args: [--config=_typos.toml]
# PDF-converted source.md and BibTeX have OCR/author-name false positives;
# _typos.toml extend-exclude is ignored when files are passed explicitly.
exclude: ^research/collection/[^/]+/[^/]+/source\.md$|^research/collection/[^/]+/[^/]+\.bib$
# tests/fixtures/ carries upstream HBN sidecars verbatim, so any
# spelling artifacts present in the source dataset stay untouched.
exclude: ^research/collection/[^/]+/[^/]+/source\.md$|^research/collection/[^/]+/[^/]+\.bib$|^tests/fixtures/

- repo: https://github.com/adrienverge/yamllint
rev: v1.35.1
Expand Down
154 changes: 154 additions & 0 deletions scripts/build_test_fixture.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
function build_test_fixture(opts)
%BUILD_TEST_FIXTURE Build a small real-EEG BIDS fixture for CI.
%
% Reads the first NDuration seconds of sub-NDARAA948VFH's ThePresent
% recording, writes a self-contained mini BIDS tree under
% tests/fixtures/bids_mini/. The output is real EEG, real events,
% and real sidecars; nothing is synthesized.
%
% Run from the worktree root:
% matlab -batch "run('scripts/build_test_fixture.m')"
%
% Name-value arguments:
% SourceRoot source BIDS root (default: the local R3 100 Hz path)
% Subject subject ID to slice (default: sub-NDARAA948VFH)
% NDuration number of seconds to retain from t=0 (default: 60)
% OutRoot output BIDS root (default: tests/fixtures/bids_mini)
%
% The default window (0..60 s) covers shot_events.tsv rows 1..15,
% which include boy-only, puppy-only, and "neither" shots; enough
% diversity for downstream epoching tests once Phase 4 lands.

arguments
opts.SourceRoot (1, 1) string = "/Volumes/S1/Datasets/HBN/L100/R3_L100_bdf"
opts.Subject (1, 1) string = "sub-NDARAA948VFH"
opts.NDuration (1, 1) double {mustBePositive} = 60
opts.OutRoot (1, 1) string = "tests/fixtures/bids_mini"
end

script_dir = fileparts(mfilename('fullpath'));
repo_root = fileparts(script_dir);
cd(repo_root);

addpath('/Users/yahya/Documents/git/eeg/eeglab');
evalc('eeglab nogui');

src = opts.SourceRoot;
sub = opts.Subject;
nd = opts.NDuration;
out = fullfile(repo_root, opts.OutRoot);

if ~isfolder(src)
error("hbn:build_fixture:no_source", ...
"Source BIDS root not found: %s", src);
end

src_eeg_dir = fullfile(src, sub, 'eeg');
src_bdf = fullfile(src_eeg_dir, sprintf("%s_task-ThePresent_eeg.bdf", sub));
src_eegj = fullfile(src_eeg_dir, sprintf("%s_task-ThePresent_eeg.json", sub));
src_chans = fullfile(src_eeg_dir, sprintf("%s_task-ThePresent_channels.tsv", sub));
src_events = fullfile(src_eeg_dir, sprintf("%s_task-ThePresent_events.tsv", sub));
for f = [src_bdf, src_eegj, src_chans, src_events]
if ~isfile(f); error("hbn:build_fixture:missing_input", "Missing: %s", f); end
end

out_eeg_dir = fullfile(out, sub, 'eeg');
if ~isfolder(out_eeg_dir); mkdir(out_eeg_dir); end

fprintf("[fixture] reading %s\n", src_bdf);
EEG = pop_biosig(char(src_bdf));
fprintf("[fixture] source: %d ch, srate=%g, %d samples (%.1f s)\n", ...
EEG.nbchan, EEG.srate, EEG.pnts, EEG.xmax);

if EEG.xmax < nd
error("hbn:build_fixture:too_short", ...
"Source recording is %.1f s, shorter than requested %.1f s.", EEG.xmax, nd);
end

cropped = pop_select(EEG, 'time', [0 nd]);
fprintf("[fixture] cropped to %d samples (%.1f s)\n", cropped.pnts, cropped.xmax);

out_bdf = fullfile(out_eeg_dir, sprintf("%s_task-ThePresent_eeg.bdf", sub));
pop_writeeeg(cropped, char(out_bdf), 'TYPE', 'BDF');
bdf_info = dir(out_bdf);
fprintf("[fixture] wrote %s (%.2f MB)\n", out_bdf, bdf_info.bytes / 1024 / 1024);

out_chans = fullfile(out_eeg_dir, sprintf("%s_task-ThePresent_channels.tsv", sub));
copyfile(src_chans, out_chans);
fprintf("[fixture] copied %s\n", out_chans);

src_events_tbl = readtable(src_events, 'FileType', 'text', 'Delimiter', '\t');
src_events_tbl = src_events_tbl(src_events_tbl.onset < nd, :);
out_events = fullfile(out_eeg_dir, sprintf("%s_task-ThePresent_events.tsv", sub));
writetable(src_events_tbl, out_events, 'FileType', 'text', 'Delimiter', '\t');
fprintf("[fixture] wrote %s with %d in-window event(s)\n", out_events, height(src_events_tbl));

out_eegj = fullfile(out_eeg_dir, sprintf("%s_task-ThePresent_eeg.json", sub));
src_eegj_struct = jsondecode(fileread(src_eegj));
src_eegj_struct.RecordingDuration = nd;
fid_j = fopen(out_eegj, 'w');
fprintf(fid_j, '%s', jsonencode(src_eegj_struct, 'PrettyPrint', true));
fclose(fid_j);
fprintf("[fixture] wrote %s (RecordingDuration set to %g)\n", out_eegj, nd);

% Root-level BIDS inheritance: pop_importbids reads task-*_eeg.json at
% the dataset root and merges it with the subject-level sidecar. The
% root file carries Manufacturer / ManufacturersModelName that EEGLAB
% uses to auto-look-up channel locations. Without it, clean_rawdata
% falls into a location-free path that fails at srate < 110 Hz.
for rootSidecar = ["task-ThePresent_eeg.json", "task-ThePresent_events.json"]
srcRoot = fullfile(src, rootSidecar);
if isfile(srcRoot)
copyfile(srcRoot, fullfile(out, rootSidecar));
fprintf("[fixture] copied root-level %s\n", rootSidecar);
end
end

src_ds = fullfile(src, 'dataset_description.json');
if isfile(src_ds)
copyfile(src_ds, fullfile(out, 'dataset_description.json'));
end

src_pj = fullfile(src, 'participants.json');
if isfile(src_pj)
copyfile(src_pj, fullfile(out, 'participants.json'));
end

src_pt = readtable(fullfile(src, 'participants.tsv'), ...
'FileType', 'text', 'Delimiter', '\t');
src_pt = src_pt(strcmp(src_pt.participant_id, char(sub)), :);
writetable(src_pt, fullfile(out, 'participants.tsv'), ...
'FileType', 'text', 'Delimiter', '\t');
fprintf("[fixture] wrote participants.tsv with 1 row\n");

src_readme = fullfile(src, 'README');
if isfile(src_readme); copyfile(src_readme, fullfile(out, 'README')); end

fixture_readme = fullfile(out, 'FIXTURE_README.md');
fid = fopen(fixture_readme, 'w');
fprintf(fid, "# HBN R3 fixture\n\n");
fprintf(fid, "Mini BIDS-EEG dataset derived from the HBN R3 100 Hz local copy.\n");
fprintf(fid, "Used by `tests/matlab/*.m` to run real-data smoke tests in CI\n");
fprintf(fid, "without requiring the full /Volumes mount.\n\n");
fprintf(fid, "## Provenance\n\n");
fprintf(fid, "- Subject: `%s`\n", sub);
fprintf(fid, "- Task: `ThePresent`\n");
fprintf(fid, "- Window: 0 to %g s (from full recording of %.1f s)\n", nd, EEG.xmax);
fprintf(fid, "- Sampling rate: %g Hz\n", cropped.srate);
fprintf(fid, "- Channels: %d (unchanged from source)\n", cropped.nbchan);
fprintf(fid, "- Built from: `%s`\n", src);
fprintf(fid, "- License: CC-BY-SA 4.0 (inherited from HBN-EEG; see `dataset_description.json`).\n\n");
fprintf(fid, "## Reproduce\n\n");
fprintf(fid, "```bash\n");
fprintf(fid, 'matlab -batch "run(''scripts/build_test_fixture.m'')"\n');
fprintf(fid, "```\n\n");
fprintf(fid, "## Why this window\n\n");
fprintf(fid, "shot_events.tsv rows that fall in [0, %g) span shots 1..15, which\n", nd);
fprintf(fid, "include boy-only (#2..#5), neither (#7, #14), boy+puppy (#16 sits\n");
fprintf(fid, "exactly at the boundary, dropped), and the first puppy-only shot\n");
fprintf(fid, "(#15 at 55.042 s). Phase 4 epoching has enough material.\n");
fclose(fid);
fprintf("[fixture] wrote %s\n", fixture_readme);

fprintf("[fixture] done.\n");
end
21 changes: 15 additions & 6 deletions scripts/local_ci.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,22 @@ if [[ -z "$EEGLAB_PATH" || ! -d "$EEGLAB_PATH" ]]; then
echo "ERROR: EEGLAB_PATH must be set to an existing directory (got '$EEGLAB_PATH')." >&2
exit 1
fi
if [[ -z "$BIDS_ROOT" || ! -d "$BIDS_ROOT" ]]; then
# The whole point of local CI is to run on real BDF data; a missing
# dataset would make smoke tests early-return on the no-data branch and
# mask regressions as green. Fail loudly instead.
echo "ERROR: BIDS_ROOT must be set to an existing directory (got '$BIDS_ROOT')." >&2
exit 1
if [[ -z "${BIDS_ROOT:-}" || ! -d "$BIDS_ROOT" ]]; then
# When BIDS_ROOT is unset, fall back to the committed real-EEG fixture
# under tests/fixtures/bids_mini. This is the same fixture CI uses, so
# local and CI runs hit the same code paths on the same data when
# the full /Volumes mount is not available. The fail-loudly rule
# still applies: if neither the env var nor the fixture exists, error.
FIXTURE_ROOT="$REPO_ROOT/tests/fixtures/bids_mini"
if [[ -d "$FIXTURE_ROOT" ]]; then
echo "BIDS_ROOT unset; falling back to fixture at $FIXTURE_ROOT"
BIDS_ROOT="$FIXTURE_ROOT"
else
echo "ERROR: BIDS_ROOT unset and fixture not found at $FIXTURE_ROOT." >&2
exit 1
fi
fi
export HBN_BIDS_ROOT="$BIDS_ROOT"

# 1. Style + lint via miss_hit. Skipping silently was masking missing
# coverage on dev machines without the tool installed; require it unless
Expand Down
28 changes: 28 additions & 0 deletions tests/fixtures/bids_mini/FIXTURE_README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# HBN R3 fixture

Mini BIDS-EEG dataset derived from the HBN R3 100 Hz local copy.
Used by `tests/matlab/*.m` to run real-data smoke tests in CI
without requiring the full /Volumes mount.

## Provenance

- Subject: `sub-NDARAA948VFH`
- Task: `ThePresent`
- Window: 0 to 60 s (from full recording of 206.0 s)
- Sampling rate: 100 Hz
- Channels: 129 (unchanged from source)
- Built from: `/Volumes/S1/Datasets/HBN/L100/R3_L100_bdf`
- License: CC-BY-SA 4.0 (inherited from HBN-EEG; see `dataset_description.json`).

## Reproduce

```bash
matlab -batch "run('scripts/build_test_fixture.m')"
```

## Why this window

shot_events.tsv rows that fall in [0, 60) span shots 1..15, which
include boy-only (#2..#5), neither (#7, #14), boy+puppy (#16 sits
exactly at the boundary, dropped), and the first puppy-only shot
(#15 at 55.042 s). Phase 4 epoching has enough material.
Loading
Loading