Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changes/devel/12720.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix in-memory anonymization of data read with :func:`mne.io.read_raw_edf` by `Eric Larson`_.
2 changes: 2 additions & 0 deletions doc/changes/devel/12720.newfeature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Use :class:`python:datetime.date` for ``info["subject_info"]["birthday"]`` rather than
a tuple of ``(year, month, day)`` by `Eric Larson`_.
48 changes: 34 additions & 14 deletions mne/_fiff/meas_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,13 @@ def _check_line_freq(line_freq, *, info):

def _check_subject_info(subject_info, *, info):
_validate_type(subject_info, (None, dict), "subject_info")
if isinstance(subject_info, dict):
if "birthday" in subject_info:
_validate_type(
subject_info["birthday"],
(datetime.date, None),
"subject_info['birthday']",
)
return subject_info


Expand All @@ -1045,6 +1052,13 @@ def _check_helium_info(helium_info, *, info):
),
"helium_info",
)
if isinstance(helium_info, dict):
if "meas_date" in helium_info:
_validate_type(
helium_info["meas_date"],
datetime.datetime,
"helium_info['meas_date']",
)
return helium_info


Expand Down Expand Up @@ -1331,9 +1345,13 @@ class Info(dict, SetChannelsMixin, MontageMixin, ContainsMixin):
Helium level (%) after position correction.
orig_file_guid : str
Original file GUID.
meas_date : tuple of int
meas_date : datetime.datetime
The helium level meas date.

.. versionchanged:: 1.8
This is stored as a :class:`~python:datetime.datetime` object
instead of a tuple of seconds/microseconds.

* ``hpi_meas`` list of dict:

creator : str
Expand Down Expand Up @@ -1446,8 +1464,12 @@ class Info(dict, SetChannelsMixin, MontageMixin, ContainsMixin):
First name.
middle_name : str
Middle name.
birthday : tuple of int
Birthday in (year, month, day) format.
meas_date : datetime.date
The subject birthday.

.. versionchanged:: 1.8
This is stored as a :class:`~python:datetime.date` object
instead of a tuple of seconds/microseconds.
sex : int
Subject sex (0=unknown, 1=male, 2=female).
hand : int
Expand Down Expand Up @@ -2406,7 +2428,9 @@ def read_meas_info(fid, tree, clean_bads=False, verbose=None):
hi["orig_file_guid"] = str(tag.data)
elif kind == FIFF.FIFF_MEAS_DATE:
tag = read_tag(fid, pos)
hi["meas_date"] = tuple(int(t) for t in tag.data)
hi["meas_date"] = _ensure_meas_date_none_or_dt(
tuple(int(t) for t in tag.data),
)
info["helium_info"] = hi
del hi

Expand Down Expand Up @@ -2792,7 +2816,7 @@ def write_meas_info(fid, info, data_type=None, reset_range=True):
write_float(fid, FIFF.FIFF_HELIUM_LEVEL, hi["helium_level"])
if hi.get("orig_file_guid") is not None:
write_string(fid, FIFF.FIFF_ORIG_FILE_GUID, hi["orig_file_guid"])
write_int(fid, FIFF.FIFF_MEAS_DATE, hi["meas_date"])
write_int(fid, FIFF.FIFF_MEAS_DATE, _dt_to_stamp(hi["meas_date"]))
end_block(fid, FIFF.FIFFB_HELIUM)
del hi

Expand Down Expand Up @@ -3402,13 +3426,7 @@ def anonymize_info(info, daysback=None, keep_his=False, verbose=None):
if none_meas_date:
subject_info.pop("birthday", None)
elif subject_info.get("birthday") is not None:
dob = datetime.datetime(
subject_info["birthday"][0],
subject_info["birthday"][1],
subject_info["birthday"][2],
)
dob -= delta_t
subject_info["birthday"] = dob.year, dob.month, dob.day
subject_info["birthday"] = subject_info["birthday"] - delta_t

for key in ("weight", "height"):
if subject_info.get(key) is not None:
Expand Down Expand Up @@ -3445,9 +3463,11 @@ def anonymize_info(info, daysback=None, keep_his=False, verbose=None):
if hi.get("orig_file_guid") is not None:
hi["orig_file_guid"] = default_str
if none_meas_date and hi.get("meas_date") is not None:
hi["meas_date"] = DATE_NONE
hi["meas_date"] = _ensure_meas_date_none_or_dt(DATE_NONE)
elif hi.get("meas_date") is not None:
hi["meas_date"] = _add_timedelta_to_stamp(hi["meas_date"], -delta_t)
hi["meas_date"] = _ensure_meas_date_none_or_dt(
_add_timedelta_to_stamp(hi["meas_date"], -delta_t)
)

di = info.get("device_info")
if di is not None:
Expand Down
4 changes: 2 additions & 2 deletions mne/_fiff/tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from scipy.sparse import csc_array, csr_array

from ..utils import _check_option, warn
from ..utils.numerics import _julian_to_cal
from ..utils.numerics import _julian_to_date
from .constants import (
FIFF,
_ch_coil_type_named,
Expand Down Expand Up @@ -371,7 +371,7 @@ def _read_dir_entry_struct(fid, tag, shape, rlims):

def _read_julian(fid, tag, shape, rlims):
"""Read julian tag."""
return _julian_to_cal(int(np.frombuffer(fid.read(4), dtype=">i4").item()))
return _julian_to_date(int(np.frombuffer(fid.read(4), dtype=">i4").item()))


# Read types call dict
Expand Down
15 changes: 11 additions & 4 deletions mne/_fiff/tests/test_meas_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -592,7 +592,7 @@ def _test_anonymize_info(base_info):
his_id="foobar",
last_name="bar",
first_name="bar",
birthday=(1987, 4, 8),
birthday=date(1987, 4, 8),
sex=0,
hand=1,
)
Expand All @@ -616,7 +616,7 @@ def _test_anonymize_info(base_info):
# this bday is 3653 days different. the change in day is due to a
# different number of leap days between 1987 and 1977 than between
# 2010 and 2000.
exp_info["subject_info"]["birthday"] = (1977, 4, 7)
exp_info["subject_info"]["birthday"] = date(1977, 4, 7)
exp_info["meas_date"] = default_anon_dos
exp_info._unlocked = False

Expand Down Expand Up @@ -644,7 +644,7 @@ def _test_anonymize_info(base_info):
# exp 3 tests is a supplied daysback
delta_t_2 = timedelta(days=43)
with exp_info_3._unlock():
exp_info_3["subject_info"]["birthday"] = (1987, 2, 24)
exp_info_3["subject_info"]["birthday"] = date(1987, 2, 24)
exp_info_3["meas_date"] = meas_date - delta_t_2
for key in ("file_id", "meas_id"):
value = exp_info_3.get(key)
Expand Down Expand Up @@ -860,12 +860,19 @@ def test_field_round_trip(tmp_path):
info[key] = _generate_meas_id()
info["device_info"] = dict(type="a", model="b", serial="c", site="d")
info["helium_info"] = dict(
he_level_raw=1.0, helium_level=2.0, orig_file_guid="e", meas_date=(1, 2)
he_level_raw=1.0,
helium_level=2.0,
orig_file_guid="e",
meas_date=_stamp_to_dt((1, 2)),
)
fname = tmp_path / "temp-info.fif"
write_info(fname, info)
info_read = read_info(fname)
assert_object_equal(info, info_read)
info["helium_info"]["meas_date"] = (1, 2)
with pytest.raises(TypeError, match="datetime"):
# trigger the check
info["helium_info"] = info["helium_info"]


def test_equalize_channels():
Expand Down
7 changes: 4 additions & 3 deletions mne/_fiff/write.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# License: BSD-3-Clause
# Copyright the MNE-Python contributors.

import datetime
import os.path as op
import re
import time
Expand All @@ -15,7 +16,7 @@
from scipy.sparse import csc_array, csr_array

from ..utils import _file_like, _validate_type, logger
from ..utils.numerics import _cal_to_julian
from ..utils.numerics import _date_to_julian
from .constants import FIFF

# We choose a "magic" date to store (because meas_date is obligatory)
Expand Down Expand Up @@ -120,9 +121,9 @@ def write_complex128(fid, kind, data):

def write_julian(fid, kind, data):
"""Write a Julian-formatted date to a FIF file."""
assert len(data) == 3
assert isinstance(data, datetime.date), type(data)
data_size = 4
jd = np.sum(_cal_to_julian(*data))
jd = _date_to_julian(data)
data = np.array(jd, dtype=">i4")
_write(fid, data, kind, data_size, FIFF.FIFFT_JULIAN, ">i4")

Expand Down
2 changes: 0 additions & 2 deletions mne/export/_edf.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,6 @@ def _export_raw(fname, raw, physical_range, add_ch_type):
name = "_".join(filter(None, [first_name, middle_name, last_name]))

birthday = subj_info.get("birthday")
if birthday is not None:
birthday = dt.date(*birthday)
hand = subj_info.get("hand")
weight = subj_info.get("weight")
height = subj_info.get("height")
Expand Down
6 changes: 3 additions & 3 deletions mne/export/tests/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# Copyright the MNE-Python contributors.

from contextlib import nullcontext
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from pathlib import Path

import numpy as np
Expand Down Expand Up @@ -160,7 +160,7 @@ def test_double_export_edf(tmp_path):
his_id="12345",
first_name="mne",
last_name="python",
birthday=(1992, 1, 20),
birthday=date(1992, 1, 20),
sex=1,
weight=78.3,
height=1.75,
Expand Down Expand Up @@ -290,7 +290,7 @@ def test_rawarray_edf(tmp_path):
raw.info["subject_info"] = dict(
first_name="mne",
last_name="python",
birthday=(1992, 1, 20),
birthday=date(1992, 1, 20),
sex=1,
hand=3,
)
Expand Down
7 changes: 5 additions & 2 deletions mne/io/edf/edf.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

import os
import re
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone

