ephystoolkit.EphysToolkit

   1# class imports
   2import re
   3import math
   4import json
   5import warnings
   6import os.path
   7import csv
   8import mat73
   9
  10import numpy as np
  11import scipy.io
  12import pandas as pd
  13import rhd
  14from scipy import stats
  15from scipy import signal
  16
  17from pathexplorer.PathExplorer import path_explorer
  18
  19
  20class ephys_toolkit:
  21
  22    def __init__(self):
  23        
  24        self._SAMPLING_RATE = 20000
  25        self._modpath = os.path.dirname(__file__)
  26
  27    def _bin_events(self, bin_size, events):
  28        self.frames = bin_size ** -1
  29        self.numerator = self._SAMPLING_RATE / self.frames
  30
  31        return events / self.numerator
  32
  33    def _minmax_norm(self, array):
  34        """
  35        Normalize an array with minmax normalization.
  36        """
  37        return (array - array.min()) / (array.max() - array.min())
  38
  39    def _zscore_norm(self, array):
  40        """
  41        Normalize an array with z score normalization.
  42        """
  43        return stats.zscore(array)
  44
  45    def _average_response(self, array, stim_reps):
  46        """
  47        Return the average frequency of a unit's response
  48        across stimulus repeats.
  49        """
  50        return (array / stim_reps) * self.frames
  51    
  52    def apply_temporal_smoothing(self, x, k, t_axis):
  53        """
  54        Apply temporal smoothing to an array, matrix, or tensor 
  55        with a convolution kernel. 
  56        
  57        Args:
  58        - x: Input data.
  59        - k: Convolution kernel.
  60        - t_axis: The dimension in the input data corresponding to time (0 if one dimensional).
  61        
  62        """
  63        return np.apply_along_axis(
  64                        lambda m: np.convolve(
  65                            m, k, 
  66                            mode='same'
  67                        ), axis=t_axis, arr=x
  68                    )
  69    
  70    def static_grating(
  71            self,
  72            windowSizeX_Pixel, windowSizeY_Pixel, # size of the stimulus window in pixels
  73            windowSizeX_Visual, windowSizeY_Visual, # size of the stimulus window in degrees
  74            spatialFrequency, # spatial frequency in cpd
  75            ang, # orientation angle of the grating in degrees
  76            phi, # phase of the grating in degrees
  77            diameter = None # dimater of the circular patch in degrees
  78    ):    
  79        """
  80        Generate a matrix of pixel intensities   
  81        representing a static grating stimulus
  82        with the given parameters.
  83        
  84        Args:
  85        
  86        - windowSizeX_Pixel: Horizontal size of the stimulus screen in pixels.
  87        - windowSizeY_Pixel: Vertical size of the stimulus screen in pixels.
  88        - windowSizeX_Visual: Horizontal size of the stimulus screen in degrees.
  89        - windowSizeY_Visual: Vertical size of the stimulus screen in degrees.
  90        - spatialFrequency: Spatial frequency in cycles per degre (cpd).
  91        - ang: Orientation angle of the grating in degrees.
  92        - phi: Phase of the grating in degrees
  93        - diameter: Dimater of the circular patch in degrees. 
  94        """
  95        
  96        if ((windowSizeX_Pixel <=10)
  97            or (windowSizeX_Pixel%2 != 0)
  98        ):
  99            print('windowSizeX_Pixel must be greater than ten and even')
 100
 101        if ((windowSizeY_Pixel <=10)
 102            or (windowSizeY_Pixel%2 != 0)
 103        ):
 104            print('windowSizeY_Pixel must be greater than ten and even')
 105
 106        if (math.floor(windowSizeX_Pixel/windowSizeY_Pixel*1000) 
 107            != math.floor(windowSizeX_Visual/windowSizeY_Visual*1000)
 108        ):
 109            print('The ratio of X and Y for Pixel and Visual are different!');
 110
 111        if spatialFrequency < 0:
 112            error('spatialFrequency less than zero')
 113
 114        # x, y 
 115        x_center = windowSizeX_Pixel/2
 116        y_center = windowSizeY_Pixel/2
 117
 118        x_range = np.arange(-x_center, x_center,1)
 119        y_range = np.arange(-y_center, y_center,1)
 120        x, y = np.meshgrid(x_range,y_range)
 121
 122        # theta
 123        theta = -np.radians(ang);
 124        xyTheta = y * np.cos(theta) - x * np.sin(theta);
 125        
 126        # phi
 127        phi = np.radians(phi)
 128
 129        # Spatial Frenquency
 130        degreePerPixel = windowSizeX_Visual / windowSizeX_Pixel
 131        sf = spatialFrequency  * degreePerPixel # cycles / pixel
 132        sw = 2 * np.pi * sf # radian / pixel
 133
 134        # contrast
 135        pixelIntensity = (np.sin(sw * xyTheta - phi)) # range: -1 to 1
 136
 137        # round image
 138        if diameter == None:
 139            pass
 140        else:
 141            diameter = diameter / degreePerPixel
 142            r = diameter/2;
 143            c_mask = ( (x**2+y**2) <= r**2 )
 144            pixelIntensity = pixelIntensity*c_mask 
 145
 146        return pixelIntensity
 147
 148    def drifting_grating(
 149            self,
 150            windowSizeX_Pixel, windowSizeY_Pixel, # size of the stimulus window in pixels
 151            windowSizeX_Visual, windowSizeY_Visual, # size of the stimulus window in degrees
 152            tf,  # Temporal frequency - pass as an integer or floating point value
 153            dt,  # Time step value - pass as a floating point value
 154            t,  # Total duration of the stimulus - pass as an int or float of the appropriate time unit
 155            spatialFrequency, # spatial frequency in cpd
 156            ang, # orientation angle of the grating in degrees
 157            diameter = None # dimater of the circular patch in degrees
 158    ):
 159        """
 160        Returns a list of matricies representing
 161        frames of a drifitng grating stimulus.
 162        
 163        Args:
 164        - windowSizeX_Pixel: Horizontal size of the stimulus screen in pixels.
 165        - windowSizeY_Pixel: Vertical size of the stimulus screen in pixels.
 166        - windowSizeX_Visual: Horizontal size of the stimulus screen in degrees.
 167        - windowSizeY_Visual: Vertical size of the stimulus screen in degrees.    
 168        - tf: Temporal frequency - pass as an integer or floating point value.
 169        - dt: Time step value - pass as a floating point value.
 170        - t: Total duration of the stimulus - pass as an int or float of the appropriate time unit.
 171        - spatialFrequency: Spatial frequency in cycles per degre (cpd).
 172        - ang: Orientation angle of the grating in degrees.
 173        - diameter: Dimater of the circular patch in degrees. 
 174        
 175        """
 176
 177        tensor = []
 178        params = []
 179        deg_step = dt * 360 * tf
 180
 181        d = np.arange(dt, t, dt)
 182
 183        phi = 0
 184        for x in d:
 185            m = self.static_grating(
 186                windowSizeX_Pixel, windowSizeY_Pixel, 
 187                windowSizeX_Visual, windowSizeY_Visual, 
 188                spatialFrequency,
 189                ang,
 190                phi,
 191                diameter = diameter
 192            ) 
 193            tensor.append(m)
 194            params.append([sf, tf, ori, phase])
 195            phi += deg_step
 196
 197        return tensor, params
 198
 199    def _discrete_radius(self, dim=tuple, radius=int):
 200        x = np.linspace(-1, 1, dim[1])
 201        y = np.linspace(-1, 1, dim[0])
 202        
 203
 204        m = []
 205        for i0 in range(len(x)):
 206            row = []
 207            for i1 in range(len(y)):
 208                if ((x[i0] ** 2 + y[i1] ** 2)
 209                        < ((radius / 360) ** 2)):
 210                    row.append(1)
 211                else:
 212                    row.append(0)
 213            m.append(row)
 214
 215        return np.array(m).T
 216
 217    def _gaussian_radius(self, dim:tuple, radius:int):
 218        x, y = np.meshgrid(
 219            np.linspace(-1, 1, dim[0]),
 220            np.linspace(-1, 1, dim[1])
 221        )
 222        
 223        
 224        d = np.sqrt(x * x + y * y)
 225        sigma, mu = radius / 360, 0.0
 226        g = np.exp(-((d - mu) ** 2 / (2.0 * sigma ** 2)))
 227
 228        return g
 229
 230    def spike_sorting_metrics(self, file_path):
 231        """
 232        Returns a dataframe with the spike sorting metrics
 233        of a given recording section.
 234        
 235        Args:
 236        
 237        - file_path: Path to the spike sorting metrics file.
 238        """
 239
 240        with open(file_path, 'r') as sorting_file:
 241            sorting_info = json.load(sorting_file)
 242
 243        spike_sorting_data = [
 244            # [cluster['label'] for cluster in sorting_info['clusters']],
 245            [cluster['metrics']['isolation'] for cluster in sorting_info['clusters']],
 246            [cluster['metrics']['noise_overlap'] for cluster in sorting_info['clusters']]
 247        ]
 248
 249        ss_df = pd.DataFrame(np.array(spike_sorting_data).T)
 250        ss_df.columns = [
 251            # 'cluster', 
 252            'isolation', 'noise_overlap'
 253        ]
 254
 255        return ss_df
 256
 257    # functions to make concatenated across trial and non concatenated across trial rasters
 258    def _concatenated_raster(self, stims, spikes, thresh=tuple):
 259        if thresh == tuple:
 260            r = np.array([spikes - st for st in stims])
 261            raster = np.concatenate(r)
 262        else:
 263            r = np.array([spikes - st for st in stims])
 264            ti = np.where(np.logical_and(r <= thresh[1], r >= thresh[0]))
 265            raster = r[ti]
 266        return raster
 267
 268    def _unconcatenated_raster(self, stims, spikes, thresh=tuple):
 269        if thresh == tuple:
 270            rasters = np.array([spikes - st for st in stims])
 271        else:
 272            rasters = []
 273            for i, st in enumerate(stims):  # enumerate to make an initial array then vstack
 274                unthreshed = spikes - st
 275                i = np.where(np.logical_and(unthreshed <= thresh[1], unthreshed >= thresh[0]))
 276                rasters.append(list(unthreshed[i]))
 277        return rasters
 278
 279    def _linear_extrapolation(self, xd, yd, x):
 280        y2, y1 = yd[-1], yd[-2]
 281        x2, x1 = xd[-1], xd[-2]
 282
 283        return y1+(((x-x1)/(x2-x1))*(y2-y1))
 284
 285    def raster(
 286            self,
 287            stims,  # Array of stimulus onset times
 288            spikes,  # Array of spike onset times
 289            thresh=tuple,  # Bounding threshold around the stimulus onset at t = 0 - pass as a tuple
 290            concatenate=True  # Concatenate rasters across trials - pass False to return unconcatenated rasters
 291    ):
 292        """
 293        Returns an array representing a raster of spike times centered 
 294        around the onset times of a given stimulus.
 295        
 296        Args:
 297        
 298        - stims: Array of stimulus onset times.
 299        - spikes: Array of spike onset times.
 300        - thresh: Bounding threshold around the stimulus onset at t = 0 - pass as a tuple.
 301        - concatenate: Concatenate rasters across trials; pass False to return unconcatenated rasters.
 302        
 303        """
 304
 305        if concatenate:
 306            return self._concatenated_raster(stims, spikes, thresh)
 307        else:
 308            return self._unconcatenated_raster(stims, spikes, thresh)
 309        
 310
 311
 312class load_experiment(ephys_toolkit):
 313    """
 314    Create an experiment object for a given recording block.
 315    Takes the spike data file path as the first argument and the
 316    stimulus data file path as the second argument. Initializing
 317    an experiment object generates some important class attributes:
 318    
 319    .stim_data: A pandas dataframe with the stimulus data.
 320    
 321    .spike_data: A dictionary object with the spiking data
 322                 of all the identified clusters.
 323                 
 324    Args:
 325    
 326    - spikefile: Path to the spike data file.
 327    - stimfile: Path to the stimulus data file.
 328    """
 329
 330    def __init__(self, spikefile, stimfile, logfile, **kwargs):
 331        ephys_toolkit.__init__(self)
 332        
 333        self._stim_m73 = None
 334        self._spike_m73 = None
 335        self._spikefile = spikefile # path to the spike file
 336        self._stimfile = stimfile # path to the stim file
 337        self._depth_data = kwargs['kwargs']['depth_data'] 
 338        
 339        try:
 340            self.spikes_mat = scipy.io.loadmat(spikefile)
 341            self.spikes = self.spikes_mat['Data'][0] # raw spikes dictionary
 342        except NotImplementedError:
 343            self._spike_m73 = True
 344            self.spikes_mat = mat73.loadmat(spikefile)
 345            self.spikes = self.spikes_mat['Data']
 346            
 347        try:
 348            self.stims_mat = scipy.io.loadmat(stimfile)
 349            self.stims = self.stims_mat['StimulusData'][0][0] # raw stims dictionary
 350        except NotImplementedError:
 351            self._stim_m73 = True
 352            self.stims_mat = mat73.loadmat(stimfile)
 353            self.stims = self.stims_mat['StimulusData']
 354            
 355        self._logfile = logfile # stimulus log data
 356        self._nodf = False
 357        self._init_stim_data()
 358        self._init_spike_data()
 359        self.set_time_unit()
 360
 361        self._parameters_matched = False
 362        
 363        if self._nodf:
 364            self.stim_conditions = np.unique(
 365                self.stim_data['stim_condition_ids']
 366            )
 367        else:
 368            self.stim_conditions = self.stim_data[
 369                'stim_condition_ids'
 370            ].unique()
 371        
 372        if self._logfile != None:
 373            self._get_conditions_from_log()
 374        else:
 375            r = r'([A-Z]{1,2}_M\d{1,}_Section_\d{1,}_BLK\d{1,})'
 376            experiment_id = re.search(r, spikefile).group(1)
 377            warn_text = f"""
 378            No stimulus log file found for experiment: {experiment_id}.
 379            If this experiment has more that 255 stimulus conditions, the stimulus 
 380            condition IDs will be incorrect in the self.stim_data attribute.
 381            """
 382            warnings.warn(warn_text, stacklevel = 4)
 383
 384    # Generates the .stim_data attribute.
 385    def _init_stim_data(self):
 386        
 387        if self._stim_m73:
 388            stim_data = {
 389                'stim_start_indicies': self.stims['stimulusOnsets'],
 390                'stim_stop_indicies': self.stims['stimulusOffsets'],
 391                'stim_condition_ids': self.stims['conditionNumbers']
 392            }
 393        else:
 394            stims_starts = self.stims[0]
 395            stims_stops = self.stims[1]
 396            stims_condition_ids = self.stims[2]
 397            stim_data = {
 398                'stim_start_indicies': stims_starts[0],
 399                'stim_stop_indicies': stims_stops[0],
 400                'stim_condition_ids': stims_condition_ids[0]
 401            }
 402        
 403        try:
 404            self.stim_data = pd.DataFrame(stim_data)
 405        except ValueError as e:
 406            if str(e) == "All arrays must be of the same length":
 407                warn_text = f"""
 408                Stimulus start time, stop time, and condition 
 409                arrays are unequal in length. This typically
 410                only happens with natural image recordings.
 411                If you include a stimulus log file in the data
 412                folder, ephystoolkit will attempt to fix this.
 413                Otherwise, the .stim_data attribute will return
 414                a dictionary instead of a dataframe.
 415                """
 416                warnings.warn(warn_text, stacklevel = 4)
 417                self.stim_data = stim_data
 418                self._nodf = True
 419
 420    # Generates the .spike_data attribute.
 421    def _init_spike_data(self):
 422        if self._spike_m73:
 423            ci = [int(i) for i in self.spikes['ChannelID']]
 424            si = self.spikes['SpikePoints']
 425            st = self.spikes['SpikeTimes']
 426            self.spike_data = [
 427                {
 428                    'channel_id': ci[i],
 429                    'spike_index': si[i],
 430                    'spike_time': st[i]
 431                }
 432                for i in range(len(st))
 433            ]
 434        else:
 435            
 436            self.spike_data = [
 437                {
 438                    'channel_id': unit[1][0][0],
 439                    'depth': self._depth_data.loc[self._depth_data.channel == unit[1][0][0]]['distance'].values[0],
 440                    'spike_index': unit[2][0],
 441                    'spike_time': unit[3][0]
 442                }
 443                for unit in
 444                self.spikes
 445            ]
 446        """
 447        A dictionary object with the spiking data.
 448        """
 449        
 450    def _get_conditions_from_log(self):
 451        
 452        with open(self._logfile, 'r') as f:
 453            stimlog = f.readlines()
 454
 455        stimlog = stimlog[1:-2]
 456        
 457        # extract conditions for log
 458        r0 = r'[Cc]ondition#: *(\d{1,})'
 459        r1 = r'[Cc]onditions:(\d{1,} +...*)'
 460        condition_ids = []
 461        
 462        if re.search(r0, stimlog[1]): # if the experiment is gratings or checkerboard
 463            for line in stimlog:
 464                c_id = re.search(r0, line).group(1)
 465                condition_ids.append(int(c_id))
 466                
 467            try: 
 468                # replace conditions in experiment with conditions from log
 469                self.stim_data.stim_condition_ids = condition_ids
 470                self.stim_conditions = self.stim_data.stim_condition_ids.unique()
 471
 472            except ValueError:
 473                pass
 474            
 475        elif re.search(r1, stimlog[1]): # if the experiment is natural images
 476            condition_ids = []
 477            for x in stimlog:
 478                cindex = x.index(':')+1 # index where condition numbers begin
 479                numbers = x[cindex:].strip() # remove whitespace at the end
 480                numbers = numbers.replace('  ', ' ') # remove double spaces
 481                numbers = numbers.split(' ') # split into a list of conditon numbers
 482                numbers = [int(x) for x in numbers] # format str into int
 483                condition_ids += numbers #collect condition ids
 484            
 485            # Attempt to fix unequal stimulus arrays/missing values
 486            if self._nodf:
 487                stim_start_indicies = self.stim_data['stim_start_indicies']
 488                stim_stop_indicies = self.stim_data['stim_stop_indicies']
 489                stim_start_times = self.stim_data['stim_start_times']
 490                stim_stop_times = self.stim_data['stim_stop_times']
 491            else:      
 492                stim_start_indicies = self.stim_data['stim_start_indicies'].values
 493                stim_stop_indicies = self.stim_data['stim_stop_indicies'].values
 494                stim_start_times = self.stim_data['stim_start_times'].values
 495                stim_stop_times = self.stim_data['stim_stop_times'].values
 496
 497            stim_data_fixed = {
 498                'stim_condition_ids': condition_ids,
 499                'stim_start_indicies': stim_start_indicies,
 500                'stim_stop_indicies': stim_stop_indicies,
 501                'stim_start_times': stim_start_times,
 502                'stim_stop_times': stim_stop_times
 503            }
 504            keys = list(stim_data_fixed.keys())
 505
 506            # extrapolate the missing values
 507            for key in keys:
 508                xd = np.arange(len(stim_data_fixed[key]))+1
 509                yd = stim_data_fixed[key]
 510                x = np.arange(len(yd), len(condition_ids))+1
 511                y_pred = self._linear_extrapolation(xd, yd, x)
 512                if 'indicies' in key:
 513                    y_pred = np.round(y_pred)
 514                stim_data_fixed[key] = np.concatenate((yd, y_pred))
 515            
 516            self.stim_data = pd.DataFrame(stim_data_fixed)
 517            self.stim_conditions = self.stim_data.stim_condition_ids.unique()
 518            
 519    def set_time_unit(self, bin_size=0.001):
 520        """
 521        Change the time unit of the relative spike times.
 522        Give a bin size relative to 1 second.
 523        IE: If you want a 1 ms bin size, enter 0.001;
 524        if you want a 10 ms bin size, enter 0.01 etc.
 525        
 526        Args:
 527        
 528        - bin_size: Time unit given relative to 1 second. The default unit is 1ms/0.001s.
 529        """
 530        if self._nodf:
 531            self.stim_data['stim_start_times'] = self._bin_events(bin_size,
 532                self.stim_data['stim_start_indicies'])
 533
 534            self.stim_data['stim_stop_times'] = self._bin_events(bin_size,
 535                self.stim_data['stim_stop_indicies'])  
 536        else:
 537            self.stim_data['stim_start_times'] = self._bin_events(bin_size,
 538                self.stim_data['stim_start_indicies'].values)
 539
 540            self.stim_data['stim_stop_times'] = self._bin_events(bin_size,
 541                self.stim_data['stim_stop_indicies'].values)
 542
 543            self.stim_data = self.stim_data[
 544                ['stim_condition_ids',
 545                 'stim_start_indicies',
 546                 'stim_stop_indicies',
 547                 'stim_start_times',
 548                 'stim_stop_times']
 549            ]
 550
 551        for i, unit in enumerate(self.spike_data):
 552            try:
 553                unit.update({
 554                    'rel_spike_time': self._bin_events(bin_size,
 555                                                       unit['spike_index'])
 556                })
 557            except TypeError as e:
 558                if 'NoneType' in str(e):
 559                    print(f"No spikes recorded for unit {i}, skipping...")
 560                else:
 561                    raise TypeError
 562
 563    def condition_times(self, condition):
 564        """
 565        Returns a dictionary object of the start times and
 566        stop times of a particular stimulus condition.
 567        
 568        Args:
 569        
 570        -condition: Condition id for the chosen stimulus condition.
 571        """
 572
 573        condition_starts = self.stim_data.groupby(
 574            self.stim_data.stim_condition_ids
 575        ).get_group(condition)['stim_start_times'].values
 576
 577        condition_stops = self.stim_data.groupby(
 578            self.stim_data.stim_condition_ids
 579        ).get_group(condition)['stim_stop_times'].values
 580
 581        return {
 582            'start': condition_starts,
 583            'stop': condition_stops
 584        }
 585
 586    def match_condition_parameters(self, params_file):
 587        """
 588        Takes a parameters file and alters the .stim_data dataframe
 589        to match stimulus condition parameters to their corresponding 
 590        condition id.
 591        
 592        Args:
 593        
 594        - params_file: Path to the stimulus parameters file.
 595        """
 596        
 597        # if parameters are given in a log file
 598        if os.path.splitext(params_file)[1] == '.log':
 599            df = self.stim_data
 600            with open(params_file, 'r') as f:
 601                lines = f.readlines()[1:-2]
 602            
 603            # read the lines to make a series of columns to insert
 604            insert = []
 605            for line in lines:
 606                items = re.split(r'\t+', line)[2:-1]
 607                dic = {
 608                    item.split(':')[0]:float(item.split(':')[1].strip(' ')) 
 609                    for item in items
 610                }
 611                insert.append(dic)
 612            
 613            # add the parameters to the stim_data dataframe
 614            insert = pd.DataFrame(insert)
 615            newcol = [list(df.columns)[0]] + list(insert.columns) + list(df.columns)[1:]
 616            self.stim_data = df.join(insert)[newcol]
 617            
 618            # make the parameter map
 619            stop_index = list(self.stim_data.columns).index('stim_start_indicies')
 620            df_new = self.stim_data[list(self.stim_data.columns)[:stop_index]]
 621            df_new.columns = ['condition']+list(df_new.columns)[1:]
 622            df_new = df_new.drop_duplicates().sort_values(by = 'condition')
 623            self.parameter_map = df_new.reset_index().drop('index', axis = 1)
 624            
 625            self._parameters_matched = True
 626            
 627        # if parameters are given in a matlab file        
 628        elif os.path.splitext(params_file)[1] == '.mat':
 629            
 630            # load the parameter file and extract the keys
 631            params = scipy.io.loadmat(params_file)
 632            param_keys = list(params.keys())
 633
 634            # regex to get the parameter names + values
 635            name_identifier = r'Var\d{1,}Val'
 636            val_identifier = r'var\d{1,}value'
 637
 638            if 'fnames' in param_keys: # if experiment is natural images
 639
 640                # get the stim file names
 641                stim_file_names = [
 642                    name[0][0] for 
 643                    name in scipy.io.loadmat(params_file)['fnames']
 644                ]
 645
 646                # make a dictionary for the parameter mapping
 647                ni_filemap = {
 648                    'condition': [],
 649                    'size': [],
 650                    'filter': [],
 651                    'file': []
 652                }
 653
 654                # match condition to parameters
 655                freg = r'^[A-Z]{2,3}'
 656                for i in self.stim_conditions:
 657                    if i%2 == 0: # if condition is even
 658
 659                        #append condition & size
 660                        ni_filemap['condition'].append(i)
 661                        ni_filemap['size'].append(60)
 662
 663                        #append filename
 664                        filename = stim_file_names[int(i/2)-1]
 665                        ni_filemap['file'].append(filename) 
 666
 667                        #append filtering condition
 668                        filt = re.search(freg, filename).group(0)
 669                        ni_filemap['filter'].append(filt)
 670
 671                    else: # if condition is odd
 672                        #append condition & size
 673                        ni_filemap['condition'].append(i)
 674                        ni_filemap['size'].append(30)
 675
 676                        #append filename
 677                        filename = stim_file_names[int((i+1)/2)-1]
 678                        ni_filemap['file'].append(filename)
 679
 680                        #append filtering condition
 681                        filt = re.search(freg, filename).group(0)
 682                        ni_filemap['filter'].append(filt)
 683
 684                self.parameter_map = pd.DataFrame(ni_filemap) 
 685                df = self.stim_data
 686
 687                # get the parameters to insert into the original dataframe
 688                insert = []
 689                for cond in df.stim_condition_ids.values:
 690                    arr = self.parameter_map.loc[self.parameter_map.condition == cond].values[0]
 691                    insert.append(arr)
 692                insert = pd.DataFrame(insert, columns=self.parameter_map.columns.values)
 693
 694                # insert the parameter values into the original dataframe
 695                df1 = df.join(insert)
 696
 697                # reset the stim_data attribute
 698                self.stim_data = df1[
 699                    list([df.columns.values[0]])
 700                    + list(self.parameter_map.columns.values[1:])
 701                    + list(
 702                        df.columns.values[1:]
 703                    )
 704                    ]
 705
 706            else: # if experiment is gratings or checkerboard
 707                # use regex to get the param names and values
 708                name_keys = [key for key in param_keys if re.search(name_identifier, key)]
 709                val_keys = [key for key in param_keys if re.search(val_identifier, key)]
 710
 711                # get the condition numbers
 712                condition_range = len(params[val_keys[0]][0])
 713                condition_ids = [i + 1 for i in range(condition_range)]
 714
 715                # map conditions to parameter values
 716                parameter_map = {'condition': condition_ids}
 717                for i in range(len(name_keys)):
 718                    parameter_name = str(params[name_keys[i]][0])
 719                    parameter_values = np.array(params[val_keys[i]][0])
 720
 721                    parameter_map[parameter_name] = parameter_values
 722
 723                    # parameter dataframe + the original stim_data dataframe
 724                self.parameter_map = pd.DataFrame(parameter_map)
 725                df = self.stim_data
 726
 727                # get the parameters to insert into the original dataframe
 728                insert = []
 729                for cond in df.stim_condition_ids.values:
 730                    arr = self.parameter_map.loc[self.parameter_map.condition == cond].values[0]
 731                    insert.append(arr)
 732                insert = pd.DataFrame(insert, columns=self.parameter_map.columns.values)
 733
 734                # insert the parameter values into the original dataframe
 735                df1 = df.join(insert)
 736
 737                # reset the stim_data attribute
 738                self.stim_data = df1[
 739                    list([df.columns.values[0]])
 740                    + list(self.parameter_map.columns.values[1:])
 741                    + list(
 742                        df.columns.values[1:]
 743                    )
 744                    ]
 745
 746            self._parameters_matched = True
 747
 748    def _pr_unitcols(
 749            self,
 750            include_units,
 751            stim_condition=None, 
 752            columns='cluster_id',
 753            thresh=None,
 754            norm=None 
 755    ):
 756
 757        if thresh is None:
 758            return "Please provide a response boundary threshhold as a tuple (ex: (-50,500))"
 759        else:
 760            thresh_min = thresh[0] - 0.5
 761            thresh_max = thresh[1] + 0.5
 762
 763        if stim_condition is 'all':
 764            stim_condition = self.stim_conditions
 765
 766        population = {}
 767        cond_col = []
 768        param_col = []
 769
 770        for condition in stim_condition:
 771            condition_start_times = self.condition_times(condition)['start']
 772
 773            for i, cluster_id in enumerate(include_units):
 774                if type(cluster_id) == np.ndarray:
 775                    unit_spike_times = cluster_id
 776                    cluster_id = f"n{i}"
 777                    
 778                else:
 779                    unit_spike_times = self.spike_data[cluster_id]['rel_spike_time']
 780
 781                # Raster
 782                x = self.raster(
 783                    condition_start_times, 
 784                    unit_spike_times,
 785                    thresh=thresh)
 786
 787                # Raster to histogram 
 788                h, bins = np.histogram(
 789                    x, 
 790                    bins=np.arange(thresh_min,thresh_max,1)
 791                )
 792
 793                # Nonresponsive unit histogram
 794                if h.min() == 0 and h.max() == 0:
 795                    h = np.zeros(len(h))
 796
 797                # Normalize the response
 798                else:
 799                    pass
 800                    norm_method = {
 801                        'minmax': self._minmax_norm(h),
 802                        'zscore': self._zscore_norm(h),
 803                        'average': self._average_response(h, len(condition_start_times))
 804                    }
 805
 806                    if norm is None: continue
 807
 808                    elif (norm is not None
 809                          and norm in list(norm_method.keys())):
 810                        h = norm_method[norm]
 811                    else:
 812                        raise _UnrecognizedNorm(
 813                            f"Normalization method is not recognized,\
 814                            please choose from the following:\
 815                            {list(norm_method.keys())}"
 816                        )
 817
 818                # fill the population dictionary
 819                if cluster_id not in population:
 820                    population[cluster_id] = h
 821                else:
 822                    population[cluster_id] = np.concatenate(
 823                        (population[cluster_id], h)
 824                    )
 825
 826            # append condition ids to the conditions column
 827            cond_col += [int(condition)]*len(h)
 828
 829            # append condition parameters
 830            if self._parameters_matched:
 831                params = list(
 832                    self.parameter_map.loc[self.parameter_map.condition == condition].values[0][1:]
 833                )
 834                param_col += [params] * len(h)
 835
 836        # Rearrange the dataframe
 837        if self._parameters_matched:
 838
 839            # Make the population & stimulus parameters dataframes
 840            population = pd.DataFrame(population)
 841            param_col = pd.DataFrame(
 842                param_col, columns=self.parameter_map.columns.values[1:]
 843            )
 844
 845            # Retrieve their column labels
 846            pcol = list(population.columns.values)
 847            pacol = list(param_col.columns.values)
 848
 849            # Add the condition id column & the stimulus parameters columns
 850            population['stimulus_condition'] = cond_col
 851            population = population.join(param_col)
 852
 853            # Rearrange column order
 854            new_columns = ['stimulus_condition'] + pacol + pcol
 855            population = population[new_columns]
 856
 857        else:
 858            # Make the population dataframe
 859            population = pd.DataFrame(population)
 860
 861            # Retrieve its column labels
 862            pcol = list(population.columns.values)
 863
 864            # Add the condition id column
 865            population['stimulus_condition'] = cond_col
 866
 867            # Rearrange column order
 868            population = population[['stimulus_condition']+pcol]
 869
 870        return population       
 871
 872    def _pr_stimcols(
 873            self,
 874            include_units, 
 875            stim_condition=None, 
 876            columns='cluster_id',
 877            thresh=None, 
 878            norm=None
 879    ):
 880
 881        if thresh is None:
 882            return "Please provide a response boundary threshhold as a tuple (ex: (-50,500))"
 883        else:
 884            thresh_min = thresh[0] - 0.5
 885            thresh_max = thresh[1] + 0.5
 886
 887        if stim_condition is 'all':
 888            stim_condition = np.sort(self.stim_conditions)
 889
 890        population = {}
 891        unit_col = []
 892
 893        for cluster_id in include_units:
 894            unit_spike_times = self.spike_data[cluster_id]['rel_spike_time']
 895
 896            for condition in stim_condition:
 897                condition_start_times = self.condition_times(condition)['start']
 898
 899                # Raster
 900                x = self.raster(
 901                    condition_start_times, 
 902                    unit_spike_times,
 903                    thresh=thresh)
 904
 905                # Raster to histogram 
 906                h, bins = np.histogram(
 907                    x, 
 908                    bins=np.arange(thresh_min,thresh_max,1)
 909                )
 910
 911                # Nonresponsive unit histogram
 912                if h.min() == 0 and h.max() == 0:
 913                    h = np.zeros(len(h))
 914
 915                # Normalize the response
 916                else:
 917                    pass
 918                    norm_method = {
 919                        'minmax': self._minmax_norm(h),
 920                        'zscore': self._zscore_norm(h),
 921                        'average': self._average_response(h, len(condition_start_times))
 922                    }
 923
 924                    if norm is None: continue
 925
 926                    elif (norm is not None
 927                          and norm in list(norm_method.keys())):
 928                        h = norm_method[norm]
 929                    else:
 930                        raise _UnrecognizedNorm(
 931                            f"Normalization method is not recognized,\
 932                            please choose from the following:\
 933                            {list(norm_method.keys())}"
 934                        )
 935
 936                # fill the population dictionary
 937                if condition not in population:
 938                    population[int(condition)] = h
 939                else:
 940                    population[condition] = np.concatenate(
 941                        (population[int(condition)], h)
 942                    )
 943
 944            # append condition ids to the conditions column
 945            unit_col += [cluster_id]*len(h)
 946
 947        # Rearrange the dataframe
 948        population = pd.DataFrame(population)
 949        pcol = list(population.columns.values)
 950        population['cluster_id'] = unit_col
 951        population = population[['cluster_id']+pcol]
 952
 953        return population       
 954
 955    def population_response(
 956            self,
 957            include_units,  # Units to include in the dataframe
 958            stim_condition=None,  # Stimulus condition(s) to include in the dataframe
 959            columns='cluster_id',  # Set column label arrangement
 960            thresh=None,  # Bounding threshold around the stimulus onset at t = 0 - pass as a tuple
 961            norm=None  # Normalization method - choose from: 'minmax', 'zscore', or 'average'
 962    ):
 963        """
 964        Returns a dataframe of the population response PSTH.
 965        By default, each column label represents the
 966        included units. A single column identifies
 967        the stimulus condition at each row. Each row is the
 968        average response at each time step.
 969
 970        Setting columns = "stimulus_condition" will
 971        return a data frame where each column label
 972        represents a stimulus condition. A single
 973        column identifes the included unit at each row.
 974        Each row is the average response at each time step.
 975
 976        Args:
 977
 978        - include_units: Units to include in the dataframe - pass as a 1d array or list like object.
 979        - stim_condition: Stimulus condition(s) to include in the dataframe - 
 980          pass a list of condition ids or 'all' to include all conditions.
 981        - columns: Set column label arrangement - pass either 'cluster_id' or 'stimulus_condition'. 
 982          Default argument is 'stimulus_condition'.
 983        - thresh: Bounding threshold around the stimulus onset at t = 0 - pass as a tuple.
 984        - norm: Normalization method - pass either 'minmax', 'zscore', or 'average'.
 985        """
 986
 987        # Case checks:
 988        if stim_condition is None:
 989            raise _NoStimulusCondition()
 990        elif stim_condition is 'all': pass
 991        elif set(stim_condition).issubset(set(self.stim_conditions)): pass
 992        else:
 993            raise _UnrecognizedStimulusCondition()
 994
 995        col_formats = ['cluster_id', 'stimulus_condition'] 
 996        if columns not in col_formats:
 997            raise _UnrecognizedColumnsInput(list(columns_dic.keys()))
 998
 999        elif columns == 'cluster_id':
