From e516e0de1cd2abd16ba7dae4febdf8365c6f3f35 Mon Sep 17 00:00:00 2001 From: Arnaud Delorme Date: Mon, 7 Sep 2026 13:33:21 -0700 Subject: [PATCH] Write MATLAB field classes EEGLAB expects on save --- docs/source/user_guide/concepts.rst | 3 + src/eegprep/functions/popfunc/pop_saveset.py | 80 ++++++++++++++++++-- tests/test_pop_saveset.py | 58 ++++++++++++++ 3 files changed, 134 insertions(+), 7 deletions(-) diff --git a/docs/source/user_guide/concepts.rst b/docs/source/user_guide/concepts.rst index f2d91855..8e810197 100644 --- a/docs/source/user_guide/concepts.rst +++ b/docs/source/user_guide/concepts.rst @@ -98,6 +98,9 @@ Practical rules: one-based on disk. * Single-trial datasets have no ``epoch`` structure: ``eeg_checkset`` empties it and removes ``event[i]["epoch"]``, as EEGLAB does. +* ``pop_saveset`` writes the MATLAB classes EEGLAB expects: integer fields + become ``double``, boolean masks stay ``logical``, and ``epoch`` event fields + are cell arrays when any epoch holds more than one event. For epoched data, ``EEG["data"][:, :, trial_index]`` is zero-based Python indexing. User-facing epoch and component selectors in GUI dialogs use diff --git a/src/eegprep/functions/popfunc/pop_saveset.py b/src/eegprep/functions/popfunc/pop_saveset.py index aaa931c7..16cb7e77 100644 --- a/src/eegprep/functions/popfunc/pop_saveset.py +++ b/src/eegprep/functions/popfunc/pop_saveset.py @@ -111,6 +111,60 @@ def _one_based(value): return value + 1 +def _matlab_double(value): + """Recursively cast integer values to double, EEGLAB's numeric class. + + MATLAB arithmetic on int64 rounds (``int64(3) * 1000 / 128`` is ``23``), so + integer-typed fields such as ``event.position`` or ``reject.threshentropy`` + would silently misbehave in EEGLAB. Booleans stay boolean so savemat writes + MATLAB logical masks (``etc.clean_sample_mask``). Recurses into dicts, lists, + and object arrays (MATLAB cells); float, string, and structured arrays pass + through. + """ + if isinstance(value, (bool, np.bool_)): + return value + if isinstance(value, (int, np.integer)): + return float(value) + if isinstance(value, dict): + return {k: _matlab_double(v) for k, v in value.items()} + if isinstance(value, list): + return [_matlab_double(v) for v in value] + if not isinstance(value, np.ndarray) or value.dtype.names is not None: + return value + if value.dtype == object: + out = np.empty(value.shape, dtype=object) + for idx in np.ndindex(value.shape): + out[idx] = _matlab_double(value[idx]) + return out + if value.dtype.kind in 'iu': + return value.astype(np.float64) + return value + + +def _epoch_fields_to_matlab(epoch): + """Type ``epoch`` fields in place the way ``eeg_checkset.m`` builds them. + + ``event`` is a double row vector. Each ``event`` is a cell array, + unless no epoch holds more than one event, in which case it is the bare + value ([] for an epoch without events). + """ + maxlen = max((np.size(ep['event']) for ep in epoch if 'event' in ep), default=0) + for ep in epoch: + for key, value in ep.items(): + if not key.startswith('event'): + continue + values = list(value) if isinstance(value, (list, np.ndarray)) else [value] + if key == 'event': + ep[key] = np.asarray(values, dtype=np.float64) + elif maxlen <= 1: + ep[key] = values[0] if values else default_empty + else: + cell = np.empty((1, len(values)), dtype=object) + for i, v in enumerate(values): + cell[0, i] = v + ep[key] = cell + + def _matlab_empty_struct_if_missing(EEG, key): """Return an EEGLAB empty array for optional empty struct-like fields.""" value = _matlab_empty_if_missing(EEG, key) @@ -119,6 +173,14 @@ def _matlab_empty_struct_if_missing(EEG, key): return value +def _matlab_empty_cell_if_missing(EEG, key): + """Return an EEGLAB empty cell ``{}`` for optional cell fields that are missing or empty.""" + value = _matlab_empty_if_missing(EEG, key) + if np.size(value) == 0: + return np.empty((0, 0), dtype=object) + return value + + def flatten_dict(data): """Flatten dictionary data. @@ -156,7 +218,7 @@ def flatten_dict(data): # (scipy.io.savemat handles mixed typed/object recarrays poorly) if has_object: dtype = np.dtype([(f, 'O') for f in fields]) - data_tuples = [tuple(item[field] for field in fields) for item in flat_data] + data_tuples = [tuple(_matlab_double(item[field]) for field in fields) for item in flat_data] else: dtype = np.dtype(dtypes) data_tuples = [] @@ -291,7 +353,7 @@ def _chanlocs_to_struct_array(chanlocs_list): ('sph_phi', np.float64), ('sph_radius', np.float64), ('type', 'U10'), - ('urchan', np.int32), + ('urchan', np.float64), ('ref', 'U100'), ('unit', 'U20'), ] @@ -412,21 +474,21 @@ def pop_saveset(EEG, file_name=None, *args, **kwargs): 'ref': EEG.get('ref', 'common'), 'event': _matlab_empty_or_copy(EEG, 'event'), 'urevent': _matlab_empty_if_missing(EEG, 'urevent'), - 'eventdescription': _matlab_empty_if_missing(EEG, 'eventdescription'), + 'eventdescription': _matlab_empty_cell_if_missing(EEG, 'eventdescription'), 'epoch': _matlab_empty_or_copy(EEG, 'epoch'), - 'epochdescription': _matlab_empty_if_missing(EEG, 'epochdescription'), + 'epochdescription': _matlab_empty_cell_if_missing(EEG, 'epochdescription'), 'reject': _matlab_empty_if_missing(EEG, 'reject'), 'stats': _matlab_empty_if_missing(EEG, 'stats'), 'specdata': _matlab_empty_if_missing(EEG, 'specdata'), 'specicaact': _matlab_empty_if_missing(EEG, 'specicaact'), - 'splinefile': _matlab_empty_if_missing(EEG, 'splinefile'), - 'icasplinefile': _matlab_empty_if_missing(EEG, 'icasplinefile'), + 'splinefile': _string_field(EEG.get('splinefile', '')), + 'icasplinefile': _string_field(EEG.get('icasplinefile', '')), 'dipfit': _matlab_empty_if_missing(EEG, 'dipfit'), 'history': EEG.get('history', ''), 'saved': EEG.get('saved', 'yes'), 'etc': _matlab_empty_struct_if_missing(EEG, 'etc'), 'run': _matlab_empty_if_missing(EEG, 'run'), - 'roi': _matlab_empty_if_missing(EEG, 'roi'), + 'roi': _matlab_empty_struct_if_missing(EEG, 'roi'), } # add 1 to EEG['icachansind'] to make it 1-based @@ -450,6 +512,7 @@ def pop_saveset(EEG, file_name=None, *args, **kwargs): for key in ('event', 'eventurevent'): if key in ep: ep[key] = _one_based(ep[key]) + _epoch_fields_to_matlab(eeglab_dict['epoch']) # Serialize chanlocs through the single canonical chanloc converter so the # primary channel struct uses the same schema as chaninfo.removedchans. @@ -476,6 +539,9 @@ def pop_saveset(EEG, file_name=None, *args, **kwargs): ): eeglab_dict[key] = flatten_dict(eeglab_dict[key]) + for key in eeglab_dict: + eeglab_dict[key] = _matlab_double(eeglab_dict[key]) + if not os.path.exists(save_dir): os.makedirs(save_dir) diff --git a/tests/test_pop_saveset.py b/tests/test_pop_saveset.py index 51f6ea5f..e0a2ca4a 100644 --- a/tests/test_pop_saveset.py +++ b/tests/test_pop_saveset.py @@ -6,6 +6,7 @@ import scipy.io from eegprep import pop_loadset, pop_saveset # Explicitly import pop_resample +from eegprep.functions.adminfunc.eeg_checkset import eeg_checkset # where the test resources @@ -91,6 +92,63 @@ def test_saveset_writes_epoch_event_indices_one_based(self): self.assertEqual(epoch_after, epoch_before) # caller's dict not mutated epoch_reloaded = [(list(ep['event']), list(ep['eventurevent'])) for ep in reloaded['epoch']] self.assertEqual(epoch_reloaded, epoch_before) # round trip + + def test_saveset_writes_matlab_field_classes(self): + # EEGLAB stores numeric fields as double and multi-event epoch fields as + # cell arrays; int64/uint8 or char matrices break MATLAB arithmetic and + # indexing code that expects those classes. + EEG = pop_loadset(os.path.join(local_url, 'eeglab_data_epochs_ica.set')) + with tempfile.TemporaryDirectory() as tmp: + out = os.path.join(tmp, 'classes.set') + pop_saveset(EEG, out) + raw = scipy.io.loadmat(out, struct_as_record=False, squeeze_me=False) + reloaded = pop_loadset(out) + + ep = raw['epoch'][0, 0] + self.assertEqual(ep.event.dtype, np.float64) + np.testing.assert_array_equal(ep.event, [[1, 2, 3]]) + for name in ('eventtype', 'eventlatency', 'eventposition', 'eventurevent'): + self.assertEqual(getattr(ep, name).dtype, object, name) # MATLAB cell + self.assertEqual(getattr(ep, name).shape, (1, 3), name) + self.assertEqual(str(ep.eventtype[0, 0][0]), EEG['epoch'][0]['eventtype'][0]) + self.assertEqual(ep.eventlatency[0, 0].dtype, np.float64) + self.assertEqual(ep.eventurevent[0, 0].dtype, np.float64) + np.testing.assert_array_equal([c[0, 0] for c in ep.eventurevent[0]], [1, 2, 3]) + + ev = raw['event'][0, 0] + self.assertEqual(ev.position.dtype, np.float64) + self.assertEqual(ev.urevent.dtype, np.float64) + self.assertEqual(ev.epoch.dtype, np.float64) + self.assertEqual(raw['icachansind'].dtype, np.float64) + self.assertEqual(raw['chanlocs'][0, 0].urchan.dtype, np.float64) + self.assertEqual(raw['urchanlocs'][0, 0].theta.dtype, np.float64) + self.assertEqual(raw['reject'][0, 0].threshentropy.dtype, np.float64) + self.assertEqual(raw['reject'][0, 0].gcompreject.dtype, np.float64) + + # In-memory round trip is unchanged by the on-disk classes. + for before, after in zip(EEG['epoch'], reloaded['epoch']): + self.assertEqual(list(before['event']), list(after['event'])) + self.assertEqual(list(before['eventtype']), list(after['eventtype'])) + np.testing.assert_allclose(before['eventlatency'], after['eventlatency']) + + def test_saveset_epoch_fields_are_scalars_with_one_event_per_epoch(self): + # eeg_checkset.m only builds cell arrays when some epoch holds more than + # one event; with at most one event per epoch each field is a bare value. + EEG = pop_loadset(os.path.join(local_url, 'eeglab_data_epochs_ica.set')) + seen = set() + EEG['event'] = [ev for ev in EEG['event'] if not (ev['epoch'] in seen or seen.add(ev['epoch']))] + EEG = eeg_checkset(EEG) + self.assertTrue(all(len(ep['event']) == 1 for ep in EEG['epoch'])) + + with tempfile.TemporaryDirectory() as tmp: + out = os.path.join(tmp, 'one_per_epoch.set') + pop_saveset(EEG, out) + ep = scipy.io.loadmat(out, struct_as_record=False, squeeze_me=True)['epoch'][0] + + self.assertEqual(float(ep.event), 1.0) + self.assertIsInstance(ep.eventtype, str) + self.assertEqual(np.ndim(ep.eventlatency), 0) + self.assertEqual(np.ndim(ep.eventurevent), 0) # """Test basic resampling functionality with different engines""" # # Apply resampling with different engines # EEG_python = pop_resample(self.EEG.copy(), self.new_freq, engine='scipy')