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

1"""Behaviour QC. 

2 

3This module runs a list of quality control metrics on the behaviour data. 

4 

5NB: The QC should be loaded using :meth:`ibllib.pipes.base_tasks.BehaviourTask.run_qc` and not 

6instantiated directly. 

7 

8Examples 

9-------- 

10Running on a rig computer and updating QC fields in Alyx: 

11 

12>>> from ibllib.qc.task_metrics import TaskQC 

13>>> TaskQC('path/to/session').run(update=True) 

14 

15Downloading the required data and inspecting the QC on a different computer: 

16 

17>>> from ibllib.qc.task_metrics import TaskQC 

18>>> qc = TaskQC(eid) 

19>>> outcome, results = qc.run() 

20 

21Inspecting individual test outcomes 

22 

23>>> from ibllib.qc.task_metrics import TaskQC 

24>>> qc = TaskQC(eid) 

25>>> outcome, results, outcomes = qc.compute().compute_session_status() 

26 

27Running bpod QC on ephys session 

28 

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() 

33 

34Running bpod QC only, from training rig PC 

35 

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) 

43 

44Running ephys QC, from local server PC (after ephys + bpod data have been copied to a same folder) 

45 

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 

61 

62import numpy as np 

63from scipy.stats import chisquare 

64 

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 

70 

71_log = logging.getLogger(__name__) 

72 

73 

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} 

91 

92 

93def compute_session_status_from_dict(results, criteria=None): 

94 """ 

95 Compute overall task QC value from QC check results. 

96 

97 Given a dictionary of results, computes the overall session QC for each key and aggregates 

98 in a single value. 

99 

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. 

106 

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()} 

118 

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

122 

123 

124def update_dataset_qc(qc, registered_datasets, one, override=False): 

125 """ 

126 Update QC values for individual datasets. 

127 

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. 

138 

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

148 

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

171 

172 

173class TaskQC(base.QC): 

174 """Task QC for training, biased, and ephys choice world.""" 

175 

176 criteria = BWM_CRITERIA 

177 

178 extractor = None 

179 """ibllib.qc.task_extractors.TaskQCExtractor: A task extractor object containing raw and extracted data.""" 

180 

181 @staticmethod 

182 def thresholding(qc_value, thresholds=None) -> spec.QC: 

183 """ 

184 Compute the outcome of a single key by applying thresholding. 

185 

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). 

193 

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

210 

211 def __init__(self, session_path_or_eid, **kwargs): 

212 """ 

213 Task QC for training, biased, and ephys choice world. 

214 

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

222 

223 # Data 

224 self.extractor = None 1ijcadb

225 

226 # Metrics and passed trials 

227 self.metrics = None 1ijcadb

228 self.passed = None 1ijcadb

229 

230 # Criteria (initialize as outcomes vary by class, task, and hardware) 

231 self.criteria = BWM_CRITERIA.copy() 1ijcadb

232 

233 def load_data(self, bpod_only=False, download_data=True): 

234 """Extract the data from raw data files. 

235 

236 Extracts all the required task data from the raw data files. 

237 

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) 

248 

249 def compute(self, **kwargs): 

250 """Compute and store the QC metrics. 

251 

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. 

254 

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) 

266 

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

274 

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 ) 

285 

286 def _get_checks(self): 

287 """ 

288 Find all methods that begin with 'check_'. 

289 

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

298 

299 return dict(getmembers(sys.modules[__name__], is_metric)) 1ujcadgfeb

300 

301 def get_bpodqc_metrics_frame(self, data, **kwargs): 

302 """ 

303 Evaluate task QC metrics. 

304 

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. 

307 

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. 

324 

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

337 

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

343 

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

351 

352 def run(self, update=False, namespace='task', **kwargs): 

353 """ 

354 Compute the QC outcomes and return overall task QC outcome. 

355 

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. 

367 

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

382 

383 def compute_session_status(self): 

384 """ 

385 Compute the overall session QC for each key and aggregates in a single value. 

386 

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

403 

404 @staticmethod 

405 def compute_dataset_qc_status(outcomes): 

406 """Return map of dataset specific QC values. 

407 

408 Parameters 

409 ---------- 

410 outcomes : dict 

411 Map of checks and their individual outcomes. 

412 

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

437 

438 

439class HabituationQC(TaskQC): 

440 """Task QC for habituation choice world.""" 

441 

442 def compute(self, download_data=None, **kwargs): 

