Coverage for ibllib/qc/task_metrics.py: 97%

381 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-10-11 11:13 +0100

1"""Behaviour QC 

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

3 

4Examples 

5-------- 

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

7 

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

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

10 

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

12 

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

14>>> qc = TaskQC(eid) 

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

16 

17Inspecting individual test outcomes 

18 

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

20>>> qc = TaskQC(eid) 

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

22 

23Running bpod QC on ephys session 

24 

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

26>>> qc = TaskQC(eid) 

27>>> qc.load_data(bpod_only=True) # Extract without FPGA 

28>>> bpod_qc = qc.run() 

29 

30Running bpod QC only, from training rig PC 

31 

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

33>>> from ibllib.qc.qcplots import plot_results 

34>>> session_path = r'/home/nico/Downloads/FlatIron/mrsicflogellab/Subjects/SWC_023/2020-02-14/001' 

35>>> qc = TaskQC(session_path) 

36>>> qc.load_data(bpod_only=True, download_data=False) # Extract without FPGA 

37>>> qc.run() 

38>>> plot_results(qc, save_path=session_path) 

39 

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

41 

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

43>>> from ibllib.qc.qcplots import plot_results 

44>>> session_path = r'/home/nico/Downloads/FlatIron/mrsicflogellab/Subjects/SWC_023/2020-02-14/001' 

45>>> qc = TaskQC(session_path) 

46>>> qc.run() 

47>>> plot_results(qc, save_path=session_path) 

48""" 

49import logging 

50import sys 

51from datetime import datetime, timedelta 

52from inspect import getmembers, isfunction 

53from functools import reduce 

54from collections.abc import Sized 

55 

56import numpy as np 

57from scipy.stats import chisquare 

58 

59from brainbox.behavior.wheel import cm_to_rad, traces_by_trial 

60from ibllib.qc.task_extractors import TaskQCExtractor 

61from ibllib.io.extractors import ephys_fpga 

62from one.alf.spec import is_session_path 

63from . import base 

64 

65_log = logging.getLogger(__name__) 

66 

67 

68class TaskQC(base.QC): 

69 """A class for computing task QC metrics""" 

70 

71 criteria = dict() 

72 criteria['default'] = {"PASS": 0.99, "WARNING": 0.90, "FAIL": 0} # Note: WARNING was 0.95 prior to Aug 2022 

73 criteria['_task_stimOff_itiIn_delays'] = {"PASS": 0.99, "WARNING": 0} 

74 criteria['_task_positive_feedback_stimOff_delays'] = {"PASS": 0.99, "WARNING": 0} 

75 criteria['_task_negative_feedback_stimOff_delays'] = {"PASS": 0.99, "WARNING": 0} 

76 criteria['_task_wheel_move_during_closed_loop'] = {"PASS": 0.99, "WARNING": 0} 

77 criteria['_task_response_stimFreeze_delays'] = {"PASS": 0.99, "WARNING": 0} 

78 criteria['_task_detected_wheel_moves'] = {"PASS": 0.99, "WARNING": 0} 

79 criteria['_task_trial_length'] = {"PASS": 0.99, "WARNING": 0} 

80 criteria['_task_goCue_delays'] = {"PASS": 0.99, "WARNING": 0} 

81 criteria['_task_errorCue_delays'] = {"PASS": 0.99, "WARNING": 0} 

82 criteria['_task_stimOn_delays'] = {"PASS": 0.99, "WARNING": 0} 

83 criteria['_task_stimOff_delays'] = {"PASS": 0.99, "WARNING": 0} 

84 criteria['_task_stimFreeze_delays'] = {"PASS": 0.99, "WARNING": 0} 

85 criteria['_task_iti_delays'] = {"NOT_SET": 0} 

86 criteria['_task_passed_trial_checks'] = {"NOT_SET": 0} 

87 

88 @staticmethod 

89 def _thresholding(qc_value, thresholds=None): 

90 """ 

91 Computes the outcome of a single key by applying thresholding. 

92 :param qc_value: proportion of passing qcs, between 0 and 1 

93 :param thresholds: dictionary with keys 'PASS', 'WARNING', 'FAIL' 

94 (cf. TaskQC.criteria attribute) 

95 :return: int where -1: NOT_SET, 0: PASS, 1: WARNING, 2: FAIL 

96 """ 

97 MAX_BOUND, MIN_BOUND = (1, 0) 1vonpkjfcabehgd

98 if not thresholds: 1vonpkjfcabehgd

99 thresholds = TaskQC.criteria['default'].copy() 

100 if qc_value is None or np.isnan(qc_value): 1vonpkjfcabehgd

101 return int(-1) 1onp

102 elif (qc_value > MAX_BOUND) or (qc_value < MIN_BOUND): 1vonpkjfcabehgd

103 raise ValueError("Values out of bound") 1v

104 if 'PASS' in thresholds.keys() and qc_value >= thresholds['PASS']: 1onpkjfcabehgd

105 return 0 1onpkjfcabehgd

106 if 'WARNING' in thresholds.keys() and qc_value >= thresholds['WARNING']: 1onpkjfcabehgd

107 return 1 1opjfcabehgd

108 if 'FAIL' in thresholds and qc_value >= thresholds['FAIL']: 1onkjfcabehgd

109 return 2 1okjfcabhgd

110 if 'NOT_SET' in thresholds and qc_value >= thresholds['NOT_SET']: 1nfcabehgd

111 return -1 1nfcabehgd

112 # if None of this applies, return 'NOT_SET' 

113 return -1 

114 

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

116 """ 

117 :param session_path_or_eid: A session eid or path 

118 :param log: A logging.Logger instance, if None the 'ibllib' logger is used 

119 :param one: An ONE instance for fetching and setting the QC on Alyx 

120 """ 

121 # When an eid is provided, we will download the required data by default (if necessary) 

122 self.download_data = not is_session_path(session_path_or_eid) 1ljfcabed

123 super().__init__(session_path_or_eid, **kwargs) 1ljfcabed

124 

125 # Data 

126 self.extractor = None 1ljfcabed

127 

128 # Metrics and passed trials 

129 self.metrics = None 1ljfcabed

130 self.passed = None 1ljfcabed

131 

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

133 """Extract the data from raw data files 

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

135 

136 :param bpod_only: if True no data is extracted from the FPGA for ephys sessions 

137 :param download_data: if True, any missing raw data is downloaded via ONE. 

138 """ 

139 self.extractor = TaskQCExtractor( 1lab

140 self.session_path, one=self.one, download_data=download_data, bpod_only=bpod_only) 

141 

142 def compute(self, **kwargs): 

143 """Compute and store the QC metrics 

144 Runs the QC on the session and stores a map of the metrics for each datapoint for each 

145 test, and a map of which datapoints passed for each test 

146 :param bpod_only: if True no data is extracted from the FPGA for ephys sessions 

147 :param download_data: if True, any missing raw data is downloaded via ONE. By default 

148 data are not downloaded if a session path was provided to the constructor. 

149 :return: 

150 """ 

