|
| 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 |
0 commit comments