Coverage for ibllib/qc/critical_reasons.py: 88%

223 statements  

« prev     ^ index     » next       coverage.py v7.7.0, created at 2025-03-17 15:25 +0000

1""" 

2Methods for adding QC sign-off notes to Alyx. 

3 

4Includes a GUI to prompt experimenter for reason for marking session/insertion as CRITICAL. 

5Choices are listed in the global variables. Multiple reasons can be selected. 

6Places info in Alyx session note in a format that is machine retrievable (text->json). 

7""" 

8import abc 

9import logging 

10import json 

11from datetime import datetime 

12from one.webclient import AlyxClient 

13from one.alf.spec import is_uuid 

14 

15_logger = logging.getLogger('ibllib') 

16 

17 

18def main_gui(uuid, reasons_selected, alyx=None): 

19 """ 

20 Main function to call to input a reason for marking an insertion as CRITICAL from the alignment GUI. 

21 

22 It wil create note text, after deleting any similar notes existing already. 

23 

24 Parameters 

25 ---------- 

26 uuid : uuid.UUID, str 

27 An insertion ID. 

28 reasons_selected : list of str 

29 A subset of REASONS_INS_CRIT_GUI. 

30 alyx : one.webclient.AlyxClient 

31 An AlyxClient instance. 

32 """ 

33 # hit the database to check if uuid is insertion uuid 

34 alyx = alyx or AlyxClient() 1g

35 ins_list = alyx.rest('insertions', 'list', id=uuid, no_cache=True) 1g

36 if len(ins_list) != 1: 1g

37 raise ValueError(f'N={len(ins_list)} insertion found, expected N=1. Check uuid provided.') 

38 

39 note = CriticalInsertionNote(uuid, alyx) 1g

40 

41 # assert that reasons are all within REASONS_INS_CRIT_GUI 

42 for item_str in reasons_selected: 1g

43 assert item_str in note.descriptions_gui 1g

44 

45 note.selected_reasons = reasons_selected 1g

46 note.other_reason = [] 1g

47 note._upload_note(overwrite=True) 1g

48 

49 

50def main(uuid, alyx=None): 

51 """ 

52 Main function to call to input a reason for marking a session/insertion as CRITICAL programmatically. 

53 

54 It will: 

55 - ask reasons for selection of critical status 

56 - check if 'other' reason has been selected, inquire why (free text) 

57 - create note text, checking whether similar notes exist already 

58 - upload note to Alyx if none exist previously or if overwrite is chosen Q&A are prompted via the Python terminal 

59 

60 Parameters 

61 ---------- 

62 uuid : uuid.UUID, str 

63 An experiment UUID or an insertion UUID. 

64 alyx : one.webclient.AlyxClient 

65 An AlyxClient instance. 

66 

67 Examples 

68 -------- 

69 Retrieve Alyx note to test 

70 

71 >>> alyx = AlyxClient(base_url='https://dev.alyx.internationalbrainlab.org') 

72 >>> uuid = '2ffd3ed5-477e-4153-9af7-7fdad3c6946b' 

73 >>> main(uuid=uuid, alyx=alyx) 

74 

75 Get notes with pattern 

76 

77 >>> notes = alyx.rest('notes', 'list', 

78 ... django=f'text__icontains,{STR_NOTES_STATIC},' 

79 ... f'object_id,{uuid}') 

80 >>> test_json_read = json.loads(notes[0]['text']) 

81 

82 """ 

83 alyx = alyx or AlyxClient() 1cef

84 # ask reasons for selection of critical status 

85 

86 # hit the database to know if uuid is insertion or session uuid 

87 sess_list = alyx.get('/sessions?&django=pk,' + str(uuid), clobber=True) 1cef

88 ins_list = alyx.get('/insertions?&django=pk,' + str(uuid), clobber=True) 1cef

89 

90 if len(sess_list) > 0 and len(ins_list) == 0: # session 1cef

91 note = CriticalSessionNote(uuid, alyx) 1cf

92 elif len(ins_list) > 0 and len(sess_list) == 0: # insertion 1e

93 note = CriticalInsertionNote(uuid, alyx) 1e

94 else: 

95 raise ValueError(f'Inadequate number of session (n={len(sess_list)}) ' 

96 f'or insertion (n={len(ins_list)}) found for uuid {uuid}.' 

97 f'The query output should be of length 1.') 