151 if self.extractor is None: 1fcabeihgd

152 kwargs['download_data'] = kwargs.pop('download_data', self.download_data) 1ab

153 self.load_data(**kwargs) 1ab

154 self.log.info(f"Session {self.session_path}: Running QC on behavior data...") 1fcabeihgd

155 self.metrics, self.passed = get_bpodqc_metrics_frame( 1fcabeihgd

156 self.extractor.data, 

157 wheel_gain=self.extractor.settings['STIM_GAIN'], # The wheel gain 

158 photodiode=self.extractor.frame_ttls, 

159 audio=self.extractor.audio_ttls, 

160 re_encoding=self.extractor.wheel_encoding or 'X1', 

161 min_qt=self.extractor.settings.get('QUIESCENT_PERIOD') or 0.2 

162 ) 

163 return 1fcabeihgd

164 

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

166 """ 

167 :param update: if True, updates the session QC fields on Alyx 

168 :param bpod_only: if True no data is extracted from the FPGA for ephys sessions 

169 :param download_data: if True, any missing raw data is downloaded via ONE. By default 

170 data are not downloaded if a session path was provided to the constructor. 

171 :return: QC outcome (str), a dict for extended QC 

172 """ 

173 if self.metrics is None: 1jfcabegd

174 self.compute(**kwargs) 1jfcabegd

175 outcome, results, _ = self.compute_session_status() 1jfcabegd

176 if update: 1jfcabegd

177 self.update_extended_qc(results) 1fcgd

178 self.update(outcome, namespace) 1fcgd

179 return outcome, results 1jfcabegd

180 

181 @staticmethod 

182 def compute_session_status_from_dict(results): 

183 """ 

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

185 in a single value 

186 :param results: a dictionary of qc keys containing (usually scalar) values 

187 :return: Overall session QC outcome as a string 

188 :return: A dict of QC tests and their outcomes 

189 """ 

190 indices = np.zeros(len(results), dtype=int) 1vonpkjfcabehgd

191 for i, k in enumerate(results): 1vonpkjfcabehgd

192 if k in TaskQC.criteria.keys(): 1vonpkjfcabehgd

193 indices[i] = TaskQC._thresholding(results[k], thresholds=TaskQC.criteria[k]) 1npkjfcabehgd

194 else: 

195 indices[i] = TaskQC._thresholding(results[k], thresholds=TaskQC.criteria['default']) 1vonpkjfcabehgd

196 

197 def key_map(x): 1onpkjfcabehgd

198 return 'NOT_SET' if x < 0 else list(TaskQC.criteria['default'].keys())[x] 1onpkjfcabehgd

199 # Criteria map is in order of severity so the max index is our overall QC outcome 

200 session_outcome = key_map(max(indices)) 1onpkjfcabehgd

201 outcomes = dict(zip(results.keys(), map(key_map, indices))) 1onpkjfcabehgd

202 return session_outcome, outcomes 1onpkjfcabehgd

203 

204 def compute_session_status(self): 

205 """ 

206 Computes the overall session QC for each key and aggregates in a single value 

207 :return: Overall session QC outcome as a string 

208 :return: A dict of QC tests and the proportion of data points that passed them 

209 :return: A dict of QC tests and their outcomes 

210 """ 

211 if self.passed is None: 1kjfcabehgd

212 raise AttributeError('passed is None; compute QC first') 1h

213 # Get mean passed of each check, or None if passed is None or all NaN 

214 results = {k: None if v is None or np.isnan(v).all() else np.nanmean(v) 1kjfcabehgd

215 for k, v in self.passed.items()} 

216 session_outcome, outcomes = self.compute_session_status_from_dict(results) 1kjfcabehgd

217 return session_outcome, results, outcomes 1kjfcabehgd

218 

219 

220class HabituationQC(TaskQC): 

221 

222 def compute(self, download_data=None): 

223 """Compute and store the QC metrics 

224 Runs the QC on the session and stores a map of the metrics for each datapoint for each 

225 test, and a map of which datapoints passed for each test 

226 :return: 

227 """ 

228 if self.extractor is None: 1kj

229 # If download_data is None, decide based on whether eid or session path was provided 

230 ensure_data = self.download_data if download_data is None else download_data 

231 self.load_data(download_data=ensure_data) 

232 self.log.info(f"Session {self.session_path}: Running QC on habituation data...") 1kj

233 

234 # Initialize checks 

235 prefix = '_task_' 1kj

236 data = self.extractor.data 1kj

237 metrics = {} 1kj

238 passed = {} 1kj

239 

240 # Check all reward volumes == 3.0ul 

241 check = prefix + 'reward_volumes' 1kj

242 metrics[check] = data['rewardVolume'] 1kj

243 passed[check] = metrics[check] == 3.0 1kj

244 

245 # Check session durations are increasing in steps >= 12 minutes 

246 check = prefix + 'habituation_time' 1kj

247 if not self.one or not self.session_path: 1kj

248 self.log.warning('unable to determine session trials without ONE') 

249 metrics[check] = passed[check] = None 

250 else: 

251 subject, session_date = self.session_path.parts[-3:-1] 1kj

252 # compute from the date specified 

253 date_minus_week = ( 1kj

254 datetime.strptime(session_date, '%Y-%m-%d') - timedelta(days=7) 

255 ).strftime('%Y-%m-%d') 

256 sessions = self.one.alyx.rest('sessions', 'list', subject=subject, 1kj

257 date_range=[date_minus_week, session_date], 

258 task_protocol='habituation') 

259 # Remove the current session if already registered 

260 if sessions and sessions[0]['start_time'].startswith(session_date): 1kj

261 sessions = sessions[1:] 

262 metric = ([0, data['intervals'][-1, 1] - data['intervals'][0, 0]] + 1kj

263 [(datetime.fromisoformat(x['end_time']) - 

264 datetime.fromisoformat(x['start_time'])).total_seconds() / 60 

265 for x in [self.one.alyx.get(s['url']) for s in sessions]]) 

266 

267 # The duration from raw trial data 

268 # duration = map(float, self.extractor.raw_data[-1]['elapsed_time'].split(':')) 

269 # duration = timedelta(**dict(zip(('hours', 'minutes', 'seconds'), 

270 # duration))).total_seconds() / 60 

271 metrics[check] = np.array(metric) 1kj

272 passed[check] = np.diff(metric) >= 12 1kj

273 

274 # Check event orders: trial_start < stim on < stim center < feedback < stim off 

275 check = prefix + 'trial_event_sequence' 1kj

