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
3 changes: 3 additions & 0 deletions docs/source/user_guide/concepts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ Two deliberate differences from EEGLAB:
Staying near the deleted dataset is friendlier when you delete from a long list.
* EEGLAB refuses to delete when only one dataset is loaded and tells you to clear
all datasets instead. EEGPrep deletes it and leaves an empty ``ALLEEG``.
* ``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
Expand Down
80 changes: 73 additions & 7 deletions src/eegprep/functions/popfunc/pop_saveset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<field>`` 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)
Expand All @@ -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.

Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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'),
]
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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)

Expand Down
58 changes: 58 additions & 0 deletions tests/test_pop_saveset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down
Loading