Repository: mne-tools/mne-bids

Issue:
Simplify storage of empty-room data
Currently, storing empty-room data is unnecessarily complicated and error-prone.

Current approach:

- manually create a `BIDSPath` with `subject='emptyroom'`, `task='noise'`, and `session='YYYYMMDD'` (meaning one first has to manually construct the date/session string)
- pass this `BIDSPath` and the empty-room raw to `write_raw_bids`

Problems:

- `write_raw_bids` special-handles the `emptyroom` subject
- this is not documented anywhere
- if there's a typo in the subject name, the empty-room-special-handling branches of code will be omitted and the user won't be notified, as empty-room data will be treated like regular experimental data
- inconsistent behavior when the user requests anonymization: suddenly, `write_raw_bids` starts fiddling with `bids_path.session` to ensure that `daysback` is applied there as well, meaning that the data is written to a session directory that differs from what the user explicitly passed
- before we anonymize, we demand that empty-room session and the date in `raw.info` are the same and raise an exception otherwise; again, this behavior is not documented

Proposal:

- Add a kwarg `is_emptyroom` to `write_raw_bids`; if `True`, automatically set `subject`, `task`, `session` (and ignore values potentially present in `bids_path`)

Problem with proposal:

- We already have a kwarg `emptyroom`, which could lead to confusion. We could rename it to `emptyroom_bids_path` or `er_bids_path`, though?

Alternatives:

- Add a dedicated `write_raw_bids_emptyroom` function. Seems overkill?


Discussion:
can you paste how the user code  looks now and how it would look like with
your proposal?

>

Current approach (important bits indicated by the pointing fingers):
```python
from pathlib import Path
import mne
from mne.datasets import sample
from mne_bids import (write_raw_bids, BIDSPath)

data_path = Path(sample.data_path())
raw_fname = data_path / 'MEG' / 'sample' / 'sample_audvis_raw.fif'
er_raw_fname = data_path / 'MEG' / 'sample' / 'ernoise_raw.fif'
bids_root = data_path.parent /  'MNE-sample-data-bids'

raw = mne.io.read_raw_fif(raw_fname)
raw_er = mne.io.read_raw_fif(er_raw_fname)
raw.info['line_freq'] = 60
raw_er.info['line_freq'] = 60


bids_path = BIDSPath(
    subject='01',
    task='audiovisual',
    root=bids_root,
    suffix='meg',
    extension='.fif',
    datatype='meg'
)

# 👇 👇 👇
er_date = raw_er.info['meas_date'].strftime('%Y%m%d')
bids_path_er = bids_path.copy().update(
    subject='emptyroom',
    task='noise',
    session=er_date
)
write_raw_bids(raw=raw_er, bids_path=bids_path_er, overwrite=True)
# 👆 👆 👆

write_raw_bids(
    raw=raw, bids_path=bids_path, empty_room=bids_path_er, overwrite=True
)
```

The highlighted region would be simplified to the following:
```python
# 👇 👇 👇
write_raw_bids(
    raw=raw_er, bids_path=bids_path, is_empty_room=True,
    overwrite=True
)
# 👆 👆 👆
```
No need to fiddle with session name, subject, and task anymore.
I am convinced !
Just started working on this an wasn't super happy with how it turned out; bumping this to the 0.10 milestone and will confine myself to only renaming the existing `write_raw_bids()` parameter `empty_room` to something more explicit

Resolving patch:
diff --git a/examples/convert_empty_room.py b/examples/convert_empty_room.py
--- a/examples/convert_empty_room.py
+++ b/examples/convert_empty_room.py
@@ -1,12 +1,12 @@
 """
 .. _ex-convert-empty-room:
 
-==========================================
-09. Storing empty room data in BIDS format
-==========================================
+====================================
+09. Manually storing empty room data
+====================================
 
-This example demonstrates how to store empty room data in BIDS format
-and how to retrieve them.
+This example demonstrates how to store empty room data "manually" in the BIDS
+format and how to retrieve them.
 """
 
 # Authors: Mainak Jas <mainakjas@gmail.com>
