298 lines
11 KiB
Python
298 lines
11 KiB
Python
"""Conversion tool from CTF to FIF."""
|
|
|
|
# Authors: The MNE-Python contributors.
|
|
# License: BSD-3-Clause
|
|
# Copyright the MNE-Python contributors.
|
|
|
|
import os
|
|
|
|
import numpy as np
|
|
|
|
from ..._fiff._digitization import _format_dig_points
|
|
from ..._fiff.utils import _blk_read_lims, _mult_cal_one
|
|
from ...utils import (
|
|
_check_fname,
|
|
_check_option,
|
|
_clean_names,
|
|
fill_doc,
|
|
logger,
|
|
verbose,
|
|
)
|
|
from ..base import BaseRaw
|
|
from .constants import CTF
|
|
from .eeg import _read_eeg, _read_pos
|
|
from .hc import _read_hc
|
|
from .info import _annotate_bad_segments, _compose_meas_info, _read_bad_chans
|
|
from .markers import _read_annotations_ctf_call
|
|
from .res4 import _make_ctf_name, _read_res4
|
|
from .trans import _make_ctf_coord_trans_set
|
|
|
|
|
|
@fill_doc
|
|
def read_raw_ctf(
|
|
directory, system_clock="truncate", preload=False, clean_names=False, verbose=None
|
|
) -> "RawCTF":
|
|
"""Raw object from CTF directory.
|
|
|
|
Parameters
|
|
----------
|
|
directory : path-like
|
|
Path to the CTF data (ending in ``'.ds'``).
|
|
system_clock : str
|
|
How to treat the system clock. Use "truncate" (default) to truncate
|
|
the data file when the system clock drops to zero, and use "ignore"
|
|
to ignore the system clock (e.g., if head positions are measured
|
|
multiple times during a recording).
|
|
%(preload)s
|
|
clean_names : bool, optional
|
|
If True main channel names and compensation channel names will
|
|
be cleaned from CTF suffixes. The default is False.
|
|
%(verbose)s
|
|
|
|
Returns
|
|
-------
|
|
raw : instance of RawCTF
|
|
The raw data.
|
|
|
|
Notes
|
|
-----
|
|
.. versionadded:: 0.11
|
|
|
|
To read in the Polhemus digitization data (for example, from
|
|
a .pos file), include the file in the CTF directory. The
|
|
points will then automatically be read into the `mne.io.Raw`
|
|
instance via `mne.io.read_raw_ctf`.
|
|
"""
|
|
return RawCTF(
|
|
directory,
|
|
system_clock,
|
|
preload=preload,
|
|
clean_names=clean_names,
|
|
verbose=verbose,
|
|
)
|
|
|
|
|
|
@fill_doc
|
|
class RawCTF(BaseRaw):
|
|
"""Raw object from CTF directory.
|
|
|
|
Parameters
|
|
----------
|
|
directory : path-like
|
|
Path to the CTF data (ending in ``'.ds'``).
|
|
system_clock : str
|
|
How to treat the system clock. Use ``"truncate"`` (default) to truncate
|
|
the data file when the system clock drops to zero, and use ``"ignore"``
|
|
to ignore the system clock (e.g., if head positions are measured
|
|
multiple times during a recording).
|
|
%(preload)s
|
|
clean_names : bool, optional
|
|
If True main channel names and compensation channel names will
|
|
be cleaned from CTF suffixes. The default is False.
|
|
%(verbose)s
|
|
|
|
See Also
|
|
--------
|
|
mne.io.Raw : Documentation of attributes and methods.
|
|
"""
|
|
|
|
@verbose
|
|
def __init__(
|
|
self,
|
|
directory,
|
|
system_clock="truncate",
|
|
preload=False,
|
|
verbose=None,
|
|
clean_names=False,
|
|
):
|
|
# adapted from mne_ctf2fiff.c
|
|
directory = str(
|
|
_check_fname(directory, "read", True, "directory", need_dir=True)
|
|
)
|
|
if not directory.endswith(".ds"):
|
|
raise TypeError(
|
|
f'directory must be a directory ending with ".ds", got {directory}'
|
|
)
|
|
_check_option("system_clock", system_clock, ["ignore", "truncate"])
|
|
logger.info(f"ds directory : {directory}")
|
|
res4 = _read_res4(directory) # Read the magical res4 file
|
|
coils = _read_hc(directory) # Read the coil locations
|
|
eeg = _read_eeg(directory) # Read the EEG electrode loc info
|
|
|
|
# Investigate the coil location data to get the coordinate trans
|
|
coord_trans = _make_ctf_coord_trans_set(res4, coils)
|
|
|
|
digs = _read_pos(directory, coord_trans)
|
|
|
|
# Compose a structure which makes fiff writing a piece of cake
|
|
info = _compose_meas_info(res4, coils, coord_trans, eeg)
|
|
with info._unlock():
|
|
info["dig"] += digs
|
|
info["dig"] = _format_dig_points(info["dig"])
|
|
info["bads"] += _read_bad_chans(directory, info)
|
|
|
|
# Determine how our data is distributed across files
|
|
fnames = list()
|
|
last_samps = list()
|
|
raw_extras = list()
|
|
missing_names = list()
|
|
no_samps = list()
|
|
while True:
|
|
suffix = "meg4" if len(fnames) == 0 else ("%d_meg4" % len(fnames))
|
|
meg4_name, found = _make_ctf_name(directory, suffix, raise_error=False)
|
|
if not found:
|
|
missing_names.append(os.path.relpath(meg4_name, directory))
|
|
break
|
|
# check how much data is in the file
|
|
sample_info = _get_sample_info(meg4_name, res4, system_clock)
|
|
if sample_info["n_samp"] == 0:
|
|
no_samps.append(os.path.relpath(meg4_name, directory))
|
|
break
|
|
if len(fnames) == 0:
|
|
buffer_size_sec = sample_info["block_size"] / info["sfreq"]
|
|
else:
|
|
buffer_size_sec = 1.0
|
|
fnames.append(meg4_name)
|
|
last_samps.append(sample_info["n_samp"] - 1)
|
|
raw_extras.append(sample_info)
|
|
first_samps = [0] * len(last_samps)
|
|
if len(fnames) == 0:
|
|
raise OSError(
|
|
f"Could not find any data, could not find the following "
|
|
f"file(s): {missing_names}, and the following file(s) had no "
|
|
f"valid samples: {no_samps}"
|
|
)
|
|
super().__init__(
|
|
info,
|
|
preload,
|
|
first_samps=first_samps,
|
|
last_samps=last_samps,
|
|
filenames=fnames,
|
|
raw_extras=raw_extras,
|
|
orig_format="int",
|
|
buffer_size_sec=buffer_size_sec,
|
|
verbose=verbose,
|
|
)
|
|
|
|
# Add bad segments as Annotations (correct for start time)
|
|
start_time = -res4["pre_trig_pts"] / float(info["sfreq"])
|
|
annot = _annotate_bad_segments(directory, start_time, info["meas_date"])
|
|
marker_annot = _read_annotations_ctf_call(
|
|
directory=directory,
|
|
total_offset=(res4["pre_trig_pts"] / res4["sfreq"]),
|
|
trial_duration=(res4["nsamp"] / res4["sfreq"]),
|
|
meas_date=info["meas_date"],
|
|
)
|
|
annot = marker_annot if annot is None else annot + marker_annot
|
|
self.set_annotations(annot)
|
|
if clean_names:
|
|
_clean_names_inst(self)
|
|
|
|
def _read_segment_file(self, data, idx, fi, start, stop, cals, mult):
|
|
"""Read a chunk of raw data."""
|
|
si = self._raw_extras[fi]
|
|
offset = 0
|
|
trial_start_idx, r_lims, d_lims = _blk_read_lims(
|
|
start, stop, int(si["block_size"])
|
|
)
|
|
with open(self._filenames[fi], "rb") as fid:
|
|
for bi in range(len(r_lims)):
|
|
samp_offset = (bi + trial_start_idx) * si["res4_nsamp"]
|
|
n_read = min(si["n_samp_tot"] - samp_offset, si["block_size"])
|
|
# read the chunk of data
|
|
# have to be careful on Windows and make sure we are using
|
|
# 64-bit integers here
|
|
with np.errstate(over="raise"):
|
|
pos = np.int64(CTF.HEADER_SIZE)
|
|
pos += np.int64(samp_offset) * si["n_chan"] * 4
|
|
fid.seek(pos, 0)
|
|
this_data = np.fromfile(fid, ">i4", count=si["n_chan"] * n_read)
|
|
this_data.shape = (si["n_chan"], n_read)
|
|
this_data = this_data[:, r_lims[bi, 0] : r_lims[bi, 1]]
|
|
data_view = data[:, d_lims[bi, 0] : d_lims[bi, 1]]
|
|
_mult_cal_one(data_view, this_data, idx, cals, mult)
|
|
offset += n_read
|
|
|
|
|
|
def _clean_names_inst(inst):
|
|
"""Clean up CTF suffixes from channel names."""
|
|
mapping = dict(zip(inst.ch_names, _clean_names(inst.ch_names)))
|
|
inst.rename_channels(mapping)
|
|
for comp in inst.info["comps"]:
|
|
for key in ("row_names", "col_names"):
|
|
comp["data"][key] = _clean_names(comp["data"][key])
|
|
|
|
|
|
def _get_sample_info(fname, res4, system_clock):
|
|
"""Determine the number of valid samples."""
|
|
logger.info(f"Finding samples for {fname}: ")
|
|
if CTF.SYSTEM_CLOCK_CH in res4["ch_names"]:
|
|
clock_ch = res4["ch_names"].index(CTF.SYSTEM_CLOCK_CH)
|
|
else:
|
|
clock_ch = None
|
|
for k, ch in enumerate(res4["chs"]):
|
|
if ch["ch_name"] == CTF.SYSTEM_CLOCK_CH:
|
|
clock_ch = k
|
|
break
|
|
with open(fname, "rb") as fid:
|
|
fid.seek(0, os.SEEK_END)
|
|
st_size = fid.tell()
|
|
fid.seek(0, 0)
|
|
if (st_size - CTF.HEADER_SIZE) % (4 * res4["nsamp"] * res4["nchan"]) != 0:
|
|
raise RuntimeError(
|
|
"The number of samples is not an even multiple of the trial size"
|
|
)
|
|
n_samp_tot = (st_size - CTF.HEADER_SIZE) // (4 * res4["nchan"])
|
|
n_trial = n_samp_tot // res4["nsamp"]
|
|
n_samp = n_samp_tot
|
|
if clock_ch is None:
|
|
logger.info(
|
|
" System clock channel is not available, assuming "
|
|
"all samples to be valid."
|
|
)
|
|
elif system_clock == "ignore":
|
|
logger.info(" System clock channel is available, but ignored.")
|
|
else: # use it
|
|
logger.info(
|
|
" System clock channel is available, checking "
|
|
"which samples are valid."
|
|
)
|
|
for t in range(n_trial):
|
|
# Skip to the correct trial
|
|
samp_offset = t * res4["nsamp"]
|
|
offset = (
|
|
CTF.HEADER_SIZE
|
|
+ (samp_offset * res4["nchan"] + (clock_ch * res4["nsamp"])) * 4
|
|
)
|
|
fid.seek(offset, 0)
|
|
this_data = np.fromfile(fid, ">i4", res4["nsamp"])
|
|
if len(this_data) != res4["nsamp"]:
|
|
raise RuntimeError("Cannot read data for trial %d" % (t + 1))
|
|
end = np.where(this_data == 0)[0]
|
|
if len(end) > 0:
|
|
n_samp = samp_offset + end[0]
|
|
break
|
|
if n_samp < res4["nsamp"]:
|
|
n_trial = 1
|
|
logger.info(
|
|
" %d x %d = %d samples from %d chs"
|
|
% (n_trial, n_samp, n_samp, res4["nchan"])
|
|
)
|
|
else:
|
|
n_trial = n_samp // res4["nsamp"]
|
|
n_omit = n_samp_tot - n_samp
|
|
logger.info(
|
|
" %d x %d = %d samples from %d chs"
|
|
% (n_trial, res4["nsamp"], n_samp, res4["nchan"])
|
|
)
|
|
if n_omit != 0:
|
|
logger.info(" %d samples omitted at the end" % n_omit)
|
|
|
|
return dict(
|
|
n_samp=n_samp,
|
|
n_samp_tot=n_samp_tot,
|
|
block_size=res4["nsamp"],
|
|
res4_nsamp=res4["nsamp"],
|
|
n_chan=res4["nchan"],
|
|
)
|