276 nans = ( 1kj

277 np.isnan(data["intervals"][:, 0]) | # noqa 

278 np.isnan(data["stimOn_times"]) | # noqa 

279 np.isnan(data["stimCenter_times"]) | 

280 np.isnan(data["valveOpen_times"]) | # noqa 

281 np.isnan(data["stimOff_times"]) 

282 ) 

283 a = np.less(data["intervals"][:, 0], data["stimOn_times"], where=~nans) 1kj

284 b = np.less(data["stimOn_times"], data["stimCenter_times"], where=~nans) 1kj

285 c = np.less(data["stimCenter_times"], data["valveOpen_times"], where=~nans) 1kj

286 d = np.less(data["valveOpen_times"], data["stimOff_times"], where=~nans) 1kj

287 

288 metrics[check] = a & b & c & d & ~nans 1kj

289 passed[check] = metrics[check].astype(float) 1kj

290 

291 # Check that the time difference between the visual stimulus center-command being 

292 # triggered and the stimulus effectively appearing in the center is smaller than 150 ms. 

293 check = prefix + 'stimCenter_delays' 1kj

294 metric = np.nan_to_num(data["stimCenter_times"] - data["stimCenterTrigger_times"], 1kj

295 nan=np.inf) 

296 passed[check] = (metric <= 0.15) & (metric > 0) 1kj

297 metrics[check] = metric 1kj

298 

299 # Phase check 

300 check = prefix + 'phase' 1kj

301 metric = data['phase'] 1kj

302 passed[check] = (metric <= 2 * np.pi) & (metric >= 0) 1kj

303 metrics[check] = metric 1kj

304 

305 check = prefix + 'phase_distribution' 1kj

306 metric, _ = np.histogram(data['phase']) 1kj

307 _, p = chisquare(metric) 1kj

308 passed[check] = p < 0.05 1kj

309 metrics[check] = metric 1kj

310 

311 # Checks common to training QC 

312 checks = [check_goCue_delays, check_stimOn_goCue_delays, 1kj

313 check_stimOn_delays, check_stimOff_delays] 

314 for fcn in checks: 1kj

315 check = prefix + fcn.__name__[6:] 1kj

316 metrics[check], passed[check] = fcn(data) 1kj

317 

318 self.metrics, self.passed = (metrics, passed) 1kj

319 

320 

321def get_bpodqc_metrics_frame(data, **kwargs): 

322 """ 

323 Evaluates all the QC metric functions in this module (those starting with 'check') and 

324 returns the results. The optional kwargs listed below are passed to each QC metric function. 

325 :param data: dict of extracted task data 

326 :param re_encoding: the encoding of the wheel data, X1, X2 or X4 

327 :param enc_res: the rotary encoder resolution 

328 :param wheel_gain: the STIM_GAIN task parameter 

329 :param photodiode: the fronts from Bpod's BNC1 input or FPGA frame2ttl channel 

330 :param audio: the fronts from Bpod's BNC2 input FPGA audio sync channel 

331 :param min_qt: the QUIESCENT_PERIOD task parameter 

332 :return metrics: dict of checks and their QC metrics 

333 :return passed: dict of checks and a float array of which samples passed 

334 """ 

335 def is_metric(x): 1fcabeihgd

336 return isfunction(x) and x.__name__.startswith('check_') 1fcabeihgd

337 # Find all methods that begin with 'check_' 

338 checks = getmembers(sys.modules[__name__], is_metric) 1fcabeihgd

339 prefix = '_task_' # Extended QC fields will start with this 1fcabeihgd

340 # Method 'check_foobar' stored with key '_task_foobar' in metrics map 

341 qc_metrics_map = {prefix + k[6:]: fn(data, **kwargs) for k, fn in checks} 1fcabeihgd

342 

343 # Split metrics and passed frames 

344 metrics = {} 1fcabeihgd

345 passed = {} 1fcabeihgd

346 for k in qc_metrics_map: 1fcabeihgd

347 metrics[k], passed[k] = qc_metrics_map[k] 1fcabeihgd

348 

349 # Add a check for trial level pass: did a given trial pass all checks? 

350 n_trials = data['intervals'].shape[0] 1fcabeihgd

351 # Trial-level checks return an array the length that equals the number of trials 

352 trial_level_passed = [m for m in passed.values() 1fcabeihgd

353 if isinstance(m, Sized) and len(m) == n_trials] 

354 name = prefix + 'passed_trial_checks' 1fcabeihgd

355 metrics[name] = reduce(np.logical_and, trial_level_passed or (None, None)) 1fcabeihgd

356 passed[name] = metrics[name].astype(float) if trial_level_passed else None 1fcabeihgd

357 

358 return metrics, passed 1fcabeihgd

359 

360 

361# SINGLE METRICS 

362# ---------------------------------------------------------------------------- # 

363 

364# === Delays between events checks === 

365 

366def check_stimOn_goCue_delays(data, **_): 

367 """ Checks that the time difference between the onset of the visual stimulus 

368 and the onset of the go cue tone is positive and less than 10ms. 

369 

370 Metric: M = stimOn_times - goCue_times 

371 Criteria: 0 < M < 0.010 s 

372 Units: seconds [s] 

373 

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

375 """ 

376 # Calculate the difference between stimOn and goCue times. 

377 # If either are NaN, the result will be Inf to ensure that it crosses the failure threshold. 

378 metric = np.nan_to_num(data["goCue_times"] - data["stimOn_times"], nan=np.inf) 1kGjfcabeihgd

379 passed = (metric < 0.01) & (metric > 0) 1kGjfcabeihgd

380 assert data["intervals"].shape[0] == len(metric) == len(passed) 1kGjfcabeihgd

381 return metric, passed 1kGjfcabeihgd

382 

383 

384def check_response_feedback_delays(data, **_): 

385 """ Checks that the time difference between the response and the feedback onset 

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

387 

388 Metric: M = feedback_time - response_time 

389 Criterion: 0 < M < 0.010 s 

390 Units: seconds [s] 

391 

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

393 """ 

394 metric = np.nan_to_num(data["feedback_times"] - data["response_times"], nan=np.inf) 1Hfcabeihgd

395 passed = (metric < 0.01) & (metric > 0) 1Hfcabeihgd

396 assert data["intervals"].shape[0] == len(metric) == len(passed) 1Hfcabeihgd

397 return metric, passed 1Hfcabeihgd

398 

399 

400def check_response_stimFreeze_delays(data, **_): 

401 """ Checks that the time difference between the visual stimulus freezing and the 

402 response is positive and less than 100ms. 

403 

404 Metric: M = (stimFreeze_times - response_times) 

405 Criterion: 0 < M < 0.100 s 

406 Units: seconds [s] 

407 

408 :param data: dict of trial data with keys ('stimFreeze_times', 'response_times', 'intervals', 

409 'choice') 

410 """ 

411 # Calculate the difference between stimOn and goCue times. 

412 # If either are NaN, the result will be Inf to ensure that it crosses the failure threshold. 