98 

99 note.upload_note() 1cef

100 

101 

102class Note(abc.ABC): 

103 descriptions = [] 

104 

105 @property 

106 def default_descriptions(self): 

107 return self.descriptions + ['Other'] 1bdcf

108 

109 @property 

110 def extra_prompt(self): 

111 return '' 

112 

113 @property 

114 def note_title(self): 

115 return '' 

116 

117 @property 

118 def n_description(self): 

119 return len(self.default_descriptions) 1bdcef

120 

121 def __init__(self, uuid, alyx, content_type=None): 

122 """ 

123 Base class for attaching notes to an alyx endpoint. Do not use this class directly but use parent classes that inherit 

124 this base class 

125 

126 Parameters 

127 ---------- 

128 uuid : uuid.UUID, str 

129 A UUID of a session, insertion, or other Alyx model to attach note to. 

130 alyx : one.webclient.AlyxClient 

131 An AlyxClient instance. 

132 content_type : str 

133 The Alyx model name of the UUID. 

134 """ 

135 if not is_uuid(uuid, versions=(4,)): 1hbdgcef

136 raise ValueError('Expected `uuid` to be a UUIDv4 object') 

137 self.uuid = uuid 1hbdgcef

138 self.alyx = alyx 1hbdgcef

139 self.selected_reasons = [] 1hbdgcef

140 self.other_reason = [] 1hbdgcef

141 if content_type is not None: 1hbdgcef

142 self.content_type = content_type 1hbdgcef

143 else: 

144 self.content_type = self.get_content_type() 

145 

146 def get_content_type(self): 

147 """ 

148 Infer the content_type from the uuid. Only checks to see if uuid is a session or insertion. 

149 If not recognised will raise an error and the content_type must be specified on note 

150 initialisation e.g. Note(uuid, alyx, content_type='subject') 

151 

152 Returns 

153 ------- 

154 str 

155 The Alyx model name, inferred from the UUID. 

156 """ 

157 

158 # see if it as session or an insertion 

159 if self.alyx.rest('sessions', 'list', id=self.uuid): 

160 content_type = 'session' 

161 elif self.alyx.rest('insertions', 'list', id=self.uuid): 

162 content_type = 'probeinsertion' 

163 else: 

164 raise ValueError(f'Content type cannot be recognised from {self.uuid}. ' 

165 'Specify on initialistion e.g Note(uuid, alyx, content_type="subject"') 

166 return content_type 

167 

168 def describe(self): 

169 """ 

170 Print list of default reasons that can be chosen from 

171 :return: 

172 """ 

173 for i, d in enumerate(self.descriptions): 

174 print(f'{i}. {d} \n') 

175 

176 def numbered_descriptions(self): 

177 """ 

178 Return list of numbered default reasons 

179 :return: 

180 """ 

181 return [f'{i}) {d}' for i, d in enumerate(self.default_descriptions)] 1bdcef

182 

183 def upload_note(self, nums=None, other_reason=None, **kwargs): 

184 """ 

185 Upload note to Alyx. 

186 

187 If no values for nums and other_reason are specified, user will receive a prompt in command 

188 line asking them to choose from default list of reasons to add to note as well as option 

189 for free text. To upload without receiving prompt a value for either `nums` or 

190 `other_reason` must be given. 

191 

192 Parameters 

193 ---------- 

194 nums : str 

195 string of numbers matching those in default descriptions, e.g, '1,3'. Options can be 

196 seen using note.describe(). 

197 other_reason : str 

198 Other comment or reason(s) to add to note. 

199 

200 """ 

201 

202 if nums is None and other_reason is None: 1bdcef

203 self.selected_reasons, self.other_reason = self.reasons_prompt() 1bdcef

204 else: 

205 self.selected_reasons = self._map_num_to_description(nums) 1b

206 self.other_reason = other_reason or [] 1b

207 

208 self._upload_note(**kwargs) 1bdcef

209 

210 def _upload_note(self, **kwargs): 

211 existing_note, notes = self._check_existing_note() 1bdgcef

212 if existing_note: 1bdgcef

213 self.update_existing_note(notes, **kwargs) 1bc

214 else: 

215 text = self.format_note(**kwargs) 1bdgcef