@@ -18,6 +18,15 @@
 # "empty room" recordings for noise modeling. Below we show that we can use
 # MNE-BIDS to also save such a recording with the just converted data.
 #
+# .. note::
+#    The steps described below should only be followed if you intend to store
+#    empty-room and experimental data in **separate** steps for some reason.
+#    Otherwise, we recommend you store both with a **single** call to
+#    :func:`mne_bids.write_raw_bids` by passing the empty-room raw data via the
+#    ``empty_room`` parameter, as demonstrated in :ref:`ex-convert-mne-sample`.
+#    What is described in the example below is targeted towards advanced users
+#    only.
+#
 # Let us first import mne_bids.
 
 import os.path as op
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
@@ -47,6 +47,7 @@
             'Visual/Right': 4, 'Smiley': 5, 'Button': 32}
 
 raw_fname = op.join(data_path, 'MEG', 'sample', 'sample_audvis_raw.fif')
+er_fname = op.join(data_path, 'MEG', 'sample', 'ernoise_raw.fif')  # empty room
 events_data = op.join(data_path, 'MEG', 'sample', 'sample_audvis_raw-eve.fif')
 output_path = op.join(data_path, '..', 'MNE-sample-data-bids')
 
@@ -72,23 +73,42 @@
 #   *not* guess and you will have to update your BIDS fields manually.
 #
 # Based on our path definitions above, we read the raw data file, define
-# a new BIDS name for it, and then run the automatic BIDS conversion.
-
-raw = mne.io.read_raw_fif(raw_fname)
-raw.info['line_freq'] = 60  # specify power line frequency as required by BIDS
-
-bids_path = BIDSPath(subject='01', session='01',
-                     task='audiovisual', run='01', root=output_path)
-write_raw_bids(raw, bids_path, events_data=events_data,
-               event_id=event_id, overwrite=True)
+# a new BIDS name for it, and then run the automatic BIDS conversion for both
+# the experimental data and its associated empty-room recording.
+
+raw = mne.io.read_raw(raw_fname)
+raw_er = mne.io.read_raw(er_fname)
+
+# specify power line frequency as required by BIDS
+raw.info['line_freq'] = 60
+raw_er.info['line_freq'] = 60
+
+bids_path = BIDSPath(
+    subject='01',
+    session='01',
+    task='audiovisual',
+    run='1',
+    root=output_path
+)
+write_raw_bids(
+    raw=raw,
+    bids_path=bids_path,
+    events_data=events_data,
+    event_id=event_id,
+    empty_room=raw_er,
+    overwrite=True
+)
 
 # %%
 # Let's pause and check that the information that we've written out to the
 # sidecar files that describe our data is correct.
 
 # Get the sidecar ``.json`` file
-print(bids_path.copy().update(extension='.json').fpath.read_text(
-    encoding='utf-8-sig'))
+sidecar_json_bids_path = bids_path.copy().update(extension='.json')
+sidecar_json_content = sidecar_json_bids_path.fpath.read_text(
+    encoding='utf-8-sig'
+)
+print(sidecar_json_content)
 
 # %%
 # The sample MEG dataset comes with fine-calibration and crosstalk files that
@@ -127,6 +147,15 @@
 epochs = mne.Epochs(raw, events, event_id)
 epochs['Auditory'].average().plot()
 
+# %%
+# We can easily get the :class:`mne_bids.BIDSPath` of the empty-room recording
+# that was associated with the experimental data while writing. The empty-room
+# data can then be loaded with :func:`read_raw_bids`.
+
+er_bids_path = bids_path.find_empty_room(use_sidecar_only=True)
+er_data = read_raw_bids(er_bids_path)
+er_data
+
 # %%
 # It is trivial to retrieve the path of the fine-calibration and crosstalk
 # files, too.
