From 48ecc493101d58fd9d5e2435ff43a57147ce83ad Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 11 Dec 2024 13:15:48 +0000 Subject: [PATCH 01/38] initial work on PyQt based online plots --- iblrig/commands.py | 6 +- iblrig/gui/online_plots.py | 384 +++++++++++++++++++++++++++++++++++++ iblrig/raw_data_loaders.py | 21 +- pdm.lock | 28 ++- pyproject.toml | 3 + 5 files changed, 428 insertions(+), 14 deletions(-) create mode 100644 iblrig/gui/online_plots.py diff --git a/iblrig/commands.py b/iblrig/commands.py index 37c2298d2..e1437aaa7 100644 --- a/iblrig/commands.py +++ b/iblrig/commands.py @@ -9,6 +9,7 @@ import yaml import iblrig +from iblrig.gui.online_plots import online_plots_cli from iblrig.hardware import Bpod from iblrig.online_plots import OnlinePlots from iblrig.path_helper import get_local_and_remote_paths @@ -17,7 +18,6 @@ logger = logging.getLogger(__name__) - tag2copier = { 'behavior': BehaviorCopier, 'video': VideoCopier, @@ -358,6 +358,10 @@ def view_session(): online_plots.run(file_jsonable=args.file_jsonable) +def view_session2(): + online_plots_cli() + + def flush(): """Flush the valve until the user hits enter.""" file_settings = Path(iblrig.__file__).parents[1].joinpath('settings', 'hardware_settings.yaml') diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py new file mode 100644 index 000000000..86d2b5867 --- /dev/null +++ b/iblrig/gui/online_plots.py @@ -0,0 +1,384 @@ +import json +from pathlib import Path + +import numpy as np +import pandas as pd +import pyqtgraph as pg +from pydantic import DirectoryPath, Field, validate_call +from pydantic_settings import BaseSettings, CliPositionalArg +from qtpy.QtCore import QFileSystemWatcher, QItemSelection, QObject, QRectF, Qt, Signal, Slot +from qtpy.QtGui import QColor, QPainter, QTransform +from qtpy.QtWidgets import ( + QApplication, + QFrame, + QGraphicsRectItem, + QGridLayout, + QHeaderView, + QLabel, + QMainWindow, + QSizePolicy, + QStyledItemDelegate, + QTableView, +) + +from iblqt.core import DataFrameTableModel +from iblrig.raw_data_loaders import bpod_session_data_to_dataframe, load_task_jsonable + + +class OnlinePlotsModel(QObject): + currentTrialChanged = Signal(int) + _trial_data = pd.DataFrame() + _bpod_data = pd.DataFrame() + trials_table = pd.DataFrame() + table_model = DataFrameTableModel() + _jsonableOffset = 0 + _currentTrial = 0 + + @validate_call(config=dict(arbitrary_types_allowed=True)) + def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None): + super().__init__(parent=parent) + self.raw_data_folder = raw_data_folder + self.jsonable_file = raw_data_folder.joinpath('_iblrig_taskData.raw.jsonable') + self.settings_file = raw_data_folder.joinpath('_iblrig_taskSettings.raw.json') + + if not self.jsonable_file.exists(): + raise FileNotFoundError(self.jsonable_file) + if not self.settings_file.exists(): + raise FileNotFoundError(self.settings_file) + + with self.settings_file.open('r') as f: + self.task_settings = json.load(f) + + self.readJsonable(self.jsonable_file) + self.jsonableWatcher = QFileSystemWatcher([str(self.jsonable_file)], parent=self) + self.jsonableWatcher.fileChanged.connect(self.readJsonable) + + @Slot(str) + def readJsonable(self, _: str) -> None: + trial_data, bpod_data = load_task_jsonable(self.jsonable_file, offset=self._jsonableOffset) + self._jsonableOffset = self.jsonable_file.stat().st_size + self._trial_data = pd.concat([self._trial_data, trial_data]) + self._bpod_data = bpod_session_data_to_dataframe(bpod_data=bpod_data, existing_data=self._bpod_data) + + table = pd.DataFrame() + table['Trial'] = self._trial_data.trial_num + table['Stimulus'] = np.sign(self._trial_data.position) * self._trial_data.contrast + table['Outcome'] = self._trial_data.apply( + lambda row: 'no-go' if row['response_side'] == 0 else ('correct' if row['trial_correct'] else 'error'), axis=1 + ) + table['Response Time / s'] = self._trial_data.apply( + lambda row: np.NAN if row['response_side'] == 0 else row['response_time'], axis=1 + ) + self.table_model.setDataFrame(table) + + self.setCurrentTrial(self.nTrials() - 1) + + @Slot(int) + def setCurrentTrial(self, value: int) -> None: + if value != self._currentTrial: + self._currentTrial = value + self.currentTrialChanged.emit(value) + + def currentTrial(self) -> int: + return self._currentTrial + + def nTrials(self) -> int: + return len(self._trial_data) + + def bpod_data(self, trial: int) -> pd.DataFrame: + return self._bpod_data[self._bpod_data.Trial == trial] + + +class StimulusDelegate(QStyledItemDelegate): + pen = QColor(0, 0, 0, 128) + + def paint(self, painter, option, index): + value = index.data() + color = QColor() + color.setHslF(0, 0, 1.0 - abs(value)) + + diameter = int(option.rect.height() * 0.8) + spacing = (option.rect.height() - diameter) // 2 + x_pos = option.rect.left() + spacing if value < 0 else option.rect.right() - diameter - spacing + y_pos = option.rect.top() + spacing + + painter.setRenderHint(QPainter.Antialiasing) + painter.setBrush(color) + painter.setPen(self.pen) + painter.drawEllipse(x_pos, y_pos, diameter, diameter) # Draw circle + + +class ResponseTimeDelegate(QStyledItemDelegate): + norm_min = 0.1 + norm_max = 60.0 + norm_div = np.log(norm_max / norm_min) + color_correct = QColor(90, 180, 172) + color_error = QColor(216, 179, 101) + color_nogo = QColor(220, 220, 220) + + def paint(self, painter, option, index): + # Get the float value from the model + value = index.data() + status = index.sibling(index.row(), 2).data() + + # Draw the progress bar + painter.fillRect(option.rect, option.backgroundBrush) + if status == 'no-go': + filled_rect = QRectF(option.rect) + painter.setBrush(self.color_nogo) + else: + norm_value = np.log(value / self.norm_min) / self.norm_div + filled_rect = QRectF(option.rect) + filled_rect.setWidth(filled_rect.width() * norm_value) + painter.setBrush(self.color_correct if status == 'correct' else self.color_error) + painter.setPen(Qt.NoPen) + painter.drawRect(filled_rect) + + # Draw the value text + painter.setPen(Qt.black) + value_text = f'{value:.2f}' if status != 'no-go' else 'N/A' + painter.drawText(option.rect, Qt.AlignVCenter | Qt.AlignCenter, value_text) + + +class StateRegionItem(pg.LinearRegionItem): + statusMessage = Signal(str) + + def __init__(self, *args, stateName: str, **kwargs): + super().__init__(*args, **kwargs) + self.setMovable(False) + self.stateName = stateName + + def hoverEvent(self, ev): + if ev.enter: + self.statusMessage.emit(f'State: "{self.stateName}"') + elif ev.exit: + if not hasattr(ev, '_scenePos'): + self.statusMessage.emit('') + else: + item = self.scene().itemAt(ev.scenePos(), QTransform()) + if not isinstance(item, QGraphicsRectItem): + self.statusMessage.emit('') + + +class BpodWidget(pg.GraphicsLayoutWidget): + data = pd.DataFrame() + colors = pg.colormap.get('glasbey_light', source='colorcet') + labels: dict[str, pg.LabelItem] = dict() + plots: dict[str, pg.PlotDataItem] = dict() + viewBoxes: dict[str, pg.ViewBox] = dict() + + def __init__(self, *args, title: str | None = None, **kwargs): + super().__init__(*args, **kwargs) + + self.setRenderHints(QPainter.Antialiasing) + self.setBackground('white') + self.centralWidget.setSpacing(0) + + # add title + if title is not None: + self.centralWidget.nextRow() + self.addLabel(title, col=1, color='k') + + # add plots for digital channels + for channel in ('BNC1', 'BNC2', 'Port1'): + self.addDigitalChannel(channel) + + # add x axis + self.centralWidget.nextRow() + a = pg.AxisItem(orientation='bottom', textPen='k', linkView=list(self.viewBoxes.values())[0], parent=self.centralWidget) + a.setLabel(text='Time', units='s') + a.enableAutoSIPrefix(True) + self.centralWidget.addItem(a, col=1) + + def addDigitalChannel(self, channel: str, label: str | None = None): + label = channel if label is None else label + self.centralWidget.nextRow() + self.labels[channel] = self.addLabel(label, col=0, color='k') + self.plots[channel] = pg.PlotDataItem(pen='k', stepMode='right') + self.plots[channel].setSkipFiniteCheck(True) + self.viewBoxes[channel] = self.addViewBox(col=1) + self.viewBoxes[channel].addItem(self.plots[channel]) + self.viewBoxes[channel].setMouseEnabled(x=True, y=False) + self.viewBoxes[channel].sigXRangeChanged.connect(self.updateXRange) + + def setData(self, data: pd.DataFrame): + self.data = data + self.showTrial() + self.drawStateRegionItems() + + def drawStateRegionItems(self): + start = self.data[self.data.Type == 'TrialStart'].index[0] + t0 = self.data[self.data.Type == 'StateStart'] + t1 = self.data[self.data.Type == 'StateEnd'] + + # remove existing regions + for view_box in self.viewBoxes.values(): + for item in view_box.allChildren(): + if isinstance(item, StateRegionItem): + view_box.removeItem(item) + item.deleteLater() + + pen = (0, 0, 0, 0) + for state_idx, state in enumerate(self.data['State'].cat.categories): + state_mask = t0['State'] == state + t0_state = (t0.index[state_mask] - start).total_seconds().to_list() + t1_state = (t1.index[state_mask] - start).total_seconds().to_list() + brush = self.colors.getByIndex(state_idx) + brush.setAlphaF(0.2) + + for times in zip(t0_state, t1_state, strict=False): + for view_box in self.viewBoxes.values(): + r = StateRegionItem(times, stateName=state, brush=brush, pen=pen) + r.statusMessage.connect(self.showStatusMessage) + view_box.addItem(r) + + @Slot(str) + def showStatusMessage(self, string: str): + self.window().statusBar().showMessage(string) + + def showTrial(self): + limits = self.data[self.data['Type'].isin(['TrialStart', 'TrialEnd'])] + limits = limits.index.total_seconds() + self.limits = {'xMin': 0, 'xMax': limits[1] - limits[0], 'minXRange': 0.001} + + for channel in self.plots: + values = self.data.loc[self.data.Channel == channel, 'Value'] + x = values.index.total_seconds().to_numpy() - limits[0] + y = values.to_numpy() + + # Since Bpod only supports *changes* in the digital signals, we need + # to extend the plots to the axes limits. + if len(x) > 0: + x = np.insert(x, 0, 0) + x = np.append(x, limits[1]) + y = np.insert(y, 0, not y[0]) + y = np.append(y, y[-1]) + + self.plots[channel].setData(x, y) + self.viewBoxes[channel].setLimits(**self.limits) + + list(self.viewBoxes.values())[0].setXRange( + self.data.index[0].total_seconds(), self.data.index[-1].total_seconds(), padding=0 + ) + + def updateXRange(self): + sender = self.sender() + x_range = sender.viewRange()[0] + + # Update the x-range for all other ViewBoxes + for view_box in self.viewBoxes.values(): + if view_box is not sender: # Avoid updating the sender + view_box.setXRange(x_range[0], x_range[1], padding=0) + + +class OnlinePlotsView(QMainWindow): + def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None): + super().__init__(parent) + pg.setConfigOptions(antialias=True) + self.model = OnlinePlotsModel(raw_data_folder, self) + + self.statusBar().showMessage('') + self.setWindowTitle('Online Plots') + + # the frame that contains all the plots + frame = QFrame(self) + frame.setFrameStyle(QFrame.StyledPanel) + frame.setStyleSheet('background-color: rgb(255, 255, 255);') + self.setCentralWidget(frame) + + # we use a grid layout to organize the different plots + layout = QGridLayout(frame) + layout.setSpacing(0) + frame.setLayout(layout) + + # main title + self.title = QLabel('This is the main title') + self.title.setAlignment(Qt.AlignHCenter) + font = self.title.font() + font.setPointSize(15) + font.setBold(True) + self.title.setFont(font) + self.title.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) + layout.addWidget(self.title, 0, 0, 1, 2) + + # sub title + subtitle = QLabel('This is the sub-title') + subtitle.setAlignment(Qt.AlignHCenter) + subtitle.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) + layout.addWidget(subtitle, 1, 0, 1, 2) + + # trial data + self.trials = QTableView(self) + self.trials.setModel(self.model.table_model) + self.trials.verticalHeader().hide() + self.trials.horizontalHeader().setSectionResizeMode(QHeaderView.Fixed) + self.trials.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + self.trials.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) + # self.trials.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) + self.trials.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) + self.trials.setStyleSheet('QHeaderView::section { border: none; background-color: white; }') + self.trials.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.trials.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.stimulusDelegate = StimulusDelegate() + self.responseTimeDelegate = ResponseTimeDelegate() + self.trials.setItemDelegateForColumn(1, self.stimulusDelegate) + self.trials.setColumnHidden(2, True) + self.trials.setItemDelegateForColumn(3, self.responseTimeDelegate) + self.trials.setShowGrid(False) + self.trials.setFrameShape(QTableView.NoFrame) + self.trials.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.trials.setSelectionMode(QTableView.SingleSelection) + self.trials.setSelectionBehavior(QTableView.SelectRows) + self.trials.selectionModel().selectionChanged.connect(self.onSelectionChanged) + layout.addWidget(self.trials, 2, 0, 1, 1) + + # bpod data + self.bpodWidget = BpodWidget(self, title='Bpod States and Input Channels') + self.bpodWidget.setMinimumHeight(200) + self.bpodWidget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) + layout.addWidget(self.bpodWidget, 3, 0, 1, 2) + + self.model.currentTrialChanged.connect(self.updatePlots) + + @Slot(int) + def updatePlots(self, trial: int): + self.title.setText(f'Trial {trial}') + self.bpodWidget.setData(self.model.bpod_data(trial)) + self.trials.setCurrentIndex(self.model.table_model.index(trial, 0)) + self.update() + + def onSelectionChanged(self, selected: QItemSelection, _: QItemSelection): + self.model.setCurrentTrial(selected.indexes()[0].row()) + + def keyPressEvent(self, event) -> None: + """Navigate trials using directional keys.""" + match event.key(): + case Qt.Key.Key_Up: + if self.model.currentTrial() > 0: + self.model.setCurrentTrial(self.model.currentTrial() - 1) + case Qt.Key.Key_Down: + if self.model.currentTrial() < (self.model.nTrials() - 1): + self.model.setCurrentTrial(self.model.currentTrial() + 1) + case Qt.Key.Key_Home: + self.model.setCurrentTrial(0) + case Qt.Key.Key_End: + self.model.setCurrentTrial(self.model.nTrials() - 1) + case _: + return + event.accept() + + +def online_plots_cli(): + class Settings(BaseSettings, cli_parse_args=True): + directory: CliPositionalArg[Path] = Field(description='Raw Data Directory') + + app = QApplication([]) + + window = OnlinePlotsView(Settings().directory) + window.show() + + app.exec() + + +if __name__ == '__main__': + online_plots_cli() diff --git a/iblrig/raw_data_loaders.py b/iblrig/raw_data_loaders.py index 823201bcf..0cb7c278d 100644 --- a/iblrig/raw_data_loaders.py +++ b/iblrig/raw_data_loaders.py @@ -43,7 +43,7 @@ def load_task_jsonable(jsonable_file: str | Path, offset: int | None = None) -> return trials_table, bpod_data -def bpod_session_data_to_dataframe(bpod_data: list[dict[str, Any]], trials: int | list[int] | slice | None = None): +def bpod_session_data_to_dataframe(bpod_data: list[dict[str, Any]], existing_data: pd.DataFrame | None = None) -> pd.DataFrame: """ Convert Bpod session data into a single Pandas DataFrame. @@ -51,8 +51,8 @@ def bpod_session_data_to_dataframe(bpod_data: list[dict[str, Any]], trials: int ---------- bpod_data : list of dict A list of dictionaries as returned by load_task_jsonable, where each dictionary contains data for a single trial. - trials : int, list of int, slice, or None, optional - Specifies which trials to include in the DataFrame. All trials are included by default. + existing_data : pd.DataFrame + Existing dataframe that the incoming data will be appended to. Returns ------- @@ -75,17 +75,14 @@ def bpod_session_data_to_dataframe(bpod_data: list[dict[str, Any]], trials: int value of the event (only for a subset of InputEvents) """ # define trial index - if trials is None: - trials = range(len(bpod_data)) - elif isinstance(trials, int): - return bpod_trial_data_to_dataframe(bpod_data[trials], trials) - elif isinstance(trials, slice): - trials = range(len(bpod_data))[trials] + trials = np.arange(len(bpod_data)) + if existing_data is not None and 'Trial' in existing_data: + trials += existing_data.iloc[-1].Trial + 1 # loop over requested trials - dataframes = [] - for trial in trials: - dataframes.append(bpod_trial_data_to_dataframe(bpod_data[trial], trial)) + dataframes = [] if existing_data is None or len(existing_data) == 0 else [existing_data] + for index, trial in enumerate(trials): + dataframes.append(bpod_trial_data_to_dataframe(bpod_data[index], trial)) # combine trials into a single dataframe categories_type = union_categoricals([df['Type'] for df in dataframes]) diff --git a/pdm.lock b/pdm.lock index 2ea5974d4..1ad6231df 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "ci", "dev", "doc", "project-extraction", "test", "typing"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:929627e3cbf02e2d0e6981063bcea0ca78239f62528cfd70c906d929786bbb98" +content_hash = "sha256:a088cc789cafbe10e734c4be25323175172013ba247c695773f222939adc5dba" [[metadata.targets]] requires_python = "==3.10.*" @@ -1951,6 +1951,21 @@ files = [ {file = "pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235"}, ] +[[package]] +name = "pydantic-settings" +version = "2.6.1" +requires_python = ">=3.8" +summary = "Settings management using Pydantic" +groups = ["default"] +dependencies = [ + "pydantic>=2.7.0", + "python-dotenv>=0.21.0", +] +files = [ + {file = "pydantic_settings-2.6.1-py3-none-any.whl", hash = "sha256:7fb0637c786a558d3103436278a7c4f1cfd29ba8973238a50c5bb9a55387da87"}, + {file = "pydantic_settings-2.6.1.tar.gz", hash = "sha256:e0f92546d8a9923cb8941689abf85d6601a8c19a23e97a34b2964a2e3f813ca0"}, +] + [[package]] name = "pydyf" version = "0.11.0" @@ -2222,6 +2237,17 @@ files = [ {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, ] +[[package]] +name = "python-dotenv" +version = "1.0.1" +requires_python = ">=3.8" +summary = "Read key-value pairs from a .env file and set them as environment variables" +groups = ["default"] +files = [ + {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, + {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, +] + [[package]] name = "python-osc" version = "1.9.0" diff --git a/pyproject.toml b/pyproject.toml index 6e1bbf902..d55d8d12a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,8 @@ dependencies = [ "PyYAML>=6.0.2", "scipy>=1.14.1", "sounddevice>=0.5.0", + "pydantic-settings>=2.6.1", + "qtpy>=2.4.2", ] [project.optional-dependencies] project-extraction = [ @@ -53,6 +55,7 @@ project-extraction = [ [project.scripts] view_session = "iblrig.commands:view_session" +view_session2 = "iblrig.commands:view_session2" transfer_data = "iblrig.commands:transfer_data_cli" transfer_video_data = "iblrig.commands:transfer_video_data_cli" transfer_ephys_data = "iblrig.commands:transfer_ephys_data_cli" From 5f15bc3beb498cc8bc318c33796d7ff7c6802928 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 11 Dec 2024 13:18:08 +0000 Subject: [PATCH 02/38] simplify entry-point --- iblrig/commands.py | 5 ----- pyproject.toml | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/iblrig/commands.py b/iblrig/commands.py index e1437aaa7..fa89b005d 100644 --- a/iblrig/commands.py +++ b/iblrig/commands.py @@ -9,7 +9,6 @@ import yaml import iblrig -from iblrig.gui.online_plots import online_plots_cli from iblrig.hardware import Bpod from iblrig.online_plots import OnlinePlots from iblrig.path_helper import get_local_and_remote_paths @@ -358,10 +357,6 @@ def view_session(): online_plots.run(file_jsonable=args.file_jsonable) -def view_session2(): - online_plots_cli() - - def flush(): """Flush the valve until the user hits enter.""" file_settings = Path(iblrig.__file__).parents[1].joinpath('settings', 'hardware_settings.yaml') diff --git a/pyproject.toml b/pyproject.toml index d55d8d12a..8dbc58546 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ project-extraction = [ [project.scripts] view_session = "iblrig.commands:view_session" -view_session2 = "iblrig.commands:view_session2" +view_session2 = "iblrig.gui.online_plots:online_plots_cli" transfer_data = "iblrig.commands:transfer_data_cli" transfer_video_data = "iblrig.commands:transfer_video_data_cli" transfer_ephys_data = "iblrig.commands:transfer_ephys_data_cli" From 79b854023a9422683e54d9f2c58c0d4e72fed704 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Tue, 10 Dec 2024 11:15:22 +0000 Subject: [PATCH 03/38] Update wizard.py --- iblrig/gui/wizard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iblrig/gui/wizard.py b/iblrig/gui/wizard.py index 69eb13b0f..d2d3f92e9 100644 --- a/iblrig/gui/wizard.py +++ b/iblrig/gui/wizard.py @@ -1036,7 +1036,7 @@ def start_stop(self): cmd.extend(['--remote', *remotes]) for key, value in self.task_arguments.items(): if key == '--delay_secs': - value = str(int(value) * 60) + value = str(int(value) * 60) # noqa: PLW2901 if isinstance(value, list): cmd.extend([key] + value) elif isinstance(value, bool) and value is True: From a12e5a4fe3bcc96d5bb80d817d84556d8cfec184 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 11 Dec 2024 13:51:50 +0000 Subject: [PATCH 04/38] correct colors --- iblrig/gui/online_plots.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 86d2b5867..d47dda8a1 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -93,6 +93,8 @@ class StimulusDelegate(QStyledItemDelegate): pen = QColor(0, 0, 0, 128) def paint(self, painter, option, index): + super().paint(painter, option, index) + value = index.data() color = QColor() color.setHslF(0, 0, 1.0 - abs(value)) @@ -107,16 +109,21 @@ def paint(self, painter, option, index): painter.setPen(self.pen) painter.drawEllipse(x_pos, y_pos, diameter, diameter) # Draw circle + def displayText(self, value, locale): + return '' + class ResponseTimeDelegate(QStyledItemDelegate): norm_min = 0.1 norm_max = 60.0 norm_div = np.log(norm_max / norm_min) - color_correct = QColor(90, 180, 172) - color_error = QColor(216, 179, 101) + color_correct = QColor(44, 162, 95) + color_error = QColor(227, 74, 51) color_nogo = QColor(220, 220, 220) def paint(self, painter, option, index): + super().paint(painter, option, index) + # Get the float value from the model value = index.data() status = index.sibling(index.row(), 2).data() @@ -139,6 +146,9 @@ def paint(self, painter, option, index): value_text = f'{value:.2f}' if status != 'no-go' else 'N/A' painter.drawText(option.rect, Qt.AlignVCenter | Qt.AlignCenter, value_text) + def displayText(self, value, locale): + return '' + class StateRegionItem(pg.LinearRegionItem): statusMessage = Signal(str) From 650a0228e5c01f052612bfb939da0570141de2f2 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 11 Dec 2024 15:40:26 +0000 Subject: [PATCH 05/38] use linear gradient for ResponseTimeDelegate --- iblrig/gui/online_plots.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index d47dda8a1..3c9434ccd 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -7,7 +7,7 @@ from pydantic import DirectoryPath, Field, validate_call from pydantic_settings import BaseSettings, CliPositionalArg from qtpy.QtCore import QFileSystemWatcher, QItemSelection, QObject, QRectF, Qt, Signal, Slot -from qtpy.QtGui import QColor, QPainter, QTransform +from qtpy.QtGui import QColor, QPainter, QTransform, QLinearGradient from qtpy.QtWidgets import ( QApplication, QFrame, @@ -137,7 +137,10 @@ def paint(self, painter, option, index): norm_value = np.log(value / self.norm_min) / self.norm_div filled_rect = QRectF(option.rect) filled_rect.setWidth(filled_rect.width() * norm_value) - painter.setBrush(self.color_correct if status == 'correct' else self.color_error) + gradient = QLinearGradient(filled_rect.topLeft(), filled_rect.topRight()) + gradient.setColorAt(0, QColor(255, 255, 255, 0)) + gradient.setColorAt(1, self.color_correct if status == 'correct' else self.color_error) + painter.setBrush(gradient) painter.setPen(Qt.NoPen) painter.drawRect(filled_rect) From 824d4e496742df4d50d4264932e2ba1958c4cec7 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 11 Dec 2024 16:37:05 +0000 Subject: [PATCH 06/38] Add status tips for trials table --- iblrig/gui/online_plots.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 3c9434ccd..1f37e41db 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -1,13 +1,14 @@ import json from pathlib import Path +from typing import Any import numpy as np import pandas as pd import pyqtgraph as pg from pydantic import DirectoryPath, Field, validate_call from pydantic_settings import BaseSettings, CliPositionalArg -from qtpy.QtCore import QFileSystemWatcher, QItemSelection, QObject, QRectF, Qt, Signal, Slot -from qtpy.QtGui import QColor, QPainter, QTransform, QLinearGradient +from qtpy.QtCore import QFileSystemWatcher, QItemSelection, QModelIndex, QObject, QRectF, Qt, Signal, Slot +from qtpy.QtGui import QColor, QLinearGradient, QPainter, QTransform from qtpy.QtWidgets import ( QApplication, QFrame, @@ -25,12 +26,27 @@ from iblrig.raw_data_loaders import bpod_session_data_to_dataframe, load_task_jsonable +class TrialsTableModel(DataFrameTableModel): + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any | None: + if index.isValid() and role == Qt.ItemDataRole.StatusTipRole: + trial = self.data(index.siblingAtColumn(0), Qt.ItemDataRole.DisplayRole) + stim = self.data(index.siblingAtColumn(1), Qt.ItemDataRole.DisplayRole) + outcome = self.data(index.siblingAtColumn(2), Qt.ItemDataRole.DisplayRole) + timing = self.data(index.siblingAtColumn(3), Qt.ItemDataRole.DisplayRole) + tip = ( + f'Trial {trial}: {abs(stim) * 100:g}% contrast on {"right" if np.sign(stim) == 1 else "left"} side ' + f'of screen, {outcome}' + ) + return tip if outcome == 'no-go' else f'{tip} after {timing:0.2f} s' + return super().data(index, role) + + class OnlinePlotsModel(QObject): currentTrialChanged = Signal(int) _trial_data = pd.DataFrame() _bpod_data = pd.DataFrame() trials_table = pd.DataFrame() - table_model = DataFrameTableModel() + table_model = TrialsTableModel() _jsonableOffset = 0 _currentTrial = 0 @@ -323,6 +339,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None # trial data self.trials = QTableView(self) self.trials.setModel(self.model.table_model) + self.trials.setMouseTracking(True) self.trials.verticalHeader().hide() self.trials.horizontalHeader().setSectionResizeMode(QHeaderView.Fixed) self.trials.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) From bb30060b8d342c603692e6a84f58d9a7f4b0b916 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 11 Dec 2024 17:11:15 +0000 Subject: [PATCH 07/38] correct 0% contrast position --- iblrig/gui/online_plots.py | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 1f37e41db..c3a8e3f5c 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -27,17 +27,20 @@ class TrialsTableModel(DataFrameTableModel): + """Child of :class:`~iblqt.core.DataFrameTableModel` that displays status tips for entries in the trials table.""" + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any | None: if index.isValid() and role == Qt.ItemDataRole.StatusTipRole: - trial = self.data(index.siblingAtColumn(0), Qt.ItemDataRole.DisplayRole) - stim = self.data(index.siblingAtColumn(1), Qt.ItemDataRole.DisplayRole) - outcome = self.data(index.siblingAtColumn(2), Qt.ItemDataRole.DisplayRole) - timing = self.data(index.siblingAtColumn(3), Qt.ItemDataRole.DisplayRole) + trial = index.siblingAtColumn(0).data() + position = index.siblingAtColumn(1).data() + contrast = index.siblingAtColumn(2).data() * 100 + outcome = index.siblingAtColumn(3).data() + timing = index.siblingAtColumn(4).data() tip = ( - f'Trial {trial}: {abs(stim) * 100:g}% contrast on {"right" if np.sign(stim) == 1 else "left"} side ' - f'of screen, {outcome}' + f'Trial {trial}: stimulus with {contrast:g}% contrast on {"right" if position == 1 else "left"} ' + f'side of screen, {outcome}' ) - return tip if outcome == 'no-go' else f'{tip} after {timing:0.2f} s' + return f'{tip}.' if outcome == 'no-go' else f'{tip} after {timing:0.2f} s.' return super().data(index, role) @@ -78,7 +81,8 @@ def readJsonable(self, _: str) -> None: table = pd.DataFrame() table['Trial'] = self._trial_data.trial_num - table['Stimulus'] = np.sign(self._trial_data.position) * self._trial_data.contrast + table['Stimulus'] = np.sign(self._trial_data.position) + table['Contrast'] = self._trial_data.contrast table['Outcome'] = self._trial_data.apply( lambda row: 'no-go' if row['response_side'] == 0 else ('correct' if row['trial_correct'] else 'error'), axis=1 ) @@ -108,16 +112,16 @@ def bpod_data(self, trial: int) -> pd.DataFrame: class StimulusDelegate(QStyledItemDelegate): pen = QColor(0, 0, 0, 128) - def paint(self, painter, option, index): + def paint(self, painter, option, index: QModelIndex): super().paint(painter, option, index) - - value = index.data() + location = index.siblingAtColumn(1).data() + contrast = index.siblingAtColumn(2).data() color = QColor() - color.setHslF(0, 0, 1.0 - abs(value)) + color.setHslF(0, 0, 1.0 - contrast) diameter = int(option.rect.height() * 0.8) spacing = (option.rect.height() - diameter) // 2 - x_pos = option.rect.left() + spacing if value < 0 else option.rect.right() - diameter - spacing + x_pos = option.rect.left() + spacing if location < 0 else option.rect.right() - diameter - spacing y_pos = option.rect.top() + spacing painter.setRenderHint(QPainter.Antialiasing) @@ -353,7 +357,8 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.responseTimeDelegate = ResponseTimeDelegate() self.trials.setItemDelegateForColumn(1, self.stimulusDelegate) self.trials.setColumnHidden(2, True) - self.trials.setItemDelegateForColumn(3, self.responseTimeDelegate) + self.trials.setColumnHidden(3, True) + self.trials.setItemDelegateForColumn(4, self.responseTimeDelegate) self.trials.setShowGrid(False) self.trials.setFrameShape(QTableView.NoFrame) self.trials.setFocusPolicy(Qt.FocusPolicy.NoFocus) From e850bbfae78b4554a2a1c5e05919329c279ee9e6 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 11 Dec 2024 18:46:48 +0000 Subject: [PATCH 08/38] correct index of `outcome` column in `ResponseTimeDelegate` --- iblrig/gui/online_plots.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index c3a8e3f5c..6503985ec 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -146,11 +146,11 @@ def paint(self, painter, option, index): # Get the float value from the model value = index.data() - status = index.sibling(index.row(), 2).data() + outcome = index.sibling(index.row(), 3).data() # Draw the progress bar painter.fillRect(option.rect, option.backgroundBrush) - if status == 'no-go': + if outcome == 'no-go': filled_rect = QRectF(option.rect) painter.setBrush(self.color_nogo) else: @@ -159,14 +159,14 @@ def paint(self, painter, option, index): filled_rect.setWidth(filled_rect.width() * norm_value) gradient = QLinearGradient(filled_rect.topLeft(), filled_rect.topRight()) gradient.setColorAt(0, QColor(255, 255, 255, 0)) - gradient.setColorAt(1, self.color_correct if status == 'correct' else self.color_error) + gradient.setColorAt(1, self.color_correct if outcome == 'correct' else self.color_error) painter.setBrush(gradient) painter.setPen(Qt.NoPen) painter.drawRect(filled_rect) # Draw the value text painter.setPen(Qt.black) - value_text = f'{value:.2f}' if status != 'no-go' else 'N/A' + value_text = f'{value:.2f}' if outcome != 'no-go' else 'N/A' painter.drawText(option.rect, Qt.AlignVCenter | Qt.AlignCenter, value_text) def displayText(self, value, locale): From 997c3b4e573ddd6d1af4ca3c103d97784b43fdef Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 11 Dec 2024 19:14:40 +0000 Subject: [PATCH 09/38] correct colors --- iblrig/gui/online_plots.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 6503985ec..9b5dccfdf 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -139,7 +139,7 @@ class ResponseTimeDelegate(QStyledItemDelegate): norm_div = np.log(norm_max / norm_min) color_correct = QColor(44, 162, 95) color_error = QColor(227, 74, 51) - color_nogo = QColor(220, 220, 220) + color_nogo = QColor(192, 192, 192) def paint(self, painter, option, index): super().paint(painter, option, index) @@ -165,7 +165,7 @@ def paint(self, painter, option, index): painter.drawRect(filled_rect) # Draw the value text - painter.setPen(Qt.black) + painter.setPen(option.palette.text().color()) value_text = f'{value:.2f}' if outcome != 'no-go' else 'N/A' painter.drawText(option.rect, Qt.AlignVCenter | Qt.AlignCenter, value_text) @@ -348,9 +348,9 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.trials.horizontalHeader().setSectionResizeMode(QHeaderView.Fixed) self.trials.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) self.trials.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) - # self.trials.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) - self.trials.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) - self.trials.setStyleSheet('QHeaderView::section { border: none; background-color: white; }') + self.trials.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) + self.trials.setStyleSheet('QHeaderView::section { border: none; background-color: white; }' + 'QTableView::item:selected { color: black; background-color: lightgray; }') self.trials.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.trials.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.stimulusDelegate = StimulusDelegate() From 409148e13034bc70bf851dbfd3943ba297f25f1f Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 11 Dec 2024 23:22:32 +0000 Subject: [PATCH 10/38] use `PColorMeshItem` instead of `LinearRegionItem` --- iblrig/gui/online_plots.py | 100 ++++++++++++++++++++----------------- 1 file changed, 53 insertions(+), 47 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 9b5dccfdf..66266306c 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -173,18 +173,15 @@ def displayText(self, value, locale): return '' -class StateRegionItem(pg.LinearRegionItem): +class StateMeshItem(pg.PColorMeshItem): statusMessage = Signal(str) + stateIndex = Signal(int) - def __init__(self, *args, stateName: str, **kwargs): + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setMovable(False) - self.stateName = stateName def hoverEvent(self, ev): - if ev.enter: - self.statusMessage.emit(f'State: "{self.stateName}"') - elif ev.exit: + if ev.exit: if not hasattr(ev, '_scenePos'): self.statusMessage.emit('') else: @@ -192,12 +189,23 @@ def hoverEvent(self, ev): if not isinstance(item, QGraphicsRectItem): self.statusMessage.emit('') + try: + x = self.mapFromParent(ev.pos()).x() + except AttributeError: + return + try: + i = self.z[:, np.where(self.x[0, :] <= x)[0][-1]][0] + except IndexError: + return + self.stateIndex.emit(i) + # self.statusMessage.emit(f'State: "{self.stateName}"') + class BpodWidget(pg.GraphicsLayoutWidget): data = pd.DataFrame() - colors = pg.colormap.get('glasbey_light', source='colorcet') labels: dict[str, pg.LabelItem] = dict() plots: dict[str, pg.PlotDataItem] = dict() + meshes: dict[str, StateMeshItem] = dict() viewBoxes: dict[str, pg.ViewBox] = dict() def __init__(self, *args, title: str | None = None, **kwargs): @@ -207,6 +215,11 @@ def __init__(self, *args, title: str | None = None, **kwargs): self.setBackground('white') self.centralWidget.setSpacing(0) + colormap = pg.colormap.get('glasbey_light', source='colorcet') + colors = colormap.getLookupTable(0, 1, 256, alpha=True) + colors[:, 3] = 64 # set alpha + self.colormap = pg.ColorMap(colormap.pos, colors) + # add title if title is not None: self.centralWidget.nextRow() @@ -227,9 +240,13 @@ def addDigitalChannel(self, channel: str, label: str | None = None): label = channel if label is None else label self.centralWidget.nextRow() self.labels[channel] = self.addLabel(label, col=0, color='k') + self.meshes[channel] = StateMeshItem(colorMap=self.colormap) + self.meshes[channel].statusMessage.connect(self.showStatusMessage) + self.meshes[channel].stateIndex.connect(self.showStatusState) self.plots[channel] = pg.PlotDataItem(pen='k', stepMode='right') self.plots[channel].setSkipFiniteCheck(True) self.viewBoxes[channel] = self.addViewBox(col=1) + self.viewBoxes[channel].addItem(self.meshes[channel]) self.viewBoxes[channel].addItem(self.plots[channel]) self.viewBoxes[channel].setMouseEnabled(x=True, y=False) self.viewBoxes[channel].sigXRangeChanged.connect(self.updateXRange) @@ -237,57 +254,44 @@ def addDigitalChannel(self, channel: str, label: str | None = None): def setData(self, data: pd.DataFrame): self.data = data self.showTrial() - self.drawStateRegionItems() - - def drawStateRegionItems(self): - start = self.data[self.data.Type == 'TrialStart'].index[0] - t0 = self.data[self.data.Type == 'StateStart'] - t1 = self.data[self.data.Type == 'StateEnd'] - - # remove existing regions - for view_box in self.viewBoxes.values(): - for item in view_box.allChildren(): - if isinstance(item, StateRegionItem): - view_box.removeItem(item) - item.deleteLater() - - pen = (0, 0, 0, 0) - for state_idx, state in enumerate(self.data['State'].cat.categories): - state_mask = t0['State'] == state - t0_state = (t0.index[state_mask] - start).total_seconds().to_list() - t1_state = (t1.index[state_mask] - start).total_seconds().to_list() - brush = self.colors.getByIndex(state_idx) - brush.setAlphaF(0.2) - - for times in zip(t0_state, t1_state, strict=False): - for view_box in self.viewBoxes.values(): - r = StateRegionItem(times, stateName=state, brush=brush, pen=pen) - r.statusMessage.connect(self.showStatusMessage) - view_box.addItem(r) @Slot(str) def showStatusMessage(self, string: str): self.window().statusBar().showMessage(string) + @Slot(int) + def showStatusState(self, index: int): + self.window().statusBar().showMessage(f'State: {self.data.State.cat.categories[index]}') + def showTrial(self): limits = self.data[self.data['Type'].isin(['TrialStart', 'TrialEnd'])] limits = limits.index.total_seconds() - self.limits = {'xMin': 0, 'xMax': limits[1] - limits[0], 'minXRange': 0.001} + self.limits = {'xMin': 0, 'xMax': limits[1] - limits[0], 'minXRange': 0.001, 'yMin': -0.2, 'yMax': 1.2} + + t0 = self.data[self.data.Type == 'StateStart'] + t1 = self.data[self.data.Type == 'StateEnd'] + mesh_x = np.append(t0.index.total_seconds(), t1.index[-1].total_seconds()) - limits[0] + mesh_x = np.tile(mesh_x, (2, 1)) + mesh_y = np.zeros(mesh_x.shape) - 0.2 + mesh_y[1, :] = 1.2 + mesh_z = t0.State.cat.codes.to_numpy() + mesh_z = mesh_z[np.newaxis, :] for channel in self.plots: values = self.data.loc[self.data.Channel == channel, 'Value'] - x = values.index.total_seconds().to_numpy() - limits[0] - y = values.to_numpy() + plot_x = values.index.total_seconds().to_numpy() - limits[0] + plot_y = values.to_numpy() # Since Bpod only supports *changes* in the digital signals, we need # to extend the plots to the axes limits. - if len(x) > 0: - x = np.insert(x, 0, 0) - x = np.append(x, limits[1]) - y = np.insert(y, 0, not y[0]) - y = np.append(y, y[-1]) - - self.plots[channel].setData(x, y) + if len(plot_x) > 0: + plot_x = np.insert(plot_x, 0, 0) + plot_x = np.append(plot_x, limits[1]) + plot_y = np.insert(plot_y, 0, not plot_y[0]) + plot_y = np.append(plot_y, plot_y[-1]) + + self.plots[channel].setData(plot_x, plot_y) + self.meshes[channel].setData(mesh_x, mesh_y, mesh_z) self.viewBoxes[channel].setLimits(**self.limits) list(self.viewBoxes.values())[0].setXRange( @@ -349,8 +353,10 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.trials.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) self.trials.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) self.trials.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) - self.trials.setStyleSheet('QHeaderView::section { border: none; background-color: white; }' - 'QTableView::item:selected { color: black; background-color: lightgray; }') + self.trials.setStyleSheet( + 'QHeaderView::section { border: none; background-color: white; }' + 'QTableView::item:selected { color: black; background-color: lightgray; }' + ) self.trials.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.trials.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.stimulusDelegate = StimulusDelegate() From 23eac80d6e686e6fd14f1a0a2eb52edb5de74cd7 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Thu, 12 Dec 2024 08:56:47 +0000 Subject: [PATCH 11/38] simplify signals for status message --- iblrig/gui/online_plots.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 66266306c..d445f022e 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -174,7 +174,6 @@ def displayText(self, value, locale): class StateMeshItem(pg.PColorMeshItem): - statusMessage = Signal(str) stateIndex = Signal(int) def __init__(self, *args, **kwargs): @@ -183,11 +182,12 @@ def __init__(self, *args, **kwargs): def hoverEvent(self, ev): if ev.exit: if not hasattr(ev, '_scenePos'): - self.statusMessage.emit('') + self.stateIndex.emit(-1) else: item = self.scene().itemAt(ev.scenePos(), QTransform()) if not isinstance(item, QGraphicsRectItem): - self.statusMessage.emit('') + self.stateIndex.emit(-1) + return try: x = self.mapFromParent(ev.pos()).x() @@ -198,7 +198,6 @@ def hoverEvent(self, ev): except IndexError: return self.stateIndex.emit(i) - # self.statusMessage.emit(f'State: "{self.stateName}"') class BpodWidget(pg.GraphicsLayoutWidget): @@ -241,7 +240,6 @@ def addDigitalChannel(self, channel: str, label: str | None = None): self.centralWidget.nextRow() self.labels[channel] = self.addLabel(label, col=0, color='k') self.meshes[channel] = StateMeshItem(colorMap=self.colormap) - self.meshes[channel].statusMessage.connect(self.showStatusMessage) self.meshes[channel].stateIndex.connect(self.showStatusState) self.plots[channel] = pg.PlotDataItem(pen='k', stepMode='right') self.plots[channel].setSkipFiniteCheck(True) @@ -255,13 +253,12 @@ def setData(self, data: pd.DataFrame): self.data = data self.showTrial() - @Slot(str) - def showStatusMessage(self, string: str): - self.window().statusBar().showMessage(string) - @Slot(int) def showStatusState(self, index: int): - self.window().statusBar().showMessage(f'State: {self.data.State.cat.categories[index]}') + if index < 0: + self.window().statusBar().clearMessage() + else: + self.window().statusBar().showMessage(f'State: {self.data.State.cat.categories[index]}') def showTrial(self): limits = self.data[self.data['Type'].isin(['TrialStart', 'TrialEnd'])] @@ -314,7 +311,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None pg.setConfigOptions(antialias=True) self.model = OnlinePlotsModel(raw_data_folder, self) - self.statusBar().showMessage('') + self.statusBar().clearMessage() self.setWindowTitle('Online Plots') # the frame that contains all the plots From 65d2941b4daac3e769e17fd8d04eb8931494cf00 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Thu, 12 Dec 2024 09:14:53 +0000 Subject: [PATCH 12/38] Update online_plots.py --- iblrig/gui/online_plots.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index d445f022e..73a431aae 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -37,10 +37,9 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A outcome = index.siblingAtColumn(3).data() timing = index.siblingAtColumn(4).data() tip = ( - f'Trial {trial}: stimulus with {contrast:g}% contrast on {"right" if position == 1 else "left"} ' - f'side of screen, {outcome}' + f'Trial {trial}: {contrast:g}% contrast / {abs(position):g}° {"right" if position > 0 else "left"} / {outcome}' ) - return f'{tip}.' if outcome == 'no-go' else f'{tip} after {timing:0.2f} s.' + return tip + ('.' if outcome == 'no-go' else f' after {timing:0.2f} s.') return super().data(index, role) @@ -81,7 +80,7 @@ def readJsonable(self, _: str) -> None: table = pd.DataFrame() table['Trial'] = self._trial_data.trial_num - table['Stimulus'] = np.sign(self._trial_data.position) + table['Stimulus'] = self._trial_data.position table['Contrast'] = self._trial_data.contrast table['Outcome'] = self._trial_data.apply( lambda row: 'no-go' if row['response_side'] == 0 else ('correct' if row['trial_correct'] else 'error'), axis=1 @@ -150,10 +149,7 @@ def paint(self, painter, option, index): # Draw the progress bar painter.fillRect(option.rect, option.backgroundBrush) - if outcome == 'no-go': - filled_rect = QRectF(option.rect) - painter.setBrush(self.color_nogo) - else: + if outcome != 'no-go': norm_value = np.log(value / self.norm_min) / self.norm_div filled_rect = QRectF(option.rect) filled_rect.setWidth(filled_rect.width() * norm_value) @@ -161,8 +157,8 @@ def paint(self, painter, option, index): gradient.setColorAt(0, QColor(255, 255, 255, 0)) gradient.setColorAt(1, self.color_correct if outcome == 'correct' else self.color_error) painter.setBrush(gradient) - painter.setPen(Qt.NoPen) - painter.drawRect(filled_rect) + painter.setPen(Qt.NoPen) + painter.drawRect(filled_rect) # Draw the value text painter.setPen(option.palette.text().color()) From 0221c944178cb69e5a2ddb187bef9a650fa99206 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Thu, 12 Dec 2024 12:18:28 +0000 Subject: [PATCH 13/38] prepare axes for psychometric function and response time plots --- iblrig/gui/online_plots.py | 56 ++++++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 73a431aae..900a68147 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -36,9 +36,7 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A contrast = index.siblingAtColumn(2).data() * 100 outcome = index.siblingAtColumn(3).data() timing = index.siblingAtColumn(4).data() - tip = ( - f'Trial {trial}: {contrast:g}% contrast / {abs(position):g}° {"right" if position > 0 else "left"} / {outcome}' - ) + tip = f'Trial {trial}: {contrast:g}% contrast / {abs(position):g}° {"right" if position > 0 else "left"} / {outcome}' return tip + ('.' if outcome == 'no-go' else f' after {timing:0.2f} s.') return super().data(index, role) @@ -236,13 +234,14 @@ def addDigitalChannel(self, channel: str, label: str | None = None): self.centralWidget.nextRow() self.labels[channel] = self.addLabel(label, col=0, color='k') self.meshes[channel] = StateMeshItem(colorMap=self.colormap) - self.meshes[channel].stateIndex.connect(self.showStatusState) + self.meshes[channel].stateIndex.connect(self.showStateInfo) self.plots[channel] = pg.PlotDataItem(pen='k', stepMode='right') self.plots[channel].setSkipFiniteCheck(True) self.viewBoxes[channel] = self.addViewBox(col=1) self.viewBoxes[channel].addItem(self.meshes[channel]) self.viewBoxes[channel].addItem(self.plots[channel]) self.viewBoxes[channel].setMouseEnabled(x=True, y=False) + self.viewBoxes[channel].setMenuEnabled(False) self.viewBoxes[channel].sigXRangeChanged.connect(self.updateXRange) def setData(self, data: pd.DataFrame): @@ -250,7 +249,7 @@ def setData(self, data: pd.DataFrame): self.showTrial() @Slot(int) - def showStatusState(self, index: int): + def showStateInfo(self, index: int): if index < 0: self.window().statusBar().clearMessage() else: @@ -261,13 +260,13 @@ def showTrial(self): limits = limits.index.total_seconds() self.limits = {'xMin': 0, 'xMax': limits[1] - limits[0], 'minXRange': 0.001, 'yMin': -0.2, 'yMax': 1.2} - t0 = self.data[self.data.Type == 'StateStart'] - t1 = self.data[self.data.Type == 'StateEnd'] - mesh_x = np.append(t0.index.total_seconds(), t1.index[-1].total_seconds()) - limits[0] + state_t0 = self.data[self.data.Type == 'StateStart'] + state_t1 = self.data[self.data.Type == 'StateEnd'] + mesh_x = np.append(state_t0.index.total_seconds(), state_t1.index[-1].total_seconds()) - limits[0] mesh_x = np.tile(mesh_x, (2, 1)) mesh_y = np.zeros(mesh_x.shape) - 0.2 mesh_y[1, :] = 1.2 - mesh_z = t0.State.cat.codes.to_numpy() + mesh_z = state_t0.State.cat.codes.to_numpy() mesh_z = mesh_z[np.newaxis, :] for channel in self.plots: @@ -346,6 +345,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.trials.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) self.trials.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) self.trials.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) + self.trials.horizontalHeader().setStretchLastSection(True) self.trials.setStyleSheet( 'QHeaderView::section { border: none; background-color: white; }' 'QTableView::item:selected { color: black; background-color: lightgray; }' @@ -364,13 +364,47 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.trials.setSelectionMode(QTableView.SingleSelection) self.trials.setSelectionBehavior(QTableView.SelectRows) self.trials.selectionModel().selectionChanged.connect(self.onSelectionChanged) - layout.addWidget(self.trials, 2, 0, 1, 1) + layout.addWidget(self.trials, 2, 0, 2, 1) + + # psychometric function + self.psychometricFunction = pg.PlotWidget(parent=self, background='white') + self.psychometricFunction.plotItem.setTitle('Psychometric Function', color='k') + self.psychometricFunction.plotItem.getAxis('left').setLabel('Rightward Choices (%)') + self.psychometricFunction.plotItem.getAxis('bottom').setLabel('Signed Contrast') + for axis in ('left', 'bottom'): + self.psychometricFunction.plotItem.getAxis(axis).setGrid(128) + self.psychometricFunction.plotItem.getAxis(axis).setTextPen('k') + self.psychometricFunction.plotItem.setXRange(-1, 1, padding=0) + self.psychometricFunction.plotItem.setYRange(0, 1, padding=0) + self.psychometricFunction.plotItem.setMouseEnabled(x=False, y=False) + self.psychometricFunction.plotItem.setMenuEnabled(False) + self.psychometricFunction.plotItem.hideButtons() + self.psychometricFunction.plotItem.addItem(pg.InfiniteLine(0.5, 0, 'black')) + self.psychometricFunction.plotItem.addItem(pg.InfiniteLine(0, 90, 'black')) + layout.addWidget(self.psychometricFunction, 2, 1, 1, 1) + + # response time + self.responseTimeWidget = pg.PlotWidget(parent=self, background='white') + self.responseTimeWidget.plotItem.setTitle('Response Time', color='k') + self.responseTimeWidget.plotItem.getAxis('left').setLabel('Response Time (s)') + self.responseTimeWidget.plotItem.getAxis('bottom').setLabel('Signed Contrast') + for axis in ('left', 'bottom'): + self.responseTimeWidget.plotItem.getAxis(axis).setGrid(128) + self.responseTimeWidget.plotItem.getAxis(axis).setTextPen('k') + self.responseTimeWidget.plotItem.setLogMode(x=False, y=True) + self.responseTimeWidget.plotItem.setXRange(-1, 1, padding=0) + self.responseTimeWidget.plotItem.setYRange(-1, 2, padding=0) + self.responseTimeWidget.plotItem.setMouseEnabled(x=False, y=False) + self.responseTimeWidget.plotItem.setMenuEnabled(False) + self.responseTimeWidget.plotItem.hideButtons() + self.responseTimeWidget.plotItem.addItem(pg.InfiniteLine(0, 90, 'black')) + layout.addWidget(self.responseTimeWidget, 3, 1, 1, 1) # bpod data self.bpodWidget = BpodWidget(self, title='Bpod States and Input Channels') self.bpodWidget.setMinimumHeight(200) self.bpodWidget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) - layout.addWidget(self.bpodWidget, 3, 0, 1, 2) + layout.addWidget(self.bpodWidget, 4, 0, 1, 2) self.model.currentTrialChanged.connect(self.updatePlots) From 82a3b9523d7d6c32bf0236940c4cf30fd45cf346 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Thu, 12 Dec 2024 12:24:17 +0000 Subject: [PATCH 14/38] response time bars: align text with bar end --- iblrig/gui/online_plots.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 900a68147..7775a4947 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -147,21 +147,23 @@ def paint(self, painter, option, index): # Draw the progress bar painter.fillRect(option.rect, option.backgroundBrush) - if outcome != 'no-go': - norm_value = np.log(value / self.norm_min) / self.norm_div - filled_rect = QRectF(option.rect) - filled_rect.setWidth(filled_rect.width() * norm_value) - gradient = QLinearGradient(filled_rect.topLeft(), filled_rect.topRight()) - gradient.setColorAt(0, QColor(255, 255, 255, 0)) - gradient.setColorAt(1, self.color_correct if outcome == 'correct' else self.color_error) - painter.setBrush(gradient) - painter.setPen(Qt.NoPen) - painter.drawRect(filled_rect) - - # Draw the value text - painter.setPen(option.palette.text().color()) + if outcome == 'no-go': + return + + norm_value = np.log(value / self.norm_min) / self.norm_div + filled_rect = QRectF(option.rect) + filled_rect.setWidth(filled_rect.width() * norm_value) + gradient = QLinearGradient(filled_rect.topLeft(), filled_rect.topRight()) + gradient.setColorAt(0, QColor(255, 255, 255, 0)) + gradient.setColorAt(1, self.color_correct if outcome == 'correct' else self.color_error) + painter.setBrush(gradient) + painter.setPen(Qt.NoPen) + painter.drawRect(filled_rect) + + painter.setPen(pg.mkPen('white')) value_text = f'{value:.2f}' if outcome != 'no-go' else 'N/A' - painter.drawText(option.rect, Qt.AlignVCenter | Qt.AlignCenter, value_text) + filled_rect.adjust(0, 0, -5, 0) + painter.drawText(filled_rect, Qt.AlignVCenter | Qt.AlignRight, value_text) def displayText(self, value, locale): return '' From a7d2ff1c802efb4601cd195d39d9888f5ff50552 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Thu, 12 Dec 2024 12:25:32 +0000 Subject: [PATCH 15/38] Update online_plots.py --- iblrig/gui/online_plots.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 7775a4947..8a256f9a7 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -137,6 +137,7 @@ class ResponseTimeDelegate(QStyledItemDelegate): color_correct = QColor(44, 162, 95) color_error = QColor(227, 74, 51) color_nogo = QColor(192, 192, 192) + color_text = QColor('white') def paint(self, painter, option, index): super().paint(painter, option, index) @@ -160,7 +161,7 @@ def paint(self, painter, option, index): painter.setPen(Qt.NoPen) painter.drawRect(filled_rect) - painter.setPen(pg.mkPen('white')) + painter.setPen(self.color_text) value_text = f'{value:.2f}' if outcome != 'no-go' else 'N/A' filled_rect.adjust(0, 0, -5, 0) painter.drawText(filled_rect, Qt.AlignVCenter | Qt.AlignRight, value_text) From c7cf43bad6fb84487a50b7a5ac0dd3d573fbff35 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Thu, 12 Dec 2024 12:26:32 +0000 Subject: [PATCH 16/38] Update online_plots.py --- iblrig/gui/online_plots.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 8a256f9a7..5f8c0532e 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -138,6 +138,7 @@ class ResponseTimeDelegate(QStyledItemDelegate): color_error = QColor(227, 74, 51) color_nogo = QColor(192, 192, 192) color_text = QColor('white') + color_gradient0 = QColor(255, 255, 255, 0) def paint(self, painter, option, index): super().paint(painter, option, index) @@ -155,7 +156,7 @@ def paint(self, painter, option, index): filled_rect = QRectF(option.rect) filled_rect.setWidth(filled_rect.width() * norm_value) gradient = QLinearGradient(filled_rect.topLeft(), filled_rect.topRight()) - gradient.setColorAt(0, QColor(255, 255, 255, 0)) + gradient.setColorAt(0, self.color_gradient0) gradient.setColorAt(1, self.color_correct if outcome == 'correct' else self.color_error) painter.setBrush(gradient) painter.setPen(Qt.NoPen) From d058f9fd2adc68f31e91112f9577dbfeb7e2c5bc Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Mon, 16 Dec 2024 14:29:17 +0000 Subject: [PATCH 17/38] remove spacing around Bpod widget --- iblrig/gui/online_plots.py | 1 + 1 file changed, 1 insertion(+) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 5f8c0532e..5cd99b581 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -211,6 +211,7 @@ def __init__(self, *args, title: str | None = None, **kwargs): self.setRenderHints(QPainter.Antialiasing) self.setBackground('white') self.centralWidget.setSpacing(0) + self.centralWidget.setContentsMargins(0, 0, 0, 0) colormap = pg.colormap.get('glasbey_light', source='colorcet') colors = colormap.getLookupTable(0, 1, 256, alpha=True) From fb38ab904db32c495a55471b94d4cbd0cb682369 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Mon, 16 Dec 2024 16:19:05 +0000 Subject: [PATCH 18/38] Update online_plots.py --- iblrig/gui/online_plots.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 5cd99b581..5a6e47241 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -221,7 +221,7 @@ def __init__(self, *args, title: str | None = None, **kwargs): # add title if title is not None: self.centralWidget.nextRow() - self.addLabel(title, col=1, color='k') + self.addLabel(title, size='11pt', col=1, color='k') # add plots for digital channels for channel in ('BNC1', 'BNC2', 'Port1'): @@ -313,6 +313,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.statusBar().clearMessage() self.setWindowTitle('Online Plots') + self.setMinimumSize(900, 700) # the frame that contains all the plots frame = QFrame(self) @@ -322,7 +323,6 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None # we use a grid layout to organize the different plots layout = QGridLayout(frame) - layout.setSpacing(0) frame.setLayout(layout) # main title @@ -349,7 +349,6 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.trials.horizontalHeader().setSectionResizeMode(QHeaderView.Fixed) self.trials.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) self.trials.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) - self.trials.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) self.trials.horizontalHeader().setStretchLastSection(True) self.trials.setStyleSheet( 'QHeaderView::section { border: none; background-color: white; }' @@ -379,8 +378,8 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None for axis in ('left', 'bottom'): self.psychometricFunction.plotItem.getAxis(axis).setGrid(128) self.psychometricFunction.plotItem.getAxis(axis).setTextPen('k') - self.psychometricFunction.plotItem.setXRange(-1, 1, padding=0) - self.psychometricFunction.plotItem.setYRange(0, 1, padding=0) + self.psychometricFunction.plotItem.setXRange(-1, 1, padding=0.05) + self.psychometricFunction.plotItem.setYRange(0, 1, padding=0.05) self.psychometricFunction.plotItem.setMouseEnabled(x=False, y=False) self.psychometricFunction.plotItem.setMenuEnabled(False) self.psychometricFunction.plotItem.hideButtons() @@ -397,8 +396,8 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.responseTimeWidget.plotItem.getAxis(axis).setGrid(128) self.responseTimeWidget.plotItem.getAxis(axis).setTextPen('k') self.responseTimeWidget.plotItem.setLogMode(x=False, y=True) - self.responseTimeWidget.plotItem.setXRange(-1, 1, padding=0) - self.responseTimeWidget.plotItem.setYRange(-1, 2, padding=0) + self.responseTimeWidget.plotItem.setXRange(-1, 1, padding=0.05) + self.responseTimeWidget.plotItem.setYRange(-1, 2, padding=0.05) self.responseTimeWidget.plotItem.setMouseEnabled(x=False, y=False) self.responseTimeWidget.plotItem.setMenuEnabled(False) self.responseTimeWidget.plotItem.hideButtons() @@ -407,7 +406,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None # bpod data self.bpodWidget = BpodWidget(self, title='Bpod States and Input Channels') - self.bpodWidget.setMinimumHeight(200) + self.bpodWidget.setMinimumHeight(160) self.bpodWidget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) layout.addWidget(self.bpodWidget, 4, 0, 1, 2) From 69a3541bccf733302c4f26dc44ef3314a93a34fb Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Mon, 16 Dec 2024 20:57:23 +0000 Subject: [PATCH 19/38] add background grid to reaction time bar-graph --- iblrig/gui/online_plots.py | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 5a6e47241..4472dd2d6 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -38,9 +38,31 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A timing = index.siblingAtColumn(4).data() tip = f'Trial {trial}: {contrast:g}% contrast / {abs(position):g}° {"right" if position > 0 else "left"} / {outcome}' return tip + ('.' if outcome == 'no-go' else f' after {timing:0.2f} s.') + if index.isValid() and index.column() == 0 and role == Qt.TextAlignmentRole: + return Qt.AlignRight | Qt.AlignVCenter return super().data(index, role) +class TrialsTableView(QTableView): + norm_min = 0.1 + norm_max = 102.0 + norm_div = np.log10(norm_max / norm_min) + + def paintEvent(self, event): + painter = QPainter(self.viewport()) + painter.setPen(QColor(229, 229, 229)) + for x in (i / j for j in (10, 1, 0.1) for i in range(2, 10)): + xval = np.log10(x / self.norm_min) / self.norm_div + line_x = self.columnViewportPosition(4) + round(self.columnWidth(4) * xval) + painter.drawLine(line_x, 0, line_x, self.height()) + painter.setPen(QColor(202, 202, 202)) + for x in np.power(10.0, np.arange(-1, 3)): + xval = np.log10(x / self.norm_min) / self.norm_div + line_x = self.columnViewportPosition(4) + round(self.columnWidth(4) * xval) + painter.drawLine(line_x, 0, line_x, self.height()) + super().paintEvent(event) + + class OnlinePlotsModel(QObject): currentTrialChanged = Signal(int) _trial_data = pd.DataFrame() @@ -132,7 +154,7 @@ def displayText(self, value, locale): class ResponseTimeDelegate(QStyledItemDelegate): norm_min = 0.1 - norm_max = 60.0 + norm_max = 102.0 norm_div = np.log(norm_max / norm_min) color_correct = QColor(44, 162, 95) color_error = QColor(227, 74, 51) @@ -321,7 +343,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None frame.setStyleSheet('background-color: rgb(255, 255, 255);') self.setCentralWidget(frame) - # we use a grid layout to organize the different plots + # we use a grid layout to organize the different widgets layout = QGridLayout(frame) frame.setLayout(layout) @@ -342,17 +364,18 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None layout.addWidget(subtitle, 1, 0, 1, 2) # trial data - self.trials = QTableView(self) + self.trials = TrialsTableView(self) self.trials.setModel(self.model.table_model) self.trials.setMouseTracking(True) self.trials.verticalHeader().hide() + self.trials.horizontalHeader().setDefaultAlignment(Qt.AlignLeft) self.trials.horizontalHeader().setSectionResizeMode(QHeaderView.Fixed) self.trials.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) self.trials.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) self.trials.horizontalHeader().setStretchLastSection(True) self.trials.setStyleSheet( 'QHeaderView::section { border: none; background-color: white; }' - 'QTableView::item:selected { color: black; background-color: lightgray; }' + 'QTableView::item:selected { color: black; selection-background-color: rgb(229, 229, 229); }' ) self.trials.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.trials.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) From 3cea33a8769b01ebd9fcc878b80cf195f56fbd0a Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Mon, 16 Dec 2024 21:53:23 +0000 Subject: [PATCH 20/38] add debiasing information --- iblrig/gui/online_plots.py | 44 +++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 4472dd2d6..5938367f3 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -7,8 +7,8 @@ import pyqtgraph as pg from pydantic import DirectoryPath, Field, validate_call from pydantic_settings import BaseSettings, CliPositionalArg -from qtpy.QtCore import QFileSystemWatcher, QItemSelection, QModelIndex, QObject, QRectF, Qt, Signal, Slot -from qtpy.QtGui import QColor, QLinearGradient, QPainter, QTransform +from qtpy.QtCore import QFileSystemWatcher, QItemSelection, QModelIndex, QObject, QRect, QRectF, Qt, Signal, Slot +from qtpy.QtGui import QColor, QFont, QLinearGradient, QPainter, QTransform from qtpy.QtWidgets import ( QApplication, QFrame, @@ -34,9 +34,13 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A trial = index.siblingAtColumn(0).data() position = index.siblingAtColumn(1).data() contrast = index.siblingAtColumn(2).data() * 100 - outcome = index.siblingAtColumn(3).data() - timing = index.siblingAtColumn(4).data() - tip = f'Trial {trial}: {contrast:g}% contrast / {abs(position):g}° {"right" if position > 0 else "left"} / {outcome}' + debias = index.siblingAtColumn(3).data() + outcome = index.siblingAtColumn(4).data() + timing = index.siblingAtColumn(5).data() + tip = ( + f'Trial {trial}: {contrast:g}% contrast / {abs(position):g}° {"right" if position > 0 else "left"} ' + f'{"(debiasing) " if debias else ""}/ {outcome}' + ) return tip + ('.' if outcome == 'no-go' else f' after {timing:0.2f} s.') if index.isValid() and index.column() == 0 and role == Qt.TextAlignmentRole: return Qt.AlignRight | Qt.AlignVCenter @@ -51,14 +55,16 @@ class TrialsTableView(QTableView): def paintEvent(self, event): painter = QPainter(self.viewport()) painter.setPen(QColor(229, 229, 229)) + viewport_pos = self.columnViewportPosition(5) + col_width = self.columnWidth(5) for x in (i / j for j in (10, 1, 0.1) for i in range(2, 10)): - xval = np.log10(x / self.norm_min) / self.norm_div - line_x = self.columnViewportPosition(4) + round(self.columnWidth(4) * xval) + x_val = np.log10(x / self.norm_min) / self.norm_div + line_x = viewport_pos + round(col_width * x_val) painter.drawLine(line_x, 0, line_x, self.height()) painter.setPen(QColor(202, 202, 202)) for x in np.power(10.0, np.arange(-1, 3)): - xval = np.log10(x / self.norm_min) / self.norm_div - line_x = self.columnViewportPosition(4) + round(self.columnWidth(4) * xval) + x_val = np.log10(x / self.norm_min) / self.norm_div + line_x = viewport_pos + round(col_width * x_val) painter.drawLine(line_x, 0, line_x, self.height()) super().paintEvent(event) @@ -87,6 +93,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None with self.settings_file.open('r') as f: self.task_settings = json.load(f) + # read the jsonable file and instantiate a QFileSystemWatcher self.readJsonable(self.jsonable_file) self.jsonableWatcher = QFileSystemWatcher([str(self.jsonable_file)], parent=self) self.jsonableWatcher.fileChanged.connect(self.readJsonable) @@ -102,6 +109,7 @@ def readJsonable(self, _: str) -> None: table['Trial'] = self._trial_data.trial_num table['Stimulus'] = self._trial_data.position table['Contrast'] = self._trial_data.contrast + table['Debias'] = self._trial_data.debias_trial if 'debias_trial' in self._trial_data.columns else False table['Outcome'] = self._trial_data.apply( lambda row: 'no-go' if row['response_side'] == 0 else ('correct' if row['trial_correct'] else 'error'), axis=1 ) @@ -135,18 +143,29 @@ def paint(self, painter, option, index: QModelIndex): super().paint(painter, option, index) location = index.siblingAtColumn(1).data() contrast = index.siblingAtColumn(2).data() + debias = index.siblingAtColumn(3).data() + color = QColor() color.setHslF(0, 0, 1.0 - contrast) - diameter = int(option.rect.height() * 0.8) + diameter = round(option.rect.height() * 0.8) spacing = (option.rect.height() - diameter) // 2 x_pos = option.rect.left() + spacing if location < 0 else option.rect.right() - diameter - spacing y_pos = option.rect.top() + spacing + # draw circle painter.setRenderHint(QPainter.Antialiasing) painter.setBrush(color) painter.setPen(self.pen) - painter.drawEllipse(x_pos, y_pos, diameter, diameter) # Draw circle + painter.drawEllipse(x_pos, y_pos, diameter, diameter) + + if debias: + rect = QRect(x_pos, y_pos, diameter, diameter) + painter.setPen(QColor('white') if contrast > 0.5 else QColor('black')) + painter.save() + painter.setFont(QFont(painter.font().family(), 9, -1, False)) + painter.drawText(rect, Qt.AlignHCenter | Qt.AlignVCenter, 'DB') + painter.restore() def displayText(self, value, locale): return '' @@ -384,7 +403,8 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.trials.setItemDelegateForColumn(1, self.stimulusDelegate) self.trials.setColumnHidden(2, True) self.trials.setColumnHidden(3, True) - self.trials.setItemDelegateForColumn(4, self.responseTimeDelegate) + self.trials.setColumnHidden(4, True) + self.trials.setItemDelegateForColumn(5, self.responseTimeDelegate) self.trials.setShowGrid(False) self.trials.setFrameShape(QTableView.NoFrame) self.trials.setFocusPolicy(Qt.FocusPolicy.NoFocus) From b4d73ad31da4d05453b0dba6b236f6d570bac935 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Mon, 16 Dec 2024 21:55:15 +0000 Subject: [PATCH 21/38] Update online_plots.py --- iblrig/gui/online_plots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 5938367f3..7269f53e2 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -186,7 +186,7 @@ def paint(self, painter, option, index): # Get the float value from the model value = index.data() - outcome = index.sibling(index.row(), 3).data() + outcome = index.sibling(index.row(), 4).data() # Draw the progress bar painter.fillRect(option.rect, option.backgroundBrush) From aa8626ec5126824fdbcdbff43799ed56734102cc Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Mon, 16 Dec 2024 22:07:53 +0000 Subject: [PATCH 22/38] some cleaning up --- iblrig/gui/online_plots.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 7269f53e2..39073f712 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -27,7 +27,7 @@ class TrialsTableModel(DataFrameTableModel): - """Child of :class:`~iblqt.core.DataFrameTableModel` that displays status tips for entries in the trials table.""" + """A table model that displays status tips for entries in the trials table.""" def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any | None: if index.isValid() and role == Qt.ItemDataRole.StatusTipRole: @@ -48,21 +48,27 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A class TrialsTableView(QTableView): + """A table view that shows a logarithmic x-grid in one column""" norm_min = 0.1 norm_max = 102.0 norm_div = np.log10(norm_max / norm_min) + x_minor = [i / j for j in (10, 1, 0.1) for i in range(2, 10)] + x_major = np.power(10.0, np.arange(-1, 3)) + color_minor = QColor(229, 229, 229) + color_major = QColor(202, 202, 202) + grid_col = 5 def paintEvent(self, event): + viewport_pos = self.columnViewportPosition(self.grid_col) + col_width = self.columnWidth(self.grid_col) painter = QPainter(self.viewport()) - painter.setPen(QColor(229, 229, 229)) - viewport_pos = self.columnViewportPosition(5) - col_width = self.columnWidth(5) - for x in (i / j for j in (10, 1, 0.1) for i in range(2, 10)): + painter.setPen(self.color_minor) + for x in self.x_minor: x_val = np.log10(x / self.norm_min) / self.norm_div line_x = viewport_pos + round(col_width * x_val) painter.drawLine(line_x, 0, line_x, self.height()) - painter.setPen(QColor(202, 202, 202)) - for x in np.power(10.0, np.arange(-1, 3)): + painter.setPen(self.color_major) + for x in self.x_major: x_val = np.log10(x / self.norm_min) / self.norm_div line_x = viewport_pos + round(col_width * x_val) painter.drawLine(line_x, 0, line_x, self.height()) @@ -154,6 +160,7 @@ def paint(self, painter, option, index: QModelIndex): y_pos = option.rect.top() + spacing # draw circle + painter.save() painter.setRenderHint(QPainter.Antialiasing) painter.setBrush(color) painter.setPen(self.pen) @@ -162,10 +169,9 @@ def paint(self, painter, option, index: QModelIndex): if debias: rect = QRect(x_pos, y_pos, diameter, diameter) painter.setPen(QColor('white') if contrast > 0.5 else QColor('black')) - painter.save() painter.setFont(QFont(painter.font().family(), 9, -1, False)) painter.drawText(rect, Qt.AlignHCenter | Qt.AlignVCenter, 'DB') - painter.restore() + painter.restore() def displayText(self, value, locale): return '' @@ -192,7 +198,6 @@ def paint(self, painter, option, index): painter.fillRect(option.rect, option.backgroundBrush) if outcome == 'no-go': return - norm_value = np.log(value / self.norm_min) / self.norm_div filled_rect = QRectF(option.rect) filled_rect.setWidth(filled_rect.width() * norm_value) @@ -354,7 +359,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.statusBar().clearMessage() self.setWindowTitle('Online Plots') - self.setMinimumSize(900, 700) + self.setMinimumSize(1024, 768) # the frame that contains all the plots frame = QFrame(self) From 260fa4c5bbdc5dcdf9637c53b4508a308bb30c04 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Mon, 16 Dec 2024 22:17:33 +0000 Subject: [PATCH 23/38] Update online_plots.py --- iblrig/gui/online_plots.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 39073f712..e8825b720 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -54,8 +54,8 @@ class TrialsTableView(QTableView): norm_div = np.log10(norm_max / norm_min) x_minor = [i / j for j in (10, 1, 0.1) for i in range(2, 10)] x_major = np.power(10.0, np.arange(-1, 3)) - color_minor = QColor(229, 229, 229) - color_major = QColor(202, 202, 202) + color_minor = QColor(238, 238, 238) + color_major = QColor(199, 199, 199) grid_col = 5 def paintEvent(self, event): From 5b6cdeb824af5d3af29b21680431e03f44f8fc1f Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Tue, 17 Dec 2024 09:17:55 +0000 Subject: [PATCH 24/38] add icon --- iblrig/gui/online_plots.py | 39 +++++++++++++++++++++++++++++++++----- iblrig/raw_data_loaders.py | 2 +- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index e8825b720..7cf8b0f44 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -1,4 +1,6 @@ +import ctypes import json +import os from pathlib import Path from typing import Any @@ -7,9 +9,21 @@ import pyqtgraph as pg from pydantic import DirectoryPath, Field, validate_call from pydantic_settings import BaseSettings, CliPositionalArg -from qtpy.QtCore import QFileSystemWatcher, QItemSelection, QModelIndex, QObject, QRect, QRectF, Qt, Signal, Slot -from qtpy.QtGui import QColor, QFont, QLinearGradient, QPainter, QTransform +from qtpy.QtCore import ( + QCoreApplication, + QFileSystemWatcher, + QItemSelection, + QModelIndex, + QObject, + QRect, + QRectF, + Qt, + Signal, + Slot, +) +from qtpy.QtGui import QColor, QFont, QIcon, QLinearGradient, QPainter, QPixmap, QTransform from qtpy.QtWidgets import ( + QAbstractItemView, QApplication, QFrame, QGraphicsRectItem, @@ -23,6 +37,8 @@ ) from iblqt.core import DataFrameTableModel +from iblrig import __version__ as iblrig_version +from iblrig.gui import resources_rc # noqa: F401 from iblrig.raw_data_loaders import bpod_session_data_to_dataframe, load_task_jsonable @@ -49,6 +65,7 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A class TrialsTableView(QTableView): """A table view that shows a logarithmic x-grid in one column""" + norm_min = 0.1 norm_max = 102.0 norm_div = np.log10(norm_max / norm_min) @@ -360,6 +377,9 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.statusBar().clearMessage() self.setWindowTitle('Online Plots') self.setMinimumSize(1024, 768) + icon = QIcon() + icon.addPixmap(QPixmap(':/images/iblrig_logo'), QIcon.Normal, QIcon.Off) + self.setWindowIcon(icon) # the frame that contains all the plots frame = QFrame(self) @@ -391,6 +411,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.trials = TrialsTableView(self) self.trials.setModel(self.model.table_model) self.trials.setMouseTracking(True) + self.trials.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel) self.trials.verticalHeader().hide() self.trials.horizontalHeader().setDefaultAlignment(Qt.AlignLeft) self.trials.horizontalHeader().setSectionResizeMode(QHeaderView.Fixed) @@ -435,9 +456,9 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.psychometricFunction.plotItem.addItem(pg.InfiniteLine(0, 90, 'black')) layout.addWidget(self.psychometricFunction, 2, 1, 1, 1) - # response time + # chronometric function self.responseTimeWidget = pg.PlotWidget(parent=self, background='white') - self.responseTimeWidget.plotItem.setTitle('Response Time', color='k') + self.responseTimeWidget.plotItem.setTitle('Chronometric Function', color='k') self.responseTimeWidget.plotItem.getAxis('left').setLabel('Response Time (s)') self.responseTimeWidget.plotItem.getAxis('bottom').setLabel('Signed Contrast') for axis in ('left', 'bottom'): @@ -454,7 +475,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None # bpod data self.bpodWidget = BpodWidget(self, title='Bpod States and Input Channels') - self.bpodWidget.setMinimumHeight(160) + self.bpodWidget.setMinimumHeight(130) self.bpodWidget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) layout.addWidget(self.bpodWidget, 4, 0, 1, 2) @@ -492,6 +513,14 @@ def online_plots_cli(): class Settings(BaseSettings, cli_parse_args=True): directory: CliPositionalArg[Path] = Field(description='Raw Data Directory') + # set app information + QCoreApplication.setOrganizationName('International Brain Laboratory') + QCoreApplication.setOrganizationDomain('internationalbrainlab.org') + QCoreApplication.setApplicationName('IBLRIG Online Plots') + if os.name == 'nt': + app_id = f'IBL.iblrig.online_plots.{iblrig_version}' + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id) + app = QApplication([]) window = OnlinePlotsView(Settings().directory) diff --git a/iblrig/raw_data_loaders.py b/iblrig/raw_data_loaders.py index 0cb7c278d..6d5b768a3 100644 --- a/iblrig/raw_data_loaders.py +++ b/iblrig/raw_data_loaders.py @@ -157,7 +157,7 @@ def bpod_trial_data_to_dataframe(bpod_trial_data: dict[str, Any], trial: int) -> # deduce channels and values from event names df[['Channel', 'Value']] = df['Event'].str.extract(RE_PATTERN_EVENT, expand=True) df['Channel'] = df['Channel'].astype('category') - df['Value'] = df['Value'].replace({'Low': 0, 'High': 1, 'Out': 0, 'In': 1}) + df['Value'] = df['Value'].replace({'Low': '0', 'High': '1', 'Out': '0', 'In': '1'}) df['Value'] = pd.to_numeric(df['Value'], errors='coerce', downcast='unsigned', dtype_backend='numpy_nullable') return df From 06c612d8c1ddacccd5ea35d9303df82f475ae6a5 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Tue, 17 Dec 2024 10:30:38 +0000 Subject: [PATCH 25/38] transparent selection / updated colors --- iblrig/gui/online_plots.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 7cf8b0f44..5742289ea 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -198,8 +198,8 @@ class ResponseTimeDelegate(QStyledItemDelegate): norm_min = 0.1 norm_max = 102.0 norm_div = np.log(norm_max / norm_min) - color_correct = QColor(44, 162, 95) - color_error = QColor(227, 74, 51) + color_correct = QColor(0, 107, 90) + color_error = QColor(219, 67, 37) color_nogo = QColor(192, 192, 192) color_text = QColor('white') color_gradient0 = QColor(255, 255, 255, 0) @@ -420,7 +420,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.trials.horizontalHeader().setStretchLastSection(True) self.trials.setStyleSheet( 'QHeaderView::section { border: none; background-color: white; }' - 'QTableView::item:selected { color: black; selection-background-color: rgb(229, 229, 229); }' + 'QTableView::item:selected { color: black; selection-background-color: rgba(0, 0, 0, 15%); }' ) self.trials.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.trials.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) @@ -486,6 +486,7 @@ def updatePlots(self, trial: int): self.title.setText(f'Trial {trial}') self.bpodWidget.setData(self.model.bpod_data(trial)) self.trials.setCurrentIndex(self.model.table_model.index(trial, 0)) + self.trials.scrollToBottom() self.update() def onSelectionChanged(self, selected: QItemSelection, _: QItemSelection): From 124201fce2d8a00701747dd974fbcf7cbc99d010 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Tue, 17 Dec 2024 17:33:40 +0000 Subject: [PATCH 26/38] Update online_plots.py --- iblrig/gui/online_plots.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 5742289ea..bed1d4a46 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -55,7 +55,7 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A timing = index.siblingAtColumn(5).data() tip = ( f'Trial {trial}: {contrast:g}% contrast / {abs(position):g}° {"right" if position > 0 else "left"} ' - f'{"(debiasing) " if debias else ""}/ {outcome}' + f'{"/ debiasing " if debias else ""}/ {outcome}' ) return tip + ('.' if outcome == 'no-go' else f' after {timing:0.2f} s.') if index.isValid() and index.column() == 0 and role == Qt.TextAlignmentRole: @@ -115,6 +115,14 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None with self.settings_file.open('r') as f: self.task_settings = json.load(f) + self.probability_set = [self.task_settings.get('PROBABILITY_LEFT')] + self.task_settings.get('BLOCK_PROBABILITY_SET', []) + self.contrast_set = np.unique(np.abs(self.task_settings.get('CONTRAST_SET'))) + signed_contrasts = np.r_[-np.flipud(self.contrast_set[1:]), self.contrast_set] + self.psychometrics = pd.DataFrame( + columns=['count', 'response_time', 'choice', 'response_time_std', 'choice_std'], + index=pd.MultiIndex.from_product([self.probability_set, signed_contrasts]), + ) + self.psychometrics['count'] = 0 # read the jsonable file and instantiate a QFileSystemWatcher self.readJsonable(self.jsonable_file) @@ -128,11 +136,10 @@ def readJsonable(self, _: str) -> None: self._trial_data = pd.concat([self._trial_data, trial_data]) self._bpod_data = bpod_session_data_to_dataframe(bpod_data=bpod_data, existing_data=self._bpod_data) - table = pd.DataFrame() - table['Trial'] = self._trial_data.trial_num - table['Stimulus'] = self._trial_data.position - table['Contrast'] = self._trial_data.contrast - table['Debias'] = self._trial_data.debias_trial if 'debias_trial' in self._trial_data.columns else False + # update data for trial history table + table = self._trial_data[['trial_num', 'position', 'contrast']].copy() + table.columns = ['Trial', 'Stimulus', 'Contrast'] + table['Debias'] = self._trial_data.get('debias_trial', False) table['Outcome'] = self._trial_data.apply( lambda row: 'no-go' if row['response_side'] == 0 else ('correct' if row['trial_correct'] else 'error'), axis=1 ) @@ -413,6 +420,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.trials.setMouseTracking(True) self.trials.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel) self.trials.verticalHeader().hide() + # self.trials.horizontalHeader().hide() self.trials.horizontalHeader().setDefaultAlignment(Qt.AlignLeft) self.trials.horizontalHeader().setSectionResizeMode(QHeaderView.Fixed) self.trials.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) @@ -420,7 +428,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.trials.horizontalHeader().setStretchLastSection(True) self.trials.setStyleSheet( 'QHeaderView::section { border: none; background-color: white; }' - 'QTableView::item:selected { color: black; selection-background-color: rgba(0, 0, 0, 15%); }' + 'QTableView::item:selected { color: black; selection-background-color: rgba(0, 0, 0, 10%); }' ) self.trials.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.trials.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) @@ -486,7 +494,8 @@ def updatePlots(self, trial: int): self.title.setText(f'Trial {trial}') self.bpodWidget.setData(self.model.bpod_data(trial)) self.trials.setCurrentIndex(self.model.table_model.index(trial, 0)) - self.trials.scrollToBottom() + if trial == self.model.table_model.columnCount() - 1: + self.trials.scrollToBottom() self.update() def onSelectionChanged(self, selected: QItemSelection, _: QItemSelection): From db7057a8edfee0c0bc64f9fb370aabfdf42989ad Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Tue, 17 Dec 2024 18:09:30 +0000 Subject: [PATCH 27/38] Update online_plots.py --- iblrig/gui/online_plots.py | 60 ++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index bed1d4a46..aa131e011 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -75,6 +75,31 @@ class TrialsTableView(QTableView): color_major = QColor(199, 199, 199) grid_col = 5 + def __init__(self, parent: QObject): + super().__init__(parent) + self.setMouseTracking(True) + self.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel) + self.verticalHeader().hide() + # self.horizontalHeader().hide() + self.horizontalHeader().setDefaultAlignment(Qt.AlignLeft) + self.horizontalHeader().setSectionResizeMode(QHeaderView.Fixed) + self.horizontalHeader().setStretchLastSection(True) + self.setStyleSheet( + 'QHeaderView::section { border: none; background-color: white; }' + 'QTableView::item:selected { color: black; selection-background-color: rgba(0, 0, 0, 10%); }' + ) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.stimulusDelegate = StimulusDelegate() + self.responseTimeDelegate = ResponseTimeDelegate() + self.setItemDelegateForColumn(1, self.stimulusDelegate) + self.setItemDelegateForColumn(5, self.responseTimeDelegate) + self.setShowGrid(False) + self.setFrameShape(QTableView.NoFrame) + self.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.setSelectionMode(QTableView.SingleSelection) + self.setSelectionBehavior(QTableView.SelectRows) + def paintEvent(self, event): viewport_pos = self.columnViewportPosition(self.grid_col) col_width = self.columnWidth(self.grid_col) @@ -384,9 +409,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.statusBar().clearMessage() self.setWindowTitle('Online Plots') self.setMinimumSize(1024, 768) - icon = QIcon() - icon.addPixmap(QPixmap(':/images/iblrig_logo'), QIcon.Normal, QIcon.Off) - self.setWindowIcon(icon) + self.setWindowIcon(QIcon(QPixmap(':/images/iblrig_logo'))) # the frame that contains all the plots frame = QFrame(self) @@ -397,9 +420,11 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None # we use a grid layout to organize the different widgets layout = QGridLayout(frame) frame.setLayout(layout) + layout.setColumnStretch(0, 1) + layout.setColumnStretch(1, 2) # main title - self.title = QLabel('This is the main title') + self.title = QLabel('This is the main title', self) self.title.setAlignment(Qt.AlignHCenter) font = self.title.font() font.setPointSize(15) @@ -409,7 +434,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None layout.addWidget(self.title, 0, 0, 1, 2) # sub title - subtitle = QLabel('This is the sub-title') + subtitle = QLabel('This is the sub-title', self) subtitle.setAlignment(Qt.AlignHCenter) subtitle.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) layout.addWidget(subtitle, 1, 0, 1, 2) @@ -417,34 +442,12 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None # trial data self.trials = TrialsTableView(self) self.trials.setModel(self.model.table_model) - self.trials.setMouseTracking(True) - self.trials.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel) - self.trials.verticalHeader().hide() - # self.trials.horizontalHeader().hide() - self.trials.horizontalHeader().setDefaultAlignment(Qt.AlignLeft) - self.trials.horizontalHeader().setSectionResizeMode(QHeaderView.Fixed) + self.trials.selectionModel().selectionChanged.connect(self.onSelectionChanged) self.trials.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) self.trials.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) - self.trials.horizontalHeader().setStretchLastSection(True) - self.trials.setStyleSheet( - 'QHeaderView::section { border: none; background-color: white; }' - 'QTableView::item:selected { color: black; selection-background-color: rgba(0, 0, 0, 10%); }' - ) - self.trials.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.trials.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.stimulusDelegate = StimulusDelegate() - self.responseTimeDelegate = ResponseTimeDelegate() - self.trials.setItemDelegateForColumn(1, self.stimulusDelegate) self.trials.setColumnHidden(2, True) self.trials.setColumnHidden(3, True) self.trials.setColumnHidden(4, True) - self.trials.setItemDelegateForColumn(5, self.responseTimeDelegate) - self.trials.setShowGrid(False) - self.trials.setFrameShape(QTableView.NoFrame) - self.trials.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self.trials.setSelectionMode(QTableView.SingleSelection) - self.trials.setSelectionBehavior(QTableView.SelectRows) - self.trials.selectionModel().selectionChanged.connect(self.onSelectionChanged) layout.addWidget(self.trials, 2, 0, 2, 1) # psychometric function @@ -488,6 +491,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None layout.addWidget(self.bpodWidget, 4, 0, 1, 2) self.model.currentTrialChanged.connect(self.updatePlots) + self.updatePlots(self.model.nTrials() - 1) @Slot(int) def updatePlots(self, trial: int): From e9934b73a60455fb9d522d4dcd9423c1418d7cd6 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 18 Dec 2024 13:41:43 +0000 Subject: [PATCH 28/38] prepare psychometric/chronometric functions --- iblrig/gui/online_plots.py | 63 +++++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index aa131e011..cb66d9f61 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -401,6 +401,8 @@ def updateXRange(self): class OnlinePlotsView(QMainWindow): + colormap = pg.colormap.get('tab10', source='matplotlib') + def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None): super().__init__(parent) pg.setConfigOptions(antialias=True) @@ -452,6 +454,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None # psychometric function self.psychometricFunction = pg.PlotWidget(parent=self, background='white') + layout.addWidget(self.psychometricFunction, 2, 1, 1, 1) self.psychometricFunction.plotItem.setTitle('Psychometric Function', color='k') self.psychometricFunction.plotItem.getAxis('left').setLabel('Rightward Choices (%)') self.psychometricFunction.plotItem.getAxis('bottom').setLabel('Signed Contrast') @@ -465,24 +468,54 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.psychometricFunction.plotItem.hideButtons() self.psychometricFunction.plotItem.addItem(pg.InfiniteLine(0.5, 0, 'black')) self.psychometricFunction.plotItem.addItem(pg.InfiniteLine(0, 90, 'black')) - layout.addWidget(self.psychometricFunction, 2, 1, 1, 1) + legend = pg.LegendItem(pen='lightgray', brush='w', offset=(60, 30), verSpacing=-5, labelTextColor='k') + legend.setParentItem(self.psychometricFunction.plotItem.graphicsItem()) + legend.setZValue(1) + self.psychometricPlotItems = dict() + for idx, p in enumerate([1, 2]): + color = self.colormap.getByIndex(idx) + self.psychometricPlotItems[p] = self.psychometricFunction.plotItem.plot( + x=[-1, np.NAN], + y=[np.NAN, 1], + pen=pg.mkPen(color=color, width=2), + symbolPen=color, + symbolBrush=color, + symbolSize=7 + ) + legend.addItem(self.psychometricPlotItems[p], f'p = {p:0.1f}') # chronometric function - self.responseTimeWidget = pg.PlotWidget(parent=self, background='white') - self.responseTimeWidget.plotItem.setTitle('Chronometric Function', color='k') - self.responseTimeWidget.plotItem.getAxis('left').setLabel('Response Time (s)') - self.responseTimeWidget.plotItem.getAxis('bottom').setLabel('Signed Contrast') + self.chronometricFunction = pg.PlotWidget(parent=self, background='white') + layout.addWidget(self.chronometricFunction, 3, 1, 1, 1) + self.chronometricFunction.plotItem.setTitle('Chronometric Function', color='k') + self.chronometricFunction.plotItem.getAxis('left').setLabel('Response Time (s)') + self.chronometricFunction.plotItem.getAxis('bottom').setLabel('Signed Contrast') for axis in ('left', 'bottom'): - self.responseTimeWidget.plotItem.getAxis(axis).setGrid(128) - self.responseTimeWidget.plotItem.getAxis(axis).setTextPen('k') - self.responseTimeWidget.plotItem.setLogMode(x=False, y=True) - self.responseTimeWidget.plotItem.setXRange(-1, 1, padding=0.05) - self.responseTimeWidget.plotItem.setYRange(-1, 2, padding=0.05) - self.responseTimeWidget.plotItem.setMouseEnabled(x=False, y=False) - self.responseTimeWidget.plotItem.setMenuEnabled(False) - self.responseTimeWidget.plotItem.hideButtons() - self.responseTimeWidget.plotItem.addItem(pg.InfiniteLine(0, 90, 'black')) - layout.addWidget(self.responseTimeWidget, 3, 1, 1, 1) + self.chronometricFunction.plotItem.getAxis(axis).setGrid(128) + self.chronometricFunction.plotItem.getAxis(axis).setTextPen('k') + self.chronometricFunction.plotItem.setLogMode(x=False, y=True) + self.chronometricFunction.plotItem.setXRange(-1, 1, padding=0.05) + self.chronometricFunction.plotItem.setYRange(-1, 2, padding=0.05) + self.chronometricFunction.plotItem.setMouseEnabled(x=False, y=False) + self.chronometricFunction.plotItem.setMenuEnabled(False) + self.chronometricFunction.plotItem.hideButtons() + self.chronometricFunction.plotItem.addItem(pg.InfiniteLine(0, 90, 'black')) + self.chronometricFunction.plotItem.addLegend() + legend = pg.LegendItem(pen='lightgray', brush='w', offset=(60, 30), verSpacing=-5, labelTextColor='k') + legend.setParentItem(self.chronometricFunction.plotItem.graphicsItem()) + legend.setZValue(1) + self.chronometricPlotItems = dict() + for idx, p in enumerate([1, 2]): + color = self.colormap.getByIndex(idx) + self.chronometricPlotItems[p] = self.chronometricFunction.plotItem.plot( + x=[-1, np.NAN], + y=[np.NAN, 1], + pen=pg.mkPen(color=color, width=2), + symbolPen=color, + symbolBrush=color, + symbolSize=7 + ) + legend.addItem(self.chronometricPlotItems[p], f'p = {p:0.1f}') # bpod data self.bpodWidget = BpodWidget(self, title='Bpod States and Input Channels') From 8f61507a237c4a259af3727cd4be2192f52c0474 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 18 Dec 2024 13:54:27 +0000 Subject: [PATCH 29/38] add background color to plot items --- iblrig/gui/online_plots.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index cb66d9f61..b7232fd5d 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -86,7 +86,8 @@ def __init__(self, parent: QObject): self.horizontalHeader().setStretchLastSection(True) self.setStyleSheet( 'QHeaderView::section { border: none; background-color: white; }' - 'QTableView::item:selected { color: black; selection-background-color: rgba(0, 0, 0, 10%); }' + 'QTableView::item:selected { color: black; selection-background-color: rgb(250, 250, 250); }' + 'QTableView { background-color: rgba(0, 0, 0, 3%); }' ) self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) @@ -456,6 +457,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.psychometricFunction = pg.PlotWidget(parent=self, background='white') layout.addWidget(self.psychometricFunction, 2, 1, 1, 1) self.psychometricFunction.plotItem.setTitle('Psychometric Function', color='k') + self.psychometricFunction.plotItem.getViewBox().setBackgroundColor(pg.mkColor(250, 250, 250)) self.psychometricFunction.plotItem.getAxis('left').setLabel('Rightward Choices (%)') self.psychometricFunction.plotItem.getAxis('bottom').setLabel('Signed Contrast') for axis in ('left', 'bottom'): @@ -480,7 +482,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None pen=pg.mkPen(color=color, width=2), symbolPen=color, symbolBrush=color, - symbolSize=7 + symbolSize=7, ) legend.addItem(self.psychometricPlotItems[p], f'p = {p:0.1f}') @@ -488,6 +490,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.chronometricFunction = pg.PlotWidget(parent=self, background='white') layout.addWidget(self.chronometricFunction, 3, 1, 1, 1) self.chronometricFunction.plotItem.setTitle('Chronometric Function', color='k') + self.chronometricFunction.plotItem.getViewBox().setBackgroundColor(pg.mkColor(250, 250, 250)) self.chronometricFunction.plotItem.getAxis('left').setLabel('Response Time (s)') self.chronometricFunction.plotItem.getAxis('bottom').setLabel('Signed Contrast') for axis in ('left', 'bottom'): @@ -513,7 +516,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None pen=pg.mkPen(color=color, width=2), symbolPen=color, symbolBrush=color, - symbolSize=7 + symbolSize=7, ) legend.addItem(self.chronometricPlotItems[p], f'p = {p:0.1f}') From d68db77aca7aeb2b47a00d7e5d615412a64c3956 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 18 Dec 2024 14:19:35 +0000 Subject: [PATCH 30/38] unify setting of plots' common parameters --- iblrig/gui/online_plots.py | 68 ++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 39 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index b7232fd5d..968b949c9 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -453,71 +453,61 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.trials.setColumnHidden(4, True) layout.addWidget(self.trials, 2, 0, 2, 1) + # set common properties for psychometric/chronometric PlotItems + def setCommonPlotItemSettings(item: pg.PlotItem): + item.addItem(pg.InfiniteLine(0, 90, 'black')) + item.getViewBox().setBackgroundColor(pg.mkColor(250, 250, 250)) + for axis in ('left', 'bottom'): + item.getAxis(axis).setGrid(128) + item.getAxis(axis).setTextPen('k') + item.getAxis('bottom').setLabel('Signed Contrast') + item.setXRange(-1, 1, padding=0.05) + item.setMouseEnabled(x=False, y=False) + item.setMenuEnabled(False) + item.hideButtons() + + # set common properties for psychometric/chronometric PlotDataItems + def setCommonPlotDataItemSettings(item: pg.PlotDataItem, color: QColor): + item.setData(x=[1, np.NAN], y=[np.NAN, 1]) + item.setPen(pg.mkPen(color=color, width=2)) + item.setSymbolPen(color) + item.setSymbolBrush(color) + item.setSymbolSize(7) + # psychometric function self.psychometricFunction = pg.PlotWidget(parent=self, background='white') layout.addWidget(self.psychometricFunction, 2, 1, 1, 1) self.psychometricFunction.plotItem.setTitle('Psychometric Function', color='k') - self.psychometricFunction.plotItem.getViewBox().setBackgroundColor(pg.mkColor(250, 250, 250)) self.psychometricFunction.plotItem.getAxis('left').setLabel('Rightward Choices (%)') - self.psychometricFunction.plotItem.getAxis('bottom').setLabel('Signed Contrast') - for axis in ('left', 'bottom'): - self.psychometricFunction.plotItem.getAxis(axis).setGrid(128) - self.psychometricFunction.plotItem.getAxis(axis).setTextPen('k') - self.psychometricFunction.plotItem.setXRange(-1, 1, padding=0.05) self.psychometricFunction.plotItem.setYRange(0, 1, padding=0.05) - self.psychometricFunction.plotItem.setMouseEnabled(x=False, y=False) - self.psychometricFunction.plotItem.setMenuEnabled(False) - self.psychometricFunction.plotItem.hideButtons() self.psychometricFunction.plotItem.addItem(pg.InfiniteLine(0.5, 0, 'black')) - self.psychometricFunction.plotItem.addItem(pg.InfiniteLine(0, 90, 'black')) legend = pg.LegendItem(pen='lightgray', brush='w', offset=(60, 30), verSpacing=-5, labelTextColor='k') legend.setParentItem(self.psychometricFunction.plotItem.graphicsItem()) + setCommonPlotItemSettings(self.psychometricFunction.plotItem) legend.setZValue(1) - self.psychometricPlotItems = dict() + self.psychometricPlotDataItems = dict() for idx, p in enumerate([1, 2]): color = self.colormap.getByIndex(idx) - self.psychometricPlotItems[p] = self.psychometricFunction.plotItem.plot( - x=[-1, np.NAN], - y=[np.NAN, 1], - pen=pg.mkPen(color=color, width=2), - symbolPen=color, - symbolBrush=color, - symbolSize=7, - ) - legend.addItem(self.psychometricPlotItems[p], f'p = {p:0.1f}') + self.psychometricPlotDataItems[p] = self.psychometricFunction.plotItem.plot() + setCommonPlotDataItemSettings(self.psychometricPlotDataItems[p], color) + legend.addItem(self.psychometricPlotDataItems[p], f'p = {p:0.1f}') # chronometric function self.chronometricFunction = pg.PlotWidget(parent=self, background='white') layout.addWidget(self.chronometricFunction, 3, 1, 1, 1) self.chronometricFunction.plotItem.setTitle('Chronometric Function', color='k') - self.chronometricFunction.plotItem.getViewBox().setBackgroundColor(pg.mkColor(250, 250, 250)) self.chronometricFunction.plotItem.getAxis('left').setLabel('Response Time (s)') - self.chronometricFunction.plotItem.getAxis('bottom').setLabel('Signed Contrast') - for axis in ('left', 'bottom'): - self.chronometricFunction.plotItem.getAxis(axis).setGrid(128) - self.chronometricFunction.plotItem.getAxis(axis).setTextPen('k') self.chronometricFunction.plotItem.setLogMode(x=False, y=True) - self.chronometricFunction.plotItem.setXRange(-1, 1, padding=0.05) self.chronometricFunction.plotItem.setYRange(-1, 2, padding=0.05) - self.chronometricFunction.plotItem.setMouseEnabled(x=False, y=False) - self.chronometricFunction.plotItem.setMenuEnabled(False) - self.chronometricFunction.plotItem.hideButtons() - self.chronometricFunction.plotItem.addItem(pg.InfiniteLine(0, 90, 'black')) - self.chronometricFunction.plotItem.addLegend() + setCommonPlotItemSettings(self.chronometricFunction.plotItem) legend = pg.LegendItem(pen='lightgray', brush='w', offset=(60, 30), verSpacing=-5, labelTextColor='k') legend.setParentItem(self.chronometricFunction.plotItem.graphicsItem()) legend.setZValue(1) self.chronometricPlotItems = dict() for idx, p in enumerate([1, 2]): color = self.colormap.getByIndex(idx) - self.chronometricPlotItems[p] = self.chronometricFunction.plotItem.plot( - x=[-1, np.NAN], - y=[np.NAN, 1], - pen=pg.mkPen(color=color, width=2), - symbolPen=color, - symbolBrush=color, - symbolSize=7, - ) + self.chronometricPlotItems[p] = self.chronometricFunction.plotItem.plot() + setCommonPlotDataItemSettings(self.chronometricPlotItems[p], color) legend.addItem(self.chronometricPlotItems[p], f'p = {p:0.1f}') # bpod data From f6071ab1021261b5b68e650bcc23162bf37c6c35 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 18 Dec 2024 14:25:23 +0000 Subject: [PATCH 31/38] further simplifications of plot properties --- iblrig/gui/online_plots.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 968b949c9..4bb2fa3e0 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -454,7 +454,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None layout.addWidget(self.trials, 2, 0, 2, 1) # set common properties for psychometric/chronometric PlotItems - def setCommonPlotItemSettings(item: pg.PlotItem): + def setCommonPlotItemSettings(item: pg.PlotItem) -> pg.LegendItem: item.addItem(pg.InfiniteLine(0, 90, 'black')) item.getViewBox().setBackgroundColor(pg.mkColor(250, 250, 250)) for axis in ('left', 'bottom'): @@ -465,9 +465,14 @@ def setCommonPlotItemSettings(item: pg.PlotItem): item.setMouseEnabled(x=False, y=False) item.setMenuEnabled(False) item.hideButtons() + legend = pg.LegendItem(pen='lightgray', brush='w', offset=(60, 30), verSpacing=-5, labelTextColor='k') + legend.setParentItem(item.graphicsItem()) + legend.setZValue(1) + return legend # set common properties for psychometric/chronometric PlotDataItems - def setCommonPlotDataItemSettings(item: pg.PlotDataItem, color: QColor): + def setCommonPlotDataItemSettings(item: pg.PlotDataItem, index: int): + color = self.colormap.getByIndex(index) item.setData(x=[1, np.NAN], y=[np.NAN, 1]) item.setPen(pg.mkPen(color=color, width=2)) item.setSymbolPen(color) @@ -481,15 +486,11 @@ def setCommonPlotDataItemSettings(item: pg.PlotDataItem, color: QColor): self.psychometricFunction.plotItem.getAxis('left').setLabel('Rightward Choices (%)') self.psychometricFunction.plotItem.setYRange(0, 1, padding=0.05) self.psychometricFunction.plotItem.addItem(pg.InfiniteLine(0.5, 0, 'black')) - legend = pg.LegendItem(pen='lightgray', brush='w', offset=(60, 30), verSpacing=-5, labelTextColor='k') - legend.setParentItem(self.psychometricFunction.plotItem.graphicsItem()) - setCommonPlotItemSettings(self.psychometricFunction.plotItem) - legend.setZValue(1) + legend = setCommonPlotItemSettings(self.psychometricFunction.plotItem) self.psychometricPlotDataItems = dict() for idx, p in enumerate([1, 2]): - color = self.colormap.getByIndex(idx) self.psychometricPlotDataItems[p] = self.psychometricFunction.plotItem.plot() - setCommonPlotDataItemSettings(self.psychometricPlotDataItems[p], color) + setCommonPlotDataItemSettings(self.psychometricPlotDataItems[p], idx) legend.addItem(self.psychometricPlotDataItems[p], f'p = {p:0.1f}') # chronometric function @@ -499,15 +500,11 @@ def setCommonPlotDataItemSettings(item: pg.PlotDataItem, color: QColor): self.chronometricFunction.plotItem.getAxis('left').setLabel('Response Time (s)') self.chronometricFunction.plotItem.setLogMode(x=False, y=True) self.chronometricFunction.plotItem.setYRange(-1, 2, padding=0.05) - setCommonPlotItemSettings(self.chronometricFunction.plotItem) - legend = pg.LegendItem(pen='lightgray', brush='w', offset=(60, 30), verSpacing=-5, labelTextColor='k') - legend.setParentItem(self.chronometricFunction.plotItem.graphicsItem()) - legend.setZValue(1) + legend = setCommonPlotItemSettings(self.chronometricFunction.plotItem) self.chronometricPlotItems = dict() for idx, p in enumerate([1, 2]): - color = self.colormap.getByIndex(idx) self.chronometricPlotItems[p] = self.chronometricFunction.plotItem.plot() - setCommonPlotDataItemSettings(self.chronometricPlotItems[p], color) + setCommonPlotDataItemSettings(self.chronometricPlotItems[p], idx) legend.addItem(self.chronometricPlotItems[p], f'p = {p:0.1f}') # bpod data From 75b025b2a3f177b23aaf9218237012b545cccdab Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 18 Dec 2024 14:30:45 +0000 Subject: [PATCH 32/38] more of the same --- iblrig/gui/online_plots.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 4bb2fa3e0..2b5292c9b 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -471,13 +471,15 @@ def setCommonPlotItemSettings(item: pg.PlotItem) -> pg.LegendItem: return legend # set common properties for psychometric/chronometric PlotDataItems - def setCommonPlotDataItemSettings(item: pg.PlotDataItem, index: int): + def createPlotDataItems(plot_item: pg.PlotItem, index: int) -> pg.PlotDataItem: + item = plot_item.plot() color = self.colormap.getByIndex(index) item.setData(x=[1, np.NAN], y=[np.NAN, 1]) item.setPen(pg.mkPen(color=color, width=2)) item.setSymbolPen(color) item.setSymbolBrush(color) item.setSymbolSize(7) + return item # psychometric function self.psychometricFunction = pg.PlotWidget(parent=self, background='white') @@ -489,8 +491,7 @@ def setCommonPlotDataItemSettings(item: pg.PlotDataItem, index: int): legend = setCommonPlotItemSettings(self.psychometricFunction.plotItem) self.psychometricPlotDataItems = dict() for idx, p in enumerate([1, 2]): - self.psychometricPlotDataItems[p] = self.psychometricFunction.plotItem.plot() - setCommonPlotDataItemSettings(self.psychometricPlotDataItems[p], idx) + self.psychometricPlotDataItems[p] = createPlotDataItems(self.psychometricFunction.plotItem, idx) legend.addItem(self.psychometricPlotDataItems[p], f'p = {p:0.1f}') # chronometric function @@ -503,8 +504,7 @@ def setCommonPlotDataItemSettings(item: pg.PlotDataItem, index: int): legend = setCommonPlotItemSettings(self.chronometricFunction.plotItem) self.chronometricPlotItems = dict() for idx, p in enumerate([1, 2]): - self.chronometricPlotItems[p] = self.chronometricFunction.plotItem.plot() - setCommonPlotDataItemSettings(self.chronometricPlotItems[p], idx) + self.chronometricPlotItems[p] = createPlotDataItems(self.chronometricFunction.plotItem, idx) legend.addItem(self.chronometricPlotItems[p], f'p = {p:0.1f}') # bpod data From 3eab4095d17c3296b8c2f71aeecb5f5455d16750 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 18 Dec 2024 15:16:36 +0000 Subject: [PATCH 33/38] even more of the same --- iblrig/gui/online_plots.py | 61 +++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 34 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 2b5292c9b..908d29ac9 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -1,6 +1,7 @@ import ctypes import json import os +from collections.abc import Sequence from pathlib import Path from typing import Any @@ -453,33 +454,33 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.trials.setColumnHidden(4, True) layout.addWidget(self.trials, 2, 0, 2, 1) - # set common properties for psychometric/chronometric PlotItems - def setCommonPlotItemSettings(item: pg.PlotItem) -> pg.LegendItem: - item.addItem(pg.InfiniteLine(0, 90, 'black')) - item.getViewBox().setBackgroundColor(pg.mkColor(250, 250, 250)) + # set common properties for psychometric/chronometric functions + def commonFunctionSettings(plot_widget: pg.PlotWidget, categories: Sequence[Any]) -> dict[Any, pg.PlotDataItem]: + plot_item = plot_widget.plotItem + plot_item.addItem(pg.InfiniteLine(0, 90, 'black')) + plot_item.getViewBox().setBackgroundColor(pg.mkColor(250, 250, 250)) for axis in ('left', 'bottom'): - item.getAxis(axis).setGrid(128) - item.getAxis(axis).setTextPen('k') - item.getAxis('bottom').setLabel('Signed Contrast') - item.setXRange(-1, 1, padding=0.05) - item.setMouseEnabled(x=False, y=False) - item.setMenuEnabled(False) - item.hideButtons() + plot_item.getAxis(axis).setGrid(128) + plot_item.getAxis(axis).setTextPen('k') + plot_item.getAxis('bottom').setLabel('Signed Contrast') + plot_item.setXRange(-1, 1, padding=0.05) + plot_item.setMouseEnabled(x=False, y=False) + plot_item.setMenuEnabled(False) + plot_item.hideButtons() legend = pg.LegendItem(pen='lightgray', brush='w', offset=(60, 30), verSpacing=-5, labelTextColor='k') - legend.setParentItem(item.graphicsItem()) + legend.setParentItem(plot_item.graphicsItem()) legend.setZValue(1) - return legend - - # set common properties for psychometric/chronometric PlotDataItems - def createPlotDataItems(plot_item: pg.PlotItem, index: int) -> pg.PlotDataItem: - item = plot_item.plot() - color = self.colormap.getByIndex(index) - item.setData(x=[1, np.NAN], y=[np.NAN, 1]) - item.setPen(pg.mkPen(color=color, width=2)) - item.setSymbolPen(color) - item.setSymbolBrush(color) - item.setSymbolSize(7) - return item + plot_data_items = dict() + for idx, category in enumerate(categories): + plot_data_items[category] = plot_item.plot() + color = self.colormap.getByIndex(idx) + plot_data_items[category].setData(x=[1, np.NAN], y=[np.NAN, 1]) + plot_data_items[category].setPen(pg.mkPen(color=color, width=2)) + plot_data_items[category].setSymbolPen(color) + plot_data_items[category].setSymbolBrush(color) + plot_data_items[category].setSymbolSize(7) + legend.addItem(plot_data_items[category], f'p = {category:0.1f}') + return plot_data_items # psychometric function self.psychometricFunction = pg.PlotWidget(parent=self, background='white') @@ -488,11 +489,7 @@ def createPlotDataItems(plot_item: pg.PlotItem, index: int) -> pg.PlotDataItem: self.psychometricFunction.plotItem.getAxis('left').setLabel('Rightward Choices (%)') self.psychometricFunction.plotItem.setYRange(0, 1, padding=0.05) self.psychometricFunction.plotItem.addItem(pg.InfiniteLine(0.5, 0, 'black')) - legend = setCommonPlotItemSettings(self.psychometricFunction.plotItem) - self.psychometricPlotDataItems = dict() - for idx, p in enumerate([1, 2]): - self.psychometricPlotDataItems[p] = createPlotDataItems(self.psychometricFunction.plotItem, idx) - legend.addItem(self.psychometricPlotDataItems[p], f'p = {p:0.1f}') + self.psychometricPlotDataItems = commonFunctionSettings(self.psychometricFunction, [1, 2]) # chronometric function self.chronometricFunction = pg.PlotWidget(parent=self, background='white') @@ -501,11 +498,7 @@ def createPlotDataItems(plot_item: pg.PlotItem, index: int) -> pg.PlotDataItem: self.chronometricFunction.plotItem.getAxis('left').setLabel('Response Time (s)') self.chronometricFunction.plotItem.setLogMode(x=False, y=True) self.chronometricFunction.plotItem.setYRange(-1, 2, padding=0.05) - legend = setCommonPlotItemSettings(self.chronometricFunction.plotItem) - self.chronometricPlotItems = dict() - for idx, p in enumerate([1, 2]): - self.chronometricPlotItems[p] = createPlotDataItems(self.chronometricFunction.plotItem, idx) - legend.addItem(self.chronometricPlotItems[p], f'p = {p:0.1f}') + self.chronometricPlotDataItems = commonFunctionSettings(self.chronometricFunction, [1, 2]) # bpod data self.bpodWidget = BpodWidget(self, title='Bpod States and Input Channels') From accac8fe63c75cbb4bdeaf479356adf6c4d6d1b5 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 18 Dec 2024 19:51:12 +0000 Subject: [PATCH 34/38] Update online_plots.py --- iblrig/gui/online_plots.py | 123 +++++++++++++++++++++++++++++-------- 1 file changed, 99 insertions(+), 24 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 908d29ac9..47b37f8c1 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -1,7 +1,6 @@ import ctypes import json import os -from collections.abc import Sequence from pathlib import Path from typing import Any @@ -40,6 +39,7 @@ from iblqt.core import DataFrameTableModel from iblrig import __version__ as iblrig_version from iblrig.gui import resources_rc # noqa: F401 +from iblrig.misc import online_std from iblrig.raw_data_loaders import bpod_session_data_to_dataframe, load_task_jsonable @@ -87,7 +87,7 @@ def __init__(self, parent: QObject): self.horizontalHeader().setStretchLastSection(True) self.setStyleSheet( 'QHeaderView::section { border: none; background-color: white; }' - 'QTableView::item:selected { color: black; selection-background-color: rgb(250, 250, 250); }' + 'QTableView::item:selected { color: black; selection-background-color: rgba(0, 0, 0, 6%); }' 'QTableView { background-color: rgba(0, 0, 0, 3%); }' ) self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) @@ -144,12 +144,14 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.task_settings = json.load(f) self.probability_set = [self.task_settings.get('PROBABILITY_LEFT')] + self.task_settings.get('BLOCK_PROBABILITY_SET', []) self.contrast_set = np.unique(np.abs(self.task_settings.get('CONTRAST_SET'))) - signed_contrasts = np.r_[-np.flipud(self.contrast_set[1:]), self.contrast_set] + self.signed_contrasts = np.r_[-np.flipud(self.contrast_set[1:]), self.contrast_set] self.psychometrics = pd.DataFrame( columns=['count', 'response_time', 'choice', 'response_time_std', 'choice_std'], - index=pd.MultiIndex.from_product([self.probability_set, signed_contrasts]), + index=pd.MultiIndex.from_product([self.probability_set, self.signed_contrasts]), ) self.psychometrics['count'] = 0 + self.reward_amount = 0 + self.ntrials_correct = 0 # read the jsonable file and instantiate a QFileSystemWatcher self.readJsonable(self.jsonable_file) @@ -158,6 +160,8 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None @Slot(str) def readJsonable(self, _: str) -> None: + if not self.jsonable_file.exists(): + return trial_data, bpod_data = load_task_jsonable(self.jsonable_file, offset=self._jsonableOffset) self._jsonableOffset = self.jsonable_file.stat().st_size self._trial_data = pd.concat([self._trial_data, trial_data]) @@ -175,6 +179,30 @@ def readJsonable(self, _: str) -> None: ) self.table_model.setDataFrame(table) + # update psychometrics using online statistics method + for _, row in trial_data.iterrows(): + signed_contrast = np.sign(row.position) * row.contrast + choice = row.position > 0 if row.trial_correct else row.position < 0 + indexer = (row.stim_probability_left, signed_contrast) + if indexer not in self.psychometrics.index: + self.psychometrics.loc[indexer, :] = np.nan + self.psychometrics.loc[indexer, 'count'] = 0 + self.psychometrics.loc[indexer, 'count'] += 1 + self.psychometrics.loc[indexer, 'response_time'], self.psychometrics.loc[indexer, 'response_time_std'] = online_std( + new_sample=row.response_time, + new_count=self.psychometrics.loc[indexer, 'count'], + old_mean=self.psychometrics.loc[indexer, 'response_time'], + old_std=self.psychometrics.loc[indexer, 'response_time_std'], + ) + self.psychometrics.loc[indexer, 'choice'], self.psychometrics.loc[indexer, 'choice_std'] = online_std( + new_sample=float(choice), + new_count=self.psychometrics.loc[indexer, 'count'], + old_mean=self.psychometrics.loc[indexer, 'choice'], + old_std=self.psychometrics.loc[indexer, 'choice_std'], + ) + self.reward_amount += row.reward_amount + self.ntrials_correct += row.trial_correct + self.setCurrentTrial(self.nTrials() - 1) @Slot(int) @@ -189,6 +217,9 @@ def currentTrial(self) -> int: def nTrials(self) -> int: return len(self._trial_data) + def percentCorrect(self) -> float: + return self.ntrials_correct / (self.nTrials() if self.nTrials() > 0 else np.nan) * 100 + def bpod_data(self, trial: int) -> pd.DataFrame: return self._bpod_data[self._bpod_data.Trial == trial] @@ -435,13 +466,13 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None font.setBold(True) self.title.setFont(font) self.title.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) - layout.addWidget(self.title, 0, 0, 1, 2) + layout.addWidget(self.title, 0, 0, 1, 3) # sub title subtitle = QLabel('This is the sub-title', self) subtitle.setAlignment(Qt.AlignHCenter) subtitle.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) - layout.addWidget(subtitle, 1, 0, 1, 2) + layout.addWidget(subtitle, 1, 0, 1, 3) # trial data self.trials = TrialsTableView(self) @@ -454,32 +485,39 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.trials.setColumnHidden(4, True) layout.addWidget(self.trials, 2, 0, 2, 1) - # set common properties for psychometric/chronometric functions - def commonFunctionSettings(plot_widget: pg.PlotWidget, categories: Sequence[Any]) -> dict[Any, pg.PlotDataItem]: + # properties common to all pyqtgraph plots + def common_plot_item_props(plot_item: pg.PlotItem): + plot_item.getViewBox().setBackgroundColor(pg.mkColor(250, 250, 250)) + plot_item.setMouseEnabled(x=False, y=False) + plot_item.setMenuEnabled(False) + plot_item.hideButtons() + for axis in ('left', 'bottom'): + plot_item.getAxis(axis).setTextPen('k') + + # properties common to psychometric/chronometric functions + def common_function_props(plot_widget: pg.PlotWidget) -> dict[Any, pg.PlotDataItem]: plot_item = plot_widget.plotItem + common_plot_item_props(plot_item) plot_item.addItem(pg.InfiniteLine(0, 90, 'black')) - plot_item.getViewBox().setBackgroundColor(pg.mkColor(250, 250, 250)) for axis in ('left', 'bottom'): plot_item.getAxis(axis).setGrid(128) plot_item.getAxis(axis).setTextPen('k') plot_item.getAxis('bottom').setLabel('Signed Contrast') plot_item.setXRange(-1, 1, padding=0.05) - plot_item.setMouseEnabled(x=False, y=False) - plot_item.setMenuEnabled(False) - plot_item.hideButtons() - legend = pg.LegendItem(pen='lightgray', brush='w', offset=(60, 30), verSpacing=-5, labelTextColor='k') + legend = pg.LegendItem(pen='lightgray', brush='w', offset=(45, 35), verSpacing=-5, labelTextColor='k') legend.setParentItem(plot_item.graphicsItem()) legend.setZValue(1) plot_data_items = dict() - for idx, category in enumerate(categories): - plot_data_items[category] = plot_item.plot() + for idx, probability in enumerate(self.model.probability_set): + plot_data_items[probability] = plot_item.plot(connect='all') color = self.colormap.getByIndex(idx) - plot_data_items[category].setData(x=[1, np.NAN], y=[np.NAN, 1]) - plot_data_items[category].setPen(pg.mkPen(color=color, width=2)) - plot_data_items[category].setSymbolPen(color) - plot_data_items[category].setSymbolBrush(color) - plot_data_items[category].setSymbolSize(7) - legend.addItem(plot_data_items[category], f'p = {category:0.1f}') + plot_data_items[probability].setData(x=[1, np.NAN], y=[np.NAN, 1]) + plot_data_items[probability].setPen(pg.mkPen(color=color, width=2)) + plot_data_items[probability].setSymbol('o') + plot_data_items[probability].setSymbolPen(color) + plot_data_items[probability].setSymbolBrush(color) + plot_data_items[probability].setSymbolSize(5) + legend.addItem(plot_data_items[probability], f'p = {probability:0.1f}') return plot_data_items # psychometric function @@ -489,7 +527,7 @@ def commonFunctionSettings(plot_widget: pg.PlotWidget, categories: Sequence[Any] self.psychometricFunction.plotItem.getAxis('left').setLabel('Rightward Choices (%)') self.psychometricFunction.plotItem.setYRange(0, 1, padding=0.05) self.psychometricFunction.plotItem.addItem(pg.InfiniteLine(0.5, 0, 'black')) - self.psychometricPlotDataItems = commonFunctionSettings(self.psychometricFunction, [1, 2]) + self.psychometricPlots = common_function_props(self.psychometricFunction) # chronometric function self.chronometricFunction = pg.PlotWidget(parent=self, background='white') @@ -498,13 +536,44 @@ def commonFunctionSettings(plot_widget: pg.PlotWidget, categories: Sequence[Any] self.chronometricFunction.plotItem.getAxis('left').setLabel('Response Time (s)') self.chronometricFunction.plotItem.setLogMode(x=False, y=True) self.chronometricFunction.plotItem.setYRange(-1, 2, padding=0.05) - self.chronometricPlotDataItems = commonFunctionSettings(self.chronometricFunction, [1, 2]) + self.chronometricPlots = common_function_props(self.chronometricFunction) + + # properties common to all bar charts + def common_bar_chart_props(plot_item: pg.PlotItem): + common_plot_item_props(plot_item) + plot_item.getAxis('left').setWidth(40) + plot_item.getAxis('left').setGrid(128) + plot_item.getAxis('bottom').setLabel(' ') + plot_item.getAxis('bottom').setTicks([[(1, ' ')], []]) + plot_item.getAxis('bottom').setStyle(tickLength=0) + plot_item.setXRange(min=0, max=2, padding=0) + + # performance chart + self.performanceWidget = pg.PlotWidget(parent=self, background='white') + layout.addWidget(self.performanceWidget, 2, 2, 1, 1) + common_bar_chart_props(self.performanceWidget.plotItem) + self.performanceWidget.plotItem.setTitle('Performance', color='k') + self.performanceWidget.plotItem.getAxis('left').setLabel('Performance (%)') + self.performancePlot = pg.BarGraphItem(x=1, width=2, height=0, pen=None, brush='k') + self.performanceWidget.addItem(self.performancePlot) + self.performanceWidget.plotItem.setYRange(0, 105, padding=0) + + # reward chart + self.rewardWidget = pg.PlotWidget(parent=self, background='white') + self.rewardWidget.setMinimumWidth(135) + layout.addWidget(self.rewardWidget, 3, 2, 1, 1) + common_bar_chart_props(self.rewardWidget.plotItem) + self.rewardWidget.plotItem.setTitle('Total Reward', color='k') + self.rewardWidget.plotItem.getAxis('left').setLabel('Reward Amount (μl)') + self.rewardPlot = pg.BarGraphItem(x=1, width=2, height=0, pen=None, brush='b') + self.rewardWidget.addItem(self.rewardPlot) + self.rewardWidget.plotItem.setYRange(0, 1050, padding=0) # bpod data self.bpodWidget = BpodWidget(self, title='Bpod States and Input Channels') self.bpodWidget.setMinimumHeight(130) self.bpodWidget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) - layout.addWidget(self.bpodWidget, 4, 0, 1, 2) + layout.addWidget(self.bpodWidget, 4, 0, 1, 3) self.model.currentTrialChanged.connect(self.updatePlots) self.updatePlots(self.model.nTrials() - 1) @@ -516,6 +585,12 @@ def updatePlots(self, trial: int): self.trials.setCurrentIndex(self.model.table_model.index(trial, 0)) if trial == self.model.table_model.columnCount() - 1: self.trials.scrollToBottom() + for p in self.model.probability_set: + idx = (p, self.model.signed_contrasts) + self.psychometricPlots[p].setData(x=idx[1], y=self.model.psychometrics.loc[idx, 'choice'].to_list()) + self.chronometricPlots[p].setData(x=idx[1], y=self.model.psychometrics.loc[idx, 'response_time'].to_list()) + self.performancePlot.setOpts(height=self.model.percentCorrect()) + self.rewardPlot.setOpts(height=self.model.reward_amount) self.update() def onSelectionChanged(self, selected: QItemSelection, _: QItemSelection): From 6244318942658b3f345f3b2623c4cf2cfa3177ad Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Thu, 19 Dec 2024 11:34:52 +0000 Subject: [PATCH 35/38] add status tips for bar charts --- iblrig/gui/online_plots.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 47b37f8c1..5ed630b53 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -547,13 +547,14 @@ def common_bar_chart_props(plot_item: pg.PlotItem): plot_item.getAxis('bottom').setTicks([[(1, ' ')], []]) plot_item.getAxis('bottom').setStyle(tickLength=0) plot_item.setXRange(min=0, max=2, padding=0) + plot_item.hoverEvent = self.mouseOverBarChart # performance chart self.performanceWidget = pg.PlotWidget(parent=self, background='white') layout.addWidget(self.performanceWidget, 2, 2, 1, 1) common_bar_chart_props(self.performanceWidget.plotItem) self.performanceWidget.plotItem.setTitle('Performance', color='k') - self.performanceWidget.plotItem.getAxis('left').setLabel('Performance (%)') + self.performanceWidget.plotItem.getAxis('left').setLabel('Correct Choices (%)') self.performancePlot = pg.BarGraphItem(x=1, width=2, height=0, pen=None, brush='k') self.performanceWidget.addItem(self.performancePlot) self.performanceWidget.plotItem.setYRange(0, 105, padding=0) @@ -578,6 +579,16 @@ def common_bar_chart_props(plot_item: pg.PlotItem): self.model.currentTrialChanged.connect(self.updatePlots) self.updatePlots(self.model.nTrials() - 1) + def mouseOverBarChart(self, event): + statusbar = self.window().statusBar() + if event.exit: + statusbar.clearMessage() + elif event.currentItem.vb.sceneBoundingRect().contains(event.scenePos()): + if event.currentItem == self.performanceWidget.plotItem: + statusbar.showMessage(f'Performance: {self.model.percentCorrect():0.1f}% correct choices') + else: + statusbar.showMessage(f'Total reward amount: {self.model.reward_amount:0.1f} μl') + @Slot(int) def updatePlots(self, trial: int): self.title.setText(f'Trial {trial}') From 1f045d6b2e5f2aa704d7652d2e1c671b9516478f Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Thu, 19 Dec 2024 16:16:07 +0000 Subject: [PATCH 36/38] Update online_plots.py --- iblrig/gui/online_plots.py | 144 +++++++++++++++++++++++-------------- 1 file changed, 90 insertions(+), 54 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 5ed630b53..01337c0df 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -34,6 +34,8 @@ QSizePolicy, QStyledItemDelegate, QTableView, + QVBoxLayout, + QWidget, ) from iblqt.core import DataFrameTableModel @@ -43,6 +45,39 @@ from iblrig.raw_data_loaders import bpod_session_data_to_dataframe, load_task_jsonable +class PlotWidget(pg.PlotWidget): + """PlotWidget with tuned default settings.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setBackground('white') + self.plotItem.getViewBox().setBackgroundColor(pg.mkColor(250, 250, 250)) + self.plotItem.setMouseEnabled(x=False, y=False) + self.plotItem.setMenuEnabled(False) + self.plotItem.hideButtons() + for axis in ('left', 'bottom'): + self.plotItem.getAxis(axis).setTextPen('k') + + +class SingleBarChart(PlotWidget): + """A bar chart with a single column""" + + def __init__(self, *args, barBrush='k', **kwargs): + super().__init__(*args, **kwargs) + self.plotItem.getAxis('left').setWidth(40) + self.plotItem.getAxis('left').setGrid(128) + self.plotItem.getAxis('bottom').setLabel(' ') + self.plotItem.getAxis('bottom').setTicks([[(1, ' ')], []]) + self.plotItem.getAxis('bottom').setStyle(tickLength=0, tickAlpha=0) + self.plotItem.setXRange(min=0, max=2, padding=0) + self._barGraphItem = pg.BarGraphItem(x=1, width=2, height=0, pen=None, brush=barBrush) + self.addItem(self._barGraphItem) + + @Slot(float) + def setValue(self, value: float): + self._barGraphItem.setOpts(height=value) + + class TrialsTableModel(DataFrameTableModel): """A table model that displays status tips for entries in the trials table.""" @@ -79,9 +114,9 @@ class TrialsTableView(QTableView): def __init__(self, parent: QObject): super().__init__(parent) self.setMouseTracking(True) - self.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel) + # self.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel) self.verticalHeader().hide() - # self.horizontalHeader().hide() + self.horizontalHeader().hide() self.horizontalHeader().setDefaultAlignment(Qt.AlignLeft) self.horizontalHeader().setSectionResizeMode(QHeaderView.Fixed) self.horizontalHeader().setStretchLastSection(True) @@ -119,6 +154,41 @@ def paintEvent(self, event): super().paintEvent(event) +class TrialsWidget(QWidget): + trialSelected = Signal(int) + + def __init__(self, parent: QObject, model: TrialsTableModel): + super().__init__(parent) + self.model = model + + layout = QVBoxLayout(self) + layout.setSpacing(4) + layout.setContentsMargins(0, 8, 0, 36) + self.setLayout(layout) + + self.titleLabel = QLabel('Trials History') + self.titleLabel.setAlignment(Qt.AlignHCenter) + font = self.titleLabel.font() + font.setPointSize(11) + self.titleLabel.setFont(font) + layout.addWidget(self.titleLabel) + + self.table_view = TrialsTableView(self) + self.table_view.setModel(self.model) + self.table_view.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + self.table_view.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) + self.table_view.setColumnHidden(2, True) + self.table_view.setColumnHidden(3, True) + self.table_view.setColumnHidden(4, True) + self.table_view.selectionModel().selectionChanged.connect(self._onSelectionChange) + layout.addWidget(self.table_view) + layout.setStretch(1, 1) + + @Slot(QItemSelection, QItemSelection) + def _onSelectionChange(self, selected: QItemSelection, _deselected: QItemSelection): + self.trialSelected.emit(selected.indexes()[0].row()) + + class OnlinePlotsModel(QObject): currentTrialChanged = Signal(int) _trial_data = pd.DataFrame() @@ -443,7 +513,7 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None self.statusBar().clearMessage() self.setWindowTitle('Online Plots') - self.setMinimumSize(1024, 768) + self.setMinimumSize(1024, 771) self.setWindowIcon(QIcon(QPixmap(':/images/iblrig_logo'))) # the frame that contains all the plots @@ -475,29 +545,13 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None layout.addWidget(subtitle, 1, 0, 1, 3) # trial data - self.trials = TrialsTableView(self) - self.trials.setModel(self.model.table_model) - self.trials.selectionModel().selectionChanged.connect(self.onSelectionChanged) - self.trials.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) - self.trials.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) - self.trials.setColumnHidden(2, True) - self.trials.setColumnHidden(3, True) - self.trials.setColumnHidden(4, True) + self.trials = TrialsWidget(self, self.model.table_model) + self.trials.trialSelected.connect(self.model.setCurrentTrial) layout.addWidget(self.trials, 2, 0, 2, 1) - # properties common to all pyqtgraph plots - def common_plot_item_props(plot_item: pg.PlotItem): - plot_item.getViewBox().setBackgroundColor(pg.mkColor(250, 250, 250)) - plot_item.setMouseEnabled(x=False, y=False) - plot_item.setMenuEnabled(False) - plot_item.hideButtons() - for axis in ('left', 'bottom'): - plot_item.getAxis(axis).setTextPen('k') - # properties common to psychometric/chronometric functions def common_function_props(plot_widget: pg.PlotWidget) -> dict[Any, pg.PlotDataItem]: plot_item = plot_widget.plotItem - common_plot_item_props(plot_item) plot_item.addItem(pg.InfiniteLine(0, 90, 'black')) for axis in ('left', 'bottom'): plot_item.getAxis(axis).setGrid(128) @@ -521,7 +575,7 @@ def common_function_props(plot_widget: pg.PlotWidget) -> dict[Any, pg.PlotDataIt return plot_data_items # psychometric function - self.psychometricFunction = pg.PlotWidget(parent=self, background='white') + self.psychometricFunction = PlotWidget(parent=self) layout.addWidget(self.psychometricFunction, 2, 1, 1, 1) self.psychometricFunction.plotItem.setTitle('Psychometric Function', color='k') self.psychometricFunction.plotItem.getAxis('left').setLabel('Rightward Choices (%)') @@ -530,7 +584,7 @@ def common_function_props(plot_widget: pg.PlotWidget) -> dict[Any, pg.PlotDataIt self.psychometricPlots = common_function_props(self.psychometricFunction) # chronometric function - self.chronometricFunction = pg.PlotWidget(parent=self, background='white') + self.chronometricFunction = PlotWidget(parent=self) layout.addWidget(self.chronometricFunction, 3, 1, 1, 1) self.chronometricFunction.plotItem.setTitle('Chronometric Function', color='k') self.chronometricFunction.plotItem.getAxis('left').setLabel('Response Time (s)') @@ -538,37 +592,22 @@ def common_function_props(plot_widget: pg.PlotWidget) -> dict[Any, pg.PlotDataIt self.chronometricFunction.plotItem.setYRange(-1, 2, padding=0.05) self.chronometricPlots = common_function_props(self.chronometricFunction) - # properties common to all bar charts - def common_bar_chart_props(plot_item: pg.PlotItem): - common_plot_item_props(plot_item) - plot_item.getAxis('left').setWidth(40) - plot_item.getAxis('left').setGrid(128) - plot_item.getAxis('bottom').setLabel(' ') - plot_item.getAxis('bottom').setTicks([[(1, ' ')], []]) - plot_item.getAxis('bottom').setStyle(tickLength=0) - plot_item.setXRange(min=0, max=2, padding=0) - plot_item.hoverEvent = self.mouseOverBarChart - # performance chart - self.performanceWidget = pg.PlotWidget(parent=self, background='white') - layout.addWidget(self.performanceWidget, 2, 2, 1, 1) - common_bar_chart_props(self.performanceWidget.plotItem) + self.performanceWidget = SingleBarChart(parent=self) + self.performanceWidget.setMinimumWidth(155) self.performanceWidget.plotItem.setTitle('Performance', color='k') self.performanceWidget.plotItem.getAxis('left').setLabel('Correct Choices (%)') - self.performancePlot = pg.BarGraphItem(x=1, width=2, height=0, pen=None, brush='k') - self.performanceWidget.addItem(self.performancePlot) self.performanceWidget.plotItem.setYRange(0, 105, padding=0) + self.performanceWidget.plotItem.hoverEvent = self.mouseOverBarChart + layout.addWidget(self.performanceWidget, 2, 2, 1, 1) # reward chart - self.rewardWidget = pg.PlotWidget(parent=self, background='white') - self.rewardWidget.setMinimumWidth(135) - layout.addWidget(self.rewardWidget, 3, 2, 1, 1) - common_bar_chart_props(self.rewardWidget.plotItem) - self.rewardWidget.plotItem.setTitle('Total Reward', color='k') - self.rewardWidget.plotItem.getAxis('left').setLabel('Reward Amount (μl)') - self.rewardPlot = pg.BarGraphItem(x=1, width=2, height=0, pen=None, brush='b') - self.rewardWidget.addItem(self.rewardPlot) + self.rewardWidget = SingleBarChart(parent=self, barBrush='blue') + self.rewardWidget.plotItem.setTitle('Reward Amount', color='k') + self.rewardWidget.plotItem.getAxis('left').setLabel('Total Reward Volume (μl)') self.rewardWidget.plotItem.setYRange(0, 1050, padding=0) + self.rewardWidget.plotItem.hoverEvent = self.mouseOverBarChart + layout.addWidget(self.rewardWidget, 3, 2, 1, 1) # bpod data self.bpodWidget = BpodWidget(self, title='Bpod States and Input Channels') @@ -587,26 +626,23 @@ def mouseOverBarChart(self, event): if event.currentItem == self.performanceWidget.plotItem: statusbar.showMessage(f'Performance: {self.model.percentCorrect():0.1f}% correct choices') else: - statusbar.showMessage(f'Total reward amount: {self.model.reward_amount:0.1f} μl') + statusbar.showMessage(f'Total reward volume: {self.model.reward_amount:0.1f} μl') @Slot(int) def updatePlots(self, trial: int): self.title.setText(f'Trial {trial}') self.bpodWidget.setData(self.model.bpod_data(trial)) - self.trials.setCurrentIndex(self.model.table_model.index(trial, 0)) + self.trials.table_view.setCurrentIndex(self.model.table_model.index(trial, 0)) if trial == self.model.table_model.columnCount() - 1: self.trials.scrollToBottom() for p in self.model.probability_set: idx = (p, self.model.signed_contrasts) self.psychometricPlots[p].setData(x=idx[1], y=self.model.psychometrics.loc[idx, 'choice'].to_list()) self.chronometricPlots[p].setData(x=idx[1], y=self.model.psychometrics.loc[idx, 'response_time'].to_list()) - self.performancePlot.setOpts(height=self.model.percentCorrect()) - self.rewardPlot.setOpts(height=self.model.reward_amount) + self.performanceWidget.setValue(self.model.percentCorrect()) + self.rewardWidget.setValue(self.model.reward_amount) self.update() - def onSelectionChanged(self, selected: QItemSelection, _: QItemSelection): - self.model.setCurrentTrial(selected.indexes()[0].row()) - def keyPressEvent(self, event) -> None: """Navigate trials using directional keys.""" match event.key(): From b48184d158ebd5353f6b216706853c0d6ef545aa Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Thu, 19 Dec 2024 16:18:08 +0000 Subject: [PATCH 37/38] Update online_plots.py --- iblrig/gui/online_plots.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 01337c0df..fc4ae2aec 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -162,8 +162,8 @@ def __init__(self, parent: QObject, model: TrialsTableModel): self.model = model layout = QVBoxLayout(self) - layout.setSpacing(4) - layout.setContentsMargins(0, 8, 0, 36) + layout.setSpacing(5) + layout.setContentsMargins(0, 7, 0, 36) self.setLayout(layout) self.titleLabel = QLabel('Trials History') From 20a1f18921a593f2aa522a8fdcbc90603e5f999e Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Thu, 19 Dec 2024 16:32:50 +0000 Subject: [PATCH 38/38] clean-up layout --- iblrig/gui/online_plots.py | 94 +++++++++++++++++++------------------- 1 file changed, 48 insertions(+), 46 deletions(-) diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index fc4ae2aec..2ec5592b6 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -1,6 +1,7 @@ import ctypes import json import os +from collections.abc import Iterable from pathlib import Path from typing import Any @@ -23,7 +24,6 @@ ) from qtpy.QtGui import QColor, QFont, QIcon, QLinearGradient, QPainter, QPixmap, QTransform from qtpy.QtWidgets import ( - QAbstractItemView, QApplication, QFrame, QGraphicsRectItem, @@ -59,7 +59,7 @@ def __init__(self, *args, **kwargs): self.plotItem.getAxis(axis).setTextPen('k') -class SingleBarChart(PlotWidget): +class SingleBarChartWidget(PlotWidget): """A bar chart with a single column""" def __init__(self, *args, barBrush='k', **kwargs): @@ -78,6 +78,33 @@ def setValue(self, value: float): self._barGraphItem.setOpts(height=value) +class FunctionWidget(PlotWidget): + """A widget for psychometric and chronometric functions""" + + def __init__(self, *args, colors: pg.ColorMap, probabilities: Iterable[float], **kwargs): + super().__init__(*args, **kwargs) + self.plotItem.addItem(pg.InfiniteLine(0, 90, 'black')) + for axis in ('left', 'bottom'): + self.plotItem.getAxis(axis).setGrid(128) + self.plotItem.getAxis(axis).setTextPen('k') + self.plotItem.getAxis('bottom').setLabel('Signed Contrast') + self.plotItem.setXRange(-1, 1, padding=0.05) + legend = pg.LegendItem(pen='lightgray', brush='w', offset=(45, 35), verSpacing=-5, labelTextColor='k') + legend.setParentItem(self.plotItem.graphicsItem()) + legend.setZValue(1) + self.plotDataItems = dict() + for idx, probability in enumerate(probabilities): + self.plotDataItems[probability] = self.plotItem.plot(connect='all') + color = colors.getByIndex(idx) + self.plotDataItems[probability].setData(x=[1, np.NAN], y=[np.NAN, 1]) + self.plotDataItems[probability].setPen(pg.mkPen(color=color, width=2)) + self.plotDataItems[probability].setSymbol('o') + self.plotDataItems[probability].setSymbolPen(color) + self.plotDataItems[probability].setSymbolBrush(color) + self.plotDataItems[probability].setSymbolSize(5) + legend.addItem(self.plotDataItems[probability], f'p = {probability:0.1f}') + + class TrialsTableModel(DataFrameTableModel): """A table model that displays status tips for entries in the trials table.""" @@ -544,56 +571,29 @@ def __init__(self, raw_data_folder: DirectoryPath, parent: QObject | None = None subtitle.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) layout.addWidget(subtitle, 1, 0, 1, 3) - # trial data + # trial history self.trials = TrialsWidget(self, self.model.table_model) self.trials.trialSelected.connect(self.model.setCurrentTrial) layout.addWidget(self.trials, 2, 0, 2, 1) - # properties common to psychometric/chronometric functions - def common_function_props(plot_widget: pg.PlotWidget) -> dict[Any, pg.PlotDataItem]: - plot_item = plot_widget.plotItem - plot_item.addItem(pg.InfiniteLine(0, 90, 'black')) - for axis in ('left', 'bottom'): - plot_item.getAxis(axis).setGrid(128) - plot_item.getAxis(axis).setTextPen('k') - plot_item.getAxis('bottom').setLabel('Signed Contrast') - plot_item.setXRange(-1, 1, padding=0.05) - legend = pg.LegendItem(pen='lightgray', brush='w', offset=(45, 35), verSpacing=-5, labelTextColor='k') - legend.setParentItem(plot_item.graphicsItem()) - legend.setZValue(1) - plot_data_items = dict() - for idx, probability in enumerate(self.model.probability_set): - plot_data_items[probability] = plot_item.plot(connect='all') - color = self.colormap.getByIndex(idx) - plot_data_items[probability].setData(x=[1, np.NAN], y=[np.NAN, 1]) - plot_data_items[probability].setPen(pg.mkPen(color=color, width=2)) - plot_data_items[probability].setSymbol('o') - plot_data_items[probability].setSymbolPen(color) - plot_data_items[probability].setSymbolBrush(color) - plot_data_items[probability].setSymbolSize(5) - legend.addItem(plot_data_items[probability], f'p = {probability:0.1f}') - return plot_data_items - # psychometric function - self.psychometricFunction = PlotWidget(parent=self) - layout.addWidget(self.psychometricFunction, 2, 1, 1, 1) - self.psychometricFunction.plotItem.setTitle('Psychometric Function', color='k') - self.psychometricFunction.plotItem.getAxis('left').setLabel('Rightward Choices (%)') - self.psychometricFunction.plotItem.setYRange(0, 1, padding=0.05) - self.psychometricFunction.plotItem.addItem(pg.InfiniteLine(0.5, 0, 'black')) - self.psychometricPlots = common_function_props(self.psychometricFunction) + self.psychometricWidget = FunctionWidget(parent=self, colors=self.colormap, probabilities=self.model.probability_set) + self.psychometricWidget.plotItem.setTitle('Psychometric Function', color='k') + self.psychometricWidget.plotItem.getAxis('left').setLabel('Rightward Choices (%)') + self.psychometricWidget.plotItem.addItem(pg.InfiniteLine(0.5, 0, 'black')) + self.psychometricWidget.plotItem.setYRange(0, 1, padding=0.05) + layout.addWidget(self.psychometricWidget, 2, 1, 1, 1) # chronometric function - self.chronometricFunction = PlotWidget(parent=self) - layout.addWidget(self.chronometricFunction, 3, 1, 1, 1) - self.chronometricFunction.plotItem.setTitle('Chronometric Function', color='k') - self.chronometricFunction.plotItem.getAxis('left').setLabel('Response Time (s)') - self.chronometricFunction.plotItem.setLogMode(x=False, y=True) - self.chronometricFunction.plotItem.setYRange(-1, 2, padding=0.05) - self.chronometricPlots = common_function_props(self.chronometricFunction) + self.chronometricWidget = FunctionWidget(parent=self, colors=self.colormap, probabilities=self.model.probability_set) + self.chronometricWidget.plotItem.setTitle('Chronometric Function', color='k') + self.chronometricWidget.plotItem.getAxis('left').setLabel('Response Time (s)') + self.chronometricWidget.plotItem.setLogMode(x=False, y=True) + self.chronometricWidget.plotItem.setYRange(-1, 2, padding=0.05) + layout.addWidget(self.chronometricWidget, 3, 1, 1, 1) # performance chart - self.performanceWidget = SingleBarChart(parent=self) + self.performanceWidget = SingleBarChartWidget(parent=self) self.performanceWidget.setMinimumWidth(155) self.performanceWidget.plotItem.setTitle('Performance', color='k') self.performanceWidget.plotItem.getAxis('left').setLabel('Correct Choices (%)') @@ -602,7 +602,7 @@ def common_function_props(plot_widget: pg.PlotWidget) -> dict[Any, pg.PlotDataIt layout.addWidget(self.performanceWidget, 2, 2, 1, 1) # reward chart - self.rewardWidget = SingleBarChart(parent=self, barBrush='blue') + self.rewardWidget = SingleBarChartWidget(parent=self, barBrush='blue') self.rewardWidget.plotItem.setTitle('Reward Amount', color='k') self.rewardWidget.plotItem.getAxis('left').setLabel('Total Reward Volume (μl)') self.rewardWidget.plotItem.setYRange(0, 1050, padding=0) @@ -637,8 +637,10 @@ def updatePlots(self, trial: int): self.trials.scrollToBottom() for p in self.model.probability_set: idx = (p, self.model.signed_contrasts) - self.psychometricPlots[p].setData(x=idx[1], y=self.model.psychometrics.loc[idx, 'choice'].to_list()) - self.chronometricPlots[p].setData(x=idx[1], y=self.model.psychometrics.loc[idx, 'response_time'].to_list()) + self.psychometricWidget.plotDataItems[p].setData(x=idx[1], y=self.model.psychometrics.loc[idx, 'choice'].to_list()) + self.chronometricWidget.plotDataItems[p].setData( + x=idx[1], y=self.model.psychometrics.loc[idx, 'response_time'].to_list() + ) self.performanceWidget.setValue(self.model.percentCorrect()) self.rewardWidget.setValue(self.model.reward_amount) self.update()