413 metric = np.nan_to_num(data["stimFreeze_times"] - data["response_times"], nan=np.inf) 1Bfcabeihgd

414 # Test for valid values 

415 passed = ((metric < 0.1) & (metric > 0)).astype(float) 1Bfcabeihgd

416 # Finally remove no_go trials (stimFreeze triggered differently in no_go trials) 

417 # These values are ignored in calculation of proportion passed 

418 passed[data["choice"] == 0] = np.nan 1Bfcabeihgd

419 assert data["intervals"].shape[0] == len(metric) == len(passed) 1Bfcabeihgd

420 return metric, passed 1Bfcabeihgd

421 

422 

423def check_stimOff_itiIn_delays(data, **_): 

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

425 

426 Metric: M = itiIn_times - stimOff_times 

427 Criterion: 0 < M < 0.010 s 

428 Units: seconds [s] 

429 

430 :param data: dict of trial data with keys ('stimOff_times', 'itiIn_times', 'intervals', 

431 'choice') 

432 """ 

433 # If either are NaN, the result will be Inf to ensure that it crosses the failure threshold. 

434 metric = np.nan_to_num(data["itiIn_times"] - data["stimOff_times"], nan=np.inf) 1Cfcabeihgd

435 passed = ((metric < 0.01) & (metric >= 0)).astype(float) 1Cfcabeihgd

436 # Remove no_go trials (stimOff triggered differently in no_go trials) 

437 # NaN values are ignored in calculation of proportion passed 

438 metric[data["choice"] == 0] = passed[data["choice"] == 0] = np.nan 1Cfcabeihgd

439 assert data["intervals"].shape[0] == len(metric) == len(passed) 1Cfcabeihgd

440 return metric, passed 1Cfcabeihgd

441 

442 

443def check_iti_delays(data, **_): 

444 """ Check that the period of gray screen between stim off and the start of the next trial is 

445 0.5s +/- 200%. 

446 

447 Metric: M = stimOff (n) - trialStart (n+1) - 0.5 

448 Criterion: |M| < 1 

449 Units: seconds [s] 

450 

451 :param data: dict of trial data with keys ('stimOff_times', 'intervals') 

452 """ 

453 # Initialize array the length of completed trials 

454 metric = np.full(data["intervals"].shape[0], np.nan) 1Afcabeihgd

455 passed = metric.copy() 1Afcabeihgd

456 # Get the difference between stim off and the start of the next trial 

457 # Missing data are set to Inf, except for the last trial which is a NaN 

458 metric[:-1] = \ 1Afcabeihgd

459 np.nan_to_num(data["intervals"][1:, 0] - data["stimOff_times"][:-1] - 0.5, nan=np.inf) 

460 passed[:-1] = np.abs(metric[:-1]) < .5 # Last trial is not counted 1Afcabeihgd

461 assert data["intervals"].shape[0] == len(metric) == len(passed) 1Afcabeihgd

462 return metric, passed 1Afcabeihgd

463 

464 

465def check_positive_feedback_stimOff_delays(data, **_): 

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

467 is 1 ± 0.150 seconds. 

468 

469 Metric: M = stimOff_times - feedback_times - 1s 

470 Criterion: |M| < 0.150 s 

471 Units: seconds [s] 

472 

473 :param data: dict of trial data with keys ('stimOff_times', 'feedback_times', 'intervals', 

474 'correct') 

475 """ 

476 # If either are NaN, the result will be Inf to ensure that it crosses the failure threshold. 

477 metric = np.nan_to_num(data["stimOff_times"] - data["feedback_times"] - 1, nan=np.inf) 1Dfcabeihgd

478 passed = (np.abs(metric) < 0.15).astype(float) 1Dfcabeihgd

479 # NaN values are ignored in calculation of proportion passed; ignore incorrect trials here 

480 metric[~data["correct"]] = passed[~data["correct"]] = np.nan 1Dfcabeihgd

481 assert data["intervals"].shape[0] == len(metric) == len(passed) 1Dfcabeihgd

482 return metric, passed 1Dfcabeihgd

483 

484 

485def check_negative_feedback_stimOff_delays(data, **_): 

486 """ Check that the time difference between the error sound and the visual stimulus 

487 turning off is 2 ± 0.150 seconds. 

488 

489 Metric: M = stimOff_times - errorCue_times - 2s 

490 Criterion: |M| < 0.150 s 

491 Units: seconds [s] 

492 

493 :param data: dict of trial data with keys ('stimOff_times', 'errorCue_times', 'intervals') 

494 """ 

495 metric = np.nan_to_num(data["stimOff_times"] - data["errorCue_times"] - 2, nan=np.inf) 1Efcabeihgd

496 # Apply criteria 

497 passed = (np.abs(metric) < 0.15).astype(float) 1Efcabeihgd

498 # Remove none negative feedback trials 

499 metric[data["correct"]] = passed[data["correct"]] = np.nan 1Efcabeihgd

500 assert data["intervals"].shape[0] == len(metric) == len(passed) 1Efcabeihgd

501 return metric, passed 1Efcabeihgd

502 

503 

504# === Wheel movement during trial checks === 

505 

506def check_wheel_move_before_feedback(data, **_): 

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

508 

509 Metric: M = (w_t - 0.05) - (w_t + 0.05), where t = feedback_times 

510 Criterion: M != 0 

511 Units: radians 

512 

513 :param data: dict of trial data with keys ('wheel_timestamps', 'wheel_position', 'choice', 

514 'intervals', 'feedback_times') 

515 """ 

516 # Get tuple of wheel times and positions within 100ms of feedback 

517 traces = traces_by_trial( 1rfcabeihgd

518 data["wheel_timestamps"], 

519 data["wheel_position"], 

520 start=data["feedback_times"] - 0.05, 

521 end=data["feedback_times"] + 0.05, 

522 ) 

523 metric = np.zeros_like(data["feedback_times"]) 1rfcabeihgd

524 # For each trial find the displacement 

525 for i, trial in enumerate(traces): 1rfcabeihgd

526 pos = trial[1] 1rfcabeihgd

527 if pos.size > 1: 1rfcabeihgd

528 metric[i] = pos[-1] - pos[0] 1rfcabeihgd

529 

530 # except no-go trials 

531 metric[data["choice"] == 0] = np.nan # NaN = trial ignored for this check 1rfcabeihgd

532 nans = np.isnan(metric) 1rfcabeihgd

533 passed = np.zeros_like(metric) * np.nan 1rfcabeihgd

534 

535 passed[~nans] = (metric[~nans] != 0).astype(float) 1rfcabeihgd

536 assert data["intervals"].shape[0] == len(metric) == len(passed) 1rfcabeihgd

537 return metric, passed 1rfcabeihgd

538 

539 

540def _wheel_move_during_closed_loop(re_ts, re_pos, data, wheel_gain=None, tol=1, **_): 

541 """ Check that the wheel moves by approximately 35 degrees during the closed-loop period 

