275 lines
8.9 KiB
Python
275 lines
8.9 KiB
Python
# Authors: The MNE-Python contributors.
|
|
# License: BSD-3-Clause
|
|
# Copyright the MNE-Python contributors.
|
|
|
|
from collections import OrderedDict
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
|
|
from ..._fiff.meas_info import create_info
|
|
from ...evoked import EvokedArray
|
|
from ...utils import fill_doc, logger, verbose
|
|
|
|
|
|
@fill_doc
|
|
@verbose
|
|
def read_evoked_besa(fname, verbose=None):
|
|
"""Reader function for BESA ``.avr`` or ``.mul`` files.
|
|
|
|
When a ``.elp`` sidecar file is present, it will be used to determine
|
|
electrode information.
|
|
|
|
Parameters
|
|
----------
|
|
fname : path-like
|
|
Path to the ``.avr`` or ``.mul`` file.
|
|
%(verbose)s
|
|
|
|
Returns
|
|
-------
|
|
ev : Evoked
|
|
The evoked data in the .avr or .mul file.
|
|
"""
|
|
fname = Path(fname)
|
|
if fname.suffix == ".avr":
|
|
return _read_evoked_besa_avr(fname, verbose)
|
|
elif fname.suffix == ".mul":
|
|
return _read_evoked_besa_mul(fname, verbose)
|
|
else:
|
|
raise ValueError("Filename must end in either .avr or .mul")
|
|
|
|
|
|
@verbose
|
|
def _read_evoked_besa_avr(fname, verbose):
|
|
"""Create EvokedArray from a BESA .avr file."""
|
|
with open(fname) as f:
|
|
header = f.readline().strip()
|
|
|
|
# There are two versions of .avr files. The old style, generated by
|
|
# BESA 1, 2 and 3 does not define Nchan and does not have channel names
|
|
# in the file.
|
|
new_style = "Nchan=" in header
|
|
if new_style:
|
|
ch_names = f.readline().strip().split()
|
|
else:
|
|
ch_names = None
|
|
|
|
fields = _parse_header(header)
|
|
data = np.loadtxt(fname, skiprows=2 if new_style else 1, ndmin=2)
|
|
ch_types = _read_elp_sidecar(fname)
|
|
|
|
# Consolidate channel names
|
|
if new_style:
|
|
if len(ch_names) != len(data):
|
|
raise RuntimeError(
|
|
"Mismatch between the number of channel names defined in "
|
|
f"the .avr file ({len(ch_names)}) and the number of rows "
|
|
f"in the data matrix ({len(data)})."
|
|
)
|
|
else:
|
|
# Determine channel names from the .elp sidecar file
|
|
if ch_types is not None:
|
|
ch_names = list(ch_types.keys())
|
|
if len(ch_names) != len(data):
|
|
raise RuntimeError(
|
|
"Mismatch between the number of channels "
|
|
f"defined in the .avr file ({len(data)}) "
|
|
f"and .elp file ({len(ch_names)})."
|
|
)
|
|
else:
|
|
logger.info(
|
|
"No .elp file found and no channel names present in "
|
|
"the .avr file. Falling back to generic names. "
|
|
)
|
|
ch_names = [f"CH{i + 1:02d}" for i in range(len(data))]
|
|
|
|
# Consolidate channel types
|
|
if ch_types is None:
|
|
logger.info("Marking all channels as EEG.")
|
|
ch_types = ["eeg"] * len(ch_names)
|
|
else:
|
|
ch_types = [ch_types[ch] for ch in ch_names]
|
|
|
|
# Go over all the header fields and make sure they are all defined to
|
|
# something sensible.
|
|
if "Npts" in fields:
|
|
fields["Npts"] = int(fields["Npts"])
|
|
if fields["Npts"] != data.shape[1]:
|
|
logger.warn(
|
|
f"The size of the data matrix ({data.shape}) does not "
|
|
f'match the "Npts" field ({fields["Npts"]}).'
|
|
)
|
|
if "Nchan" in fields:
|
|
fields["Nchan"] = int(fields["Nchan"])
|
|
if fields["Nchan"] != data.shape[0]:
|
|
logger.warn(
|
|
f"The size of the data matrix ({data.shape}) does not "
|
|
f'match the "Nchan" field ({fields["Nchan"]}).'
|
|
)
|
|
if "DI" in fields:
|
|
fields["DI"] = float(fields["DI"])
|
|
else:
|
|
raise RuntimeError(
|
|
'No "DI" field present. Could not determine sampling frequency.'
|
|
)
|
|
if "TSB" in fields:
|
|
fields["TSB"] = float(fields["TSB"])
|
|
else:
|
|
fields["TSB"] = 0
|
|
if "SB" in fields:
|
|
fields["SB"] = float(fields["SB"])
|
|
else:
|
|
fields["SB"] = 1.0
|
|
if "SegmentName" not in fields:
|
|
fields["SegmentName"] = ""
|
|
|
|
# Build the Evoked object based on the header fields.
|
|
info = create_info(ch_names, sfreq=1000 / fields["DI"], ch_types="eeg")
|
|
return EvokedArray(
|
|
data / fields["SB"] / 1e6,
|
|
info,
|
|
tmin=fields["TSB"] / 1000,
|
|
comment=fields["SegmentName"],
|
|
verbose=verbose,
|
|
)
|
|
|
|
|
|
@verbose
|
|
def _read_evoked_besa_mul(fname, verbose):
|
|
"""Create EvokedArray from a BESA .mul file."""
|
|
with open(fname) as f:
|
|
header = f.readline().strip()
|
|
ch_names = f.readline().strip().split()
|
|
|
|
fields = _parse_header(header)
|
|
data = np.loadtxt(fname, skiprows=2, ndmin=2)
|
|
|
|
if len(ch_names) != data.shape[1]:
|
|
raise RuntimeError(
|
|
"Mismatch between the number of channel names "
|
|
f"defined in the .mul file ({len(ch_names)}) "
|
|
"and the number of columns in the data matrix "
|
|
f"({data.shape[1]})."
|
|
)
|
|
|
|
# Consolidate channel types
|
|
ch_types = _read_elp_sidecar(fname)
|
|
if ch_types is None:
|
|
logger.info("Marking all channels as EEG.")
|
|
ch_types = ["eeg"] * len(ch_names)
|
|
else:
|
|
ch_types = [ch_types[ch] for ch in ch_names]
|
|
|
|
# Go over all the header fields and make sure they are all defined to
|
|
# something sensible.
|
|
if "TimePoints" in fields:
|
|
fields["TimePoints"] = int(fields["TimePoints"])
|
|
if fields["TimePoints"] != data.shape[0]:
|
|
logger.warn(
|
|
f"The size of the data matrix ({data.shape}) does not "
|
|
f'match the "TimePoints" field ({fields["TimePoints"]}).'
|
|
)
|
|
if "Channels" in fields:
|
|
fields["Channels"] = int(fields["Channels"])
|
|
if fields["Channels"] != data.shape[1]:
|
|
logger.warn(
|
|
f"The size of the data matrix ({data.shape}) does not "
|
|
f'match the "Channels" field ({fields["Channels"]}).'
|
|
)
|
|
if "SamplingInterval[ms]" in fields:
|
|
fields["SamplingInterval[ms]"] = float(fields["SamplingInterval[ms]"])
|
|
else:
|
|
raise RuntimeError(
|
|
'No "SamplingInterval[ms]" field present. Could '
|
|
"not determine sampling frequency."
|
|
)
|
|
if "BeginSweep[ms]" in fields:
|
|
fields["BeginSweep[ms]"] = float(fields["BeginSweep[ms]"])
|
|
else:
|
|
fields["BeginSweep[ms]"] = 0.0
|
|
if "Bins/uV" in fields:
|
|
fields["Bins/uV"] = float(fields["Bins/uV"])
|
|
else:
|
|
fields["Bins/uV"] = 1
|
|
if "SegmentName" not in fields:
|
|
fields["SegmentName"] = ""
|
|
|
|
# Build the Evoked object based on the header fields.
|
|
info = create_info(
|
|
ch_names, sfreq=1000 / fields["SamplingInterval[ms]"], ch_types=ch_types
|
|
)
|
|
return EvokedArray(
|
|
data.T / fields["Bins/uV"] / 1e6,
|
|
info,
|
|
tmin=fields["BeginSweep[ms]"] / 1000,
|
|
comment=fields["SegmentName"],
|
|
verbose=verbose,
|
|
)
|
|
|
|
|
|
def _parse_header(header):
|
|
"""Parse an .avr or .mul header string into name/val pairs.
|
|
|
|
The header line looks like:
|
|
Npts= 256 TSB= 0.000 DI= 4.000000 SB= 1.000 SC= 200.0 Nchan= 27
|
|
No consistent use of separation chars, so parsing this is a bit iffy.
|
|
|
|
Parameters
|
|
----------
|
|
header : str
|
|
The first line of the file.
|
|
|
|
Returns
|
|
-------
|
|
fields : dict
|
|
The parsed header fields
|
|
"""
|
|
parts = header.split() # Splits on one or more spaces
|
|
name_val_pairs = zip(parts[::2], parts[1::2])
|
|
return dict((name.replace("=", ""), val) for name, val in name_val_pairs)
|
|
|
|
|
|
def _read_elp_sidecar(fname):
|
|
"""Read a possible .elp sidecar file with electrode information.
|
|
|
|
The reason we don't use the read_custom_montage for this is that we are
|
|
interested in the channel types, which a DigMontage object does not provide
|
|
us.
|
|
|
|
Parameters
|
|
----------
|
|
fname : Path
|
|
The path of the .avr or .mul file. The corresponding .elp file will be
|
|
derived from this path.
|
|
|
|
Returns
|
|
-------
|
|
ch_type : OrderedDict | None
|
|
If the sidecar file exists, return a dictionary mapping channel names
|
|
to channel types. Otherwise returns ``None``.
|
|
"""
|
|
fname_elp = fname.parent / (fname.stem + ".elp")
|
|
if not fname_elp.exists():
|
|
logger.info(f"No {fname_elp} file present containing electrode information.")
|
|
return None
|
|
|
|
logger.info(f"Reading electrode names and types from {fname_elp}")
|
|
ch_types = OrderedDict()
|
|
with open(fname_elp) as f:
|
|
lines = f.readlines()
|
|
if len(lines[0].split()) > 3:
|
|
# Channel types present
|
|
for line in lines:
|
|
ch_type, ch_name = line.split()[:2]
|
|
ch_types[ch_name] = ch_type.lower()
|
|
else:
|
|
# No channel types present
|
|
logger.info(
|
|
"No channel types present in .elp file. Marking all channels as EEG."
|
|
)
|
|
for line in lines:
|
|
ch_name = line.split()[:1]
|
|
ch_types[ch_name] = "eeg"
|
|
return ch_types
|