Repository: mne-tools/mne-bids

Issue:
We are misusing info["subject_info"]
As revealed by:

- https://github.com/mne-tools/mne-python/pull/12875

And this CI failure: https://github.com/mne-tools/mne-bids/actions/runs/11081048651/job/30792323679#step:16:327

We are adding some keys to `info["subject_info"]` that should not be added:

https://github.com/mne-tools/mne-bids/blob/9575eabd893002819a54d3128d923fcc69db1dca/mne_bids/read.py#L290-L293

We should only add items to `info["subject_info"]` that are also permitted, see the documentation: https://mne.tools/stable/generated/mne.Info.html (search for `subject_info`).

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/mne-tools/mne-bids/pull/1312?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mne-tools) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 97.35%. Comparing base [(`9575eab`)](https://app.codecov.io/gh/mne-tools/mne-bids/commit/9575eabd893002819a54d3128d923fcc69db1dca?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mne-tools) to head [(`3049ae6`)](https://app.codecov.io/gh/mne-tools/mne-bids/commit/3049ae60eff6db5196ae337ab54370185ec89f80?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mne-tools).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1312      +/-   ##
==========================================
- Coverage   97.46%   97.35%   -0.11%     
==========================================
  Files          40       40              
  Lines        8781     8810      +29     
==========================================
+ Hits         8558     8577      +19     
- Misses        223      233      +10     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/mne-tools/mne-bids/pull/1312?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mne-tools).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mne-tools).


Patch:
diff --git a/mne_bids/read.py b/mne_bids/read.py
index e0281899..b216b03c 100644
--- a/mne_bids/read.py
+++ b/mne_bids/read.py
@@ -7,7 +7,7 @@ import json
 import os
 import os.path as op
 import re
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
 from difflib import get_close_matches
 from pathlib import Path
 
@@ -260,38 +260,72 @@ def _handle_participants_reading(participants_fname, raw, subject):
     raw.info["subject_info"] = dict()  # start from scratch
 
     # set data from participants tsv into subject_info
+    # TODO: Could potentially use "comment" someday to store other options e.g. in JSON
+    # https://github.com/mne-tools/fiff-constants/blob/e27f68cbf74dbfc5193ad429cc77900a59475181/DictionaryTags.txt#L369
+    allowed_keys = set(
+        """
+    id his_id last_name first_name middle_name birthday sex hand weight height
+    """.strip().split()
+    )
+    bad_key_vals = list()
     for col_name, value in participants_tsv.items():
+        orig_value = value = value[row_ind]
         if col_name in ("sex", "hand"):
-            value = _map_options(
-                what=col_name, key=value[row_ind], fro="bids", to="mne"
-            )
+            value = _map_options(what=col_name, key=value, fro="bids", to="mne")
             # We don't know how to translate to MNE, so skip.
             if value is None:
                 if col_name == "sex":
                     info_str = "subject sex"
                 else:
                     info_str = "subject handedness"
-                warn(
-                    f'Unable to map "{col_name}" value "{value}" to MNE. '
-                    f"Not setting {info_str}."
-                )
+                bad_key_vals.append((col_name, orig_value, info_str))
         elif col_name in ("height", "weight"):
             try:
-                value = float(value[row_ind])
+                value = float(value)
             except ValueError:
                 value = None
+        elif col_name == "age":
+            if raw.info["meas_date"] is None:
+                value = None
+            elif value is not None:
+                try:
+                    value = float(value)
+                except Exception:
+                    value = None
+                else:
+                    value = (
+                        raw.info["meas_date"]
+                        - timedelta(days=int(np.ceil(365.25 * value)))
+                    ).date()
         else:
-            if value[row_ind] == "n/a":
+            if value == "n/a":
                 value = None
-            else:
-                value = value[row_ind]
+
+        # adjust keys to match MNE nomenclature
+        key = col_name
+        if col_name == "participant_id":
+            key = "his_id"
+        elif col_name == "age":
+            key = "birthday"
+
+        if key not in allowed_keys:
+            bad_key_vals.append((col_name, orig_value, None))
+            continue
 
         # add data into raw.Info
-        key = "his_id" if col_name == "participant_id" else col_name
         if value is not None:
             assert key not in raw.info["subject_info"]
             raw.info["subject_info"][key] = value
 
+    if bad_key_vals:
+        warn_str = "Unable to map the following column(s) to to MNE:"
+        for col_name, orig_value, info_str in bad_key_vals:
+            warn_str += f"\n{col_name}"
+            if info_str is not None:
+                warn_str += f" ({info_str})"
+            warn_str += f": {orig_value}"
+        warn(warn_str)
+
     return raw
 
 


Test patch:
diff --git a/mne_bids/tests/test_copyfiles.py b/mne_bids/tests/test_copyfiles.py
index 68f89673..763d2f20 100644
--- a/mne_bids/tests/test_copyfiles.py
+++ b/mne_bids/tests/test_copyfiles.py
@@ -96,7 +96,7 @@ def test_copyfile_brainvision(tmp_path):
 
     # Try to read with MNE - if this works, the links are correct
     raw = mne.io.read_raw_brainvision(new_name)