1000            population = self._pr_unitcols(
1001            include_units,
1002            stim_condition=stim_condition,
1003            columns=columns,
1004            thresh=thresh,
1005            norm=norm)
1006
1007        elif columns == 'stimulus_condition':
1008            population = self._pr_stimcols(
1009            include_units,
1010            stim_condition=stim_condition,
1011            columns=columns,
1012            thresh=thresh,
1013            norm=norm)
1014
1015        return population
1016    
1017    def map_rf(self, rfunc, xpix, ypix, xvis, yvis, radius):
1018        """
1019        Map the spatiotemporal receptive field of a unit with 
1020        a response function across a stimulus space. 
1021        
1022        Args:
1023        
1024        - rfunc: The unit response function. Dim0 must be equal to
1025          the total number of stimulus conditions.  
1026        - xpix: The width of the image in pixels.
1027        - ypix: The height of the image in pixels.
1028        - xvis: The azimuth of the image in degrees.
1029        - yvis: The elevation of the image in degrees.
1030        - radius: The radius of the circular frame around the image in degrees.
1031        """
1032        
1033        
1034        self._stim_frame_map(xpix, ypix, xvis, yvis, radius)
1035        fmap = np.array([x for x in self.frame_map.values()])
1036        if fmap.T.shape[-1] == rfunc.shape[0]:
1037            return np.dot(fmap.T, rfunc)
1038        else:
1039            raise _InvalidRfuncShape((fmap.T.shape[-1], rfunc.shape[0]))
1040                    
1041    def _stim_frame_map(self, xpix, ypix, xvis, yvis, radius):
1042        frame_map = {}
1043        for i in range(len(self.parameter_map)):
1044            con = i+1
1045            dx = self.parameter_map.iloc[i]
1046            
1047            frame = self.static_grating(
1048                xpix, ypix,
1049                xvis, yvis,
1050                float(dx['Spatial Freq']), 
1051                float(dx['Orientation']), 
1052                float(dx['Phase'])*360,
1053                diameter = radius
1054                )
1055
1056            frame_map[con] = frame
1057
1058        self.frame_map = frame_map
1059
1060    def _stim_frames(self):
1061        cond = self.stim_data.stim_condition_ids.values
1062        self.stim_frames = np.array([self.frame_map[i] for i in cond])
1063
1064class lfp_tools(ephys_toolkit):
1065
1066    #### initialize class with the necessary paths ###
1067    def __init__(self):
1068        ephys_toolkit.__init__(self)
1069        self._low = 1
1070        self._high = 120
1071        
1072    ### process the lfp data by running the necssary methods ###
1073    def process_lfp(self, intan_file, probe = None):
1074        """
1075        This method runs the lfp processing pipeline and
1076        generates two important attributes:
1077        
1078        - .lfp_heatmaps: contains a dictionary the heatmaps of the 
1079          lfp data for each channel column and each stimulus contrast.
1080          
1081        - .depth_data: contains the depth in micrometeres of each 
1082          channel normalized to the center of layer 4 as 0. 
1083          Negative values are ventral to layer 4 and positive values
1084          are dorsal to layer 4.
1085        
1086        """
1087        self._probe_fol = os.path.join(self._modpath, 'probes')
1088        valid_probes0 = os.listdir(self._probe_fol)
1089        valid_probes = [os.path.splitext(x)[0] for x in valid_probes0]
1090        if probe == None:
1091            inputtext = fr"""
1092            Please enter the probe used in this project. Available probes include: 
1093            {valid_probes}. 
1094            If the probe used in your recording is not included in the list, 
1095            navigate to the folder where the ephystoolkit lirbary is installed. 
1096            
1097            If you are using Anaconda on windows, this folder should be located at:
1098            C:\Users\(username)\conda\envs\(env_name)\lib\site-packages\ephystoolkit
1099            
1100            If you are using Anaconda on Linux, this folder should be located at:
1101            ~/anaconda3/envs/(env_name)/lib/(Python_version)/site-packages/ephystoolkit'
1102            
1103            Once you are in the module folder, navigate the to folder called 'probes'
1104            and copy a csv file mapping your channel ids to their distance from the tip
1105            of the probe. Each line index should correspond to the channel id. Indexes
1106            for this purpose start at 1. For reference, check out any of the included
1107            csv files."""
1108            
1109            probe = input(inputtext)
1110        while probe not in valid_probes:
1111            inputtext = fr"""
1112            {probe} is not included in the list of available probes. The list of available 
1113            probes includes: 
1114            {valid_probes}
1115            If the probe used in your recording is not included in the list, 
1116            navigate to the folder in which the ephys_toolkit module is installed. 
1117
1118            If you are using Anaconda on windows, this folder should be located at:
1119            C:\Users\(username)\conda\envs\(env_name)\lib\site-packages\ephystoolkit
1120
1121            If you are using Anaconda on Linux, this folder should be located at:
1122            ~/anaconda3/envs/(env_name)/lib/(Python_version)/site-packages/ephystoolkit
1123
1124            Once you are in the module folder, navigate the to folder called 'probes'
1125            and copy a csv file mapping your channel ids to their distance from the tip
1126            of the probe. Each line index should correspond to the channel id. Indexes
1127            for this purpose start at 1. For reference, check out any of the included
1128            csv files.
1129            """
1130            probe = input(inputtext)
1131        else:
1132            pass
1133        
1134        self._probe = probe
1135        self.intan_file = intan_file
1136        self.channel_file = os.path.join(self._probe_fol, f"{probe}.csv")
1137        
1138        self._load_lfp_data()
1139        
1140        print("Sorting channel depth...")
1141        self._sort_depth()
1142        
1143        print("Retrieving stimulus onset times...")
1144        self._get_onsets()
1145        
1146        print("Generating lfp heatmap...")
1147        self._get_lfp_heatmap()
1148        
1149        print("Normalizing channel depth to layer 4 depth...")
1150        self._find_l4_distances()
1151        self._normalize_depth()
1152        print("Done!")
1153    
1154    ### bandpass filter for the raw voltage data ###
1155    def _bandpass_filter(self, signal):
1156        
1157        #SAMPLING_RATE = 20000.0
1158        nyqs = 0.5 * self._SAMPLING_RATE
1159        low = self._low/nyqs
1160        high = self._high/nyqs
1161
1162        order = 2
1163
1164        b, a = scipy.signal.butter(order, [low, high], 'bandpass', analog = False)
1165        y = scipy.signal.filtfilt(b, a, signal, axis=0)
1166
1167        return y
1168
1169    ### load the necessary data ###
1170    def _load_lfp_data(self):
1171        
1172        # load the rhd file
1173        self.intan_data = data = rhd.read_data(self.intan_file)
1174        self.amplifier_data = self.intan_data['amplifier_data']
1175        self.board_data = self.intan_data['board_dig_in_data']
1176
1177        # load the channel file
1178        self.channel_data = []
1179        with open(self.channel_file, newline='') as channels:
1180            for row in csv.reader(channels):
1181                self.channel_data.append(np.array(row).astype(float))
1182        self.channel_data = np.array(self.channel_data)
1183
1184        # bandpass filter each channel
1185        print("Bandpass filtering the data...")
1186        self.amplifier_data_filt = np.array([
1187            self._bandpass_filter(channel) for
1188            channel in self.amplifier_data
1189        ]
1190        )
1191        
1192    ### sort amplifier data by channel depth ###
1193    def _sort_depth(self):
1194        ## 0 = deepest
1195        
1196        # adjust single channels offset on x by a negligable amount
1197        if self._probe == '64D':
1198            self.channel_data[np.where(self.channel_data == 16)] = 20
1199            self.channel_data[np.where(self.channel_data == -16)] = -20
1200        if self._probe == '64H':
1201            self.channel_data[np.where(self.channel_data == 20)] = 22.5
1202            self.channel_data[np.where(self.channel_data == -20)] = -22.5
1203            self.channel_data[np.where(self.channel_data == 180.1)] = 177.6
1204            self.channel_data[np.where(self.channel_data == 220.1)] = 222.6
1205        
1206        # index ordered by distance from tip
1207        self.y_index = np.argsort(self.channel_data[:,1])[::-1]
1208        
1209        # list of channel depths ordered by distance from tip
1210        self.c_depth = self.channel_data[self.y_index][:,1]
1211
1212        # unique x coordinates for the channels
1213        xarr = np.unique(self.channel_data[:,0])
1214        
1215        # get channel depths along specific columns
1216        self.channels_bycol = {}
1217        for x_d in xarr: 
1218            col = np.where(self.channel_data[self.y_index][:,0] == x_d)[0]
1219            self.channels_bycol[x_d] = {
1220                'y_index': self.y_index[col],
1221                'c_depth': self.channel_data[self.y_index[col]][:,1]
1222            }
1223        
1224        # get amplifier data along specific columns
1225        self.ampdata_bycol = {}    
1226        for col, subdict in self.channels_bycol.items():
1227            self.ampdata_bycol[col] = self.amplifier_data_filt[
1228                subdict['y_index']
1229            ]
1230
1231    
1232    ### Get the onset times of each stimuls contrast ###
1233    def _get_onsets(self):
1234        
1235        # get indices of when contrast 0 is on
1236        stim0 = self.board_data[1]
1237        stim0_on_index = (np.where(stim0 == True)[0])
1238
1239        # get indices of when contrast 1 is on
1240        stim1 = self.board_data[2]
1241        stim1_on_index = (np.where(stim1 == True)[0])
1242
1243        # get the onset indices of contrast 0
1244        first0_on = stim0_on_index[0]
1245        self.c0_onsets = [first0_on]
1246        for i0, val in enumerate(stim0_on_index[1:]):
1247            i1 = i0+1
1248            if stim0_on_index[i0]+1 != (stim0_on_index[i1]):
1249                self.c0_onsets.append(val)
1250
1251        # get the onset indices of contrast 1
1252        first1_on = stim1_on_index[0]
1253        self.c1_onsets = [first1_on]
1254        for i0, val in enumerate(stim1_on_index[1:]):
1255            i1 = i0+1
1256            if stim1_on_index[i0]+1 != (stim1_on_index[i1]):
1257                self.c1_onsets.append(val)
1258                
1259    ### get the heatmap of lfp data averaged across stimulus repeats ###
1260    def _get_lfp_heatmap(self):
1261
1262        self.lfp_heatmaps = {}
1263        for col, subdict in self.ampdata_bycol.items():
1264            self.lfp_heatmaps[col] = {
1265                'contrast0': np.array([
1266                    subdict[:,i:i+5000] for i in self.c0_onsets
1267                ]).mean(0),
1268                'contrast1': np.array([
1269                    subdict[:,i:i+5000] for i in self.c1_onsets
1270                ]).mean(0),
1271
1272            }
1273        
1274    ### assign a depth to each channel relative to layer 4 ###
1275    def _find_l4_distances(self):
1276        
1277        self.l4_distances = {}
1278        self.l4_distance_list = []
1279        for col, chandata in self.channels_bycol.items():
1280            lfp_heatmap = self.lfp_heatmaps[col]
1281            self.l4_distances[col] = {}
1282            for c, contrast in lfp_heatmap.items():
1283            
1284                # find the timepoint of the deepest sink
1285                pe_delay = 1000
1286                tmin = np.where(contrast[:,pe_delay:] == contrast[:,pe_delay:].min())[1][0]
1287                tmin_curve = contrast[:,tmin+pe_delay]
1288
1289                ## Find the center of layer 4 via cubic spline interpolation
1290
1291                # flip the order because the interpolation function
1292                # strictly requires an x axis with ascending order
1293                x = chandata['c_depth'][::-1]
1294                y = tmin_curve
1295
1296                # initialize the interpolation function
1297                cs = scipy.interpolate.CubicSpline(x, y)
1298
1299                # interpolate the data and flip it back to the correct order
1300                xnew = np.linspace(0, x[-1], 1575)[::-1]
1301                ynew = cs(xnew)[::-1]
1302
1303                # get the layer 4 distance from tip
1304                l4_distance = xnew[np.where(ynew == ynew.min())[0][0]].round(2)
1305                self.l4_distances[col][c] = l4_distance
1306                self.l4_distance_list.append(l4_distance)
1307        self.l4_distance_list = np.array(self.l4_distance_list)
1308    
1309    def _normalize_depth(self):
1310        
1311        # find the average distance across channel
1312        # columns and stimulus contrasts
1313        avg_distance = self.l4_distance_list.mean()
1314
1315        # normalize channel distances by distance from layer 4
1316        # negative distances = below layer 4
1317        # positive distances = above layer 4
1318        l4_normalization = self.c_depth - avg_distance
1319
1320        # compile the depth data into a dataframe
1321        depth_data = {
1322            'channel': self.y_index+1,
1323            'distance': l4_normalization
1324        }
1325        self._depth_data = pd.DataFrame(depth_data)        
1326
1327class load_project(lfp_tools, load_experiment):
1328    """
1329    Initialize the load_project class with a full path to the
1330    directory containing the project files. If the LFP data rhd
1331    file is included in the project directory, this class will 
1332    automatically process the LFP data for the project. 
1333    
1334    The .workbook attribute contains a list of dictionaries
1335    with the following structure:
1336    
1337
1338      
1339          {
1340          
1341              'section_id': int - Identification number of the recording 
1342               section,
1343              
1344              'spike_sorting_metrics': Pandas dataframe - Contains spike
1345               sorting metrics data,
1346              
1347              'lfp_heatmaps': dictionary - Contains the lfp heatmap
1348               at each channel column and stimulus contrast,
1349              
1350              'depth_data': Pandas dataframe - Contains the distance of each
1351               channel of the probe relative to layer 4,
1352              
1353              'blocks': [
1354              
1355                  {
1356                  
1357                  'block_id': int - Identification number of the recording block, 
1358                  
1359                  'experiment', experiment object - Contains the experiment data for
1360                   the given block
1361                  
1362                  },
1363                  
1364                  ]
1365                  
1366          },
1367    
1368    Args:
1369    
1370    - project_path: Path to the directory containing the project files.
1371    """
1372
1373    def __init__(self, project_path, probe = None, use_lfp_file = 0):
1374        lfp_tools.__init__(self)
1375        self._lfp_index = use_lfp_file
1376        self._probe = probe
1377        self._ppath = project_path
1378        self._init_project_workbook()
1379
1380    # generate the workbook of project data
1381    def _init_project_workbook(self):
1382        explorer = path_explorer()
1383
1384        # find and sort spike files
1385        spike_files = explorer.findext(self._ppath, '.mat', r='firings')
1386        spike_files.sort(key=lambda x: int(re.search(r'BLK(\d{1,})', x).group(1)))
1387
1388        # find and sort stim files
1389        stim_files = explorer.findext(self._ppath, '.mat', r='stimulusData')
1390        stim_files.sort(key=lambda x: int(re.search(r'BLK(\d{1,})', x).group(1)))
1391        
1392        # find and sort log files
1393        log_files = explorer.findext(self._ppath, '.log')
1394        log_files.sort(key=lambda x: int(re.search(r'BLK(\d{1,})', x).group(1)))
1395        
1396        # count how many log files are in the data folder
1397        log_diff = len(spike_files)-len(log_files)
1398        for i in range(log_diff):
1399            log_files.append(None)
1400
1401        # zip matching blocks
1402        matched_block_files = zip(spike_files, stim_files, log_files)
1403
1404        # find metrics files
1405        metrics_files = explorer.findext(self._ppath, '.json', r='metrics_isolation')
1406        metrics_files.sort(key=lambda x: int(re.search(r'Section_(\d{1,})', x).group(1)))
1407        
1408        # find checkerboard rhd files
1409        rhd_files = explorer.findext(self._ppath, '.rhd')
1410        rhd_files.sort(key=lambda x: int(re.search(r'Section_(\d{1,})', x).group(1)))
1411        
1412        # group together multiple checkerboard recordings
1413        # from the same section
1414        i = 0
1415        for rhd_file in rhd_files[1:]:
1416            if type(rhd_files[i])!=list:
1417                r = r"[A-Z][A-Z]_M\d{1,}_Section_(\d)"
1418                id_sec_post = re.search(r, rhd_file).group(0)
1419                id_sec_pre = re.search(r, rhd_files[i]).group(0)
1420                if id_sec_post == id_sec_pre:
1421                    rhd_files[i] = [rhd_files[i], rhd_file]
1422                    post_index = rhd_files.index(rhd_file)
1423                    del rhd_files[post_index]
1424
1425            elif (type(rhd_files[i])==list):
1426                id_sec_post = re.search(r, rhd_file).group(0)
1427                id_sec_pre = re.search(r, rhd_files[i][0]).group(0)
1428                if id_sec_post == id_sec_pre:
1429                    rhd_files[i].append(rhd_file)
1430                    post_index = rhd_files.index(rhd_file)
1431                    del rhd_files[post_index]
1432            else:
1433                i+=1
1434
1435        ################################################################################
1436
1437        # compile the workbook
1438        self.workbook = []
1439        
1440        # compile metrics
1441        for metrics_file in metrics_files:
1442            section_parent = int(re.search(r'Section_(\d{1,})', metrics_file).group(1))
1443            df = self.spike_sorting_metrics(metrics_file)
1444            self.workbook.append(
1445                {
1446                    'section_id': section_parent,
1447                    'spike_sorting_metrics': df,
1448                    'lfp_heatmaps': None,
1449                    'depth_data': None,
1450                    'blocks': []
1451                }
1452            )
1453            
1454        # compile rhd
1455        if rhd_files == []:
1456            warn_text = """
1457            No LFP data files found in this project folder. LFP data will be
1458            unavailable for this project. Please include the LFP data rhd file 
1459            in the project folder if you want to access the LFP data.
1460            """
1461            warnings.warn(warn_text, stacklevel = 4)
1462        else:
1463            for rhd_file in rhd_files:
1464                if (type(rhd_file) == list) & (self._lfp_index == 0):
1465                    warn_text = """
1466                    WARNING: More than one LFP data file found in this section.
1467                    Defaulting to the first LFP file found in the section. If 
1468                    You wish to change which file to be used, pass an integer 
1469                    value to the use_lfp_file parameter of the load_project class
1470                    corresponding to the file you wish to use. 
1471                    (ie: load_project(use_lfp_file = 2) lets you use the 2nd LFP 
1472                    data file.).
1473                    """
1474                    warnings.warn(warn_text, stacklevel = 4)
1475                    
1476                    rhd_file = rhd_file[self._lfp_index]
1477                elif (type(rhd_file) == list) & (self._lfp_index != 0):
1478                    use_lfp_file = use_lfp_file-1
1479                    rhd_file = rhd_file[self._lfp_index]
1480                else:
1481                    pass
1482                
1483                self.process_lfp(rhd_file, self._probe)
1484                section_parent = int(re.search(r'Section_(\d{1,})', rhd_file).group(1))
1485                self.workbook[section_parent-1]['lfp_heatmaps'] = self.lfp_heatmaps
1486                self.workbook[section_parent-1]['depth_data'] = self._depth_data
1487
1488        # match experiment (block) objects to section
1489        for matched_files in list(matched_block_files):
1490            # a regex to get the experiment identity
1491            ex_r = r'[A-Z]{2}_M\d+_Section_\d+_BLK\d+' 
1492            experiment_id = re.search(ex_r, matched_files[0]).group(0)
1493            
1494            section_child = int(re.search(r'Section_(\d{1,})', matched_files[0]).group(1))
1495            block = int(re.search(r'BLK(\d{1,})', matched_files[0]).group(1))
1496            
1497            experiment = load_experiment(*matched_files, kwargs = {'depth_data': self._depth_data})
1498            self.workbook[section_child - 1]['blocks'].append({
1499                'block_id': block,
1500                'experiment': experiment
1501            })
1502            print(f"Sucessfully loaded {experiment_id}.")
1503
1504
1505# Class Errors
1506class _UnrecognizedNorm(Exception):
1507    """
1508    Exception raised for unrecognized user input
1509    for array normalization.
1510    """
1511
1512    def __init__(self, message):
1513        self.message = message
1514        super().__init__(self.message)
1515
1516
1517class _NoStimulusCondition(Exception):
1518    """
1519    Exception raised for no user input in the
1520    stim_condition argument in the 
1521    self.population_response method.
1522    """
1523
1524    def __init__(self):
1525        self.message = """
1526        Please pass a stimulus condition or enter 'all' to generate 
1527        a joint condition population response matrix.
1528        """
1529        super().__init__(self.message)
1530
1531
1532class _UnrecognizedStimulusCondition(Exception):
1533    """
1534    Exception raised for invalid user input in the
1535    stim_condition argument in the 
1536    self.population_response method.
1537    """
1538
1539    def __init__(self):
1540        self.message = """
1541        The stimulus condition does not exist within this experiment.
1542        Please enter a valid stimulus condition.
1543        """
1544        super().__init__(self.message)
1545
1546
1547class _UnrecognizedColumnsInput(Exception):
1548    """
1549    Exception raised for invalid user input in the
1550    columns argument in the 
1551    self.population_response method.
1552    """
1553
1554    def __init__(self, arg):
1555        self.message = f"""
1556        Invalid input for 'columns'. Please select one of: {arg}.
1557        """
1558        super().__init__(self.message)
1559        
1560class _InvalidRfuncShape(Exception):
1561    """
1562    Exception raised for invalid shape of the response
1563    function given for receptive field mapping.
1564    """
1565
1566    def __init__(self, arg):
1567        self.message = f"""
1568        Shape mistmatch: Frame map dim - 1 ({arg[0]}) != Response function dim 0 ({arg[1]}).
1569        """
1570        super().__init__(self.message)
class ephys_toolkit:
 21class ephys_toolkit:
 22
 23    def __init__(self):
 24        
 25        self._SAMPLING_RATE = 20000
 26        self._modpath = os.path.dirname(__file__)
 27
 28    def _bin_events(self, bin_size, events):
 29        self.frames = bin_size ** -1
 30        self.numerator = self._SAMPLING_RATE / self.frames
 31
 32        return events / self.numerator
 33
 34    def _minmax_norm(self, array):
 35        """
 36        Normalize an array with minmax normalization.
 37        """
 38        return (array - array.min()) / (array.max() - array.min())
 39
 40    def _zscore_norm(self, array):
 41        """
 42        Normalize an array with z score normalization.
 43        """
 44        return stats.zscore(array)
 45
 46    def _average_response(self, array, stim_reps):
 47        """
 48        Return the average frequency of a unit's response
 49        across stimulus repeats.
 50        """
 51        return (array / stim_reps) * self.frames
 52    
 53    def apply_temporal_smoothing(self, x, k, t_axis):
 54        """
 55        Apply temporal smoothing to an array, matrix, or tensor 
 56        with a convolution kernel. 
 57        
 58        Args:
 59        - x: Input data.
 60        - k: Convolution kernel.
 61        - t_axis: The dimension in the input data corresponding to time (0 if one dimensional).
 62        
 63        """
 64        return np.apply_along_axis(
 65                        lambda m: np.convolve(
 66                            m, k, 
 67                            mode='same'
 68                        ), axis=t_axis, arr=x
 69                    )
 70    
 71    def static_grating(
 72            self,
 73            windowSizeX_Pixel, windowSizeY_Pixel, # size of the stimulus window in pixels
 74            windowSizeX_Visual, windowSizeY_Visual, # size of the stimulus window in degrees
 75            spatialFrequency, # spatial frequency in cpd
 76            ang, # orientation angle of the grating in degrees
 77            phi, # phase of the grating in degrees
 78            diameter = None # dimater of the circular patch in degrees
 79    ):    
 80        """
 81        Generate a matrix of pixel intensities   
 82        representing a static grating stimulus
 83        with the given parameters.
 84        
 85        Args:
 86        
 87        - windowSizeX_Pixel: Horizontal size of the stimulus screen in pixels.
 88        - windowSizeY_Pixel: Vertical size of the stimulus screen in pixels.
 89        - windowSizeX_Visual: Horizontal size of the stimulus screen in degrees.
 90        - windowSizeY_Visual: Vertical size of the stimulus screen in degrees.
 91        - spatialFrequency: Spatial frequency in cycles per degre (cpd).
 92        - ang: Orientation angle of the grating in degrees.
 93        - phi: Phase of the grating in degrees
 94        - diameter: Dimater of the circular patch in degrees. 
 95        """
 96        
 97        if ((windowSizeX_Pixel <=10)
 98            or (windowSizeX_Pixel%2 != 0)
 99        ):
