diff --git a/CITATION.cff b/CITATION.cff index 727e91000..98bec4910 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -187,6 +187,10 @@ authors: family-names: Benitez affiliation: 'Magnetoencephalography Core, National Institutes of Health, Bethesda, Maryland, USA' orcid: 'https://orcid.org/0000-0001-6364-7272' + - given-names: Thomas + family-names: Hartmann + affiliation: 'Paris-Lodron-University Salzburg, Centre for Cogntitive Neuroscience, Department of Psychology, Salzburg, Austria' + orcid: 'https://orcid.org/0000-0002-8298-8125' - given-names: Alexandre family-names: Gramfort affiliation: 'Université Paris-Saclay, Inria, CEA, Palaiseau, France' diff --git a/doc/authors.rst b/doc/authors.rst index 487d69cb5..1d0cb016e 100644 --- a/doc/authors.rst +++ b/doc/authors.rst @@ -48,3 +48,4 @@ .. _Julius Welzel: https://github.com/JuliusWelzel .. _Kaare Mikkelsen: https://github.com/kaare-mikkelsen .. _Amaia Benitez: https://github.com/AmaiaBA +.. _Thomas Hartmann: https://github.com/thht diff --git a/doc/whats_new.rst b/doc/whats_new.rst index f549a0d7b..492feddd0 100644 --- a/doc/whats_new.rst +++ b/doc/whats_new.rst @@ -19,6 +19,7 @@ The following authors contributed for the first time. Thank you so much! 🤩 * `Kaare Mikkelsen`_ * `Amaia Benitez`_ +* `Thomas Hartmann`_ The following authors had contributed before. Thank you for sticking around! 🤘 @@ -33,6 +34,7 @@ Detailed list of changes ^^^^^^^^^^^^^^^ - :meth:`mne_bids.BIDSPath.match()` and :func:`mne_bids.find_matching_paths` now have additional parameters ``ignore_json`` and ``ignore_nosub``, to give users more control over which type of files are matched, by `Kaare Mikkelsen`_ (:gh:`1281`) +- :func:`mne_bids.write_raw_bids()` can now handle event metadata as a pandas DataFrame, by `Thomas Hartmann`_ (:gh:`1285`) 🧐 API and behavior changes ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/mne_bids/read.py b/mne_bids/read.py index ba0b649fd..e02818998 100644 --- a/mne_bids/read.py +++ b/mne_bids/read.py @@ -169,7 +169,7 @@ def _read_events(events, event_id, raw, bids_path=None): # If we have events, convert them to Annotations so they can be easily # merged with existing Annotations. - if events.size > 0: + if events.size > 0 and event_id is not None: ids_without_desc = set(events[:, 2]) - set(event_id.values()) if ids_without_desc: raise ValueError( @@ -200,6 +200,22 @@ def _read_events(events, event_id, raw, bids_path=None): raw.set_annotations(annotations) del id_to_desc_map, annotations, new_annotations + if events.size > 0 and event_id is None: + new_annotations = mne.annotations_from_events( + events=events, + sfreq=raw.info["sfreq"], + orig_time=raw.annotations.orig_time, + ) + + raw = raw.copy() # Don't alter the original. + annotations = raw.annotations.copy() + + # We use `+=` here because `Annotations.__iadd__()` does the right + # thing and also performs a sanity check on `Annotations.orig_time`. + annotations += new_annotations + raw.set_annotations(annotations) + del annotations, new_annotations + # Now convert the Annotations to events. all_events, all_desc = events_from_annotations( raw, diff --git a/mne_bids/tests/test_write.py b/mne_bids/tests/test_write.py index e39a8d4ee..629c7c422 100644 --- a/mne_bids/tests/test_write.py +++ b/mne_bids/tests/test_write.py @@ -20,6 +20,7 @@ import mne import numpy as np +import pandas as pd import pytest from mne.datasets import testing from mne.io import anonymize_info @@ -4113,3 +4114,68 @@ def test_write_neuromag122(_bids_validate, tmp_path): ) write_raw_bids(raw, bids_path, overwrite=True, allow_preload=True, format="FIF") _bids_validate(bids_root) + + +@testing.requires_testing_data +def test_write_evt_metadata(_bids_validate, tmp_path): + """Test writing events and metadata to BIDS.""" + bids_root = tmp_path / "bids" + raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif" + raw = _read_raw_fif(raw_fname) + events = mne.find_events(raw, initial_event=True) + df_list = [] + for idx, event in enumerate(events): + direction = None + if event[2] in (1, 3): + direction = "left" + elif event[2] in (2, 4): + direction = "right" + + event_type = "button_press" if event[2] == 32 else "stimulus" + stimulus_kind = None + if event[2] == 5: + stimulus_kind = "smiley" + elif event[2] in (1, 2): + stimulus_kind = "auditory" + elif event[2] in (3, 4): + stimulus_kind = "visual" + + df_list.append( + { + "direction": direction, + "event_type": event_type, + "stimulus_kind": stimulus_kind, + } + ) + + event_metadata = pd.DataFrame(df_list) + + bids_path = _bids_path.copy().update(root=bids_root, datatype="meg") + write_raw_bids( + raw, + bids_path=bids_path, + events=events, + event_metadata=event_metadata, + overwrite=True, + extra_columns_descriptions={ + "direction": "The direction of the stimulus", + "event_type": "The type of the event", + "stimulus_kind": "The stimulus modality", + }, + ) + _bids_validate(bids_root) + events_tsv_path = bids_path.copy().update(suffix="events", extension=".tsv") + events_json_path = events_tsv_path.copy().update(extension=".json") + + assert events_tsv_path.fpath.exists() + assert events_json_path.fpath.exists() + + events_json = json.loads(events_json_path.fpath.read_text()) + events_tsv = _from_tsv(events_tsv_path) + + assert "trial_type" not in events_tsv + assert "trial_type" not in events_json + + for cur_col in event_metadata.columns: + assert cur_col in events_tsv + assert cur_col in events_json diff --git a/mne_bids/write.py b/mne_bids/write.py index 16b817be8..8255ffffb 100644 --- a/mne_bids/write.py +++ b/mne_bids/write.py @@ -266,7 +266,9 @@ def _get_fid_coords(dig_points, raise_error=True): return fid_coords, coord_frame -def _events_tsv(events, durations, raw, fname, trial_type, overwrite=False): +def _events_tsv( + events, durations, raw, fname, trial_type, event_metadata=None, overwrite=False +): """Create an events.tsv file and save it. This function will write the mandatory 'onset', and 'duration' columns as @@ -290,6 +292,9 @@ def _events_tsv(events, durations, raw, fname, trial_type, overwrite=False): trial_type : dict | None Dictionary mapping a brief description key to an event id (value). For example {'Go': 1, 'No Go': 2}. + event_metadata : pandas.DataFrame | None + Additional metadata to be stored in the events.tsv file. Must have one + row per event. overwrite : bool Whether to overwrite the existing file. Defaults to False. @@ -319,19 +324,30 @@ def _events_tsv(events, durations, raw, fname, trial_type, overwrite=False): else: del data["trial_type"] + if event_metadata is not None: + for key, values in event_metadata.items(): + data[key] = values + _write_tsv(fname, data, overwrite) -def _events_json(fname, overwrite=False): +def _events_json(fname, extra_columns=None, has_trial_type=True, overwrite=False): """Create participants.json for non-default columns in accompanying TSV. Parameters ---------- fname : str | mne_bids.BIDSPath Output filename. + extra_columns : dict | None + Dictionary with additional columns to be added to the events.json file. + has_trial_type : bool + Whether the events.tsv file should contain a 'trial_type' column. overwrite : bool Whether to overwrite the output file if it exists. """ + if extra_columns is None: + extra_columns = dict() + new_data = { "onset": { "Description": ( @@ -361,9 +377,16 @@ def _events_json(fname, overwrite=False): "associated with the event." ) }, - "trial_type": {"Description": "The type, category, or name of the event."}, } + if has_trial_type: + new_data["trial_type"] = { + "Description": "The type, category, or name of the event." + } + + for key, value in extra_columns.items(): + new_data[key] = {"Description": value} + # make sure to append any JSON fields added by the user fname = Path(fname) if fname.exists(): @@ -1378,6 +1401,8 @@ def write_raw_bids( bids_path, events=None, event_id=None, + event_metadata=None, + extra_columns_descriptions=None, *, anonymize=None, format="auto", @@ -1463,8 +1488,9 @@ def write_raw_bids( call ``raw.set_annotations(None)`` before invoking this function. .. note:: - Descriptions of all event codes must be specified via the - ``event_id`` parameter. + Either, descriptions of all event codes must be specified via the + ``event_id`` parameter or each event must be accompanied by a + row in ``event_metadata``. event_id : dict | None Descriptions or names describing the event codes, if you passed @@ -1475,6 +1501,11 @@ def write_raw_bids( contains :class:`~mne.Annotations`, you can use this parameter to assign event codes to each unique annotation description (mapping from description to event code). + event_metadata : pandas.DataFrame | None + Metadata for each event in ``events``. Each row corresponds to an event. + extra_columns_descriptions : dict | None + A dictionary that maps column names of the ``event_metadata`` to descriptions. + Each column of ``event_metadata`` must have a corresponding entry in this. anonymize : dict | None If `None` (default), no anonymization is performed. If a dictionary, data will be anonymized depending on the dictionary @@ -1678,8 +1709,24 @@ def write_raw_bids( '"bids_path.task = "' ) - if events is not None and event_id is None: - raise ValueError("You passed events, but no event_id dictionary.") + if events is not None and event_id is None and event_metadata is None: + raise ValueError( + "You passed events, but no event_id dictionary " "or event_metadata." + ) + + if event_metadata is not None and extra_columns_descriptions is None: + raise ValueError( + "You passed event_metadata, but no " + "extra_columns_descriptions dictionary." + ) + + if event_metadata is not None: + for column in event_metadata.columns: + if column not in extra_columns_descriptions: + raise ValueError( + f"Extra column {column} in event_metadata " + f"is not described in extra_columns_descriptions." + ) _validate_type( item=empty_room, item_name="empty_room", types=(mne.io.BaseRaw, BIDSPath, None) @@ -1974,8 +2021,15 @@ def write_raw_bids( # Write events. if not data_is_emptyroom: events_array, event_dur, event_desc_id_map = _read_events( - events, event_id, raw, bids_path=bids_path + events, + event_id, + raw, + bids_path=bids_path, ) + + if event_metadata is not None: + event_desc_id_map = None + if events_array.size != 0: _events_tsv( events=events_array, @@ -1983,9 +2037,17 @@ def write_raw_bids( raw=raw, fname=events_tsv_path.fpath, trial_type=event_desc_id_map, + event_metadata=event_metadata, + overwrite=overwrite, + ) + has_trial_type = event_desc_id_map is not None + + _events_json( + fname=events_json_path.fpath, + extra_columns=extra_columns_descriptions, + has_trial_type=has_trial_type, overwrite=overwrite, ) - _events_json(fname=events_json_path.fpath, overwrite=overwrite) # Kepp events_array around for BrainVision writing below. del event_desc_id_map, events, event_id, event_dur