Skip to content

Commit 387af54

Browse files
Merge pull request #30 from OpenScience-Collective/feature/issue-11-matlab-ci-fixture
ci: real-data fixture + matlab tests on github actions
2 parents 812a553 + 5ac5d70 commit 387af54

20 files changed

Lines changed: 1021 additions & 21 deletions

.github/workflows/lint.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ name: lint
22

33
on:
44
push:
5-
branches: [main, develop]
5+
branches: [main]
66
pull_request:
7-
branches: [main, develop]
7+
branches: [main]
88

99
permissions:
1010
contents: read

.github/workflows/matlab-tests.yml

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
name: matlab-tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
permissions:
10+
contents: read
11+
12+
# A single test job that installs MATLAB (matlab-actions/setup-matlab is
13+
# free for public repos), clones EEGLAB plus the plugins needed for our
14+
# Phase 1 tests, then runs tests/matlab/run_all_tests.m against the
15+
# real-data fixture under tests/fixtures/bids_mini/. The fixture itself
16+
# is committed to the repo (tracked, ~2-3 MB) so CI never reaches for the
17+
# /Volumes path that only exists on the maintainer's machine.
18+
19+
jobs:
20+
matlab-tests:
21+
name: matlab unit + smoke tests
22+
runs-on: ubuntu-latest
23+
timeout-minutes: 30
24+
25+
env:
26+
# Pin EEGLAB to a tagged release so CI is reproducible across runs.
27+
# 2026.0.0 is the latest stable as of 2026-05-12. The release bundles
28+
# the plugins we need for phase 1 as git submodules: EEG-BIDS,
29+
# clean_rawdata, dipfit, firfilt, ICLabel. Biosig (for BDF I/O) is
30+
# not bundled and is auto-installed by EEGLAB's plugin manager on
31+
# first call to pop_biosig; we trigger that explicitly below.
32+
EEGLAB_TAG: "2026.0.0"
33+
EEGLAB_DIR: ${{ github.workspace }}/.eeglab
34+
HBN_BIDS_ROOT: ${{ github.workspace }}/tests/fixtures/bids_mini
35+
36+
steps:
37+
- uses: actions/checkout@v4
38+
39+
# EEGLAB plus its bundled submodule plugins is >500 MB and slow to
40+
# clone fresh on every run. Cache it keyed on the pinned tag so a
41+
# version bump invalidates cleanly.
42+
- name: Cache EEGLAB + bundled plugins
43+
id: cache-eeglab
44+
uses: actions/cache@v4
45+
with:
46+
path: ${{ env.EEGLAB_DIR }}
47+
key: eeglab-${{ env.EEGLAB_TAG }}-${{ runner.os }}-v1
48+
49+
- name: Clone EEGLAB (cache miss)
50+
if: steps.cache-eeglab.outputs.cache-hit != 'true'
51+
run: |
52+
set -euo pipefail
53+
git clone --depth 1 --branch "$EEGLAB_TAG" --recurse-submodules \
54+
https://github.com/sccn/eeglab.git "$EEGLAB_DIR"
55+
56+
- uses: matlab-actions/setup-matlab@v2
57+
with:
58+
release: R2024a
59+
60+
- name: Run MATLAB tests against the fixture
61+
uses: matlab-actions/run-command@v2
62+
with:
63+
command: |
64+
addpath('${{ env.EEGLAB_DIR }}');
65+
evalc('eeglab nogui');
66+
% Biosig is required for BDF I/O and is not bundled with EEGLAB.
67+
% plugin_askinstall runs headless and pulls the plugin into
68+
% EEGLAB_DIR/plugins/, where eeglab's path setup finds it on the
69+
% next call. The Biosig install is what enables pop_biosig and
70+
% pop_writeeeg.
71+
try
72+
plugin_askinstall('Biosig', [], true);
73+
catch ME
74+
warning('hbn:ci:biosig_install', ...
75+
'Biosig auto-install failed: %s', ME.message);
76+
end
77+
evalc('eeglab nogui'); % re-init to pick up newly installed plugins
78+
addpath(genpath(fullfile(pwd, 'src', 'matlab')));
79+
addpath(genpath(fullfile(pwd, 'tests', 'matlab')));
80+
results = run_all_tests();
81+
disp(results);
82+
if any(~results.passed)
83+
error('hbn:ci:test_failures', ...
84+
'One or more MATLAB tests failed; see table above.');
85+
end

