diff --git a/.gitignore b/.gitignore index 11dab1b..cec67c2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ EEG-BIDS_testcases *.asv *~ *.asv + +CLAUDE.md +.DS_Store +.context/ +.rules/ \ No newline at end of file diff --git a/bids_check_regular_sampling.m b/bids_check_regular_sampling.m new file mode 100644 index 0000000..46f5e50 --- /dev/null +++ b/bids_check_regular_sampling.m @@ -0,0 +1,67 @@ +% BIDS_CHECK_REGULAR_SAMPLING - Check if EEG data has regular sampling +% +% Usage: +% [isRegular, avgFreq] = bids_check_regular_sampling(EEG) +% [isRegular, avgFreq] = bids_check_regular_sampling(EEG, tolerance) +% +% Inputs: +% EEG - [struct] EEGLAB dataset structure +% tolerance - [float] acceptable deviation from regular sampling (default: 0.0001 = 0.01%) +% +% Outputs: +% isRegular - [boolean] true if sampling is regular within tolerance +% avgFreq - [float] average sampling frequency in Hz +% +% Note: +% EDF and BDF formats require perfectly regular sampling. This function +% checks if data has irregular timestamps and calculates the average +% frequency for potential resampling. +% +% Authors: Seyed Yahya Shirazi, 2025 + +function [isRegular, avgFreq] = bids_check_regular_sampling(EEG, tolerance) + +if nargin < 1 + help bids_check_regular_sampling; + return; +end + +if nargin < 2 + tolerance = 0.01; % 1% tolerance for regular sampling detection +end + +if isempty(EEG.data) + error('EEG.data is empty'); +end + +if EEG.trials > 1 + isRegular = true; + avgFreq = EEG.srate; + return; +end + +if isfield(EEG, 'times') && length(EEG.times) > 1 + intervals = diff(EEG.times); + + if length(unique(intervals)) == 1 + isRegular = true; + avgFreq = EEG.srate; + return; + end + + avgInterval = mean(intervals); + maxDeviation = max(abs(intervals - avgInterval)) / avgInterval; + + isRegular = maxDeviation < tolerance; + % Check if times are in seconds (continuous) or milliseconds (epoched) + if EEG.trials == 1 && avgInterval < 1 + % Continuous data: times in seconds + avgFreq = 1 / avgInterval; + else + % Epoched data: times in milliseconds + avgFreq = 1000 / avgInterval; + end +else + isRegular = true; + avgFreq = EEG.srate; +end \ No newline at end of file diff --git a/bids_export.m b/bids_export.m index 3c11b7e..3f576a5 100644 --- a/bids_export.m +++ b/bids_export.m @@ -347,7 +347,7 @@ function bids_export(files, varargin) 'rmtempfiles' 'string' {'on' 'off'} 'on'; 'exportformat' 'string' {'same' 'eeglab' 'edf' 'bdf'} 'eeglab'; 'individualEventsJson' 'string' {'on' 'off'} 'off'; - 'modality' 'string' {'ieeg' 'meg' 'eeg' 'auto'} 'auto'; + 'modality' 'string' {'ieeg' 'meg' 'eeg' 'emg' 'auto'} 'auto'; 'README' 'string' {} ''; 'CHANGES' 'string' {} '' ; 'copydata' 'integer' {} [0 1]; % legacy, does nothing now @@ -371,6 +371,13 @@ function bids_export(files, varargin) opt.SourceDatasets = opt.sourceDatasets; end +if strcmpi(opt.modality, 'emg') + if strcmpi(opt.exportformat, 'eeglab') + opt.exportformat = 'bdf'; + fprintf('EMG data detected: changing export format to bdf\n'); + end +end + % deleting folder % --------------- fprintf('Exporting data to %s...\n', opt.targetdir); @@ -872,6 +879,13 @@ function copy_data_bids_eeg(sIn, subjectStr, sess, fileStr, opt) [pathIn,fileInNoExt,ext] = fileparts(fileIn); fprintf('Processing file %s\n', fileIn); [EEG,opt.modality] = eeg_import(fileIn, 'modality', opt.modality, 'noevents', opt.noevents, 'importfunc', opt.importfunc); + +% Set default export format to EDF for EMG data (after modality is determined) +if strcmpi(opt.modality, 'emg') && strcmpi(opt.exportformat, 'eeglab') + opt.exportformat = 'edf'; + fprintf('Note: EMG data will be exported to EDF format (default for EMG)\n'); +end + if contains(fileIn, '_bids_tmp_') && strcmpi(opt.rmtempfiles, 'on') fprintf('Deleting temporary file %s\n', fileIn); delete(fileIn); @@ -892,7 +906,7 @@ function copy_data_bids_eeg(sIn, subjectStr, sess, fileStr, opt) fileOut = [fileBase '_' opt.modality ext]; % select data subset -EEG = eeg_selectsegment(EEG, 'eventtype', eventtype, 'eventindex', eventindex, 'timeoffset', timeoffset ); +EEG = eeg_selectsegment(EEG, 'eventtype', eventtype, 'eventindex', eventindex, 'timeoffset', timeoffset ); if ~isequal(opt.exportformat, 'same') || isequal(ext, '.set') % export data if necessary @@ -900,7 +914,29 @@ function copy_data_bids_eeg(sIn, subjectStr, sess, fileStr, opt) if isequal(opt.exportformat, 'eeglab') pop_saveset(EEG, 'filename', [ fileOutNoExt '.set' ], 'filepath', filePathTmp); else - pop_writeeeg(EEG, fullfile(filePathTmp, [ fileOutNoExt '.' opt.exportformat]), 'TYPE',upper(opt.exportformat)); + % Check for regular sampling when exporting to EDF/BDF + if strcmpi(opt.exportformat, 'edf') || strcmpi(opt.exportformat, 'bdf') + [isRegular, avgFreq] = bids_check_regular_sampling(EEG); + if ~isRegular + error(['EDF/BDF export requires regular sampling. Your data has irregular sampling (avg %.2f Hz).\n' ... + 'Please resample your data before exporting:\n' ... + ' EEG = pop_resample(EEG, %.0f);\n' ... + 'Then re-run bids_export.'], avgFreq, round(avgFreq)); + end + end + + % For EMG (and other modalities), save without events in EDF + % Events are saved separately in events.tsv + if strcmpi(opt.modality, 'emg') && (strcmpi(opt.exportformat, 'edf') || strcmpi(opt.exportformat, 'bdf')) + % Temporarily remove events before writing EDF + tmpEvents = EEG.event; + EEG.event = []; + pop_writeeeg(EEG, fullfile(filePathTmp, [ fileOutNoExt '.' opt.exportformat]), 'TYPE',upper(opt.exportformat)); + % Restore events for events.tsv export + EEG.event = tmpEvents; + else + pop_writeeeg(EEG, fullfile(filePathTmp, [ fileOutNoExt '.' opt.exportformat]), 'TYPE',upper(opt.exportformat)); + end end else % copy the file @@ -966,18 +1002,30 @@ function copy_data_bids_eeg(sIn, subjectStr, sess, fileStr, opt) EEG=pop_chanedit(EEG, 'lookup', opt.chanlookup); end +% Set datatype before writing channel/electrode files +if strcmpi(opt.modality, 'eeg') + EEG.etc.datatype = 'eeg'; +elseif strcmpi(opt.modality, 'ieeg') + EEG.etc.datatype = 'ieeg'; +elseif strcmpi(opt.modality, 'meg') + EEG.etc.datatype = 'meg'; +elseif strcmpi(opt.modality, 'emg') + EEG.etc.datatype = 'emg'; +end + % Write electrode file information (electrodes.tsv and coordsystem.json) bids_writechanfile(EEG, fileOutRed); -bids_writeelectrodefile(EEG, fileOutRed, 'export', opt.elecexport); +bids_writeelectrodefile(EEG, fileOutRed, 'export', opt.elecexport, 'rootdir', opt.targetdir); + +% Write modality-specific info files if strcmpi(opt.modality, 'eeg') bids_writetinfofile(EEG, tInfo, notes, fileOutRed); - EEG.etc.datatype = 'eeg'; elseif strcmpi(opt.modality, 'ieeg') bids_writeieegtinfofile(EEG, tInfo, notes, fileOutRed); - EEG.etc.datatype = 'ieeg'; elseif strcmpi(opt.modality, 'meg') bids_writemegtinfofile(EEG, tInfo, notes, fileOutRed); - EEG.etc.datatype = 'meg'; +elseif strcmpi(opt.modality, 'emg') + bids_writeemgtinfofile(EEG, tInfo, notes, fileOutRed); end % write channel information diff --git a/bids_getinfofromfolder.m b/bids_getinfofromfolder.m index f630ea7..ef72fc5 100644 --- a/bids_getinfofromfolder.m +++ b/bids_getinfofromfolder.m @@ -27,11 +27,12 @@ % along with this program; if not, to the Free Software % Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -function [tasklist,sessions,runs] = bids_getinfofromfolder(bidsFolder) +function [tasklist,sessions,runs,recordings] = bids_getinfofromfolder(bidsFolder) tasklist = {}; sessions = {}; runs = {}; +recordings = {}; files = dir(bidsFolder); [files(:).folder] = deal(bidsFolder); %fprintf('Scanning %s\n', bidsFolder); @@ -41,10 +42,11 @@ sessions = union(sessions, { files(iFile).name }); end - [tasklistTmp,sessionTmp,runsTmp] = bids_getinfofromfolder(fullfile(files(iFile).folder, fullfile(files(iFile).name))); + [tasklistTmp,sessionTmp,runsTmp,recordingsTmp] = bids_getinfofromfolder(fullfile(files(iFile).folder, fullfile(files(iFile).name))); tasklist = union(tasklist, tasklistTmp); sessions = union(sessions, sessionTmp); runs = union(runs , runsTmp); + recordings = union(recordings, recordingsTmp); else if (~isempty(strfind(files(iFile).name, 'eeg')) || ~isempty(strfind(files(iFile).name, 'meg'))) && ~isempty(strfind(files(iFile).name, '_task')) pos = strfind(files(iFile).name, '_task'); @@ -53,12 +55,29 @@ newTask = tmpStr(1:underS(1)-1); tasklist = union( tasklist, { newTask }); end - if (~isempty(strfind(files(iFile).name, 'eeg')) || ~isempty(strfind(files(iFile).name, 'meg'))) && ~isempty(strfind(files(iFile).name, '_run')) + if (~isempty(strfind(files(iFile).name, 'eeg')) || ~isempty(strfind(files(iFile).name, 'meg')) || ~isempty(strfind(files(iFile).name, 'emg'))) && ~isempty(strfind(files(iFile).name, '_run')) pos = strfind(files(iFile).name, '_run'); tmpStr = files(iFile).name(pos+5:end); underS = find(tmpStr == '_'); newRun = tmpStr(1:underS(1)-1); runs = union( runs, { newRun } ); end + if (~isempty(strfind(files(iFile).name, 'eeg')) || ~isempty(strfind(files(iFile).name, 'meg')) || ~isempty(strfind(files(iFile).name, 'emg')) || ~isempty(strfind(files(iFile).name, 'ieeg'))) && ~isempty(strfind(files(iFile).name, '_recording-')) + pos = strfind(files(iFile).name, '_recording-'); + tmpStr = files(iFile).name(pos+11:end); + underS = find(tmpStr == '_'); + if ~isempty(underS) + newRecording = tmpStr(1:underS(1)-1); + else + % recording is last entity before extension + dotS = find(tmpStr == '.'); + if ~isempty(dotS) + newRecording = tmpStr(1:dotS(1)-1); + else + newRecording = tmpStr; + end + end + recordings = union( recordings, { newRecording } ); + end end end diff --git a/bids_importchanlocs.m b/bids_importchanlocs.m index d66b48e..79d5326 100644 --- a/bids_importchanlocs.m +++ b/bids_importchanlocs.m @@ -55,6 +55,10 @@ chanlocs(iChan-1).X = elecData{iChan,2}; chanlocs(iChan-1).Y = elecData{iChan,3}; chanlocs(iChan-1).Z = elecData{iChan,4}; + % Import coordinate_system (5th column) if present (EMG) + if size(elecData,2) >= 5 && ~isempty(elecData{iChan,5}) && ~strcmpi(elecData{iChan,5}, 'n/a') + chanlocs(iChan-1).coordinate_system = elecData{iChan,5}; + end end end diff --git a/bids_importcoordsystemfile.m b/bids_importcoordsystemfile.m index 28075cd..c79b0d1 100644 --- a/bids_importcoordsystemfile.m +++ b/bids_importcoordsystemfile.m @@ -5,17 +5,19 @@ % % Inputs: % 'EEG' - [struct] the EEG structure to which event information will be imported -% coordfile - [string] path to the coordsystem.json file. -% e.g. ~/BIDS_EXPORT/sub-01/ses-01/eeg/sub-01_ses-01_task-GoNogo_coordsystem.json +% coordfile - [string or cell array] path(s) to coordsystem.json file(s) +% Single: ~/BIDS/sub-01/emg/sub-01_coordsystem.json +% Multiple: {~/BIDS/sub-01/emg/sub-01_space-hand_coordsystem.json, ...} % % Optional inputs: % 'bids' - [struct] structure that saves imported BIDS information. Default is [] % % Outputs: -% EEG - [struct] the EEG structure with event info imported -% bids - [struct] structure that saves BIDS information with event information +% EEG - [struct] the EEG structure with coordinate info imported +% bids - [struct] structure that saves BIDS information with coordinate information % % Authors: Arnaud Delorme, 2022 +% Yahya Alwabari, 2025 (multiple coordinate systems support) function [EEG, bids] = bids_importcoordsystemfile(EEG, coordfile, varargin) @@ -28,68 +30,129 @@ if isstr(g), error(g); end bids = g.bids; - -% coordinate information -bids(1).coordsystem = bids_importjson(coordfile, '_coordsystem.json'); %bids_loadfile( coordfile, ''); + +% Handle empty coordfile +if isempty(coordfile) + return; +end + +% Convert single file to cell array for uniform processing +if ischar(coordfile) + coordfile = {coordfile}; +end + +% Initialize coordsystems storage if ~isfield(EEG.chaninfo, 'nodatchans') EEG.chaninfo.nodatchans = []; end -EEG.chaninfo.BIDS = bids(1).coordsystem; - -% import anatomical landmark -% -------------------------- -if isfield(bids.coordsystem, 'AnatomicalLandmarkCoordinates') && ~isempty(bids.coordsystem.AnatomicalLandmarkCoordinates) - factor = checkunit(EEG.chaninfo, 'AnatomicalLandmarkCoordinateUnits'); - fieldNames = fieldnames(bids.coordsystem.AnatomicalLandmarkCoordinates); - for iField = 1:length(fieldNames) - EEG.chaninfo.nodatchans(end+1).labels = fieldNames{iField}; - EEG.chaninfo.nodatchans(end).type = 'FID'; - EEG.chaninfo.nodatchans(end).X = bids.coordsystem.AnatomicalLandmarkCoordinates.(fieldNames{iField})(1)*factor; - EEG.chaninfo.nodatchans(end).Y = bids.coordsystem.AnatomicalLandmarkCoordinates.(fieldNames{iField})(2)*factor; - EEG.chaninfo.nodatchans(end).Z = bids.coordsystem.AnatomicalLandmarkCoordinates.(fieldNames{iField})(3)*factor; + +% Process all coordsystem files +coordsystems = {}; +for iCoord = 1:length(coordfile) + if isempty(coordfile{iCoord}) + continue; end - EEG.chaninfo.nodatchans = convertlocs(EEG.chaninfo.nodatchans); -end -% import head position -% -------------------- -if isfield(bids.coordsystem, 'DigitizedHeadPoints') && ~isempty(bids.coordsystem.DigitizedHeadPoints) - factor = checkunit(EEG.chaninfo, 'DigitizedHeadPointsCoordinateUnits'); - try - headpos = readlocs(bids.coordsystem.DigitizedHeadPoints, 'filetype', 'sfp'); - for iPoint = 1:length(headpos) - EEG.chaninfo.nodatchans(end+1).labels = headpos{iField}; - EEG.chaninfo.nodatchans(end).type = 'HeadPoint'; - EEG.chaninfo.nodatchans(end).X = headpos(iPoint).X*factor; - EEG.chaninfo.nodatchans(end).Y = headpos(iPoint).Y*factor; - EEG.chaninfo.nodatchans(end).Z = headpos(iPoint).Z*factor; + % Load coordsystem JSON + coordData = bids_importjson(coordfile{iCoord}, '_coordsystem.json'); + + % Parse space entity from filename + % Handles both subject-level and root-level (inheritance) patterns: + % - Subject: sub-01_space-hand_coordsystem.json + % - Root: space-leftForearm_coordsystem.json + [~, filename, ~] = fileparts(coordfile{iCoord}); + spaceLabel = ''; + + % Try with prefix underscore first (subject-level) + spaceMatch = regexp(filename, '_space-([a-zA-Z0-9]+)_', 'tokens'); + if ~isempty(spaceMatch) + spaceLabel = spaceMatch{1}{1}; + else + % Try without prefix underscore (root-level, BIDS inheritance) + spaceMatch = regexp(filename, '^space-([a-zA-Z0-9]+)_', 'tokens'); + if ~isempty(spaceMatch) + spaceLabel = spaceMatch{1}{1}; + end + end + + % Add space label to coordData + coordData.space = spaceLabel; + + % Store in coordsystems cell array + coordsystems{end+1} = coordData; + + % Import anatomical landmarks (only for first coordsystem to avoid duplicates) + if iCoord == 1 && isfield(coordData, 'AnatomicalLandmarkCoordinates') && ~isempty(coordData.AnatomicalLandmarkCoordinates) + factor = checkunit(EEG.chaninfo, coordData, 'AnatomicalLandmarkCoordinateUnits'); + fieldNames = fieldnames(coordData.AnatomicalLandmarkCoordinates); + for iField = 1:length(fieldNames) + EEG.chaninfo.nodatchans(end+1).labels = fieldNames{iField}; + EEG.chaninfo.nodatchans(end).type = 'FID'; + EEG.chaninfo.nodatchans(end).X = coordData.AnatomicalLandmarkCoordinates.(fieldNames{iField})(1)*factor; + EEG.chaninfo.nodatchans(end).Y = coordData.AnatomicalLandmarkCoordinates.(fieldNames{iField})(2)*factor; + EEG.chaninfo.nodatchans(end).Z = coordData.AnatomicalLandmarkCoordinates.(fieldNames{iField})(3)*factor; end EEG.chaninfo.nodatchans = convertlocs(EEG.chaninfo.nodatchans); - catch - if ischar(bids.coordsystem.DigitizedHeadPoints) - fprintf('Could not read head points file %s\n', bids.coordsystem.DigitizedHeadPoints); + end + + % Import head position (only for first coordsystem) + if iCoord == 1 && isfield(coordData, 'DigitizedHeadPoints') && ~isempty(coordData.DigitizedHeadPoints) + factor = checkunit(EEG.chaninfo, coordData, 'DigitizedHeadPointsCoordinateUnits'); + try + headpos = readlocs(coordData.DigitizedHeadPoints, 'filetype', 'sfp'); + for iPoint = 1:length(headpos) + EEG.chaninfo.nodatchans(end+1).labels = headpos(iPoint).labels; + EEG.chaninfo.nodatchans(end).type = 'HeadPoint'; + EEG.chaninfo.nodatchans(end).X = headpos(iPoint).X*factor; + EEG.chaninfo.nodatchans(end).Y = headpos(iPoint).Y*factor; + EEG.chaninfo.nodatchans(end).Z = headpos(iPoint).Z*factor; + end + EEG.chaninfo.nodatchans = convertlocs(EEG.chaninfo.nodatchans); + catch + if ischar(coordData.DigitizedHeadPoints) + fprintf('Could not read head points file %s\n', coordData.DigitizedHeadPoints); + end end end end +% Store coordsystems in EEG structure +if length(coordsystems) == 1 && isempty(coordsystems{1}.space) + % Single coordsystem without space - backward compatibility + % Store directly in EEG.chaninfo.BIDS + coordsystems{1} = rmfield(coordsystems{1}, 'space'); + EEG.chaninfo.BIDS = coordsystems{1}; + bids(1).coordsystem = coordsystems{1}; +elseif length(coordsystems) >= 1 + % Multiple coordsystems or single with space entity + % Store as cell array + EEG.chaninfo.BIDS.coordsystems = coordsystems; + bids(1).coordsystems = coordsystems; + + % Also store first one directly for backward compat + if ~isempty(coordsystems) + bids(1).coordsystem = coordsystems{1}; + end +end + % coordinate transform factor % --------------------------- -function factor = checkunit(chaninfo, field) +function factor = checkunit(chaninfo, coordData, field) factor = 1; - if isfield(chaninfo, 'BIDS') && isfield(chaninfo.BIDS, field) && isfield(chaninfo, 'unit') - if isequal(chaninfo.BIDS.(field), 'mm') && isequal(chaninfo.unit, 'cm') + if isfield(coordData, field) && isfield(chaninfo, 'unit') + if isequal(coordData.(field), 'mm') && isequal(chaninfo.unit, 'cm') factor = 1/10; - elseif isequal(chaninfo.BIDS.(field), 'mm') && isequal(chaninfo.unit, 'm') + elseif isequal(coordData.(field), 'mm') && isequal(chaninfo.unit, 'm') factor = 1/1000; - elseif isequal(chaninfo.BIDS.(field), 'cm') && isequal(chaninfo.unit, 'mm') + elseif isequal(coordData.(field), 'cm') && isequal(chaninfo.unit, 'mm') factor = 10; - elseif isequal(chaninfo.BIDS.(field), 'cm') && isequal(chaninfo.unit, 'm') + elseif isequal(coordData.(field), 'cm') && isequal(chaninfo.unit, 'm') factor = 1/10; - elseif isequal(chaninfo.BIDS.(field), 'm') && isequal(chaninfo.unit, 'cm') + elseif isequal(coordData.(field), 'm') && isequal(chaninfo.unit, 'cm') factor = 100; - elseif isequal(chaninfo.BIDS.(field), 'm') && isequal(chaninfo.unit, 'mm') + elseif isequal(coordData.(field), 'm') && isequal(chaninfo.unit, 'mm') factor = 1000; - elseif isequal(chaninfo.BIDS.(field), chaninfo.unit) + elseif isequal(coordData.(field), chaninfo.unit) factor = 1; else error('Unit not supported') diff --git a/bids_writechanfile.m b/bids_writechanfile.m index 0d10e1a..a886953 100644 --- a/bids_writechanfile.m +++ b/bids_writechanfile.m @@ -21,27 +21,85 @@ function bids_writechanfile(EEG, fileOut) fid = fopen( [ fileOut '_channels.tsv' ], 'w'); + +isEMG = isfield(EEG, 'etc') && isfield(EEG.etc, 'datatype') && strcmpi(EEG.etc.datatype, 'emg'); +isiEEG = isfield(EEG, 'etc') && isfield(EEG.etc, 'datatype') && strcmpi(EEG.etc.datatype, 'ieeg'); + if isempty(EEG.chanlocs) - if contains(fileOut, 'ieeg') + if isiEEG fprintf(fid, 'name\ttype\tunits\tlow_cutoff\thigh_cutoff\n'); for iChan = 1:EEG.nbchan - fprintf(fid, 'E%d\tiEEG\tmicroV\tn/a\tn/a\n', iChan); + fprintf(fid, 'E%d\tiEEG\tmicroV\tn/a\tn/a\n', iChan); + end + elseif isEMG + % EMG - only write REQUIRED columns when no chanlocs + fprintf(fid, 'name\ttype\tunits\n'); + for iChan = 1:EEG.nbchan + fprintf(fid, 'E%d\tEMG\tV\n', iChan); end else fprintf(fid, 'name\ttype\tunits\n'); for iChan = 1:EEG.nbchan - fprintf(fid, 'E%d\tEEG\tmicroV\n', iChan); + fprintf(fid, 'E%d\tEEG\tmicroV\n', iChan); end end channelsCount = struct([]); else - if contains(fileOut, 'ieeg') - fprintf(fid, 'name\ttype\tunits\tlow_cutoff\thigh_cutoff\n'); - else - fprintf(fid, 'name\ttype\tunits\n'); + % Determine which columns to write based on available data + columnsToWrite = {'name', 'type', 'units'}; % REQUIRED + + if isEMG + % Check EMG RECOMMENDED columns for actual data + recommendedFields = {'signal_electrode', 'reference', 'group', 'target_muscle', ... + 'placement_scheme', 'placement_description', 'interelectrode_distance', ... + 'low_cutoff', 'high_cutoff', 'sampling_frequency'}; + + availableFields = {}; + missingFields = {}; + + for iField = 1:length(recommendedFields) + fieldName = recommendedFields{iField}; + hasData = false; + + % Check if any channel has this field with actual data + for iChan = 1:EEG.nbchan + if isfield(EEG.chanlocs(iChan), fieldName) && ... + ~isempty(EEG.chanlocs(iChan).(fieldName)) && ... + ~strcmpi(EEG.chanlocs(iChan).(fieldName), 'n/a') + hasData = true; + break; + end + end + + if hasData + columnsToWrite{end+1} = fieldName; + availableFields{end+1} = fieldName; + else + missingFields{end+1} = fieldName; + end + end + + % Display warning about missing RECOMMENDED columns + if ~isempty(missingFields) + fprintf('Note: The following RECOMMENDED EMG channel columns are not included (no data available): %s\n', ... + strjoin(missingFields, ', ')); + end + elseif isiEEG + % iEEG includes low_cutoff and high_cutoff + columnsToWrite = [columnsToWrite, {'low_cutoff', 'high_cutoff'}]; end + + % Write header + fprintf(fid, '%s\n', strjoin(columnsToWrite, '\t')); + + % Write data acceptedChannelTypes = { 'AUDIO' 'EEG' 'EOG' 'ECG' 'EMG' 'EYEGAZE' 'GSR' 'HEOG' 'MISC' 'PUPIL' 'REF' 'RESP' 'SYSCLOCK' 'TEMP' 'TRIG' 'VEOG' }; for iChan = 1:EEG.nbchan + values = {}; + + % Name (always) + values{end+1} = EEG.chanlocs(iChan).labels; + % Type if ~isfield(EEG.chanlocs, 'type') || isempty(EEG.chanlocs(iChan).type) type = 'n/a'; @@ -50,6 +108,8 @@ function bids_writechanfile(EEG, fileOut) else type = 'MISC'; end + values{end+1} = type; + % Unit if isfield(EEG.chanlocs(iChan), 'unit') unit = EEG.chanlocs(iChan).unit; @@ -60,13 +120,37 @@ function bids_writechanfile(EEG, fileOut) unit = 'n/a'; end end + values{end+1} = unit; - %Write - if contains(fileOut, 'ieeg') - fprintf(fid, '%s\t%s\t%s\tn/a\tn/a\n', EEG.chanlocs(iChan).labels, type, unit); - else - fprintf(fid, '%s\t%s\t%s\n', EEG.chanlocs(iChan).labels, type, unit); + % Additional columns (only those determined to have data) + for iCol = 4:length(columnsToWrite) + fieldName = columnsToWrite{iCol}; + + if isfield(EEG.chanlocs(iChan), fieldName) && ~isempty(EEG.chanlocs(iChan).(fieldName)) + val = EEG.chanlocs(iChan).(fieldName); + if isnumeric(val) + values{end+1} = num2str(val); + else + values{end+1} = val; + end + else + values{end+1} = 'n/a'; + end end + + fprintf(fid, '%s\n', strjoin(values, '\t')); end end fclose(fid); + +% Helper function to get field value or 'n/a' +function value = getfield_or_na(struct, fieldname) +if isfield(struct, fieldname) && ~isempty(struct.(fieldname)) + value = struct.(fieldname); + % Convert numeric to string + if isnumeric(value) + value = num2str(value); + end +else + value = 'n/a'; +end diff --git a/bids_writeelectrodefile.m b/bids_writeelectrodefile.m index a54c780..3a70fa1 100644 --- a/bids_writeelectrodefile.m +++ b/bids_writeelectrodefile.m @@ -10,17 +10,25 @@ % % Optional inputs: % 'Export' - ['on'|'off'|'auto'] +% 'rootdir' - [string] root BIDS directory for space-entity coordsystem files % % Authors: Dung Truong, Arnaud Delorme, 2022 function bids_writeelectrodefile(EEG, fileOut, varargin) -if nargin > 2 - flagExport = varargin{2}; -else - flagExport = 'auto'; +opt = finputcheck(varargin, { ... + 'export' 'string' {'on' 'off' 'auto'} 'auto'; ... + 'rootdir' 'string' {} '' ... + }, 'bids_writeelectrodefile'); +if ischar(opt), error(opt); end + +% Legacy support: if called with old signature +if nargin > 2 && ~ischar(varargin{1}) + opt.export = varargin{2}; end +flagExport = opt.export; + % remove task because a bug in v1.10.0 validator returns an error (MAYBE REMOVE THAT SECTION LATER) ind = strfind(fileOut, 'task-'); if ~isempty(ind) @@ -50,33 +58,273 @@ function bids_writeelectrodefile(EEG, fileOut, varargin) end if any(strcmp(flagExport, {'auto', 'on'})) && ~isempty(EEG.chanlocs) && isfield(EEG.chanlocs, 'X') && any(cellfun(@(x)~isempty(x), { EEG.chanlocs.X })) + % Check if EMG for extended columns + isEMG = isfield(EEG, 'etc') && isfield(EEG.etc, 'datatype') && strcmpi(EEG.etc.datatype, 'emg'); + + % Determine which columns have actual data + columnsToWrite = {'name', 'x', 'y'}; % REQUIRED (z is optional) + + % Check if z has actual data (not just zeros or n/a) + hasZ = false; + for iChan = 1:EEG.nbchan + if isfield(EEG.chanlocs(iChan), 'Z') && ~isempty(EEG.chanlocs(iChan).Z) && ... + ~isnan(EEG.chanlocs(iChan).Z) && EEG.chanlocs(iChan).Z ~= 0 + hasZ = true; + break; + end + end + if hasZ + columnsToWrite{end+1} = 'z'; + end + if isEMG + % Check EMG RECOMMENDED columns + recommendedFields = { + 'coordinate_system', 'coordinate_system'; + 'electrode_type', 'type'; + 'electrode_material', 'material'; + 'impedance', 'impedance'; + 'group', 'group' + }; + + availableFields = {}; + missingFields = {}; + + for iField = 1:size(recommendedFields, 1) + fieldName = recommendedFields{iField, 1}; + colName = recommendedFields{iField, 2}; + hasData = false; + + % Check if any electrode has this field with actual data + for iChan = 1:EEG.nbchan + if isfield(EEG.chanlocs(iChan), fieldName) && ... + ~isempty(EEG.chanlocs(iChan).(fieldName)) && ... + ~strcmpi(EEG.chanlocs(iChan).(fieldName), 'n/a') + hasData = true; + break; + end + end + + if hasData + columnsToWrite{end+1} = colName; + availableFields{end+1} = colName; + else + missingFields{end+1} = colName; + end + end + + % Display warning about missing RECOMMENDED columns + if ~isempty(missingFields) + fprintf('Note: The following RECOMMENDED EMG electrode columns are not included (no data available): %s\n', ... + strjoin(missingFields, ', ')); + end + end + + % Write TSV file with determined columns fid = fopen( [ fileOut '_electrodes.tsv' ], 'w'); - fprintf(fid, 'name\tx\ty\tz\n'); - + fprintf(fid, '%s\n', strjoin(columnsToWrite, '\t')); + for iChan = 1:EEG.nbchan + values = {}; + + % Name (always) + % For EMG: use signal_electrode if available, otherwise use channel label + if isEMG && isfield(EEG.chanlocs(iChan), 'signal_electrode') && ~isempty(EEG.chanlocs(iChan).signal_electrode) + values{end+1} = EEG.chanlocs(iChan).signal_electrode; + else + values{end+1} = EEG.chanlocs(iChan).labels; + end + + % X, Y if isempty(EEG.chanlocs(iChan).X) || isnan(EEG.chanlocs(iChan).X) || contains(fileOut, 'ieeg') - fprintf(fid, '%s\tn/a\tn/a\tn/a\n', EEG.chanlocs(iChan).labels ); + values{end+1} = 'n/a'; + values{end+1} = 'n/a'; else - fprintf(fid, '%s\t%2.6f\t%2.6f\t%2.6f\n', EEG.chanlocs(iChan).labels, EEG.chanlocs(iChan).X, EEG.chanlocs(iChan).Y, EEG.chanlocs(iChan).Z ); + % Determine decimal precision based on coordinate units + if isEMG && isfield(EEG.chaninfo, 'BIDS') && isfield(EEG.chaninfo.BIDS, 'EMGCoordinateUnits') + units = EEG.chaninfo.BIDS.EMGCoordinateUnits; + if strcmpi(units, 'percent') + fmt = '%.0f'; % No decimals for percent + elseif strcmpi(units, 'ratio') + fmt = '%.2f'; % Max 2 decimals for ratio + else + fmt = '%2.6f'; % Default: 6 decimals for mm, etc. + end + else + fmt = '%2.6f'; % Default + end + values{end+1} = sprintf(fmt, EEG.chanlocs(iChan).X); + values{end+1} = sprintf(fmt, EEG.chanlocs(iChan).Y); end + + % Z (only if column exists) + if ismember('z', columnsToWrite) + if isempty(EEG.chanlocs(iChan).X) || isnan(EEG.chanlocs(iChan).X) || contains(fileOut, 'ieeg') + values{end+1} = 'n/a'; + else + % Use same format as X/Y + if isEMG && isfield(EEG.chaninfo, 'BIDS') && isfield(EEG.chaninfo.BIDS, 'EMGCoordinateUnits') + units = EEG.chaninfo.BIDS.EMGCoordinateUnits; + if strcmpi(units, 'percent') + fmt = '%.0f'; + elseif strcmpi(units, 'ratio') + fmt = '%.2f'; + else + fmt = '%2.6f'; + end + else + fmt = '%2.6f'; + end + values{end+1} = sprintf(fmt, EEG.chanlocs(iChan).Z); + end + end + + % Find starting column for additional fields + startCol = 3; % name, x, y + if ismember('z', columnsToWrite) + startCol = 4; + end + + % Additional EMG columns (only those with data) + if isEMG + for iCol = (startCol+1):length(columnsToWrite) + colName = columnsToWrite{iCol}; + + % Map column name to field name + switch colName + case 'coordinate_system' + fieldName = 'coordinate_system'; + case 'type' + fieldName = 'electrode_type'; + case 'material' + fieldName = 'electrode_material'; + case 'impedance' + fieldName = 'impedance'; + case 'group' + fieldName = 'group'; + end + + % Get value or 'n/a' + if isfield(EEG.chanlocs(iChan), fieldName) && ... + ~isempty(EEG.chanlocs(iChan).(fieldName)) + val = EEG.chanlocs(iChan).(fieldName); + if isnumeric(val) + values{end+1} = num2str(val); + else + values{end+1} = val; + end + else + values{end+1} = 'n/a'; + end + end + end + + fprintf(fid, '%s\n', strjoin(values, '\t')); end fclose(fid); - + % Write coordinate file information (coordsystem.json) - if isfield(EEG.chaninfo, 'BIDS') && isfield(EEG.chaninfo.BIDS, 'EEGCoordinateUnits') - coordsystemStruct.EEGCoordinateUnits = EEG.chaninfo.BIDS.EEGCoordinateUnits; - else - coordsystemStruct.EEGCoordinateUnits = 'mm'; - end - if isfield(EEG.chaninfo, 'BIDS') &&isfield(EEG.chaninfo.BIDS, 'EEGCoordinateSystem') - coordsystemStruct.EEGCoordinateSystem = EEG.chaninfo.BIDS.EEGCoordinateSystem; + % Supports both single and multiple coordinate systems + isEMG = isfield(EEG, 'etc') && isfield(EEG.etc, 'datatype') && strcmpi(EEG.etc.datatype, 'emg'); + + % Check for multiple coordinate systems + if isfield(EEG.chaninfo, 'BIDS') && isfield(EEG.chaninfo.BIDS, 'coordsystems') + % Multiple coordinate systems (with space entities) + coordsystems = EEG.chaninfo.BIDS.coordsystems; + + % Validate parent references for nested coordinate systems + spaceLabels = cellfun(@(x) x.space, coordsystems, 'UniformOutput', false); + for iCoord = 1:length(coordsystems) + cs = coordsystems{iCoord}; + if isfield(cs, 'ParentCoordinateSystem') && ~isempty(cs.ParentCoordinateSystem) + if ~ismember(cs.ParentCoordinateSystem, spaceLabels) + error('Invalid parent coordinate system "%s" for space "%s". Parent must exist.', ... + cs.ParentCoordinateSystem, cs.space); + end + end + end + + % Write each coordinate system as separate file + for iCoord = 1:length(coordsystems) + cs = coordsystems{iCoord}; + coordStruct = struct(); + + % Copy all fields except 'space' + fields = fieldnames(cs); + for iField = 1:length(fields) + if ~strcmpi(fields{iField}, 'space') + coordStruct.(fields{iField}) = cs.(fields{iField}); + end + end + + % Write with space entity in filename + if ~isempty(cs.space) + % Space-entity coordsystem files go at root if rootdir is provided + if ~isempty(opt.rootdir) + filename = fullfile(opt.rootdir, sprintf('space-%s_coordsystem.json', cs.space)); + else + filename = sprintf('%s_space-%s_coordsystem.json', fileOut, cs.space); + end + else + filename = sprintf('%s_coordsystem.json', fileOut); + end + jsonwrite(filename, coordStruct, struct('indent',' ')); + end else - coordsystemStruct.EEGCoordinateSystem = 'CTF'; + % Single coordinate system (backward compatibility) + coordsystemStruct = struct(); + + if isEMG + if isfield(EEG.chaninfo, 'BIDS') && isfield(EEG.chaninfo.BIDS, 'EMGCoordinateUnits') + coordsystemStruct.EMGCoordinateUnits = EEG.chaninfo.BIDS.EMGCoordinateUnits; + else + coordsystemStruct.EMGCoordinateUnits = 'mm'; + end + if isfield(EEG.chaninfo, 'BIDS') && isfield(EEG.chaninfo.BIDS, 'EMGCoordinateSystem') + coordsystemStruct.EMGCoordinateSystem = EEG.chaninfo.BIDS.EMGCoordinateSystem; + else + coordsystemStruct.EMGCoordinateSystem = 'Other'; + end + if isfield(EEG.chaninfo, 'BIDS') && isfield(EEG.chaninfo.BIDS, 'EMGCoordinateSystemDescription') + coordsystemStruct.EMGCoordinateSystemDescription = EEG.chaninfo.BIDS.EMGCoordinateSystemDescription; + else + coordsystemStruct.EMGCoordinateSystemDescription = 'Electrode locations in mm'; + end + else + if isfield(EEG.chaninfo, 'BIDS') && isfield(EEG.chaninfo.BIDS, 'EEGCoordinateUnits') + coordsystemStruct.EEGCoordinateUnits = EEG.chaninfo.BIDS.EEGCoordinateUnits; + else + coordsystemStruct.EEGCoordinateUnits = 'mm'; + end + if isfield(EEG.chaninfo, 'BIDS') &&isfield(EEG.chaninfo.BIDS, 'EEGCoordinateSystem') + coordsystemStruct.EEGCoordinateSystem = EEG.chaninfo.BIDS.EEGCoordinateSystem; + else + coordsystemStruct.EEGCoordinateSystem = 'CTF'; + end + if isfield(EEG.chaninfo, 'BIDS') &&isfield(EEG.chaninfo.BIDS, 'EEGCoordinateSystemDescription') + coordsystemStruct.EEGCoordinateSystemDescription = EEG.chaninfo.BIDS.EEGCoordinateSystemDescription; + else + coordsystemStruct.EEGCoordinateSystemDescription = 'EEGLAB'; + end + end + % Write coordsystem file + % For EMG with single coordinate system, write at root if rootdir provided + if isEMG && ~isempty(opt.rootdir) + filename = fullfile(opt.rootdir, 'coordsystem.json'); + else + filename = [fileOut '_coordsystem.json']; + end + jsonwrite(filename, coordsystemStruct, struct('indent',' ')); end - if isfield(EEG.chaninfo, 'BIDS') &&isfield(EEG.chaninfo.BIDS, 'EEGCoordinateSystemDescription') - coordsystemStruct.EEGCoordinateSystemDescription = EEG.chaninfo.BIDS.EEGCoordinateSystemDescription; - else - coordsystemStruct.EEGCoordinateSystemDescription = 'EEGLAB'; +end + +% Helper function to get field value or 'n/a' for electrode fields +function value = getfield_or_na_elec(struct, fieldname) +if isfield(struct, fieldname) && ~isempty(struct.(fieldname)) + value = struct.(fieldname); + % Convert numeric to string + if isnumeric(value) + value = num2str(value); end - jsonwrite( [ fileOut '_coordsystem.json' ], coordsystemStruct); +else + value = 'n/a'; end diff --git a/bids_writeemgtinfofile.m b/bids_writeemgtinfofile.m new file mode 100644 index 0000000..f3b1729 --- /dev/null +++ b/bids_writeemgtinfofile.m @@ -0,0 +1,97 @@ +% BIDS_WRITEEMGTINFOFILE - write tinfo file for EMG data +% +% Usage: +% bids_writeemgtinfofile(EEG, tinfo, notes, fileOut) +% +% Inputs: +% EEG - [struct] EEGLAB dataset information +% tinfo - [struct] structure containing task information +% notes - [string] notes to store along with the data info +% fileOut - [string] filepath of the desired output location with file basename +% e.g. ~/BIDS_EXPORT/sub-01/ses-01/emg/sub-01_ses-01_task-holdWeight +% +% Authors: Seyed Yahya Shirazi, 2025 + +function tInfo = bids_writeemgtinfofile(EEG, tInfo, notes, fileOutRed) + +[~,channelsCount] = eeg_getchantype(EEG); + +nonEmptyChannelTypes = fieldnames(channelsCount); +for i=1:numel(nonEmptyChannelTypes) + if strcmp(nonEmptyChannelTypes{i}, 'MISC') + tInfo.('MiscChannelCount') = channelsCount.('MISC'); + else + tInfo.([nonEmptyChannelTypes{i} 'ChannelCount']) = channelsCount.(nonEmptyChannelTypes{i}); + end +end + +if ~isfield(tInfo, 'EMGReference') + if ~ischar(EEG.ref) && numel(EEG.ref) > 1 + refChanLocs = EEG.chanlocs(EEG.ref); + ref = join({refChanLocs.labels},','); + ref = ref{1}; + else + ref = EEG.ref; + end + tInfo.EMGReference = ref; +end + +if EEG.trials == 1 + tInfo.RecordingType = 'continuous'; + tInfo.RecordingDuration = EEG.pnts/EEG.srate; +else + tInfo.RecordingType = 'epoched'; + tInfo.EpochLength = EEG.pnts/EEG.srate; + tInfo.RecordingDuration = (EEG.pnts/EEG.srate)*EEG.trials; +end + +tInfo.SamplingFrequency = EEG.srate; + +if ~isempty(notes) + tInfo.SubjectArtefactDescription = notes; +end + +tInfoFields = {... + 'TaskName' 'REQUIRED' 'char' ''; + 'TaskDescription' 'RECOMMENDED' 'char' ''; + 'Instructions' 'RECOMMENDED' 'char' ''; + 'CogAtlasID' 'RECOMMENDED' 'char' ''; + 'CogPOID' 'RECOMMENDED' 'char' ''; + 'InstitutionName' 'RECOMMENDED' 'char' ''; + 'InstitutionAddress' 'RECOMMENDED' 'char' ''; + 'InstitutionalDepartmentName' 'RECOMMENDED' 'char' ''; + 'DeviceSerialNumber' 'RECOMMENDED' 'char' ''; + 'SamplingFrequency' 'REQUIRED' '' ''; + 'EMGChannelCount' 'RECOMMENDED' '' ''; + 'EOGChannelCount' 'RECOMMENDED' '' 0; + 'ECGChannelCount' 'RECOMMENDED' '' 0; + 'EMGReference' 'REQUIRED' 'char' 'Unknown'; + 'EMGGround' 'RECOMMENDED' 'char' ''; + 'EMGPlacementScheme' 'REQUIRED' 'char' 'Other'; + 'EMGPlacementSchemeDescription' 'RECOMMENDED' 'char' ''; + 'PowerLineFrequency' 'REQUIRED' '' 'n/a'; + 'MiscChannelCount' 'OPTIONAL' '' ''; + 'TriggerChannelCount' 'RECOMMENDED' '' ''; + 'Manufacturer' 'RECOMMENDED' 'char' ''; + 'ManufacturersModelName' 'OPTIONAL' 'char' ''; + 'ElectrodeManufacturer' 'RECOMMENDED' 'char' ''; + 'ElectrodeManufacturersModelName' 'RECOMMENDED' 'char' ''; + 'ElectrodeType' 'RECOMMENDED' 'char' ''; + 'ElectrodeMaterial' 'RECOMMENDED' 'char' ''; + 'InterelectrodeDistance' 'RECOMMENDED' '' ''; + 'HardwareFilters' 'OPTIONAL' 'struct' 'n/a'; + 'SoftwareFilters' 'REQUIRED' 'struct' 'n/a'; + 'RecordingDuration' 'RECOMMENDED' '' 'n/a'; + 'RecordingType' 'RECOMMENDED' 'char' ''; + 'EpochLength' 'RECOMMENDED' '' 'n/a'; + 'SoftwareVersions' 'RECOMMENDED' 'char' ''; + 'SubjectArtefactDescription' 'OPTIONAL' 'char' ''; + 'SkinPreparation' 'OPTIONAL' 'char' ''}; + +tInfo = bids_checkfields(tInfo, tInfoFields, 'tInfo'); + +if any(contains(tInfo.TaskName, '_')) || any(contains(tInfo.TaskName, ' ')) + error('Task name cannot contain underscore or space character(s)'); +end + +jsonwrite([fileOutRed '_emg.json'], tInfo, struct('indent',' ')); \ No newline at end of file diff --git a/eeg_import.m b/eeg_import.m index 50238ed..4ba02ee 100644 --- a/eeg_import.m +++ b/eeg_import.m @@ -74,7 +74,7 @@ 'ctffunc' 'string' { 'fileio' 'ctfimport' } 'fileio'; ... 'importfunc' '' {} ''; 'importfunc' '' {} ''; - 'modality' 'string' {'ieeg' 'meg' 'eeg' 'auto'} 'auto'; + 'modality' 'string' {'ieeg' 'meg' 'eeg' 'emg' 'auto'} 'auto'; 'noevents' 'string' {'on' 'off'} 'off' }, 'eeg_import'); if isstr(opt), error(opt); end @@ -91,8 +91,15 @@ [fpathin, fname, ext] = fileparts(fileIn); EEG = pop_loadbv(fpathin, [fname ext]); elseif strcmpi(ext, '.set') - if strcmpi(opt.modality, 'auto'), opt.modality = 'eeg'; end EEG = pop_loadset(fileIn); + if strcmpi(opt.modality, 'auto') + % Check EEG.etc.datatype to determine actual modality + if isfield(EEG, 'etc') && isfield(EEG.etc, 'datatype') && ~isempty(EEG.etc.datatype) + opt.modality = lower(EEG.etc.datatype); + else + opt.modality = 'eeg'; + end + end elseif strcmpi(ext, '.cnt') if strcmpi(opt.modality, 'auto'), opt.modality = 'eeg'; end EEG = pop_loadcnt(fileIn, 'dataformat', 'auto'); diff --git a/pop_importbids.m b/pop_importbids.m index 9c2dc25..a3b8105 100644 --- a/pop_importbids.m +++ b/pop_importbids.m @@ -78,7 +78,7 @@ disp('Scanning folders...'); % scan if multiple tasks are present - [tasklist,sessions,runs] = bids_getinfofromfolder(bidsFolder); + [tasklist,sessions,runs,recordings] = bids_getinfofromfolder(bidsFolder); % scan for event fields type_fields = bids_geteventfieldsfromfolder(bidsFolder); indVal = strmatch('value', type_fields); @@ -91,11 +91,12 @@ if isempty(type_fields) type_fields = { 'n/a' }; end if isempty(tasklist) tasklist = { 'n/a' }; end - cb_event = 'set(findobj(gcbf, ''userdata'', ''bidstype''), ''enable'', fastif(get(gcbo, ''value''), ''on'', ''off''));'; - cb_task = 'set(findobj(gcbf, ''userdata'', ''task'' ), ''enable'', fastif(get(gcbo, ''value''), ''on'', ''off''));'; - cb_sess = 'set(findobj(gcbf, ''userdata'', ''sessions''), ''enable'', fastif(get(gcbo, ''value''), ''on'', ''off''));'; - cb_run = 'set(findobj(gcbf, ''userdata'', ''runs'' ), ''enable'', fastif(get(gcbo, ''value''), ''on'', ''off''));'; - cb_subjects = 'set(findobj(gcbf, ''userdata'', ''subjects''), ''enable'', fastif(get(gcbo, ''value''), ''on'', ''off''));'; + cb_event = 'set(findobj(gcbf, ''userdata'', ''bidstype''), ''enable'', fastif(get(gcbo, ''value''), ''on'', ''off''));'; + cb_task = 'set(findobj(gcbf, ''userdata'', ''task'' ), ''enable'', fastif(get(gcbo, ''value''), ''on'', ''off''));'; + cb_sess = 'set(findobj(gcbf, ''userdata'', ''sessions''), ''enable'', fastif(get(gcbo, ''value''), ''on'', ''off''));'; + cb_run = 'set(findobj(gcbf, ''userdata'', ''runs'' ), ''enable'', fastif(get(gcbo, ''value''), ''on'', ''off''));'; + cb_recording = 'set(findobj(gcbf, ''userdata'', ''recordings''), ''enable'', fastif(get(gcbo, ''value''), ''on'', ''off''));'; + cb_subjects = 'set(findobj(gcbf, ''userdata'', ''subjects''), ''enable'', fastif(get(gcbo, ''value''), ''on'', ''off''));'; promptstr = { ... { 'style' 'text' 'string' 'Enter study name (default is BIDS folder name)' } ... { 'style' 'edit' 'string' '' 'tag' 'studyName' } ... @@ -109,6 +110,8 @@ { 'style' 'listbox' 'string' sessions 'tag' 'bidsessionstr' 'max' 2 'value' [] 'userdata' 'sessions' 'enable' 'off' } {} ... { 'style' 'checkbox' 'string' 'Import only the following runs' 'tag' 'bidsruns' 'value' 0 'callback' cb_run } ... { 'style' 'listbox' 'string' runs 'tag' 'bidsrunsstr' 'max' 2 'value' [] 'userdata' 'runs' 'enable' 'off' } {} ... + { 'style' 'checkbox' 'string' 'Import only the following recordings (multi-device)' 'tag' 'bidsrecordings' 'value' 0 'callback' cb_recording } ... + { 'style' 'listbox' 'string' recordings 'tag' 'bidsrecordingsstr' 'max' 2 'value' [] 'userdata' 'recordings' 'enable' 'off' } {} ... { 'style' 'checkbox' 'string' 'Import only the following participant indices' 'tag' 'bidssubjects' 'value' 0 'callback' cb_subjects } ... { 'style' 'edit' 'string' '' 'tag' 'bidssubjectsstr' 'userdata' 'subjects' 'enable' 'off' } {} ... {} ... @@ -116,15 +119,20 @@ { 'style' 'edit' 'string' fullfile(bidsFolder, 'derivatives', 'eeglab') 'tag' 'folder' 'HorizontalAlignment' 'left' } ... { 'style' 'pushbutton' 'string' '...' 'callback' cb_select } ... }; - geometry = {[2 1.5], 1, 1,[1 0.35],[0.6 0.35 0.5],[0.6 0.35 0.5],[0.6 0.35 0.5],[0.6 0.35 0.5],1,[1 2 0.5]}; - geomvert = [1 0.5, 1 1 1 1.5 1.5 1 0.5 1]; - if isempty(runs) + geometry = {[2 1.5], 1, 1,[1 0.35],[0.6 0.35 0.5],[0.6 0.35 0.5],[0.6 0.35 0.5],[0.6 0.35 0.5],[0.6 0.35 0.5],1,[1 2 0.5]}; + geomvert = [1 0.5, 1 1 1 1.5 1.5 1.5 1 0.5 1]; + if isempty(recordings) promptstr(13:15) = []; + geometry(8) = []; + geomvert(8) = []; + end + if isempty(runs) + promptstr(10:12) = []; geometry(7) = []; geomvert(7) = []; end if isempty(sessions) - promptstr(10:12) = []; + promptstr(7:9) = []; geometry(6) = []; geomvert(6) = []; end @@ -142,10 +150,13 @@ if res.bidssessions && ~isempty(res.bidsessionstr), options = { options{:} 'sessions' sessions(res.bidsessionstr) }; end end if isfield(res, 'bidsruns') - if res.bidsruns && ~isempty(res.bidsruns), options = { options{:} 'runs' str2double(runs(res.bidsrunsstr)) }; end + if res.bidsruns && ~isempty(res.bidsrunsstr), options = { options{:} 'runs' str2double(runs(res.bidsrunsstr)) }; end + end + if isfield(res, 'bidsrecordings') + if res.bidsrecordings && ~isempty(res.bidsrecordingsstr), options = { options{:} 'recordings' recordings(res.bidsrecordingsstr) }; end end if isfield(res, 'bidssubjects') - if res.bidssubjects && ~isempty(res.bidssubjects), options = { options{:} 'subjects' str2double(res.bidssubjectsstr) }; end + if res.bidssubjects && ~isempty(res.bidssubjectsstr), options = { options{:} 'subjects' str2double(res.bidssubjectsstr) }; end end else options = varargin; @@ -160,6 +171,7 @@ 'subjects' {'cell' 'integer'} {{},[]} []; ... 'sessions' 'cell' {} {}; ... 'runs' {'cell' 'integer'} {{},[]} []; ... + 'recordings' 'cell' {} {}; ... 'metadata' 'string' { 'on' 'off' } 'off'; ... 'ctffunc' 'string' { 'fileio' 'ctfimport' } 'fileio'; ... 'eventtype' 'string' { } 'value'; ... @@ -287,7 +299,7 @@ subjectFolderOut = {}; if ~isempty(opt.sessions) subFolders = intersect(subFolders, opt.sessions); - end + end for iFold = 1:length(subFolders) subjectFolder{ iFold} = fullfile(parentSubjectFolder, subFolders{iFold}, 'eeg'); @@ -298,6 +310,10 @@ if ~exist(subjectFolder{iFold},'dir') subjectFolder{ iFold} = fullfile(parentSubjectFolder, subFolders{iFold}, 'ieeg'); subjectFolderOut{iFold} = fullfile(outputSubjectFolder, subFolders{iFold}, 'ieeg'); + if ~exist(subjectFolder{iFold},'dir') + subjectFolder{ iFold} = fullfile(parentSubjectFolder, subFolders{iFold}, 'emg'); + subjectFolderOut{iFold} = fullfile(outputSubjectFolder, subFolders{iFold}, 'emg'); + end end end end @@ -338,7 +354,20 @@ if isempty(eegFile) eegFile = searchparent(subjectFolder{iFold}, '*_ieeg.*'); end + if isempty(eegFile) + eegFile = searchparent(subjectFolder{iFold}, '*_emg.*'); + end + % Search for modality-specific JSON file (eeg, ieeg, meg, or emg) infoFile = searchparent(subjectFolder{iFold}, '*_eeg.json'); + if isempty(infoFile) + infoFile = searchparent(subjectFolder{iFold}, '*_ieeg.json'); + end + if isempty(infoFile) + infoFile = searchparent(subjectFolder{iFold}, '*_meg.json'); + end + if isempty(infoFile) + infoFile = searchparent(subjectFolder{iFold}, '*_emg.json'); + end channelFile = searchparent(subjectFolder{iFold}, '*_channels.tsv'); elecFile = searchparent(subjectFolder{iFold}, '*_electrodes.tsv'); eventFile = searchparent(subjectFolder{iFold}, '*_events.tsv'); @@ -365,7 +394,7 @@ behFile = filterFiles(behFile , opt.bidstask); end - % check the task + % check the runs if ~isempty(opt.runs) eegFile = filterFilesRun(eegFile , opt.runs); infoFile = filterFilesRun(infoFile , opt.runs); @@ -375,6 +404,18 @@ eventDescFile = filterFilesRun(eventDescFile, opt.runs); % no runs for BEH or coordsystem end + + % check the recordings (multi-device) + if ~isempty(opt.recordings) + eegFile = filterFilesRecording(eegFile , opt.recordings); + infoFile = filterFilesRecording(infoFile , opt.recordings); + channelFile = filterFilesRecording(channelFile , opt.recordings); + elecFile = filterFilesRecording(elecFile , opt.recordings); + eventFile = filterFilesRecording(eventFile , opt.recordings); + eventDescFile = filterFilesRecording(eventDescFile, opt.recordings); + coordFile = filterFilesRecording(coordFile , opt.recordings); + % events and coords may or may not have recording entity + end % raw data allFiles = { eegFile.name }; @@ -415,7 +456,33 @@ end eegFileRawAll = allFiles(ind); end - + + % Check for multiple recordings (multi-device acquisitions) + if length(eegFileRawAll) > 1 + recordingDetected = cellfun(@(x)contains(x, '_recording-'), eegFileRawAll); + if any(recordingDetected) + recordingLabels = {}; + for iR = 1:length(eegFileRawAll) + if recordingDetected(iR) + indRec = strfind(eegFileRawAll{iR}, '_recording-'); + tmpStr = eegFileRawAll{iR}(indRec(1)+11:end); + indUnder = find(tmpStr == '_'); + if ~isempty(indUnder) + recordingLabels{end+1} = tmpStr(1:indUnder(1)-1); + else + [~,~,ext] = fileparts(tmpStr); + recordingLabels{end+1} = tmpStr(1:end-length(ext)); + end + end + end + if ~isempty(recordingLabels) + fprintf('Detected %d recordings with recording entity: %s\n', ... + length(recordingLabels), strjoin(recordingLabels, ', ')); + fprintf(' → Importing each recording as a separate dataset\n'); + end + end + end + % identify non-EEG data files %-------------------------------------------------------------- if ~isempty(behFile) % should be a single file @@ -463,9 +530,28 @@ if isnan(iRun) iRun = str2double(tmpEegFileRaw(1:indUnder(1)-2)); % rare case run 5H in ds003190/sub-01/ses-01/eeg/sub-01_ses-01_task-ctos_run-5H_eeg.eeg if isnan(iRun) - error('Problem converting run information'); + error('Problem converting run information'); end end + end + + % what is the recording entity (for multi-device EMG) + recordingLabel = ''; + indRec = strfind(eegFileRaw, '_recording-'); + if ~isempty(indRec) + tmpEegFileRaw = eegFileRaw(indRec(1)+11:end); + indUnder = find(tmpEegFileRaw == '_'); + if ~isempty(indUnder) + recordingLabel = tmpEegFileRaw(1:indUnder(1)-1); + else + % recording is last entity before extension + [~,~,ext] = fileparts(tmpEegFileRaw); + recordingLabel = tmpEegFileRaw(1:end-length(ext)); + end + end + + % check for BEH file (run-specific) + if ~isempty(ind) % check for BEH file filePathTmp = fileparts(eegFileRaw); behFileTmp = fullfile(filePathTmp,'..', 'beh', [eegFileRaw(1:ind(1)-1) '_beh.tsv' ]); @@ -495,13 +581,19 @@ if ~strcmpi(tmpFileName(underScores(end)+1:end), 'eeg') if ~strcmpi(tmpFileName(underScores(end)+1:end), 'meg.fif') if ~strcmpi(tmpFileName(underScores(end)+1:end), 'meg') - error('Data file name does not contain eeg, ieeg, or meg'); % theoretically impossible + if ~strcmpi(tmpFileName(underScores(end)+1:end), 'emg') + error('Data file name does not contain eeg, ieeg, meg, or emg'); + else + modality = 'emg'; + end else modality = 'meg'; end else modality = 'meg'; end + elseif contains(eegFileRaw, '_emg.') + modality = 'emg'; else modality = 'eeg'; end @@ -620,11 +712,13 @@ end end - % coordsystem file - % ---------------- + % coordsystem file(s) + % ------------------- if strcmpi(opt.bidscoord, 'on') - coordFile = bids_get_file(eegFileRaw(1:end-8), '_coordsystem.json', coordFile); - [EEG, bids] = bids_importcoordsystemfile(EEG, coordFile, 'bids', bids); + % Get all coordsystem files for this subject/session + % Could be single (_coordsystem.json) or multiple (_space-*_coordsystem.json) + coordFiles = bids_get_all_coordsystem_files(eegFileRaw(1:end-8), coordFile); + [EEG, bids] = bids_importcoordsystemfile(EEG, coordFiles, 'bids', bids); end % copy information inside dataset @@ -632,7 +726,12 @@ EEG.session = iFold; EEG.run = iRun; EEG.task = task(6:end); % task is currently of format "task-" - + if ~isempty(recordingLabel) + EEG.recording = recordingLabel; + end + % Set datatype for export + EEG.etc.datatype = modality; + % build `EEG.BIDS` from `bids` BIDS.gInfo = bids.dataset_description; BIDS.gInfo.README = bids.README; @@ -663,7 +762,10 @@ % building study command commands = [ commands { 'index' count 'load' eegFileNameOut 'subject' bids.participants{iSubject,pInd} 'session' iFold 'task' task(6:end) 'run' iRun } ]; - + if ~isempty(recordingLabel) + commands = [ commands { 'recording' recordingLabel } ]; + end + % custom numerical fields for iCol = 2:size(bids.participants,2) commands = [ commands { bids.participants{1,iCol} bids.participants{iSubject,iCol} } ]; @@ -842,17 +944,15 @@ runs = {runs}; % integer now in a cell end keepInd = arrayfun(@(x) contains(extractAfter(x.name,'run-'),runs), fileList); -% keepInd = zeros(1,length(fileList)); -% for iFile = 1:length(fileList) -% runInd = strfind(fileList(iFile).name, '_run-'); -% if ~isempty(runInd) -% strTmp = fileList(iFile).name(runInd+5:end); -% underScore = find(strTmp == '_'); -% if any(runs == str2double(strTmp(1:underScore(1)-1))) -% keepInd(iFile) = 1; -% end -% end -% end +fileList = fileList(logical(keepInd)); + +% filter files by recording entity +% --------------------------------- +function fileList = filterFilesRecording(fileList, recordings) +if ~iscell(recordings) + recordings = {recordings}; +end +keepInd = arrayfun(@(x) contains(extractAfter(x.name,'recording-'),recordings), fileList); fileList = fileList(logical(keepInd)); @@ -881,6 +981,16 @@ end end +% get all coordsystem files (single or multiple with space entity) +function coordFiles = bids_get_all_coordsystem_files(baseName, coordFileStruct) +coordFiles = {}; +if ~isempty(coordFileStruct) && isfield(coordFileStruct, 'folder') && isfield(coordFileStruct, 'name') + % Return all coordsystem files found (could be single or multiple with space) + for i = 1:length(coordFileStruct) + coordFiles{end+1} = fullfile(coordFileStruct(i).folder, coordFileStruct(i).name); + end +end + % format other data types than EEG, MEG, iEEG %-------------------------------------------- function [DATA, dataFileOut] = import_noneeg(dataType, dataFile, dataRaw, subject, scansData, session, onlyMetadata, useChanlocs, useScans, subjectFolder, subjectDataFolderOut)