542 on trials where a feedback (error sound or valve) is delivered. 

543 

544 Metric: M = abs(w_resp - w_t0) - threshold_displacement, where w_resp = position at response 

545 time, w_t0 = position at go cue time, threshold_displacement = displacement required to 

546 move 35 visual degrees 

547 Criterion: displacement < tol visual degree 

548 Units: degrees angle of wheel turn 

549 

550 :param re_ts: extracted wheel timestamps in seconds 

551 :param re_pos: extracted wheel positions in radians 

552 :param data: a dict with the keys (goCueTrigger_times, response_times, feedback_times, 

553 position, choice, intervals) 

554 :param wheel_gain: the 'STIM_GAIN' task setting 

555 :param tol: the criterion in visual degrees 

556 """ 

557 if wheel_gain is None: 1mfcabeihgd

558 _log.warning("No wheel_gain input in function call, returning None") 

559 return None, None 

560 

561 # Get tuple of wheel times and positions over each trial's closed-loop period 

562 traces = traces_by_trial(re_ts, re_pos, 1mfcabeihgd

563 start=data["goCueTrigger_times"], 

564 end=data["response_times"]) 

565 

566 metric = np.zeros_like(data["feedback_times"]) 1mfcabeihgd

567 # For each trial find the absolute displacement 

568 for i, trial in enumerate(traces): 1mfcabeihgd

569 t, pos = trial 1mfcabeihgd

570 if pos.size != 0: 1mfcabeihgd

571 # Find the position of the preceding sample and subtract it 

572 idx = np.abs(re_ts - t[0]).argmin() - 1 1mfcabeihgd

573 origin = re_pos[idx] 1mfcabeihgd

574 metric[i] = np.abs(pos - origin).max() 1mfcabeihgd

575 

576 # Load wheel_gain and thresholds for each trial 

577 wheel_gain = np.array([wheel_gain] * len(data["position"])) 1mfcabeihgd

578 thresh = data["position"] 1mfcabeihgd

579 # abs displacement, s, in mm required to move 35 visual degrees 

580 s_mm = np.abs(thresh / wheel_gain) # don't care about direction 1mfcabeihgd

581 criterion = cm_to_rad(s_mm * 1e-1) # convert abs displacement to radians (wheel pos is in rad) 1mfcabeihgd

582 metric = metric - criterion # difference should be close to 0 1mfcabeihgd

583 rad_per_deg = cm_to_rad(1 / wheel_gain * 1e-1) 1mfcabeihgd

584 passed = (np.abs(metric) < rad_per_deg * tol).astype(float) # less than 1 visual degree off 1mfcabeihgd

585 metric[data["choice"] == 0] = passed[data["choice"] == 0] = np.nan # except no-go trials 1mfcabeihgd

586 assert data["intervals"].shape[0] == len(metric) == len(passed) 1mfcabeihgd

587 return metric, passed 1mfcabeihgd

588 

589 

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

591 """ Check that the wheel moves by approximately 35 degrees during the closed-loop period 

592 on trials where a feedback (error sound or valve) is delivered. 

593 

594 Metric: M = abs(w_resp - w_t0) - threshold_displacement, where w_resp = position at response 

595 time, w_t0 = position at go cue time, threshold_displacement = displacement required to 

596 move 35 visual degrees 

597 Criterion: displacement < 3 visual degrees 

598 Units: degrees angle of wheel turn 

599 

600 :param data: dict of trial data with keys ('wheel_timestamps', 'wheel_position', 'choice', 

601 'intervals', 'goCueTrigger_times', 'response_times', 'feedback_times', 'position') 

602 :param wheel_gain: the 'STIM_GAIN' task setting 

603 """ 

604 # Get the Bpod extracted wheel data 

605 timestamps = data['wheel_timestamps'] 1mfcabeihgd

606 position = data['wheel_position'] 1mfcabeihgd

607 

608 return _wheel_move_during_closed_loop(timestamps, position, data, wheel_gain, tol=3) 1mfcabeihgd

609 

610 

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

612 """ Check that the wheel moves by approximately 35 degrees during the closed-loop period 

613 on trials where a feedback (error sound or valve) is delivered. This check uses the Bpod 

614 wheel data (measured at a lower resolution) with a stricter tolerance (1 visual degree). 

615 

616 Metric: M = abs(w_resp - w_t0) - threshold_displacement, where w_resp = position at response 

617 time, w_t0 = position at go cue time, threshold_displacement = displacement required to 

618 move 35 visual degrees 

619 Criterion: displacement < 1 visual degree 

620 Units: degrees angle of wheel turn 

621 

622 :param data: dict of trial data with keys ('wheel_timestamps(_bpod)', 'wheel_position(_bpod)', 

623 'choice', 'intervals', 'goCueTrigger_times', 'response_times', 'feedback_times', 'position') 

624 :param wheel_gain: the 'STIM_GAIN' task setting 

625 """ 

626 # Get the Bpod extracted wheel data 

627 timestamps = data.get('wheel_timestamps_bpod', data['wheel_timestamps']) 1fcabeihgd

628 position = data.get('wheel_position_bpod', data['wheel_position']) 1fcabeihgd

629 

630 return _wheel_move_during_closed_loop(timestamps, position, data, wheel_gain, tol=1) 1fcabeihgd

631 

632 

633def check_wheel_freeze_during_quiescence(data, **_): 

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

635 quiescence interval before the stimulus appears. 

636 

637 Metric: M = |max(W) - min(W)| where W is wheel pos over quiescence interval 

638 interval = [stimOnTrigger_times - quiescent_duration, stimOnTrigger_times] 

639 Criterion: M < 2 degrees 

640 Units: degrees angle of wheel turn 

641 

642 :param data: dict of trial data with keys ('wheel_timestamps', 'wheel_position', 'quiescence', 

643 'intervals', 'stimOnTrigger_times') 

644 """ 

645 assert np.all(np.diff(data["wheel_timestamps"]) >= 0) 1qfcabeihgd

646 assert data["quiescence"].size == data["stimOnTrigger_times"].size 1qfcabeihgd

647 # Get tuple of wheel times and positions over each trial's quiescence period 

648 qevt_start_times = data["stimOnTrigger_times"] - data["quiescence"] 1qfcabeihgd

649 traces = traces_by_trial( 1qfcabeihgd

650 data["wheel_timestamps"], 

651 data["wheel_position"], 

652 start=qevt_start_times, 

653 end=data["stimOnTrigger_times"] 

654 ) 

655 

656 metric = np.zeros((len(data["quiescence"]), 2)) # (n_trials, n_directions) 1qfcabeihgd

657 for i, trial in enumerate(traces): 1qfcabeihgd

