labdata 0.0.3__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.
- labdata/__init__.py +17 -0
- labdata/cli.py +499 -0
- labdata/compute/__init__.py +27 -0
- labdata/compute/ec2.py +198 -0
- labdata/compute/ephys.py +469 -0
- labdata/compute/pose.py +281 -0
- labdata/compute/schedulers.py +194 -0
- labdata/compute/singularity.py +95 -0
- labdata/compute/utils.py +561 -0
- labdata/copy.py +351 -0
- labdata/rules/__init__.py +78 -0
- labdata/rules/ephys.py +188 -0
- labdata/rules/imaging.py +618 -0
- labdata/rules/utils.py +290 -0
- labdata/s3.py +317 -0
- labdata/schema/__init__.py +24 -0
- labdata/schema/ephys.py +547 -0
- labdata/schema/general.py +647 -0
- labdata/schema/histology.py +309 -0
- labdata/schema/onephoton.py +93 -0
- labdata/schema/procedures.py +102 -0
- labdata/schema/tasks.py +66 -0
- labdata/schema/twophoton.py +142 -0
- labdata/schema/utils.py +25 -0
- labdata/schema/video.py +243 -0
- labdata/stacks.py +182 -0
- labdata/utils.py +598 -0
- labdata/widgets.py +412 -0
- labdata-0.0.3.dist-info/METADATA +42 -0
- labdata-0.0.3.dist-info/RECORD +36 -0
- labdata-0.0.3.dist-info/WHEEL +5 -0
- labdata-0.0.3.dist-info/entry_points.txt +2 -0
- labdata-0.0.3.dist-info/licenses/LICENSE +674 -0
- labdata-0.0.3.dist-info/top_level.txt +2 -0
- labdata_frontend/Home.py +39 -0
- labdata_frontend/__init__.py +0 -0
labdata/copy.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
from .utils import *
|
|
2
|
+
from warnings import warn
|
|
3
|
+
|
|
4
|
+
def _copyfile_to_upload_server(filepath, local_path=None, server_path = None,overwrite = False):
|
|
5
|
+
'''
|
|
6
|
+
|
|
7
|
+
This is an internal function, not meant to be called by the end user, call copy_to_upload_server instead.
|
|
8
|
+
|
|
9
|
+
This is a support function that will copy data between computers; it will not overwrite, unless forced.
|
|
10
|
+
|
|
11
|
+
It will raise an exception if the files are already there unless overwrite is true.
|
|
12
|
+
|
|
13
|
+
Does not insert to the Upload table.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
filepath : str or Path
|
|
19
|
+
Path to the file to copy, relative to local_path
|
|
20
|
+
local_path : str or Path, optional
|
|
21
|
+
Source directory containing the file. If None, uses first path from preferences
|
|
22
|
+
server_path : str or Path, optional
|
|
23
|
+
Destination directory to copy to. If None, uses upload_path from preferences
|
|
24
|
+
overwrite : bool, default False
|
|
25
|
+
Whether to overwrite existing files at the destination
|
|
26
|
+
|
|
27
|
+
Returns
|
|
28
|
+
-------
|
|
29
|
+
dict
|
|
30
|
+
Dictionary containing:
|
|
31
|
+
- src_path: Relative path to the file
|
|
32
|
+
- src_md5: MD5 hash of the file
|
|
33
|
+
- src_size: Size of the file in bytes
|
|
34
|
+
- src_datetime: Creation timestamp of the file
|
|
35
|
+
|
|
36
|
+
Raises
|
|
37
|
+
------
|
|
38
|
+
OSError
|
|
39
|
+
If filepath is a directory
|
|
40
|
+
If destination file exists and overwrite=False
|
|
41
|
+
If copy operation fails
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
Joao Couto - labdata 2024
|
|
45
|
+
|
|
46
|
+
'''
|
|
47
|
+
|
|
48
|
+
src = Path(local_path)/filepath
|
|
49
|
+
if src.is_dir():
|
|
50
|
+
raise OSError(f'Can only handle files {src}; copy each file in the folder separately.')
|
|
51
|
+
dst = Path(server_path)/filepath
|
|
52
|
+
if not overwrite and dst.exists() and not src == dst:
|
|
53
|
+
raise OSError(f'File {dst} exists; will not overwrite.')
|
|
54
|
+
hash = compute_md5_hash(src) # computes the hash
|
|
55
|
+
srcstat = src.stat()
|
|
56
|
+
file_size = srcstat.st_size
|
|
57
|
+
from shutil import copy2
|
|
58
|
+
dst.parent.mkdir(parents=True, exist_ok = True)
|
|
59
|
+
if not src == dst:
|
|
60
|
+
try:
|
|
61
|
+
copy2(src, dst)
|
|
62
|
+
except:
|
|
63
|
+
raise OSError(f'Could not copy {src} to {dst}.')
|
|
64
|
+
else:
|
|
65
|
+
# if the source and destination are the same, don't copy - just return cause its in the same server..
|
|
66
|
+
print('[copy_to_upload_server] Source and destination are the same, not copying.', flush = True)
|
|
67
|
+
filepath = str(filepath).replace(pathlib.os.sep,'/').replace('//','/')
|
|
68
|
+
return dict(src_path = filepath,
|
|
69
|
+
src_md5 = hash,
|
|
70
|
+
src_size = file_size,
|
|
71
|
+
src_datetime = datetime.fromtimestamp(srcstat.st_ctime))
|
|
72
|
+
|
|
73
|
+
def copy_to_upload_server(filepaths, local_path = None, server_path = None,
|
|
74
|
+
upload_storage = None,
|
|
75
|
+
overwrite = False,
|
|
76
|
+
n_jobs = 8,
|
|
77
|
+
job_rule = None,
|
|
78
|
+
parse_filename = True,
|
|
79
|
+
**kwargs):
|
|
80
|
+
'''
|
|
81
|
+
Copy data between computers; it will not overwrite, unless forced.
|
|
82
|
+
|
|
83
|
+
Returns a list of dictionaries with the file paths and md5 checksums.
|
|
84
|
+
|
|
85
|
+
Inserts in the Upload table.
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
filepaths : list
|
|
89
|
+
List of file paths to copy to upload server
|
|
90
|
+
local_path : str, optional
|
|
91
|
+
Local path where files are stored. If None, uses first path from preferences
|
|
92
|
+
server_path : str, optional
|
|
93
|
+
Path on upload server. If None, uses upload_path from preferences
|
|
94
|
+
upload_storage : str, optional
|
|
95
|
+
Storage location for uploaded files. If None, uses upload_storage from preferences
|
|
96
|
+
overwrite : bool, default False
|
|
97
|
+
Whether to overwrite existing files on server
|
|
98
|
+
n_jobs : int, default 8
|
|
99
|
+
Number of parallel jobs for copying files
|
|
100
|
+
job_rule : str, optional
|
|
101
|
+
Rule to apply during upload
|
|
102
|
+
parse_filename : bool, default True
|
|
103
|
+
Whether to parse metadata from filenames based on path rules
|
|
104
|
+
**kwargs : dict
|
|
105
|
+
Additional metadata to store with upload (e.g. subject_name, session_name)
|
|
106
|
+
|
|
107
|
+
Returns
|
|
108
|
+
-------
|
|
109
|
+
dict
|
|
110
|
+
Dictionary containing upload job information
|
|
111
|
+
|
|
112
|
+
Joao Couto - labdata 2024
|
|
113
|
+
'''
|
|
114
|
+
|
|
115
|
+
from .schema import UploadJob,Setup,Subject,Session,Dataset,dj
|
|
116
|
+
if local_path is None: # get the local_path from the preferences
|
|
117
|
+
if not 'local_paths' in prefs.keys():
|
|
118
|
+
raise OSError('Local data path [local_paths] not specified in the preference file.')
|
|
119
|
+
local_path = prefs['local_paths'][0]
|
|
120
|
+
if server_path is None: # get the upload_path from the preferences
|
|
121
|
+
if not 'upload_path' in prefs.keys():
|
|
122
|
+
raise OSError('Upload storage [upload_path], not specified in the preference file.')
|
|
123
|
+
server_path = prefs['upload_path']
|
|
124
|
+
if upload_storage is None: # get the upload_storage from the preferences, where data will be stored.
|
|
125
|
+
if not 'upload_storage' in prefs.keys():
|
|
126
|
+
raise OSError('Upload storage [upload_storage], not specified in the preference file.')
|
|
127
|
+
upload_storage = prefs['upload_storage']
|
|
128
|
+
|
|
129
|
+
if not type(filepaths) is list: # Check if the filepaths are in a list
|
|
130
|
+
raise ValueError('Input filepaths must be a list of paths.')
|
|
131
|
+
# replace local_path if the user passed it like that by accident.
|
|
132
|
+
filepaths = [str(f).replace(str(local_path),'') for f in filepaths]
|
|
133
|
+
# remove trailing / or \
|
|
134
|
+
filepaths = [f if not f.startswith(pathlib.os.sep) else f[1:] for f in filepaths]
|
|
135
|
+
# make unique
|
|
136
|
+
filepaths = [f for f in np.unique(filepaths)]
|
|
137
|
+
|
|
138
|
+
if any_path_uploaded(filepaths):
|
|
139
|
+
if not job_rule in ['replace']:
|
|
140
|
+
raise OSError('Path was already uploaded {0}'.format(Path(filepaths[0]).parent))
|
|
141
|
+
else:
|
|
142
|
+
warn('[copy_to_upload_server] Files exist in the server and will be replaced.')
|
|
143
|
+
|
|
144
|
+
if parse_filename: # parse filename based on the path rules
|
|
145
|
+
tmp = parse_filepath_parts(filepaths[0])
|
|
146
|
+
for k in tmp.keys():
|
|
147
|
+
if not k in kwargs.keys():
|
|
148
|
+
kwargs[k] = tmp[k]
|
|
149
|
+
|
|
150
|
+
n_jobs = validate_num_jobs_joblib(n_jobs) # avoid nested parallelism.
|
|
151
|
+
# copy and compute checksum for all paths in parallel.
|
|
152
|
+
res = Parallel(n_jobs = n_jobs)(
|
|
153
|
+
delayed(_copyfile_to_upload_server)(path,
|
|
154
|
+
local_path = local_path,
|
|
155
|
+
server_path = server_path,
|
|
156
|
+
overwrite = overwrite) for path in filepaths)
|
|
157
|
+
# Add it to the upload table
|
|
158
|
+
# check the job id
|
|
159
|
+
with dj.conn().transaction:
|
|
160
|
+
#print(kwargs)
|
|
161
|
+
if "setup_name" in kwargs.keys():
|
|
162
|
+
Setup.insert1(kwargs, skip_duplicates = True, ignore_extra_fields = True) # try to insert setup
|
|
163
|
+
if "dataset_name" in kwargs.keys() and "session_name" in kwargs.keys() and "subject_name" in kwargs.keys():
|
|
164
|
+
if not len(Subject() & dict(subject_name=kwargs['subject_name'])):
|
|
165
|
+
try:
|
|
166
|
+
Subject.insert1(kwargs, skip_duplicates = True,ignore_extra_fields = True) # try to insert subject
|
|
167
|
+
except Exception as err:
|
|
168
|
+
warn(f'Could not insert subject {kwargs}')
|
|
169
|
+
print(err, flush = True)
|
|
170
|
+
# needs date of birth and sex
|
|
171
|
+
if not len(Session() & dict(subject_name=kwargs['subject_name'],
|
|
172
|
+
session_name = kwargs['session_name'])):
|
|
173
|
+
Session.insert1(kwargs, skip_duplicates = True,ignore_extra_fields = True) # try to insert session
|
|
174
|
+
if not len(Dataset() & dict(subject_name = kwargs['subject_name'],
|
|
175
|
+
session_name = kwargs['session_name'],
|
|
176
|
+
dataset_name = kwargs['dataset_name'])):
|
|
177
|
+
Dataset.insert1(kwargs, skip_duplicates = True, ignore_extra_fields = True) # try to insert dataset
|
|
178
|
+
# this is a brute force way... there should be a better way of doing this but the auto-increment not return in datajoint...
|
|
179
|
+
attempts = 10
|
|
180
|
+
jobid = UploadJob().fetch('job_id')
|
|
181
|
+
if len(jobid):
|
|
182
|
+
jobid = np.max(jobid) + 1
|
|
183
|
+
else:
|
|
184
|
+
jobid = 1
|
|
185
|
+
job_insert_failed = 1
|
|
186
|
+
for iattempt in range(attempts):
|
|
187
|
+
try:
|
|
188
|
+
UploadJob.insert1(dict(job_id = jobid,
|
|
189
|
+
job_status = "ON SERVER",
|
|
190
|
+
upload_storage = upload_storage,
|
|
191
|
+
job_rule = job_rule,
|
|
192
|
+
**kwargs),
|
|
193
|
+
ignore_extra_fields = True) # Need to insert the dataset first if not there
|
|
194
|
+
job_insert_failed = 0
|
|
195
|
+
break # we have the job id
|
|
196
|
+
except Exception as err:
|
|
197
|
+
jobid += 1
|
|
198
|
+
print(err) # we don't have it, do it again
|
|
199
|
+
if job_insert_failed:
|
|
200
|
+
raise ValueError(f'Job insert failed because could not add {jobid} to the UploadJob queue.')
|
|
201
|
+
res = [dict(r, job_id = jobid) for r in res] # add dataset through kwargs
|
|
202
|
+
UploadJob.AssignedFiles.insert(res, ignore_extra_fields=True)
|
|
203
|
+
# the upload server will run the checksum and upload the files.
|
|
204
|
+
return res
|
|
205
|
+
|
|
206
|
+
def any_path_uploaded(filepaths):
|
|
207
|
+
'''
|
|
208
|
+
any_path_uploaded(filepaths)
|
|
209
|
+
|
|
210
|
+
Checks if any of the provided file paths:
|
|
211
|
+
- Exist in the File table (already uploaded)
|
|
212
|
+
- Exist in the UploadJob.AssignedFiles table (queued for upload)
|
|
213
|
+
- Exist in the ProcessedFile table (original file deleted because the dataset was processed with a Rule)
|
|
214
|
+
|
|
215
|
+
Parameters
|
|
216
|
+
----------
|
|
217
|
+
filepaths : list
|
|
218
|
+
List of file paths to check
|
|
219
|
+
|
|
220
|
+
Returns
|
|
221
|
+
-------
|
|
222
|
+
bool
|
|
223
|
+
True if any of the files are already uploaded or in the upload queue
|
|
224
|
+
False if none of the files are uploaded or queued
|
|
225
|
+
'''
|
|
226
|
+
from .schema import UploadJob, File, ProcessedFile
|
|
227
|
+
# check if the paths are in "Upload or in Files"
|
|
228
|
+
paths = []
|
|
229
|
+
src_paths = []
|
|
230
|
+
for p in filepaths: # if true for any of the filepaths return True
|
|
231
|
+
pn = p.replace(pathlib.os.sep,'/').replace('//','/')
|
|
232
|
+
paths.append(dict(file_path = pn))
|
|
233
|
+
src_paths.append(dict(src_path = pn))
|
|
234
|
+
if len((File() & paths)) > 0:
|
|
235
|
+
warn(f'Found paths in File table {File() & paths}')
|
|
236
|
+
return True
|
|
237
|
+
if len(UploadJob.AssignedFiles() & src_paths) > 0:
|
|
238
|
+
warn(f'Found paths in UploadJob table {UploadJob.AssignedFiles() & src_paths}')
|
|
239
|
+
return True
|
|
240
|
+
if len(ProcessedFile() & paths) > 0:
|
|
241
|
+
warn(f'Found paths in ProcessedFile table {ProcessedFile() & paths}')
|
|
242
|
+
return True
|
|
243
|
+
return False # Otherwise return False
|
|
244
|
+
|
|
245
|
+
def all_paths_uploaded(filepaths):
|
|
246
|
+
'''
|
|
247
|
+
all_paths_uploaded(filepaths)
|
|
248
|
+
|
|
249
|
+
Checks if all of the provided file paths exist in the File table (already uploaded)
|
|
250
|
+
|
|
251
|
+
This function only checks the File table, unlike any_path_uploaded() which also checks
|
|
252
|
+
UploadJob.AssignedFiles and ProcessedFile tables.
|
|
253
|
+
|
|
254
|
+
Parameters
|
|
255
|
+
----------
|
|
256
|
+
filepaths : list
|
|
257
|
+
List of file paths to check
|
|
258
|
+
|
|
259
|
+
Returns
|
|
260
|
+
-------
|
|
261
|
+
bool
|
|
262
|
+
True if all of the files are already uploaded
|
|
263
|
+
False if any files are missing from the File table
|
|
264
|
+
'''
|
|
265
|
+
from .schema import UploadJob, File, ProcessedFile
|
|
266
|
+
# check if the paths are in "Upload or in Files"
|
|
267
|
+
paths = []
|
|
268
|
+
src_paths = []
|
|
269
|
+
for p in filepaths: # if true for any of the filepaths return True
|
|
270
|
+
pn = p.replace(pathlib.os.sep,'/').replace('//','/')
|
|
271
|
+
paths.append(dict(file_path = pn))
|
|
272
|
+
src_paths.append(dict(src_path = pn))
|
|
273
|
+
if len((File() & paths)) == len(filepaths):
|
|
274
|
+
return True
|
|
275
|
+
|
|
276
|
+
warn(f'Could not find some files in the table {File() & paths} when trying to find {filepaths}')
|
|
277
|
+
return False # Otherwise return False
|
|
278
|
+
|
|
279
|
+
def clean_local_path(local_path = None, filterkeys = [], dry_run = False):
|
|
280
|
+
'''
|
|
281
|
+
clean_local_path(local_path = None, filterkeys = [], dry_run = False)
|
|
282
|
+
|
|
283
|
+
Remove local files that are already in the database from a local path
|
|
284
|
+
Clean local files that have already been uploaded to the database.
|
|
285
|
+
|
|
286
|
+
This function checks files in a local directory against the database and removes
|
|
287
|
+
local copies that have already been successfully uploaded (verified by MD5 checksum).
|
|
288
|
+
|
|
289
|
+
Files are only deleted if they exist in either the File or ProcessedFile tables
|
|
290
|
+
AND their MD5 checksums match exactly
|
|
291
|
+
|
|
292
|
+
Files with mismatched checksums are kept and reported
|
|
293
|
+
|
|
294
|
+
Empty directories are not automatically removed (TODO)
|
|
295
|
+
|
|
296
|
+
Parameters
|
|
297
|
+
----------
|
|
298
|
+
local_path : str or Path, optional
|
|
299
|
+
Path to local directory to clean. If None, uses first path from preferences.
|
|
300
|
+
filterkeys : list, optional
|
|
301
|
+
List of strings to filter filenames by. Only files containing all filterkeys will be checked.
|
|
302
|
+
dry_run : bool, optional
|
|
303
|
+
If True, only prints what would be deleted without actually deleting files.
|
|
304
|
+
|
|
305
|
+
Returns
|
|
306
|
+
-------
|
|
307
|
+
deleted : list
|
|
308
|
+
List of files that were deleted (or would be deleted in dry_run mode)
|
|
309
|
+
keep : list
|
|
310
|
+
List of files that were kept because their checksums did not match the database
|
|
311
|
+
|
|
312
|
+
Joao Couto - labdata 2024
|
|
313
|
+
'''
|
|
314
|
+
from .schema import File, ProcessedFile
|
|
315
|
+
if local_path is None:
|
|
316
|
+
local_path = prefs['local_paths'][0]
|
|
317
|
+
filelist = Path(local_path).rglob('*')
|
|
318
|
+
local_filelist = list(filter(lambda x: x.is_file(),filelist))
|
|
319
|
+
remote_filelist = np.array(drop_local_path(local_filelist))
|
|
320
|
+
# check that the files follow a filter
|
|
321
|
+
selection = np.ones_like(remote_filelist, dtype=bool)
|
|
322
|
+
for f in filterkeys:
|
|
323
|
+
selection *= np.array([1 if f in str(k) else 0 for k in remote_filelist],dtype=bool)
|
|
324
|
+
remote_keys_all = [dict(file_path = str(r).replace('\\','/')) for r in remote_filelist[selection]]
|
|
325
|
+
# get the remote keys
|
|
326
|
+
remote_keys = (File() & remote_keys_all).fetch(as_dict = True)
|
|
327
|
+
remote_keys += (ProcessedFile() & remote_keys_all).fetch(as_dict = True)
|
|
328
|
+
keys = [dict(r,
|
|
329
|
+
local_path = Path(local_path)/r['file_path']) for r in remote_keys]
|
|
330
|
+
res,comparison = compare_md5s([r['local_path'] for r in keys],[r['file_md5'] for r in keys],
|
|
331
|
+
full_output = True,
|
|
332
|
+
show_progress = True,
|
|
333
|
+
suppress_file_not_found = True) # so if the files disappear it doesn't crash.
|
|
334
|
+
if not res:
|
|
335
|
+
print(tcolor['r'](f'{np.sum(~np.array(comparison))} local files have different checksums.'),flush = True)
|
|
336
|
+
for k,r in zip(keys,comparison):
|
|
337
|
+
if not r:
|
|
338
|
+
print(tcolor['r'](f" \t {k['local_path']}"),flush = True)
|
|
339
|
+
keep = []
|
|
340
|
+
deleted = []
|
|
341
|
+
for k,r in zip(keys,comparison):
|
|
342
|
+
if not r:
|
|
343
|
+
keep.append(k)
|
|
344
|
+
else:
|
|
345
|
+
if not dry_run:
|
|
346
|
+
os.unlink(k['local_path'])
|
|
347
|
+
deleted.append(k['local_path'])
|
|
348
|
+
# check if the "deleted" folders are empty
|
|
349
|
+
for f in deleted:
|
|
350
|
+
print(f.parent)
|
|
351
|
+
return deleted,keep
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'''Upload rules for lab data management.
|
|
2
|
+
|
|
3
|
+
This module provides rules for processing data uploads to the database.
|
|
4
|
+
Rules handle validation, preprocessing, and storage of different data types.
|
|
5
|
+
|
|
6
|
+
Default rules included:
|
|
7
|
+
- UploadRule: Base rule for generic file uploads
|
|
8
|
+
- EphysRule: Rule for electrophysiology data (SpikeGLX)
|
|
9
|
+
- TwoPhotonRule: Rule for two-photon microscopy data (ScanImage/Scanbox)
|
|
10
|
+
- OnePhotonRule: Rule for one-photon imaging data (Widefield - labcams)
|
|
11
|
+
- MiniscopeRule: Rule for (UCLA) Miniscope imaging data
|
|
12
|
+
- FixedBrainRule: Rule for fixed tissue microscopy data
|
|
13
|
+
- ReplaceRule: Rule for replacing existing files
|
|
14
|
+
|
|
15
|
+
Custom rules can be added to the user_preferences.json configuration.
|
|
16
|
+
'''
|
|
17
|
+
|
|
18
|
+
from .utils import *
|
|
19
|
+
from .ephys import *
|
|
20
|
+
from .imaging import *
|
|
21
|
+
|
|
22
|
+
rulesmap = dict(none = UploadRule,
|
|
23
|
+
ephys = EphysRule,
|
|
24
|
+
two_photon = TwoPhotonRule,
|
|
25
|
+
one_photon = OnePhotonRule,
|
|
26
|
+
fixed_brain = FixedBrainRule,
|
|
27
|
+
miniscope = MiniscopeRule,
|
|
28
|
+
replace = ReplaceRule)
|
|
29
|
+
|
|
30
|
+
if 'upload_rules' in prefs.keys():
|
|
31
|
+
for k in prefs['upload_rules']:
|
|
32
|
+
# TODO: this may need a special field for imports?
|
|
33
|
+
rulesmap[k] = eval(prefs['upload_rules'][k])
|
|
34
|
+
|
|
35
|
+
def process_upload_jobs(key = None,
|
|
36
|
+
rule = 'all',
|
|
37
|
+
n_jobs = 8,
|
|
38
|
+
job_host = None,
|
|
39
|
+
force = False,
|
|
40
|
+
prefs = None):
|
|
41
|
+
'''
|
|
42
|
+
Process UploadJobs using UploadRule(s).
|
|
43
|
+
Custom upload rules can be added as plugins, just include in prefs['upload_rules']
|
|
44
|
+
|
|
45
|
+
Joao Couto - labdata 2024
|
|
46
|
+
'''
|
|
47
|
+
from tqdm import tqdm
|
|
48
|
+
from ..schema import UploadJob
|
|
49
|
+
def _job(j,rule = None, prefs = None):
|
|
50
|
+
jb = (UploadJob() & f'job_id = {j}').fetch()
|
|
51
|
+
if len(jb):
|
|
52
|
+
if jb[0]['job_rule'] in rulesmap.keys():
|
|
53
|
+
rl = rulesmap[jb[0]['job_rule']](j, prefs = prefs)
|
|
54
|
+
else:
|
|
55
|
+
rl = UploadRule(j,prefs = prefs)
|
|
56
|
+
res = rl.apply()
|
|
57
|
+
return res
|
|
58
|
+
if key is None:
|
|
59
|
+
jobs = (UploadJob() & 'job_waiting = 1').fetch('job_id')
|
|
60
|
+
if not job_host is None:
|
|
61
|
+
if job_host == 'self':
|
|
62
|
+
job_host = prefs['hostname']
|
|
63
|
+
jobs = (UploadJob() &
|
|
64
|
+
'job_waiting = 1' &
|
|
65
|
+
f'job_host = "{job_host}"').fetch('job_id')
|
|
66
|
+
else:
|
|
67
|
+
jobs = (UploadJob() & key).fetch('job_id')
|
|
68
|
+
if force:
|
|
69
|
+
for j in jobs:
|
|
70
|
+
UploadJob().update1(dict(job_id = j, job_waiting = 1)) # reset job
|
|
71
|
+
if len(jobs) == 1:
|
|
72
|
+
res = [_job(jobs[0], rule = rule, prefs = prefs)]
|
|
73
|
+
else:
|
|
74
|
+
res = Parallel(backend='loky',n_jobs = n_jobs)(delayed(_job)(u,
|
|
75
|
+
rule = rule,
|
|
76
|
+
prefs = prefs)
|
|
77
|
+
for u in tqdm(jobs,desc = "Running upload jobs: "))
|
|
78
|
+
return res
|
labdata/rules/ephys.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from .utils import *
|
|
2
|
+
|
|
3
|
+
class EphysRule(UploadRule):
|
|
4
|
+
def __init__(self, job_id,prefs = None):
|
|
5
|
+
super(EphysRule,self).__init__(job_id = job_id,prefs = prefs)
|
|
6
|
+
self.rule_name = 'ephys'
|
|
7
|
+
self.max_concurrent = 2
|
|
8
|
+
|
|
9
|
+
def _apply_rule(self):
|
|
10
|
+
# compress ap files or lf files.
|
|
11
|
+
files_to_compress = list(filter(lambda x: ('.ap.bin' in x) or
|
|
12
|
+
('.lf.bin' in x),
|
|
13
|
+
self.src_paths.src_path.values))
|
|
14
|
+
n_jobs = DEFAULT_N_JOBS
|
|
15
|
+
# compress these in parallel, will work for multiprobe sessions faster?
|
|
16
|
+
if len(files_to_compress): # in some cases data might have already been compressed
|
|
17
|
+
self.set_job_status(job_status = 'WORKING',
|
|
18
|
+
job_log = datetime.now().strftime(f'Compressing {len(files_to_compress)} files [%Y %m %d %H:%M:%S]'))
|
|
19
|
+
res = Parallel(n_jobs = n_jobs)(delayed(compress_ephys_file)(
|
|
20
|
+
filename,
|
|
21
|
+
local_path = self.local_path,
|
|
22
|
+
n_jobs = n_jobs,
|
|
23
|
+
prefs = self.prefs) for filename in files_to_compress)
|
|
24
|
+
new_files = np.stack(res).flatten() # stack the resulting files and add them to the path
|
|
25
|
+
self.set_job_status(job_status = 'WORKING',
|
|
26
|
+
job_log = datetime.now().strftime(f'Compressed {len(files_to_compress)} files [%Y %m %d %H:%M:%S]'))
|
|
27
|
+
self._handle_processed_and_src_paths(files_to_compress, new_files)
|
|
28
|
+
return self.src_paths
|
|
29
|
+
|
|
30
|
+
def _post_upload(self):
|
|
31
|
+
if not self.dataset_key is None:
|
|
32
|
+
from ..schema import EphysRecording
|
|
33
|
+
EphysRecording().add_spikeglx_recording(self.dataset_key)
|
|
34
|
+
EphysRecording().add_nidq_events(self.dataset_key)
|
|
35
|
+
|
|
36
|
+
############################################################################################################
|
|
37
|
+
############################################################################################################
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def compress_ephys_file(filename, local_path = None,
|
|
41
|
+
ext = '.bin',
|
|
42
|
+
n_jobs = DEFAULT_N_JOBS,
|
|
43
|
+
check_after_compress = True,
|
|
44
|
+
prefs = None):
|
|
45
|
+
'''
|
|
46
|
+
Compress ephys data
|
|
47
|
+
'''
|
|
48
|
+
if prefs is None:
|
|
49
|
+
from ..utils import prefs
|
|
50
|
+
if local_path is None:
|
|
51
|
+
local_path = prefs['local_paths'][0]
|
|
52
|
+
local_path = Path(local_path)
|
|
53
|
+
|
|
54
|
+
from spks.spikeglx_utils import read_spikeglx_meta
|
|
55
|
+
|
|
56
|
+
binfile = local_path/filename
|
|
57
|
+
if not binfile.exists():
|
|
58
|
+
raise OSError(f'Could not find binfile to compress ephys {binfile}')
|
|
59
|
+
|
|
60
|
+
metafile = local_path/str(filename).replace(ext,'.meta')
|
|
61
|
+
if not metafile.exists():
|
|
62
|
+
raise OSError(f'Could not find metafile to compress ephys {metafile}')
|
|
63
|
+
|
|
64
|
+
meta = read_spikeglx_meta(metafile) # to get the sampling rate and nchannels
|
|
65
|
+
srate = meta['sRateHz']
|
|
66
|
+
nchannels = meta['nSavedChans']
|
|
67
|
+
from mtscomp import compress, decompress
|
|
68
|
+
# Compress a .bin file into a pair .cbin (compressed binary file) and .ch (JSON file).
|
|
69
|
+
cbin,ch = (str(binfile).replace(ext,'.cbin'),str(binfile).replace(ext,'.ch'))
|
|
70
|
+
compress(binfile, cbin, ch,
|
|
71
|
+
sample_rate = srate, n_channels = int(nchannels),
|
|
72
|
+
check_after_compress = check_after_compress,
|
|
73
|
+
chunk_duration = 1, dtype=np.int16, n_threads = n_jobs)
|
|
74
|
+
return cbin.replace(str(local_path),'').strip(pathlib.os.sep),ch.replace(str(local_path),'').strip(pathlib.os.sep)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_probe_configuration(meta):
|
|
78
|
+
'''
|
|
79
|
+
Meta can be a file or a dictionary.
|
|
80
|
+
Uses spks for now to parse the metadata.
|
|
81
|
+
'''
|
|
82
|
+
if not hasattr(meta,'keys'):
|
|
83
|
+
meta = Path(meta)
|
|
84
|
+
if not meta.exists():
|
|
85
|
+
raise OSError(f'File not found: {meta}')
|
|
86
|
+
try:
|
|
87
|
+
from spks.spikeglx_utils import read_spikeglx_meta
|
|
88
|
+
# TODO: consider porting a minimal version over
|
|
89
|
+
except:
|
|
90
|
+
raise OSError('Could not import spks: install from https://github.com/spkware/spks')
|
|
91
|
+
meta = read_spikeglx_meta(meta)
|
|
92
|
+
|
|
93
|
+
probe_type = str(int(meta['imDatPrb_type']))
|
|
94
|
+
recording_software = 'spikeglx' # make this work with openephys also
|
|
95
|
+
return dict(probe_id = str(int(meta['imDatPrb_sn'])),
|
|
96
|
+
recording_software = recording_software,
|
|
97
|
+
recording_duration = meta['fileTimeSecs'],
|
|
98
|
+
sampling_rate = meta['sRateHz'],
|
|
99
|
+
probe_type = probe_type,
|
|
100
|
+
probe_n_shanks = 4 if probe_type in ['24','2013','2014'] else 1,
|
|
101
|
+
probe_gain = meta['conversion_factor_microV'][0],
|
|
102
|
+
probe_n_channels = len(meta['channel_idx']),
|
|
103
|
+
channel_idx = meta['channel_idx'],
|
|
104
|
+
channel_coords = meta['coords'],
|
|
105
|
+
channel_shank = meta['channel_shank'],
|
|
106
|
+
probe_recording_channels = int(meta['nSavedChans']-1))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def ephys_noise_statistics_from_file(filepath,channel_indices, gain, sampling_rate = 30000, duration = 60):
|
|
110
|
+
'''
|
|
111
|
+
statistics = ephys_noise_statistics_from_file(filepath,channel_indices, gain, sampling_rate = 30000, duration = 60)
|
|
112
|
+
|
|
113
|
+
Gets the noise statistics from a raw data file. It won't parse the whole file, instead it will extract 2 chunks, one
|
|
114
|
+
from t=duration to t=duration*2 and another from t=end of recording-duration*2 to t=end of recording-duration.
|
|
115
|
+
Then computes: the peak to peak, min, max, median and absolute median deviation of those chunks.
|
|
116
|
+
|
|
117
|
+
This is useful just to compare the start and end of the recording or to have ballpark estimations of these values.
|
|
118
|
+
For more accurate measurements split the recording in chunks of e.g. 1 second, compute it for the entire file, then average and std.
|
|
119
|
+
This will max if there are artifacts in the chunks.
|
|
120
|
+
|
|
121
|
+
Joao Couto - labdata 2024
|
|
122
|
+
'''
|
|
123
|
+
|
|
124
|
+
filepath = Path(filepath)
|
|
125
|
+
if str(filepath).endswith('.cbin'):
|
|
126
|
+
from mtscomp import decompress
|
|
127
|
+
data = decompress(filepath) #,filepath.with_suffix('.ch'))
|
|
128
|
+
elif str(filepath).endswith('.bin'):
|
|
129
|
+
from spks.spikeglx_utils import load_spikeglx_binary
|
|
130
|
+
data,meta = load_spikeglx_binary(filepath)
|
|
131
|
+
else:
|
|
132
|
+
raise ValueError(f'Could not handle extension: {filepath}')
|
|
133
|
+
if data.shape[0]/sampling_rate < duration:
|
|
134
|
+
duration = np.floor((data.shape[0]/sampling_rate)/2)
|
|
135
|
+
# read the head and tail data
|
|
136
|
+
head_data = np.array(data[int(sampling_rate*duration):int(sampling_rate*duration)*2],dtype=np.float32)*gain
|
|
137
|
+
tail_data = np.array(data[-int(sampling_rate*duration)*2:-int(sampling_rate*duration)],dtype = np.float32)*gain
|
|
138
|
+
dd = [head_data,tail_data]
|
|
139
|
+
res = dict(channel_peak_to_peak = np.zeros((len(channel_indices),len(dd))),
|
|
140
|
+
channel_median = np.zeros((len(channel_indices),len(dd))),
|
|
141
|
+
channel_mad = np.zeros((len(channel_indices),len(dd))),
|
|
142
|
+
channel_max = np.zeros((len(channel_indices),len(dd))),
|
|
143
|
+
channel_min = np.zeros((len(channel_indices),len(dd))))
|
|
144
|
+
from scipy.stats import median_abs_deviation
|
|
145
|
+
for i,d in enumerate(dd):
|
|
146
|
+
res['channel_mad'][:,i] = median_abs_deviation(d[:,channel_indices],axis = 0)
|
|
147
|
+
res['channel_max'][:,i] = np.max(d[:,channel_indices],axis = 0)
|
|
148
|
+
res['channel_min'][:,i] = np.min(d[:,channel_indices],axis = 0)
|
|
149
|
+
res['channel_median'][:,i] = np.median(d[:,channel_indices],axis = 0)
|
|
150
|
+
res['channel_peak_to_peak'][:,i] = res['channel_max'][:,i]-res['channel_min'][:,i]
|
|
151
|
+
return res
|
|
152
|
+
|
|
153
|
+
def extract_events_from_nidq(paths):
|
|
154
|
+
'''
|
|
155
|
+
Extracts the events from the nidq files (spikeglx) and formats it so they can be inserted in the database.
|
|
156
|
+
'''
|
|
157
|
+
from spks.spikeglx_utils import load_spikeglx_binary,read_spikeglx_meta
|
|
158
|
+
from spks.sync import unpackbits_gpu
|
|
159
|
+
|
|
160
|
+
nidqfilepath = list(filter(lambda x: '.bin' in x.suffix ,paths))
|
|
161
|
+
if not len(nidqfilepath):
|
|
162
|
+
# didnt find the files, try compressed
|
|
163
|
+
nidqfilepath = list(filter(lambda x: '.cbin' in x.suffix ,paths))
|
|
164
|
+
if not len(nidqfilepath):
|
|
165
|
+
raise(OSError(f'Could not find the nidaq file path for {paths}.'))
|
|
166
|
+
nidqfilepath = nidqfilepath[0]
|
|
167
|
+
# load the nidq file
|
|
168
|
+
if str(nidqfilepath).endswith('.bin'):
|
|
169
|
+
dat,meta = load_spikeglx_binary(nidqfilepath)
|
|
170
|
+
else:
|
|
171
|
+
from mtscomp import decompress
|
|
172
|
+
dat = decompress(nidqfilepath)
|
|
173
|
+
meta = read_spikeglx_meta(nidqfilepath.with_suffix('.meta'))
|
|
174
|
+
# read the sync channel
|
|
175
|
+
onsets,offsets = unpackbits_gpu(dat[:,-1])
|
|
176
|
+
|
|
177
|
+
stream_name = 'nidq'
|
|
178
|
+
events = []
|
|
179
|
+
for k in onsets.keys():
|
|
180
|
+
o = onsets[k]
|
|
181
|
+
f = offsets[k]
|
|
182
|
+
ts = np.hstack([o,f])
|
|
183
|
+
v = np.hstack([np.array(o)*0+1,np.array(f)*0]).astype('uint8')
|
|
184
|
+
ii = np.argsort(ts)
|
|
185
|
+
events.append(dict(event_name = k,
|
|
186
|
+
event_timestamps = ts[ii]/meta['sRateHz'], # in seconds
|
|
187
|
+
event_values = v[ii])) # store the onsets and offsets
|
|
188
|
+
return events, dat
|