From 91f1550016ea9f6d2c87218255302c3efe589f93 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Mon, 26 Aug 2024 11:38:05 +0100 Subject: [PATCH 01/44] Update release.yaml --- .github/workflows/release.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 4d0e20139..bde2fd10c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -2,8 +2,6 @@ name: Release on: push: - branches: - - iblrigv8 tags: - '[0-9]+.[0-9]+.[0-9]+' @@ -41,4 +39,4 @@ jobs: name: documentation - uses: softprops/action-gh-release@v2 with: - files: documentation/*.pdf \ No newline at end of file + files: iblrig_*_reference.pdf \ No newline at end of file From 017dc7ca45e71909ef823f11dcf439237f3bd2cc Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Mon, 26 Aug 2024 11:38:49 +0100 Subject: [PATCH 02/44] Update release.yaml --- .github/workflows/release.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index bde2fd10c..522444a51 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -39,4 +39,5 @@ jobs: name: documentation - uses: softprops/action-gh-release@v2 with: - files: iblrig_*_reference.pdf \ No newline at end of file + files: iblrig_*_reference.pdf + \ No newline at end of file From 04baddcad56384983eae437f13344750b8761f5f Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Mon, 26 Aug 2024 16:48:16 +0100 Subject: [PATCH 03/44] Create validate_hifi.py --- scripts/validate_hifi.py | 50 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 scripts/validate_hifi.py diff --git a/scripts/validate_hifi.py b/scripts/validate_hifi.py new file mode 100644 index 000000000..9ec77a201 --- /dev/null +++ b/scripts/validate_hifi.py @@ -0,0 +1,50 @@ +# Validate sound output of the Bpod HiFi Module across a range of configurations +# +# When running this script you should hear a series of identical beeps (500 ms, 440 Hz). +# Any distortion, crackling, pops etc could indicate an issue with the HiFi module. +# +# NOTE: Adapt SERIAL_PORT according to the connected hardware. +# WARNING: Be careful when using headphones for testing - the sound-output could be very loud! + +import logging +from time import sleep + +import numpy as np + +from iblrig.hifi import HiFi +from iblutil.util import setup_logger + +setup_logger(name='iblrig', level='DEBUG') +log = logging.getLogger(__name__) + +SERIAL_PORT = '/dev/ttyACM0' +DURATION_SEC = 0.5 +PAUSE_SEC = 0.5 +FREQUENCY_HZ = 480 +FADE_SEC = 0.02 + +hifi = HiFi(SERIAL_PORT, attenuation_db=0) + +for channels in ['mono', 'stereo']: + for sampling_rate_hz in [44100, 48e3, 96e3, 192e3]: + # create signal + t = np.linspace(0, DURATION_SEC, int(sampling_rate_hz * DURATION_SEC), False) + sound = np.sin(2 * np.pi * FREQUENCY_HZ * t) * 0.1 + + # avoid pops by fading the signal in and out + fade = np.linspace(0, 1, round(FADE_SEC * sampling_rate_hz)) + sound[: len(fade)] *= fade + sound[-len(fade) :] *= np.flip(fade) + + # create stereo signal by duplication + if channels == 'stereo': + sound = sound.reshape(-1, 1).repeat(2, axis=1) + + # load & play sound + hifi.sampling_rate_hz = sampling_rate_hz + hifi.load(0, sound) + hifi.push() + hifi.play(0) + + # wait for next iteration + sleep(DURATION_SEC + PAUSE_SEC) From 1d5591b6d11ea82690a95a6ae59af094ea50e903 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Mon, 26 Aug 2024 16:48:20 +0100 Subject: [PATCH 04/44] Update release.yaml --- .github/workflows/release.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 522444a51..c7ff9e7c3 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -40,4 +40,3 @@ jobs: - uses: softprops/action-gh-release@v2 with: files: iblrig_*_reference.pdf - \ No newline at end of file From eaf3516eb49236440ef6132e13b751e0c8c3517c Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Mon, 26 Aug 2024 16:54:37 +0100 Subject: [PATCH 05/44] add changelog / version info --- CHANGELOG.md | 4 ++++ iblrig/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20fc9c08a..bb3ff35d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ Changelog ========= +8.23.1 +------ +* add validation script for Bpod HiFi Module + 8.23.1 ------ * feature: post hardware information to alyx diff --git a/iblrig/__init__.py b/iblrig/__init__.py index 45e724ad2..af637f102 100644 --- a/iblrig/__init__.py +++ b/iblrig/__init__.py @@ -6,7 +6,7 @@ # 5) git tag the release in accordance to the version number below (after merge!) # >>> git tag 8.15.6 # >>> git push origin --tags -__version__ = '8.23.1' +__version__ = '8.23.2' from iblrig.version_management import get_detailed_version_string From dd1af77295a298dbb73d8174faa7b67747332b5b Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Mon, 26 Aug 2024 17:01:36 +0100 Subject: [PATCH 06/44] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb3ff35d7..e12359068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Changelog ========= -8.23.1 +8.23.2 ------ * add validation script for Bpod HiFi Module From f26ed87ce5201e5a733190258e750cf03f878e0b Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Mon, 26 Aug 2024 23:11:32 +0100 Subject: [PATCH 07/44] Update faq.rst --- docs/source/faq.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/source/faq.rst b/docs/source/faq.rst index 480b2cf16..8a98a9134 100644 --- a/docs/source/faq.rst +++ b/docs/source/faq.rst @@ -37,11 +37,13 @@ Sound Issues * Is ``hardware_settings.yaml`` set up correctly? Valid options for sound ``OUTPUT`` are: + - ``hifi``, - ``harp``, - ``xonar``, or - ``sysdefault``. Make sure that this value matches the actual soundcard used on your rig. + Note that ``sysdefault`` is only used in test scenarios and should not be used during actual experiments. Screen Issues From 14a471e276a3c59fe4e6be023e3fb6c755fa1594 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Tue, 27 Aug 2024 17:24:11 +0100 Subject: [PATCH 08/44] document states in get_state_machine_trial() --- iblrig/base_choice_world.py | 44 +++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/iblrig/base_choice_world.py b/iblrig/base_choice_world.py index fbc772a26..dc54b43b7 100644 --- a/iblrig/base_choice_world.py +++ b/iblrig/base_choice_world.py @@ -298,6 +298,7 @@ def _instantiate_state_machine(self, *args, **kwargs): def get_state_machine_trial(self, i): # we define the trial number here for subclasses that may need it sma = self._instantiate_state_machine(trial_number=i) + if i == 0: # First trial exception start camera session_delay_start = self.task_params.get('SESSION_DELAY_START', 0) log.info('First trial initializing, will move to next trial only if:') @@ -323,6 +324,10 @@ def get_state_machine_trial(self, i): output_actions=[self.bpod.actions.stop_sound, ('BNC1', 255)], ) # stop all sounds + # Reset the rotary encoder by sending the following opcodes via the modules serial interface + # - 'Z' (ASCII 90): Set current rotary encoder position to zero + # - 'E' (ASCII 69): Enable all position thresholds (that may have been disabled by a threshold-crossing) + # cf. https://sanworks.github.io/Bpod_Wiki/serial-interfaces/rotary-encoder-module-serial-interface/ sma.add_state( state_name='reset_rotary_encoder', state_timer=0, @@ -330,7 +335,9 @@ def get_state_machine_trial(self, i): state_change_conditions={'Tup': 'quiescent_period'}, ) - sma.add_state( # '>back' | '>reset_timer' + # Quiescent Period. If the wheel is moved past one of the thresholds: Reset the rotary encoder and start over. + # Continue with the stimulation once the quiescent period has passed without triggering movement thresholds. + sma.add_state( state_name='quiescent_period', state_timer=self.quiescent_period, output_actions=[], @@ -340,21 +347,26 @@ def get_state_machine_trial(self, i): self.movement_right: 'reset_rotary_encoder', }, ) - # show stimulus, move on to next state if a frame2ttl is detected, with a time-out of 0.1s + + # Show the visual stimulus. This is achieved by sending a time-stamped byte-message to Bonsai via the Rotary + # Encoder Module's ongoing USB-stream. Move to the next state once the Frame2TTL has been triggered, i.e., + # when the stimulus has been rendered on screen. Use the state-timer as a backup to prevent a stall. sma.add_state( state_name='stim_on', state_timer=0.1, output_actions=[self.bpod.actions.bonsai_show_stim], - state_change_conditions={'Tup': 'interactive_delay', 'BNC1High': 'interactive_delay', 'BNC1Low': 'interactive_delay'}, + state_change_conditions={'BNC1High': 'interactive_delay', 'BNC1Low': 'interactive_delay', 'Tup': 'interactive_delay'}, ) - # this is a feature that can eventually add a delay between visual and auditory cue + + # Defined delay between visual and auditory cue sma.add_state( state_name='interactive_delay', state_timer=self.task_params.INTERACTIVE_DELAY, output_actions=[], state_change_conditions={'Tup': 'play_tone'}, ) - # play tone, move on to next state if sound is detected, with a time-out of 0.1s + + # Play tone. Move to next state if sound is detected. Use the state-timer as a backup to prevent a stall. sma.add_state( state_name='play_tone', state_timer=0.1, @@ -362,13 +374,20 @@ def get_state_machine_trial(self, i): state_change_conditions={'Tup': 'reset2_rotary_encoder', 'BNC2High': 'reset2_rotary_encoder'}, ) + # Reset rotary encoder (see above). Move on after brief delay (to avoid a race conditions in the bonsai flow). sma.add_state( state_name='reset2_rotary_encoder', - state_timer=0.05, # the delay here is to avoid race conditions in the bonsai flow + state_timer=0.05, output_actions=[self.bpod.actions.rotary_encoder_reset], state_change_conditions={'Tup': 'closed_loop'}, ) + # Start the closed loop state in which the animal controls the position of the visual stimulus by means of the + # rotary encoder. The three possible outcomes are: + # 1) wheel has NOT been moved past a threshold: continue with no-go condition + # 2) wheel has been moved in WRONG direction: continue with error condition + # 3) wheel has been moved in CORRECT direction: continue with reward condition + sma.add_state( state_name='closed_loop', state_timer=self.task_params.RESPONSE_WINDOW, @@ -376,6 +395,7 @@ def get_state_machine_trial(self, i): state_change_conditions={'Tup': 'no_go', self.event_error: 'freeze_error', self.event_reward: 'freeze_reward'}, ) + # No-go: hide the visual stimulus and play white noise. Go to exit_state after FEEDBACK_NOGO_DELAY_SECS. sma.add_state( state_name='no_go', state_timer=self.task_params.FEEDBACK_NOGO_DELAY_SECS, @@ -383,13 +403,14 @@ def get_state_machine_trial(self, i): state_change_conditions={'Tup': 'exit_state'}, ) + # Error: Freeze the stimulus and play white noise. + # Continue to hide_stim/exit_state once FEEDBACK_ERROR_DELAY_SECS have passed. sma.add_state( state_name='freeze_error', state_timer=0, output_actions=[self.bpod.actions.bonsai_freeze_stim], state_change_conditions={'Tup': 'error'}, ) - sma.add_state( state_name='error', state_timer=self.task_params.FEEDBACK_ERROR_DELAY_SECS, @@ -397,20 +418,20 @@ def get_state_machine_trial(self, i): state_change_conditions={'Tup': 'hide_stim'}, ) + # Reward: open the valve for a defined duration (and set BNC1 to high), freeze stimulus in center of screen. + # Continue to hide_stim/exit_state once FEEDBACK_CORRECT_DELAY_SECS have passed. sma.add_state( state_name='freeze_reward', state_timer=0, output_actions=[self.bpod.actions.bonsai_show_center], state_change_conditions={'Tup': 'reward'}, ) - sma.add_state( state_name='reward', state_timer=self.reward_time, output_actions=[('Valve1', 255), ('BNC1', 255)], state_change_conditions={'Tup': 'correct'}, ) - sma.add_state( state_name='correct', state_timer=self.task_params.FEEDBACK_CORRECT_DELAY_SECS - self.reward_time, @@ -418,6 +439,9 @@ def get_state_machine_trial(self, i): state_change_conditions={'Tup': 'hide_stim'}, ) + # Hide the visual stimulus. This is achieved by sending a time-stamped byte-message to Bonsai via the Rotary + # Encoder Module's ongoing USB-stream. Move to the next state once the Frame2TTL has been triggered, i.e., + # when the stimulus has been rendered on screen. Use the state-timer as a backup to prevent a stall. sma.add_state( state_name='hide_stim', state_timer=0.1, @@ -425,12 +449,14 @@ def get_state_machine_trial(self, i): state_change_conditions={'Tup': 'exit_state', 'BNC1High': 'exit_state', 'BNC1Low': 'exit_state'}, ) + # Wait for ITI_DELAY_SECS before ending the trial. Raise BNC1 to mark this event. sma.add_state( state_name='exit_state', state_timer=self.task_params.ITI_DELAY_SECS, output_actions=[('BNC1', 255)], state_change_conditions={'Tup': 'exit'}, ) + return sma @abc.abstractmethod From fe5c2c24728708cc4fc48d03ddea846805de149f Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 28 Aug 2024 19:41:26 +0100 Subject: [PATCH 09/44] add QObjects for handling Alyx --- iblrig/gui/tools.py | 114 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/iblrig/gui/tools.py b/iblrig/gui/tools.py index 2cfc72514..208e80198 100644 --- a/iblrig/gui/tools.py +++ b/iblrig/gui/tools.py @@ -1,4 +1,5 @@ import argparse +import logging import subprocess import sys import traceback @@ -24,12 +25,17 @@ pyqtSlot, ) from PyQt5.QtGui import QStandardItem, QStandardItemModel -from PyQt5.QtWidgets import QListView, QProgressBar +from PyQt5.QtWidgets import QAction, QLineEdit, QListView, QProgressBar, QPushButton +from requests import HTTPError from iblrig.constants import BASE_PATH +from iblrig.gui import resources_rc # noqa: F401 from iblrig.net import get_remote_devices from iblrig.pydantic_definitions import RigSettings from iblutil.util import dir_size +from one.webclient import AlyxClient + +log = logging.getLogger(__name__) def convert_uis(): @@ -381,3 +387,109 @@ def update(self): item.setStatusTip(f'Remote Device "{device_name}" - {device_address}') item.setData(device_name, Qt.UserRole) self.appendRow(item) + + +class AlyxObject(QAction): + statusChanged = pyqtSignal(bool) + loggedIn = pyqtSignal(str) + loggedOut = pyqtSignal(str) + loginFailed = pyqtSignal(str) + + def __init__(self, *args, alyxUrl: str | None = None, alyxClient: AlyxClient | None = None, **kwargs): + super().__init__(*args, **kwargs) + self._icon = super().icon() + + if alyxUrl is not None: + self.client = AlyxClient(base_url=alyxUrl, silent=True) + else: + self.client = alyxClient + + @pyqtSlot(str, object) + def logIn(self, username: str, password: str | None = None, cacheToken: bool = False) -> bool: + if self.client is None: + return False + try: + self.client.authenticate(username, password, cache_token=cacheToken, force=password is not None) + except HTTPError as e: + if e.errno == 400 and any(x in e.response.text for x in ('credentials', 'required')): + log.error(e.filename) + self.loginFailed.emit(username) + else: + raise e + if status := self.client.is_logged_in and self.client.user == username: + log.debug(f"Logged into {self.client.base_url} as user '{username}'") + self.statusChanged.emit(True) + self.loggedIn.emit(username) + return status + + @pyqtSlot() + def logOut(self): + if self.client is None or not self.isLoggedIn: + return + username = self.client.user + self.client.logout() + if not (connected := self.client.is_logged_in): + log.debug(f"User '{username}' logged out of {self.client.base_url}") + self.statusChanged.emit(connected) + self.loggedOut.emit(username) + + @property + def isLoggedIn(self): + return self.client.is_logged_in if isinstance(self.client, AlyxClient) else False + + @property + def username(self) -> str | None: + return self.client.user if self.isLoggedIn else None + + +class LineEditAlyxUser(QLineEdit): + def __init__(self, *args, alyx: AlyxObject, **kwargs): + super().__init__(*args, **kwargs) + self.alyx = alyx + + # Use a QAction to indicate the connection status + self._checkmarkIcon = QAction(parent=self, icon=QtGui.QIcon(':/images/check')) + self.addAction(self._checkmarkIcon, self.ActionPosition.TrailingPosition) + + if self.alyx.client is None: + self.setEnabled(False) + else: + self.setPlaceholderText('not logged in') + self.alyx.statusChanged.connect(self._onStatusChanged) + self.alyx.loginFailed.connect(self._onLoginFailed) + self.returnPressed.connect(self.logIn) + self._onStatusChanged(self.alyx.isLoggedIn) + + @pyqtSlot(bool) + def _onStatusChanged(self, connected: bool): + self._checkmarkIcon.setVisible(connected) + self._checkmarkIcon.setToolTip(f'Connected to {self.alyx.client.base_url}' if connected else '') + self.setText(self.alyx.username or '') + self.setReadOnly(connected) + + @pyqtSlot() + def logIn(self): + self.alyx.logIn(self.text()) + + +class LoginPushButton(QPushButton): + clickedLogIn = pyqtSignal() + clickedLogOut = pyqtSignal() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._loggedIn = False + self.setLoggedIn(False) + self.clicked.connect(self._onClick) + + @pyqtSlot(bool) + def setLoggedIn(self, loggedIn: bool): + self._loggedIn = loggedIn + self.setText('Log Out' if loggedIn else 'Log In') + + @pyqtSlot() + def _onClick(self): + if self._loggedIn: + self.clickedLogOut.emit() + else: + self.clickedLogIn.emit() From 110cbf45d4269b226663ece0d1bb6dcaf6c6f442 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 28 Aug 2024 23:24:51 +0100 Subject: [PATCH 10/44] add documentation --- iblrig/gui/tools.py | 108 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 4 deletions(-) diff --git a/iblrig/gui/tools.py b/iblrig/gui/tools.py index 208e80198..8ed5a4a7f 100644 --- a/iblrig/gui/tools.py +++ b/iblrig/gui/tools.py @@ -389,13 +389,55 @@ def update(self): self.appendRow(item) -class AlyxObject(QAction): +class AlyxObject(QObject): + """ + A class to manage user authentication with an AlyxClient. + + This class provides methods to log in and log out users, emitting signals to indicate changes in authentication status. + + Parameters + ---------- + alyxUrl : str, optional + The base URL for the Alyx API. If provided, an AlyxClient will be created. + alyxClient : AlyxClient, optional + An existing AlyxClient instance. If provided, it will be used for authentication. + + Attributes + ---------- + isLoggedIn : bool + Indicates whether a user is currently logged in. + username : str or None + The username of the logged-in user, or None if not logged in. + statusChanged : pyqtSignal + Emitted when the login status changes (logged in or out). The signal carries a boolean indicating the new status. + loggedIn : pyqtSignal + Emitted when a user logs in. The signal carries a string representing the username. + loggedOut : pyqtSignal + Emitted when a user logs out. The signal carries a string representing the username. + loginFailed : pyqtSignal + Emitted when a login attempt fails. The signal carries a string representing the username. + """ + statusChanged = pyqtSignal(bool) loggedIn = pyqtSignal(str) loggedOut = pyqtSignal(str) loginFailed = pyqtSignal(str) def __init__(self, *args, alyxUrl: str | None = None, alyxClient: AlyxClient | None = None, **kwargs): + """ + Initializes the AlyxObject. + + Parameters + ---------- + *args : tuple + Positional arguments for QObject. + alyxUrl : str, optional + The base URL for the Alyx API. + alyxClient : AlyxClient, optional + An existing AlyxClient instance. + **kwargs : dict + Keyword arguments for QObject. + """ super().__init__(*args, **kwargs) self._icon = super().icon() @@ -404,8 +446,27 @@ def __init__(self, *args, alyxUrl: str | None = None, alyxClient: AlyxClient | N else: self.client = alyxClient - @pyqtSlot(str, object) + @pyqtSlot(str, object, bool) def logIn(self, username: str, password: str | None = None, cacheToken: bool = False) -> bool: + """ + Logs in a user with the provided username and password. + + Emits the loggedIn and statusChanged signals if the logout is successful, and the loginFailed signal otherwise. + + Parameters + ---------- + username : str + The username of the user attempting to log in. + password : str or None, optional + The password of the user. If None, the login will proceed without a password. + cacheToken : bool, optional + Whether to cache the authentication token. + + Returns + ------- + bool + True if the login was successful, False otherwise. + """ if self.client is None: return False try: @@ -423,7 +484,12 @@ def logIn(self, username: str, password: str | None = None, cacheToken: bool = F return status @pyqtSlot() - def logOut(self): + def logOut(self) -> None: + """ + Logs out the currently logged-in user. + + Emits the loggedOut and statusChanged signals if the logout is successful. + """ if self.client is None or not self.isLoggedIn: return username = self.client.user @@ -435,15 +501,48 @@ def logOut(self): @property def isLoggedIn(self): + """Indicates whether a user is currently logged in.""" return self.client.is_logged_in if isinstance(self.client, AlyxClient) else False @property def username(self) -> str | None: + """The username of the logged-in user, or None if not logged in.""" return self.client.user if self.isLoggedIn else None class LineEditAlyxUser(QLineEdit): + """ + A custom QLineEdit widget for managing user login with an AlyxObject. + + This widget displays a checkmark icon to indicate the connection status + and allows the user to input their username for logging in. + + Parameters + ---------- + *args : tuple + Positional arguments passed to the QLineEdit constructor. + alyx : AlyxObject + An instance of AlyxObject used to manage login and connection status. + **kwargs : dict + Keyword arguments passed to the QLineEdit constructor. + """ + def __init__(self, *args, alyx: AlyxObject, **kwargs): + """ + Initializes the LineEditAlyxUser widget. + + Sets up the checkmark icon, connects signals for login status, + and configures the line edit based on the AlyxObject's state. + + Parameters + ---------- + *args : tuple + Positional arguments passed to the QLineEdit constructor. + alyx : AlyxObject + An instance of AlyxObject. + **kwargs : dict + Keyword arguments passed to the QLineEdit constructor. + """ super().__init__(*args, **kwargs) self.alyx = alyx @@ -456,12 +555,12 @@ def __init__(self, *args, alyx: AlyxObject, **kwargs): else: self.setPlaceholderText('not logged in') self.alyx.statusChanged.connect(self._onStatusChanged) - self.alyx.loginFailed.connect(self._onLoginFailed) self.returnPressed.connect(self.logIn) self._onStatusChanged(self.alyx.isLoggedIn) @pyqtSlot(bool) def _onStatusChanged(self, connected: bool): + """Set some of the widget's properties depending on the current connection-status.""" self._checkmarkIcon.setVisible(connected) self._checkmarkIcon.setToolTip(f'Connected to {self.alyx.client.base_url}' if connected else '') self.setText(self.alyx.username or '') @@ -469,6 +568,7 @@ def _onStatusChanged(self, connected: bool): @pyqtSlot() def logIn(self): + """Attempt to log in using the line edit's current text.""" self.alyx.logIn(self.text()) From 5ce2e10fa2d419ccbcfbd54764be3fd3b2cca736 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 28 Aug 2024 23:36:03 +0100 Subject: [PATCH 11/44] Update tools.py --- iblrig/gui/tools.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/iblrig/gui/tools.py b/iblrig/gui/tools.py index 8ed5a4a7f..ea186c04f 100644 --- a/iblrig/gui/tools.py +++ b/iblrig/gui/tools.py @@ -446,6 +446,8 @@ def __init__(self, *args, alyxUrl: str | None = None, alyxClient: AlyxClient | N else: self.client = alyxClient + @pyqtSlot(str) + @pyqtSlot(str, object) @pyqtSlot(str, object, bool) def logIn(self, username: str, password: str | None = None, cacheToken: bool = False) -> bool: """ From 955823c23dab5e88f7a490ee256ea35832000822 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 28 Aug 2024 23:37:29 +0100 Subject: [PATCH 12/44] Update tools.py --- iblrig/gui/tools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iblrig/gui/tools.py b/iblrig/gui/tools.py index ea186c04f..86f486dc7 100644 --- a/iblrig/gui/tools.py +++ b/iblrig/gui/tools.py @@ -447,8 +447,8 @@ def __init__(self, *args, alyxUrl: str | None = None, alyxClient: AlyxClient | N self.client = alyxClient @pyqtSlot(str) - @pyqtSlot(str, object) - @pyqtSlot(str, object, bool) + @pyqtSlot(str, str) + @pyqtSlot(str, str, bool) def logIn(self, username: str, password: str | None = None, cacheToken: bool = False) -> bool: """ Logs in a user with the provided username and password. From 3011ba29763b870f43866bf16292f414a42f21ea Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Thu, 29 Aug 2024 10:45:41 +0100 Subject: [PATCH 13/44] add StatefulButton --- iblrig/gui/tools.py | 84 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 72 insertions(+), 12 deletions(-) diff --git a/iblrig/gui/tools.py b/iblrig/gui/tools.py index 86f486dc7..c9eaf4c94 100644 --- a/iblrig/gui/tools.py +++ b/iblrig/gui/tools.py @@ -574,24 +574,84 @@ def logIn(self): self.alyx.logIn(self.text()) -class LoginPushButton(QPushButton): - clickedLogIn = pyqtSignal() - clickedLogOut = pyqtSignal() - def __init__(self, *args, **kwargs): +class StatefulButton(QPushButton): + """ + A QPushButton that maintains an active/inactive state and emits different signals + based on its state when clicked. + + Parameters + ---------- + active : bool, optional + Initial state of the button (default is False). + + Attributes + ---------- + clickedWhileActive : pyqtSignal + Emitted when the button is clicked while it is in the active state. + clickedWhileInactive : pyqtSignal + Emitted when the button is clicked while it is in the inactive state. + stateChanged : pyqtSignal + Emitted when the button's state has changed. The signal carries the new state. + """ + clickedWhileActive = pyqtSignal() + clickedWhileInactive = pyqtSignal() + stateChanged = pyqtSignal(bool) + + def __init__(self, *args, active: bool = False, **kwargs): + """ + Initialize the StateButton with the specified active state. + + Parameters + ---------- + *args : tuple + Positional arguments to be passed to the QPushButton constructor. + active : bool, optional + Initial state of the button (default is False). + **kwargs : dict + Keyword arguments to be passed to the QPushButton constructor. + """ super().__init__(*args, **kwargs) - self._loggedIn = False - self.setLoggedIn(False) + self._isActive = active self.clicked.connect(self._onClick) + @pyqtProperty(bool) + def isActive(self) -> bool: + """ + Get the active state of the button. + + Returns + ------- + bool + True if the button is active, False otherwise. + """ + return self._isActive + @pyqtSlot(bool) - def setLoggedIn(self, loggedIn: bool): - self._loggedIn = loggedIn - self.setText('Log Out' if loggedIn else 'Log In') + def setActive(self, active: bool): + """ + Set the active state of the button. + + Emits `stateChanged` if the state has changed. + + Parameters + ---------- + active : bool + The new active state of the button. + """ + if self._isActive != active: + self._isActive = active + self.stateChanged.emit(self._isActive) @pyqtSlot() def _onClick(self): - if self._loggedIn: - self.clickedLogOut.emit() + """ + Handle the button click event. + + Emits `clickedWhileActive` if the button is active, + otherwise emits `clickedWhileInactive`. + """ + if self._isActive: + self.clickedWhileActive.emit() else: - self.clickedLogIn.emit() + self.clickedWhileInactive.emit() From 3cb4a891a74c3b66a94a19e5d1c97d345c73fbdc Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Thu, 29 Aug 2024 10:46:31 +0100 Subject: [PATCH 14/44] Update tools.py --- iblrig/gui/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iblrig/gui/tools.py b/iblrig/gui/tools.py index c9eaf4c94..50180120c 100644 --- a/iblrig/gui/tools.py +++ b/iblrig/gui/tools.py @@ -574,7 +574,6 @@ def logIn(self): self.alyx.logIn(self.text()) - class StatefulButton(QPushButton): """ A QPushButton that maintains an active/inactive state and emits different signals @@ -594,6 +593,7 @@ class StatefulButton(QPushButton): stateChanged : pyqtSignal Emitted when the button's state has changed. The signal carries the new state. """ + clickedWhileActive = pyqtSignal() clickedWhileInactive = pyqtSignal() stateChanged = pyqtSignal(bool) From ddd83aa1be3b82fd5e90ac413a572d5f61d73f7e Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Fri, 30 Aug 2024 16:35:45 +0100 Subject: [PATCH 15/44] validate trialdata --- iblrig/base_choice_world.py | 22 +++++++++++++++++----- iblrig/pydantic_definitions.py | 24 +++++++++++++++++++++++- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/iblrig/base_choice_world.py b/iblrig/base_choice_world.py index dc54b43b7..d06fdfdd9 100644 --- a/iblrig/base_choice_world.py +++ b/iblrig/base_choice_world.py @@ -9,6 +9,7 @@ import time from pathlib import Path from string import ascii_letters +from typing import final import numpy as np import pandas as pd @@ -17,6 +18,7 @@ import iblrig.graphic from iblrig import choiceworld, misc from iblrig.hardware import SOFTCODE +from iblrig.pydantic_definitions import TrialData from iblutil.io import jsonable from iblutil.util import Bunch from pybpodapi.com.messaging.trial import Trial @@ -84,6 +86,7 @@ class ChoiceWorldSession( ): # task_params = ChoiceWorldParams() base_parameters_file = Path(__file__).parent.joinpath('base_choice_world_params.yaml') + TrialDataDefinition = TrialData def __init__(self, *args, delay_secs=0, **kwargs): super().__init__(**kwargs) @@ -507,16 +510,25 @@ def trial_completed(self, bpod_data): self.session_info.TOTAL_WATER_DELIVERED += self.trials_table.at[self.trial_num, 'reward_amount'] self.session_info.NTRIALS += 1 # SAVE TRIAL DATA - save_dict = self.trials_table.iloc[self.trial_num].to_dict() - save_dict['behavior_data'] = bpod_data - # Dump and save - with open(self.paths['DATA_FILE_PATH'], 'a') as fp: - fp.write(json.dumps(save_dict) + '\n') + self.save_trial_data_to_json(bpod_data) # this is a flag for the online plots. If online plots were in pyqt5, there is a file watcher functionality Path(self.paths['DATA_FILE_PATH']).parent.joinpath('new_trial.flag').touch() self.paths.SESSION_FOLDER.joinpath('transfer_me.flag').touch() self.check_sync_pulses(bpod_data=bpod_data) + @final + def save_trial_data_to_json(self, bpod_data: dict): + # get trial's data as a dict, validate by passing through pydantic model + trial_data = self.trials_table.iloc[self.trial_num].to_dict() + trial_data = self.TrialDataDefinition.model_validate(trial_data).model_dump() + + # add bpod_data as 'behavior_data' + trial_data['behavior_data'] = bpod_data + + # write json data to file + with open(self.paths['DATA_FILE_PATH'], 'a') as fp: + fp.write(json.dumps(trial_data) + '\n') + def check_sync_pulses(self, bpod_data): # todo move this in the post trial when we have a task flow if not self.bpod.is_connected: diff --git a/iblrig/pydantic_definitions.py b/iblrig/pydantic_definitions.py index 2b4bd9642..cc3d7fb09 100644 --- a/iblrig/pydantic_definitions.py +++ b/iblrig/pydantic_definitions.py @@ -1,7 +1,7 @@ from collections import abc from datetime import date from pathlib import Path -from typing import Annotated, Literal +from typing import Annotated, Any, Literal from annotated_types import Ge, Le from pydantic import ( @@ -199,3 +199,25 @@ class HardwareSettings(BunchModel): device_cameras: dict[str, dict[str, HardwareSettingsCameraWorkflow | HardwareSettingsCamera]] | None device_microphone: HardwareSettingsMicrophone | None = None VERSION: str + + +class TrialData(BaseModel): + # allow adding extra fields + model_config = ConfigDict(extra='allow') + + contrast: Annotated[float, Ge(0.0), Le(1.0)] + position: int + quiescent_period: Annotated[int, Ge(0.0)] + response_side: Literal[-1, 0, 1] + response_time: Annotated[float, Ge(0.0)] + reward_amount: Annotated[float, Ge(0.0)] + reward_valve_time: Annotated[float, Ge(0.0)] + stim_angle: Annotated[float, Ge(-180.0), Le(180.0)] + stim_freq: PositiveFloat + stim_gain: float + stim_phase: float + stim_reverse: bool + stim_sigma: float + trial_correct: bool + trial_num: Annotated[int, Ge(0.0)] + pause_duration: Annotated[float, Ge(0.0)] From 89d13b34503daa8befad306f27928c4f636d759a Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Fri, 30 Aug 2024 17:28:04 +0100 Subject: [PATCH 16/44] Update pydantic_definitions.py --- iblrig/pydantic_definitions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iblrig/pydantic_definitions.py b/iblrig/pydantic_definitions.py index cc3d7fb09..b62c7da81 100644 --- a/iblrig/pydantic_definitions.py +++ b/iblrig/pydantic_definitions.py @@ -1,7 +1,7 @@ from collections import abc from datetime import date from pathlib import Path -from typing import Annotated, Any, Literal +from typing import Annotated, Literal from annotated_types import Ge, Le from pydantic import ( From 54b9c919c1ed4d755183591a2951dd64111b32f1 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Fri, 30 Aug 2024 17:35:17 +0100 Subject: [PATCH 17/44] Update base_choice_world.py --- iblrig/base_choice_world.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/iblrig/base_choice_world.py b/iblrig/base_choice_world.py index d06fdfdd9..99c5ad957 100644 --- a/iblrig/base_choice_world.py +++ b/iblrig/base_choice_world.py @@ -518,6 +518,17 @@ def trial_completed(self, bpod_data): @final def save_trial_data_to_json(self, bpod_data: dict): + """Validate and save trial data. + + This method retrieve's the current trial's data from the trial_table and validates it using a Pydantic model + (self.TrialDataDefinition). In merges in the trial's bpod_data dict and appends everything to the session's + JSON data file. + + Parameters + ---------- + bpod_data : dict + Trial data returned from pybpod. + """ # get trial's data as a dict, validate by passing through pydantic model trial_data = self.trials_table.iloc[self.trial_num].to_dict() trial_data = self.TrialDataDefinition.model_validate(trial_data).model_dump() From 0d5512267c26c7588117937af349347bbd7f1edd Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Fri, 30 Aug 2024 17:36:15 +0100 Subject: [PATCH 18/44] Update pydantic_definitions.py --- iblrig/pydantic_definitions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iblrig/pydantic_definitions.py b/iblrig/pydantic_definitions.py index b62c7da81..17ead10d2 100644 --- a/iblrig/pydantic_definitions.py +++ b/iblrig/pydantic_definitions.py @@ -207,7 +207,7 @@ class TrialData(BaseModel): contrast: Annotated[float, Ge(0.0), Le(1.0)] position: int - quiescent_period: Annotated[int, Ge(0.0)] + quiescent_period: Annotated[float, Ge(0.0)] response_side: Literal[-1, 0, 1] response_time: Annotated[float, Ge(0.0)] reward_amount: Annotated[float, Ge(0.0)] From 947df8595695e07b218b7465213c7b4d02ffa828 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Fri, 30 Aug 2024 18:46:04 +0100 Subject: [PATCH 19/44] class method for creating empty dataframe based on pydantic model --- iblrig/base_choice_world.py | 21 +-------------------- iblrig/pydantic_definitions.py | 23 ++++++++++++++++------- 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/iblrig/base_choice_world.py b/iblrig/base_choice_world.py index 99c5ad957..fc976de18 100644 --- a/iblrig/base_choice_world.py +++ b/iblrig/base_choice_world.py @@ -99,26 +99,7 @@ def __init__(self, *args, delay_secs=0, **kwargs): self.block_num = -1 self.block_trial_num = -1 # init the tables, there are 2 of them: a trials table and a ambient sensor data table - self.trials_table = pd.DataFrame( - { - 'contrast': np.zeros(NTRIALS_INIT) * np.NaN, - 'position': np.zeros(NTRIALS_INIT) * np.NaN, - 'quiescent_period': np.zeros(NTRIALS_INIT) * np.NaN, - 'response_side': np.zeros(NTRIALS_INIT, dtype=np.int8), - 'response_time': np.zeros(NTRIALS_INIT) * np.NaN, - 'reward_amount': np.zeros(NTRIALS_INIT) * np.NaN, - 'reward_valve_time': np.zeros(NTRIALS_INIT) * np.NaN, - 'stim_angle': np.zeros(NTRIALS_INIT) * np.NaN, - 'stim_freq': np.zeros(NTRIALS_INIT) * np.NaN, - 'stim_gain': np.zeros(NTRIALS_INIT) * np.NaN, - 'stim_phase': np.zeros(NTRIALS_INIT) * np.NaN, - 'stim_reverse': np.zeros(NTRIALS_INIT, dtype=bool), - 'stim_sigma': np.zeros(NTRIALS_INIT) * np.NaN, - 'trial_correct': np.zeros(NTRIALS_INIT, dtype=bool), - 'trial_num': np.zeros(NTRIALS_INIT, dtype=np.int16), - 'pause_duration': np.zeros(NTRIALS_INIT, dtype=float), - } - ) + self.trials_table = TrialData.prepare_dataframe(NTRIALS_INIT) self.ambient_sensor_table = pd.DataFrame( { diff --git a/iblrig/pydantic_definitions.py b/iblrig/pydantic_definitions.py index 17ead10d2..5f14daa44 100644 --- a/iblrig/pydantic_definitions.py +++ b/iblrig/pydantic_definitions.py @@ -3,6 +3,8 @@ from pathlib import Path from typing import Annotated, Literal +import numpy as np +import pandas as pd from annotated_types import Ge, Le from pydantic import ( AnyUrl, @@ -20,7 +22,7 @@ from iblrig.constants import BASE_PATH -FilePath = Annotated[FilePath, PlainSerializer(lambda s: str(s), return_type=str)] +ExistingFilePath = Annotated[FilePath, PlainSerializer(lambda s: str(s), return_type=str)] """Validate that path exists and is file. Cast to str upon save.""" BehaviourInputPort = Annotated[int, Ge(1), Le(4)] @@ -108,7 +110,7 @@ class HardwareSettingsRotaryEncoder(BunchModel): class HardwareSettingsScreen(BunchModel): - DISPLAY_IDX: int = Field(gte=0, lte=1) # -1 = Default, 0 = First, 1 = Second, 2 = Third, etc + DISPLAY_IDX: int = Field(ge=0, le=1) # -1 = Default, 0 = First, 1 = Second, 2 = Third, etc SCREEN_FREQ_TARGET: int = Field(gt=0) SCREEN_FREQ_TEST_DATE: date | None = None SCREEN_FREQ_TEST_STATUS: str | None = None @@ -161,12 +163,12 @@ class HardwareSettingsCamera(BunchModel): class HardwareSettingsCameraWorkflow(BunchModel): - setup: FilePath | None = Field( + setup: ExistingFilePath | None = Field( title='Optional camera setup workflow', default=None, description='An optional path to the camera setup Bonsai workflow.', ) - recording: FilePath = Field( + recording: ExistingFilePath = Field( title='Camera recording workflow', description='The path to the Bonsai workflow for camera recording.' ) @@ -201,14 +203,21 @@ class HardwareSettings(BunchModel): VERSION: str -class TrialData(BaseModel): +class TrialDataModel(BaseModel): # allow adding extra fields model_config = ConfigDict(extra='allow') + @classmethod + def prepare_dataframe(cls, n_rows: int) -> pd.DataFrame: + dtypes = {field: field_info.annotation for field, field_info in cls.model_fields.items()} + return pd.DataFrame(np.zeros((n_rows, len(dtypes))), columns=dtypes.keys()).astype(dtypes) + + +class TrialData(TrialDataModel): contrast: Annotated[float, Ge(0.0), Le(1.0)] - position: int + position: float quiescent_period: Annotated[float, Ge(0.0)] - response_side: Literal[-1, 0, 1] + response_side: Annotated[int, Ge(-1), Le(1)] response_time: Annotated[float, Ge(0.0)] reward_amount: Annotated[float, Ge(0.0)] reward_valve_time: Annotated[float, Ge(0.0)] From d6c6b95f2d3d2a80395cb56089f48d7294698843 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Sat, 31 Aug 2024 20:15:07 +0100 Subject: [PATCH 20/44] Finish implementation of trial_data check --- iblrig/base_choice_world.py | 13 ++-- iblrig/pydantic_definitions.py | 70 +++++++++++++++---- .../tasks/test_biased_choice_world_family.py | 2 +- 3 files changed, 66 insertions(+), 19 deletions(-) diff --git a/iblrig/base_choice_world.py b/iblrig/base_choice_world.py index fc976de18..6b010c1cc 100644 --- a/iblrig/base_choice_world.py +++ b/iblrig/base_choice_world.py @@ -18,7 +18,7 @@ import iblrig.graphic from iblrig import choiceworld, misc from iblrig.hardware import SOFTCODE -from iblrig.pydantic_definitions import TrialData +from iblrig.pydantic_definitions import TrialDataActiveChoiceWorld, TrialDataChoiceWorld from iblutil.io import jsonable from iblutil.util import Bunch from pybpodapi.com.messaging.trial import Trial @@ -86,7 +86,7 @@ class ChoiceWorldSession( ): # task_params = ChoiceWorldParams() base_parameters_file = Path(__file__).parent.joinpath('base_choice_world_params.yaml') - TrialDataDefinition = TrialData + TrialDataDefinition = TrialDataChoiceWorld def __init__(self, *args, delay_secs=0, **kwargs): super().__init__(**kwargs) @@ -99,7 +99,7 @@ def __init__(self, *args, delay_secs=0, **kwargs): self.block_num = -1 self.block_trial_num = -1 # init the tables, there are 2 of them: a trials table and a ambient sensor data table - self.trials_table = TrialData.prepare_dataframe(NTRIALS_INIT) + self.trials_table = TrialDataChoiceWorld.preallocate_dataframe(NTRIALS_INIT) self.ambient_sensor_table = pd.DataFrame( { @@ -184,6 +184,7 @@ def _run(self): self.trials_table.at[self.trial_num, 'pause_duration'] = time.time() - time_last_trial_end if not flag_stop.exists(): log.info('Resuming session') + # save trial and update log self.trial_completed(self.bpod.session.current_trial.export()) self.ambient_sensor_table.loc[i] = self.bpod.get_ambient_sensor_reading() @@ -664,6 +665,8 @@ class ActiveChoiceWorldSession(ChoiceWorldSession): The TrainingChoiceWorld, BiasedChoiceWorld are all subclasses of this class """ + TrialDataDefinition = TrialDataActiveChoiceWorld + def __init__(self, **kwargs): super().__init__(**kwargs) self.trials_table['stim_probability_left'] = np.zeros(NTRIALS_INIT, dtype=np.float64) @@ -710,8 +713,8 @@ def trial_completed(self, bpod_data): outcome = next(k for k in raw_outcome if raw_outcome[k]) # Update response buffer -1 for left, 0 for nogo, and 1 for rightward position = self.trials_table.at[self.trial_num, 'position'] + self.trials_table.at[self.trial_num, 'trial_correct'] = 'correct' in outcome if 'correct' in outcome: - self.trials_table.at[self.trial_num, 'trial_correct'] = True self.session_info.NTRIALS_CORRECT += 1 self.trials_table.at[self.trial_num, 'response_side'] = -np.sign(position) elif 'error' in outcome: @@ -868,7 +871,7 @@ def get_subject_training_info(self): def compute_performance(self): """Aggregate the trials table to compute the performance of the mouse on each contrast.""" - self.trials_table['signed_contrast'] = self.trials_table['contrast'] * np.sign(self.trials_table['position']) + self.trials_table['signed_contrast'] = self.trials_table.contrast * self.trials_table.position performance = self.trials_table.groupby(['signed_contrast']).agg( last_50_perf=pd.NamedAgg(column='trial_correct', aggfunc=lambda x: np.sum(x[np.maximum(-50, -x.size) :]) / 50), ntrials=pd.NamedAgg(column='trial_correct', aggfunc='count'), diff --git a/iblrig/pydantic_definitions.py b/iblrig/pydantic_definitions.py index 5f14daa44..fbd58d397 100644 --- a/iblrig/pydantic_definitions.py +++ b/iblrig/pydantic_definitions.py @@ -1,11 +1,11 @@ from collections import abc from datetime import date +from math import isnan, nan from pathlib import Path from typing import Annotated, Literal -import numpy as np import pandas as pd -from annotated_types import Ge, Le +from annotated_types import Ge, Le, Predicate from pydantic import ( AnyUrl, BaseModel, @@ -19,6 +19,7 @@ field_serializer, field_validator, ) +from pydantic_core._pydantic_core import PydanticUndefined from iblrig.constants import BASE_PATH @@ -204,21 +205,50 @@ class HardwareSettings(BunchModel): class TrialDataModel(BaseModel): - # allow adding extra fields - model_config = ConfigDict(extra='allow') + """ + A data model for trial data that extends BaseModel. - @classmethod - def prepare_dataframe(cls, n_rows: int) -> pd.DataFrame: - dtypes = {field: field_info.annotation for field, field_info in cls.model_fields.items()} - return pd.DataFrame(np.zeros((n_rows, len(dtypes))), columns=dtypes.keys()).astype(dtypes) + This model allows for the addition of extra fields beyond those defined in the model. + """ + model_config = ConfigDict(extra='allow') # allow adding extra fields -class TrialData(TrialDataModel): + @classmethod + def preallocate_dataframe(cls, n_rows: int) -> pd.DataFrame: + """ + Preallocate a DataFrame with specified number of rows, using default values or pandas.NA. + + This method creates a pandas DataFrame with the same columns as the fields defined in the Pydantic model. + Each column is initialized with the field's default value if available, otherwise with pandas.NA. + + We use Pandas.NA for default values rather than NaN, None or Zero. This allows + + Parameters + ---------- + n_rows : int + The number of rows to create in the DataFrame. + + Returns + ------- + pd.DataFrame + A DataFrame with `n_rows` rows and columns corresponding to the model's fields. + """ + # dtypes = {field: field_info.annotation for field, field_info in cls.model_fields.items()} + # data = {field: [pd.NA] * n_rows for field in dtypes.keys()} + # return pd.DataFrame(data) + + data = {} + for field, field_info in cls.model_fields.items(): + default_value = field_info.default if field_info.default is not PydanticUndefined else pd.NA + data[field] = [default_value] * n_rows + return pd.DataFrame(data) + + +class TrialDataChoiceWorld(TrialDataModel): + """Definition of Trial Data for ChoiceWorldSession""" contrast: Annotated[float, Ge(0.0), Le(1.0)] position: float quiescent_period: Annotated[float, Ge(0.0)] - response_side: Annotated[int, Ge(-1), Le(1)] - response_time: Annotated[float, Ge(0.0)] reward_amount: Annotated[float, Ge(0.0)] reward_valve_time: Annotated[float, Ge(0.0)] stim_angle: Annotated[float, Ge(-180.0), Le(180.0)] @@ -227,6 +257,20 @@ class TrialData(TrialDataModel): stim_phase: float stim_reverse: bool stim_sigma: float - trial_correct: bool trial_num: Annotated[int, Ge(0.0)] - pause_duration: Annotated[float, Ge(0.0)] + pause_duration: Annotated[float, Ge(0.0)] = 0.0 + + # The following variables are only used in ActiveChoiceWorld + # We keep them here with fixed default values for sake of compatibility + # + # TODO: Yes, this should probably be done differently. + response_side: Literal[0] = 0 + response_time: Annotated[float, Predicate(isnan)] = nan + trial_correct: Literal[False] = False + + +class TrialDataActiveChoiceWorld(TrialDataChoiceWorld): + """Definition of Trial Data for ActiveChoiceWorldSession""" + response_side: Annotated[int, Ge(-1), Le(1)] + response_time: Annotated[float, Ge(0.0)] + trial_correct: bool diff --git a/iblrig/test/tasks/test_biased_choice_world_family.py b/iblrig/test/tasks/test_biased_choice_world_family.py index 1028e041c..a919aeb7f 100644 --- a/iblrig/test/tasks/test_biased_choice_world_family.py +++ b/iblrig/test/tasks/test_biased_choice_world_family.py @@ -45,7 +45,7 @@ def test_task(self, reward_set: np.ndarray | None = None): # makes sure the water reward counts check out assert task.trials_table['reward_amount'].sum() == task.session_info.TOTAL_WATER_DELIVERED assert np.sum(task.trials_table['reward_amount'] == 0) == task.trial_num + 1 - task.session_info.NTRIALS_CORRECT - assert np.all(~np.isnan(task.trials_table['reward_valve_time'])) + assert not task.trials_table['reward_valve_time'].isna().any() # Test the blocks task logic df_blocks = task.trials_table.groupby('block_num').agg( count=pd.NamedAgg(column='stim_angle', aggfunc='count'), From 6226a4d26d3f041e0bd53efbf1d509b536e9fccb Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Sat, 31 Aug 2024 20:16:01 +0100 Subject: [PATCH 21/44] Update pydantic_definitions.py --- iblrig/pydantic_definitions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/iblrig/pydantic_definitions.py b/iblrig/pydantic_definitions.py index fbd58d397..7fcb4a199 100644 --- a/iblrig/pydantic_definitions.py +++ b/iblrig/pydantic_definitions.py @@ -246,6 +246,7 @@ def preallocate_dataframe(cls, n_rows: int) -> pd.DataFrame: class TrialDataChoiceWorld(TrialDataModel): """Definition of Trial Data for ChoiceWorldSession""" + contrast: Annotated[float, Ge(0.0), Le(1.0)] position: float quiescent_period: Annotated[float, Ge(0.0)] @@ -271,6 +272,7 @@ class TrialDataChoiceWorld(TrialDataModel): class TrialDataActiveChoiceWorld(TrialDataChoiceWorld): """Definition of Trial Data for ActiveChoiceWorldSession""" + response_side: Annotated[int, Ge(-1), Le(1)] response_time: Annotated[float, Ge(0.0)] trial_correct: bool From b6f43bac6890ca1c5de3f2c6c88df667aeaaa350 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Sun, 1 Sep 2024 00:42:11 +0100 Subject: [PATCH 22/44] place TrialDataModels with session classes --- iblrig/base_choice_world.py | 51 ++++++++++++++++++++++++++++++---- iblrig/base_tasks.py | 17 ++++++------ iblrig/pydantic_definitions.py | 37 +----------------------- 3 files changed, 55 insertions(+), 50 deletions(-) diff --git a/iblrig/base_choice_world.py b/iblrig/base_choice_world.py index 6b010c1cc..2fe57fb5b 100644 --- a/iblrig/base_choice_world.py +++ b/iblrig/base_choice_world.py @@ -9,16 +9,17 @@ import time from pathlib import Path from string import ascii_letters -from typing import final +from typing import Annotated, final import numpy as np import pandas as pd +from annotated_types import Interval, IsNan import iblrig.base_tasks import iblrig.graphic from iblrig import choiceworld, misc from iblrig.hardware import SOFTCODE -from iblrig.pydantic_definitions import TrialDataActiveChoiceWorld, TrialDataChoiceWorld +from iblrig.pydantic_definitions import TrialDataModel from iblutil.io import jsonable from iblutil.util import Bunch from pybpodapi.com.messaging.trial import Trial @@ -74,6 +75,32 @@ # WHITE_NOISE_IDX: int = 3 +class ChoiceWorldTrialData(TrialDataModel): + """Pydantic Model for Trial Data.""" + + contrast: Annotated[float, Interval(ge=0.0, le=1.0)] + position: float + quiescent_period: Annotated[float, Interval(ge=0.0)] + reward_amount: Annotated[float, Interval(ge=0.0)] + reward_valve_time: Annotated[float, Interval(ge=0.0)] + stim_angle: Annotated[float, Interval(ge=-180.0, le=180.0)] + stim_freq: Annotated[float, Interval(ge=0.0)] + stim_gain: float + stim_phase: float + stim_reverse: bool + stim_sigma: float + trial_num: Annotated[int, Interval(ge=0.0)] + pause_duration: Annotated[float, Interval(ge=0.0)] = 0.0 + + # The following variables are only used in ActiveChoiceWorld + # We keep them here with fixed default values for sake of compatibility + # + # TODO: Yes, this should probably be done differently. + response_side: Annotated[int, Interval(ge=0, le=0)] = 0 + response_time: IsNan[float] = np.nan + trial_correct: Annotated[int, Interval(ge=0, le=0)] = False + + class ChoiceWorldSession( iblrig.base_tasks.BonsaiRecordingMixin, iblrig.base_tasks.BonsaiVisualStimulusMixin, @@ -86,7 +113,10 @@ class ChoiceWorldSession( ): # task_params = ChoiceWorldParams() base_parameters_file = Path(__file__).parent.joinpath('base_choice_world_params.yaml') - TrialDataDefinition = TrialDataChoiceWorld + + @property + def get_trial_data_model(self): + return ChoiceWorldTrialData def __init__(self, *args, delay_secs=0, **kwargs): super().__init__(**kwargs) @@ -99,7 +129,7 @@ def __init__(self, *args, delay_secs=0, **kwargs): self.block_num = -1 self.block_trial_num = -1 # init the tables, there are 2 of them: a trials table and a ambient sensor data table - self.trials_table = TrialDataChoiceWorld.preallocate_dataframe(NTRIALS_INIT) + self.trials_table = self.get_trial_data_model.preallocate_dataframe(NTRIALS_INIT) self.ambient_sensor_table = pd.DataFrame( { @@ -513,7 +543,7 @@ def save_trial_data_to_json(self, bpod_data: dict): """ # get trial's data as a dict, validate by passing through pydantic model trial_data = self.trials_table.iloc[self.trial_num].to_dict() - trial_data = self.TrialDataDefinition.model_validate(trial_data).model_dump() + trial_data = self.get_trial_data_model.model_validate(trial_data).model_dump() # add bpod_data as 'behavior_data' trial_data['behavior_data'] = bpod_data @@ -651,6 +681,14 @@ def get_state_machine_trial(self, i): return sma +class ActiveChoiceWorldTrialData(ChoiceWorldTrialData): + """Pydantic Model for Trial Data, extended from ChoiceWorldSession.""" + + response_side: Annotated[int, Interval(ge=-1, le=1)] + response_time: Annotated[float, Interval(ge=0.0)] + trial_correct: bool + + class ActiveChoiceWorldSession(ChoiceWorldSession): """ The ActiveChoiceWorldSession is a base class for protocols where the mouse is actively making decisions @@ -665,7 +703,8 @@ class ActiveChoiceWorldSession(ChoiceWorldSession): The TrainingChoiceWorld, BiasedChoiceWorld are all subclasses of this class """ - TrialDataDefinition = TrialDataActiveChoiceWorld + def get_trial_data_model(self): + return ActiveChoiceWorldTrialData def __init__(self, **kwargs): super().__init__(**kwargs) diff --git a/iblrig/base_tasks.py b/iblrig/base_tasks.py index ab3aa755f..c94a03dbe 100644 --- a/iblrig/base_tasks.py +++ b/iblrig/base_tasks.py @@ -5,7 +5,6 @@ This module tries to exclude task related logic. """ -import abc import argparse import contextlib import datetime @@ -17,7 +16,7 @@ import sys import time import traceback -from abc import ABC +from abc import ABC, abstractmethod from collections import OrderedDict from collections.abc import Callable from pathlib import Path @@ -41,7 +40,7 @@ from iblrig.hardware import SOFTCODE, Bpod, MyRotaryEncoder, sound_device_factory from iblrig.hifi import HiFi from iblrig.path_helper import load_pydantic_yaml -from iblrig.pydantic_definitions import HardwareSettings, RigSettings +from iblrig.pydantic_definitions import HardwareSettings, RigSettings, TrialDataModel from iblrig.tools import call_bonsai from iblrig.transfer_experiments import BehaviorCopier, VideoCopier from iblutil.io.net.base import ExpMessage @@ -72,6 +71,9 @@ class BaseSession(ABC): extractor_tasks: list | None = None """list of str: An optional list of pipeline task class names to instantiate when preprocessing task data.""" + @abstractmethod + def get_trial_data_model(self) -> type[TrialDataModel]: ... + def __init__( self, subject=None, @@ -598,7 +600,7 @@ def sigint_handler(*args, **kwargs): self._execute_mixins_shared_function('stop_mixin') self._execute_mixins_shared_function('cleanup_mixin') - @abc.abstractmethod + @abstractmethod def start_hardware(self): """ Start the hardware. @@ -606,11 +608,10 @@ def start_hardware(self): This method doesn't explicitly start the mixins as the order has to be defined in the child classes. This needs to be implemented in the child classes, and should start and connect to all hardware pieces. """ - pass + ... - @abc.abstractmethod - def _run(self): - pass + @abstractmethod + def _run(self): ... @staticmethod def extra_parser(): diff --git a/iblrig/pydantic_definitions.py b/iblrig/pydantic_definitions.py index 7fcb4a199..d7e32c453 100644 --- a/iblrig/pydantic_definitions.py +++ b/iblrig/pydantic_definitions.py @@ -1,11 +1,10 @@ from collections import abc from datetime import date -from math import isnan, nan from pathlib import Path from typing import Annotated, Literal import pandas as pd -from annotated_types import Ge, Le, Predicate +from annotated_types import Ge, Le from pydantic import ( AnyUrl, BaseModel, @@ -242,37 +241,3 @@ def preallocate_dataframe(cls, n_rows: int) -> pd.DataFrame: default_value = field_info.default if field_info.default is not PydanticUndefined else pd.NA data[field] = [default_value] * n_rows return pd.DataFrame(data) - - -class TrialDataChoiceWorld(TrialDataModel): - """Definition of Trial Data for ChoiceWorldSession""" - - contrast: Annotated[float, Ge(0.0), Le(1.0)] - position: float - quiescent_period: Annotated[float, Ge(0.0)] - reward_amount: Annotated[float, Ge(0.0)] - reward_valve_time: Annotated[float, Ge(0.0)] - stim_angle: Annotated[float, Ge(-180.0), Le(180.0)] - stim_freq: PositiveFloat - stim_gain: float - stim_phase: float - stim_reverse: bool - stim_sigma: float - trial_num: Annotated[int, Ge(0.0)] - pause_duration: Annotated[float, Ge(0.0)] = 0.0 - - # The following variables are only used in ActiveChoiceWorld - # We keep them here with fixed default values for sake of compatibility - # - # TODO: Yes, this should probably be done differently. - response_side: Literal[0] = 0 - response_time: Annotated[float, Predicate(isnan)] = nan - trial_correct: Literal[False] = False - - -class TrialDataActiveChoiceWorld(TrialDataChoiceWorld): - """Definition of Trial Data for ActiveChoiceWorldSession""" - - response_side: Annotated[int, Ge(-1), Le(1)] - response_time: Annotated[float, Ge(0.0)] - trial_correct: bool From b20bd36e363cfc33a8220a0e5072a94720638a15 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Mon, 2 Sep 2024 17:39:26 +0100 Subject: [PATCH 23/44] work on documentation --- .gitignore | 1 + .../_templates/custom-class-template.rst | 10 +++ .../_templates/custom-module-template.rst | 74 +++++++++++++++++++ docs/source/api.rst | 10 +++ docs/source/conf.py | 53 +++++++++++-- docs/source/index.rst | 1 + docs/source/reference_write_your_own_task.rst | 17 +++-- iblrig/base_choice_world.py | 9 ++- iblrig/net.py | 2 + iblrig/pydantic_definitions.py | 7 +- pdm.lock | 16 +++- pyproject.toml | 1 + 12 files changed, 179 insertions(+), 22 deletions(-) create mode 100644 docs/source/_templates/custom-class-template.rst create mode 100644 docs/source/_templates/custom-module-template.rst create mode 100644 docs/source/api.rst diff --git a/.gitignore b/.gitignore index f917b05c2..283cedd7f 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ devices/camera_recordings/*.layout .pdm-python /dist *~ +docs/source/api/* \ No newline at end of file diff --git a/docs/source/_templates/custom-class-template.rst b/docs/source/_templates/custom-class-template.rst new file mode 100644 index 000000000..77226c60c --- /dev/null +++ b/docs/source/_templates/custom-class-template.rst @@ -0,0 +1,10 @@ +{{ fullname | escape | underline}} + +.. currentmodule:: {{ module }} + +.. inheritance-diagram:: {{ objname }} +| + +.. autoclass:: {{ objname }} + :members: + :undoc-members: diff --git a/docs/source/_templates/custom-module-template.rst b/docs/source/_templates/custom-module-template.rst new file mode 100644 index 000000000..6509c87ce --- /dev/null +++ b/docs/source/_templates/custom-module-template.rst @@ -0,0 +1,74 @@ +{{ fullname | escape | underline}} + +.. automodule:: {{ fullname }} + {%- if fullname=='iblrig_tasks' %} + :private-members: + {% endif %} + + {% block attributes %} + {%- if attributes %} + .. rubric:: {{ _('Module Attributes') }} + + .. autosummary:: + :nosignatures: + :toctree: + {% for item in attributes %} + {{ item }} + {%- endfor %} + {% endif %} + {%- endblock %} + + {%- block functions %} + {%- if functions %} + .. rubric:: {{ _('Functions') }} + + .. autosummary:: + :nosignatures: + :toctree: + {% for item in functions %} + {{ item }} + {%- endfor %} + {% endif %} + {%- endblock %} + + {%- block classes %} + {%- if classes %} + .. rubric:: {{ _('Classes') }} + + .. autosummary:: + :nosignatures: + :toctree: + :template: custom-class-template.rst + {% for item in classes %} + {{ item }} + {%- endfor %} + {% endif %} + {%- endblock %} + + {%- block exceptions %} + {%- if exceptions %} + .. rubric:: {{ _('Exceptions') }} + + .. autosummary:: + :nosignatures: + :toctree: + {% for item in exceptions %} + {{ item }} + {%- endfor %} + {% endif %} + {%- endblock %} + +{%- block modules %} +{%- if modules %} +.. rubric:: Modules + +.. autosummary:: + :nosignatures: + :toctree: + :template: custom-module-template.rst + :recursive: +{% for item in modules %} + {{ item }} +{%- endfor %} +{% endif %} +{%- endblock %} diff --git a/docs/source/api.rst b/docs/source/api.rst new file mode 100644 index 000000000..b7643c321 --- /dev/null +++ b/docs/source/api.rst @@ -0,0 +1,10 @@ +API Reference +============= + +.. autosummary:: + :toctree: api + :template: custom-module-template.rst + :recursive: + + iblrig + iblrig_tasks \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index 4b87d1fc4..61c04719b 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,7 +1,10 @@ +import os +import sys from datetime import date +sys.path.insert(0, os.path.abspath('../..')) from iblrig import __version__ -from iblrig.constants import BASE_PATH + project = 'iblrig' copyright = f'2018 – {date.today().year} International Brain Laboratory' @@ -12,20 +15,41 @@ # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -extensions = ['sphinx_lesson', 'sphinx.ext.autosectionlabel', 'sphinx_simplepdf'] +templates_path = ['_templates'] +extensions = [ + 'sphinx_lesson', + 'sphinx.ext.autosectionlabel', + 'sphinx_simplepdf', + 'sphinx.ext.intersphinx', + 'sphinx.ext.napoleon', + 'sphinx.ext.autodoc', + 'sphinx.ext.autosummary', + 'sphinx.ext.inheritance_diagram', + 'sphinx.ext.todo', +] +autodoc_typehints = 'description' +autosummary_generate = True autosectionlabel_prefix_document = True source_suffix = ['.rst', '.md'] - -templates_path = ['_templates'] exclude_patterns = [] - +intersphinx_mapping = { + 'python': ('https://docs.python.org/3', None), + 'sphinx': ('https://www.sphinx-doc.org/en/master/', None), + 'matplotlib': ('https://matplotlib.org/stable/', None), + 'numpy': ('https://numpy.org/doc/stable/', None), + 'pandas': ('https://pandas.pydata.org/docs/', None), + 'scipy': ('https://docs.scipy.org/doc/scipy/', None), + 'one:': ('https://int-brain-lab.github.io/ONE/', None), + 'pydantic': ('https://docs.pydantic.dev/latest/', None), + 'iblenv': ('https://int-brain-lab.github.io/iblenv/', None), + 'pyserial': ('https://pyserial.readthedocs.io/en/latest/', None), + 'Sphinx': ('https://www.sphinx-doc.org/en/master/', None), +} # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output html_theme = 'sphinx_rtd_theme' -html_static_path = ['_static'] - simplepdf_vars = { 'primary': '#004f8c', @@ -39,3 +63,18 @@ 'docs_scope': 'external', 'cover_meta_data': 'International Brain Laboratory', } + +# -- Napoleon Settings ------------------------------------------------------- +napoleon_google_docstring = False +napoleon_numpy_docstring = True +napoleon_include_init_with_doc = True +napoleon_include_special_with_doc = False +napoleon_use_admonition_for_examples = True +napoleon_use_admonition_for_notes = True +napoleon_use_admonition_for_references = True +napoleon_use_ivar = True +napoleon_use_param = True +napoleon_use_rtype = False +napoleon_preprocess_types = True +napoleon_type_aliases = None +napoleon_attr_annotations = True \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index c043419f9..382286582 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -19,6 +19,7 @@ .. toctree:: :hidden: + api changelog .. toctree:: diff --git a/docs/source/reference_write_your_own_task.rst b/docs/source/reference_write_your_own_task.rst index 94871fc2b..6f19ad6b9 100644 --- a/docs/source/reference_write_your_own_task.rst +++ b/docs/source/reference_write_your_own_task.rst @@ -8,13 +8,13 @@ During the lifetime of the IBL project, we realized that multiple task variants This left us with the only option of developing a flexible task framework through hierarchical inheritance. -All tasks inherit from the ``iblrig.base_tasks.BaseSession`` class, which provides the following functionalities: +All tasks inherit from the :class:`iblrig.base_tasks.BaseSession` class, which provides the following functionalities: - read hardware parameters and rig parameters - optionally interfaces with the `Alyx experimental database `_ - creates the folder structure for the session - writes the task and rig parameters, log, and :doc:`acquisition description files <../description_file>` -Additionally the ``iblrig.base_tasks`` module provides "hardware mixins". Those are classes that provide hardware-specific functionalities, such as connecting to a Bpod or a rotary encoder. They are composed with the ``BaseSession`` class to create a task. +Additionally the :mod:`iblrig.base_tasks` module provides "hardware mixins". Those are classes that provide hardware-specific functionalities, such as connecting to a Bpod or a rotary encoder. They are composed with the :class:`~.iblrig.base_tasks.BaseSession` class to create a task. .. warning:: @@ -22,6 +22,13 @@ Additionally the ``iblrig.base_tasks`` module provides "hardware mixins". Those Forecasting all possible tasks and hardware add-ons and modification is fool's errand, however we can go through specific examples of task implementations. +Just a test +----------- + +Please see :class:`iblrig.pydantic_definitions.TrialDataModel` +Please see :class:`pydantic.BaseModel` + + Guide to Creating Your Own Task ------------------------------- @@ -33,21 +40,21 @@ What Happens When Running an IBL Task? - Reading of task parameters. - Instantiation of hardware mixins. -2. The task initiates the ``run()`` method. Prior to execution, this +2. The task initiates the :meth:`~.iblrig.base_tasks.run` method. Prior to execution, this method: - Launches the hardware modules. - Establishes a session folder. - Saves the parameters to disk. -3. The experiment unfolds: the ``run()`` method triggers the ``_run()`` +3. The experiment unfolds: the :meth:`~.iblrig.base_tasks.run` method triggers the ``_run()`` method within the child class: - Typically, this involves a loop that generates a Bpod state machine for each trial and runs it. 4. Upon SIGINT or when the maximum trial count is reached, the - experiment concludes. The end of the ``run()`` method includes: + experiment concludes. The end of the :meth:`~.iblrig.base_tasks.run` method includes: - Saving the final parameter file. - Recording administered water and session performance on Alyx. diff --git a/iblrig/base_choice_world.py b/iblrig/base_choice_world.py index 2fe57fb5b..97736275c 100644 --- a/iblrig/base_choice_world.py +++ b/iblrig/base_choice_world.py @@ -682,7 +682,7 @@ def get_state_machine_trial(self, i): class ActiveChoiceWorldTrialData(ChoiceWorldTrialData): - """Pydantic Model for Trial Data, extended from ChoiceWorldSession.""" + """Pydantic Model for Trial Data, extended from :class:`~.iblrig.base_choice_world.ChoiceWorldTrialData`.""" response_side: Annotated[int, Interval(ge=-1, le=1)] response_time: Annotated[float, Interval(ge=0.0)] @@ -693,6 +693,7 @@ 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 @@ -735,10 +736,12 @@ def show_trial_log(self, extra_info=''): def trial_completed(self, bpod_data): """ The purpose of this method is to - - update the trials table with information about the behaviour coming from the bpod - Constraints on the state machine data: + + - update the trials table with information about the behaviour coming from the bpod + Constraints on the state machine data: - mandatory states: ['correct', 'error', 'no_go', 'reward'] - optional states : ['omit_correct', 'omit_error', 'omit_no_go'] + :param bpod_data: :return: """ diff --git a/iblrig/net.py b/iblrig/net.py index 745888539..738a5c865 100644 --- a/iblrig/net.py +++ b/iblrig/net.py @@ -10,6 +10,8 @@ tasks: 'udp://123.654.8.8' ``` +Todo +---- TODO case study: starts services but times out due to one service. How to restart without stopping services? Perhaps it can throw a warning if the status is running but continue on anyway? diff --git a/iblrig/pydantic_definitions.py b/iblrig/pydantic_definitions.py index d7e32c453..0d68312a4 100644 --- a/iblrig/pydantic_definitions.py +++ b/iblrig/pydantic_definitions.py @@ -220,7 +220,8 @@ def preallocate_dataframe(cls, n_rows: int) -> pd.DataFrame: This method creates a pandas DataFrame with the same columns as the fields defined in the Pydantic model. Each column is initialized with the field's default value if available, otherwise with pandas.NA. - We use Pandas.NA for default values rather than NaN, None or Zero. This allows + We use Pandas.NA for default values rather than NaN, None or Zero. This allows us to clearly indicate missing + values - which will raise a Pydantic ValidationError. Parameters ---------- @@ -232,10 +233,6 @@ def preallocate_dataframe(cls, n_rows: int) -> pd.DataFrame: pd.DataFrame A DataFrame with `n_rows` rows and columns corresponding to the model's fields. """ - # dtypes = {field: field_info.annotation for field, field_info in cls.model_fields.items()} - # data = {field: [pd.NA] * n_rows for field in dtypes.keys()} - # return pd.DataFrame(data) - data = {} for field, field_info in cls.model_fields.items(): default_value = field_info.default if field_info.default is not PydanticUndefined else pd.NA diff --git a/pdm.lock b/pdm.lock index a5b08782c..9c4c55862 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "ci", "dev", "doc", "project-extraction", "test", "typing"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:02c08779c288dce1bc517fc41e2c3d385fd24fc15a0e2fa491f87e5f94863e0b" +content_hash = "sha256:330cd2b56b2399f658b3b05fbb715b5da2b8f6adf3b452435b779237209007a5" [[metadata.targets]] requires_python = "==3.10.*" @@ -567,6 +567,7 @@ version = "1.2.2" requires_python = ">=3.7" summary = "Backport of PEP 654 (exception groups)" groups = ["default", "ci", "dev", "doc", "test"] +marker = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -700,7 +701,7 @@ version = "3.0.3" requires_python = ">=3.7" summary = "Lightweight in-process concurrent programming" groups = ["dev", "doc"] -marker = "platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\"" +marker = "(platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\") and python_version < \"3.13\"" files = [ {file = "greenlet-3.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9da2bd29ed9e4f15955dd1595ad7bc9320308a3b766ef7f837e23ad4b4aac31a"}, {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d353cadd6083fdb056bb46ed07e4340b0869c305c8ca54ef9da3421acbdf6881"}, @@ -3039,6 +3040,17 @@ files = [ {file = "tycmd_wrapper-0.2.1-py3-none-win_amd64.whl", hash = "sha256:73c17d7c6d073d7dd2b050731988a5fabb3be69ec339c3ef5fcfaae42eefe090"}, ] +[[package]] +name = "types-pyserial" +version = "3.5.0.20240826" +requires_python = ">=3.8" +summary = "Typing stubs for pyserial" +groups = ["dev", "typing"] +files = [ + {file = "types-pyserial-3.5.0.20240826.tar.gz", hash = "sha256:c88c603734410ad714fba85eb10f145dc592ccf1542bb958f12a8481722f37db"}, + {file = "types_pyserial-3.5.0.20240826-py3-none-any.whl", hash = "sha256:f3fddafe593060afeec489ed6f6a18dcf05ae5eae1c8e9026b50c7960f00b076"}, +] + [[package]] name = "types-python-dateutil" version = "2.9.0.20240316" diff --git a/pyproject.toml b/pyproject.toml index 4ec7a74f7..741703339 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,6 +96,7 @@ typing = [ "types-PyYAML>=6.0.12.20240808", "types-requests>=2.32.0.20240712", "types-python-dateutil>=2.9.0.20240316", + "types-pyserial>=3.5.0.20240826", ] ci = [ "pytest-github-actions-annotate-failures>=0.2.0", From 4fdd124fddf0529813aabe72f19374cb08803732 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Mon, 2 Sep 2024 20:23:00 +0100 Subject: [PATCH 24/44] repair CI --- docs/source/_templates/custom-class-template.rst | 2 ++ docs/source/conf.py | 11 ++++++----- iblrig/base_choice_world.py | 5 ++--- iblrig/base_tasks.py | 12 ++++++------ iblrig/serial_singleton.py | 4 ++-- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/docs/source/_templates/custom-class-template.rst b/docs/source/_templates/custom-class-template.rst index 77226c60c..999ac4127 100644 --- a/docs/source/_templates/custom-class-template.rst +++ b/docs/source/_templates/custom-class-template.rst @@ -3,6 +3,8 @@ .. currentmodule:: {{ module }} .. inheritance-diagram:: {{ objname }} + :parts: 1 + | .. autoclass:: {{ objname }} diff --git a/docs/source/conf.py b/docs/source/conf.py index 61c04719b..976c709ae 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -25,10 +25,11 @@ 'sphinx.ext.autodoc', 'sphinx.ext.autosummary', 'sphinx.ext.inheritance_diagram', - 'sphinx.ext.todo', + 'sphinx.ext.viewcode', ] -autodoc_typehints = 'description' +autodoc_typehints = 'none' autosummary_generate = True +autosummary_imported_members = False autosectionlabel_prefix_document = True source_suffix = ['.rst', '.md'] exclude_patterns = [] @@ -65,7 +66,7 @@ } # -- Napoleon Settings ------------------------------------------------------- -napoleon_google_docstring = False +napoleon_google_docstring = True napoleon_numpy_docstring = True napoleon_include_init_with_doc = True napoleon_include_special_with_doc = False @@ -74,7 +75,7 @@ napoleon_use_admonition_for_references = True napoleon_use_ivar = True napoleon_use_param = True -napoleon_use_rtype = False +napoleon_use_rtype = True napoleon_preprocess_types = True napoleon_type_aliases = None -napoleon_attr_annotations = True \ No newline at end of file +napoleon_attr_annotations = True diff --git a/iblrig/base_choice_world.py b/iblrig/base_choice_world.py index 97736275c..4647e2979 100644 --- a/iblrig/base_choice_world.py +++ b/iblrig/base_choice_world.py @@ -114,7 +114,6 @@ class ChoiceWorldSession( # task_params = ChoiceWorldParams() base_parameters_file = Path(__file__).parent.joinpath('base_choice_world_params.yaml') - @property def get_trial_data_model(self): return ChoiceWorldTrialData @@ -129,7 +128,7 @@ def __init__(self, *args, delay_secs=0, **kwargs): self.block_num = -1 self.block_trial_num = -1 # init the tables, there are 2 of them: a trials table and a ambient sensor data table - self.trials_table = self.get_trial_data_model.preallocate_dataframe(NTRIALS_INIT) + self.trials_table = self.get_trial_data_model().preallocate_dataframe(NTRIALS_INIT) self.ambient_sensor_table = pd.DataFrame( { @@ -543,7 +542,7 @@ def save_trial_data_to_json(self, bpod_data: dict): """ # get trial's data as a dict, validate by passing through pydantic model trial_data = self.trials_table.iloc[self.trial_num].to_dict() - trial_data = self.get_trial_data_model.model_validate(trial_data).model_dump() + trial_data = self.get_trial_data_model().model_validate(trial_data).model_dump() # add bpod_data as 'behavior_data' trial_data['behavior_data'] = bpod_data diff --git a/iblrig/base_tasks.py b/iblrig/base_tasks.py index c94a03dbe..77776b078 100644 --- a/iblrig/base_tasks.py +++ b/iblrig/base_tasks.py @@ -29,12 +29,11 @@ from pythonosc import udp_client import ibllib.io.session_params as ses_params -import iblrig import iblrig.graphic as graph import iblrig.path_helper import pybpodapi from ibllib.oneibl.registration import IBLRegistrationClient -from iblrig import net, sound +from iblrig import net, path_helper, sound from iblrig.constants import BASE_PATH, BONSAI_EXE, PYSPIN_AVAILABLE from iblrig.frame2ttl import Frame2TTL from iblrig.hardware import SOFTCODE, Bpod, MyRotaryEncoder, sound_device_factory @@ -71,8 +70,8 @@ class BaseSession(ABC): extractor_tasks: list | None = None """list of str: An optional list of pipeline task class names to instantiate when preprocessing task data.""" - @abstractmethod - def get_trial_data_model(self) -> type[TrialDataModel]: ... + def get_trial_data_model(self): + return TrialDataModel def __init__( self, @@ -262,7 +261,7 @@ def _init_paths(self, append: bool = False) -> Bunch: * SETTINGS_FILE_PATH: contains the task settings `C:\iblrigv8_data\mainenlab\Subjects\SWC_043\2019-01-01\001\raw_task_data_00\_iblrig_taskSettings.raw.json` """ - rig_computer_paths = iblrig.path_helper.get_local_and_remote_paths( + rig_computer_paths = path_helper.get_local_and_remote_paths( local_path=self.iblrig_settings.iblrig_local_data_path, remote_path=self.iblrig_settings.iblrig_remote_data_path, lab=self.iblrig_settings.ALYX_LAB, @@ -315,7 +314,8 @@ def _setup_loggers(self, level='INFO', level_bpod='WARNING', file=None): self._logger = setup_logger('iblrig', level=level, file=file) # logger attr used by create_session to determine log level setup_logger('pybpodapi', level=level_bpod, file=file) - def _remove_file_loggers(self): + @staticmethod + def _remove_file_loggers(): for logger_name in ['iblrig', 'pybpodapi']: logger = logging.getLogger(logger_name) file_handlers = [fh for fh in logger.handlers if isinstance(fh, logging.FileHandler)] diff --git a/iblrig/serial_singleton.py b/iblrig/serial_singleton.py index d94c061b1..d2cad388f 100644 --- a/iblrig/serial_singleton.py +++ b/iblrig/serial_singleton.py @@ -188,7 +188,7 @@ def query(self, query, data_specifier=1): Parameters ---------- - query : any + query : Any Query to be sent to the serial device. data_specifier : int or str, default: 1 The number of bytes to receive from the serial device, or a format string @@ -222,7 +222,7 @@ def to_bytes(data: Any) -> bytes: Parameters ---------- - data : any + data : Any Data to be converted to bytestring. Returns From dca9248c7a74d43c3f6116546a65034b00077d39 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Mon, 2 Sep 2024 22:40:12 +0100 Subject: [PATCH 25/44] simplify use of TrialDataModel --- iblrig/base_choice_world.py | 37 ++++--------------------------------- iblrig/base_tasks.py | 28 ++++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/iblrig/base_choice_world.py b/iblrig/base_choice_world.py index 4647e2979..adfed7985 100644 --- a/iblrig/base_choice_world.py +++ b/iblrig/base_choice_world.py @@ -1,7 +1,6 @@ """Extends the base_tasks modules by providing task logic around the Choice World protocol.""" import abc -import json import logging import math import random @@ -9,7 +8,7 @@ import time from pathlib import Path from string import ascii_letters -from typing import Annotated, final +from typing import Annotated import numpy as np import pandas as pd @@ -113,9 +112,7 @@ class ChoiceWorldSession( ): # task_params = ChoiceWorldParams() base_parameters_file = Path(__file__).parent.joinpath('base_choice_world_params.yaml') - - def get_trial_data_model(self): - return ChoiceWorldTrialData + TrialDataModel = ChoiceWorldTrialData def __init__(self, *args, delay_secs=0, **kwargs): super().__init__(**kwargs) @@ -128,8 +125,7 @@ def __init__(self, *args, delay_secs=0, **kwargs): self.block_num = -1 self.block_trial_num = -1 # init the tables, there are 2 of them: a trials table and a ambient sensor data table - self.trials_table = self.get_trial_data_model().preallocate_dataframe(NTRIALS_INIT) - + self.trials_table = self.TrialDataModel.preallocate_dataframe(NTRIALS_INIT) self.ambient_sensor_table = pd.DataFrame( { 'Temperature_C': np.zeros(NTRIALS_INIT) * np.NaN, @@ -527,30 +523,6 @@ def trial_completed(self, bpod_data): self.paths.SESSION_FOLDER.joinpath('transfer_me.flag').touch() self.check_sync_pulses(bpod_data=bpod_data) - @final - def save_trial_data_to_json(self, bpod_data: dict): - """Validate and save trial data. - - This method retrieve's the current trial's data from the trial_table and validates it using a Pydantic model - (self.TrialDataDefinition). In merges in the trial's bpod_data dict and appends everything to the session's - JSON data file. - - Parameters - ---------- - bpod_data : dict - Trial data returned from pybpod. - """ - # get trial's data as a dict, validate by passing through pydantic model - trial_data = self.trials_table.iloc[self.trial_num].to_dict() - trial_data = self.get_trial_data_model().model_validate(trial_data).model_dump() - - # add bpod_data as 'behavior_data' - trial_data['behavior_data'] = bpod_data - - # write json data to file - with open(self.paths['DATA_FILE_PATH'], 'a') as fp: - fp.write(json.dumps(trial_data) + '\n') - def check_sync_pulses(self, bpod_data): # todo move this in the post trial when we have a task flow if not self.bpod.is_connected: @@ -703,8 +675,7 @@ class ActiveChoiceWorldSession(ChoiceWorldSession): The TrainingChoiceWorld, BiasedChoiceWorld are all subclasses of this class """ - def get_trial_data_model(self): - return ActiveChoiceWorldTrialData + TrialDataModel = ActiveChoiceWorldTrialData def __init__(self, **kwargs): super().__init__(**kwargs) diff --git a/iblrig/base_tasks.py b/iblrig/base_tasks.py index 77776b078..c7fa6bf21 100644 --- a/iblrig/base_tasks.py +++ b/iblrig/base_tasks.py @@ -20,6 +20,7 @@ from collections import OrderedDict from collections.abc import Callable from pathlib import Path +from typing import final import numpy as np import pandas as pd @@ -70,8 +71,7 @@ class BaseSession(ABC): extractor_tasks: list | None = None """list of str: An optional list of pipeline task class names to instantiate when preprocessing task data.""" - def get_trial_data_model(self): - return TrialDataModel + TrialDataModel: type[TrialDataModel] def __init__( self, @@ -442,6 +442,30 @@ def save_task_parameters_to_json_file(self, destination_folder: Path | None = No json.dump(output_dict, outfile, indent=4, sort_keys=True, default=str) # converts datetime objects to string return json_file # PosixPath + @final + def save_trial_data_to_json(self, bpod_data: dict): + """Validate and save trial data. + + This method retrieve's the current trial's data from the trial_table and validates it using a Pydantic model + (self.TrialDataDefinition). In merges in the trial's bpod_data dict and appends everything to the session's + JSON data file. + + Parameters + ---------- + bpod_data : dict + Trial data returned from pybpod. + """ + # get trial's data as a dict, validate by passing through pydantic model + trial_data = self.trials_table.iloc[self.trial_num].to_dict() + trial_data = self.TrialDataModel.model_validate(trial_data).model_dump() + + # add bpod_data as 'behavior_data' + trial_data['behavior_data'] = bpod_data + + # write json data to file + with open(self.paths['DATA_FILE_PATH'], 'a') as fp: + fp.write(json.dumps(trial_data) + '\n') + @property def one(self): """ONE getter.""" From dd2301d104555d2050d401df91e43c2aaa0d06d8 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Tue, 3 Sep 2024 08:38:08 +0100 Subject: [PATCH 26/44] minor fix --- docs/source/conf.py | 4 +++- iblrig/base_choice_world.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 976c709ae..5aa9d0042 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -27,7 +27,9 @@ 'sphinx.ext.inheritance_diagram', 'sphinx.ext.viewcode', ] -autodoc_typehints = 'none' +# autodoc_mock_imports = ["PySpin"] +autodoc_typehints = 'description' +autodoc_member_order = 'groupwise' autosummary_generate = True autosummary_imported_members = False autosectionlabel_prefix_document = True diff --git a/iblrig/base_choice_world.py b/iblrig/base_choice_world.py index adfed7985..d754e376c 100644 --- a/iblrig/base_choice_world.py +++ b/iblrig/base_choice_world.py @@ -8,7 +8,7 @@ import time from pathlib import Path from string import ascii_letters -from typing import Annotated +from typing import Annotated, Any import numpy as np import pandas as pd @@ -508,7 +508,7 @@ def draw_next_trial_info(self, pleft=0.5, contrast=None, position=None, reward_a self.trials_table.at[self.trial_num, 'stim_probability_left'] = pleft self.send_trial_info_to_bonsai() - def trial_completed(self, bpod_data): + def trial_completed(self, bpod_data: dict[str, Any]) -> None: # if the reward state has not been triggered, null the reward if np.isnan(bpod_data['States timestamps']['reward'][0][0]): self.trials_table.at[self.trial_num, 'reward_amount'] = 0 From 0c9f77de0e6d81e7bf3a3f4189d65d0a953e5560 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Tue, 3 Sep 2024 11:18:34 +0100 Subject: [PATCH 27/44] make protocol_name an abstract property of BaseSession --- iblrig/base_tasks.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/iblrig/base_tasks.py b/iblrig/base_tasks.py index c7fa6bf21..66b737994 100644 --- a/iblrig/base_tasks.py +++ b/iblrig/base_tasks.py @@ -20,7 +20,7 @@ from collections import OrderedDict from collections.abc import Callable from pathlib import Path -from typing import final +from typing import final, Protocol import numpy as np import pandas as pd @@ -58,7 +58,7 @@ class BaseSession(ABC): version = None """str: !!CURRENTLY UNUSED!! task version string.""" - protocol_name: str | None = None + # protocol_name: str | None = None """str: The name of the task protocol (NB: avoid spaces).""" base_parameters_file: Path | None = None """Path: A YAML file containing base, default task parameters.""" @@ -73,6 +73,11 @@ class BaseSession(ABC): TrialDataModel: type[TrialDataModel] + @property + @abstractmethod + def protocol_name(self) -> str: + ... + def __init__( self, subject=None, @@ -108,7 +113,6 @@ def __init__( :param append: bool, if True, append to the latest existing session of the same subject for the same day """ self.extractor_tasks = getattr(self, 'extractor_tasks', None) - assert self.protocol_name is not None, 'Protocol name must be defined by the child class' self._logger = None self._setup_loggers(level=log_level) if not isinstance(self, EmptySession): From 770d43571f8700fef8fea90f901c0a7375c5c4d1 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Tue, 3 Sep 2024 11:29:18 +0100 Subject: [PATCH 28/44] do not cache subjects and projects --- iblrig/gui/wizard.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/iblrig/gui/wizard.py b/iblrig/gui/wizard.py index 395086136..1aeab0238 100644 --- a/iblrig/gui/wizard.py +++ b/iblrig/gui/wizard.py @@ -249,12 +249,14 @@ def login( QtWidgets.QMessageBox().critical(None, 'Error', f'{message}\n\n{solution}') # get subjects from Alyx: this is the set of subjects that are alive and not stock in the lab defined in settings - rest_subjects = self.alyx.rest('subjects', 'list', alive=True, stock=False, lab=self.iblrig_settings['ALYX_LAB']) + rest_subjects = self.alyx.rest( + 'subjects', 'list', alive=True, stock=False, lab=self.iblrig_settings['ALYX_LAB'], no_cache=True + ) self.all_subjects.remove(self.test_subject_name) self.all_subjects = [self.test_subject_name] + sorted(set(self.all_subjects + [s['nickname'] for s in rest_subjects])) # then get the projects that map to the current user - rest_projects = self.alyx.rest('projects', 'list') + rest_projects = self.alyx.rest('projects', 'list', no_cache=True) projects = [p['name'] for p in rest_projects if (username in p['users'] or len(p['users']) == 0)] self.all_projects = sorted(set(projects + self.all_projects)) From 768a1fa142e9227403435cac18756a8b79c61179 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Tue, 3 Sep 2024 12:13:43 +0100 Subject: [PATCH 29/44] Update base_tasks.py --- iblrig/base_tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iblrig/base_tasks.py b/iblrig/base_tasks.py index 66b737994..f5d6d20de 100644 --- a/iblrig/base_tasks.py +++ b/iblrig/base_tasks.py @@ -20,7 +20,7 @@ from collections import OrderedDict from collections.abc import Callable from pathlib import Path -from typing import final, Protocol +from typing import Protocol, final import numpy as np import pandas as pd From 0ad4f6f1f8fb9fad1f27b1a3ce1994677302c7fb Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Tue, 3 Sep 2024 12:15:43 +0100 Subject: [PATCH 30/44] minor changes to base_tasks (protocol, abstract properties) --- iblrig/base_tasks.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/iblrig/base_tasks.py b/iblrig/base_tasks.py index f5d6d20de..370d4da79 100644 --- a/iblrig/base_tasks.py +++ b/iblrig/base_tasks.py @@ -55,6 +55,10 @@ log = logging.getLogger(__name__) +class HasBpod(Protocol): + bpod: Bpod + + class BaseSession(ABC): version = None """str: !!CURRENTLY UNUSED!! task version string.""" @@ -75,8 +79,7 @@ class BaseSession(ABC): @property @abstractmethod - def protocol_name(self) -> str: - ... + def protocol_name(self) -> str: ... def __init__( self, @@ -721,6 +724,8 @@ def exit(self): class BonsaiRecordingMixin(BaseSession): + config: dict + def init_mixin_bonsai_recordings(self, *args, **kwargs): self.bonsai_camera = Bunch({'udp_client': OSCClient(port=7111)}) self.bonsai_microphone = Bunch({'udp_client': OSCClient(port=7112)}) @@ -1069,7 +1074,7 @@ def valve_open(self, reward_valve_time): return self.bpod.session.current_trial.export() -class SoundMixin(BaseSession): +class SoundMixin(BaseSession, HasBpod): """Sound interface methods for state machine.""" def init_mixin_sound(self): From 33716579d12f085a803ef9d70d735bca4c7f6f08 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Tue, 3 Sep 2024 17:28:29 +0100 Subject: [PATCH 31/44] Update custom-module-template.rst --- docs/source/_templates/custom-module-template.rst | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/source/_templates/custom-module-template.rst b/docs/source/_templates/custom-module-template.rst index 6509c87ce..9fcf2406d 100644 --- a/docs/source/_templates/custom-module-template.rst +++ b/docs/source/_templates/custom-module-template.rst @@ -1,10 +1,6 @@ {{ fullname | escape | underline}} .. automodule:: {{ fullname }} - {%- if fullname=='iblrig_tasks' %} - :private-members: - {% endif %} - {% block attributes %} {%- if attributes %} .. rubric:: {{ _('Module Attributes') }} From 9552658b4ec7c0bb5f7edaca11830a98d886f6bf Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Tue, 3 Sep 2024 18:42:20 +0100 Subject: [PATCH 32/44] add `TrialDataModel` for all session types in `base_choice_world` --- iblrig/base_choice_world.py | 48 +++++++++++++------ .../test/tasks/test_training_choice_world.py | 4 +- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/iblrig/base_choice_world.py b/iblrig/base_choice_world.py index d754e376c..f9b968920 100644 --- a/iblrig/base_choice_world.py +++ b/iblrig/base_choice_world.py @@ -13,6 +13,7 @@ import numpy as np import pandas as pd from annotated_types import Interval, IsNan +from pydantic import NonNegativeFloat, NonNegativeInt import iblrig.base_tasks import iblrig.graphic @@ -79,17 +80,17 @@ class ChoiceWorldTrialData(TrialDataModel): contrast: Annotated[float, Interval(ge=0.0, le=1.0)] position: float - quiescent_period: Annotated[float, Interval(ge=0.0)] - reward_amount: Annotated[float, Interval(ge=0.0)] - reward_valve_time: Annotated[float, Interval(ge=0.0)] + quiescent_period: NonNegativeFloat + reward_amount: NonNegativeFloat + reward_valve_time: NonNegativeFloat stim_angle: Annotated[float, Interval(ge=-180.0, le=180.0)] - stim_freq: Annotated[float, Interval(ge=0.0)] + stim_freq: NonNegativeFloat stim_gain: float stim_phase: float stim_reverse: bool stim_sigma: float - trial_num: Annotated[int, Interval(ge=0.0)] - pause_duration: Annotated[float, Interval(ge=0.0)] = 0.0 + trial_num: NonNegativeInt + pause_duration: NonNegativeFloat = 0.0 # The following variables are only used in ActiveChoiceWorld # We keep them here with fixed default values for sake of compatibility @@ -582,12 +583,15 @@ def event_reward(self): return self.device_rotary_encoder.THRESHOLD_EVENTS[(1 if self.task_params.STIM_REVERSE else -1) * self.position] +class HabituationChoiceWorldTrialData(ChoiceWorldTrialData): + """Pydantic Model for Trial Data, extended from :class:`~.iblrig.base_choice_world.ChoiceWorldTrialData`.""" + + delay_to_stim_center: NonNegativeFloat + + class HabituationChoiceWorldSession(ChoiceWorldSession): protocol_name = '_iblrig_tasks_habituationChoiceWorld' - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.trials_table['delay_to_stim_center'] = np.zeros(NTRIALS_INIT) * np.NaN + TrialDataModel = HabituationChoiceWorldTrialData def next_trial(self): self.trial_num += 1 @@ -656,7 +660,7 @@ class ActiveChoiceWorldTrialData(ChoiceWorldTrialData): """Pydantic Model for Trial Data, extended from :class:`~.iblrig.base_choice_world.ChoiceWorldTrialData`.""" response_side: Annotated[int, Interval(ge=-1, le=1)] - response_time: Annotated[float, Interval(ge=0.0)] + response_time: NonNegativeFloat trial_correct: bool @@ -749,6 +753,13 @@ def trial_completed(self, bpod_data): raise e +class BiasedChoiceWorldTrialData(ActiveChoiceWorldTrialData): + """Pydantic Model for Trial Data, extended from :class:`~.iblrig.base_choice_world.ChoiceWorldTrialData`.""" + + block_num: NonNegativeInt = 0 + block_trial_num: NonNegativeInt = 0 + + class BiasedChoiceWorldSession(ActiveChoiceWorldSession): """ Biased choice world session is the instantiation of ActiveChoiceWorld where the notion of biased @@ -757,14 +768,13 @@ class BiasedChoiceWorldSession(ActiveChoiceWorldSession): base_parameters_file = Path(__file__).parent.joinpath('base_biased_choice_world_params.yaml') protocol_name = '_iblrig_tasks_biasedChoiceWorld' + TrialDataModel = BiasedChoiceWorldTrialData def __init__(self, **kwargs): super().__init__(**kwargs) self.blocks_table = pd.DataFrame( {'probability_left': np.zeros(NBLOCKS_INIT) * np.NaN, 'block_length': np.zeros(NBLOCKS_INIT, dtype=np.int16) * -1} ) - self.trials_table['block_num'] = np.zeros(NTRIALS_INIT, dtype=np.int16) - self.trials_table['block_trial_num'] = np.zeros(NTRIALS_INIT, dtype=np.int16) def new_block(self): """ @@ -820,6 +830,13 @@ def show_trial_log(self): super().show_trial_log(extra_info=extra_info) +class TrainingChoiceWorldTrialData(ActiveChoiceWorldTrialData): + """Pydantic Model for Trial Data, extended from :class:`~.iblrig.base_choice_world.ActiveChoiceWorldTrialData`.""" + + training_phase: NonNegativeInt + debias_trial: bool + + class TrainingChoiceWorldSession(ActiveChoiceWorldSession): """ The TrainingChoiceWorldSession corresponds to the first training protocol of the choice world task. @@ -828,6 +845,7 @@ class TrainingChoiceWorldSession(ActiveChoiceWorldSession): """ protocol_name = '_iblrig_tasks_trainingChoiceWorld' + TrialDataModel = TrainingChoiceWorldTrialData def __init__(self, training_phase=-1, adaptive_reward=-1.0, adaptive_gain=None, **kwargs): super().__init__(**kwargs) @@ -851,8 +869,6 @@ def __init__(self, training_phase=-1, adaptive_reward=-1.0, adaptive_gain=None, log.critical(f'Adaptive gain manually set to {adaptive_gain} degrees/mm') self.session_info['ADAPTIVE_GAIN_VALUE'] = adaptive_gain self.var = {'training_phase_trial_counts': np.zeros(6), 'last_10_responses_sides': np.zeros(10)} - self.trials_table['training_phase'] = np.zeros(NTRIALS_INIT, dtype=np.int8) - self.trials_table['debias_trial'] = np.zeros(NTRIALS_INIT, dtype=bool) @property def default_reward_amount(self): @@ -934,6 +950,8 @@ def next_trial(self): position = self.task_params.STIM_POSITIONS[int(np.random.normal(average_right, 0.5) >= 0.5)] # contrast is the last contrast contrast = last_contrast + else: + self.trials_table.at[self.trial_num, 'debias_trial'] = False # save and send trial info to bonsai self.draw_next_trial_info(pleft=self.task_params.PROBABILITY_LEFT, position=position, contrast=contrast) self.trials_table.at[self.trial_num, 'training_phase'] = self.training_phase diff --git a/iblrig/test/tasks/test_training_choice_world.py b/iblrig/test/tasks/test_training_choice_world.py index 88dc9bf99..793970c9b 100644 --- a/iblrig/test/tasks/test_training_choice_world.py +++ b/iblrig/test/tasks/test_training_choice_world.py @@ -77,9 +77,9 @@ def test_task(self): normalized_counts = normalized_counts / (nt / contrast_set.size) np.testing.assert_array_less(normalized_counts, 0.33) if debias: - assert np.sum(trials_table['debias_trial']) > 20 + assert trials_table.debias_trial.astype(int).sum() > 20 else: - assert np.sum(trials_table['debias_trial']) == 0 + assert trials_table.debias_trial.astype(int).sum() == 0 class TestInstantiationTraining(BaseTestCases.CommonTestInstantiateTask): From 1d178bcffee45cf79bffcf7db1da1becacc79cd5 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Tue, 3 Sep 2024 21:31:19 +0100 Subject: [PATCH 33/44] Update reference_write_your_own_task.rst --- docs/source/reference_write_your_own_task.rst | 7 ------- 1 file changed, 7 deletions(-) diff --git a/docs/source/reference_write_your_own_task.rst b/docs/source/reference_write_your_own_task.rst index 6f19ad6b9..bcf933895 100644 --- a/docs/source/reference_write_your_own_task.rst +++ b/docs/source/reference_write_your_own_task.rst @@ -22,13 +22,6 @@ Additionally the :mod:`iblrig.base_tasks` module provides "hardware mixins". Tho Forecasting all possible tasks and hardware add-ons and modification is fool's errand, however we can go through specific examples of task implementations. -Just a test ------------ - -Please see :class:`iblrig.pydantic_definitions.TrialDataModel` -Please see :class:`pydantic.BaseModel` - - Guide to Creating Your Own Task ------------------------------- From f37a84b7f4a498335a86799474f7e4484e2b0cb4 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 4 Sep 2024 00:04:50 +0100 Subject: [PATCH 34/44] add private submodules of iblrig_tasks to API reference --- .../source/_templates/custom-module-template.rst | 16 +++++++++++++++- docs/source/api.rst | 2 +- iblrig/base_tasks.py | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/source/_templates/custom-module-template.rst b/docs/source/_templates/custom-module-template.rst index 9fcf2406d..405e407a9 100644 --- a/docs/source/_templates/custom-module-template.rst +++ b/docs/source/_templates/custom-module-template.rst @@ -55,7 +55,7 @@ {%- endblock %} {%- block modules %} -{%- if modules %} +{%- if modules or name == 'iblrig_tasks' %} .. rubric:: Modules .. autosummary:: @@ -64,7 +64,21 @@ :template: custom-module-template.rst :recursive: {% for item in modules %} + {%- if item != 'test' %} {# EXCLUDE TESTS FROM API #} {{ item }} + {% endif %} {%- endfor %} +{%- if name == 'iblrig_tasks' %} + _iblrig_tasks_advancedChoiceWorld + _iblrig_tasks_biasedChoiceWorld + _iblrig_tasks_ephysChoiceWorld + _iblrig_tasks_habituationChoiceWorld + _iblrig_tasks_ImagingChoiceWorld + _iblrig_tasks_neuroModulatorChoiceWorld + _iblrig_tasks_passiveChoiceWorld + _iblrig_tasks_spontaneous + _iblrig_tasks_trainingChoiceWorld + _iblrig_tasks_trainingPhaseChoiceWorld +{% endif %} {% endif %} {%- endblock %} diff --git a/docs/source/api.rst b/docs/source/api.rst index b7643c321..793234776 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -7,4 +7,4 @@ API Reference :recursive: iblrig - iblrig_tasks \ No newline at end of file + iblrig_tasks diff --git a/iblrig/base_tasks.py b/iblrig/base_tasks.py index 370d4da79..bbff5be17 100644 --- a/iblrig/base_tasks.py +++ b/iblrig/base_tasks.py @@ -524,7 +524,7 @@ def register_to_alyx(self): See Also -------- - ibllib.oneibl.IBLRegistrationClient.register_session - The registration method. + :external+iblenv:meth:`ibllib.oneibl.registration.IBLRegistrationClient.register_session` - The registration method. """ if self.session_info['SUBJECT_NAME'] in ('iblrig_test_subject', 'test', 'test_subject'): log.warning('Not registering test subject to Alyx') From 75932fa9761b40624b3ad8c035c09ce1aa37f15e Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 4 Sep 2024 11:15:40 +0100 Subject: [PATCH 35/44] add TrialDataModel to NeuroModulatorChoiceWorld --- .../_iblrig_tasks_neuroModulatorChoiceWorld/task.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/iblrig_tasks/_iblrig_tasks_neuroModulatorChoiceWorld/task.py b/iblrig_tasks/_iblrig_tasks_neuroModulatorChoiceWorld/task.py index 71fea2bc2..5e240b58d 100644 --- a/iblrig_tasks/_iblrig_tasks_neuroModulatorChoiceWorld/task.py +++ b/iblrig_tasks/_iblrig_tasks_neuroModulatorChoiceWorld/task.py @@ -1,9 +1,10 @@ import logging import numpy as np +from pydantic import NonNegativeFloat import iblrig.misc -from iblrig.base_choice_world import BiasedChoiceWorldSession +from iblrig.base_choice_world import BiasedChoiceWorldSession, BiasedChoiceWorldTrialData from iblrig.hardware import SOFTCODE from pybpodapi.protocol import StateMachine @@ -11,13 +12,17 @@ log = logging.getLogger(__name__) +class NeuroModulatorChoiceTrialData(BiasedChoiceWorldTrialData): + omit_feedback: bool + choice_delay: NonNegativeFloat + + class Session(BiasedChoiceWorldSession): protocol_name = '_iblrig_tasks_neuromodulatorChoiceWorld' + TrialDataModel = NeuroModulatorChoiceTrialData def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.trials_table['omit_feedback'] = np.zeros(self.trials_table.shape[0], dtype=bool) - self.trials_table['choice_delay'] = np.zeros(self.trials_table.shape[0], dtype=np.float32) def next_trial(self): super().next_trial() From b39fd8ed61ecf9d0631a1186709d41acb4680a99 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 4 Sep 2024 15:09:34 +0100 Subject: [PATCH 36/44] EphysCW: actually use pregenerated data from parquet --- CHANGELOG.md | 4 +++- iblrig/base_choice_world.py | 5 +++-- iblrig/test/tasks/test_biased_choice_world_family.py | 11 +++++++++++ iblrig_tasks/_iblrig_tasks_ephysChoiceWorld/task.py | 11 +++++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e12359068..0a46c8f3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,9 @@ Changelog 8.23.2 ------ -* add validation script for Bpod HiFi Module +* add validation script for Bpod HiFi Module (folder `scripts`) +* fix: issue with `_ephysChoiceWorld` - values from the pre-generated sessions were not actually used +* feature: validate values in `trials_table` using Pydantic 8.23.1 ------ diff --git a/iblrig/base_choice_world.py b/iblrig/base_choice_world.py index f9b968920..1a950d98f 100644 --- a/iblrig/base_choice_world.py +++ b/iblrig/base_choice_world.py @@ -815,8 +815,9 @@ def next_trial(self): # get and store probability left pleft = self.blocks_table.loc[self.block_num, 'probability_left'] # update trial table fields specific to biased choice world task - self.trials_table.at[self.trial_num, 'block_num'] = self.block_num - self.trials_table.at[self.trial_num, 'block_trial_num'] = self.block_trial_num + if self.trials_table.at[self.trial_num, 'block_num'] is pd.NA: + self.trials_table.at[self.trial_num, 'block_num'] = self.block_num + self.trials_table.at[self.trial_num, 'block_trial_num'] = self.block_trial_num # save and send trial info to bonsai self.draw_next_trial_info(pleft=pleft) diff --git a/iblrig/test/tasks/test_biased_choice_world_family.py b/iblrig/test/tasks/test_biased_choice_world_family.py index a919aeb7f..3cb09ca0b 100644 --- a/iblrig/test/tasks/test_biased_choice_world_family.py +++ b/iblrig/test/tasks/test_biased_choice_world_family.py @@ -105,6 +105,17 @@ def setUp(self) -> None: self.get_task_kwargs() self.task = EphysChoiceWorldSession(**self.task_kwargs) + def test_task(self, _=None): + super().test_task() + + # check that the task in fact uses the pre-generated data + cols = list( + set(self.task.get_session_template(0).columns) + - {'index', 'reward_valve_time', 'response_side', 'response_time', 'trial_correct'} + ) + template = self.task.get_session_template(0).head(len(self.task.trials_table)) + assert (self.task.trials_table == template)[cols].all().all() + class TestNeuroModulatorBiasedChoiceWorld(TestInstantiationBiased): def setUp(self) -> None: diff --git a/iblrig_tasks/_iblrig_tasks_ephysChoiceWorld/task.py b/iblrig_tasks/_iblrig_tasks_ephysChoiceWorld/task.py index 8cf2ca6c2..15aa3e54f 100644 --- a/iblrig_tasks/_iblrig_tasks_ephysChoiceWorld/task.py +++ b/iblrig_tasks/_iblrig_tasks_ephysChoiceWorld/task.py @@ -24,6 +24,17 @@ def __init__(self, *args, session_template_id=0, **kwargs): block_length=pd.NamedAgg(column='stim_probability_left', aggfunc='count'), ) + def next_trial(self): + self.trial_num += 1 + trial_params = self.trials_table.iloc[self.trial_num] + self.block_num = trial_params['block_num'] + self.draw_next_trial_info( + pleft=trial_params['stim_probability_left'], + contrast=trial_params['contrast'], + position=trial_params['position'], + reward_amount=trial_params['reward_amount'], + ) + @staticmethod def get_session_template(session_template_id: int) -> pd.DataFrame: """ From 71e9be0d50921df0934c80d5e2c8287b4898020b Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 4 Sep 2024 15:20:21 +0100 Subject: [PATCH 37/44] Update base_choice_world.py --- iblrig/base_choice_world.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/iblrig/base_choice_world.py b/iblrig/base_choice_world.py index 1a950d98f..f9b968920 100644 --- a/iblrig/base_choice_world.py +++ b/iblrig/base_choice_world.py @@ -815,9 +815,8 @@ def next_trial(self): # get and store probability left pleft = self.blocks_table.loc[self.block_num, 'probability_left'] # update trial table fields specific to biased choice world task - if self.trials_table.at[self.trial_num, 'block_num'] is pd.NA: - self.trials_table.at[self.trial_num, 'block_num'] = self.block_num - self.trials_table.at[self.trial_num, 'block_trial_num'] = self.block_trial_num + self.trials_table.at[self.trial_num, 'block_num'] = self.block_num + self.trials_table.at[self.trial_num, 'block_trial_num'] = self.block_trial_num # save and send trial info to bonsai self.draw_next_trial_info(pleft=pleft) From 6c6684f1975a7ac9c253de1059ffdb81f4c1ddb2 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 4 Sep 2024 17:51:57 +0100 Subject: [PATCH 38/44] correct trial fixtures for ephysChoiceWorld --- .../trials_fixtures.pqt | Bin 535876 -> 535878 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/iblrig_tasks/_iblrig_tasks_ephysChoiceWorld/trials_fixtures.pqt b/iblrig_tasks/_iblrig_tasks_ephysChoiceWorld/trials_fixtures.pqt index 165d1ee509e1db6001e67e6eb449104c2553694f..35cea61a235327a22bde54922a0838896fc2c2c8 100644 GIT binary patch delta 620 zcmX?dOX1ipg$ak58Q26i9y_N3qNn??bHr}`qB@5U%-ag&HGeQ^|6sxh#7x^im@rqb z1n8hx`$i&7l-ETJgN{}(zf6Zps1dHtB;jrGGFqi#X09ZWo9s3bhu(gT29C6$0 zzOW|>d0Sp2NLym)^04xIZaWt3#^sF4Fq~KfJbe`1$!A$7KmGb-i6o& z^l0jI13nHJ#%J4o`8b@pm<-LQPn6)0W;N0S!s#0&IJy`kx7$c^Y~(fY4M{Hbbv7yY z%rhwVboMHb3^&b=jPOfK$qlPaHp(eW%JekzboQ+D$?`1q&GfR|F00OQh?P-c`U4#f VDUiXxbvPV3eyB4r1ULp60sunjtq=eJ delta 639 zcmX?hOX0{Zg$ak51sK>i9y_N3qNn??bHr}`qB@5U%-ag&HGeQ^|6sxh#7x^im@rqb z1n8hx`$OLq_-)#1kAY-=wn$4~W7TL$cVZA+JF8j9tuz2J<_9LudYZG}n;X9 z1#zpjy|s}MWsp<_8q3E6wGrsacy6^VU*Ix8&$@sO1bQ=oM{UIgdl^v{h+BZ(h1do3 zXzFwWJ`Neir`vt`IGni@E%gla43uhh6qE`Q^HLIvUl5a1YO2mqn5v# Date: Wed, 4 Sep 2024 17:53:30 +0100 Subject: [PATCH 39/44] simplify overriding of trial parameters in draw_next_trial_info --- iblrig/base_choice_world.py | 20 ++++++++++++------- iblrig/base_tasks.py | 12 ++++++++++- .../tasks/test_biased_choice_world_family.py | 2 +- .../_iblrig_tasks_ephysChoiceWorld/task.py | 9 ++------- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/iblrig/base_choice_world.py b/iblrig/base_choice_world.py index f9b968920..c78a06602 100644 --- a/iblrig/base_choice_world.py +++ b/iblrig/base_choice_world.py @@ -79,6 +79,7 @@ class ChoiceWorldTrialData(TrialDataModel): """Pydantic Model for Trial Data.""" contrast: Annotated[float, Interval(ge=0.0, le=1.0)] + stim_probability_left: Annotated[float, Interval(ge=0.0, le=1.0)] position: float quiescent_period: NonNegativeFloat reward_amount: NonNegativeFloat @@ -86,7 +87,7 @@ class ChoiceWorldTrialData(TrialDataModel): stim_angle: Annotated[float, Interval(ge=-180.0, le=180.0)] stim_freq: NonNegativeFloat stim_gain: float - stim_phase: float + stim_phase: Annotated[float, Interval(ge=0.0, le=2 * math.pi)] stim_reverse: bool stim_sigma: float trial_num: NonNegativeInt @@ -478,20 +479,18 @@ def next_trial(self): def default_reward_amount(self): return self.task_params.REWARD_AMOUNT_UL - def draw_next_trial_info(self, pleft=0.5, contrast=None, position=None, reward_amount=None): + def draw_next_trial_info(self, pleft=0.5, **kwargs): """Draw next trial variables. calls :meth:`send_trial_info_to_bonsai`. This is called by the `next_trial` method before updating the Bpod state machine. """ - if contrast is None: - contrast = misc.draw_contrast(self.task_params.CONTRAST_SET, self.task_params.CONTRAST_SET_PROBABILITY_TYPE) assert len(self.task_params.STIM_POSITIONS) == 2, 'Only two positions are supported' - position = position or int(np.random.choice(self.task_params.STIM_POSITIONS, p=[pleft, 1 - pleft])) + contrast = misc.draw_contrast(self.task_params.CONTRAST_SET, self.task_params.CONTRAST_SET_PROBABILITY_TYPE) + position = int(np.random.choice(self.task_params.STIM_POSITIONS, p=[pleft, 1 - pleft])) quiescent_period = self.task_params.QUIESCENT_PERIOD + misc.truncated_exponential( scale=0.35, min_value=0.2, max_value=0.5 ) - reward_amount = self.default_reward_amount if reward_amount is None else reward_amount stim_gain = ( self.session_info.ADAPTIVE_GAIN_VALUE if self.task_params.get('ADAPTIVE_GAIN', False) else self.task_params.STIM_GAIN ) @@ -505,8 +504,15 @@ def draw_next_trial_info(self, pleft=0.5, contrast=None, position=None, reward_a self.trials_table.at[self.trial_num, 'stim_reverse'] = self.task_params.STIM_REVERSE self.trials_table.at[self.trial_num, 'trial_num'] = self.trial_num self.trials_table.at[self.trial_num, 'position'] = position - self.trials_table.at[self.trial_num, 'reward_amount'] = reward_amount + self.trials_table.at[self.trial_num, 'reward_amount'] = self.default_reward_amount self.trials_table.at[self.trial_num, 'stim_probability_left'] = pleft + + # use the kwargs dict to override computed values + for key, value in kwargs.items(): + if key == 'index': + pass + self.trials_table.at[self.trial_num, key] = value + self.send_trial_info_to_bonsai() def trial_completed(self, bpod_data: dict[str, Any]) -> None: diff --git a/iblrig/base_tasks.py b/iblrig/base_tasks.py index bbff5be17..db84b5147 100644 --- a/iblrig/base_tasks.py +++ b/iblrig/base_tasks.py @@ -462,8 +462,18 @@ def save_trial_data_to_json(self, bpod_data: dict): bpod_data : dict Trial data returned from pybpod. """ - # get trial's data as a dict, validate by passing through pydantic model + # get trial's data as a dict trial_data = self.trials_table.iloc[self.trial_num].to_dict() + + # warn about entries not covered by pydantic model + if trial_data.get('trial_num', 1) == 0: + for key in set(trial_data.keys()) - set(self.TrialDataModel.model_fields) - {'index'}: + log.warning( + f'Key "{key}" in trial_data is missing from TrialDataModel - ' + f'its value ({trial_data[key]}) will not be validated.' + ) + + # validate by passing through pydantic model trial_data = self.TrialDataModel.model_validate(trial_data).model_dump() # add bpod_data as 'behavior_data' diff --git a/iblrig/test/tasks/test_biased_choice_world_family.py b/iblrig/test/tasks/test_biased_choice_world_family.py index 3cb09ca0b..ff5b96717 100644 --- a/iblrig/test/tasks/test_biased_choice_world_family.py +++ b/iblrig/test/tasks/test_biased_choice_world_family.py @@ -111,7 +111,7 @@ def test_task(self, _=None): # check that the task in fact uses the pre-generated data cols = list( set(self.task.get_session_template(0).columns) - - {'index', 'reward_valve_time', 'response_side', 'response_time', 'trial_correct'} + - {'index', 'reward_amount', 'reward_valve_time', 'response_side', 'response_time', 'trial_correct'} ) template = self.task.get_session_template(0).head(len(self.task.trials_table)) assert (self.task.trials_table == template)[cols].all().all() diff --git a/iblrig_tasks/_iblrig_tasks_ephysChoiceWorld/task.py b/iblrig_tasks/_iblrig_tasks_ephysChoiceWorld/task.py index 15aa3e54f..83a027786 100644 --- a/iblrig_tasks/_iblrig_tasks_ephysChoiceWorld/task.py +++ b/iblrig_tasks/_iblrig_tasks_ephysChoiceWorld/task.py @@ -26,14 +26,9 @@ def __init__(self, *args, session_template_id=0, **kwargs): def next_trial(self): self.trial_num += 1 - trial_params = self.trials_table.iloc[self.trial_num] + trial_params = self.trials_table.iloc[self.trial_num].drop(['index', 'trial_num']).to_dict() self.block_num = trial_params['block_num'] - self.draw_next_trial_info( - pleft=trial_params['stim_probability_left'], - contrast=trial_params['contrast'], - position=trial_params['position'], - reward_amount=trial_params['reward_amount'], - ) + self.draw_next_trial_info(**trial_params) @staticmethod def get_session_template(session_template_id: int) -> pd.DataFrame: From b0a17e91fadd2565c6d20ffc4594c1feac4a675a Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Tue, 10 Sep 2024 12:10:17 +0100 Subject: [PATCH 40/44] minor update to documentation --- docs/source/conf.py | 29 ++++++---- docs/source/reference_write_your_own_task.rst | 2 +- iblrig/base_tasks.py | 2 +- pdm.lock | 56 +++++++++---------- pyproject.toml | 2 +- 5 files changed, 50 insertions(+), 41 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 5aa9d0042..e33cc90e6 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -27,11 +27,6 @@ 'sphinx.ext.inheritance_diagram', 'sphinx.ext.viewcode', ] -# autodoc_mock_imports = ["PySpin"] -autodoc_typehints = 'description' -autodoc_member_order = 'groupwise' -autosummary_generate = True -autosummary_imported_members = False autosectionlabel_prefix_document = True source_suffix = ['.rst', '.md'] exclude_patterns = [] @@ -54,6 +49,7 @@ html_theme = 'sphinx_rtd_theme' +# -- Options for PDF creation ------------------------------------------------ simplepdf_vars = { 'primary': '#004f8c', 'secondary': '#004f8c', @@ -67,17 +63,30 @@ 'cover_meta_data': 'International Brain Laboratory', } -# -- Napoleon Settings ------------------------------------------------------- -napoleon_google_docstring = True +# -- Settings for automatic API generation ----------------------------------- +autodoc_mock_imports = ["PySpin"] +autodoc_class_signature = 'separated' # 'mixed', 'separated' +autodoc_member_order = 'groupwise' # 'alphabetical', 'groupwise', 'bysource' +autodoc_inherit_docstrings = False +autodoc_typehints = 'description' # 'description', 'signature', 'none', 'both' +autodoc_typehints_description_target = 'all' # 'all', 'documented', 'documented_params' +autodoc_typehints_format = 'short' # 'fully-qualified', 'short' + +autosummary_generate = True +autosummary_imported_members = False + +napoleon_google_docstring = False napoleon_numpy_docstring = True napoleon_include_init_with_doc = True +napoleon_include_private_with_doc = False napoleon_include_special_with_doc = False napoleon_use_admonition_for_examples = True napoleon_use_admonition_for_notes = True napoleon_use_admonition_for_references = True -napoleon_use_ivar = True -napoleon_use_param = True +napoleon_use_ivar = False +napoleon_use_param = False napoleon_use_rtype = True +napoleon_use_keyword = True napoleon_preprocess_types = True napoleon_type_aliases = None -napoleon_attr_annotations = True +napoleon_attr_annotations = False diff --git a/docs/source/reference_write_your_own_task.rst b/docs/source/reference_write_your_own_task.rst index bcf933895..b61a2a8ea 100644 --- a/docs/source/reference_write_your_own_task.rst +++ b/docs/source/reference_write_your_own_task.rst @@ -12,7 +12,7 @@ All tasks inherit from the :class:`iblrig.base_tasks.BaseSession` class, which p - read hardware parameters and rig parameters - optionally interfaces with the `Alyx experimental database `_ - creates the folder structure for the session - - writes the task and rig parameters, log, and :doc:`acquisition description files <../description_file>` + - writes the task and rig parameters, log, and :doc:`acquisition description files <../reference_description_file>` Additionally the :mod:`iblrig.base_tasks` module provides "hardware mixins". Those are classes that provide hardware-specific functionalities, such as connecting to a Bpod or a rotary encoder. They are composed with the :class:`~.iblrig.base_tasks.BaseSession` class to create a task. diff --git a/iblrig/base_tasks.py b/iblrig/base_tasks.py index db84b5147..1d64c571f 100644 --- a/iblrig/base_tasks.py +++ b/iblrig/base_tasks.py @@ -1023,7 +1023,7 @@ def start_mixin_rotary_encoder(self): log.info('Rotary encoder module loaded: OK') -class ValveMixin(BaseSession): +class ValveMixin(BaseSession, HasBpod): def init_mixin_valve(self: object): self.valve = Bunch({}) # the template settings files have a date in 2099, so assume that the rig is not calibrated if that is the case diff --git a/pdm.lock b/pdm.lock index 9c4c55862..99581b35d 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "ci", "dev", "doc", "project-extraction", "test", "typing"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:330cd2b56b2399f658b3b05fbb715b5da2b8f6adf3b452435b779237209007a5" +content_hash = "sha256:714a13365ed6f4d37114c9146f1b274c61e9b99dee7955f3add4550cabdf3845" [[metadata.targets]] requires_python = "==3.10.*" @@ -1873,24 +1873,24 @@ files = [ [[package]] name = "pydantic" -version = "2.8.2" +version = "2.9.1" requires_python = ">=3.8" summary = "Data validation using Python type hints" groups = ["default"] dependencies = [ - "annotated-types>=0.4.0", - "pydantic-core==2.20.1", + "annotated-types>=0.6.0", + "pydantic-core==2.23.3", "typing-extensions>=4.12.2; python_version >= \"3.13\"", "typing-extensions>=4.6.1; python_version < \"3.13\"", ] files = [ - {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, - {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, + {file = "pydantic-2.9.1-py3-none-any.whl", hash = "sha256:7aff4db5fdf3cf573d4b3c30926a510a10e19a0774d38fc4967f78beb6deb612"}, + {file = "pydantic-2.9.1.tar.gz", hash = "sha256:1363c7d975c7036df0db2b4a61f2e062fbc0aa5ab5f2772e0ffc7191a4f4bce2"}, ] [[package]] name = "pydantic-core" -version = "2.20.1" +version = "2.23.3" requires_python = ">=3.8" summary = "Core functionality for Pydantic validation and serialization" groups = ["default"] @@ -1898,27 +1898,27 @@ dependencies = [ "typing-extensions!=4.7.0,>=4.6.0", ] files = [ - {file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"}, - {file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"}, - {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"}, - {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"}, - {file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"}, - {file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, - {file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, + {file = "pydantic_core-2.23.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7f10a5d1b9281392f1bf507d16ac720e78285dfd635b05737c3911637601bae6"}, + {file = "pydantic_core-2.23.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3c09a7885dd33ee8c65266e5aa7fb7e2f23d49d8043f089989726391dd7350c5"}, + {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6470b5a1ec4d1c2e9afe928c6cb37eb33381cab99292a708b8cb9aa89e62429b"}, + {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9172d2088e27d9a185ea0a6c8cebe227a9139fd90295221d7d495944d2367700"}, + {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86fc6c762ca7ac8fbbdff80d61b2c59fb6b7d144aa46e2d54d9e1b7b0e780e01"}, + {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0cb80fd5c2df4898693aa841425ea1727b1b6d2167448253077d2a49003e0ed"}, + {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03667cec5daf43ac4995cefa8aaf58f99de036204a37b889c24a80927b629cec"}, + {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:047531242f8e9c2db733599f1c612925de095e93c9cc0e599e96cf536aaf56ba"}, + {file = "pydantic_core-2.23.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5499798317fff7f25dbef9347f4451b91ac2a4330c6669821c8202fd354c7bee"}, + {file = "pydantic_core-2.23.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bbb5e45eab7624440516ee3722a3044b83fff4c0372efe183fd6ba678ff681fe"}, + {file = "pydantic_core-2.23.3-cp310-none-win32.whl", hash = "sha256:8b5b3ed73abb147704a6e9f556d8c5cb078f8c095be4588e669d315e0d11893b"}, + {file = "pydantic_core-2.23.3-cp310-none-win_amd64.whl", hash = "sha256:2b603cde285322758a0279995b5796d64b63060bfbe214b50a3ca23b5cee3e83"}, + {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f399e8657c67313476a121a6944311fab377085ca7f490648c9af97fc732732d"}, + {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b5547d098c76e1694ba85f05b595720d7c60d342f24d5aad32c3049131fa5c4"}, + {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0dda0290a6f608504882d9f7650975b4651ff91c85673341789a476b1159f211"}, + {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65b6e5da855e9c55a0c67f4db8a492bf13d8d3316a59999cfbaf98cc6e401961"}, + {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:09e926397f392059ce0afdcac920df29d9c833256354d0c55f1584b0b70cf07e"}, + {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:87cfa0ed6b8c5bd6ae8b66de941cece179281239d482f363814d2b986b79cedc"}, + {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e61328920154b6a44d98cabcb709f10e8b74276bc709c9a513a8c37a18786cc4"}, + {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ce3317d155628301d649fe5e16a99528d5680af4ec7aa70b90b8dacd2d725c9b"}, + {file = "pydantic_core-2.23.3.tar.gz", hash = "sha256:3cb0f65d8b4121c1b015c60104a685feb929a29d7cf204387c7f2688c7974690"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 741703339..daaaf9811 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ dependencies = [ "numpy>=1.26.4", "packaging>=24.1", "pandas>=2.2.2", - "pydantic>=2.8.2", + "pydantic>=2.9.1", "pyqtgraph>=0.13.7", "python-osc>=1.8.3", "pyusb>=1.2.1", From 3d014f2528cf4075b28336198740b1bf1ddd70e8 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Tue, 10 Sep 2024 12:38:15 +0100 Subject: [PATCH 41/44] prepare release --- CHANGELOG.md | 10 +++++++--- iblrig/__init__.py | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a46c8f3b..1dc9bfd65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,15 @@ Changelog ========= -8.23.2 +8.24.0 ------ * add validation script for Bpod HiFi Module (folder `scripts`) -* fix: issue with `_ephysChoiceWorld` - values from the pre-generated sessions were not actually used -* feature: validate values in `trials_table` using Pydantic +* fix: `_ephysChoiceWorld` - values from the pre-generated sessions were not actually used +* fix: `_ephysChoiceWorld` - trial fixtures contained inverted values for `probability_left` +* feature: validate values in `trials_table` using Pydantic +* feature: add API documentation + +------------------------------- 8.23.1 ------ diff --git a/iblrig/__init__.py b/iblrig/__init__.py index af637f102..339e36d72 100644 --- a/iblrig/__init__.py +++ b/iblrig/__init__.py @@ -6,7 +6,7 @@ # 5) git tag the release in accordance to the version number below (after merge!) # >>> git tag 8.15.6 # >>> git push origin --tags -__version__ = '8.23.2' +__version__ = '8.24.0' from iblrig.version_management import get_detailed_version_string From b9334f3cad876d9cd690980724970f773c55aba4 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Tue, 10 Sep 2024 14:06:39 +0100 Subject: [PATCH 42/44] use dict for passing extra log items in show_trial_log --- CHANGELOG.md | 1 + iblrig/base_choice_world.py | 121 ++++++++++++++++++++++++++---------- 2 files changed, 89 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dc9bfd65..a565f2c1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Changelog * fix: `_ephysChoiceWorld` - trial fixtures contained inverted values for `probability_left` * feature: validate values in `trials_table` using Pydantic * feature: add API documentation +* changed: show_trial_log() now accepts a dict for including additional log items ------------------------------- diff --git a/iblrig/base_choice_world.py b/iblrig/base_choice_world.py index c78a06602..fa1570e41 100644 --- a/iblrig/base_choice_world.py +++ b/iblrig/base_choice_world.py @@ -542,19 +542,55 @@ def check_sync_pulses(self, bpod_data): if not misc.get_port_events(events, name='Port1'): log.warning("NO CAMERA SYNC PULSES RECEIVED ON BPOD'S BEHAVIOR PORT 1") - def show_trial_log(self, extra_info='', log_level: int = logging.INFO): + def show_trial_log(self, extra_info: dict[str, Any] | None = None, log_level: int = logging.INFO): + """ + Log the details of the current trial. + + This method retrieves information about the current trial from the + trials table and logs it. It can also incorporate additional information + provided through the `extra_info` parameter. + + Parameters + ---------- + extra_info : dict[str, Any], optional + A dictionary containing additional information to include in the + log. + + log_level : int, optional + The logging level to use when logging the trial information. + Default is logging.INFO. + + Notes + ----- + When overloading, make sure to call the super class and pass additional + log items by means of the extra_info parameter. See the implementation + of :py:meth:`~iblrig.base_choice_world.ActiveChoiceWorldSession.show_trial_log` in + :mod:`~iblrig.base_choice_world.ActiveChoiceWorldSession` for reference. + """ + # construct base info dict trial_info = self.trials_table.iloc[self.trial_num] - + info_dict = { + 'Stim. Position': trial_info.position, + 'Stim. Contrast': trial_info.contrast, + 'Stim. Phase': f'{trial_info.stim_phase:.2f}', + 'Stim. p Left': trial_info.stim_probability_left, + 'Water delivered': f'{self.session_info.TOTAL_WATER_DELIVERED:.1f} µl', + 'Time from Start': self.time_elapsed, + 'Temperature': f'{self.ambient_sensor_table.loc[self.trial_num, "Temperature_C"]:.1f} °C', + 'Air Pressure': f'{self.ambient_sensor_table.loc[self.trial_num, "AirPressure_mb"]:.1f} mb', + 'Rel. Humidity': f'{self.ambient_sensor_table.loc[self.trial_num, "RelativeHumidity"]:.1f} %', + } + + # update info dict with extra_info dict + if isinstance(extra_info, dict): + info_dict.update(extra_info) + + # log info dict log.log(log_level, f'Outcome of Trial #{trial_info.trial_num}:') - log.log(log_level, f'- Stim. Position: {trial_info.position}') - log.log(log_level, f'- Stim. Contrast: {trial_info.contrast}') - log.log(log_level, f'- Stim. Phase: {trial_info.stim_phase}') - log.log(log_level, f'- Stim. p Left: {trial_info.stim_probability_left}') - log.log(log_level, f'- Water delivered: {self.session_info.TOTAL_WATER_DELIVERED:.1f} µl') - log.log(log_level, f'- Time from Start: {self.time_elapsed}') - log.log(log_level, f'- Temperature: {self.ambient_sensor_table.loc[self.trial_num, "Temperature_C"]:.1f} °C') - log.log(log_level, f'- Air Pressure: {self.ambient_sensor_table.loc[self.trial_num, "AirPressure_mb"]:.1f} mb') - log.log(log_level, f'- Rel. Humidity: {self.ambient_sensor_table.loc[self.trial_num, "RelativeHumidity"]:.1f} %\n') + max_key_length = max(len(key) for key in info_dict) + for key, value in info_dict.items(): + spaces = (max_key_length - len(key)) * ' ' + log.log(log_level, f'- {key}: {spaces}{str(value)}') @property def iti_reward(self): @@ -701,17 +737,22 @@ def _run(self): ) super()._run() - def show_trial_log(self, extra_info=''): + def show_trial_log(self, extra_info: dict[str, Any] | None = None, log_level: int = logging.INFO): + # construct info dict trial_info = self.trials_table.iloc[self.trial_num] - extra_info = f""" -RESPONSE TIME: {trial_info.response_time} -{extra_info} + info_dict = { + 'Response Time': f'{trial_info.response_time:.2f} s', + 'Trial Correct': trial_info.trial_correct, + 'N Trials Correct': self.session_info.NTRIALS_CORRECT, + 'N Trials Error': self.trial_num - self.session_info.NTRIALS_CORRECT, + } -TRIAL CORRECT: {trial_info.trial_correct} -NTRIALS CORRECT: {self.session_info.NTRIALS_CORRECT} -NTRIALS ERROR: {self.trial_num - self.session_info.NTRIALS_CORRECT} - """ - super().show_trial_log(extra_info=extra_info) + # update info dict with extra_info dict + if isinstance(extra_info, dict): + info_dict.update(extra_info) + + # call parent method + super().show_trial_log(extra_info=info_dict, log_level=log_level) def trial_completed(self, bpod_data): """ @@ -826,14 +867,21 @@ def next_trial(self): # save and send trial info to bonsai self.draw_next_trial_info(pleft=pleft) - def show_trial_log(self): + def show_trial_log(self, extra_info: dict[str, Any] | None = None, log_level: int = logging.INFO): + # construct info dict trial_info = self.trials_table.iloc[self.trial_num] - extra_info = f""" -BLOCK NUMBER: {trial_info.block_num} -BLOCK LENGTH: {self.blocks_table.loc[self.block_num, 'block_length']} -TRIALS IN BLOCK: {trial_info.block_trial_num} - """ - super().show_trial_log(extra_info=extra_info) + info_dict = { + 'Block Number': trial_info.block_num, + 'Block Length': self.blocks_table.loc[self.block_num, 'block_length'], + 'N Trials in Block': trial_info.block_trial_num, + } + + # update info dict with extra_info dict + if isinstance(extra_info, dict): + info_dict.update(extra_info) + + # call parent method + super().show_trial_log(extra_info=info_dict, log_level=log_level) class TrainingChoiceWorldTrialData(ActiveChoiceWorldTrialData): @@ -962,9 +1010,16 @@ def next_trial(self): self.draw_next_trial_info(pleft=self.task_params.PROBABILITY_LEFT, position=position, contrast=contrast) self.trials_table.at[self.trial_num, 'training_phase'] = self.training_phase - def show_trial_log(self): - extra_info = f""" -CONTRAST SET: {np.unique(np.abs(choiceworld.contrasts_set(self.training_phase)))} -SUBJECT TRAINING PHASE (0-5): {self.training_phase} - """ - super().show_trial_log(extra_info=extra_info) + def show_trial_log(self, extra_info: dict[str, Any] | None = None, log_level: int = logging.INFO): + # construct info dict + info_dict = { + 'Contrast Set': np.unique(np.abs(choiceworld.contrasts_set(self.training_phase))), + 'Training Phase': self.training_phase, + } + + # update info dict with extra_info dict + if isinstance(extra_info, dict): + info_dict.update(extra_info) + + # call parent method + super().show_trial_log(extra_info=info_dict, log_level=log_level) From a93837d58458889e583a7be3db859bd7eb5c1ae2 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Tue, 10 Sep 2024 14:11:48 +0100 Subject: [PATCH 43/44] Update CHANGELOG.md --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a565f2c1b..7a97c2951 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,12 @@ Changelog 8.24.0 ------ -* add validation script for Bpod HiFi Module (folder `scripts`) +* feature: validate values in `trials_table` using Pydantic +* feature: add auto-generated API reference to documentation +* changed: `show_trial_log()` now accepts a dict for including additional log items * fix: `_ephysChoiceWorld` - values from the pre-generated sessions were not actually used * fix: `_ephysChoiceWorld` - trial fixtures contained inverted values for `probability_left` -* feature: validate values in `trials_table` using Pydantic -* feature: add API documentation -* changed: show_trial_log() now accepts a dict for including additional log items +* add script for validating audio output of Bpod HiFi Module (in `scripts/` folder) ------------------------------- From f4abec1a457bbb9ad46fc7b5c75890258cad1db2 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Tue, 10 Sep 2024 14:15:14 +0100 Subject: [PATCH 44/44] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a97c2951..b05ab29b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Changelog * changed: `show_trial_log()` now accepts a dict for including additional log items * fix: `_ephysChoiceWorld` - values from the pre-generated sessions were not actually used * fix: `_ephysChoiceWorld` - trial fixtures contained inverted values for `probability_left` +* fix: GUI - Subjects and Projects are not being cached * add script for validating audio output of Bpod HiFi Module (in `scripts/` folder) -------------------------------