Repository: mne-tools/mne-bids

Issue:
BIDS `sample` and `value` columns will be considered "arbitrary" columns soon
as discussed in:

- https://github.com/bids-standard/bids-specification/pull/1441

and implemented in:

- https://github.com/bids-standard/bids-specification/pull/1457

the currently OPTIONAL `sample` and `value` columns in `*_events.tsv` files will be demoted to "arbitrary" columns in BIDS 1.9.0.

Practically this means, that `mne-bids` should write entries into `*_events.json`, defining how **we** use `sample` and `value`.

Otherwise, datasets produced by mne-bids might soon generate a "warning" (not an error 😉 ) that `sample` and `value` are undefined.


Discussion:
thanks for the update. I guess we will need a few PRs :)

On Tue, Apr 11, 2023 at 3:10 PM Stefan Appelhoff ***@***.***>
wrote:

> as discussed in:
>
>    - bids-standard/bids-specification#1441
>    <https://github.com/bids-standard/bids-specification/pull/1441>
>
> and implemented in:
>
>    - bids-standard/bids-specification#1457
>    <https://github.com/bids-standard/bids-specification/pull/1457>
>
> the currently OPTIONAL sample and value columns in *_events.tsv files
> will be demoted to "arbitrary" columns in BIDS 1.9.0.
>
> Practically this means, that mne-bids should write entries into
> *_events.json, defining how *we* use sample and value.
>
> Otherwise, datasets produced by mne-bids might soon generate a "warning"
> (not an error 😉 ) that sample and value are undefined.
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/mne-tools/mne-bids/issues/1130>, or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AABHKHHEE3HYBPICN5GDB7LXAVJ3VANCNFSM6AAAAAAW2IHALQ>
> .
> You are receiving this because you are subscribed to this thread.Message
> ID: ***@***.***>
>


Resolving patch:
diff --git a/examples/convert_mne_sample.py b/examples/convert_mne_sample.py
--- a/examples/convert_mne_sample.py
+++ b/examples/convert_mne_sample.py
@@ -94,6 +94,7 @@
     session='01',
     task=task,
     run='1',
+    datatype='meg',
     root=output_path
 )
 write_raw_bids(
@@ -110,7 +111,9 @@
 # sidecar files that describe our data is correct.
 
 # Get the sidecar ``.json`` file
-sidecar_json_bids_path = bids_path.copy().update(extension='.json')
+sidecar_json_bids_path = bids_path.copy().update(
+    suffix='meg', extension='.json'
+)
 sidecar_json_content = sidecar_json_bids_path.fpath.read_text(
     encoding='utf-8-sig'
 )
diff --git a/mne_bids/path.py b/mne_bids/path.py
--- a/mne_bids/path.py
+++ b/mne_bids/path.py
@@ -911,9 +911,9 @@ def find_empty_room(self, use_sidecar_only=False, *, verbose=None):
                              'to set the root of the BIDS folder to read.')
 
         # needed to deal with inheritance principle
-        sidecar_fname = \
-            self.copy().update(datatype=None).find_matching_sidecar(
-                extension='.json')
+        sidecar_fname = self.copy().update(
+            datatype=None, suffix='meg'
+        ).find_matching_sidecar(extension='.json')
         with open(sidecar_fname, 'r', encoding='utf-8') as f:
             sidecar_json = json.load(f)
 
diff --git a/mne_bids/write.py b/mne_bids/write.py
--- a/mne_bids/write.py
+++ b/mne_bids/write.py
@@ -289,6 +289,43 @@ def _events_tsv(events, durations, raw, fname, trial_type, overwrite=False):
     _write_tsv(fname, data, overwrite)
 
 