.gitignore

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ derivatives/**/_bids_import_scratch/
2626
!derivatives/**/params.json
2727
!derivatives/**/README.md
2828

29+
# Allow-list: track the real-EEG test fixture so CI can run MATLAB tests
30+
# without the maintainer's /Volumes mount. The fixture is small (~2.3 MB,
31+
# 60 s of one HBN R3 subject; license CC-BY-SA 4.0 inherited).
32+
!tests/fixtures/
33+
!tests/fixtures/**/
34+
!tests/fixtures/**/*.bdf
35+
!tests/fixtures/**/*.tsv
36+
!tests/fixtures/**/*.json
37+
!tests/fixtures/**/*.md
38+
!tests/fixtures/**/README
39+
2940
# MATLAB scratch
3041
*.asv
3142
*.m~
@@ -54,3 +65,4 @@ __pycache__/
5465
# .claude/ is tracked so collaborators see project agents, commands,
5566
# and epic-dev state. Only the per-user permission file is private.
5667
.claude/settings.local.json
68+
logs/

.pre-commit-config.yaml

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ repos:
33
rev: v5.0.0
44
hooks:
55
- id: trailing-whitespace
6-
exclude: \.(set|fdt|bdf|edf|mat)$
6+
# tests/fixtures/ mirrors upstream HBN sidecars byte-for-byte.
7+
exclude: \.(set|fdt|bdf|edf|mat)$|^tests/fixtures/
78
- id: end-of-file-fixer
8-
exclude: \.(set|fdt|bdf|edf|mat|tsv)$
9+
# tests/fixtures/ mirrors upstream HBN sidecars byte-for-byte;
10+
# do not rewrite their final newlines.
11+
exclude: \.(set|fdt|bdf|edf|mat|tsv)$|^tests/fixtures/
912
- id: check-yaml
1013
- id: check-json
1114
- id: check-merge-conflict
@@ -15,6 +18,10 @@ repos:
1518
args: [--maxkb=30000]
1619
- id: mixed-line-ending
1720
args: [--fix=lf]
21+
# Skip binary EEG formats; the hook treats unknown files as text
22+
# and would corrupt BDF/EDF/SET/FDT/MAT by rewriting bytes that
23+
# happen to match CRLF/CR patterns.
24+
exclude: \.(set|fdt|bdf|edf|mat)$
1825

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

2837
- repo: https://github.com/adrienverge/yamllint
2938
rev: v1.35.1