100            print('windowSizeX_Pixel must be greater than ten and even')
101
102        if ((windowSizeY_Pixel <=10)
103            or (windowSizeY_Pixel%2 != 0)
104        ):
105            print('windowSizeY_Pixel must be greater than ten and even')
106
107        if (math.floor(windowSizeX_Pixel/windowSizeY_Pixel*1000) 
108            != math.floor(windowSizeX_Visual/windowSizeY_Visual*1000)
109        ):
110            print('The ratio of X and Y for Pixel and Visual are different!');
111
112        if spatialFrequency < 0:
113            error('spatialFrequency less than zero')
114
115        # x, y 
116        x_center = windowSizeX_Pixel/2
117        y_center = windowSizeY_Pixel/2
118
119        x_range = np.arange(-x_center, x_center,1)
120        y_range = np.arange(-y_center, y_center,1)
121        x, y = np.meshgrid(x_range,y_range)
122
123        # theta
124        theta = -np.radians(ang);
125        xyTheta = y * np.cos(theta) - x * np.sin(theta);
126        
127        # phi
128        phi = np.radians(phi)
129
130        # Spatial Frenquency
131        degreePerPixel = windowSizeX_Visual / windowSizeX_Pixel
132        sf = spatialFrequency  * degreePerPixel # cycles / pixel
133        sw = 2 * np.pi * sf # radian / pixel
134
135        # contrast
136        pixelIntensity = (np.sin(sw * xyTheta - phi)) # range: -1 to 1
137
138        # round image
139        if diameter == None:
140            pass
141        else:
142            diameter = diameter / degreePerPixel
143            r = diameter/2;
144            c_mask = ( (x**2+y**2) <= r**2 )
145            pixelIntensity = pixelIntensity*c_mask 
146
147        return pixelIntensity
148
149    def drifting_grating(
150            self,
151            windowSizeX_Pixel, windowSizeY_Pixel, # size of the stimulus window in pixels
152            windowSizeX_Visual, windowSizeY_Visual, # size of the stimulus window in degrees
153            tf,  # Temporal frequency - pass as an integer or floating point value
154            dt,  # Time step value - pass as a floating point value
155            t,  # Total duration of the stimulus - pass as an int or float of the appropriate time unit
156            spatialFrequency, # spatial frequency in cpd
157            ang, # orientation angle of the grating in degrees
158            diameter = None # dimater of the circular patch in degrees
159    ):
160        """
161        Returns a list of matricies representing
162        frames of a drifitng grating stimulus.
163        
164        Args:
165        - windowSizeX_Pixel: Horizontal size of the stimulus screen in pixels.
166        - windowSizeY_Pixel: Vertical size of the stimulus screen in pixels.
167        - windowSizeX_Visual: Horizontal size of the stimulus screen in degrees.
168        - windowSizeY_Visual: Vertical size of the stimulus screen in degrees.    
169        - tf: Temporal frequency - pass as an integer or floating point value.
170        - dt: Time step value - pass as a floating point value.
171        - t: Total duration of the stimulus - pass as an int or float of the appropriate time unit.
172        - spatialFrequency: Spatial frequency in cycles per degre (cpd).
173        - ang: Orientation angle of the grating in degrees.
174        - diameter: Dimater of the circular patch in degrees. 
175        
176        """
177
178        tensor = []
179        params = []
180        deg_step = dt * 360 * tf
181
182        d = np.arange(dt, t, dt)
183
184        phi = 0
185        for x in d:
186            m = self.static_grating(
187                windowSizeX_Pixel, windowSizeY_Pixel, 
188                windowSizeX_Visual, windowSizeY_Visual, 
189                spatialFrequency,
190                ang,
191                phi,
192                diameter = diameter
193            ) 
194            tensor.append(m)
195            params.append([sf, tf, ori, phase])
196            phi += deg_step
197
198        return tensor, params
199
200    def _discrete_radius(self, dim=tuple, radius=int):
201        x = np.linspace(-1, 1, dim[1])
202        y = np.linspace(-1, 1, dim[0])
203        
204
205        m = []
206        for i0 in range(len(x)):
207            row = []
208            for i1 in range(len(y)):
209                if ((x[i0] ** 2 + y[i1] ** 2)
210                        < ((radius / 360) ** 2)):
211                    row.append(1)
212                else:
213                    row.append(0)
214            m.append(row)
215
216        return np.array(m).T
217
218    def _gaussian_radius(self, dim:tuple, radius:int):
219        x, y = np.meshgrid(
220            np.linspace(-1, 1, dim[0]),
221            np.linspace(-1, 1, dim[1])
222        )
223        
224        
225        d = np.sqrt(x * x + y * y)
226        sigma, mu = radius / 360, 0.0
227        g = np.exp(-((d - mu) ** 2 / (2.0 * sigma ** 2)))
228
229        return g
230
231    def spike_sorting_metrics(self, file_path):
232        """
233        Returns a dataframe with the spike sorting metrics
234        of a given recording section.
235        
236        Args:
237        
238        - file_path: Path to the spike sorting metrics file.
239        """
240
241        with open(file_path, 'r') as sorting_file:
242            sorting_info = json.load(sorting_file)
243
244        spike_sorting_data = [
245            # [cluster['label'] for cluster in sorting_info['clusters']],
246            [cluster['metrics']['isolation'] for cluster in sorting_info['clusters']],
247            [cluster['metrics']['noise_overlap'] for cluster in sorting_info['clusters']]
248        ]
249
250        ss_df = pd.DataFrame(np.array(spike_sorting_data).T)
251        ss_df.columns = [
252            # 'cluster', 
253            'isolation', 'noise_overlap'
254        ]
255
256        return ss_df
257
258    # functions to make concatenated across trial and non concatenated across trial rasters
259    def _concatenated_raster(self, stims, spikes, thresh=tuple):
260        if thresh == tuple:
261            r = np.array([spikes - st for st in stims])
262            raster = np.concatenate(r)
263        else:
264            r = np.array([spikes - st for st in stims])
265            ti = np.where(np.logical_and(r <= thresh[1], r >= thresh[0]))
266            raster = r[ti]
267        return raster
268
269    def _unconcatenated_raster(self, stims, spikes, thresh=tuple):
270        if thresh == tuple:
271            rasters = np.array([spikes - st for st in stims])
272        else:
273            rasters = []
274            for i, st in enumerate(stims):  # enumerate to make an initial array then vstack
275                unthreshed = spikes - st
276                i = np.where(np.logical_and(unthreshed <= thresh[1], unthreshed >= thresh[0]))
277                rasters.append(list(unthreshed[i]))
278        return rasters
279
280    def _linear_extrapolation(self, xd, yd, x):
281        y2, y1 = yd[-1], yd[-2]
282        x2, x1 = xd[-1], xd[-2]
283
284        return y1+(((x-x1)/(x2-x1))*(y2-y1))
285
286    def raster(
287            self,
288            stims,  # Array of stimulus onset times
289            spikes,  # Array of spike onset times
290            thresh=tuple,  # Bounding threshold around the stimulus onset at t = 0 - pass as a tuple
291            concatenate=True  # Concatenate rasters across trials - pass False to return unconcatenated rasters
292    ):
293        """
294        Returns an array representing a raster of spike times centered 
295        around the onset times of a given stimulus.
296        
297        Args:
298        
299        - stims: Array of stimulus onset times.
300        - spikes: Array of spike onset times.
301        - thresh: Bounding threshold around the stimulus onset at t = 0 - pass as a tuple.
302        - concatenate: Concatenate rasters across trials; pass False to return unconcatenated rasters.
303        
304        """
305
306        if concatenate:
307            return self._concatenated_raster(stims, spikes, thresh)
308        else:
309            return self._unconcatenated_raster(stims, spikes, thresh)
def apply_temporal_smoothing(self, x, k, t_axis):
53    def apply_temporal_smoothing(self, x, k, t_axis):
54        """
55        Apply temporal smoothing to an array, matrix, or tensor 
56        with a convolution kernel. 
57        
58        Args:
59        - x: Input data.
60        - k: Convolution kernel.
61        - t_axis: The dimension in the input data corresponding to time (0 if one dimensional).
62        
63        """
64        return np.apply_along_axis(
65                        lambda m: np.convolve(
66                            m, k, 
67                            mode='same'
68                        ), axis=t_axis, arr=x
69                    )

