Coverage for ibllib/qc/task_metrics.py: 96%
408 statements
« prev ^ index » next coverage.py v7.5.4, created at 2024-07-08 17:16 +0100
« prev ^ index » next coverage.py v7.5.4, created at 2024-07-08 17:16 +0100
1"""Behaviour QC.
3This module runs a list of quality control metrics on the behaviour data.
5NB: The QC should be loaded using :meth:`ibllib.pipes.base_tasks.BehaviourTask.run_qc` and not
6instantiated directly.
8Examples
9--------
10Running on a rig computer and updating QC fields in Alyx:
12>>> from ibllib.qc.task_metrics import TaskQC
13>>> TaskQC('path/to/session').run(update=True)
15Downloading the required data and inspecting the QC on a different computer:
17>>> from ibllib.qc.task_metrics import TaskQC
18>>> qc = TaskQC(eid)
19>>> outcome, results = qc.run()
21Inspecting individual test outcomes
23>>> from ibllib.qc.task_metrics import TaskQC
24>>> qc = TaskQC(eid)
25>>> outcome, results, outcomes = qc.compute().compute_session_status()
27Running bpod QC on ephys session
29>>> from ibllib.qc.task_metrics import TaskQC
30>>> qc = TaskQC(eid)
31>>> qc.load_data(bpod_only=True) # Extract without FPGA
32>>> bpod_qc = qc.run()
34Running bpod QC only, from training rig PC
36>>> from ibllib.qc.task_metrics import TaskQC
37>>> from ibllib.qc.qcplots import plot_results
38>>> session_path = r'/home/nico/Downloads/FlatIron/mrsicflogellab/Subjects/SWC_023/2020-02-14/001'
39>>> qc = TaskQC(session_path)
40>>> qc.load_data(bpod_only=True, download_data=False) # Extract without FPGA
41>>> qc.run()
42>>> plot_results(qc, save_path=session_path)
44Running ephys QC, from local server PC (after ephys + bpod data have been copied to a same folder)
46>>> from ibllib.qc.task_metrics import TaskQC
47>>> from ibllib.qc.qcplots import plot_results
48>>> session_path = r'/home/nico/Downloads/FlatIron/mrsicflogellab/Subjects/SWC_023/2020-02-14/001'
49>>> qc = TaskQC(session_path)
50>>> qc.run()
51>>> plot_results(qc, save_path=session_path)
52"""
53import logging
54import sys
55from packaging import version
56from pathlib import Path, PurePosixPath
57from datetime import datetime, timedelta
58from inspect import getmembers, isfunction
59from functools import reduce
60from collections.abc import Sized
62import numpy as np
63from scipy.stats import chisquare
65from brainbox.behavior.wheel import cm_to_rad, traces_by_trial
66from ibllib.qc.task_extractors import TaskQCExtractor
67from ibllib.io.extractors import ephys_fpga
68from one.alf import spec
69from . import base
71_log = logging.getLogger(__name__)
74BWM_CRITERIA = {
75 'default': {'PASS': 0.99, 'WARNING': 0.90, 'FAIL': 0}, # Note: WARNING was 0.95 prior to Aug 2022
76 '_task_stimOff_itiIn_delays': {'PASS': 0.99, 'WARNING': 0},
77 '_task_positive_feedback_stimOff_delays': {'PASS': 0.99, 'WARNING': 0},
78 '_task_negative_feedback_stimOff_delays': {'PASS': 0.99, 'WARNING': 0},
79 '_task_wheel_move_during_closed_loop': {'PASS': 0.99, 'WARNING': 0},
80 '_task_response_stimFreeze_delays': {'PASS': 0.99, 'WARNING': 0},
81 '_task_detected_wheel_moves': {'PASS': 0.99, 'WARNING': 0},
82 '_task_trial_length': {'PASS': 0.99, 'WARNING': 0},
83 '_task_goCue_delays': {'PASS': 0.99, 'WARNING': 0},
84 '_task_errorCue_delays': {'PASS': 0.99, 'WARNING': 0},
85 '_task_stimOn_delays': {'PASS': 0.99, 'WARNING': 0},
86 '_task_stimOff_delays': {'PASS': 0.99, 'WARNING': 0},
87 '_task_stimFreeze_delays': {'PASS': 0.99, 'WARNING': 0},
88 '_task_iti_delays': {'NOT_SET': 0},
89 '_task_passed_trial_checks': {'NOT_SET': 0}
90}
93def compute_session_status_from_dict(results, criteria=None):
94 """
95 Compute overall task QC value from QC check results.
97 Given a dictionary of results, computes the overall session QC for each key and aggregates
98 in a single value.
100 Parameters
101 ----------
102 results : dict
103 A dictionary of QC keys containing (usually scalar) values.
104 criteria : dict
105 A dictionary of qc keys containing map of PASS, WARNING, FAIL thresholds.
107 Returns
108 -------
109 one.alf.spec.QC
110 Overall session QC outcome.
111 dict
112 A map of QC tests and their outcomes.
113 """
114 if not criteria: 1ynmohcadfeb
115 criteria = {'default': BWM_CRITERIA['default']}
116 outcomes = {k: TaskQC.thresholding(v, thresholds=criteria.get(k, criteria['default'])) 1ynmohcadfeb
117 for k, v in results.items()}
119 # Criteria map is in order of severity so the max index is our overall QC outcome
120 session_outcome = base.QC.overall_outcome(outcomes.values()) 1nmohcadfeb
121 return session_outcome, outcomes 1nmohcadfeb
124def update_dataset_qc(qc, registered_datasets, one, override=False):
125 """
126 Update QC values for individual datasets.
128 Parameters
129 ----------
130 qc : ibllib.qc.task_metrics.TaskQC
131 A TaskQC object that has been run.
132 registered_datasets : list of dict
133 A list of Alyx dataset records.
134 one : one.api.OneAlyx
135 An online instance of ONE.
136 override : bool
137 If True the QC field is updated even if new value is better than previous.
139 Returns
140 -------
141 list of dict
142 The list of registered datasets but with the 'qc' fields updated.
143 """
144 # Create map of dataset name, sans extension, to dataset id
145 stem2id = {PurePosixPath(dset['name']).stem: dset.get('id') for dset in registered_datasets} 1jcb
146 # Ensure dataset stems are unique
147 assert len(stem2id) == len(registered_datasets), 'ambiguous dataset names' 1jcb
149 # dict of QC check to outcome (as enum value)
150 *_, outcomes = qc.compute_session_status() 1jcb
151 # work over map of dataset name (sans extension) to outcome (enum or dict of columns: enum)
152 for name, outcome in qc.compute_dataset_qc_status(outcomes).items(): 1jcb
153 # if outcome is a dict, calculate aggregate outcome for each column
154 if isinstance(outcome, dict): 1jcb
155 extended_qc = outcome 1jcb
156 outcome = qc.overall_outcome(outcome.values()) 1jcb
157 else:
158 extended_qc = {} 1jcb
159 # check if dataset was registered to Alyx
160 if not (did := stem2id.get(name)): 1jcb
161 _log.debug('dataset %s not registered, skipping', name) 1jcb
162 continue 1jcb
163 # update the dataset QC value on Alyx
164 if outcome > spec.QC.NOT_SET or override: 1jcb
165 dset_qc = base.QC(did, one=one, log=_log, endpoint='datasets') 1jcb
166 dset = next(x for x in registered_datasets if did == x.get('id')) 1jcb
167 dset['qc'] = dset_qc.update(outcome, namespace='', override=override).name 1jcb
168 if extended_qc: 1jcb
169 dset_qc.update_extended_qc(extended_qc) 1jcb
170 return registered_datasets 1jcb
173class TaskQC(base.QC):
174 """Task QC for training, biased, and ephys choice world."""
176 criteria = BWM_CRITERIA
178 extractor = None
179 """ibllib.qc.task_extractors.TaskQCExtractor: A task extractor object containing raw and extracted data."""
181 @staticmethod
182 def thresholding(qc_value, thresholds=None) -> spec.QC:
183 """
184 Compute the outcome of a single key by applying thresholding.
186 Parameters
187 ----------
188 qc_value : float
189 Proportion of passing qcs, between 0 and 1.
190 thresholds : dict
191 Dictionary with keys 'PASS', 'WARNING', 'FAIL', (or enum
192 integers, c.f. one.alf.spec.QC).
194 Returns
195 -------
196 one.alf.spec.QC
197 The outcome.
198 """
199 thresholds = {spec.QC.validate(k): v for k, v in thresholds.items() or {}} 1ynmohcadfeb
200 MAX_BOUND, MIN_BOUND = (1, 0) 1ynmohcadfeb
201 if qc_value is None or np.isnan(qc_value): 1ynmohcadfeb
202 return spec.QC.NOT_SET 1nmohafeb
203 elif (qc_value > MAX_BOUND) or (qc_value < MIN_BOUND): 1ynmohcadfeb
204 raise ValueError('Values out of bound') 1y
205 for crit in filter(None, sorted(spec.QC)): 1nmohcadfeb
206 if crit in thresholds.keys() and qc_value >= thresholds[crit]: 1nmohcadfeb
207 return crit 1nmohcadfeb
208 # if None of this applies, return 'NOT_SET'
209 return spec.QC.NOT_SET 1mhcadfeb
211 def __init__(self, session_path_or_eid, **kwargs):
212 """
213 Task QC for training, biased, and ephys choice world.
215 :param session_path_or_eid: A session eid or path
216 :param log: A logging.Logger instance, if None the 'ibllib' logger is used
217 :param one: An ONE instance for fetching and setting the QC on Alyx
218 """
219 # When an eid is provided, we will download the required data by default (if necessary)
220 self.download_data = not spec.is_session_path(Path(session_path_or_eid)) 1ijcadb
221 super().__init__(session_path_or_eid, **kwargs) 1ijcadb
223 # Data
224 self.extractor = None 1ijcadb
226 # Metrics and passed trials
227 self.metrics = None 1ijcadb
228 self.passed = None 1ijcadb
230 # Criteria (initialize as outcomes vary by class, task, and hardware)
231 self.criteria = BWM_CRITERIA.copy() 1ijcadb
233 def load_data(self, bpod_only=False, download_data=True):
234 """Extract the data from raw data files.
236 Extracts all the required task data from the raw data files.
238 Parameters
239 ----------
240 bpod_only : bool
241 If True no data is extracted from the FPGA for ephys sessions.
242 download_data : bool
243 If True, any missing raw data is downloaded via ONE. By default data are not downloaded
244 if a session path was provided to the constructor.
245 """
246 self.extractor = TaskQCExtractor(
247 self.session_path, one=self.one, download_data=download_data, bpod_only=bpod_only)
249 def compute(self, **kwargs):
250 """Compute and store the QC metrics.
252 Runs the QC on the session and stores a map of the metrics for each datapoint for each
253 test, and a map of which datapoints passed for each test.
255 Parameters
256 ----------
257 bpod_only : bool
258 If True no data is extracted from the FPGA for ephys sessions.
259 download_data : bool
260 If True, any missing raw data is downloaded via ONE. By default data are not downloaded
261 if a session path was provided to the constructor.
262 """
263 if self.extractor is None: 1cadgfeb
264 kwargs['download_data'] = kwargs.pop('download_data', self.download_data)
265 self.load_data(**kwargs)
267 ver = self.extractor.settings.get('IBLRIG_VERSION', '') or '0.0.0' 1cadgfeb
268 if version.parse(ver) >= version.parse('8.0.0'): 1cadgfeb
269 self.criteria['_task_iti_delays'] = {'PASS': 0.99, 'WARNING': 0}
270 self.criteria['_task_passed_trial_checks'] = {'PASS': 0.7, 'WARNING': 0}
271 else:
272 self.criteria['_task_iti_delays'] = {'NOT_SET': 0} 1cadgfeb
273 self.criteria['_task_passed_trial_checks'] = {'NOT_SET': 0} 1cadgfeb
275 self.log.info(f'Session {self.session_path}: Running QC on behavior data...') 1cadgfeb
276 self.get_bpodqc_metrics_frame( 1cadgfeb
277 self.extractor.data,
278 wheel_gain=self.extractor.settings['STIM_GAIN'], # The wheel gain
279 photodiode=self.extractor.frame_ttls,
280 audio=self.extractor.audio_ttls,
281 re_encoding=self.extractor.wheel_encoding or 'X1',
282 min_qt=self.extractor.settings.get('QUIESCENT_PERIOD') or 0.2,
283 audio_output=self.extractor.settings.get('device_sound', {}).get('OUTPUT', 'unknown')
284 )
286 def _get_checks(self):
287 """
288 Find all methods that begin with 'check_'.
290 Returns
291 -------
292 Dict[str, function]
293 A map of QC check function names and the corresponding functions that return `metric`
294 (any), `passed` (bool).
295 """
296 def is_metric(x): 1ujcadgfeb
297 return isfunction(x) and x.__name__.startswith('check_') 1ujcadgfeb
299 return dict(getmembers(sys.modules[__name__], is_metric)) 1ujcadgfeb
301 def get_bpodqc_metrics_frame(self, data, **kwargs):
302 """
303 Evaluate task QC metrics.
305 Evaluates all the QC metric functions in this module (those starting with 'check') and
306 returns the results. The optional kwargs listed below are passed to each QC metric function.
308 Parameters
309 ----------
310 data : dict
311 The extracted task data.
312 re_encoding : str {'X1', 'X2', 'X4'}
313 The encoding configuration of the rotary encoder.
314 enc_res : int
315 The rotary encoder resolution as number of fronts per revolution.
316 wheel_gain : float
317 The STIM_GAIN task parameter.
318 photodiode : dict
319 The fronts from Bpod's BNC1 input or FPGA frame2ttl channel.
320 audio : dict
321 The fronts from Bpod's BNC2 input FPGA audio sync channel.
322 min_qt : float
323 The QUIESCENT_PERIOD task parameter.
325 Returns
326 -------
327 dict
328 Map of checks and their QC metric values (1 per trial).
329 dict
330 Map of checks and a float array of which samples passed.
331 """
332 # Find all methods that begin with 'check_'
333 checks = self._get_checks() 1cadgfeb
334 prefix = '_task_' # Extended QC fields will start with this 1cadgfeb
335 # Method 'check_foobar' stored with key '_task_foobar' in metrics map
336 qc_metrics_map = {prefix + k[6:]: fn(data, **kwargs) for k, fn in checks.items()} 1cadgfeb
338 # Split metrics and passed frames
339 self.metrics = {} 1cadgfeb
340 self.passed = {} 1cadgfeb
341 for k in qc_metrics_map: 1cadgfeb
342 self.metrics[k], self.passed[k] = qc_metrics_map[k] 1cadgfeb
344 # Add a check for trial level pass: did a given trial pass all checks?
345 n_trials = data['intervals'].shape[0] 1cadgfeb
346 # Trial-level checks return an array the length that equals the number of trials
347 trial_level_passed = [m for m in self.passed.values() if isinstance(m, Sized) and len(m) == n_trials] 1cadgfeb
348 name = prefix + 'passed_trial_checks' 1cadgfeb
349 self.metrics[name] = reduce(np.logical_and, trial_level_passed or (None, None)) 1cadgfeb
350 self.passed[name] = self.metrics[name].astype(float) if trial_level_passed else None 1cadgfeb
352 def run(self, update=False, namespace='task', **kwargs):
353 """
354 Compute the QC outcomes and return overall task QC outcome.
356 Parameters
357 ----------
358 update : bool
359 If True, updates the session QC fields on Alyx.
360 namespace : str
361 The namespace of the QC fields in the Alyx JSON field.
362 bpod_only : bool
363 If True no data is extracted from the FPGA for ephys sessions.
364 download_data : bool
365 If True, any missing raw data is downloaded via ONE. By default data are not downloaded
366 if a session path was provided to the constructor.
368 Returns
369 -------
370 str
371 Overall task QC outcome.
372 dict
373 A map of QC tests and the proportion of data points that passed them.
374 """
375 if self.metrics is None: 1cadeb
376 self.compute(**kwargs) 1cadeb
377 outcome, results, _ = self.compute_session_status() 1cadeb
378 if update: 1cadeb
379 self.update_extended_qc(results) 1ceb
380 self.update(outcome, namespace) 1ceb
381 return outcome, results 1cadeb
383 def compute_session_status(self):
384 """
385 Compute the overall session QC for each key and aggregates in a single value.
387 Returns
388 -------
389 str
390 Overall session QC outcome.
391 dict
392 A map of QC tests and the proportion of data points that passed them.
393 dict
394 A map of QC tests and their outcomes.
395 """
396 if self.passed is None: 1hcadfeb
397 raise AttributeError('passed is None; compute QC first') 1f
398 # Get mean passed of each check, or None if passed is None or all NaN
399 results = {k: None if v is None or np.isnan(v).all() else np.nanmean(v) 1hcadfeb
400 for k, v in self.passed.items()}
401 session_outcome, outcomes = compute_session_status_from_dict(results, self.criteria) 1hcadfeb
402 return session_outcome, results, outcomes 1hcadfeb
404 @staticmethod
405 def compute_dataset_qc_status(outcomes):
406 """Return map of dataset specific QC values.
408 Parameters
409 ----------
410 outcomes : dict
411 Map of checks and their individual outcomes.
413 Returns
414 -------
415 dict
416 Map of dataset names and their outcome.
417 """
418 trials_table_outcomes = { 1ujcb
419 'intervals': outcomes.get('_task_iti_delays', spec.QC.NOT_SET),
420 'goCue_times': outcomes.get('_task_goCue_delays', spec.QC.NOT_SET),
421 'response_times': spec.QC.NOT_SET, 'choice': spec.QC.NOT_SET,
422 'stimOn_times': outcomes.get('_task_stimOn_delays', spec.QC.NOT_SET),
423 'contrastLeft': spec.QC.NOT_SET, 'contrastRight': spec.QC.NOT_SET,
424 'feedbackType': spec.QC.NOT_SET, 'probabilityLeft': spec.QC.NOT_SET,
425 'feedback_times': outcomes.get('_task_errorCue_delays', spec.QC.NOT_SET),
426 'firstMovement_times': spec.QC.NOT_SET
427 }
428 reward_checks = ('_task_reward_volumes', '_task_reward_volume_set') 1ujcb
429 trials_table_outcomes['rewardVolume']: TaskQC.overall_outcome( 1ujcb
430 (outcomes.get(x, spec.QC.NOT_SET) for x in reward_checks)
431 )
432 dataset_outcomes = { 1ujcb
433 '_ibl_trials.stimOff_times': outcomes.get('_task_stimOff_delays', spec.QC.NOT_SET),
434 '_ibl_trials.table': trials_table_outcomes,
435 }
436 return dataset_outcomes 1ujcb
439class HabituationQC(TaskQC):
440 """Task QC for habituation choice world."""
442 def compute(self, download_data=None, **kwargs):
443 """Compute and store the QC metrics.
445 Runs the QC on the session and stores a map of the metrics for each datapoint for each
446 test, and a map of which datapoints passed for each test.
447 """
448 if self.extractor is None: 1ha
449 # If download_data is None, decide based on whether eid or session path was provided
450 ensure_data = self.download_data if download_data is None else download_data
451 self.load_data(download_data=ensure_data, **kwargs)
452 self.log.info(f'Session {self.session_path}: Running QC on habituation data...') 1ha
454 # Initialize checks
455 prefix = '_task_' 1ha
456 data = self.extractor.data 1ha
457 audio_output = self.extractor.settings.get('device_sound', {}).get('OUTPUT', 'unknown') 1ha
458 metrics = {} 1ha
459 passed = {} 1ha
461 # Modify criteria based on version
462 ver = self.extractor.settings.get('IBLRIG_VERSION', '') or '0.0.0' 1ha
463 is_v8 = version.parse(ver) >= version.parse('8.0.0') 1ha
464 self.criteria['_task_iti_delays'] = {'PASS': 0.99, 'WARNING': 0} if is_v8 else {'NOT_SET': 0} 1ha
466 # Check all reward volumes == 3.0ul
467 check = prefix + 'reward_volumes' 1ha
468 metrics[check] = data['rewardVolume'] 1ha
469 passed[check] = metrics[check] == 3.0 1ha
471 # Check session durations are increasing in steps >= 12 minutes
472 check = prefix + 'habituation_time' 1ha
473 if not self.one or not self.session_path: 1ha
474 self.log.warning('unable to determine session trials without ONE')
475 metrics[check] = passed[check] = None
476 else:
477 subject, session_date = self.session_path.parts[-3:-1] 1ha
478 # compute from the date specified
479 date_minus_week = ( 1ha
480 datetime.strptime(session_date, '%Y-%m-%d') - timedelta(days=7)
481 ).strftime('%Y-%m-%d')
482 sessions = self.one.alyx.rest('sessions', 'list', subject=subject, 1ha
483 date_range=[date_minus_week, session_date],
484 task_protocol='habituation')
485 # Remove the current session if already registered
486 if sessions and sessions[0]['start_time'].startswith(session_date): 1ha
487 sessions = sessions[1:]
488 metric = ([0, data['intervals'][-1, 1] - data['intervals'][0, 0]] + 1ha
489 [(datetime.fromisoformat(x['end_time']) -
490 datetime.fromisoformat(x['start_time'])).total_seconds() / 60
491 for x in [self.one.alyx.get(s['url']) for s in sessions]])
493 # The duration from raw trial data
494 # duration = map(float, self.extractor.raw_data[-1]['elapsed_time'].split(':'))
495 # duration = timedelta(**dict(zip(('hours', 'minutes', 'seconds'),
496 # duration))).total_seconds() / 60
497 metrics[check] = np.array(metric) 1ha
498 passed[check] = np.diff(metric) >= 12 1ha
500 # Check event orders: trial_start < stim on < stim center < feedback < stim off
501 check = prefix + 'trial_event_sequence' 1ha
502 nans = ( 1ha
503 np.isnan(data['intervals'][:, 0]) | # noqa
504 np.isnan(data['stimOn_times']) | # noqa
505 np.isnan(data['stimCenter_times']) |
506 np.isnan(data['valveOpen_times']) | # noqa
507 np.isnan(data['stimOff_times'])
508 )
509 a = np.less(data['intervals'][:, 0], data['stimOn_times'], where=~nans) 1ha
510 b = np.less(data['stimOn_times'], data['stimCenter_times'], where=~nans) 1ha
511 c = np.less(data['stimCenter_times'], data['valveOpen_times'], where=~nans) 1ha
512 d = np.less(data['valveOpen_times'], data['stimOff_times'], where=~nans) 1ha
514 metrics[check] = a & b & c & d & ~nans 1ha
515 passed[check] = metrics[check].astype(float) 1ha
517 # Check that the time difference between the visual stimulus center-command being
518 # triggered and the stimulus effectively appearing in the center is smaller than 150 ms.
519 check = prefix + 'stimCenter_delays' 1ha
520 metric = np.nan_to_num(data['stimCenter_times'] - data['stimCenterTrigger_times'], 1ha
521 nan=np.inf)
522 passed[check] = (metric <= 0.15) & (metric > 0) 1ha
523 metrics[check] = metric 1ha
525 # Phase check
526 check = prefix + 'phase' 1ha
527 metric = data['phase'] 1ha
528 passed[check] = (metric <= 2 * np.pi) & (metric >= 0) 1ha
529 metrics[check] = metric 1ha
531 # This is not very useful as a check because there are so few trials
532 check = prefix + 'phase_distribution' 1ha
533 metric, _ = np.histogram(data['phase']) 1ha
534 _, p = chisquare(metric) 1ha
535 passed[check] = p < 0.05 if len(data['phase']) >= 400 else None # skip if too few trials 1ha
536 metrics[check] = metric 1ha
538 # Check that the period of gray screen between stim off and the start of the next trial is
539 # 1s +/- 10%.
540 check = prefix + 'iti_delays' 1ha
541 iti = (np.roll(data['stimOn_times'], -1) - data['stimOff_times'])[:-1] 1ha
542 metric = np.r_[np.nan_to_num(iti, nan=np.inf), np.nan] - 1. 1ha
543 passed[check] = np.abs(metric) <= 0.1 1ha
544 passed[check][-1] = np.NaN 1ha
545 metrics[check] = metric 1ha
547 # Checks common to training QC
548 checks = [check_goCue_delays, check_stimOn_goCue_delays, 1ha
549 check_stimOn_delays, check_stimOff_delays]
550 for fcn in checks: 1ha
551 check = prefix + fcn.__name__[6:] 1ha
552 metrics[check], passed[check] = fcn(data, audio_output=audio_output) 1ha
554 self.metrics, self.passed = (metrics, passed) 1ha
557# SINGLE METRICS
558# ---------------------------------------------------------------------------- #
560# === Delays between events checks ===
562def check_stimOn_goCue_delays(data, audio_output='harp', **_):
563 """
564 Check the go cue tone occurs less than 10ms before stimulus on.
566 Checks that the time difference between the onset of the visual stimulus
567 and the onset of the go cue tone is positive and less than 10ms.
569 Metric:
570 M = stimOn_times - goCue_times
572 Criteria:
573 0 < M < 0.010 s
575 Units:
576 seconds [s]
578 :param data: dict of trial data with keys ('goCue_times', 'stimOn_times', 'intervals')
579 :param audio_output: audio output device name.
581 Notes
582 -----
583 - For non-harp sound card the permissible delay is 0.053s. This was chosen by taking the 99.5th
584 percentile of delays over 500 training sessions using the Xonar soundcard.
585 """
586 # Calculate the difference between stimOn and goCue times.
587 # If either are NaN, the result will be Inf to ensure that it crosses the failure threshold.
588 threshold = 0.01 if audio_output.lower() == 'harp' else 0.053 1hBcadgfeb
589 metric = np.nan_to_num(data['goCue_times'] - data['stimOn_times'], nan=np.inf) 1hBcadgfeb
590 passed = (metric < threshold) & (metric > 0) 1hBcadgfeb
591 assert data['intervals'].shape[0] == len(metric) == len(passed) 1hBcadgfeb
592 return metric, passed 1hBcadgfeb
595def check_response_feedback_delays(data, audio_output='harp', **_):
596 """
597 Check the feedback delivered within 10ms of the response threshold.
599 Checks that the time difference between the response and the feedback onset
600 (error sound or valve) is positive and less than 10ms.
602 Metric:
603 M = feedback_time - response_time
605 Criterion:
606 0 < M < 0.010 s
608 Units:
609 seconds [s]
611 :param data: dict of trial data with keys ('feedback_times', 'response_times', 'intervals')
612 :param audio_output: audio output device name.
614 Notes
615 -----
616 - For non-harp sound card the permissible delay is 0.053s. This was chosen by taking the 99.5th
617 percentile of delays over 500 training sessions using the Xonar soundcard.
618 """
619 threshold = 0.01 if audio_output.lower() == 'harp' else 0.053 1Ccadgfeb
620 metric = np.nan_to_num(data['feedback_times'] - data['response_times'], nan=np.inf) 1Ccadgfeb
621 passed = (metric < threshold) & (metric > 0) 1Ccadgfeb
622 assert data['intervals'].shape[0] == len(metric) == len(passed) 1Ccadgfeb
623 return metric, passed 1Ccadgfeb
626def check_response_stimFreeze_delays(data, **_):
627 """
628 Check the stimulus freezes within 100ms of the expected time.
630 Checks that the time difference between the visual stimulus freezing and the
631 response is positive and less than 100ms.
633 Metric:
634 M = (stimFreeze_times - response_times)
636 Criterion:
637 0 < M < 0.100 s
639 Units:
640 seconds [s]
642 :param data: dict of trial data with keys ('stimFreeze_times', 'response_times', 'intervals',
643 'choice')
644 """
645 # Calculate the difference between stimOn and goCue times.
646 # If either are NaN, the result will be Inf to ensure that it crosses the failure threshold.
647 metric = np.nan_to_num(data['stimFreeze_times'] - data['response_times'], nan=np.inf) 1Dcadgfeb
648 # Test for valid values
649 passed = ((metric < 0.1) & (metric > 0)).astype(float) 1Dcadgfeb
650 # Finally remove no_go trials (stimFreeze triggered differently in no_go trials)
651 # These values are ignored in calculation of proportion passed
652 passed[data['choice'] == 0] = np.nan 1Dcadgfeb
653 assert data['intervals'].shape[0] == len(metric) == len(passed) 1Dcadgfeb
654 return metric, passed 1Dcadgfeb
657def check_stimOff_itiIn_delays(data, **_):
658 """Check that the start of the trial interval is within 10ms of the visual stimulus turning off.
660 Metric:
661 M = itiIn_times - stimOff_times
663 Criterion:
664 0 < M < 0.010 s
666 Units:
667 seconds [s]
669 :param data: dict of trial data with keys ('stimOff_times', 'itiIn_times', 'intervals',
670 'choice')
671 """
672 # If either are NaN, the result will be Inf to ensure that it crosses the failure threshold.
673 metric = np.nan_to_num(data['itiIn_times'] - data['stimOff_times'], nan=np.inf) 1Ecadgfeb
674 passed = ((metric < 0.01) & (metric >= 0)).astype(float) 1Ecadgfeb
675 # Remove no_go trials (stimOff triggered differently in no_go trials)
676 # NaN values are ignored in calculation of proportion passed
677 metric[data['choice'] == 0] = passed[data['choice'] == 0] = np.nan 1Ecadgfeb
678 assert data['intervals'].shape[0] == len(metric) == len(passed) 1Ecadgfeb
679 return metric, passed 1Ecadgfeb
682def check_iti_delays(data, subtract_pauses=False, **_):
683 """
684 Check the open-loop grey screen period is approximately 1 second.
686 Check that the period of grey screen between stim off and the start of the next trial is
687 1s +/- 10%. If the trial was paused during this time, the check will account for that
689 Metric:
690 M = stimOff (n) - trialStart (n+1) - 1.
692 Criterion:
693 |M| < 0.1
695 Units:
696 seconds [s]
698 Parameters
699 ----------
700 data : dict
701 Trial data with keys ('stimOff_times', 'intervals', 'pause_duration').
702 subtract_pauses: bool
703 If True, account for experimenter-initiated pauses between trials; if False, trials where
704 the experimenter paused the task may fail this check.
706 Returns
707 -------
708 numpy.array
709 An array of metric values to threshold.
710 numpy.array
711 An array of boolean values, 1 per trial, where True means trial passes QC threshold.
712 """
713 # Initialize array the length of completed trials
714 ITI = 1. 1vcadgfeb
715 metric = np.full(data['intervals'].shape[0], np.nan) 1vcadgfeb
716 passed = metric.copy() 1vcadgfeb
717 pauses = (data['pause_duration'] if subtract_pauses else np.zeros_like(metric))[:-1] 1vcadgfeb
718 # Get the difference between stim off and the start of the next trial
719 # Missing data are set to Inf, except for the last trial which is a NaN
720 metric[:-1] = \ 1vcadgfeb
721 np.nan_to_num(data['intervals'][1:, 0] - data['stimOff_times'][:-1] - ITI - pauses, nan=np.inf)
722 passed[:-1] = np.abs(metric[:-1]) < (ITI / 10) # Last trial is not counted 1vcadgfeb
723 assert data['intervals'].shape[0] == len(metric) == len(passed) 1vcadgfeb
724 return metric, passed 1vcadgfeb
727def check_positive_feedback_stimOff_delays(data, **_):
728 """
729 Check stimulus offset occurs approximately 1 second after reward delivered.
731 Check that the time difference between the valve onset and the visual stimulus turning off
732 is 1 ± 0.150 seconds.
734 Metric:
735 M = stimOff_times - feedback_times - 1s
737 Criterion:
738 |M| < 0.150 s
740 Units:
741 seconds [s]
743 :param data: dict of trial data with keys ('stimOff_times', 'feedback_times', 'intervals',
744 'correct')
745 """
746 # If either are NaN, the result will be Inf to ensure that it crosses the failure threshold.
747 metric = np.nan_to_num(data['stimOff_times'] - data['feedback_times'] - 1, nan=np.inf) 1Fcadgfeb
748 passed = (np.abs(metric) < 0.15).astype(float) 1Fcadgfeb
749 # NaN values are ignored in calculation of proportion passed; ignore incorrect trials here
750 metric[~data['correct']] = passed[~data['correct']] = np.nan 1Fcadgfeb
751 assert data['intervals'].shape[0] == len(metric) == len(passed) 1Fcadgfeb
752 return metric, passed 1Fcadgfeb
755def check_negative_feedback_stimOff_delays(data, **_):
756 """
757 Check the stimulus offset occurs approximately 2 seconds after negative feedback delivery.
759 Check that the time difference between the error sound and the visual stimulus
760 turning off is 2 ± 0.150 seconds.
762 Metric:
763 M = stimOff_times - errorCue_times - 2s
765 Criterion:
766 |M| < 0.150 s
768 Units:
769 seconds [s]
771 :param data: dict of trial data with keys ('stimOff_times', 'errorCue_times', 'intervals')
772 """
773 metric = np.nan_to_num(data['stimOff_times'] - data['errorCue_times'] - 2, nan=np.inf) 1Gcadgfeb
774 # Apply criteria
775 passed = (np.abs(metric) < 0.15).astype(float) 1Gcadgfeb
776 # Remove none negative feedback trials
777 metric[data['correct']] = passed[data['correct']] = np.nan 1Gcadgfeb
778 assert data['intervals'].shape[0] == len(metric) == len(passed) 1Gcadgfeb
779 return metric, passed 1Gcadgfeb
782# === Wheel movement during trial checks ===
784def check_wheel_move_before_feedback(data, **_):
785 """Check that the wheel does move within 100ms of the feedback onset (error sound or valve).
787 Metric:
788 M = (w_t - 0.05) - (w_t + 0.05), where t = feedback_times
790 Criterion:
791 M != 0
793 Units:
794 radians
796 :param data: dict of trial data with keys ('wheel_timestamps', 'wheel_position', 'choice',
797 'intervals', 'feedback_times')
798 """
799 # Get tuple of wheel times and positions within 100ms of feedback
800 traces = traces_by_trial( 1pcadgfeb
801 data['wheel_timestamps'],
802 data['wheel_position'],
803 start=data['feedback_times'] - 0.05,
804 end=data['feedback_times'] + 0.05,
805 )
806 metric = np.zeros_like(data['feedback_times']) 1pcadgfeb
807 # For each trial find the displacement
808 for i, trial in enumerate(traces): 1pcadgfeb
809 pos = trial[1] 1pcadgfeb
810 if pos.size > 1: 1pcadgfeb
811 metric[i] = pos[-1] - pos[0] 1pcadgfeb
813 # except no-go trials
814 metric[data['choice'] == 0] = np.nan # NaN = trial ignored for this check 1pcadgfeb
815 nans = np.isnan(metric) 1pcadgfeb
816 passed = np.zeros_like(metric) * np.nan 1pcadgfeb
818 passed[~nans] = (metric[~nans] != 0).astype(float) 1pcadgfeb
819 assert data['intervals'].shape[0] == len(metric) == len(passed) 1pcadgfeb
820 return metric, passed 1pcadgfeb
823def _wheel_move_during_closed_loop(re_ts, re_pos, data, wheel_gain=None, tol=1, **_):
824 """
825 Check the wheel moves the correct amount to reach threshold.
827 Check that the wheel moves by approximately 35 degrees during the closed-loop period
828 on trials where a feedback (error sound or valve) is delivered.
830 Metric:
831 M = abs(w_resp - w_t0) - threshold_displacement, where w_resp = position at response
832 time, w_t0 = position at go cue time, threshold_displacement = displacement required to
833 move 35 visual degrees
835 Criterion:
836 displacement < tol visual degree
838 Units:
839 degrees angle of wheel turn
841 :param re_ts: extracted wheel timestamps in seconds
842 :param re_pos: extracted wheel positions in radians
843 :param data: a dict with the keys (goCueTrigger_times, response_times, feedback_times,
844 position, choice, intervals)
845 :param wheel_gain: the 'STIM_GAIN' task setting
846 :param tol: the criterion in visual degrees
847 """
848 if wheel_gain is None: 1kcadgfeb
849 _log.warning('No wheel_gain input in function call, returning None')
850 return None, None
852 # Get tuple of wheel times and positions over each trial's closed-loop period
853 traces = traces_by_trial(re_ts, re_pos, 1kcadgfeb
854 start=data['goCueTrigger_times'],
855 end=data['response_times'])
857 metric = np.zeros_like(data['feedback_times']) 1kcadgfeb
858 # For each trial find the absolute displacement
859 for i, trial in enumerate(traces): 1kcadgfeb
860 t, pos = trial 1kcadgfeb
861 if pos.size != 0: 1kcadgfeb
862 # Find the position of the preceding sample and subtract it
863 idx = np.abs(re_ts - t[0]).argmin() - 1 1kcadgfeb
864 origin = re_pos[idx] 1kcadgfeb
865 metric[i] = np.abs(pos - origin).max() 1kcadgfeb
867 # Load wheel_gain and thresholds for each trial
868 wheel_gain = np.array([wheel_gain] * len(data['position'])) 1kcadgfeb
869 thresh = data['position'] 1kcadgfeb
870 # abs displacement, s, in mm required to move 35 visual degrees
871 s_mm = np.abs(thresh / wheel_gain) # don't care about direction 1kcadgfeb
872 criterion = cm_to_rad(s_mm * 1e-1) # convert abs displacement to radians (wheel pos is in rad) 1kcadgfeb
873 metric = metric - criterion # difference should be close to 0 1kcadgfeb
874 rad_per_deg = cm_to_rad(1 / wheel_gain * 1e-1) 1kcadgfeb
875 passed = (np.abs(metric) < rad_per_deg * tol).astype(float) # less than 1 visual degree off 1kcadgfeb
876 metric[data['choice'] == 0] = passed[data['choice'] == 0] = np.nan # except no-go trials 1kcadgfeb
877 assert data['intervals'].shape[0] == len(metric) == len(passed) 1kcadgfeb
878 return metric, passed 1kcadgfeb
881def check_wheel_move_during_closed_loop(data, wheel_gain=None, **_):
882 """
883 Check the wheel moves the correct amount to reach threshold.
885 Check that the wheel moves by approximately 35 degrees during the closed-loop period
886 on trials where a feedback (error sound or valve) is delivered.
888 Metric:
889 M = abs(w_resp - w_t0) - threshold_displacement, where w_resp = position at response
890 time, w_t0 = position at go cue time, threshold_displacement = displacement required to
891 move 35 visual degrees
893 Criterion:
894 displacement < 3 visual degrees
896 Units:
897 degrees angle of wheel turn
899 :param data: dict of trial data with keys ('wheel_timestamps', 'wheel_position', 'choice',
900 'intervals', 'goCueTrigger_times', 'response_times', 'feedback_times', 'position')
901 :param wheel_gain: the 'STIM_GAIN' task setting
902 """
903 # Get the Bpod extracted wheel data
904 timestamps = data['wheel_timestamps'] 1kcadgfeb
905 position = data['wheel_position'] 1kcadgfeb
907 return _wheel_move_during_closed_loop(timestamps, position, data, wheel_gain, tol=3) 1kcadgfeb
910def check_wheel_move_during_closed_loop_bpod(data, wheel_gain=None, **_):
911 """
912 Check the wheel moves the correct amount to reach threshold.
914 Check that the wheel moves by approximately 35 degrees during the closed-loop period
915 on trials where a feedback (error sound or valve) is delivered. This check uses the Bpod
916 wheel data (measured at a lower resolution) with a stricter tolerance (1 visual degree).
918 Metric:
919 M = abs(w_resp - w_t0) - threshold_displacement, where w_resp = position at response
920 time, w_t0 = position at go cue time, threshold_displacement = displacement required to
921 move 35 visual degrees.
923 Criterion:
924 displacement < 1 visual degree
926 Units:
927 degrees angle of wheel turn
929 :param data: dict of trial data with keys ('wheel_timestamps(_bpod)', 'wheel_position(_bpod)',
930 'choice', 'intervals', 'goCueTrigger_times', 'response_times', 'feedback_times', 'position')
931 :param wheel_gain: the 'STIM_GAIN' task setting
932 """
933 # Get the Bpod extracted wheel data
934 timestamps = data.get('wheel_timestamps_bpod', data['wheel_timestamps']) 1cadgfeb
935 position = data.get('wheel_position_bpod', data['wheel_position']) 1cadgfeb
937 return _wheel_move_during_closed_loop(timestamps, position, data, wheel_gain, tol=1) 1cadgfeb
940def check_wheel_freeze_during_quiescence(data, **_):
941 """
942 Check the wheel is indeed still during the quiescent period.
944 Check that the wheel does not move more than 2 degrees in each direction during the
945 quiescence interval before the stimulus appears.
947 Metric:
948 M = |max(W) - min(W)| where W is wheel pos over quiescence interval;
949 interval = [stimOnTrigger_times - quiescent_duration, stimOnTrigger_times]
951 Criterion:
952 M < 2 degrees
954 Units:
955 degrees angle of wheel turn
957 :param data: dict of trial data with keys ('wheel_timestamps', 'wheel_position', 'quiescence',
958 'intervals', 'stimOnTrigger_times')
959 """
960 assert np.all(np.diff(data['wheel_timestamps']) >= 0) 1lcadgfeb
961 assert data['quiescence'].size == data['stimOnTrigger_times'].size 1lcadgfeb
962 # Get tuple of wheel times and positions over each trial's quiescence period
963 qevt_start_times = data['stimOnTrigger_times'] - data['quiescence'] 1lcadgfeb
964 traces = traces_by_trial( 1lcadgfeb
965 data['wheel_timestamps'],
966 data['wheel_position'],
967 start=qevt_start_times,
968 end=data['stimOnTrigger_times']
969 )
971 metric = np.zeros((len(data['quiescence']), 2)) # (n_trials, n_directions) 1lcadgfeb
972 for i, trial in enumerate(traces): 1lcadgfeb
973 t, pos = trial 1lcadgfeb
974 # Get the last position before the period began
975 if pos.size > 0: 1lcadgfeb
976 # Find the position of the preceding sample and subtract it
977 idx = np.abs(data['wheel_timestamps'] - t[0]).argmin() - 1 1lcadb
978 origin = data['wheel_position'][idx if idx != -1 else 0] 1lcadb
979 # Find the absolute min and max relative to the last sample
980 metric[i, :] = np.abs([np.min(pos - origin), np.max(pos - origin)]) 1lcadb
981 # Reduce to the largest displacement found in any direction
982 metric = np.max(metric, axis=1) 1lcadgfeb
983 metric = 180 * metric / np.pi # convert to degrees from radians 1lcadgfeb
984 criterion = 2 # Position shouldn't change more than 2 in either direction 1lcadgfeb
985 passed = metric < criterion 1lcadgfeb
986 assert data['intervals'].shape[0] == len(metric) == len(passed) 1lcadgfeb
987 return metric, passed 1lcadgfeb
990def check_detected_wheel_moves(data, min_qt=0, **_):
991 """Check that the detected first movement times are reasonable.
993 Metric:
994 M = firstMovement times
996 Criterion:
997 (goCue trigger time - min quiescent period) < M < response time
999 Units:
1000 Seconds [s]
1002 :param data: dict of trial data with keys ('firstMovement_times', 'goCueTrigger_times',
1003 'response_times', 'choice', 'intervals')
1004 :param min_qt: the minimum possible quiescent period (the QUIESCENT_PERIOD task parameter)
1005 """
1006 # Depending on task version this may be a single value or an array of quiescent periods
1007 min_qt = np.array(min_qt) 1tcadgfeb
1008 if min_qt.size > data['intervals'].shape[0]: 1tcadgfeb
1009 min_qt = min_qt[:data['intervals'].shape[0]] 1d
1011 metric = data['firstMovement_times'] 1tcadgfeb
1012 qevt_start = data['goCueTrigger_times'] - np.array(min_qt) 1tcadgfeb
1013 response = data['response_times'] 1tcadgfeb
1014 # First movement time for each trial should be after the quiescent period and before feedback
1015 passed = np.array([a < m < b for m, a, b in zip(metric, qevt_start, response)], dtype=float) 1tcadgfeb
1016 nogo = data['choice'] == 0 1tcadgfeb
1017 passed[nogo] = np.nan # No go trial may have no movement times and that's fine 1tcadgfeb
1018 return metric, passed 1tcadgfeb
1021# === Sequence of events checks ===
1023def check_error_trial_event_sequence(data, **_):
1024 """
1025 Check trial events occur in correct order for negative feedback trials.
1027 Check that on incorrect / miss trials, there are exactly:
1028 2 audio events (go cue sound and error sound) and 2 Bpod events (trial start, ITI), occurring
1029 in the correct order
1031 Metric:
1032 M = Bpod (trial start) > audio (go cue) > audio (error) > Bpod (ITI) > Bpod (trial end)
1034 Criterion:
1035 M == True
1037 Units:
1038 -none-
1040 :param data: dict of trial data with keys ('errorCue_times', 'goCue_times', 'intervals',
1041 'itiIn_times', 'correct')
1042 """
1043 # An array the length of N trials where True means at least one event time was NaN (bad)
1044 nans = ( 1rcadgfeb
1045 np.isnan(data['intervals'][:, 0]) |
1046 np.isnan(data['goCue_times']) | # noqa
1047 np.isnan(data['errorCue_times']) | # noqa
1048 np.isnan(data['itiIn_times']) | # noqa
1049 np.isnan(data['intervals'][:, 1])
1050 )
1052 # For each trial check that the events happened in the correct order (ignore NaN values)
1053 a = np.less(data['intervals'][:, 0], data['goCue_times'], where=~nans) # Start time < go cue 1rcadgfeb
1054 b = np.less(data['goCue_times'], data['errorCue_times'], where=~nans) # Go cue < error cue 1rcadgfeb
1055 c = np.less(data['errorCue_times'], data['itiIn_times'], where=~nans) # Error cue < ITI start 1rcadgfeb
1056 d = np.less(data['itiIn_times'], data['intervals'][:, 1], where=~nans) # ITI start < end time 1rcadgfeb
1058 # For each trial check all events were in order AND all event times were not NaN
1059 metric = a & b & c & d & ~nans 1rcadgfeb
1061 passed = metric.astype(float) 1rcadgfeb
1062 passed[data['correct']] = np.nan # Look only at incorrect trials 1rcadgfeb
1063 assert data['intervals'].shape[0] == len(metric) == len(passed) 1rcadgfeb
1064 return metric, passed 1rcadgfeb
1067def check_correct_trial_event_sequence(data, **_):
1068 """
1069 Check trial events occur in correct order for positive feedback trials.
1071 Check that on correct trials, there are exactly:
1072 1 audio events and 3 Bpod events (valve open, trial start, ITI), occurring in the correct order
1074 Metric:
1075 M = Bpod (trial start) > audio (go cue) > Bpod (valve) > Bpod (ITI) > Bpod (trial end)
1077 Criterion:
1078 M == True
1080 Units:
1081 -none-
1083 :param data: dict of trial data with keys ('valveOpen_times', 'goCue_times', 'intervals',
1084 'itiIn_times', 'correct')
1085 """
1086 # An array the length of N trials where True means at least one event time was NaN (bad)
1087 nans = ( 1scadgfeb
1088 np.isnan(data['intervals'][:, 0]) |
1089 np.isnan(data['goCue_times']) | # noqa
1090 np.isnan(data['valveOpen_times']) |
1091 np.isnan(data['itiIn_times']) | # noqa
1092 np.isnan(data['intervals'][:, 1])
1093 )
1095 # For each trial check that the events happened in the correct order (ignore NaN values)
1096 a = np.less(data['intervals'][:, 0], data['goCue_times'], where=~nans) # Start time < go cue 1scadgfeb
1097 b = np.less(data['goCue_times'], data['valveOpen_times'], where=~nans) # Go cue < feedback 1scadgfeb
1098 c = np.less(data['valveOpen_times'], data['itiIn_times'], where=~nans) # Feedback < ITI start 1scadgfeb
1099 d = np.less(data['itiIn_times'], data['intervals'][:, 1], where=~nans) # ITI start < end time 1scadgfeb
1101 # For each trial True means all events were in order AND all event times were not NaN
1102 metric = a & b & c & d & ~nans 1scadgfeb
1104 passed = metric.astype(float) 1scadgfeb
1105 passed[~data['correct']] = np.nan # Look only at correct trials 1scadgfeb
1106 assert data['intervals'].shape[0] == len(metric) == len(passed) 1scadgfeb
1107 return metric, passed 1scadgfeb
1110def check_n_trial_events(data, **_):
1111 """Check that the number events per trial is correct.
1113 Within every trial interval there should be one of each trial event, except for
1114 goCueTrigger_times which should only be defined for incorrect trials
1116 Metric:
1117 M = all(start < event < end) for all event times except errorCueTrigger_times where
1118 start < error_trigger < end if not correct trial, else error_trigger == NaN
1120 Criterion:
1121 M == True
1123 Units:
1124 -none-, boolean
1126 :param data: dict of trial data with keys ('intervals', 'stimOnTrigger_times',
1127 'stimOffTrigger_times', 'stimOn_times', 'stimOff_times',
1128 'stimFreezeTrigger_times', 'errorCueTrigger_times', 'itiIn_times',
1129 'goCueTrigger_times', 'goCue_times', 'response_times', 'feedback_times')
1130 """
1131 intervals = data['intervals'] 1qcadgfeb
1132 correct = data['correct'] 1qcadgfeb
1133 err_trig = data['errorCueTrigger_times'] 1qcadgfeb
1135 # Exclude these fields; valve and errorCue times are the same as feedback_times and we must
1136 # test errorCueTrigger_times separately
1137 # stimFreeze_times fails often due to TTL flicker
1138 exclude = ['camera_timestamps', 'errorCueTrigger_times', 'errorCue_times', 1qcadgfeb
1139 'firstMovement_times', 'peakVelocity_times', 'valveOpen_times',
1140 'wheel_moves_peak_amplitude', 'wheel_moves_intervals', 'wheel_timestamps',
1141 'wheel_intervals', 'stimFreeze_times']
1142 events = [k for k in data.keys() if k.endswith('_times') and k not in exclude] 1qcadgfeb
1143 metric = np.zeros(data['intervals'].shape[0], dtype=bool) 1qcadgfeb
1145 # For each trial interval check that one of each trial event occurred. For incorrect trials,
1146 # check the error cue trigger occurred within the interval, otherwise check it is nan.
1147 for i, (start, end) in enumerate(intervals): 1qcadgfeb
1148 metric[i] = (all([start < data[k][i] < end for k in events]) and 1qcadgfeb
1149 (np.isnan(err_trig[i]) if correct[i] else start < err_trig[i] < end))
1150 passed = metric.astype(bool) 1qcadgfeb
1151 assert intervals.shape[0] == len(metric) == len(passed) 1qcadgfeb
1152 return metric, passed 1qcadgfeb
1155def check_trial_length(data, **_):
1156 """
1157 Check open-loop duration positive and <= 1 minute.
1159 Check that the time difference between the onset of the go cue sound
1160 and the feedback (error sound or valve) is positive and smaller than 60.1 s.
1162 Metric:
1163 M = feedback_times - goCue_times
1165 Criteria:
1166 0 < M < 60.1 s
1168 Units:
1169 seconds [s]
1171 :param data: dict of trial data with keys ('feedback_times', 'goCue_times', 'intervals')
1172 """
1173 # NaN values are usually ignored so replace them with Inf so they fail the threshold
1174 metric = np.nan_to_num(data['feedback_times'] - data['goCue_times'], nan=np.inf) 1Icadgfeb
1175 passed = (metric < 60.1) & (metric > 0) 1Icadgfeb
1176 assert data['intervals'].shape[0] == len(metric) == len(passed) 1Icadgfeb
1177 return metric, passed 1Icadgfeb
1180# === Trigger-response delay checks ===
1182def check_goCue_delays(data, audio_output='harp', **_):
1183 """
1184 Check the go cue tone occurs within 1ms of the intended time.
1186 Check that the time difference between the go cue sound being triggered and
1187 effectively played is smaller than 1ms.
1189 Metric:
1190 M = goCue_times - goCueTrigger_times
1192 Criterion:
1193 0 < M <= 0.0015 s
1195 Units:
1196 seconds [s]
1198 :param data: dict of trial data with keys ('goCue_times', 'goCueTrigger_times', 'intervals').
1199 :param audio_output: audio output device name.
1201 Notes
1202 -----
1203 - For non-harp sound card the permissible delay is 0.053s. This was chosen by taking the 99.5th
1204 percentile of delays over 500 training sessions using the Xonar soundcard.
1205 """
1206 threshold = 0.0015 if audio_output.lower() == 'harp' else 0.053 1hHcadgfeb
1207 metric = np.nan_to_num(data['goCue_times'] - data['goCueTrigger_times'], nan=np.inf) 1hHcadgfeb
1208 passed = (metric <= threshold) & (metric > 0) 1hHcadgfeb
1209 assert data['intervals'].shape[0] == len(metric) == len(passed) 1hHcadgfeb
1210 return metric, passed 1hHcadgfeb
1213def check_errorCue_delays(data, audio_output='harp', **_):
1214 """
1215 Check the error tone occurs within 1.5ms of the intended time.
1217 Check that the time difference between the error sound being triggered and
1218 effectively played is smaller than 1ms.
1220 Metric:
1221 M = errorCue_times - errorCueTrigger_times
1223 Criterion:
1224 0 < M <= 0.0015 s
1226 Units:
1227 seconds [s]
1229 :param data: dict of trial data with keys ('errorCue_times', 'errorCueTrigger_times',
1230 'intervals', 'correct')
1231 :param audio_output: audio output device name.
1233 Notes
1234 -----
1235 - For non-harp sound card the permissible delay is 0.062s. This was chosen by taking the 99.5th
1236 percentile of delays over 500 training sessions using the Xonar soundcard.
1237 """
1238 threshold = 0.0015 if audio_output.lower() == 'harp' else 0.062 1Acadgfeb
1239 metric = np.nan_to_num(data['errorCue_times'] - data['errorCueTrigger_times'], nan=np.inf) 1Acadgfeb
1240 passed = ((metric <= threshold) & (metric > 0)).astype(float) 1Acadgfeb
1241 passed[data['correct']] = metric[data['correct']] = np.nan 1Acadgfeb
1242 assert data['intervals'].shape[0] == len(metric) == len(passed) 1Acadgfeb
1243 return metric, passed 1Acadgfeb
1246def check_stimOn_delays(data, **_):
1247 """
1248 Check the visual stimulus onset occurs within 150ms of the intended time.
1250 Check that the time difference between the visual stimulus onset-command being triggered
1251 and the stimulus effectively appearing on the screen is smaller than 150 ms.
1253 Metric:
1254 M = stimOn_times - stimOnTrigger_times
1256 Criterion:
1257 0 < M < 0.15 s
1259 Units:
1260 seconds [s]
1262 :param data: dict of trial data with keys ('stimOn_times', 'stimOnTrigger_times',
1263 'intervals')
1264 """
1265 metric = np.nan_to_num(data['stimOn_times'] - data['stimOnTrigger_times'], nan=np.inf) 1hJcadgfeb
1266 passed = (metric <= 0.15) & (metric > 0) 1hJcadgfeb
1267 assert data['intervals'].shape[0] == len(metric) == len(passed) 1hJcadgfeb
1268 return metric, passed 1hJcadgfeb
1271def check_stimOff_delays(data, **_):
1272 """
1273 Check stimulus offset occurs within 150ms of the intended time.
1275 Check that the time difference between the visual stimulus offset-command
1276 being triggered and the visual stimulus effectively turning off on the screen
1277 is smaller than 150 ms.
1279 Metric:
1280 M = stimOff_times - stimOffTrigger_times
1282 Criterion:
1283 0 < M < 0.15 s
1285 Units:
1286 seconds [s]
1288 :param data: dict of trial data with keys ('stimOff_times', 'stimOffTrigger_times',
1289 'intervals')
1290 """
1291 metric = np.nan_to_num(data['stimOff_times'] - data['stimOffTrigger_times'], nan=np.inf) 1hKcadgfeb
1292 passed = (metric <= 0.15) & (metric > 0) 1hKcadgfeb
1293 assert data['intervals'].shape[0] == len(metric) == len(passed) 1hKcadgfeb
1294 return metric, passed 1hKcadgfeb
1297def check_stimFreeze_delays(data, **_):
1298 """Check the stimulus freezes within 150ms of the intended time.
1300 Check that the time difference between the visual stimulus freeze-command
1301 being triggered and the visual stimulus effectively freezing on the screen
1302 is smaller than 150 ms.
1304 Metric:
1305 M = stimFreeze_times - stimFreezeTrigger_times
1307 Criterion:
1308 0 < M < 0.15 s
1310 Units:
1311 seconds [s]
1313 :param data: dict of trial data with keys ('stimFreeze_times', 'stimFreezeTrigger_times',
1314 'intervals')
1315 """
1316 metric = np.nan_to_num(data['stimFreeze_times'] - data['stimFreezeTrigger_times'], nan=np.inf) 1Lcadgfeb
1317 passed = (metric <= 0.15) & (metric > 0) 1Lcadgfeb
1318 assert data['intervals'].shape[0] == len(metric) == len(passed) 1Lcadgfeb
1319 return metric, passed 1Lcadgfeb
1322# === Data integrity checks ===
1324def check_reward_volumes(data, **_):
1325 """Check that the reward volume is between 1.5 and 3 uL for correct trials, 0 for incorrect.
1327 Metric:
1328 M = reward volume
1330 Criterion:
1331 1.5 <= M <= 3 if correct else M == 0
1333 Units:
1334 uL
1336 :param data: dict of trial data with keys ('rewardVolume', 'correct', 'intervals')
1337 """
1338 metric = data['rewardVolume'] 1zcadgfeb
1339 correct = data['correct'] 1zcadgfeb
1340 passed = np.zeros_like(metric, dtype=bool) 1zcadgfeb
1341 # Check correct trials within correct range
1342 passed[correct] = (1.5 <= metric[correct]) & (metric[correct] <= 3.) 1zcadgfeb
1343 # Check incorrect trials are 0
1344 passed[~correct] = metric[~correct] == 0 1zcadgfeb
1345 assert data['intervals'].shape[0] == len(metric) == len(passed) 1zcadgfeb
1346 return metric, passed 1zcadgfeb
1349def check_reward_volume_set(data, **_):
1350 """Check that there is only two reward volumes within a session, one of which is 0.
1352 Metric:
1353 M = set(rewardVolume)
1355 Criterion:
1356 (0 < len(M) <= 2) and 0 in M
1358 :param data: dict of trial data with keys ('rewardVolume')
1359 """
1360 metric = data['rewardVolume'] 1Mcadgfeb
1361 passed = 0 < len(set(metric)) <= 2 and 0. in metric 1Mcadgfeb
1362 return metric, passed 1Mcadgfeb
1365def check_wheel_integrity(data, re_encoding='X1', enc_res=None, **_):
1366 """
1367 Check wheel position sampled at the expected resolution.
1369 Check that the difference between wheel position samples is close to the encoder resolution
1370 and that the wheel timestamps strictly increase.
1372 Metric:
1373 M = (absolute difference of the positions < 1.5 * encoder resolution)
1374 + 1 if (difference of timestamps <= 0) else 0
1376 Criterion:
1377 M ~= 0
1379 Units:
1380 arbitrary (radians, sometimes + 1)
1382 :param data: dict of wheel data with keys ('wheel_timestamps', 'wheel_position')
1383 :param re_encoding: the encoding of the wheel data, X1, X2 or X4
1384 :param enc_res: the rotary encoder resolution (default 1024 ticks per revolution)
1386 Notes
1387 -----
1388 - At high velocities some samples are missed due to the scanning frequency of the DAQ.
1389 This checks for more than 1 missing sample in a row (i.e. the difference between samples >= 2)
1391 """
1392 if isinstance(re_encoding, str): 1wcadgfeb
1393 re_encoding = int(re_encoding[-1]) 1wcadgfeb
1394 # The expected difference between samples in the extracted units
1395 resolution = 1 / (enc_res or ephys_fpga.WHEEL_TICKS 1wcadgfeb
1396 ) * np.pi * 2 * ephys_fpga.WHEEL_RADIUS_CM / re_encoding
1397 # We expect the difference of neighbouring positions to be close to the resolution
1398 pos_check = np.abs(np.diff(data['wheel_position'])) 1wcadgfeb
1399 # Timestamps should be strictly increasing
1400 ts_check = np.diff(data['wheel_timestamps']) <= 0. 1wcadgfeb
1401 metric = pos_check + ts_check.astype(float) # all values should be close to zero 1wcadgfeb
1402 passed = metric < 1.5 * resolution 1wcadgfeb
1403 return metric, passed 1wcadgfeb
1406# === Pre-stimulus checks ===
1407def check_stimulus_move_before_goCue(data, photodiode=None, **_):
1408 """
1409 Check there are no stimulus events before the go cue tone.
1411 Check that there are no visual stimulus change(s) between the start of the trial and the
1412 go cue sound onset, except for stim on.
1414 Metric:
1415 M = number of visual stimulus change events between trial start and goCue_times
1417 Criterion:
1418 M == 1
1420 Units:
1421 -none-, integer
1423 Parameters
1424 ----------
1425 data : dict
1426 Trial data with keys ('goCue_times', 'intervals', 'choice').
1427 photodiode : dict
1428 The fronts from Bpod's BNC1 input or FPGA frame2ttl channel.
1430 Returns
1431 -------
1432 numpy.array
1433 An array of metric values to threshold.
1434 numpy.array
1435 An array of boolean values, 1 per trial, where True means trial passes QC threshold.
1437 Notes
1438 -----
1439 - There should be exactly 1 stimulus change before goCue; stimulus onset. Even if the stimulus
1440 contrast is 0, the sync square will still flip at stimulus onset, etc.
1441 - If there are no goCue times (all are NaN), the status should be NOT_SET.
1442 """
1443 if photodiode is None: 1cadgfeb
1444 _log.warning('No photodiode TTL input in function call, returning None')
1445 return None
1446 photodiode_clean = ephys_fpga._clean_frame2ttl(photodiode) 1cadgfeb
1447 s = photodiode_clean['times'] 1cadgfeb
1448 s = s[~np.isnan(s)] # Remove NaNs 1cadgfeb
1449 metric = np.array([]) 1cadgfeb
1450 for i, c in zip(data['intervals'][:, 0], data['goCue_times']): 1cadgfeb
1451 metric = np.append(metric, np.count_nonzero(s[s > i] < c)) 1cadgfeb
1453 passed = (metric == 1).astype(float) 1cadgfeb
1454 passed[np.isnan(data['goCue_times'])] = np.nan 1cadgfeb
1455 assert data['intervals'].shape[0] == len(metric) == len(passed) 1cadgfeb
1456 return metric, passed 1cadgfeb
1459def check_audio_pre_trial(data, audio=None, **_):
1460 """
1461 Check no audio stimuli before the go cue.
1463 Check there are no audio outputs between the start of the trial and the go cue sound onset - 20 ms.
1465 Metric:
1466 M = sum(start_times < audio TTL < (goCue_times - 20ms))
1468 Criterion:
1469 M == 0
1471 Units:
1472 -none-, integer
1474 Parameters
1475 ----------
1476 data : dict
1477 Trial data with keys ('goCue_times', 'intervals').
1478 audio : dict
1479 The fronts from Bpod's BNC2 input FPGA audio sync channel.
1481 Returns
1482 -------
1483 numpy.array
1484 An array of metric values to threshold.
1485 numpy.array
1486 An array of boolean values, 1 per trial, where True means trial passes QC threshold.
1487 """
1488 if audio is None: 1xcadgfeb
1489 _log.warning('No BNC2 input in function call, retuning None')
1490 return None, None
1491 s = audio['times'][~np.isnan(audio['times'])] # Audio TTLs with NaNs removed 1xcadgfeb
1492 metric = np.array([], dtype=np.int8) 1xcadgfeb
1493 for i, c in zip(data['intervals'][:, 0], data['goCue_times']): 1xcadgfeb
1494 metric = np.append(metric, sum(s[s > i] < (c - 0.02))) 1xcadgfeb
1495 passed = metric == 0 1xcadgfeb
1496 assert data['intervals'].shape[0] == len(metric) == len(passed) 1xcadgfeb
1497 return metric, passed 1xcadgfeb