216 self._create_note(text) 1bdgcef

217 _logger.info('The selected reasons were saved on Alyx.') 1bdgcef

218 

219 def _create_note(self, text): 

220 

221 data = {'user': self.alyx.user, 1bdgcef

222 'content_type': self.content_type, 

223 'object_id': self.uuid, 

224 'text': f'{text}'} 

225 self.alyx.rest('notes', 'create', data=data) 1bdgcef

226 

227 def _update_note(self, note_id, text): 

228 

229 data = {'user': self.alyx.user, 1b

230 'content_type': self.content_type, 

231 'object_id': self.uuid, 

232 'text': f'{text}'} 

233 self.alyx.rest('notes', 'partial_update', id=note_id, data=data) 1b

234 

235 def _delete_note(self, note_id): 

236 self.alyx.rest('notes', 'delete', id=note_id) 1c

237 

238 def _delete_notes(self, notes): 

239 for note in notes: 1c

240 self._delete_note(note['id']) 1c

241 

242 def _check_existing_note(self): 

243 query = f'text__icontains,{self.note_title},object_id,{str(self.uuid)}' 1bdgcef

244 notes = self.alyx.rest('notes', 'list', django=query, no_cache=True) 1bdgcef

245 if len(notes) == 0: 1bdgcef

246 return False, None 1bdgcef

247 else: 

248 return True, notes 1bc

249 

250 def _map_num_to_description(self, nums): 

251 

252 if nums is None: 1bdcef

253 return [] 

254 

255 string_list = nums.split(',') 1bdcef

256 int_list = list(map(int, string_list)) 1bdcef

257 

258 if max(int_list) >= self.n_description or min(int_list) < 0: 1bdcef

259 raise ValueError(f'Chosen values out of range, must be between 0 and {self.n_description - 1}') 

260 

261 return [self.default_descriptions[n] for n in int_list] 1bdcef

262 

263 def reasons_prompt(self): 

264 """ 

265 Prompt for user to enter reasons 

266 :return: 

267 """ 

268 

269 prompt = f'{self.extra_prompt} ' \ 1bdcef

270 f'\n {self.numbered_descriptions()} \n ' \ 

271 f'and enter the corresponding numbers separated by commas, e.g. 1,3 -> enter: ' 

272 

273 ans = input(prompt).strip().lower() 1bdcef

274 

275 try: 1bdcef

276 selected_reasons = self._map_num_to_description(ans) 1bdcef

277 print(f'You selected reason(s): {selected_reasons}') 1bdcef

278 if 'Other' in selected_reasons: 1bdcef

279 other_reasons = self.other_reason_prompt() 1bdcef

280 return selected_reasons, other_reasons 1bdcef

281 else: 

282 return selected_reasons, [] 

283 

284 except ValueError: 

285 print(f'{ans} is invalid, please try again...') 

286 return self.reasons_prompt() 

287 

288 def other_reason_prompt(self): 

289 """ 

290 Prompt for user to enter other reasons 

291 :return: 

292 """ 

293 

294 prompt = 'Explain why you selected "other" (free text): ' 1bdcef

295 ans = input(prompt).strip().lower() 1bdcef

296 return ans 1bdcef

297 

298 @abc.abstractmethod 

299 def format_note(self, **kwargs): 

300 """ 

301 Method to format text field of note according to type of note wanting to be uploaded 

302 :param kwargs: 

303 :return: 

304 """ 

305 

306 @abc.abstractmethod 

307 def update_existing_note(self, note, **kwargs): 

308 """ 

309 Method to specify behavior in the case of a note with the same title already existing 

310 :param note: 

311 :param kwargs: 

312 :return: 

313 """ 

314 

315 

316class CriticalNote(Note): 

317 """ 

318 Class for uploading a critical note to a session or insertion. Do not use directly but use CriticalSessionNote or 

319 CriticalInsertionNote instead 

320 """ 

321 

322 def format_note(self, **kwargs): 

323 note_text = { 1gcef

324 "title": self.note_title, 

325 "reasons_selected": self.selected_reasons, 

326 "reason_for_other": self.other_reason 

327 } 

328 return json.dumps(note_text) 1gcef

329 

330 def update_existing_note(self, notes, **kwargs): 

331 