scripts/build_test_fixture.m

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
function build_test_fixture(opts)
2+
%BUILD_TEST_FIXTURE Build a small real-EEG BIDS fixture for CI.
3+
%
4+
% Reads the first NDuration seconds of sub-NDARAA948VFH's ThePresent
5+
% recording, writes a self-contained mini BIDS tree under
6+
% tests/fixtures/bids_mini/. The output is real EEG, real events,
7+
% and real sidecars; nothing is synthesized.
8+
%
9+
% Run from the worktree root:
10+
% matlab -batch "run('scripts/build_test_fixture.m')"
11+
%
12+
% Name-value arguments:
13+
% SourceRoot source BIDS root (default: the local R3 100 Hz path)
14+
% Subject subject ID to slice (default: sub-NDARAA948VFH)
15+
% NDuration number of seconds to retain from t=0 (default: 60)
16+
% OutRoot output BIDS root (default: tests/fixtures/bids_mini)
17+
%
18+
% The default window (0..60 s) covers shot_events.tsv rows 1..15,
19+
% which include boy-only, puppy-only, and "neither" shots; enough
20+
% diversity for downstream epoching tests once Phase 4 lands.
21+
22+
arguments
23+
opts.SourceRoot (1, 1) string = "/Volumes/S1/Datasets/HBN/L100/R3_L100_bdf"
24+
opts.Subject (1, 1) string = "sub-NDARAA948VFH"
25+
opts.NDuration (1, 1) double {mustBePositive} = 60
26+
opts.OutRoot (1, 1) string = "tests/fixtures/bids_mini"
27+
end
28+
29+
script_dir = fileparts(mfilename('fullpath'));
30+
repo_root = fileparts(script_dir);
31+
cd(repo_root);
32+
33+
addpath('/Users/yahya/Documents/git/eeg/eeglab');
34+
evalc('eeglab nogui');
35+
36+
src = opts.SourceRoot;
37+
sub = opts.Subject;
38+
nd = opts.NDuration;
39+
out = fullfile(repo_root, opts.OutRoot);
40+
41+
if ~isfolder(src)
42+
error("hbn:build_fixture:no_source", ...
43+
"Source BIDS root not found: %s", src);
44+
end
45+
46+
src_eeg_dir = fullfile(src, sub, 'eeg');
47+
src_bdf = fullfile(src_eeg_dir, sprintf("%s_task-ThePresent_eeg.bdf", sub));
48+
src_eegj = fullfile(src_eeg_dir, sprintf("%s_task-ThePresent_eeg.json", sub));
49+
src_chans = fullfile(src_eeg_dir, sprintf("%s_task-ThePresent_channels.tsv", sub));
50+
src_events = fullfile(src_eeg_dir, sprintf("%s_task-ThePresent_events.tsv", sub));
51+
for f = [src_bdf, src_eegj, src_chans, src_events]
52+
if ~isfile(f); error("hbn:build_fixture:missing_input", "Missing: %s", f); end
53+
end
54+
55+
out_eeg_dir = fullfile(out, sub, 'eeg');
56+
if ~isfolder(out_eeg_dir); mkdir(out_eeg_dir); end
57+
58+
fprintf("[fixture] reading %s\n", src_bdf);
59+
EEG = pop_biosig(char(src_bdf));
60+
fprintf("[fixture] source: %d ch, srate=%g, %d samples (%.1f s)\n", ...
61+
EEG.nbchan, EEG.srate, EEG.pnts, EEG.xmax);
62+
63+
if EEG.xmax < nd
64+
error("hbn:build_fixture:too_short", ...
65+
"Source recording is %.1f s, shorter than requested %.1f s.", EEG.xmax, nd);
66+
end
67+
68+
cropped = pop_select(EEG, 'time', [0 nd]);
69+
fprintf("[fixture] cropped to %d samples (%.1f s)\n", cropped.pnts, cropped.xmax);
70+
71+
out_bdf = fullfile(out_eeg_dir, sprintf("%s_task-ThePresent_eeg.bdf", sub));
72+
pop_writeeeg(cropped, char(out_bdf), 'TYPE', 'BDF');
73+
bdf_info = dir(out_bdf);
74+
fprintf("[fixture] wrote %s (%.2f MB)\n", out_bdf, bdf_info.bytes / 1024 / 1024);
75+
76+
out_chans = fullfile(out_eeg_dir, sprintf("%s_task-ThePresent_channels.tsv", sub));
77+
copyfile(src_chans, out_chans);
78+
fprintf("[fixture] copied %s\n", out_chans);
79+
80+
src_events_tbl = readtable(src_events, 'FileType', 'text', 'Delimiter', '\t');
81+
src_events_tbl = src_events_tbl(src_events_tbl.onset < nd, :);
82+
out_events = fullfile(out_eeg_dir, sprintf("%s_task-ThePresent_events.tsv", sub));
83+
writetable(src_events_tbl, out_events, 'FileType', 'text', 'Delimiter', '\t');
84+
fprintf("[fixture] wrote %s with %d in-window event(s)\n", out_events, height(src_events_tbl));
85+
86+
out_eegj = fullfile(out_eeg_dir, sprintf("%s_task-ThePresent_eeg.json", sub));
87+
src_eegj_struct = jsondecode(fileread(src_eegj));
88+
src_eegj_struct.RecordingDuration = nd;
89+
fid_j = fopen(out_eegj, 'w');
90+
fprintf(fid_j, '%s', jsonencode(src_eegj_struct, 'PrettyPrint', true));
91+
fclose(fid_j);
92+
fprintf("[fixture] wrote %s (RecordingDuration set to %g)\n", out_eegj, nd);
93+
94+
% Root-level BIDS inheritance: pop_importbids reads task-*_eeg.json at
95+
% the dataset root and merges it with the subject-level sidecar. The
96+
% root file carries Manufacturer / ManufacturersModelName that EEGLAB
97+
% uses to auto-look-up channel locations. Without it, clean_rawdata
98+
% falls into a location-free path that fails at srate < 110 Hz.
99+
for rootSidecar = ["task-ThePresent_eeg.json", "task-ThePresent_events.json"]
100+
srcRoot = fullfile(src, rootSidecar);
101+
if isfile(srcRoot)
102+
copyfile(srcRoot, fullfile(out, rootSidecar));
103+
fprintf("[fixture] copied root-level %s\n", rootSidecar);
104+
end
105+
end
106+
107+
src_ds = fullfile(src, 'dataset_description.json');
108+
if isfile(src_ds)
109+
copyfile(src_ds, fullfile(out, 'dataset_description.json'));
110+
end
111+
112+
src_pj = fullfile(src, 'participants.json');
113+
if isfile(src_pj)
114+
copyfile(src_pj, fullfile(out, 'participants.json'));
115+
end
116+
117+
src_pt = readtable(fullfile(src, 'participants.tsv'), ...
118+
'FileType', 'text', 'Delimiter', '\t');
119+
src_pt = src_pt(strcmp(src_pt.participant_id, char(sub)), :);
120+
writetable(src_pt, fullfile(out, 'participants.tsv'), ...
121+
'FileType', 'text', 'Delimiter', '\t');
122+
fprintf("[fixture] wrote participants.tsv with 1 row\n");
123+
124+
src_readme = fullfile(src, 'README');
125+
if isfile(src_readme); copyfile(src_readme, fullfile(out, 'README')); end
126+
127+
fixture_readme = fullfile(out, 'FIXTURE_README.md');
128+
fid = fopen(fixture_readme, 'w');
129+
fprintf(fid, "# HBN R3 fixture\n\n");
130+
fprintf(fid, "Mini BIDS-EEG dataset derived from the HBN R3 100 Hz local copy.\n");
131+
fprintf(fid, "Used by `tests/matlab/*.m` to run real-data smoke tests in CI\n");
132+
fprintf(fid, "without requiring the full /Volumes mount.\n\n");
133+
fprintf(fid, "## Provenance\n\n");
134+
fprintf(fid, "- Subject: `%s`\n", sub);
135+
fprintf(fid, "- Task: `ThePresent`\n");
136+
fprintf(fid, "- Window: 0 to %g s (from full recording of %.1f s)\n", nd, EEG.xmax);
137+
fprintf(fid, "- Sampling rate: %g Hz\n", cropped.srate);
138+
fprintf(fid, "- Channels: %d (unchanged from source)\n", cropped.nbchan);
139+
fprintf(fid, "- Built from: `%s`\n", src);
140+
fprintf(fid, "- License: CC-BY-SA 4.0 (inherited from HBN-EEG; see `dataset_description.json`).\n\n");
141+
fprintf(fid, "## Reproduce\n\n");
142+
fprintf(fid, "```bash\n");
143+
fprintf(fid, 'matlab -batch "run(''scripts/build_test_fixture.m'')"\n');
144+
fprintf(fid, "```\n\n");
145+
fprintf(fid, "## Why this window\n\n");
146+
fprintf(fid, "shot_events.tsv rows that fall in [0, %g) span shots 1..15, which\n", nd);
147+
fprintf(fid, "include boy-only (#2..#5), neither (#7, #14), boy+puppy (#16 sits\n");
148+
fprintf(fid, "exactly at the boundary, dropped), and the first puppy-only shot\n");
149+
fprintf(fid, "(#15 at 55.042 s). Phase 4 epoching has enough material.\n");
150+
fclose(fid);
151+
fprintf("[fixture] wrote %s\n", fixture_readme);
152+
153+
fprintf("[fixture] done.\n");
154+
end