+def _events_json(fname, overwrite=False):
+    """Create participants.json for non-default columns in accompanying TSV.
+
+    Parameters
+    ----------
+    fname : str | mne_bids.BIDSPath
+        Output filename.
+    overwrite : bool
+        Whether to overwrite the output file if it exists.
+    """
+    new_data = {
+        'sample': {
+            'Description': 'The event onset time in number of sampling points.'
+        },
+        'value': {
+            'Description': (
+                'The event code (also known as trigger code or event ID) '
+                'associated with the event.'
+            )
+        },
+        'trial_type': {
+            'Description': 'The type, category, or name of the event.'
+        },
+    }
+
+    # make sure to append any JSON fields added by the user
+    fname = Path(fname)
+    if fname.exists():
+        orig_data = json.loads(
+            fname.read_text(encoding='utf-8'),
+            object_pairs_hook=OrderedDict
+        )
+        new_data = {**orig_data, **new_data}
+
+    _write_json(fname, new_data, overwrite)
+
+
 def _readme(datatype, fname, overwrite=False):
     """Create a README file and save it.
 
@@ -454,7 +491,7 @@ def _participants_json(fname, overwrite=False):
     Parameters
     ----------
     fname : str | mne_bids.BIDSPath
-        Filename to save the scans.tsv to.
+        Output filename.
     overwrite : bool
         Defaults to False.
         Whether to overwrite the existing data in the file.
@@ -462,7 +499,7 @@ def _participants_json(fname, overwrite=False):
         an error will be raised.
 
     """
-    data = {
+    new_data = {
         'participant_id': {
             'Description': 'Unique participant identifier'
         },
@@ -500,15 +537,13 @@ def _participants_json(fname, overwrite=False):
     # if `overwrite` is True
     fname = Path(fname)
     if fname.exists():
-        orig_cols = json.loads(
+        orig_data = json.loads(
             fname.read_text(encoding='utf-8'),
             object_pairs_hook=OrderedDict
         )
-        for key, val in orig_cols.items():
-            if key not in data:
-                data[key] = val
+        new_data = {**orig_data, **new_data}
 
-    _write_json(fname, data, overwrite)
+    _write_json(fname, new_data, overwrite)
 
 
 def _scans_tsv(raw, raw_fname, fname, keep_source, overwrite=False):
@@ -1763,7 +1798,10 @@ def write_raw_bids(
 
     sidecar_path = bids_path.copy().update(suffix=bids_path.datatype,
                                            extension='.json')
-    events_path = bids_path.copy().update(suffix='events', extension='.tsv')
+    events_tsv_path = bids_path.copy().update(
+        suffix='events', extension='.tsv'
+    )
+    events_json_path = events_tsv_path.copy().update(extension='.json')
     channels_path = bids_path.copy().update(
         suffix='channels', extension='.tsv')
 
@@ -1838,9 +1876,12 @@ def write_raw_bids(
         events_array, event_dur, event_desc_id_map = _read_events(
             events, event_id, raw, bids_path=bids_path)
         if events_array.size != 0:
-            _events_tsv(events=events_array, durations=event_dur, raw=raw,
-                        fname=events_path.fpath, trial_type=event_desc_id_map,
-                        overwrite=overwrite)
+            _events_tsv(
+                events=events_array, durations=event_dur, raw=raw,
+                fname=events_tsv_path.fpath, trial_type=event_desc_id_map,
+                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
 


Test patch:
diff --git a/mne_bids/tests/test_write.py b/mne_bids/tests/test_write.py
--- a/mne_bids/tests/test_write.py
+++ b/mne_bids/tests/test_write.py
@@ -2647,6 +2647,7 @@ def test_annotations_and_events(_bids_validate, tmp_path, write_events):
         suffix='events',
         extension='.tsv',
     )
+    events_json_fname = events_tsv_fname.copy().update(extension='.json')
 
     events = mne.read_events(events_fname)
     events = events[events[:, 2] != 0]  # drop unknown "0" events
@@ -2699,6 +2700,12 @@ def test_annotations_and_events(_bids_validate, tmp_path, write_events):
 
     if write_events:
         n_events_expected = len(events) + len(raw.annotations)
+        events_json = json.loads(
+            events_json_fname.fpath.read_text(encoding='utf-8')
+        )
+        assert 'value' in events_json
+        assert 'sample' in events_json
+        assert 'trial_type' in events_json
     else:
         n_events_expected = len(raw.annotations)
 
@@ -3752,7 +3759,7 @@ def test_anonymize_dataset(_bids_validate, tmpdir):
     # Ensure that additional JSON sidecar fields are transferred if they are
     # "safe", and are omitted if they are not whitelisted
     bids_path.datatype = 'meg'
-    meg_json_path = bids_path.copy().update(extension='.json')
+    meg_json_path = bids_path.copy().update(suffix='meg', extension='.json')
     meg_json = json.loads(meg_json_path.fpath.read_text(encoding='utf-8'))
     assert 'Instructions' not in meg_json  # ensure following test makes sense
     meg_json['Instructions'] = 'Foo'