diff --git a/mne_bids/write.py b/mne_bids/write.py
--- a/mne_bids/write.py
+++ b/mne_bids/write.py
@@ -1339,12 +1339,19 @@ def write_raw_bids(raw, bids_path, events_data=None, event_id=None,
            Symlinks are currently only supported on macOS and Linux. We will
            add support for Windows 10 at a later time.
 
-    empty_room : BIDSPath | None
+    empty_room : mne.io.Raw | BIDSPath | None
         The empty-room recording to be associated with this file. This is
-        only supported for MEG data, and only if the ``root`` attributes of
-        ``bids_path`` and ``empty_room`` are the same. Pass ``None``
-        (default) if you do not wish to specify an associated empty-room
-        recording.
+        only supported for MEG data.
+        If :class:`~mne.io.Raw`, you may pass raw data that was not preloaded
+        (otherwise, pass ``allow_preload=True``); i.e., it behaves similar to
+        the ``raw`` parameter. The session name will be automatically generated
+        from the raw object's ``info['meas_date']``.
+        If a :class:`~mne_bids.BIDSPath`, the ``root`` attribute must be the
+        same as in ``bids_path``. Pass ``None`` (default) if you do not wish to
+        specify an associated empty-room recording.
+
+        .. versionchanged:: 0.11
+           Accepts :class:`~mne.io.Raw` data.
     allow_preload : bool
         If ``True``, allow writing of preloaded raw objects (i.e.,
         ``raw.preload`` is ``True``). Because the original file is ignored, you
@@ -1387,6 +1394,11 @@ def write_raw_bids(raw, bids_path, events_data=None, event_id=None,
     bids_path : BIDSPath
         The path of the created data file.
 
+        .. note::
+           If you passed empty-room raw data via ``empty_room``, the
+           :class:`~mne_bids.BIDSPath` of the empty-room recording can be
+           retrieved via ``bids_path.find_empty_room(use_sidecar_only=True)``.
+
     Notes
     -----
     You should ensure that ``raw.info['subject_info']`` and
@@ -1484,7 +1496,7 @@ def write_raw_bids(raw, bids_path, events_data=None, event_id=None,
                            'array. You need to pass both, or neither.')
 
     _validate_type(item=empty_room, item_name='empty_room',
-                   types=(BIDSPath, None))
+                   types=(mne.io.BaseRaw, BIDSPath, None))
     _validate_type(montage, (mne.channels.DigMontage, None), 'montage')
     _validate_type(acpc_aligned, bool, 'acpc_aligned')
 
@@ -1581,7 +1593,38 @@ def write_raw_bids(raw, bids_path, events_data=None, event_id=None,
                 )
 
     associated_er_path = None
-    if empty_room is not None:
+
+    if isinstance(empty_room, mne.io.BaseRaw):
+        er_date = empty_room.info['meas_date']
+        if not er_date:
+            raise ValueError(
+                'The empty-room raw data must have a valid measurement date '
+                'set. Please update its info["meas_date"] field.'
+            )
+        er_session = er_date.strftime("%Y%m%d")
+        er_bids_path = bids_path.copy().update(
+            subject='emptyroom',
+            session=er_session,
+            task='noise',
+            run=None
+        )
+        write_raw_bids(
+            raw=empty_room,
+            bids_path=er_bids_path,
+            events_data=None,
+            event_id=None,
+            anonymize=anonymize,
+            format=format,
+            symlink=symlink,
+            allow_preload=allow_preload,
+            montage=montage,
+            acpc_aligned=acpc_aligned,
+            overwrite=overwrite,
+            verbose=verbose
+        )
+        associated_er_path = er_bids_path.fpath
+        del er_bids_path, er_date, er_session
+    elif isinstance(empty_room, BIDSPath):
         if bids_path.datatype != 'meg':
             raise ValueError('"empty_room" is only supported for '
                              'MEG data.')
@@ -1591,15 +1634,16 @@ def write_raw_bids(raw, bids_path, events_data=None, event_id=None,
         if bids_path.root != empty_room.root:
             raise ValueError('The MEG data and its associated empty-room '
                              'recording must share the same BIDS root.')
-
         associated_er_path = empty_room.fpath
+
+    if associated_er_path is not None:
         if not associated_er_path.exists():
             raise FileNotFoundError(f'Empty-room data file not found: '
                                     f'{associated_er_path}')
 
         # Turn it into a path relative to the BIDS root
         associated_er_path = Path(str(associated_er_path)
-                                  .replace(str(empty_room.root), ''))
+                                  .replace(str(bids_path.root), ''))
         # Ensure it works on Windows too
         associated_er_path = associated_er_path.as_posix()
 


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
@@ -13,7 +13,6 @@
 import sys
 import os
 import os.path as op
-import pytest
 from glob import glob
 from datetime import datetime, timezone, timedelta
 import shutil as sh
@@ -24,6 +23,7 @@
 
 from pkg_resources import parse_version
 
+import pytest
 import numpy as np
 from numpy.testing import (assert_allclose, assert_array_equal,
                            assert_array_almost_equal)
@@ -3269,7 +3269,10 @@ def test_symlink(tmp_path):
 
 
 @pytest.mark.filterwarnings(warning_str['channel_unit_changed'])
-def test_write_associated_emptyroom(_bids_validate, tmp_path):
+@pytest.mark.parametrize('empty_room_dtype', ['BIDSPath', 'raw'])
+def test_write_associated_emptyroom(
+    _bids_validate, tmp_path, empty_room_dtype
+):
     """Test functionality of the write_raw_bids conversion for fif."""
     bids_root = tmp_path / 'bids1'
     data_path = testing.data_path()
@@ -3277,19 +3280,37 @@ def test_write_associated_emptyroom(_bids_validate, tmp_path):
                         'sample_audvis_trunc_raw.fif')
     raw = _read_raw_fif(raw_fname)
     meas_date = datetime(year=2020, month=1, day=10, tzinfo=timezone.utc)