Apply temporal smoothing to an array, matrix, or tensor with a convolution kernel.

Args:

  • x: Input data.
  • k: Convolution kernel.
  • t_axis: The dimension in the input data corresponding to time (0 if one dimensional).
def static_grating( self, windowSizeX_Pixel, windowSizeY_Pixel, windowSizeX_Visual, windowSizeY_Visual, spatialFrequency, ang, phi, diameter=None):
 71    def static_grating(
 72            self,
 73            windowSizeX_Pixel, windowSizeY_Pixel, # size of the stimulus window in pixels
 74            windowSizeX_Visual, windowSizeY_Visual, # size of the stimulus window in degrees
 75            spatialFrequency, # spatial frequency in cpd
 76            ang, # orientation angle of the grating in degrees
 77            phi, # phase of the grating in degrees
 78            diameter = None # dimater of the circular patch in degrees
 79    ):    
 80        """
 81        Generate a matrix of pixel intensities   
 82        representing a static grating stimulus
 83        with the given parameters.
 84        
 85        Args:
 86        
 87        - windowSizeX_Pixel: Horizontal size of the stimulus screen in pixels.
 88        - windowSizeY_Pixel: Vertical size of the stimulus screen in pixels.
 89        - windowSizeX_Visual: Horizontal size of the stimulus screen in degrees.
 90        - windowSizeY_Visual: Vertical size of the stimulus screen in degrees.
 91        - spatialFrequency: Spatial frequency in cycles per degre (cpd).
 92        - ang: Orientation angle of the grating in degrees.
 93        - phi: Phase of the grating in degrees
 94        - diameter: Dimater of the circular patch in degrees. 
 95        """
 96        
 97        if ((windowSizeX_Pixel <=10)
 98            or (windowSizeX_Pixel%2 != 0)
 99        ):
100            print('windowSizeX_Pixel must be greater than ten and even')
101
102        if ((windowSizeY_Pixel <=10)
103            or (windowSizeY_Pixel%2 != 0)
104        ):
105            print('windowSizeY_Pixel must be greater than ten and even')
106
107        if (math.floor(windowSizeX_Pixel/windowSizeY_Pixel*1000) 
108            != math.floor(windowSizeX_Visual/windowSizeY_Visual*1000)
109        ):
110            print('The ratio of X and Y for Pixel and Visual are different!');
111
112        if spatialFrequency < 0:
113            error('spatialFrequency less than zero')
114
115        # x, y 
116        x_center = windowSizeX_Pixel/2
117        y_center = windowSizeY_Pixel/2
118
119        x_range = np.arange(-x_center, x_center,1)
120        y_range = np.arange(-y_center, y_center,1)
121        x, y = np.meshgrid(x_range,y_range)
122
123        # theta
124        theta = -np.radians(ang);
125        xyTheta = y * np.cos(theta) - x * np.sin(theta);
126        
127        # phi
128        phi = np.radians(phi)
129
130        # Spatial Frenquency
131        degreePerPixel = windowSizeX_Visual / windowSizeX_Pixel
132        sf = spatialFrequency  * degreePerPixel # cycles / pixel
133        sw = 2 * np.pi * sf # radian / pixel
134
135        # contrast
136        pixelIntensity = (np.sin(sw * xyTheta - phi)) # range: -1 to 1
137
138        # round image
139        if diameter == None:
140            pass
141        else:
142            diameter = diameter / degreePerPixel
143            r = diameter/2;
144            c_mask = ( (x**2+y**2) <= r**2 )
145            pixelIntensity = pixelIntensity*c_mask 
146
147        return pixelIntensity

