fmatoolbox 0.1.2__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fmatoolbox
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Python port of FMAToolbox
5
5
  Author: Pietro Bozzo
6
6
  License-Expression: GPL-3.0-or-later
@@ -11,11 +11,13 @@ Requires-Python: >=3.11
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
13
  Requires-Dist: numpy
14
+ Requires-Dist: numba
14
15
  Requires-Dist: scipy
15
16
  Requires-Dist: matplotlib
16
17
  Requires-Dist: statsmodels
17
- Requires-Dist: scikit-learn
18
- Requires-Dist: scikit-image
18
+ Provides-Extra: assemblies
19
+ Requires-Dist: scikit-learn; extra == "assemblies"
20
+ Requires-Dist: scikit-image; extra == "assemblies"
19
21
  Dynamic: license-file
20
22
 
21
23
  # FMAToolbox (Python Port)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fmatoolbox"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "Python port of FMAToolbox"
9
9
  authors = [{name = "Pietro Bozzo"}]
10
10
  readme = "README.md"
@@ -13,9 +13,14 @@ license = "GPL-3.0-or-later"
13
13
  requires-python = ">=3.11"
14
14
  dependencies = [
15
15
  "numpy",
16
+ "numba",
16
17
  "scipy",
17
18
  "matplotlib",
18
19
  "statsmodels",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ assemblies = [
19
24
  "scikit-learn",
20
25
  "scikit-image",
21
26
  ]
@@ -3,20 +3,19 @@
3
3
  import fmatoolbox.general
4
4
  import numpy as np
5
5
  import scipy as sp
6
- import sklearn.decomposition as skdc
7
- import skimage.filters as skif
8
6
  import joblib
9
7
  import statsmodels.stats.multitest
10
8
  import warnings
9
+ from numba import njit
11
10
  from typing import Callable
12
11
 
13
12
 
14
13
  def istantaneousRate(samples, start:float=None, stop:float=None, bin:float=None, step:int=None, smooth:float=None, g_range:tuple[int,int]=None):
15
- '''
14
+ """
16
15
  estimate the istantaneous rate of a point process from a realization of its time stamps, e.g., the firing rate from spike times
17
16
 
18
17
  arguments:
19
- samples (n,:) float, every row is either [sample time (s)] or [sample time (s), process id]; if 1d, interpreted as (n,1)
18
+ samples (n,) float | (n,2) float, every row is either [sample time (s)] or [sample time (s), process id]; if 1d, interpreted as (n,1)
20
19
  start float = min(samples[:,0]) s, time to start count at
21
20
  stop float = max(samples[:,0]) s, time to stop count at
22
21
  bin float = 0.05 s, time bin to count samples
@@ -27,7 +26,7 @@ def istantaneousRate(samples, start:float=None, stop:float=None, bin:float=None,
27
26
 
28
27
  output:
29
28
  rate (:,g+1) float, every row is [time stamp, rates for g processes], g is 1 if 'samples' has just one column
30
- '''
29
+ """
31
30
 
32
31
  # validate input
33
32
  samples = np.asarray(samples)
@@ -78,107 +77,41 @@ def istantaneousRate(samples, start:float=None, stop:float=None, bin:float=None,
78
77
  rate = np.array(rate).T / bin
79
78
 
80
79
  # make time axis
81
- t = bin/step/2 + np.linspace(0,(n_bins-1)*bin,n_bins*step)
80
+ t = start + bin/step/2 + np.linspace(0,(n_bins-1)*bin,n_bins*step)
82
81
 
83
82
  # apply smoothing
84
83
  if smooth is not None:
85
- firing_rate = sp.ndimage.gaussian_filter(firing_rate,smooth,axes=0)
84
+ rate = sp.ndimage.gaussian_filter(rate,smooth,axes=0)
86
85
 
87
86
  return np.column_stack((t,rate))
88
87
 
89
88
 
90
- def firingRate(spikes, start:float=None, stop:float=None, bin_size:float=None, step:int=None, smooth:float=None, u_range:tuple[int,int]=None):
91
- # estimate istantaneous firing rate from spike times
92
- #
93
- # arguments:
94
- # spikes (n,:) float, every row is either [spike time] (s) or [spike time, unit id]
95
- # start float = min(spike_times) s, time to start count at
96
- # stop float = max(spike_times) s, time to stop count at
97
- # bin_size float = 0.05 s, time bin to count spikes
98
- # step int = 1, firing rate is computed in windows of length 'bin_size' and overlap 'bin_size' / 'step', default is no overlap
99
- # smooth float = None, gaussian kernel std for smoothing over time
100
- # u_range (2) int, range of units to consider in computation, default is [min(spikes[:,1]), max(spikes[:,1])]
101
- # (boundaries included, only for 2-columns 'spikes')
102
- #
103
- # output:
104
- # firing_rate (:,m+1) float, every row is [time stamp, firing rates for m units], m is 1 if spikes has just one column
105
-
106
- warnings.warn("firingRate is deprecated, use istantaneousRate instead!", DeprecationWarning)
107
-
108
- # validate input
109
- spikes = np.asarray(spikes)
110
- if bin_size is None: bin_size = 0.05
111
- if step is None: step = 1
112
- if step % 1 or step == 0:
113
- raise ValueError("'step' must be a non-zero integer")
114
-
115
- units = []
116
- if spikes.ndim == 1:
117
- times = spikes
118
- elif spikes.shape[1] == 1:
119
- times = spikes.reshape(-1)
120
- else:
121
- times = spikes[:,0]
122
- units = spikes[:,1]
123
- if u_range is None:
124
- u_range = np.unique(units).astype(int)[[0,-1]]
125
- else:
126
- u_range = np.array(u_range,dtype=int)
127
-
128
- # build time bins, overlapping if requested
129
- if start is None:
130
- start = times.min()
131
- if stop is None:
132
- stop = times.max()
133
- time_bins = [np.arange(start,stop+bin_size,bin_size) + i*bin_size/step for i in range(step)]
134
-
135
- if len(units) == 0:
136
- # compute firing rate once
137
- firing_rate = [np.histogram(times,bins=tb)[0] for tb in time_bins]
138
- # flatten and convert to Hz
139
- firing_rate = np.array(firing_rate).reshape((-1,1),order='F') / bin_size
140
- else:
141
- # compute firing rate once per unit and stack into a matrix
142
- firing_rate = []
143
- for u in range(u_range[0],u_range[-1]+1):
144
- fr = [np.histogram(times[units==u],bins=tb)[0] for tb in time_bins]
145
- firing_rate.append(np.array(fr).flatten('F'))
146
- firing_rate = np.array(firing_rate).T / bin_size
147
-
148
- # center times into time bins
149
- time_bins = [(tb[:-1] + tb[1:]) / 2 for tb in time_bins]
150
- time_bins = np.array(time_bins).reshape((-1,1),order='F')
151
-
152
- # apply smoothing
153
- if smooth is not None:
154
- firing_rate = sp.ndimage.gaussian_filter(firing_rate,smooth,axes=0)
155
-
156
- return np.concatenate((time_bins,firing_rate),1)
89
+ def PETH(samples, events, groups=None, g_range:tuple[int,int]=None, limits:tuple[float,float]=None, n_bins:int=None, bin:float=None, step:int=None, smooth:float=None, fast:bool=False):
90
+ '''
91
+ compute peri-event time histogram of a signal relative to synchronizing events
157
92
 
93
+ arguments:
94
+ samples float, either:
95
+ - (n) array of time stamps (s), describing a point process
96
+ - (n,:) array, where each row is [time stamp (s), value1, ...], describing one or more continous signals
97
+ events (m) float, synchronizing events' times, their order is maintained in the output 'mat'
98
+ groups (n) int, grouping indeces for samples, to compute separate PETHs (only for point process 'samples')
99
+ g_range (2) int = [0,max(groups)], min and max group id
100
+ groups (:) int, grouping indeces for samples, to compute separate PETHs (only for point process 'samples')
101
+ limits (2) float = [-0.5,0.5] (s), defines a window around events, divided into 'n_bins' time bins to compute PETH
102
+ n_bins float = 101, number of time bins around event times
103
+ bin float = None (s), bin size, can be given instead of 'n_bins', which will be deduced from 'bin' and 'limits'
104
+ step int = 1, only for point-process 'samples', for values higher than 1, time bins inside a window will overlap, yielding:
105
+ - bin_size of (limit[1]-limit[0]) / n_bins, unchanged
106
+ - time resolution of bin_size / step
107
+ smooth float = None, gaussian kernel std to smooth mean output 'm' over time, has no effect on 'mat'
108
+ fast bool = False, if True, 'samples' must be time sorted to save computation time (only for point process 'samples')
158
109
 
159
- def PETH(samples, events, groups=None, g_range:tuple[int,int]=None, limits:tuple[float,float]=None, n_bins:int=None, bin:float=None, step:int=None, fast:bool=False):
160
- # compute peri-event time histogram of a signal relative to synchronizing events
161
- #
162
- # arguments:
163
- # samples float, either:
164
- # - (n) array of time stamps (s), describing a point process
165
- # - (n,:) array, where each row is [time stamp (s), value1, ...], describing one or more continous signals
166
- # events (m) float, synchronizing events' times, their order is maintained in the output 'mat'
167
- # groups (n) int, grouping indeces for samples, to compute separate PETHs (only for point process 'samples')
168
- # g_range (2) int = [0,max(groups)], min and max group id
169
- # groups (:) int, grouping indeces for samples, to compute separate PETHs (only for point process 'samples')
170
- # limits (2) float = [-0.5,0.5] (s), defines a window around events, divided into 'n_bins' time bins to compute PETH
171
- # n_bins float = 101, number of time bins around event times
172
- # bin float = None (s), bin size, can be given instead of 'n_bins', which will be deduced from 'bin' and 'limits'
173
- # step int = 1, only for point-process 'samples', for values higher than 1, time bins inside a window will overlap, yielding:
174
- # - bin_size of (limit[1]-limit[0]) / n_bins, unchanged
175
- # - time resolution of bin_size / step
176
- # fast bool = False, if True, 'samples' must be time sorted to save computation time (only for point process 'samples')
177
- #
178
- # output:
179
- # mat (m,n_bins) float, every row corresponds to samples centered on an event
180
- # t (n_bins) float, times (s)
181
- # m (n_bins) float, average samples across events
110
+ output:
111
+ mat (m,n_bins) float, every row corresponds to samples centered on an event
112
+ t (n_bins) float, times (s)
113
+ m (n_bins) float, average of 'samples' across events
114
+ '''
182
115
 
183
116
  # default values
184
117
  samples = np.array(samples,ndmin=1)
@@ -233,7 +166,7 @@ def PETH(samples, events, groups=None, g_range:tuple[int,int]=None, limits:tuple
233
166
  valid = counts > 0
234
167
  # repeat event indices according to how many samples they match
235
168
  event_idx = np.repeat(np.arange(len(events))[valid],counts[valid])
236
- sample_idx = np.concatenate([np.arange(l,r) for l, r in zip(left[valid],right[valid])])
169
+ sample_idx = np.concatenate([np.arange(l,r) for l, r in zip(left[valid],right[valid])]) if np.any(valid) else []
237
170
  # build lists of matches
238
171
  e_rep = events[event_idx]
239
172
  s_sel = samples[sample_idx]
@@ -258,11 +191,193 @@ def PETH(samples, events, groups=None, g_range:tuple[int,int]=None, limits:tuple
258
191
  if squeeze:
259
192
  mat = mat.reshape(mat.shape[:2])
260
193
 
261
- m = np.mean(mat,axis=0)
194
+ m = np.nanmean(mat,axis=0)
195
+ if smooth is not None:
196
+ m = sp.ndimage.gaussian_filter(m,smooth,axes=0)
262
197
 
263
198
  return mat, t, m
264
199
 
265
200
 
201
+ def jointPETH(samples, events, bin:float=None, step:int=None, n_bins=None, limits=None, return_peths:bool=None, smooth:float=None):
202
+ '''
203
+ compute joint peri-event time histogram of two signals A and B relative to synchronizing events, i.e., the co-occurrence rate (Hz)
204
+ for different time-lag pairs applied to A and B
205
+
206
+ arguments:
207
+ samples (2,) tuple, whose elements can be either:
208
+ - (n) float of time stamps (s), describing a point process
209
+ - (n,:) float, where each row is [time stamp (s), value1, ...], describing one or more continous signals
210
+ events (m) float, synchronizing events' times, their order is maintained in the output 'mat' /!\
211
+ bin float = 0.05 (s), bin size to compute PETHs
212
+ n_bins int | (2,) int = 101, number of time bins around event times, either one for both PETHS or two values
213
+ limits (2,) float | (2,2) float = None, can be given instead of 'n_bins', which will be deduced from 'bin' and 'limits', and
214
+ defines a [start, stop] window (in s) around events, divided into time bins of size 'bin' to compute PETHs; can be a
215
+ single tuple for both PETHS or a tuple of tuples
216
+ step int = 1, only for point-process 'samples', for values higher than 1, time bins inside each window will overlap, yielding:
217
+ - bin_size of (limit[1]-limit[0]) / n_bins, unchanged
218
+ - time resolution of bin_size / step
219
+ smooth float = None, gaussian kernel std to smooth mean output 'peth' over time, has no effect on other outputs
220
+ ARGS YET TO TEST
221
+ groups (n) int, grouping indeces for samples, to compute separate PETHs (only for point process 'samples')
222
+ g_range (2) int = [0,max(groups)], min and max group id
223
+ groups (:) int, grouping indeces for samples, to compute separate PETHs (only for point process 'samples')
224
+ fast bool = False, if True, 'samples' must be time sorted to save computation time (only for point process 'samples')
225
+
226
+ output:
227
+ joint (n_bins0,n_bins1) float, lagged co-occurrency rate (Hz), dimensions correspond to time lags w.r.t. 'events' for
228
+ A and B, respectively
229
+ null (n_bins0,n_bins1) float, null model for 'joint' in case A and B occurr independently, conditioned on 'events';
230
+ i.e., their conditioned co-occurrence probability is the product of their average event PETHs
231
+ difference (n_bins0,n_bins1) float, 'joint' - 'null', co-occurrence rate (Hz) unexplained by the null model, values far from 0 suggest
232
+ that conditioning on 'events' is not sufficient to explain the co-occurrence of the two signals
233
+ '''
234
+
235
+ # 1. ensure PETHs are produced with the same bin size
236
+ isscalar = lambda x: x is None or np.isscalar(x)
237
+ if bin is None: bin = 0.05
238
+ # duplicate single-peth inputs for two peths
239
+ if isscalar(n_bins):
240
+ n_bins = [n_bins,n_bins]
241
+ else:
242
+ n_bins = list(n_bins)
243
+ if limits is None:
244
+ limits = [None,None]
245
+ else:
246
+ limits = [list(limits),list(limits)] if np.isscalar(limits[0]) and np.isscalar(limits[1]) else [l if isscalar(l) else list(l) for l in limits]
247
+ # deduce 'n_bins' or 'limits'
248
+ for i in [0,1]:
249
+ if n_bins[i] is None:
250
+ if limits[i] is None:
251
+ n_bins[i] = 101
252
+ limits[i] = (-bin * n_bins[i] / 2, bin * n_bins[i] / 2)
253
+ else:
254
+ window = np.diff(limits[i]).item()
255
+ n_bins[i] = np.ceil(window / bin).astype(int)
256
+ extra = n_bins[i] * bin - window
257
+ limits[i][0] -= extra / 2
258
+ limits[i][1] += extra / 2
259
+ else:
260
+ limits[i] = (-bin * n_bins[i] / 2, bin * n_bins[i] / 2)
261
+
262
+ # 2. PETHs
263
+ mat0, t0, mean0 = PETH(samples[0],events,limits=limits[0],n_bins=n_bins[0],bin=bin,step=step)
264
+ mat1, t1, mean1 = PETH(samples[1],events,limits=limits[1],n_bins=n_bins[1],bin=bin,step=step)
265
+ dt = t0[1] - t0[0]
266
+ if not np.isclose(dt, t1[1]-t1[0]):
267
+ raise ValueError('time resolution of the two PETHs must coincide')
268
+
269
+ # observed co-occurrence between 'samples0' and 'samples1' at every time bin
270
+ joint = (mat0.T @ mat1) / len(events)
271
+ # expected co-occurrence if the signals were independent: product
272
+ null = np.outer(mean0, mean1)
273
+ # convert to Hz
274
+ joint = np.sqrt(joint) / dt
275
+ null = np.sqrt(null) / dt
276
+ # difference(i,j) = sqrt[ mean_e( mat0(e,i) * mat1(e,j) ) ] - sqrt[ mean0(i) * mean1(j) ]
277
+ difference = joint - null
278
+
279
+ if return_peths:
280
+ if smooth is not None:
281
+ # recompute with smoothing
282
+ _, _, mean0 = PETH(samples[0],events,limits=limits[0],n_bins=n_bins[0],bin=bin,step=step,smooth=smooth)
283
+ _, _, mean1 = PETH(samples[1],events,limits=limits[1],n_bins=n_bins[1],bin=bin,step=step,smooth=smooth)
284
+ mean0 /= dt
285
+ mean1 /= dt
286
+ return joint, null, difference, t0, t1, mean0, mean1
287
+ return joint, null, difference, t0, t1
288
+
289
+
290
+ @njit
291
+ def _ccg_numba(times, proc, nproc, bin_width, lag_start, lag_stop):
292
+ # ccg: (reference process, target process, lag bin)
293
+
294
+ n = len(times)
295
+ nbins = int(np.ceil((lag_stop - lag_start) / bin_width))
296
+ inv_bin = nbins / (lag_stop - lag_start) # faster than dividing at every iteration
297
+
298
+ n_samples = np.zeros(nproc,dtype=np.int64)
299
+ ccg = np.zeros((nproc,nproc,nbins),dtype=np.int64)
300
+ for i in range(n):
301
+ ti = times[i]
302
+ pi = proc[i]
303
+ n_samples[pi] += 1
304
+
305
+ # find first event that can contribute, starting from next event
306
+ j0 = i + 1
307
+ while j0 < n and times[j0] - ti < lag_start:
308
+ j0 += 1
309
+ j = j0
310
+
311
+ while j < n:
312
+ dt = times[j] - ti
313
+ if dt >= lag_stop:
314
+ break
315
+ pj = proc[j]
316
+
317
+ # positive lag contribution
318
+ if dt >= lag_start:
319
+ b = int((dt - lag_start) * inv_bin)
320
+ if 0 <= b < nbins:
321
+ ccg[pi,pj,b] += 1
322
+
323
+ # negative lag contribution (swap reference and target)
324
+ if -dt >= lag_start and -dt < lag_stop:
325
+ b = int((-dt - lag_start) * inv_bin)
326
+ if 0 <= b < nbins:
327
+ ccg[pj,pi,b] += 1
328
+
329
+ j += 1
330
+
331
+ return ccg, n_samples
332
+
333
+
334
+ def CCG(samples, bin:float=None, limits:tuple[float,float]=None, fast:bool=None, norm:str=None):
335
+ '''
336
+ compute cross-correlograms from time stamps generated by point processes
337
+
338
+ arguments:
339
+ samples (:,) float | (:,2) float, every row is either [sample time (s)] or [sample time (s), process id]; if 1d, interpreted as (n,1)
340
+ bin float = 0.05 s, time bin to count samples
341
+ limits (2) float = [-0.5,0.5] (s), defines a window around time stamps, divided into time bins to compute CCG
342
+ fast bool = False, if True, 'samples' must be time sorted to save computation time
343
+ norm str = {'rate','count'}, determines unit of measurement of 'ccg'
344
+
345
+ output:
346
+ ccg DESCRIBE
347
+ t DESCRIBE
348
+ '''
349
+
350
+ if bin is None: bin = 0.05
351
+ if limits is None: limits = (-0.5,0.5)
352
+ if norm is None: norm = 'rate'
353
+
354
+ samples = np.asarray(samples)
355
+ if samples.ndim == 1:
356
+ times = samples.astype(np.float64)
357
+ id = np.zeros(len(times),dtype=np.int64)
358
+ elif samples.shape[1] == 2:
359
+ times = samples[:,0].astype(np.float64)
360
+ id = samples[:,1].astype(np.int64)
361
+ else:
362
+ raise ValueError("'samples' must be (n,) or (n,2)")
363
+ nproc = id.max() + 1
364
+
365
+ # sort by time
366
+ if not fast:
367
+ order = np.argsort(times)
368
+ times = times[order]
369
+ id = id[order]
370
+
371
+ ccg, n_samples = _ccg_numba(times, id, nproc, float(bin), float(limits[0]), float(limits[1]))
372
+ edges = np.linspace(limits[0],limits[1],ccg.shape[2]+1)
373
+ t = (edges[1:] + edges[:-1]) / 2
374
+
375
+ if norm == 'rate':
376
+ ccg = ccg.astype(float) / (n_samples[:,None,None] * bin)
377
+
378
+ return ccg, t
379
+
380
+
266
381
  def avalanchesFromProfile(x, threshold:float, time_step:float, t0:float=0):
267
382
  # compute avalanches' sizes and [start,stop] intervals from a time series
268
383
  #
@@ -304,6 +419,12 @@ def avalanchesFromProfile(x, threshold:float, time_step:float, t0:float=0):
304
419
  def cellAssembliesICA(spikes, window:float=None, when=None, drop_mix:bool=False):
305
420
  # detect assemblies from spike trains with PCA + ICA
306
421
 
422
+ try:
423
+ import sklearn.decomposition as skdc
424
+ import skimage.filters as skif
425
+ except ImportError as e:
426
+ raise ImportError('cellAssembliesICA requires scikit-learn, did you do: pip install "fmatoolbox[assemblies]" ?') from e
427
+
307
428
  if window is None: window = 0.025
308
429
 
309
430
  raster = firingRate(spikes,bin_size=window)
@@ -1,5 +1,5 @@
1
- ''' Session data handling functions for FMAToolbox '''
2
- from typing import Any, Callable
1
+ ''' Functions to load data for a recording session '''
2
+
3
3
  import pathlib
4
4
  import scipy.io
5
5
  import numpy as np
@@ -10,27 +10,32 @@ import re
10
10
  import fmatoolbox.exceptions
11
11
  import collections
12
12
  import concurrent.futures
13
-
14
-
15
- def loadSpikeTimes(session:str, output:str='dict', anat_file:str=None, return_elec:bool=False, return_loc:bool=False, reload:bool=False):
16
- # load spikes from a session
17
- #
18
- # arguments:
19
- # session string, path to session .xml file, spike files must be in session folder
20
- # output string = None, determines output type, can be:
21
- # 'dict', dictionary of spike times per unit
22
- # 'compact', (:,2) array of [timestamps, unit ids]
23
- # 'full', (:,2) array of [timestamps, electrode groups, clusters]
24
- # 'regions', dict of {region id: dict}, entries are {'spikes': region spikes, 'units': region units}
25
- # anat_file str = None, DESCRIBE
26
- # return_elec bool = False, if true, return cluster_loc
27
- # return_loc bool = False, if true, return cluster_loc
28
- # reload bool = False, load original files, bypassing Regions/<basename>_spikes.npz backup (for 'output' = 'regions')
29
- #
30
- # output:
31
- # spikes (see 'output')
32
- # electrodes (n) float, optional, electrode id per unit
33
- # cluster_loc (n) float, optional, index of max spike-amplitude cluster per unit
13
+ import xml.etree.ElementTree
14
+ from typing import Any, Callable
15
+ from os import PathLike
16
+
17
+
18
+ def loadSpikeTimes(session:str|PathLike[str], output:str='dict', anat_file:str=None, return_elec:bool=False, return_loc:bool=False, reload:bool=False):
19
+ """
20
+ load spikes for a session
21
+
22
+ arguments:
23
+ session file, path to session .xml file, spike files must be in session folder
24
+ output string = None, determines output type, either:
25
+ - 'dict', dictionary of spike times per unit
26
+ - 'compact', (:,2) array of [timestamps, unit ids]
27
+ - 'full', (:,2) array of [timestamps, electrode groups, clusters]
28
+ - 'regions', dict of {region id: dict}, entries are {'spikes': region spikes, 'units': region units}
29
+ anat_file str = None, DESCRIBE
30
+ return_elec bool = False, if true, return cluster_loc
31
+ return_loc bool = False, if true, return cluster_loc
32
+ reload bool = False, load original files, bypassing Regions/<basename>_spikes.npz backup (for 'output' = 'regions')
33
+
34
+ output:
35
+ spikes (see 'output')
36
+ electrodes (n) float, optional, electrode id per unit
37
+ cluster_loc (n) float, optional, index of max spike-amplitude cluster per unit
38
+ """
34
39
 
35
40
  file_root = pathlib.Path(session).with_suffix('')
36
41
  cell_metrics_file = file_root.with_suffix('.cell_metrics.cellinfo.mat')
@@ -50,7 +55,7 @@ def loadSpikeTimes(session:str, output:str='dict', anat_file:str=None, return_el
50
55
  return out[:2+return_loc:2-return_elec]
51
56
 
52
57
 
53
- def loadCellMetricsFile(session:str, output:str='dict', anat_file:str=None, return_extra:bool=False, reload:bool=False):
58
+ def loadCellMetricsFile(session:str|PathLike[str], output:str='dict', anat_file:str=None, return_extra:bool=False, reload:bool=False):
54
59
 
55
60
  file_root = pathlib.Path(session).with_suffix('')
56
61
 
@@ -92,7 +97,7 @@ def loadCellMetricsFile(session:str, output:str='dict', anat_file:str=None, retu
92
97
  if anat_file is None:
93
98
  raise ValueError("'anat_file' must be provided when 'output' = 'regions'")
94
99
  anat = loadAnatomyFile(anat_file)
95
- anat = anat[anat['rat'] == int(file_root.stem[3:6])] # keep rat of interest, deduced from file name
100
+ anat = anat[anat['rat'] == int(file_root.stem[3:6])] # keep rat of interest, deduced from file name
96
101
  ids = np.unique(anat['region'])
97
102
  spikes = {str(id): {} for id in ids}
98
103
  for id in ids:
@@ -108,7 +113,7 @@ def loadCellMetricsFile(session:str, output:str='dict', anat_file:str=None, retu
108
113
  s = np.concatenate(s)
109
114
  spikes[id]['spikes'] = s[s[:,0].argsort()]
110
115
  else:
111
- spikes[id]['spikes'] = np.array(s,ndmin=2)
116
+ spikes[id]['spikes'] = np.empty((0,2))
112
117
  # save
113
118
  saveRegionSpikes(spike_file,spikes)
114
119
 
@@ -120,13 +125,19 @@ def loadCellMetricsFile(session:str, output:str='dict', anat_file:str=None, retu
120
125
  return spikes
121
126
 
122
127
 
123
- def loadCluFiles(session:str, rate:float=20000, output:str='dict', anat_file:str=None, reload:bool=False):
128
+ def loadCluFiles(session:str|PathLike[str], rate:float=None, output:str='dict', anat_file:str=None, reload:bool=False):
124
129
 
125
130
  if output not in ['dict','compact','full','regions']:
126
131
  raise ValueError("'output' must be 'dict', 'compact', 'full', or 'regions'")
127
132
 
128
- # load .res and .clu files
133
+ # load parameters n_channels from .xml
129
134
  froot = pathlib.Path(session).with_suffix('')
135
+ if rate is None:
136
+ tree = xml.etree.ElementTree.parse(froot.with_suffix(".xml"))
137
+ root = tree.getroot()
138
+ rate = float(root.find(".//samplingRate").text)
139
+
140
+ # load .res and .clu files
130
141
  res_files = {int(p.name[len(froot.name)+5:]): p for p in froot.parent.iterdir()
131
142
  if p.is_file() and p.name.startswith(froot.name + '.res.') and p.name[len(froot.name)+5:].isdigit()}
132
143
  clu_files = {int(p.name[len(froot.name)+5:]): p for p in froot.parent.iterdir()
@@ -203,7 +214,7 @@ def loadCluFiles(session:str, rate:float=20000, output:str='dict', anat_file:str
203
214
  return spikes
204
215
 
205
216
 
206
- def loadAnatomyFile(file_path):
217
+ def loadAnatomyFile(file_path:str|PathLike[str]):
207
218
  # load .anat file, whose columns must be [rat, electrode, brain region] (comma separated)
208
219
  #
209
220
  # arguments:
@@ -212,7 +223,7 @@ def loadAnatomyFile(file_path):
212
223
  return np.genfromtxt(file_path,delimiter=",",comments="%",dtype=[("rat",int),("electrode",int),("region","U50")])
213
224
 
214
225
 
215
- def loadRegionSpikes(file):
226
+ def loadRegionSpikes(file:str|PathLike[str]):
216
227
  spike_npz = np.load(file)
217
228
  spikes = {r[3:]: {'spikes': spike_npz[r]} for r in spike_npz.files if r.startswith('sp_')}
218
229
  for reg in spikes:
@@ -221,7 +232,7 @@ def loadRegionSpikes(file):
221
232
  return spikes
222
233
 
223
234
 
224
- def saveRegionSpikes(file,spikes):
235
+ def saveRegionSpikes(file:str|PathLike[str], spikes):
225
236
  file = pathlib.Path(file)
226
237
  if not file.parent.exists():
227
238
  pathlib.Path.mkdir(file.parent)
@@ -231,7 +242,7 @@ def saveRegionSpikes(file,spikes):
231
242
  return
232
243
 
233
244
 
234
- def loadEventFile(filename: str, compact: bool = False):
245
+ def loadEventFile(filename:str|PathLike[str], compact:bool=False):
235
246
  # load events from a .evt file
236
247
  #
237
248
  # arguments:
@@ -289,8 +300,8 @@ def loadEventFile(filename: str, compact: bool = False):
289
300
  return events
290
301
 
291
302
 
292
- def loadEvents(session:str, extra:str|list[str]):
293
- # load event files from a session
303
+ def loadEvents(session:str|PathLike[str], extra:str|list[str]):
304
+ # load event files for a session
294
305
  #
295
306
  # arguments:
296
307
  # session string, path to session .xml file, event files must be in session folder
@@ -340,7 +351,7 @@ def loadEvents(session:str, extra:str|list[str]):
340
351
  return events, cat_names
341
352
 
342
353
 
343
- def loadSpikeWaveforms(session: str):
354
+ def loadSpikeWaveforms(session:str|PathLike[str]):
344
355
 
345
356
  file_root = pathlib.Path(session).with_suffix('')
346
357
  data = scipy.io.loadmat(file_root.with_suffix('.cell_metrics.cellinfo.mat'),simplify_cells=True)['cell_metrics']
@@ -351,7 +362,98 @@ def loadSpikeWaveforms(session: str):
351
362
  return
352
363
 
353
364
 
354
- def loadLFP(session: str):
365
+ def loadWideband(session:str|PathLike[str], dtype=None, channels=None, intervals=None, skip:int=None, cat:bool=None, trim:bool=None):
366
+ '''
367
+ load wideband data for a session, which is organized in records: chunks of data containing one sample for each channel
368
+
369
+ arguments:
370
+ session file, path to session .xml file, spike files must be in session folder
371
+ dtype type = np.int16
372
+ channels (:,) int = None, channels to load, if None, load all channels; counted base 0 as NeuroScope
373
+ intervals (:,2) float = None, time intervals to load, if None, load all data
374
+ skip int = 0, number of records to skip after each record is read (to subsample data)
375
+ cat bool = True, concatenate loaded data into a single array; if False, return a list of arrays for each interval
376
+ trim bool = False, trim each loaded chunk of data so that they all have the same number of time points (useful if
377
+ 'intervals' are of the same length)
378
+
379
+ output:
380
+ data (records, channels) dtype, data for each channel, each row is a record; list of arrays if 'cat' is False
381
+ t (records,) float, time of each record; list of arrays if 'cat' is False (contrary to FMAT standard 't' returned
382
+ separately from 'data' to preserve different data types)
383
+ '''
384
+
385
+ file_root = pathlib.Path(session).with_suffix("")
386
+ dat_file = file_root.with_suffix(".dat")
387
+
388
+ # load parameters n_channels from .xml
389
+ tree = xml.etree.ElementTree.parse(file_root.with_suffix(".xml"))
390
+ root = tree.getroot()
391
+ frequency = float(root.find(".//samplingRate").text)
392
+ n_channels = int(root.find(".//nChannels").text)
393
+
394
+ if dtype is None: dtype = np.int16
395
+ if channels is not None: channels = np.array(channels, ndmin=1)
396
+ if intervals is None: intervals = [[0,np.inf]]
397
+ intervals = np.array(intervals,ndmin=2)
398
+ if skip is None: skip = 0
399
+ if cat is None: cat = True
400
+
401
+ sample_size = np.dtype(dtype).itemsize
402
+ file_size = dat_file.stat().st_size
403
+ results = []
404
+ t = []
405
+ min_len = np.inf
406
+ with open(dat_file,'rb') as f:
407
+ for start, stop in intervals:
408
+
409
+ # locate 'start' in file
410
+ record_offset = int(np.floor(start * frequency))
411
+ byte_offset = record_offset * n_channels * sample_size
412
+ f.seek(byte_offset)
413
+
414
+ # copute number of records to read
415
+ if np.isinf(stop):
416
+ remaining_bytes = file_size - byte_offset
417
+ n_records = remaining_bytes // (n_channels * sample_size)
418
+ else:
419
+ n_records = int(np.floor((stop-start) * frequency))
420
+ max_records = (file_size - byte_offset) // (n_channels * sample_size)
421
+ n_records = min(n_records,max_records)
422
+
423
+ raw = np.fromfile(f,dtype=dtype,count=n_records*n_channels).reshape((-1,n_channels))
424
+ if raw.size == 0:
425
+ break
426
+
427
+ # select requested channels
428
+ if channels is not None:
429
+ raw = raw[:,channels]
430
+ # apply skipping
431
+ if skip > 0:
432
+ raw = raw[::skip+1]
433
+
434
+ results.append(raw)
435
+ t.append(np.linspace(start, start+raw.shape[0]/frequency*(skip+1), raw.shape[0]))
436
+ min_len = min(min_len,raw.shape[0])
437
+
438
+ # handle empty output
439
+ if len(results) == 0:
440
+ if channels is None: channels = np.arange(n_channels)
441
+ results = [np.empty((0,len(channels)),dtype=dtype)]
442
+ t = [np.empty(0)]
443
+
444
+ if trim:
445
+ results = [r[:min_len,:] for r in results]
446
+ t = [_t[:min_len] for _t in t]
447
+
448
+ # concatenate results from intervals
449
+ if cat:
450
+ results = np.vstack(results)
451
+ t = np.concatenate(t)
452
+
453
+ return results, t
454
+
455
+
456
+ def loadLFP(session:str|PathLike[str]):
355
457
 
356
458
  print('implement!')
357
459
 
@@ -365,9 +467,36 @@ def saveMatrix():
365
467
 
366
468
  return
367
469
 
470
+
471
+ def printXML(session:str|PathLike[str]):
472
+ """
473
+ simple utility to print contents of a session's .xml file
474
+ """
475
+
476
+ def print_xml(element,level=0):
477
+ indent = " " * level
478
+ # print tag and attributes
479
+ print(f"{indent}{element.tag}",end="")
480
+ if element.attrib:
481
+ print(f" {element.attrib}",end="")
482
+ # print text if present
483
+ text = element.text.strip() if element.text else ""
484
+ if text:
485
+ print(f" -> {text}",end="")
486
+ print()
487
+ # recurse into children
488
+ for child in element:
489
+ print_xml(child,level+1)
490
+
491
+ tree = xml.etree.ElementTree.parse(pathlib.Path(session).with_suffix(".xml"))
492
+ root = tree.getroot()
493
+ print_xml(root)
494
+
495
+ return
496
+
368
497
  # functions to run batch
369
498
 
370
- def readBatchFile(file_path: str):
499
+ def readBatchFile(file_path:str|PathLike[str]):
371
500
  # read batch file
372
501
  #
373
502
  # arguments:
@@ -425,7 +554,7 @@ def _batchWorker(payload):
425
554
  return -i-1, e
426
555
 
427
556
 
428
- def runBatch(batch_file:str, func:Callable, args:list[list[Any]]=None, rnd_seed:str=None, kwargs:dict|list[dict]=None, ignore_args:bool=False,
557
+ def runBatch(batch_file:str|PathLike[str], func:Callable, args:list[list[Any]]=None, rnd_seed:str=None, kwargs:dict|list[dict]=None, ignore_args:bool=False,
429
558
  sessions:list[int]=None, parallel:bool|int=False, verbose:bool=True) -> tuple[list, ...]:
430
559
  # run a routine on multiple sessions
431
560
  #
@@ -154,12 +154,9 @@ def restrict(samples, intervals, shift:bool=False, s_ind:bool=False, i_ind:bool=
154
154
  # samples (p,m) float, restricted samples, i. e., rows of 'samples' whose first element falls into 'intervals'
155
155
  # Is (n) bool, optional, indicese of original samples which were kept
156
156
  # Ii (n) bool, optional, indicese of intervals which contain kept samples
157
-
158
- samples = np.asarray(samples)
159
- if samples.size == 0:
160
- samples = np.array([])
161
157
 
162
158
  # promote 1d arrays to 2d
159
+ samples = np.asarray(samples)
163
160
  squeeze = samples.ndim == 1
164
161
  if squeeze:
165
162
  samples = samples.reshape((-1,1))
@@ -1,5 +1,6 @@
1
1
  ''' Plotting utilities for publication grade figures '''
2
2
 
3
+ import fmatoolbox
3
4
  import numpy as np
4
5
  import numpy.typing as npt
5
6
  import matplotlib.axes as mpla
@@ -9,11 +10,13 @@ import matplotlib.typing as mplt
9
10
  import scipy.stats as spst
10
11
  import scipy as sp
11
12
  from collections.abc import Iterable
13
+ from networkx.algorithms.centrality import flow_matrix
12
14
  from typing import Literal, Callable
15
+ import matplotlib as mpl
13
16
 
14
17
 
15
18
  def adjustAxes(axs:mpla.Axes|Iterable[mpla.Axes], format:Literal['paper','poster']='paper'):
16
- # adjust axes properties to emprove figure appearance
19
+ # adjust axes properties to improve figure appearance
17
20
  #
18
21
  # arguments:
19
22
  # axs iterable of matplotlib.axes.Axes
@@ -26,8 +29,11 @@ def adjustAxes(axs:mpla.Axes|Iterable[mpla.Axes], format:Literal['paper','poster
26
29
 
27
30
  lw = 1 if format == 'paper' else 2
28
31
  axw = 1.3 if format == 'paper' else 2.1
29
- axtick = 9 if format == 'paper' else 14
30
- axfont = 10 if format == 'paper' else 18
32
+ ax_label_fs = 9 if format == 'paper' else 18
33
+ ax_label_pad = 0.1 if format == 'paper' else 1
34
+ ax_tick_fs = 8 if format == 'paper' else 14
35
+ ax_tick_l = 2 if format == 'paper' else 5
36
+ ax_tick_pad = 1 if format == 'paper' else 2
31
37
 
32
38
  for ax in axs:
33
39
 
@@ -36,9 +42,11 @@ def adjustAxes(axs:mpla.Axes|Iterable[mpla.Axes], format:Literal['paper','poster
36
42
 
37
43
  # adjust thickness and font size
38
44
  [ax.spines[spine].set_linewidth(lw) for spine in ['bottom','left','polar'] if spine in ax.spines]
39
- ax.tick_params(width=axw,labelsize=axtick)
40
- ax.xaxis.label.set_fontsize(axfont)
41
- ax.yaxis.label.set_fontsize(axfont)
45
+ ax.tick_params(width=axw,labelsize=ax_tick_fs,pad=ax_tick_pad,length=ax_tick_l)
46
+ ax.xaxis.label.set_fontsize(ax_label_fs)
47
+ ax.yaxis.label.set_fontsize(ax_label_fs)
48
+ ax.xaxis.labelpad = ax_label_pad
49
+ ax.yaxis.labelpad = ax_label_pad
42
50
 
43
51
  return
44
52
 
@@ -141,8 +149,8 @@ def plotXY(data, start=None, stop=None, color:mplt.ColorType=None, label=None, a
141
149
  return
142
150
 
143
151
 
144
- def plotColorMap(data:npt.NDArray[np.floating], vmin:float=None, vmax:float=None, zscore=None, sortby:npt.NDArray[np.floating]|Callable|str=None, sortax:int=0,
145
- xzoom:float=None, yzoom:float=None, x=None, y=None, aspect:float=3/4, ax:mpla.Axes=None):
152
+ def plotColorMap(data:npt.NDArray[np.floating], vmin:float=None, vmax:float=None, zscore=None, sortby:npt.NDArray[np.floating]|Callable|str=None, sortax:int=None,
153
+ xzoom:float=None, yzoom:float=None, x=None, y=None, aspect:float=None, bar:str=None, ax:mpla.Axes=None):
146
154
  '''
147
155
  plot a 2D array as a colormap with optional normalization, sorting, and resampling
148
156
 
@@ -159,6 +167,7 @@ def plotColorMap(data:npt.NDArray[np.floating], vmin:float=None, vmax:float=None
159
167
  xzoom, yzoom float = None, optional horizontal / vertical resampling factor passed to ``scipy.ndimage.zoom`` (after sorting)
160
168
  x, y (:,) float, coordinates corresponding to columns and rows of `data`, defaults are range(m) and range(n)
161
169
  aspect float = 3/4, image aspect ratio
170
+ bar str = None, if given, draw colorbar next to `ax` with label specified by `bar`
162
171
  ax matplotlib.axes.Axes = matplotlib.pyplot.gca(), axes to plot in
163
172
  '''
164
173
 
@@ -170,6 +179,7 @@ def plotColorMap(data:npt.NDArray[np.floating], vmin:float=None, vmax:float=None
170
179
  zscore = None
171
180
  data = spst.zscore(data,axis=zscore,nan_policy='omit')
172
181
 
182
+ if sortax is None: sortax = 0
173
183
  if sortby is not None:
174
184
  if isinstance(sortby,str) and sortby == 'peak':
175
185
  sortby = lambda x : np.argsort(np.argmax(x,1-sortax))
@@ -197,8 +207,11 @@ def plotColorMap(data:npt.NDArray[np.floating], vmin:float=None, vmax:float=None
197
207
  if ax is None:
198
208
  ax = plt.gca()
199
209
 
210
+ if aspect is None: aspect = 3 / 4
200
211
  ax.set_aspect(aspect)
201
212
  im = ax.imshow(data,aspect='auto',vmin=vmin,vmax=vmax,origin='lower',extent=[x[0]-dx,x[-1]+dx,y[0]-dy,y[-1]+dy])
213
+ if bar is not None:
214
+ plt.colorbar(im,label=bar,ax=ax)
202
215
 
203
216
  return im
204
217
 
@@ -214,6 +227,7 @@ def semPlot(x, y, ci:str|Callable=None, zscore:bool|int=None, color:mplt.ColorTy
214
227
  - 'nansem', standard error of the mean (SEM) for each column of `y`, ignoring missing values
215
228
  - callable, must have signature ``low, high = ci(y)``
216
229
  zscore bool = False | int, if True (or 1), z-score w.r.t. average of y, if 2, z-score each row of y independently
230
+ smooth float = None, gaussian kernel std for smoothing over time, NOT IMPLEMENTED
217
231
  color color = None
218
232
  mode str = 'area' | 'error', plot 'ci' either as a shaded area or as error bars
219
233
  alpha float = 0.5, shaded area transparency value (only for 'area' mode)
@@ -419,30 +433,28 @@ def pHorzLine(p, t=None, dy=None, color:mplt.ColorType=None, ax:mpla.Axes=None):
419
433
  return
420
434
 
421
435
 
422
- def plotIntervals(intervals, alpha=0.3, color:mplt.ColorType='gray', ax:mpla.Axes=None):
423
-
424
- intervals = np.array(intervals,ndmin=2)
425
- if intervals.ndim != 2 or intervals.shape[1] != 2:
426
- raise ValueError("'intervals' must have shape (n,2)")
436
+ def plotIntervals(intervals, color:mplt.ColorType='gray', alpha=0.3, label:str=None, ax:mpla.Axes=None, **plot_kwargs):
427
437
 
438
+ intervals = fmatoolbox.general.consolidateIntervals(intervals)
428
439
  if ax is None:
429
440
  ax = plt.gca()
430
441
 
431
- for start, stop in intervals:
432
- ax.axvspan(start,stop,alpha=alpha,color=color)
442
+ ax.axvspan(intervals[0,0],intervals[0,1],color=color,alpha=alpha,label=label,**plot_kwargs)
443
+ for start, stop in intervals[1:]:
444
+ ax.axvspan(start,stop,color=color,alpha=alpha,**plot_kwargs)
433
445
 
434
446
 
435
- def plotPDF(x, mode:str=None, bandwidth:float|str=None, eps:float=None, n_points:int=None, norm=None, color:mplt.ColorType=None, label=None, ax:mpla.Axes=None, **plot_kwargs):
447
+ def plotPDF(x, mode:str=None, bandwidth:float|str=None, eps:float=None, n_points:int=None, bins=None, norm=None, color:mplt.ColorType=None, label=None, ax:mpla.Axes=None, **plot_kwargs):
436
448
  '''
437
449
  estimate and plot probability density function (PDF) of data
438
450
 
439
451
  arguments:
440
452
  x (n,) tuple | array, data to plot
441
453
  mode str = {'normal','log','polar'}, DESCRIBE
442
- log bool = False, plot log-transformed PDF, DEPRECATED
443
454
  bandwidth float | str = 'scott', bandwidth for gaussian kernel
444
455
  eps float = 1e-12, small value used to avoid log(0)
445
456
  n_points int = 50, number of points used to evaluate PDF
457
+ bins (:,) float = None, bin edges, if None, bins are linearly spaced between min and max of x
446
458
  norm str = {'density','max'}, normalization mode, 'density' computes PDF, 'max' normalizes its maximum to 1
447
459
  color color = None, line color
448
460
  label str = None, legend label for line
@@ -452,14 +464,14 @@ def plotPDF(x, mode:str=None, bandwidth:float|str=None, eps:float=None, n_points
452
464
  if mode is None: mode = 'normal'
453
465
  if bandwidth is None: bandwidth = 0.05 if mode == 'polar' else 'scott'
454
466
  if eps is None: eps = 1e-12
455
- if n_points is None: n_points = 50
467
+ if n_points is None and bins is None: n_points = 50
456
468
  if norm is None: norm = 'density'
457
469
  if ax is None: ax = plt.gca()
458
470
  if isinstance(x,tuple):
459
- if color is None:
460
- color = [None] * len(x)
461
- if label is None:
462
- label = [None] * len(x)
471
+ if color is None or isinstance(color,str):
472
+ color = [color] * len(x)
473
+ if label is None or isinstance(label,str):
474
+ label = [label] * len(x)
463
475
  else:
464
476
  x = (x,)
465
477
  color = [color]
@@ -481,7 +493,10 @@ def plotPDF(x, mode:str=None, bandwidth:float|str=None, eps:float=None, n_points
481
493
  match mode:
482
494
  # 1. real-valued data using gaussian kernel density estimator
483
495
  case 'normal':
484
- this_grid = np.linspace(data.min(),data.max(),n_points) # linear grid
496
+ if n_points is None:
497
+ this_grid = bins
498
+ else:
499
+ this_grid = np.linspace(data.min(),data.max(),n_points) # linear grid
485
500
  kde = sp.stats.gaussian_kde(data,bw_method=bandwidth)
486
501
  this_density = kde(this_grid)
487
502
  if norm == 'max':
@@ -493,7 +508,10 @@ def plotPDF(x, mode:str=None, bandwidth:float|str=None, eps:float=None, n_points
493
508
  if data.min() <= 0:
494
509
  raise ValueError('log-transforming data requires positive values')
495
510
  data = np.log(data + eps) # log-transform data
496
- this_grid = np.linspace(data.min(),data.max(),n_points) # linear grid in log-space
511
+ if n_points is None:
512
+ this_grid = bins
513
+ else:
514
+ this_grid = np.linspace(data.min(),data.max(),n_points) # linear grid in log-space
497
515
  jacobian = np.exp(this_grid) # jacobian term to transform density back to linear
498
516
  kde = sp.stats.gaussian_kde(data,bw_method=bandwidth)
499
517
  this_density = kde(this_grid) / jacobian
@@ -504,7 +522,10 @@ def plotPDF(x, mode:str=None, bandwidth:float|str=None, eps:float=None, n_points
504
522
  # 3. circular data using von-Mises kernel density estimator
505
523
  case 'polar':
506
524
  data = np.mod(data,2*np.pi)
507
- this_grid = np.linspace(0,2*np.pi,n_points) # linear grid in [0,2*pi]
525
+ if n_points is None:
526
+ this_grid = bins
527
+ else:
528
+ this_grid = np.linspace(0,2*np.pi,n_points) # linear grid in [0,2*pi]
508
529
  this_density = np.zeros_like(this_grid)
509
530
  for theta in data:
510
531
  this_density += spst.vonmises.pdf(this_grid-theta,1/bandwidth)
@@ -7,6 +7,7 @@ import re
7
7
  import fmatoolbox.analysis
8
8
  import fmatoolbox.data
9
9
  import warnings
10
+ import xml.etree.ElementTree
10
11
  from collections.abc import Iterable
11
12
 
12
13
  def _regionDataPath():
@@ -34,8 +35,9 @@ class regions:
34
35
  # reload bool = False, load spikes from original files, bypassing Regions/<basename>_spikes.npz backup
35
36
  # anat_file string = None, DESCRIBE
36
37
 
37
- self.session = pathlib.Path(session).parent
38
- self.basename = pathlib.Path(session).stem
38
+ session = pathlib.Path(session)
39
+ self.session = session.parent
40
+ self.basename = session.stem
39
41
  self.rat = self.basename[3:6]
40
42
 
41
43
  # 1. load events
@@ -97,10 +99,12 @@ class regions:
97
99
  # restrict to session epochs
98
100
  if phase_names:
99
101
  _, valid = fmatoolbox.general.restrict(e['intervals'],epoch_intervals,s_ind=True)
100
- e = {k: v[valid] for k, v in e.items()}
102
+ e = {k: v[valid] for k, v in e.items() if len(v) == len(valid)}
101
103
 
102
104
  self.events[name] = e
103
105
 
106
+ if isinstance(ids,str):
107
+ ids = [ids]
104
108
  if ids:
105
109
  self.ids = list(dict.fromkeys(ids))
106
110
  self.region = {id : {} for id in ids}
@@ -109,13 +113,13 @@ class regions:
109
113
  self.region = {}
110
114
 
111
115
  # 4. load spikes and store them per region
116
+ if anat_file is None:
117
+ anat_file = next(_regionDataPath().glob('*.anat'), None)
118
+ else:
119
+ anat_file = pathlib.Path(anat_file)
120
+ if not anat_file.exists():
121
+ anat_file = _regionDataPath() / anat_file
112
122
  if load_spikes:
113
- if anat_file is None:
114
- anat_file = next(_regionDataPath().glob('*.anat'), None)
115
- else:
116
- anat_file = pathlib.Path(anat_file)
117
- if not anat_file.exists():
118
- anat_file = _regionDataPath() / anat_file
119
123
  self.region = fmatoolbox.data.loadSpikeTimes(session,output='regions',anat_file=anat_file,reload=reload)
120
124
  if ids:
121
125
  self.region = {r: self.region[r] for r in ids}
@@ -126,6 +130,25 @@ class regions:
126
130
  for id in self.ids:
127
131
  self.region[id]['spikes'] = fmatoolbox.general.restrict(self.region[id]['spikes'],epoch_intervals)
128
132
 
133
+ # 5. load electrode groups and channels per region
134
+ try:
135
+ anat = fmatoolbox.data.loadAnatomyFile(anat_file)
136
+ anat = anat[anat['rat'] == int(self.rat)] # keep rat of interest, deduced from file name
137
+ anat_ids = np.unique(anat['region'])
138
+ tree = xml.etree.ElementTree.parse(session.with_suffix(".xml"))
139
+ root = tree.getroot()
140
+ channel_groups = root.find(".//channelGroups").findall("group")
141
+ channel_groups = [[int(element.text) for element in c.findall(".//channel")] for c in channel_groups]
142
+ if len(self.region):
143
+ for id in self.ids:
144
+ self.region[id]['channels'] = {int(e): channel_groups[e-1] for e in anat[anat['region'] == id]['electrode']}
145
+ else:
146
+ self.ids = anat_ids.tolist()
147
+ for id in self.ids:
148
+ self.region[id] = {'channels': {int(e): channel_groups[e-1] for e in anat[anat['region'] == id]['electrode']} }
149
+ except FileNotFoundError:
150
+ pass
151
+
129
152
  return
130
153
 
131
154
 
@@ -166,7 +189,8 @@ class regions:
166
189
  regs = regs[np.sort(idx)]
167
190
 
168
191
  # default: all electrode groups
169
- all_e_groups = np.concatenate([list(self.region[r]['e_group'].keys()) for r in self.ids])
192
+ all_e_groups = [list(self.region[r]['e_group'].keys()) for r in self.ids if 'e_group' in self.region[r]]
193
+ if len(all_e_groups): all_e_groups = np.concatenate(all_e_groups)
170
194
  if np.all(e_groups == None):
171
195
  if exclusive and np.any(regs != None):
172
196
  e_groups = np.array([None])
@@ -296,17 +320,36 @@ class regions:
296
320
 
297
321
 
298
322
  def electrodes(self,regs=None):
299
- # get pooled list of electrode groups for regions
300
- #
301
- # arguments:
302
- # regs (:) string = None, electrode of these regions are returned as an array, default is all regions
303
- #
304
- # output:
305
- # electrodes (:) int, sorted by value
323
+ """
324
+ get pooled list of electrode groups for regions
325
+
326
+ arguments:
327
+ regs (:) string = None, electrode of these regions are returned as an array, default is all regions
328
+
329
+ output:
330
+ electrodes (:) int, sorted by value
331
+ """
306
332
 
307
333
  regs, _, _ = self._checkIDs(regs=regs)
308
334
 
309
335
  return np.concatenate([list(self.region[r]['e_group'].keys()) for r in regs])
336
+
337
+
338
+ def channels(self,regs=None):
339
+ """
340
+ get pooled list of recording channels for regions
341
+
342
+ arguments:
343
+ regs (:) string = None, channels of these regions are returned as an array, default is all regions
344
+
345
+ output:
346
+ channels (:) int
347
+ """
348
+
349
+ regs, _, _ = self._checkIDs(regs=regs)
350
+ values = [list(self.region[r]['channels'].values()) for r in regs]
351
+
352
+ return np.array([x for outer in values for inner in outer for x in inner])
310
353
 
311
354
 
312
355
  def units(self,regs=None,e_groups=None):
@@ -376,8 +419,8 @@ class regions:
376
419
 
377
420
  ## functions to compute quantities ##
378
421
 
379
- def firingRate(self, regs:Iterable[str]=None, e_groups:Iterable[int]=None, states:Iterable[str]=None, when=None, shift=False,
380
- window=None, step=None, smooth=None, norm=False):
422
+ def firingRate(self, regs:Iterable[str]=None, e_groups:Iterable[int]=None, states:Iterable[str]=None, when=None, shift:bool=None,
423
+ window=None, step:int=None, smooth=None, norm:bool=None):
381
424
  '''
382
425
  get region firing rate
383
426
 
@@ -390,7 +433,7 @@ class regions:
390
433
  window float = 0.05, window size to count spikes
391
434
  step int = 1, firing rate is computed in windows of length 'binSize' and overlap 'binSize' / 'step',
392
435
  default is no overlap
393
- smooth float = None, gaussian kernel std for smoothing over time
436
+ smooth float = None, gaussian kernel std to smooth rate over time
394
437
  norm bool = False, normalize by neuron number per region
395
438
 
396
439
  output:
@@ -400,8 +443,10 @@ class regions:
400
443
 
401
444
  if states is not None and when is not None:
402
445
  raise ValueError("'states' and 'when' cannot be specified at the same time")
446
+ if shift is None: shift = False
403
447
  if window is None: window = 0.05
404
448
  if step is None: step = 1
449
+ if norm is None: norm = False
405
450
 
406
451
  regs, e_groups, states = self._checkIDs(regs=regs,e_groups=e_groups,states=states,fuse=True)
407
452
 
@@ -450,7 +495,7 @@ class regions:
450
495
  return firing_rate
451
496
 
452
497
 
453
- def unitFiringRate(self,regs=None,states=None,when=None,shift=False,window=None,step=None,smooth=None):
498
+ def unitFiringRate(self, regs=None, states=None, when=None, shift:bool=None, window=None, step:int=None, smooth:bool=None):
454
499
  # get units' firing rate
455
500
  #
456
501
  # arguments:
@@ -461,13 +506,14 @@ class regions:
461
506
  # window float = 0.05 s, window size to count spikes
462
507
  # step int = 1, firing rate is computed in windows of length 'binSize' and overlap 'binSize' / 'step',
463
508
  # default is no overlap
464
- # smooth float = None, gaussian kernel std for smoothing over time
509
+ # smooth float = None, gaussian kernel std to smooth rate over time
465
510
  #
466
511
  # output:
467
512
  # rate (:,n+1) float, every row is [time stamp, firing rates for n units]
468
513
 
469
514
  if states is not None and when is not None:
470
515
  raise ValueError("'states' and 'when' cannot be specified at the same time")
516
+ if shift is None: shift = False
471
517
  if window is None: window = 0.05
472
518
  if step is None: step = 1
473
519
 
@@ -498,7 +544,7 @@ class regions:
498
544
  return firing_rate
499
545
 
500
546
 
501
- def avalanches(self,regs=None,states=None,when=None,shift=False,thresh=30,window=0.05,step=1,smooth=None,return_fr=False,norm=False):
547
+ def avalanches(self, regs=None, states=None, when=None, shift:bool=None, thresh:float=None, window:float=None, step:int=None, smooth:bool=None, norm:bool=None, return_fr:bool=None):
502
548
  # compute avalanches per region from population firing rate
503
549
  #
504
550
  # arguments:
@@ -510,7 +556,7 @@ class regions:
510
556
  # window float = 0.05 s, window size to count spikes
511
557
  # step float = 1, firing rate is computed in windows of length 'binSize' and overlap 'binSize' / 'step',
512
558
  # default is no overlap
513
- # smooth float = None, gaussian kernel std for smoothing over time
559
+ # smooth float = None, gaussian kernel std to smooth rate over time before finding avalanches
514
560
  # norm bool = False, normalize by neuron number per region
515
561
  #
516
562
  # output:
@@ -519,6 +565,9 @@ class regions:
519
565
  # size_t (m) float, size over time, in which every avalanche is separated by a 0
520
566
  # fr (:,r+1) float, every row is [time stamp, firing rates for r regions], optional
521
567
 
568
+ if thresh is None: thresh = 30
569
+ if return_fr is None: return_fr = False
570
+
522
571
  none_state = states is None
523
572
  regs, _, states = self._checkIDs(regs=regs,states=states,fuse=True)
524
573
  if none_state:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fmatoolbox
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Python port of FMAToolbox
5
5
  Author: Pietro Bozzo
6
6
  License-Expression: GPL-3.0-or-later
@@ -11,11 +11,13 @@ Requires-Python: >=3.11
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
13
  Requires-Dist: numpy
14
+ Requires-Dist: numba
14
15
  Requires-Dist: scipy
15
16
  Requires-Dist: matplotlib
16
17
  Requires-Dist: statsmodels
17
- Requires-Dist: scikit-learn
18
- Requires-Dist: scikit-image
18
+ Provides-Extra: assemblies
19
+ Requires-Dist: scikit-learn; extra == "assemblies"
20
+ Requires-Dist: scikit-image; extra == "assemblies"
19
21
  Dynamic: license-file
20
22
 
21
23
  # FMAToolbox (Python Port)
@@ -1,6 +1,9 @@
1
1
  numpy
2
+ numba
2
3
  scipy
3
4
  matplotlib
4
5
  statsmodels
6
+
7
+ [assemblies]
5
8
  scikit-learn
6
9
  scikit-image
File without changes
File without changes
File without changes