fmatoolbox 0.1.8__tar.gz → 0.2.0__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.
Files changed (22) hide show
  1. {fmatoolbox-0.1.8/src/fmatoolbox.egg-info → fmatoolbox-0.2.0}/PKG-INFO +6 -6
  2. {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/pyproject.toml +6 -6
  3. {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox/__init__.py +1 -0
  4. {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox/analysis.py +3 -3
  5. {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox/data.py +162 -92
  6. fmatoolbox-0.2.0/src/fmatoolbox/general.py +178 -0
  7. fmatoolbox-0.2.0/src/fmatoolbox/intervals.py +299 -0
  8. {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox/plotting.py +5 -3
  9. {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox/preprocess.py +3 -2
  10. {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox/regions.py +34 -36
  11. {fmatoolbox-0.1.8 → fmatoolbox-0.2.0/src/fmatoolbox.egg-info}/PKG-INFO +6 -6
  12. fmatoolbox-0.1.8/src/fmatoolbox/general.py +0 -439
  13. fmatoolbox-0.1.8/src/fmatoolbox/intervals.py +0 -104
  14. {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/LICENSE +0 -0
  15. {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/README.md +0 -0
  16. {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/setup.cfg +0 -0
  17. {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox/exceptions.py +0 -0
  18. {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox/statistics.py +0 -0
  19. {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox.egg-info/SOURCES.txt +0 -0
  20. {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox.egg-info/dependency_links.txt +0 -0
  21. {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox.egg-info/requires.txt +4 -4
  22. {fmatoolbox-0.1.8 → fmatoolbox-0.2.0}/src/fmatoolbox.egg-info/top_level.txt +0 -0
@@ -1,25 +1,25 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fmatoolbox
3
- Version: 0.1.8
3
+ Version: 0.2.0
4
4
  Summary: Python port of FMAToolbox
5
5
  Author: Pietro Bozzo
6
6
  License-Expression: GPL-3.0-or-later
7
7
  Project-URL: Homepage, https://github.com/pietro-bozzo/FMAToolboxPython
8
8
  Project-URL: Repository, https://github.com/pietro-bozzo/FMAToolboxPython
9
9
  Project-URL: Issues, https://github.com/pietro-bozzo/FMAToolboxPython/issues
10
- Requires-Python: >=3.11
10
+ Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
- Requires-Dist: numpy
13
+ Requires-Dist: loky
14
14
  Requires-Dist: numba
15
- Requires-Dist: scipy
15
+ Requires-Dist: numpy
16
16
  Requires-Dist: matplotlib
17
+ Requires-Dist: scipy
17
18
  Requires-Dist: statsmodels
18
- Requires-Dist: loky
19
19
  Provides-Extra: assemblies
20
+ Requires-Dist: joblib; extra == "assemblies"
20
21
  Requires-Dist: scikit-learn; extra == "assemblies"
21
22
  Requires-Dist: scikit-image; extra == "assemblies"
22
- Requires-Dist: joblib; extra == "assemblies"
23
23
  Provides-Extra: hierarch
24
24
  Requires-Dist: hierarch; extra == "hierarch"
25
25
  Requires-Dist: pandas; extra == "hierarch"
@@ -4,27 +4,27 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fmatoolbox"
7
- version = "0.1.8"
7
+ version = "0.2.0"
8
8
  description = "Python port of FMAToolbox"
9
9
  authors = [{name = "Pietro Bozzo"}]
10
10
  readme = "README.md"
11
11
  license = "GPL-3.0-or-later"
12
12
 
13
- requires-python = ">=3.11"
13
+ requires-python = ">=3.10"
14
14
  dependencies = [
15
- "numpy",
15
+ "loky",
16
16
  "numba",
17
- "scipy",
17
+ "numpy",
18
18
  "matplotlib",
19
+ "scipy",
19
20
  "statsmodels",
20
- "loky",
21
21
  ]
22
22
 
23
23
  [project.optional-dependencies]
24
24
  assemblies = [
25
+ "joblib",
25
26
  "scikit-learn",
26
27
  "scikit-image",
27
- "joblib",
28
28
  ]
29
29
  hierarch = [
30
30
  "hierarch",
@@ -1,5 +1,6 @@
1
1
  from . import analysis
2
2
  from . import data
3
+ from . import exceptions
3
4
  from . import general
4
5
  from . import intervals
5
6
  from . import plotting
@@ -1,6 +1,6 @@
1
- ''' Specialized analyses for FMAToolbox '''
1
+ """ Specialized analyses for FMAToolbox """
2
2
 
3
- import fmatoolbox.general
3
+ import fmatoolbox.intervals
4
4
  import numpy as np
5
5
  import scipy as sp
6
6
  import statsmodels.stats.multitest
@@ -553,7 +553,7 @@ def cellAssembliesICA(spikes, window:float=None, when=None, drop_mix:bool=False)
553
553
  time = raster[:,0]
554
554
  n = raster[:,1:] # discard time column
555
555
  if when is not None:
556
- time, valid = fmatoolbox.general.restrict(time,when,s_ind=True)
556
+ time, valid = fmatoolbox.intervals.restrict(time,when,s_ind=True)
557
557
  n = n[valid]
558
558
 
559
559
  # remove units which never spiked to avoid cov error
@@ -1,4 +1,4 @@
1
- ''' Functions to load data for a recording session '''
1
+ """ Functions to load data for a recording session """
2
2
 
3
3
  import pathlib
4
4
  import scipy.io
@@ -242,80 +242,130 @@ def saveRegionSpikes(file:str|PathLike[str], spikes):
242
242
  return
243
243
 
244
244
 
245
- def loadEventFile(filename:str|PathLike[str], compact:bool=False):
246
- # load events from a .evt file
247
- #
248
- # arguments:
249
- # filename file, .evt file, each line must have format 'beginning of basename_event1_1'
250
- # compact bool = false, if true, return events as compact dictionary
251
- #
252
- # output:
253
- # events dict, keys are either 'times' and 'descriptions' or event names if 'compact' = True
245
+ def loadEventFile(filename:str|PathLike[str], compact:bool=None):
246
+ """load an event file, accepted formats:
247
+ - .npy: each column of the array is returned as a value in `events` (single event)
248
+ - .npz: each array of the archive is returned as an event in `events`
249
+ - .mat: DESCRIBE
250
+ - .evt: text file where each line must have format 'beginning of basename_event1_1' or 'end of basename_event1_1'
251
+ - other extensions: interpreted as text files, as produced by ``saveEvent``
254
252
 
255
- with open(filename,'r') as f:
256
- lines = f.read().splitlines()
253
+ Args:
254
+ filename: file to read
255
+ compact: if True, for .npz files, return a single event instead of a dict of events, for .evt files, return a
256
+ compact dictionary; ignored otherwise, defaults to True
257
257
 
258
- # extract first non-whitespace token
259
- times = [line.split()[0] for line in lines]
260
- times = np.array([float(t) / 1000.0 for t in times]) # convert to seconds
258
+ Returns:
259
+ events: either a single event (a dict) or a dict of events (a dict of dict)
260
+ in general, an event is a dict of type {'name': 1d np array}
261
+ """
261
262
 
262
- # remove first token and following whitespace
263
- descriptions = []
264
- for line in lines:
265
- parts = line.split(maxsplit=1)
266
- descriptions.append(parts[1] if len(parts) > 1 else '')
267
-
268
- if compact:
269
- # group by events, description is of type 'beginning of basename_event1' or 'beginning of basename_event1_1'
270
- events = {} #collections.defaultdict(lambda: collections.defaultdict(list))
271
- for t, d in zip(times,descriptions):
272
- if " of " not in d:
273
- raise fmatoolbox.exceptions.FileFormatError(f"Unexpected format: '{d}'")
274
- phase, full_id = d.split(" of ",1)
275
- phase = phase.strip()
276
- full_id = full_id.strip()
277
- # remove trailing 'bis' and '_1'
278
- if full_id.endswith('bis'):
279
- full_id = full_id[:-3]
280
- parts = full_id.split('_')
281
- try:
282
- if parts[-1].isdigit():
283
- part = parts[-2]
284
- else:
285
- part = parts[-1]
286
- except:
287
- raise ValueError(f"Invalid event ID format: '{full_id}'")
288
- event_id = part
289
- # concatenate values
290
- if event_id in events:
291
- if phase in events[event_id]:
292
- events[event_id][phase].append(t)
293
- else:
294
- events[event_id][phase] = [t]
263
+ if compact is None: compact = True
264
+ def event_dict(data, columns=None):
265
+ if columns is None: columns = ['col0'] if data.ndim == 1 else [f'col{i}' for i in range(data.shape[1])]
266
+ return {columns[0]: data} if data.ndim == 1 else {columns[i]: data[:,i] for i in range(data.shape[1])}
267
+
268
+ filename = pathlib.Path(filename)
269
+ format = filename.suffix
270
+ match format:
271
+
272
+ # 1. npy file
273
+ case '.npy':
274
+ events = event_dict(np.load(filename))
275
+
276
+ # 2. npz file
277
+ case '.npz':
278
+ with np.load(filename) as data:
279
+ events = dict(data) if compact else {d: event_dict(data[d]) for d in data}
280
+
281
+ # 3. MATLAB's mat file
282
+ case '.mat':
283
+ name = filename.name.removesuffix(".events.mat").rsplit(".",1)[-1]
284
+ events = {name: scipy.io.loadmat(filename, simplify_cells=True)[name]}
285
+
286
+ # 4. evt file (retrocompatibility)
287
+ case '.evt':
288
+ with open(filename,'r') as f:
289
+ lines = f.read().splitlines()
290
+
291
+ # extract first non-whitespace token
292
+ times = [line.split()[0] for line in lines]
293
+ times = np.array([float(t) / 1000.0 for t in times]) # convert to seconds
294
+
295
+ # remove first token and following whitespace
296
+ descriptions = []
297
+ for line in lines:
298
+ parts = line.split(maxsplit=1)
299
+ descriptions.append(parts[1] if len(parts) > 1 else '')
300
+
301
+ if compact:
302
+ # group by events, description is of type 'beginning of basename_event1' or 'beginning of basename_event1_1'
303
+ events = {}
304
+ for t, d in zip(times,descriptions):
305
+ if " of " not in d:
306
+ raise fmatoolbox.exceptions.FileFormatError(f"Unexpected format: '{d}'")
307
+ phase, full_id = d.split(" of ",1)
308
+ phase = phase.strip()
309
+ full_id = full_id.strip()
310
+ # remove trailing 'bis' and '_1'
311
+ if full_id.endswith('bis'):
312
+ full_id = full_id[:-3]
313
+ parts = full_id.split('_')
314
+ try:
315
+ if parts[-1].isdigit():
316
+ part = parts[-2]
317
+ else:
318
+ part = parts[-1]
319
+ except:
320
+ raise ValueError(f"Invalid event ID format: '{full_id}'")
321
+ event_id = part
322
+ # concatenate values
323
+ if event_id in events:
324
+ if phase in events[event_id]:
325
+ events[event_id][phase].append(t)
326
+ else:
327
+ events[event_id][phase] = [t]
328
+ else:
329
+ events[event_id] = {phase : [t]}
295
330
  else:
296
- events[event_id] = {phase : [t]}
297
- else:
298
- events = { 'time': times, 'description': descriptions }
331
+ events = {'time':times, 'description':descriptions}
332
+
333
+ # 5. text file
334
+ case _:
335
+ data = np.loadtxt(filename, comments='%', delimiter=',', ndmin=2)
336
+ # try reading column names from header
337
+ columns = [f'col{i}' for i in range(data.shape[1])]
338
+ with filename.open('r') as f:
339
+ for line in f:
340
+ line = line.strip()
341
+ if not line.startswith('%'):
342
+ break
343
+ line = line.removeprefix('%').strip()
344
+ if line.startswith('columns:'):
345
+ for i, c in enumerate(line.removeprefix('columns:').split(',')):
346
+ columns[i] = c.strip()
347
+ events = {columns[i]: data[:,i] for i in range(data.shape[1])}
299
348
 
300
349
  return events
301
350
 
302
351
 
303
352
  def loadEvents(session:str|PathLike[str], extra:str|list[str]):
304
- # load event files for a session
305
- #
306
- # arguments:
307
- # session path to session's .xml file, event files must be in session folder
308
- # extra (:) string, extensions of other event files named <basename>.extra[i] to load,
309
- # can be 'subdir/extension' to load '.../basename/subdir/basename.extension'
310
- #
311
- # output:
312
- # events dict, keys are event names
313
- # cat_names (:) string, names of events contained in <basename>.cat.evt
353
+ """load event files for a session
354
+
355
+ Args:
356
+ session: path to session's .xml file, event files must be in session folder
357
+ extra: (:) string, extensions of other event files named <basename>.extra[i] to load,
358
+ can be 'subdir/extension' to load '.../basename/subdir/basename.extension'
359
+
360
+ Returns:
361
+ events: dict, keys are event names
362
+ cat_names: (:) string, names of events contained in <basename>.cat.evt
363
+ """
314
364
 
315
365
  session = pathlib.Path(session)
316
366
  file_root = session.parent
317
367
  # accept single string input
318
- if isinstance(extra,str):
368
+ if isinstance(extra, str):
319
369
  extra = [extra]
320
370
 
321
371
  # load all *.evt files
@@ -323,13 +373,13 @@ def loadEvents(session:str|PathLike[str], extra:str|list[str]):
323
373
  cat_names = []
324
374
  for file in file_root.glob("*.evt"):
325
375
  try:
326
- events.update(loadEventFile(file,compact=True))
376
+ events.update(loadEventFile(file, compact=True))
327
377
  except fmatoolbox.exceptions.FileFormatError:
328
378
  pass
329
379
  if str(file)[-8:] == '.cat.evt':
330
380
  cat_names = list(events.keys())
331
381
 
332
- # load other file types
382
+ # load other file types TO DO: add npy support, use loadEventFile for mat files as well
333
383
  for extension in extra:
334
384
  # handle subfolders, e.g., 'subfolder/name'
335
385
  e = pathlib.Path(extension).name
@@ -337,20 +387,63 @@ def loadEvents(session:str|PathLike[str], extra:str|list[str]):
337
387
  this_froot = file_root / p / session.stem
338
388
  if this_froot.with_suffix(f'.{e}.npz').exists():
339
389
  # load .npz file
340
- data = np.load(this_froot.with_suffix(f'.{e}.npz'))
341
- for d in data:
342
- events[d] = {f'col{i}': data[d][:,i] for i in range(data[d].shape[1])}
390
+ _e = loadEventFile(this_froot.with_suffix(f'.{e}.npz'), compact=False)
391
+ events.update(_e)
343
392
  elif this_froot.with_suffix(f'.{e}.events.mat').exists():
344
393
  # load .events.mat file
345
394
  events[e] = scipy.io.loadmat(this_froot.with_suffix(f'.{e}.events.mat'),simplify_cells=True)[e]
346
395
  else:
347
396
  # load text file
348
- data = np.loadtxt(this_froot.with_suffix(f'.{e}'),comments='%',delimiter=',',ndmin=2)
349
- events[e] = {f'col{i}': data[:,i] for i in range(data.shape[1])}
397
+ events[e] = loadEventFile(this_froot.with_suffix(f'.{e}'))
350
398
 
351
399
  return events, cat_names
352
400
 
353
401
 
402
+ def saveEvent(session:str|PathLike[str], format:str, data, columns=None, name=None):
403
+ """save an event in standard FMAT format
404
+
405
+ Args:
406
+ session: path to session's .xml file, along which the event file will be created
407
+ format: format of event file, either:
408
+ - 'npy': DESCRIBE
409
+ - 'npz': DESCRIBE
410
+ - 'name.np#': shorthand for format='np#', name='name'
411
+ - a different str, to save the event as a text file named <basename>.<format>, prepending a metadata header
412
+ in this case, `data` must be an array whose columns represent different quantities; if 1d, interpreted as one column
413
+ data: array to save; for npz format, must be a dict of {'name': array}
414
+ columns: names of columns of `data`, mandatory for text formats, ignored otherwise
415
+ name: event name used to construct the output file name, ignored for text formats, mandatory otherwise
416
+
417
+ Examples:
418
+ # save <basename>.sleep in text format containing two events: 'onset' and 'offset'
419
+ saveEvent(session, 'sleep', [[1,2]], columns=['onset','offset'])
420
+
421
+ # save the same as <basename>.sleep.npz
422
+ saveEvent(session, 'npz', {'onset':[1,2], 'offset':[1.5,3]}, name='sleep')
423
+ # or
424
+ saveEvent(session, 'sleep.npz', {'onset':[1,2], 'offset':[1.5,3]})
425
+ """
426
+
427
+ # parse format
428
+ parts = format.rsplit('.', 1)
429
+ format = parts[-1]
430
+ if len(parts) > 1: name = parts[0]
431
+
432
+ if format == 'npy' or format == 'npz':
433
+ if name is None:
434
+ raise ValueError(f"'name' must be specified for {format} format")
435
+ fname = pathlib.Path(session).with_suffix(f'.{name}.{format}')
436
+ np.save(fname, data) if format == 'npy' else np.savez(fname, **data)
437
+
438
+ else:
439
+ if columns is None:
440
+ raise ValueError("'columns' must be specified for text formats")
441
+ fname = pathlib.Path(session).with_suffix(f'.{format}')
442
+ np.savetxt(fname, data, delimiter=',', header=f" columns: {', '.join(columns)}", comments="%")
443
+
444
+ return
445
+
446
+
354
447
  def loadSpikeWaveforms(session:str|PathLike[str]):
355
448
 
356
449
  file_root = pathlib.Path(session).with_suffix('')
@@ -474,29 +567,6 @@ def makeAnatFile(session:str|PathLike[str]):
474
567
  return
475
568
 
476
569
 
477
- def saveEvent(session:str|PathLike[str], format:str, data, columns):
478
- """save an event in standard FMAT format
479
-
480
- Args:
481
- session: path to session's .xml file, along which the event file will be created
482
- format: format of event file, either:
483
- - 'npy': to implement
484
- - 'npz': to implement
485
- - a different str, to save the event as a text file named <basename>.<format>, prepending a metadata header
486
- in this case, `data` must be an array whose columns represent different quantities; if 1d, interpreted as one column
487
- data: array to save
488
- columns: names of columns of `data`
489
- """
490
-
491
- if extension == 'npy' or extension == 'npz':
492
- print('implement!')
493
- else:
494
- fname = pathlib.Path(session).with_suffix(f'.{extension}')
495
- np.savetxt(fname, data, delimiter=',', header=f" columns: {', '.join(columns)}", comments="%")
496
-
497
- return
498
-
499
-
500
570
  def printXML(session:str|PathLike[str]):
501
571
  """simple utility to print contents of a session's .xml file"""
502
572
 
@@ -0,0 +1,178 @@
1
+ """ General-purpose data processing for FMAToolbox """
2
+
3
+ import numpy as np
4
+
5
+
6
+ def unwhere(indices,shape=None):
7
+ """perform the inverse operation of ``numpy.where``
8
+
9
+ arguments:
10
+ indices ndarray of int, output of `numpy.where`
11
+ shape tuple = None, shape of the output array
12
+
13
+ output:
14
+ mask ndarray of bool, original input to `numpy.where`
15
+ """
16
+
17
+ if shape is None:
18
+ shape = tuple(i.max() + 1 if len(i) else 0 for i in indices)
19
+ mask = np.zeros(shape,dtype=bool)
20
+ mask[indices] = True
21
+ return mask
22
+
23
+
24
+ def shuffleEvents(events, offset:float=None, n:int=None, group=None, fast:bool=None, intervals=None):
25
+ '''
26
+ shuffle point-process events (i.e., time stamps) preserving their inter-event interval
27
+
28
+ arguments:
29
+ events (s,) | (s,f) float, samples to shuffle; s: # samples, f: # features, if 2d, first column contains time stamps
30
+ offset float = 0 s, reference time, necessary when events start at a time != 0, e.g., for a portion of a recording
31
+ in [1000 s, 3000 s]; must be smaller than first event time, ignored if 'intervals' are provided
32
+ n int = 1, number of independent repetitions of the shuffling procedure, changes output shape
33
+ group (s,) int, grouping variable to shuffle events separately per group, adds output 'group'
34
+ fast bool = False, if True, 'events' must be time sorted (to save computation time)
35
+ intervals (:,2) float = None, rows are [start, stop] times of intervals to restrict shuffle into; if 1d, reshaped to (1,2)
36
+
37
+ output:
38
+ shuffled (:,:,) float, shuffled events, shape depends on 'n':
39
+ - same as 'events' if 'n' = 1
40
+ - (s,f,n), i.e., with an extra dimension for 'n' shuffling repetitions, otherwise
41
+ group (:,) int, optional, grouping variable for shuffled events, only returned if 'group' is not None, shape depends on 'n':
42
+ - (s,) if 'n' = 1
43
+ - (s,n), otherwise
44
+ '''
45
+
46
+ # default values
47
+ events = np.asarray(events)
48
+ n_dim = events.ndim
49
+ if offset is None: offset = 0
50
+ if n is None: n = 1
51
+ if group is not None: group = np.asarray(group,dtype=int)
52
+ if intervals is not None:
53
+ events, valid = restrict(events,intervals,shift=True,s_ind=True)
54
+ offset = 0
55
+ if group is not None:
56
+ group = group[valid]
57
+
58
+ # zero-repetitions case
59
+ if n == 0:
60
+ if group is not None:
61
+ return np.zeros(events.shape+(0,)), np.zeros((events.shape[0],0))
62
+ return np.zeros(events.shape+(0,))
63
+
64
+ # make 'events' 3d by appending singleton dimensions
65
+ events = events.reshape(*events.shape, *(1,) * (3 - n_dim)) # (events, features, 1)
66
+
67
+ # sort by time
68
+ if not fast:
69
+ order = events[:,0,0].argsort(axis=0)
70
+ events = events[order]
71
+ if group is not None:
72
+ group = group[order]
73
+
74
+ # repeat events 'n' times
75
+ events = np.tile(events,(1,1,n)) # (events, features, shuffle repetitions)
76
+
77
+ # core f to shuffle events of a group
78
+ rng = np.random.default_rng()
79
+ def shuffleGroup(e):
80
+ # shuffle inter-event intervals (IEI)
81
+ iei = np.diff(e[:,0,:],prepend=offset,axis=0)
82
+ iei_shape = iei.shape
83
+ permutation_idx = np.argsort(rng.random(iei_shape),axis=0)
84
+ iei = iei[permutation_idx,np.arange(iei_shape[1])]
85
+ # reconstruct shuffled samples
86
+ shuffled = offset + np.cumsum(iei,axis=0) # shuffled times (events, shuffle repetitions)
87
+ features = e[permutation_idx[:,None,:], np.arange(1,e.shape[1])[None,:,None], np.arange(iei_shape[1])[None,None,:]]
88
+ shuffled = np.concatenate((shuffled[:,None],features),axis=1) # append shuffled features
89
+ return shuffled
90
+
91
+ # perform shuffling
92
+ if group is None:
93
+ shuffled = shuffleGroup(events)
94
+ else:
95
+ shuffled = []
96
+ unique_groups, g_count = np.unique(group,return_counts=True)
97
+ for g in unique_groups:
98
+ shuffled.append(shuffleGroup(events[group==g]))
99
+ # sort by time
100
+ shuffled = np.concatenate(shuffled,axis=0)
101
+ order = shuffled[:,0,:].argsort(axis=0)
102
+ shuffled = shuffled[order[:,None,:], np.arange(shuffled.shape[1])[None,:,None], np.arange(shuffled.shape[2])[None,None,:]]
103
+ # build 'group' for output
104
+ group = np.repeat(unique_groups,g_count) # use unique output to sort
105
+ group = group[order] # sort as 'shuffled'
106
+
107
+ # unshift events back into intervals
108
+ if intervals is not None:
109
+ for i in range(n):
110
+ shuffled[:,:,i] = unshift(shuffled[:,:,i],intervals)
111
+
112
+ # restore shape
113
+ if n_dim == 1 and n == 1:
114
+ shuffled = shuffled.ravel()
115
+ elif n == 1:
116
+ shuffled = shuffled[:,:,0]
117
+
118
+ if group is not None:
119
+ if n_dim == 1 and n == 1:
120
+ group = group.ravel()
121
+ elif n == 1:
122
+ group = group[:,0]
123
+ return shuffled, group
124
+
125
+ return shuffled
126
+
127
+
128
+ def circularShift(x, shift, axis:int=None):
129
+ """independent circular shift of each slice of 'x' along a given axis
130
+
131
+ arguments:
132
+ x ndarray to shift
133
+ shift int | (:,) int, shift amount per slice; if int, the same shift is applied to each slice
134
+ axis int = 0, axis to roll along; 0 means columns of 'x' will be rolled
135
+ """
136
+
137
+ if axis is None: axis = 0
138
+ shift = np.array(shift,ndmin=1)
139
+
140
+ n, m = x.shape[:2]
141
+ if axis == 0:
142
+ if shift.size == 1:
143
+ shift = np.repeat(shift,m,axis=0)
144
+ shifted = x[(np.arange(n)[:,None]-shift) % n, np.arange(m)]
145
+ elif axis == 1:
146
+ if shift.size == 1:
147
+ shift = np.repeat(shift,n,axis=0)
148
+ shifted = x[np.arange(n)[:,None], (np.arange(m)-shift[:,None]) % m]
149
+ else:
150
+ raise ValueError('not implemented!')
151
+
152
+ return shifted
153
+
154
+
155
+ def jitter(samples,dt,low=None):
156
+ """add a jitter drawn from a uniform distribution to each sample's time stamp
157
+
158
+ arguments:
159
+ samples (s,) | (s,f) numeric, samples to jitter; s: # samples, f: # features, if 2d, first column contains time stamps
160
+ dt float, higher bound of the uniform distribution
161
+ low float = -dt, lower bound of the uniform distribution
162
+
163
+ output:
164
+ samples numeric, jittered samples (only time stamps are changed)
165
+ """
166
+
167
+ if low is None: low = -dt
168
+
169
+ samples = np.asarray(samples,dtype=float)
170
+ n_dim = samples.ndim
171
+ rng = np.random.default_rng()
172
+
173
+ if n_dim < 2:
174
+ samples += rng.uniform(low=low,high=dt,size=samples.shape)
175
+ elif n_dim == 2:
176
+ samples[:,0,...] += rng.uniform(low=low,high=dt,size=samples.shape[:1] + samples.shape[2:])
177
+
178
+ return samples