Skip to content

Commit

Permalink
Add event metadata handling (#1285)
Browse files Browse the repository at this point in the history
* add failing test

* code works but should not

* we have a failing test

* do not use trial type in tsv

* my tests are green

* make the linter happy

* add docstrings

* fix docs

* add first time stuff

---------

Co-authored-by: Stefan Appelhoff <[email protected]>
  • Loading branch information
thht and sappelhoff authored Aug 23, 2024
1 parent f919962 commit 471bd0c
Show file tree
Hide file tree
Showing 6 changed files with 161 additions and 10 deletions.
4 changes: 4 additions & 0 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions doc/authors.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions doc/whats_new.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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! 🤘

Expand All @@ -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
^^^^^^^^^^^^^^^^^^^^^^^^^^^
Expand Down
18 changes: 17 additions & 1 deletion mne_bids/read.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
66 changes: 66 additions & 0 deletions mne_bids/tests/test_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
80 changes: 71 additions & 9 deletions mne_bids/write.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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": (
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -1678,8 +1709,24 @@ def write_raw_bids(
'"bids_path.task = <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)
Expand Down Expand Up @@ -1974,18 +2021,33 @@ 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,
durations=event_dur,
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

Expand Down

0 comments on commit 471bd0c

Please sign in to comment.