332 overwrite = kwargs.get('overwrite', None) 1c

333 if overwrite is None: 1c

334 overwrite = self.delete_note_prompt(notes) 1c

335 

336 if overwrite: 1c

337 self._delete_notes(notes) 1c

338 text = self.format_note() 1c

339 self._create_note(text) 1c

340 _logger.info('The selected reasons were saved on Alyx; old notes were deleted') 1c

341 else: 

342 _logger.info('The selected reasons were NOT saved on Alyx; old notes remain.') 

343 

344 def delete_note_prompt(self, notes): 

345 

346 prompt = f'You are about to delete {len(notes)} existing notes; ' \ 1c

347 f'do you want to proceed? y/n: ' 

348 

349 ans = input(prompt).strip().lower() 1c

350 

351 if ans not in ['y', 'n']: 1c

352 print(f'{ans} is invalid, please try again...') 

353 return self.delete_note_prompt(notes) 

354 else: 

355 return True if ans == 'y' else False 1c

356 

357 

358class CriticalInsertionNote(CriticalNote): 

359 """ 

360 Class for uploading a critical note to an insertion. 

361 

362 Examples 

363 -------- 

364 >>> note = CriticalInsertionNote(pid, AlyxClient()) 

365 

366 Print list of default reasons 

367 

368 >>> note.describe() 

369 

370 To receive a command line prompt to fill in note 

371 

372 >>> note.upload_note() 

373 

374 To upload note automatically without prompt 

375 

376 >>> note.upload_note(nums='1,4', other_reason='lots of bad channels') 

377 """ 

378 

379 descriptions_gui = [ 

380 'Noise and artifact', 

381 'Drift', 

382 'Poor neural yield', 

383 'Brain Damage', 

384 'Other' 

385 ] 

386 

387 descriptions = [ 

388 'Histological images missing', 

389 'Track not visible on imaging data' 

390 ] 

391 

392 @property 

393 def default_descriptions(self): 

394 return self.descriptions + self.descriptions_gui 1e

395 

396 @property 

397 def extra_prompt(self): 

398 return 'Select from this list the reason(s) why you are marking the insertion as CRITICAL:' 1e

399 

400 @property 

401 def note_title(self): 

402 return '=== EXPERIMENTER REASON(S) FOR MARKING THE INSERTION AS CRITICAL ===' 1ge

403 

404 def __init__(self, uuid, alyx): 

405 super(CriticalInsertionNote, self).__init__(uuid, alyx, content_type='probeinsertion') 1ge

406 

407 

408class CriticalSessionNote(CriticalNote): 

409 """ 

410 Class for uploading a critical note to a session. 

411 

412 Example 

413 ------- 

414 >>> note = CriticalInsertionNote(uuid, AlyxClient) 

415 

416 Print list of default reasons 

417 

418 >>> note.describe() 

419 

420 To receive a command line prompt to fill in note 

421 

422 >>> note.upload_note() 

423 

424 To upload note automatically without prompt 

425 

426 >>> note.upload_note(nums='1,4', other_reason='session with no ephys recording') 

427 """ 

428 

429 descriptions = [ 

430 'within experiment system crash', 

431 'synching impossible', 

432 'dud or mock session', 

433 'essential dataset missing', 

434 ] 

435 

436 @property 

437 def extra_prompt(self): 

438 return 'Select from this list the reason(s) why you are marking the session as CRITICAL:' 1cf

439 

440 @property 

441 def note_title(self): 

442 return '=== EXPERIMENTER REASON(S) FOR MARKING THE SESSION AS CRITICAL ===' 1cf

443 

444 def __init__(self, uuid, alyx): 

445 super(CriticalSessionNote, self).__init__(uuid, alyx, content_type='session') 1cf

446 

447 

448class SignOffNote(Note): 

449 """ 

450 Class for signing off a session and optionally adding a related explanation note. 

451 Do not use directly but use classes that inherit from this class e.g TaskSignOffNote, RawEphysSignOffNote 

452 """ 

453 

454 @property 

455 def extra_prompt(self): 

456 return 'Select from this list the reason(s) that describe issues with this session:' 1bd

457 

458 @property 

459 def note_title(self): 

460 return f'=== SIGN-OFF NOTE FOR {self.sign_off_key} ===' 1bd

