From 32c05980c080ade55d83e01a6e2375587426cfbc Mon Sep 17 00:00:00 2001 From: Miles Wells Date: Tue, 20 Feb 2024 11:13:15 +0200 Subject: [PATCH] Moved task QC viewer to ibllib.qc.task_qc_viewer --- task_qc_viewer/README.md | 63 --------- task_qc_viewer/ViewEphysQC.py | 182 ------------------------- task_qc_viewer/__init__.py | 0 task_qc_viewer/requirements.txt | 8 -- task_qc_viewer/task_qc.py | 226 -------------------------------- task_qc_viewer/version.py | 1 - 6 files changed, 480 deletions(-) delete mode 100644 task_qc_viewer/README.md delete mode 100644 task_qc_viewer/ViewEphysQC.py delete mode 100644 task_qc_viewer/__init__.py delete mode 100644 task_qc_viewer/requirements.txt delete mode 100644 task_qc_viewer/task_qc.py delete mode 100644 task_qc_viewer/version.py diff --git a/task_qc_viewer/README.md b/task_qc_viewer/README.md deleted file mode 100644 index e413ef5..0000000 --- a/task_qc_viewer/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# Task QC Viewer -This will download the TTL pulses and data collected on Bpod and/or FPGA and plot the results -alongside an interactive table. -The UUID is the session id. - -## Setup -Needs the iblenv installed properly. Follow this guide for setup: https://github.com/int-brain-lab/iblenv#iblenv - - -## Usage: command line -If on the server PC, activate the environment by typing: -``` -conda activate iblenv -``` -Otherwise, activate the iblenv as described in the guide above. - -Go into the iblapps directory that you cloned: -``` -cd /home/olivier/Documents/PYTHON/iblapps/task_qc_viewer -git checkout develop -git pull -``` -Launch the Viewer by typing `ipython task_qc.py session_UUID` , example: -``` -python task_qc.py c9fec76e-7a20-4da4-93ad-04510a89473b -# or with ipython -ipython task_qc.py -- c9fec76e-7a20-4da4-93ad-04510a89473b -``` - -Or just using a local path (on a local server for example): -``` -python task_qc.py /mnt/s0/Subjects/KS022/2019-12-10/001 --local -# or with ipython -ipython task_qc.py -- /mnt/s0/Subjects/KS022/2019-12-10/001 --local -``` - -## Usage: from ipython prompt -``` python -from iblapps.task_qc_viewer.task_qc import show_session_task_qc -session_path = "/datadisk/Data/IntegrationTests/ephys/choice_world_init/KS022/2019-12-10/001" -show_session_task_qc(session_path, local=True) -``` - -## Plots -1) Sync pulse display: -- TTL sync pulses (as recorded on the Bpod or FPGA for ephys sessions) for some key apparatus (i -.e. frame2TTL, audio signal). TTL pulse trains are displayed in black (time on x-axis, voltage on y-axis), offset by an increment of 1 each time (e.g. audio signal is on line 3, cf legend). -- trial event types, vertical lines (marked in different colours) - -2) Wheel display: -- the wheel position in radians -- trial event types, vertical lines (marked in different colours) - -3) Interactive table: -Each row is a trial entry. Each column is a trial event - -When double-clicking on any field of that table, the Sync pulse display time (x-) axis is adjusted so as to visualise the corresponding trial selected. - -### What to look for -Tests are defined in the SINGLE METRICS section of ibllib/qc/task_metrics.py: https://github.com/int-brain-lab/ibllib/blob/master/ibllib/qc/task_metrics.py#L148-L149 - -### Exit -Close the GUI window containing the interactive table to exit. diff --git a/task_qc_viewer/ViewEphysQC.py b/task_qc_viewer/ViewEphysQC.py deleted file mode 100644 index 1bd7803..0000000 --- a/task_qc_viewer/ViewEphysQC.py +++ /dev/null @@ -1,182 +0,0 @@ -import logging - -from PyQt5 import QtCore, QtWidgets -from matplotlib.figure import Figure -from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg, NavigationToolbar2QT -import pandas as pd -import numpy as np - -import qt as qt - -_logger = logging.getLogger('ibllib') - - -class DataFrameModel(QtCore.QAbstractTableModel): - DtypeRole = QtCore.Qt.UserRole + 1000 - ValueRole = QtCore.Qt.UserRole + 1001 - - def __init__(self, df=pd.DataFrame(), parent=None): - super(DataFrameModel, self).__init__(parent) - self._dataframe = df - - def setDataFrame(self, dataframe): - self.beginResetModel() - self._dataframe = dataframe.copy() - self.endResetModel() - - def dataFrame(self): - return self._dataframe - - dataFrame = QtCore.pyqtProperty(pd.DataFrame, fget=dataFrame, fset=setDataFrame) - - @QtCore.pyqtSlot(int, QtCore.Qt.Orientation, result=str) - def headerData(self, section: int, orientation: QtCore.Qt.Orientation, - role: int = QtCore.Qt.DisplayRole): - if role == QtCore.Qt.DisplayRole: - if orientation == QtCore.Qt.Horizontal: - return self._dataframe.columns[section] - else: - return str(self._dataframe.index[section]) - return QtCore.QVariant() - - def rowCount(self, parent=QtCore.QModelIndex()): - if parent.isValid(): - return 0 - return len(self._dataframe.index) - - def columnCount(self, parent=QtCore.QModelIndex()): - if parent.isValid(): - return 0 - return self._dataframe.columns.size - - def data(self, index, role=QtCore.Qt.DisplayRole): - if (not index.isValid() or not (0 <= index.row() < self.rowCount() and - 0 <= index.column() < self.columnCount())): - return QtCore.QVariant() - row = self._dataframe.index[index.row()] - col = self._dataframe.columns[index.column()] - dt = self._dataframe[col].dtype - - val = self._dataframe.iloc[row][col] - if role == QtCore.Qt.DisplayRole: - return str(val) - elif role == DataFrameModel.ValueRole: - return val - if role == DataFrameModel.DtypeRole: - return dt - return QtCore.QVariant() - - def roleNames(self): - roles = { - QtCore.Qt.DisplayRole: b'display', - DataFrameModel.DtypeRole: b'dtype', - DataFrameModel.ValueRole: b'value' - } - return roles - - def sort(self, col, order): - """ - Sort table by given column number - :param col: the column number selected (between 0 and self._dataframe.columns.size) - :param order: the order to be sorted, 0 is descending; 1, ascending - :return: - """ - self.layoutAboutToBeChanged.emit() - col_name = self._dataframe.columns.values[col] - # print('sorting by ' + col_name) - self._dataframe.sort_values(by=col_name, ascending=not order, inplace=True) - self._dataframe.reset_index(inplace=True, drop=True) - self.layoutChanged.emit() - - -class PlotCanvas(FigureCanvasQTAgg): - - def __init__(self, parent=None, width=5, height=4, dpi=100, wheel=None): - fig = Figure(figsize=(width, height), dpi=dpi) - - FigureCanvasQTAgg.__init__(self, fig) - self.setParent(parent) - - FigureCanvasQTAgg.setSizePolicy( - self, - QtWidgets.QSizePolicy.Expanding, - QtWidgets.QSizePolicy.Expanding) - FigureCanvasQTAgg.updateGeometry(self) - if wheel: - self.ax, self.ax2 = fig.subplots( - 2, 1, gridspec_kw={'height_ratios': [2, 1]}, sharex=True) - else: - self.ax = fig.add_subplot(111) - self.draw() - - -class PlotWindow(QtWidgets.QWidget): - def __init__(self, parent=None, wheel=None): - QtWidgets.QWidget.__init__(self, parent=None) - self.canvas = PlotCanvas(wheel=wheel) - self.vbl = QtWidgets.QVBoxLayout() # Set box for plotting - self.vbl.addWidget(self.canvas) - self.setLayout(self.vbl) - self.vbl.addWidget(NavigationToolbar2QT(self.canvas, self)) - - -class GraphWindow(QtWidgets.QWidget): - def __init__(self, parent=None, wheel=None): - QtWidgets.QWidget.__init__(self, parent=None) - vLayout = QtWidgets.QVBoxLayout(self) - hLayout = QtWidgets.QHBoxLayout() - self.pathLE = QtWidgets.QLineEdit(self) - hLayout.addWidget(self.pathLE) - self.loadBtn = QtWidgets.QPushButton("Select File", self) - hLayout.addWidget(self.loadBtn) - vLayout.addLayout(hLayout) - self.pandasTv = QtWidgets.QTableView(self) - vLayout.addWidget(self.pandasTv) - self.loadBtn.clicked.connect(self.loadFile) - self.pandasTv.setSortingEnabled(True) - self.pandasTv.doubleClicked.connect(self.tv_double_clicked) - self.wplot = PlotWindow(wheel=wheel) - self.wplot.show() - self.wheel = wheel - - def loadFile(self): - fileName, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Open File", "", - "CSV Files (*.csv)") - self.pathLE.setText(fileName) - df = pd.read_csv(fileName) - self.update_df(df) - - def update_df(self, df): - model = DataFrameModel(df) - self.pandasTv.setModel(model) - self.wplot.canvas.draw() - - def tv_double_clicked(self): - df = self.pandasTv.model()._dataframe - ind = self.pandasTv.currentIndex() - start = df.loc[ind.row()]['intervals_0'] - finish = df.loc[ind.row()]['intervals_1'] - dt = finish - start - if self.wheel: - idx = np.searchsorted(self.wheel['re_ts'], np.array([start - dt / 10, - finish + dt / 10])) - period = self.wheel['re_pos'][idx[0]:idx[1]] - if period.size == 0: - _logger.warning('No wheel data during trial #%i', ind.row()) - else: - min_val, max_val = np.min(period), np.max(period) - self.wplot.canvas.ax2.set_ylim(min_val - 1, max_val + 1) - self.wplot.canvas.ax2.set_xlim(start - dt / 10, finish + dt / 10) - self.wplot.canvas.ax.set_xlim(start - dt / 10, finish + dt / 10) - - self.wplot.canvas.draw() - - -def viewqc(qc=None, title=None, wheel=None): - qt.create_app() - qcw = GraphWindow(wheel=wheel) - qcw.setWindowTitle(title) - if qc is not None: - qcw.update_df(qc) - qcw.show() - return qcw diff --git a/task_qc_viewer/__init__.py b/task_qc_viewer/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/task_qc_viewer/requirements.txt b/task_qc_viewer/requirements.txt deleted file mode 100644 index de93d2b..0000000 --- a/task_qc_viewer/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -flake8 -ibllib -matplotlib -pandas -pyqt5 -pyqtgraph -ipython - diff --git a/task_qc_viewer/task_qc.py b/task_qc_viewer/task_qc.py deleted file mode 100644 index 137f725..0000000 --- a/task_qc_viewer/task_qc.py +++ /dev/null @@ -1,226 +0,0 @@ -import logging -import argparse -from itertools import cycle -import random -from collections.abc import Sized - -import pandas as pd -import qt as qt -from matplotlib.colors import TABLEAU_COLORS - -from one.api import ONE -import ibllib.plots as plots -from ibllib.io.extractors import ephys_fpga -from ibllib.qc.task_metrics import TaskQC -from ibllib.qc.task_extractors import TaskQCExtractor - -from task_qc_viewer import ViewEphysQC - -EVENT_MAP = {'goCue_times': ['#2ca02c', 'solid'], # green - 'goCueTrigger_times': ['#2ca02c', 'dotted'], # green - 'errorCue_times': ['#d62728', 'solid'], # red - 'errorCueTrigger_times': ['#d62728', 'dotted'], # red - 'valveOpen_times': ['#17becf', 'solid'], # cyan - 'stimFreeze_times': ['#0000ff', 'solid'], # blue - 'stimFreezeTrigger_times': ['#0000ff', 'dotted'], # blue - 'stimOff_times': ['#9400d3', 'solid'], # dark violet - 'stimOffTrigger_times': ['#9400d3', 'dotted'], # dark violet - 'stimOn_times': ['#e377c2', 'solid'], # pink - 'stimOnTrigger_times': ['#e377c2', 'dotted'], # pink - 'response_times': ['#8c564b', 'solid'], # brown - } -cm = [EVENT_MAP[k][0] for k in EVENT_MAP] -ls = [EVENT_MAP[k][1] for k in EVENT_MAP] -CRITICAL_CHECKS = ( - 'check_audio_pre_trial', - 'check_correct_trial_event_sequence', - 'check_error_trial_event_sequence', - 'check_n_trial_events', - 'check_response_feedback_delays', - 'check_response_stimFreeze_delays', - 'check_reward_volume_set', - 'check_reward_volumes', - 'check_stimOn_goCue_delays', - 'check_stimulus_move_before_goCue', - 'check_wheel_move_before_feedback', - 'check_wheel_freeze_during_quiescence' -) - - -_logger = logging.getLogger('ibllib') - - -class QcFrame(TaskQC): - - def __init__(self, session, bpod_only=False, local=False, one=None): - """ - Loads and extracts the QC data for a given session path - :param session: A str or Path to a session, or a session eid - :param bpod_only: When True all data is extracted from Bpod instead of FPGA for ephys - """ - if not isinstance(session, TaskQC): - one = one or ONE() - super().__init__(session, one=one, log=_logger) - - if local: - dsets, out_files = ephys_fpga.extract_all(session, save=True) - self.extractor = TaskQCExtractor(session, lazy=True, one=one) - # Extract extra datasets required for QC - self.extractor.data = dsets - self.extractor.extract_data() - # Aggregate and update Alyx QC fields - self.run(update=False) - else: - self.load_data(bpod_only=bpod_only) - self.compute() - else: - assert session.extractor and session.metrics, 'Please run QC before passing to QcFrame' - super().__init__(session.eid or session.session_path, one=session.one, log=session.log) - for attr in ('criteria', 'criteria', '_outcome', 'extractor', 'metrics', 'passed'): - setattr(self, attr, getattr(session, attr)) - self.n_trials = self.extractor.data['intervals'].shape[0] - self.wheel_data = {'re_pos': self.extractor.data.pop('wheel_position'), - 're_ts': self.extractor.data.pop('wheel_timestamps')} - - # Print failed - outcome, results, outcomes = self.compute_session_status() - map = {k: [] for k in set(outcomes.values())} - for k, v in outcomes.items(): - map[v].append(k[6:]) - for k, v in map.items(): - if k == 'PASS': - continue - print(f'The following checks were labelled {k}:') - print('\n'.join(v), '\n') - - print('The following *critical* checks did not pass:') - critical_checks = [f'_{x.replace("check", "task")}' for x in CRITICAL_CHECKS] - for k, v in outcomes.items(): - if v != 'PASS' and k in critical_checks: - print(k[6:]) - - # Make DataFrame from the trail level metrics - def get_trial_level_failed(d): - new_dict = {k[6:]: v for k, v in d.items() if - isinstance(v, Sized) and len(v) == self.n_trials} - return pd.DataFrame.from_dict(new_dict) - - self.frame = get_trial_level_failed(self.metrics) - self.frame['intervals_0'] = self.extractor.data['intervals'][:, 0] - self.frame['intervals_1'] = self.extractor.data['intervals'][:, 1] - self.frame.insert(loc=0, column='trial_no', value=self.frame.index) - - def create_plots(self, axes, - wheel_axes=None, trial_events=None, color_map=None, linestyle=None): - """ - Plots the data for bnc1 (sound) and bnc2 (frame2ttl) - :param axes: An axes handle on which to plot the TTL events - :param wheel_axes: An axes handle on which to plot the wheel trace - :param trial_events: A list of Bpod trial events to plot, e.g. ['stimFreeze_times'], - if None, valve, sound and stimulus events are plotted - :param color_map: A color map to use for the events, default is the tableau color map - linestyle: A line style map to use for the events, default is random. - :return: None - """ - color_map = color_map or TABLEAU_COLORS.keys() - if trial_events is None: - # Default trial events to plot as vertical lines - trial_events = [ - 'goCue_times', - 'goCueTrigger_times', - 'feedback_times', - 'stimFreeze_times', - 'stimOff_times', - 'stimOn_times' - ] - - plot_args = { - 'ymin': 0, - 'ymax': 4, - 'linewidth': 2, - 'ax': axes - } - - bnc1 = self.extractor.frame_ttls - bnc2 = self.extractor.audio_ttls - trial_data = self.extractor.data - - if bnc1['times'].size: - plots.squares(bnc1['times'], bnc1['polarities'] * 0.4 + 1, ax=axes, color='k') - if bnc2['times'].size: - plots.squares(bnc2['times'], bnc2['polarities'] * 0.4 + 2, ax=axes, color='k') - linestyle = linestyle or random.choices(('-', '--', '-.', ':'), k=len(trial_events)) - - if self.extractor.bpod_ttls is not None: - bpttls = self.extractor.bpod_ttls - plots.squares(bpttls['times'], bpttls['polarities'] * 0.4 + 3, ax=axes, color='k') - plot_args['ymax'] = 4 - ylabels = ['', 'frame2ttl', 'sound', 'bpod', ''] - else: - plot_args['ymax'] = 3 - ylabels = ['', 'frame2ttl', 'sound', ''] - - for event, c, l in zip(trial_events, cycle(color_map), linestyle): - plots.vertical_lines(trial_data[event], label=event, color=c, linestyle=l, **plot_args) - - axes.legend(loc='upper left', fontsize='xx-small', bbox_to_anchor=(1, 0.5)) - axes.set_yticks(list(range(plot_args['ymax'] + 1))) - axes.set_yticklabels(ylabels) - axes.set_ylim([0, plot_args['ymax']]) - - if wheel_axes: - wheel_plot_args = { - 'ax': wheel_axes, - 'ymin': self.wheel_data['re_pos'].min(), - 'ymax': self.wheel_data['re_pos'].max()} - plot_args = {**plot_args, **wheel_plot_args} - - wheel_axes.plot(self.wheel_data['re_ts'], self.wheel_data['re_pos'], 'k-x') - for event, c, ln in zip(trial_events, cycle(color_map), linestyle): - plots.vertical_lines(trial_data[event], - label=event, color=c, linestyle=ln, **plot_args) - - -def show_session_task_qc(qc_or_session=None, bpod_only=False, local=False, one=None): - """ - Displays the task QC for a given session - :param qc_or_session: session_path or TaskQC object - :param bpod_only: (no FPGA) - :param local: set True for local extraction - :return: The QC object - """ - if isinstance(qc_or_session, QcFrame): - qc = qc_or_session - elif isinstance(qc_or_session, TaskQC): - qc = QcFrame(qc_or_session, one=one) - else: - qc = QcFrame(qc_or_session, bpod_only=bpod_only, local=local, one=one) - # Run QC and plot - w = ViewEphysQC.viewqc(wheel=qc.wheel_data) - qc.create_plots(w.wplot.canvas.ax, - wheel_axes=w.wplot.canvas.ax2, - trial_events=EVENT_MAP.keys(), - color_map=cm, - linestyle=ls) - # Update table and callbacks - w.update_df(qc.frame) - qt.run_app() - return qc - - -if __name__ == "__main__": - """Run TaskQC viewer with wheel data - For information on the QC checks see the QC Flags & failures document: - https://docs.google.com/document/d/1X-ypFEIxqwX6lU9pig4V_zrcR5lITpd8UJQWzW9I9zI/edit# - ipython task_qc.py c9fec76e-7a20-4da4-93ad-04510a89473b - ipython task_qc.py ./KS022/2019-12-10/001 --local - """ - # Parse parameters - parser = argparse.ArgumentParser(description='Quick viewer to see the behaviour data from' - 'choice world sessions.') - parser.add_argument('session', help='session uuid') - parser.add_argument('--bpod', action='store_true', help='run QC on Bpod data only (no FPGA)') - parser.add_argument('--local', action='store_true', help='run from disk location (lab server') - args = parser.parse_args() # returns data from the options specified (echo) - - show_session_task_qc(qc_or_session=args.session, bpod_only=args.bpod, local=args.local) diff --git a/task_qc_viewer/version.py b/task_qc_viewer/version.py deleted file mode 100644 index 1f356cc..0000000 --- a/task_qc_viewer/version.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = '1.0.0'