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.
@@ -0,0 +1,3 @@
1
+ """Init package"""
2
+
3
+ __version__ = "0.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](https://img.shields.io/badge/license-MIT-brightgreen)](LICENSE)
28
+ ![Code Style](https://img.shields.io/badge/code%20style-black-black)
29
+ [![semantic-release: angular](https://img.shields.io/badge/semantic--release-angular-e10079?logo=semantic-release)](https://github.com/semantic-release/semantic-release)
30
+ ![Interrogate](https://img.shields.io/badge/interrogate-100.0%25-brightgreen)
31
+ ![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen?logo=codecov)
32
+ ![Python](https://img.shields.io/badge/python->=3.7-blue?logo=python)
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.3.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ aind_dynamic_foraging_data_utils