658 t, pos = trial 1qfcabeihgd

659 # Get the last position before the period began 

660 if pos.size > 0: 1qfcabeihgd

661 # Find the position of the preceding sample and subtract it 

662 idx = np.abs(data["wheel_timestamps"] - t[0]).argmin() - 1 1qcabed

663 origin = data["wheel_position"][idx if idx != -1 else 0] 1qcabed

664 # Find the absolute min and max relative to the last sample 

665 metric[i, :] = np.abs([np.min(pos - origin), np.max(pos - origin)]) 1qcabed

666 # Reduce to the largest displacement found in any direction 

667 metric = np.max(metric, axis=1) 1qfcabeihgd

668 metric = 180 * metric / np.pi # convert to degrees from radians 1qfcabeihgd

669 criterion = 2 # Position shouldn't change more than 2 in either direction 1qfcabeihgd

670 passed = metric < criterion 1qfcabeihgd

671 assert data["intervals"].shape[0] == len(metric) == len(passed) 1qfcabeihgd

672 return metric, passed 1qfcabeihgd

673 

674 

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

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

677 

678 Metric: M = firstMovement times 

679 Criterion: (goCue trigger time - min quiescent period) < M < response time 

680 Units: Seconds [s] 

681 

682 :param data: dict of trial data with keys ('firstMovement_times', 'goCueTrigger_times', 

683 'response_times', 'choice', 'intervals') 

684 :param min_qt: the minimum possible quiescent period (the QUIESCENT_PERIOD task parameter) 

685 """ 

686 # Depending on task version this may be a single value or an array of quiescent periods 

687 min_qt = np.array(min_qt) 1wfcabeihgd

688 if min_qt.size > data["intervals"].shape[0]: 1wfcabeihgd

689 min_qt = min_qt[:data["intervals"].shape[0]] 1e

690 

691 metric = data['firstMovement_times'] 1wfcabeihgd

692 qevt_start = data['goCueTrigger_times'] - np.array(min_qt) 1wfcabeihgd

693 response = data['response_times'] 1wfcabeihgd

694 # First movement time for each trial should be after the quiescent period and before feedback 

695 passed = np.array([a < m < b for m, a, b in zip(metric, qevt_start, response)], dtype=float) 1wfcabeihgd

696 nogo = data['choice'] == 0 1wfcabeihgd

697 passed[nogo] = np.nan # No go trial may have no movement times and that's fine 1wfcabeihgd

698 return metric, passed 1wfcabeihgd

699 

700 

701# === Sequence of events checks === 

702 

703def check_error_trial_event_sequence(data, **_): 

704 """ Check that on incorrect / miss trials, there are exactly: 

705 2 audio events (go cue sound and error sound) and 2 Bpod events (trial start, ITI), occurring 

706 in the correct order 

707 

708 Metric: M = Bpod (trial start) > audio (go cue) > audio (error) > Bpod (ITI) > Bpod (trial end) 

709 Criterion: M == True 

710 Units: -none- 

711 

712 :param data: dict of trial data with keys ('errorCue_times', 'goCue_times', 'intervals', 

713 'itiIn_times', 'correct') 

714 """ 

715 # An array the length of N trials where True means at least one event time was NaN (bad) 

716 nans = ( 1tfcabeihgd

717 np.isnan(data["intervals"][:, 0]) | 

718 np.isnan(data["goCue_times"]) | # noqa 

719 np.isnan(data["errorCue_times"]) | # noqa 

720 np.isnan(data["itiIn_times"]) | # noqa 

721 np.isnan(data["intervals"][:, 1]) 

722 ) 

723 

724 # For each trial check that the events happened in the correct order (ignore NaN values) 

725 a = np.less(data["intervals"][:, 0], data["goCue_times"], where=~nans) # Start time < go cue 1tfcabeihgd

726 b = np.less(data["goCue_times"], data["errorCue_times"], where=~nans) # Go cue < error cue 1tfcabeihgd

727 c = np.less(data["errorCue_times"], data["itiIn_times"], where=~nans) # Error cue < ITI start 1tfcabeihgd

728 d = np.less(data["itiIn_times"], data["intervals"][:, 1], where=~nans) # ITI start < end time 1tfcabeihgd

729 

730 # For each trial check all events were in order AND all event times were not NaN 

731 metric = a & b & c & d & ~nans 1tfcabeihgd

732 

733 passed = metric.astype(float) 1tfcabeihgd

734 passed[data["correct"]] = np.nan # Look only at incorrect trials 1tfcabeihgd

735 assert data["intervals"].shape[0] == len(metric) == len(passed) 1tfcabeihgd

736 return metric, passed 1tfcabeihgd

737 

738 

739def check_correct_trial_event_sequence(data, **_): 

740 """ Check that on correct trials, there are exactly: 

741 1 audio events and 3 Bpod events (valve open, trial start, ITI), occurring in the correct order 

742 

743 Metric: M = Bpod (trial start) > audio (go cue) > Bpod (valve) > Bpod (ITI) > Bpod (trial end) 

744 Criterion: M == True 

745 Units: -none- 

746 

747 :param data: dict of trial data with keys ('valveOpen_times', 'goCue_times', 'intervals', 

748 'itiIn_times', 'correct') 

749 """ 

750 # An array the length of N trials where True means at least one event time was NaN (bad) 

751 nans = ( 1ufcabeihgd

752 np.isnan(data["intervals"][:, 0]) | 

753 np.isnan(data["goCue_times"]) | # noqa 

754 np.isnan(data["valveOpen_times"]) | 

755 np.isnan(data["itiIn_times"]) | # noqa 

756 np.isnan(data["intervals"][:, 1]) 

757 ) 

758 

759 # For each trial check that the events happened in the correct order (ignore NaN values) 

760 a = np.less(data["intervals"][:, 0], data["goCue_times"], where=~nans) # Start time < go cue 1ufcabeihgd

761 b = np.less(data["goCue_times"], data["valveOpen_times"], where=~nans) # Go cue < feedback 1ufcabeihgd

762 c = np.less(data["valveOpen_times"], data["itiIn_times"], where=~nans) # Feedback < ITI start 1ufcabeihgd

763 d = np.less(data["itiIn_times"], data["intervals"][:, 1], where=~nans) # ITI start < end time 1ufcabeihgd

764 

765 # For each trial True means all events were in order AND all event times were not NaN 

766 metric = a & b & c & d & ~nans 1ufcabeihgd

767 

768 passed = metric.astype(float) 1ufcabeihgd

769 passed[~data["correct"]] = np.nan # Look only at correct trials 1ufcabeihgd

770 assert data["intervals"].shape[0] == len(metric) == len(passed) 1ufcabeihgd

771 return metric, passed 1ufcabeihgd

772 

773 

774def check_n_trial_events(data, **_): 

775 """ Check that the number events per trial is correct 