-    assert raw.filenames[0] == (op.join(head, "tested_conversion.eeg"))
+    assert Path(raw.filenames[0]) == Path(head) / "tested_conversion.eeg"
 
     # Test with anonymization
     raw = mne.io.read_raw_brainvision(raw_fname)
diff --git a/mne_bids/tests/test_read.py b/mne_bids/tests/test_read.py
index ac58858c..9594b2fc 100644
--- a/mne_bids/tests/test_read.py
+++ b/mne_bids/tests/test_read.py
@@ -148,8 +148,10 @@ def test_read_participants_data(tmp_path):
     participants_tsv_fpath = tmp_path / "participants.tsv"
     participants_tsv = _from_tsv(participants_tsv_fpath)
     participants_tsv["hand"][0] = "n/a"
+    participants_tsv["outcome"] = ["good"]  # e.g. clinical tutorial from MNE-Python
     _to_tsv(participants_tsv, participants_tsv_fpath)
-    raw = read_raw_bids(bids_path=bids_path)
+    with pytest.warns(RuntimeWarning, match="Unable to map"):
+        raw = read_raw_bids(bids_path=bids_path)
     assert raw.info["subject_info"]["hand"] == 0
     assert raw.info["subject_info"]["sex"] == 2
     assert raw.info["subject_info"]["weight"] == 70.5
@@ -163,6 +165,7 @@ def test_read_participants_data(tmp_path):
     # 'n/a' values should get omitted
     participants_tsv["weight"] = ["n/a"]
     participants_tsv["height"] = ["tall"]
+    del participants_tsv["outcome"]
 
     _to_tsv(participants_tsv, participants_tsv_fpath)
     with pytest.warns(RuntimeWarning, match="Unable to map"):
diff --git a/mne_bids/tests/test_write.py b/mne_bids/tests/test_write.py
index ffbb7de3..63485cda 100644
--- a/mne_bids/tests/test_write.py
+++ b/mne_bids/tests/test_write.py
@@ -95,7 +95,7 @@ warning_str = dict(
     cnt_warning2="ignore:.*Could not define the number of bytes automatically."
     " Defaulting to 2.",
     cnt_warning3="ignore:.*Coordinate frame could not be inferred.*",
-    no_hand="ignore:.*Not setting subject handedness.:RuntimeWarning:mne",
+    no_hand=r"ignore:Unable to map.*\n.*subject handedness.*:RuntimeWarning:mne",
     no_montage=r"ignore:Not setting position of.*channel found in "
     r"montage.*:RuntimeWarning:mne",
 )
@@ -103,7 +103,7 @@ warning_str = dict(
 
 def _wrap_read_raw(read_raw):
     def fn(fname, *args, **kwargs):
-        if str(fname).endswith(".mff") and check_version("mne", "1.8"):
+        if Path(fname).suffix == ".mff" and check_version("mne", "1.8"):
             kwargs["events_as_annotations"] = True
         raw = read_raw(fname, *args, **kwargs)
         raw.info["line_freq"] = 60
@@ -3414,10 +3414,10 @@ def test_convert_eeg_formats(dir_name, fmt, fname, reader, tmp_path):
     assert channels_tsv["units"][0] == "V"
 
     if fmt == "BrainVision":
-        assert raw2.filenames[0].endswith(".eeg")
+        assert Path(raw2.filenames[0]).suffix == ".eeg"
         assert bids_output_path.extension == ".vhdr"
     elif fmt == "EDF":
-        assert raw2.filenames[0].endswith(".edf")
+        assert Path(raw2.filenames[0]).suffix == ".edf"
         assert bids_output_path.extension == ".edf"
 
     orig_len = len(raw)
@@ -3513,7 +3513,7 @@ def test_convert_meg_formats(dir_name, fmt, fname, reader, tmp_path):
     raw2 = read_raw_bids(bids_output_path)
 
     if fmt == "FIF":
-        assert raw2.filenames[0].endswith(".fif")
+        assert Path(raw2.filenames[0]).suffix == ".fif"
         assert bids_output_path.extension == ".fif"
 
     orig_len = len(raw)
@@ -4086,16 +4086,16 @@ def test_unknown_extension(_bids_validate, tmp_path):
     raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
 
     raw = _read_raw_fif(raw_fname)
-    raw._filenames = (raw.filenames[0].replace(".fif", ".foo"),)
+    raw._filenames = (Path(raw.filenames[0]).with_suffix(".foo"),)
 
     # When data is not preloaded, we should raise an exception.
     with pytest.raises(ValueError, match="file format not supported by BIDS"):
         write_raw_bids(raw, bids_path)
 
     # With preloaded data, writing should work.
-    raw._filenames = (raw.filenames[0].replace(".foo", ".fif"),)
+    raw._filenames = (Path(raw.filenames[0]).with_suffix(".fif"),)
     raw.load_data()
-    raw._filenames = (raw.filenames[0].replace(".fif", ".foo"),)
+    raw._filenames = (Path(raw.filenames[0]).with_suffix(".foo"),)
 
     write_raw_bids(raw, bids_path, allow_preload=True, format="FIF")
     _bids_validate(bids_root)