443 """Compute and store the QC metrics. 

444 

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

453 

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

460 

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

465 

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

470 

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]]) 

492 

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

499 

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

513 

514 metrics[check] = a & b & c & d & ~nans 1ha

515 passed[check] = metrics[check].astype(float) 1ha

516 

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

524 

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

530 

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

537 

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

546 

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

553 

554 self.metrics, self.passed = (metrics, passed) 1ha

555 

556 

557# SINGLE METRICS 

558# ---------------------------------------------------------------------------- # 

559 

560# === Delays between events checks === 

561 

562def check_stimOn_goCue_delays(data, audio_output='harp', **_): 

563 """ 

564 Check the go cue tone occurs less than 10ms before stimulus on. 

565 

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. 

568 

569 Metric: 

570 M = stimOn_times - goCue_times 

571 

572 Criteria: 

573 0 < M < 0.010 s 

574 

575 Units: 

576 seconds [s] 

577 

578 :param data: dict of trial data with keys ('goCue_times', 'stimOn_times', 'intervals') 

579 :param audio_output: audio output device name. 

580 

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

593 

594 

595def check_response_feedback_delays(data, audio_output='harp', **_): 

596 """ 

597 Check the feedback delivered within 10ms of the response threshold. 

598 

599 Checks that the time difference between the response and the feedback onset 

600 (error sound or valve) is positive and less than 10ms. 

601 

602 Metric: 

603 M = feedback_time - response_time 

604 

605 Criterion: 

606 0 < M < 0.010 s 

607 

608 Units: 

609 seconds [s] 

610 

611 :param data: dict of trial data with keys ('feedback_times', 'response_times', 'intervals') 

612 :param audio_output: audio output device name. 

613 

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

624 

625 

626def check_response_stimFreeze_delays(data, **_): 

627 """ 

628 Check the stimulus freezes within 100ms of the expected time. 

629 

630 Checks that the time difference between the visual stimulus freezing and the 

631 response is positive and less than 100ms. 

632 

633 Metric: 

634 M = (stimFreeze_times - response_times) 

635 

636 Criterion: 

637 0 < M < 0.100 s 

638 

639 Units: 

640 seconds [s] 

641 

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

655 

656 

657def check_stimOff_itiIn_delays(data, **_): 

658 """Check that the start of the trial interval is within 10ms of the visual stimulus turning off. 

659 

660 Metric: 

661 M = itiIn_times - stimOff_times 

662 

663 Criterion: 

664 0 < M < 0.010 s 

665 

666 Units: 

667 seconds [s] 

668 

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

680 

681 

682def check_iti_delays(data, subtract_pauses=False, **_): 

683 """ 

684 Check the open-loop grey screen period is approximately 1 second. 

685 

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 

688 

689 Metric: 

690 M = stimOff (n) - trialStart (n+1) - 1. 

691 

692 Criterion: 

693 |M| < 0.1 

694 

695 Units: 

696 seconds [s] 

697 

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. 

705 

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

725 

726 

727def check_positive_feedback_stimOff_delays(data, **_): 

728 """ 

729 Check stimulus offset occurs approximately 1 second after reward delivered. 

730 

731 Check that the time difference between the valve onset and the visual stimulus turning off 

732 is 1 ± 0.150 seconds. 

733 

734 Metric: 

735 M = stimOff_times - feedback_times - 1s 

736 

737 Criterion: 

738 |M| < 0.150 s 

739 

740 Units: 

741 seconds [s] 

742 

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

753 

754 

755def check_negative_feedback_stimOff_delays(data, **_): 

756 """ 

757 Check the stimulus offset occurs approximately 2 seconds after negative feedback delivery. 

758 

759 Check that the time difference between the error sound and the visual stimulus 

760 turning off is 2 ± 0.150 seconds. 

761 

762 Metric: 

763 M = stimOff_times - errorCue_times - 2s 

764 

765 Criterion: 

766 |M| < 0.150 s 

767 

768 Units: 

769 seconds [s] 

770 

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

780 

781 

782# === Wheel movement during trial checks === 

783 

784def check_wheel_move_before_feedback(data, **_): 

785 """Check that the wheel does move within 100ms of the feedback onset (error sound or valve). 

786 

787 Metric: 

788 M = (w_t - 0.05) - (w_t + 0.05), where t = feedback_times 

789 

790 Criterion: 

791 M != 0 

792 

793 Units: 

794 radians 

795 

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

812 

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

817 

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

821 

822 

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. 

826 

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. 

829 

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 

834 

835 Criterion: 

836 displacement < tol visual degree 

837 

838 Units: 

839 degrees angle of wheel turn 

840 

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 

851 

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']) 

856 

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

866 

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

879 

880 

881def check_wheel_move_during_closed_loop(data, wheel_gain=None, **_): 

882 """ 