776 Within every trial interval there should be one of each trial event, except for 

777 goCueTrigger_times which should only be defined for incorrect trials 

778 

779 Metric: M = all(start < event < end) for all event times except errorCueTrigger_times where 

780 start < error_trigger < end if not correct trial, else error_trigger == NaN 

781 Criterion: M == True 

782 Units: -none-, boolean 

783 

784 :param data: dict of trial data with keys ('intervals', 'stimOnTrigger_times', 

785 'stimOffTrigger_times', 'stimOn_times', 'stimOff_times', 

786 'stimFreezeTrigger_times', 'errorCueTrigger_times', 'itiIn_times', 

787 'goCueTrigger_times', 'goCue_times', 'response_times', 'feedback_times') 

788 """ 

789 

790 intervals = data['intervals'] 1sfcabeihgd

791 correct = data['correct'] 1sfcabeihgd

792 err_trig = data['errorCueTrigger_times'] 1sfcabeihgd

793 

794 # Exclude these fields; valve and errorCue times are the same as feedback_times and we must 

795 # test errorCueTrigger_times separately 

796 # stimFreeze_times fails often due to TTL flicker 

797 exclude = ['camera_timestamps', 'errorCueTrigger_times', 'errorCue_times', 1sfcabeihgd

798 'firstMovement_times', 'peakVelocity_times', 'valveOpen_times', 

799 'wheel_moves_peak_amplitude', 'wheel_moves_intervals', 'wheel_timestamps', 

800 'wheel_intervals', 'stimFreeze_times'] 

801 events = [k for k in data.keys() if k.endswith('_times') and k not in exclude] 1sfcabeihgd

802 metric = np.zeros(data["intervals"].shape[0], dtype=bool) 1sfcabeihgd

803 

804 # For each trial interval check that one of each trial event occurred. For incorrect trials, 

805 # check the error cue trigger occurred within the interval, otherwise check it is nan. 

806 for i, (start, end) in enumerate(intervals): 1sfcabeihgd

807 metric[i] = (all([start < data[k][i] < end for k in events]) and 1sfcabeihgd

808 (np.isnan(err_trig[i]) if correct[i] else start < err_trig[i] < end)) 

809 passed = metric.astype(bool) 1sfcabeihgd

810 assert intervals.shape[0] == len(metric) == len(passed) 1sfcabeihgd

811 return metric, passed 1sfcabeihgd

812 

813 

814def check_trial_length(data, **_): 

815 """ Check that the time difference between the onset of the go cue sound 

816 and the feedback (error sound or valve) is positive and smaller than 60.1 s. 

817 

818 Metric: M = feedback_times - goCue_times 

819 Criteria: 0 < M < 60.1 s 

820 Units: seconds [s] 

821 

822 :param data: dict of trial data with keys ('feedback_times', 'goCue_times', 'intervals') 

823 """ 

824 # NaN values are usually ignored so replace them with Inf so they fail the threshold 

825 metric = np.nan_to_num(data["feedback_times"] - data["goCue_times"], nan=np.inf) 1Ifcabeihgd

826 passed = (metric < 60.1) & (metric > 0) 1Ifcabeihgd

827 assert data["intervals"].shape[0] == len(metric) == len(passed) 1Ifcabeihgd

828 return metric, passed 1Ifcabeihgd

829 

830 

831# === Trigger-response delay checks === 

832 

833def check_goCue_delays(data, **_): 

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

835 effectively played is smaller than 1ms. 

836 

837 Metric: M = goCue_times - goCueTrigger_times 

838 Criterion: 0 < M <= 0.001 s 

839 Units: seconds [s] 

840 

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

842 """ 

843 metric = np.nan_to_num(data["goCue_times"] - data["goCueTrigger_times"], nan=np.inf) 1kJjfcabeihgd

844 passed = (metric <= 0.0015) & (metric > 0) 1kJjfcabeihgd

845 assert data["intervals"].shape[0] == len(metric) == len(passed) 1kJjfcabeihgd

846 return metric, passed 1kJjfcabeihgd

847 

848 

849def check_errorCue_delays(data, **_): 

850 """ Check that the time difference between the error sound being triggered and 

851 effectively played is smaller than 1ms. 

852 Metric: M = errorCue_times - errorCueTrigger_times 

853 Criterion: 0 < M <= 0.001 s 

854 Units: seconds [s] 

855 

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

857 'intervals', 'correct') 

858 """ 

859 metric = np.nan_to_num(data["errorCue_times"] - data["errorCueTrigger_times"], nan=np.inf) 1Ffcabeihgd

860 passed = ((metric <= 0.0015) & (metric > 0)).astype(float) 1Ffcabeihgd

861 passed[data["correct"]] = metric[data["correct"]] = np.nan 1Ffcabeihgd

862 assert data["intervals"].shape[0] == len(metric) == len(passed) 1Ffcabeihgd

863 return metric, passed 1Ffcabeihgd

864 

865 

866def check_stimOn_delays(data, **_): 

867 """ Check that the time difference between the visual stimulus onset-command being triggered 

868 and the stimulus effectively appearing on the screen is smaller than 150 ms. 

869 

870 Metric: M = stimOn_times - stimOnTrigger_times 

871 Criterion: 0 < M < 0.150 s 

872 Units: seconds [s] 

873 

874 :param data: dict of trial data with keys ('stimOn_times', 'stimOnTrigger_times', 

875 'intervals') 

876 """ 

877 metric = np.nan_to_num(data["stimOn_times"] - data["stimOnTrigger_times"], nan=np.inf) 1kKjfcabeihgd

878 passed = (metric <= 0.15) & (metric > 0) 1kKjfcabeihgd

879 assert data["intervals"].shape[0] == len(metric) == len(passed) 1kKjfcabeihgd

880 return metric, passed 1kKjfcabeihgd

881 

882 

883def check_stimOff_delays(data, **_): 

884 """ Check that the time difference between the visual stimulus offset-command 

885 being triggered and the visual stimulus effectively turning off on the screen 

886 is smaller than 150 ms. 

887 

888 Metric: M = stimOff_times - stimOffTrigger_times 

889 Criterion: 0 < M < 0.150 s 

890 Units: seconds [s] 

891 

892 :param data: dict of trial data with keys ('stimOff_times', 'stimOffTrigger_times', 

893 'intervals') 

894 """ 

895 metric = np.nan_to_num(data["stimOff_times"] - data["stimOffTrigger_times"], nan=np.inf) 1kLjfcabeihgd

896 passed = (metric <= 0.15) & (metric > 0) 1kLjfcabeihgd

897 assert data["intervals"].shape[0] == len(metric) == len(passed) 1kLjfcabeihgd

898 return metric, passed 1kLjfcabeihgd

899 

900 

901def check_stimFreeze_delays(data, **_): 

