Skip to content

Commit 80ff129

Browse files
authored
Merge pull request #965 from int-brain-lab/TimelineTrialsHabituation
Timeline trials habituation
2 parents efd28f6 + 46318c3 commit 80ff129

File tree

5 files changed

+142
-27
lines changed

5 files changed

+142
-27
lines changed

ibllib/io/extractors/ephys_fpga.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@
3535
from pathlib import Path
3636
import uuid
3737
import re
38-
from functools import partial
3938

4039
import matplotlib.pyplot as plt
4140
from matplotlib.colors import TABLEAU_COLORS
@@ -794,6 +793,7 @@ def _extract(self, sync=None, chmap=None, sync_collection='raw_ephys_data',
794793
elif (protocol_number := kwargs.get('protocol_number')) is not None: # look for spacer
795794
# The spacers are TTLs generated by Bpod at the start of each protocol
796795
tmin, tmax = get_protocol_period(self.session_path, protocol_number, bpod)
796+
tmin += (Spacer().times[-1] + Spacer().tup + 0.05) # exclude spacer itself
797797
else:
798798
# Older sessions don't have protocol spacers so we sync the Bpod intervals here to
799799
# find the approximate end time of the protocol (this will exclude the passive signals
@@ -1453,16 +1453,23 @@ def get_bpod_event_times(self, sync, chmap, bpod_event_ttls=None, display=False,
14531453
# lengths are defined by the state machine of the task protocol and therefore vary.
14541454
if bpod_event_ttls is None:
14551455
# Currently (at least v8.12 and below) there is no trial start or end TTL, only an ITI pulse
1456-
bpod_event_ttls = {'trial_iti': (1, 1.1), 'valve_open': (0, 0.4)}
1456+
bpod_event_ttls = {'trial_iti': (.999, 1.1), 'valve_open': (0, 0.4)}
14571457
bpod_event_intervals = self._assign_events(
14581458
bpod['times'], bpod['polarities'], bpod_event_ttls, display=display)
14591459

14601460
# The first trial pulse is shorter and assigned to valve_open. Here we remove the first
14611461
# valve event, prepend a 0 to the trial_start events, and drop the last trial if it was
14621462
# incomplete in Bpod.
1463-
bpod_event_intervals['trial_iti'] = np.r_[bpod_event_intervals['valve_open'][0:1, :],
1464-
bpod_event_intervals['trial_iti']]
1465-
bpod_event_intervals['valve_open'] = bpod_event_intervals['valve_open'][1:, :]
1463+
t0 = bpod_event_intervals['trial_iti'][0, 0] # expect 1st event to be trial_start
1464+
pretrial = [(k, v[0, 0]) for k, v in bpod_event_intervals.items() if v.size and v[0, 0] < t0]
1465+
if pretrial:
1466+
(pretrial, _) = sorted(pretrial, key=lambda x: x[1])[0] # take the earliest event
1467+
dt = np.diff(bpod_event_intervals[pretrial][0, :]) * 1e3 # record TTL length to log
1468+
_logger.debug('Reassigning first %s to trial_start. TTL length = %.3g ms', pretrial, dt)
1469+
bpod_event_intervals['trial_iti'] = np.r_[
1470+
bpod_event_intervals[pretrial][0:1, :], bpod_event_intervals['trial_iti']
1471+
]
1472+
bpod_event_intervals[pretrial] = bpod_event_intervals[pretrial][1:, :]
14661473

14671474
return bpod, bpod_event_intervals
14681475

@@ -1514,13 +1521,16 @@ def build_trials(self, sync, chmap, display=False, **kwargs):
15141521
out.update({k: self.bpod2fpga(self.bpod_trials[k][ibpod]) for k in self.bpod_rsync_fields})
15151522

15161523
# Assigning each event to a trial ensures exactly one event per trial (missing events are NaN)
1517-
assign_to_trial = partial(_assign_events_to_trial, fpga_events['intervals_0'])
15181524
trials = alfio.AlfBunch({
1519-
'goCue_times': assign_to_trial(fpga_events['goCue_times'], take='first'),
1520-
'feedback_times': assign_to_trial(fpga_events['feedback_times']),
1521-
'stimCenter_times': assign_to_trial(self.frame2ttl['times'], take=-2),
1522-
'stimOn_times': assign_to_trial(self.frame2ttl['times'], take='first'),
1523-
'stimOff_times': assign_to_trial(self.frame2ttl['times']),
1525+
'goCue_times': _assign_events_to_trial(out['goCueTrigger_times'], fpga_events['goCue_times'], take='first'),
1526+
'feedback_times': _assign_events_to_trial(fpga_events['intervals_0'], fpga_events['feedback_times']),
1527+
'stimCenter_times': _assign_events_to_trial(
1528+
out['stimCenterTrigger_times'], self.frame2ttl['times'], take='first', t_trial_end=out['stimOffTrigger_times']),
1529+
'stimOn_times': _assign_events_to_trial(
1530+
out['stimOnTrigger_times'], self.frame2ttl['times'], take='first', t_trial_end=out['stimCenterTrigger_times']),
1531+
'stimOff_times': _assign_events_to_trial(
1532+
out['stimOffTrigger_times'], self.frame2ttl['times'],
1533+
take='first', t_trial_end=np.r_[out['intervals'][1:, 0], np.inf])
15241534
})
15251535
out.update({k: trials[k][ifpga] for k in trials.keys()})
15261536

ibllib/io/extractors/mesoscope.py

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@
1111
from packaging import version
1212

1313
from ibllib.plots.misc import squares, vertical_lines
14-
from ibllib.io.raw_daq_loaders import (extract_sync_timeline, timeline_get_channel,
15-
correct_counter_discontinuities, load_timeline_sync_and_chmap)
14+
from ibllib.io.raw_daq_loaders import (
15+
extract_sync_timeline, timeline_get_channel, correct_counter_discontinuities, load_timeline_sync_and_chmap)
1616
import ibllib.io.extractors.base as extractors_base
17-
from ibllib.io.extractors.ephys_fpga import FpgaTrials, WHEEL_TICKS, WHEEL_RADIUS_CM, _assign_events_to_trial
17+
from ibllib.io.extractors.ephys_fpga import (
18+
FpgaTrials, FpgaTrialsHabituation, WHEEL_TICKS, WHEEL_RADIUS_CM, _assign_events_to_trial)
1819
from ibllib.io.extractors.training_wheel import extract_wheel_moves
1920
from ibllib.io.extractors.camera import attribute_times
2021
from brainbox.behavior.wheel import velocity_filtered
@@ -157,7 +158,7 @@ def load_sync(self, sync_collection='raw_sync_data', chmap=None, **_):
157158
return sync, chmap
158159

159160
def _extract(self, sync=None, chmap=None, sync_collection='raw_sync_data', **kwargs) -> dict:
160-
trials = super()._extract(sync, chmap, sync_collection='raw_sync_data', **kwargs)
161+
trials = super()._extract(sync, chmap, sync_collection=sync_collection, **kwargs)
161162
if kwargs.get('display', False):
162163
plot_timeline(self.timeline, channels=chmap.keys(), raw=True)
163164
return trials
@@ -267,7 +268,8 @@ def assign_to_trial(events, take='last', starts=start_times, **kwargs):
267268
# Extract valve open times from the DAQ
268269
valve_driver_ttls = bpod_event_intervals['valve_open']
269270
correct = self.bpod_trials['feedbackType'] == 1
270-
# If there is a reward_valve channel, the valve has
271+
# If there is a reward_valve channel, the voltage across the valve has been recorded and
272+
# should give a more accurate readout of the valve's activity.
271273
if any(ch['name'] == 'reward_valve' for ch in self.timeline['meta']['inputs']):
272274
# TODO Let's look at the expected open length based on calibration and reward volume
273275
# import scipy.interpolate
@@ -600,6 +602,63 @@ def _assign_events_audio(self, audio_times, audio_polarities, display=False):
600602
return t_ready_tone_in, t_error_tone_in
601603

602604

605+
class TimelineTrialsHabituation(FpgaTrialsHabituation, TimelineTrials):
606+
"""Habituation trials extraction from Timeline DAQ data."""
607+
608+
sync_field = 'intervals_0'
609+
610+
def build_trials(self, sync=None, chmap=None, **kwargs):
611+
"""
612+
Extract task related event times from the sync.
613+
614+
The valve used at the mesoscope has a way to record the raw voltage across the solenoid,
615+
giving a more accurate readout of the valve's activity. If the reward_valve channel is
616+
present on the DAQ, this is used to extract the valve open times.
617+
618+
Parameters
619+
----------
620+
sync : dict
621+
'polarities' of fronts detected on sync trace for all 16 chans and their 'times'
622+
chmap : dict
623+
Map of channel names and their corresponding index. Default to constant.
624+
625+
Returns
626+
-------
627+
dict
628+
A map of trial event timestamps.
629+
"""
630+
out = super().build_trials(sync, chmap, **kwargs)
631+
632+
start_times = out['intervals'][:, 0]
633+
last_trial_end = out['intervals'][-1, 1]
634+
635+
# Extract valve open times from the DAQ
636+
_, bpod_event_intervals = self.get_bpod_event_times(sync, chmap, **kwargs)
637+
bpod_feedback_times = self.bpod2fpga(self.bpod_trials['feedback_times'])
638+
valve_driver_ttls = bpod_event_intervals['valve_open']
639+
# If there is a reward_valve channel, the voltage across the valve has been recorded and
640+
# should give a more accurate readout of the valve's activity.
641+
if any(ch['name'] == 'reward_valve' for ch in self.timeline['meta']['inputs']):
642+
# Use the driver TTLs to find the valve open times that correspond to the valve opening
643+
valve_intervals, valve_open_times = self.get_valve_open_times(driver_ttls=valve_driver_ttls)
644+
if valve_open_times.size != start_times.size:
645+
_logger.warning(
646+
'Number of valve open times does not equal number of correct trials (%i != %i)',
647+
valve_open_times.size, start_times.size)
648+
else:
649+
# Use the valve controller TTLs recorded on the Bpod channel as the reward time
650+
valve_open_times = valve_driver_ttls[:, 0]
651+
# there may be an extra last trial that's not in the Bpod intervals as the extractor ignores the last trial
652+
valve_open_times = valve_open_times[valve_open_times <= last_trial_end]
653+
out['valveOpen_times'] = _assign_events_to_trial(
654+
bpod_feedback_times, valve_open_times, take='first', t_trial_end=out['intervals'][:, 1])
655+
656+
# Feedback times
657+
out['feedback_times'] = np.copy(out['valveOpen_times'])
658+
659+
return out
660+
661+
603662
class MesoscopeSyncTimeline(extractors_base.BaseExtractor):
604663
"""Extraction of mesoscope imaging times."""
605664

ibllib/pipes/behavior_tasks.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from ibllib.io.extractors.ephys_passive import PassiveChoiceWorld
1717
from ibllib.io.extractors.bpod_trials import get_bpod_extractor
1818
from ibllib.io.extractors.ephys_fpga import FpgaTrials, FpgaTrialsHabituation, get_sync_and_chn_map
19-
from ibllib.io.extractors.mesoscope import TimelineTrials
19+
from ibllib.io.extractors.mesoscope import TimelineTrials, TimelineTrialsHabituation
2020
from ibllib.pipes import training_status
2121
from ibllib.plots.figures import BehaviourPlots
2222

@@ -157,6 +157,40 @@ def run_qc(self, trials_data=None, update=True, **_):
157157
return qc
158158

159159

160+
class HabituationTrialsTimeline(HabituationTrialsNidq):
161+
"""Behaviour task extractor with DAQdata.raw NPY datasets."""
162+
@property
163+
def signature(self):
164+
signature = super().signature
165+
signature['input_files'] = [
166+
('_iblrig_taskData.raw.*', self.collection, True),
167+
('_iblrig_taskSettings.raw.*', self.collection, True),
168+
(f'_{self.sync_namespace}_DAQdata.raw.npy', self.sync_collection, True),
169+
(f'_{self.sync_namespace}_DAQdata.timestamps.npy', self.sync_collection, True),
170+
(f'_{self.sync_namespace}_DAQdata.meta.json', self.sync_collection, True),
171+
]
172+
return signature
173+
174+
def extract_behaviour(self, save=True, **kwargs):
175+
"""Extract the Bpod trials data and Timeline acquired signals."""
176+
# First determine the extractor from the task protocol
177+
bpod_trials, _ = HabituationTrialsBpod.extract_behaviour(self, save=False, **kwargs)
178+
179+
# Sync Bpod trials to DAQ
180+
self.extractor = TimelineTrialsHabituation(self.session_path, bpod_trials=bpod_trials, bpod_extractor=self.extractor)
181+
save_path = self.session_path / self.output_collection
182+
if not self._spacer_support(self.extractor.settings):
183+
_logger.warning('Protocol spacers not supported; setting protocol_number to None')
184+
self.protocol_number = None
185+
186+
# NB: The stimOff times are called stimCenter times for habituation choice world
187+
dsets, out_files = self.extractor.extract(
188+
save=save, path_out=save_path, sync_collection=self.sync_collection,
189+
task_collection=self.collection, protocol_number=self.protocol_number, **kwargs)
190+
191+
return dsets, out_files
192+
193+
160194
class TrialRegisterRaw(base_tasks.RegisterRawDataTask, base_tasks.BehaviourTask):
161195
priority = 100
162196
job_size = 'small'

ibllib/qc/task_metrics.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -458,14 +458,14 @@ def compute(self, **kwargs):
458458
sessions = sessions[1:]
459459
metric = ([0, data['intervals'][-1, 1] - data['intervals'][0, 0]] +
460460
[(datetime.fromisoformat(x['end_time']) -
461-
datetime.fromisoformat(x['start_time'])).total_seconds() / 60
461+
datetime.fromisoformat(x['start_time'])).total_seconds()
462462
for x in [self.one.alyx.get(s['url']) for s in sessions]])
463463

464464
# The duration from raw trial data
465465
# duration = map(float, self.extractor.raw_data[-1]['elapsed_time'].split(':'))
466466
# duration = timedelta(**dict(zip(('hours', 'minutes', 'seconds'),
467-
# duration))).total_seconds() / 60
468-
metrics[check] = np.array(metric)
467+
# duration))).total_seconds()
468+
metrics[check] = np.array(metric) / 60
469469
passed[check] = np.diff(metric) >= 12
470470

471471
# Check event orders: trial_start < stim on < stim center < feedback < stim off
@@ -515,9 +515,19 @@ def compute(self, **kwargs):
515515
passed[check][-1] = np.nan
516516
metrics[check] = metric
517517

518+
# Check stim on go cue delay
519+
# Unlike in ChoiceWorld, the go cue is in a race condition with the stimulus onset and
520+
# therefore usually presents slightly before the stimulus. For now we will keep the QC
521+
# thresholds the same as in ChoiceWorld which means this will always fail
522+
check = prefix + 'stimOn_goCue_delays'
523+
# If either are NaN, the result will be Inf to ensure that it crosses the failure threshold.
524+
threshold = 0.01 if audio_output.lower() == 'harp' else 0.053
525+
metric = np.nan_to_num(data['goCue_times'] - data['stimOn_times'], nan=np.inf)
526+
passed[check] = np.abs(metric) < threshold
527+
metrics[check] = metric
528+
518529
# Checks common to training QC
519-
checks = [check_goCue_delays, check_stimOn_goCue_delays,
520-
check_stimOn_delays, check_stimOff_delays]
530+
checks = [check_goCue_delays, check_stimOn_delays, check_stimOff_delays]
521531
for fcn in checks:
522532
check = prefix + fcn.__name__[6:]
523533
metrics[check], passed[check] = fcn(data, audio_output=audio_output)

ibllib/qc/task_qc_viewer/task_qc.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,8 @@ def n_trials(self):
106106
return self.qc.extractor.data['intervals'].shape[0]
107107

108108
def get_wheel_data(self):
109-
return {'re_pos': self.qc.extractor.data.get('wheel_position', np.array([])),
110-
're_ts': self.qc.extractor.data.get('wheel_timestamps', np.array([]))}
109+
return {'re_pos': self.qc.extractor.data.get('wheel_position') or np.array([]),
110+
're_ts': self.qc.extractor.data.get('wheel_timestamps') or np.array([])}
111111

112112
def create_plots(self, axes,
113113
wheel_axes=None, trial_events=None, color_map=None, linestyle=None):
@@ -280,9 +280,11 @@ def show_session_task_qc(qc_or_session=None, bpod_only=False, local=False, one=N
280280
events = map(lambda x: x.replace('stimFreeze', 'stimCenter'), events)
281281

282282
# Run QC and plot
283-
w = ViewEphysQC.viewqc(wheel=qc.get_wheel_data())
283+
wheel_data = qc.get_wheel_data()
284+
w = ViewEphysQC.viewqc(wheel=wheel_data if wheel_data['re_pos'].size else None)
285+
284286
qc.create_plots(w.wplot.canvas.ax,
285-
wheel_axes=w.wplot.canvas.ax2,
287+
wheel_axes=getattr(w.wplot.canvas, 'ax2', None),
286288
trial_events=list(events),
287289
color_map=cm,
288290
linestyle=ls)
@@ -292,7 +294,7 @@ def show_session_task_qc(qc_or_session=None, bpod_only=False, local=False, one=N
292294
if 'task_qc' in locals():
293295
df_trials = pd.DataFrame({
294296
k: v for k, v in task_qc.extractor.data.items()
295-
if v.size == n_trials and not k.startswith('wheel')
297+
if not k.startswith('wheel') and v.size == n_trials
296298
})
297299
df = df_trials.merge(qc.frame, left_index=True, right_index=True)
298300
else:

0 commit comments

Comments
 (0)