Skip to content

Commit 8e4e13d

Browse files
make EDF writer handle anonymized meas_date (#1479)
* make EDF writer handle anonymized meas_date * indent * test + coverage * decorate * pyproject.toml lint * bump python versions * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * missed a few * lint * back to 3.13; 3.14 needs help * changelog --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent dd3c984 commit 8e4e13d

File tree

11 files changed

+99
-48
lines changed

11 files changed

+99
-48
lines changed

.github/workflows/unit_tests.yml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ jobs:
5151
fail-fast: false
5252
matrix:
5353
os: [ubuntu-latest]
54-
python-version: ["3.10", "3.13"] # Oldest and newest supported versions
54+
python-version: ["3.11", "3.13"] # Oldest and newest supported versions
5555
steps:
5656
- name: Set up Python ${{ matrix.python-version }}
5757
uses: actions/setup-python@v6
@@ -129,22 +129,22 @@ jobs:
129129
#
130130
# 8 checks:
131131
# for each machine type (ubuntu, macos, windows):
132-
# 3.10 / mne-stable / full / validator-stable
132+
# 3.11 / mne-stable / full / validator-stable
133133
# 3.13 / mne-stable / full / validator-stable
134134
#
135135
# 4 additional checks with alternative MNE-Python and BIDS validator versions:
136136
# ubuntu / 3.13 / mne-main / full / validator-main --> to test cutting edge of everything
137-
# ubuntu / 3.10 / mne-prev / full / validator-stable --> to test last supported of everything
137+
# ubuntu / 3.11 / mne-prev / full / validator-stable --> to test last supported of everything
138138
# ubuntu / 3.13 / mne-stable / minimal / validator-stable --> to test a minimal installation
139-
# ubuntu / 3.12 / mne-stable / full / validator-main-schema --> to test next-gen BIDS validator
139+
# ubuntu / 3.13 / mne-stable / full / validator-main-schema --> to test next-gen BIDS validator
140140
# macos-15-intel / 3.13 / mne-stable / full / validator-stable --> to test macos x86_64
141141
timeout-minutes: 60
142142
runs-on: ${{ matrix.os }}
143143
strategy:
144144
fail-fast: false
145145
matrix:
146146
os: [ubuntu-latest, macos-latest, windows-latest]
147-
python-version: ["3.10", "3.13"] # Oldest and newest supported versions
147+
python-version: ["3.11", "3.13"] # Oldest and newest supported versions
148148
mne-version: [mne-stable]
149149
mne-bids-install: [full]
150150
bids-validator-version: [validator-stable]
@@ -159,7 +159,7 @@ jobs:
159159
bids-validator-version: validator-main
160160
# Test previous MNE stable version
161161
- os: ubuntu-latest
162-
python-version: "3.10"
162+
python-version: "3.11"
163163
mne-version: mne-prev-stable
164164
mne-bids-install: full
165165
bids-validator-version: validator-stable
@@ -171,7 +171,7 @@ jobs:
171171
bids-validator-version: validator-stable
172172
# Test next gen BIDS schema validator
173173
- os: ubuntu-latest
174-
python-version: "3.12"
174+
python-version: "3.13"
175175
mne-version: mne-stable
176176
mne-bids-install: full
177177
bids-validator-version: validator-main-schema

doc/install.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Optional:
3232
We recommend installing ``mne-bids`` into an isolated Python environment,
3333
for example created via ``conda``
3434
(may be obtained through `miniconda <https://docs.conda.io/en/latest/miniconda.html>`_).
35-
We require that you **use Python 3.10 or higher**.
35+
We require that you **use Python 3.11 or higher**.
3636
You may choose to install ``mne-bids`` into your isolated Python environment
3737
`via pip <#installation-via-pip>`_ or
3838
`via conda <#installation-via-conda>`_.

doc/whats_new.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ Detailed list of changes
5151

5252
🛠 Requirements
5353
^^^^^^^^^^^^^^^
54+
- MNE-BIDS now requires Python 3.11 or higher.
5455
- Including ``filelock`` as a dependency to handle atomic file writing and parallel processing support by `Bruno Aristimunha`_ (:gh:`1451`)
5556

5657
- None yet
@@ -63,6 +64,7 @@ Detailed list of changes
6364
- Made the lock helpers skip reference counting when the optional ``filelock`` dependency is missing, preventing spurious ``AttributeError`` crashes during reads, by `Bruno Aristimunha`_ (:gh:`1469`)
6465
- Fixed a bug in :func:`mne_bids.read_raw_bids` that caused it to fail when reading BIDS datasets where the acquisition time was specified in local time rather than UTC only in Windows, by `Bruno Aristimunha`_ (:gh:`1452`)
6566
- Fixed bug in :func:`~mne_bids.write_raw_bids` where incorrect unit was sometimes written into ``channels.tsv`` file when converting data to BrainVision, EDF, or EEGLAB formats, by `Daniel McCloy`_ (:gh:`1475`)
67+
- Converting data to EDF format while also anonymizing it will now yield valid EDF files (with ``startdate`` set to 1985-01-01 00:00:00); the BIDS-compliant anonymized date will still be stored in ``scans.tsv`` file and restored to the in-memory Raw object when read using :func:`~mne_bids.read_raw_bids`, by `Daniel McCloy`_ (:gh:`1479`)
6668

6769
⚕️ Code health
6870
^^^^^^^^^^^^^^

examples/convert_empty_room.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
# Let us first import mne_bids.
3030

3131
import shutil
32-
from datetime import datetime, timezone
32+
from datetime import UTC, datetime
3333

3434
import mne
3535
from mne.datasets import sample
@@ -98,7 +98,7 @@
9898
for date in dates:
9999
er_bids_path.update(session=date)
100100
er_meas_date = datetime.strptime(date, "%Y%m%d")
101-
er_raw.set_meas_date(er_meas_date.replace(tzinfo=timezone.utc))
101+
er_raw.set_meas_date(er_meas_date.replace(tzinfo=UTC))
102102
write_raw_bids(er_raw, er_bids_path, overwrite=True)
103103

104104
# %%

mne_bids/read.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import json
77
import os
88
import re
9-
from datetime import datetime, timedelta, timezone
9+
from datetime import UTC, datetime, timedelta
1010
from difflib import get_close_matches
1111
from pathlib import Path
1212

@@ -417,14 +417,14 @@ def _handle_scans_reading(scans_fname, raw, bids_path):
417417

418418
if acq_time_is_utc:
419419
# Enforce setting timezone to UTC without additonal conversion
420-
acq_time = acq_time.replace(tzinfo=timezone.utc)
420+
acq_time = acq_time.replace(tzinfo=UTC)
421421
else:
422422
# Convert time offset to UTC
423423
if acq_time.tzinfo is None:
424424
# Windows needs an explicit local tz for naive, pre-epoch times.
425-
local_tz = datetime.now().astimezone().tzinfo or timezone.utc
425+
local_tz = datetime.now().astimezone().tzinfo or UTC
426426
acq_time = acq_time.replace(tzinfo=local_tz)
427-
acq_time = acq_time.astimezone(timezone.utc)
427+
acq_time = acq_time.astimezone(UTC)
428428

429429
logger.debug(f"Loaded {scans_fname} scans file to set acq_time as {acq_time}.")
430430
# First set measurement date to None and then call call anonymize() to

mne_bids/tests/test_path.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import shutil
99
import shutil as sh
1010
import timeit
11-
from datetime import datetime, timezone
11+
from datetime import UTC, datetime
1212
from pathlib import Path
1313

1414
import mne
@@ -1435,7 +1435,7 @@ def test_find_empty_room(return_bids_test_dir, tmp_path):
14351435
for date in dates:
14361436
er_bids_path.update(session=date)
14371437
er_meas_date = datetime.strptime(date, "%Y%m%d")
1438-
er_meas_date = er_meas_date.replace(tzinfo=timezone.utc)
1438+
er_meas_date = er_meas_date.replace(tzinfo=UTC)
14391439
er_raw.set_meas_date(er_meas_date)
14401440
write_raw_bids(er_raw, er_bids_path, verbose=False)
14411441

@@ -1460,8 +1460,8 @@ def test_find_empty_room(return_bids_test_dir, tmp_path):
14601460
bids_root = tmp_path / "associated-empty-room"
14611461
bids_root.mkdir()
14621462
raw = _read_raw_fif(raw_fname)
1463-
meas_date = datetime(year=2020, month=1, day=10, tzinfo=timezone.utc)
1464-
er_date = datetime(year=2010, month=1, day=1, tzinfo=timezone.utc)
1463+
meas_date = datetime(year=2020, month=1, day=10, tzinfo=UTC)
1464+
er_date = datetime(year=2010, month=1, day=1, tzinfo=UTC)
14651465
raw.set_meas_date(meas_date)
14661466

14671467
er_raw_matching_date = er_raw.copy().set_meas_date(meas_date)
@@ -1543,7 +1543,7 @@ def test_find_emptyroom_ties(tmp_path):
15431543
)
15441544
er_dir = er_dir_path.mkdir().directory
15451545