883 Check the wheel moves the correct amount to reach threshold. 

884 

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. 

887 

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 

892 

893 Criterion: 

894 displacement < 3 visual degrees 

895 

896 Units: 

897 degrees angle of wheel turn 

898 

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

906 

907 return _wheel_move_during_closed_loop(timestamps, position, data, wheel_gain, tol=3) 1kcadgfeb

908 

909 

910def check_wheel_move_during_closed_loop_bpod(data, wheel_gain=None, **_): 

911 """ 

912 Check the wheel moves the correct amount to reach threshold. 

913 

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). 

917 

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. 

922 

923 Criterion: 

924 displacement < 1 visual degree 

925 

926 Units: 

927 degrees angle of wheel turn 

928 

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

936 

937 return _wheel_move_during_closed_loop(timestamps, position, data, wheel_gain, tol=1) 1cadgfeb

938 

939 

940def check_wheel_freeze_during_quiescence(data, **_): 

941 """ 

942 Check the wheel is indeed still during the quiescent period. 

943 

944 Check that the wheel does not move more than 2 degrees in each direction during the 

945 quiescence interval before the stimulus appears. 

946 

947 Metric: 

948 M = |max(W) - min(W)| where W is wheel pos over quiescence interval; 

949 interval = [stimOnTrigger_times - quiescent_duration, stimOnTrigger_times] 

950 

951 Criterion: 

952 M < 2 degrees 

953 

954 Units: 

955 degrees angle of wheel turn 

956 

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 ) 

970 

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

988 

989 

990def check_detected_wheel_moves(data, min_qt=0, **_): 

991 """Check that the detected first movement times are reasonable. 

992 

993 Metric: 

994 M = firstMovement times 

995 

996 Criterion: 

997 (goCue trigger time - min quiescent period) < M < response time 

998 

999 Units: 

1000 Seconds [s] 

1001 

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

1010 

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

1019 

1020 

1021# === Sequence of events checks === 

1022 

1023def check_error_trial_event_sequence(data, **_): 

1024 """ 

1025 Check trial events occur in correct order for negative feedback trials. 

1026 

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 

1030 

1031 Metric: 

1032 M = Bpod (trial start) > audio (go cue) > audio (error) > Bpod (ITI) > Bpod (trial end) 

1033 

1034 Criterion: 

1035 M == True 

1036 

1037 Units: 

1038 -none- 

1039 

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 ) 

1051 

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

1057 

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

1060 

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

1065 

1066 

1067def check_correct_trial_event_sequence(data, **_): 

1068 """ 

1069 Check trial events occur in correct order for positive feedback trials. 

1070 

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 

1073 

1074 Metric: 

1075 M = Bpod (trial start) > audio (go cue) > Bpod (valve) > Bpod (ITI) > Bpod (trial end) 

1076 

1077 Criterion: 

1078 M == True 

1079 

1080 Units: 

1081 -none- 

1082 

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 ) 

1094 

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

1100 

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

1103 

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

1108 

1109 

1110def check_n_trial_events(data, **_): 

1111 """Check that the number events per trial is correct. 

1112 

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 

1115 

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 

1119 

1120 Criterion: 

1121 M == True 

1122 

1123 Units: 

1124 -none-, boolean 

1125 

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

1134 

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

1144 

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

1153 

1154 

1155def check_trial_length(data, **_): 

1156 """ 

1157 Check open-loop duration positive and <= 1 minute. 

1158 

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. 

1161 

1162 Metric: 

1163 M = feedback_times - goCue_times 

1164 

1165 Criteria: 

1166 0 < M < 60.1 s 

1167 

1168 Units: 

1169 seconds [s] 

1170 

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

1178 

1179 

1180# === Trigger-response delay checks === 

1181 

1182def check_goCue_delays(data, audio_output='harp', **_): 

1183 """ 

1184 Check the go cue tone occurs within 1ms of the intended time. 

1185 

1186 Check that the time difference between the go cue sound being triggered and 

1187 effectively played is smaller than 1ms. 

1188 

1189 Metric: 

1190 M = goCue_times - goCueTrigger_times 

1191 

1192 Criterion: 

1193 0 < M <= 0.0015 s 

1194 

1195 Units: 

1196 seconds [s] 

1197 

