From 100edb7891f1ba81820fe5d4db79f517b8ac0a52 Mon Sep 17 00:00:00 2001 From: mikkeyboi Date: Wed, 11 Dec 2019 17:45:32 -0500 Subject: [PATCH 01/10] Update GetUnityTaskEvents, supports Unitybase submodule ModifierType, ConditionType, and ResponseType to classify each trial. The previous indices used to label target positions/objects/colour are still used. --- GetUnityTaskEvents.py | 54 +++++++++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/GetUnityTaskEvents.py b/GetUnityTaskEvents.py index 0b087be..32a3b59 100644 --- a/GetUnityTaskEvents.py +++ b/GetUnityTaskEvents.py @@ -12,7 +12,7 @@ class GetUnityTaskEvents(Node): @classmethod def description(cls): - return Description(name='Get Behaviour for Michael Saccade VR study', + return Description(name='Get Behaviour for Michael Saccade VR study (New submodule post Sept 10 revisions)', description="""Parse marker strings into table of data""", version='0.1', license=Licenses.MIT) @@ -39,17 +39,17 @@ def data(self, pkt): """ Input event markers: TrialState: + condition (int): See conditiontype_map isCorrect (bool) + modifier (int): See modifiertype_map trialIndex (uint) - taskType (int): See tasktype_map - inhibitionIndex: See countermand_map + response: See responsetype_map cuedPositionIndex: See position_map targetPositionIndex: See position_map - targetObjectIndex (int): Different stimuli. 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 - selectedObjectIndex (int): in -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 + targetObjectIndex (int): 0 + selectedObjectIndex (int): in -1, 0 selectedPositionIndex: in -1, 0, 1 - environmentIndex: 0 - targetColorIndex: 3 + targetColorIndex: -1 trialPhaseIndex: see phase_map Input: An event whenever a user input is registered (e.g., gaze collides with object) trialIndex (int) @@ -66,10 +66,11 @@ def data(self, pkt): phase_map = {1: 'Intertrial', 2: 'Fixate', 3: 'Cue', 4: 'Delay', 5: 'Target', 6: 'Go', 7: 'Countermand', 8: 'Response', 9: 'Feedback', -1: 'UserInput'} phase_inv_map = {v: k for k, v in phase_map.items()} - tasktype_map = {0: 'AttendShape', 1: 'AttendColour', 2: 'AttendDirection'} - countermand_map = {0: 'Prosaccade', 1: 'TargetSwitch', 2: 'NoGo', 3: 'Antisaccade'} + modifiertype_map = {0: 'None', 1: 'Cued', 2: 'MemoryGuided', 3: 'NoGo', 4: 'Catch'} + conditiontype_map = {0: 'None', 1:'AttendShape', 2: 'AttendColour', 3: 'AttendNumber', 4: 'AttendDirection', 5: 'AttendPosition', 6: 'AttendFixation'} + # Note: ResponseType 3 to 5 are added post data collection to expedite analysis + responsetype_map = {0: 'None', 1: 'Prosaccade', 2: 'Antisaccade', 3: 'CuedSaccade', 4: 'NoGoProsaccade', 5: 'NoGoAntisaccade'} position_map = {-1: 'Unknown', 0: 'Left', 1: 'Right', 2: 'NoGo'} - cue_type_map = {0: 'Prosaccade', 1: 'Antisaccade'} """ There are many more events than we need, including events for positioning invisible targets and changing @@ -88,11 +89,11 @@ def data(self, pkt): - Last Input event must be CentralFixation to proceed. - ObjectInfo to show the cue. (_isVisible: True) - TrialState with trialPhaseIndex=3 to indicate cue phase. - - multiple ObjectInfo events with _isVisible False while the cue disappears and targets are positioned. + - ObjectInfo shows colour change of cue to indicate Prosaccade/Antisaccade trial. + - TrialState with trialPhaseIndex=4 for the Delay (memory) period. - - ObjectInfo events to make the targets visible. - TrialState event with trialPhaseIndex 5 to indicate this is the target phase (map memory to saccade plan) - - ObjectInfo with CentralFixation set to _isVisible False. This is the imperative go cue. + - ObjectInfo with CentralFixation set to _isVisible False. This is the imperative go cue. - TrialState with trialPhaseIndex 6 to indicate the Go phase. TODO: Check if the time is same as above. - (Optional) Input event after fixation disappears because we are now selecting CentralWall behind fixation. - (if countermanding) ObjectInfo when fixation reappears. Start of countermanding. @@ -111,14 +112,15 @@ def data(self, pkt): field_name_type_prop = [ ('UnityTrialIndex', int, ValueProperty.INTEGER + ValueProperty.NONNEGATIVE), ('Marker', object, ValueProperty.STRING + ValueProperty.CATEGORY), # Used to hold trial phase. - ('TaskType', object, ValueProperty.STRING + ValueProperty.CATEGORY), - ('CountermandingType', object, ValueProperty.STRING + ValueProperty.CATEGORY), + ('ModifierType', object, ValueProperty.STRING + ValueProperty.CATEGORY), + ('ConditionType', object, ValueProperty.STRING + ValueProperty.CATEGORY), + ('ResponseType', object, ValueProperty.STRING + ValueProperty.CATEGORY), ('CuedPosition', object, ValueProperty.STRING + ValueProperty.CATEGORY), ('CuedObject', object, ValueProperty.STRING + ValueProperty.CATEGORY), ('TargetPosition', object, ValueProperty.STRING + ValueProperty.CATEGORY), ('TargetObjectIndex', int, ValueProperty.INTEGER + ValueProperty.CATEGORY), # ('TargetColour', object, ValueProperty.STRING + ValueProperty.CATEGORY), - ('EnvironmentIndex', int, ValueProperty.INTEGER + ValueProperty.NONNEGATIVE), + # ('EnvironmentIndex', int, ValueProperty.INTEGER + ValueProperty.NONNEGATIVE), ('CountermandingDelay', float, ValueProperty.UNKNOWN), ('SelectedPosition', object, ValueProperty.STRING + ValueProperty.CATEGORY), ('SelectedObjectIndex', int, ValueProperty.INTEGER + ValueProperty.CATEGORY), @@ -176,21 +178,27 @@ def data(self, pkt): fbstate = tr_events[tr_phases == phase_inv_map['Feedback']][0]['TrialState'] details = { 'UnityTrialIndex': fbstate['trialIndex'], - 'TaskType': tasktype_map[fbstate['taskType']], - 'CountermandingType': countermand_map[fbstate['inhibitionIndex']], - 'CuedPosition': position_map[fbstate['cuedPositionIndex']], + 'ModifierType': modifiertype_map[fbstate['modifier']], + 'ConditionType': conditiontype_map[fbstate['condition']], + 'ResponseType': responsetype_map[fbstate['response']] if fbstate['modifier'] == 0 else 'CuedOrNoGo', + 'CuedPosition': position_map[fbstate['cuePositionIndex']], 'TargetPosition': position_map[fbstate['targetPositionIndex']], 'TargetObjectIndex': fbstate['targetObjectIndex'], # TODO: Map to object name # 'TargetColour': color_map[fbstate['targetColorIndex']], - 'EnvironmentIndex': fbstate['environmentIndex'], # TODO: Map to environment name. + # 'EnvironmentIndex': fbstate['environmentIndex'], # TODO: Map to environment name. 'SelectedPosition': position_map[fbstate['selectedPositionIndex']], 'SelectedObjectIndex': fbstate['selectedObjectIndex'], # TODO: Map to object name 'IsCorrect': fbstate['isCorrect'], 'CountermandingDelay': np.nan, - 'ReactionTime': np.nan, + 'ReactionTime': np.nan + # No need for CueTypeIndex, ResponseType indicates whether trial is Pro or Anti-saccade # CueTypeIndex. For "TaskSwitch" experiment, tells if trial is Pro or Anti-saccade. - 'CueTypeIndex': cue_type_map[fbstate['saccadeIndex']] if 'saccadeIndex' in fbstate else -1 + # 'CueTypeIndex': cue_type_map[fbstate['saccadeIndex']] if 'saccadeIndex' in fbstate else -1 } + # Additional ResponseTypes are added in analysis (here), for comparing against conditions + if details['ResponseType'] == 'CuedOrNoGo': + details['ResponseType'] = 'CuedSaccade' if fbstate['modifier'] == 1 \ + else ('NoGoProsaccade' if fbstate['response'] == 1 else 'NoGoAntisaccade') # Get some more details that we can only get from events. df_to_extend = [] @@ -254,7 +262,7 @@ def data(self, pkt): # Event 7 (optional) - Countermanding cue. # Get countermanding delay - if details['CountermandingType'] != 'Prosaccade' and phase_inv_map['Countermand'] in tr_phases: + if details['ResponseType'] != 'Prosaccade' and phase_inv_map['Countermand'] in tr_phases: df_to_extend.append({'Marker': 'Countermand'}) # Find last fixation-visible event before response period. From 9ba89d3a86dbb788798c25f126815d19412a18d4 Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Mon, 23 Dec 2019 16:44:21 -0500 Subject: [PATCH 02/10] Added script to demonstrate real-time time-series plotting. --- scripts/test_np_vis.py | 46 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 scripts/test_np_vis.py diff --git a/scripts/test_np_vis.py b/scripts/test_np_vis.py new file mode 100644 index 0000000..4dc4419 --- /dev/null +++ b/scripts/test_np_vis.py @@ -0,0 +1,46 @@ +# First download and run AudioCapture application from here: +# https://github.com/labstreaminglayer/App-AudioCapture/releases +# Allow it on your network if required. + +# Your PyCharm environment should have Neuropype cpe attached as a dependency. + +# Then run this script to visualize a real-time plot of the computer's microphone input. + +# import cProfile +import sys +from pyqtgraph.Qt import QtGui, QtCore +import pyqtgraph as pg +import time as time_ +from neuropype.engine import Graph, Scheduler +import neuropype.nodes as nn + +inlet = nn.LSLInput(query="type='Audio'") +timeseriesplot = nn.TimeSeriesPlot(scale=0.25, time_range=3.0, initial_dims=[0, 100, 800, 600]) + +patch = Graph() +patch.chain(inlet, timeseriesplot) +scheduler = Scheduler(patch) +tlast = time_.time() + + +def update(): + global tlast, scheduler + tstart = time_.time() + scheduler.advance() + tnow = time_.time() + # print("{0:8.3f}, {1:8.3f}, {2:8.3f}".format(1000 * (tnow - tstart), 1000 * (tstart - tlast), 1000 * (tnow - tlast))) + tlast = tnow + + +def main(): + qapp = QtGui.QApplication(sys.argv) + timer = pg.QtCore.QTimer() + timer.timeout.connect(update) + # Delay not needed because scheduler.advance will block while waiting for data as LSLInput has block_wait=True + timer.start(1) + # print("scheduler.advance(), outside, total") + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + sys.exit(QtGui.QApplication.instance().exec_()) + +# cProfile.run("main()", filename="vistest.profile") +main() From c34e59423da3814667b253d315fd7ff9f881faa2 Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Thu, 7 May 2020 14:06:27 -0400 Subject: [PATCH 03/10] FixChannames no longer necessary. Use nn.RenameChannels --- FixChannames.py | 34 ---------------------------------- 1 file changed, 34 deletions(-) delete mode 100644 FixChannames.py diff --git a/FixChannames.py b/FixChannames.py deleted file mode 100644 index ea48e15..0000000 --- a/FixChannames.py +++ /dev/null @@ -1,34 +0,0 @@ -import logging -import neuropype.engine as ne - - -logger = logging.getLogger(__name__) - - -class FixChannames(ne.Node): - # --- Input/output ports --- - data = ne.Port(None, ne.Packet, "Data to process.", required=True, - editable=False, mutating=True) - - @classmethod - def description(cls): - return ne.Description(name='Rename channels.', - description=""" - analogsignals - elec1 --> ch1, etc. - """, - version='0.1', - license=ne.Licenses.MIT) - - @data.setter - def data(self, pkt): - if pkt is not None: - logger.info("Fixing channel names (elecX --> chX) ...") - blk = pkt.chunks['analogsignals'].block - new_axes = list(blk.axes) - sp_ix = blk.axes.index(ne.space) - new_chan_names = ['ch' + cn[4:] if cn[0:4] == 'elec' else cn for cn in new_axes[sp_ix].names] - new_axes[sp_ix] = ne.SpaceAxis(names=new_chan_names) - pkt.chunks['analogsignals'].block = ne.Block(data=blk.data, axes=new_axes) - - self._data = pkt From 6680c557e236d186db7619f7d9da6694d24d62f5 Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Wed, 10 Jun 2020 16:17:53 -0400 Subject: [PATCH 04/10] New custom nodes - AsType, ReconContFromEvents, UpdateElectrodePositions --- AsType.py | 85 +++++++++++++++++++++++ ReconContFromEvents.py | 124 +++++++++++++++++++++++++++++++++ UpdateElectrodePositions.py | 134 ++++++++++++++++++++++++++++++++++++ __init__.py | 4 ++ 4 files changed, 347 insertions(+) create mode 100644 AsType.py create mode 100644 ReconContFromEvents.py create mode 100644 UpdateElectrodePositions.py diff --git a/AsType.py b/AsType.py new file mode 100644 index 0000000..f58dc24 --- /dev/null +++ b/AsType.py @@ -0,0 +1,85 @@ +import logging +import numpy as np +from neuropype.engine import * +from neuropype.utilities import cache +import tempfile +import h5pickle as h5py +import weakref +import lazy_ops + + +logger = logging.getLogger(__name__) + + +class AsType(Node): + """Change data type.""" + + # --- Input/output ports --- + data = DataPort(Packet, "Data to process.") + + # --- Properties --- + dtype = EnumPort('none', domain=['float64', 'float32', 'float16', 'int64', 'int32', 'int16', 'int8', 'none'], + help="""The new dtype. Use 'none' for no change.""") + data_class = EnumPort('none', domain=['DatasetView', 'ndarray', 'none']) + use_caching = BoolPort(False, """Enable caching.""", expert=True) + + def __init__(self, + dtype: Union[str, None, Type[Keep]] = Keep, + data_class: Union[str, None, Type[Keep]] = Keep, + use_caching: Union[bool, None, Type[Keep]] = Keep, + **kwargs): + super().__init__(dtype=dtype, data_class=data_class, use_caching=use_caching, **kwargs) + + @classmethod + def description(cls): + """Declare descriptive information about the node.""" + return Description(name='As Type', + description="""\ + Change the dtype of the chunk data, + and/or change the block._data class. + """, + version='0.1.0', status=DevStatus.alpha) + + @data.setter + def data(self, v): + # try to read from cache + record = cache.try_lookup(context=self, enabled=self.use_caching, + verbose=True, data=v, state=None) + + if record.success(): + self._data = record.data + return + + for n, chunk in enumerate_chunks(v, nonempty=True): + + dtype = {'float64': np.float64, 'float32': np.float32, 'float16': np.float16, + 'int64': np.int64, 'int32': np.int32, 'int16': np.int16, 'int8': np.int8, + 'none': chunk.block._data.dtype}[self.dtype] + + if self.data_class == 'DatasetView' or\ + (isinstance(chunk.block._data, lazy_ops.DatasetView) and self.data_class == 'none'): + # Create a tempfile and close it. + tf = tempfile.NamedTemporaryFile(delete=False) + tf.close() + # Open again with h5py/h5pickle + f = h5py.File(tf.name, mode='w', libver='latest') + dset_name_in_parent = chunk.block._data._dataset.name.split('/')[-1] + chunk.block._data._dataset = f.create_dataset(dset_name_in_parent, + data=chunk.block._data, dtype=dtype, + chunks=True, compression="gzip") + f.swmr_mode = True + # Setup an automatic deleter for the new tempfile + chunk.block._data._finalizer = weakref.finalize( + chunk.block._data, chunk.block._data.on_finalize, f, f.filename) + + elif self.data_class == 'ndarray' or\ + (isinstance(chunk.block._data, np.ndarray) and self.data_class == 'none'): + chunk.block._data = np.array(chunk.block._data).astype(dtype) + + record.writeback(data=v) + self._data = v + + def on_port_assigned(self): + """Callback to reset internal state when a value was assigned to a + port (unless the port's setter has been overridden).""" + self.signal_changed(True) diff --git a/ReconContFromEvents.py b/ReconContFromEvents.py new file mode 100644 index 0000000..219a503 --- /dev/null +++ b/ReconContFromEvents.py @@ -0,0 +1,124 @@ +import logging +import numpy as np +from neuropype.engine import * + + +logger = logging.getLogger(__name__) + + +class ReconContFromEvents(Node): + """Reconstitute Continuous Signal From Sparse Events""" + + # --- Input/output ports --- + data = DataPort(Packet, "Data to process.", mutating=False) + + # --- Properties --- + add_noise = BoolPort(False, """Add white noise to reconstituted data.""") + add_lfps = BoolPort(False, """If present, upsample analog signals and add to reconstituted data.""") + + def __init__(self, + add_noise: Union[bool, None, Type[Keep]] = Keep, + add_lfps: Union[bool, None, Type[Keep]] = Keep, + **kwargs): + super().__init__(add_noise=add_noise, add_lfps=add_lfps, **kwargs) + + @classmethod + def description(cls): + """Declare descriptive information about the node.""" + return Description(name='Reconstitute Continuous Signal From (Sparse) Events', + description="""\ + Use sparse event waveforms (and optionally sparse event train) to reconstitute + a continuous signal on a zeros background. Optionally add noise and lfps (if provided) + to the output. + Note that sorting is lost. + """, + version='0.1.0', status=DevStatus.alpha) + + @data.setter + def data(self, pkt): + + # Get the waveforms chunk, if present. + wf_name, wf_chunk = find_first_chunk(pkt, with_axes=(instance, time), + without_flags=(Flags.is_signal, Flags.is_sparse), + allow_markers=False) + + # Get the event train. Only useful for + evt_name, evt_chunk = find_first_chunk(pkt, with_axes=(space, time), + with_flags=Flags.is_sparse, + allow_markers=False) + + # Get the signals chunk, if present. + sig_name, sig_chunk = find_first_chunk(pkt, with_axes=(space, time), + without_flags=Flags.is_sparse) + + # Make TimeAxis new continuous data + wf_blk = wf_chunk.block + t0 = 0.0 # np.min(wf_blk.axes['ne.instance'].times) + wf_blk.axes['time'].times[0] + t_end = np.max(wf_blk.axes[instance].times) + wf_blk.axes[time].times[-1] + step_size = 1 / wf_blk.axes[time].nominal_rate + t_vec = np.arange(t0, t_end + step_size, step_size) + new_time_ax = TimeAxis(times=t_vec, nominal_rate=wf_blk.axes[time].nominal_rate) + + # Make SpaceAxis for new continuous data + # Get channel labels, sort so Ch10 is after Ch9, etc. + chan_labels = np.unique(wf_blk.axes[instance].data['chan_label']) + sort_ix = np.argsort([int(_[2:]) for _ in chan_labels]) + chan_labels = chan_labels[sort_ix] + # Get channel positions if available + if evt_name: + sp_ax = evt_chunk.block.axes[space] + new_pos = np.array([sp_ax.positions[sp_ax.names == _][0] for _ in chan_labels]) + else: + new_pos = None + new_space_ax = SpaceAxis(names=chan_labels, positions=new_pos) + + # Calculate index offsets for each waveform + wf_idx_off = (wf_blk.axes[time].times * wf_blk.axes[time].nominal_rate).astype(int) + + # Initialize null continuous data + dat = np.zeros((len(chan_labels), len(t_vec)), dtype=wf_blk.data.dtype) + + # Superimpose spikes on zeros, one channel at a time + for ch_ix, ch_label in enumerate(chan_labels): + ch_blk = wf_blk[..., instance[wf_blk.axes[instance].data['chan_label'] == ch_label], ...] + if ch_blk.size: + ch_wf_dat = np.copy(ch_blk[instance, time].data).flatten() + ch_idx = np.searchsorted(t_vec, ch_blk.axes[instance].times)[:, None] + wf_idx_off[None, :] + ch_idx = ch_idx.flatten() + # Only take the first occurrence of any particular sample to avoid + # samples that may be over-represented if there are overlapping waveforms. + uq_ch_idx, uq_wf_idx = np.unique(ch_idx, return_index=True) + dat[ch_ix, uq_ch_idx] = ch_wf_dat[uq_wf_idx] + + raw_blk = Block(data=dat, axes=(new_space_ax, new_time_ax)) + + # Add white noise to samples that weren't written with waveforms. + if self.add_noise: + # Noise should very rarely cross threshold. + # Set 4 STDs (=99.96% of samples) to be less than threshold of -54 uV: + noise_std = 54/4 + for ch_dat in raw_blk.data: + b_zero = ch_dat == 0. + ch_dat[b_zero] = (noise_std * np.random.randn(np.sum(b_zero))).astype(np.int16) + + # Superimpose interpolated LFPs if available + if self.add_lfps and sig_name is not None: + lfp_blk = sig_chunk.block[space[raw_blk.axes[space].names.tolist()], ..., time] + # Manual interpolation, one channel at a time, to save memory + from scipy.interpolate import interp1d + new_times = raw_blk.axes[time].times + old_times = lfp_blk.axes[time].times + for chan_ix, chan_label in enumerate(lfp_blk.axes[space].names): + if chan_label in chan_labels: + f = interp1d(old_times, lfp_blk.data[chan_ix], kind='cubic', axis=-1, + assume_sorted=True, fill_value='extrapolate') + lfp_upsamp = f(new_times) + full_ch_ix = np.where(chan_labels == chan_label)[0][0] + raw_blk.data[full_ch_ix] = raw_blk.data[full_ch_ix] + lfp_upsamp.astype(raw_blk.data.dtype) + + self._data = Packet(chunks={'recon_raw': raw_blk}) + + def on_port_assigned(self): + """Callback to reset internal state when a value was assigned to a + port (unless the port's setter has been overridden).""" + self.signal_changed(True) diff --git a/UpdateElectrodePositions.py b/UpdateElectrodePositions.py new file mode 100644 index 0000000..48e13b8 --- /dev/null +++ b/UpdateElectrodePositions.py @@ -0,0 +1,134 @@ +import logging +import scipy.io +import numpy as np +from neuropype.engine import * +from neuropype.utilities.cloud import storage + + +logger = logging.getLogger(__name__) + + +class UpdateElectrodePositions(Node): + # --- Input/output ports --- + data = Port(None, Packet, "Packet with Blackrock packet output from ImportNSX", required=True, + editable=False, mutating=True) + filename = StringPort("", """Path to the map file. + """, is_filename=True) + + banks = ListPort(['A', 'B', 'C', 'D'], domain=str, help=""" + List of single-character-strings ['A', 'B', 'C', 'D'] to indicate which banks were + recorded in the input data packet. + """) + + # options for cloud-hosted files + cloud_host = EnumPort("Default", ["Default", "Azure", "S3", "Google", + "Local", "None"], """Cloud storage host to + use (if any). You can override this option to select from what kind of + cloud storage service data should be downloaded. On some environments + (e.g., on NeuroScale), the value Default will be map to the default + storage provider on that environment.""") + cloud_account = StringPort("", """Cloud account name on storage provider + (use default if omitted). You can override this to choose a non-default + account name for some storage provider (e.g., Azure or S3.). On some + environments (e.g., on NeuroScale), this value will be + default-initialized to your account.""") + cloud_bucket = StringPort("", """Cloud bucket to read from (use default if + omitted). This is the bucket or container on the cloud storage provider + that the file would be read from. On some environments (e.g., on + NeuroScale), this value will be default-initialized to a bucket + that has been created for you.""") + cloud_credentials = StringPort("", """Secure credential to access cloud data + (use default if omitted). These are the security credentials (e.g., + password or access token) for the the cloud storage provider. On some + environments (e.g., on NeuroScale), this value will be + default-initialized to the right credentials for you.""") + + @classmethod + def description(cls): + return Description(name='Update Electrode Positions for Utah Array', + description=""" + The Martinez-Trujillo lab constructs map files (.cmp) to describe the + Utah electrode array channel mapping. Here we process the map file + and update the channel positions in the data packet. + """, + version='0.1', + license=Licenses.MIT) + + @data.setter + def data(self, pkt): + if pkt is not None: + import pandas as pd + filename = storage.cloud_get(self.filename, host=self.cloud_host, + account=self.cloud_account, + bucket=self.cloud_bucket, + credentials=self.cloud_credentials) + + logger.info("Replacing electrode positions with positions loaded from %s..." % filename) + map_info = {} + with open(filename, 'r') as f: + _ = f.readline() + line_ix = 1 + map_start = None + while True: + line = f.readline() + if not line: + # nothing returned + break + words = line.strip().split() + if not len(words): + # empty line after stripping + continue + if words[0].lower() in ['subject', 'hemisphere']: + map_info[words[0].lower()] = words[1] + elif words[0].lower() in ['wireorientation', 'implantorientation', 'electrodespacing']: + # according to notes: + # wire pointing right and array on left hemi + map_info[words[0].lower()] = int(words[1]) + elif words[0] == 'Cerebus': + # Reached map + map_start = line_ix + 1 + break + line_ix += 1 + df = None + if map_start is not None: + df = pd.read_csv(filename, sep='\t', header=None, names=['X', 'Y', 'Bank', 'ChInBank'], + skiprows=map_start + 1) + df = df.infer_objects() + # The Matlab code says to flip ud, but not flipping is the better way to align with the diagrams I + # received from members of Julio's lab. + # df['Y'] = max(df['Y']) - df['Y'] + # Convert X and Y into um + spacing = map_info['electrodespacing'] if 'electrodespacing' in map_info else 400 + df['X'] *= spacing + df['Y'] *= spacing + # Add a column of channel indices + ch_offset = np.array([32 * (ord(_) - 65) for _ in df['Bank']]) + df['ChIdx'] = df['ChInBank'] + ch_offset + df = df.sort_values('ChIdx') + # Trim out the rows from banks not in self.banks + df = df[df['Bank'].isin(self.banks)] + + # For each chunk, replace the electrode positions with positions from the map file stored in the df. + # - The chunk>Block>SpaceAxis names are created by python-neo and do not correspond to + # any channel names we have in our df. We create channel names for our df from ch0 to chN + # - When the data have fewer channels than exist in the df, we exhaust the banks in order. + # (Another approach not used is to get equal numbers of channels from each bank). + if df is not None: + positions = df[['X', 'Y']].to_numpy().astype(float) / 10e6 # um to m + positions = np.hstack((positions, np.zeros_like(positions[:, 0][:, None]))) # Add on z dimension + ch_names = [f'ch{_:d}' for _ in range(1, 1+len(positions))] + for n, chnk in enumerate_chunks(pkt, with_axes=(space,)): + sp_idx_in_df = [ch_names.index(_) for _ in chnk.block.axes[space].names] + chnk.block.axes[space].positions[:] = positions[sp_idx_in_df] + + if False: + new_space_ax = chnk.block.axes['space'] + import matplotlib.pyplot as plt + fig, ax = plt.subplots(1, 1) + ax.set_xlim([-0.2, 4.0]) + ax.set_ylim([-0.2, 4.0]) + for name, xy in zip(new_space_ax.names, new_space_ax.positions[:, :2]): + ax.text(xy[0], xy[1], name, ha="center", va="center") + plt.show() + + self._data = pkt diff --git a/__init__.py b/__init__.py index f2d3101..92d35c5 100644 --- a/__init__.py +++ b/__init__.py @@ -1,4 +1,8 @@ from .GetUnityTaskEvents import GetUnityTaskEvents from .NSLRHMM import NSLRHMM from .PupilToAngle import PupilToAngle +from .UpdateElectrodePositions import UpdateElectrodePositions from .VariantLDA import VariantLDA +from .MountainSort import MountainSort +from .AsType import AsType +from .ReconContFromEvents import ReconContFromEvents From 4b988268b21b45b48ff493af76630b6ddc7e7949 Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Wed, 10 Jun 2020 17:39:48 -0400 Subject: [PATCH 05/10] Fix attempted import of removed node. --- __init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/__init__.py b/__init__.py index 92d35c5..f893694 100644 --- a/__init__.py +++ b/__init__.py @@ -3,6 +3,5 @@ from .PupilToAngle import PupilToAngle from .UpdateElectrodePositions import UpdateElectrodePositions from .VariantLDA import VariantLDA -from .MountainSort import MountainSort from .AsType import AsType from .ReconContFromEvents import ReconContFromEvents From 8ebc7356c4b394d655c57f4c953b22edf479f0fc Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Thu, 9 Jul 2020 22:46:02 -0400 Subject: [PATCH 06/10] Added and updated some nodes for esoteric spike train processing. --- AsType.py | 45 ++++++++++---------- GetNonzeroData.py | 94 ++++++++++++++++++++++++++++++++++++++++++ ReconContFromEvents.py | 68 +++++++++++++++++++----------- __init__.py | 1 + 4 files changed, 160 insertions(+), 48 deletions(-) create mode 100644 GetNonzeroData.py diff --git a/AsType.py b/AsType.py index f58dc24..97245ab 100644 --- a/AsType.py +++ b/AsType.py @@ -2,9 +2,6 @@ import numpy as np from neuropype.engine import * from neuropype.utilities import cache -import tempfile -import h5pickle as h5py -import weakref import lazy_ops @@ -15,7 +12,7 @@ class AsType(Node): """Change data type.""" # --- Input/output ports --- - data = DataPort(Packet, "Data to process.") + data = DataPort(Packet, "Data to process.", mutating=False) # --- Properties --- dtype = EnumPort('none', domain=['float64', 'float32', 'float16', 'int64', 'int32', 'int16', 'int8', 'none'], @@ -41,16 +38,19 @@ def description(cls): version='0.1.0', status=DevStatus.alpha) @data.setter - def data(self, v): + def data(self, pkt): # try to read from cache record = cache.try_lookup(context=self, enabled=self.use_caching, - verbose=True, data=v, state=None) + verbose=True, data=pkt, state=None) if record.success(): self._data = record.data return - for n, chunk in enumerate_chunks(v, nonempty=True): + out_chunks = {} + for n, chunk in enumerate_chunks(pkt, nonempty=True): + + out_axes = deepcopy_most(chunk.block.axes) dtype = {'float64': np.float64, 'float32': np.float32, 'float16': np.float16, 'int64': np.int64, 'int32': np.int32, 'int16': np.int16, 'int8': np.int8, @@ -58,26 +58,25 @@ def data(self, v): if self.data_class == 'DatasetView' or\ (isinstance(chunk.block._data, lazy_ops.DatasetView) and self.data_class == 'none'): - # Create a tempfile and close it. - tf = tempfile.NamedTemporaryFile(delete=False) - tf.close() - # Open again with h5py/h5pickle - f = h5py.File(tf.name, mode='w', libver='latest') - dset_name_in_parent = chunk.block._data._dataset.name.split('/')[-1] - chunk.block._data._dataset = f.create_dataset(dset_name_in_parent, - data=chunk.block._data, dtype=dtype, - chunks=True, compression="gzip") - f.swmr_mode = True - # Setup an automatic deleter for the new tempfile - chunk.block._data._finalizer = weakref.finalize( - chunk.block._data, chunk.block._data.on_finalize, f, f.filename) + # Create new DatasetView backed by tempfile + cache_settings = chunk.block._data._dataset.file.id.get_access_plist().get_cache() + file_kwargs = {'rdcc_nbytes': cache_settings[2], + 'rdcc_nslots': cache_settings[1]} + data = lazy_ops.create_with_tempfile(chunk.block.shape, dtype=dtype, + chunks=chunk.block._data._dataset.chunks, + **file_kwargs) + data[:] = chunk.block._data elif self.data_class == 'ndarray' or\ (isinstance(chunk.block._data, np.ndarray) and self.data_class == 'none'): - chunk.block._data = np.array(chunk.block._data).astype(dtype) + data = np.array(chunk.block._data).astype(dtype) + + out_chunks[n] = Chunk(block=Block(data=data, axes=out_axes), + props=deepcopy_most(chunk.props)) - record.writeback(data=v) - self._data = v + pkt = Packet(chunks=out_chunks) + record.writeback(data=pkt) + self._data = pkt def on_port_assigned(self): """Callback to reset internal state when a value was assigned to a diff --git a/GetNonzeroData.py b/GetNonzeroData.py new file mode 100644 index 0000000..56cf55d --- /dev/null +++ b/GetNonzeroData.py @@ -0,0 +1,94 @@ +import logging +import numpy as np + +from neuropype.engine import * +from neuropype.utilities import cache + + +logger = logging.getLogger(__name__) + + +class GetNonzeroData(Node): + """Get a copy of continuous timeseries including only samples where all channels were zero.""" + + # --- Input/output ports --- + data = DataPort(Packet, "Data to process.", mutating=False) + + waveform_window = ListPort([0, 0], float, """Time window (seconds) around + each event that is assumed to have non-zero waveform data. + """, verbose_name='waveform window') + use_caching = BoolPort(False, """Enable caching.""", expert=True) + + def __init__(self, + waveform_window: Union[List[float], None, Type[Keep]] = Keep, + use_caching: Union[bool, None, Type[Keep]] = Keep, + **kwargs): + super().__init__(waveform_window=waveform_window, use_caching=use_caching, **kwargs) + + @classmethod + def description(cls): + """Declare descriptive information about the node.""" + return Description(name='Drop Blank Times', + description="""\ + Drop samples (time axis) where all channels were value == 0 + """, + version='0.1.0', status=DevStatus.alpha) + + @data.setter + def data(self, pkt): + + record = cache.try_lookup(context=self, enabled=self.use_caching, + verbose=True, data=pkt, state=None) + if record.success(): + self._data = record.data + return + + # Get the event train, if present. + evt_name, evt_chunk = find_first_chunk(pkt, with_axes=(space, time), + with_flags=Flags.is_sparse, + allow_markers=False) + + # Get the signals chunk, if present. + sig_name, sig_chunk = find_first_chunk(pkt, with_axes=(space, time), + without_flags=Flags.is_sparse) + + if sig_name is None: + return + + b_keep = np.zeros((len(sig_chunk.block.axes['time']),), dtype=bool) + if evt_name is not None: + # if evt_chunk is present then we can use that to identify which samples were covered by a waveform + spk_blk = evt_chunk.block + spike_inds = np.sort(np.unique(spk_blk._data.indices)) + wf_samps = [int(_ * sig_chunk.block.axes[time].nominal_rate) for _ in self.waveform_window] + spike_inds = spike_inds[np.logical_and(spike_inds > wf_samps[0], spike_inds < (len(b_keep) - wf_samps[1]))] + dat_inds = np.unique(spike_inds[:, None] + np.arange(wf_samps[0], wf_samps[1], dtype=int)[None, :]) + b_keep[dat_inds] = True + else: + # else, scan the data. This is probably slower than above. + for ch_ix in range(len(sig_chunk.block.axes[space])): + b_keep = np.logical_or(b_keep, + sig_chunk.block[space[ch_ix], ...].data[0] != 0) + + logger.info(f"Copying {np.sum(b_keep)} / {len(b_keep)} samples ({100.*np.sum(b_keep)/len(b_keep):.2f} %)...") + + # Create output block that is copy of input + out_axes = list(sig_chunk.block[space, time].axes) + out_axes[-1] = TimeAxis(times=out_axes[-1].times[b_keep], nominal_rate=out_axes[-1].nominal_rate) + out_blk = Block(data=sig_chunk.block._data, axes=out_axes, data_only_for_type=True) + for ch_ix in range(len(sig_chunk.block.axes[space])): + # Non-slice and non-scalar indexing of long block axes is quite slow, so get full time then slice that. + out_blk[ch_ix:ch_ix+1, :].data = sig_chunk.block[ch_ix:ch_ix+1, :].data[:, b_keep] + + # Create a new packet using only nonzero samples. Note this uses a ndarray, not DatasetView + self._data = Packet(chunks={sig_name: Chunk( + block=out_blk, + props=deepcopy_most(sig_chunk.props) + )}) + + record.writeback(data=self._data) + + def on_port_assigned(self): + """Callback to reset internal state when a value was assigned to a + port (unless the port's setter has been overridden).""" + self.signal_changed(True) diff --git a/ReconContFromEvents.py b/ReconContFromEvents.py index 219a503..4b0ce73 100644 --- a/ReconContFromEvents.py +++ b/ReconContFromEvents.py @@ -41,8 +41,9 @@ def data(self, pkt): wf_name, wf_chunk = find_first_chunk(pkt, with_axes=(instance, time), without_flags=(Flags.is_signal, Flags.is_sparse), allow_markers=False) + wf_blk = wf_chunk.block - # Get the event train. Only useful for + # Get the event train. evt_name, evt_chunk = find_first_chunk(pkt, with_axes=(space, time), with_flags=Flags.is_sparse, allow_markers=False) @@ -51,19 +52,22 @@ def data(self, pkt): sig_name, sig_chunk = find_first_chunk(pkt, with_axes=(space, time), without_flags=Flags.is_sparse) - # Make TimeAxis new continuous data - wf_blk = wf_chunk.block - t0 = 0.0 # np.min(wf_blk.axes['ne.instance'].times) + wf_blk.axes['time'].times[0] - t_end = np.max(wf_blk.axes[instance].times) + wf_blk.axes[time].times[-1] - step_size = 1 / wf_blk.axes[time].nominal_rate - t_vec = np.arange(t0, t_end + step_size, step_size) - new_time_ax = TimeAxis(times=t_vec, nominal_rate=wf_blk.axes[time].nominal_rate) + # Make time axis for new continuous data + if evt_name is not None: + new_time_ax = deepcopy_most(evt_chunk.block.axes[time]) + else: + t0 = 0.0 # np.min(wf_blk.axes['ne.instance'].times) + wf_blk.axes['time'].times[0] + t_end = np.max(wf_blk.axes[instance].times) + wf_blk.axes[time].times[-1] + step_size = 1 / wf_blk.axes[time].nominal_rate + t_vec = np.arange(t0, t_end + step_size, step_size) + new_time_ax = TimeAxis(times=t_vec, nominal_rate=wf_blk.axes[time].nominal_rate) # Make SpaceAxis for new continuous data # Get channel labels, sort so Ch10 is after Ch9, etc. chan_labels = np.unique(wf_blk.axes[instance].data['chan_label']) sort_ix = np.argsort([int(_[2:]) for _ in chan_labels]) chan_labels = chan_labels[sort_ix] + # Get channel positions if available if evt_name: sp_ax = evt_chunk.block.axes[space] @@ -72,41 +76,54 @@ def data(self, pkt): new_pos = None new_space_ax = SpaceAxis(names=chan_labels, positions=new_pos) - # Calculate index offsets for each waveform + # Calculate offset sample indices for a single waveform relative to event at t=0 wf_idx_off = (wf_blk.axes[time].times * wf_blk.axes[time].nominal_rate).astype(int) - # Initialize null continuous data - dat = np.zeros((len(chan_labels), len(t_vec)), dtype=wf_blk.data.dtype) + # Initialize output block with zeros + out_shape = (len(new_space_ax), len(new_time_ax)) + if False: + import lazy_ops + dset = lazy_ops.create_with_tempfile(out_shape, dtype=wf_blk.dtype) + else: + dset = np.zeros(out_shape, dtype=wf_blk.dtype) + + sig_blk = Block(data=dset, axes=(new_space_ax, new_time_ax)) # Superimpose spikes on zeros, one channel at a time for ch_ix, ch_label in enumerate(chan_labels): - ch_blk = wf_blk[..., instance[wf_blk.axes[instance].data['chan_label'] == ch_label], ...] - if ch_blk.size: - ch_wf_dat = np.copy(ch_blk[instance, time].data).flatten() - ch_idx = np.searchsorted(t_vec, ch_blk.axes[instance].times)[:, None] + wf_idx_off[None, :] - ch_idx = ch_idx.flatten() + b_insts = wf_blk.axes[instance].data['chan_label'] == ch_label + if np.any(b_insts): + # Get all the waveforms for this channel, then concatenate them side-by-side + ch_wf_dat = wf_blk[instance, ...].data[b_insts].flatten() + # Get the time-indices for all the waveforms, then concatenate them side-by-side + ch_wf_idx = (np.searchsorted(new_time_ax.times, wf_blk.axes[instance].times[b_insts])[:, None] + + wf_idx_off[None, :]).flatten() + # Drop samps of waveforms that extend beyond data limits + ch_wf_idx = ch_wf_idx[ch_wf_idx < len(sig_blk.axes[time])] + ch_wf_idx = ch_wf_idx[ch_wf_idx >= 0] # Only take the first occurrence of any particular sample to avoid # samples that may be over-represented if there are overlapping waveforms. - uq_ch_idx, uq_wf_idx = np.unique(ch_idx, return_index=True) - dat[ch_ix, uq_ch_idx] = ch_wf_dat[uq_wf_idx] - - raw_blk = Block(data=dat, axes=(new_space_ax, new_time_ax)) + uq_ch_wf_idx, uq_wf_idx = np.unique(ch_wf_idx, return_index=True) + if isinstance(sig_blk._data, np.ndarray): + sig_blk.data[ch_ix, uq_ch_wf_idx] = ch_wf_dat[uq_wf_idx] + else: # lazy_ops DatasetView + sig_blk[space[ch_ix], time].data[:, uq_ch_wf_idx] = ch_wf_dat[uq_wf_idx] # Add white noise to samples that weren't written with waveforms. if self.add_noise: # Noise should very rarely cross threshold. # Set 4 STDs (=99.96% of samples) to be less than threshold of -54 uV: noise_std = 54/4 - for ch_dat in raw_blk.data: + for ch_dat in sig_blk.data: b_zero = ch_dat == 0. ch_dat[b_zero] = (noise_std * np.random.randn(np.sum(b_zero))).astype(np.int16) # Superimpose interpolated LFPs if available if self.add_lfps and sig_name is not None: - lfp_blk = sig_chunk.block[space[raw_blk.axes[space].names.tolist()], ..., time] + lfp_blk = sig_chunk.block[space[sig_blk.axes[space].names.tolist()], ..., time] # Manual interpolation, one channel at a time, to save memory from scipy.interpolate import interp1d - new_times = raw_blk.axes[time].times + new_times = sig_blk.axes[time].times old_times = lfp_blk.axes[time].times for chan_ix, chan_label in enumerate(lfp_blk.axes[space].names): if chan_label in chan_labels: @@ -114,9 +131,10 @@ def data(self, pkt): assume_sorted=True, fill_value='extrapolate') lfp_upsamp = f(new_times) full_ch_ix = np.where(chan_labels == chan_label)[0][0] - raw_blk.data[full_ch_ix] = raw_blk.data[full_ch_ix] + lfp_upsamp.astype(raw_blk.data.dtype) + sig_blk.data[full_ch_ix] = sig_blk.data[full_ch_ix] + lfp_upsamp.astype(sig_blk.data.dtype) - self._data = Packet(chunks={'recon_raw': raw_blk}) + self._data = Packet(chunks={'recon_raw': Chunk(block=sig_blk, + props={Flags.is_streaming: False, Flags.is_signal: True})}) def on_port_assigned(self): """Callback to reset internal state when a value was assigned to a diff --git a/__init__.py b/__init__.py index f893694..0f4da85 100644 --- a/__init__.py +++ b/__init__.py @@ -5,3 +5,4 @@ from .VariantLDA import VariantLDA from .AsType import AsType from .ReconContFromEvents import ReconContFromEvents +from .GetNonzeroData import GetNonzeroData From 1044c0ba46980f59b405151113220b3049331fd9 Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Wed, 23 Dec 2020 12:33:32 -0500 Subject: [PATCH 07/10] Hide lazy_ops import in method to allow module import without lazy_ops installed. --- AsType.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AsType.py b/AsType.py index 97245ab..e6cee02 100644 --- a/AsType.py +++ b/AsType.py @@ -2,7 +2,6 @@ import numpy as np from neuropype.engine import * from neuropype.utilities import cache -import lazy_ops logger = logging.getLogger(__name__) @@ -39,6 +38,8 @@ def description(cls): @data.setter def data(self, pkt): + import lazy_ops + # try to read from cache record = cache.try_lookup(context=self, enabled=self.use_caching, verbose=True, data=pkt, state=None) From 09e1f24c1bcd6440fe1be87fb6f69d9f1c4f2174 Mon Sep 17 00:00:00 2001 From: Michael Leung Date: Tue, 3 Aug 2021 18:25:34 -0400 Subject: [PATCH 08/10] parser with timestamps column in markers df --- GetUnityTaskEventsTS.py | 314 ++++++++++++++++++++++++++++++++++++++++ __init__.py | 1 + 2 files changed, 315 insertions(+) create mode 100644 GetUnityTaskEventsTS.py diff --git a/GetUnityTaskEventsTS.py b/GetUnityTaskEventsTS.py new file mode 100644 index 0000000..01faaaf --- /dev/null +++ b/GetUnityTaskEventsTS.py @@ -0,0 +1,314 @@ +import logging +import numpy as np +from neuropype.engine import * + +logger = logging.getLogger(__name__) + + +class GetUnityTaskEventsTS(Node): + # --- Input/output ports --- + data = Port(None, Packet, "Data to process.", required=True, + editable=False, mutating=True) + + @classmethod + def description(cls): + return Description(name='A copy of GetUnityTaskEvents that adds TimeStamps column in DF.', + description="""Parse marker strings into table of data""", + version='0.1', + license=Licenses.MIT) + + @data.setter + def data(self, pkt): + mrk_n, mrk_chnk = find_first_chunk(pkt, name_equals='markers') + if mrk_n is not None: + ev_times = mrk_chnk.block.axes[instance].times + + # Load the data + import json + dict_arr = mrk_chnk.block.axes[instance].data['Marker'] + events = [] + for ix, ev in enumerate(dict_arr): + # Fix some mistakes in the json encoding in Unity + dat = json.loads(ev) + if 'CameraRecenter:' in dat: + dat = {'CameraRecenter': dat['CameraRecenter:']} + if 'Input:' in dat: + dat = {'Input': dat['Input:']} + events.append(dat) + + """ + Input event markers: + TrialState: + condition (int): See conditiontype_map + isCorrect (bool) + modifier (int): See modifiertype_map + trialIndex (uint) + response: See responsetype_map + cuedPositionIndex: See position_map + targetPositionIndex: See position_map + targetObjectIndex (int): 0 + selectedObjectIndex (int): in -1, 0 + selectedPositionIndex: in -1, 0, 1 + targetColorIndex: -1 + trialPhaseIndex: see phase_map + Input: An event whenever a user input is registered (e.g., gaze collides with object) + trialIndex (int) + selectedObjectClass (str): in 'Background', 'Fixation', 'Target', 'Wall' + info (key-value pair): 'Selected: ' + ObjectInfo: + _isVisible (bool) + _identity (string) + _position (x,y,z) + _pointingTo (x,y,z) + CameraRecenter: (bool) Camera height and yaw recentered on user + """ + # Trial phase indices map to trial phases + phase_map = {1: 'Intertrial', 2: 'Fixate', 3: 'Cue', 4: 'Delay', 5: 'Target', + 6: 'Go', 7: 'Countermand', 8: 'Response', 9: 'Feedback', -1: 'UserInput'} + phase_inv_map = {v: k for k, v in phase_map.items()} + modifiertype_map = {0: 'None', 1: 'Cued', 2: 'MemoryGuided', 3: 'NoGo', 4: 'Catch'} + conditiontype_map = {0: 'None', 1:'AttendShape', 2: 'AttendColour', 3: 'AttendNumber', 4: 'AttendDirection', 5: 'AttendPosition', 6: 'AttendFixation'} + # Note: ResponseType 3 to 5 are added post data collection to expedite analysis + responsetype_map = {0: 'None', 1: 'Prosaccade', 2: 'Antisaccade', 3: 'CuedSaccade', 4: 'NoGoProsaccade', 5: 'NoGoAntisaccade'} + position_map = {-1: 'Unknown', 0: 'Left', 1: 'Right', 2: 'NoGo'} + + """ + There are many more events than we need, including events for positioning invisible targets and changing + their colour. + For each trial, we want to keep any events where the stimulus changed or where the user saw something. + Each row will also have other data that describe the whole trial, so when we select individual events + later, we still have all of the info we need to know what kind of trial it was. + Note that the ObjectInfo events occur before their associated TrialState event, so the most accurate + timestamps will come from ObjectInfo, not TrialState. + + Trial lifecycle: + - ObjectInfo event when target is placed but still invisible + - TrialState event with trialPhaseIndex 1 to indicate intertrial + - Input event (>=1) to indicate subject is selecting CentralFixation / CentralWall. + - TrialState with trialPhaseIndex = 2 to indicate Fixate phase. + - Last Input event must be CentralFixation to proceed. + - ObjectInfo to show the cue. (_isVisible: True) + - TrialState with trialPhaseIndex=3 to indicate cue phase. + - ObjectInfo shows colour change of cue to indicate Prosaccade/Antisaccade trial. + + - TrialState with trialPhaseIndex=4 for the Delay (memory) period. + - TrialState event with trialPhaseIndex 5 to indicate this is the target phase (map memory to saccade plan) + - ObjectInfo with CentralFixation set to _isVisible False. This is the imperative go cue. + - TrialState with trialPhaseIndex 6 to indicate the Go phase. TODO: Check if the time is same as above. + - (Optional) Input event after fixation disappears because we are now selecting CentralWall behind fixation. + - (if countermanding) ObjectInfo when fixation reappears. Start of countermanding. + - (if countermanding) Input when fixation goes back on to central + - TrialState with trialPhaseIndex 7 to indicate beginning of countermanding phase, whether or not stim given + - ObjectInfo when CentralFixation disappears again + - TrialState with trialPhaseIndex 8 to indicate beginning of Response phase + - Input to indicate hitting target (or non-target, or opposite wall in antisaccade) + - ObjectInfo to clear out CentralFixation + - TrialState with trialPhaseIndex 8 again, but this time the isCorrect has changed. + - TrialState with trialPhaseIndex 9 to indicate feedback phase. + The next ObjectInfo event indicates the start of the next trial + """ + + # Output table will have the following fields + field_name_type_prop = [ + ('UnityTrialIndex', int, ValueProperty.INTEGER + ValueProperty.NONNEGATIVE), + ('Marker', object, ValueProperty.STRING + ValueProperty.CATEGORY), # Used to hold trial phase. + ('ModifierType', object, ValueProperty.STRING + ValueProperty.CATEGORY), + ('ConditionType', object, ValueProperty.STRING + ValueProperty.CATEGORY), + ('ResponseType', object, ValueProperty.STRING + ValueProperty.CATEGORY), + ('CuedPosition', object, ValueProperty.STRING + ValueProperty.CATEGORY), + ('CuedObject', object, ValueProperty.STRING + ValueProperty.CATEGORY), + ('TargetPosition', object, ValueProperty.STRING + ValueProperty.CATEGORY), + ('TargetObjectIndex', int, ValueProperty.INTEGER + ValueProperty.CATEGORY), + # ('TargetColour', object, ValueProperty.STRING + ValueProperty.CATEGORY), + # ('EnvironmentIndex', int, ValueProperty.INTEGER + ValueProperty.NONNEGATIVE), + ('CountermandingDelay', float, ValueProperty.UNKNOWN), + ('SelectedPosition', object, ValueProperty.STRING + ValueProperty.CATEGORY), + ('SelectedObjectIndex', int, ValueProperty.INTEGER + ValueProperty.CATEGORY), + ('IsCorrect', bool, ValueProperty.NONNEGATIVE), + ('ReactionTime', float, ValueProperty.UNKNOWN), + ('CueTypeIndex', object, ValueProperty.STRING + ValueProperty.CATEGORY), + ('Timestamps', float, ValueProperty.UNKNOWN) + ] + field_names, field_types, field_props = zip(*field_name_type_prop) + ra_dtype = list(zip(zip(field_props, field_names), field_types)) # For recarray + + # Identify the trial index for each event, even the ObjectInfo and Input events. + ev_types = np.array([list(_.keys())[0] for _ in events]) + last_tr_ind = 0 + last_phase = 9 + object_bump = False + ev_tr = [] + for ev_ix, ev in enumerate(events): + if ev_types[ev_ix] == 'TrialState': + last_phase = ev['TrialState']['trialPhaseIndex'] + last_tr_ind = ev['TrialState']['trialIndex'] + object_bump = False + elif ev_types[ev_ix] == 'ObjectInfo' and last_phase == 9 and not object_bump: + # The first ObjectInfo event after a phase-9 event is the start of a new trial. + last_tr_ind += 1 + object_bump = True + ev_tr.append(last_tr_ind) + ev_tr = np.array(ev_tr) + + # ev_tr might wrap if there were multiple files loaded. + while np.any(np.diff(ev_tr) < 0): + switch_ind = np.where(np.diff(ev_tr) < 0)[0] + 1 + offset = ev_tr[switch_ind - 1] + try: + ev_tr[switch_ind[0]:] += offset + except ValueError as err: + print('Detect potential multi-file load,', err) + break + + # Start to build the dataframe + import pandas as pd + df = pd.DataFrame(columns=field_names) + out_times = [] + + for tr_ix, tr_ind in enumerate(np.unique(ev_tr)): + b_tr = ev_tr == tr_ind + tr_types = ev_types[b_tr] + if 'TrialState' not in tr_types: + continue + + tr_events = np.array(events)[b_tr] + + tr_phases = np.array([_['TrialState']['trialPhaseIndex'] + if 'TrialState' in _ else np.nan for _ in tr_events]) + + # Details to be saved along with each event for this trial. + # Every trial should have feedback phase and it should be the most informative. + if phase_inv_map['Feedback'] not in tr_phases: + continue + fbstate = tr_events[tr_phases == phase_inv_map['Feedback']][0]['TrialState'] + details = { + 'UnityTrialIndex': fbstate['trialIndex'], + 'ModifierType': modifiertype_map[fbstate['modifier']], + 'ConditionType': conditiontype_map[fbstate['condition']], + 'ResponseType': responsetype_map[fbstate['response']] if fbstate['modifier'] == 0 else 'CuedOrNoGo', + 'CuedPosition': position_map[fbstate['cuePositionIndex']], + 'TargetPosition': position_map[fbstate['targetPositionIndex']], + 'TargetObjectIndex': fbstate['targetObjectIndex'], # TODO: Map to object name + # 'TargetColour': color_map[fbstate['targetColorIndex']], + # 'EnvironmentIndex': fbstate['environmentIndex'], # TODO: Map to environment name. + 'SelectedPosition': position_map[fbstate['selectedPositionIndex']], + 'SelectedObjectIndex': fbstate['selectedObjectIndex'], # TODO: Map to object name + 'IsCorrect': fbstate['isCorrect'], + 'CountermandingDelay': np.nan, + 'ReactionTime': np.nan + # No need for CueTypeIndex, ResponseType indicates whether trial is Pro or Anti-saccade + # CueTypeIndex. For "TaskSwitch" experiment, tells if trial is Pro or Anti-saccade. + # 'CueTypeIndex': cue_type_map[fbstate['saccadeIndex']] if 'saccadeIndex' in fbstate else -1 + } + # Additional ResponseTypes are added in analysis (here), for comparing against conditions + if details['ResponseType'] == 'CuedOrNoGo': + details['ResponseType'] = 'CuedSaccade' if fbstate['modifier'] == 1 \ + else ('NoGoProsaccade' if fbstate['response'] == 1 else 'NoGoAntisaccade') + + # Get some more details that we can only get from events. + df_to_extend = [] + tr_times = ev_times[b_tr] + tr_is_obj = tr_types == 'ObjectInfo' + tr_obj_is_vis = np.array([tr_events[_]['ObjectInfo']['_isVisible'] if tr_is_obj[_] else False + for _ in range(len(tr_events))]) + tr_obj_id = np.array([tr_events[_]['ObjectInfo']['_identity'] if tr_is_obj[_] else None + for _ in range(len(tr_events))]) + + # Event 1 - Intertrial. ObjectInfo cue placed but hidden. Use phase transition. + df_to_extend.append({'Marker': 'Intertrial'}) + iti_ix = np.where(tr_phases == phase_inv_map['Intertrial'])[0][0] + out_times.append(tr_times[iti_ix]) + + # Event 2 - Fixation achieved. Use phase transition. + if phase_inv_map['Fixate'] in tr_phases: + df_to_extend.append({'Marker': 'Fixate'}) + fix_start_ix = np.where(tr_phases == phase_inv_map['Fixate'])[0][0] + out_times.append(tr_times[fix_start_ix]) + + # Event 3 - Cue presentation. Transition to phase 3 and Object appears (maybe reversed order) + if phase_inv_map['Cue'] in tr_phases: + df_to_extend.append({'Marker': 'Cue'}) + cue_ix = np.where(tr_phases == phase_inv_map['Cue'])[0][0] + # TODO: Current experiment does not have a ObjectInfo event near time of cue. + # obj_ix = np.where(tr_obj_is_vis)[0][np.argmin(np.abs(tr_times[tr_obj_is_vis] - tr_times[cue_ix]))] + # details['CuedObject'] = tr_obj_id[obj_ix] + out_times.append(tr_times[cue_ix]) # TODO: use obj_ix in new experiment. + + # Event 4 - Delay period. ObjectInfo cue disappears; transition to phase 4. + if phase_inv_map['Delay'] in tr_phases: + df_to_extend.append({'Marker': 'Delay'}) + pre_ix = np.where(tr_phases == phase_inv_map['Cue'])[0][0] + ph_ix = np.where(tr_phases == phase_inv_map['Delay'])[0][0] + del_ix = pre_ix + np.where(tr_is_obj[pre_ix:ph_ix])[0][0] + out_times.append(tr_times[del_ix]) + + # Event 5 - Target presentation. ObjectInfo targets appear; transition to phase 5. + if phase_inv_map['Target'] in tr_phases: + df_to_extend.append({'Marker': 'Target'}) + targ_ix = np.where(np.logical_and(tr_obj_id == 'Target', tr_obj_is_vis))[0] + if len(targ_ix) > 0: + targ_ix = targ_ix[-1] + else: + targ_ix = np.where(tr_phases == phase_inv_map['Target'])[0][0] + out_times.append(tr_times[targ_ix]) + + # Event 6 - Imperative cue. Fixation pt disappears. Transition to Phase 6. + if phase_inv_map['Go'] in tr_phases: + df_to_extend.append({'Marker': 'Go'}) + go_ix = np.where(np.logical_and(tr_obj_id == 'CentralFixation', ~tr_obj_is_vis))[0] + if len(go_ix) > 0: + go_ix = go_ix[0] + else: + go_ix = np.where(tr_phases == phase_inv_map['Go'])[0][0] + go_time = tr_times[go_ix] + out_times.append(go_time) + else: + logger.debug("Go cue not found for trial {}.".format(tr_ind)) + + # Event 7 (optional) - Countermanding cue. + # Get countermanding delay + if details['ResponseType'] != 'Prosaccade' and phase_inv_map['Countermand'] in tr_phases: + df_to_extend.append({'Marker': 'Countermand'}) + + # Find last fixation-visible event before response period. + resp_ix = np.where(tr_phases == phase_inv_map['Response'])[0][0] + b_countermand = np.logical_and(tr_obj_id[:resp_ix] == 'CentralFixation', tr_obj_is_vis[:resp_ix]) + cm_ix = np.where(b_countermand)[0] + if len(cm_ix) > 0: + cm_ix = cm_ix[-1] + else: + cm_ix = np.where(tr_phases == phase_inv_map['Countermand'])[0][0] + details['CountermandingDelay'] = tr_times[cm_ix] - go_time + out_times.append(tr_times[cm_ix]) + + # Event 8 - Response. Without pupil data yet, we use Input event. + # Get reaction time + if phase_inv_map['Response'] in tr_phases: + df_to_extend.append({'Marker': 'Response'}) + ph_ix = np.where(tr_phases == phase_inv_map['Response'])[0][0] + resp_ix = ph_ix + np.where(tr_types[ph_ix:] == 'Input')[0] + resp_ix = [_ for _ in resp_ix if tr_events[_]['Input']['selectedObjectClass'] != 'Fixation'] + resp_ix = resp_ix[0] if len(resp_ix) > 0 else ph_ix + details['ReactionTime'] = tr_times[resp_ix] - go_time + out_times.append(tr_times[resp_ix]) + + # Event 9 - Feedback. Use phase transition. + df_to_extend.append({'Marker': 'Feedback'}) + fb_ix = np.where(tr_phases == phase_inv_map['Feedback'])[0][0] + out_times.append(tr_times[fb_ix]) + + for new_ev in df_to_extend: + df = df.append(dict(new_ev, **details), ignore_index=True) + df['Timestamps'] = out_times + + # Try to infer column datatypes. + # df.infer_objects() <- requires pandas >= 0.21 + df['UnityTrialIndex'] = df['UnityTrialIndex'].astype(int) + + # Modify instance axis + new_data = df.to_records(index=False).astype(ra_dtype) + pkt.chunks[mrk_n].block = Block(data=np.full((len(out_times),), np.nan), + axes=(InstanceAxis(times=out_times, data=new_data, + instance_type='markers'),)) + + self._data = pkt diff --git a/__init__.py b/__init__.py index 0f4da85..630938c 100644 --- a/__init__.py +++ b/__init__.py @@ -1,4 +1,5 @@ from .GetUnityTaskEvents import GetUnityTaskEvents +from .GetUnityTaskEventsTS import GetUnityTaskEventsTS from .NSLRHMM import NSLRHMM from .PupilToAngle import PupilToAngle from .UpdateElectrodePositions import UpdateElectrodePositions From b1a4f30861005b78c22419747b86a632f80a43a6 Mon Sep 17 00:00:00 2001 From: Michael Leung Date: Tue, 3 Aug 2021 19:18:12 -0400 Subject: [PATCH 09/10] Michael's PD version of GetUnityTaskEvents --- GetUnityTaskEvents.py | 121 +++++++++++++++++++------------------- GetUnityTaskEventsTS.py | 127 ++++++++++++++++++++-------------------- 2 files changed, 125 insertions(+), 123 deletions(-) diff --git a/GetUnityTaskEvents.py b/GetUnityTaskEvents.py index 32a3b59..77b2bc0 100644 --- a/GetUnityTaskEvents.py +++ b/GetUnityTaskEvents.py @@ -1,5 +1,7 @@ +# NOTE: .txt because from upload restrictions. Change to .py import logging import numpy as np +import copy from neuropype.engine import * logger = logging.getLogger(__name__) @@ -12,7 +14,7 @@ class GetUnityTaskEvents(Node): @classmethod def description(cls): - return Description(name='Get Behaviour for Michael Saccade VR study (New submodule post Sept 10 revisions)', + return Description(name='Get Behaviour for Michael Saccade VR study', description="""Parse marker strings into table of data""", version='0.1', license=Licenses.MIT) @@ -62,9 +64,11 @@ def data(self, pkt): _pointingTo (x,y,z) CameraRecenter: (bool) Camera height and yaw recentered on user """ + # Trial phase indices map to trial phases - phase_map = {1: 'Intertrial', 2: 'Fixate', 3: 'Cue', 4: 'Delay', 5: 'Target', - 6: 'Go', 7: 'Countermand', 8: 'Response', 9: 'Feedback', -1: 'UserInput'} + phase_map = {0: 'Setup', 1: 'Intertrial', 2: 'Fixation', 3: 'Cue', 4: 'Delay_1', 5: 'Target', + 6: 'Delay_2', 7: 'Distractor', 8: 'Delay_3', 9: 'Countermanding', 10: 'Delay_4', 11: 'Response', + 12: 'Feedback', 13: 'Delay_5', 14: 'Misc', 15: 'Selector', 16: 'Null', -1: 'UserInput'} phase_inv_map = {v: k for k, v in phase_map.items()} modifiertype_map = {0: 'None', 1: 'Cued', 2: 'MemoryGuided', 3: 'NoGo', 4: 'Catch'} conditiontype_map = {0: 'None', 1:'AttendShape', 2: 'AttendColour', 3: 'AttendNumber', 4: 'AttendDirection', 5: 'AttendPosition', 6: 'AttendFixation'} @@ -81,31 +85,32 @@ def data(self, pkt): Note that the ObjectInfo events occur before their associated TrialState event, so the most accurate timestamps will come from ObjectInfo, not TrialState. - Trial lifecycle: - - ObjectInfo event when target is placed but still invisible - - TrialState event with trialPhaseIndex 1 to indicate intertrial - - Input event (>=1) to indicate subject is selecting CentralFixation / CentralWall. - - TrialState with trialPhaseIndex = 2 to indicate Fixate phase. - - Last Input event must be CentralFixation to proceed. - - ObjectInfo to show the cue. (_isVisible: True) - - TrialState with trialPhaseIndex=3 to indicate cue phase. - - ObjectInfo shows colour change of cue to indicate Prosaccade/Antisaccade trial. + Trial life cycle (Shortest 15 Events): + 1- ObjectInfo event when target is placed but still invisible + 2- TrialState event with trialPhaseIndex 1 to indicates intertrial + - Input event (>=1) to indicate subject is selecting CentralFixation / CentralWall. + 3- TrialState with trialPhaseIndex = 2 to indicate Fixation phase. + 4- ObjectInfo to show the cue. (_isVisible: True) + 5- TrialState with trialPhaseIndex=3 to indicate cue phase. + 6- ObjectInfo shows colour change of cue to indicate Prosaccade/Antisaccade trial. - - TrialState with trialPhaseIndex=4 for the Delay (memory) period. - - TrialState event with trialPhaseIndex 5 to indicate this is the target phase (map memory to saccade plan) - - ObjectInfo with CentralFixation set to _isVisible False. This is the imperative go cue. - - TrialState with trialPhaseIndex 6 to indicate the Go phase. TODO: Check if the time is same as above. - - (Optional) Input event after fixation disappears because we are now selecting CentralWall behind fixation. - - (if countermanding) ObjectInfo when fixation reappears. Start of countermanding. - - (if countermanding) Input when fixation goes back on to central - - TrialState with trialPhaseIndex 7 to indicate beginning of countermanding phase, whether or not stim given - - ObjectInfo when CentralFixation disappears again - - TrialState with trialPhaseIndex 8 to indicate beginning of Response phase - - Input to indicate hitting target (or non-target, or opposite wall in antisaccade) - - ObjectInfo to clear out CentralFixation - - TrialState with trialPhaseIndex 8 again, but this time the isCorrect has changed. - - TrialState with trialPhaseIndex 9 to indicate feedback phase. - The next ObjectInfo event indicates the start of the next trial + 7- Input event selects fixation point (!! TODO: This should be during the gating phase) + 8- TrialState event with trialPhaseIndex=4 for the Delay (memory) period. + 9- TrialState event with trialPhaseIndex=6 to indicate this is the variable delay phase (map memory to saccade plan) + 10- TrialState event with trialPhaseIndex=15 to select whether the trial is countermanding or not + 11- ObjectInfo events: _isVisible: True for showing _identity: Target (For Cued Trials, this happens during Cue Phase) + 12- ObjectInfo events: _isVisible: False indicates GO imperative + 13- TrialState with trialPhaseIndex=11 to indicate the Go phase. TODO: Check if the time is same as above. + 14- Input event selects the target + 15- TrialState event with trialPhaseIndex=12 to indicate feedback phase (!! IsCorrect changes here !!) + + Countermanding trial life cycle (~17 Events) + 1-12 are the same as the above + 13- TrialState event with trialPhaseIndex=9 to indicate countermand + 14- ObjectInfo event when fixation reappear. Start of countermanding. + 15- TrialState with trialPhaseIndex=11 to indicate Response phase. This is when the subject has to maintain fixation + 16- TrialState event with trialPhaseIndex=12 to indicate feedback phase (!! IsCorrect changes here !!) + 17- ObjectInfo (start of new trial but seems like after countermanding there's an additional ObjectInfo to hide the fixation) """ # Output table will have the following fields @@ -134,7 +139,7 @@ def data(self, pkt): # Identify the trial index for each event, even the ObjectInfo and Input events. ev_types = np.array([list(_.keys())[0] for _ in events]) last_tr_ind = 0 - last_phase = 9 + last_phase = 12 object_bump = False ev_tr = [] for ev_ix, ev in enumerate(events): @@ -142,8 +147,8 @@ def data(self, pkt): last_phase = ev['TrialState']['trialPhaseIndex'] last_tr_ind = ev['TrialState']['trialIndex'] object_bump = False - elif ev_types[ev_ix] == 'ObjectInfo' and last_phase == 9 and not object_bump: - # The first ObjectInfo event after a phase-9 event is the start of a new trial. + elif ev_types[ev_ix] == 'ObjectInfo' and last_phase == 12 and not object_bump: + # The first ObjectInfo event after a phase-12 event is the start of a new trial. last_tr_ind += 1 object_bump = True ev_tr.append(last_tr_ind) @@ -183,17 +188,14 @@ def data(self, pkt): 'ResponseType': responsetype_map[fbstate['response']] if fbstate['modifier'] == 0 else 'CuedOrNoGo', 'CuedPosition': position_map[fbstate['cuePositionIndex']], 'TargetPosition': position_map[fbstate['targetPositionIndex']], - 'TargetObjectIndex': fbstate['targetObjectIndex'], # TODO: Map to object name + 'TargetObjectIndex': fbstate['targetObjectIndex'], # 'TargetColour': color_map[fbstate['targetColorIndex']], - # 'EnvironmentIndex': fbstate['environmentIndex'], # TODO: Map to environment name. + # 'EnvironmentIndex': fbstate['environmentIndex'], 'SelectedPosition': position_map[fbstate['selectedPositionIndex']], - 'SelectedObjectIndex': fbstate['selectedObjectIndex'], # TODO: Map to object name + 'SelectedObjectIndex': fbstate['selectedObjectIndex'], 'IsCorrect': fbstate['isCorrect'], 'CountermandingDelay': np.nan, 'ReactionTime': np.nan - # No need for CueTypeIndex, ResponseType indicates whether trial is Pro or Anti-saccade - # CueTypeIndex. For "TaskSwitch" experiment, tells if trial is Pro or Anti-saccade. - # 'CueTypeIndex': cue_type_map[fbstate['saccadeIndex']] if 'saccadeIndex' in fbstate else -1 } # Additional ResponseTypes are added in analysis (here), for comparing against conditions if details['ResponseType'] == 'CuedOrNoGo': @@ -211,50 +213,51 @@ def data(self, pkt): # Event 1 - Intertrial. ObjectInfo cue placed but hidden. Use phase transition. df_to_extend.append({'Marker': 'Intertrial'}) - iti_ix = np.where(tr_phases == phase_inv_map['Intertrial'])[0][0] + try: + iti_ix = np.where(tr_phases == phase_inv_map['Intertrial'])[0][0] + except IndexError: + print('Invalid Trial, skipped intertrial') + continue out_times.append(tr_times[iti_ix]) # Event 2 - Fixation achieved. Use phase transition. - if phase_inv_map['Fixate'] in tr_phases: - df_to_extend.append({'Marker': 'Fixate'}) - fix_start_ix = np.where(tr_phases == phase_inv_map['Fixate'])[0][0] + if phase_inv_map['Fixation'] in tr_phases: + df_to_extend.append({'Marker': 'Fixation'}) + fix_start_ix = np.where(tr_phases == phase_inv_map['Fixation'])[0][0] out_times.append(tr_times[fix_start_ix]) - # Event 3 - Cue presentation. Transition to phase 3 and Object appears (maybe reversed order) + # Event 3 - Cue presentation. Transition to phaseIndex 3 and Object appears (maybe reversed order) if phase_inv_map['Cue'] in tr_phases: df_to_extend.append({'Marker': 'Cue'}) cue_ix = np.where(tr_phases == phase_inv_map['Cue'])[0][0] - # TODO: Current experiment does not have a ObjectInfo event near time of cue. - # obj_ix = np.where(tr_obj_is_vis)[0][np.argmin(np.abs(tr_times[tr_obj_is_vis] - tr_times[cue_ix]))] - # details['CuedObject'] = tr_obj_id[obj_ix] - out_times.append(tr_times[cue_ix]) # TODO: use obj_ix in new experiment. + out_times.append(tr_times[cue_ix]) - # Event 4 - Delay period. ObjectInfo cue disappears; transition to phase 4. - if phase_inv_map['Delay'] in tr_phases: - df_to_extend.append({'Marker': 'Delay'}) + # Event 4 - Delay period. ObjectInfo cue disappears; transition to phaseIndex 4. + if phase_inv_map['Delay_1'] in tr_phases: + df_to_extend.append({'Marker': 'Delay_1'}) pre_ix = np.where(tr_phases == phase_inv_map['Cue'])[0][0] - ph_ix = np.where(tr_phases == phase_inv_map['Delay'])[0][0] + ph_ix = np.where(tr_phases == phase_inv_map['Delay_1'])[0][0] del_ix = pre_ix + np.where(tr_is_obj[pre_ix:ph_ix])[0][0] out_times.append(tr_times[del_ix]) - # Event 5 - Target presentation. ObjectInfo targets appear; transition to phase 5. - if phase_inv_map['Target'] in tr_phases: - df_to_extend.append({'Marker': 'Target'}) - targ_ix = np.where(np.logical_and(tr_obj_id == 'Target', tr_obj_is_vis))[0] + # Event 5 - Target presentation. ObjectInfo targets appear; transition to phaseIndex 6. + if phase_inv_map['Delay_2'] in tr_phases: + df_to_extend.append({'Marker': 'Delay_2'}) + targ_ix = np.where(np.logical_and(tr_obj_id == 'Delay_2', tr_obj_is_vis))[0] if len(targ_ix) > 0: targ_ix = targ_ix[-1] else: - targ_ix = np.where(tr_phases == phase_inv_map['Target'])[0][0] + targ_ix = np.where(tr_phases == phase_inv_map['Delay_2'])[0][0] out_times.append(tr_times[targ_ix]) - # Event 6 - Imperative cue. Fixation pt disappears. Transition to Phase 6. - if phase_inv_map['Go'] in tr_phases: + # Event 6 - Imperative cue. Fixation pt disappears. Transition to phaseIndex 15. + if phase_inv_map['Selector'] in tr_phases: df_to_extend.append({'Marker': 'Go'}) go_ix = np.where(np.logical_and(tr_obj_id == 'CentralFixation', ~tr_obj_is_vis))[0] if len(go_ix) > 0: go_ix = go_ix[0] else: - go_ix = np.where(tr_phases == phase_inv_map['Go'])[0][0] + go_ix = np.where(tr_phases == phase_inv_map['Selector'])[0][0] go_time = tr_times[go_ix] out_times.append(go_time) else: @@ -262,7 +265,7 @@ def data(self, pkt): # Event 7 (optional) - Countermanding cue. # Get countermanding delay - if details['ResponseType'] != 'Prosaccade' and phase_inv_map['Countermand'] in tr_phases: + if details['ResponseType'] != 'Prosaccade' and phase_inv_map['Countermanding'] in tr_phases: df_to_extend.append({'Marker': 'Countermand'}) # Find last fixation-visible event before response period. @@ -272,7 +275,7 @@ def data(self, pkt): if len(cm_ix) > 0: cm_ix = cm_ix[-1] else: - cm_ix = np.where(tr_phases == phase_inv_map['Countermand'])[0][0] + cm_ix = np.where(tr_phases == phase_inv_map['Countermanding'])[0][0] details['CountermandingDelay'] = tr_times[cm_ix] - go_time out_times.append(tr_times[cm_ix]) diff --git a/GetUnityTaskEventsTS.py b/GetUnityTaskEventsTS.py index 01faaaf..fbde1e5 100644 --- a/GetUnityTaskEventsTS.py +++ b/GetUnityTaskEventsTS.py @@ -1,5 +1,7 @@ +# NOTE: .txt because from upload restrictions. Change to .py import logging import numpy as np +import copy from neuropype.engine import * logger = logging.getLogger(__name__) @@ -12,7 +14,7 @@ class GetUnityTaskEventsTS(Node): @classmethod def description(cls): - return Description(name='A copy of GetUnityTaskEvents that adds TimeStamps column in DF.', + return Description(name='Copy of GetUnityTaskEvents that joins a corresponding timetsamps column.', description="""Parse marker strings into table of data""", version='0.1', license=Licenses.MIT) @@ -62,9 +64,11 @@ def data(self, pkt): _pointingTo (x,y,z) CameraRecenter: (bool) Camera height and yaw recentered on user """ + # Trial phase indices map to trial phases - phase_map = {1: 'Intertrial', 2: 'Fixate', 3: 'Cue', 4: 'Delay', 5: 'Target', - 6: 'Go', 7: 'Countermand', 8: 'Response', 9: 'Feedback', -1: 'UserInput'} + phase_map = {0: 'Setup', 1: 'Intertrial', 2: 'Fixation', 3: 'Cue', 4: 'Delay_1', 5: 'Target', + 6: 'Delay_2', 7: 'Distractor', 8: 'Delay_3', 9: 'Countermanding', 10: 'Delay_4', 11: 'Response', + 12: 'Feedback', 13: 'Delay_5', 14: 'Misc', 15: 'Selector', 16: 'Null', -1: 'UserInput'} phase_inv_map = {v: k for k, v in phase_map.items()} modifiertype_map = {0: 'None', 1: 'Cued', 2: 'MemoryGuided', 3: 'NoGo', 4: 'Catch'} conditiontype_map = {0: 'None', 1:'AttendShape', 2: 'AttendColour', 3: 'AttendNumber', 4: 'AttendDirection', 5: 'AttendPosition', 6: 'AttendFixation'} @@ -81,31 +85,32 @@ def data(self, pkt): Note that the ObjectInfo events occur before their associated TrialState event, so the most accurate timestamps will come from ObjectInfo, not TrialState. - Trial lifecycle: - - ObjectInfo event when target is placed but still invisible - - TrialState event with trialPhaseIndex 1 to indicate intertrial - - Input event (>=1) to indicate subject is selecting CentralFixation / CentralWall. - - TrialState with trialPhaseIndex = 2 to indicate Fixate phase. - - Last Input event must be CentralFixation to proceed. - - ObjectInfo to show the cue. (_isVisible: True) - - TrialState with trialPhaseIndex=3 to indicate cue phase. - - ObjectInfo shows colour change of cue to indicate Prosaccade/Antisaccade trial. + Trial life cycle (Shortest 15 Events): + 1- ObjectInfo event when target is placed but still invisible + 2- TrialState event with trialPhaseIndex 1 to indicates intertrial + - Input event (>=1) to indicate subject is selecting CentralFixation / CentralWall. + 3- TrialState with trialPhaseIndex = 2 to indicate Fixation phase. + 4- ObjectInfo to show the cue. (_isVisible: True) + 5- TrialState with trialPhaseIndex=3 to indicate cue phase. + 6- ObjectInfo shows colour change of cue to indicate Prosaccade/Antisaccade trial. - - TrialState with trialPhaseIndex=4 for the Delay (memory) period. - - TrialState event with trialPhaseIndex 5 to indicate this is the target phase (map memory to saccade plan) - - ObjectInfo with CentralFixation set to _isVisible False. This is the imperative go cue. - - TrialState with trialPhaseIndex 6 to indicate the Go phase. TODO: Check if the time is same as above. - - (Optional) Input event after fixation disappears because we are now selecting CentralWall behind fixation. - - (if countermanding) ObjectInfo when fixation reappears. Start of countermanding. - - (if countermanding) Input when fixation goes back on to central - - TrialState with trialPhaseIndex 7 to indicate beginning of countermanding phase, whether or not stim given - - ObjectInfo when CentralFixation disappears again - - TrialState with trialPhaseIndex 8 to indicate beginning of Response phase - - Input to indicate hitting target (or non-target, or opposite wall in antisaccade) - - ObjectInfo to clear out CentralFixation - - TrialState with trialPhaseIndex 8 again, but this time the isCorrect has changed. - - TrialState with trialPhaseIndex 9 to indicate feedback phase. - The next ObjectInfo event indicates the start of the next trial + 7- Input event selects fixation point (!! TODO: This should be during the gating phase) + 8- TrialState event with trialPhaseIndex=4 for the Delay (memory) period. + 9- TrialState event with trialPhaseIndex=6 to indicate this is the variable delay phase (map memory to saccade plan) + 10- TrialState event with trialPhaseIndex=15 to select whether the trial is countermanding or not + 11- ObjectInfo events: _isVisible: True for showing _identity: Target (For Cued Trials, this happens during Cue Phase) + 12- ObjectInfo events: _isVisible: False indicates GO imperative + 13- TrialState with trialPhaseIndex=11 to indicate the Go phase. TODO: Check if the time is same as above. + 14- Input event selects the target + 15- TrialState event with trialPhaseIndex=12 to indicate feedback phase (!! IsCorrect changes here !!) + + Countermanding trial life cycle (~17 Events) + 1-12 are the same as the above + 13- TrialState event with trialPhaseIndex=9 to indicate countermand + 14- ObjectInfo event when fixation reappear. Start of countermanding. + 15- TrialState with trialPhaseIndex=11 to indicate Response phase. This is when the subject has to maintain fixation + 16- TrialState event with trialPhaseIndex=12 to indicate feedback phase (!! IsCorrect changes here !!) + 17- ObjectInfo (start of new trial but seems like after countermanding there's an additional ObjectInfo to hide the fixation) """ # Output table will have the following fields @@ -135,7 +140,7 @@ def data(self, pkt): # Identify the trial index for each event, even the ObjectInfo and Input events. ev_types = np.array([list(_.keys())[0] for _ in events]) last_tr_ind = 0 - last_phase = 9 + last_phase = 12 object_bump = False ev_tr = [] for ev_ix, ev in enumerate(events): @@ -143,8 +148,8 @@ def data(self, pkt): last_phase = ev['TrialState']['trialPhaseIndex'] last_tr_ind = ev['TrialState']['trialIndex'] object_bump = False - elif ev_types[ev_ix] == 'ObjectInfo' and last_phase == 9 and not object_bump: - # The first ObjectInfo event after a phase-9 event is the start of a new trial. + elif ev_types[ev_ix] == 'ObjectInfo' and last_phase == 12 and not object_bump: + # The first ObjectInfo event after a phase-12 event is the start of a new trial. last_tr_ind += 1 object_bump = True ev_tr.append(last_tr_ind) @@ -154,11 +159,7 @@ def data(self, pkt): while np.any(np.diff(ev_tr) < 0): switch_ind = np.where(np.diff(ev_tr) < 0)[0] + 1 offset = ev_tr[switch_ind - 1] - try: - ev_tr[switch_ind[0]:] += offset - except ValueError as err: - print('Detect potential multi-file load,', err) - break + ev_tr[switch_ind[0]:] += offset # Start to build the dataframe import pandas as pd @@ -188,17 +189,14 @@ def data(self, pkt): 'ResponseType': responsetype_map[fbstate['response']] if fbstate['modifier'] == 0 else 'CuedOrNoGo', 'CuedPosition': position_map[fbstate['cuePositionIndex']], 'TargetPosition': position_map[fbstate['targetPositionIndex']], - 'TargetObjectIndex': fbstate['targetObjectIndex'], # TODO: Map to object name + 'TargetObjectIndex': fbstate['targetObjectIndex'], # 'TargetColour': color_map[fbstate['targetColorIndex']], - # 'EnvironmentIndex': fbstate['environmentIndex'], # TODO: Map to environment name. + # 'EnvironmentIndex': fbstate['environmentIndex'], 'SelectedPosition': position_map[fbstate['selectedPositionIndex']], - 'SelectedObjectIndex': fbstate['selectedObjectIndex'], # TODO: Map to object name + 'SelectedObjectIndex': fbstate['selectedObjectIndex'], 'IsCorrect': fbstate['isCorrect'], 'CountermandingDelay': np.nan, 'ReactionTime': np.nan - # No need for CueTypeIndex, ResponseType indicates whether trial is Pro or Anti-saccade - # CueTypeIndex. For "TaskSwitch" experiment, tells if trial is Pro or Anti-saccade. - # 'CueTypeIndex': cue_type_map[fbstate['saccadeIndex']] if 'saccadeIndex' in fbstate else -1 } # Additional ResponseTypes are added in analysis (here), for comparing against conditions if details['ResponseType'] == 'CuedOrNoGo': @@ -216,50 +214,51 @@ def data(self, pkt): # Event 1 - Intertrial. ObjectInfo cue placed but hidden. Use phase transition. df_to_extend.append({'Marker': 'Intertrial'}) - iti_ix = np.where(tr_phases == phase_inv_map['Intertrial'])[0][0] + try: + iti_ix = np.where(tr_phases == phase_inv_map['Intertrial'])[0][0] + except IndexError: + print('Invalid Trial, skipped intertrial') + continue out_times.append(tr_times[iti_ix]) # Event 2 - Fixation achieved. Use phase transition. - if phase_inv_map['Fixate'] in tr_phases: - df_to_extend.append({'Marker': 'Fixate'}) - fix_start_ix = np.where(tr_phases == phase_inv_map['Fixate'])[0][0] + if phase_inv_map['Fixation'] in tr_phases: + df_to_extend.append({'Marker': 'Fixation'}) + fix_start_ix = np.where(tr_phases == phase_inv_map['Fixation'])[0][0] out_times.append(tr_times[fix_start_ix]) - # Event 3 - Cue presentation. Transition to phase 3 and Object appears (maybe reversed order) + # Event 3 - Cue presentation. Transition to phaseIndex 3 and Object appears (maybe reversed order) if phase_inv_map['Cue'] in tr_phases: df_to_extend.append({'Marker': 'Cue'}) cue_ix = np.where(tr_phases == phase_inv_map['Cue'])[0][0] - # TODO: Current experiment does not have a ObjectInfo event near time of cue. - # obj_ix = np.where(tr_obj_is_vis)[0][np.argmin(np.abs(tr_times[tr_obj_is_vis] - tr_times[cue_ix]))] - # details['CuedObject'] = tr_obj_id[obj_ix] - out_times.append(tr_times[cue_ix]) # TODO: use obj_ix in new experiment. + out_times.append(tr_times[cue_ix]) - # Event 4 - Delay period. ObjectInfo cue disappears; transition to phase 4. - if phase_inv_map['Delay'] in tr_phases: - df_to_extend.append({'Marker': 'Delay'}) + # Event 4 - Delay period. ObjectInfo cue disappears; transition to phaseIndex 4. + if phase_inv_map['Delay_1'] in tr_phases: + df_to_extend.append({'Marker': 'Delay_1'}) pre_ix = np.where(tr_phases == phase_inv_map['Cue'])[0][0] - ph_ix = np.where(tr_phases == phase_inv_map['Delay'])[0][0] + ph_ix = np.where(tr_phases == phase_inv_map['Delay_1'])[0][0] del_ix = pre_ix + np.where(tr_is_obj[pre_ix:ph_ix])[0][0] out_times.append(tr_times[del_ix]) - # Event 5 - Target presentation. ObjectInfo targets appear; transition to phase 5. - if phase_inv_map['Target'] in tr_phases: - df_to_extend.append({'Marker': 'Target'}) - targ_ix = np.where(np.logical_and(tr_obj_id == 'Target', tr_obj_is_vis))[0] + # Event 5 - Target presentation. ObjectInfo targets appear; transition to phaseIndex 6. + if phase_inv_map['Delay_2'] in tr_phases: + df_to_extend.append({'Marker': 'Delay_2'}) + targ_ix = np.where(np.logical_and(tr_obj_id == 'Delay_2', tr_obj_is_vis))[0] if len(targ_ix) > 0: targ_ix = targ_ix[-1] else: - targ_ix = np.where(tr_phases == phase_inv_map['Target'])[0][0] + targ_ix = np.where(tr_phases == phase_inv_map['Delay_2'])[0][0] out_times.append(tr_times[targ_ix]) - # Event 6 - Imperative cue. Fixation pt disappears. Transition to Phase 6. - if phase_inv_map['Go'] in tr_phases: + # Event 6 - Imperative cue. Fixation pt disappears. Transition to phaseIndex 15. + if phase_inv_map['Selector'] in tr_phases: df_to_extend.append({'Marker': 'Go'}) go_ix = np.where(np.logical_and(tr_obj_id == 'CentralFixation', ~tr_obj_is_vis))[0] if len(go_ix) > 0: go_ix = go_ix[0] else: - go_ix = np.where(tr_phases == phase_inv_map['Go'])[0][0] + go_ix = np.where(tr_phases == phase_inv_map['Selector'])[0][0] go_time = tr_times[go_ix] out_times.append(go_time) else: @@ -267,7 +266,7 @@ def data(self, pkt): # Event 7 (optional) - Countermanding cue. # Get countermanding delay - if details['ResponseType'] != 'Prosaccade' and phase_inv_map['Countermand'] in tr_phases: + if details['ResponseType'] != 'Prosaccade' and phase_inv_map['Countermanding'] in tr_phases: df_to_extend.append({'Marker': 'Countermand'}) # Find last fixation-visible event before response period. @@ -277,7 +276,7 @@ def data(self, pkt): if len(cm_ix) > 0: cm_ix = cm_ix[-1] else: - cm_ix = np.where(tr_phases == phase_inv_map['Countermand'])[0][0] + cm_ix = np.where(tr_phases == phase_inv_map['Countermanding'])[0][0] details['CountermandingDelay'] = tr_times[cm_ix] - go_time out_times.append(tr_times[cm_ix]) From 2e108070e98f8ba426e6a34cf3f612b855dcd948 Mon Sep 17 00:00:00 2001 From: Michael Leung Date: Tue, 3 Aug 2021 19:20:28 -0400 Subject: [PATCH 10/10] minor delete comment line at top --- GetUnityTaskEvents.py | 1 - GetUnityTaskEventsTS.py | 1 - 2 files changed, 2 deletions(-) diff --git a/GetUnityTaskEvents.py b/GetUnityTaskEvents.py index 77b2bc0..94d767d 100644 --- a/GetUnityTaskEvents.py +++ b/GetUnityTaskEvents.py @@ -1,4 +1,3 @@ -# NOTE: .txt because from upload restrictions. Change to .py import logging import numpy as np import copy diff --git a/GetUnityTaskEventsTS.py b/GetUnityTaskEventsTS.py index fbde1e5..cbc29f3 100644 --- a/GetUnityTaskEventsTS.py +++ b/GetUnityTaskEventsTS.py @@ -1,4 +1,3 @@ -# NOTE: .txt because from upload restrictions. Change to .py import logging import numpy as np import copy