scripts/local_ci.sh

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,22 @@ if [[ -z "$EEGLAB_PATH" || ! -d "$EEGLAB_PATH" ]]; then
6868
echo "ERROR: EEGLAB_PATH must be set to an existing directory (got '$EEGLAB_PATH')." >&2
6969
exit 1
7070
fi
71-
if [[ -z "$BIDS_ROOT" || ! -d "$BIDS_ROOT" ]]; then
72-
# The whole point of local CI is to run on real BDF data; a missing
73-
# dataset would make smoke tests early-return on the no-data branch and
74-
# mask regressions as green. Fail loudly instead.
75-
echo "ERROR: BIDS_ROOT must be set to an existing directory (got '$BIDS_ROOT')." >&2
76-
exit 1
71+
if [[ -z "${BIDS_ROOT:-}" || ! -d "$BIDS_ROOT" ]]; then
72+
# When BIDS_ROOT is unset, fall back to the committed real-EEG fixture
73+
# under tests/fixtures/bids_mini. This is the same fixture CI uses, so
74+
# local and CI runs hit the same code paths on the same data when
75+
# the full /Volumes mount is not available. The fail-loudly rule
76+
# still applies: if neither the env var nor the fixture exists, error.
77+
FIXTURE_ROOT="$REPO_ROOT/tests/fixtures/bids_mini"
78+
if [[ -d "$FIXTURE_ROOT" ]]; then
79+
echo "BIDS_ROOT unset; falling back to fixture at $FIXTURE_ROOT"
80+
BIDS_ROOT="$FIXTURE_ROOT"
81+
else
82+
echo "ERROR: BIDS_ROOT unset and fixture not found at $FIXTURE_ROOT." >&2
83+
exit 1
84+
fi
7785
fi
86+
export HBN_BIDS_ROOT="$BIDS_ROOT"
7887