Generate a matrix of pixel intensities
representing a static grating stimulus with the given parameters.

Args:

  • windowSizeX_Pixel: Horizontal size of the stimulus screen in pixels.
  • windowSizeY_Pixel: Vertical size of the stimulus screen in pixels.
  • windowSizeX_Visual: Horizontal size of the stimulus screen in degrees.
  • windowSizeY_Visual: Vertical size of the stimulus screen in degrees.
  • spatialFrequency: Spatial frequency in cycles per degre (cpd).
  • ang: Orientation angle of the grating in degrees.
  • phi: Phase of the grating in degrees
  • diameter: Dimater of the circular patch in degrees.
def drifting_grating( self, windowSizeX_Pixel, windowSizeY_Pixel, windowSizeX_Visual, windowSizeY_Visual, tf, dt, t, spatialFrequency, ang, diameter=None):
149    def drifting_grating(
150            self,
151            windowSizeX_Pixel, windowSizeY_Pixel, # size of the stimulus window in pixels
152            windowSizeX_Visual, windowSizeY_Visual, # size of the stimulus window in degrees
153            tf,  # Temporal frequency - pass as an integer or floating point value
154            dt,  # Time step value - pass as a floating point value
155            t,  # Total duration of the stimulus - pass as an int or float of the appropriate time unit
156            spatialFrequency, # spatial frequency in cpd
157            ang, # orientation angle of the grating in degrees
158            diameter = None # dimater of the circular patch in degrees
159    ):
160        """
161        Returns a list of matricies representing
162        frames of a drifitng grating stimulus.
163        
164        Args:
165        - windowSizeX_Pixel: Horizontal size of the stimulus screen in pixels.
166        - windowSizeY_Pixel: Vertical size of the stimulus screen in pixels.
167        - windowSizeX_Visual: Horizontal size of the stimulus screen in degrees.
168        - windowSizeY_Visual: Vertical size of the stimulus screen in degrees.    
169        - tf: Temporal frequency - pass as an integer or floating point value.
170        - dt: Time step value - pass as a floating point value.
171        - t: Total duration of the stimulus - pass as an int or float of the appropriate time unit.
172        - spatialFrequency: Spatial frequency in cycles per degre (cpd).
173        - ang: Orientation angle of the grating in degrees.
174        - diameter: Dimater of the circular patch in degrees. 
175        
176        """
177
178        tensor = []
179        params = []
180        deg_step = dt * 360 * tf
181
182        d = np.arange(dt, t, dt)
183
184        phi = 0
185        for x in d:
186            m = self.static_grating(
187                windowSizeX_Pixel, windowSizeY_Pixel, 
188                windowSizeX_Visual, windowSizeY_Visual, 
189                spatialFrequency,
190                ang,
191                phi,
192                diameter = diameter
193            ) 
194            tensor.append(m)
195            params.append([sf, tf, ori, phase])
196            phi += deg_step
197
198        return tensor, params

Returns a list of matricies representing frames of a drifitng grating stimulus.

Args:

  • windowSizeX_Pixel: Horizontal size of the stimulus screen in pixels.
  • windowSizeY_Pixel: Vertical size of the stimulus screen in pixels.
  • windowSizeX_Visual: Horizontal size of the stimulus screen in degrees.
  • windowSizeY_Visual: Vertical size of the stimulus screen in degrees.
  • tf: Temporal frequency - pass as an integer or floating point value.
  • dt: Time step value - pass as a floating point value.
  • t: Total duration of the stimulus - pass as an int or float of the appropriate time unit.
  • spatialFrequency: Spatial frequency in cycles per degre (cpd).
  • ang: Orientation angle of the grating in degrees.
  • diameter: Dimater of the circular patch in degrees.
def spike_sorting_metrics(self, file_path):
231    def spike_sorting_metrics(self, file_path):
232        """
233        Returns a dataframe with the spike sorting metrics
234        of a given recording section.
235        
236        Args:
237        
238        - file_path: Path to the spike sorting metrics file.
239        """
240
241        with open(file_path, 'r') as sorting_file:
242            sorting_info = json.load(sorting_file)
243
244        spike_sorting_data = [
245            # [cluster['label'] for cluster in sorting_info['clusters']],
246            [cluster['metrics']['isolation'] for cluster in sorting_info['clusters']],
247            [cluster['metrics']['noise_overlap'] for cluster in sorting_info['clusters']]
248        ]
249
250        ss_df = pd.DataFrame(np.array(spike_sorting_data).T)
251        ss_df.columns = [
252            # 'cluster', 
253            'isolation', 'noise_overlap'
254        ]
255
256        return ss_df

Returns a dataframe with the spike sorting metrics of a given recording section.

Args:

  • file_path: Path to the spike sorting metrics file.
def raster(self, stims, spikes, thresh=<class 'tuple'>, concatenate=True):
286    def raster(
287            self,
288            stims,  # Array of stimulus onset times
289            spikes,  # Array of spike onset times
290            thresh=tuple,  # Bounding threshold around the stimulus onset at t = 0 - pass as a tuple
291            concatenate=True  # Concatenate rasters across trials - pass False to return unconcatenated rasters
292    ):
293        """
294        Returns an array representing a raster of spike times centered 
295        around the onset times of a given stimulus.
296        
297        Args:
298        
299        - stims: Array of stimulus onset times.
300        - spikes: Array of spike onset times.
301        - thresh: Bounding threshold around the stimulus onset at t = 0 - pass as a tuple.
302        - concatenate: Concatenate rasters across trials; pass False to return unconcatenated rasters.
303        
304        """
305
306        if concatenate:
307            return self._concatenated_raster(stims, spikes, thresh)
308        else:
309            return self._unconcatenated_raster(stims, spikes, thresh)

Returns an array representing a raster of spike times centered around the onset times of a given stimulus.

Args:

  • stims: Array of stimulus onset times.
  • spikes: Array of spike onset times.
  • thresh: Bounding threshold around the stimulus onset at t = 0 - pass as a tuple.
  • concatenate: Concatenate rasters across trials; pass False to return unconcatenated rasters.
