initial commit
This commit is contained in:
7
mne/io/besa/__init__.py
Normal file
7
mne/io/besa/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Support for various BESA file formats."""
|
||||
|
||||
# Authors: The MNE-Python contributors.
|
||||
# License: BSD-3-Clause
|
||||
# Copyright the MNE-Python contributors.
|
||||
|
||||
from .besa import read_evoked_besa
|
||||
274
mne/io/besa/besa.py
Normal file
274
mne/io/besa/besa.py
Normal file
@@ -0,0 +1,274 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user