diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9fc6ef326..72e3bec08 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: - id: check-xml - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.7 + rev: v0.12.4 hooks: - id: ruff types_or: [ python, pyi ] diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e1bd0117..f67427c24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ Changelog ========= +8.30.0 +------ +* added: Online Plots are stored as PNG and added to Alyx as a Session Note + 8.29.0 ------ * added: GUI settings for changing MAIN_SYNC diff --git a/README.md b/README.md index 430ca44d1..fe845e1b9 100644 --- a/README.md +++ b/README.md @@ -19,4 +19,5 @@ Links [![License](https://img.shields.io/github/license/int-brain-lab/iblrig)](https://github.com/int-brain-lab/iblrig/blob/iblrigv8/LICENSE) [![GitHub tag](https://img.shields.io/github/v/tag/int-brain-lab/iblrig)](https://github.com/int-brain-lab/iblrig/tags) [![GitHub Discussions](https://img.shields.io/github/discussions/int-brain-lab/iblrig)](https://github.com/int-brain-lab/iblrig/discussions) +[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit) diff --git a/iblrig/base_choice_world.py b/iblrig/base_choice_world.py index 5ad0dfdc3..d1ccb9d12 100644 --- a/iblrig/base_choice_world.py +++ b/iblrig/base_choice_world.py @@ -4,8 +4,9 @@ import enum import logging import math +import multiprocessing import random -import subprocess +import shutil import time from pathlib import Path from re import split as re_split @@ -18,11 +19,14 @@ from pydantic import NonNegativeFloat, NonNegativeInt import iblrig.base_tasks +from ibllib.plots.snapshot import Snapshot from iblrig import choiceworld, misc +from iblrig.gui.online_plots import online_plots_app from iblrig.hardware import DTYPE_AMBIENT_SENSOR_BIN, SOFTCODE from iblrig.pydantic_definitions import TrialDataModel from iblutil.io import binary, jsonable from iblutil.util import Bunch +from one.api import OneAlyx from pybpodapi.com.messaging.trial import Trial from pybpodapi.protocol import StateMachine @@ -790,43 +794,77 @@ class ActiveChoiceWorldSession(ChoiceWorldSession): The ActiveChoiceWorldSession is a base class for protocols where the mouse is actively making decisions by turning the wheel. It has the following characteristics - - it is trial based - - it is decision based - - left and right simulus are equiprobable: there is no biased block - - a trial can either be correct / error / no_go depending on the side of the stimulus and the response - - it has a quantifiable performance by computing the proportion of correct trials of passive stimulations protocols or - habituation protocols. + - it is trial based + - it is decision based + - left and right simulus are equiprobable: there is no biased block + - a trial can either be correct / error / no_go depending on the side of the stimulus and the response + - it has a quantifiable performance by computing the proportion of correct trials of passive stimulations protocols or + habituation protocols. The TrainingChoiceWorld, BiasedChoiceWorld are all subclasses of this class """ TrialDataModel = ActiveChoiceWorldTrialData - plot_subprocess: subprocess.Popen | None = None + stop_event = multiprocessing.Event() + plot_process: multiprocessing.Process | None = None def __init__(self, **kwargs): super().__init__(**kwargs) self.trials_table['stim_probability_left'] = np.zeros(NTRIALS_INIT, dtype=np.float64) def _run(self): - # starts online plotting + # start online plotting if self.interactive: log.info('Starting subprocess: online plots') - self.plot_subprocess = subprocess.Popen( - ['view_session', str(self.paths['SESSION_RAW_DATA_FOLDER'])], - stdout=subprocess.DEVNULL, - stderr=subprocess.STDOUT, + self.plot_process = multiprocessing.Process( + target=online_plots_app, + name='online_plots_app', + kwargs={ + 'session': self.paths['SESSION_RAW_DATA_FOLDER'], + 'stop_event': self.stop_event, + 'live': True, + }, ) + self.plot_process.start() + + # run super method super()._run() - def _finalize(self): - if isinstance(self.plot_subprocess, subprocess.Popen) and self.plot_subprocess.poll() is None: - log.info('Terminating subprocess: online plots') - self.plot_subprocess.terminate() - try: - self.plot_subprocess.wait(timeout=5) - except subprocess.TimeoutExpired: + # stop online plotting + if self.interactive: + log.info('Signaling online plots process to exit') + self.stop_event.set() + self.plot_process.join(timeout=5) + if self.plot_process.is_alive(): log.warning('Process did not terminate within 5 seconds - killing it.') - self.plot_subprocess.kill() + self.plot_process.kill() + self.plot_process = None + self.stop_event.clear() + + def register_to_alyx(self) -> dict | None: + # move online plots to session folder + register_snapshot = False + path_plot: Path = self.paths['SESSION_RAW_DATA_FOLDER'] / 'online_plots.png' + suffix = self.paths['SESSION_RAW_DATA_FOLDER'].name[-2:] + if path_plot.exists(): + new_path = self.paths['SESSION_FOLDER'] / f'online_plots_{suffix}.png' + if not new_path.exists(): + register_snapshot = True + shutil.move(path_plot, new_path) + path_plot = new_path + + # call register_to_alyx of super class + session = super().register_to_alyx() + + # register online plot as session note + try: + if register_snapshot and session is not None and isinstance(self.one, OneAlyx) and self.one.alyx.is_logged_in: + snapshot = Snapshot(object_id=session['id'], content_type='session', one=self.one) + snapshot.register_image(image_file=path_plot, text=f'Snapshot of Online Plots #{suffix}', width='orig') + except Exception as e: + log.error('Failed to register online plots as session note', exc_info=e) + + return session def show_trial_log(self, extra_info: dict[str, Any] | None = None, log_level: int = logging.INFO): # construct info dict @@ -893,6 +931,9 @@ def trial_completed(self, bpod_data: dict) -> None: raise e # record the trial's outcome in the trials_table + # NOTE: response_side corresponds to the direction of the wheel turn, i.e. + # - +1 for wheel movement to the right / CW + # - -1 for wheel movement to the left / CCW self.trials_table.at[self.trial_num, 'trial_correct'] = 'correct' in outcome if 'correct' in outcome: self.session_info.NTRIALS_CORRECT += 1 @@ -1094,7 +1135,31 @@ def next_trial(self): position = self.task_params.STIM_POSITIONS[int(np.sign(signed_contrast) == 1)] contrast = np.abs(signed_contrast) - # debiasing: if the previous trial was incorrect, not a no-go and easy + # DEBIASING + # + # Appendix 2 - IBL protocol for mice training: + # + # Repeat trials occur if the response was incorrect and the Gabor patch contrast was easy (>= 50%). On repeat trials + # the previous contrast is repeated and the side on which the Gabor patch is presented is not randomly selected, but + # rather drawn from a normal distribution with a standard deviation of 0.5 and mean of the fraction of the previous 10 + # responses that were 'rightward' (that is, when the wheel was turned clockwise from the point of view of the mouse). + # + # For example, if the last 10 trials were: + # L-R-R-L-R-L-L-R-R-L, where R means 'rightward' responses, the fraction(R) = 5/10 = 0.5 + # + # If the sampled value is strictly less than 0.5, the Gabor patch is presented on the left, otherwise on the right. + # This is a form of soft counter biasing where the more the mouse turns in one direction, the more likely it is that + # this will be incorrect on the next trial, meaning the correct movement would be to the opposite side (again, on + # repeat trials only). + # + # + # A trial qualifies for debiasing if: + # 1. the debiasing flag is set in the task parameters, + # 2. the trial number is greater than or equal to 1 (i.e. not the first trial), + # 3. the training phase is less than 5 (i.e. not the last training phase), + # 4. the previous trial was incorrect, + # 5. the previous trial was not a no-go trial, and + # 6. the previous trial was easy (i.e. contrast >= 0.5). if self.task_params.DEBIAS and self.trial_num >= 1 and self.training_phase < 5: last_contrast = self.trials_table.loc[self.trial_num - 1, 'contrast'] do_debias_trial = ( @@ -1103,19 +1168,27 @@ def next_trial(self): and last_contrast >= 0.5 ) self.trials_table.at[self.trial_num, 'debias_trial'] = do_debias_trial + if do_debias_trial: - # indices of trials that had a response + # Identify indices of trials with valid responses, excluding no-go trials iresponse = np.logical_and(self.trials_table['response_side'].notna(), self.trials_table['response_side'] != 0) iresponse = iresponse.index[iresponse] - # takes the average of right responses over last 10 response trials - average_right = (self.trials_table['response_side'][iresponse[-np.minimum(10, iresponse.size) :]] == 1).mean() - - # the probability of the next stimulus being on the left is a draw from a normal distribution centered - # on the average right with sigma 0.5 - if it is less than 0.5 the next stimulus will be on the left. - position = self.task_params.STIM_POSITIONS[int(np.random.normal(average_right, 0.5) >= 0.5)] - - # contrast is the last contrast + # Calculate the mean movement direction across the last 10 response trials: + # - a value of 0.5 indicates equal proportions of leftward and rightward wheel movements, + # - a value larger than 0.5 indicates a bias towards rightward / CW wheel movements, + # - a value smaller than 0.5 indicates a bias towards leftward / CCW wheel movements. + mean_direction = (self.trials_table['response_side'][iresponse[-np.minimum(10, iresponse.size) :]] == 1).mean() + + # The position of the next stimulus (and hence the required movement direction) is determined by drawing from a + # normal distribution centered on the mean response direction (see above) with a standard deviation of 0.5: + # - if the drawn value is LESS THAN 0.5, the next stimulus will be displayed on the LEFT SIDE of the screen, + # requiring a RIGHTWARD / CW MOVEMENT to center the stimulus (countering a LEFTWARD MOVEMENT BIAS). + # - if the drawn value is GREATER THAN OR EQUAL TO 0.5, the stimulus will be displayed on the RIGHT SIDE of the + # screen, requiring a LEFTWARD / CCW MOVEMENT to center the stimulus (countering a RIGHTWARD MOVEMENT BIAS). + position = self.task_params.STIM_POSITIONS[int(np.random.normal(mean_direction, 0.5) >= 0.5)] + + # The contrast of the debiasing trial is identical to the contrast of the previous trial. contrast = last_contrast else: self.trials_table.at[self.trial_num, 'debias_trial'] = False diff --git a/iblrig/base_tasks.py b/iblrig/base_tasks.py index 2c16d2598..5616114e3 100644 --- a/iblrig/base_tasks.py +++ b/iblrig/base_tasks.py @@ -16,6 +16,7 @@ import sys import time import traceback +import warnings import weakref from abc import ABC, abstractmethod from collections import OrderedDict @@ -47,7 +48,7 @@ from iblutil.spacer import Spacer from iblutil.util import Bunch, flatten, setup_logger from one.alf.io import next_num_folder -from one.api import ONE, OneAlyx +from one.api import ONE, One, OneAlyx from pybpodapi.protocol import StateMachine OSC_CLIENT_IP = '127.0.0.1' @@ -74,14 +75,14 @@ class BaseSession(ABC): # protocol_name: str | None = None # """The name of the task protocol (NB: avoid spaces).""" base_parameters_file: Path | None = None - """Path: A YAML file containing base, default task parameters.""" + """A YAML file containing base, default task parameters.""" is_mock: bool = False """Wether the session is a mock session.""" logger: logging.Logger | None = None """Logger instance used solely to keep track of log level passed to constructor.""" experiment_description: dict = {} """The experiment description.""" - extractor_tasks: list | None = None + _extractor_tasks: list | None = None """An optional list of pipeline task class names to instantiate when preprocessing task data.""" TrialDataModel: type[TrialDataModel] @@ -142,7 +143,6 @@ def __init__( append : bool, optional If True, append to the latest existing session of the same subject for the same day. """ - self.extractor_tasks = getattr(self, 'extractor_tasks', None) self._logger = None self._setup_loggers(level=log_level) if not isinstance(self, EmptySession): @@ -242,6 +242,14 @@ def stopped(self) -> bool: def paused(self) -> bool: return self._pause_flag + @property + def extractor_tasks(self) -> list[str] | None: + return self._extractor_tasks + + @extractor_tasks.setter + def extractor_tasks(self, value: list[str] | None): + self._extractor_tasks = value + def _load_settings( self, file_hardware_settings: Path | str | None = None, @@ -582,11 +590,11 @@ def save_trial_data_to_json(self, bpod_data: dict, validate: bool = True): log.debug(f'Trial data dumped to `{self.paths["DATA_FILE_PATH"].name}`') @property - def one(self): + def one(self) -> OneAlyx | One | None: """ONE getter.""" if self._one is None: if self.iblrig_settings['ALYX_URL'] is None: - return + return None info_str = ( f'alyx client with user name {self.iblrig_settings["ALYX_USER"]} ' + f'and url: {self.iblrig_settings["ALYX_URL"]}' @@ -604,13 +612,13 @@ def one(self): log.error('could not connect to ' + info_str) return self._one - def register_to_alyx(self): + def register_to_alyx(self) -> dict | None: """ Registers the session to Alyx. This registers the session using the IBLRegistrationClient class. This uses the settings file(s) and experiment description file to extract the session data. This may be called - any number of times and if the session record already exists in Alyx it will be updated. + any number of times, and if the session record already exists in Alyx it will be updated. If session registration fails, it will be done before extraction in the ibllib pipeline. Note that currently the subject weight is registered once and only once. The recorded @@ -627,7 +635,7 @@ def register_to_alyx(self): Returns ------- - dict + dict or None The registered session record. See Also @@ -636,31 +644,34 @@ def register_to_alyx(self): """ if self.session_info['SUBJECT_NAME'] in ('iblrig_test_subject', 'test', 'test_subject'): log.warning('Not registering test subject to Alyx') - return + return None if not self.one or self.one.offline: - return + return None try: client = IBLRegistrationClient(self.one) - ses, _ = client.register_session(self.paths.SESSION_FOLDER, register_reward=False) - except Exception: - log.error(traceback.format_exc()) - log.error('Could not register session to Alyx') - return + session, _ = client.register_session(self.paths['SESSION_FOLDER'], register_reward=False) + except Exception as e: + log.error('Could not register session to Alyx', exc_info=e) + return None + # add the water administration if there was water administered try: if self.session_info['TOTAL_WATER_DELIVERED']: - wa = client.register_water_administration( - self.session_info.SUBJECT_NAME, + water_administration_record = client.register_water_administration( + self.session_info['SUBJECT_NAME'], self.session_info['TOTAL_WATER_DELIVERED'] / 1000, - session=ses['url'][-36:], + session=session['url'][-36:], water_type=self.task_params.get('REWARD_TYPE', None), ) - log.info(f'Water administered registered in Alyx database: {ses["subject"]}, {wa["water_administered"]}mL') - except Exception: - log.error(traceback.format_exc()) - log.error('Could not register water administration to Alyx') - return - return ses + log.info( + f'Water administered registered in Alyx database: {session["subject"]},' + f'{water_administration_record["water_administered"]}mL' + ) + except Exception as e: + log.error('Could not register water administration to Alyx', exc_info=e) + return None + + return session def _execute_mixins_shared_function(self, pattern: str) -> None: """ @@ -1175,6 +1186,14 @@ class SoundMixin(BaseSession, HasBpod): """Sound interface methods for state machine.""" def init_mixin_sound(self): + # deprecation warning for Xonar sound card + if self.hardware_settings.device_sound['OUTPUT'] == 'xonar': + warnings.warn( + 'Support for Xonar sound card is deprecated and will be removed in future versions.', + DeprecationWarning, + stacklevel=2, + ) + self.sound = Bunch({'GO_TONE': None, 'WHITE_NOISE': None}) sound_output = self.hardware_settings.device_sound['OUTPUT'] @@ -1189,7 +1208,10 @@ def init_mixin_sound(self): # sound device sd is actually the module soundevice imported above. # not sure how this plays out when referenced outside of this python file - self.sound['sd'], self.sound['samplerate'], self.sound['channels'] = sound_device_factory(output=sound_output) + self.sound['sd'], self.sound['samplerate'], self.sound['channels'] = sound_device_factory( + output=sound_output, + channel_config=self.hardware_settings.device_sound.DEFAULT_CHANNELS, + ) # Create sounds and output actions of state machine self.sound['GO_TONE'] = iblrig.sound.make_sound( rate=self.sound['samplerate'], @@ -1329,7 +1351,7 @@ def __init__(self, *_, remote_rigs=None, **kwargs): raise ex @property - def one(self): + def one(self) -> OneAlyx | One: """Return ONE instance. Unlike super class getter, this method will always instantiate ONE, allowing subclasses to update with an Alyx diff --git a/iblrig/gui/online_plots.py b/iblrig/gui/online_plots.py index 12c787332..de59dc0bd 100644 --- a/iblrig/gui/online_plots.py +++ b/iblrig/gui/online_plots.py @@ -1,4 +1,3 @@ -import ctypes import datetime import json import os @@ -7,13 +6,14 @@ from collections.abc import Iterable from copy import copy from dataclasses import dataclass +from multiprocessing.synchronize import Event as mpEvent from pathlib import Path from typing import Annotated, Any import numpy as np import pandas as pd import pyqtgraph as pg -from pydantic import UUID4, AfterValidator, DirectoryPath, Field, FilePath, PlainSerializer, validate_call +from pydantic import UUID4, AfterValidator, AliasChoices, DirectoryPath, Field, FilePath, PlainSerializer, validate_call from pydantic_settings import BaseSettings, CliPositionalArg from qtpy.QtCore import ( QCoreApplication, @@ -28,6 +28,7 @@ QSize, Qt, QThreadPool, + QTimer, Signal, Slot, ) @@ -87,10 +88,9 @@ class EngagedCriterion: TRIAL_COUNT = 400 -@dataclass -class DefaultSettings: - CONTRAST_SET = np.array([0, 1 / 16, 1 / 8, 1 / 4, 1 / 2, 1]) - PROBABILITY_SET = np.array([0.2, 0.5, 0.8]) +GROUPING_VARIABLE = 'stim_probability_left' # default to splitting by blocks +FILENAME_SETTINGS = Path('_iblrig_taskSettings.raw.json') +FILENAME_DATA = Path('_iblrig_taskData.raw.jsonable') class PlotWidget(pg.PlotWidget): @@ -167,37 +167,42 @@ def _setTextAnchor(self): 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) + def __init__(self, parent: QWidget, colors: pg.ColorMap, grouping_values: Iterable[float], grouping_label: str, **kwargs): + super().__init__(parent=parent, **kwargs) self.plotItem.addItem(pg.InfiniteLine(0, 90, 'black')) + self._colors = colors + self._grouping_label = grouping_label for axis in ('left', 'bottom'): self.plotItem.getAxis(axis).setGrid(128) self.plotItem.getAxis(axis).setTextPen('k') self.plotItem.getAxis('bottom').setLabel('Signed Contrast') - legend = pg.LegendItem(pen='lightgray', brush='w', offset=(45, 35), verSpacing=-5, labelTextColor='k') - legend.setParentItem(self.plotItem.graphicsItem()) - legend.setZValue(1) + self.legend = pg.LegendItem(pen='lightgray', brush='w', offset=(45, 35), verSpacing=-5, labelTextColor='k') + self.legend.setParentItem(self.plotItem.graphicsItem()) + self.legend.setZValue(1) self.plotDataItems = dict() self.upperCurves = dict() self.lowerCurves = dict() self.fillItems = dict() + for key in grouping_values: + self.addFunction(key) + + def addFunction(self, key: str): null_pen = pg.mkPen((0, 0, 0, 0)) - for idx, p in enumerate(probabilities): - line_color = colors.getByIndex(idx) - fill_color = copy(line_color) - fill_color.setAlpha(32) - self.upperCurves[p] = self.plotItem.plot(pen=null_pen) - self.lowerCurves[p] = self.plotItem.plot(pen=null_pen) - self.fillItems[p] = pg.FillBetweenItem(self.upperCurves[p], self.lowerCurves[p], brush=fill_color, pen=null_pen) - self.addItem(self.fillItems[p]) - self.plotDataItems[p] = self.plotItem.plot(connect='all') - self.plotDataItems[p].setData(x=[1, np.NAN], y=[np.NAN, 1]) - self.plotDataItems[p].setPen(pg.mkPen(color=line_color, width=4)) - self.plotDataItems[p].setSymbol('o') - self.plotDataItems[p].setSymbolPen(line_color) - self.plotDataItems[p].setSymbolBrush(line_color.lighter(150)) - self.plotDataItems[p].setSymbolSize(4) - legend.addItem(self.plotDataItems[p], f'p = {p:0.1f}') + line_color = self._colors.getByIndex(len(self.upperCurves)) + fill_color = copy(line_color) + fill_color.setAlpha(32) + self.upperCurves[key] = self.plotItem.plot(pen=null_pen) + self.lowerCurves[key] = self.plotItem.plot(pen=null_pen) + self.fillItems[key] = pg.FillBetweenItem(self.upperCurves[key], self.lowerCurves[key], brush=fill_color, pen=null_pen) + self.addItem(self.fillItems[key]) + self.plotDataItems[key] = self.plotItem.plot(connect='all') + self.plotDataItems[key].setData(x=[1, np.NAN], y=[np.NAN, 1]) + self.plotDataItems[key].setPen(pg.mkPen(color=line_color, width=4)) + self.plotDataItems[key].setSymbol('o') + self.plotDataItems[key].setSymbolPen(line_color) + self.plotDataItems[key].setSymbolBrush(line_color.lighter(150)) + self.plotDataItems[key].setSymbolSize(4) + self.legend.addItem(self.plotDataItems[key], f'{self._grouping_label} = {key:0.1f}') class TrialsTableModel(DataFrameTableModel): @@ -619,17 +624,21 @@ class OnlinePlotsModel(QObject): sessionStringAvailable = Signal(str) tableModel = TrialsTableModel() sessionString = '' - probability_set = DefaultSettings.PROBABILITY_SET - contrast_set = DefaultSettings.CONTRAST_SET _trial_data = pd.DataFrame() _bpod_data: list[pd.DataFrame] = list() _jsonable_offset = 0 _current_trial = 0 @validate_call(config=dict(arbitrary_types_allowed=True)) - def __init__(self, session: FilePath | DirectoryPath | UUID4, parent: QObject | None = None): + def __init__( + self, + session: FilePath | DirectoryPath | UUID4, + grouping_variable: str | None = None, + parent: QObject | None = None, + live: bool = False, + ): super().__init__(parent=parent) - is_live = False + self.is_live = live # If session is a UUID ... if not isinstance(session, Path): @@ -641,13 +650,13 @@ def __init__(self, session: FilePath | DirectoryPath | UUID4, parent: QObject | raise ValueError(f'Could not find session with ID {session}') # load Task Data File - datasets = one.list_datasets(session, filename='*taskData.raw.jsonable') + datasets = one.list_datasets(session, filename=f'*{FILENAME_DATA}') if len(datasets) == 0: raise ValueError(f'Could not find Task Data File for session {session}') session = one.load_dataset(session, datasets[0], download_only=True) # load Task Settings File - datasets = one.list_datasets(session, filename='*_iblrig_taskSettings.raw.json') + datasets = one.list_datasets(session, filename=f'*{FILENAME_SETTINGS}') if len(datasets) > 0: one.load_dataset(session, datasets[0], download_only=True) @@ -656,36 +665,34 @@ def __init__(self, session: FilePath | DirectoryPath | UUID4, parent: QObject | if not session.name.startswith('raw_task_data'): raise ValueError(f'Not a Raw Data Directory: {session}') self.raw_data_folder = session - self.jsonable_file = self.raw_data_folder.joinpath('_iblrig_taskData.raw.jsonable') - self.settings_file = self.raw_data_folder.joinpath('_iblrig_taskSettings.raw.json') + self.jsonable_file = self.raw_data_folder.joinpath(FILENAME_DATA) + self.settings_file = self.raw_data_folder.joinpath(FILENAME_SETTINGS) if not self.jsonable_file.exists(): print('Waiting for data ...') while not self.jsonable_file.exists(): time.sleep(0.2) - is_live = True # If session is a file ... elif session.is_file(): - if not session.name.endswith('.raw.jsonable'): + if not session.name.endswith(''.join(FILENAME_DATA.suffixes)): raise ValueError(f'Not a Task Data File: {session}') self.jsonable_file = session self.raw_data_folder = session.parent - self.settings_file = self.raw_data_folder.joinpath('_iblrig_taskSettings.raw.json') + self.settings_file = self.raw_data_folder.joinpath(FILENAME_SETTINGS) + # load settings json file if self.settings_file.exists(): 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'))) - - self.signed_contrasts = np.r_[-np.flipud(self.contrast_set[1:]), self.contrast_set] + self.grouping_variable = grouping_variable or getattr(self, 'task_settings', {}).get( + 'PLOT_GROUPING_VARIABLE', GROUPING_VARIABLE + ) self.psychometrics = pd.DataFrame( columns=['count', 'response_time', 'choice', 'response_time_std', 'choice_std'], - index=pd.MultiIndex.from_product([self.probability_set, self.signed_contrasts]), + index=pd.MultiIndex(levels=[[], []], codes=[[], []], names=[self.grouping_variable, 'signed_contrast'], dtype=float), + dtype=float, ) - self.psychometrics['count'] = 0 + self.psychometrics['count'] = self.psychometrics['count'].astype('int') self.reward_amount = 0 self._t0 = 0 self._n_trials = 0 @@ -700,7 +707,7 @@ def __init__(self, session: FilePath | DirectoryPath | UUID4, parent: QObject | # read the jsonable file and instantiate a QFileSystemWatcher self.readJsonable(self.jsonable_file) - if is_live: + if self.is_live: self.jsonableWatcher = QFileSystemWatcher([str(self.jsonable_file)], parent=self) self.jsonableWatcher.fileChanged.connect(self.readJsonable) @@ -747,10 +754,15 @@ def readJsonable(self, _: str) -> None: if row.get('response_side') == 0: continue choice = row.position > 0 if row.trial_correct else row.position < 0 - indexer = (row.stim_probability_left, row.signed_contrast) - if indexer not in self.psychometrics.index: - self.psychometrics.loc[indexer, :] = np.nan - self.psychometrics.loc[indexer, 'count'] = 0 + indexer = (row[self.grouping_variable], row.signed_contrast) + if indexer not in self.psychometrics.index: # add row for a new trial type if it's not there yet + new_trial_type = pd.DataFrame( + data=[[0] + [float('nan')] * (len(self.psychometrics.columns) - 1)], + columns=self.psychometrics.columns, + index=pd.MultiIndex.from_tuples([indexer], names=self.psychometrics.index.names), + ) + self.psychometrics = pd.concat([self.psychometrics, new_trial_type]).sort_index(level='signed_contrast') + 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, @@ -848,10 +860,16 @@ def getTitle(self) -> str: class OnlinePlotsView(QMainWindow): colormap = pg.colormap.get('tab10', source='matplotlib') - def __init__(self, session: FilePath | DirectoryPath | UUID4, parent: QObject | None = None): + def __init__( + self, + session: FilePath | DirectoryPath | UUID4, + group_by: str | None = None, + parent: QObject | None = None, + live: bool = False, + ): super().__init__(parent) pg.setConfigOptions(antialias=True) - self.model = OnlinePlotsModel(session, self) + self.model = OnlinePlotsModel(session=session, grouping_variable=group_by, parent=self, live=live) self.statusBar().clearMessage() self.setWindowTitle('Online Plots') @@ -903,7 +921,11 @@ def __init__(self, session: FilePath | DirectoryPath | UUID4, parent: QObject | layout.addWidget(self.trials, 1, 0, 2, 1) # psychometric function - self.psychometricWidget = FunctionWidget(parent=self, colors=self.colormap, probabilities=self.model.probability_set) + grouping_values = np.unique(self.model.psychometrics.index.get_level_values(self.model.grouping_variable)) + grouping_label = self.model.grouping_variable + if grouping_label == 'stim_probability_left': + grouping_label = 'p' + self.psychometricWidget = FunctionWidget(self, self.colormap, grouping_values, grouping_label) 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')) @@ -912,7 +934,7 @@ def __init__(self, session: FilePath | DirectoryPath | UUID4, parent: QObject | layout.addWidget(self.psychometricWidget, 1, 1, 1, 1) # chronometric function - self.chronometricWidget = FunctionWidget(parent=self, colors=self.colormap, probabilities=self.model.probability_set) + self.chronometricWidget = FunctionWidget(self, self.colormap, grouping_values, grouping_label) 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) @@ -990,20 +1012,23 @@ def updatePlots(self, trial: int): self.bpodWidget.setData(self.model.bpod_data(trial)) self.trials.table_view.setCurrentIndex(self.model.tableModel.index(trial, 0)) self.trials.table_view.scrollTo(self.model.tableModel.index(trial, 0)) - for p in self.model.probability_set: - data = self.model.psychometrics.loc[p].dropna(axis=0).astype(float) - x = data.index.to_numpy() + for group_var, data in self.model.psychometrics.groupby(self.model.grouping_variable): + x = data.index.get_level_values('signed_contrast').to_numpy('float') y = data.choice.to_numpy() sqrt_n = np.sqrt(data['count'].to_numpy()) e = data.choice_std.to_numpy() / sqrt_n - self.psychometricWidget.upperCurves[p].setData(x=x, y=y + e) - self.psychometricWidget.lowerCurves[p].setData(x=x, y=y - e) - self.psychometricWidget.plotDataItems[p].setData(x=x, y=y) + if group_var not in self.psychometricWidget.upperCurves: + self.psychometricWidget.addFunction(group_var) + self.psychometricWidget.upperCurves[group_var].setData(x=x, y=y + e) + self.psychometricWidget.lowerCurves[group_var].setData(x=x, y=y - e) + self.psychometricWidget.plotDataItems[group_var].setData(x=x, y=y) y = data.response_time.to_numpy() e = data.response_time_std.to_numpy() / sqrt_n - self.chronometricWidget.upperCurves[p].setData(x=x, y=y + e) - self.chronometricWidget.lowerCurves[p].setData(x=x, y=np.clip(y - e, np.finfo(float).tiny, None)) - self.chronometricWidget.plotDataItems[p].setData(x=x, y=y) + if group_var not in self.chronometricWidget.upperCurves: + self.chronometricWidget.addFunction(group_var) + self.chronometricWidget.upperCurves[group_var].setData(x=x, y=y + e) + self.chronometricWidget.lowerCurves[group_var].setData(x=x, y=np.clip(y - e, np.finfo(float).tiny, None)) + self.chronometricWidget.plotDataItems[group_var].setData(x=x, y=y) self.performanceWidget.setValue(self.model.percentCorrect()) self.rewardWidget.setValue(self.model.reward_amount) self.update() @@ -1035,37 +1060,116 @@ def resizeEvent(self, event): self.settings.setValue('size', self.size()) super().resizeEvent(event) + def closeEvent(self, event): + if self.model.is_live and self.model.raw_data_folder is not None: + self.model.setCurrentTrial(self.model.nTrials() - 1) + filename = self.model.raw_data_folder / 'online_plots.png' + if not filename.exists(): + self.save_as_png(filename) + event.accept() + + def save_as_png(self, filename: os.PathLike | str) -> None: + """Save plot as a PNG file.""" + filename = Path(filename).with_suffix('.png') + img = self.centralWidget().grab() + img.save(str(filename), 'PNG') + def online_plots_cli(*args): + """ + Command-line entry point for launching the IBL Online Plots application. + + This function extends ``sys.argv`` with the provided arguments, parses + them via a Pydantic CLI settings class, and then invokes + :func:`online_plots_app`. + + Parameters + ---------- + *args : Any + Additional arguments to simulate command-line input. These will be + converted to strings and appended to ``sys.argv``. + """ sys.argv.extend([str(arg) for arg in args]) - class CLISettings(BaseSettings, cli_parse_args=True, cli_enforce_required=False, cli_avoid_json=True): + class CLISettings( + BaseSettings, + cli_parse_args=True, + cli_enforce_required=False, + cli_avoid_json=True, + cli_hide_none_type=True, + ): """Display a Session's Online Plot.""" session: CliPositionalArg[FilePath | DirectoryPath | UUID4] = Field(description="a session's Task Data File or eID") + group: str | None = Field( + description='override the data column to group data by', + validation_alias=AliasChoices('g', 'group'), + default=None, + ) + + if len(sys.argv) < 2: + session, group = None, None + else: + cli = CLISettings() + session, group = cli.session, cli.group + online_plots_app(session=session, group=group) + + +def online_plots_app( + session: FilePath | DirectoryPath | UUID4 | None = None, + group: str | None = None, + stop_event: mpEvent | None = None, + live: bool = False, +) -> None: + """ + Launch the IBL Online Plots GUI. + + This function initializes a Qt application, loads the selected session + (or prompts the user to select one if none is provided), and displays the + OnlinePlotsView window. - # set app information + Parameters + ---------- + session : FilePath | DirectoryPath | UUID4, optional + The session data to load. Can be a file path, directory path, UUID, or + ``None``. If ``None``, a file dialog will prompt the user to choose a + Task Data file. + group : str | None, optional + Name of the data column to group by. If ``None``, the default grouping + will be used. + stop_event : multiprocessing.synchronize.Event, optional + Event to signal graceful shutdown. + live : bool, optional + Whether to use live plotting. If ``True``, the GUI will update on + changes. Default is ``False``. + """ QCoreApplication.setOrganizationName('International Brain Laboratory') QCoreApplication.setOrganizationDomain('internationalbrainlab.org') QCoreApplication.setApplicationName('IBLRIG Online Plots') if os.name == 'nt': + import ctypes + app_id = f'IBL.iblrig.online_plots.{iblrig_version}' ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id) app = QApplication([]) - if len(sys.argv) < 2: + if session is None: local_subjects_folder = str(get_local_and_remote_paths()['local_subjects_folder']) session, _ = QFileDialog.getOpenFileName( caption='Select Task Data File', filter='Task Data (*.raw.jsonable)', directory=local_subjects_folder ) if len(session) == 0: return - else: - session = CLISettings().session - window = OnlinePlotsView(session) + + window = OnlinePlotsView(session=session, group_by=group, live=live) window.show() + if stop_event is not None: + timer = QTimer() + timer.timeout.connect(lambda: window.close() if stop_event.is_set() else None) + timer.start(500) + sys.exit(app.exec()) diff --git a/iblrig/gui/splash.py b/iblrig/gui/splash.py index d9958f54b..eab6228ca 100644 --- a/iblrig/gui/splash.py +++ b/iblrig/gui/splash.py @@ -1,67 +1,117 @@ -from qtpy import QtCore -from qtpy.QtCore import QEasingCurve, QPoint, QPropertyAnimation, QThreadPool, QTimer -from qtpy.QtWidgets import QDialog +from qtpy.QtCore import QEasingCurve, QEvent, QPoint, QPropertyAnimation, QSize, Qt, QThreadPool, QTimer +from qtpy.QtGui import QFont, QPalette, QPixmap, QRadialGradient +from qtpy.QtWidgets import QDialog, QHBoxLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget from typing_extensions import override from iblqt.core import Worker from iblrig import __version__ as version from iblrig.constants import COPYRIGHT_YEAR -from iblrig.gui.ui_splash import Ui_splash from iblrig.hardware_validation import Result, get_all_validators from iblrig.path_helper import load_pydantic_yaml from iblrig.pydantic_definitions import HardwareSettings, RigSettings -class Splash(QDialog, Ui_splash): +class Splash(QDialog): validation_results: list[Result] = [] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setupUi(self) - self.setWindowFlags(QtCore.Qt.SplashScreen | QtCore.Qt.FramelessWindowHint) - self.installEventFilter(self) - # store arguments as members self.hardware_settings = load_pydantic_yaml(HardwareSettings) self.rig_settings = load_pydantic_yaml(RigSettings) - # update a few strings - self.labelVersion.setText(f'v{version}') - self.labelCopyright.setText(f'© {COPYRIGHT_YEAR}, International Brain Laboratory') + # window properties + self.setWindowModality(Qt.ApplicationModal) + self.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) + self.setMinimumSize(QSize(350, 400)) + self.setMaximumSize(QSize(350, 400)) + self.setWindowFlags(Qt.SplashScreen | Qt.FramelessWindowHint) + self.installEventFilter(self) - # extremely important animation - self.hat.setProperty('pos', QPoint(0, -250)) - self.animation = QPropertyAnimation(self.hat, b'pos') - self.animation.setEasingCurve(QEasingCurve.InQuad) - self.animation.setEndValue(QPoint(0, 40)) - self.animation.setDuration(500) - self.animation.start() + # background gradient + gradient = QRadialGradient(0.5, 0.45, 0.5, 0.5, 0.45) + gradient.setColorAt(0.0, QPalette().window().color().lighter(110)) + gradient.setColorAt(1.0, QPalette().window().color().darker(115)) + gradient.setCoordinateMode(gradient.ObjectMode) + p = QPalette() + p.setBrush(QPalette.Window, gradient) + self.setPalette(p) + + # logo + horizontal_widget = QWidget(self) + widget_logo = QWidget(horizontal_widget) + widget_logo.setMinimumSize(QSize(250, 240)) + widget_logo.setMaximumSize(QSize(250, 16777215)) + logo = QLabel(widget_logo) + logo.setGeometry(50, 100, 150, 150) + logo.setPixmap(QPixmap(':/images/logo_ibl')) + logo.setScaledContents(True) + hat = QLabel(widget_logo) + hat.setGeometry(0, 50, 150, 150) + hat.setPixmap(QPixmap(':/images/iblrig_logo')) + hat.setScaledContents(True) + + # text labels + label_rig = QLabel('IBLRIG Wizard', self) + label_rig.setFont(QFont('Arial', 15, 75, False)) + label_rig.setAlignment(Qt.AlignCenter) + label_version = QLabel(f'v{version}', self) + label_version.setAlignment(Qt.AlignCenter) + label_copyright = QLabel(f'© {COPYRIGHT_YEAR}, International Brain Laboratory', self) + label_copyright.setAlignment(Qt.AlignCenter) + + # system validation status + widget_status = QWidget(self) + widget_status.setAutoFillBackground(False) + widget_status.setStyleSheet('background-color: rgb(255, 255, 255);') + layout_status = QHBoxLayout(widget_status) + layout_status.setContentsMargins(9, 2, 9, 2) + self.label_status = QLabel(widget_status) + layout_status.addWidget(self.label_status) - # start timer for force close - QTimer.singleShot(20000, self.stop_and_close) + # layout + horizontal_layout = QHBoxLayout(horizontal_widget) + horizontal_layout.addStretch(1) + horizontal_layout.addWidget(widget_logo) + horizontal_layout.addStretch(1) + vertical_layout = QVBoxLayout(self) + vertical_layout.setContentsMargins(0, 0, 0, 0) + vertical_layout.setSpacing(0) + vertical_layout.addWidget(horizontal_widget) + vertical_layout.addSpacing(30) + vertical_layout.addWidget(label_rig) + vertical_layout.addWidget(label_version) + vertical_layout.addStretch(40) + vertical_layout.addWidget(widget_status) + vertical_layout.addSpacing(20) + vertical_layout.addWidget(label_copyright) + vertical_layout.addSpacing(5) + self.setLayout(vertical_layout) # start validation worker worker = Worker(self.validation) worker.signals.finished.connect(self.close) QThreadPool.globalInstance().tryStart(worker) + QTimer.singleShot(20000, self.close) # max wait time + + # extremely important animation + hat.setProperty('pos', QPoint(0, -250)) + self.animation = QPropertyAnimation(hat, b'pos') + self.animation.setEasingCurve(QEasingCurve.InQuad) + self.animation.setEndValue(QPoint(0, 40)) + self.animation.setDuration(500) self.show() + self.animation.start() def validation(self): for validator in get_all_validators(): validator_instance = validator(hardware_settings=self.hardware_settings, iblrig_settings=self.rig_settings) - self.labelStatus.setText(f'Validating {validator_instance.name} ...') + self.label_status.setText(f'Validating {validator_instance.name} ...') for result in validator_instance.run(): self.validation_results.append(result) - def stop_and_close(self): - self.close() - - @override - def close(self): - super().close() - @override def eventFilter(self, obj, event): """Disregard all key-presses.""" - return obj is self and event.type() == QtCore.QEvent.KeyPress + return obj is self and event.type() == QEvent.KeyPress diff --git a/iblrig/gui/tab_about.py b/iblrig/gui/tab_about.py index 7fd8a56be..3bb9491d4 100644 --- a/iblrig/gui/tab_about.py +++ b/iblrig/gui/tab_about.py @@ -1,34 +1,74 @@ import webbrowser -from qtpy.QtCore import QThreadPool -from qtpy.QtWidgets import QWidget +from qtpy.QtCore import QSize, Qt, QThreadPool +from qtpy.QtGui import QIcon, QPixmap +from qtpy.QtWidgets import QCommandLinkButton, QGridLayout, QLabel, QSizePolicy, QSpacerItem, QWidget from iblqt.core import Worker from iblrig import __version__ as iblrig_version from iblrig.constants import COPYRIGHT_YEAR, URL_DISCUSSION, URL_DOC, URL_ISSUES, URL_REPO -from iblrig.gui.ui_tab_about import Ui_TabAbout +from iblrig.gui import resources_rc # noqa: F401 from iblrig.tools import get_anydesk_id -class TabAbout(QWidget, Ui_TabAbout): +class TabAbout(QWidget): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setupUi(self) - # set version & copyright strings - self.uiLabelCopyright.setText(f'**IBLRIG v{iblrig_version}**\n\n© {COPYRIGHT_YEAR}, International Brain Laboratory') + # logo + label_logo = QLabel('', self) + label_logo.setMaximumSize(QSize(90, 90)) + label_logo.setPixmap(QPixmap(':/images/iblrig_logo')) + label_logo.setScaledContents(True) - # define actions for command link buttons - self.commandLinkButtonGitHub.clicked.connect(lambda: webbrowser.open(URL_REPO)) - self.commandLinkButtonDoc.clicked.connect(lambda: webbrowser.open(URL_DOC)) - self.commandLinkButtonIssues.clicked.connect(lambda: webbrowser.open(URL_ISSUES)) - self.commandLinkButtonDiscussion.clicked.connect(lambda: webbrowser.open(URL_DISCUSSION)) + # copyright label + label_copyright = QLabel(f'**IBLRIG v{iblrig_version}**\n\n© {COPYRIGHT_YEAR}, International Brain Laboratory', self) + label_copyright.setTextFormat(Qt.MarkdownText) + label_copyright.setAlignment(Qt.AlignCenter) - # try to obtain AnyDesk ID + # command link buttons + def create_button(text: str, icon: str, tooltip: str, url: str) -> QCommandLinkButton: + button = QCommandLinkButton(text, self) + button.setIcon(QIcon(f':/images/{icon}')) + button.setToolTip(f'Open the IBLRIG {tooltip}') + button.clicked.connect(lambda: webbrowser.open(url)) + return button + + button_doc = create_button('&Documentation', 'help', 'documentation', URL_DOC) + button_github = create_button('&GitHub', 'github', 'GitHub repository', URL_REPO) + button_discussion = create_button('Discussion &Board', 'discussion', 'discussion board', URL_DISCUSSION) + button_issues = create_button('&Issue Tracker', 'bug', 'issue tracker', URL_ISSUES) + + # anydesk label + self.label_anydesk = QLabel('', self) + self.label_anydesk.setAlignment(Qt.AlignCenter) worker = Worker(get_anydesk_id, silent=True) - worker.signals.result.connect(self.onGetAnydeskResult) + worker.signals.result.connect(self._on_get_anydesk_result) QThreadPool.globalInstance().tryStart(worker) - def onGetAnydeskResult(self, result: str | None) -> None: + # spacer items + horizontal_spacer_1 = QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Minimum) + horizontal_spacer_2 = QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Minimum) + horizontal_spacer_3 = QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Minimum) + horizontal_spacer_4 = QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Minimum) + + # grid layout + grid_layout = QGridLayout(self) + grid_layout.setRowStretch(0, 3) + grid_layout.addItem(horizontal_spacer_1, 1, 0, 1, 2) + grid_layout.addWidget(label_logo, 1, 2, 1, 1) + grid_layout.addItem(horizontal_spacer_2, 1, 3, 1, 2) + grid_layout.addWidget(label_copyright, 2, 0, 1, 5) + grid_layout.setRowStretch(3, 3) + grid_layout.addItem(horizontal_spacer_3, 4, 0, 4, 1) + grid_layout.addWidget(button_doc, 4, 1, 1, 3) + grid_layout.addWidget(button_github, 5, 1, 1, 3) + grid_layout.addWidget(button_discussion, 6, 1, 1, 3) + grid_layout.addWidget(button_issues, 7, 1, 1, 3) + grid_layout.addItem(horizontal_spacer_4, 4, 4, 4, 1) + grid_layout.setRowStretch(8, 4) + grid_layout.addWidget(self.label_anydesk, 9, 0, 1, 5) + + def _on_get_anydesk_result(self, result: str | None) -> None: if result is not None: - self.uiLabelAnyDesk.setText(f'Your AnyDesk ID: {result}') + self.label_anydesk.setText(f'Your AnyDesk ID: {result}') diff --git a/iblrig/gui/tab_data.py b/iblrig/gui/tab_data.py index aed936bc6..824184ac9 100644 --- a/iblrig/gui/tab_data.py +++ b/iblrig/gui/tab_data.py @@ -15,13 +15,22 @@ Signal, Slot, ) -from qtpy.QtWidgets import QHeaderView, QStyledItemDelegate, QWidget +from qtpy.QtWidgets import ( + QAbstractItemView, + QHBoxLayout, + QHeaderView, + QLineEdit, + QPushButton, + QStyledItemDelegate, + QTableView, + QVBoxLayout, + QWidget, +) from iblqt.core import DataFrameTableModel -from iblrig.gui.ui_tab_data import Ui_TabData from iblrig.path_helper import get_local_and_remote_paths from iblrig.transfer_experiments import CopyState, SessionCopier -from iblutil.util import dir_size +from iblutil.util import dir_size, format_bytes if platform.system() == 'Windows': from os import startfile @@ -37,14 +46,6 @@ SESSIONS_GLOB = r'*/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/[0-9][0-9][0-9]/' -def sizeof_fmt(num, suffix='B'): - for unit in ('', 'K', 'M', 'G', 'T', 'P', 'E', 'Z'): - if abs(num) < 1024.0: - return f'{num:3.1f} {unit}{suffix}' - num /= 1024.0 - return f'{num:.1f} Y{suffix}' - - class Column(NamedTuple): name: str hidden: bool = False @@ -66,68 +67,103 @@ def initStyleOption(self, option, index): super().initStyleOption(option, index) header_text = index.model().headerData(index.column(), Qt.Horizontal, Qt.DisplayRole) if 'Size' in header_text: - option.text = sizeof_fmt(index.data()) + data = index.data() + option.text = format_bytes(int(data)) if isinstance(data, float) else '' option.displayAlignment = Qt.AlignRight | Qt.AlignVCenter -class TabData(QWidget, Ui_TabData): +class TabData(QWidget): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setupUi(self) + self.settings = QSettings() - self.localSubjectsPath = get_local_and_remote_paths().local_subjects_folder - self.remoteSubjectsPath = get_local_and_remote_paths().remote_subjects_folder + self.local_subjects_path = get_local_and_remote_paths()['local_subjects_folder'] + self.remote_subjects_path = get_local_and_remote_paths()['remote_subjects_folder'] # create empty DataFrameTableModel data = pd.DataFrame(None, index=[], columns=[c.name for c in COLUMNS]) - self.tableModel = DataFrameTableModel(dataFrame=data) + self.table_model = DataFrameTableModel(dataFrame=data) # create filter proxy & assign it to view - self.tableProxy = QSortFilterProxyModel() - self.tableProxy.setSourceModel(self.tableModel) - self.tableProxy.setFilterKeyColumn(1) - - # define view - self.tableView.setModel(self.tableProxy) - header = self.tableView.horizontalHeader() - header.setDefaultAlignment(Qt.AlignLeft) - for idx, column in enumerate(COLUMNS): - self.tableView.setColumnHidden(idx, column.hidden) - if not column.hidden: - if column.resizeMode == QHeaderView.Fixed: - header.resizeSection(idx, column.sectionWidth) - else: - header.setSectionResizeMode(idx, column.resizeMode) - self.tableView.setItemDelegate(DataItemDelegate(self.tableView)) - self.tableView.sortByColumn( + self.table_proxy = QSortFilterProxyModel() + self.table_proxy.setSourceModel(self.table_model) + self.table_proxy.setFilterKeyColumn(1) + + # define worker for assembling data + self.data_worker = DataWorker(self, self.table_model) + self.data_worker.initialized.connect(self.table_model.setDataFrame) + self.data_worker.update.connect(self.table_model.setData) + self.data_worker.started.connect(lambda: button_update.setEnabled(False)) + self.data_worker.lazyLoadComplete.connect(lambda: button_update.setEnabled(True)) + + # define table view + self.table_view = QTableView(self) + self.table_view.setModel(self.table_proxy) + self.table_view.doubleClicked.connect(self._open_dir) + self.table_view.setToolTip('Double-click a row to open the respective folder') + self.table_view.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table_view.setSelectionMode(QAbstractItemView.SingleSelection) + self.table_view.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table_view.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel) + self.table_view.setTabKeyNavigation(False) + self.table_view.setProperty('showDropIndicator', False) + self.table_view.setShowGrid(False) + self.table_view.setSortingEnabled(True) + self.table_view.setWordWrap(False) + self.table_view.setItemDelegate(DataItemDelegate(self.table_view)) + self.table_view.sortByColumn( self.settings.value('sortColumn', [c.name for c in COLUMNS].index('Date'), int), self.settings.value('sortOrder', Qt.AscendingOrder, Qt.SortOrder), ) - # define worker for assembling data - self.dataWorker = DataWorker(self) - - # connect signals to slots - self.dataWorker.initialized.connect(self.tableModel.setDataFrame) - self.dataWorker.update.connect(self.tableModel.setData) - self.dataWorker.started.connect(lambda: self.pushButtonUpdate.setEnabled(False)) - self.dataWorker.lazyLoadComplete.connect(lambda: self.pushButtonUpdate.setEnabled(True)) - self.tableView.doubleClicked.connect(self._openDir) - self.tableView.horizontalHeader().sectionClicked.connect(self._storeSort) - self.pushButtonUpdate.clicked.connect(self.dataWorker.start) - self.lineEditFilter.textChanged.connect(self._filter) + # define table headers + horizontal_header = self.table_view.horizontalHeader() + horizontal_header.setCascadingSectionResizes(True) + horizontal_header.setHighlightSections(False) + horizontal_header.sectionClicked.connect(self._store_sort) + horizontal_header.setDefaultAlignment(Qt.AlignLeft) + for idx, column in enumerate(COLUMNS): + self.table_view.setColumnHidden(idx, column.hidden) + if not column.hidden: + if column.resizeMode == QHeaderView.Fixed: + horizontal_header.resizeSection(idx, column.sectionWidth) + else: + horizontal_header.setSectionResizeMode(idx, column.resizeMode) + self.table_view.verticalHeader().setVisible(False) + + # define remaining widgets + edit_filter = QLineEdit(self) + edit_filter.setToolTip('Filter table by subject') + edit_filter.setPlaceholderText('Filter by Subject') + edit_filter.textChanged.connect(self._filter) + button_update = QPushButton(self) + button_update.setToolTip('Update table') + button_update.setText('Update') + button_update.clicked.connect(self.data_worker.start) + + # define layout + horizontal_widget = QWidget(self) + horizontal_layout = QHBoxLayout(horizontal_widget) + horizontal_layout.setContentsMargins(0, 0, 0, 0) + horizontal_layout.addWidget(edit_filter) + horizontal_layout.addStretch(1) + horizontal_layout.addWidget(button_update) + vertical_layout = QVBoxLayout(self) + vertical_layout.addWidget(self.table_view) + vertical_layout.addWidget(horizontal_widget) @Slot(str) def _filter(self, text: str): - self.tableProxy.setFilterRegExp(QRegExp(text, Qt.CaseInsensitive)) + self.table_proxy.setFilterRegExp(QRegExp(text, Qt.CaseInsensitive)) def showEvent(self, a0): - if self.tableModel.rowCount() == 0: - self.dataWorker.start() + if self.table_model.rowCount() == 0: + self.data_worker.initialize() @Slot(QModelIndex) - def _openDir(self, index: QModelIndex): - directory = self.tableView.model().itemData(index.siblingAtColumn(0))[0] + def _open_dir(self, index: QModelIndex): + source_index = self.table_proxy.mapToSource(index) + directory = self.table_model.itemData(source_index.siblingAtColumn(0))[0] if platform.system() == 'Windows': startfile(directory) elif platform.system() == 'Darwin': @@ -136,9 +172,9 @@ def _openDir(self, index: QModelIndex): subprocess.Popen(['xdg-open', directory]) @Slot(int) - def _storeSort(self, index: int): - self.settings.setValue('sortColumn', self.tableView.horizontalHeader().sortIndicatorSection()) - self.settings.setValue('sortOrder', self.tableView.horizontalHeader().sortIndicatorOrder()) + def _store_sort(self, index: int): + self.settings.setValue('sortColumn', self.table_view.horizontalHeader().sortIndicatorSection()) + self.settings.setValue('sortOrder', self.table_view.horizontalHeader().sortIndicatorOrder()) class DataWorker(QThread): @@ -146,16 +182,18 @@ class DataWorker(QThread): update = Signal(QModelIndex, object) lazyLoadComplete = Signal() - def __init__(self, parent: TabData): + def __init__(self, parent: TabData, model: DataFrameTableModel): super().__init__(parent) - self.localSubjectsPath = parent.localSubjectsPath - self.remoteSubjectsPath = parent.remoteSubjectsPath - self.tableModel = parent.tableModel - self.tableModel.modelReset.connect(self.lazyLoadStatus) - - def run(self): + self.local_subjects_path = parent.local_subjects_path + self.remote_subjects_path = parent.remote_subjects_path + self.table_model = model + self.col_size = self.table_model.getDataFrame().columns.get_loc('Size') + self.col_status = self.table_model.getDataFrame().columns.get_loc('Copy Status') + self.table_model.modelReset.connect(self.start) + + def initialize(self): data = [] - for session_dir in self.localSubjectsPath.glob(SESSIONS_GLOB): + for session_dir in self.local_subjects_path.glob(SESSIONS_GLOB): # make sure we're dealing with a directory if not session_dir.is_dir(): continue @@ -172,17 +210,20 @@ def run(self): session_dir.parents[1].name, QDateTime.fromTime_t(int(date.timestamp())), '', # will be lazy-loaded in a separate step - float(dir_size(session_dir)), + '', # will be lazy-loaded in a separate step ] ) data = pd.DataFrame(data=data, columns=[c.name for c in COLUMNS]) self.initialized.emit(data) + self.start() - def lazyLoadStatus(self): - col_status = self.tableModel.dataFrame.columns.get_loc('Copy Status') - for row, row_data in self.tableModel.dataFrame.iterrows(): - state = SessionCopier(row_data['Directory'], remote_subjects_folder=self.remoteSubjectsPath).state + def run(self): + for row, row_data in self.table_model.getDataFrame().iterrows(): + index = self.table_model.index(row, self.col_size) + size = float(dir_size(row_data['Directory'])) + self.update.emit(index, size) + state = SessionCopier(row_data['Directory'], remote_subjects_folder=self.remote_subjects_path).state state = COPY_STATE_STRINGS.get(state, 'N/A') - index = self.tableModel.index(row, col_status) + index = self.table_model.index(row, self.col_status) self.update.emit(index, state) self.lazyLoadComplete.emit() diff --git a/iblrig/gui/tab_log.py b/iblrig/gui/tab_log.py index 6bfae5f7d..2a5d5201f 100644 --- a/iblrig/gui/tab_log.py +++ b/iblrig/gui/tab_log.py @@ -1,36 +1,113 @@ -from qtpy.QtCore import QSettings, QTimer, Signal, Slot -from qtpy.QtGui import QBrush, QColorConstants, QFont -from qtpy.QtWidgets import QApplication, QWidget - -from iblrig.gui.ui_tab_log import Ui_TabLog - - -class TabLog(QWidget, Ui_TabLog): +from qtpy.QtCore import QSettings, Qt, QTimer, Signal, Slot +from qtpy.QtGui import QBrush, QColorConstants, QFont, QIcon +from qtpy.QtWidgets import ( + QAbstractSpinBox, + QApplication, + QFrame, + QGroupBox, + QHBoxLayout, + QLabel, + QPlainTextEdit, + QPushButton, + QSizePolicy, + QSpinBox, + QSplitter, + QVBoxLayout, + QWidget, +) + +from iblrig.gui import resources_rc # noqa: F401 + + +class TabLog(QWidget): _narrative = b'' narrativeUpdated = Signal(bytes) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setupUi(self) self.settings = QSettings() + # splitter widget + splitter = QSplitter(Qt.Vertical, self) + splitter.setFrameShape(QFrame.NoFrame) + splitter.setHandleWidth(12) + splitter.setChildrenCollapsible(False) + + # upper group box (log) + group_box_log = QGroupBox('Session Log', splitter) + size_policy_log = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) + size_policy_log.setVerticalStretch(3) + group_box_log.setSizePolicy(size_policy_log) + group_box_log.setMinimumHeight(200) + + # lower group box (session narrative) + group_box_narrative = QGroupBox('Session Narrative', splitter) + size_policy_narrative = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) + size_policy_narrative.setVerticalStretch(1) + group_box_narrative.setSizePolicy(size_policy_narrative) + + # read-only edit field for log + self.plainTextEditLog = QPlainTextEdit(group_box_log) + self.plainTextEditLog.setStyleSheet('QPlainTextEdit {background-color: rgb(0, 0, 0)};') + self.plainTextEditLog.setLineWrapMode(QPlainTextEdit.NoWrap) + self.plainTextEditLog.setReadOnly(True) font = QFont('Monospace') font.setStyleHint(QFont.TypeWriter) self.plainTextEditLog.setFont(font) - self.pushButtonClipboard.setEnabled(False) - self.pushButtonClipboard.clicked.connect(self.copyToClipboard) - - self.spinBoxFontSize.valueChanged.connect(self.setFontSize) - self.spinBoxFontSize.setValue(self.settings.value('font_size', 11, int)) - - self.plainTextEditNarrative.textChanged.connect(self.narrativeChanged) - self.narrativeTimer = QTimer() + # toolbar for upper group box + toolbar_widget = QWidget(group_box_log) + spin_box_font_size = QSpinBox(toolbar_widget) + spin_box_font_size.setAlignment(Qt.AlignRight | Qt.AlignTrailing | Qt.AlignVCenter) + spin_box_font_size.setButtonSymbols(QAbstractSpinBox.PlusMinus) + spin_box_font_size.setAccelerated(False) + spin_box_font_size.setMinimum(7) + spin_box_font_size.setMaximum(99) + spin_box_font_size.setProperty('value', 11) + spin_box_font_size.valueChanged.connect(self._set_font_size) + spin_box_font_size.setValue(self.settings.value('font_size', 11, int)) + spin_box_font_size.setToolTip("Set the log's font size") + label_font_size = QLabel('&Font Size', toolbar_widget) + label_font_size.setToolTip("Set the log's font size") + label_font_size.setBuddy(spin_box_font_size) + self.button_clipboard = QPushButton(' &Copy', toolbar_widget) + self.button_clipboard.setIcon(QIcon(':/images/clipboard')) + self.button_clipboard.setToolTip('Copy log to clipboard') + self.button_clipboard.setEnabled(False) + self.button_clipboard.clicked.connect(self._copy_to_clipboard) + + # edit field for narrative + self.plainTextEditNarrative = QPlainTextEdit(group_box_narrative) + self.plainTextEditNarrative.setPlaceholderText('Enter your obvservations here ...') + self.plainTextEditNarrative.textChanged.connect(self._narrative_changed) + self.plainTextEditNarrative.setStyleSheet('QPlainTextEdit {background-color: rgb(0, 0, 0)};') + + # assemble layouts and widgets + main_layout = QVBoxLayout(self) + main_layout.addWidget(splitter) + layout_log = QVBoxLayout(group_box_log) + layout_log.addWidget(self.plainTextEditLog) + layout_log.addWidget(toolbar_widget) + layout_toolbar = QHBoxLayout(toolbar_widget) + layout_toolbar.setContentsMargins(0, 0, 0, 0) + layout_toolbar.addWidget(label_font_size) + layout_toolbar.addWidget(spin_box_font_size) + layout_toolbar.addStretch(1) + layout_toolbar.addWidget(self.button_clipboard) + layout_narrative = QVBoxLayout(group_box_narrative) + layout_narrative.addWidget(self.plainTextEditNarrative) + layout_splitter = QVBoxLayout(splitter) + layout_splitter.setContentsMargins(0, 0, 0, 0) + layout_splitter.addWidget(group_box_log) + layout_splitter.addWidget(group_box_narrative) + + # timer + self.narrativeTimer = QTimer(self) self.narrativeTimer.setSingleShot(True) self.narrativeTimer.timeout.connect(self.narrativeTimerTimeout) @Slot() - def narrativeChanged(self): + def _narrative_changed(self): self.narrativeTimer.start(5000) @Slot() @@ -43,7 +120,7 @@ def narrativeTimerTimeout(self): @Slot() def clear(self): """Clear the log.""" - self.pushButtonClipboard.setEnabled(False) + self.button_clipboard.setEnabled(False) self.plainTextEditLog.clear() @Slot(str, str) @@ -57,45 +134,45 @@ def appendText(self, text: str, color: str = 'White'): The text to append. color : str, optional The color of the text. Should be a valid color name recognized by - QtGui.QColorConstants. Defaults to 'White'. + QColorConstants. Defaults to 'White'. """ - self.pushButtonClipboard.setEnabled(True) - self.setLogColor(color) + self.button_clipboard.setEnabled(True) + self._set_log_color(color) self.plainTextEditLog.appendPlainText(text) @Slot() - def copyToClipboard(self): + def _copy_to_clipboard(self): """Copy the log contents to the clipboard as a markdown code-block.""" - text = f'"""\n{self.plainTextEditLog.toPlainText()}\n"""' + text = f'```\n{self.plainTextEditLog.toPlainText()}\n```' QApplication.clipboard().setText(text) @Slot(int) - def setFontSize(self, fontSize: int): + def _set_font_size(self, font_size: int): """ Set the font size of the log-widget's contents. Parameters ---------- - fontSize : int + font_size : int Font size of the log-widget's contents in points. """ font = self.plainTextEditLog.font() - font.setPointSize(fontSize) + font.setPointSize(font_size) self.plainTextEditLog.setFont(font) - self.settings.setValue('font_size', fontSize) + self.settings.setValue('font_size', font_size) - def setLogColor(self, colorName: str): + def _set_log_color(self, color_name: str): """ Set the foreground color of characters in the log widget. Parameters ---------- - colorName : str, optional + color_name : str, optional The name of the color to set. Default is 'White'. Should be a valid color name - recognized by QtGui.QColorConstants. If the provided color name is not found, - it defaults to QtGui.QColorConstants.White. + recognized by QColorConstants. If the provided color name is not found, + it defaults to QColorConstants.White. """ - color = getattr(QColorConstants, colorName, QColorConstants.White) + color = getattr(QColorConstants, color_name, QColorConstants.White) char_format = self.plainTextEditLog.currentCharFormat() char_format.setForeground(QBrush(color)) self.plainTextEditLog.setCurrentCharFormat(char_format) diff --git a/iblrig/gui/ui_splash.py b/iblrig/gui/ui_splash.py deleted file mode 100644 index 266c385dd..000000000 --- a/iblrig/gui/ui_splash.py +++ /dev/null @@ -1,131 +0,0 @@ -# -*- coding: utf-8 -*- - -# Form implementation generated from reading ui file 'iblrig\gui\ui_splash.ui' -# -# Created by: PyQt5 UI code generator 5.15.10 -# -# WARNING: Any manual changes made to this file will be lost when pyuic5 is -# run again. Do not edit this file unless you know what you are doing. - - -from PyQt5 import QtCore, QtGui, QtWidgets - - -class Ui_splash(object): - def setupUi(self, splash): - splash.setObjectName("splash") - splash.setWindowModality(QtCore.Qt.ApplicationModal) - splash.resize(350, 400) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(splash.sizePolicy().hasHeightForWidth()) - splash.setSizePolicy(sizePolicy) - splash.setMinimumSize(QtCore.QSize(350, 400)) - splash.setMaximumSize(QtCore.QSize(350, 400)) - self.verticalLayout = QtWidgets.QVBoxLayout(splash) - self.verticalLayout.setContentsMargins(0, 0, 0, 0) - self.verticalLayout.setSpacing(0) - self.verticalLayout.setObjectName("verticalLayout") - self.containerWidget = QtWidgets.QWidget(splash) - self.containerWidget.setStyleSheet("QWidget#containerWidget { background-color: qradialgradient(spread:pad, cx:0.5, cy:0.5, radius:0.5, fx:0.5, fy:0.5, stop:0 rgba(255, 255, 255, 0), stop:1 rgba(200, 200, 200, 255)) };") - self.containerWidget.setObjectName("containerWidget") - self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.containerWidget) - self.verticalLayout_3.setContentsMargins(0, 0, 0, 0) - self.verticalLayout_3.setSpacing(0) - self.verticalLayout_3.setObjectName("verticalLayout_3") - self.horizontalWidget = QtWidgets.QWidget(self.containerWidget) - self.horizontalWidget.setObjectName("horizontalWidget") - self.horizontalLayout = QtWidgets.QHBoxLayout(self.horizontalWidget) - self.horizontalLayout.setContentsMargins(0, 0, 0, 0) - self.horizontalLayout.setSpacing(0) - self.horizontalLayout.setObjectName("horizontalLayout") - spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.horizontalLayout.addItem(spacerItem) - self.widgetLogo = QtWidgets.QWidget(self.horizontalWidget) - self.widgetLogo.setMinimumSize(QtCore.QSize(250, 240)) - self.widgetLogo.setMaximumSize(QtCore.QSize(250, 16777215)) - self.widgetLogo.setStyleSheet("") - self.widgetLogo.setObjectName("widgetLogo") - self.logo = QtWidgets.QLabel(self.widgetLogo) - self.logo.setGeometry(QtCore.QRect(50, 100, 150, 150)) - self.logo.setStyleSheet("") - self.logo.setPixmap(QtGui.QPixmap(":/images/logo_ibl")) - self.logo.setScaledContents(True) - self.logo.setObjectName("logo") - self.hat = QtWidgets.QLabel(self.widgetLogo) - self.hat.setGeometry(QtCore.QRect(0, 50, 150, 150)) - self.hat.setPixmap(QtGui.QPixmap(":/images/iblrig_logo")) - self.hat.setScaledContents(True) - self.hat.setObjectName("hat") - self.horizontalLayout.addWidget(self.widgetLogo) - spacerItem1 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.horizontalLayout.addItem(spacerItem1) - self.verticalLayout_3.addWidget(self.horizontalWidget) - spacerItem2 = QtWidgets.QSpacerItem(20, 30, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) - self.verticalLayout_3.addItem(spacerItem2) - self.widgetText = QtWidgets.QWidget(self.containerWidget) - self.widgetText.setObjectName("widgetText") - self.verticalLayout_4 = QtWidgets.QVBoxLayout(self.widgetText) - self.verticalLayout_4.setContentsMargins(0, 0, 0, 0) - self.verticalLayout_4.setSpacing(0) - self.verticalLayout_4.setObjectName("verticalLayout_4") - self.labelRig = QtWidgets.QLabel(self.widgetText) - font = QtGui.QFont() - font.setFamily("Arial") - font.setPointSize(15) - font.setBold(True) - font.setWeight(75) - self.labelRig.setFont(font) - self.labelRig.setAlignment(QtCore.Qt.AlignCenter) - self.labelRig.setObjectName("labelRig") - self.verticalLayout_4.addWidget(self.labelRig) - self.labelVersion = QtWidgets.QLabel(self.widgetText) - self.labelVersion.setAlignment(QtCore.Qt.AlignCenter) - self.labelVersion.setObjectName("labelVersion") - self.verticalLayout_4.addWidget(self.labelVersion) - self.verticalLayout_3.addWidget(self.widgetText) - spacerItem3 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) - self.verticalLayout_3.addItem(spacerItem3) - self.horizontalWidget1 = QtWidgets.QWidget(self.containerWidget) - self.horizontalWidget1.setAutoFillBackground(False) - self.horizontalWidget1.setStyleSheet("background-color: rgb(255, 255, 255);") - self.horizontalWidget1.setObjectName("horizontalWidget1") - self.horizontalLayout_2 = QtWidgets.QHBoxLayout(self.horizontalWidget1) - self.horizontalLayout_2.setContentsMargins(9, 2, 9, 2) - self.horizontalLayout_2.setObjectName("horizontalLayout_2") - self.labelStatus = QtWidgets.QLabel(self.horizontalWidget1) - self.labelStatus.setObjectName("labelStatus") - self.horizontalLayout_2.addWidget(self.labelStatus) - self.verticalLayout_3.addWidget(self.horizontalWidget1) - spacerItem4 = QtWidgets.QSpacerItem(20, 20, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) - self.verticalLayout_3.addItem(spacerItem4) - self.labelCopyright = QtWidgets.QLabel(self.containerWidget) - self.labelCopyright.setAlignment(QtCore.Qt.AlignCenter) - self.labelCopyright.setObjectName("labelCopyright") - self.verticalLayout_3.addWidget(self.labelCopyright) - spacerItem5 = QtWidgets.QSpacerItem(20, 5, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) - self.verticalLayout_3.addItem(spacerItem5) - self.verticalLayout.addWidget(self.containerWidget) - - self.retranslateUi(splash) - QtCore.QMetaObject.connectSlotsByName(splash) - - def retranslateUi(self, splash): - _translate = QtCore.QCoreApplication.translate - splash.setWindowTitle(_translate("splash", "Dialog")) - self.labelRig.setText(_translate("splash", "IBLRIG Wizard")) - self.labelVersion.setText(_translate("splash", "v8.13.0")) - self.labelStatus.setText(_translate("splash", "Status")) - self.labelCopyright.setText(_translate("splash", "© 2024, International Brain Laboratory")) -from iblrig.gui import resources_rc - - -if __name__ == "__main__": - import sys - app = QtWidgets.QApplication(sys.argv) - splash = QtWidgets.QDialog() - ui = Ui_splash() - ui.setupUi(splash) - splash.show() - sys.exit(app.exec_()) diff --git a/iblrig/gui/ui_splash.ui b/iblrig/gui/ui_splash.ui deleted file mode 100644 index 3b27d83fd..000000000 --- a/iblrig/gui/ui_splash.ui +++ /dev/null @@ -1,335 +0,0 @@ - - - splash - - - Qt::ApplicationModal - - - - 0 - 0 - 350 - 400 - - - - - 0 - 0 - - - - - 350 - 400 - - - - - 350 - 400 - - - - Dialog - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - QWidget#containerWidget { background-color: qradialgradient(spread:pad, cx:0.5, cy:0.5, radius:0.5, fx:0.5, fy:0.5, stop:0 rgba(255, 255, 255, 0), stop:1 rgba(200, 200, 200, 255)) }; - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - 250 - 240 - - - - - 250 - 16777215 - - - - - - - - - 50 - 100 - 150 - 150 - - - - - - - :/images/logo_ibl - - - true - - - - - - 0 - 50 - 150 - 150 - - - - :/images/iblrig_logo - - - true - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 30 - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - Arial - 15 - 75 - true - - - - IBLRIG Wizard - - - Qt::AlignCenter - - - - - - - v8.13.0 - - - Qt::AlignCenter - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - false - - - background-color: rgb(255, 255, 255); - - - - 9 - - - 2 - - - 9 - - - 2 - - - - - Status - - - - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 20 - - - - - - - - © 2024, International Brain Laboratory - - - Qt::AlignCenter - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 5 - - - - - - - - - - - - - - diff --git a/iblrig/gui/ui_tab_about.py b/iblrig/gui/ui_tab_about.py deleted file mode 100644 index 03d6dc4c1..000000000 --- a/iblrig/gui/ui_tab_about.py +++ /dev/null @@ -1,103 +0,0 @@ -# -*- coding: utf-8 -*- - -# Form implementation generated from reading ui file 'iblrig/gui/ui_tab_about.ui' -# -# Created by: PyQt5 UI code generator 5.15.9 -# -# WARNING: Any manual changes made to this file will be lost when pyuic5 is -# run again. Do not edit this file unless you know what you are doing. - - -from PyQt5 import QtCore, QtGui, QtWidgets - - -class Ui_TabAbout(object): - def setupUi(self, TabAbout): - TabAbout.setObjectName("TabAbout") - TabAbout.resize(498, 559) - self.gridLayout = QtWidgets.QGridLayout(TabAbout) - self.gridLayout.setObjectName("gridLayout") - spacerItem = QtWidgets.QSpacerItem(87, 45, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) - self.gridLayout.addItem(spacerItem, 0, 2, 1, 1) - spacerItem1 = QtWidgets.QSpacerItem(186, 87, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.gridLayout.addItem(spacerItem1, 1, 0, 1, 2) - self.uiLabelLogo = QtWidgets.QLabel(TabAbout) - self.uiLabelLogo.setMaximumSize(QtCore.QSize(90, 90)) - self.uiLabelLogo.setText("") - self.uiLabelLogo.setPixmap(QtGui.QPixmap(":/images/iblrig_logo")) - self.uiLabelLogo.setScaledContents(True) - self.uiLabelLogo.setObjectName("uiLabelLogo") - self.gridLayout.addWidget(self.uiLabelLogo, 1, 2, 1, 1) - spacerItem2 = QtWidgets.QSpacerItem(186, 87, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.gridLayout.addItem(spacerItem2, 1, 3, 1, 2) - self.uiLabelCopyright = QtWidgets.QLabel(TabAbout) - self.uiLabelCopyright.setTextFormat(QtCore.Qt.MarkdownText) - self.uiLabelCopyright.setAlignment(QtCore.Qt.AlignCenter) - self.uiLabelCopyright.setObjectName("uiLabelCopyright") - self.gridLayout.addWidget(self.uiLabelCopyright, 2, 0, 1, 5) - spacerItem3 = QtWidgets.QSpacerItem(407, 46, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) - self.gridLayout.addItem(spacerItem3, 3, 0, 1, 5) - spacerItem4 = QtWidgets.QSpacerItem(145, 157, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.gridLayout.addItem(spacerItem4, 4, 0, 4, 1) - self.commandLinkButtonGitHub = QtWidgets.QCommandLinkButton(TabAbout) - icon = QtGui.QIcon() - icon.addPixmap(QtGui.QPixmap(":/images/github"), QtGui.QIcon.Normal, QtGui.QIcon.Off) - self.commandLinkButtonGitHub.setIcon(icon) - self.commandLinkButtonGitHub.setObjectName("commandLinkButtonGitHub") - self.gridLayout.addWidget(self.commandLinkButtonGitHub, 4, 1, 1, 3) - spacerItem5 = QtWidgets.QSpacerItem(145, 157, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.gridLayout.addItem(spacerItem5, 4, 4, 4, 1) - self.commandLinkButtonDoc = QtWidgets.QCommandLinkButton(TabAbout) - icon1 = QtGui.QIcon() - icon1.addPixmap(QtGui.QPixmap(":/images/help"), QtGui.QIcon.Normal, QtGui.QIcon.Off) - self.commandLinkButtonDoc.setIcon(icon1) - self.commandLinkButtonDoc.setObjectName("commandLinkButtonDoc") - self.gridLayout.addWidget(self.commandLinkButtonDoc, 5, 1, 1, 3) - self.commandLinkButtonDiscussion = QtWidgets.QCommandLinkButton(TabAbout) - icon2 = QtGui.QIcon() - icon2.addPixmap(QtGui.QPixmap(":/images/discussion"), QtGui.QIcon.Normal, QtGui.QIcon.Off) - self.commandLinkButtonDiscussion.setIcon(icon2) - self.commandLinkButtonDiscussion.setObjectName("commandLinkButtonDiscussion") - self.gridLayout.addWidget(self.commandLinkButtonDiscussion, 6, 1, 1, 3) - self.commandLinkButtonIssues = QtWidgets.QCommandLinkButton(TabAbout) - icon3 = QtGui.QIcon() - icon3.addPixmap(QtGui.QPixmap(":/images/bug"), QtGui.QIcon.Normal, QtGui.QIcon.Off) - self.commandLinkButtonIssues.setIcon(icon3) - self.commandLinkButtonIssues.setObjectName("commandLinkButtonIssues") - self.gridLayout.addWidget(self.commandLinkButtonIssues, 7, 1, 1, 3) - spacerItem6 = QtWidgets.QSpacerItem(407, 74, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) - self.gridLayout.addItem(spacerItem6, 8, 0, 1, 5) - self.uiLabelAnyDesk = QtWidgets.QLabel(TabAbout) - self.uiLabelAnyDesk.setText("") - self.uiLabelAnyDesk.setAlignment(QtCore.Qt.AlignCenter) - self.uiLabelAnyDesk.setObjectName("uiLabelAnyDesk") - self.gridLayout.addWidget(self.uiLabelAnyDesk, 9, 0, 1, 5) - - self.retranslateUi(TabAbout) - QtCore.QMetaObject.connectSlotsByName(TabAbout) - - def retranslateUi(self, TabAbout): - _translate = QtCore.QCoreApplication.translate - TabAbout.setWindowTitle(_translate("TabAbout", "Form")) - self.uiLabelCopyright.setText(_translate("TabAbout", "**IBLRIG v8.13.0**\n" -"\n" -"© 2024, International Brain Laboratory")) - self.commandLinkButtonGitHub.setToolTip(_translate("TabAbout", "Open the IBLRIG GitHub repository")) - self.commandLinkButtonGitHub.setText(_translate("TabAbout", "&GitHub")) - self.commandLinkButtonDoc.setToolTip(_translate("TabAbout", "Open the IBLRIG documentation")) - self.commandLinkButtonDoc.setText(_translate("TabAbout", "&Documentation")) - self.commandLinkButtonDiscussion.setToolTip(_translate("TabAbout", "Open the IBLRIG discussion board")) - self.commandLinkButtonDiscussion.setText(_translate("TabAbout", "Discussion &Board")) - self.commandLinkButtonIssues.setToolTip(_translate("TabAbout", "Oen the IBLRIG issue tracker")) - self.commandLinkButtonIssues.setText(_translate("TabAbout", "&Issue Tracker")) -from iblrig.gui import resources_rc - - -if __name__ == "__main__": - import sys - app = QtWidgets.QApplication(sys.argv) - TabAbout = QtWidgets.QWidget() - ui = Ui_TabAbout() - ui.setupUi(TabAbout) - TabAbout.show() - sys.exit(app.exec_()) diff --git a/iblrig/gui/ui_tab_about.ui b/iblrig/gui/ui_tab_about.ui deleted file mode 100644 index dc23656eb..000000000 --- a/iblrig/gui/ui_tab_about.ui +++ /dev/null @@ -1,214 +0,0 @@ - - - TabAbout - - - - 0 - 0 - 498 - 559 - - - - Form - - - - - - Qt::Vertical - - - - 87 - 45 - - - - - - - - Qt::Horizontal - - - - 186 - 87 - - - - - - - - - 90 - 90 - - - - - - - :/images/iblrig_logo - - - true - - - - - - - Qt::Horizontal - - - - 186 - 87 - - - - - - - - **IBLRIG v8.13.0** - -© 2024, International Brain Laboratory - - - Qt::MarkdownText - - - Qt::AlignCenter - - - - - - - Qt::Vertical - - - - 407 - 46 - - - - - - - - Qt::Horizontal - - - - 145 - 157 - - - - - - - - Open the IBLRIG GitHub repository - - - &GitHub - - - - :/images/github:/images/github - - - - - - - Qt::Horizontal - - - - 145 - 157 - - - - - - - - Open the IBLRIG documentation - - - &Documentation - - - - :/images/help:/images/help - - - - - - - Open the IBLRIG discussion board - - - Discussion &Board - - - - :/images/discussion:/images/discussion - - - - - - - Open the IBLRIG issue tracker - - - &Issue Tracker - - - - :/images/bug:/images/bug - - - - - - - Qt::Vertical - - - - 407 - 74 - - - - - - - - - - - Qt::AlignCenter - - - - - - - - - - diff --git a/iblrig/gui/ui_tab_data.py b/iblrig/gui/ui_tab_data.py deleted file mode 100644 index da28ff7b9..000000000 --- a/iblrig/gui/ui_tab_data.py +++ /dev/null @@ -1,71 +0,0 @@ -# -*- coding: utf-8 -*- - -# Form implementation generated from reading ui file 'iblrig\gui\ui_tab_data.ui' -# -# Created by: PyQt5 UI code generator 5.15.10 -# -# WARNING: Any manual changes made to this file will be lost when pyuic5 is -# run again. Do not edit this file unless you know what you are doing. - - -from PyQt5 import QtCore, QtGui, QtWidgets - - -class Ui_TabData(object): - def setupUi(self, TabData): - TabData.setObjectName("TabData") - TabData.resize(776, 678) - self.verticalLayout = QtWidgets.QVBoxLayout(TabData) - self.verticalLayout.setObjectName("verticalLayout") - self.tableView = QtWidgets.QTableView(TabData) - self.tableView.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) - self.tableView.setTabKeyNavigation(False) - self.tableView.setProperty("showDropIndicator", False) - self.tableView.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) - self.tableView.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) - self.tableView.setHorizontalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) - self.tableView.setShowGrid(False) - self.tableView.setSortingEnabled(True) - self.tableView.setWordWrap(False) - self.tableView.setObjectName("tableView") - self.tableView.horizontalHeader().setCascadingSectionResizes(True) - self.tableView.horizontalHeader().setHighlightSections(False) - self.tableView.verticalHeader().setVisible(False) - self.verticalLayout.addWidget(self.tableView) - self.horizontalWidget = QtWidgets.QWidget(TabData) - self.horizontalWidget.setObjectName("horizontalWidget") - self.horizontalLayout = QtWidgets.QHBoxLayout(self.horizontalWidget) - self.horizontalLayout.setContentsMargins(0, 0, 0, 0) - self.horizontalLayout.setObjectName("horizontalLayout") - self.lineEditFilter = QtWidgets.QLineEdit(self.horizontalWidget) - self.lineEditFilter.setObjectName("lineEditFilter") - self.horizontalLayout.addWidget(self.lineEditFilter) - spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.horizontalLayout.addItem(spacerItem) - self.pushButtonUpdate = QtWidgets.QPushButton(self.horizontalWidget) - self.pushButtonUpdate.setObjectName("pushButtonUpdate") - self.horizontalLayout.addWidget(self.pushButtonUpdate) - self.horizontalLayout.setStretch(1, 1) - self.verticalLayout.addWidget(self.horizontalWidget) - - self.retranslateUi(TabData) - QtCore.QMetaObject.connectSlotsByName(TabData) - - def retranslateUi(self, TabData): - _translate = QtCore.QCoreApplication.translate - TabData.setWindowTitle(_translate("TabData", "Form")) - self.tableView.setToolTip(_translate("TabData", "Double-click a row to open the respective folder")) - self.lineEditFilter.setToolTip(_translate("TabData", "Filter table by subject")) - self.lineEditFilter.setPlaceholderText(_translate("TabData", "Filter by Subject")) - self.pushButtonUpdate.setToolTip(_translate("TabData", "Update table")) - self.pushButtonUpdate.setText(_translate("TabData", "Update")) - - -if __name__ == "__main__": - import sys - app = QtWidgets.QApplication(sys.argv) - TabData = QtWidgets.QWidget() - ui = Ui_TabData() - ui.setupUi(TabData) - TabData.show() - sys.exit(app.exec_()) diff --git a/iblrig/gui/ui_tab_data.ui b/iblrig/gui/ui_tab_data.ui deleted file mode 100644 index b48078554..000000000 --- a/iblrig/gui/ui_tab_data.ui +++ /dev/null @@ -1,115 +0,0 @@ - - - TabData - - - - 0 - 0 - 776 - 678 - - - - Form - - - - - - Double-click a row to open the respective folder - - - QAbstractItemView::NoEditTriggers - - - false - - - false - - - QAbstractItemView::SingleSelection - - - QAbstractItemView::SelectRows - - - QAbstractItemView::ScrollPerPixel - - - false - - - true - - - false - - - true - - - false - - - false - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Filter table by subject - - - Filter by Subject - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Update table - - - Update - - - - - - - - - - - diff --git a/iblrig/gui/ui_tab_log.py b/iblrig/gui/ui_tab_log.py deleted file mode 100644 index 58acf9750..000000000 --- a/iblrig/gui/ui_tab_log.py +++ /dev/null @@ -1,123 +0,0 @@ -# -*- coding: utf-8 -*- - -# Form implementation generated from reading ui file 'iblrig\gui\ui_tab_log.ui' -# -# Created by: PyQt5 UI code generator 5.15.10 -# -# WARNING: Any manual changes made to this file will be lost when pyuic5 is -# run again. Do not edit this file unless you know what you are doing. - - -from PyQt5 import QtCore, QtGui, QtWidgets - - -class Ui_TabLog(object): - def setupUi(self, TabLog): - TabLog.setObjectName("TabLog") - TabLog.resize(619, 821) - self.verticalLayout = QtWidgets.QVBoxLayout(TabLog) - self.verticalLayout.setObjectName("verticalLayout") - self.splitter = QtWidgets.QSplitter(TabLog) - self.splitter.setFrameShape(QtWidgets.QFrame.NoFrame) - self.splitter.setOrientation(QtCore.Qt.Vertical) - self.splitter.setHandleWidth(12) - self.splitter.setChildrenCollapsible(False) - self.splitter.setObjectName("splitter") - self.widgetLog = QtWidgets.QWidget(self.splitter) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(2) - sizePolicy.setHeightForWidth(self.widgetLog.sizePolicy().hasHeightForWidth()) - self.widgetLog.setSizePolicy(sizePolicy) - self.widgetLog.setObjectName("widgetLog") - self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.widgetLog) - self.verticalLayout_2.setContentsMargins(0, 0, 0, 0) - self.verticalLayout_2.setObjectName("verticalLayout_2") - self.groupBoxLog = QtWidgets.QGroupBox(self.widgetLog) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(3) - sizePolicy.setHeightForWidth(self.groupBoxLog.sizePolicy().hasHeightForWidth()) - self.groupBoxLog.setSizePolicy(sizePolicy) - self.groupBoxLog.setObjectName("groupBoxLog") - self.verticalLayout_5 = QtWidgets.QVBoxLayout(self.groupBoxLog) - self.verticalLayout_5.setObjectName("verticalLayout_5") - self.plainTextEditLog = QtWidgets.QPlainTextEdit(self.groupBoxLog) - self.plainTextEditLog.setStyleSheet("QPlainTextEdit {background-color: rgb(0, 0, 0)};") - self.plainTextEditLog.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap) - self.plainTextEditLog.setReadOnly(True) - self.plainTextEditLog.setObjectName("plainTextEditLog") - self.verticalLayout_5.addWidget(self.plainTextEditLog) - self.widget = QtWidgets.QWidget(self.groupBoxLog) - self.widget.setObjectName("widget") - self.horizontalLayout = QtWidgets.QHBoxLayout(self.widget) - self.horizontalLayout.setContentsMargins(0, 0, 0, 0) - self.horizontalLayout.setObjectName("horizontalLayout") - self.labelFontSize = QtWidgets.QLabel(self.widget) - self.labelFontSize.setObjectName("labelFontSize") - self.horizontalLayout.addWidget(self.labelFontSize) - self.spinBoxFontSize = QtWidgets.QSpinBox(self.widget) - self.spinBoxFontSize.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) - self.spinBoxFontSize.setButtonSymbols(QtWidgets.QAbstractSpinBox.PlusMinus) - self.spinBoxFontSize.setAccelerated(False) - self.spinBoxFontSize.setMinimum(7) - self.spinBoxFontSize.setMaximum(99) - self.spinBoxFontSize.setProperty("value", 11) - self.spinBoxFontSize.setObjectName("spinBoxFontSize") - self.horizontalLayout.addWidget(self.spinBoxFontSize) - spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.horizontalLayout.addItem(spacerItem) - self.pushButtonClipboard = QtWidgets.QPushButton(self.widget) - icon = QtGui.QIcon() - icon.addPixmap(QtGui.QPixmap(":/images/clipboard"), QtGui.QIcon.Normal, QtGui.QIcon.Off) - self.pushButtonClipboard.setIcon(icon) - self.pushButtonClipboard.setObjectName("pushButtonClipboard") - self.horizontalLayout.addWidget(self.pushButtonClipboard) - self.verticalLayout_5.addWidget(self.widget) - self.verticalLayout_2.addWidget(self.groupBoxLog) - self.widgetNarrative = QtWidgets.QWidget(self.splitter) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(1) - sizePolicy.setHeightForWidth(self.widgetNarrative.sizePolicy().hasHeightForWidth()) - self.widgetNarrative.setSizePolicy(sizePolicy) - self.widgetNarrative.setObjectName("widgetNarrative") - self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.widgetNarrative) - self.verticalLayout_3.setContentsMargins(0, 0, 0, 0) - self.verticalLayout_3.setObjectName("verticalLayout_3") - self.groupsBoxNarrative = QtWidgets.QGroupBox(self.widgetNarrative) - self.groupsBoxNarrative.setObjectName("groupsBoxNarrative") - self.verticalLayout_4 = QtWidgets.QVBoxLayout(self.groupsBoxNarrative) - self.verticalLayout_4.setObjectName("verticalLayout_4") - self.plainTextEditNarrative = QtWidgets.QPlainTextEdit(self.groupsBoxNarrative) - self.plainTextEditNarrative.setObjectName("plainTextEditNarrative") - self.verticalLayout_4.addWidget(self.plainTextEditNarrative) - self.verticalLayout_3.addWidget(self.groupsBoxNarrative) - self.verticalLayout.addWidget(self.splitter) - self.labelFontSize.setBuddy(self.spinBoxFontSize) - - self.retranslateUi(TabLog) - QtCore.QMetaObject.connectSlotsByName(TabLog) - - def retranslateUi(self, TabLog): - _translate = QtCore.QCoreApplication.translate - TabLog.setWindowTitle(_translate("TabLog", "Form")) - self.groupBoxLog.setTitle(_translate("TabLog", "Session Log")) - self.labelFontSize.setToolTip(_translate("TabLog", "Set the log\'s font size")) - self.labelFontSize.setText(_translate("TabLog", "&Font Size")) - self.spinBoxFontSize.setToolTip(_translate("TabLog", "Set the log\'s font size")) - self.pushButtonClipboard.setToolTip(_translate("TabLog", "Copy log to clipboard")) - self.pushButtonClipboard.setText(_translate("TabLog", " &Copy")) - self.groupsBoxNarrative.setTitle(_translate("TabLog", "Session Narrative")) - self.plainTextEditNarrative.setPlaceholderText(_translate("TabLog", "Enter your obvservations here ...")) -from iblrig.gui import resources_rc - - -if __name__ == "__main__": - import sys - app = QtWidgets.QApplication(sys.argv) - TabLog = QtWidgets.QWidget() - ui = Ui_TabLog() - ui.setupUi(TabLog) - TabLog.show() - sys.exit(app.exec_()) diff --git a/iblrig/gui/ui_tab_log.ui b/iblrig/gui/ui_tab_log.ui deleted file mode 100644 index 86c074ad2..000000000 --- a/iblrig/gui/ui_tab_log.ui +++ /dev/null @@ -1,210 +0,0 @@ - - - TabLog - - - - 0 - 0 - 619 - 821 - - - - Form - - - - - - QFrame::NoFrame - - - Qt::Vertical - - - 12 - - - false - - - - - 0 - 2 - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 3 - - - - Session Log - - - - - - QPlainTextEdit {background-color: rgb(0, 0, 0)}; - - - QPlainTextEdit::NoWrap - - - true - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Set the log's font size - - - &Font Size - - - spinBoxFontSize - - - - - - - Set the log's font size - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - QAbstractSpinBox::PlusMinus - - - false - - - 7 - - - 99 - - - 11 - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Copy log to clipboard - - - &Copy - - - - :/images/clipboard:/images/clipboard - - - - - - - - - - - - - - - 0 - 1 - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Session Narrative - - - - - - Enter your obvservations here ... - - - - - - - - - - - - - - - - - diff --git a/iblrig/gui/ui_tab_session.py b/iblrig/gui/ui_tab_session.py deleted file mode 100644 index e29e3c578..000000000 --- a/iblrig/gui/ui_tab_session.py +++ /dev/null @@ -1,349 +0,0 @@ -# -*- coding: utf-8 -*- - -# Form implementation generated from reading ui file 'iblrig/gui/ui_tab_session.ui' -# -# Created by: PyQt5 UI code generator 5.15.9 -# -# WARNING: Any manual changes made to this file will be lost when pyuic5 is -# run again. Do not edit this file unless you know what you are doing. - - -from PyQt5 import QtCore, QtGui, QtWidgets - - -class Ui_tabSession(object): - def setupUi(self, tabSession): - tabSession.setObjectName("tabSession") - tabSession.resize(346, 636) - self.gridLayout = QtWidgets.QGridLayout(tabSession) - self.gridLayout.setObjectName("gridLayout") - self.uiGroupParameters = QtWidgets.QGroupBox(tabSession) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.uiGroupParameters.sizePolicy().hasHeightForWidth()) - self.uiGroupParameters.setSizePolicy(sizePolicy) - self.uiGroupParameters.setObjectName("uiGroupParameters") - self.formLayout = QtWidgets.QFormLayout(self.uiGroupParameters) - self.formLayout.setSizeConstraint(QtWidgets.QLayout.SetDefaultConstraint) - self.formLayout.setFieldGrowthPolicy(QtWidgets.QFormLayout.ExpandingFieldsGrow) - self.formLayout.setLabelAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) - self.formLayout.setObjectName("formLayout") - self.label = QtWidgets.QLabel(self.uiGroupParameters) - self.label.setObjectName("label") - self.formLayout.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.label) - self.frame_3 = QtWidgets.QFrame(self.uiGroupParameters) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.frame_3.sizePolicy().hasHeightForWidth()) - self.frame_3.setSizePolicy(sizePolicy) - self.frame_3.setFrameShape(QtWidgets.QFrame.NoFrame) - self.frame_3.setObjectName("frame_3") - self.horizontalLayout_2 = QtWidgets.QHBoxLayout(self.frame_3) - self.horizontalLayout_2.setContentsMargins(0, 0, 0, 0) - self.horizontalLayout_2.setObjectName("horizontalLayout_2") - self.uiComboUser = QtWidgets.QComboBox(self.frame_3) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(3) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.uiComboUser.sizePolicy().hasHeightForWidth()) - self.uiComboUser.setSizePolicy(sizePolicy) - self.uiComboUser.setEditable(True) - self.uiComboUser.setObjectName("uiComboUser") - self.horizontalLayout_2.addWidget(self.uiComboUser) - self.uiPushConnect = QtWidgets.QPushButton(self.frame_3) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(2) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.uiPushConnect.sizePolicy().hasHeightForWidth()) - self.uiPushConnect.setSizePolicy(sizePolicy) - self.uiPushConnect.setMaximumSize(QtCore.QSize(150, 16777215)) - self.uiPushConnect.setToolTip("") - self.uiPushConnect.setObjectName("uiPushConnect") - self.horizontalLayout_2.addWidget(self.uiPushConnect) - self.formLayout.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.frame_3) - self.label_2 = QtWidgets.QLabel(self.uiGroupParameters) - self.label_2.setObjectName("label_2") - self.formLayout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.label_2) - self.frame_2 = QtWidgets.QFrame(self.uiGroupParameters) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.frame_2.sizePolicy().hasHeightForWidth()) - self.frame_2.setSizePolicy(sizePolicy) - self.frame_2.setFrameShape(QtWidgets.QFrame.NoFrame) - self.frame_2.setObjectName("frame_2") - self.horizontalLayout = QtWidgets.QHBoxLayout(self.frame_2) - self.horizontalLayout.setContentsMargins(0, 0, 0, 0) - self.horizontalLayout.setObjectName("horizontalLayout") - self.uiComboSubject = QtWidgets.QComboBox(self.frame_2) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(3) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.uiComboSubject.sizePolicy().hasHeightForWidth()) - self.uiComboSubject.setSizePolicy(sizePolicy) - self.uiComboSubject.setMinimumSize(QtCore.QSize(0, 0)) - self.uiComboSubject.setObjectName("uiComboSubject") - self.horizontalLayout.addWidget(self.uiComboSubject) - self.lineEditSubject = QtWidgets.QLineEdit(self.frame_2) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(2) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.lineEditSubject.sizePolicy().hasHeightForWidth()) - self.lineEditSubject.setSizePolicy(sizePolicy) - self.lineEditSubject.setLayoutDirection(QtCore.Qt.LeftToRight) - self.lineEditSubject.setObjectName("lineEditSubject") - self.horizontalLayout.addWidget(self.lineEditSubject) - self.formLayout.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.frame_2) - self.label_4 = QtWidgets.QLabel(self.uiGroupParameters) - self.label_4.setObjectName("label_4") - self.formLayout.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.label_4) - self.uiComboTask = QtWidgets.QComboBox(self.uiGroupParameters) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.uiComboTask.sizePolicy().hasHeightForWidth()) - self.uiComboTask.setSizePolicy(sizePolicy) - self.uiComboTask.setMinimumSize(QtCore.QSize(0, 0)) - self.uiComboTask.setObjectName("uiComboTask") - self.formLayout.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.uiComboTask) - self.label_3 = QtWidgets.QLabel(self.uiGroupParameters) - self.label_3.setObjectName("label_3") - self.formLayout.setWidget(3, QtWidgets.QFormLayout.LabelRole, self.label_3) - self.uiListProjects = QtWidgets.QListView(self.uiGroupParameters) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.uiListProjects.sizePolicy().hasHeightForWidth()) - self.uiListProjects.setSizePolicy(sizePolicy) - self.uiListProjects.setMaximumSize(QtCore.QSize(16777215, 80)) - palette = QtGui.QPalette() - brush = QtGui.QBrush(QtGui.QColor(0, 120, 215)) - brush.setStyle(QtCore.Qt.SolidPattern) - palette.setBrush(QtGui.QPalette.Active, QtGui.QPalette.Highlight, brush) - brush = QtGui.QBrush(QtGui.QColor(255, 255, 255)) - brush.setStyle(QtCore.Qt.SolidPattern) - palette.setBrush(QtGui.QPalette.Active, QtGui.QPalette.HighlightedText, brush) - brush = QtGui.QBrush(QtGui.QColor(0, 120, 215)) - brush.setStyle(QtCore.Qt.SolidPattern) - palette.setBrush(QtGui.QPalette.Inactive, QtGui.QPalette.Highlight, brush) - brush = QtGui.QBrush(QtGui.QColor(255, 255, 255)) - brush.setStyle(QtCore.Qt.SolidPattern) - palette.setBrush(QtGui.QPalette.Inactive, QtGui.QPalette.HighlightedText, brush) - brush = QtGui.QBrush(QtGui.QColor(0, 120, 215)) - brush.setStyle(QtCore.Qt.SolidPattern) - palette.setBrush(QtGui.QPalette.Disabled, QtGui.QPalette.Highlight, brush) - brush = QtGui.QBrush(QtGui.QColor(255, 255, 255)) - brush.setStyle(QtCore.Qt.SolidPattern) - palette.setBrush(QtGui.QPalette.Disabled, QtGui.QPalette.HighlightedText, brush) - self.uiListProjects.setPalette(palette) - self.uiListProjects.viewport().setProperty("cursor", QtGui.QCursor(QtCore.Qt.PointingHandCursor)) - self.uiListProjects.setFocusPolicy(QtCore.Qt.TabFocus) - self.uiListProjects.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) - self.uiListProjects.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection) - self.uiListProjects.setObjectName("uiListProjects") - self.formLayout.setWidget(3, QtWidgets.QFormLayout.FieldRole, self.uiListProjects) - self.label_5 = QtWidgets.QLabel(self.uiGroupParameters) - self.label_5.setObjectName("label_5") - self.formLayout.setWidget(4, QtWidgets.QFormLayout.LabelRole, self.label_5) - self.uiListProcedures = QtWidgets.QListView(self.uiGroupParameters) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.uiListProcedures.sizePolicy().hasHeightForWidth()) - self.uiListProcedures.setSizePolicy(sizePolicy) - self.uiListProcedures.setMaximumSize(QtCore.QSize(16777215, 80)) - palette = QtGui.QPalette() - brush = QtGui.QBrush(QtGui.QColor(0, 120, 215)) - brush.setStyle(QtCore.Qt.SolidPattern) - palette.setBrush(QtGui.QPalette.Active, QtGui.QPalette.Highlight, brush) - brush = QtGui.QBrush(QtGui.QColor(255, 255, 255)) - brush.setStyle(QtCore.Qt.SolidPattern) - palette.setBrush(QtGui.QPalette.Active, QtGui.QPalette.HighlightedText, brush) - brush = QtGui.QBrush(QtGui.QColor(0, 120, 215)) - brush.setStyle(QtCore.Qt.SolidPattern) - palette.setBrush(QtGui.QPalette.Inactive, QtGui.QPalette.Highlight, brush) - brush = QtGui.QBrush(QtGui.QColor(255, 255, 255)) - brush.setStyle(QtCore.Qt.SolidPattern) - palette.setBrush(QtGui.QPalette.Inactive, QtGui.QPalette.HighlightedText, brush) - brush = QtGui.QBrush(QtGui.QColor(0, 120, 215)) - brush.setStyle(QtCore.Qt.SolidPattern) - palette.setBrush(QtGui.QPalette.Disabled, QtGui.QPalette.Highlight, brush) - brush = QtGui.QBrush(QtGui.QColor(255, 255, 255)) - brush.setStyle(QtCore.Qt.SolidPattern) - palette.setBrush(QtGui.QPalette.Disabled, QtGui.QPalette.HighlightedText, brush) - self.uiListProcedures.setPalette(palette) - self.uiListProcedures.viewport().setProperty("cursor", QtGui.QCursor(QtCore.Qt.PointingHandCursor)) - self.uiListProcedures.setFocusPolicy(QtCore.Qt.TabFocus) - self.uiListProcedures.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) - self.uiListProcedures.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection) - self.uiListProcedures.setObjectName("uiListProcedures") - self.formLayout.setWidget(4, QtWidgets.QFormLayout.FieldRole, self.uiListProcedures) - self.gridLayout.addWidget(self.uiGroupParameters, 0, 0, 1, 2) - self.uiGroupTaskParameters = QtWidgets.QGroupBox(tabSession) - self.uiGroupTaskParameters.setEnabled(True) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.uiGroupTaskParameters.sizePolicy().hasHeightForWidth()) - self.uiGroupTaskParameters.setSizePolicy(sizePolicy) - self.uiGroupTaskParameters.setMinimumSize(QtCore.QSize(0, 0)) - self.uiGroupTaskParameters.setObjectName("uiGroupTaskParameters") - self.formLayout_3 = QtWidgets.QFormLayout(self.uiGroupTaskParameters) - self.formLayout_3.setFieldGrowthPolicy(QtWidgets.QFormLayout.AllNonFixedFieldsGrow) - self.formLayout_3.setLabelAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) - self.formLayout_3.setObjectName("formLayout_3") - self.gridLayout.addWidget(self.uiGroupTaskParameters, 1, 0, 1, 2) - self.uiGroupSessionControl = QtWidgets.QGroupBox(tabSession) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Minimum) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.uiGroupSessionControl.sizePolicy().hasHeightForWidth()) - self.uiGroupSessionControl.setSizePolicy(sizePolicy) - self.uiGroupSessionControl.setObjectName("uiGroupSessionControl") - self.gridLayout_2 = QtWidgets.QGridLayout(self.uiGroupSessionControl) - self.gridLayout_2.setObjectName("gridLayout_2") - self.uiPushPause = QtWidgets.QPushButton(self.uiGroupSessionControl) - self.uiPushPause.setEnabled(False) - self.uiPushPause.setCheckable(True) - self.uiPushPause.setChecked(False) - self.uiPushPause.setObjectName("uiPushPause") - self.gridLayout_2.addWidget(self.uiPushPause, 2, 1, 1, 1) - self.uiPushStart = QtWidgets.QPushButton(self.uiGroupSessionControl) - self.uiPushStart.setStyleSheet("QPushButton { background-color: red; }") - self.uiPushStart.setObjectName("uiPushStart") - self.gridLayout_2.addWidget(self.uiPushStart, 2, 2, 1, 1) - self.uiCheckAppend = QtWidgets.QCheckBox(self.uiGroupSessionControl) - self.uiCheckAppend.setObjectName("uiCheckAppend") - self.gridLayout_2.addWidget(self.uiCheckAppend, 3, 2, 1, 1) - self.gridLayout.addWidget(self.uiGroupSessionControl, 2, 0, 1, 2) - self.uiGroupTools = QtWidgets.QGroupBox(tabSession) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Expanding) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.uiGroupTools.sizePolicy().hasHeightForWidth()) - self.uiGroupTools.setSizePolicy(sizePolicy) - self.uiGroupTools.setObjectName("uiGroupTools") - self.gridLayout_3 = QtWidgets.QGridLayout(self.uiGroupTools) - self.gridLayout_3.setSizeConstraint(QtWidgets.QLayout.SetDefaultConstraint) - self.gridLayout_3.setObjectName("gridLayout_3") - self.uiPushFlush = QtWidgets.QPushButton(self.uiGroupTools) - self.uiPushFlush.setEnabled(True) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.uiPushFlush.sizePolicy().hasHeightForWidth()) - self.uiPushFlush.setSizePolicy(sizePolicy) - icon = QtGui.QIcon() - icon.addPixmap(QtGui.QPixmap(":/images/flush"), QtGui.QIcon.Normal, QtGui.QIcon.Off) - self.uiPushFlush.setIcon(icon) - self.uiPushFlush.setCheckable(True) - self.uiPushFlush.setObjectName("uiPushFlush") - self.gridLayout_3.addWidget(self.uiPushFlush, 0, 0, 1, 1) - self.uiPushHelp = QtWidgets.QPushButton(self.uiGroupTools) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.uiPushHelp.sizePolicy().hasHeightForWidth()) - self.uiPushHelp.setSizePolicy(sizePolicy) - icon1 = QtGui.QIcon() - icon1.addPixmap(QtGui.QPixmap(":/images/help"), QtGui.QIcon.Normal, QtGui.QIcon.Off) - self.uiPushHelp.setIcon(icon1) - self.uiPushHelp.setObjectName("uiPushHelp") - self.gridLayout_3.addWidget(self.uiPushHelp, 2, 0, 1, 1) - self.uiPushStatusLED = QtWidgets.QPushButton(self.uiGroupTools) - icon2 = QtGui.QIcon() - icon2.addPixmap(QtGui.QPixmap(":/images/status_led"), QtGui.QIcon.Normal, QtGui.QIcon.Off) - self.uiPushStatusLED.setIcon(icon2) - self.uiPushStatusLED.setCheckable(True) - self.uiPushStatusLED.setObjectName("uiPushStatusLED") - self.gridLayout_3.addWidget(self.uiPushStatusLED, 1, 0, 1, 1) - self.gridLayout.addWidget(self.uiGroupTools, 3, 0, 1, 1) - self.uiGroupDiskSpace = QtWidgets.QGroupBox(tabSession) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Expanding) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.uiGroupDiskSpace.sizePolicy().hasHeightForWidth()) - self.uiGroupDiskSpace.setSizePolicy(sizePolicy) - self.uiGroupDiskSpace.setObjectName("uiGroupDiskSpace") - self.formLayout_2 = QtWidgets.QFormLayout(self.uiGroupDiskSpace) - self.formLayout_2.setObjectName("formLayout_2") - self.uiProgressDiskSpace = QtWidgets.QProgressBar(self.uiGroupDiskSpace) - self.uiProgressDiskSpace.setProperty("value", 24) - self.uiProgressDiskSpace.setInvertedAppearance(False) - self.uiProgressDiskSpace.setTextDirection(QtWidgets.QProgressBar.TopToBottom) - self.uiProgressDiskSpace.setObjectName("uiProgressDiskSpace") - self.formLayout_2.setWidget(1, QtWidgets.QFormLayout.SpanningRole, self.uiProgressDiskSpace) - self.uiLabelDiskIblrig = QtWidgets.QLabel(self.uiGroupDiskSpace) - self.uiLabelDiskIblrig.setObjectName("uiLabelDiskIblrig") - self.formLayout_2.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.uiLabelDiskIblrig) - self.uiLabelDiskIblrigValue = QtWidgets.QLabel(self.uiGroupDiskSpace) - self.uiLabelDiskIblrigValue.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) - self.uiLabelDiskIblrigValue.setObjectName("uiLabelDiskIblrigValue") - self.formLayout_2.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.uiLabelDiskIblrigValue) - self.uiLabelDiskAvailable = QtWidgets.QLabel(self.uiGroupDiskSpace) - self.uiLabelDiskAvailable.setObjectName("uiLabelDiskAvailable") - self.formLayout_2.setWidget(3, QtWidgets.QFormLayout.LabelRole, self.uiLabelDiskAvailable) - self.uiLabelDiskAvailableValue = QtWidgets.QLabel(self.uiGroupDiskSpace) - self.uiLabelDiskAvailableValue.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) - self.uiLabelDiskAvailableValue.setObjectName("uiLabelDiskAvailableValue") - self.formLayout_2.setWidget(3, QtWidgets.QFormLayout.FieldRole, self.uiLabelDiskAvailableValue) - spacerItem = QtWidgets.QSpacerItem(20, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) - self.formLayout_2.setItem(0, QtWidgets.QFormLayout.SpanningRole, spacerItem) - spacerItem1 = QtWidgets.QSpacerItem(20, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) - self.formLayout_2.setItem(4, QtWidgets.QFormLayout.SpanningRole, spacerItem1) - self.gridLayout.addWidget(self.uiGroupDiskSpace, 3, 1, 1, 1) - - self.retranslateUi(tabSession) - QtCore.QMetaObject.connectSlotsByName(tabSession) - - def retranslateUi(self, tabSession): - _translate = QtCore.QCoreApplication.translate - tabSession.setWindowTitle(_translate("tabSession", "Form")) - self.uiGroupParameters.setTitle(_translate("tabSession", "General Parameters")) - self.label.setText(_translate("tabSession", "Alyx User")) - self.uiComboUser.setToolTip(_translate("tabSession", "Enter or select your Alyx username")) - self.uiPushConnect.setToolTip(_translate("tabSession", "Connect to Alyx")) - self.uiPushConnect.setText(_translate("tabSession", "Connect")) - self.label_2.setText(_translate("tabSession", "Subject")) - self.uiComboSubject.setToolTip(_translate("tabSession", "Choose a subject")) - self.lineEditSubject.setToolTip(_translate("tabSession", "Filter displayed subjects by name")) - self.lineEditSubject.setPlaceholderText(_translate("tabSession", "Filter")) - self.label_4.setText(_translate("tabSession", "Task")) - self.uiComboTask.setToolTip(_translate("tabSession", "Choose a task for the session")) - self.label_3.setText(_translate("tabSession", "Project")) - self.uiListProjects.setToolTip(_translate("tabSession", "Select one or several projects for the session (mandatory)")) - self.label_5.setText(_translate("tabSession", "Procedure")) - self.uiListProcedures.setToolTip(_translate("tabSession", "Select one or several procedures for the session (mandatory)")) - self.uiGroupTaskParameters.setTitle(_translate("tabSession", "Task Specific Parameters")) - self.uiGroupSessionControl.setTitle(_translate("tabSession", "Session Control")) - self.uiPushPause.setToolTip(_translate("tabSession", "Pause the session after the current trial")) - self.uiPushPause.setText(_translate("tabSession", "Pause")) - self.uiPushStart.setToolTip(_translate("tabSession", "Start the session")) - self.uiPushStart.setText(_translate("tabSession", "Start")) - self.uiCheckAppend.setToolTip(_translate("tabSession", "Append to previous session")) - self.uiCheckAppend.setText(_translate("tabSession", "Append")) - self.uiGroupTools.setTitle(_translate("tabSession", "Tools")) - self.uiPushFlush.setToolTip(_translate("tabSession", "Flush the valve")) - self.uiPushFlush.setText(_translate("tabSession", " Flush Valve")) - self.uiPushHelp.setToolTip(_translate("tabSession", "Open the iblrig documentation in your browser")) - self.uiPushHelp.setText(_translate("tabSession", " Help!")) - self.uiPushStatusLED.setToolTip(_translate("tabSession", "Toggle the Bpod Status LED (always off during sessions)")) - self.uiPushStatusLED.setText(_translate("tabSession", " Status LED")) - self.uiGroupDiskSpace.setTitle(_translate("tabSession", "Disk Usage")) - self.uiLabelDiskIblrig.setText(_translate("tabSession", "IBL Rig Data:")) - self.uiLabelDiskIblrigValue.setText(_translate("tabSession", "1.2 GB")) - self.uiLabelDiskAvailable.setText(_translate("tabSession", "Available Space:")) - self.uiLabelDiskAvailableValue.setText(_translate("tabSession", "80.3 GB")) -from iblrig.gui import resources_rc - - -if __name__ == "__main__": - import sys - app = QtWidgets.QApplication(sys.argv) - tabSession = QtWidgets.QWidget() - ui = Ui_tabSession() - ui.setupUi(tabSession) - tabSession.show() - sys.exit(app.exec_()) diff --git a/iblrig/gui/ui_tab_session.ui b/iblrig/gui/ui_tab_session.ui deleted file mode 100644 index 8f1e639df..000000000 --- a/iblrig/gui/ui_tab_session.ui +++ /dev/null @@ -1,680 +0,0 @@ - - - tabSession - - - - 0 - 0 - 346 - 636 - - - - Form - - - - - - - 0 - 0 - - - - General Parameters - - - - QLayout::SetDefaultConstraint - - - QFormLayout::ExpandingFieldsGrow - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - Alyx User - - - - - - - - 0 - 0 - - - - QFrame::NoFrame - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 3 - 0 - - - - enter or select your Alyx username - - - true - - - - - - - - 2 - 0 - - - - - 150 - 16777215 - - - - - - - connect to Alyx - - - Connect - - - - - - - - - - Subject - - - - - - - - 0 - 0 - - - - QFrame::NoFrame - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 3 - 0 - - - - - 0 - 0 - - - - choose a subject - - - - - - - - 2 - 0 - - - - filter displayed subjects by name - - - Qt::LeftToRight - - - Filter - - - - - - - - - - Task - - - - - - - - 0 - 0 - - - - - 0 - 0 - - - - choose a task for the session - - - - - - - Project - - - - - - - - 0 - 0 - - - - - 16777215 - 80 - - - - - - - - - 0 - 120 - 215 - - - - - - - 255 - 255 - 255 - - - - - - - - - 0 - 120 - 215 - - - - - - - 255 - 255 - 255 - - - - - - - - - 0 - 120 - 215 - - - - - - - 255 - 255 - 255 - - - - - - - - PointingHandCursor - - - Qt::TabFocus - - - select one or several projects for the session (mandatory) - - - QAbstractItemView::NoEditTriggers - - - QAbstractItemView::MultiSelection - - - - - - - Procedure - - - - - - - - 0 - 0 - - - - - 16777215 - 80 - - - - - - - - - 0 - 120 - 215 - - - - - - - 255 - 255 - 255 - - - - - - - - - 0 - 120 - 215 - - - - - - - 255 - 255 - 255 - - - - - - - - - 0 - 120 - 215 - - - - - - - 255 - 255 - 255 - - - - - - - - PointingHandCursor - - - Qt::TabFocus - - - select one or several procedures for the session (mandatory) - - - QAbstractItemView::NoEditTriggers - - - QAbstractItemView::MultiSelection - - - - - - - - - - true - - - - 0 - 0 - - - - - 0 - 0 - - - - Task Specific Parameters - - - - QFormLayout::AllNonFixedFieldsGrow - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - - 0 - 0 - - - - Session Control - - - - - - false - - - pause the session after the current trial - - - Pause - - - true - - - false - - - - - - - start the session - - - QPushButton { background-color: red; } - - - Start - - - - - - - append to previous session - - - Append - - - - - - - - - - - 0 - 0 - - - - Tools - - - - QLayout::SetDefaultConstraint - - - - - true - - - - 0 - 0 - - - - flush the valve - - - Flush Valve - - - - :/images/flush:/images/flush - - - true - - - - - - - - 0 - 0 - - - - open the iblrig documentation in your browser - - - Help! - - - - :/images/help:/images/help - - - - - - - toggle the Bpod Status LED (always off during sessions) - - - Status LED - - - - :/images/status_led:/images/status_led - - - true - - - - - - - - - - - 0 - 0 - - - - Disk Usage - - - - - - 24 - - - false - - - QProgressBar::TopToBottom - - - - - - - IBL Rig Data: - - - - - - - 1.2 GB - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Available Space: - - - - - - - 80.3 GB - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Qt::Vertical - - - - 20 - 0 - - - - - - - - Qt::Vertical - - - - 20 - 0 - - - - - - - - - - - - - - diff --git a/iblrig/hardware.py b/iblrig/hardware.py index 7b9475aa2..a5b907e27 100644 --- a/iblrig/hardware.py +++ b/iblrig/hardware.py @@ -392,16 +392,23 @@ def __del__(self): self.close() -def sound_device_factory(output: Literal['xonar', 'harp', 'hifi', 'sysdefault'] = 'sysdefault', samplerate: int | None = None): +def sound_device_factory( + output: Literal['xonar', 'harp', 'hifi', 'sysdefault'] = 'sysdefault', + samplerate: int | None = None, + channel_config: Literal['stereo', 'left', 'right'] = 'stereo', +): """ Will import, configure, and return sounddevice module to play sounds using onboard sound card. Parameters ---------- - output - defaults to "sysdefault", should be 'xonar' or 'harp' - samplerate - audio sample rate, defaults to 44100 + output : str + Defaults to "sysdefault", should be 'xonar', 'harp' or 'hifi'. + samplerate : int, optional + Sample rate override, defaults to None. + channel_config : str, optional + Preferred channel configuration, defaults to 'stereo'. + Will be ignored for 'xonar' output. """ match output: case 'xonar': @@ -416,16 +423,16 @@ def sound_device_factory(output: Literal['xonar', 'harp', 'hifi', 'sysdefault'] samplerate = samplerate if samplerate is not None else 96000 sd.default.samplerate = samplerate sd.default.channels = 2 - channels = 'stereo' + channels = channel_config case 'hifi': samplerate = samplerate if samplerate is not None else 192000 - channels = 'stereo' + channels = channel_config case 'sysdefault': samplerate = samplerate if samplerate is not None else 44100 sd.default.latency = 'low' sd.default.channels = 2 sd.default.samplerate = samplerate - channels = 'stereo' + channels = channel_config case _: raise ValueError() return sd, samplerate, channels diff --git a/iblrig/pydantic_definitions.py b/iblrig/pydantic_definitions.py index aa3398593..829e24651 100644 --- a/iblrig/pydantic_definitions.py +++ b/iblrig/pydantic_definitions.py @@ -124,6 +124,7 @@ class HardwareSettingsSound(BunchModel): OUTPUT: Literal['harp', 'xonar', 'hifi', 'sysdefault'] COM_SOUND: str | None = None AMP_TYPE: Literal['harp', 'AMP2X15'] | None = None + DEFAULT_CHANNELS: Literal['stereo', 'left', 'right'] = 'stereo' # ATTENUATION_DB: float = Field(default=0, le=0) diff --git a/iblrig/sound.py b/iblrig/sound.py index d7584cfcc..3d3a37076 100644 --- a/iblrig/sound.py +++ b/iblrig/sound.py @@ -1,4 +1,5 @@ import logging +from typing import Literal import numpy as np @@ -7,118 +8,257 @@ log = logging.getLogger(__name__) -def make_sound(rate=44100, frequency=5000, duration=0.1, amplitude=1, fade=0.01, chans='L+TTL'): +def sine_wave(d: float, f: float, fs: int = 44100) -> np.ndarray: """ - Build sounds and save bin file for upload to soundcard or play via - sounddevice lib. - - :param rate: sample rate of the soundcard use 96000 for Bpod, - defaults to 44100 for soundcard - :type rate: int, optional - :param frequency: (Hz) of the tone, if -1 will create uniform random white - noise, defaults to 10000 - :type frequency: int, optional - :param duration: (s) of sound, defaults to 0.1 - :type duration: float, optional - :param amplitude: E[0, 1] of the sound 1=max 0=min, defaults to 1 - :type amplitude: intor float, optional - :param fade: (s) time of fading window rise and decay, defaults to 0.01 - :type fade: float, optional - :param chans: ['mono', 'L', 'R', 'stereo', 'L+TTL', 'TTL+R'] number of - sound channels and type of output, defaults to 'L+TTL' - :type chans: str, optional - :return: streo sound from mono definitions - :rtype: np.ndarray with shape (Nsamples, 2) + Generate a sine wave signal. + + Parameters + ---------- + d : float + Duration of the sine wave in seconds. Must be positive. + f : float + Frequency of the sine wave in Hertz (Hz). Must be positive. + fs : int, optional + Sampling rate in samples per second (Hz). Default is 44100. + + Returns + ------- + np.ndarray + Array containing the sine wave samples. + """ + t = np.arange(d * fs) / fs + return np.sin(2 * np.pi * f * t) + + +def apply_hanning_envelope(waveform: np.ndarray, d: float, fs: int = 44100) -> np.ndarray: + """ + Apply a Hanning fade-in and fade-out to an audio waveform. + + Parameters + ---------- + waveform : np.ndarray + The input audio waveform (1D array of samples). + d : float + Duration of the fade-in and fade-out sections in seconds. + fs : int, optional + Sampling rate in samples per second (Hz). Default is 44100. + + Returns + ------- + np.ndarray + The waveform with the Hanning amplitude envelope applied. + + Raises + ------ + ValueError + If the fade duration is too long. """ - sample_rate = rate # Sound card dependent, - tone_duration = duration # sec - fade_duration = fade # sec - chans = chans if isinstance(chans, str) else chans[0] - tvec = np.linspace(0, tone_duration, int(tone_duration * sample_rate)) - tone = amplitude * np.sin(2 * np.pi * frequency * tvec) # tone vec - - len_fade = int(fade_duration * sample_rate) - fade_io = np.hanning(len_fade * 2) - fadein = fade_io[:len_fade] - fadeout = fade_io[len_fade:] - win = np.ones(len(tvec)) - win[:len_fade] = fadein - win[-len_fade:] = fadeout - - tone = tone * win + n_samples_waveform = len(waveform) + n_samples_fade = int(d * fs) + if 2 * n_samples_fade > n_samples_waveform: + raise ValueError('Fade duration is too long for the waveform length.') + + # generate Hanning window and split into fade-in and fade-out + window = np.hanning(2 * n_samples_fade) + fade_in = window[:n_samples_fade] + fade_out = window[n_samples_fade:] + + # apply envelope to waveform + sustain = np.ones(n_samples_waveform - 2 * n_samples_fade) + envelope = np.concatenate([fade_in, sustain, fade_out]) + return waveform * envelope + + +def sine_stimulus( + d: float | int, f: float | int, fs: int = 44100, amplitude: float = 1.0, gain_db: float = 0.0, d_fade: float = 0.01 +) -> np.ndarray: + """ + Generate a sine wave stimulus: A sine wave with a Hanning fade-in and fade-out, defined amplitude and gain. + + Parameters + ---------- + d : float or int + Duration of the sine wave in seconds. Must be positive. + f : float or int + Frequency of the sine wave in Hertz (Hz). Must be positive. + fs : int, optional + Sampling rate in samples per second (Hz). Default is 44100. + amplitude : float, optional + Base amplitude of the tone before gain adjustment. Default is 1.0. + gain_db: float = 0.0 + Gain adjustment in decibels. Positive to amplify, negative to attenuate. Default is 0.0. + d_fade : float or int + Duration of the fade-in and fade-out sections in seconds. + """ + stimulus = sine_wave(d=d, f=f, fs=fs) + stimulus = apply_hanning_envelope(waveform=stimulus, d=d_fade, fs=fs) + stimulus *= amplitude + stimulus *= 10 ** (gain_db / 20) + return stimulus + + +def make_sound( + rate: int = 44100, + frequency: float = 5000, + duration: float = 0.1, + amplitude: float = 1, + fade: float = 0.01, + chans: Literal['mono', 'L', 'R', 'stereo', 'L+TTL', 'TTL+R', 'left', 'right'] = 'L+TTL', + gain_db: float = 0.0, +) -> np.ndarray: + """ + Generate a sound waveform with optional fade and channel configurations. + + Parameters + ---------- + rate : int, optional + Sampling rate in Hz. Default is 44100. + frequency : float, optional + Frequency of the tone in Hz. Negative values will result in white noise. Default is 5000. + duration : float, optional + Duration of the sound in seconds. Default is 0.1. + amplitude : float, optional + Amplitude of the tone. Default is 1. + fade : float, optional + Duration of fade-in and fade-out in seconds. Default is 0.01. + chans : str, optional + Output channel configuration: + - 'mono': single channel + - 'L': tone on left channel only + - 'R': tone on right channel only + - 'stereo': tone on both channels + - 'L+TTL': tone on left, TTL pulse on right + - 'TTL+R': TTL pulse on left, tone on right + Default is 'L+TTL'. + gain_db: float = 0.0 + Gain adjustment in decibels. Positive to amplify, negative to attenuate. Default is 0.0. + + Returns + ------- + np.ndarray + The generated sound waveform, shape (samples,) for mono or (samples, 2) for stereo. + """ + if frequency < 0: + tone = amplitude * np.random.rand(int(rate * duration)) * (10 ** (gain_db / 20)) + else: + tone = sine_stimulus(d=duration, f=frequency, fs=rate, amplitude=amplitude, d_fade=fade, gain_db=gain_db) + ttl = np.ones(len(tone)) * 0.99 - one_ms = round(sample_rate / 1000) * 10 - ttl[one_ms:] = 0 + ttl[round(rate / 100) :] = 0 # 10 ms TTL null = np.zeros(len(tone)) - if frequency == -1: - tone = amplitude * np.random.rand(tone.size) - - if chans == 'mono': - sound = np.array(tone) - elif chans == 'L': - sound = np.array([tone, null]).T - elif chans == 'R': - sound = np.array([null, tone]).T - elif chans == 'stereo': - sound = np.array([tone, tone]).T - elif chans == 'L+TTL': - sound = np.array([tone, ttl]).T - elif chans == 'TTL+R': - sound = np.array([ttl, tone]).T - + match chans: + case 'mono': + sound = tone + case chans if chans in ['L', 'left']: + sound = np.column_stack((tone, null)) + case chans if chans in ['RL', 'right']: + sound = np.column_stack((null, tone)) + case 'stereo': + sound = np.column_stack((tone, tone)) + case 'L+TTL': + sound = np.column_stack((tone, ttl)) + case 'TTL+R': + sound = np.column_stack((ttl, tone)) + case _: + raise ValueError(f'Unsupported channel configuration: {chans}') return sound -def format_sound(sound, file_path=None, flat=False): +def format_sound(sound: np.array, file_path: str = None, flat: bool = False) -> np.ndarray: """ - Format sound to send to sound card. + Format a stereo sound array into a binary-compatible int32 format. - Binary files to be sent to the sound card need to be a single contiguous - vector of int32 s. 4 Bytes left speaker, 4 Bytes right speaker, ..., etc. + This formats the audio data for output to a sound card. The sound is expected + to be stereo (2 channels) and in float format [-1.0, 1.0]. The result is an + array of int32 values interleaved [L, R, L, R, ...]. + Parameters + ---------- + sound : np.ndarray + A 2D NumPy array of shape (n_samples, 2) containing stereo float audio data. + file_path : str, optional + If provided, the formatted audio will be written to this binary file. + flat : bool, optional + If True, return a 1D flattened array. Otherwise, return (n_samples, 2) shape. - :param sound: Stereo sound - :type sound: 2d numpy.array os shape (n_samples, 2) - :param file_path: full path of file. [default: None] - :type file_path: str - """ - bin_sound = (sound * ((2**31) - 1)).astype(np.int32) + Returns + ------- + np.ndarray + The formatted int32 sound array, either flattened or in original shape. - if bin_sound.flags.f_contiguous: - bin_sound = np.ascontiguousarray(bin_sound) + Raises + ------ + ValueError + If `sound` is not a 2D array with shape (n_samples, 2). + """ + if sound.ndim != 2 or sound.shape[1] != 2: + raise ValueError('Sound must be a 2D array with shape (n_samples, 2) for stereo output.') - bin_save = bin_sound.reshape(1, np.multiply(*bin_sound.shape)) - bin_save = np.ascontiguousarray(bin_save) + bin_sound = (sound * ((2**31) - 1)).astype(np.int32) # Scale from float [-1.0, 1.0] to int32 range + bin_sound = np.ascontiguousarray(bin_sound) # Ensure memory layout is contiguous + interleaved = bin_sound.reshape(-1) # Interleave the samples as a 1D array: [L, R, L, R, ...] + # Optionally save to binary file if file_path: with open(file_path, 'wb') as bf: - bf.writelines(bin_save) - bf.flush() + bf.write(interleaved.tobytes()) return bin_sound.flatten() if flat else bin_sound -def configure_sound_card(card=None, sounds=None, indexes=None, sample_rate=96): +def configure_sound_card( + card: SoundCardModule | None = None, + sounds: list[np.ndarray] | None = None, + indexes: list[int] | None = None, + sample_rate: int = 96000, +) -> None: + """ + Configure a Harp sound card with given sounds at specified indexes and sample rate. + + Parameters + ---------- + card : SoundCardModule, optional + An instance of the sound card interface to send sounds to. + If None, a new SoundCardModule instance will be created and closed after use. + Default is None. + sounds : list of np.ndarray, optional + A list of stereo sound arrays to be formatted and sent to the card. + Each sound array should be 2D (n_samples, 2). Default is None (empty list). + indexes : list of int, optional + List of indexes corresponding to each sound in `sounds`. + Must be the same length as `sounds`. All values must be in range [2, 32]. + Default is None (empty list). + sample_rate : int, optional + Sample rate in Hz for playback. Must be 96000 or 192000. Default is 96000. + + Raises + ------ + ValueError + If `sample_rate` is not 96000 or 192000. + If the lengths of `sounds` and `indexes` do not match. + If one or several indices are outside valid range. + """ if indexes is None: indexes = [] if sounds is None: sounds = [] + close_card = card is None if card is None: card = SoundCardModule() - close_card = True - if sample_rate in (192, 192000): + if len(sounds) != len(indexes): + raise ValueError('Number of sounds and indices must match') + if not all([2 <= idx <= 32 for idx in indexes]): + raise ValueError('One or more indices out of valid range [2, 32]') + + # yes, this is painful - send_sound() complains if sample_rate is not type SampleRate + if sample_rate == 192000: sample_rate = SampleRate._192000HZ - elif sample_rate in (96, 96000): + elif sample_rate == 96000: sample_rate = SampleRate._96000HZ else: - log.error(f'Sound sample rate {sample_rate} should be 96 or 192 (KHz)') - raise (ValueError) - - if len(sounds) != len(indexes): - log.error('Wrong number of sounds and indexes') - raise (ValueError) + raise ValueError(f'Sound sample rate {sample_rate} must be 96000 or 192000') sounds = [format_sound(s, flat=True) for s in sounds] for sound, index in zip(sounds, indexes, strict=False): diff --git a/iblrig/test/tasks/test_training_choice_world.py b/iblrig/test/tasks/test_training_choice_world.py index 81a5adc0e..aec0a6b85 100644 --- a/iblrig/test/tasks/test_training_choice_world.py +++ b/iblrig/test/tasks/test_training_choice_world.py @@ -1,5 +1,8 @@ +from unittest.mock import MagicMock, patch + import numpy as np import pandas as pd +import pytest from iblrig import choiceworld from iblrig.test.base import BaseTestCases @@ -187,3 +190,128 @@ def test_acquisition_description(self): 'tasks': [{'_iblrig_tasks_trainingChoiceWorld': {'collection': 'raw_task_data_00'}}], } self.assertEqual(actual_description, actual_description | expected_description) + + +class TestDebiasing: + original_normal = np.random.normal + normal_value = 0.0 + + @pytest.fixture + def mock_session(self): + with patch('iblrig.base_choice_world.TrainingChoiceWorldSession') as mock_session: + instance = mock_session.return_value + instance.task_params = TrainingChoiceWorldSession.read_task_parameter_files() + instance.next_trial = MagicMock(side_effect=lambda: TrainingChoiceWorldSession.next_trial(instance)) + instance.trials_table = TrainingChoiceWorldSession.TrialDataModel.preallocate_dataframe(100) + instance.trial_num = -1 + instance.training_phase = 1 + instance.trials_table.contrast = 0.5 # 50% contrast + instance.trials_table.trial_correct = False # incorrect trial + instance.trials_table.response_side = 1 # rightward movement + instance.next_trial() + return instance + + def test_debias_flag(self, mock_session): + """Debiasing can be controlled by the DEBIAS task parameter""" + assert 'DEBIAS' in mock_session.task_params + mock_session.task_params['DEBIAS'] = False + for _ in range(10): + mock_session.next_trial() + assert not mock_session.trials_table.debias_trial[mock_session.trial_num] + mock_session.task_params['DEBIAS'] = True + for _ in range(10): + mock_session.next_trial() + assert mock_session.trials_table.debias_trial[mock_session.trial_num] + + def test_first_trial(self, mock_session): + """The first trial should never be debiased.""" + assert mock_session.trial_num == 0 + assert not mock_session.trials_table.debias_trial[0] + for _ in range(10): + mock_session.next_trial() + assert mock_session.trials_table.debias_trial[mock_session.trial_num] + + def test_training_phase(self, mock_session): + """Only training phases 0 to 4 should have debias trials.""" + for training_phase in range(6): + mock_session.training_phase = training_phase + mock_session.next_trial() + if training_phase < 5: + assert mock_session.trials_table.debias_trial[mock_session.trial_num] + else: + assert not mock_session.trials_table.debias_trial[mock_session.trial_num] + + def test_debias_conditions(self, mock_session): + """Debias trials should only occur after an incorrect, high-contrast go trial.""" + mock_session.trials_table.contrast = 0.5 + mock_session.trials_table.trial_correct = False + mock_session.next_trial() + assert mock_session.trials_table.debias_trial[mock_session.trial_num] # high-contrast incorrect trial + mock_session.trials_table.contrast = 0.499 + mock_session.trials_table.trial_correct = False + mock_session.next_trial() + assert not mock_session.trials_table.debias_trial[mock_session.trial_num] # low-contrast incorrect trial + mock_session.trials_table.contrast = 0.5 + mock_session.trials_table.trial_correct = True + mock_session.next_trial() + assert not mock_session.trials_table.debias_trial[mock_session.trial_num] # high-contrast correct trial + mock_session.trials_table.contrast = 0.5 + mock_session.trials_table.trial_correct = False + mock_session.trials_table.response_side = 0 + assert not mock_session.trials_table.debias_trial[mock_session.trial_num] # high-contrast no-go trial + + @pytest.fixture + def mock_normal(self): + def side_effect(*args, **kwargs): + result = self.original_normal(*args, **kwargs) + self.normal_value = result + return result + + with patch('iblrig.base_choice_world.np.random.normal', side_effect=side_effect) as normal: + yield normal + + def test_debiasing_logic(self, mock_normal, mock_session): + """Debiasing should take into account the previous 10 trials with valid responses, excluding no-go trials.""" + mock_session.trials_table.loc[0, 'position'] = mock_session.task_params['STIM_POSITIONS'][0] + mock_session.trials_table.response_side = pd.NA + mock_session.trials_table.trial_correct = pd.NA + mock_session.draw_next_trial_info = MagicMock( + side_effect=lambda *args, **kwargs: TrainingChoiceWorldSession.draw_next_trial_info(mock_session, *args, **kwargs) + ) + for trial_num in range(1, len(mock_session.trials_table)): + # simulate the previous trial's outcome, store to trials_table + prev_stimulus_pos = mock_session.trials_table.position[trial_num - 1] + prev_response_side = np.random.choice([-1, 0, 1], p=[0.2, 0.1, 0.7]) # biased towards rightward responses + prev_correct = prev_response_side == -1 * np.sign(prev_stimulus_pos) + mock_session.trials_table.loc[trial_num - 1, 'response_side'] = prev_response_side + mock_session.trials_table.loc[trial_num - 1, 'trial_correct'] = prev_correct + + # get the next trial + mock_normal.reset_mock() + mock_session.next_trial() + assert mock_session.trial_num == trial_num + + # do not debias if the previous response was correct, no-go or if the contrast was below 0.5 + previous_trial = mock_session.trials_table.iloc[trial_num - 1] + skip_debias = previous_trial.trial_correct or previous_trial.response_side == 0 or previous_trial.contrast < 0.5 + if skip_debias: + mock_normal.assert_not_called() + assert not mock_session.trials_table.debias_trial[trial_num], 'the trial should not be marked as a debias trial' + continue + + # calculate the proportion of rightward responses based on the last 10 valid responses + responses = mock_session.trials_table.response_side[:trial_num] + considered_responses = responses[responses != 0].tail(10) + rightward_proportion = (considered_responses == 1).mean() + + # the position of the stimulus is drawn from a normal distribution centered on rightward_proportion + # i.e. if there is a bias towards moving the stimulus to one side, the stimulus will more likely appear on that side, + # so the subject has to move it to the other side + assert mock_normal.call_args[0][0] == rightward_proportion, 'Mean of normal dist should match rightward proportion' + assert mock_normal.call_args[0][1] == 0.5, 'standard deviation should be 0.5' + next_stim_on_right = self.normal_value >= 0.5 + expected_position = mock_session.task_params['STIM_POSITIONS'][next_stim_on_right] + actual_position = mock_session.trials_table.position[trial_num] + assert actual_position == expected_position + + assert mock_session.trials_table.debias_trial[trial_num], 'the trial should be marked as a debias trial' diff --git a/iblrig/test/test_alyx.py b/iblrig/test/test_alyx.py index 966618976..623e16771 100644 --- a/iblrig/test/test_alyx.py +++ b/iblrig/test/test_alyx.py @@ -93,7 +93,7 @@ def test_register_session(self): ): self.assertIsNone(chained.register_to_alyx()) self.assertIn('AssertionError', log.output[0]) - self.assertIn('Could not register session to Alyx', log.output[1]) + self.assertIn('Could not register session to Alyx', log.output[0]) # An empty session record should cause an error when attempting to register weight with ( @@ -101,7 +101,7 @@ def test_register_session(self): self.assertLogs('iblrig.base_tasks', 'ERROR') as log, ): self.assertIsNone(chained.register_to_alyx()) - self.assertIn('Could not register water administration to Alyx', log.output[1]) + self.assertIn('Could not register water administration to Alyx', log.output[0]) # ONE in offline mode should simply return self.one.mode = 'local' diff --git a/iblrig/test/test_online_plots.py b/iblrig/test/test_online_plots.py index 4f6608fbe..df0762f53 100644 --- a/iblrig/test/test_online_plots.py +++ b/iblrig/test/test_online_plots.py @@ -25,7 +25,7 @@ def task_file(self): temp_dir.cleanup() def test_during_task(self, task_file, qtbot): - view = op.OnlinePlotsView(task_file.parent) + view = op.OnlinePlotsView(task_file.parent, live=True) model = view.model assert hasattr(model, 'jsonableWatcher') assert Path(model.jsonableWatcher.files()[0]) == task_file @@ -35,6 +35,10 @@ def test_during_task(self, task_file, qtbot): with qtbot.waitSignal(model.jsonableWatcher.fileChanged, timeout=5), open(task_file, 'a') as f: f.writelines([line]) assert model._n_trials == n_trials + 1 + path_online_plots = task_file.parent / 'online_plots.png' + assert not path_online_plots.exists() + view.close() + assert path_online_plots.exists() model.jsonableWatcher.removePath(str(task_file)) def test_from_existing_file(self, task_file, qtbot): @@ -42,7 +46,7 @@ def test_from_existing_file(self, task_file, qtbot): assert view.model._n_trials > 0 def test_colors(self, task_file, qtbot): - view = op.OnlinePlotsView(task_file.parent) + view = op.OnlinePlotsView(task_file.parent, live=True) model = view.model model._trial_data['response_time'] = 1 model._seconds_elapsed = 0 diff --git a/iblrig/test/test_sound.py b/iblrig/test/test_sound.py new file mode 100644 index 000000000..861829d1e --- /dev/null +++ b/iblrig/test/test_sound.py @@ -0,0 +1,200 @@ +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from iblrig import sound +from pybpod_soundcard_module.module_api import DataType + + +class TestSineWave: + def test_basic_properties(self): + duration = 1.0 # seconds + frequency = 440 # Hz + fs = 44100 + + wave = sound.sine_wave(d=duration, f=frequency, fs=fs) + + assert isinstance(wave, np.ndarray) + assert wave.ndim == 1 + assert len(wave) == int(fs * duration) + assert np.all(np.abs(wave) <= 1.0) + + def test_frequency_content(self): + f = 440.0 + fs = 44100 + wave = sound.sine_wave(d=1.0, f=f, fs=fs) + spectrum = np.fft.rfft(wave) + freqs = np.fft.rfftfreq(len(wave), d=1 / fs) + peak_freq = freqs[np.argmax(np.abs(spectrum))] + assert np.isclose(peak_freq, f, atol=1.0), f'Peak freq {peak_freq} != {f}' + + +class TestApplyHanningEnvelope: + def test_applies_fade_correctly(self): + fs = 1000 + fade_duration = 0.1 # 100 ms fade-in and fade-out + + waveform = np.ones(fs) + enveloped = sound.apply_hanning_envelope(waveform, d=fade_duration, fs=fs) + assert enveloped.shape == waveform.shape + + n_fade = int(fade_duration * fs) + assert np.isclose(enveloped[0], 0.0, atol=1e-6) + assert np.isclose(enveloped[n_fade - 1], 1.0, atol=0.1) + assert np.isclose(enveloped[-1], 0.0, atol=1e-6) + assert np.allclose(enveloped[n_fade:-n_fade], 1.0, atol=1e-6) + + def test_raises_on_too_long_fade(self): + fs = 1000 + waveform = np.ones(100) + fade_duration = 0.1 # 100 ms => 100 samples; 2 * 100 > 100 ⇒ should fail + with pytest.raises(ValueError, match='Fade duration is too long'): + sound.apply_hanning_envelope(waveform, d=fade_duration, fs=fs) + + def test_output_range(self): + fs = 44100 + waveform = np.random.uniform(low=-1, high=1, size=fs) + enveloped = sound.apply_hanning_envelope(waveform, d=0.01, fs=fs) + assert np.max(enveloped) <= np.max(waveform) + assert np.min(enveloped) >= np.min(waveform) + + def test_zero_fade(self): + waveform = np.ones(1000) + enveloped = sound.apply_hanning_envelope(waveform, d=0, fs=44100) + assert np.array_equal(waveform, enveloped) + + def test_too_short_waveform(self): + waveform = np.ones(10) + with pytest.raises(ValueError): + sound.apply_hanning_envelope(waveform, d=0.01, fs=44100) + + +class TestSineStimulus: + def test_basic_output(self): + d = 1.0 + f = 440 + fs = 44100 + + stim = sound.sine_stimulus(d=d, f=f, fs=fs) + assert isinstance(stim, np.ndarray) + assert stim.ndim == 1 + assert len(stim) == int(d * fs) + assert np.max(np.abs(stim)) <= 1.0 + + def test_amplitude_and_gain(self): + d = 0.1 + f = 1000 + fs = 44100 + + base = sound.sine_stimulus(d=d, f=f, fs=fs, amplitude=1.0, gain_db=0.0) + attenuated = sound.sine_stimulus(d=d, f=f, fs=fs, amplitude=1.0, gain_db=-6.0) + amplified = sound.sine_stimulus(d=d, f=f, fs=fs, amplitude=1.0, gain_db=6.0) + assert np.max(np.abs(attenuated)) < np.max(np.abs(base)) + assert np.max(np.abs(amplified)) > np.max(np.abs(base)) + + stim_amp_2 = sound.sine_stimulus(d=d, f=f, fs=fs, amplitude=2.0, gain_db=0.0) + assert np.isclose(np.max(np.abs(stim_amp_2)), 2 * np.max(np.abs(base)), rtol=1e-2) + + def test_fade_envelope_applied(self): + f = 440 + fs = 44100 + d_fade = 0.05 + stim = sound.sine_stimulus(d=1, f=f, fs=fs, d_fade=d_fade) + n_fade = int(d_fade * fs) + assert np.isclose(stim[0], 0.0, atol=1e-4) + assert np.isclose(stim[-1], 0.0, atol=1e-4) + assert np.max(np.abs(stim[n_fade:-n_fade])) > 0.7 + + def test_frequency_content(self): + f = 1000 + fs = 44100 + stim = sound.sine_stimulus(d=1, f=f, fs=fs) + spectrum = np.fft.rfft(stim) + freqs = np.fft.rfftfreq(len(stim), 1 / fs) + peak_freq = freqs[np.argmax(np.abs(spectrum))] + assert np.isclose(peak_freq, f, atol=1.0), f'Peak frequency {peak_freq} not close to {f}' + + +class TestFormatSound: + def test_output_dtype_and_shape(self): + stereo_wave = np.array([[0.5, -0.5], [1.0, -1.0], [-0.25, 0.25]], dtype=np.float32) + result = sound.format_sound(stereo_wave) + scale = (2**31) - 1 + expected = (stereo_wave * scale).astype(np.int32) + assert result.shape == (3, 2) + assert result.dtype == np.int32 + np.testing.assert_array_equal(result, expected) + + def test_flat_output(self): + stereo_wave = np.array([[0.1, 0.2], [0.3, 0.4]], dtype=np.float32) + flat = sound.format_sound(stereo_wave, flat=True) + assert flat.ndim == 1 + assert flat.shape == (4,) + assert flat[::2].tolist() == [flat[0], flat[2]] # L samples + assert flat[1::2].tolist() == [flat[1], flat[3]] # R samples + + def test_file_output(self, tmp_path): + stereo_wave = np.ones((10, 2), dtype=np.float32) * 0.5 + file_path = tmp_path / 'test_sound.bin' + _ = sound.format_sound(stereo_wave, file_path=str(file_path)) + assert file_path.exists() + with open(file_path, 'rb') as f: + data = f.read() + assert len(data) == 10 * 2 * 4 # 10 samples × 2 channels × 4 bytes + + def test_invalid_input_raises(self): + mono_wave = np.ones((10,), dtype=np.float32) # Not stereo + with pytest.raises(ValueError, match='Sound must be a 2D array'): + sound.format_sound(mono_wave) + + +class DummyCard: + def __init__(self): + self.send_sound = MagicMock() + self.close = MagicMock() + + +class TestConfigureSoundCard: + @pytest.fixture + def dummy_card(self): + return DummyCard() + + @patch('iblrig.sound.format_sound', side_effect=lambda s, flat=True: s) + @patch('iblrig.sound.SoundCardModule', autospec=True) + def test_configure_sound_card(self, mock_card_class, mock_format_sound, dummy_card): + mock_card_class.return_value = dummy_card + + # Test default card creation and close called + sounds = [[0.1, 0.2], [0.3, 0.4]] + indexes = [2, 3] + sound.configure_sound_card(sounds=sounds, indexes=indexes, sample_rate=96000) + assert mock_format_sound.call_count == 2 + + # send_sound called with formatted sounds, correct indexes and sample rate + calls = dummy_card.send_sound.call_args_list + assert len(calls) == 2 + for call, idx in zip(calls, indexes, strict=False): + args, kwargs = call + assert args[1] == idx + assert args[2] == 96000 + assert args[3].name == 'INT32' or args[3] == DataType.INT32 + + # card.close called because card was created inside + dummy_card.close.assert_called_once() + + # Test passing in an existing card disables close + dummy_card.send_sound.reset_mock() + dummy_card.close.reset_mock() + sound.configure_sound_card(card=dummy_card, sounds=sounds, indexes=indexes, sample_rate=192000) + dummy_card.send_sound.assert_called() + dummy_card.close.assert_not_called() + + with pytest.raises(ValueError): + sound.configure_sound_card(card=dummy_card, sounds=sounds, indexes=indexes, sample_rate=12345) + with pytest.raises(ValueError): + sound.configure_sound_card(card=dummy_card, sounds=sounds, indexes=[4]) + with pytest.raises(ValueError): + sound.configure_sound_card(card=dummy_card, sounds=sounds, indexes=[0, 1]) + with pytest.raises(ValueError): + sound.configure_sound_card(card=dummy_card, sounds=sounds, indexes=[32, 33]) diff --git a/iblrig/test/test_transfers.py b/iblrig/test/test_transfers.py index 0f01188f0..383b8296d 100644 --- a/iblrig/test/test_transfers.py +++ b/iblrig/test/test_transfers.py @@ -157,7 +157,7 @@ def test_copier(self): # check that the correct data was copied remote_photometry_path = copier.remote_session_path.joinpath('raw_photometry_data') assert remote_photometry_path.joinpath('_neurophotometrics_fpData.channels.csv').exists() - assert remote_photometry_path.joinpath('_neurophotometrics_fpData.digitalIntputs.pqt').exists() + assert remote_photometry_path.joinpath('_neurophotometrics_fpData.digitalInputs.pqt').exists() assert remote_photometry_path.joinpath('_neurophotometrics_fpData.raw.pqt').exists() data_raw_local = pd.read_csv(local_photometry_path.joinpath('raw_photometry', 'raw_photometry.csv')) data_raw_remote = pd.read_parquet(remote_photometry_path.joinpath('_neurophotometrics_fpData.raw.pqt')) diff --git a/iblrig/transfer_experiments.py b/iblrig/transfer_experiments.py index e14d3481c..fc82b26b9 100644 --- a/iblrig/transfer_experiments.py +++ b/iblrig/transfer_experiments.py @@ -638,7 +638,7 @@ def _copy_collections(self) -> bool: # copy the digital inputs file csv_digital_inputs = neurophotometrics_session_path.joinpath('digital_inputs.csv') digital_inputs_df = fpio.read_digital_inputs_csv(csv_digital_inputs, validate=True) - digital_inputs_df.to_parquet(remote_photometry_path.joinpath('_neurophotometrics_fpData.digitalIntputs.pqt')) + digital_inputs_df.to_parquet(remote_photometry_path.joinpath('_neurophotometrics_fpData.digitalInputs.pqt')) case 'daqami': # find the daqami files that correspond to the current acquisition session_date = subject_ini_time.date().strftime('%Y-%m-%d') diff --git a/iblrig/video.py b/iblrig/video.py index 1fe1acd99..486841284 100644 --- a/iblrig/video.py +++ b/iblrig/video.py @@ -3,6 +3,7 @@ import contextlib import logging import os +import shutil import subprocess import sys import tempfile @@ -401,7 +402,7 @@ def _copy_log_to_session(self): new_file = self.paths['SESSION_RAW_DATA_FOLDER'].joinpath('_ibl_log.info-acquisition.log') new_file.parent.mkdir(parents=True, exist_ok=True) self.logger.debug('Moving log file: %s -> %s', file_handler.baseFilename, new_file) - Path(file_handler.baseFilename).replace(new_file) + shutil.move(file_handler.baseFilename, new_file) @property def cameras(self): diff --git a/settings/hardware_settings_template.yaml b/settings/hardware_settings_template.yaml index aaae7e710..cf1fe431d 100644 --- a/settings/hardware_settings_template.yaml +++ b/settings/hardware_settings_template.yaml @@ -23,9 +23,10 @@ device_screen: SCREEN_LUX_DATE: null # optional SCREEN_LUX_VALUE: null # optional device_sound: - OUTPUT: sysdefault # harp, hifi, xonar or sysdefault + OUTPUT: sysdefault # harp, hifi, xonar, or sysdefault COM_SOUND: null AMP_TYPE: null # harp or AMP2X15 + DEFAULT_CHANNELS: stereo # stereo, left, or right device_microphone: BONSAI_WORKFLOW: devices/microphone/record_mic.bonsai device_valve: