Skip to content

Commit 61bc8b8

Browse files
cbrnrpre-commit-ci[bot]larsoner
authored
Add BDF export (mne-tools#13435)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Eric Larson <[email protected]>
1 parent 4fdde3a commit 61bc8b8

File tree

7 files changed

+150
-54
lines changed

7 files changed

+150
-54
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for BDF export in :func:`mne.export.export_raw`, by `Clemens Brunner`_

environment.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ dependencies:
99
- decorator
1010
- defusedxml
1111
- dipy
12-
- edfio >=0.2.1
12+
- edfio >=0.4.10
1313
- eeglabio
1414
- filelock >=3.18.0
1515
- h5io >=0.2.4

mne/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
event_id, tmin, tmax = 1, -0.1, 1.0
7979
vv_layout = read_layout("Vectorview-all")
8080

81-
collect_ignore = ["export/_brainvision.py", "export/_eeglab.py", "export/_edf.py"]
81+
collect_ignore = ["export/_brainvision.py", "export/_eeglab.py", "export/_edf_bdf.py"]
8282

8383

8484
def pytest_configure(config: pytest.Config):
Lines changed: 75 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,19 @@
77

88
import numpy as np
99

10-
from ..annotations import _sync_onset
11-
from ..utils import _check_edfio_installed, warn
10+
from mne.annotations import _sync_onset
11+
from mne.utils import _check_edfio_installed, warn
1212

1313
_check_edfio_installed()
14-
from edfio import Edf, EdfAnnotation, EdfSignal, Patient, Recording # noqa: E402
14+
from edfio import ( # noqa: E402
15+
Bdf,
16+
BdfSignal,
17+
Edf,
18+
EdfAnnotation,
19+
EdfSignal,
20+
Patient,
21+
Recording,
22+
)
1523

1624

1725
# copied from edfio (Apache license)
@@ -29,44 +37,61 @@ def _round_float_to_8_characters(
2937
return round_func(value * factor) / factor
3038

3139

32-
def _export_raw(fname, raw, physical_range, add_ch_type):
33-
"""Export Raw objects to EDF files.
40+
def _export_raw_edf_bdf(fname, raw, physical_range, add_ch_type, file_format):
41+
"""Export Raw objects to EDF/BDF files.
3442
43+
Parameters
44+
----------
45+
fname : str
46+
Output file name.
47+
raw : instance of Raw
48+
The raw instance to export.
49+
physical_range : str or tuple
50+
Physical range setting.
51+
add_ch_type : bool
52+
Whether to add channel type to signal label.
53+
file_format : str
54+
File format ("EDF" or "BDF").
55+
56+
Notes
57+
-----
3558
TODO: if in future the Info object supports transducer or technician information,
3659
allow writing those here.
3760
"""
38-
# get voltage-based data in uV
3961
units = dict(
4062
eeg="uV", ecog="uV", seeg="uV", eog="uV", ecg="uV", emg="uV", bio="uV", dbs="uV"
4163
)
4264

43-
digital_min, digital_max = -32767, 32767
44-
annotations = []
45-
46-
# load data first
47-
raw.load_data()
65+
if file_format == "EDF":
66+
digital_min, digital_max = -32767, 32767 # 16-bit
67+
signal_class = EdfSignal
68+
writer_class = Edf
69+
else: # BDF
70+
digital_min, digital_max = -8388607, 8388607 # 24-bit
71+
signal_class = BdfSignal
72+
writer_class = Bdf
4873

4974
ch_types = np.array(raw.get_channel_types())
50-
n_times = raw.n_times
5175

52-
# get the entire dataset in uV
76+
# load and prepare data
77+
raw.load_data()
5378
data = raw.get_data(units=units)
54-
55-
# Sampling frequency in EDF only supports integers, so to allow for float sampling
56-
# rates from Raw, we adjust the output sampling rate for all channels and the data
57-
# record duration.
5879
sfreq = raw.info["sfreq"]
80+
pad_annotations = []
81+
82+
# Sampling frequency in EDF/BDF only supports integers, so to allow for float
83+
# sampling rates from Raw, we adjust the output sampling rate for all channels and
84+
# the data record duration.
5985
if float(sfreq).is_integer():
6086
out_sfreq = int(sfreq)
6187
data_record_duration = None
6288
# make non-integer second durations work
63-
if (pad_width := int(np.ceil(n_times / sfreq) * sfreq - n_times)) > 0:
89+
if (pad_width := int(np.ceil(raw.n_times / sfreq) * sfreq - raw.n_times)) > 0:
6490
warn(
65-
"EDF format requires equal-length data blocks, so "
91+
f"{file_format} format requires equal-length data blocks, so "
6692
f"{pad_width / sfreq:.3g} seconds of edge values were appended to all "
6793
"channels when writing the final block."
6894
)
69-
orig_shape = data.shape
7095
data = np.pad(
7196
data,
7297
(
@@ -75,10 +100,8 @@ def _export_raw(fname, raw, physical_range, add_ch_type):
75100
),
76101
"edge",
77102
)
78-
assert data.shape[0] == orig_shape[0]
79-
assert data.shape[1] > orig_shape[1]
80103

81-
annotations.append(
104+
pad_annotations.append(
82105
EdfAnnotation(
83106
raw.times[-1] + 1 / sfreq, pad_width / sfreq, "BAD_ACQ_SKIP"
84107
)
@@ -89,18 +112,19 @@ def _export_raw(fname, raw, physical_range, add_ch_type):
89112
)
90113
out_sfreq = np.floor(sfreq) / data_record_duration
91114
warn(
92-
f"Data has a non-integer sampling rate of {sfreq}; writing to EDF format "
93-
"may cause a small change to sample times."
115+
f"Data has a non-integer sampling rate of {sfreq}; writing to "
116+
f"{file_format} format may cause a small change to sample times."
94117
)
95118

96-
# get any filter information applied to the data
119+
# extract filter information
97120
lowpass = raw.info["lowpass"]
98121
highpass = raw.info["highpass"]
99122
linefreq = raw.info["line_freq"]
100123
filter_str_info = f"HP:{highpass}Hz LP:{lowpass}Hz"
101124
if linefreq is not None:
102-
filter_str_info += " N:{linefreq}Hz"
125+
filter_str_info += f" N:{linefreq}Hz"
103126

127+
# compute physical range
104128
if physical_range == "auto":
105129
# get max and min for each channel type data
106130
ch_types_phys_max = dict()
@@ -136,15 +160,17 @@ def _export_raw(fname, raw, physical_range, add_ch_type):
136160
)
137161
data = np.clip(data, pmin, pmax)
138162
prange = pmin, pmax
163+
164+
# create signals
139165
signals = []
140166
for idx, ch in enumerate(raw.ch_names):
141167
ch_type = ch_types[idx]
142168
signal_label = f"{ch_type.upper()} {ch}" if add_ch_type else ch
143169
if len(signal_label) > 16:
144170
raise RuntimeError(
145171
f"Signal label for {ch} ({ch_type}) is longer than 16 characters, which"
146-
" is not supported by the EDF standard. Please shorten the channel name"
147-
"before exporting to EDF."
172+
f" is not supported by the {file_format} standard. Please shorten the "
173+
f"channel name before exporting to {file_format}."
148174
)
149175

150176
if physical_range == "auto": # per channel type
@@ -155,7 +181,7 @@ def _export_raw(fname, raw, physical_range, add_ch_type):
155181
prange = pmin, pmax
156182

157183
signals.append(
158-
EdfSignal(
184+
signal_class(
159185
data[idx],
160186
out_sfreq,
161187
label=signal_label,
@@ -167,7 +193,7 @@ def _export_raw(fname, raw, physical_range, add_ch_type):
167193
)
168194
)
169195

170-
# set patient info
196+
# create patient info
171197
subj_info = raw.info.get("subject_info")
172198
if subj_info is not None:
173199
# get the full name of subject if available
@@ -197,7 +223,7 @@ def _export_raw(fname, raw, physical_range, add_ch_type):
197223
else:
198224
patient = None
199225

200-
# set measurement date
226+
# create recording info
201227
if (meas_date := raw.info["meas_date"]) is not None:
202228
startdate = dt.date(meas_date.year, meas_date.month, meas_date.day)
203229
starttime = dt.time(
@@ -214,9 +240,11 @@ def _export_raw(fname, raw, physical_range, add_ch_type):
214240
else:
215241
recording = Recording(startdate=startdate)
216242

243+
# create annotations
244+
annotations = []
217245
for desc, onset, duration, ch_names in zip(
218246
raw.annotations.description,
219-
# subtract raw.first_time because EDF marks events starting from the first
247+
# subtract raw.first_time because EDF/BDF marks events starting from the first
220248
# available data point and ignores raw.first_time
221249
_sync_onset(raw, raw.annotations.onset, inverse=False),
222250
raw.annotations.duration,
@@ -230,11 +258,24 @@ def _export_raw(fname, raw, physical_range, add_ch_type):
230258
else:
231259
annotations.append(EdfAnnotation(onset, duration, desc))
232260

233-
Edf(
261+
annotations.extend(pad_annotations)
262+
263+
# write to file
264+
writer_class(
234265
signals=signals,
235266
patient=patient,
236267
recording=recording,
237268
starttime=starttime,
238269
data_record_duration=data_record_duration,
239270
annotations=annotations,
240271
).write(fname)
272+
273+
274+
def _export_raw_edf(fname, raw, physical_range, add_ch_type):
275+
"""Export Raw object to EDF."""
276+
_export_raw_edf_bdf(fname, raw, physical_range, add_ch_type, file_format="EDF")
277+
278+
279+
def _export_raw_bdf(fname, raw, physical_range, add_ch_type):
280+
"""Export Raw object to BDF."""
281+
_export_raw_edf_bdf(fname, raw, physical_range, add_ch_type, file_format="BDF")

mne/export/_export.py

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
# License: BSD-3-Clause
33
# Copyright the MNE-Python contributors.
44

5-
import os.path as op
5+
import os
66

7-
from ..utils import _check_fname, _validate_type, logger, verbose, warn
8-
from ._egimff import export_evokeds_mff
7+
from mne.export._egimff import export_evokeds_mff
8+
from mne.utils import _check_fname, _validate_type, logger, verbose, warn
99

1010

1111
@verbose
@@ -56,13 +56,14 @@ def export_raw(
5656
"""
5757
fname = str(_check_fname(fname, overwrite=overwrite))
5858
supported_export_formats = { # format : (extensions,)
59-
"eeglab": ("set",),
60-
"edf": ("edf",),
59+
"bdf": ("bdf",),
6160
"brainvision": (
6261
"eeg",
6362
"vmrk",
6463
"vhdr",
6564
),
65+
"edf": ("edf",),
66+
"eeglab": ("set",),
6667
}
6768
fmt = _infer_check_export_fmt(fmt, fname, supported_export_formats)
6869

@@ -73,18 +74,23 @@ def export_raw(
7374
"them before exporting with raw.apply_proj()."
7475
)
7576

76-
if fmt == "eeglab":
77-
from ._eeglab import _export_raw
77+
match fmt:
78+
case "bdf":
79+
from mne.export._edf_bdf import _export_raw_bdf
80+
81+
_export_raw_bdf(fname, raw, physical_range, add_ch_type)
82+
case "brainvision":
83+
from mne.export._brainvision import _export_raw
7884

79-
_export_raw(fname, raw)
80-
elif fmt == "edf":
81-
from ._edf import _export_raw
85+
_export_raw(fname, raw, overwrite)
86+
case "edf":
87+
from mne.export._edf_bdf import _export_raw_edf
8288

83-
_export_raw(fname, raw, physical_range, add_ch_type)
84-
elif fmt == "brainvision":
85-
from ._brainvision import _export_raw
89+
_export_raw_edf(fname, raw, physical_range, add_ch_type)
90+
case "eeglab":
91+
from mne.export._eeglab import _export_raw
8692

87-
_export_raw(fname, raw, overwrite)
93+
_export_raw(fname, raw)
8894

8995

9096
@verbose
@@ -127,7 +133,7 @@ def export_epochs(fname, epochs, fmt="auto", *, overwrite=False, verbose=None):
127133
)
128134

129135
if fmt == "eeglab":
130-
from ._eeglab import _export_epochs
136+
from mne.export._eeglab import _export_epochs
131137

132138
_export_epochs(fname, epochs)
133139

@@ -204,7 +210,7 @@ def _infer_check_export_fmt(fmt, fname, supported_formats):
204210
_validate_type(fmt, str, "fmt")
205211
fmt = fmt.lower()
206212
if fmt == "auto":
207-
fmt = op.splitext(fname)[1]
213+
fmt = os.path.splitext(fname)[1]
208214
if fmt:
209215
fmt = fmt[1:].lower()
210216
# find fmt in supported formats dict's tuples

mne/export/tests/test_export.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from mne.fixes import _compare_version
2626
from mne.io import (
2727
RawArray,
28+
read_raw_bdf,
2829
read_raw_brainvision,
2930
read_raw_edf,
3031
read_raw_eeglab,
@@ -670,3 +671,50 @@ def test_export_evokeds_unsupported_format(fmt, ext):
670671
errstr = fmt.lower() if fmt != "auto" else "vhdr"
671672
with pytest.raises(ValueError, match=f"Format '{errstr}' is not .*"):
672673
export_evokeds(f"output.{ext}", evoked, fmt=fmt)
674+
675+
676+
@edfio_mark()
677+
@pytest.mark.parametrize(
678+
("input_path", "warning_msg"),
679+
[
680+
(fname_raw, "Data has a non-integer"),
681+
pytest.param(
682+
misc_path / "ecog" / "sample_ecog_ieeg.fif",
683+
"BDF format requires",
684+
marks=[pytest.mark.slowtest, misc._pytest_mark()],
685+
),
686+
],
687+
)
688+
def test_export_raw_bdf(tmp_path, input_path, warning_msg):
689+
"""Test saving a Raw instance to BDF format."""
690+
raw = read_raw_fif(input_path)
691+
692+
# only test with EEG channels
693+
raw.pick(picks=["eeg", "ecog", "seeg"]).load_data()
694+
temp_fname = tmp_path / "test.bdf"
695+
696+
with pytest.warns(RuntimeWarning, match=warning_msg):
697+
raw.export(temp_fname)
698+
699+
if "epoc" in raw.ch_names:
700+
raw.drop_channels(["epoc"])
701+
702+
raw_read = read_raw_bdf(temp_fname, preload=True)
703+
assert raw.ch_names == raw_read.ch_names
704+
# only compare the original length, since extra zeros are appended
705+
orig_raw_len = len(raw)
706+
707+
# assert data and times are not different
708+
# Due to the physical range of the data, reading and writing is not lossless. For
709+
# example, a physical min/max of -/+ 3200 uV will result in a resolution of 0.38 nV.
710+
# This resolution is more than sufficient for EEG.
711+
assert_array_almost_equal(
712+
raw.get_data(), raw_read.get_data()[:, :orig_raw_len], decimal=11
713+
)
714+
715+
# Due to the data record duration limitations of BDF files, one cannot store
716+
# arbitrary float sampling rate exactly. Usually this results in two sampling rates
717+
# that are off by very low number of decimal points. This for practical purposes
718+
# does not matter but will result in an error when say the number of time points is
719+
# very very large.
720+
assert_allclose(raw.times, raw_read.times[:orig_raw_len], rtol=0, atol=1e-5)

0 commit comments

Comments
 (0)