class load_experiment(ephys_toolkit):
 313class load_experiment(ephys_toolkit):
 314    """
 315    Create an experiment object for a given recording block.
 316    Takes the spike data file path as the first argument and the
 317    stimulus data file path as the second argument. Initializing
 318    an experiment object generates some important class attributes:
 319    
 320    .stim_data: A pandas dataframe with the stimulus data.
 321    
 322    .spike_data: A dictionary object with the spiking data
 323                 of all the identified clusters.
 324                 
 325    Args:
 326    
 327    - spikefile: Path to the spike data file.
 328    - stimfile: Path to the stimulus data file.
 329    """
 330
 331    def __init__(self, spikefile, stimfile, logfile, **kwargs):
 332        ephys_toolkit.__init__(self)
 333        
 334        self._stim_m73 = None
 335        self._spike_m73 = None
 336        self._spikefile = spikefile # path to the spike file
 337        self._stimfile = stimfile # path to the stim file
 338        self._depth_data = kwargs['kwargs']['depth_data'] 
 339        
 340        try:
 341            self.spikes_mat = scipy.io.loadmat(spikefile)
 342            self.spikes = self.spikes_mat['Data'][0] # raw spikes dictionary
 343        except NotImplementedError:
 344            self._spike_m73 = True
 345            self.spikes_mat = mat73.loadmat(spikefile)
 346            self.spikes = self.spikes_mat['Data']
 347            
 348        try:
 349            self.stims_mat = scipy.io.loadmat(stimfile)
 350            self.stims = self.stims_mat['StimulusData'][0][0] # raw stims dictionary
 351        except NotImplementedError:
 352            self._stim_m73 = True
 353            self.stims_mat = mat73.loadmat(stimfile)
 354            self.stims = self.stims_mat['StimulusData']
 355            
 356        self._logfile = logfile # stimulus log data
 357        self._nodf = False
 358        self._init_stim_data()
 359        self._init_spike_data()
 360        self.set_time_unit()
 361
 362        self._parameters_matched = False
 363        
 364        if self._nodf:
 365            self.stim_conditions = np.unique(
 366                self.stim_data['stim_condition_ids']
 367            )
 368        else:
 369            self.stim_conditions = self.stim_data[
 370                'stim_condition_ids'
 371            ].unique()
 372        
 373        if self._logfile != None:
 374            self._get_conditions_from_log()
 375        else:
 376            r = r'([A-Z]{1,2}_M\d{1,}_Section_\d{1,}_BLK\d{1,})'
 377            experiment_id = re.search(r, spikefile).group(1)
 378            warn_text = f"""
 379            No stimulus log file found for experiment: {experiment_id}.
 380            If this experiment has more that 255 stimulus conditions, the stimulus 
 381            condition IDs will be incorrect in the self.stim_data attribute.
 382            """
 383            warnings.warn(warn_text, stacklevel = 4)
 384
 385    # Generates the .stim_data attribute.
 386    def _init_stim_data(self):
 387        
 388        if self._stim_m73:
 389            stim_data = {
 390                'stim_start_indicies': self.stims['stimulusOnsets'],
 391                'stim_stop_indicies': self.stims['stimulusOffsets'],
 392                'stim_condition_ids': self.stims['conditionNumbers']
 393            }
 394        else:
 395            stims_starts = self.stims[0]
 396            stims_stops = self.stims[1]
 397            stims_condition_ids = self.stims[2]
 398            stim_data = {
 399                'stim_start_indicies': stims_starts[0],
 400                'stim_stop_indicies': stims_stops[0],
 401                'stim_condition_ids': stims_condition_ids[0]
 402            }
 403        
 404        try:
 405            self.stim_data = pd.DataFrame(stim_data)
 406        except ValueError as e:
 407            if str(e) == "All arrays must be of the same length":
 408                warn_text = f"""
 409                Stimulus start time, stop time, and condition 
 410                arrays are unequal in length. This typically
 411                only happens with natural image recordings.
 412                If you include a stimulus log file in the data
 413                folder, ephystoolkit will attempt to fix this.
 414                Otherwise, the .stim_data attribute will return
 415                a dictionary instead of a dataframe.
 416                """
 417                warnings.warn(warn_text, stacklevel = 4)
 418                self.stim_data = stim_data
 419                self._nodf = True
 420
 421    # Generates the .spike_data attribute.
 422    def _init_spike_data(self):
 423        if self._spike_m73:
 424            ci = [int(i) for i in self.spikes['ChannelID']]
 425            si = self.spikes['SpikePoints']
 426            st = self.spikes['SpikeTimes']
 427            self.spike_data = [
 428                {
 429                    'channel_id': ci[i],
 430                    'spike_index': si[i],
 431                    'spike_time': st[i]
 432                }
 433                for i in range(len(st))
 434            ]
 435        else:
 436            
 437            self.spike_data = [
 438                {
 439                    'channel_id': unit[1][0][0],
 440                    'depth': self._depth_data.loc[self._depth_data.channel == unit[1][0][0]]['distance'].values[0],
 441                    'spike_index': unit[2][0],
 442                    'spike_time': unit[3][0]
 443                }
 444                for unit in
 445                self.spikes
 446            ]
 447        """
 448        A dictionary object with the spiking data.
 449        """
 450        
 451    def _get_conditions_from_log(self):
 452        
 453        with open(self._logfile, 'r') as f:
 454            stimlog = f.readlines()
 455
 456        stimlog = stimlog[1:-2]
 457        
 458        # extract conditions for log
 459        r0 = r'[Cc]ondition#: *(\d{1,})'
 460        r1 = r'[Cc]onditions:(\d{1,} +...*)'
 461        condition_ids = []
 462        
 463        if re.search(r0, stimlog[1]): # if the experiment is gratings or checkerboard
 464            for line in stimlog:
 465                c_id = re.search(r0, line).group(1)
 466                condition_ids.append(int(c_id))
 467                
 468            try: 
 469                # replace conditions in experiment with conditions from log
 470                self.stim_data.stim_condition_ids = condition_ids
 471                self.stim_conditions = self.stim_data.stim_condition_ids.unique()
 472
 473            except ValueError:
 474                pass
 475            
 476        elif re.search(r1, stimlog[1]): # if the experiment is natural images
 477            condition_ids = []
 478            for x in stimlog:
 479                cindex = x.index(':')+1 # index where condition numbers begin
 480                numbers = x[cindex:].strip() # remove whitespace at the end
 481                numbers = numbers.replace('  ', ' ') # remove double spaces
 482                numbers = numbers.split(' ') # split into a list of conditon numbers
 483                numbers = [int(x) for x in numbers] # format str into int
 484                condition_ids += numbers #collect condition ids
 485            
 486            # Attempt to fix unequal stimulus arrays/missing values
 487            if self._nodf:
 488                stim_start_indicies = self.stim_data['stim_start_indicies']
 489                stim_stop_indicies = self.stim_data['stim_stop_indicies']
 490                stim_start_times = self.stim_data['stim_start_times']
 491                stim_stop_times = self.stim_data['stim_stop_times']
 492            else:      
 493                stim_start_indicies = self.stim_data['stim_start_indicies'].values
 494                stim_stop_indicies = self.stim_data['stim_stop_indicies'].values
 495                stim_start_times = self.stim_data['stim_start_times'].values
 496                stim_stop_times = self.stim_data['stim_stop_times'].values
 497
 498            stim_data_fixed = {
 499                'stim_condition_ids': condition_ids,
 500                'stim_start_indicies': stim_start_indicies,
 501                'stim_stop_indicies': stim_stop_indicies,
 502                'stim_start_times': stim_start_times,
 503                'stim_stop_times': stim_stop_times
 504            }
 505            keys = list(stim_data_fixed.keys())
 506
 507            # extrapolate the missing values
 508            for key in keys:
 509                xd = np.arange(len(stim_data_fixed[key]))+1
 510                yd = stim_data_fixed[key]
 511                x = np.arange(len(yd), len(condition_ids))+1
 512                y_pred = self._linear_extrapolation(xd, yd, x)
 513                if 'indicies' in key:
 514                    y_pred = np.round(y_pred)
 515                stim_data_fixed[key] = np.concatenate((yd, y_pred))
 516            
 517            self.stim_data = pd.DataFrame(stim_data_fixed)
 518            self.stim_conditions = self.stim_data.stim_condition_ids.unique()
 519            
 520    def set_time_unit(self, bin_size=0.001):
 521        """
 522        Change the time unit of the relative spike times.
 523        Give a bin size relative to 1 second.
 524        IE: If you want a 1 ms bin size, enter 0.001;
 525        if you want a 10 ms bin size, enter 0.01 etc.
 526        
 527        Args:
 528        
 529        - bin_size: Time unit given relative to 1 second. The default unit is 1ms/0.001s.
 530        """
 531        if self._nodf:
 532            self.stim_data['stim_start_times'] = self._bin_events(bin_size,
 533                self.stim_data['stim_start_indicies'])
 534
 535            self.stim_data['stim_stop_times'] = self._bin_events(bin_size,
 536                self.stim_data['stim_stop_indicies'])  
 537        else:
 538            self.stim_data['stim_start_times'] = self._bin_events(bin_size,
 539                self.stim_data['stim_start_indicies'].values)
 540
 541            self.stim_data['stim_stop_times'] = self._bin_events(bin_size,
 542                self.stim_data['stim_stop_indicies'].values)
 543
 544            self.stim_data = self.stim_data[
 545                ['stim_condition_ids',
 546                 'stim_start_indicies',
 547                 'stim_stop_indicies',
 548                 'stim_start_times',
 549                 'stim_stop_times']
 550            ]
 551
 552        for i, unit in enumerate(self.spike_data):
 553            try:
 554                unit.update({
 555                    'rel_spike_time': self._bin_events(bin_size,
 556                                                       unit['spike_index'])
 557                })
 558            except TypeError as e:
 559                if 'NoneType' in str(e):
 560                    print(f"No spikes recorded for unit {i}, skipping...")
 561                else:
 562                    raise TypeError
 563
 564    def condition_times(self, condition):
 565        """
 566        Returns a dictionary object of the start times and
 567        stop times of a particular stimulus condition.
 568        
 569        Args:
 570        
 571        -condition: Condition id for the chosen stimulus condition.
 572        """
 573
 574        condition_starts = self.stim_data.groupby(
 575            self.stim_data.stim_condition_ids
 576        ).get_group(condition)['stim_start_times'].values
 577
 578        condition_stops = self.stim_data.groupby(
 579            self.stim_data.stim_condition_ids
 580        ).get_group(condition)['stim_stop_times'].values
 581
 582        return {
 583            'start': condition_starts,
 584            'stop': condition_stops
 585        }
 586
 587    def match_condition_parameters(self, params_file):
 588        """
 589        Takes a parameters file and alters the .stim_data dataframe
 590        to match stimulus condition parameters to their corresponding 
 591        condition id.
 592        
 593        Args:
 594        
 595        - params_file: Path to the stimulus parameters file.
 596        """
 597        
 598        # if parameters are given in a log file
 599        if os.path.splitext(params_file)[1] == '.log':
 600            df = self.stim_data
 601            with open(params_file, 'r') as f:
 602                lines = f.readlines()[1:-2]
 603            
 604            # read the lines to make a series of columns to insert
 605            insert = []
 606            for line in lines:
 607                items = re.split(r'\t+', line)[2:-1]
 608                dic = {
 609                    item.split(':')[0]:float(item.split(':')[1].strip(' ')) 
 610                    for item in items
 611                }
 612                insert.append(dic)
 613            
 614            # add the parameters to the stim_data dataframe
 615            insert = pd.DataFrame(insert)
 616            newcol = [list(df.columns)[0]] + list(insert.columns) + list(df.columns)[1:]
 617            self.stim_data = df.join(insert)[newcol]
 618            
 619            # make the parameter map
 620            stop_index = list(self.stim_data.columns).index('stim_start_indicies')
 621            df_new = self.stim_data[list(self.stim_data.columns)[:stop_index]]
 622            df_new.columns = ['condition']+list(df_new.columns)[1:]
 623            df_new = df_new.drop_duplicates().sort_values(by = 'condition')
 624            self.parameter_map = df_new.reset_index().drop('index', axis = 1)
 625            
 626            self._parameters_matched = True
 627            
 628        # if parameters are given in a matlab file        
 629        elif os.path.splitext(params_file)[1] == '.mat':
 630            
 631            # load the parameter file and extract the keys
 632            params = scipy.io.loadmat(params_file)
 633            param_keys = list(params.keys())
 634
 635            # regex to get the parameter names + values
 636            name_identifier = r'Var\d{1,}Val'
 637            val_identifier = r'var\d{1,}value'
 638
 639            if 'fnames' in param_keys: # if experiment is natural images
 640
 641                # get the stim file names
 642                stim_file_names = [
 643                    name[0][0] for 
 644                    name in scipy.io.loadmat(params_file)['fnames']
 645                ]
 646
 647                # make a dictionary for the parameter mapping
 648                ni_filemap = {
 649                    'condition': [],
 650                    'size': [],
 651                    'filter': [],
 652                    'file': []
 653                }
 654
 655                # match condition to parameters
 656                freg = r'^[A-Z]{2,3}'
 657                for i in self.stim_conditions:
 658                    if i%2 == 0: # if condition is even
 659
 660                        #append condition & size
 661                        ni_filemap['condition'].append(i)
 662                        ni_filemap['size'].append(60)
 663
 664                        #append filename
 665                        filename = stim_file_names[int(i/2)-1]
 666                        ni_filemap['file'].append(filename) 
 667
 668                        #append filtering condition
 669                        filt = re.search(freg, filename).group(0)
 670                        ni_filemap['filter'].append(filt)
 671
 672                    else: # if condition is odd
 673                        #append condition & size
 674                        ni_filemap['condition'].append(i)
 675                        ni_filemap['size'].append(30)
 676
 677                        #append filename
 678                        filename = stim_file_names[int((i+1)/2)-1]
 679                        ni_filemap['file'].append(filename)
 680
 681                        #append filtering condition
 682                        filt = re.search(freg, filename).group(0)
 683                        ni_filemap['filter'].append(filt)
 684
 685                self.parameter_map = pd.DataFrame(ni_filemap) 
 686                df = self.stim_data
 687
 688                # get the parameters to insert into the original dataframe
 689                insert = []
 690                for cond in df.stim_condition_ids.values:
 691                    arr = self.parameter_map.loc[self.parameter_map.condition == cond].values[0]
 692                    insert.append(arr)
 693                insert = pd.DataFrame(insert, columns=self.parameter_map.columns.values)
 694
 695                # insert the parameter values into the original dataframe
 696                df1 = df.join(insert)
 697
 698                # reset the stim_data attribute
 699                self.stim_data = df1[
 700                    list([df.columns.values[0]])
 701                    + list(self.parameter_map.columns.values[1:])
 702                    + list(
 703                        df.columns.values[1:]
 704                    )
 705                    ]
 706
 707            else: # if experiment is gratings or checkerboard
 708                # use regex to get the param names and values
 709                name_keys = [key for key in param_keys if re.search(name_identifier, key)]
 710                val_keys = [key for key in param_keys if re.search(val_identifier, key)]
 711
 712                # get the condition numbers
 713                condition_range = len(params[val_keys[0]][0])
 714                condition_ids = [i + 1 for i in range(condition_range)]
 715
 716                # map conditions to parameter values
 717                parameter_map = {'condition': condition_ids}
 718                for i in range(len(name_keys)):
 719                    parameter_name = str(params[name_keys[i]][0])
 720                    parameter_values = np.array(params[val_keys[i]][0])
 721
 722                    parameter_map[parameter_name] = parameter_values
 723
 724                    # parameter dataframe + the original stim_data dataframe
 725                self.parameter_map = pd.DataFrame(parameter_map)
 726                df = self.stim_data
 727
 728                # get the parameters to insert into the original dataframe
 729                insert = []
 730                for cond in df.stim_condition_ids.values:
 731                    arr = self.parameter_map.loc[self.parameter_map.condition == cond].values[0]
 732                    insert.append(arr)
 733                insert = pd.DataFrame(insert, columns=self.parameter_map.columns.values)
 734
 735                # insert the parameter values into the original dataframe
 736                df1 = df.join(insert)
 737
 738                # reset the stim_data attribute
 739                self.stim_data = df1[
 740                    list([df.columns.values[0]])
 741                    + list(self.parameter_map.columns.values[1:])
 742                    + list(
 743                        df.columns.values[1:]
 744                    )
 745                    ]
 746
 747            self._parameters_matched = True
 748
 749    def _pr_unitcols(
 750            self,
 751            include_units,
 752            stim_condition=None, 
 753            columns='cluster_id',
 754            thresh=None,
 755            norm=None 
 756    ):
 757
 758        if thresh is None:
 759            return "Please provide a response boundary threshhold as a tuple (ex: (-50,500))"
 760        else:
 761            thresh_min = thresh[0] - 0.5
 762            thresh_max = thresh[1] + 0.5
 763
 764        if stim_condition is 'all':
 765            stim_condition = self.stim_conditions
 766
 767        population = {}
 768        cond_col = []
 769        param_col = []
 770
 771        for condition in stim_condition:
 772            condition_start_times = self.condition_times(condition)['start']
 773
 774            for i, cluster_id in enumerate(include_units):
 775                if type(cluster_id) == np.ndarray:
 776                    unit_spike_times = cluster_id
 777                    cluster_id = f"n{i}"
 778                    
 779                else:
 780                    unit_spike_times = self.spike_data[cluster_id]['rel_spike_time']
 781
 782                # Raster
 783                x = self.raster(
 784                    condition_start_times, 
 785                    unit_spike_times,
 786                    thresh=thresh)
 787
 788                # Raster to histogram 
 789                h, bins = np.histogram(
 790                    x, 
 791                    bins=np.arange(thresh_min,thresh_max,1)
 792                )
 793
 794                # Nonresponsive unit histogram
 795                if h.min() == 0 and h.max() == 0:
 796                    h = np.zeros(len(h))
 797
 798                # Normalize the response
 799                else:
 800                    pass
 801                    norm_method = {
 802                        'minmax': self._minmax_norm(h),
 803                        'zscore': self._zscore_norm(h),
 804                        'average': self._average_response(h, len(condition_start_times))
 805                    }
 806
 807                    if norm is None: continue
 808
 809                    elif (norm is not None
 810                          and norm in list(norm_method.keys())):
 811                        h = norm_method[norm]
 812                    else:
 813                        raise _UnrecognizedNorm(
 814                            f"Normalization method is not recognized,\
 815                            please choose from the following:\
 816                            {list(norm_method.keys())}"
 817                        )
 818
 819                # fill the population dictionary
 820                if cluster_id not in population:
 821                    population[cluster_id] = h
 822                else:
 823                    population[cluster_id] = np.concatenate(
 824                        (population[cluster_id], h)
 825                    )
 826
 827            # append condition ids to the conditions column
 828            cond_col += [int(condition)]*len(h)
 829
 830            # append condition parameters
 831            if self._parameters_matched:
 832                params = list(
 833                    self.parameter_map.loc[self.parameter_map.condition == condition].values[0][1:]
 834                )
 835                param_col += [params] * len(h)
 836
 837        # Rearrange the dataframe
 838        if self._parameters_matched:
 839
 840            # Make the population & stimulus parameters dataframes
 841            population = pd.DataFrame(population)
 842            param_col = pd.DataFrame(
 843                param_col, columns=self.parameter_map.columns.values[1:]
 844            )
 845
 846            # Retrieve their column labels
 847            pcol = list(population.columns.values)
 848            pacol = list(param_col.columns.values)
 849
 850            # Add the condition id column & the stimulus parameters columns
 851            population['stimulus_condition'] = cond_col
 852            population = population.join(param_col)
 853
 854            # Rearrange column order
 855            new_columns = ['stimulus_condition'] + pacol + pcol
 856            population = population[new_columns]
 857
 858        else:
 859            # Make the population dataframe
 860            population = pd.DataFrame(population)
 861
 862            # Retrieve its column labels
 863            pcol = list(population.columns.values)
 864
 865            # Add the condition id column
 866            population['stimulus_condition'] = cond_col
 867
 868            # Rearrange column order
 869            population = population[['stimulus_condition']+pcol]
 870
 871        return population       
 872
 873    def _pr_stimcols(
 874            self,
 875            include_units, 
 876            stim_condition=None, 
 877            columns='cluster_id',
 878            thresh=None, 
 879            norm=None
 880    ):
 881
 882        if thresh is None:
 883            return "Please provide a response boundary threshhold as a tuple (ex: (-50,500))"
 884        else:
 885            thresh_min = thresh[0] - 0.5
 886            thresh_max = thresh[1] + 0.5
 887
 888        if stim_condition is 'all':
 889            stim_condition = np.sort(self.stim_conditions)
 890
 891        population = {}
 892        unit_col = []
 893
 894        for cluster_id in include_units:
 895            unit_spike_times = self.spike_data[cluster_id]['rel_spike_time']
 896
 897            for condition in stim_condition:
 898                condition_start_times = self.condition_times(condition)['start']
 899
 900                # Raster
 901                x = self.raster(
 902                    condition_start_times, 
 903                    unit_spike_times,
 904                    thresh=thresh)
 905
 906                # Raster to histogram 
 907                h, bins = np.histogram(
 908                    x, 
 909                    bins=np.arange(thresh_min,thresh_max,1)
 910                )
 911
 912                # Nonresponsive unit histogram
 913                if h.min() == 0 and h.max() == 0:
 914                    h = np.zeros(len(h))
 915
 916                # Normalize the response
 917                else:
 918                    pass
 919                    norm_method = {
 920                        'minmax': self._minmax_norm(h),
 921                        'zscore': self._zscore_norm(h),
 922                        'average': self._average_response(h, len(condition_start_times))
 923                    }
 924
 925                    if norm is None: continue
 926
 927                    elif (norm is not None
 928                          and norm in list(norm_method.keys())):
 929                        h = norm_method[norm]
 930                    else:
 931                        raise _UnrecognizedNorm(
 932                            f"Normalization method is not recognized,\
 933                            please choose from the following:\
 934                            {list(norm_method.keys())}"
 935                        )
 936
 937                # fill the population dictionary
 938                if condition not in population:
 939                    population[int(condition)] = h
 940                else:
 941                    population[condition] = np.concatenate(
 942                        (population[int(condition)], h)
 943                    )
 944
 945            # append condition ids to the conditions column
 946            unit_col += [cluster_id]*len(h)
 947
 948        # Rearrange the dataframe
 949        population = pd.DataFrame(population)
 950        pcol = list(population.columns.values)
 951        population['cluster_id'] = unit_col
 952        population = population[['cluster_id']+pcol]
 953
 954        return population       
 955
 956    def population_response(
 957            self,
 958            include_units,  # Units to include in the dataframe
 959            stim_condition=None,  # Stimulus condition(s) to include in the dataframe
 960            columns='cluster_id',  # Set column label arrangement
 961            thresh=None,  # Bounding threshold around the stimulus onset at t = 0 - pass as a tuple
 962            norm=None  # Normalization method - choose from: 'minmax', 'zscore', or 'average'
 963    ):
 964        """
 965        Returns a dataframe of the population response PSTH.
 966        By default, each column label represents the
 967        included units. A single column identifies
 968        the stimulus condition at each row. Each row is the
 969        average response at each time step.
 970
 971        Setting columns = "stimulus_condition" will
 972        return a data frame where each column label
 973        represents a stimulus condition. A single
 974        column identifes the included unit at each row.
 975        Each row is the average response at each time step.
 976
 977        Args:
 978
 979        - include_units: Units to include in the dataframe - pass as a 1d array or list like object.
 980        - stim_condition: Stimulus condition(s) to include in the dataframe - 
 981          pass a list of condition ids or 'all' to include all conditions.
 982        - columns: Set column label arrangement - pass either 'cluster_id' or 'stimulus_condition'. 
 983          Default argument is 'stimulus_condition'.
 984        - thresh: Bounding threshold around the stimulus onset at t = 0 - pass as a tuple.
 985        - norm: Normalization method - pass either 'minmax', 'zscore', or 'average'.
 986        """
 987
 988        # Case checks:
 989        if stim_condition is None:
 990            raise _NoStimulusCondition()
 991        elif stim_condition is 'all': pass
 992        elif set(stim_condition).issubset(set(self.stim_conditions)): pass
 993        else:
 994            raise _UnrecognizedStimulusCondition()
 995
 996        col_formats = ['cluster_id', 'stimulus_condition'] 
 997        if columns not in col_formats:
 998            raise _UnrecognizedColumnsInput(list(columns_dic.keys()))
 999
1000        elif columns == 'cluster_id':
1001            population = self._pr_unitcols(
1002            include_units,
1003            stim_condition=stim_condition,
1004            columns=columns,
1005            thresh=thresh,
1006            norm=norm)
1007
1008        elif columns == 'stimulus_condition':
1009            population = self._pr_stimcols(
1010            include_units,
1011            stim_condition=stim_condition,
1012            columns=columns,
1013            thresh=thresh,
1014            norm=norm)
1015
1016        return population
1017    
1018    def map_rf(self, rfunc, xpix, ypix, xvis, yvis, radius):
1019        """
1020        Map the spatiotemporal receptive field of a unit with 
1021        a response function across a stimulus space. 
1022        
1023        Args:
1024        
1025        - rfunc: The unit response function. Dim0 must be equal to
1026          the total number of stimulus conditions.  
1027        - xpix: The width of the image in pixels.
1028        - ypix: The height of the image in pixels.
1029        - xvis: The azimuth of the image in degrees.
1030        - yvis: The elevation of the image in degrees.
1031        - radius: The radius of the circular frame around the image in degrees.
1032        """
1033        
1034        
1035        self._stim_frame_map(xpix, ypix, xvis, yvis, radius)
1036        fmap = np.array([x for x in self.frame_map.values()])
1037        if fmap.T.shape[-1] == rfunc.shape[0]:
1038            return np.dot(fmap.T, rfunc)
1039        else:
1040            raise _InvalidRfuncShape((fmap.T.shape[-1], rfunc.shape[0]))
1041                    
1042    def _stim_frame_map(self, xpix, ypix, xvis, yvis, radius):
1043        frame_map = {}
1044        for i in range(len(self.parameter_map)):
1045            con = i+1
1046            dx = self.parameter_map.iloc[i]
1047            
1048            frame = self.static_grating(
1049                xpix, ypix,
1050                xvis, yvis,
1051                float(dx['Spatial Freq']), 
1052                float(dx['Orientation']), 
1053                float(dx['Phase'])*360,
1054                diameter = radius
1055                )
1056
1057            frame_map[con] = frame
1058
1059        self.frame_map = frame_map
1060
1061    def _stim_frames(self):
1062        cond = self.stim_data.stim_condition_ids.values
1063        self.stim_frames = np.array([self.frame_map[i] for i in cond])

Create an experiment object for a given recording block. Takes the spike data file path as the first argument and the stimulus data file path as the second argument. Initializing an experiment object generates some important class attributes:

.stim_data: A pandas dataframe with the stimulus data.

.spike_data: A dictionary object with the spiking data of all the identified clusters.

Args:

  • spikefile: Path to the spike data file.
  • stimfile: Path to the stimulus data file.
load_experiment(spikefile, stimfile, logfile, **kwargs)
331    def __init__(self, spikefile, stimfile, logfile, **kwargs):
332        ephys_toolkit.__init__(self)
333        
334        self._stim_m73 = None
335        self._spike_m73 = None
336        self._spikefile = spikefile # path to the spike file
337        self._stimfile = stimfile # path to the stim file
338        self._depth_data = kwargs['kwargs']['depth_data'] 
339        
340        try:
341            self.spikes_mat = scipy.io.loadmat(spikefile)
342            self.spikes = self.spikes_mat['Data'][0] # raw spikes dictionary
343        except NotImplementedError:
344            self._spike_m73 = True
345            self.spikes_mat = mat73.loadmat(spikefile)
346            self.spikes = self.spikes_mat['Data']
347            
348        try:
349            self.stims_mat = scipy.io.loadmat(stimfile)
350            self.stims = self.stims_mat['StimulusData'][0][0] # raw stims dictionary
351        except NotImplementedError:
352            self._stim_m73 = True
353            self.stims_mat = mat73.loadmat(stimfile)
354            self.stims = self.stims_mat['StimulusData']
355            
356        self._logfile = logfile # stimulus log data
357        self._nodf = False
358        self._init_stim_data()
359        self._init_spike_data()
360        self.set_time_unit()
361
362        self._parameters_matched = False
363        
364        if self._nodf:
365            self.stim_conditions = np.unique(
366                self.stim_data['stim_condition_ids']
367            )
368        else:
369            self.stim_conditions = self.stim_data[
370                'stim_condition_ids'
371            ].unique()
372        
373        if self._logfile != None:
374            self._get_conditions_from_log()
375        else:
376            r = r'([A-Z]{1,2}_M\d{1,}_Section_\d{1,}_BLK\d{1,})'
377            experiment_id = re.search(r, spikefile).group(1)
378            warn_text = f"""
379            No stimulus log file found for experiment: {experiment_id}.
380            If this experiment has more that 255 stimulus conditions, the stimulus 
381            condition IDs will be incorrect in the self.stim_data attribute.
382            """
383            warnings.warn(warn_text, stacklevel = 4)
def set_time_unit(self, bin_size=0.001):
520    def set_time_unit(self, bin_size=0.001):
521        """
522        Change the time unit of the relative spike times.
523        Give a bin size relative to 1 second.
524        IE: If you want a 1 ms bin size, enter 0.001;
525        if you want a 10 ms bin size, enter 0.01 etc.
526        
527        Args:
528        
529        - bin_size: Time unit given relative to 1 second. The default unit is 1ms/0.001s.
530        """
531        if self._nodf:
532            self.stim_data['stim_start_times'] = self._bin_events(bin_size,
533                self.stim_data['stim_start_indicies'])
534
535            self.stim_data['stim_stop_times'] = self._bin_events(bin_size,
536                self.stim_data['stim_stop_indicies'])  
537        else:
538            self.stim_data['stim_start_times'] = self._bin_events(bin_size,
539                self.stim_data['stim_start_indicies'].values)
540
541            self.stim_data['stim_stop_times'] = self._bin_events(bin_size,
542                self.stim_data['stim_stop_indicies'].values)
543
544            self.stim_data = self.stim_data[
545                ['stim_condition_ids',
546                 'stim_start_indicies',
547                 'stim_stop_indicies',
548                 'stim_start_times',
549                 'stim_stop_times']
550            ]
551
552        for i, unit in enumerate(self.spike_data):
553            try:
554                unit.update({
555                    'rel_spike_time': self._bin_events(bin_size,
556                                                       unit['spike_index'])
557                })
558            except TypeError as e:
559                if 'NoneType' in str(e):
560                    print(f"No spikes recorded for unit {i}, skipping...")
561                else:
562                    raise TypeError