import numpy as np
from scipy.interpolate import interp1d
Expand Down Expand Up @@ -690,7 +690,7 @@ def _get_info(
info["subject_info"]["last_name"] = sub_names[2]
# Birthday in (year, month, day) format.
if isinstance(edf_info["subject_info"].get("birthday"), datetime):
info["subject_info"]["birthday"] = (
info["subject_info"]["birthday"] = date(
edf_info["subject_info"]["birthday"].year,
edf_info["subject_info"]["birthday"].month,
edf_info["subject_info"]["birthday"].day,
Expand All @@ -704,6 +704,9 @@ def _get_info(
# Weight in kilograms.
if edf_info["subject_info"].get("weight") is not None:
info["subject_info"]["weight"] = float(edf_info["subject_info"]["weight"])
# Remove values after conversion to help with in-memory anonymization
for key in ("subject_info", "meas_date"):
del edf_info[key]

# Filter settings
if filt_ch_idxs := [x for x in range(len(sel)) if x not in stim_channel_idxs]:
Expand Down
29 changes: 15 additions & 14 deletions mne/io/edf/tests/test_edf.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,24 +129,12 @@ def test_subject_info(tmp_path):
want = {
"his_id": "X",
"sex": 1,
"birthday": (1967, 10, 9),
"birthday": datetime.date(1967, 10, 9),
"last_name": "X",
}
for key, val in want.items():
assert raw.info["subject_info"][key] == val, key

# check "subject_info" from `_raw_extras`
edf_info = raw._raw_extras[0]
assert edf_info["subject_info"] is not None
want = {
"id": "X",
"sex": "M",
"birthday": datetime.datetime(1967, 10, 9, 0, 0),
"name": "X",
}
for key, val in want.items():
assert edf_info["subject_info"][key] == val, key

# add information
raw.info["subject_info"]["hand"] = 0

Expand All @@ -160,7 +148,7 @@ def test_subject_info(tmp_path):
want = {
"his_id": "X",
"sex": 1,
"birthday": (1967, 10, 9),
"birthday": datetime.date(1967, 10, 9),
"last_name": "X",
"hand": 0,
}
Expand Down Expand Up @@ -1195,3 +1183,16 @@ def test_ch_types():
raw = read_raw_edf(edf_chtypes_path, units="uV") # should be okay
data_units = raw.get_data()
assert_allclose(data, data_units)


@testing.requires_testing_data
def test_anonymization():
"""Test that RawEDF anonymizes data in memory."""
# gh-11966
raw = read_raw_edf(edf_stim_resamp_path)
for key in ("meas_date", "subject_info"):
assert key not in raw._raw_extras[0]
bday = raw.info["subject_info"]["birthday"]
assert bday == datetime.date(1967, 10, 9)
raw.anonymize()
assert raw.info["subject_info"]["birthday"] != bday
9 changes: 4 additions & 5 deletions mne/io/edf/tests/test_gdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# Copyright the MNE-Python contributors.

import shutil
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone

import numpy as np
import scipy.io as sio
Expand Down Expand Up @@ -99,11 +99,10 @@ def test_gdf2_birthday(tmp_path):
fid.seek(176, 0)
assert np.fromfile(fid, np.uint64, 1)[0] == d
raw = read_raw_gdf(new_fname, eog=None, misc=None, preload=True)
assert raw._raw_extras[0]["subject_info"]["age"] == 44
assert "subject_info" not in raw._raw_extras[0]
assert raw.info["subject_info"] is not None

birthdate = datetime(1, 1, 1, tzinfo=timezone.utc) + offset_44_yr
assert raw.info["subject_info"]["birthday"] == (
assert raw.info["subject_info"]["birthday"] == date(
birthdate.year,
birthdate.month,
birthdate.day,
Expand All @@ -119,7 +118,7 @@ def test_gdf2_data():
misc=None,
preload=True,
)
assert raw._raw_extras[0]["subject_info"]["age"] is None
assert raw.info["subject_info"]["birthday"] == date(1, 1, 1)

picks = pick_types(raw.info, meg=False, eeg=True, exclude="bads")
data, _ = raw[picks]
Expand Down
3 changes: 2 additions & 1 deletion mne/io/fiff/tests/test_raw_fiff.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# License: BSD-3-Clause
# Copyright the MNE-Python contributors.

import datetime
import os
import pathlib
import pickle
Expand Down Expand Up @@ -189,7 +190,7 @@ def test_subject_info(tmp_path):
assert raw.info["subject_info"] is None
# fake some subject data
keys = ["id", "his_id", "last_name", "first_name", "birthday", "sex", "hand"]
vals = [1, "foobar", "bar", "foo", (1901, 2, 3), 0, 1]
vals = [1, "foobar", "bar", "foo", datetime.date(1901, 2, 3), 0, 1]
subject_info = dict()
for key, val in zip(keys, vals):
subject_info[key] = val
Expand Down
2 changes: 1 addition & 1 deletion mne/io/hitachi/hitachi.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ def _get_hitachi_info(fname, S_offset, D_offset, ignore_names):
last_samp = len(bounds) - 2

if age is not None and meas_date is not None:
subject_info["birthday"] = (
subject_info["birthday"] = dt.date(
meas_date.year - age,
meas_date.month,
meas_date.day,
Expand Down
2 changes: 1 addition & 1 deletion mne/io/nirx/nirx.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ def __init__(self, fname, saturated, preload=False, verbose=None):
else:
subject_info["sex"] = FIFF.FIFFV_SUBJ_SEX_UNKNOWN
if inf["age"] != "":
subject_info["birthday"] = (
subject_info["birthday"] = dt.date(
meas_date.year - int(inf["age"]),
meas_date.month,
meas_date.day,
Expand Down
Loading