# Authors: The MNE-Python contributors. # License: BSD-3-Clause # Copyright the MNE-Python contributors. import calendar import datetime import os.path as op import numpy as np from scipy.spatial.distance import cdist from ..._fiff._digitization import DigPoint, _make_dig_points from ..._fiff.constants import FIFF from ..._fiff.meas_info import _empty_info from ..._fiff.utils import _read_segments_file from ...transforms import Transform, apply_trans, get_ras_to_neuromag_trans from ...utils import _check_fname, logger, verbose, warn from ..base import BaseRaw from .utils import _load_mne_locs, _read_pos @verbose def read_raw_artemis123( input_fname, preload=False, verbose=None, pos_fname=None, add_head_trans=True ) -> "RawArtemis123": """Read Artemis123 data as raw object. Parameters ---------- input_fname : path-like Path to the data file (extension ``.bin``). The header file with the same file name stem and an extension ``.txt`` is expected to be found in the same directory. %(preload)s %(verbose)s pos_fname : path-like | None If not None, load digitized head points from this file. add_head_trans : bool (default True) If True attempt to perform initial head localization. Compute initial device to head coordinate transform using HPI coils. If no HPI coils are in info['dig'] hpi coils are assumed to be in canonical order of fiducial points (nas, rpa, lpa). Returns ------- raw : instance of Raw A Raw object containing the data. See Also -------- mne.io.Raw : Documentation of attributes and methods. """ return RawArtemis123( input_fname, preload=preload, verbose=verbose, pos_fname=pos_fname, add_head_trans=add_head_trans, ) def _get_artemis123_info(fname, pos_fname=None): """Generate info struct from artemis123 header file.""" fname = op.splitext(fname)[0] header = fname + ".txt" logger.info("Reading header...") # key names for artemis channel info... chan_keys = [ "name", "scaling", "FLL_Gain", "FLL_Mode", "FLL_HighPass", "FLL_AutoReset", "FLL_ResetLock", ] header_info = dict() header_info["filter_hist"] = [] header_info["comments"] = "" header_info["channels"] = [] with open(header) as fid: # section flag # 0 - None # 1 - main header # 2 - channel header # 3 - comments # 4 - length # 5 - filtering History sectionFlag = 0 for line in fid: # skip emptylines or header line for channel info if (not line.strip()) or (sectionFlag == 2 and line.startswith("DAQ Map")): continue # set sectionFlag if line.startswith(""): sectionFlag = 1 elif line.startswith(""): sectionFlag = 2 elif line.startswith(""): sectionFlag = 3 elif line.startswith(""): sectionFlag = 4 elif line.startswith(""): sectionFlag = 5 else: # parse header info lines # part of main header - lines are name value pairs if sectionFlag == 1: values = line.strip().split("\t") if len(values) == 1: values.append("") header_info[values[0]] = values[1] # part of channel header - lines are Channel Info elif sectionFlag == 2: values = line.strip().split("\t") if len(values) != 7: raise OSError( f"Error parsing line \n\t:{line}\nfrom file {header}" ) tmp = dict() for k, v in zip(chan_keys, values): tmp[k] = v header_info["channels"].append(tmp) elif sectionFlag == 3: header_info["comments"] = f"{header_info['comments']}{line.strip()}" elif sectionFlag == 4: header_info["num_samples"] = int(line.strip()) elif sectionFlag == 5: header_info["filter_hist"].append(line.strip()) for k in [ "Temporal Filter Active?", "Decimation Active?", "Spatial Filter Active?", ]: if header_info[k] != "FALSE": warn(f"{k} - set to but is not supported") if header_info["filter_hist"]: warn("Non-Empty Filter history found, BUT is not supported") # build mne info struct info = _empty_info(float(header_info["DAQ Sample Rate"])) # Attempt to get time/date from fname # Artemis123 files saved from the scanner observe the following # naming convention 'Artemis_Data_YYYY-MM-DD-HHh-MMm_[chosen by user].bin' try: date = datetime.datetime.strptime( op.basename(fname).split("_")[2], "%Y-%m-%d-%Hh-%Mm" ) meas_date = (calendar.timegm(date.utctimetuple()), 0) except Exception: meas_date = None # build subject info must be an integer (as per FIFF) try: subject_info = {"id": int(header_info["Subject ID"])} except ValueError: subject_info = {"id": 0} # build description desc = "" for k in ["Purpose", "Notes"]: desc += f"{k} : {header_info[k]}\n" desc += f"Comments : {header_info['comments']}" info.update( { "meas_date": meas_date, "description": desc, "subject_info": subject_info, "proj_name": header_info["Project Name"], } ) # Channel Names by type ref_mag_names = ["REF_001", "REF_002", "REF_003", "REF_004", "REF_005", "REF_006"] ref_grad_names = ["REF_007", "REF_008", "REF_009", "REF_010", "REF_011", "REF_012"] # load mne loc dictionary loc_dict = _load_mne_locs() info["chs"] = [] bads = [] for i, chan in enumerate(header_info["channels"]): # build chs struct t = { "cal": float(chan["scaling"]), "ch_name": chan["name"], "logno": i + 1, "scanno": i + 1, "range": 1.0, "unit_mul": FIFF.FIFF_UNITM_NONE, "coord_frame": FIFF.FIFFV_COORD_DEVICE, } # REF_018 has a zero cal which can cause problems. Let's set it to # a value of another ref channel to make writers/readers happy. if t["cal"] == 0: t["cal"] = 4.716e-10 bads.append(t["ch_name"]) t["loc"] = loc_dict.get(chan["name"], np.zeros(12)) if chan["name"].startswith("MEG"): t["coil_type"] = FIFF.FIFFV_COIL_ARTEMIS123_GRAD t["kind"] = FIFF.FIFFV_MEG_CH # While gradiometer units are T/m, the meg sensors referred to as # gradiometers report the field difference between 2 pick-up coils. # Therefore the units of the measurements should be T # *AND* the baseline (difference between pickup coils) # should not be used in leadfield / forwardfield computations. t["unit"] = FIFF.FIFF_UNIT_T t["unit_mul"] = FIFF.FIFF_UNITM_F # 3 axis reference magnetometers elif chan["name"] in ref_mag_names: t["coil_type"] = FIFF.FIFFV_COIL_ARTEMIS123_REF_MAG t["kind"] = FIFF.FIFFV_REF_MEG_CH t["unit"] = FIFF.FIFF_UNIT_T t["unit_mul"] = FIFF.FIFF_UNITM_F # reference gradiometers elif chan["name"] in ref_grad_names: t["coil_type"] = FIFF.FIFFV_COIL_ARTEMIS123_REF_GRAD t["kind"] = FIFF.FIFFV_REF_MEG_CH # While gradiometer units are T/m, the meg sensors referred to as # gradiometers report the field difference between 2 pick-up coils. # Therefore the units of the measurements should be T # *AND* the baseline (difference between pickup coils) # should not be used in leadfield / forwardfield computations. t["unit"] = FIFF.FIFF_UNIT_T t["unit_mul"] = FIFF.FIFF_UNITM_F # other reference channels are unplugged and should be ignored. elif chan["name"].startswith("REF"): t["coil_type"] = FIFF.FIFFV_COIL_NONE t["kind"] = FIFF.FIFFV_MISC_CH t["unit"] = FIFF.FIFF_UNIT_V bads.append(t["ch_name"]) elif chan["name"].startswith(("AUX", "TRG", "MIO")): t["coil_type"] = FIFF.FIFFV_COIL_NONE t["unit"] = FIFF.FIFF_UNIT_V if chan["name"].startswith("TRG"): t["kind"] = FIFF.FIFFV_STIM_CH else: t["kind"] = FIFF.FIFFV_MISC_CH else: raise ValueError( f'Channel does not match expected channel Types:"{chan["name"]}"' ) # incorporate multiplier (unit_mul) into calibration t["cal"] *= 10 ** t["unit_mul"] t["unit_mul"] = FIFF.FIFF_UNITM_NONE # append this channel to the info info["chs"].append(t) if chan["FLL_ResetLock"] == "TRUE": bads.append(t["ch_name"]) # HPI information # print header_info.keys() hpi_sub = dict() # Don't know what event_channel is don't think we have it HPIs are either # always on or always off. # hpi_sub['event_channel'] = ??? hpi_sub["hpi_coils"] = [dict(), dict(), dict(), dict()] hpi_coils = [dict(), dict(), dict(), dict()] drive_channels = ["MIO_001", "MIO_003", "MIO_009", "MIO_011"] key_base = "Head Tracking %s %d" # set default HPI frequencies if info["sfreq"] == 1000: default_freqs = [140, 150, 160, 40] else: default_freqs = [700, 750, 800, 40] for i in range(4): # build coil structure hpi_coils[i]["number"] = i + 1 hpi_coils[i]["drive_chan"] = drive_channels[i] this_freq = header_info.pop(key_base % ("Frequency", i + 1), default_freqs[i]) hpi_coils[i]["coil_freq"] = this_freq # check if coil is on if header_info[key_base % ("Channel", i + 1)] == "OFF": hpi_sub["hpi_coils"][i]["event_bits"] = [0] else: hpi_sub["hpi_coils"][i]["event_bits"] = [256] info["hpi_subsystem"] = hpi_sub info["hpi_meas"] = [{"hpi_coils": hpi_coils}] # read in digitized points if supplied if pos_fname is not None: info["dig"] = _read_pos(pos_fname) else: info["dig"] = [] info._unlocked = False info._update_redundant() # reduce info['bads'] to unique set info["bads"] = list(set(bads)) del bads return info, header_info class RawArtemis123(BaseRaw): """Raw object from Artemis123 file. Parameters ---------- input_fname : path-like Path to the Artemis123 data file (ending in ``'.bin'``). %(preload)s %(verbose)s See Also -------- mne.io.Raw : Documentation of attributes and methods. """ @verbose def __init__( self, input_fname, preload=False, verbose=None, pos_fname=None, add_head_trans=True, ): from ...chpi import ( _fit_coil_order_dev_head_trans, compute_chpi_amplitudes, compute_chpi_locs, ) input_fname = str(_check_fname(input_fname, "read", True, "input_fname")) fname, ext = op.splitext(input_fname) if ext == ".txt": input_fname = fname + ".bin" elif ext != ".bin": raise RuntimeError( 'Valid artemis123 files must end in "txt"' + ' or ".bin".' ) if not op.exists(input_fname): raise RuntimeError(f"{input_fname} - Not Found") info, header_info = _get_artemis123_info(input_fname, pos_fname=pos_fname) last_samps = [header_info.get("num_samples", 1) - 1] super().__init__( info, preload, filenames=[input_fname], raw_extras=[header_info], last_samps=last_samps, orig_format="single", verbose=verbose, ) if add_head_trans: n_hpis = 0 for d in info["hpi_subsystem"]["hpi_coils"]: if d["event_bits"] == [256]: n_hpis += 1 if n_hpis < 3: warn( f"{n_hpis:d} HPIs active. At least 3 needed to perform" "head localization\n *NO* head localization performed" ) else: # Localized HPIs using the 1st 250 milliseconds of data. with info._unlock(): info["hpi_results"] = [ dict( dig_points=[ dict( r=np.zeros(3), coord_frame=FIFF.FIFFV_COORD_DEVICE, ident=ii + 1, ) for ii in range(n_hpis) ], coord_trans=Transform("meg", "head"), ) ] coil_amplitudes = compute_chpi_amplitudes( self, tmin=0, tmax=0.25, t_window=0.25, t_step_min=0.25 ) assert len(coil_amplitudes["times"]) == 1 coil_locs = compute_chpi_locs(self.info, coil_amplitudes) with info._unlock(): info["hpi_results"] = None hpi_g = coil_locs["gofs"][0] hpi_dev = coil_locs["rrs"][0] # only use HPI coils with localizaton goodness_of_fit > 0.98 bad_idx = [] for i, g in enumerate(hpi_g): msg = f"HPI coil {i + 1} - location goodness of fit ({g:0.3f})" if g < 0.98: bad_idx.append(i) msg += " *Removed from coregistration*" logger.info(msg) hpi_dev = np.delete(hpi_dev, bad_idx, axis=0) hpi_g = np.delete(hpi_g, bad_idx, axis=0) if pos_fname is not None: # Digitized HPI points are needed. hpi_head = np.array( [ d["r"] for d in self.info.get("dig", []) if d["kind"] == FIFF.FIFFV_POINT_HPI ] ) if len(hpi_head) != len(hpi_dev): raise RuntimeError( f"number of digitized ({len(hpi_head)}) and active " f"({len(hpi_dev)}) HPI coils are not the same." ) # compute initial head to dev transform and hpi ordering head_to_dev_t, order, trans_g = _fit_coil_order_dev_head_trans( hpi_dev, hpi_head ) # set the device to head transform self.info["dev_head_t"] = Transform( FIFF.FIFFV_COORD_DEVICE, FIFF.FIFFV_COORD_HEAD, head_to_dev_t ) # add hpi_meg_dev to dig... for idx, point in enumerate(hpi_dev): d = { "r": point, "ident": idx + 1, "kind": FIFF.FIFFV_POINT_HPI, "coord_frame": FIFF.FIFFV_COORD_DEVICE, } self.info["dig"].append(DigPoint(d)) dig_dists = cdist(hpi_head[order], hpi_head[order]) dev_dists = cdist(hpi_dev, hpi_dev) tmp_dists = np.abs(dig_dists - dev_dists) dist_limit = tmp_dists.max() * 1.1 logger.info( "HPI-Dig corrregsitration\n" f"\tGOF : {trans_g:0.3f}\n" f"\tMax Coil Error : {100 * tmp_dists.max():0.3f} cm\n" ) else: logger.info("Assuming Cardinal HPIs") nas = hpi_dev[0] lpa = hpi_dev[2] rpa = hpi_dev[1] t = get_ras_to_neuromag_trans(nas, lpa, rpa) with self.info._unlock(): self.info["dev_head_t"] = Transform( FIFF.FIFFV_COORD_DEVICE, FIFF.FIFFV_COORD_HEAD, t ) # transform fiducial points nas = apply_trans(t, nas) lpa = apply_trans(t, lpa) rpa = apply_trans(t, rpa) hpi = apply_trans(self.info["dev_head_t"], hpi_dev) with self.info._unlock(): self.info["dig"] = _make_dig_points( nasion=nas, lpa=lpa, rpa=rpa, hpi=hpi ) order = np.array([0, 1, 2]) dist_limit = 0.005 # fill in hpi_results hpi_result = dict() # add HPI points in device coords... dig = [] for idx, point in enumerate(hpi_dev): dig.append( { "r": point, "ident": idx + 1, "kind": FIFF.FIFFV_POINT_HPI, "coord_frame": FIFF.FIFFV_COORD_DEVICE, } ) hpi_result["dig_points"] = dig # attach Transform hpi_result["coord_trans"] = self.info["dev_head_t"] # 1 based indexing hpi_result["order"] = order + 1 hpi_result["used"] = np.arange(3) + 1 hpi_result["dist_limit"] = dist_limit hpi_result["good_limit"] = 0.98 # Warn for large discrepancies between digitized and fit # cHPI locations if hpi_result["dist_limit"] > 0.005: warn( "Large difference between digitized geometry" " and HPI geometry. Max coil to coil difference" f" is {100.0 * tmp_dists.max():0.2f} cm\n" "beware of *POOR* head localization" ) # store it with self.info._unlock(): self.info["hpi_results"] = [hpi_result] def _read_segment_file(self, data, idx, fi, start, stop, cals, mult): """Read a chunk of raw data.""" _read_segments_file(self, data, idx, fi, start, stop, cals, mult, dtype=">f4")