Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add event metadata handling #1285

Merged
merged 10 commits into from
Aug 23, 2024
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
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 @@
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 @@
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 @@
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 @@
"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 @@
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 @@
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 @@
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 @@
'"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(

Check warning on line 1718 in mne_bids/write.py

View check run for this annotation

Codecov / codecov/patch

mne_bids/write.py#L1718

Added line #L1718 was not covered by tests
"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(

Check warning on line 1726 in mne_bids/write.py

View check run for this annotation

Codecov / codecov/patch

mne_bids/write.py#L1726

Added line #L1726 was not covered by tests
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 @@
# 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