Change the time unit of the relative spike times. Give a bin size relative to 1 second. IE: If you want a 1 ms bin size, enter 0.001; if you want a 10 ms bin size, enter 0.01 etc.

Args:

  • bin_size: Time unit given relative to 1 second. The default unit is 1ms/0.001s.
def condition_times(self, condition):
564    def condition_times(self, condition):
565        """
566        Returns a dictionary object of the start times and
567        stop times of a particular stimulus condition.
568        
569        Args:
570        
571        -condition: Condition id for the chosen stimulus condition.
572        """
573
574        condition_starts = self.stim_data.groupby(
575            self.stim_data.stim_condition_ids
576        ).get_group(condition)['stim_start_times'].values
577
578        condition_stops = self.stim_data.groupby(
579            self.stim_data.stim_condition_ids
580        ).get_group(condition)['stim_stop_times'].values
581
582        return {
583            'start': condition_starts,
584            'stop': condition_stops
585        }

Returns a dictionary object of the start times and stop times of a particular stimulus condition.

Args:

-condition: Condition id for the chosen stimulus condition.

def match_condition_parameters(self, params_file):
587    def match_condition_parameters(self, params_file):
588        """
589        Takes a parameters file and alters the .stim_data dataframe
590        to match stimulus condition parameters to their corresponding 
591        condition id.
592        
593        Args:
594        
595        - params_file: Path to the stimulus parameters file.
596        """
597        
598        # if parameters are given in a log file
599        if os.path.splitext(params_file)[1] == '.log':
600            df = self.stim_data
601            with open(params_file, 'r') as f:
602                lines = f.readlines()[1:-2]
603            
604            # read the lines to make a series of columns to insert
605            insert = []
606            for line in lines:
607                items = re.split(r'\t+', line)[2:-1]
608                dic = {
609                    item.split(':')[0]:float(item.split(':')[1].strip(' ')) 
610                    for item in items
611                }
612                insert.append(dic)
613            
614            # add the parameters to the stim_data dataframe
615            insert = pd.DataFrame(insert)
616            newcol = [list(df.columns)[0]] + list(insert.columns) + list(df.columns)[1:]
617            self.stim_data = df.join(insert)[newcol]
618            
619            # make the parameter map
620            stop_index = list(self.stim_data.columns).index('stim_start_indicies')
621            df_new = self.stim_data[list(self.stim_data.columns)[:stop_index]]
622            df_new.columns = ['condition']+list(df_new.columns)[1:]
623            df_new = df_new.drop_duplicates().sort_values(by = 'condition')
624            self.parameter_map = df_new.reset_index().drop('index', axis = 1)
625            
626            self._parameters_matched = True
627            
628        # if parameters are given in a matlab file        
629        elif os.path.splitext(params_file)[1] == '.mat':
630            
631            # load the parameter file and extract the keys
632            params = scipy.io.loadmat(params_file)
633            param_keys = list(params.keys())
634
635            # regex to get the parameter names + values
636            name_identifier = r'Var\d{1,}Val'
637            val_identifier = r'var\d{1,}value'
638
639            if 'fnames' in param_keys: # if experiment is natural images
640
641                # get the stim file names
642                stim_file_names = [
643                    name[0][0] for 
644                    name in scipy.io.loadmat(params_file)['fnames']
645                ]
646
647                # make a dictionary for the parameter mapping
648                ni_filemap = {
649                    'condition': [],
650                    'size': [],
651                    'filter': [],
652                    'file': []
653                }
654
655                # match condition to parameters
656                freg = r'^[A-Z]{2,3}'
657                for i in self.stim_conditions:
658                    if i%2 == 0: # if condition is even
659
660                        #append condition & size
661                        ni_filemap['condition'].append(i)
662                        ni_filemap['size'].append(60)
663
664                        #append filename
665                        filename = stim_file_names[int(i/2)-1]
666                        ni_filemap['file'].append(filename) 
667
668                        #append filtering condition
669                        filt = re.search(freg, filename).group(0)
670                        ni_filemap['filter'].append(filt)
671
672                    else: # if condition is odd
673                        #append condition & size
674                        ni_filemap['condition'].append(i)
675                        ni_filemap['size'].append(30)
676
677                        #append filename
678                        filename = stim_file_names[int((i+1)/2)-1]
679                        ni_filemap['file'].append(filename)
680
681                        #append filtering condition
682                        filt = re.search(freg, filename).group(0)
683                        ni_filemap['filter'].append(filt)
684
685                self.parameter_map = pd.DataFrame(ni_filemap) 
686                df = self.stim_data
687
688                # get the parameters to insert into the original dataframe
689                insert = []
690                for cond in df.stim_condition_ids.values:
691                    arr = self.parameter_map.loc[self.parameter_map.condition == cond].values[0]
692                    insert.append(arr)
693                insert = pd.DataFrame(insert, columns=self.parameter_map.columns.values)
694
695                # insert the parameter values into the original dataframe
696                df1 = df.join(insert)
697
698                # reset the stim_data attribute
699                self.stim_data = df1[
700                    list([df.columns.values[0]])
701                    + list(self.parameter_map.columns.values[1:])
702                    + list(
703                        df.columns.values[1:]
704                    )
705                    ]
706
707            else: # if experiment is gratings or checkerboard
708                # use regex to get the param names and values
709                name_keys = [key for key in param_keys if re.search(name_identifier, key)]
710                val_keys = [key for key in param_keys if re.search(val_identifier, key)]
711
712                # get the condition numbers
713                condition_range = len(params[val_keys[0]][0])
714                condition_ids = [i + 1 for i in range(condition_range)]
715
716                # map conditions to parameter values
717                parameter_map = {'condition': condition_ids}
718                for i in range(len(name_keys)):
719                    parameter_name = str(params[name_keys[i]][0])
720                    parameter_values = np.array(params[val_keys[i]][0])
721
722                    parameter_map[parameter_name] = parameter_values
723
724                    # parameter dataframe + the original stim_data dataframe
725                self.parameter_map = pd.DataFrame(parameter_map)
726                df = self.stim_data
727
728                # get the parameters to insert into the original dataframe
729                insert = []
730                for cond in df.stim_condition_ids.values:
731                    arr = self.parameter_map.loc[self.parameter_map.condition == cond].values[0]
732                    insert.append(arr)
733                insert = pd.DataFrame(insert, columns=self.parameter_map.columns.values)
734
735                # insert the parameter values into the original dataframe
736                df1 = df.join(insert)
737
738                # reset the stim_data attribute
739                self.stim_data = df1[
740                    list([df.columns.values[0]])
741                    + list(self.parameter_map.columns.values[1:])
742                    + list(
743                        df.columns.values[1:]
744                    )
745                    ]
746
747            self._parameters_matched = True

Takes a parameters file and alters the .stim_data dataframe to match stimulus condition parameters to their corresponding condition id.

Args:

  • params_file: Path to the stimulus parameters file.
def population_response( self, include_units, stim_condition=None, columns='cluster_id', thresh=None, norm=None):
 956    def population_response(
 957            self,
 958            include_units,  # Units to include in the dataframe
 959            stim_condition=None,  # Stimulus condition(s) to include in the dataframe
 960            columns='cluster_id',  # Set column label arrangement
 961            thresh=None,  # Bounding threshold around the stimulus onset at t = 0 - pass as a tuple
 962            norm=None  # Normalization method - choose from: 'minmax', 'zscore', or 'average'
 963    ):
 964        """
 965        Returns a dataframe of the population response PSTH.
 966        By default, each column label represents the
 967        included units. A single column identifies
 968        the stimulus condition at each row. Each row is the
 969        average response at each time step.
 970
 971        Setting columns = "stimulus_condition" will
 972        return a data frame where each column label
 973        represents a stimulus condition. A single
 974        column identifes the included unit at each row.
 975        Each row is the average response at each time step.
 976
 977        Args:
 978
 979        - include_units: Units to include in the dataframe - pass as a 1d array or list like object.
 980        - stim_condition: Stimulus condition(s) to include in the dataframe - 
 981          pass a list of condition ids or 'all' to include all conditions.
 982        - columns: Set column label arrangement - pass either 'cluster_id' or 'stimulus_condition'. 
 983          Default argument is 'stimulus_condition'.
 984        - thresh: Bounding threshold around the stimulus onset at t = 0 - pass as a tuple.
 985        - norm: Normalization method - pass either 'minmax', 'zscore', or 'average'.
 986        """
 987
 988        # Case checks:
 989        if stim_condition is None:
 990            raise _NoStimulusCondition()
 991        elif stim_condition is 'all': pass
 992        elif set(stim_condition).issubset(set(self.stim_conditions)): pass
 993        else:
 994            raise _UnrecognizedStimulusCondition()
 995
 996        col_formats = ['cluster_id', 'stimulus_condition'] 
 997        if columns not in col_formats:
 998            raise _UnrecognizedColumnsInput(list(columns_dic.keys()))
 999
1000        elif columns == 'cluster_id':
1001            population = self._pr_unitcols(
1002            include_units,
1003            stim_condition=stim_condition,
1004            columns=columns,
1005            thresh=thresh,
1006            norm=norm)
1007
1008        elif columns == 'stimulus_condition':
1009            population = self._pr_stimcols(
1010            include_units,
1011            stim_condition=stim_condition,
1012            columns=columns,
1013            thresh=thresh,
1014            norm=norm)
1015
1016        return population

Returns a dataframe of the population response PSTH. By default, each column label represents the included units. A single column identifies the stimulus condition at each row. Each row is the average response at each time step.

Setting columns = "stimulus_condition" will return a data frame where each column label represents a stimulus condition. A single column identifes the included unit at each row. Each row is the average response at each time step.

Args:

  • include_units: Units to include in the dataframe - pass as a 1d array or list like object.
  • stim_condition: Stimulus condition(s) to include in the dataframe - pass a list of condition ids or 'all' to include all conditions.
  • columns: Set column label arrangement - pass either 'cluster_id' or 'stimulus_condition'. Default argument is 'stimulus_condition'.
  • thresh: Bounding threshold around the stimulus onset at t = 0 - pass as a tuple.
  • norm: Normalization method - pass either 'minmax', 'zscore', or 'average'.
def map_rf(self, rfunc, xpix, ypix, xvis, yvis, radius):
1018    def map_rf(self, rfunc, xpix, ypix, xvis, yvis, radius):
1019        """
1020        Map the spatiotemporal receptive field of a unit with 
1021        a response function across a stimulus space. 
1022        
1023        Args:
1024        
1025        - rfunc: The unit response function. Dim0 must be equal to
1026          the total number of stimulus conditions.  
1027        - xpix: The width of the image in pixels.
1028        - ypix: The height of the image in pixels.
1029        - xvis: The azimuth of the image in degrees.
1030        - yvis: The elevation of the image in degrees.
1031        - radius: The radius of the circular frame around the image in degrees.
1032        """
1033        
1034        
1035        self._stim_frame_map(xpix, ypix, xvis, yvis, radius)
1036        fmap = np.array([x for x in self.frame_map.values()])
1037        if fmap.T.shape[-1] == rfunc.shape[0]:
1038            return np.dot(fmap.T, rfunc)
1039        else:
1040            raise _InvalidRfuncShape((fmap.T.shape[-1], rfunc.shape[0]))

Map the spatiotemporal receptive field of a unit with a response function across a stimulus space.

Args:

  • rfunc: The unit response function. Dim0 must be equal to the total number of stimulus conditions.
  • xpix: The width of the image in pixels.
  • ypix: The height of the image in pixels.
  • xvis: The azimuth of the image in degrees.
  • yvis: The elevation of the image in degrees.
  • radius: The radius of the circular frame around the image in degrees.