1198 :param data: dict of trial data with keys ('goCue_times', 'goCueTrigger_times', 'intervals'). 

1199 :param audio_output: audio output device name. 

1200 

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

1211 

1212 

1213def check_errorCue_delays(data, audio_output='harp', **_): 

1214 """ 

1215 Check the error tone occurs within 1.5ms of the intended time. 

1216 

1217 Check that the time difference between the error sound being triggered and 

1218 effectively played is smaller than 1ms. 

1219 

1220 Metric: 

1221 M = errorCue_times - errorCueTrigger_times 

1222 

1223 Criterion: 

1224 0 < M <= 0.0015 s 

1225 

1226 Units: 

1227 seconds [s] 

1228 

1229 :param data: dict of trial data with keys ('errorCue_times', 'errorCueTrigger_times', 

1230 'intervals', 'correct') 

1231 :param audio_output: audio output device name. 

1232 

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

1244 

1245 

1246def check_stimOn_delays(data, **_): 

1247 """ 

1248 Check the visual stimulus onset occurs within 150ms of the intended time. 

1249 

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. 

1252 

1253 Metric: 

1254 M = stimOn_times - stimOnTrigger_times 

1255 

1256 Criterion: 

1257 0 < M < 0.15 s 

1258 

1259 Units: 

1260 seconds [s] 

1261 

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

1269 

1270 

1271def check_stimOff_delays(data, **_): 

1272 """ 

1273 Check stimulus offset occurs within 150ms of the intended time. 

1274 

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. 

1278 

1279 Metric: 

1280 M = stimOff_times - stimOffTrigger_times 

1281 

1282 Criterion: 

1283 0 < M < 0.15 s 

1284 

1285 Units: 

1286 seconds [s] 

1287 

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

1295 

1296 

1297def check_stimFreeze_delays(data, **_): 

1298 """Check the stimulus freezes within 150ms of the intended time. 

1299 

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. 

1303 

1304 Metric: 

1305 M = stimFreeze_times - stimFreezeTrigger_times 

1306 

1307 Criterion: 

1308 0 < M < 0.15 s 

1309 

1310 Units: 

1311 seconds [s] 

1312 

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

1320 

1321 

1322# === Data integrity checks === 

1323 

1324def check_reward_volumes(data, **_): 

1325 """Check that the reward volume is between 1.5 and 3 uL for correct trials, 0 for incorrect. 

1326 

1327 Metric: 

1328 M = reward volume 

1329 

1330 Criterion: 

1331 1.5 <= M <= 3 if correct else M == 0 

1332 

1333 Units: 

1334 uL 

1335 

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

1347 

1348 

1349def check_reward_volume_set(data, **_): 

1350 """Check that there is only two reward volumes within a session, one of which is 0. 

1351 

1352 Metric: 

1353 M = set(rewardVolume) 

1354 

1355 Criterion: 

1356 (0 < len(M) <= 2) and 0 in M 

1357 

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

1363 

1364 

1365def check_wheel_integrity(data, re_encoding='X1', enc_res=None, **_): 

1366 """ 

1367 Check wheel position sampled at the expected resolution. 

1368 

1369 Check that the difference between wheel position samples is close to the encoder resolution 

1370 and that the wheel timestamps strictly increase. 

1371 

1372 Metric: 

1373 M = (absolute difference of the positions < 1.5 * encoder resolution) 

1374 + 1 if (difference of timestamps <= 0) else 0 

1375 

1376 Criterion: 

1377 M ~= 0 

1378 

1379 Units: 

1380 arbitrary (radians, sometimes + 1) 

1381 

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) 

1385 

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) 

1390 

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

1404 

1405 

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. 

1410 

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. 

1413 

1414 Metric: 

1415 M = number of visual stimulus change events between trial start and goCue_times 

1416 

1417 Criterion: 

1418 M == 1 

1419 

1420 Units: 

1421 -none-, integer 

1422 

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. 

1429 

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. 

1436 

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

1452 

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

1457 

1458 

1459def check_audio_pre_trial(data, audio=None, **_): 

1460 """ 

1461 Check no audio stimuli before the go cue. 

1462 

1463 Check there are no audio outputs between the start of the trial and the go cue sound onset - 20 ms. 

1464 

1465 Metric: 

1466 M = sum(start_times < audio TTL < (goCue_times - 20ms)) 

1467 

1468 Criterion: 

1469 M == 0 

1470 

1471 Units: 

1472 -none-, integer 

1473 

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. 

1480 

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