aind-dynamic-foraging-data-utils 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.
- aind_dynamic_foraging_data_utils/__init__.py +3 -0
- aind_dynamic_foraging_data_utils/alignment.py +573 -0
- aind_dynamic_foraging_data_utils/nwb_utils.py +599 -0
- aind_dynamic_foraging_data_utils-0.1.0.dist-info/LICENSE +21 -0
- aind_dynamic_foraging_data_utils-0.1.0.dist-info/METADATA +125 -0
- aind_dynamic_foraging_data_utils-0.1.0.dist-info/RECORD +8 -0
- aind_dynamic_foraging_data_utils-0.1.0.dist-info/WHEEL +5 -0
- aind_dynamic_foraging_data_utils-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tools for aligning continuous and discrete events
|
|
3
|
+
functions:
|
|
4
|
+
get_time_array
|
|
5
|
+
slice_inds_and_offsets
|
|
6
|
+
index_of_nearest_value
|
|
7
|
+
event_triggered_response
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_time_array(
|
|
15
|
+
t_start, t_end, sampling_rate=None, step_size=None, include_endpoint=True
|
|
16
|
+
): # NOQA E501
|
|
17
|
+
"""
|
|
18
|
+
A function to get a time array between two specified timepoints at a defined sampling rate # NOQA E501
|
|
19
|
+
Deals with possibility of time range not being evenly divisible by desired sampling rate # NOQA E501
|
|
20
|
+
Uses np.linspace instead of np.arange given decimal precision issues with np.arange (see np.arange documentation for details) # NOQA E501
|
|
21
|
+
|
|
22
|
+
Parameters:
|
|
23
|
+
-----------
|
|
24
|
+
t_start : float
|
|
25
|
+
start time for array
|
|
26
|
+
t_end : float
|
|
27
|
+
end time for array
|
|
28
|
+
sampling_rate : float
|
|
29
|
+
desired sampling of array
|
|
30
|
+
Note: user must specify either sampling_rate or step_size, not both
|
|
31
|
+
step_size : float
|
|
32
|
+
desired step size of array
|
|
33
|
+
Note: user must specify either sampling_rate or step_size, not both
|
|
34
|
+
include_endpoint : Boolean
|
|
35
|
+
Passed to np.linspace to calculate relative time
|
|
36
|
+
If True, stop is the last sample. Otherwise, it is not included.
|
|
37
|
+
Default is True
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
--------
|
|
41
|
+
numpy.array
|
|
42
|
+
an array of timepoints at the desired sampling rate
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
---------
|
|
46
|
+
get a time array exclusive of the endpoint
|
|
47
|
+
>>> t_array = get_time_array(
|
|
48
|
+
t_start=-1,
|
|
49
|
+
t_end=1,
|
|
50
|
+
step_size=0.5,
|
|
51
|
+
include_endpoint=False
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
np.array([-1., -0.5, 0., 0.5])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
get a time array inclusive of the endpoint
|
|
58
|
+
>>> t_array = get_time_array(
|
|
59
|
+
t_start=-1,
|
|
60
|
+
t_end=1,
|
|
61
|
+
step_size=0.5,
|
|
62
|
+
include_endpoint=False
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
np.array([-1., -0.5, 0., 0.5, 1.0])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
get a time array where the range can't be evenly divided by the desired step_size
|
|
69
|
+
in this case, the time array includes the last timepoint before the desired endpoint
|
|
70
|
+
>>> t_array = get_time_array(
|
|
71
|
+
t_start=-1,
|
|
72
|
+
t_end=0.75,
|
|
73
|
+
step_size=0.5,
|
|
74
|
+
include_endpoint=False
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
np.array([-1., -0.5, 0., 0.5])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
Instead of passing the step_size, we can pass the sampling rate
|
|
81
|
+
>>> t_array = get_time_array(
|
|
82
|
+
t_start=-1,
|
|
83
|
+
t_end=1,
|
|
84
|
+
sampling_rate=2,
|
|
85
|
+
include_endpoint=False
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
np.array([-1., -0.5, 0., 0.5])
|
|
89
|
+
"""
|
|
90
|
+
assert (
|
|
91
|
+
sampling_rate is not None or step_size is not None
|
|
92
|
+
), "must specify either sampling_rate or step_size" # NOQA E501
|
|
93
|
+
assert (
|
|
94
|
+
sampling_rate is None or step_size is None
|
|
95
|
+
), "cannot specify both sampling_rate and step_size" # NOQA E501
|
|
96
|
+
|
|
97
|
+
# value as a linearly spaced time array
|
|
98
|
+
if not step_size:
|
|
99
|
+
step_size = 1 / sampling_rate
|
|
100
|
+
# define a time array
|
|
101
|
+
n_steps = (t_end - t_start) / step_size
|
|
102
|
+
if n_steps != int(n_steps):
|
|
103
|
+
# if the number of steps isn't an int, that means it isn't possible
|
|
104
|
+
# to end on the desired t_after using the defined sampling rate
|
|
105
|
+
# we need to round down and include the endpoint
|
|
106
|
+
n_steps = int(n_steps)
|
|
107
|
+
t_end_adjusted = t_start + n_steps * step_size
|
|
108
|
+
include_endpoint = True
|
|
109
|
+
else:
|
|
110
|
+
t_end_adjusted = t_end
|
|
111
|
+
|
|
112
|
+
if include_endpoint:
|
|
113
|
+
# add an extra step if including endpoint
|
|
114
|
+
n_steps += 1
|
|
115
|
+
|
|
116
|
+
t_array = np.linspace(t_start, t_end_adjusted, int(n_steps), endpoint=include_endpoint)
|
|
117
|
+
|
|
118
|
+
return t_array
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def slice_inds_and_offsets(
|
|
122
|
+
data_timestamps,
|
|
123
|
+
event_timestamps,
|
|
124
|
+
time_window,
|
|
125
|
+
sampling_rate=None,
|
|
126
|
+
include_endpoint=False,
|
|
127
|
+
): # NOQA E501
|
|
128
|
+
"""
|
|
129
|
+
Get nearest indices to event timestamps, plus ind offsets (start:stop)
|
|
130
|
+
for slicing out a window around the event from the trace.
|
|
131
|
+
Parameters:
|
|
132
|
+
-----------
|
|
133
|
+
data_timestamps : np.array
|
|
134
|
+
Timestamps of the datatrace.
|
|
135
|
+
event_timestamps : np.array
|
|
136
|
+
Timestamps of events around which to slice windows.
|
|
137
|
+
time_window : list
|
|
138
|
+
[start_offset, end_offset] in seconds
|
|
139
|
+
sampling_rate : float, optional, default=None
|
|
140
|
+
Sampling rate of the datatrace.
|
|
141
|
+
If left as None, samplng rate is inferred from data_timestamps.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
--------
|
|
145
|
+
event_indices : np.array
|
|
146
|
+
Indices of events from the timestamps provided.
|
|
147
|
+
start_ind_offset : int
|
|
148
|
+
end_ind_offset : int
|
|
149
|
+
trace_timebase : np.array
|
|
150
|
+
"""
|
|
151
|
+
if sampling_rate is None:
|
|
152
|
+
sampling_rate = 1 / np.diff(data_timestamps).mean()
|
|
153
|
+
|
|
154
|
+
event_indices = index_of_nearest_value(data_timestamps, event_timestamps)
|
|
155
|
+
trace_len = (time_window[1] - time_window[0]) * sampling_rate
|
|
156
|
+
start_ind_offset = int(time_window[0] * sampling_rate)
|
|
157
|
+
end_ind_offset = int(start_ind_offset + trace_len) + int(include_endpoint)
|
|
158
|
+
trace_timebase = np.arange(start_ind_offset, end_ind_offset) / sampling_rate
|
|
159
|
+
|
|
160
|
+
return event_indices, start_ind_offset, end_ind_offset, trace_timebase
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def index_of_nearest_value(data_timestamps, event_timestamps):
|
|
164
|
+
"""
|
|
165
|
+
The index of the nearest sample time for each event time.
|
|
166
|
+
|
|
167
|
+
Parameters:
|
|
168
|
+
-----------
|
|
169
|
+
sample_timestamps : np.ndarray of floats
|
|
170
|
+
sorted 1-d vector of data sample timestamps.
|
|
171
|
+
event_timestamps : np.ndarray of floats
|
|
172
|
+
1-d vector of event timestamps.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
--------
|
|
176
|
+
event_aligned_ind : np.ndarray of int
|
|
177
|
+
An array of nearest sample time index for each event times.
|
|
178
|
+
"""
|
|
179
|
+
insertion_ind = np.searchsorted(data_timestamps, event_timestamps)
|
|
180
|
+
# is the value closer to data at insertion_ind or insertion_ind-1?
|
|
181
|
+
ind_diff = data_timestamps[insertion_ind] - event_timestamps
|
|
182
|
+
ind_minus_one_diff = np.abs(
|
|
183
|
+
data_timestamps[np.clip(insertion_ind - 1, 0, np.inf).astype(int)] - event_timestamps
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
event_indices = insertion_ind - (ind_diff > ind_minus_one_diff).astype(int)
|
|
187
|
+
return event_indices
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def event_triggered_response( # noqa C901
|
|
191
|
+
data,
|
|
192
|
+
t,
|
|
193
|
+
y,
|
|
194
|
+
event_times,
|
|
195
|
+
t_start=None,
|
|
196
|
+
t_end=None,
|
|
197
|
+
t_before=None,
|
|
198
|
+
t_after=None,
|
|
199
|
+
output_sampling_rate=None,
|
|
200
|
+
include_endpoint=True,
|
|
201
|
+
output_format="tidy",
|
|
202
|
+
interpolate=True,
|
|
203
|
+
censor=True,
|
|
204
|
+
censor_times=None,
|
|
205
|
+
nan_policy="error",
|
|
206
|
+
): # NOQA E501
|
|
207
|
+
"""
|
|
208
|
+
Slices a timeseries relative to a given set of event times
|
|
209
|
+
to build an event-triggered response. From mindscope_utilities (commit af2b70a)
|
|
210
|
+
|
|
211
|
+
For example, If we have data such as a measurement of neural activity
|
|
212
|
+
over time and specific events in time that we want to align
|
|
213
|
+
the neural activity to, this function will extract segments of the neural
|
|
214
|
+
timeseries in a specified time window around each event.
|
|
215
|
+
|
|
216
|
+
The times of the events need not align with the measured
|
|
217
|
+
times of the neural data.
|
|
218
|
+
Relative times will be calculated by linear interpolation.
|
|
219
|
+
|
|
220
|
+
Parameters:
|
|
221
|
+
-----------
|
|
222
|
+
data: Pandas.DataFrame
|
|
223
|
+
Input dataframe in tidy format
|
|
224
|
+
Each row should be one observation
|
|
225
|
+
Must contains columns representing `t` and `y` (see below)
|
|
226
|
+
t : string
|
|
227
|
+
Name of column in data to use as time data
|
|
228
|
+
y : string
|
|
229
|
+
Name of column to use as y data
|
|
230
|
+
event_times: list or array of floats
|
|
231
|
+
Times of events of interest.
|
|
232
|
+
Values in column specified by `y` will be sliced and interpolated
|
|
233
|
+
relative to these times
|
|
234
|
+
t_start : float
|
|
235
|
+
start time relative to each event for desired time window
|
|
236
|
+
e.g.: t_start = -1 would start the window 1 second before each
|
|
237
|
+
t_start = 1 would start the window 1 second after each event
|
|
238
|
+
Note: cannot pass both t_start and t_before
|
|
239
|
+
t_before : float
|
|
240
|
+
time before each of event of interest to include in each slice
|
|
241
|
+
e.g.: t_before = 1 would start the window 1 second before each event
|
|
242
|
+
t_before = -1 would start the window 1 second after each event
|
|
243
|
+
Note: cannot pass both t_start and t_before
|
|
244
|
+
t_end : float
|
|
245
|
+
end time relative to each event for desired time window
|
|
246
|
+
e.g.: t_end = 1 would end the window 1 second after each event
|
|
247
|
+
t_end = -1 would end the window 1 second before each event
|
|
248
|
+
Note: cannot pass both t_end and t_after
|
|
249
|
+
t_after : float
|
|
250
|
+
time after each event of interest to include in each slice
|
|
251
|
+
e.g.: t_after = 1 would start the window 1 second after each event
|
|
252
|
+
t_after = -1 would start the window 1 second before each event
|
|
253
|
+
Note: cannot pass both t_end and t_after
|
|
254
|
+
output_sampling_rate : float
|
|
255
|
+
Desired sampling of output.
|
|
256
|
+
Input data will be interpolated to this sampling rate if interpolate = True (default). # NOQA E501
|
|
257
|
+
If passing interpolate = False, the sampling rate of the input timeseries will # NOQA E501
|
|
258
|
+
be used and output_sampling_rate should not be specified.
|
|
259
|
+
include_endpoint : Boolean
|
|
260
|
+
Passed to np.linspace to calculate relative time
|
|
261
|
+
If True, stop is the last sample. Otherwise, it is not included.
|
|
262
|
+
Default is True
|
|
263
|
+
output_format : string
|
|
264
|
+
'wide' or 'tidy' (default = 'tidy')
|
|
265
|
+
if 'tidy'
|
|
266
|
+
One column representing time
|
|
267
|
+
One column representing event_number
|
|
268
|
+
One column representing event_time
|
|
269
|
+
One row per observation (# rows = len(time) x len(event_times))
|
|
270
|
+
if 'wide', output format will be:
|
|
271
|
+
time as indices
|
|
272
|
+
One row per interpolated timepoint
|
|
273
|
+
One column per event,
|
|
274
|
+
with column names titled event_{EVENT NUMBER}_t={EVENT TIME}
|
|
275
|
+
interpolate : Boolean
|
|
276
|
+
if True (default), interpolates each response onto a common timebase
|
|
277
|
+
if False, shifts each response to align indices to a common timebase
|
|
278
|
+
censor: Boolean
|
|
279
|
+
if True (default), censor observations that take place after the next event time
|
|
280
|
+
if False, do not censor
|
|
281
|
+
censor_times: list or array or None
|
|
282
|
+
if None, and censor is True, then use event_times as the censor times
|
|
283
|
+
if times are provided, then these are the times at which ETR is censored
|
|
284
|
+
nan_policy: How to handle NaNs in the input data
|
|
285
|
+
"error": raise an exception if NaNs are present in the time window of an ETR
|
|
286
|
+
"interpolate": interpolate over NaN values
|
|
287
|
+
"exclude": exclude any response with a NaN in the response window
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
--------
|
|
291
|
+
Pandas.DataFrame
|
|
292
|
+
See description in `output_format` section above
|
|
293
|
+
|
|
294
|
+
Examples:
|
|
295
|
+
---------
|
|
296
|
+
An example use case, recover a sinousoid from noise:
|
|
297
|
+
|
|
298
|
+
First, define a time vector
|
|
299
|
+
>>> t = np.arange(-10,110,0.001)
|
|
300
|
+
|
|
301
|
+
Now build a dataframe with one column for time,
|
|
302
|
+
and another column that is a noise-corrupted sinuosoid with period of 1
|
|
303
|
+
>>> data = pd.DataFrame({
|
|
304
|
+
'time': t,
|
|
305
|
+
'noisy_sinusoid': np.sin(2*np.pi*t) + np.random.randn(len(t))*3
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
Now use the event_triggered_response function to get a tidy
|
|
309
|
+
dataframe of the signal around every event
|
|
310
|
+
|
|
311
|
+
Events will simply be generated as every 1 second interval
|
|
312
|
+
starting at 0, since our period here is 1
|
|
313
|
+
>>> etr = event_triggered_response(
|
|
314
|
+
data,
|
|
315
|
+
x = 'time',
|
|
316
|
+
y = 'noisy_sinusoid',
|
|
317
|
+
event_times = np.arange(100),
|
|
318
|
+
t_start = -1,
|
|
319
|
+
t_end = 1,
|
|
320
|
+
output_sampling_rate = 100
|
|
321
|
+
)
|
|
322
|
+
Then use seaborn to view the result
|
|
323
|
+
We're able to recover the sinusoid through averaging
|
|
324
|
+
>>> import matplotlib.pyplot as plt
|
|
325
|
+
>>> import seaborn as sns
|
|
326
|
+
>>> fig, ax = plt.subplots()
|
|
327
|
+
>>> sns.lineplot(
|
|
328
|
+
data = etr,
|
|
329
|
+
x='time',
|
|
330
|
+
y='noisy_sinusoid',
|
|
331
|
+
ax=ax
|
|
332
|
+
)
|
|
333
|
+
"""
|
|
334
|
+
# ensure that non-conflicting time values are passed
|
|
335
|
+
assert (
|
|
336
|
+
t_before is not None or t_start is not None
|
|
337
|
+
), "must pass either t_start or t_before" # noqa: E501
|
|
338
|
+
assert (
|
|
339
|
+
t_after is not None or t_end is not None
|
|
340
|
+
), "must pass either t_start or t_before" # noqa: E501
|
|
341
|
+
|
|
342
|
+
assert (
|
|
343
|
+
t_before is None or t_start is None
|
|
344
|
+
), "cannot pass both t_start and t_before" # noqa: E501
|
|
345
|
+
assert t_after is None or t_end is None, "cannot pass both t_after and t_end" # noqa: E501
|
|
346
|
+
|
|
347
|
+
if interpolate is False:
|
|
348
|
+
assert (
|
|
349
|
+
output_sampling_rate is None
|
|
350
|
+
), "if interpolation = False, the sampling rate of the input timeseries will be used. Do not specify output_sampling_rate" # NOQA E501
|
|
351
|
+
|
|
352
|
+
# assign time values to t_start and t_end
|
|
353
|
+
if t_start is None:
|
|
354
|
+
t_start = -1 * t_before
|
|
355
|
+
if t_end is None:
|
|
356
|
+
t_end = t_after
|
|
357
|
+
|
|
358
|
+
# ensure that t_end is greater than t_start
|
|
359
|
+
assert t_end > t_start, "must define t_end to be greater than t_start"
|
|
360
|
+
|
|
361
|
+
assert (not censor) or (output_format == "tidy"), "cannot censor data in wide output"
|
|
362
|
+
|
|
363
|
+
assert nan_policy in ["error", "interpolate", "exclude"], "unrecognized nan_policy"
|
|
364
|
+
|
|
365
|
+
if censor:
|
|
366
|
+
event_times = np.sort(event_times)
|
|
367
|
+
|
|
368
|
+
if output_sampling_rate is None:
|
|
369
|
+
# if sampling rate is None,
|
|
370
|
+
# set it to be the mean sampling rate of the input data
|
|
371
|
+
output_sampling_rate = 1 / np.diff(data[t]).mean()
|
|
372
|
+
|
|
373
|
+
# if interpolate is set to True,
|
|
374
|
+
# we will calculate a common timebase and
|
|
375
|
+
# interpolate every response onto that timebase
|
|
376
|
+
if interpolate:
|
|
377
|
+
# set up a dictionary with key 'time' and
|
|
378
|
+
t_array = get_time_array(
|
|
379
|
+
t_start=t_start,
|
|
380
|
+
t_end=t_end,
|
|
381
|
+
sampling_rate=output_sampling_rate,
|
|
382
|
+
include_endpoint=include_endpoint,
|
|
383
|
+
)
|
|
384
|
+
data_dict = {"time": t_array}
|
|
385
|
+
|
|
386
|
+
# iterate over all event times
|
|
387
|
+
data_reindexed = data.set_index(t, inplace=False)
|
|
388
|
+
|
|
389
|
+
for event_number, event_time in enumerate(np.array(event_times)):
|
|
390
|
+
# get a slice of the input data surrounding each event time
|
|
391
|
+
data_slice = data_reindexed[y].loc[
|
|
392
|
+
event_time + t_start : event_time + t_end
|
|
393
|
+
] # noqa: E501
|
|
394
|
+
|
|
395
|
+
# if the slice is empty, we will fill it with NaNs
|
|
396
|
+
if len(data_slice) == 0:
|
|
397
|
+
data_dict.update(
|
|
398
|
+
{
|
|
399
|
+
"event_{}_t={}".format(event_number, event_time): np.full(
|
|
400
|
+
len(t_array), np.nan
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
elif np.any(np.isnan(data_slice)):
|
|
406
|
+
if nan_policy == "error":
|
|
407
|
+
# raise exception
|
|
408
|
+
raise Exception("NaN value in data slice, at event time {}".format(event_time))
|
|
409
|
+
elif nan_policy == "exclude":
|
|
410
|
+
# exclude this event
|
|
411
|
+
data_dict.update(
|
|
412
|
+
{
|
|
413
|
+
"event_{}_t={}".format(event_number, event_time): np.full(
|
|
414
|
+
len(t_array), np.nan
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
)
|
|
418
|
+
else:
|
|
419
|
+
# Interpolate over NaNs
|
|
420
|
+
x_data = data_slice[~data_slice.isnull()]
|
|
421
|
+
data_slice[:] = np.interp(
|
|
422
|
+
data_slice.index.values, x_data.index.values, x_data.values
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
# Add to data dict as normal
|
|
426
|
+
data_dict.update(
|
|
427
|
+
{
|
|
428
|
+
"event_{}_t={}".format(event_number, event_time): np.interp(
|
|
429
|
+
data_dict["time"],
|
|
430
|
+
data_slice.index - event_time,
|
|
431
|
+
data_slice.values,
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
)
|
|
435
|
+
else:
|
|
436
|
+
# update our dictionary to have a new key defined as
|
|
437
|
+
# 'event_{EVENT NUMBER}_t={EVENT TIME}' and
|
|
438
|
+
# a value that includes an array that represents the
|
|
439
|
+
# sliced data around the current event, interpolated
|
|
440
|
+
# on the linearly spaced time array
|
|
441
|
+
data_dict.update(
|
|
442
|
+
{
|
|
443
|
+
"event_{}_t={}".format(event_number, event_time): np.interp(
|
|
444
|
+
data_dict["time"],
|
|
445
|
+
data_slice.index - event_time,
|
|
446
|
+
data_slice.values,
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
# define a wide dataframe as a dataframe of the above compiled dictionary # NOQA E501
|
|
452
|
+
wide_etr = pd.DataFrame(data_dict)
|
|
453
|
+
|
|
454
|
+
# if interpolate is False,
|
|
455
|
+
# we will calculate a common timebase and
|
|
456
|
+
# shift every response onto that timebase
|
|
457
|
+
else:
|
|
458
|
+
(
|
|
459
|
+
event_indices,
|
|
460
|
+
start_ind_offset,
|
|
461
|
+
end_ind_offset,
|
|
462
|
+
trace_timebase,
|
|
463
|
+
) = slice_inds_and_offsets( # NOQA E501
|
|
464
|
+
np.array(data[t]),
|
|
465
|
+
np.array(event_times),
|
|
466
|
+
time_window=[t_start, t_end],
|
|
467
|
+
sampling_rate=None,
|
|
468
|
+
include_endpoint=True,
|
|
469
|
+
)
|
|
470
|
+
all_inds = event_indices + np.arange(start_ind_offset, end_ind_offset)[:, None]
|
|
471
|
+
wide_etr = (
|
|
472
|
+
pd.DataFrame(
|
|
473
|
+
data[y].values.T[all_inds],
|
|
474
|
+
index=trace_timebase,
|
|
475
|
+
columns=[
|
|
476
|
+
"event_{}_t={}".format(event_index, event_time)
|
|
477
|
+
for event_index, event_time in enumerate(event_times)
|
|
478
|
+
], # NOQA E501
|
|
479
|
+
)
|
|
480
|
+
.rename_axis(index="time")
|
|
481
|
+
.reset_index()
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
if output_format == "wide":
|
|
485
|
+
# return the wide dataframe if output_format is 'wide'
|
|
486
|
+
return wide_etr.set_index("time")
|
|
487
|
+
elif output_format == "tidy":
|
|
488
|
+
# if output format is 'tidy',
|
|
489
|
+
# transform the wide dataframe to tidy format
|
|
490
|
+
# first, melt the dataframe with the 'id_vars' column as "time"
|
|
491
|
+
tidy_etr = wide_etr.melt(id_vars="time")
|
|
492
|
+
|
|
493
|
+
# add an "event_number" column that contains the event number
|
|
494
|
+
tidy_etr["event_number"] = (
|
|
495
|
+
tidy_etr["variable"].map(lambda s: s.split("event_")[1].split("_")[0]).astype(int)
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
# add an "event_time" column that contains the event time ()
|
|
499
|
+
tidy_etr["event_time"] = tidy_etr["variable"].map(lambda s: s.split("t=")[1]).astype(float)
|
|
500
|
+
|
|
501
|
+
# drop the "variable" column, rename the "value" column
|
|
502
|
+
tidy_etr = tidy_etr.drop(columns=["variable"]).rename(columns={"value": y})
|
|
503
|
+
# return the tidy event triggered responses
|
|
504
|
+
if censor:
|
|
505
|
+
tidy_etr = censor_event_triggered_response(
|
|
506
|
+
tidy_etr, y, t_start, t_end, event_times, censor_times
|
|
507
|
+
)
|
|
508
|
+
return tidy_etr
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def censor_event_triggered_response(etr, y, t_start, t_end, event_times, censor_times=None):
|
|
512
|
+
"""
|
|
513
|
+
censors the event triggered response by the immediately preceeding or
|
|
514
|
+
subsequent event times if that event time is within the (t_start, t_end)
|
|
515
|
+
time window
|
|
516
|
+
|
|
517
|
+
censored timepoints are replaced with NaN, so all data points are still present
|
|
518
|
+
|
|
519
|
+
etr: dataframe, event triggered response
|
|
520
|
+
y: column of the response variable to censor
|
|
521
|
+
t_start: start of event triggered response window
|
|
522
|
+
t_end: end of event triggered response window
|
|
523
|
+
censor: Boolean
|
|
524
|
+
if True, censor observations that take place after the next event time
|
|
525
|
+
if False, do not censor
|
|
526
|
+
censor_times: list or array or None
|
|
527
|
+
if None, and censor is True, then use event_times as the censor times
|
|
528
|
+
if times are provided, then these are the times at which ETR is censored
|
|
529
|
+
"""
|
|
530
|
+
|
|
531
|
+
if censor_times is None:
|
|
532
|
+
# Compute when we should censor
|
|
533
|
+
diff = np.diff(event_times)
|
|
534
|
+
diff_backward = np.concatenate([[np.inf], diff])
|
|
535
|
+
diff_forward = np.concatenate([diff, [np.inf]])
|
|
536
|
+
backward_time = [-np.min([np.abs(t_start), x]) for x in diff_backward]
|
|
537
|
+
forward_time = [np.min([t_end, x]) for x in diff_forward]
|
|
538
|
+
|
|
539
|
+
# double check we have all events
|
|
540
|
+
assert len(event_times) == len(etr["event_number"].unique()), "event times missing"
|
|
541
|
+
|
|
542
|
+
# Censor trials
|
|
543
|
+
for index, time in enumerate(event_times):
|
|
544
|
+
vec = (etr["event_number"] == index) & (etr["time"] < backward_time[index])
|
|
545
|
+
etr.loc[vec, y] = np.nan
|
|
546
|
+
vec = (etr["event_number"] == index) & (etr["time"] > forward_time[index])
|
|
547
|
+
etr.loc[vec, y] = np.nan
|
|
548
|
+
|
|
549
|
+
return etr
|
|
550
|
+
else:
|
|
551
|
+
censor_times = np.sort(censor_times)
|
|
552
|
+
backward_time = []
|
|
553
|
+
forward_time = []
|
|
554
|
+
for e in event_times:
|
|
555
|
+
before = censor_times[censor_times < e]
|
|
556
|
+
if len(before) == 0:
|
|
557
|
+
backward_time.append(t_start)
|
|
558
|
+
else:
|
|
559
|
+
backward_time.append(np.max([t_start, before[-1] - e]))
|
|
560
|
+
after = censor_times[censor_times > e]
|
|
561
|
+
if len(after) == 0:
|
|
562
|
+
forward_time.append(t_end)
|
|
563
|
+
else:
|
|
564
|
+
forward_time.append(np.min([t_end, after[0] - e]))
|
|
565
|
+
|
|
566
|
+
# Censor trials
|
|
567
|
+
for index, time in enumerate(event_times):
|
|
568
|
+
vec = (etr["event_number"] == index) & (etr["time"] < backward_time[index])
|
|
569
|
+
etr.loc[vec, y] = np.nan
|
|
570
|
+
vec = (etr["event_number"] == index) & (etr["time"] > forward_time[index])
|
|
571
|
+
etr.loc[vec, y] = np.nan
|
|
572
|
+
|
|
573
|
+
return etr
|
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility functions for processing dynamic foraging data.
|
|
3
|
+
load_nwb_from_filename
|
|
4
|
+
unpack_metadata
|
|
5
|
+
create_single_df_session_inner
|
|
6
|
+
create_df_session
|
|
7
|
+
create_single_df_session
|
|
8
|
+
create_df_trials
|
|
9
|
+
create_events_df
|
|
10
|
+
create_fib_df
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
from pynwb import NWBHDF5IO
|
|
19
|
+
from hdmf_zarr import NWBZarrIO
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_nwb_from_filename(filename):
|
|
23
|
+
"""
|
|
24
|
+
Load NWB from file, checking for HDF5 or Zarr
|
|
25
|
+
if filename is not a string, then return the input, assuming its the NWB file already
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
if type(filename) is str:
|
|
29
|
+
if os.path.isdir(filename):
|
|
30
|
+
io = NWBZarrIO(filename, mode="r")
|
|
31
|
+
nwb = io.read()
|
|
32
|
+
return nwb
|
|
33
|
+
elif os.path.isfile(filename):
|
|
34
|
+
io = NWBHDF5IO(filename, mode="r")
|
|
35
|
+
nwb = io.read()
|
|
36
|
+
return nwb
|
|
37
|
+
else:
|
|
38
|
+
raise FileNotFoundError(filename)
|
|
39
|
+
else:
|
|
40
|
+
# Assuming its already an NWB
|
|
41
|
+
return filename
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def unpack_metadata(nwb):
|
|
45
|
+
"""
|
|
46
|
+
Unpacks metadata as a dictionary attribute, instead of a Dynamic
|
|
47
|
+
table nested inside a dictionary
|
|
48
|
+
"""
|
|
49
|
+
nwb.metadata = nwb.scratch["metadata"].to_dataframe().iloc[0].to_dict()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def create_single_df_session_inner(nwb):
|
|
53
|
+
"""
|
|
54
|
+
given a nwb file, output a tidy dataframe
|
|
55
|
+
"""
|
|
56
|
+
df_trials = nwb.trials.to_dataframe()
|
|
57
|
+
|
|
58
|
+
# Reformat data
|
|
59
|
+
choice_history = df_trials.animal_response.map({0: 0, 1: 1, 2: np.nan}).values
|
|
60
|
+
reward_history = np.vstack([df_trials.rewarded_historyL, df_trials.rewarded_historyR])
|
|
61
|
+
|
|
62
|
+
# -- Session-based table --
|
|
63
|
+
# - Meta data -
|
|
64
|
+
session_start_time_from_meta = nwb.session_start_time
|
|
65
|
+
session_date_from_meta = session_start_time_from_meta.strftime("%Y-%m-%d")
|
|
66
|
+
subject_id_from_meta = nwb.subject.subject_id
|
|
67
|
+
|
|
68
|
+
# TODO, should reprocess old files, and remove this logic
|
|
69
|
+
if "behavior" in nwb.session_id:
|
|
70
|
+
splits = nwb.session_id.split("_")
|
|
71
|
+
subject_id = splits[1]
|
|
72
|
+
session_date = splits[2]
|
|
73
|
+
nwb_suffix = splits[3].replace("-", "")
|
|
74
|
+
else:
|
|
75
|
+
old_re = re.match(
|
|
76
|
+
r"(?P<subject_id>\d+)_(?P<date>\d{4}-\d{2}-\d{2})(?:_(?P<n>\d+))?\.json",
|
|
77
|
+
nwb.session_id,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if old_re is not None:
|
|
81
|
+
# If there are more than one "bonsai sessions" (the trainer clicked "Save" button in the
|
|
82
|
+
# GUI more than once) in a certain day,
|
|
83
|
+
# parse nwb_suffix from the file name (0, 1, 2, ...)
|
|
84
|
+
subject_id, session_date, nwb_suffix = old_re.groups()
|
|
85
|
+
nwb_suffix = int(nwb_suffix) if nwb_suffix is not None else 0
|
|
86
|
+
else:
|
|
87
|
+
# After https://github.com/AllenNeuralDynamics/dynamic-foraging-task/commit/
|
|
88
|
+
# 62d0e9e2bb9b47a8efe8ecb91da9653381a5f551,
|
|
89
|
+
# the suffix becomes the session start time. Therefore, I use HHMMSS as the nwb suffix,
|
|
90
|
+
# which still keeps the order as before.
|
|
91
|
+
|
|
92
|
+
# Typical situation for multiple bonsai sessions per day is that the
|
|
93
|
+
# RAs pressed more than once "Save" button but only started the session once.
|
|
94
|
+
# Therefore, I should generate nwb_suffix from the bonsai file name
|
|
95
|
+
# instead of session_start_time.
|
|
96
|
+
subject_id, session_date, session_json_time = re.match(
|
|
97
|
+
r"(?P<subject_id>\d+)_(?P<date>\d{4}-\d{2}-\d{2})(?:_(?P<time>.*))\.json",
|
|
98
|
+
nwb.session_id,
|
|
99
|
+
).groups()
|
|
100
|
+
nwb_suffix = int(session_json_time.replace("-", ""))
|
|
101
|
+
|
|
102
|
+
# Ad-hoc bug fixes for some mistyped mouse ID
|
|
103
|
+
if subject_id in ("689727"):
|
|
104
|
+
subject_id_from_meta = subject_id
|
|
105
|
+
|
|
106
|
+
assert subject_id == subject_id_from_meta, (
|
|
107
|
+
f"Subject name from the metadata ({subject_id_from_meta}) does not match "
|
|
108
|
+
f"that from json name ({subject_id})!!"
|
|
109
|
+
)
|
|
110
|
+
assert session_date == session_date_from_meta, (
|
|
111
|
+
f"Session date from the metadata ({session_date_from_meta}) does not match "
|
|
112
|
+
f"that from json name ({session_date})!!"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
session_index = pd.MultiIndex.from_tuples(
|
|
116
|
+
[(subject_id, session_date, nwb_suffix)],
|
|
117
|
+
names=["subject_id", "session_date", "nwb_suffix"],
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Parse meta info
|
|
121
|
+
# TODO: when generating nwb, put meta info in nwb.scratch and get rid of the regular expression
|
|
122
|
+
# This could be significantly cleaned up based on new metadata format
|
|
123
|
+
# But im making it consistent for now
|
|
124
|
+
# TODO, should reprocess old files, and remove this logic
|
|
125
|
+
if "behavior" not in nwb.session_id:
|
|
126
|
+
extra_water, rig = re.search(
|
|
127
|
+
r"Give extra water.*:(\d*(?:\.\d+)?)? .*?(?:tower|box):(.*)?",
|
|
128
|
+
nwb.session_description,
|
|
129
|
+
).groups()
|
|
130
|
+
weight_after_session = re.search(
|
|
131
|
+
r"Weight after.*:(\d*(?:\.\d+)?)?", nwb.subject.description
|
|
132
|
+
).groups()[0]
|
|
133
|
+
|
|
134
|
+
extra_water = float(extra_water) if extra_water != "" else 0
|
|
135
|
+
weight_after_session = float(weight_after_session) if weight_after_session != "" else np.nan
|
|
136
|
+
weight_before_session = float(nwb.subject.weight) if nwb.subject.weight != "" else np.nan
|
|
137
|
+
user_name = nwb.experimenter[0]
|
|
138
|
+
else:
|
|
139
|
+
rig = nwb.scratch["metadata"].box[0]
|
|
140
|
+
user_name = nwb.experimenter
|
|
141
|
+
weight_after_session = nwb.scratch["metadata"].weight_after[0]
|
|
142
|
+
water_during_session = nwb.scratch["metadata"].water_in_session_total[0]
|
|
143
|
+
weight_before_session = weight_after_session - water_during_session
|
|
144
|
+
extra_water = nwb.scratch["metadata"].water_in_session_manual[0]
|
|
145
|
+
|
|
146
|
+
dict_meta = {
|
|
147
|
+
"rig": rig,
|
|
148
|
+
"user_name": user_name,
|
|
149
|
+
"experiment_description": nwb.experiment_description,
|
|
150
|
+
"task": nwb.protocol,
|
|
151
|
+
"session_start_time": session_start_time_from_meta,
|
|
152
|
+
"weight_before_session": weight_before_session,
|
|
153
|
+
"weight_after_session": weight_after_session,
|
|
154
|
+
"water_during_session": weight_after_session - weight_before_session,
|
|
155
|
+
"water_extra": extra_water,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
df_session = pd.DataFrame(
|
|
159
|
+
dict_meta,
|
|
160
|
+
index=session_index,
|
|
161
|
+
)
|
|
162
|
+
# Use hierarchical index (type = {'metadata', 'session_stats'}, variable = {...}, etc.)
|
|
163
|
+
df_session.columns = pd.MultiIndex.from_product(
|
|
164
|
+
[["metadata"], dict_meta.keys()], names=["type", "variable"]
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# - Compute session-level stats -
|
|
168
|
+
# TODO: Ideally, all these simple stats could be computed in the GUI, and
|
|
169
|
+
# the GUI sends a copy to the meta session.json file and to the nwb file as well.
|
|
170
|
+
|
|
171
|
+
total_trials = len(df_trials)
|
|
172
|
+
finished_trials = np.sum(~np.isnan(choice_history))
|
|
173
|
+
reward_trials = np.sum(reward_history)
|
|
174
|
+
|
|
175
|
+
reward_rate = reward_trials / finished_trials
|
|
176
|
+
|
|
177
|
+
# TODO: add more stats
|
|
178
|
+
# See code here: https://github.com/AllenNeuralDynamics/map-ephys/blob/
|
|
179
|
+
# 7a06a5178cc621638d849457abb003151f7234ea/pipeline/foraging_analysis.py#L70C8-L70C8
|
|
180
|
+
# early_lick_ratio =
|
|
181
|
+
# double_dipping_ratio =
|
|
182
|
+
# block_num
|
|
183
|
+
# mean_block_length
|
|
184
|
+
# mean_reward_sum
|
|
185
|
+
# mean_reward_contrast
|
|
186
|
+
# autowater_num
|
|
187
|
+
# autowater_ratio
|
|
188
|
+
#
|
|
189
|
+
# mean_iti
|
|
190
|
+
# mean_reward_sum
|
|
191
|
+
# mean_reward_contrast
|
|
192
|
+
# ...
|
|
193
|
+
|
|
194
|
+
# foraging_eff_func = (
|
|
195
|
+
# foraging_eff_baiting if "bait" in nwb.protocol.lower() else foraging_eff_no_baiting
|
|
196
|
+
# )
|
|
197
|
+
# foraging_eff, foraging_eff_random_seed = foraging_eff_func(
|
|
198
|
+
# reward_rate, p_reward[LEFT, :], p_reward[RIGHT, :]
|
|
199
|
+
# )
|
|
200
|
+
|
|
201
|
+
# -- Add session stats here --
|
|
202
|
+
dict_session_stat = {
|
|
203
|
+
"total_trials": total_trials,
|
|
204
|
+
"finished_trials": finished_trials,
|
|
205
|
+
"finished_rate": finished_trials / total_trials,
|
|
206
|
+
"ignore_rate": np.sum(np.isnan(choice_history)) / total_trials,
|
|
207
|
+
"reward_trials": reward_trials,
|
|
208
|
+
"reward_rate": reward_rate,
|
|
209
|
+
# TODO: add more stats here
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
# Generate df_session_stat
|
|
213
|
+
df_session_stat = pd.DataFrame(dict_session_stat, index=session_index)
|
|
214
|
+
df_session_stat.columns = pd.MultiIndex.from_product(
|
|
215
|
+
[["session_stats"], dict_session_stat.keys()],
|
|
216
|
+
names=["type", "variable"],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# -- Add automatic training --
|
|
220
|
+
if "auto_train_engaged" in df_trials.columns:
|
|
221
|
+
df_session["auto_train", "curriculum_name"] = (
|
|
222
|
+
np.nan
|
|
223
|
+
if df_trials.auto_train_curriculum_name.mode()[0] == "none"
|
|
224
|
+
else df_trials.auto_train_curriculum_name.mode()[0]
|
|
225
|
+
)
|
|
226
|
+
df_session["auto_train", "curriculum_version"] = (
|
|
227
|
+
np.nan
|
|
228
|
+
if df_trials.auto_train_curriculum_version.mode()[0] == "none"
|
|
229
|
+
else df_trials.auto_train_curriculum_version.mode()[0]
|
|
230
|
+
)
|
|
231
|
+
df_session["auto_train", "curriculum_schema_version"] = (
|
|
232
|
+
np.nan
|
|
233
|
+
if df_trials.auto_train_curriculum_schema_version.mode()[0] == "none"
|
|
234
|
+
else df_trials.auto_train_curriculum_schema_version.mode()[0]
|
|
235
|
+
)
|
|
236
|
+
df_session["auto_train", "current_stage_actual"] = (
|
|
237
|
+
np.nan
|
|
238
|
+
if df_trials.auto_train_stage.mode()[0] == "none"
|
|
239
|
+
else df_trials.auto_train_stage.mode()[0]
|
|
240
|
+
)
|
|
241
|
+
df_session["auto_train", "if_overriden_by_trainer"] = (
|
|
242
|
+
np.nan
|
|
243
|
+
if all(df_trials.auto_train_stage_overridden.isna())
|
|
244
|
+
else df_trials.auto_train_stage_overridden.mode()[0]
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# Add a flag to indicate whether any of the auto train settings were changed
|
|
248
|
+
# during the training
|
|
249
|
+
df_session["auto_train", "if_consistent_within_session"] = (
|
|
250
|
+
len(df_trials.groupby([col for col in df_trials.columns if "auto_train" in col])) == 1
|
|
251
|
+
)
|
|
252
|
+
else:
|
|
253
|
+
for field in [
|
|
254
|
+
"curriculum_name",
|
|
255
|
+
"curriculum_version",
|
|
256
|
+
"curriculum_schema_version",
|
|
257
|
+
"current_stage_actual",
|
|
258
|
+
"if_overriden_by_trainer",
|
|
259
|
+
]:
|
|
260
|
+
df_session["auto_train", field] = None
|
|
261
|
+
|
|
262
|
+
# -- Merge to df_session --
|
|
263
|
+
df_session = pd.concat([df_session, df_session_stat], axis=1)
|
|
264
|
+
return df_session
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def create_df_session(nwb_filename):
|
|
268
|
+
"""
|
|
269
|
+
Creates a dataframe where each row is a session
|
|
270
|
+
nwb_filename can be either a single nwb file, a single filepath
|
|
271
|
+
or a list of nwb files, or a list of nwb filepaths
|
|
272
|
+
"""
|
|
273
|
+
if (type(nwb_filename) is not str) and (hasattr(nwb_filename, "__iter__")):
|
|
274
|
+
dfs = []
|
|
275
|
+
for nwb_file in nwb_filename:
|
|
276
|
+
dfs.append(create_single_df_session(nwb_file))
|
|
277
|
+
return pd.concat(dfs)
|
|
278
|
+
else:
|
|
279
|
+
return create_single_df_session(nwb_filename)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# % Process nwb and create df_session for every single session
|
|
283
|
+
def create_single_df_session(nwb_filename):
|
|
284
|
+
"""
|
|
285
|
+
create a dataframe for a single session
|
|
286
|
+
"""
|
|
287
|
+
nwb = load_nwb_from_filename(nwb_filename)
|
|
288
|
+
|
|
289
|
+
df_session = create_single_df_session_inner(nwb)
|
|
290
|
+
|
|
291
|
+
df_session.columns = df_session.columns.droplevel("type")
|
|
292
|
+
df_session = df_session.reset_index()
|
|
293
|
+
df_session["ses_idx"] = (
|
|
294
|
+
df_session["subject_id"].values + "_" + df_session["session_date"].values
|
|
295
|
+
)
|
|
296
|
+
df_session = df_session.rename(columns={"variable": "session_num"})
|
|
297
|
+
return df_session
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def create_df_trials(nwb_filename):
|
|
301
|
+
"""
|
|
302
|
+
Process nwb and create df_trials for every single session
|
|
303
|
+
"""
|
|
304
|
+
nwb = load_nwb_from_filename(nwb_filename)
|
|
305
|
+
|
|
306
|
+
key_from_acq = [
|
|
307
|
+
"left_lick_time",
|
|
308
|
+
"right_lick_time",
|
|
309
|
+
"left_reward_delivery_time",
|
|
310
|
+
"right_reward_delivery_time",
|
|
311
|
+
"FIP_falling_time",
|
|
312
|
+
"FIP_rising_time",
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
# Parse subject and session_date
|
|
316
|
+
if nwb.session_id.startswith("behavior") or nwb.session_id.startswith("FIP"):
|
|
317
|
+
splits = nwb.session_id.split("_")
|
|
318
|
+
subject_id = splits[1]
|
|
319
|
+
session_date = splits[2]
|
|
320
|
+
else:
|
|
321
|
+
splits = nwb.session_id.split("_")
|
|
322
|
+
subject_id = splits[0]
|
|
323
|
+
session_date = splits[1]
|
|
324
|
+
|
|
325
|
+
ses_idx = subject_id + "_" + session_date
|
|
326
|
+
|
|
327
|
+
df_ses_trials = nwb.trials.to_dataframe().reset_index()
|
|
328
|
+
df_ses_trials = df_ses_trials.rename(columns={"id": "trial"})
|
|
329
|
+
df_ses_trials["ses_idx"] = ses_idx
|
|
330
|
+
|
|
331
|
+
# Adjust all times relative to start of the first trial
|
|
332
|
+
t0 = df_ses_trials.start_time[0]
|
|
333
|
+
skip_cols = ["right_valve_open_time", "left_valve_open_time"]
|
|
334
|
+
for col in df_ses_trials.columns:
|
|
335
|
+
if ("time" in col) and (col not in skip_cols):
|
|
336
|
+
df_ses_trials[col + "_absolute"] = df_ses_trials[col] - t0
|
|
337
|
+
|
|
338
|
+
# Adjust for gaps in trial start/stop, and use the last stop time
|
|
339
|
+
last_stop = df_ses_trials.iloc[-1]["stop_time_absolute"]
|
|
340
|
+
df_ses_trials["stop_time_absolute"] = df_ses_trials["start_time_absolute"].shift(
|
|
341
|
+
-1, fill_value=last_stop
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# Adjust times relative to go cue
|
|
345
|
+
for col in df_ses_trials.columns:
|
|
346
|
+
if (
|
|
347
|
+
("time" in col)
|
|
348
|
+
and ("time_absolute" not in col)
|
|
349
|
+
and (col != "goCue_start_time")
|
|
350
|
+
and (col not in skip_cols)
|
|
351
|
+
):
|
|
352
|
+
df_ses_trials.loc[:, col] = (
|
|
353
|
+
df_ses_trials[col].values - df_ses_trials["goCue_start_time"].values
|
|
354
|
+
)
|
|
355
|
+
df_ses_trials["goCue_start_time"] = 0.0
|
|
356
|
+
|
|
357
|
+
# Adjust event times relative to trial
|
|
358
|
+
events_ses = {key: nwb.acquisition[key].timestamps[:] - t0 for key in key_from_acq}
|
|
359
|
+
for event in [
|
|
360
|
+
"left_lick_time",
|
|
361
|
+
"right_lick_time",
|
|
362
|
+
"left_reward_delivery_time",
|
|
363
|
+
"right_reward_delivery_time",
|
|
364
|
+
]:
|
|
365
|
+
event_times = events_ses[event]
|
|
366
|
+
df_ses_trials[event] = df_ses_trials.apply(
|
|
367
|
+
lambda x: np.round(
|
|
368
|
+
event_times[
|
|
369
|
+
(event_times > (x["goCue_start_time"] + x["goCue_start_time_absolute"]))
|
|
370
|
+
& (event_times < (x["stop_time"] + x["goCue_start_time_absolute"]))
|
|
371
|
+
]
|
|
372
|
+
- x["goCue_start_time_absolute"],
|
|
373
|
+
4,
|
|
374
|
+
),
|
|
375
|
+
axis=1,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
# Compute time of reward for each trial
|
|
379
|
+
df_ses_trials["reward_time"] = df_ses_trials.apply(
|
|
380
|
+
lambda x: np.nanmin(
|
|
381
|
+
np.concatenate(
|
|
382
|
+
[
|
|
383
|
+
[np.nan],
|
|
384
|
+
x["right_reward_delivery_time"],
|
|
385
|
+
x["left_reward_delivery_time"],
|
|
386
|
+
]
|
|
387
|
+
)
|
|
388
|
+
),
|
|
389
|
+
axis=1,
|
|
390
|
+
)
|
|
391
|
+
df_ses_trials["reward_time_absolute"] = (
|
|
392
|
+
df_ses_trials["reward_time"] + df_ses_trials["goCue_start_time_absolute"]
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# Compute time of choice for each trials
|
|
396
|
+
df_ses_trials["choice_time"] = df_ses_trials.apply(
|
|
397
|
+
lambda x: np.nanmin(np.concatenate([[np.nan], x["right_lick_time"], x["left_lick_time"]])),
|
|
398
|
+
axis=1,
|
|
399
|
+
)
|
|
400
|
+
df_ses_trials["choice_time_absolute"] = (
|
|
401
|
+
df_ses_trials["choice_time"] + df_ses_trials["goCue_start_time_absolute"]
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
# Compute boolean of whether animal was rewarded
|
|
405
|
+
df_ses_trials["reward"] = df_ses_trials.rewarded_historyR.astype(
|
|
406
|
+
int
|
|
407
|
+
) | df_ses_trials.rewarded_historyL.astype(int)
|
|
408
|
+
|
|
409
|
+
# Drop columns
|
|
410
|
+
df_ses_trials = df_ses_trials.drop(
|
|
411
|
+
columns=[
|
|
412
|
+
"left_lick_time",
|
|
413
|
+
"right_lick_time",
|
|
414
|
+
"left_reward_delivery_time",
|
|
415
|
+
"right_reward_delivery_time",
|
|
416
|
+
]
|
|
417
|
+
)
|
|
418
|
+
return df_ses_trials
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def create_events_df(nwb_filename, adjust_time=True):
|
|
422
|
+
"""
|
|
423
|
+
returns a tidy dataframe of the events in the nwb file
|
|
424
|
+
|
|
425
|
+
adjust_time (bool), set time of first goCue to t=0
|
|
426
|
+
"""
|
|
427
|
+
|
|
428
|
+
nwb = load_nwb_from_filename(nwb_filename)
|
|
429
|
+
|
|
430
|
+
# Build list of all event types in acqusition, ignore FIP events
|
|
431
|
+
event_types = set(nwb.acquisition.keys())
|
|
432
|
+
ignore_types = set(
|
|
433
|
+
[
|
|
434
|
+
"FIP_falling_time",
|
|
435
|
+
"FIP_rising_time",
|
|
436
|
+
"G_1",
|
|
437
|
+
"G_2",
|
|
438
|
+
"Iso_1",
|
|
439
|
+
"R_1",
|
|
440
|
+
"R_2",
|
|
441
|
+
"Iso_2",
|
|
442
|
+
"G_1_dff-bright",
|
|
443
|
+
"G_2_dff-bright",
|
|
444
|
+
"Iso_1_dff-bright",
|
|
445
|
+
"R_1_dff-bright",
|
|
446
|
+
"R_2_dff-bright",
|
|
447
|
+
"Iso_2_dff-bright",
|
|
448
|
+
"G_1_dff-exp",
|
|
449
|
+
"G_2_dff-exp",
|
|
450
|
+
"Iso_1_dff-exp",
|
|
451
|
+
"R_1_dff-exp",
|
|
452
|
+
"R_2_dff-exp",
|
|
453
|
+
"Iso_2_dff-exp",
|
|
454
|
+
"G_1_dff-poly",
|
|
455
|
+
"G_2_dff-poly",
|
|
456
|
+
"Iso_1_dff-poly",
|
|
457
|
+
"R_1_dff-poly",
|
|
458
|
+
"R_2_dff-poly",
|
|
459
|
+
"Iso_2_dff-poly",
|
|
460
|
+
]
|
|
461
|
+
)
|
|
462
|
+
event_types -= ignore_types
|
|
463
|
+
|
|
464
|
+
# Determine time 0
|
|
465
|
+
t0 = nwb.trials.start_time[0]
|
|
466
|
+
|
|
467
|
+
# Iterate over event types and build a dataframe of each
|
|
468
|
+
events = []
|
|
469
|
+
for e in event_types:
|
|
470
|
+
# For each event, get timestamps, data, and label
|
|
471
|
+
stamps = nwb.acquisition[e].timestamps[:]
|
|
472
|
+
data = nwb.acquisition[e].data[:]
|
|
473
|
+
labels = [e] * len(data)
|
|
474
|
+
if adjust_time:
|
|
475
|
+
stamps = stamps - t0
|
|
476
|
+
df = pd.DataFrame({"timestamps": stamps, "data": data, "event": labels})
|
|
477
|
+
events.append(df)
|
|
478
|
+
|
|
479
|
+
# Add keys from trials table
|
|
480
|
+
# I don't like hardcoding dynamic foraging specific things here.
|
|
481
|
+
# I think these keys should be added to the stimulus field of the nwb
|
|
482
|
+
trial_events = ["goCue_start_time"]
|
|
483
|
+
for e in trial_events:
|
|
484
|
+
stamps = nwb.trials[:][e].values
|
|
485
|
+
labels = [e] * len(stamps)
|
|
486
|
+
if adjust_time:
|
|
487
|
+
stamps = stamps - t0
|
|
488
|
+
df = pd.DataFrame({"timestamps": stamps, "event": labels})
|
|
489
|
+
events.append(df)
|
|
490
|
+
|
|
491
|
+
# Build dataframe by concatenating each event
|
|
492
|
+
df = pd.concat(events)
|
|
493
|
+
df = df.sort_values(by="timestamps")
|
|
494
|
+
df = df.dropna(subset="timestamps").reset_index(drop=True)
|
|
495
|
+
|
|
496
|
+
# Add trial index for each event
|
|
497
|
+
trial_starts = nwb.trials.start_time[:] - nwb.trials.start_time[0]
|
|
498
|
+
last_stop = nwb.trials.stop_time[-1] - nwb.trials.start_time[0]
|
|
499
|
+
trial_index = []
|
|
500
|
+
for index, e in df.iterrows():
|
|
501
|
+
starts = np.where(e.timestamps > trial_starts)[0]
|
|
502
|
+
if len(starts) == 0:
|
|
503
|
+
trial_index.append(-1)
|
|
504
|
+
elif e.timestamps > last_stop:
|
|
505
|
+
trial_index.append(len(trial_starts))
|
|
506
|
+
else:
|
|
507
|
+
trial_index.append(starts[-1])
|
|
508
|
+
df["trial"] = trial_index
|
|
509
|
+
|
|
510
|
+
return df
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def create_fib_df(nwb_filename, tidy=True, adjust_time=True):
|
|
514
|
+
"""
|
|
515
|
+
returns a dataframe of the FIB data in the nwb file
|
|
516
|
+
if tidy, return a tidy dataframe
|
|
517
|
+
if not tidy, return pivoted by timestamp
|
|
518
|
+
|
|
519
|
+
adjust_time (bool), set time of first goCue to t=0
|
|
520
|
+
"""
|
|
521
|
+
|
|
522
|
+
nwb = load_nwb_from_filename(nwb_filename)
|
|
523
|
+
|
|
524
|
+
# Build list of all FIB events in NWB file
|
|
525
|
+
nwb_types = set(nwb.acquisition.keys())
|
|
526
|
+
event_types = set(
|
|
527
|
+
[
|
|
528
|
+
"FIP_falling_time",
|
|
529
|
+
"FIP_rising_time",
|
|
530
|
+
"G_1",
|
|
531
|
+
"G_2",
|
|
532
|
+
"Iso_1",
|
|
533
|
+
"R_1",
|
|
534
|
+
"R_2",
|
|
535
|
+
"Iso_2",
|
|
536
|
+
"G_1_dff-bright",
|
|
537
|
+
"G_2_dff-bright",
|
|
538
|
+
"Iso_1_dff-bright",
|
|
539
|
+
"R_1_dff-bright",
|
|
540
|
+
"R_2_dff-bright",
|
|
541
|
+
"Iso_2_dff-bright",
|
|
542
|
+
"G_1_dff-exp",
|
|
543
|
+
"G_2_dff-exp",
|
|
544
|
+
"Iso_1_dff-exp",
|
|
545
|
+
"R_1_dff-exp",
|
|
546
|
+
"R_2_dff-exp",
|
|
547
|
+
"Iso_2_dff-exp",
|
|
548
|
+
"G_1_dff-poly",
|
|
549
|
+
"G_2_dff-poly",
|
|
550
|
+
"Iso_1_dff-poly",
|
|
551
|
+
"R_1_dff-poly",
|
|
552
|
+
"R_2_dff-poly",
|
|
553
|
+
"Iso_2_dff-poly",
|
|
554
|
+
]
|
|
555
|
+
)
|
|
556
|
+
event_types = event_types.intersection(nwb_types)
|
|
557
|
+
|
|
558
|
+
# If no FIB data available
|
|
559
|
+
if len(event_types) == 0:
|
|
560
|
+
return None
|
|
561
|
+
|
|
562
|
+
# Determine time 0
|
|
563
|
+
t0 = nwb.trials.start_time[0]
|
|
564
|
+
|
|
565
|
+
# Iterate over event types and build a dataframe of each
|
|
566
|
+
events = []
|
|
567
|
+
for e in event_types:
|
|
568
|
+
# For each event, get timestamps, data, and label
|
|
569
|
+
stamps = nwb.acquisition[e].timestamps[:]
|
|
570
|
+
data = nwb.acquisition[e].data[:]
|
|
571
|
+
labels = [e] * len(data)
|
|
572
|
+
if adjust_time:
|
|
573
|
+
stamps = stamps - t0
|
|
574
|
+
df = pd.DataFrame({"timestamps": stamps, "data": data, "event": labels})
|
|
575
|
+
events.append(df)
|
|
576
|
+
|
|
577
|
+
# Build dataframe by concatenating each event
|
|
578
|
+
df = pd.concat(events)
|
|
579
|
+
df = df.sort_values(by="timestamps")
|
|
580
|
+
df = df.dropna(subset="timestamps").reset_index(drop=True)
|
|
581
|
+
|
|
582
|
+
# Add session_idx with subject ID and session date info - JL
|
|
583
|
+
if nwb.session_id.startswith("behavior") or nwb.session_id.startswith("FIP"):
|
|
584
|
+
splits = nwb.session_id.split("_")
|
|
585
|
+
subject_id = splits[1]
|
|
586
|
+
session_date = splits[2]
|
|
587
|
+
else:
|
|
588
|
+
splits = nwb.session_id.split("_")
|
|
589
|
+
subject_id = splits[0]
|
|
590
|
+
session_date = splits[1]
|
|
591
|
+
ses_idx = subject_id + "_" + session_date
|
|
592
|
+
df["ses_idx"] = ses_idx
|
|
593
|
+
|
|
594
|
+
# pivot table based on timestamps
|
|
595
|
+
if not tidy:
|
|
596
|
+
df_pivoted = pd.pivot(df, index="timestamps", columns=["event"], values="data")
|
|
597
|
+
return df_pivoted
|
|
598
|
+
else:
|
|
599
|
+
return df
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 Allen Institute for Neural Dynamics
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: aind-dynamic-foraging-data-utils
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Functions to help with postprocessing dynamic foraging data.
|
|
5
|
+
Author: Allen Institute for Neural Dynamics
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Requires-Python: >=3.7
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: pandas
|
|
12
|
+
Requires-Dist: numpy
|
|
13
|
+
Requires-Dist: pynwb
|
|
14
|
+
Requires-Dist: hdmf-zarr
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: black ; extra == 'dev'
|
|
17
|
+
Requires-Dist: coverage ; extra == 'dev'
|
|
18
|
+
Requires-Dist: flake8 ; extra == 'dev'
|
|
19
|
+
Requires-Dist: interrogate ; extra == 'dev'
|
|
20
|
+
Requires-Dist: isort ; extra == 'dev'
|
|
21
|
+
Requires-Dist: Sphinx ; extra == 'dev'
|
|
22
|
+
Requires-Dist: furo ; extra == 'dev'
|
|
23
|
+
|
|
24
|
+
# aind-dynamic-foraging-data-utils
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
[](LICENSE)
|
|
28
|
+

|
|
29
|
+
[](https://github.com/semantic-release/semantic-release)
|
|
30
|
+

|
|
31
|
+

|
|
32
|
+

|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
## Scope
|
|
37
|
+
Purpose: Ingests NWB and spits out dataframes with the relevant information. Focused on dynamic foraging. Other tasks can branch and build task-specific utils.
|
|
38
|
+
Inputs are nwbs, outputs are dataframes (tidy and not)
|
|
39
|
+
Dependencies: xarray (includes numpy and pandas), scikit-learn (includes scipy), matplotlib
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
To use the software, in the root directory, run
|
|
46
|
+
```bash
|
|
47
|
+
pip install -e .
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
To develop the code, run
|
|
51
|
+
```bash
|
|
52
|
+
pip install -e .[dev]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Contributing
|
|
56
|
+
|
|
57
|
+
### Linters and testing
|
|
58
|
+
|
|
59
|
+
There are several libraries used to run linters, check documentation, and run tests.
|
|
60
|
+
|
|
61
|
+
- Please test your changes using the **coverage** library, which will run the tests and log a coverage report:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
coverage run -m unittest discover && coverage report
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- Use **interrogate** to check that modules, methods, etc. have been documented thoroughly:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
interrogate .
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- Use **flake8** to check that code is up to standards (no unused imports, etc.):
|
|
74
|
+
```bash
|
|
75
|
+
flake8 .
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- Use **black** to automatically format the code into PEP standards:
|
|
79
|
+
```bash
|
|
80
|
+
black .
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- Use **isort** to automatically sort import statements:
|
|
84
|
+
```bash
|
|
85
|
+
isort .
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Pull requests
|
|
89
|
+
|
|
90
|
+
For internal members, please create a branch. For external members, please fork the repository and open a pull request from the fork. We'll primarily use [Angular](https://github.com/angular/angular/blob/main/CONTRIBUTING.md#commit) style for commit messages. Roughly, they should follow the pattern:
|
|
91
|
+
```text
|
|
92
|
+
<type>(<scope>): <short summary>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
where scope (optional) describes the packages affected by the code changes and type (mandatory) is one of:
|
|
96
|
+
|
|
97
|
+
- **build**: Changes that affect build tools or external dependencies (example scopes: pyproject.toml, setup.py)
|
|
98
|
+
- **ci**: Changes to our CI configuration files and scripts (examples: .github/workflows/ci.yml)
|
|
99
|
+
- **docs**: Documentation only changes
|
|
100
|
+
- **feat**: A new feature
|
|
101
|
+
- **fix**: A bugfix
|
|
102
|
+
- **perf**: A code change that improves performance
|
|
103
|
+
- **refactor**: A code change that neither fixes a bug nor adds a feature
|
|
104
|
+
- **test**: Adding missing tests or correcting existing tests
|
|
105
|
+
|
|
106
|
+
### Semantic Release
|
|
107
|
+
|
|
108
|
+
The table below, from [semantic release](https://github.com/semantic-release/semantic-release), shows which commit message gets you which release type when `semantic-release` runs (using the default configuration):
|
|
109
|
+
|
|
110
|
+
| Commit message | Release type |
|
|
111
|
+
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
|
|
112
|
+
| `fix(pencil): stop graphite breaking when too much pressure applied` | ~~Patch~~ Fix Release, Default release |
|
|
113
|
+
| `feat(pencil): add 'graphiteWidth' option` | ~~Minor~~ Feature Release |
|
|
114
|
+
| `perf(pencil): remove graphiteWidth option`<br><br>`BREAKING CHANGE: The graphiteWidth option has been removed.`<br>`The default graphite width of 10mm is always used for performance reasons.` | ~~Major~~ Breaking Release <br /> (Note that the `BREAKING CHANGE: ` token must be in the footer of the commit) |
|
|
115
|
+
|
|
116
|
+
### Documentation
|
|
117
|
+
To generate the rst files source files for documentation, run
|
|
118
|
+
```bash
|
|
119
|
+
sphinx-apidoc -o doc_template/source/ src
|
|
120
|
+
```
|
|
121
|
+
Then to create the documentation HTML files, run
|
|
122
|
+
```bash
|
|
123
|
+
sphinx-build -b html doc_template/source/ doc_template/build/html
|
|
124
|
+
```
|
|
125
|
+
More info on sphinx installation can be found [here](https://www.sphinx-doc.org/en/master/usage/installation.html).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
aind_dynamic_foraging_data_utils/__init__.py,sha256=GumePyX4MIeArBCTdlS-9iWt3heecE1BNf4BjIH2OyI,42
|
|
2
|
+
aind_dynamic_foraging_data_utils/alignment.py,sha256=ccTZwDtQQFSgWTS73P0X-4tDLMOR3o4nkh_rKX_BQwY,21280
|
|
3
|
+
aind_dynamic_foraging_data_utils/nwb_utils.py,sha256=9P711GaJH7IFksC_6CSQj3HiZpM0nLafStT1yNCEv_Q,20575
|
|
4
|
+
aind_dynamic_foraging_data_utils-0.1.0.dist-info/LICENSE,sha256=U0Y7B3gZJHXpjJVLgTQjM8e_c8w4JJpLgGhIdsoFR1Y,1092
|
|
5
|
+
aind_dynamic_foraging_data_utils-0.1.0.dist-info/METADATA,sha256=bnjgQlSsFHqsHt9dDrOWNOuXc5BKdC0xndyWkrsmcnE,5610
|
|
6
|
+
aind_dynamic_foraging_data_utils-0.1.0.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
|
|
7
|
+
aind_dynamic_foraging_data_utils-0.1.0.dist-info/top_level.txt,sha256=cxZtQWcebZ8eA0nZpYUavHj14nMd6BtwyrO0DYfpoWg,33
|
|
8
|
+
aind_dynamic_foraging_data_utils-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aind_dynamic_foraging_data_utils
|