1546-
meas_date = datetime.strptime(session, "%Y%m%d").replace(tzinfo=timezone.utc)
1546+
meas_date = datetime.strptime(session, "%Y%m%d").replace(tzinfo=UTC)
15471547

15481548
raw = _read_raw_fif(raw_fname)
15491549

mne_bids/tests/test_read.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import shutil as sh
1414
from collections import OrderedDict
1515
from contextlib import nullcontext
16-
from datetime import date, datetime, timezone
16+
from datetime import UTC, date, datetime
1717
from pathlib import Path
1818

1919
import mne
@@ -108,7 +108,7 @@ def _make_parallel_raw(subject, *, seed=None):
108108
info = mne.create_info(["MEG0113"], 100, ch_types="mag")
109109
data = rng.standard_normal((1, 100)) * 1e-12
110110
raw = mne.io.RawArray(data, info)
111-
raw.set_meas_date(datetime(2020, 1, 1, tzinfo=timezone.utc))
111+
raw.set_meas_date(datetime(2020, 1, 1, tzinfo=UTC))
112112
raw.info["line_freq"] = 60
113113
raw.info["subject_info"] = {
114114
"his_id": subject,
@@ -802,7 +802,7 @@ def test_handle_scans_reading(tmp_path):
802802
scans_tsv = _from_tsv(scans_path)
803803
acq_time_str = scans_tsv["acq_time"][0]
804804
acq_time = datetime.strptime(acq_time_str, "%Y-%m-%dT%H:%M:%S.%fZ")
805-
acq_time = acq_time.replace(tzinfo=timezone.utc)
805+
acq_time = acq_time.replace(tzinfo=UTC)
806806
new_acq_time = acq_time_str.split(".")[0] + "Z"
807807
assert acq_time == raw_01.info["meas_date"]
808808
scans_tsv["acq_time"][0] = new_acq_time
@@ -813,7 +813,7 @@ def test_handle_scans_reading(tmp_path):
813813
raw_02 = read_raw_bids(bids_path)
814814
new_acq_time = new_acq_time.replace("Z", ".0Z")
815815
new_acq_time = datetime.strptime(new_acq_time, "%Y-%m-%dT%H:%M:%S.%fZ")
816-
new_acq_time = new_acq_time.replace(tzinfo=timezone.utc)
816+
new_acq_time = new_acq_time.replace(tzinfo=UTC)
817817
assert raw_02.info["meas_date"] == new_acq_time
818818
assert new_acq_time != raw_01.info["meas_date"]
819819

@@ -833,7 +833,7 @@ def test_handle_scans_reading(tmp_path):
833833
# from the original date and the same as the newly altered date
834834
raw_03 = read_raw_bids(bids_path)
835835
new_acq_time = datetime.strptime(new_acq_time_str, date_format)
836-
assert raw_03.info["meas_date"] == new_acq_time.astimezone(timezone.utc)
836+
assert raw_03.info["meas_date"] == new_acq_time.astimezone(UTC)
837837

838838
# Regression for naive, pre-epoch acquisition times (Windows bug GH-1399)
839839
pre_epoch_str = "1950-06-15T13:45:30"
@@ -842,10 +842,8 @@ def test_handle_scans_reading(tmp_path):
842842

843843
raw_pre_epoch = read_raw_bids(bids_path)
844844
pre_epoch_naive = datetime.strptime(pre_epoch_str, "%Y-%m-%dT%H:%M:%S")
845-
local_tz = datetime.now().astimezone().tzinfo or timezone.utc
846-
expected_pre_epoch = pre_epoch_naive.replace(tzinfo=local_tz).astimezone(
847-
timezone.utc
848-
)
845+
local_tz = datetime.now().astimezone().tzinfo or UTC
846+
expected_pre_epoch = pre_epoch_naive.replace(tzinfo=local_tz).astimezone(UTC)
849847
assert raw_pre_epoch.info["meas_date"] == expected_pre_epoch
850848
if raw_pre_epoch.annotations.orig_time is not None:
851849
assert raw_pre_epoch.annotations.orig_time == expected_pre_epoch

mne_bids/tests/test_write.py

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import time
1616
import warnings
1717
from concurrent.futures import ProcessPoolExecutor
18-
from datetime import date, datetime, timedelta, timezone
18+
from datetime import UTC, date, datetime, timedelta
1919
from glob import glob
2020
from pathlib import Path
2121

@@ -24,7 +24,7 @@
2424
import pandas as pd
2525
import pytest
2626
from mne.datasets import testing
27-
from mne.io import anonymize_info
27+
from mne.io import anonymize_info, read_raw_edf
2828
from mne.io.constants import FIFF
2929
from mne.io.kit.kit import get_kit_info
3030
from mne.utils import check_version
@@ -115,7 +115,7 @@ def _make_parallel_raw(subject, *, seed=None):
115115
info = mne.create_info(["MEG0113"], 100, ch_types="mag")
116116
data = rng.standard_normal((1, 100)) * 1e-12
117117
raw = mne.io.RawArray(data, info, verbose=False)
118-
raw.set_meas_date(datetime(2020, 1, 1, tzinfo=timezone.utc))
118+
raw.set_meas_date(datetime(2020, 1, 1, tzinfo=UTC))
119119
raw.info["line_freq"] = 60
120120
raw.info["subject_info"] = {
121121
"his_id": subject,
@@ -267,7 +267,7 @@ def test_write_participants(_bids_validate, tmp_path):
267267
raw = _read_raw_fif(raw_fname)
268268

269269
# add fake participants data
270-
raw.set_meas_date(datetime(year=1994, month=1, day=26, tzinfo=timezone.utc))
270+
raw.set_meas_date(datetime(year=1994, month=1, day=26, tzinfo=UTC))
271271
birthday = (1993, 1, 26)
272272
birthday = date(*birthday)
273273
raw.info["subject_info"] = {
@@ -478,10 +478,10 @@ def test_stamp_to_dt():
478478
"""Test conversions of meas_date to datetime objects."""
479479
meas_date = (1346981585, 835782)
480480
meas_datetime = _stamp_to_dt(meas_date)
481-
assert meas_datetime == datetime(2012, 9, 7, 1, 33, 5, 835782, tzinfo=timezone.utc)
481+
assert meas_datetime == datetime(2012, 9, 7, 1, 33, 5, 835782, tzinfo=UTC)
482482
meas_date = (1346981585,)
483483
meas_datetime = _stamp_to_dt(meas_date)
484-
assert meas_datetime == datetime(2012, 9, 7, 1, 33, 5, 0, tzinfo=timezone.utc)
484+
assert meas_datetime == datetime(2012, 9, 7, 1, 33, 5, 0, tzinfo=UTC)
485485

486486

487487
@testing.requires_testing_data
@@ -688,7 +688,7 @@ def test_fif(_bids_validate, tmp_path):
688688
raw = _read_raw_fif(raw_fname)
689689
meas_date = raw.info["meas_date"]
690690
if not isinstance(meas_date, datetime):
691-
meas_date = datetime.fromtimestamp(meas_date[0], tz=timezone.utc)
691+
meas_date = datetime.fromtimestamp(meas_date[0], tz=UTC)
692692
er_date = meas_date.strftime("%Y%m%d")
693693
er_bids_path = BIDSPath(
694694
subject="emptyroom", session=er_date, task="noise", root=bids_root
@@ -3457,6 +3457,43 @@ def test_convert_eeg_formats(dir_name, fmt, fname, reader, tmp_path):
34573457
assert_array_almost_equal(raw.get_data(), raw2.get_data()[:, :orig_len], decimal=6)
34583458

34593459

3460+
@testing.requires_testing_data
3461+
def test_anonymize_and_convert_to_edf(tmp_path):
3462+
"""Test anonymization if converting to EDF (different codepath than EDF → EDF)."""
3463+
bids_root = tmp_path / "EDF"
3464+
raw_fname = data_path / "NihonKohden" / "MB0400FU.EEG"
3465+
bids_path = _bids_path.copy().update(root=bids_root, datatype="eeg")
3466+
raw = _read_raw_nihon(raw_fname)
3467+
3468+
with (
3469+
pytest.warns(RuntimeWarning, match="limits `startdate` to dates after 1985"),
3470+
pytest.warns(RuntimeWarning, match="Converting data files to EDF format"),
3471+
):
3472+
bids_output_path = write_raw_bids(
3473+
raw=raw,
3474+
format="EDF",
3475+
bids_path=bids_path,
3476+
overwrite=True,
3477+
verbose=False,
3478+
anonymize=dict(daysback=41234),
3479+
)
3480+
# make sure the written EDF file is valid
3481+
direct_read = read_raw_edf(bids_output_path.fpath)
3482+
assert direct_read.info["meas_date"] == datetime(1985, 1, 1, tzinfo=UTC)
3483+
# make sure MNE-BIDS replaced the 1985-1-1 date with the date from scans.tsv
3484+
bids_read = read_raw_bids(bids_output_path)
3485+
bids_output_path.update(
3486+
suffix="scans",
3487+
extension=".tsv",
3488+
task=None,
3489+
acquisition=None,
3490+
run=None,
3491+
datatype=None,
3492+
)
3493+
scans = _from_tsv(bids_output_path.fpath)
3494+
assert bids_read.info["meas_date"] == datetime.fromisoformat(scans["acq_time"][0])
3495+
3496+
34603497
@pytest.mark.parametrize("dir_name, fmt, fname, reader", test_converteeg_data)
34613498
@pytest.mark.filterwarnings(
34623499
warning_str["channel_unit_changed"],
@@ -3699,7 +3736,7 @@ def test_write_associated_emptyroom(_bids_validate, tmp_path, empty_room_dtype):
36993736
bids_root = tmp_path / "bids1"
37003737
raw_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
37013738
raw = _read_raw_fif(raw_fname)
3702-
meas_date = datetime(year=2020, month=1, day=10, tzinfo=timezone.utc)
3739+
meas_date = datetime(year=2020, month=1, day=10, tzinfo=UTC)
37033740

37043741
if empty_room_dtype == "BIDSPath":
37053742
# First write "empty-room" data

mne_bids/utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import json
77
import os
88
import re
9-
from datetime import date, datetime, timedelta, timezone
9+
from datetime import UTC, date, datetime, timedelta
1010
from pathlib import Path
1111

1212
import numpy as np
@@ -469,7 +469,7 @@ def _stamp_to_dt(utc_stamp):
469469
stamp = [int(s) for s in utc_stamp]
470470
if len(stamp) == 1: # In case there is no microseconds information
471471
stamp.append(0)
472-
return datetime.fromtimestamp(0, tz=timezone.utc) + timedelta(
472+
return datetime.fromtimestamp(0, tz=UTC) + timedelta(
473473
0, stamp[0], stamp[1]
474474
) # day, sec, μs
475475

0 commit comments

Comments
 (0)