7988
# 1. Style + lint via miss_hit. Skipping silently was masking missing
8089
# coverage on dev machines without the tool installed; require it unless
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# HBN R3 fixture
2+
3+
Mini BIDS-EEG dataset derived from the HBN R3 100 Hz local copy.
4+
Used by `tests/matlab/*.m` to run real-data smoke tests in CI
5+
without requiring the full /Volumes mount.
6+
7+
## Provenance
8+
9+
- Subject: `sub-NDARAA948VFH`
10+
- Task: `ThePresent`
11+
- Window: 0 to 60 s (from full recording of 206.0 s)
12+
- Sampling rate: 100 Hz
13+
- Channels: 129 (unchanged from source)
14+
- Built from: `/Volumes/S1/Datasets/HBN/L100/R3_L100_bdf`
15+
- License: CC-BY-SA 4.0 (inherited from HBN-EEG; see `dataset_description.json`).
16+
17+
## Reproduce
18+
19+
```bash
20+
matlab -batch "run('scripts/build_test_fixture.m')"
21+
```
22+
23+
## Why this window
24+
25+
shot_events.tsv rows that fall in [0, 60) span shots 1..15, which
26+
include boy-only (#2..#5), neither (#7, #14), boy+puppy (#16 sits
27+
exactly at the boundary, dropped), and the first puppy-only shot
28+
(#15 at 55.042 s). Phase 4 epoching has enough material.

0 commit comments

Comments
 (0)