515 lines
18 KiB
Python
515 lines
18 KiB
Python
# Authors: The MNE-Python contributors.
|
|
# License: BSD-3-Clause
|
|
# Copyright the MNE-Python contributors.
|
|
|
|
from collections import OrderedDict
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
|
|
from ..._fiff.meas_info import create_info
|
|
from ..._fiff.utils import _mult_cal_one
|
|
from ...annotations import Annotations
|
|
from ...utils import _check_fname, fill_doc, logger, verbose, warn
|
|
from ..base import BaseRaw
|
|
|
|
|
|
def _ensure_path(fname):
|
|
out = fname
|
|
if not isinstance(out, Path):
|
|
out = Path(out)
|
|
return out
|
|
|
|
|
|
@fill_doc
|
|
def read_raw_nihon(fname, preload=False, verbose=None) -> "RawNihon":
|
|
"""Reader for an Nihon Kohden EEG file.
|
|
|
|
Parameters
|
|
----------
|
|
fname : path-like
|
|
Path to the Nihon Kohden data file (``.EEG``).
|
|
preload : bool
|
|
If True, all data are loaded at initialization.
|
|
%(verbose)s
|
|
|
|
Returns
|
|
-------
|
|
raw : instance of RawNihon
|
|
A Raw object containing Nihon Kohden data.
|
|
See :class:`mne.io.Raw` for documentation of attributes and methods.
|
|
|
|
See Also
|
|
--------
|
|
mne.io.Raw : Documentation of attributes and methods of RawNihon.
|
|
"""
|
|
return RawNihon(fname, preload, verbose)
|
|
|
|
|
|
_valid_headers = [
|
|
"EEG-1100A V01.00",
|
|
"EEG-1100B V01.00",
|
|
"EEG-1100C V01.00",
|
|
"QI-403A V01.00",
|
|
"QI-403A V02.00",
|
|
"EEG-2100 V01.00",
|
|
"EEG-2100 V02.00",
|
|
"DAE-2100D V01.30",
|
|
"DAE-2100D V02.00",
|
|
# 'EEG-1200A V01.00', # Not working for the moment.
|
|
]
|
|
|
|
|
|
def _read_nihon_metadata(fname):
|
|
metadata = {}
|
|
fname = _ensure_path(fname)
|
|
pnt_fname = fname.with_suffix(".PNT")
|
|
if not pnt_fname.exists():
|
|
warn("No PNT file exists. Metadata will be blank")
|
|
return metadata
|
|
logger.info("Found PNT file, reading metadata.")
|
|
with open(pnt_fname) as fid:
|
|
version = np.fromfile(fid, "|S16", 1).astype("U16")[0]
|
|
if version not in _valid_headers:
|
|
raise ValueError(f"Not a valid Nihon Kohden PNT file ({version})")
|
|
metadata["version"] = version
|
|
|
|
# Read timestamp
|
|
fid.seek(0x40)
|
|
meas_str = np.fromfile(fid, "|S14", 1).astype("U14")[0]
|
|
meas_date = datetime.strptime(meas_str, "%Y%m%d%H%M%S")
|
|
meas_date = meas_date.replace(tzinfo=timezone.utc)
|
|
metadata["meas_date"] = meas_date
|
|
|
|
return metadata
|
|
|
|
|
|
_default_chan_labels = [
|
|
"FP1",
|
|
"FP2",
|
|
"F3",
|
|
"F4",
|
|
"C3",
|
|
"C4",
|
|
"P3",
|
|
"P4",
|
|
"O1",
|
|
"O2",
|
|
"F7",
|
|
"F8",
|
|
"T3",
|
|
"T4",
|
|
"T5",
|
|
"T6",
|
|
"FZ",
|
|
"CZ",
|
|
"PZ",
|
|
"E",
|
|
"PG1",
|
|
"PG2",
|
|
"A1",
|
|
"A2",
|
|
"T1",
|
|
"T2",
|
|
]
|
|
_default_chan_labels += [f"X{i}" for i in range(1, 12)]
|
|
_default_chan_labels += [f"NA{i}" for i in range(1, 6)]
|
|
_default_chan_labels += [f"DC{i:02}" for i in range(1, 33)]
|
|
_default_chan_labels += ["BN1", "BN2", "Mark1", "Mark2"]
|
|
_default_chan_labels += [f"NA{i}" for i in range(6, 28)]
|
|
_default_chan_labels += ["X12/BP1", "X13/BP2", "X14/BP3", "X15/BP4"]
|
|
_default_chan_labels += [f"X{i}" for i in range(16, 166)]
|
|
_default_chan_labels += ["NA28", "Z"]
|
|
|
|
_encodings = ("utf-8", "latin1")
|
|
|
|
|
|
def _read_21e_file(fname):
|
|
fname = _ensure_path(fname)
|
|
e_fname = fname.with_suffix(".21E")
|
|
_chan_labels = [x for x in _default_chan_labels]
|
|
if e_fname.exists():
|
|
# Read the 21E file and update the labels accordingly.
|
|
logger.info("Found 21E file, reading channel names.")
|
|
for enc in _encodings:
|
|
try:
|
|
with open(e_fname, encoding=enc) as fid:
|
|
keep_parsing = False
|
|
for line in fid:
|
|
if line.startswith("["):
|
|
if "ELECTRODE" in line or "REFERENCE" in line:
|
|
keep_parsing = True
|
|
else:
|
|
keep_parsing = False
|
|
elif keep_parsing is True:
|
|
idx, name = line.split("=")
|
|
idx = int(idx)
|
|
if idx >= len(_chan_labels):
|
|
n = idx - len(_chan_labels) + 1
|
|
_chan_labels.extend(["UNK"] * n)
|
|
_chan_labels[idx] = name.strip()
|
|
except UnicodeDecodeError:
|
|
pass
|
|
else:
|
|
break
|
|
else:
|
|
warn(
|
|
f"Could not decode 21E file as one of {_encodings}; "
|
|
f"Default channel names are chosen."
|
|
)
|
|
|
|
return _chan_labels
|
|
|
|
|
|
def _read_nihon_header(fname):
|
|
# Read the Nihon Kohden EEG file header
|
|
fname = _ensure_path(fname)
|
|
_chan_labels = _read_21e_file(fname)
|
|
header = {}
|
|
logger.info(f"Reading header from {fname}")
|
|
with open(fname) as fid:
|
|
version = np.fromfile(fid, "|S16", 1).astype("U16")[0]
|
|
if version not in _valid_headers:
|
|
raise ValueError(f"Not a valid Nihon Kohden EEG file ({version})")
|
|
|
|
fid.seek(0x0081)
|
|
control_block = np.fromfile(fid, "|S16", 1).astype("U16")[0]
|
|
if control_block not in _valid_headers:
|
|
raise ValueError(
|
|
f"Not a valid Nihon Kohden EEG file (control block {version})"
|
|
)
|
|
|
|
fid.seek(0x17FE)
|
|
waveform_sign = np.fromfile(fid, np.uint8, 1)[0]
|
|
if waveform_sign != 1:
|
|
raise ValueError("Not a valid Nihon Kohden EEG file (waveform block)")
|
|
header["version"] = version
|
|
|
|
fid.seek(0x0091)
|
|
n_ctlblocks = np.fromfile(fid, np.uint8, 1)[0]
|
|
header["n_ctlblocks"] = n_ctlblocks
|
|
controlblocks = []
|
|
for i_ctl_block in range(n_ctlblocks):
|
|
t_controlblock = {}
|
|
fid.seek(0x0092 + i_ctl_block * 20)
|
|
t_ctl_address = np.fromfile(fid, np.uint32, 1)[0]
|
|
t_controlblock["address"] = t_ctl_address
|
|
fid.seek(t_ctl_address + 17)
|
|
n_datablocks = np.fromfile(fid, np.uint8, 1)[0]
|
|
t_controlblock["n_datablocks"] = n_datablocks
|
|
t_controlblock["datablocks"] = []
|
|
for i_data_block in range(n_datablocks):
|
|
t_datablock = {}
|
|
fid.seek(t_ctl_address + i_data_block * 20 + 18)
|
|
t_data_address = np.fromfile(fid, np.uint32, 1)[0]
|
|
t_datablock["address"] = t_data_address
|
|
|
|
fid.seek(t_data_address + 0x26)
|
|
t_n_channels = np.fromfile(fid, np.uint8, 1)[0].astype(np.int64)
|
|
t_datablock["n_channels"] = t_n_channels
|
|
|
|
t_channels = []
|
|
for i_ch in range(t_n_channels):
|
|
fid.seek(t_data_address + 0x27 + (i_ch * 10))
|
|
t_idx = np.fromfile(fid, np.uint8, 1)[0]
|
|
t_channels.append(_chan_labels[t_idx])
|
|
|
|
t_datablock["channels"] = t_channels
|
|
|
|
fid.seek(t_data_address + 0x1C)
|
|
t_record_duration = np.fromfile(fid, np.uint32, 1)[0].astype(np.int64)
|
|
t_datablock["duration"] = t_record_duration
|
|
|
|
fid.seek(t_data_address + 0x1A)
|
|
sfreq = np.fromfile(fid, np.uint16, 1)[0] & 0x3FFF
|
|
t_datablock["sfreq"] = sfreq.astype(np.int64)
|
|
|
|
t_datablock["n_samples"] = np.int64(t_record_duration * sfreq // 10)
|
|
t_controlblock["datablocks"].append(t_datablock)
|
|
controlblocks.append(t_controlblock)
|
|
header["controlblocks"] = controlblocks
|
|
|
|
# Now check that every data block has the same channels and sfreq
|
|
chans = []
|
|
sfreqs = []
|
|
nsamples = []
|
|
for t_ctl in header["controlblocks"]:
|
|
for t_dtb in t_ctl["datablocks"]:
|
|
chans.append(t_dtb["channels"])
|
|
sfreqs.append(t_dtb["sfreq"])
|
|
nsamples.append(t_dtb["n_samples"])
|
|
for i_elem in range(1, len(chans)):
|
|
if chans[0] != chans[i_elem]:
|
|
raise ValueError("Channel names in datablocks do not match")
|
|
if sfreqs[0] != sfreqs[i_elem]:
|
|
raise ValueError("Sample frequency in datablocks do not match")
|
|
header["ch_names"] = chans[0]
|
|
header["sfreq"] = sfreqs[0]
|
|
header["n_samples"] = np.sum(nsamples)
|
|
|
|
# TODO: Support more than one controlblock and more than one datablock
|
|
if header["n_ctlblocks"] != 1:
|
|
raise NotImplementedError(
|
|
"I dont know how to read more than one "
|
|
"control block for this type of file :("
|
|
)
|
|
if header["controlblocks"][0]["n_datablocks"] > 1:
|
|
# Multiple blocks, check that they all have the same kind of data
|
|
datablocks = header["controlblocks"][0]["datablocks"]
|
|
block_0 = datablocks[0]
|
|
for t_block in datablocks[1:]:
|
|
if block_0["n_channels"] != t_block["n_channels"]:
|
|
raise ValueError(
|
|
"Cannot read NK file with different number of channels "
|
|
"in each datablock"
|
|
)
|
|
if block_0["channels"] != t_block["channels"]:
|
|
raise ValueError(
|
|
"Cannot read NK file with different channels in each datablock"
|
|
)
|
|
if block_0["sfreq"] != t_block["sfreq"]:
|
|
raise ValueError(
|
|
"Cannot read NK file with different sfreq in each datablock"
|
|
)
|
|
|
|
return header
|
|
|
|
|
|
def _read_nihon_annotations(fname):
|
|
fname = _ensure_path(fname)
|
|
log_fname = fname.with_suffix(".LOG")
|
|
if not log_fname.exists():
|
|
warn("No LOG file exists. Annotations will not be read")
|
|
return dict(onset=[], duration=[], description=[])
|
|
logger.info("Found LOG file, reading events.")
|
|
with open(log_fname) as fid:
|
|
version = np.fromfile(fid, "|S16", 1).astype("U16")[0]
|
|
if version not in _valid_headers:
|
|
raise ValueError(f"Not a valid Nihon Kohden LOG file ({version})")
|
|
|
|
fid.seek(0x91)
|
|
n_logblocks = np.fromfile(fid, np.uint8, 1)[0]
|
|
all_onsets = []
|
|
all_descriptions = []
|
|
for t_block in range(n_logblocks):
|
|
fid.seek(0x92 + t_block * 20)
|
|
t_blk_address = np.fromfile(fid, np.uint32, 1)[0]
|
|
fid.seek(t_blk_address + 0x12)
|
|
n_logs = np.fromfile(fid, np.uint8, 1)[0]
|
|
fid.seek(t_blk_address + 0x14)
|
|
t_logs = np.fromfile(fid, "|S45", n_logs)
|
|
for t_log in t_logs:
|
|
for enc in _encodings:
|
|
try:
|
|
t_log = t_log.decode(enc)
|
|
except UnicodeDecodeError:
|
|
pass
|
|
else:
|
|
break
|
|
else:
|
|
warn(f"Could not decode log as one of {_encodings}")
|
|
continue
|
|
t_desc = t_log[:20].strip("\x00")
|
|
t_onset = datetime.strptime(t_log[20:26], "%H%M%S")
|
|
t_onset = t_onset.hour * 3600 + t_onset.minute * 60 + t_onset.second
|
|
all_onsets.append(t_onset)
|
|
all_descriptions.append(t_desc)
|
|
|
|
annots = dict(
|
|
onset=all_onsets,
|
|
duration=[0] * len(all_onsets),
|
|
description=all_descriptions,
|
|
)
|
|
return annots
|
|
|
|
|
|
def _map_ch_to_type(ch_name):
|
|
ch_type_pattern = OrderedDict(
|
|
[("stim", ("Mark",)), ("misc", ("DC", "NA", "Z", "$")), ("bio", ("X",))]
|
|
)
|
|
for key, kinds in ch_type_pattern.items():
|
|
if any(kind in ch_name for kind in kinds):
|
|
return key
|
|
return "eeg"
|
|
|
|
|
|
def _map_ch_to_specs(ch_name):
|
|
unit_mult = 1e-3
|
|
phys_min = -12002.9
|
|
phys_max = 12002.56
|
|
dig_min = -32768
|
|
if ch_name.upper() in _default_chan_labels:
|
|
idx = _default_chan_labels.index(ch_name.upper())
|
|
if (idx < 42 or idx > 73) and idx not in [76, 77]:
|
|
unit_mult = 1e-6
|
|
phys_min = -3200
|
|
phys_max = 3199.902
|
|
t_range = phys_max - phys_min
|
|
cal = t_range / 65535
|
|
offset = phys_min - (dig_min * cal)
|
|
|
|
out = dict(
|
|
unit=unit_mult,
|
|
phys_min=phys_min,
|
|
phys_max=phys_max,
|
|
dig_min=dig_min,
|
|
cal=cal,
|
|
offset=offset,
|
|
)
|
|
return out
|
|
|
|
|
|
@fill_doc
|
|
class RawNihon(BaseRaw):
|
|
"""Raw object from a Nihon Kohden EEG file.
|
|
|
|
Parameters
|
|
----------
|
|
fname : path-like
|
|
Path to the Nihon Kohden data ``.eeg`` file.
|
|
preload : bool
|
|
If True, all data are loaded at initialization.
|
|
%(verbose)s
|
|
|
|
See Also
|
|
--------
|
|
mne.io.Raw : Documentation of attributes and methods.
|
|
"""
|
|
|
|
@verbose
|
|
def __init__(self, fname, preload=False, verbose=None):
|
|
fname = _check_fname(fname, "read", True, "fname")
|
|
data_name = fname.name
|
|
logger.info(f"Loading {data_name}")
|
|
|
|
header = _read_nihon_header(fname)
|
|
metadata = _read_nihon_metadata(fname)
|
|
|
|
# n_chan = len(header['ch_names']) + 1
|
|
sfreq = header["sfreq"]
|
|
# data are multiplexed int16
|
|
ch_names = header["ch_names"]
|
|
ch_types = [_map_ch_to_type(x) for x in ch_names]
|
|
|
|
info = create_info(ch_names, sfreq, ch_types)
|
|
n_samples = header["n_samples"]
|
|
|
|
if "meas_date" in metadata:
|
|
with info._unlock():
|
|
info["meas_date"] = metadata["meas_date"]
|
|
chs = {x: _map_ch_to_specs(x) for x in info["ch_names"]}
|
|
|
|
cal = np.array([chs[x]["cal"] for x in info["ch_names"]], float)[:, np.newaxis]
|
|
offsets = np.array([chs[x]["offset"] for x in info["ch_names"]], float)[
|
|
:, np.newaxis
|
|
]
|
|
gains = np.array([chs[x]["unit"] for x in info["ch_names"]], float)[
|
|
:, np.newaxis
|
|
]
|
|
|
|
raw_extras = dict(cal=cal, offsets=offsets, gains=gains, header=header)
|
|
for i_ch, ch_name in enumerate(info["ch_names"]):
|
|
t_range = chs[ch_name]["phys_max"] - chs[ch_name]["phys_min"]
|
|
info["chs"][i_ch]["range"] = t_range
|
|
info["chs"][i_ch]["cal"] = 1 / t_range
|
|
|
|
super().__init__(
|
|
info,
|
|
preload=preload,
|
|
last_samps=(n_samples - 1,),
|
|
filenames=[fname.as_posix()],
|
|
orig_format="short",
|
|
raw_extras=[raw_extras],
|
|
)
|
|
|
|
# Get annotations from LOG file
|
|
annots = _read_nihon_annotations(fname)
|
|
|
|
# Annotate acquisition skips
|
|
controlblock = header["controlblocks"][0]
|
|
cur_sample = 0
|
|
if controlblock["n_datablocks"] > 1:
|
|
for i_block in range(controlblock["n_datablocks"] - 1):
|
|
t_block = controlblock["datablocks"][i_block]
|
|
cur_sample = cur_sample + t_block["n_samples"]
|
|
cur_tpoint = (cur_sample - 0.5) / t_block["sfreq"]
|
|
# Add annotations as in append raw
|
|
annots["onset"].append(cur_tpoint)
|
|
annots["duration"].append(0.0)
|
|
annots["description"].append("BAD boundary")
|
|
annots["onset"].append(cur_tpoint)
|
|
annots["duration"].append(0.0)
|
|
annots["description"].append("EDGE boundary")
|
|
|
|
annotations = Annotations(**annots, orig_time=info["meas_date"])
|
|
self.set_annotations(annotations)
|
|
|
|
def _read_segment_file(self, data, idx, fi, start, stop, cals, mult):
|
|
"""Read a chunk of raw data."""
|
|
# For now we assume one control block
|
|
header = self._raw_extras[fi]["header"]
|
|
|
|
# Get the original cal, offsets and gains
|
|
cal = self._raw_extras[fi]["cal"]
|
|
offsets = self._raw_extras[fi]["offsets"]
|
|
gains = self._raw_extras[fi]["gains"]
|
|
|
|
# get the right datablock
|
|
datablocks = header["controlblocks"][0]["datablocks"]
|
|
ends = np.cumsum([t["n_samples"] for t in datablocks])
|
|
|
|
start_block = np.where(start < ends)[0][0]
|
|
stop_block = np.where(stop <= ends)[0][0]
|
|
|
|
if start_block != stop_block:
|
|
# Recursive call for each block independently
|
|
new_start = start
|
|
sample_start = 0
|
|
for t_block_idx in range(start_block, stop_block + 1):
|
|
t_block = datablocks[t_block_idx]
|
|
if t_block == stop_block:
|
|
# If its the last block, we stop on the last sample to read
|
|
new_stop = stop
|
|
else:
|
|
# Otherwise, stop on the last sample of the block
|
|
new_stop = t_block["n_samples"] + new_start
|
|
samples_to_read = new_stop - new_start
|
|
sample_stop = sample_start + samples_to_read
|
|
|
|
self._read_segment_file(
|
|
data[:, sample_start:sample_stop],
|
|
idx,
|
|
fi,
|
|
new_start,
|
|
new_stop,
|
|
cals,
|
|
mult,
|
|
)
|
|
|
|
# Update variables for next loop
|
|
sample_start = sample_stop
|
|
new_start = new_stop
|
|
else:
|
|
datablock = datablocks[start_block]
|
|
|
|
n_channels = datablock["n_channels"] + 1
|
|
datastart = datablock["address"] + 0x27 + (datablock["n_channels"] * 10)
|
|
|
|
# Compute start offset based on the beginning of the block
|
|
rel_start = start
|
|
if start_block != 0:
|
|
rel_start = start - ends[start_block - 1]
|
|
start_offset = datastart + rel_start * n_channels * 2
|
|
|
|
with open(self._filenames[fi], "rb") as fid:
|
|
to_read = (stop - start) * n_channels
|
|
fid.seek(start_offset)
|
|
block_data = np.fromfile(fid, "<u2", to_read) + 0x8000
|
|
block_data = block_data.astype(np.int16)
|
|
block_data = block_data.reshape(n_channels, -1, order="F")
|
|
block_data = block_data[:-1] * cal # cast to float64
|
|
block_data += offsets
|
|
block_data *= gains
|
|
_mult_cal_one(data, block_data, idx, cals, mult)
|