Coverage for ibllib/qc/critical_reasons.py: 89%
230 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-11 11:13 +0100
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-11 11:13 +0100
1"""
2Prompt experimenter for reason for marking session/insertion as CRITICAL
3Choices are listed in the global variables. Multiple reasons can be selected.
4Places info in Alyx session note in a format that is machine retrievable (text->json)
5"""
6import abc
7import logging
8import json
9import warnings
10from datetime import datetime
11from one.api import OneAlyx
12from one.webclient import AlyxClient
14_logger = logging.getLogger('ibllib')
17def main_gui(uuid, reasons_selected, one=None, alyx=None):
18 """
19 Main function to call to input a reason for marking an insertion as CRITICAL from the alignment GUI.
21 It wil create note text, after deleting any similar notes existing already.
23 Parameters
24 ----------
25 uuid : uuid.UUID, str
26 An insertion ID.
27 reasons_selected : list of str
28 A subset of REASONS_INS_CRIT_GUI.
29 one : one.api.OneAlyx
30 (DEPRECATED) An instance of ONE. NB: Pass in an instance of AlyxClient instead.
31 alyx : one.webclient.AlyxClient
32 An AlyxClient instance.
33 """
34 # hit the database to check if uuid is insertion uuid
35 if alyx is None and one is not None: 1g
36 # Deprecate ONE in future because instantiating it takes longer and is unnecessary
37 warnings.warn('In future please pass in an AlyxClient instance (i.e. `one.alyx`)', FutureWarning) 1g
38 alyx = one if isinstance(one, AlyxClient) else one.alyx 1g
39 ins_list = alyx.rest('insertions', 'list', id=uuid, no_cache=True) 1g
40 if len(ins_list) != 1: 1g
41 raise ValueError(f'N={len(ins_list)} insertion found, expected N=1. Check uuid provided.')
43 note = CriticalInsertionNote(uuid, alyx) 1g
45 # assert that reasons are all within REASONS_INS_CRIT_GUI
46 for item_str in reasons_selected: 1g
47 assert item_str in note.descriptions_gui 1g
49 note.selected_reasons = reasons_selected 1g
50 note.other_reason = [] 1g
51 note._upload_note(overwrite=True) 1g
54def main(uuid, one=None, alyx=None):
55 """
56 Main function to call to input a reason for marking a session/insertion as CRITICAL programmatically.
58 It will:
59 - ask reasons for selection of critical status
60 - check if 'other' reason has been selected, inquire why (free text)
61 - create note text, checking whether similar notes exist already
62 - upload note to Alyx if none exist previously or if overwrite is chosen Q&A are prompted via the Python terminal
64 Parameters
65 ----------
66 uuid : uuid.UUID, str
67 An experiment UUID or an insertion UUID.
68 one : one.api.OneAlyx
69 (DEPRECATED) An instance of ONE. NB: Pass in an instance of AlyxClient instead.
70 alyx : one.webclient.AlyxClient
71 An AlyxClient instance.
73 Examples
74 --------
75 Retrieve Alyx note to test
77 >>> alyx = AlyxClient(base_url='https://dev.alyx.internationalbrainlab.org')
78 >>> uuid = '2ffd3ed5-477e-4153-9af7-7fdad3c6946b'
79 >>> main(uuid=uuid, alyx=alyx)
81 Get notes with pattern
83 >>> notes = alyx.rest('notes', 'list',
84 ... django=f'text__icontains,{STR_NOTES_STATIC},'
85 ... f'object_id,{uuid}')
86 >>> test_json_read = json.loads(notes[0]['text'])
88 """
89 if alyx is None and one is not None: 1cef
90 # Deprecate ONE in future because instantiating it takes longer and is unnecessary
91 warnings.warn('In future please pass in an AlyxClient instance (i.e. `one.alyx`)', FutureWarning) 1cef
92 alyx = one if isinstance(one, AlyxClient) else one.alyx 1cef
93 if not alyx: 1cef
94 alyx = AlyxClient()
95 # ask reasons for selection of critical status
97 # hit the database to know if uuid is insertion or session uuid
98 sess_list = alyx.get('/sessions?&django=pk,' + uuid, clobber=True) 1cef
99 ins_list = alyx.get('/insertions?&django=pk,' + uuid, clobber=True) 1cef
101 if len(sess_list) > 0 and len(ins_list) == 0: # session 1cef
102 note = CriticalSessionNote(uuid, alyx) 1cf
103 elif len(ins_list) > 0 and len(sess_list) == 0: # insertion 1e
104 note = CriticalInsertionNote(uuid, alyx) 1e
105 else:
106 raise ValueError(f'Inadequate number of session (n={len(sess_list)}) '
107 f'or insertion (n={len(ins_list)}) found for uuid {uuid}.'
108 f'The query output should be of length 1.')
110 note.upload_note() 1cef
113class Note(abc.ABC):
114 descriptions = []
116 @property
117 def default_descriptions(self):
118 return self.descriptions + ['Other'] 1bdcf
120 @property
121 def extra_prompt(self):
122 return ''
124 @property
125 def note_title(self):
126 return ''
128 @property
129 def n_description(self):
130 return len(self.default_descriptions) 1bdcef
132 def __init__(self, uuid, alyx, content_type=None):
133 """
134 Base class for attaching notes to an alyx endpoint. Do not use this class directly but use parent classes that inherit
135 this base class
137 Parameters
138 ----------
139 uuid : uuid.UUID, str
140 A UUID of a session, insertion, or other Alyx model to attach note to.
141 alyx : one.webclient.AlyxClient
142 An AlyxClient instance.
143 content_type : str
144 The Alyx model name of the UUID.
145 """
146 self.uuid = uuid 1hbdgcef
147 if isinstance(alyx, OneAlyx): 1hbdgcef
148 # Deprecate ONE in future because instantiating it takes longer and is unnecessary
149 warnings.warn('In future please pass in an AlyxClient instance (i.e. `one.alyx`)', FutureWarning) 1hbd
150 alyx = alyx.alyx 1hbd
151 self.alyx = alyx 1hbdgcef
152 self.selected_reasons = [] 1hbdgcef
153 self.other_reason = [] 1hbdgcef
154 if content_type is not None: 1hbdgcef
155 self.content_type = content_type 1hbdgcef
156 else:
157 self.content_type = self.get_content_type()
159 def get_content_type(self):
160 """
161 Infer the content_type from the uuid. Only checks to see if uuid is a session or insertion.
162 If not recognised will raise an error and the content_type must be specified on note
163 initialisation e.g. Note(uuid, alyx, content_type='subject')
165 Returns
166 -------
167 str
168 The Alyx model name, inferred from the UUID.
169 """
171 # see if it as session or an insertion
172 if self.alyx.rest('sessions', 'list', id=self.uuid):
173 content_type = 'session'
174 elif self.alyx.rest('insertions', 'list', id=self.uuid):
175 content_type = 'probeinsertion'
176 else:
177 raise ValueError(f'Content type cannot be recognised from {self.uuid}. '
178 'Specify on initialistion e.g Note(uuid, alyx, content_type="subject"')
179 return content_type
181 def describe(self):
182 """
183 Print list of default reasons that can be chosen from
184 :return:
185 """
186 for i, d in enumerate(self.descriptions):
187 print(f'{i}. {d} \n')
189 def numbered_descriptions(self):
190 """
191 Return list of numbered default reasons
192 :return:
193 """
194 return [f'{i}) {d}' for i, d in enumerate(self.default_descriptions)] 1bdcef
196 def upload_note(self, nums=None, other_reason=None, **kwargs):
197 """
198 Upload note to Alyx.
200 If no values for nums and other_reason are specified, user will receive a prompt in command
201 line asking them to choose from default list of reasons to add to note as well as option
202 for free text. To upload without receiving prompt a value for either `nums` or
203 `other_reason` must be given.
205 Parameters
206 ----------
207 nums : str
208 string of numbers matching those in default descriptions, e.g, '1,3'. Options can be
209 seen using note.describe().
210 other_reason : str
211 Other comment or reason(s) to add to note.
213 """
215 if nums is None and other_reason is None: 1bdcef
216 self.selected_reasons, self.other_reason = self.reasons_prompt() 1bdcef
217 else:
218 self.selected_reasons = self._map_num_to_description(nums) 1b
219 self.other_reason = other_reason or [] 1b
221 self._upload_note(**kwargs) 1bdcef
223 def _upload_note(self, **kwargs):
224 existing_note, notes = self._check_existing_note() 1bdgcef
225 if existing_note: 1bdgcef
226 self.update_existing_note(notes, **kwargs) 1bc
227 else:
228 text = self.format_note(**kwargs) 1bdgcef
229 self._create_note(text) 1bdgcef
230 _logger.info('The selected reasons were saved on Alyx.') 1bdgcef
232 def _create_note(self, text):
234 data = {'user': self.alyx.user, 1bdgcef
235 'content_type': self.content_type,
236 'object_id': self.uuid,
237 'text': f'{text}'}
238 self.alyx.rest('notes', 'create', data=data) 1bdgcef
240 def _update_note(self, note_id, text):
242 data = {'user': self.alyx.user, 1b
243 'content_type': self.content_type,
244 'object_id': self.uuid,
245 'text': f'{text}'}
246 self.alyx.rest('notes', 'partial_update', id=note_id, data=data) 1b
248 def _delete_note(self, note_id):
249 self.alyx.rest('notes', 'delete', id=note_id) 1c
251 def _delete_notes(self, notes):
252 for note in notes: 1c
253 self._delete_note(note['id']) 1c
255 def _check_existing_note(self):
256 notes = self.alyx.rest('notes', 'list', django=f'text__icontains,{self.note_title},object_id,{self.uuid}', no_cache=True) 1bdgcef
257 if len(notes) == 0: 1bdgcef
258 return False, None 1bdgcef
259 else:
260 return True, notes 1bc
262 def _map_num_to_description(self, nums):
264 if nums is None: 1bdcef
265 return []
267 string_list = nums.split(',') 1bdcef
268 int_list = list(map(int, string_list)) 1bdcef
270 if max(int_list) >= self.n_description or min(int_list) < 0: 1bdcef
271 raise ValueError(f'Chosen values out of range, must be between 0 and {self.n_description - 1}')
273 return [self.default_descriptions[n] for n in int_list] 1bdcef
275 def reasons_prompt(self):
276 """
277 Prompt for user to enter reasons
278 :return:
279 """
281 prompt = f'{self.extra_prompt} ' \ 1bdcef
282 f'\n {self.numbered_descriptions()} \n ' \
283 f'and enter the corresponding numbers separated by commas, e.g. 1,3 -> enter: '
285 ans = input(prompt).strip().lower() 1bdcef
287 try: 1bdcef
288 selected_reasons = self._map_num_to_description(ans) 1bdcef
289 print(f'You selected reason(s): {selected_reasons}') 1bdcef
290 if 'Other' in selected_reasons: 1bdcef
291 other_reasons = self.other_reason_prompt() 1bd
292 return selected_reasons, other_reasons 1bd
293 else:
294 return selected_reasons, [] 1cef
296 except ValueError:
297 print(f'{ans} is invalid, please try again...')
298 return self.reasons_prompt()
300 def other_reason_prompt(self):
301 """
302 Prompt for user to enter other reasons
303 :return:
304 """
306 prompt = 'Explain why you selected "other" (free text): ' 1bd
307 ans = input(prompt).strip().lower() 1bd
308 return ans 1bd
310 @abc.abstractmethod
311 def format_note(self, **kwargs):
312 """
313 Method to format text field of note according to type of note wanting to be uploaded
314 :param kwargs:
315 :return:
316 """
318 @abc.abstractmethod
319 def update_existing_note(self, note, **kwargs):
320 """
321 Method to specify behavior in the case of a note with the same title already existing
322 :param note:
323 :param kwargs:
324 :return:
325 """
328class CriticalNote(Note):
329 """
330 Class for uploading a critical note to a session or insertion. Do not use directly but use CriticalSessionNote or
331 CriticalInsertionNote instead
332 """
334 def format_note(self, **kwargs):
335 note_text = { 1gcef
336 "title": self.note_title,
337 "reasons_selected": self.selected_reasons,
338 "reason_for_other": self.other_reason
339 }
340 return json.dumps(note_text) 1gcef
342 def update_existing_note(self, notes, **kwargs):
344 overwrite = kwargs.get('overwrite', None) 1c
345 if overwrite is None: 1c
346 overwrite = self.delete_note_prompt(notes) 1c
348 if overwrite: 1c
349 self._delete_notes(notes) 1c
350 text = self.format_note() 1c
351 self._create_note(text) 1c
352 _logger.info('The selected reasons were saved on Alyx; old notes were deleted') 1c
353 else:
354 _logger.info('The selected reasons were NOT saved on Alyx; old notes remain.')
356 def delete_note_prompt(self, notes):
358 prompt = f'You are about to delete {len(notes)} existing notes; ' \ 1c
359 f'do you want to proceed? y/n: '
361 ans = input(prompt).strip().lower() 1c
363 if ans not in ['y', 'n']: 1c
364 print(f'{ans} is invalid, please try again...')
365 return self.delete_note_prompt(notes)
366 else:
367 return True if ans == 'y' else False 1c
370class CriticalInsertionNote(CriticalNote):
371 """
372 Class for uploading a critical note to an insertion.
374 Examples
375 --------
376 >>> note = CriticalInsertionNote(pid, AlyxClient())
378 Print list of default reasons
380 >>> note.describe()
382 To receive a command line prompt to fill in note
384 >>> note.upload_note()
386 To upload note automatically without prompt
388 >>> note.upload_note(nums='1,4', other_reason='lots of bad channels')
389 """
391 descriptions_gui = [
392 'Noise and artifact',
393 'Drift',
394 'Poor neural yield',
395 'Brain Damage',
396 'Other'
397 ]
399 descriptions = [
400 'Histological images missing',
401 'Track not visible on imaging data'
402 ]
404 @property
405 def default_descriptions(self):
406 return self.descriptions + self.descriptions_gui 1e
408 @property
409 def extra_prompt(self):
410 return 'Select from this list the reason(s) why you are marking the insertion as CRITICAL:' 1e
412 @property
413 def note_title(self):
414 return '=== EXPERIMENTER REASON(S) FOR MARKING THE INSERTION AS CRITICAL ===' 1ge
416 def __init__(self, uuid, alyx):
417 super(CriticalInsertionNote, self).__init__(uuid, alyx, content_type='probeinsertion') 1ge
420class CriticalSessionNote(CriticalNote):
421 """
422 Class for uploading a critical note to a session.
424 Example
425 -------
426 >>> note = CriticalInsertionNote(uuid, AlyxClient)
428 Print list of default reasons
430 >>> note.describe()
432 To receive a command line prompt to fill in note
434 >>> note.upload_note()
436 To upload note automatically without prompt
438 >>> note.upload_note(nums='1,4', other_reason='session with no ephys recording')
439 """
441 descriptions = [
442 'within experiment system crash',
443 'synching impossible',
444 'dud or mock session',
445 'essential dataset missing',
446 ]
448 @property
449 def extra_prompt(self):
450 return 'Select from this list the reason(s) why you are marking the session as CRITICAL:' 1cf
452 @property
453 def note_title(self):
454 return '=== EXPERIMENTER REASON(S) FOR MARKING THE SESSION AS CRITICAL ===' 1cf
456 def __init__(self, uuid, alyx):
457 super(CriticalSessionNote, self).__init__(uuid, alyx, content_type='session') 1cf
460class SignOffNote(Note):
461 """
462 Class for signing off a session and optionally adding a related explanation note.
463 Do not use directly but use classes that inherit from this class e.g TaskSignOffNote, RawEphysSignOffNote
464 """
466 @property
467 def extra_prompt(self):
468 return 'Select from this list the reason(s) that describe issues with this session:' 1bd
470 @property
471 def note_title(self):
472 return f'=== SIGN-OFF NOTE FOR {self.sign_off_key} ===' 1bd
474 def __init__(self, uuid, alyx, sign_off_key):
475 self.sign_off_key = sign_off_key 1hbd
476 super(SignOffNote, self).__init__(uuid, alyx, content_type='session') 1hbd
477 self.datetime_key = self.get_datetime_key() 1hbd
478 self.session = self.alyx.rest('sessions', 'read', id=self.uuid, no_cache=True) 1hbd
480 def upload_note(self, nums=None, other_reason=None, **kwargs):
481 super(SignOffNote, self).upload_note(nums=nums, other_reason=other_reason, **kwargs) 1bd
482 self.sign_off() 1bd
484 def sign_off(self):
486 json = self.session['json'] 1hbd
487 sign_off_checklist = json.get('sign_off_checklist', None) 1hbd
488 if sign_off_checklist is None: 1hbd
489 sign_off_checklist = {self.sign_off_key: {'date': self.datetime_key.split('_')[0],
490 'user': self.datetime_key.split('_')[1]}}
491 else:
492 sign_off_checklist[self.sign_off_key] = {'date': self.datetime_key.split('_')[0], 1hbd
493 'user': self.datetime_key.split('_')[1]}
495 json['sign_off_checklist'] = sign_off_checklist 1hbd
497 self.alyx.json_field_update("sessions", self.uuid, 'json', data=json) 1hbd
499 def format_note(self, **kwargs):
501 note_text = { 1bd
502 "title": self.note_title,
503 f'{self.datetime_key}': {"reasons_selected": self.selected_reasons,
504 "reason_for_other": self.other_reason}
505 }
507 return json.dumps(note_text) 1bd
509 def format_existing_note(self, orignal_note):
511 extra_note = {f'{self.datetime_key}': {"reasons_selected": self.selected_reasons, 1b
512 "reason_for_other": self.other_reason}
513 }
515 orignal_note.update(extra_note) 1b
517 return json.dumps(orignal_note) 1b
519 def update_existing_note(self, notes):
520 if len(notes) != 1: 1b
521 raise ValueError(f'{len(notes)} with same title found, only expect at most 1. Clean up before proceeding')
522 else:
523 original_note = json.loads(notes[0]['text']) 1b
524 text = self.format_existing_note(original_note) 1b
525 self._update_note(notes[0]['id'], text) 1b
527 def get_datetime_key(self):
528 if not self.alyx.is_logged_in: 1hbd
529 self.alyx.authenticate()
530 assert self.alyx.is_logged_in, 'you must be logged in to the AlyxClient'
531 user = self.alyx.user 1hbd
532 date = datetime.now().date().isoformat() 1hbd
533 return date + '_' + user 1hbd
536class TaskSignOffNote(SignOffNote):
538 """
539 Class for signing off a task part of a session and optionally adding a related explanation note.
541 Examples
542 --------
543 >>> note = TaskSignOffNote(eid, AlyxClient(), '_ephysChoiceWorld_00')
545 To sign off session without any note
547 >>> note.sign_off()
549 Print list of default reasons
551 >>> note.describe()
553 To upload note and sign off with prompt
555 >>> note.upload_note()
557 To upload note automatically without prompt
559 >>> note.upload_note(nums='1,4', other_reason='session with no ephys recording')
560 """
562 descriptions = [
563 'raw trial data does not exist',
564 'wheel data corrupt',
565 'task data could not be synced',
566 ]
569class PassiveSignOffNote(SignOffNote):
571 """
572 Class for signing off a passive part of a session and optionally adding a related explanation note.
574 Examples
575 --------
576 >>> note = PassiveSignOffNote(eid, AlyxClient(), '_passiveChoiceWorld_00')
578 To sign off session without any note
580 >>> note.sign_off()
582 Print list of default reasons
584 >>> note.describe()
586 To upload note and sign off with prompt
588 >>> note.upload_note()
590 To upload note automatically without prompt
592 >>> note.upload_note(nums='1,4', other_reason='session with no ephys recording')
593 """
595 descriptions = [
596 'Raw passive data doesn’t exist (no. of spacers = 0)',
597 'Incorrect number or spacers (i.e passive cutoff midway)',
598 'RFmap file doesn’t exist',
599 'Gabor patches couldn’t be extracted',
600 'Trial playback couldn’t be extracted',
601 ]
604class VideoSignOffNote(SignOffNote):
606 """
607 Class for signing off a video part of a session and optionally adding a related explanation note.
609 Examples
610 --------
611 >>> note = VideoSignOffNote(eid, AlyxClient(), '_camera_left')
613 To sign off session without any note
615 >>> note.sign_off()
617 Print list of default reasons
619 >>> note.describe()
621 To upload note and sign off with prompt
623 >>> note.upload_note()
625 To upload note automatically without prompt
627 >>> note.upload_note(nums='1,4', other_reason='session with no ephys recording')
628 """
630 descriptions = [
631 'The video timestamps are not the same length as the video file (either empty or slightly longer/shorter)',
632 'The rotary encoder trace doesn’t not appear synced with the video',
633 'The QC fails because the GPIO file is missing or empty',
634 'The frame rate in the video header is wrong (the video plays too slow or fast)',
635 'The resolution is not what is defined in the experiment description file',
636 'The DLC QC fails because something is obscuring the pupil',
637 ]
640class RawEphysSignOffNote(SignOffNote):
642 """
643 Class for signing off a raw ephys part of a session and optionally adding a related explanation note.
645 Examples
646 --------
647 >>> note = RawEphysSignOffNote(uuid, AlyxClient(), '_neuropixel_raw_probe00')
649 To sign off session without any note
651 >>> note.sign_off()
653 Print list of default reasons
655 >>> note.describe()
657 To upload note and sign off with prompt
659 >>> note.upload_note()
661 To upload note automatically without prompt
663 >>> note.upload_note(nums='1,4', other_reason='session with no ephys recording')
664 """
666 descriptions = [
667 'Data has striping',
668 'Horizontal band',
669 'Discontunuity',
670 ]
673class SpikeSortingSignOffNote(SignOffNote):
675 """
676 Class for signing off a spike sorting part of a session and optionally adding a related explanation note.
678 Examples
679 --------
680 >>> note = SpikeSortingSignOffNote(uuid, AlyxClient(), '_neuropixel_spike_sorting_probe00')
682 To sign off session without any note
684 >>> note.sign_off()
686 Print list of default reasons
688 >>> note.describe()
690 To upload note and sign off with prompt
692 >>> note.upload_note()
694 To upload note automatically without prompt
696 >>> note.upload_note(nums='1,4', other_reason='session with no ephys recording')
697 """
699 descriptions = [
700 'Spikesorting could not be run',
701 'Poor quality spikesorting',
702 ]
705class AlignmentSignOffNote(SignOffNote):
707 """
708 Class for signing off a alignment part of a session and optionally adding a related explanation note.
710 Examples
711 --------
712 >>> note = AlignmentSignOffNote(uuid, AlyxClient(), '_neuropixel_alignment_probe00')
714 To sign off session without any note
716 >>> note.sign_off()
718 Print list of default reasons
720 >>> note.describe()
722 To upload note and sign off with prompt
724 >>> note.upload_note()
726 To upload note automatically without prompt
728 >>> note.upload_note(nums='1,4', other_reason='session with no ephys recording')
729 """
731 descriptions = []