-    raw.set_meas_date(meas_date)
-
-    # First write "empty-room" data
-    bids_path_er = BIDSPath(subject='emptyroom', session='20200110',
-                            task='noise', root=bids_root, datatype='meg',
-                            suffix='meg', extension='.fif')
-    write_raw_bids(raw, bids_path=bids_path_er)
-
-    # Now we write experimental data and associate it with the empty-room
-    # recording
-    bids_path = bids_path_er.copy().update(subject='01', session=None,
-                                           task='task')
-    write_raw_bids(raw, bids_path=bids_path, empty_room=bids_path_er)
+
+    if empty_room_dtype == 'BIDSPath':
+        # First write "empty-room" data
+        raw.set_meas_date(meas_date)
+        bids_path_er = BIDSPath(subject='emptyroom', session='20200110',
+                                task='noise', root=bids_root, datatype='meg',
+                                suffix='meg', extension='.fif')
+        write_raw_bids(raw, bids_path=bids_path_er)
+
+        # Now we write experimental data and associate it with the empty-room
+        # recording
+        bids_path = bids_path_er.copy().update(
+            subject='01', session=None, task='task'
+        )
+        write_raw_bids(raw, bids_path=bids_path, empty_room=bids_path_er)
+    elif empty_room_dtype == 'raw':
+        bids_path = _bids_path.copy().update(
+            subject='01', session='session', task='task', suffix='meg',
+            extension='.fif', datatype='meg', root=bids_root
+        )
+
+        # Should raise if no measurement date was provided
+        raw.set_meas_date(None)
+        with pytest.raises(ValueError, match='empty-room .* measurement date'):
+            write_raw_bids(raw, bids_path=bids_path, empty_room=raw)
+
+        # With a proper measurement date it should work
+        raw.set_meas_date(meas_date)
+        write_raw_bids(raw, bids_path=bids_path, empty_room=raw)
+        bids_path_er = bids_path.find_empty_room()
+
     _bids_validate(bids_path.root)
 
     meg_json_path = bids_path.copy().update(extension='.json')