fmatoolbox 0.1.0__py3-none-any.whl
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.
- fmatoolbox/__init__.py +5 -0
- fmatoolbox/analysis.py +475 -0
- fmatoolbox/data.py +546 -0
- fmatoolbox/exceptions.py +4 -0
- fmatoolbox/general.py +319 -0
- fmatoolbox/plotting.py +500 -0
- fmatoolbox/regions.py +524 -0
- fmatoolbox-0.1.0.dist-info/METADATA +31 -0
- fmatoolbox-0.1.0.dist-info/RECORD +12 -0
- fmatoolbox-0.1.0.dist-info/WHEEL +5 -0
- fmatoolbox-0.1.0.dist-info/licenses/LICENSE +7 -0
- fmatoolbox-0.1.0.dist-info/top_level.txt +1 -0
fmatoolbox/data.py
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
''' Session data handling functions for FMAToolbox '''
|
|
2
|
+
from typing import Any, Callable
|
|
3
|
+
import pathlib
|
|
4
|
+
import scipy.io
|
|
5
|
+
import numpy as np
|
|
6
|
+
import ast
|
|
7
|
+
import datetime
|
|
8
|
+
import traceback
|
|
9
|
+
import re
|
|
10
|
+
import fmatoolbox.exceptions
|
|
11
|
+
import collections
|
|
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
|
|
34
|
+
|
|
35
|
+
file_root = pathlib.Path(session).with_suffix('')
|
|
36
|
+
cell_metrics_file = file_root.with_suffix('.cell_metrics.cellinfo.mat')
|
|
37
|
+
if cell_metrics_file.exists():
|
|
38
|
+
if return_elec or return_loc:
|
|
39
|
+
spikes, electrode_id, cluster_loc = loadCellMetricsFile(session,output=output,anat_file=anat_file,return_extra=True,reload=reload)
|
|
40
|
+
else:
|
|
41
|
+
spikes = loadCellMetricsFile(session,output=output,anat_file=anat_file,reload=reload)
|
|
42
|
+
else:
|
|
43
|
+
if return_elec or return_loc:
|
|
44
|
+
raise ValueError("'return_elec' and 'return_loc' are not implemented for .clu files")
|
|
45
|
+
spikes = loadCluFiles(session,output=output,anat_file=anat_file,reload=reload)
|
|
46
|
+
|
|
47
|
+
if not return_elec and not return_loc:
|
|
48
|
+
return spikes
|
|
49
|
+
out = (spikes, electrode_id, cluster_loc) # prepare tuple to return requested outputs
|
|
50
|
+
return out[:2+return_loc:2-return_elec]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def loadCellMetricsFile(session:str, output:str='dict', anat_file:str=None, return_extra:bool=False, reload:bool=False):
|
|
54
|
+
|
|
55
|
+
file_root = pathlib.Path(session).with_suffix('')
|
|
56
|
+
|
|
57
|
+
# load .cell_metrics.cellinfo.mat file only if necessary
|
|
58
|
+
if output != 'regions' or reload or return_extra:
|
|
59
|
+
cell_metrics_file = file_root.with_suffix('.cell_metrics.cellinfo.mat')
|
|
60
|
+
if not cell_metrics_file.exists():
|
|
61
|
+
raise FileNotFoundError(f'{cell_metrics_file} not found')
|
|
62
|
+
|
|
63
|
+
data = scipy.io.loadmat(cell_metrics_file,simplify_cells=True)['cell_metrics']
|
|
64
|
+
unit_id = data['UID'] - 1
|
|
65
|
+
electrode_id = data['electrodeGroup'] # starts at 1
|
|
66
|
+
# starts from 0 CHECK WITH CLUSTER LOC, there's also maxWaveformChannelOrder, there's also Putative cell type!!
|
|
67
|
+
cluster_loc = data['maxWaveformCh']
|
|
68
|
+
spikes = data['spikes']['times']
|
|
69
|
+
|
|
70
|
+
if output == 'dict':
|
|
71
|
+
spikes = dict(zip(unit_id,spikes))
|
|
72
|
+
|
|
73
|
+
elif output == 'compact':
|
|
74
|
+
ids = np.repeat(unit_id, [len(s) for s in spikes])
|
|
75
|
+
spikes = np.stack((np.concatenate(spikes),ids), axis=1)
|
|
76
|
+
spikes = spikes[spikes[:, 0].argsort()]
|
|
77
|
+
|
|
78
|
+
elif output == 'full':
|
|
79
|
+
electrodes = np.repeat(electrode_id, [len(s) for s in spikes])
|
|
80
|
+
cluster_id = data['cluID'] # min is 2, as 0 and 1 are excluded clusters?
|
|
81
|
+
clusters = np.repeat(cluster_id, [len(s) for s in spikes])
|
|
82
|
+
spikes = np.stack((np.concatenate(spikes), electrodes, clusters), axis=1)
|
|
83
|
+
spikes = spikes[spikes[:, 0].argsort()]
|
|
84
|
+
|
|
85
|
+
elif output == 'regions':
|
|
86
|
+
# try loading
|
|
87
|
+
spike_file = file_root.parent / 'Regions' / (file_root.stem + '_spikes.npz')
|
|
88
|
+
if not reload and spike_file.exists():
|
|
89
|
+
spikes = loadRegionSpikes(spike_file)
|
|
90
|
+
else:
|
|
91
|
+
spikes_dict = dict(zip(unit_id,spikes))
|
|
92
|
+
if anat_file is None:
|
|
93
|
+
raise ValueError("'anat_file' must be provided when 'output' = 'regions'")
|
|
94
|
+
anat = loadAnatomyFile(anat_file)
|
|
95
|
+
anat = anat[anat['rat'] == int(file_root.stem[3:6])] # keep rat of interest, deduced from file name
|
|
96
|
+
ids = np.unique(anat['region'])
|
|
97
|
+
spikes = {str(id): {} for id in ids}
|
|
98
|
+
for id in ids:
|
|
99
|
+
spikes[id]['e_group'] = {g: [] for g in np.unique(anat[anat['region'] == id]['electrode'])}
|
|
100
|
+
s = []
|
|
101
|
+
for electrode in spikes[id]['e_group']:
|
|
102
|
+
electrode_units = unit_id[electrode_id == electrode]
|
|
103
|
+
spikes[id]['e_group'][electrode] = electrode_units
|
|
104
|
+
for unit in electrode_units:
|
|
105
|
+
s.append(np.array([spikes_dict[unit], [unit] * spikes_dict[unit].size]).T)
|
|
106
|
+
# assing spikes, sorted by time
|
|
107
|
+
if len(s):
|
|
108
|
+
s = np.concatenate(s)
|
|
109
|
+
spikes[id]['spikes'] = s[s[:,0].argsort()]
|
|
110
|
+
else:
|
|
111
|
+
spikes[id]['spikes'] = np.array(s,ndmin=2)
|
|
112
|
+
# save
|
|
113
|
+
saveRegionSpikes(spike_file,spikes)
|
|
114
|
+
|
|
115
|
+
else:
|
|
116
|
+
raise ValueError("'output' must be 'dict', 'compact', 'full', or 'regions'")
|
|
117
|
+
|
|
118
|
+
if return_extra:
|
|
119
|
+
return spikes, electrode_id, cluster_loc
|
|
120
|
+
return spikes
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def loadCluFiles(session:str, rate:float=20000, output:str='dict', anat_file:str=None, reload:bool=False):
|
|
124
|
+
|
|
125
|
+
if output not in ['dict','compact','full','regions']:
|
|
126
|
+
raise ValueError("'output' must be 'dict', 'compact', 'full', or 'regions'")
|
|
127
|
+
|
|
128
|
+
# load .res and .clu files
|
|
129
|
+
froot = pathlib.Path(session).with_suffix('')
|
|
130
|
+
res_files = {int(p.name[len(froot.name)+5:]): p for p in froot.parent.iterdir()
|
|
131
|
+
if p.is_file() and p.name.startswith(froot.name + '.res.') and p.name[len(froot.name)+5:].isdigit()}
|
|
132
|
+
clu_files = {int(p.name[len(froot.name)+5:]): p for p in froot.parent.iterdir()
|
|
133
|
+
if p.is_file() and p.name.startswith(froot.name + '.clu.') and p.name[len(froot.name)+5:].isdigit()}
|
|
134
|
+
res = {r: np.loadtxt(res_files[r],comments='%',delimiter=',') for r in res_files}
|
|
135
|
+
clu = {c: np.loadtxt(clu_files[c],comments='%',delimiter=',') for c in clu_files}
|
|
136
|
+
|
|
137
|
+
# create valid spike groups (i.e., electrodes where .res and .clu files have compatible length)
|
|
138
|
+
groups = np.sort(list(res.keys()))
|
|
139
|
+
clu_spikes = {}
|
|
140
|
+
for group in groups:
|
|
141
|
+
if group in res and group in clu and len(res[group]) == len(clu[group])-1:
|
|
142
|
+
clu_spikes[group] = np.array([res[group], np.full(len(res[group]),group), clu[group][1:]]).T
|
|
143
|
+
keep = (clu_spikes[group][:,-1] != 0) & (clu_spikes[group][:,-1] != 1) # remove artifacts and MUA
|
|
144
|
+
clu_spikes[group] = clu_spikes[group][keep,:]
|
|
145
|
+
clu_spikes[group][:,0] /= rate
|
|
146
|
+
if not clu_spikes:
|
|
147
|
+
raise FileNotFoundError('no valid .res or .clu file found')
|
|
148
|
+
|
|
149
|
+
if output == 'dict':
|
|
150
|
+
# loop through groups, find all units, add to dict
|
|
151
|
+
spikes = {}
|
|
152
|
+
id = 0
|
|
153
|
+
for group in clu_spikes:
|
|
154
|
+
units, idx = np.unique(clu_spikes[group][:,1:],axis=0,return_inverse=True)
|
|
155
|
+
for u in range(len(units)):
|
|
156
|
+
spikes[id] = clu_spikes[group][idx==u,0]
|
|
157
|
+
id += 1
|
|
158
|
+
|
|
159
|
+
elif output == 'compact':
|
|
160
|
+
# concatenate all values in spikes, use unique for ids
|
|
161
|
+
spikes = np.concatenate(tuple(clu_spikes.values()),axis=0)
|
|
162
|
+
units, idx = np.unique(spikes[:,1:],axis=0,return_inverse=True)
|
|
163
|
+
spikes = np.stack((spikes[:,0],idx),axis=1)
|
|
164
|
+
spikes = spikes[spikes[:,0].argsort()]
|
|
165
|
+
|
|
166
|
+
elif output == 'full':
|
|
167
|
+
spikes = np.concatenate(tuple(clu_spikes.values()),axis=0)
|
|
168
|
+
spikes = spikes[spikes[:,0].argsort()]
|
|
169
|
+
|
|
170
|
+
else:
|
|
171
|
+
# try loading
|
|
172
|
+
spike_file = froot.parent / 'Regions' / (froot.stem + '_spikes.npz')
|
|
173
|
+
if not reload and spike_file.exists():
|
|
174
|
+
spikes = loadRegionSpikes(spike_file)
|
|
175
|
+
else:
|
|
176
|
+
if anat_file is None:
|
|
177
|
+
raise ValueError("'anat_file' must be provided when 'output' = 'regions'")
|
|
178
|
+
anat = loadAnatomyFile(anat_file)
|
|
179
|
+
anat = anat[anat['rat'] == int(froot.stem[3:6])] # keep rat of interest, deduced from file name
|
|
180
|
+
ids = np.unique(anat['region'])
|
|
181
|
+
spikes = {id: {} for id in ids}
|
|
182
|
+
unit_count = 0
|
|
183
|
+
for id in ids:
|
|
184
|
+
spikes[id]['e_group'] = {g: [] for g in np.unique(anat[anat['region'] == id]['electrode'])}
|
|
185
|
+
sp = []
|
|
186
|
+
for electrode in spikes[id]['e_group']:
|
|
187
|
+
if electrode in clu_spikes:
|
|
188
|
+
# label units
|
|
189
|
+
s = clu_spikes[electrode]
|
|
190
|
+
u, idx = np.unique(s[:,1:], axis=0, return_inverse=True)
|
|
191
|
+
sp.append(np.stack((s[:,0],idx+unit_count),axis=1))
|
|
192
|
+
spikes[id]['e_group'][electrode] = np.arange(unit_count,unit_count+len(u))
|
|
193
|
+
unit_count = unit_count + len(u)
|
|
194
|
+
if len(sp):
|
|
195
|
+
sp = np.concatenate(sp)
|
|
196
|
+
spikes[id]['spikes'] = sp[sp[:,0].argsort()] # sort by time
|
|
197
|
+
else:
|
|
198
|
+
spikes[id]['spikes'] = np.array(sp,ndmin=2)
|
|
199
|
+
|
|
200
|
+
# save
|
|
201
|
+
saveRegionSpikes(spike_file,spikes)
|
|
202
|
+
|
|
203
|
+
return spikes
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def loadAnatomyFile(file_path):
|
|
207
|
+
# load .anat file, whose columns must be [rat, electrode, brain region] (comma separated)
|
|
208
|
+
#
|
|
209
|
+
# arguments:
|
|
210
|
+
# file_path string = None, path to .anat file
|
|
211
|
+
|
|
212
|
+
return np.genfromtxt(file_path,delimiter=",",comments="%",dtype=[("rat",int),("electrode",int),("region","U50")])
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def loadRegionSpikes(file):
|
|
216
|
+
spike_npz = np.load(file)
|
|
217
|
+
spikes = {r[3:]: {'spikes': spike_npz[r]} for r in spike_npz.files if r.startswith('sp_')}
|
|
218
|
+
for reg in spikes:
|
|
219
|
+
spikes[reg]['e_group'] = {int(r.rsplit('_',1)[-1]) : spike_npz[r] for r in spike_npz.files if r.startswith(f'un_{reg}')}
|
|
220
|
+
|
|
221
|
+
return spikes
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def saveRegionSpikes(file,spikes):
|
|
225
|
+
file = pathlib.Path(file)
|
|
226
|
+
if not file.parent.exists():
|
|
227
|
+
pathlib.Path.mkdir(file.parent)
|
|
228
|
+
np.savez(file, **{f'sp_{r}': s['spikes'] for r, s in spikes.items()},
|
|
229
|
+
**{f'un_{r}_{g}': units for r, s in spikes.items() for g, units in s['e_group'].items()})
|
|
230
|
+
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def loadEventFile(filename: str, compact: bool = False):
|
|
235
|
+
# load events from a .evt file
|
|
236
|
+
#
|
|
237
|
+
# arguments:
|
|
238
|
+
# filename string, .evt file, each line must have format 'beginning of basename_event1_1'
|
|
239
|
+
# compact bool = false, if true, return events as compact dictionary
|
|
240
|
+
#
|
|
241
|
+
# output:
|
|
242
|
+
# events dict, keys are either 'times' and 'descriptions' or event names if 'compact' = True
|
|
243
|
+
|
|
244
|
+
with open(filename,'r') as f:
|
|
245
|
+
lines = f.read().splitlines()
|
|
246
|
+
|
|
247
|
+
# extract first non-whitespace token
|
|
248
|
+
times = [line.split()[0] for line in lines]
|
|
249
|
+
times = np.array([float(t) / 1000.0 for t in times]) # convert to seconds
|
|
250
|
+
|
|
251
|
+
# remove first token and following whitespace
|
|
252
|
+
descriptions = []
|
|
253
|
+
for line in lines:
|
|
254
|
+
parts = line.split(maxsplit=1)
|
|
255
|
+
descriptions.append(parts[1] if len(parts) > 1 else '')
|
|
256
|
+
|
|
257
|
+
if compact:
|
|
258
|
+
# group by events, description is of type 'beginning of basename_event1' or 'beginning of basename_event1_1'
|
|
259
|
+
events = {} #collections.defaultdict(lambda: collections.defaultdict(list))
|
|
260
|
+
for t, d in zip(times,descriptions):
|
|
261
|
+
if " of " not in d:
|
|
262
|
+
raise fmatoolbox.exceptions.FileFormatError(f"Unexpected format: '{d}'")
|
|
263
|
+
phase, full_id = d.split(" of ",1)
|
|
264
|
+
phase = phase.strip()
|
|
265
|
+
full_id = full_id.strip()
|
|
266
|
+
# remove trailing 'bis' and '_1'
|
|
267
|
+
if full_id.endswith('bis'):
|
|
268
|
+
full_id = full_id[:-3]
|
|
269
|
+
parts = full_id.split('_')
|
|
270
|
+
try:
|
|
271
|
+
if parts[-1].isdigit():
|
|
272
|
+
part = parts[-2]
|
|
273
|
+
else:
|
|
274
|
+
part = parts[-1]
|
|
275
|
+
except:
|
|
276
|
+
raise ValueError(f"Invalid event ID format: '{full_id}'")
|
|
277
|
+
event_id = part
|
|
278
|
+
# concatenate values
|
|
279
|
+
if event_id in events:
|
|
280
|
+
if phase in events[event_id]:
|
|
281
|
+
events[event_id][phase].append(t)
|
|
282
|
+
else:
|
|
283
|
+
events[event_id][phase] = [t]
|
|
284
|
+
else:
|
|
285
|
+
events[event_id] = {phase : [t]}
|
|
286
|
+
else:
|
|
287
|
+
events = { 'time': times, 'description': descriptions }
|
|
288
|
+
|
|
289
|
+
return events
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def loadEvents(session:str, extra:str|list[str]):
|
|
293
|
+
# load event files from a session
|
|
294
|
+
#
|
|
295
|
+
# arguments:
|
|
296
|
+
# session string, path to session .xml file, event files must be in session folder
|
|
297
|
+
# extra (:) string, extensions of other event files named <basename>.extra[i] to load,
|
|
298
|
+
# can be 'subdir/extension' to load '.../basename/subdir/basename.extension'
|
|
299
|
+
#
|
|
300
|
+
# output:
|
|
301
|
+
# events dict, keys are event names
|
|
302
|
+
# cat_names (:) string, names of events contained in <basename>.cat.evt
|
|
303
|
+
|
|
304
|
+
session = pathlib.Path(session)
|
|
305
|
+
file_root = session.parent
|
|
306
|
+
# accept single string input
|
|
307
|
+
if isinstance(extra,str):
|
|
308
|
+
extra = [extra]
|
|
309
|
+
|
|
310
|
+
# load all *.evt files
|
|
311
|
+
events = {}
|
|
312
|
+
cat_names = []
|
|
313
|
+
for file in file_root.glob("*.evt"):
|
|
314
|
+
try:
|
|
315
|
+
events.update(loadEventFile(file,compact=True))
|
|
316
|
+
except fmatoolbox.exceptions.FileFormatError:
|
|
317
|
+
pass
|
|
318
|
+
if str(file)[-8:] == '.cat.evt':
|
|
319
|
+
cat_names = list(events.keys())
|
|
320
|
+
|
|
321
|
+
# load other file types
|
|
322
|
+
for extension in extra:
|
|
323
|
+
# handle subfolders, e.g., 'subfolder/name'
|
|
324
|
+
e = pathlib.Path(extension).name
|
|
325
|
+
p = pathlib.Path(extension).parent
|
|
326
|
+
this_froot = file_root / p / session.stem
|
|
327
|
+
if this_froot.with_suffix(f'.{e}.npz').exists():
|
|
328
|
+
# load .npz file
|
|
329
|
+
data = np.load(this_froot.with_suffix(f'.{e}.npz'))
|
|
330
|
+
for d in data:
|
|
331
|
+
events[d] = {f'col{i}': data[d][:,i] for i in range(data[d].shape[1])}
|
|
332
|
+
elif this_froot.with_suffix(f'.{e}.events.mat').exists():
|
|
333
|
+
# load .events.mat file
|
|
334
|
+
events[e] = scipy.io.loadmat(this_froot.with_suffix(f'.{e}.events.mat'),simplify_cells=True)[e]
|
|
335
|
+
else:
|
|
336
|
+
# load text file
|
|
337
|
+
data = np.loadtxt(this_froot.with_suffix(f'.{e}'),comments='%',delimiter=',',ndmin=2)
|
|
338
|
+
events[e] = {f'col{i}': data[:,i] for i in range(data.shape[1])}
|
|
339
|
+
|
|
340
|
+
return events, cat_names
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def loadSpikeWaveforms(session: str):
|
|
344
|
+
|
|
345
|
+
file_root = pathlib.Path(session).with_suffix('')
|
|
346
|
+
data = scipy.io.loadmat(file_root.with_suffix('.cell_metrics.cellinfo.mat'),simplify_cells=True)['cell_metrics']
|
|
347
|
+
waveforms = data['waveforms']
|
|
348
|
+
# INSPECT struct TO FIND OUTPUT
|
|
349
|
+
print('implement!')
|
|
350
|
+
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def loadLFP(session: str):
|
|
355
|
+
|
|
356
|
+
print('implement!')
|
|
357
|
+
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def saveMatrix():
|
|
362
|
+
# save matrix in standard FMAT format, prepending metadata header
|
|
363
|
+
|
|
364
|
+
print('implement!')
|
|
365
|
+
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
# functions to run batch
|
|
369
|
+
|
|
370
|
+
def readBatchFile(file_path: str):
|
|
371
|
+
# read batch file
|
|
372
|
+
#
|
|
373
|
+
# arguments:
|
|
374
|
+
# file_path batch file
|
|
375
|
+
#
|
|
376
|
+
# output:
|
|
377
|
+
# sessions DESCRIBE
|
|
378
|
+
# args DESCRIBE
|
|
379
|
+
|
|
380
|
+
sessions = []
|
|
381
|
+
args = []
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
with open(file_path, 'r') as f:
|
|
385
|
+
lines = f.readlines()
|
|
386
|
+
except OSError:
|
|
387
|
+
raise IOError(f"Unable to open {file_path}")
|
|
388
|
+
|
|
389
|
+
for line in lines:
|
|
390
|
+
|
|
391
|
+
# strip spaces, remove inline comments (anything after a %)
|
|
392
|
+
line = line.strip().split('%',1)[0].strip()
|
|
393
|
+
|
|
394
|
+
# split into words
|
|
395
|
+
words = line.split()
|
|
396
|
+
if not words:
|
|
397
|
+
continue
|
|
398
|
+
|
|
399
|
+
# first word is the session name
|
|
400
|
+
sessions.append(words[0])
|
|
401
|
+
|
|
402
|
+
# remaining words are arguments
|
|
403
|
+
session_args = []
|
|
404
|
+
for w in words[1:]:
|
|
405
|
+
try:
|
|
406
|
+
# evaluate numbers, lists, etc.
|
|
407
|
+
value = ast.literal_eval(w)
|
|
408
|
+
except (ValueError, SyntaxError):
|
|
409
|
+
# if not a literal, keep as string
|
|
410
|
+
value = w
|
|
411
|
+
session_args.append(value)
|
|
412
|
+
|
|
413
|
+
args.append(session_args)
|
|
414
|
+
|
|
415
|
+
return sessions, args
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _batchWorker(payload):
|
|
419
|
+
# private function to unpack payload and dispatch it to `func` inside runBatc
|
|
420
|
+
i, func, session, args, extra_args, kwargs, seed = payload
|
|
421
|
+
try:
|
|
422
|
+
result = func(session,*args,*extra_args,**kwargs,**seed)
|
|
423
|
+
return i, result
|
|
424
|
+
except Exception as e:
|
|
425
|
+
return -i-1, e
|
|
426
|
+
|
|
427
|
+
|
|
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,
|
|
429
|
+
sessions:list[int]=None, parallel:bool|int=False, verbose:bool=True) -> tuple[list, ...]:
|
|
430
|
+
# run a routine on multiple sessions
|
|
431
|
+
#
|
|
432
|
+
# arguments:
|
|
433
|
+
# batch_file string, path to batch file
|
|
434
|
+
# func function to call for each session, must take session path as first arg
|
|
435
|
+
# args list of list = [[]], positional arguments for 'func', one per session or a single list for all
|
|
436
|
+
# rnd_seed str = None, if given, spawn numpy random seeds, passed to 'func' as keyword argument, necessary to use np.random with 'parallel'
|
|
437
|
+
# kwargs list of dict = [{}], keyword arguments for 'func', a dict per session or one for all
|
|
438
|
+
# ignore_args bool = False, if True, ignore extra arguments from batch file
|
|
439
|
+
# sessions (:) int = None, indices of session to process (default is all sessions from batch file)
|
|
440
|
+
# parallel bool | int = False, parallelize calls of `func` with concurrent.futures
|
|
441
|
+
# verbose: bool = True, log progress
|
|
442
|
+
#
|
|
443
|
+
# output:
|
|
444
|
+
# variable outputs matching func's signature
|
|
445
|
+
|
|
446
|
+
# parse batch file
|
|
447
|
+
sessions_list, extra_args = readBatchFile(batch_file)
|
|
448
|
+
if sessions is not None:
|
|
449
|
+
if not isinstance(sessions, collections.abc.Iterable):
|
|
450
|
+
sessions = [sessions]
|
|
451
|
+
sessions_list = [sessions_list[i] for i in sessions]
|
|
452
|
+
extra_args = [extra_args[i] for i in sessions]
|
|
453
|
+
n_sessions = len(sessions_list)
|
|
454
|
+
if ignore_args:
|
|
455
|
+
extra_args = [[]] * n_sessions
|
|
456
|
+
|
|
457
|
+
# validate optional arguments
|
|
458
|
+
if args is None:
|
|
459
|
+
args = [[]]
|
|
460
|
+
if len(args) == 1:
|
|
461
|
+
args = args * n_sessions
|
|
462
|
+
elif len(args) != n_sessions:
|
|
463
|
+
raise ValueError("Argument 'args' must contain one list per session")
|
|
464
|
+
if rnd_seed is not None:
|
|
465
|
+
seed_gen = np.random.SeedSequence()
|
|
466
|
+
seeds = seed_gen.spawn(n_sessions)
|
|
467
|
+
seeds = [{rnd_seed: s} for s in seeds]
|
|
468
|
+
else:
|
|
469
|
+
seeds = [{}] * n_sessions
|
|
470
|
+
if kwargs is None:
|
|
471
|
+
kwargs = [{}]
|
|
472
|
+
elif isinstance(kwargs,dict):
|
|
473
|
+
kwargs = [kwargs]
|
|
474
|
+
if len(kwargs) == 1:
|
|
475
|
+
kwargs = kwargs * n_sessions
|
|
476
|
+
elif len(kwargs) != n_sessions:
|
|
477
|
+
raise ValueError("Argument 'kwargs' must contain one dict per session")
|
|
478
|
+
|
|
479
|
+
verbose and print(f"\nStarting Batch, {datetime.datetime.now()} \n")
|
|
480
|
+
results = [None] * n_sessions
|
|
481
|
+
errors = {}
|
|
482
|
+
RED = "\033[31m"
|
|
483
|
+
RESET = "\033[0m"
|
|
484
|
+
|
|
485
|
+
# 1. Serial Batch
|
|
486
|
+
if not parallel:
|
|
487
|
+
for i, session in enumerate(sessions_list):
|
|
488
|
+
verbose and print(f'Batch progress: {session}, {i+1} out of {n_sessions}')
|
|
489
|
+
try:
|
|
490
|
+
results[i] = func(session,*args[i],*extra_args[i],**kwargs[i],**seeds[i])
|
|
491
|
+
except Exception as e:
|
|
492
|
+
# log error to console
|
|
493
|
+
errors[-i-1] = e
|
|
494
|
+
print(f'Error in session {session}\n{str(e)}\nTraceback:')
|
|
495
|
+
tb = e.__traceback__
|
|
496
|
+
while tb:
|
|
497
|
+
fcode = tb.tb_frame.f_code
|
|
498
|
+
print(f'{RED}{fcode.co_filename}, line {tb.tb_lineno}, in {fcode.co_name}{RESET}')
|
|
499
|
+
tb = tb.tb_next
|
|
500
|
+
verbose and print()
|
|
501
|
+
|
|
502
|
+
# 2. Parallel Batch
|
|
503
|
+
else:
|
|
504
|
+
# pack arguments into payloads
|
|
505
|
+
payloads = [(i,func,s,args[i],extra_args[i],kwargs[i],seeds[i]) for i, s in enumerate(sessions_list)]
|
|
506
|
+
if parallel is True:
|
|
507
|
+
parallel = None # to keep default max_workers
|
|
508
|
+
with concurrent.futures.ProcessPoolExecutor(max_workers=parallel) as ex:
|
|
509
|
+
for i, result in ex.map(_batchWorker, payloads):
|
|
510
|
+
if i >= 0:
|
|
511
|
+
results[i] = result
|
|
512
|
+
else:
|
|
513
|
+
errors[i] = result
|
|
514
|
+
# log errors to console
|
|
515
|
+
for i, e in errors.items():
|
|
516
|
+
print(f'Error in session {sessions_list[-i-1]} ({-i-1})\n{str(e)}\nTraceback:')
|
|
517
|
+
tb = e.__traceback__
|
|
518
|
+
while tb: # NOT WORKING FOR NOW
|
|
519
|
+
fcode = tb.tb_frame.f_code
|
|
520
|
+
print(f'{RED}{fcode.co_filename}, line {tb.tb_lineno}, in {fcode.co_name}{RESET}')
|
|
521
|
+
tb = tb.tb_next
|
|
522
|
+
|
|
523
|
+
# determine output number (expected to be constant across func calls)
|
|
524
|
+
n_outs = None
|
|
525
|
+
outputs = [None] * n_sessions
|
|
526
|
+
for result in results:
|
|
527
|
+
if result is not None:
|
|
528
|
+
if isinstance(result,tuple):
|
|
529
|
+
n_outs = len(result)
|
|
530
|
+
outputs = [outputs.copy() for _ in range(n_outs)]
|
|
531
|
+
else:
|
|
532
|
+
n_outs = 0 # 0 marks the single output case
|
|
533
|
+
break
|
|
534
|
+
# assign outputs
|
|
535
|
+
if n_outs is not None:
|
|
536
|
+
for i in range(n_sessions):
|
|
537
|
+
if results[i] is not None:
|
|
538
|
+
if n_outs == 0:
|
|
539
|
+
outputs[i] = results[i]
|
|
540
|
+
else:
|
|
541
|
+
for j in range(n_outs):
|
|
542
|
+
outputs[j][i] = results[i][j]
|
|
543
|
+
|
|
544
|
+
verbose and print(f'Batch completed with {len(errors)} errors, {datetime.datetime.now()}')
|
|
545
|
+
|
|
546
|
+
return tuple(outputs) if n_outs else outputs
|
fmatoolbox/exceptions.py
ADDED