461 

462 def __init__(self, uuid, alyx, sign_off_key): 

463 self.sign_off_key = sign_off_key 1hbd

464 super(SignOffNote, self).__init__(uuid, alyx, content_type='session') 1hbd

465 self.datetime_key = self.get_datetime_key() 1hbd

466 self.session = self.alyx.rest('sessions', 'read', id=self.uuid, no_cache=True) 1hbd

467 

468 def upload_note(self, nums=None, other_reason=None, **kwargs): 

469 super(SignOffNote, self).upload_note(nums=nums, other_reason=other_reason, **kwargs) 1bd

470 self.sign_off() 1bd

471 

472 def sign_off(self): 

473 

474 json = self.session['json'] 1hbd

475 sign_off_checklist = json.get('sign_off_checklist', None) 1hbd

476 if sign_off_checklist is None: 1hbd

477 sign_off_checklist = {self.sign_off_key: {'date': self.datetime_key.split('_')[0], 

478 'user': self.datetime_key.split('_')[1]}} 

479 else: 

480 sign_off_checklist[self.sign_off_key] = {'date': self.datetime_key.split('_')[0], 1hbd

481 'user': self.datetime_key.split('_')[1]} 

482 

483 json['sign_off_checklist'] = sign_off_checklist 1hbd

484 

485 self.alyx.json_field_update("sessions", self.uuid, 'json', data=json) 1hbd

486 

487 def format_note(self, **kwargs): 

488 

489 note_text = { 1bd

490 "title": self.note_title, 

491 f'{self.datetime_key}': {"reasons_selected": self.selected_reasons, 

492 "reason_for_other": self.other_reason} 

493 } 

494 

495 return json.dumps(note_text) 1bd

496 

497 def format_existing_note(self, orignal_note): 

498 

499 extra_note = {f'{self.datetime_key}': {"reasons_selected": self.selected_reasons, 1b

500 "reason_for_other": self.other_reason} 

501 } 

502 

503 orignal_note.update(extra_note) 1b

504 

505 return json.dumps(orignal_note) 1b

506 

507 def update_existing_note(self, notes): 

508 if len(notes) != 1: 1b

509 raise ValueError(f'{len(notes)} with same title found, only expect at most 1. Clean up before proceeding') 

510 else: 

511 original_note = json.loads(notes[0]['text']) 1b

512 text = self.format_existing_note(original_note) 1b

513 self._update_note(notes[0]['id'], text) 1b

514 

515 def get_datetime_key(self): 

516 if not self.alyx.is_logged_in: 1hbd

517 self.alyx.authenticate() 

518 assert self.alyx.is_logged_in, 'you must be logged in to the AlyxClient' 

519 user = self.alyx.user 1hbd

520 date = datetime.now().date().isoformat() 1hbd

521 return date + '_' + user 1hbd

522 

523 

524class TaskSignOffNote(SignOffNote): 

525 

526 """ 

527 Class for signing off a task part of a session and optionally adding a related explanation note. 

528 

529 Examples 

530 -------- 

531 >>> note = TaskSignOffNote(eid, AlyxClient(), '_ephysChoiceWorld_00') 

532 

533 To sign off session without any note 

534 

535 >>> note.sign_off() 

536 

537 Print list of default reasons 

538 

539 >>> note.describe() 

540 

541 To upload note and sign off with prompt 

542 

543 >>> note.upload_note() 

544 

545 To upload note automatically without prompt 

546 

547 >>> note.upload_note(nums='1,4', other_reason='session with no ephys recording') 

548 """ 

549 

550 descriptions = [ 

551 'raw trial data does not exist', 

552 'wheel data corrupt', 

553 'task data could not be synced', 

554 'stimulus timings unreliable' 

555 ] 

556 

557 

558class PassiveSignOffNote(SignOffNote): 

559 

560 """ 

561 Class for signing off a passive part of a session and optionally adding a related explanation note. 

562 

563 Examples 

564 -------- 

565 >>> note = PassiveSignOffNote(eid, AlyxClient(), '_passiveChoiceWorld_00') 

566 

567 To sign off session without any note 

568 

569 >>> note.sign_off() 

570 

571 Print list of default reasons 

572 

573 >>> note.describe() 

574 

575 To upload note and sign off with prompt 

576 

577 >>> note.upload_note() 

578 

579 To upload note automatically without prompt 

580 

581 >>> note.upload_note(nums='1,4', other_reason='session with no ephys recording') 

582 """ 

