Repository: mne-tools/mne-bids

Issue:
BUG: Error when writing calibration for empty-room file
I tried writing a fine cal file for empty-room data and got:
```
    write_meg_calibration(this_path / "sss_cal.dat", bids_path=bp)
  File "<decorator-gen-420>", line 12, in write_meg_calibration
  File "/home/larsoner/python/mne-bids/mne_bids/write.py", line 2616, in write_meg_calibration
    out_path = BIDSPath(
               ^^^^^^^^^
  File "/home/larsoner/python/mne-bids/mne_bids/path.py", line 367, in __init__
    self.update(
  File "/home/larsoner/python/mne-bids/mne_bids/path.py", line 1017, in update
    raise e
  File "/home/larsoner/python/mne-bids/mne_bids/path.py", line 1011, in update
    self._check()
  File "/home/larsoner/python/mne-bids/mne_bids/path.py", line 1077, in _check
    _check_empty_room_basename(self)
  File "/home/larsoner/python/mne-bids/mne_bids/utils.py", line 341, in _check_empty_room_basename
    raise ValueError(msg)
ValueError: task must be "noise" if subject is "emptyroom", but received: None
```
This is because in `write_meg_calibration` it does:
```
    out_path = BIDSPath(
        subject=bids_path.subject,
        session=bids_path.session,
        acquisition="calibration",
        suffix="meg",
        extension=".dat",
        datatype="meg",
        root=bids_path.root,
    )
```
So it copies the subject (`"emptyroom"`) but not the task `("noise")`.

It seems like either the logic should be reworked here a bit, or an informative error should be raised if you try to write fine cal for empy room data.


Discussion:
Absolutely, we shouldn't expect `task-noise` when writing empty-room fine-calibration and cross-talk
agreed, these files shouldn't even have a `task` entity: https://bids-specification.readthedocs.io/en/latest/appendices/meg-file-formats.html#cross-talk-and-fine-calibration-files

Resolving patch:
diff --git a/mne_bids/path.py b/mne_bids/path.py
--- a/mne_bids/path.py
+++ b/mne_bids/path.py
@@ -1073,8 +1073,16 @@ def _check(self):
 
         # perform deeper check if user has it turned on
         if self.check:
-            if self.subject == "emptyroom":
-                _check_empty_room_basename(self)
+            _check_empty_room_basename(self)
+
+            if (
+                self.acquisition in ("calibration", "crosstalk")
+                and self.task is not None
+            ):
+                raise ValueError(
+                    f'task must be None if the acquisition is "calibration" or '
+                    f'"crosstalk", but received: {self.task}'
+                )
 
             # ensure extension starts with a '.'
             extension = self.extension
diff --git a/mne_bids/utils.py b/mne_bids/utils.py
--- a/mne_bids/utils.py
+++ b/mne_bids/utils.py
@@ -329,20 +329,19 @@ def _scale_coord_to_meters(coord, unit):
         return coord
 
 
-def _check_empty_room_basename(bids_path, on_invalid_er_task="raise"):
+def _check_empty_room_basename(bids_path):
+    if bids_path.subject != "emptyroom":
+        return
     # only check task entity for emptyroom when it is the sidecar/MEG file
-    if bids_path.suffix == "meg":
-        if bids_path.task != "noise":
-            msg = (
-                f'task must be "noise" if subject is "emptyroom", but '
-                f"received: {bids_path.task}"
-            )
-            if on_invalid_er_task == "raise":
-                raise ValueError(msg)
-            elif on_invalid_er_task == "warn":
-                logger.critical(msg)
-            else:
-                pass
+    if bids_path.suffix != "meg":
+        return
+    if bids_path.acquisition in ("calibration", "crosstalk"):
+        return
+    if bids_path.task != "noise":
+        raise ValueError(
+            f'task must be "noise" if subject is "emptyroom", but '
+            f"received: {bids_path.task}"
+        )
 
 
 def _check_anonymize(anonymize, raw, ext):


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
@@ -2660,6 +2660,10 @@ def test_write_meg_calibration(_bids_validate, tmp_path):
     write_meg_calibration(calibration=calibration, bids_path=bids_path)
     _bids_validate(bids_root)
 
+    # subject emptyroom
+    bids_path_erm = bids_path.copy().update(subject="emptyroom", task="noise")
+    write_meg_calibration(calibration=fine_cal_fname, bids_path=bids_path_erm)
+
     # Test passing in incompatible dict.
     calibration = mne.preprocessing.read_fine_calibration(fine_cal_fname)
     del calibration["locs"]
@@ -2686,7 +2690,7 @@ def test_write_meg_calibration(_bids_validate, tmp_path):
 def test_write_meg_crosstalk(_bids_validate, tmp_path):
     """Test writing of the Elekta/Neuromag fine-calibration file."""
     bids_root = tmp_path / "bids1"
-    bids_path = _bids_path.copy().update(root=bids_root)
+    bids_path = _bids_path.copy().update(root=bids_root, suffix="meg")
 
     raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
     raw = _read_raw_fif(raw_fname, verbose=False)
@@ -2697,6 +2701,13 @@ def test_write_meg_crosstalk(_bids_validate, tmp_path):
     write_meg_crosstalk(fname=crosstalk_fname, bids_path=bids_path)
     _bids_validate(bids_root)
 
+    # subject emptyroom
+    bids_path_erm = bids_path.copy().update(subject="emptyroom", task="noise")
+    write_meg_crosstalk(fname=crosstalk_fname, bids_path=bids_path_erm)
+
+    # bad path when task is provided for crosstalk
+    with pytest.raises(ValueError, match="task must be None if the acquisition is"):
+        assert bids_path_erm.copy().update(acquisition="crosstalk").task == "noise"
     # subject not set.
     bids_path = bids_path.copy().update(root=bids_root, subject=None)
     with pytest.raises(ValueError, match="must have root and subject set"):