230 lines
7.8 KiB
Python
230 lines
7.8 KiB
Python
# Authors: The MNE-Python contributors.
|
|
# License: BSD-3-Clause
|
|
# Copyright the MNE-Python contributors.
|
|
|
|
"""Import NeuroElectrics DataFormat (NEDF) files."""
|
|
|
|
from copy import deepcopy
|
|
from datetime import datetime, timezone
|
|
|
|
import numpy as np
|
|
|
|
from ..._fiff.meas_info import create_info
|
|
from ..._fiff.utils import _mult_cal_one
|
|
from ...utils import _check_fname, _soft_import, verbose, warn
|
|
from ..base import BaseRaw
|
|
|
|
|
|
def _getsubnodetext(node, name):
|
|
"""Get an element from an XML node, raise an error otherwise.
|
|
|
|
Parameters
|
|
----------
|
|
node: Element
|
|
XML Element
|
|
name: str
|
|
Child element name
|
|
|
|
Returns
|
|
-------
|
|
test: str
|
|
Text contents of the child nodes
|
|
"""
|
|
subnode = node.findtext(name)
|
|
if not subnode:
|
|
raise RuntimeError("NEDF header " + name + " not found")
|
|
return subnode
|
|
|
|
|
|
def _parse_nedf_header(header):
|
|
"""Read header information from the first 10kB of an .nedf file.
|
|
|
|
Parameters
|
|
----------
|
|
header : bytes
|
|
Null-terminated header data, mostly the file's first 10240 bytes.
|
|
|
|
Returns
|
|
-------
|
|
info : dict
|
|
A dictionary with header information.
|
|
dt : numpy.dtype
|
|
Structure of the binary EEG/accelerometer/trigger data in the file.
|
|
n_samples : int
|
|
The number of data samples.
|
|
"""
|
|
defusedxml = _soft_import("defusedxml", "reading NEDF data")
|
|
info = {}
|
|
# nedf files have three accelerometer channels sampled at 100Hz followed
|
|
# by five EEG samples + TTL trigger sampled at 500Hz
|
|
# For 32 EEG channels and no stim channels, the data layout may look like
|
|
# [ ('acc', '>u2', (3,)),
|
|
# ('data', dtype([
|
|
# ('eeg', 'u1', (32, 3)),
|
|
# ('trig', '>i4', (1,))
|
|
# ]), (5,))
|
|
# ]
|
|
|
|
dt = [] # dtype for the binary data block
|
|
datadt = [] # dtype for a single EEG sample
|
|
|
|
headerend = header.find(b"\0")
|
|
if headerend == -1:
|
|
raise RuntimeError("End of header null not found")
|
|
headerxml = defusedxml.ElementTree.fromstring(header[:headerend])
|
|
nedfversion = headerxml.findtext("NEDFversion", "")
|
|
if nedfversion not in ["1.3", "1.4"]:
|
|
warn("NEDFversion unsupported, use with caution")
|
|
|
|
if headerxml.findtext("stepDetails/DeviceClass", "") == "STARSTIM":
|
|
warn("Found Starstim, this hasn't been tested extensively!")
|
|
|
|
if headerxml.findtext("AdditionalChannelStatus", "OFF") != "OFF":
|
|
raise RuntimeError("Unknown additional channel, aborting.")
|
|
|
|
n_acc = int(headerxml.findtext("NumberOfChannelsOfAccelerometer", 0))
|
|
if n_acc:
|
|
# expect one sample of u16 accelerometer data per block
|
|
dt.append(("acc", ">u2", (n_acc,)))
|
|
|
|
eegset = headerxml.find("EEGSettings")
|
|
if eegset is None:
|
|
raise RuntimeError("No EEG channels found")
|
|
nchantotal = int(_getsubnodetext(eegset, "TotalNumberOfChannels"))
|
|
info["nchan"] = nchantotal
|
|
|
|
info["sfreq"] = int(_getsubnodetext(eegset, "EEGSamplingRate"))
|
|
info["ch_names"] = [e.text for e in eegset.find("EEGMontage")]
|
|
if nchantotal != len(info["ch_names"]):
|
|
raise RuntimeError(
|
|
f"TotalNumberOfChannels ({nchantotal}) != "
|
|
f"channel count ({len(info['ch_names'])})"
|
|
)
|
|
# expect nchantotal uint24s
|
|
datadt.append(("eeg", "B", (nchantotal, 3)))
|
|
|
|
if headerxml.find("STIMSettings") is not None:
|
|
# 2* -> two stim samples per eeg sample
|
|
datadt.append(("stim", "B", (2, nchantotal, 3)))
|
|
warn("stim channels are currently ignored")
|
|
|
|
# Trigger data: 4 bytes in newer versions, 1 byte in older versions
|
|
trigger_type = ">i4" if headerxml.findtext("NEDFversion") else "B"
|
|
datadt.append(("trig", trigger_type))
|
|
# 5 data samples per block
|
|
dt.append(("data", np.dtype(datadt), (5,)))
|
|
|
|
date = headerxml.findtext("StepDetails/StartDate_firstEEGTimestamp", 0)
|
|
info["meas_date"] = datetime.fromtimestamp(int(date) / 1000, timezone.utc)
|
|
|
|
n_samples = int(_getsubnodetext(eegset, "NumberOfRecordsOfEEG"))
|
|
n_full, n_last = divmod(n_samples, 5)
|
|
dt_last = deepcopy(dt)
|
|
assert dt_last[-1][-1] == (5,)
|
|
dt_last[-1] = list(dt_last[-1])
|
|
dt_last[-1][-1] = (n_last,)
|
|
dt_last[-1] = tuple(dt_last[-1])
|
|
return info, np.dtype(dt), np.dtype(dt_last), n_samples, n_full
|
|
|
|
|
|
# the first 10240 bytes are header in XML format, padded with NULL bytes
|
|
_HDRLEN = 10240
|
|
|
|
|
|
class RawNedf(BaseRaw):
|
|
"""Raw object from NeuroElectrics nedf file."""
|
|
|
|
def __init__(self, filename, preload=False, verbose=None):
|
|
filename = str(_check_fname(filename, "read", True, "filename"))
|
|
with open(filename, mode="rb") as fid:
|
|
header = fid.read(_HDRLEN)
|
|
header, dt, dt_last, n_samp, n_full = _parse_nedf_header(header)
|
|
ch_names = header["ch_names"] + ["STI 014"]
|
|
ch_types = ["eeg"] * len(ch_names)
|
|
ch_types[-1] = "stim"
|
|
info = create_info(ch_names, header["sfreq"], ch_types)
|
|
# scaling factor ADC-values -> volts
|
|
# taken from the NEDF EEGLAB plugin
|
|
# (https://www.neuroelectrics.com/resources/software/):
|
|
for ch in info["chs"][:-1]:
|
|
ch["cal"] = 2.4 / (6.0 * 8388607)
|
|
with info._unlock():
|
|
info["meas_date"] = header["meas_date"]
|
|
raw_extra = dict(dt=dt, dt_last=dt_last, n_full=n_full)
|
|
super().__init__(
|
|
info,
|
|
preload=preload,
|
|
filenames=[filename],
|
|
verbose=verbose,
|
|
raw_extras=[raw_extra],
|
|
last_samps=[n_samp - 1],
|
|
)
|
|
|
|
def _read_segment_file(self, data, idx, fi, start, stop, cals, mult):
|
|
dt = self._raw_extras[fi]["dt"]
|
|
dt_last = self._raw_extras[fi]["dt_last"]
|
|
n_full = self._raw_extras[fi]["n_full"]
|
|
n_eeg = dt[1].subdtype[0][0].shape[0]
|
|
# data is stored in 5-sample chunks (except maybe the last one!)
|
|
# so we have to do some gymnastics to pick the correct parts to
|
|
# read
|
|
offset = start // 5 * dt.itemsize + _HDRLEN
|
|
start_sl = start % 5
|
|
n_samples = stop - start
|
|
n_samples_full = min(stop, n_full * 5) - start
|
|
last = None
|
|
n_chunks = (n_samples_full - 1) // 5 + 1
|
|
n_tot = n_chunks * 5
|
|
with open(self._filenames[fi], "rb") as fid:
|
|
fid.seek(offset, 0)
|
|
chunks = np.fromfile(fid, dtype=dt, count=n_chunks)
|
|
assert len(chunks) == n_chunks
|
|
if n_samples != n_samples_full:
|
|
last = np.fromfile(fid, dtype=dt_last, count=1)
|
|
eeg = _convert_eeg(chunks, n_eeg, n_tot)
|
|
trig = chunks["data"]["trig"].reshape(1, n_tot)
|
|
if last is not None:
|
|
n_last = dt_last["data"].shape[0]
|
|
eeg = np.concatenate((eeg, _convert_eeg(last, n_eeg, n_last)), axis=-1)
|
|
trig = np.concatenate(
|
|
(trig, last["data"]["trig"].reshape(1, n_last)), axis=-1
|
|
)
|
|
one_ = np.concatenate((eeg, trig))
|
|
one = one_[:, start_sl : n_samples + start_sl]
|
|
_mult_cal_one(data, one, idx, cals, mult)
|
|
|
|
|
|
def _convert_eeg(chunks, n_eeg, n_tot):
|
|
# convert uint8-triplet -> int32
|
|
eeg = chunks["data"]["eeg"] @ np.array([1 << 16, 1 << 8, 1])
|
|
# convert sign if necessary
|
|
eeg[eeg > (1 << 23)] -= 1 << 24
|
|
eeg = eeg.reshape((n_tot, n_eeg)).T
|
|
return eeg
|
|
|
|
|
|
@verbose
|
|
def read_raw_nedf(filename, preload=False, verbose=None) -> RawNedf:
|
|
"""Read NeuroElectrics .nedf files.
|
|
|
|
NEDF file versions starting from 1.3 are supported.
|
|
|
|
Parameters
|
|
----------
|
|
filename : path-like
|
|
Path to the ``.nedf`` file.
|
|
%(preload)s
|
|
%(verbose)s
|
|
|
|
Returns
|
|
-------
|
|
raw : instance of RawNedf
|
|
A Raw object containing NEDF data.
|
|
See :class:`mne.io.Raw` for documentation of attributes and methods.
|
|
|
|
See Also
|
|
--------
|
|
mne.io.Raw : Documentation of attributes and methods of RawNedf.
|
|
"""
|
|
return RawNedf(filename, preload, verbose)
|