583 

584 descriptions = [ 

585 'Raw passive data doesn’t exist (no. of spacers = 0)', 

586 'Incorrect number or spacers (i.e passive cutoff midway)', 

587 'RFmap file doesn’t exist', 

588 'Gabor patches couldn’t be extracted', 

589 'Trial playback couldn’t be extracted', 

590 ] 

591 

592 

593class VideoSignOffNote(SignOffNote): 

594 

595 """ 

596 Class for signing off a video part of a session and optionally adding a related explanation note. 

597 

598 Examples 

599 -------- 

600 >>> note = VideoSignOffNote(eid, AlyxClient(), '_camera_left') 

601 

602 To sign off session without any note 

603 

604 >>> note.sign_off() 

605 

606 Print list of default reasons 

607 

608 >>> note.describe() 

609 

610 To upload note and sign off with prompt 

611 

612 >>> note.upload_note() 

613 

614 To upload note automatically without prompt 

615 

616 >>> note.upload_note(nums='1,4', other_reason='session with no ephys recording') 

617 """ 

618 

619 descriptions = [ 

620 'The video timestamps are not the same length as the video file (either empty or slightly longer/shorter)', 

621 'The rotary encoder trace doesn’t not appear synced with the video', 

622 'The QC fails because the GPIO file is missing or empty', 

623 'The frame rate in the video header is wrong (the video plays too slow or fast)', 

624 'The resolution is not what is defined in the experiment description file', 

625 'The DLC QC fails because something is obscuring the pupil', 

626 ] 

627 

628 

629class RawEphysSignOffNote(SignOffNote): 

630 

631 """ 

632 Class for signing off a raw ephys part of a session and optionally adding a related explanation note. 

633 

634 Examples 

635 -------- 

636 >>> note = RawEphysSignOffNote(uuid, AlyxClient(), '_neuropixel_raw_probe00') 

637 

638 To sign off session without any note 

639 

640 >>> note.sign_off() 

641 

642 Print list of default reasons 

643 

644 >>> note.describe() 

645 

646 To upload note and sign off with prompt 

647 

648 >>> note.upload_note() 

649 

650 To upload note automatically without prompt 

651 

652 >>> note.upload_note(nums='1,4', other_reason='session with no ephys recording') 

653 """ 

654 

655 descriptions = [ 

656 'Data has striping', 

657 'Horizontal band', 

658 'Discontunuity', 

659 ] 

660 

661 

662class SpikeSortingSignOffNote(SignOffNote): 

663 

664 """ 

665 Class for signing off a spike sorting part of a session and optionally adding a related explanation note. 

666 

667 Examples 

668 -------- 

669 >>> note = SpikeSortingSignOffNote(uuid, AlyxClient(), '_neuropixel_spike_sorting_probe00') 

670 

671 To sign off session without any note 

672 

673 >>> note.sign_off() 

674 

675 Print list of default reasons 

676 

677 >>> note.describe() 

678 

679 To upload note and sign off with prompt 

680 

681 >>> note.upload_note() 

682 

683 To upload note automatically without prompt 

684 

685 >>> note.upload_note(nums='1,4', other_reason='session with no ephys recording') 

686 """ 

687 

688 descriptions = [ 

689 'Spikesorting could not be run', 

690 'Poor quality spikesorting', 

691 ] 

692 

693 

694class AlignmentSignOffNote(SignOffNote): 

695 

696 """ 

697 Class for signing off a alignment part of a session and optionally adding a related explanation note. 

698 

699 Examples 

700 -------- 

701 >>> note = AlignmentSignOffNote(uuid, AlyxClient(), '_neuropixel_alignment_probe00') 

702 

703 To sign off session without any note 

704 

705 >>> note.sign_off() 

706 

707 Print list of default reasons 

708 

709 >>> note.describe() 

710 

711 To upload note and sign off with prompt 

712 

713 >>> note.upload_note() 

714 

715 To upload note automatically without prompt 

716 

717 >>> note.upload_note(nums='1,4', other_reason='session with no ephys recording') 

718 """ 

719 

720 descriptions = []