class lfp_tools(ephys_toolkit):
1065class lfp_tools(ephys_toolkit):
1066
1067    #### initialize class with the necessary paths ###
1068    def __init__(self):
1069        ephys_toolkit.__init__(self)
1070        self._low = 1
1071        self._high = 120
1072        
1073    ### process the lfp data by running the necssary methods ###
1074    def process_lfp(self, intan_file, probe = None):
1075        """
1076        This method runs the lfp processing pipeline and
1077        generates two important attributes:
1078        
1079        - .lfp_heatmaps: contains a dictionary the heatmaps of the 
1080          lfp data for each channel column and each stimulus contrast.
1081          
1082        - .depth_data: contains the depth in micrometeres of each 
1083          channel normalized to the center of layer 4 as 0. 
1084          Negative values are ventral to layer 4 and positive values
1085          are dorsal to layer 4.
1086        
1087        """
1088        self._probe_fol = os.path.join(self._modpath, 'probes')
1089        valid_probes0 = os.listdir(self._probe_fol)
1090        valid_probes = [os.path.splitext(x)[0] for x in valid_probes0]
1091        if probe == None:
1092            inputtext = fr"""
1093            Please enter the probe used in this project. Available probes include: 
1094            {valid_probes}. 
1095            If the probe used in your recording is not included in the list, 
1096            navigate to the folder where the ephystoolkit lirbary is installed. 
1097            
1098            If you are using Anaconda on windows, this folder should be located at:
1099            C:\Users\(username)\conda\envs\(env_name)\lib\site-packages\ephystoolkit
1100            
1101            If you are using Anaconda on Linux, this folder should be located at:
1102            ~/anaconda3/envs/(env_name)/lib/(Python_version)/site-packages/ephystoolkit'
1103            
1104            Once you are in the module folder, navigate the to folder called 'probes'
1105            and copy a csv file mapping your channel ids to their distance from the tip
1106            of the probe. Each line index should correspond to the channel id. Indexes
1107            for this purpose start at 1. For reference, check out any of the included
1108            csv files."""
1109            
1110            probe = input(inputtext)
1111        while probe not in valid_probes:
1112            inputtext = fr"""
1113            {probe} is not included in the list of available probes. The list of available 
1114            probes includes: 
1115            {valid_probes}
1116            If the probe used in your recording is not included in the list, 
1117            navigate to the folder in which the ephys_toolkit module is installed. 
1118
1119            If you are using Anaconda on windows, this folder should be located at:
1120            C:\Users\(username)\conda\envs\(env_name)\lib\site-packages\ephystoolkit
1121
1122            If you are using Anaconda on Linux, this folder should be located at:
1123            ~/anaconda3/envs/(env_name)/lib/(Python_version)/site-packages/ephystoolkit
1124
1125            Once you are in the module folder, navigate the to folder called 'probes'
1126            and copy a csv file mapping your channel ids to their distance from the tip
1127            of the probe. Each line index should correspond to the channel id. Indexes
1128            for this purpose start at 1. For reference, check out any of the included
1129            csv files.
1130            """
1131            probe = input(inputtext)
1132        else:
1133            pass
1134        
1135        self._probe = probe
1136        self.intan_file = intan_file
1137        self.channel_file = os.path.join(self._probe_fol, f"{probe}.csv")
1138        
1139        self._load_lfp_data()
1140        
1141        print("Sorting channel depth...")
1142        self._sort_depth()
1143        
1144        print("Retrieving stimulus onset times...")
1145        self._get_onsets()
1146        
1147        print("Generating lfp heatmap...")
1148        self._get_lfp_heatmap()
1149        
1150        print("Normalizing channel depth to layer 4 depth...")
1151        self._find_l4_distances()
1152        self._normalize_depth()
1153        print("Done!")
1154    
1155    ### bandpass filter for the raw voltage data ###
1156    def _bandpass_filter(self, signal):
1157        
1158        #SAMPLING_RATE = 20000.0
1159        nyqs = 0.5 * self._SAMPLING_RATE
1160        low = self._low/nyqs
1161        high = self._high/nyqs
1162
1163        order = 2
1164
1165        b, a = scipy.signal.butter(order, [low, high], 'bandpass', analog = False)
1166        y = scipy.signal.filtfilt(b, a, signal, axis=0)
1167
1168        return y
1169
1170    ### load the necessary data ###
1171    def _load_lfp_data(self):
1172        
1173        # load the rhd file
1174        self.intan_data = data = rhd.read_data(self.intan_file)
1175        self.amplifier_data = self.intan_data['amplifier_data']
1176        self.board_data = self.intan_data['board_dig_in_data']
1177
1178        # load the channel file
1179        self.channel_data = []
1180        with open(self.channel_file, newline='') as channels:
1181            for row in csv.reader(channels):
1182                self.channel_data.append(np.array(row).astype(float))
1183        self.channel_data = np.array(self.channel_data)
1184
1185        # bandpass filter each channel
1186        print("Bandpass filtering the data...")
1187        self.amplifier_data_filt = np.array([
1188            self._bandpass_filter(channel) for
1189            channel in self.amplifier_data
1190        ]
1191        )
1192        
1193    ### sort amplifier data by channel depth ###
1194    def _sort_depth(self):
1195        ## 0 = deepest
1196        
1197        # adjust single channels offset on x by a negligable amount
1198        if self._probe == '64D':
1199            self.channel_data[np.where(self.channel_data == 16)] = 20
1200            self.channel_data[np.where(self.channel_data == -16)] = -20
1201        if self._probe == '64H':
1202            self.channel_data[np.where(self.channel_data == 20)] = 22.5
1203            self.channel_data[np.where(self.channel_data == -20)] = -22.5
1204            self.channel_data[np.where(self.channel_data == 180.1)] = 177.6
1205            self.channel_data[np.where(self.channel_data == 220.1)] = 222.6
1206        
1207        # index ordered by distance from tip
1208        self.y_index = np.argsort(self.channel_data[:,1])[::-1]
1209        
1210        # list of channel depths ordered by distance from tip
1211        self.c_depth = self.channel_data[self.y_index][:,1]
1212
1213        # unique x coordinates for the channels
1214        xarr = np.unique(self.channel_data[:,0])
1215        
1216        # get channel depths along specific columns
1217        self.channels_bycol = {}
1218        for x_d in xarr: 
1219            col = np.where(self.channel_data[self.y_index][:,0] == x_d)[0]
1220            self.channels_bycol[x_d] = {
1221                'y_index': self.y_index[col],
1222                'c_depth': self.channel_data[self.y_index[col]][:,1]
1223            }
1224        
1225        # get amplifier data along specific columns
1226        self.ampdata_bycol = {}    
1227        for col, subdict in self.channels_bycol.items():
1228            self.ampdata_bycol[col] = self.amplifier_data_filt[
1229                subdict['y_index']
1230            ]
1231
1232    
1233    ### Get the onset times of each stimuls contrast ###
1234    def _get_onsets(self):
1235        
1236        # get indices of when contrast 0 is on
1237        stim0 = self.board_data[1]
1238        stim0_on_index = (np.where(stim0 == True)[0])
1239
1240        # get indices of when contrast 1 is on
1241        stim1 = self.board_data[2]
1242        stim1_on_index = (np.where(stim1 == True)[0])
1243
1244        # get the onset indices of contrast 0
1245        first0_on = stim0_on_index[0]
1246        self.c0_onsets = [first0_on]
1247        for i0, val in enumerate(stim0_on_index[1:]):
1248            i1 = i0+1
1249            if stim0_on_index[i0]+1 != (stim0_on_index[i1]):
1250                self.c0_onsets.append(val)
1251
1252        # get the onset indices of contrast 1
1253        first1_on = stim1_on_index[0]
1254        self.c1_onsets = [first1_on]
1255        for i0, val in enumerate(stim1_on_index[1:]):
1256            i1 = i0+1
1257            if stim1_on_index[i0]+1 != (stim1_on_index[i1]):
1258                self.c1_onsets.append(val)
1259                
1260    ### get the heatmap of lfp data averaged across stimulus repeats ###
1261    def _get_lfp_heatmap(self):
1262
1263        self.lfp_heatmaps = {}
1264        for col, subdict in self.ampdata_bycol.items():
1265            self.lfp_heatmaps[col] = {
1266                'contrast0': np.array([
1267                    subdict[:,i:i+5000] for i in self.c0_onsets
1268                ]).mean(0),
1269                'contrast1': np.array([
1270                    subdict[:,i:i+5000] for i in self.c1_onsets
1271                ]).mean(0),
1272
1273            }
1274        
1275    ### assign a depth to each channel relative to layer 4 ###
1276    def _find_l4_distances(self):
1277        
1278        self.l4_distances = {}
1279        self.l4_distance_list = []
1280        for col, chandata in self.channels_bycol.items():
1281            lfp_heatmap = self.lfp_heatmaps[col]
1282            self.l4_distances[col] = {}
1283            for c, contrast in lfp_heatmap.items():
1284            
1285                # find the timepoint of the deepest sink
1286                pe_delay = 1000
1287                tmin = np.where(contrast[:,pe_delay:] == contrast[:,pe_delay:].min())[1][0]
1288                tmin_curve = contrast[:,tmin+pe_delay]
1289
1290                ## Find the center of layer 4 via cubic spline interpolation
1291
1292                # flip the order because the interpolation function
1293                # strictly requires an x axis with ascending order
1294                x = chandata['c_depth'][::-1]
1295                y = tmin_curve
1296
1297                # initialize the interpolation function
1298                cs = scipy.interpolate.CubicSpline(x, y)
1299
1300                # interpolate the data and flip it back to the correct order
1301                xnew = np.linspace(0, x[-1], 1575)[::-1]
1302                ynew = cs(xnew)[::-1]
1303
1304                # get the layer 4 distance from tip
1305                l4_distance = xnew[np.where(ynew == ynew.min())[0][0]].round(2)
1306                self.l4_distances[col][c] = l4_distance
1307                self.l4_distance_list.append(l4_distance)
1308        self.l4_distance_list = np.array(self.l4_distance_list)
1309    
1310    def _normalize_depth(self):
1311        
1312        # find the average distance across channel
1313        # columns and stimulus contrasts
1314        avg_distance = self.l4_distance_list.mean()
1315
1316        # normalize channel distances by distance from layer 4
1317        # negative distances = below layer 4
1318        # positive distances = above layer 4
1319        l4_normalization = self.c_depth - avg_distance
1320
1321        # compile the depth data into a dataframe
1322        depth_data = {
1323            'channel': self.y_index+1,
1324            'distance': l4_normalization
1325        }
1326        self._depth_data = pd.DataFrame(depth_data)        
def process_lfp(self, intan_file, probe=None):
1074    def process_lfp(self, intan_file, probe = None):
1075        """
1076        This method runs the lfp processing pipeline and
1077        generates two important attributes:
1078        
1079        - .lfp_heatmaps: contains a dictionary the heatmaps of the 
1080          lfp data for each channel column and each stimulus contrast.
1081          
1082        - .depth_data: contains the depth in micrometeres of each 
1083          channel normalized to the center of layer 4 as 0. 
1084          Negative values are ventral to layer 4 and positive values
1085          are dorsal to layer 4.
1086        
1087        """
1088        self._probe_fol = os.path.join(self._modpath, 'probes')
1089        valid_probes0 = os.listdir(self._probe_fol)
1090        valid_probes = [os.path.splitext(x)[0] for x in valid_probes0]
1091        if probe == None:
1092            inputtext = fr"""
1093            Please enter the probe used in this project. Available probes include: 
1094            {valid_probes}. 
1095            If the probe used in your recording is not included in the list, 
1096            navigate to the folder where the ephystoolkit lirbary is installed. 
1097            
1098            If you are using Anaconda on windows, this folder should be located at:
1099            C:\Users\(username)\conda\envs\(env_name)\lib\site-packages\ephystoolkit
1100            
1101            If you are using Anaconda on Linux, this folder should be located at:
1102            ~/anaconda3/envs/(env_name)/lib/(Python_version)/site-packages/ephystoolkit'
1103            
1104            Once you are in the module folder, navigate the to folder called 'probes'
1105            and copy a csv file mapping your channel ids to their distance from the tip
1106            of the probe. Each line index should correspond to the channel id. Indexes
1107            for this purpose start at 1. For reference, check out any of the included
1108            csv files."""
1109            
1110            probe = input(inputtext)
1111        while probe not in valid_probes:
1112            inputtext = fr"""
1113            {probe} is not included in the list of available probes. The list of available 
1114            probes includes: 
1115            {valid_probes}
1116            If the probe used in your recording is not included in the list, 
1117            navigate to the folder in which the ephys_toolkit module is installed. 
1118
1119            If you are using Anaconda on windows, this folder should be located at:
1120            C:\Users\(username)\conda\envs\(env_name)\lib\site-packages\ephystoolkit
1121
1122            If you are using Anaconda on Linux, this folder should be located at:
1123            ~/anaconda3/envs/(env_name)/lib/(Python_version)/site-packages/ephystoolkit
1124
1125            Once you are in the module folder, navigate the to folder called 'probes'
1126            and copy a csv file mapping your channel ids to their distance from the tip
1127            of the probe. Each line index should correspond to the channel id. Indexes
1128            for this purpose start at 1. For reference, check out any of the included
1129            csv files.
1130            """
1131            probe = input(inputtext)
1132        else:
1133            pass
1134        
1135        self._probe = probe
1136        self.intan_file = intan_file
1137        self.channel_file = os.path.join(self._probe_fol, f"{probe}.csv")
1138        
1139        self._load_lfp_data()
1140        
1141        print("Sorting channel depth...")
1142        self._sort_depth()
1143        
1144        print("Retrieving stimulus onset times...")
1145        self._get_onsets()
1146        
1147        print("Generating lfp heatmap...")
1148        self._get_lfp_heatmap()
1149        
1150        print("Normalizing channel depth to layer 4 depth...")
1151        self._find_l4_distances()
1152        self._normalize_depth()
1153        print("Done!")

This method runs the lfp processing pipeline and generates two important attributes:

  • .lfp_heatmaps: contains a dictionary the heatmaps of the lfp data for each channel column and each stimulus contrast.

  • .depth_data: contains the depth in micrometeres of each channel normalized to the center of layer 4 as 0. Negative values are ventral to layer 4 and positive values are dorsal to layer 4.

class load_project(lfp_tools, load_experiment):
1328class load_project(lfp_tools, load_experiment):
1329    """
1330    Initialize the load_project class with a full path to the
1331    directory containing the project files. If the LFP data rhd
1332    file is included in the project directory, this class will 
1333    automatically process the LFP data for the project. 
1334    
1335    The .workbook attribute contains a list of dictionaries
1336    with the following structure:
1337    
1338
1339      
1340          {
1341          
1342              'section_id': int - Identification number of the recording 
1343               section,
1344              
1345              'spike_sorting_metrics': Pandas dataframe - Contains spike
1346               sorting metrics data,
1347              
1348              'lfp_heatmaps': dictionary - Contains the lfp heatmap
1349               at each channel column and stimulus contrast,
1350              
1351              'depth_data': Pandas dataframe - Contains the distance of each
1352               channel of the probe relative to layer 4,
1353              
1354              'blocks': [
1355              
1356                  {
1357                  
1358                  'block_id': int - Identification number of the recording block, 
1359                  
1360                  'experiment', experiment object - Contains the experiment data for
1361                   the given block
1362                  
1363                  },
1364                  
1365                  ]
1366                  
1367          },
1368    
1369    Args:
1370    
1371    - project_path: Path to the directory containing the project files.
1372    """
1373
1374    def __init__(self, project_path, probe = None, use_lfp_file = 0):
1375        lfp_tools.__init__(self)
1376        self._lfp_index = use_lfp_file
1377        self._probe = probe
1378        self._ppath = project_path
1379        self._init_project_workbook()
1380
1381    # generate the workbook of project data
1382    def _init_project_workbook(self):
1383        explorer = path_explorer()
1384
1385        # find and sort spike files
1386        spike_files = explorer.findext(self._ppath, '.mat', r='firings')
1387        spike_files.sort(key=lambda x: int(re.search(r'BLK(\d{1,})', x).group(1)))
1388
1389        # find and sort stim files
1390        stim_files = explorer.findext(self._ppath, '.mat', r='stimulusData')
1391        stim_files.sort(key=lambda x: int(re.search(r'BLK(\d{1,})', x).group(1)))
1392        
1393        # find and sort log files
1394        log_files = explorer.findext(self._ppath, '.log')
1395        log_files.sort(key=lambda x: int(re.search(r'BLK(\d{1,})', x).group(1)))
1396        
1397        # count how many log files are in the data folder
1398        log_diff = len(spike_files)-len(log_files)
1399        for i in range(log_diff):
1400            log_files.append(None)
1401
1402        # zip matching blocks
1403        matched_block_files = zip(spike_files, stim_files, log_files)
1404
1405        # find metrics files
1406        metrics_files = explorer.findext(self._ppath, '.json', r='metrics_isolation')
1407        metrics_files.sort(key=lambda x: int(re.search(r'Section_(\d{1,})', x).group(1)))
1408        
1409        # find checkerboard rhd files
1410        rhd_files = explorer.findext(self._ppath, '.rhd')
1411        rhd_files.sort(key=lambda x: int(re.search(r'Section_(\d{1,})', x).group(1)))
1412        
1413        # group together multiple checkerboard recordings
1414        # from the same section
1415        i = 0
1416        for rhd_file in rhd_files[1:]:
1417            if type(rhd_files[i])!=list:
1418                r = r"[A-Z][A-Z]_M\d{1,}_Section_(\d)"
1419                id_sec_post = re.search(r, rhd_file).group(0)
1420                id_sec_pre = re.search(r, rhd_files[i]).group(0)
1421                if id_sec_post == id_sec_pre:
1422                    rhd_files[i] = [rhd_files[i], rhd_file]
1423                    post_index = rhd_files.index(rhd_file)
1424                    del rhd_files[post_index]
1425
1426            elif (type(rhd_files[i])==list):
1427                id_sec_post = re.search(r, rhd_file).group(0)
1428                id_sec_pre = re.search(r, rhd_files[i][0]).group(0)
1429                if id_sec_post == id_sec_pre:
1430                    rhd_files[i].append(rhd_file)
1431                    post_index = rhd_files.index(rhd_file)
1432                    del rhd_files[post_index]
1433            else:
1434                i+=1
1435
1436        ################################################################################
1437
1438        # compile the workbook
1439        self.workbook = []
1440        
1441        # compile metrics
1442        for metrics_file in metrics_files:
1443            section_parent = int(re.search(r'Section_(\d{1,})', metrics_file).group(1))
1444            df = self.spike_sorting_metrics(metrics_file)
1445            self.workbook.append(
1446                {
1447                    'section_id': section_parent,
1448                    'spike_sorting_metrics': df,
1449                    'lfp_heatmaps': None,
1450                    'depth_data': None,
1451                    'blocks': []
1452                }
1453            )
1454            
1455        # compile rhd
1456        if rhd_files == []:
1457            warn_text = """
1458            No LFP data files found in this project folder. LFP data will be
1459            unavailable for this project. Please include the LFP data rhd file 
1460            in the project folder if you want to access the LFP data.
1461            """
1462            warnings.warn(warn_text, stacklevel = 4)
1463        else:
1464            for rhd_file in rhd_files:
1465                if (type(rhd_file) == list) & (self._lfp_index == 0):
1466                    warn_text = """
1467                    WARNING: More than one LFP data file found in this section.
1468                    Defaulting to the first LFP file found in the section. If 
1469                    You wish to change which file to be used, pass an integer 
1470                    value to the use_lfp_file parameter of the load_project class
1471                    corresponding to the file you wish to use. 
1472                    (ie: load_project(use_lfp_file = 2) lets you use the 2nd LFP 
1473                    data file.).
1474                    """
1475                    warnings.warn(warn_text, stacklevel = 4)
1476                    
1477                    rhd_file = rhd_file[self._lfp_index]
1478                elif (type(rhd_file) == list) & (self._lfp_index != 0):
1479                    use_lfp_file = use_lfp_file-1
1480                    rhd_file = rhd_file[self._lfp_index]
1481                else:
1482                    pass
1483                
1484                self.process_lfp(rhd_file, self._probe)
1485                section_parent = int(re.search(r'Section_(\d{1,})', rhd_file).group(1))
1486                self.workbook[section_parent-1]['lfp_heatmaps'] = self.lfp_heatmaps
1487                self.workbook[section_parent-1]['depth_data'] = self._depth_data
1488
1489        # match experiment (block) objects to section
1490        for matched_files in list(matched_block_files):
1491            # a regex to get the experiment identity
1492            ex_r = r'[A-Z]{2}_M\d+_Section_\d+_BLK\d+' 
1493            experiment_id = re.search(ex_r, matched_files[0]).group(0)
1494            
1495            section_child = int(re.search(r'Section_(\d{1,})', matched_files[0]).group(1))
1496            block = int(re.search(r'BLK(\d{1,})', matched_files[0]).group(1))
1497            
1498            experiment = load_experiment(*matched_files, kwargs = {'depth_data': self._depth_data})
1499            self.workbook[section_child - 1]['blocks'].append({
1500                'block_id': block,
1501                'experiment': experiment
1502            })
1503            print(f"Sucessfully loaded {experiment_id}.")

Initialize the load_project class with a full path to the directory containing the project files. If the LFP data rhd file is included in the project directory, this class will automatically process the LFP data for the project.

The .workbook attribute contains a list of dictionaries with the following structure:

  {

      'section_id': int - Identification number of the recording 
       section,

      'spike_sorting_metrics': Pandas dataframe - Contains spike
       sorting metrics data,

      'lfp_heatmaps': dictionary - Contains the lfp heatmap
       at each channel column and stimulus contrast,

      'depth_data': Pandas dataframe - Contains the distance of each
       channel of the probe relative to layer 4,

      'blocks': [

          {

          'block_id': int - Identification number of the recording block, 

          'experiment', experiment object - Contains the experiment data for
           the given block

          },

          ]

  },

Args:

  • project_path: Path to the directory containing the project files.
load_project(project_path, probe=None, use_lfp_file=0)
1374    def __init__(self, project_path, probe = None, use_lfp_file = 0):
1375        lfp_tools.__init__(self)
1376        self._lfp_index = use_lfp_file
1377        self._probe = probe
1378        self._ppath = project_path
1379        self._init_project_workbook()