902 """ Check that the time difference between the visual stimulus freeze-command 

903 being triggered and the visual stimulus effectively freezing on the screen 

904 is smaller than 150 ms. 

905 

906 Metric: M = stimFreeze_times - stimFreezeTrigger_times 

907 Criterion: 0 < M < 0.150 s 

908 Units: seconds [s] 

909 

910 :param data: dict of trial data with keys ('stimFreeze_times', 'stimFreezeTrigger_times', 

911 'intervals') 

912 """ 

913 metric = np.nan_to_num(data["stimFreeze_times"] - data["stimFreezeTrigger_times"], nan=np.inf) 1Mfcabeihgd

914 passed = (metric <= 0.15) & (metric > 0) 1Mfcabeihgd

915 assert data["intervals"].shape[0] == len(metric) == len(passed) 1Mfcabeihgd

916 return metric, passed 1Mfcabeihgd

917 

918 

919# === Data integrity checks === 

920 

921def check_reward_volumes(data, **_): 

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

923 

924 Metric: M = reward volume 

925 Criterion: 1.5 <= M <= 3 if correct else M == 0 

926 Units: uL 

927 

928 :param data: dict of trial data with keys ('rewardVolume', 'correct', 'intervals') 

929 """ 

930 metric = data['rewardVolume'] 1zfcabeihgd

931 correct = data['correct'] 1zfcabeihgd

932 passed = np.zeros_like(metric, dtype=bool) 1zfcabeihgd

933 # Check correct trials within correct range 

934 passed[correct] = (1.5 <= metric[correct]) & (metric[correct] <= 3.) 1zfcabeihgd

935 # Check incorrect trials are 0 

936 passed[~correct] = metric[~correct] == 0 1zfcabeihgd

937 assert data["intervals"].shape[0] == len(metric) == len(passed) 1zfcabeihgd

938 return metric, passed 1zfcabeihgd

939 

940 

941def check_reward_volume_set(data, **_): 

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

943 

944 Metric: M = set(rewardVolume) 

945 Criterion: (0 < len(M) <= 2) and 0 in M 

946 

947 :param data: dict of trial data with keys ('rewardVolume') 

948 """ 

949 metric = data["rewardVolume"] 1Nfcabeihgd

950 passed = 0 < len(set(metric)) <= 2 and 0. in metric 1Nfcabeihgd

951 return metric, passed 1Nfcabeihgd

952 

953 

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

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

956 and that the wheel timestamps strictly increase. 

957 

958 Note: At high velocities some samples are missed due to the scanning frequency of the DAQ. 

959 This checks for more than 1 missing sample in a row (i.e. the difference between samples >= 2) 

960 

961 Metric: M = (absolute difference of the positions < 1.5 * encoder resolution) 

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

963 Criterion: M ~= 0 

964 Units: arbitrary (radians, sometimes + 1) 

965 

966 :param data: dict of wheel data with keys ('wheel_timestamps', 'wheel_position') 

967 :param re_encoding: the encoding of the wheel data, X1, X2 or X4 

968 :param enc_res: the rotary encoder resolution (default 1024 ticks per revolution) 

969 """ 

970 if isinstance(re_encoding, str): 1xfcabeihgd

971 re_encoding = int(re_encoding[-1]) 1xfcabeihgd

972 # The expected difference between samples in the extracted units 

973 resolution = 1 / (enc_res or ephys_fpga.WHEEL_TICKS 1xfcabeihgd

974 ) * np.pi * 2 * ephys_fpga.WHEEL_RADIUS_CM / re_encoding 

975 # We expect the difference of neighbouring positions to be close to the resolution 

976 pos_check = np.abs(np.diff(data['wheel_position'])) 1xfcabeihgd

977 # Timestamps should be strictly increasing 

978 ts_check = np.diff(data['wheel_timestamps']) <= 0. 1xfcabeihgd

979 metric = pos_check + ts_check.astype(float) # all values should be close to zero 1xfcabeihgd

980 passed = metric < 1.5 * resolution 1xfcabeihgd

981 return metric, passed 1xfcabeihgd

982 

983 

984# === Pre-stimulus checks === 

985def check_stimulus_move_before_goCue(data, photodiode=None, **_): 

986 """ Check that there are no visual stimulus change(s) between the start of the trial and the 

987 go cue sound onset - 20 ms. 

988 

989 Metric: M = number of visual stimulus change events between trial start and goCue_times - 20ms 

990 Criterion: M == 0 

991 Units: -none-, integer 

992 

993 :param data: dict of trial data with keys ('goCue_times', 'intervals', 'choice') 

994 :param photodiode: the fronts from Bpod's BNC1 input or FPGA frame2ttl channel 

995 """ 

996 if photodiode is None: 1fcabeihgd

997 _log.warning("No photodiode TTL input in function call, returning None") 

998 return None 

999 photodiode_clean = ephys_fpga._clean_frame2ttl(photodiode) 1fcabeihgd

1000 s = photodiode_clean["times"] 1fcabeihgd

1001 s = s[~np.isnan(s)] # Remove NaNs 1fcabeihgd

1002 metric = np.array([]) 1fcabeihgd

1003 for i, c in zip(data["intervals"][:, 0], data["goCue_times"]): 1fcabeihgd

1004 metric = np.append(metric, np.count_nonzero(s[s > i] < (c - 0.02))) 1fcabeihgd

1005 

1006 passed = (metric == 0).astype(float) 1fcabeihgd

1007 # Remove no go trials 

1008 passed[data["choice"] == 0] = np.nan 1fcabeihgd

1009 assert data["intervals"].shape[0] == len(metric) == len(passed) 1fcabeihgd

1010 return metric, passed 1fcabeihgd

1011 

1012 

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

1014 """ Check that there are no audio outputs between the start of the trial and the 

1015 go cue sound onset - 20 ms. 

1016 

1017 Metric: M = sum(start_times < audio TTL < (goCue_times - 20ms)) 

1018 Criterion: M == 0 

1019 Units: -none-, integer 

1020 

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

1022 :param audio: the fronts from Bpod's BNC2 input FPGA audio sync channel 

1023 """ 

1024 if audio is None: 1yfcabeihgd

1025 _log.warning("No BNC2 input in function call, retuning None") 

1026 return None 

1027 s = audio["times"][~np.isnan(audio["times"])] # Audio TTLs with NaNs removed 1yfcabeihgd

1028 metric = np.array([], dtype=np.int8) 1yfcabeihgd

1029 for i, c in zip(data["intervals"][:, 0], data["goCue_times"]): 1yfcabeihgd

1030 metric = np.append(metric, sum(s[s > i] < (c - 0.02))) 1yfcabeihgd

1031 passed = metric == 0 1yfcabeihgd

1032 assert data["intervals"].shape[0] == len(metric) == len(passed) 1yfcabeihgd

1033 return metric, passed 1yfcabeihgd