Repository: mne-tools/mne-bids
Pull request #1389 (2025-04-03 12:47:36)

Refactor _handle_events_reading to allow extracting annotation information stand-alone
<!--
Thanks for contributing this pull request (PR).
If this is your first time, make sure to read
[CONTRIBUTING.md](https://github.com/mne-tools/mne-bids/blob/main/CONTRIBUTING.md)
-->

PR Description
--------------

**Aim**: 
The `_handle_events_reading` in [read.py](https://github.com/matthiasdold/mne-bids/blob/5d4320f0239c5efac25261d123834b1cb0357b1e/mne_bids/read.py#L526) reads onsets, duration and descriptions/event_ids from the `events.tsv`, then filters the columns for n\a values etc. and finally sets annotations to the raw object based on these filtered version of the `events.tsv` data.

```python
    annot_from_raw = raw.annotations.copy()
    annot_from_events = mne.Annotations(onset=ons, duration=durs, description=descrs)
    raw.set_annotations(annot_from_events)
```

On default, this will encode information from three columns within the `events.tsv`.

In order to [extract additional information from BIDS dataset within MOABB](https://github.com/NeuroTechX/moabb/pull/744) it would be necessary to ensure that all annotations that end up in the raw and whatever is extracted from the `events.tsv` are aligned. To allow for this, it would be necessary to have any filtering that happens in [L526-602](https://github.com/matthiasdold/mne-bids/blob/5d4320f0239c5efac25261d123834b1cb0357b1e/mne_bids/read.py#L526C1-L602C1) accessible standalone (without the need to provide a `raw`).

**Proposition**:
Simply have the first part of the [`_handle_events_reading`](https://github.com/matthiasdold/mne-bids/blob/5d4320f0239c5efac25261d123834b1cb0357b1e/mne_bids/read.py#L526) as its own function which returns the filtered version of the onset, description, duration and event_ids. Assuming that these columns would form a primary key within the `events.tsv`, we could always map additional meta info to the annotations in the raw unambiguously.


<!--Describe your PR here-->

Merge checklist
---------------

Maintainer, please confirm the following before merging.
If applicable:

- [ ] All comments are resolved
- [ ] This is not your own PR
- [ ] All CIs are happy
- [ ] PR title starts with [MRG]
- [ ] [whats_new.rst](https://github.com/mne-tools/mne-bids/blob/main/doc/whats_new.rst) is updated
- [ ] New contributors have been added to [CITATION.cff](https://github.com/mne-tools/mne-bids/blob/main/CITATION.cff)
- [ ] PR description includes phrase "closes <#issue-number>"


welcome[bot]: Hello! 👋 Thanks for opening your first pull request here!
Please read the [contributor guide](https://github.com/mne-tools/mne-bids/blob/main/CONTRIBUTING.md), and please follow the steps outlined in the *"Instructions for first-time contributors"* section therein. ❤️ We will try to get back to you soon. 🚴🏽‍♂️

matthiasdold: > Hi @matthiasdold to make this function public, it would need to get a more extensive docstr (numpydoc), and preferably also be used in a public example.
> 
> You may (or not) also need to add it here in some way: https://github.com/mne-tools/mne-bids/blob/main/mne_bids/__init__.py
> 
> There are also some test failures here that I haven't looked into yet.

Hi @sappelhoff, thanks for checking this out. I actually think it could be kept `private` / `_events_file_to_annotation_kwargs` function as in the [first commit](https://github.com/mne-tools/mne-bids/pull/1389/commits/c2d078ae56a173f5ef9d2db53e0d80d849bb1f64). The reason why I was later putting it to public was that I could not get the docs to build and making it public + introducing it to the `__init__.py` at least had sphynx find it. 

Unfortunately, I still cannot get the docs to build, neither locally nor here on the server. Which is very puzzling to me, as the change really is just separating the existing functions... I will have another try later this afternoon, but any hints would be much appreciated.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/mne-tools/mne-bids/pull/1389?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.42%. Comparing base [(`5d4320f`)](https://app.codecov.io/gh/mne-tools/mne-bids/commit/5d4320f0239c5efac25261d123834b1cb0357b1e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mne-tools) to head [(`2942010`)](https://app.codecov.io/gh/mne-tools/mne-bids/commit/294201008fb938eaf0cb357835820c505fb3d8df?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    #1389      +/-   ##
==========================================
- Coverage   97.48%   97.42%   -0.06%     
==========================================
  Files          40       40              
  Lines        9023     9027       +4     
==========================================
- Hits         8796     8795       -1     
- Misses        227      232       +5     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/mne-tools/mne-bids/pull/1389?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).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
drammock: > WDYT @drammock

I wasn't super familiar with the internals of what MNE-BIDS did in terms of reading `events.tsv` prior to looking at this PR.  My feeling (from a quick skim and quick think) is that:

1. there should be a public func that reads `events.tsv` files.
2. I'm conflicted about whether it should return an actual annotations object, a dict suitable for passing to `mne.Annotations`, or a dataframe. I guess the Annotations and the DataFrame can be quite easily constructed from the dict, so I guess the dict is most general/flexible.
3. for users who want an events array, they can use `mne.events_from_annotations` and we should mention that in the docstring.
4. The changelog should only mention this new public function; users won't care / don't need to know that it was part of a private function that was refactored into a separate thing.

matthiasdold: >     2. I'm conflicted about whether it should return an actual annotations object, a dict suitable for passing to `mne.Annotations`, or a dataframe. I guess the Annotations and the DataFrame can be quite easily constructed from the dict, so I guess the dict is most general/flexible.


Operating on a (pandas) dataframe would also lead to a more readable code as we can use standard filtering instead of [`_drop`](https://github.com/mne-tools/mne-bids/blob/5d4320f0239c5efac25261d123834b1cb0357b1e/mne_bids/tsv_handler.py#L92). Although just a subset of the functionality, the dataframe is what I am using in the meantime for [moabb](https://github.com/NeuroTechX/moabb/pull/744/commits/942a52026ca8023d3327bc231a228bb7ed678d00#diff-80115723a6ae060787a6ddfa3b7c8922cfc196a391ef65a7284224a19b99187d).

I didn't go the dataframe route as I wanted to be minimally invasive. Happy to provide a dataframe version as well.
sappelhoff: Thanks for your view, Dan.

I agree with your points 1, 3 and 4 -- and regarding point 2, I think returning a dict is the way to go. We do not have pandas as a required dependency of mne-bids, and I don't think the current "feature" is enough to warrant it. 
matthiasdold: > @matthiasdold I think you need double backquotes for code samples. This might be why your doc build fails https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html

Thx for the suggestion @PierreGtch  - unfortunately this seems not to be the issue. As for the :func: part, all other lines only had single backquotes, so I would not expect this to be a porblem there (the error message states that `mne_bids.events_file_to_annotations_kwargs` cannot be found, however the import works for the pytest... 

I am a bit clueless here...
PierreGtch: @matthiasdold your issue is not anymore with building the doc but before, when the ci is trying to pull the code
It is probably unrelated to you
drammock: > something is fishy with circleci.

the error looks totally related to me:
https://app.circleci.com/pipelines/github/mne-tools/mne-bids/6995/workflows/6d22be6e-fce2-43b0-a5d2-a0712a9fd896/jobs/9247?invite=true#step-108-0_142

```
/home/circleci/project/doc/whats_new.rst:43: WARNING: py:func reference target not found: mne_bids.events_file_to_annotation_kwargs [ref.func]
```

same error here:
https://github.com/mne-tools/mne-bids/actions/runs/14379592851/job/40319969519?pr=1389#step:6:257

```
/home/runner/work/mne-bids/mne-bids/doc/whats_new.rst:43: WARNING: py:func reference target not found: mne_bids.events_file_to_annotation_kwargs [ref.func]
```

drammock: add the new public function name to `doc/api.rst`
sappelhoff: > the error looks totally related to me:

I was talking about this one:

https://app.circleci.com/pipelines/github/mne-tools/mne-bids/6993/workflows/1783535a-8a54-4b94-ac2b-ace4331f6e3f/jobs/9245

![image](https://github.com/user-attachments/assets/5bb8ae77-02b3-4a6a-a854-2424f4aa82f9)

sappelhoff: yet https://github.com/mne-tools/mne-bids/pull/1391 is unaffected, so it must be something about this PR.
sappelhoff: ok fine, the errors that circleci now provides all seem meaningful and related :-) no idea what I was after in https://github.com/mne-tools/mne-bids/pull/1389#issuecomment-2794796715

@matthiasdold this should be possible for you to address. I am +1 for merging once everything is green. thanks a lot!
drammock: here's the rendered docstring:
https://output.circle-artifacts.com/output/job/1e8c4f31-cedf-4fcd-a9ef-94de47d8437b/artifacts/0/dev/generated/mne_bids.events_file_to_annotation_kwargs.html

see how the notes section bullet list isn't formatted right, and the formatting in the Returns section is inconsistent.

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 2acb64e6..4231ec20 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
 repos:
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.11.2
+    rev: v0.11.4
     hooks:
       - id: ruff
         name: ruff mne_bids/
diff --git a/CITATION.cff b/CITATION.cff
index 790e6d7d..b8f948e4 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -206,6 +206,10 @@ authors:
       family-names: Gerçek
       affiliation: 'University of Geneva, Department of Fundamental Neuroscience'
       orcid: 'https://orcid.org/0000-0003-1063-6769'
+    - given-names: Matthias
+      family-names: Dold
+      affiliation: 'Donders Institute for Brain, Cognition and Behaviour, Radboud University, Nijmegen, Netherlands'
+      orcid: 'https://orcid.org/0009-0003-1477-4912'
     - given-names: Alexandre
       family-names: Gramfort
       affiliation: 'Université Paris-Saclay, Inria, CEA, Palaiseau, France'
diff --git a/doc/api.rst b/doc/api.rst
index d8874d6d..cb2298be 100644
--- a/doc/api.rst
+++ b/doc/api.rst
@@ -45,6 +45,7 @@ mne_bids
    update_sidecar_json
    anonymize_dataset
    find_matching_paths
+   events_file_to_annotation_kwargs
 
 mne_bids.stats
 --------------
diff --git a/doc/authors.rst b/doc/authors.rst
index 22bcd1e4..f6775823 100644
--- a/doc/authors.rst
+++ b/doc/authors.rst
@@ -35,6 +35,7 @@
 .. _Mara Wolter: https://github.com/marakw
 .. _Marijn van Vliet: https://github.com/wmvanvliet
 .. _Mathieu Scheltienne: https://github.com/mscheltienne
+.. _Matthias Dold: https://github.com/matthiasdold
 .. _Matt Sanderson: https://github.com/monkeyman192
 .. _Maximilien Chaumon: https://github.com/dnacombo
 .. _Moritz Gerster: http://moritz-gerster.com
diff --git a/doc/whats_new.rst b/doc/whats_new.rst
index b9db98f7..4bcd3f90 100644
--- a/doc/whats_new.rst
+++ b/doc/whats_new.rst
@@ -20,6 +20,7 @@ The following authors contributed for the first time. Thank you so much! 🤩
 * `Christian O'Reilly`_
 * `Berk Gerçek`_
 * `Arne Gottwald`_
+* `Matthias Dold`_
 
 The following authors had contributed before. Thank you for sticking around! 🤘
 
@@ -39,6 +40,7 @@ Detailed list of changes
 - Empty-room matching now preferentially finds recordings in the subject directory tagged as `task-noise` before looking in the `sub-emptyroom` directories. This adds support for a part of the BIDS specification for ER recordings, by `Berk Gerçek`_ (:gh:`1364`)
 - Path matching is now implemenented in a more efficient manner within :meth:`mne_bids.BIDSPath.match()` and :func:`mne_bids.find_matching_paths()`, by `Arne Gottwald` (:gh:`1355`)
 - :func:`mne_bids.get_entity_vals()` has a new parameter ``include_match`` to prefilter item matching and ignore non-matched items from begin of directory scan, by `Arne Gottwald` (:gh:`1355`)
+- Data from ``events.tsv`` can now be read into an OrderedDict using :func:`mne_bids.events_file_to_annotation_kwargs()`, by `Matthias Dold` (:gh:`1389`)
 
 
 🧐 API and behavior changes
diff --git a/mne_bids/__init__.py b/mne_bids/__init__.py
index 23cc18ce..94199353 100644
--- a/mne_bids/__init__.py
+++ b/mne_bids/__init__.py
@@ -22,7 +22,11 @@ from mne_bids.path import (
     get_bids_path_from_fname,
     find_matching_paths,
 )
-from mne_bids.read import get_head_mri_trans, read_raw_bids
+from mne_bids.read import (
+    get_head_mri_trans,
+    read_raw_bids,
+    events_file_to_annotation_kwargs,
+)
 from mne_bids.utils import get_anonymization_daysback
 from mne_bids.write import (
     make_dataset_description,
diff --git a/mne_bids/read.py b/mne_bids/read.py
index 191378d5..bdc33422 100644
--- a/mne_bids/read.py
+++ b/mne_bids/read.py
@@ -523,8 +523,70 @@ def _handle_info_reading(sidecar_fname, raw):
     return raw
 
 
-def _handle_events_reading(events_fname, raw):
-    """Read associated events.tsv and convert valid events to annotations on Raw."""
+def events_file_to_annotation_kwargs(events_fname: str | Path) -> dict:
+    r"""
+    Read the ``events.tsv`` file and extract onset, duration, and description.
+
+    Parameters
+    ----------
+    events_fname : str
+        The file path to the ``events.tsv`` file.
+
+    Returns
+    -------
+    kwargs_dict : dict
+
+        A dictionary containing the following keys:
+
+        - 'onset' : np.ndarray
+            The onset times of the events in seconds.
+        - 'duration' : np.ndarray
+            The durations of the events in seconds.
+        - 'description' : np.ndarray
+            The descriptions of the events.
+        - 'event_id' : dict
+            A dictionary mapping event descriptions to integer event IDs.
+
+    Notes
+    -----
+    The function handles the following cases:
+
+    - If the ``trial_type`` column is available, it uses it for event descriptions.
+    - If the ``stim_type`` column is available, it uses it for backward compatibility.
+    - If the ``value`` column is available, it uses it to create the ``event_id``.
+    - If none of the above columns are available, it defaults to using 'n/a' for
+      descriptions and 1 for event IDs.
+
+    Examples
+    --------
+    >>> import pandas as pd
+    >>> from pathlib import Path
+    >>> import tempfile
+    >>>
+    >>> # Create a sample DataFrame
+    >>> data = {
+    ...     'onset': [0.1, 0.2, 0.3],
+    ...     'duration': [0.1, 0.1, 0.1],
+    ...     'trial_type': ['event1', 'event2', 'event1'],
+    ...     'value': [1, 2, 1],
+    ...     'sample': [10, 20, 30]
+    ... }
+    >>> df = pd.DataFrame(data)
+    >>>
+    >>> # Write the DataFrame to a temporary file
+    >>> temp_dir = tempfile.gettempdir()
+    >>> events_file = Path(temp_dir) / 'events.tsv'
+    >>> df.to_csv(events_file, sep='\t', index=False)
+    >>>
+    >>> # Read the events file using the function
+    >>> events_dict = events_file_to_annotation_kwargs(events_file)
+    >>> events_dict
+    {'onset': array([0.1, 0.2, 0.3]),
+    'duration': array([0.1, 0.1, 0.1]),
+    'description': array(['event1', 'event2', 'event1'], dtype='<U6'),
+    'event_id': {'event1': 1, 'event2': 2}}
+
+    """
     logger.info(f"Reading events from {events_fname}.")
     events_dict = _from_tsv(events_fname)
 
@@ -601,9 +663,21 @@ def _handle_events_reading(events_fname, raw):
         [0 if du == "n/a" else du for du in events_dict["duration"]], dtype=float
     )
 
+    return {"onset": ons, "duration": durs, "description": descrs, "event_id": event_id}
+
+
+def _handle_events_reading(events_fname, raw):
+    """Read associated events.tsv and convert valid events to annotations on Raw."""
+    annotations_info = events_file_to_annotation_kwargs(events_fname)
+    event_id = annotations_info["event_id"]
+
     # Add events as Annotations, but keep essential Annotations present in raw file
     annot_from_raw = raw.annotations.copy()
-    annot_from_events = mne.Annotations(onset=ons, duration=durs, description=descrs)
+    annot_from_events = mne.Annotations(
+        onset=annotations_info["onset"],
+        duration=annotations_info["duration"],
+        description=annotations_info["description"],
+    )
     raw.set_annotations(annot_from_events)
 
     annot_idx_to_keep = [


Test patch:
diff --git a/mne_bids/tests/test_path.py b/mne_bids/tests/test_path.py
index 5f0abeda..21f362c0 100644
--- a/mne_bids/tests/test_path.py
+++ b/mne_bids/tests/test_path.py
@@ -64,9 +64,7 @@ def return_bids_test_dir(tmp_path_factory):
         "Smiley": 5,
         "Button": 32,
     }
-    events_fname = op.join(
-        data_path, "MEG", "sample", "sample_audvis_trunc_raw-eve.fif"
-    )
+    events_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw-eve.fif"
     cal_fname = op.join(data_path, "SSS", "sss_cal_mgh.dat")
     crosstalk_fname = op.join(data_path, "SSS", "ct_sparse.fif")
 
diff --git a/mne_bids/tests/test_read.py b/mne_bids/tests/test_read.py
index b6adb523..c1cd3be0 100644
--- a/mne_bids/tests/test_read.py
+++ b/mne_bids/tests/test_read.py
@@ -14,6 +14,7 @@ from pathlib import Path
 
 import mne
 import numpy as np
+import pandas as pd
 import pytest
 from mne.datasets import testing
 from mne.io.constants import FIFF
@@ -32,6 +33,7 @@ from mne_bids.read import (
     _handle_events_reading,
     _handle_scans_reading,
     _read_raw,
+    events_file_to_annotation_kwargs,
     get_head_mri_trans,
     read_raw_bids,
 )
@@ -226,9 +228,7 @@ def test_get_head_mri_trans(tmp_path):
     """Test getting a trans object from BIDS data."""
     nib = pytest.importorskip("nibabel")
 
-    events_fname = op.join(
-        data_path, "MEG", "sample", "sample_audvis_trunc_raw-eve.fif"
-    )
+    events_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw-eve.fif"
     subjects_dir = op.join(data_path, "subjects")
 
     # Drop unknown events.
@@ -1466,3 +1466,78 @@ def test_gsr_and_temp_reading():
     raw = read_raw_bids(bids_path)
     assert raw.get_channel_types(["GSR"]) == ["gsr"]
     assert raw.get_channel_types(["Temperature"]) == ["temperature"]
+
+
+def test_events_file_to_annotation_kwargs(tmp_path):
+    """Test that events file is read correctly."""
+    bids_path = BIDSPath(
+        subject="01", session="eeg", task="rest", datatype="eeg", root=tiny_bids_root
+    )
+    events_fname = _find_matching_sidecar(bids_path, suffix="events", extension=".tsv")
+
+    # ---------------- plain read --------------------------------------------
+    df = pd.read_csv(events_fname, sep="\t")
+    ev_kwargs = events_file_to_annotation_kwargs(events_fname=events_fname)
+
+    np.testing.assert_equal(ev_kwargs["onset"], df["onset"].values)
+    np.testing.assert_equal(ev_kwargs["duration"], df["duration"].values)
+    np.testing.assert_equal(ev_kwargs["description"], df["trial_type"].values)
+
+    # ---------------- filtering out n/a values ------------------------------
+    tmp_tsv_file = tmp_path / "events.tsv"
+    dext = pd.concat(
+        [df.copy().assign(onset=df.onset + i) for i in range(5)]
+    ).reset_index(drop=True)
+
+    dext = dext.assign(
+        ix=range(len(dext)),
+        value=dext.trial_type.map({"start_experiment": 1, "show_stimulus": 2}),
+        duration=1.0,
+    )
+
+    # nan values for `_drop` must be string values, `_drop` is called on
+    # `onset`, `value` and `trial_type`. `duration` n/a should end up as float 0
+    for c in ["onset", "value", "trial_type", "duration"]:
+        dext[c] = dext[c].astype(str)
+
+    dext.loc[0, "onset"] = "n/a"
+    dext.loc[1, "duration"] = "n/a"
+    dext.loc[4, "trial_type"] = "n/a"
+    dext.loc[4, "value"] = (
+        "n/a"  # to check that filtering is also applied when we drop the `trial_type`
+    )
+    dext.to_csv(tmp_tsv_file, sep="\t", index=False)
+
+    ev_kwargs_filtered = events_file_to_annotation_kwargs(events_fname=tmp_tsv_file)
+
+    dext_f = dext[
+        (dext["onset"] != "n/a")
+        & (dext["trial_type"] != "n/a")
+        & (dext["value"] != "n/a")
+    ]
+
+    assert (ev_kwargs_filtered["onset"] == dext_f["onset"].astype(float).values).all()
+    assert (
+        ev_kwargs_filtered["duration"]
+        == dext_f["duration"].replace("n/a", "0.0").astype(float).values
+    ).all()
+    assert (ev_kwargs_filtered["description"] == dext_f["trial_type"].values).all()
+    assert (
+        ev_kwargs_filtered["duration"][0] == 0.0
+    )  # now idx=0, as first row is filtered out
+
+    # ---------------- default if missing trial_type  ------------------------
+    dext.drop(columns="trial_type").to_csv(tmp_tsv_file, sep="\t", index=False)
+
+    ev_kwargs_default = events_file_to_annotation_kwargs(events_fname=tmp_tsv_file)
+    np.testing.assert_array_equal(
+        ev_kwargs_default["onset"], dext_f["onset"].astype(float).values
+    )
+    np.testing.assert_array_equal(
+        ev_kwargs_default["duration"],
+        dext_f["duration"].replace("n/a", "0.0").astype(float).values,
+    )
+    np.testing.assert_array_equal(
+        np.sort(np.unique(ev_kwargs_default["description"])),
+        np.sort(dext_f["value"].unique()),
+    )
diff --git a/mne_bids/tests/test_write.py b/mne_bids/tests/test_write.py
index 70ca9038..e9201038 100644
--- a/mne_bids/tests/test_write.py
+++ b/mne_bids/tests/test_write.py
@@ -217,7 +217,7 @@ def test_write_participants(_bids_validate, tmp_path):
     files are kept, and mne-bids correctly writes all
     the subject info it can using ``raw.info['subject_info']``.
     """
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
+    raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
     raw = _read_raw_fif(raw_fname)
 
     # add fake participants data
@@ -319,7 +319,7 @@ def test_write_participants(_bids_validate, tmp_path):
 @testing.requires_testing_data
 def test_write_correct_inputs():
     """Test that inputs of write_raw_bids is correct."""
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
+    raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
     raw = _read_raw_fif(raw_fname)
 
     bids_path_str = "sub-01_ses-01_meg.fif"
@@ -441,7 +441,7 @@ def test_stamp_to_dt():
 @testing.requires_testing_data
 def test_get_anonymization_daysback():
     """Test daysback querying for anonymization."""
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
+    raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
     raw = _read_raw_fif(raw_fname)
     daysback_min, daysback_max = _get_anonymization_daysback(raw)
     # max_val off by 1 on Windows for some reason
@@ -521,7 +521,7 @@ def test_fif(_bids_validate, tmp_path):
     pytest.importorskip("pybv", PYBV_VERSION)
     bids_root = tmp_path / "bids1"
     bids_path = _bids_path.copy().update(root=bids_root, datatype="meg")
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
+    raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
 
     event_id = {
         "Auditory/Left": 1,
@@ -531,9 +531,7 @@ def test_fif(_bids_validate, tmp_path):
         "Smiley": 5,
         "Button": 32,
     }
-    events_fname = op.join(
-        data_path, "MEG", "sample", "sample_audvis_trunc_raw-eve.fif"
-    )
+    events_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw-eve.fif"
 
     # Drop unknown events.
     events = mne.read_events(events_fname)
@@ -899,7 +897,7 @@ def test_chpi(_bids_validate, tmp_path, fmt):
 def test_fif_dtype(_bids_validate, tmp_path):
     """Test functionality of the write_raw_bids conversion for fif."""
     bids_path = _bids_path.copy().update(root=tmp_path, datatype="meg")
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
+    raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
     desired_fmt = "int"
     raw = _read_raw_fif(raw_fname)
 
@@ -919,7 +917,7 @@ def test_fif_anonymize(_bids_validate, tmp_path):
     """Test write_raw_bids() with anonymization fif."""
     bids_root = tmp_path / "bids1"
     bids_path = _bids_path.copy().update(root=bids_root)
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
+    raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
 
     event_id = {
         "Auditory/Left": 1,
@@ -929,9 +927,7 @@ def test_fif_anonymize(_bids_validate, tmp_path):
         "Smiley": 5,
         "Button": 32,
     }
-    events_fname = op.join(
-        data_path, "MEG", "sample", "sample_audvis_trunc_raw-eve.fif"
-    )
+    events_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw-eve.fif"
 
     # Drop unknown events.
     events = mne.read_events(events_fname)
@@ -1006,7 +1002,7 @@ def test_fif_anonymize(_bids_validate, tmp_path):
 @testing.requires_testing_data
 def test_fif_ias(tmp_path):
     """Test writing FIF files with internal active shielding."""
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
+    raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
     raw = _read_raw_fif(raw_fname)
 
     raw.set_channel_types({raw.ch_names[0]: "ias"})
@@ -1022,7 +1018,7 @@ def test_fif_ias(tmp_path):
 @testing.requires_testing_data
 def test_fif_exci(tmp_path):
     """Test writing FIF files with excitation channel."""
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
+    raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
     raw = _read_raw_fif(raw_fname)
 
     raw.set_channel_types({raw.ch_names[0]: "exci"})
@@ -1983,10 +1979,11 @@ def test_get_anat_landmarks():
     # Needs to be converted to Nifti because we only have mgh in our test base
     t1w_mgh = op.join(data_path, "subjects", "sample", "mri", "T1.mgz")
     fs_subjects_dir = op.join(data_path, "subjects")
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
+    base_path = data_path / "MEG" / "sample"
+    raw_fname = base_path / "sample_audvis_trunc_raw.fif"
     raw = _read_raw_fif(raw_fname)
     # Write some MRI data and supply a `trans`
-    trans_fname = raw_fname.replace("_raw.fif", "-trans.fif")
+    trans_fname = base_path / "sample_audvis_trunc-trans.fif"
     trans = mne.read_trans(trans_fname)
 
     # define some keyword arguments to simplify testing
@@ -2094,7 +2091,8 @@ def test_write_anat(_bids_validate, tmp_path):
     )
 
     # write base bids directory
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
+    base_fpath = data_path / "MEG" / "sample"
+    raw_fname = base_fpath / "sample_audvis_trunc_raw.fif"
 
     event_id = {
         "Auditory/Left": 1,
@@ -2104,9 +2102,7 @@ def test_write_anat(_bids_validate, tmp_path):
         "Smiley": 5,
         "Button": 32,
     }
-    events_fname = op.join(
-        data_path, "MEG", "sample", "sample_audvis_trunc_raw-eve.fif"
-    )
+    events_fname = base_fpath / "sample_audvis_trunc_raw-eve.fif"
 
     # Drop unknown events.
     events = mne.read_events(events_fname)
@@ -2219,7 +2215,7 @@ def test_write_anat(_bids_validate, tmp_path):
 
     # Test now using FLASH
     flash_mgh = op.join(data_path, "subjects", "sample", "mri", "flash", "mef05.mgz")
-    trans_fname = raw_fname.replace("_raw.fif", "-trans.fif")
+    trans_fname = base_fpath / "sample_audvis_trunc-trans.fif"
     landmarks = get_anat_landmarks(
         flash_mgh, raw.info, trans_fname, "sample", op.join(data_path, "subjects")
     )
@@ -2254,7 +2250,7 @@ def test_write_anat(_bids_validate, tmp_path):
 @testing.requires_testing_data
 def test_write_raw_pathlike(tmp_path):
     """Ensure writing pathlib.Path works."""
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
+    raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
     event_id = {
         "Auditory/Left": 1,
         "Auditory/Right": 2,
@@ -2285,7 +2281,7 @@ def test_write_raw_pathlike(tmp_path):
 @testing.requires_testing_data
 def test_write_raw_no_dig(tmp_path):
     """Test writing without dig."""
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
+    raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
     raw = _read_raw_fif(raw_fname)
     bids_root = tmp_path
     bids_path = _bids_path.copy().update(root=bids_root)
@@ -2305,8 +2301,9 @@ def test_write_raw_no_dig(tmp_path):
 def test_write_anat_pathlike(tmp_path):
     """Test writing anatomical data with pathlib.Paths."""
     pytest.importorskip("nibabel")
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
-    trans_fname = raw_fname.replace("_raw.fif", "-trans.fif")
+    base_path = data_path / "MEG" / "sample"
+    raw_fname = base_path / "sample_audvis_trunc_raw.fif"
+    trans_fname = base_path / "sample_audvis_trunc-trans.fif"
     raw = _read_raw_fif(raw_fname)
     trans = mne.read_trans(trans_fname)
 
@@ -2338,10 +2335,8 @@ def test_write_anat_pathlike(tmp_path):
 @testing.requires_testing_data
 def test_write_does_not_alter_events_inplace(tmp_path):
     """Test that writing does not modify the passed events array."""
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
-    events_fname = op.join(
-        data_path, "MEG", "sample", "sample_audvis_trunc_raw-eve.fif"
-    )
+    raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
+    events_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw-eve.fif"
 
     raw = _read_raw_fif(raw_fname)
     events = mne.read_events(events_fname)
@@ -2419,7 +2414,7 @@ def test_mark_channels(
     # Setup: Create a fresh BIDS dataset.
     bids_root = tmp_path / "bids1"
     bids_path = _bids_path.copy().update(root=bids_root, datatype="meg", suffix="meg")
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
+    raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
     event_id = {
         "Auditory/Left": 1,
         "Auditory/Right": 2,
@@ -2428,9 +2423,7 @@ def test_mark_channels(
         "Smiley": 5,
         "Button": 32,
     }
-    events_fname = op.join(
-        data_path, "MEG", "sample", "sample_audvis_trunc_raw-eve.fif"
-    )
+    events_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw-eve.fif"
 
     # Drop unknown events.
     events = mne.read_events(events_fname)
@@ -2531,7 +2524,7 @@ def test_mark_channel_roundtrip(tmp_path):
     # Setup: Create a fresh BIDS dataset.
     bids_root = tmp_path / "bids1"
     bids_path = _bids_path.copy().update(root=bids_root, datatype="meg", suffix="meg")
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
+    raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
     event_id = {
         "Auditory/Left": 1,
         "Auditory/Right": 2,
@@ -2540,9 +2533,7 @@ def test_mark_channel_roundtrip(tmp_path):
         "Smiley": 5,
         "Button": 32,
     }
-    events_fname = op.join(
-        data_path, "MEG", "sample", "sample_audvis_trunc_raw-eve.fif"
-    )
+    events_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw-eve.fif"
 
     # Drop unknown events.
     events = mne.read_events(events_fname)
@@ -2583,7 +2574,7 @@ def test_error_mark_channels(tmp_path):
     # Setup: Create a fresh BIDS dataset.
     bids_root = tmp_path / "bids1"
     bids_path = _bids_path.copy().update(root=bids_root, datatype="meg", suffix="meg")
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
+    raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
     event_id = {
         "Auditory/Left": 1,
         "Auditory/Right": 2,
@@ -2592,9 +2583,7 @@ def test_error_mark_channels(tmp_path):
         "Smiley": 5,
         "Button": 32,
     }
-    events_fname = op.join(
-        data_path, "MEG", "sample", "sample_audvis_trunc_raw-eve.fif"
-    )
+    events_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw-eve.fif"
 
     # Drop unknown events.
     events = mne.read_events(events_fname)
@@ -2660,7 +2649,7 @@ def test_write_meg_calibration(_bids_validate, tmp_path):
     bids_root = tmp_path / "bids1"
     bids_path = _bids_path.copy().update(root=bids_root)
 
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
+    raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
     raw = _read_raw_fif(raw_fname, verbose=False)
     write_raw_bids(raw, bids_path=bids_path, verbose=False)
 
@@ -2707,7 +2696,7 @@ def test_write_meg_crosstalk(_bids_validate, tmp_path):
     bids_root = tmp_path / "bids1"
     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_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
     raw = _read_raw_fif(raw_fname, verbose=False)
     write_raw_bids(raw, bids_path=bids_path, verbose=False)
 
@@ -2746,10 +2735,8 @@ def test_annotations(_bids_validate, bad_segments, tmp_path):
     """Test that Annotations are stored as events."""
     bids_root = tmp_path / "bids1"
     bids_path = _bids_path.copy().update(root=bids_root, datatype="meg")
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
-    events_fname = op.join(
-        data_path, "MEG", "sample", "sample_audvis_trunc_raw-eve.fif"
-    )
+    raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
+    events_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw-eve.fif"
 
     events = mne.read_events(events_fname)
     event_id = {
@@ -2890,10 +2877,8 @@ def test_undescribed_events(_bids_validate, drop_undescribed_events, tmp_path):
     """Test we're raising if event descriptions are missing."""
     bids_root = tmp_path / "bids1"
     bids_path = _bids_path.copy().update(root=bids_root, datatype="meg")
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
-    events_fname = op.join(
-        data_path, "MEG", "sample", "sample_audvis_trunc_raw-eve.fif"
-    )
+    raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
+    events_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw-eve.fif"
 
     events = mne.read_events(events_fname)
     if drop_undescribed_events:
@@ -2940,10 +2925,8 @@ def test_event_storage(tmp_path):
     """Test we're retaining the original event IDs when storing events."""
     bids_root = tmp_path / "bids1"
     bids_path = _bids_path.copy().update(root=bids_root, datatype="meg")
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
-    events_fname = op.join(
-        data_path, "MEG", "sample", "sample_audvis_trunc_raw-eve.fif"
-    )
+    raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
+    events_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw-eve.fif"
     events_tsv_fname = bids_path.copy().update(suffix="events", extension=".tsv")
 
     events = mne.read_events(events_fname)
@@ -3281,10 +3264,8 @@ def test_sidecar_encoding(_bids_validate, tmp_path):
     """Test we're properly encoding text as UTF8."""
     bids_root = tmp_path / "bids1"
     bids_path = _bids_path.copy().update(root=bids_root, datatype="meg")
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
-    events_fname = op.join(
-        data_path, "MEG", "sample", "sample_audvis_trunc_raw-eve.fif"
-    )
+    raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
+    events_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw-eve.fif"
 
     raw = _read_raw_fif(raw_fname)
     events = mne.read_events(events_fname)
@@ -3668,7 +3649,7 @@ def test_symlink(tmp_path):
 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"
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
+    raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
     raw = _read_raw_fif(raw_fname)
     meas_date = datetime(year=2020, month=1, day=10, tzinfo=timezone.utc)
 
@@ -3760,7 +3741,7 @@ def test_preload(_bids_validate, tmp_path):
 @testing.requires_testing_data
 def test_write_raw_special_paths(tmp_path, dir_name):
     """Test writing to locations containing strings with special meaning."""
-    raw_fname = op.join(data_path, "MEG", "sample", "sample_audvis_trunc_raw.fif")
+    raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
     raw = _read_raw_fif(raw_fname)
 
     root = tmp_path / dir_name