diff --git a/ibllib/atlas/__init__.py b/ibllib/atlas/__init__.py index 22c7720bd..b01f8f5e7 100644 --- a/ibllib/atlas/__init__.py +++ b/ibllib/atlas/__init__.py @@ -1,4 +1,7 @@ -"""A package for working with brain atlases. +"""(DEPRECATED) A package for working with brain atlases. + +For the correct atlas documentation, see +https://docs.internationalbrainlab.org/_autosummary/iblatlas.html For examples and tutorials on using the IBL atlas package, see https://docs.internationalbrainlab.org/atlas_examples.html diff --git a/ibllib/io/extractors/base.py b/ibllib/io/extractors/base.py index cfc9557f4..e49d47980 100644 --- a/ibllib/io/extractors/base.py +++ b/ibllib/io/extractors/base.py @@ -154,9 +154,9 @@ def extract(self, bpod_trials=None, settings=None, **kwargs): if not self.settings: self.settings = raw.load_settings(self.session_path, task_collection=self.task_collection) if self.settings is None: - self.settings = {"IBLRIG_VERSION_TAG": "100.0.0"} - elif self.settings.get("IBLRIG_VERSION_TAG", "") == "": - self.settings["IBLRIG_VERSION_TAG"] = "100.0.0" + self.settings = {"IBLRIG_VERSION": "100.0.0"} + elif self.settings.get("IBLRIG_VERSION", "") == "": + self.settings["IBLRIG_VERSION"] = "100.0.0" return super(BaseBpodTrialsExtractor, self).extract(**kwargs) diff --git a/ibllib/io/extractors/biased_trials.py b/ibllib/io/extractors/biased_trials.py index e2912d11e..8db9ae24c 100644 --- a/ibllib/io/extractors/biased_trials.py +++ b/ibllib/io/extractors/biased_trials.py @@ -1,6 +1,6 @@ from pathlib import Path, PureWindowsPath -from pkg_resources import parse_version +from packaging import version import numpy as np from one.alf.io import AlfBunch @@ -80,8 +80,8 @@ def get_pregenerated_events(bpod_trials, settings): pLeft = pLeft[: ntrials] phase_path = sessions_folder.joinpath(f"session_{num}_stim_phase.npy") - is_patched_version = parse_version( - settings.get('IBLRIG_VERSION_TAG', 0)) > parse_version('6.4.0') + is_patched_version = version.parse( + settings.get('IBLRIG_VERSION') or '0') > version.parse('6.4.0') if phase_path.exists() and is_patched_version: phase = np.load(phase_path)[:ntrials] @@ -211,13 +211,13 @@ def extract_all(session_path, save=False, bpod_trials=False, settings=False, ext if not settings: settings = raw.load_settings(session_path, task_collection=task_collection) if settings is None: - settings = {'IBLRIG_VERSION_TAG': '100.0.0'} + settings = {'IBLRIG_VERSION': '100.0.0'} - if settings['IBLRIG_VERSION_TAG'] == '': - settings['IBLRIG_VERSION_TAG'] = '100.0.0' + if settings['IBLRIG_VERSION'] == '': + settings['IBLRIG_VERSION'] = '100.0.0' # Version check - if parse_version(settings['IBLRIG_VERSION_TAG']) >= parse_version('5.0.0'): + if version.parse(settings['IBLRIG_VERSION']) >= version.parse('5.0.0'): # We now extract a single trials table base = [BiasedTrials] else: diff --git a/ibllib/io/extractors/bpod_trials.py b/ibllib/io/extractors/bpod_trials.py index 950797b88..1e72d9da9 100644 --- a/ibllib/io/extractors/bpod_trials.py +++ b/ibllib/io/extractors/bpod_trials.py @@ -1,4 +1,5 @@ -"""Trials data extraction from raw Bpod output +"""Trials data extraction from raw Bpod output. + This module will extract the Bpod trials and wheel data based on the task protocol, i.e. habituation, training or biased. """ @@ -7,7 +8,7 @@ from collections import OrderedDict import warnings -from pkg_resources import parse_version +from packaging import version from ibllib.io.extractors import habituation_trials, training_trials, biased_trials, opto_trials from ibllib.io.extractors.base import get_bpod_extractor_class, protocol2extractor from ibllib.io.extractors.habituation_trials import HabituationTrials @@ -88,8 +89,8 @@ def extract_all(session_path, save=True, bpod_trials=None, settings=None, files_wheel = [] wheel = OrderedDict({k: trials.pop(k) for k in tuple(trials.keys()) if 'wheel' in k}) elif extractor_type == 'habituation': - if settings['IBLRIG_VERSION_TAG'] and \ - parse_version(settings['IBLRIG_VERSION_TAG']) <= parse_version('5.0.0'): + if settings['IBLRIG_VERSION'] and \ + version.parse(settings['IBLRIG_VERSION']) <= version.parse('5.0.0'): _logger.warning('No extraction of legacy habituation sessions') return None, None, None trials, files_trials = habituation_trials.extract_all(session_path, bpod_trials=bpod_trials, settings=settings, save=save, diff --git a/ibllib/io/extractors/ephys_passive.py b/ibllib/io/extractors/ephys_passive.py index 2dfcb34e2..f582da076 100644 --- a/ibllib/io/extractors/ephys_passive.py +++ b/ibllib/io/extractors/ephys_passive.py @@ -93,9 +93,11 @@ def _load_task_protocol(session_path: str, task_collection: str = 'raw_passive_d :type session_path: str :return: ibl rig task protocol version :rtype: str + + FIXME This function has a misleading name """ settings = rawio.load_settings(session_path, task_collection=task_collection) - ses_ver = settings["IBLRIG_VERSION_TAG"] + ses_ver = settings["IBLRIG_VERSION"] return ses_ver diff --git a/ibllib/io/extractors/mesoscope.py b/ibllib/io/extractors/mesoscope.py index e4ca6766b..20b349eb0 100644 --- a/ibllib/io/extractors/mesoscope.py +++ b/ibllib/io/extractors/mesoscope.py @@ -7,7 +7,7 @@ from one.util import ensure_list from one.alf.files import session_path_parts import matplotlib.pyplot as plt -from pkg_resources import parse_version +from packaging import version from ibllib.plots.misc import squares, vertical_lines from ibllib.io.raw_daq_loaders import (extract_sync_timeline, timeline_get_channel, @@ -38,8 +38,8 @@ def patch_imaging_meta(meta: dict) -> dict: The loaded metadata file, updated to the most recent version. """ # 2023-05-17 (unversioned) adds nFrames, channelSaved keys, MM and Deg keys - version = parse_version(meta.get('version') or '0.0.0') - if version <= parse_version('0.0.0'): + ver = version.parse(meta.get('version') or '0.0.0') + if ver <= version.parse('0.0.0'): if 'channelSaved' not in meta: meta['channelSaved'] = next((x['channelIdx'] for x in meta['FOV'] if 'channelIdx' in x), []) fields = ('topLeft', 'topRight', 'bottomLeft', 'bottomRight') @@ -47,7 +47,7 @@ def patch_imaging_meta(meta: dict) -> dict: for unit in ('Deg', 'MM'): if unit not in fov: # topLeftDeg, etc. -> Deg[topLeft] fov[unit] = {f: fov.pop(f + unit, None) for f in fields} - elif version == parse_version('0.1.0'): + elif ver == version.parse('0.1.0'): for fov in meta.get('FOV', []): if 'roiUuid' in fov: fov['roiUUID'] = fov.pop('roiUuid') diff --git a/ibllib/io/extractors/training_trials.py b/ibllib/io/extractors/training_trials.py index 41a69d815..d3ca1447d 100644 --- a/ibllib/io/extractors/training_trials.py +++ b/ibllib/io/extractors/training_trials.py @@ -1,6 +1,6 @@ import logging import numpy as np -from pkg_resources import parse_version +from packaging import version from one.alf.io import AlfBunch import ibllib.io.raw_data_loaders as raw @@ -216,7 +216,7 @@ def get_feedback_times_ge5(session_path, task_collection='raw_behavior_data', da def _extract(self): # Version check - if parse_version(self.settings['IBLRIG_VERSION_TAG']) >= parse_version('5.0.0'): + if version.parse(self.settings['IBLRIG_VERSION'] or '100.0.0') >= version.parse('5.0.0'): merge = self.get_feedback_times_ge5(self.session_path, task_collection=self.task_collection, data=self.bpod_trials) else: merge = self.get_feedback_times_lt5(self.session_path, task_collection=self.task_collection, data=self.bpod_trials) @@ -287,7 +287,7 @@ class GoCueTriggerTimes(BaseBpodTrialsExtractor): var_names = 'goCueTrigger_times' def _extract(self): - if parse_version(self.settings['IBLRIG_VERSION_TAG']) >= parse_version('5.0.0'): + if version.parse(self.settings['IBLRIG_VERSION'] or '100.0.0') >= version.parse('5.0.0'): goCue = np.array([tr['behavior_data']['States timestamps'] ['play_tone'][0][0] for tr in self.bpod_trials]) else: @@ -361,7 +361,7 @@ class IncludedTrials(BaseBpodTrialsExtractor): var_names = 'included' def _extract(self): - if parse_version(self.settings['IBLRIG_VERSION_TAG']) >= parse_version('5.0.0'): + if version.parse(self.settings['IBLRIG_VERSION'] or '100.0.0') >= version.parse('5.0.0'): trials_included = self.get_included_trials_ge5( data=self.bpod_trials, settings=self.settings) else: @@ -370,7 +370,7 @@ def _extract(self): @staticmethod def get_included_trials_lt5(data=False): - trials_included = np.array([True for t in data]) + trials_included = np.ones(len(data), dtype=bool) return trials_included @staticmethod @@ -387,7 +387,7 @@ class ItiInTimes(BaseBpodTrialsExtractor): var_names = 'itiIn_times' def _extract(self): - if parse_version(self.settings["IBLRIG_VERSION_TAG"]) < parse_version("5.0.0"): + if version.parse(self.settings["IBLRIG_VERSION"] or '100.0.0') < version.parse("5.0.0"): iti_in = np.ones(len(self.bpod_trials)) * np.nan else: iti_in = np.array( @@ -416,7 +416,7 @@ class StimFreezeTriggerTimes(BaseBpodTrialsExtractor): var_names = 'stimFreezeTrigger_times' def _extract(self): - if parse_version(self.settings["IBLRIG_VERSION_TAG"]) < parse_version("6.2.5"): + if version.parse(self.settings["IBLRIG_VERSION"] or '100.0.0') < version.parse("6.2.5"): return np.ones(len(self.bpod_trials)) * np.nan freeze_reward = np.array( [ @@ -460,9 +460,9 @@ class StimOffTriggerTimes(BaseBpodTrialsExtractor): var_names = 'stimOffTrigger_times' def _extract(self): - if parse_version(self.settings["IBLRIG_VERSION_TAG"]) >= parse_version("6.2.5"): + if version.parse(self.settings["IBLRIG_VERSION"] or '100.0.0') >= version.parse("6.2.5"): stim_off_trigger_state = "hide_stim" - elif parse_version(self.settings["IBLRIG_VERSION_TAG"]) >= parse_version("5.0.0"): + elif version.parse(self.settings["IBLRIG_VERSION"]) >= version.parse("5.0.0"): stim_off_trigger_state = "exit_state" else: stim_off_trigger_state = "trial_start" @@ -518,7 +518,7 @@ def _extract(self): # Version check _logger.warning("Deprecation Warning: this is an old version of stimOn extraction." "From version 5., use StimOnOffFreezeTimes") - if parse_version(self.settings['IBLRIG_VERSION_TAG']) >= parse_version('5.0.0'): + if version.parse(self.settings['IBLRIG_VERSION'] or '100.0.0') >= version.parse('5.0.0'): stimOn_times = self.get_stimOn_times_ge5(self.session_path, data=self.bpod_trials, task_collection=self.task_collection) else: @@ -739,11 +739,11 @@ def extract_all(session_path, save=False, bpod_trials=None, settings=None, task_ bpod_trials = raw.load_data(session_path, task_collection=task_collection) if not settings: settings = raw.load_settings(session_path, task_collection=task_collection) - if settings is None or settings['IBLRIG_VERSION_TAG'] == '': - settings = {'IBLRIG_VERSION_TAG': '100.0.0'} + if settings is None or settings['IBLRIG_VERSION'] == '': + settings = {'IBLRIG_VERSION': '100.0.0'} # Version check - if parse_version(settings['IBLRIG_VERSION_TAG']) >= parse_version('5.0.0'): + if version.parse(settings['IBLRIG_VERSION']) >= version.parse('5.0.0'): # We now extract a single trials table base = [TrainingTrials] else: diff --git a/ibllib/io/raw_data_loaders.py b/ibllib/io/raw_data_loaders.py index 200b8ca15..c2dae8c71 100644 --- a/ibllib/io/raw_data_loaders.py +++ b/ibllib/io/raw_data_loaders.py @@ -7,6 +7,7 @@ Module contains one loader function per raw datafile """ +import re import json import logging import wave @@ -16,7 +17,7 @@ from typing import Union from dateutil import parser as dateparser -from pkg_resources import parse_version +from packaging import version import numpy as np import pandas as pd @@ -323,18 +324,47 @@ def _read_settings_json_compatibility_enforced(settings): md = json.load(js) if 'IS_MOCK' not in md: md['IS_MOCK'] = False + # Many v < 8 sessions had both version and version tag keys. v > 8 have a version tag. + # Some sessions have neither key. From v8 onwards we will use IBLRIG_VERSION to test rig + # version, however some places may still use the version tag. if 'IBLRIG_VERSION_TAG' not in md.keys(): md['IBLRIG_VERSION_TAG'] = md.get('IBLRIG_VERSION', '') + if 'IBLRIG_VERSION' not in md.keys(): + md['IBLRIG_VERSION'] = md['IBLRIG_VERSION_TAG'] + elif all([md['IBLRIG_VERSION'], md['IBLRIG_VERSION_TAG']]): + # This may not be an issue; not sure what the intended difference between these keys was + assert md['IBLRIG_VERSION'] == md['IBLRIG_VERSION_TAG'], 'version and version tag mismatch' + # Test version can be parsed. If not, log an error and set the version to nothing + try: + version.parse(md['IBLRIG_VERSION'] or '0') + except version.InvalidVersion as ex: + _logger.error('%s in iblrig settings, this may affect extraction', ex) + # try a more relaxed version parse + laxed_parse = re.search(r'^\d+\.\d+\.\d+', md['IBLRIG_VERSION']) + # Set the tag as the invalid version + md['IBLRIG_VERSION_TAG'] = md['IBLRIG_VERSION'] + # overwrite version with either successfully parsed one or an empty string + md['IBLRIG_VERSION'] = laxed_parse.group() if laxed_parse else '' + if 'device_sound' not in md: + # sound device must be defined in version 8 and later # FIXME this assertion will cause tests to break + assert version.parse(md['IBLRIG_VERSION'] or '0') < version.parse('8.0.0') + # in v7 we must infer the device from the sampling frequency if SD is None + if 'sounddevice' in md.get('SD', ''): + device = 'xonar' + else: + freq_map = {192000: 'xonar', 96000: 'harp', 44100: 'sysdefault'} + device = freq_map.get(md.get('SOUND_SAMPLE_FREQ'), 'unknown') + md['device_sound'] = {'OUTPUT': device} # 2018-12-05 Version 3.2.3 fixes (permanent fixes in IBL_RIG from 3.2.4 on) - if md['IBLRIG_VERSION_TAG'] == '': + if md['IBLRIG_VERSION'] == '': pass - elif parse_version(md.get('IBLRIG_VERSION_TAG')) >= parse_version('8.0.0'): + elif version.parse(md['IBLRIG_VERSION']) >= version.parse('8.0.0'): md['SESSION_NUMBER'] = str(md['SESSION_NUMBER']).zfill(3) md['PYBPOD_BOARD'] = md['RIG_NAME'] md['PYBPOD_CREATOR'] = (md['ALYX_USER'], '') md['SESSION_DATE'] = md['SESSION_START_TIME'][:10] md['SESSION_DATETIME'] = md['SESSION_START_TIME'] - elif parse_version(md.get('IBLRIG_VERSION_TAG')) <= parse_version('3.2.3'): + elif version.parse(md['IBLRIG_VERSION']) <= version.parse('3.2.3'): if 'LAST_TRIAL_DATA' in md.keys(): md.pop('LAST_TRIAL_DATA') if 'weighings' in md['PYBPOD_SUBJECT_EXTRA'].keys(): @@ -414,16 +444,16 @@ def load_encoder_events(session_path, task_collection='raw_behavior_data', setti path = next(path.glob("_iblrig_encoderEvents.raw*.ssv"), None) if not settings: settings = load_settings(session_path, task_collection=task_collection) - if settings is None or not settings.get('IBLRIG_VERSION_TAG'): - settings = {'IBLRIG_VERSION_TAG': '100.0.0'} + if settings is None or not settings.get('IBLRIG_VERSION'): + settings = {'IBLRIG_VERSION': '100.0.0'} # auto-detect old files when version is not labeled with open(path) as fid: line = fid.readline() if line.startswith('Event') and 'StateMachine' in line: - settings = {'IBLRIG_VERSION_TAG': '0.0.0'} + settings = {'IBLRIG_VERSION': '0.0.0'} if not path: return None - if parse_version(settings['IBLRIG_VERSION_TAG']) >= parse_version('5.0.0'): + if version.parse(settings['IBLRIG_VERSION']) >= version.parse('5.0.0'): return _load_encoder_events_file_ge5(path) else: return _load_encoder_events_file_lt5(path) @@ -518,17 +548,17 @@ def load_encoder_positions(session_path, task_collection='raw_behavior_data', se path = next(path.glob("_iblrig_encoderPositions.raw*.ssv"), None) if not settings: settings = load_settings(session_path, task_collection=task_collection) - if settings is None or not settings.get('IBLRIG_VERSION_TAG'): - settings = {'IBLRIG_VERSION_TAG': '100.0.0'} + if settings is None or not settings.get('IBLRIG_VERSION'): + settings = {'IBLRIG_VERSION': '100.0.0'} # auto-detect old files when version is not labeled with open(path) as fid: line = fid.readline() if line.startswith('Position'): - settings = {'IBLRIG_VERSION_TAG': '0.0.0'} + settings = {'IBLRIG_VERSION': '0.0.0'} if not path: _logger.warning("No data loaded: could not find raw encoderPositions file") return None - if parse_version(settings['IBLRIG_VERSION_TAG']) >= parse_version('5.0.0'): + if version.parse(settings['IBLRIG_VERSION']) >= version.parse('5.0.0'): return _load_encoder_positions_file_ge5(path) else: return _load_encoder_positions_file_lt5(path) diff --git a/ibllib/io/session_params.py b/ibllib/io/session_params.py index fd9854455..417838b55 100644 --- a/ibllib/io/session_params.py +++ b/ibllib/io/session_params.py @@ -30,7 +30,7 @@ from copy import deepcopy from one.converters import ConversionMixin -from pkg_resources import parse_version +from packaging import version import ibllib.pipes.misc as misc @@ -71,9 +71,9 @@ def _patch_file(data: dict) -> dict: The patched description data. """ if data and (v := data.get('version', '0')) != SPEC_VERSION: - if parse_version(v) > parse_version(SPEC_VERSION): + if version.parse(v) > version.parse(SPEC_VERSION): _logger.warning('Description file generated by more recent code') - elif parse_version(v) <= parse_version('0.1.0'): + elif version.parse(v) <= version.parse('0.1.0'): # Change tasks key from dict to list of dicts if 'tasks' in data and isinstance(data['tasks'], dict): data['tasks'] = [{k: v} for k, v in data['tasks'].copy().items()] diff --git a/ibllib/oneibl/registration.py b/ibllib/oneibl/registration.py index 554735e15..470c8aead 100644 --- a/ibllib/oneibl/registration.py +++ b/ibllib/oneibl/registration.py @@ -4,7 +4,7 @@ import logging import itertools -from pkg_resources import parse_version +from packaging import version from one.alf.files import get_session_path, folder_parts, get_alf_path from one.registration import RegistrationClient, get_dataset_type from one.remote.globus import get_local_endpoint_id, get_lab_from_endpoint_id @@ -230,7 +230,7 @@ def register_session(self, ses_path, file_list=True, projects=None, procedures=N n_trials, n_correct_trials = _get_session_performance(settings, task_data) # TODO Add task_protocols to Alyx sessions endpoint - task_protocols = [md['PYBPOD_PROTOCOL'] + md['IBLRIG_VERSION_TAG'] for md in settings] + task_protocols = [md['PYBPOD_PROTOCOL'] + md['IBLRIG_VERSION'] for md in settings] # unless specified label the session projects with subject projects projects = subject['projects'] if projects is None else projects # makes sure projects is a list @@ -298,7 +298,7 @@ def register_session(self, ses_path, file_list=True, projects=None, procedures=N # register all files that match the Alyx patterns and file_list if any(settings): - rename_files_compatibility(ses_path, settings[0]['IBLRIG_VERSION_TAG']) + rename_files_compatibility(ses_path, settings[0]['IBLRIG_VERSION']) F = filter(lambda x: self._register_bool(x.name, file_list), self.find_files(ses_path)) recs = self.register_files(F, created_by=users[0] if users else None, versions=ibllib.__version__) return session, recs @@ -370,7 +370,7 @@ def _alyx_procedure_from_task_type(task_type): def rename_files_compatibility(ses_path, version_tag): if not version_tag: return - if parse_version(version_tag) <= parse_version('3.2.3'): + if version.parse(version_tag) <= version.parse('3.2.3'): task_code = ses_path.glob('**/_ibl_trials.iti_duration.npy') for fn in task_code: fn.replace(fn.parent.joinpath('_ibl_trials.itiDuration.npy')) diff --git a/ibllib/pipes/__init__.py b/ibllib/pipes/__init__.py index 95e8c6ce9..68c1d8445 100644 --- a/ibllib/pipes/__init__.py +++ b/ibllib/pipes/__init__.py @@ -1,16 +1,16 @@ """IBL preprocessing pipeline. This module concerns the data extraction and preprocessing for IBL data. The lab servers routinely -call `local_server.job_creator` to search for new sessions to extract. The job creator registers -the new session to Alyx (i.e. creates a new session record on the database), if required, then -deduces a set of tasks (a.k.a. the pipeline [*]_) from the 'experiment.description' file at the -root of the session (see `dynamic_pipeline.make_pipeline`). If no file exists one is created, +call :func:`local_server.job_creator` to search for new sessions to extract. The job creator +registers the new session to Alyx (i.e. creates a new session record on the database), if required, +then deduces a set of tasks (a.k.a. the pipeline[*]_) from the 'experiment.description' file at the +root of the session (see :func:`dynamic_pipeline.make_pipeline`). If no file exists one is created, inferring the acquisition hardware from the task protocol. The new session's pipeline tasks are then registered for another process (or server) to query. -Another process calls `local_server.task_queue` to get a list of queued tasks from Alyx, then -`local_server.tasks_runner` to loop through tasks. Each task is run by called -`tasks.run_alyx_task` with a dictionary of task information, including the Task class and its +Another process calls :func:`local_server.task_queue` to get a list of queued tasks from Alyx, then +:func:`local_server.tasks_runner` to loop through tasks. Each task is run by calling +:func:`tasks.run_alyx_task` with a dictionary of task information, including the Task class and its parameters. .. [*] A pipeline is a collection of tasks that depend on one another. A pipeline consists of diff --git a/ibllib/pipes/base_tasks.py b/ibllib/pipes/base_tasks.py index 9005e365d..fc848af85 100644 --- a/ibllib/pipes/base_tasks.py +++ b/ibllib/pipes/base_tasks.py @@ -2,7 +2,7 @@ import logging from pathlib import Path -from pkg_resources import parse_version +from packaging import version from one.webclient import no_cache from iblutil.util import flatten @@ -121,9 +121,9 @@ def _spacer_support(settings): bool True if task spacers are to be expected. """ - v = parse_version - version = v(settings.get('IBLRIG_VERSION_TAG')) - return version not in (v('100.0.0'), v('8.0.0')) and version >= v('7.1.0') + v = version.parse + ver = v(settings.get('IBLRIG_VERSION') or '100.0.0') + return ver not in (v('100.0.0'), v('8.0.0')) and ver >= v('7.1.0') class VideoTask(DynamicTask): diff --git a/ibllib/pipes/behavior_tasks.py b/ibllib/pipes/behavior_tasks.py index 85e21c7ac..5e7e5d829 100644 --- a/ibllib/pipes/behavior_tasks.py +++ b/ibllib/pipes/behavior_tasks.py @@ -2,7 +2,7 @@ import logging import traceback -from pkg_resources import parse_version +from packaging import version import one.alf.io as alfio from one.alf.files import session_path_parts from one.api import ONE @@ -256,8 +256,8 @@ def _run(self, **kwargs): This class exists to load the sync file and set the protocol_number to None """ settings = load_settings(self.session_path, self.collection) - version = settings.get('IBLRIG_VERSION_TAG', '100.0.0') - if version == '100.0.0' or parse_version(version) <= parse_version('7.1.0'): + ver = settings.get('IBLRIG_VERSION') or '100.0.0' + if ver == '100.0.0' or version.parse(ver) <= version.parse('7.1.0'): _logger.warning('Protocol spacers not supported; setting protocol_number to None') self.protocol_number = None diff --git a/ibllib/plots/misc.py b/ibllib/plots/misc.py index 36cd56afb..2a561ae8d 100644 --- a/ibllib/plots/misc.py +++ b/ibllib/plots/misc.py @@ -187,9 +187,9 @@ def squares(tscale, polarity, ax=None, yrange=[-1, 1], **kwargs): def vertical_lines(x, ymin=0, ymax=1, ax=None, **kwargs): """ - From a x vector, draw separate vertical lines at each x location ranging from ymin to ymax + From an x vector, draw separate vertical lines at each x location ranging from ymin to ymax - :param x: numpy array vector of x values where to display lnes + :param x: numpy array vector of x values where to display lines :param ymin: lower end of the lines (scalar) :param ymax: higher end of the lines (scalar) :param ax: (optional) matplotlib axis instance diff --git a/ibllib/qc/base.py b/ibllib/qc/base.py index 4669a860e..0fce5d18c 100644 --- a/ibllib/qc/base.py +++ b/ibllib/qc/base.py @@ -222,7 +222,7 @@ def compute_outcome_from_extended_qc(self) -> str: """ details = self.one.alyx.get(f'/{self.endpoint}/{self.eid}', clobber=True) extended_qc = details['json']['extended_qc'] if self.json else details['extended_qc'] - return self.overall_outcome(v for k, v in extended_qc or {} if k[0] != '_') + return self.overall_outcome(v for k, v in extended_qc.items() or {} if k[0] != '_') def sign_off_dict(exp_dec, sign_off_categories=None): diff --git a/ibllib/qc/task_metrics.py b/ibllib/qc/task_metrics.py index d746626d5..efe30f73c 100644 --- a/ibllib/qc/task_metrics.py +++ b/ibllib/qc/task_metrics.py @@ -55,6 +55,7 @@ from collections.abc import Sized import numpy as np +from packaging import version from scipy.stats import chisquare from brainbox.behavior.wheel import cm_to_rad, traces_by_trial @@ -131,27 +132,47 @@ def __init__(self, session_path_or_eid, **kwargs): self.passed = None def load_data(self, bpod_only=False, download_data=True): - """Extract the data from raw data files + """Extract the data from raw data files. + Extracts all the required task data from the raw data files. - :param bpod_only: if True no data is extracted from the FPGA for ephys sessions - :param download_data: if True, any missing raw data is downloaded via ONE. + Parameters + ---------- + bpod_only : bool + If True no data is extracted from the FPGA for ephys sessions. + download_data : bool + If True, any missing raw data is downloaded via ONE. By default data are not downloaded + if a session path was provided to the constructor. """ self.extractor = TaskQCExtractor( self.session_path, one=self.one, download_data=download_data, bpod_only=bpod_only) def compute(self, **kwargs): - """Compute and store the QC metrics + """Compute and store the QC metrics. + Runs the QC on the session and stores a map of the metrics for each datapoint for each - test, and a map of which datapoints passed for each test - :param bpod_only: if True no data is extracted from the FPGA for ephys sessions - :param download_data: if True, any missing raw data is downloaded via ONE. By default - data are not downloaded if a session path was provided to the constructor. - :return: + test, and a map of which datapoints passed for each test. + + Parameters + ---------- + bpod_only : bool + If True no data is extracted from the FPGA for ephys sessions. + download_data : bool + If True, any missing raw data is downloaded via ONE. By default data are not downloaded + if a session path was provided to the constructor. """ if self.extractor is None: kwargs['download_data'] = kwargs.pop('download_data', self.download_data) self.load_data(**kwargs) + + ver = self.extractor.settings.get('IBLRIG_VERSION', '') or '0.0.0' + if version.parse(ver) >= version.parse('8.0.0'): + self.criteria['_task_iti_delays'] = {'PASS': 0.99, 'WARNING': 0} + self.criteria['_task_passed_trial_checks'] = {'PASS': 0.7, 'WARNING': 0} + else: + self.criteria['_task_iti_delays'] = {'NOT_SET': 0} + self.criteria['_task_passed_trial_checks'] = {'NOT_SET': 0} + self.log.info(f'Session {self.session_path}: Running QC on behavior data...') self.metrics, self.passed = get_bpodqc_metrics_frame( self.extractor.data, @@ -159,17 +180,33 @@ def compute(self, **kwargs): photodiode=self.extractor.frame_ttls, audio=self.extractor.audio_ttls, re_encoding=self.extractor.wheel_encoding or 'X1', - min_qt=self.extractor.settings.get('QUIESCENT_PERIOD') or 0.2 + min_qt=self.extractor.settings.get('QUIESCENT_PERIOD') or 0.2, + audio_output=self.extractor.settings.get('device_sound', {}).get('OUTPUT', 'unknown') ) return def run(self, update=False, namespace='task', **kwargs): """ - :param update: if True, updates the session QC fields on Alyx - :param bpod_only: if True no data is extracted from the FPGA for ephys sessions - :param download_data: if True, any missing raw data is downloaded via ONE. By default - data are not downloaded if a session path was provided to the constructor. - :return: QC outcome (str), a dict for extended QC + Compute the QC outcomes and return overall task QC outcome. + + Parameters + ---------- + update : bool + If True, updates the session QC fields on Alyx. + namespace : str + The namespace of the QC fields in the Alyx JSON field. + bpod_only : bool + If True no data is extracted from the FPGA for ephys sessions. + download_data : bool + If True, any missing raw data is downloaded via ONE. By default data are not downloaded + if a session path was provided to the constructor. + + Returns + ------- + str + Overall task QC outcome. + dict + A map of QC tests and the proportion of data points that passed them. """ if self.metrics is None: self.compute(**kwargs) @@ -184,10 +221,20 @@ def compute_session_status_from_dict(results, criteria=None): """ Given a dictionary of results, computes the overall session QC for each key and aggregates in a single value - :param results: a dictionary of qc keys containing (usually scalar) values. - :param criteria: a dictionary of qc keys containing map of PASS, WARNING, FAIL thresholds. - :return: Overall session QC outcome as a string - :return: A dict of QC tests and their outcomes + + Parameters + ---------- + results : dict + A dictionary of QC keys containing (usually scalar) values. + criteria : dict + A dictionary of qc keys containing map of PASS, WARNING, FAIL thresholds. + + Returns + ------- + str + Overall session QC outcome as a string. + dict + A map of QC tests and their outcomes. """ indices = np.zeros(len(results), dtype=int) criteria = criteria or TaskQC.criteria @@ -206,10 +253,16 @@ def key_map(x): def compute_session_status(self): """ - Computes the overall session QC for each key and aggregates in a single value - :return: Overall session QC outcome as a string - :return: A dict of QC tests and the proportion of data points that passed them - :return: A dict of QC tests and their outcomes + Computes the overall session QC for each key and aggregates in a single value. + + Returns + ------- + str + Overall session QC outcome. + dict + A map of QC tests and the proportion of data points that passed them. + dict + A map of QC tests and their outcomes. """ if self.passed is None: raise AttributeError('passed is None; compute QC first') @@ -222,15 +275,11 @@ def compute_session_status(self): class HabituationQC(TaskQC): - criteria = dict() - criteria['default'] = {'PASS': 0.99, 'WARNING': 0.90, 'FAIL': 0} # Note: WARNING was 0.95 prior to Aug 2022 - criteria['_task_phase_distribution'] = {'PASS': 0.99, 'NOT_SET': 0} # This rarely passes due to low trial num - def compute(self, download_data=None, **kwargs): """Compute and store the QC metrics. Runs the QC on the session and stores a map of the metrics for each datapoint for each - test, and a map of which datapoints passed for each test + test, and a map of which datapoints passed for each test. :return: """ if self.extractor is None: @@ -242,6 +291,7 @@ def compute(self, download_data=None, **kwargs): # Initialize checks prefix = '_task_' data = self.extractor.data + audio_output = self.extractor.settings.get('device_sound', {}).get('OUTPUT', 'unknown') metrics = {} passed = {} @@ -314,7 +364,7 @@ def compute(self, download_data=None, **kwargs): check = prefix + 'phase_distribution' metric, _ = np.histogram(data['phase']) _, p = chisquare(metric) - passed[check] = p < 0.05 + passed[check] = p < 0.05 if len(data['phase']) >= 400 else None # skip if too few trials metrics[check] = metric # Checks common to training QC @@ -322,7 +372,7 @@ def compute(self, download_data=None, **kwargs): check_stimOn_delays, check_stimOff_delays] for fcn in checks: check = prefix + fcn.__name__[6:] - metrics[check], passed[check] = fcn(data) + metrics[check], passed[check] = fcn(data, audio_output=audio_output) self.metrics, self.passed = (metrics, passed) @@ -372,7 +422,7 @@ def is_metric(x): # === Delays between events checks === -def check_stimOn_goCue_delays(data, **_): +def check_stimOn_goCue_delays(data, audio_output='harp', **_): """ Checks that the time difference between the onset of the visual stimulus and the onset of the go cue tone is positive and less than 10ms. @@ -381,16 +431,23 @@ def check_stimOn_goCue_delays(data, **_): Units: seconds [s] :param data: dict of trial data with keys ('goCue_times', 'stimOn_times', 'intervals') + :param audio_output: audio output device name. + + Notes + ----- + For non-harp sound card the permissible delay is 0.053s. This was chosen by taking the 99.5th + percentile of delays over 500 training sessions using the Xonar soundcard. """ # Calculate the difference between stimOn and goCue times. # If either are NaN, the result will be Inf to ensure that it crosses the failure threshold. + threshold = 0.01 if audio_output.lower() == 'harp' else 0.053 metric = np.nan_to_num(data['goCue_times'] - data['stimOn_times'], nan=np.inf) - passed = (metric < 0.01) & (metric > 0) + passed = (metric < threshold) & (metric > 0) assert data['intervals'].shape[0] == len(metric) == len(passed) return metric, passed -def check_response_feedback_delays(data, **_): +def check_response_feedback_delays(data, audio_output='harp', **_): """ Checks that the time difference between the response and the feedback onset (error sound or valve) is positive and less than 10ms. @@ -399,9 +456,16 @@ def check_response_feedback_delays(data, **_): Units: seconds [s] :param data: dict of trial data with keys ('feedback_times', 'response_times', 'intervals') + :param audio_output: audio output device name. + + Notes + ----- + For non-harp sound card the permissible delay is 0.053s. This was chosen by taking the 99.5th + percentile of delays over 500 training sessions using the Xonar soundcard. """ + threshold = 0.01 if audio_output.lower() == 'harp' else 0.053 metric = np.nan_to_num(data['feedback_times'] - data['response_times'], nan=np.inf) - passed = (metric < 0.01) & (metric > 0) + passed = (metric < threshold) & (metric > 0) assert data['intervals'].shape[0] == len(metric) == len(passed) return metric, passed @@ -839,7 +903,7 @@ def check_trial_length(data, **_): # === Trigger-response delay checks === -def check_goCue_delays(data, **_): +def check_goCue_delays(data, audio_output='harp', **_): """ Check that the time difference between the go cue sound being triggered and effectively played is smaller than 1ms. @@ -847,15 +911,22 @@ def check_goCue_delays(data, **_): Criterion: 0 < M <= 0.0015 s Units: seconds [s] - :param data: dict of trial data with keys ('goCue_times', 'goCueTrigger_times', 'intervals') + :param data: dict of trial data with keys ('goCue_times', 'goCueTrigger_times', 'intervals'). + :param audio_output: audio output device name. + + Notes + ----- + For non-harp sound card the permissible delay is 0.053s. This was chosen by taking the 99.5th + percentile of delays over 500 training sessions using the Xonar soundcard. """ + threshold = 0.0015 if audio_output.lower() == 'harp' else 0.053 metric = np.nan_to_num(data['goCue_times'] - data['goCueTrigger_times'], nan=np.inf) - passed = (metric <= 0.0015) & (metric > 0) + passed = (metric <= threshold) & (metric > 0) assert data['intervals'].shape[0] == len(metric) == len(passed) return metric, passed -def check_errorCue_delays(data, **_): +def check_errorCue_delays(data, audio_output='harp', **_): """ Check that the time difference between the error sound being triggered and effectively played is smaller than 1ms. Metric: M = errorCue_times - errorCueTrigger_times @@ -864,9 +935,16 @@ def check_errorCue_delays(data, **_): :param data: dict of trial data with keys ('errorCue_times', 'errorCueTrigger_times', 'intervals', 'correct') + :param audio_output: audio output device name. + + Notes + ----- + For non-harp sound card the permissible delay is 0.062s. This was chosen by taking the 99.5th + percentile of delays over 500 training sessions using the Xonar soundcard. """ + threshold = 0.0015 if audio_output.lower() == 'harp' else 0.062 metric = np.nan_to_num(data['errorCue_times'] - data['errorCueTrigger_times'], nan=np.inf) - passed = ((metric <= 0.0015) & (metric > 0)).astype(float) + passed = ((metric <= threshold) & (metric > 0)).astype(float) passed[data['correct']] = metric[data['correct']] = np.nan assert data['intervals'].shape[0] == len(metric) == len(passed) return metric, passed @@ -993,14 +1071,20 @@ def check_wheel_integrity(data, re_encoding='X1', enc_res=None, **_): # === Pre-stimulus checks === def check_stimulus_move_before_goCue(data, photodiode=None, **_): """ Check that there are no visual stimulus change(s) between the start of the trial and the - go cue sound onset - 20 ms. + go cue sound onset, except for stim on. - Metric: M = number of visual stimulus change events between trial start and goCue_times - 20ms - Criterion: M == 0 + Metric: M = number of visual stimulus change events between trial start and goCue_times + Criterion: M == 1 Units: -none-, integer :param data: dict of trial data with keys ('goCue_times', 'intervals', 'choice') :param photodiode: the fronts from Bpod's BNC1 input or FPGA frame2ttl channel + + Notes + ----- + - There should be exactly 1 stimulus change before goCue; stimulus onset. Even if the stimulus + contrast is 0, the sync square will still flip at stimulus onset, etc. + - If there are no goCue times (all are NaN), the status should be NOT_SET. """ if photodiode is None: _log.warning('No photodiode TTL input in function call, returning None') @@ -1010,11 +1094,10 @@ def check_stimulus_move_before_goCue(data, photodiode=None, **_): s = s[~np.isnan(s)] # Remove NaNs metric = np.array([]) for i, c in zip(data['intervals'][:, 0], data['goCue_times']): - metric = np.append(metric, np.count_nonzero(s[s > i] < (c - 0.02))) + metric = np.append(metric, np.count_nonzero(s[s > i] < c)) - passed = (metric == 0).astype(float) - # Remove no go trials - passed[data['choice'] == 0] = np.nan + passed = (metric == 1).astype(float) + passed[np.isnan(data['goCue_times'])] = np.nan assert data['intervals'].shape[0] == len(metric) == len(passed) return metric, passed diff --git a/ibllib/tests/extractors/test_extractors.py b/ibllib/tests/extractors/test_extractors.py index 56a8de86d..7bd58d4f0 100644 --- a/ibllib/tests/extractors/test_extractors.py +++ b/ibllib/tests/extractors/test_extractors.py @@ -423,7 +423,7 @@ def test_get_included_trials_ge5(self): def test_get_included_trials(self): # TRAINING SESSIONS it = training_trials.IncludedTrials( - self.training_lt5['path']).extract(settings={'IBLRIG_VERSION_TAG': '4.9.9'})[0] + self.training_lt5['path']).extract(settings={'IBLRIG_VERSION': '4.9.9'})[0] self.assertTrue(isinstance(it, np.ndarray)) # -- version >= 5.0.0 it = training_trials.IncludedTrials( @@ -432,7 +432,7 @@ def test_get_included_trials(self): # BIASED SESSIONS it = biased_trials.IncludedTrials( - self.biased_lt5['path']).extract(settings={'IBLRIG_VERSION_TAG': '4.9.9'})[0] + self.biased_lt5['path']).extract(settings={'IBLRIG_VERSION': '4.9.9'})[0] self.assertTrue(isinstance(it, np.ndarray)) # -- version >= 5.0.0 it = biased_trials.IncludedTrials( @@ -445,7 +445,7 @@ def test_extract_all(self): # Expect an error raised because no wheel moves were present in test data with self.assertRaises(ValueError) as ex: training_trials.extract_all( - self.training_lt5['path'], settings={'IBLRIG_VERSION_TAG': '4.9.9'}, save=True) + self.training_lt5['path'], settings={'IBLRIG_VERSION': '4.9.9'}, save=True) self.assertIn('_ibl_wheelMoves.intervals.npy appears to be empty', str(ex.exception)) # -- version >= 5.0.0 out, files = training_trials.extract_all(self.training_ge5['path'], save=True) @@ -459,7 +459,7 @@ def test_extract_all(self): Wheel.var_names = tuple() Wheel().extract.return_value = ({}, []) out, files = biased_trials.extract_all( - self.biased_lt5['path'], settings={'IBLRIG_VERSION_TAG': '4.9.9'}, save=True) + self.biased_lt5['path'], settings={'IBLRIG_VERSION': '4.9.9'}, save=True) self.assertEqual(15, len(out)) self.assertTrue(all(map(Path.exists, files))) # -- version >= 5.0.0 @@ -508,18 +508,18 @@ def test_wheel_folders(self): def test_load_encoder_positions(self): raw.load_encoder_positions(self.training_lt5['path'], - settings={'IBLRIG_VERSION_TAG': '4.9.9'}) + settings={'IBLRIG_VERSION': '4.9.9'}) raw.load_encoder_positions(self.training_ge5['path']) raw.load_encoder_positions(self.biased_lt5['path'], - settings={'IBLRIG_VERSION_TAG': '4.9.9'}) + settings={'IBLRIG_VERSION': '4.9.9'}) raw.load_encoder_positions(self.biased_ge5['path']) def test_load_encoder_events(self): raw.load_encoder_events(self.training_lt5['path'], - settings={'IBLRIG_VERSION_TAG': '4.9.9'}) + settings={'IBLRIG_VERSION': '4.9.9'}) raw.load_encoder_events(self.training_ge5['path']) raw.load_encoder_events(self.biased_lt5['path'], - settings={'IBLRIG_VERSION_TAG': '4.9.9'}) + settings={'IBLRIG_VERSION': '4.9.9'}) raw.load_encoder_events(self.biased_ge5['path']) def test_size_outputs(self): diff --git a/ibllib/tests/qc/test_base_qc.py b/ibllib/tests/qc/test_base_qc.py index b5e68dda0..e56750c64 100644 --- a/ibllib/tests/qc/test_base_qc.py +++ b/ibllib/tests/qc/test_base_qc.py @@ -1,4 +1,5 @@ import unittest +from unittest import mock import numpy as np @@ -100,6 +101,7 @@ def test_extended_qc(self) -> None: self.assertEqual(updated, {**current, **data}, 'failed to update the extended qc') def test_outcome_setter(self): + """Test for QC.outcome property setter.""" qc = self.qc qc.outcome = 'Fail' self.assertEqual(qc.outcome, 'FAIL') @@ -116,11 +118,23 @@ def test_outcome_setter(self): self.assertEqual(qc.outcome, 'PASS') def test_code_to_outcome(self): + """Test for QC.code_to_outcome method.""" self.assertEqual(QC.code_to_outcome(3), 'FAIL') def test_overall_outcome(self): + """Test for QC.overall_outcome method.""" self.assertEqual(QC.overall_outcome(['PASS', 'NOT_SET', None, 'FAIL']), 'FAIL') + def test_compute_outcome_from_extended_qc(self): + """Test for QC.compute_outcome_from_extended_qc method.""" + detail = {'extended_qc': {'foo': 'FAIL', 'bar': 'WARNING', '_baz_': 'CRITICAL'}, + 'json': {'extended_qc': {'foo': 'PASS', 'bar': 'WARNING', '_baz_': 'CRITICAL'}}} + with mock.patch.object(self.qc.one.alyx, 'get', return_value=detail): + self.qc.json = False + self.assertEqual(self.qc.compute_outcome_from_extended_qc(), 'FAIL') + self.qc.json = True + self.assertEqual(self.qc.compute_outcome_from_extended_qc(), 'WARNING') -if __name__ == "__main__": + +if __name__ == '__main__': unittest.main(exit=False, verbosity=2) diff --git a/ibllib/tests/qc/test_task_metrics.py b/ibllib/tests/qc/test_task_metrics.py index a2b64f5ea..404876160 100644 --- a/ibllib/tests/qc/test_task_metrics.py +++ b/ibllib/tests/qc/test_task_metrics.py @@ -527,7 +527,8 @@ def setUp(self): eid = '8dd0fcb0-1151-4c97-ae35-2e2421695ad7' one = ONE(**TEST_DB) self.qc = qcmetrics.HabituationQC(eid, one=one) - self.qc.extractor = Bunch({'data': self.load_fake_bpod_data()}) # Dummy extractor obj + # Dummy extractor obj + self.qc.extractor = Bunch({'data': self.load_fake_bpod_data(), 'settings': {}}) @staticmethod def load_fake_bpod_data(n=5): @@ -578,5 +579,5 @@ def test_compute(self): self.assertEqual(outcomes['_task_habituation_time'], 'NOT_SET') -if __name__ == "__main__": +if __name__ == '__main__': unittest.main(exit=False, verbosity=2) diff --git a/ibllib/tests/test_base_tasks.py b/ibllib/tests/test_base_tasks.py index e91d20450..f5014a162 100644 --- a/ibllib/tests/test_base_tasks.py +++ b/ibllib/tests/test_base_tasks.py @@ -87,7 +87,7 @@ def test_spacer_support(self) -> None: settings = {} spacer_support = partial(base_tasks.BehaviourTask._spacer_support, settings) for version, expected in to_test: - settings['IBLRIG_VERSION_TAG'] = version + settings['IBLRIG_VERSION'] = version with self.subTest(version): self.assertIs(spacer_support(), expected) diff --git a/ibllib/tests/test_oneibl.py b/ibllib/tests/test_oneibl.py index aa9483d6b..9493e4cda 100644 --- a/ibllib/tests/test_oneibl.py +++ b/ibllib/tests/test_oneibl.py @@ -144,7 +144,7 @@ def test_dsets_2_path(self): 'SUBJECT_NAME': SUBJECT, 'PYBPOD_BOARD': '_iblrig_mainenlab_behavior_1', 'PYBPOD_PROTOCOL': '_iblrig_tasks_ephysChoiceWorld', - 'IBLRIG_VERSION_TAG': '5.4.1', + 'IBLRIG_VERSION': '5.4.1', 'SUBJECT_WEIGHT': 22, }