From 20c166d8b40dafb79d3cf1222ead68aabb09da42 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Tue, 10 Sep 2024 14:33:39 +0100 Subject: [PATCH 01/16] install PortAudio for doc action (needed for API documentation) --- .github/workflows/documentation.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/documentation.yaml b/.github/workflows/documentation.yaml index 8c8320c2e..dbf85cf53 100644 --- a/.github/workflows/documentation.yaml +++ b/.github/workflows/documentation.yaml @@ -25,6 +25,8 @@ jobs: python-version-file: pyproject.toml - name: Install requirements run: pdm sync -dG doc + - name: Install PortAudio + run: sudo apt-get install -y libportaudio2 - name: Sphinx build run: pdm run sphinx-build docs/source docs/build/html - name: Deploy From 91430d752a56418d57a6be84f26f0b43fbb49527 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Tue, 10 Sep 2024 15:29:32 +0100 Subject: [PATCH 02/16] Update documentation.yaml --- .github/workflows/documentation.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/documentation.yaml b/.github/workflows/documentation.yaml index dbf85cf53..e050242a9 100644 --- a/.github/workflows/documentation.yaml +++ b/.github/workflows/documentation.yaml @@ -25,8 +25,8 @@ jobs: python-version-file: pyproject.toml - name: Install requirements run: pdm sync -dG doc - - name: Install PortAudio - run: sudo apt-get install -y libportaudio2 + - name: Install PortAudio and GraphViz + run: sudo apt-get install -y libportaudio2 graphviz - name: Sphinx build run: pdm run sphinx-build docs/source docs/build/html - name: Deploy From 7f5e4e475898c1dafafa19270fc11ec0d39b9d9e Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 11 Sep 2024 16:49:05 +0100 Subject: [PATCH 03/16] change UI workflow for appending a session --- CHANGELOG.md | 4 ++++ docs/source/usage_behavior.rst | 6 +++--- iblrig/__init__.py | 2 +- iblrig/gui/ui_wizard.py | 18 +++++++++++------- iblrig/gui/ui_wizard.ui | 23 ++++++++++++----------- iblrig/gui/wizard.py | 23 +++++++++++++++++++---- 6 files changed, 50 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b05ab29b5..74d7e9f03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ Changelog ========= +8.24.1 +------ +* change UI workflow for appending a session + 8.24.0 ------ * feature: validate values in `trials_table` using Pydantic diff --git a/docs/source/usage_behavior.rst b/docs/source/usage_behavior.rst index 1d05aaaa1..23d96f84d 100644 --- a/docs/source/usage_behavior.rst +++ b/docs/source/usage_behavior.rst @@ -42,9 +42,9 @@ Starting a Task Supplementary Controls ~~~~~~~~~~~~~~~~~~~~~~ -- If you check the *Append* option before clicking *Start*, the task - you initiate will be linked to the preceding task, creating a - sequence of connected tasks. +- When starting a subsequent task with the same subject, you'll be asked if + you want to append to the preceding session. Doing so will result in a + sequence of connected tasks sharing the same data folder. - The *Flush* button serves to toggle the valve for cleaning purposes. diff --git a/iblrig/__init__.py b/iblrig/__init__.py index 339e36d72..9fe8f1ff0 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.24.0' +__version__ = '8.24.1' from iblrig.version_management import get_detailed_version_string diff --git a/iblrig/gui/ui_wizard.py b/iblrig/gui/ui_wizard.py index 5706727ec..06dd15668 100644 --- a/iblrig/gui/ui_wizard.py +++ b/iblrig/gui/ui_wizard.py @@ -276,15 +276,22 @@ def setupUi(self, wizard): self.uiGroupSessionControl.setObjectName("uiGroupSessionControl") self.verticalLayout = QtWidgets.QVBoxLayout(self.uiGroupSessionControl) self.verticalLayout.setObjectName("verticalLayout") - self.uiCheckAppend = QtWidgets.QCheckBox(self.uiGroupSessionControl) - self.uiCheckAppend.setObjectName("uiCheckAppend") - self.verticalLayout.addWidget(self.uiCheckAppend) self.uiPushStart = QtWidgets.QPushButton(self.uiGroupSessionControl) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.uiPushStart.sizePolicy().hasHeightForWidth()) + self.uiPushStart.setSizePolicy(sizePolicy) self.uiPushStart.setStyleSheet("QPushButton { background-color: red; }") self.uiPushStart.setObjectName("uiPushStart") self.verticalLayout.addWidget(self.uiPushStart) self.uiPushPause = QtWidgets.QPushButton(self.uiGroupSessionControl) self.uiPushPause.setEnabled(False) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.uiPushPause.sizePolicy().hasHeightForWidth()) + self.uiPushPause.setSizePolicy(sizePolicy) self.uiPushPause.setCheckable(True) self.uiPushPause.setChecked(False) self.uiPushPause.setObjectName("uiPushPause") @@ -346,8 +353,7 @@ def setupUi(self, wizard): wizard.setTabOrder(self.listViewRemoteDevices, self.uiPushFlush) wizard.setTabOrder(self.uiPushFlush, self.uiPushReward) wizard.setTabOrder(self.uiPushReward, self.uiPushStatusLED) - wizard.setTabOrder(self.uiPushStatusLED, self.uiCheckAppend) - wizard.setTabOrder(self.uiCheckAppend, self.uiPushStart) + wizard.setTabOrder(self.uiPushStatusLED, self.uiPushStart) wizard.setTabOrder(self.uiPushStart, self.uiPushPause) def retranslateUi(self, wizard): @@ -380,8 +386,6 @@ def retranslateUi(self, wizard): self.uiPushStatusLED.setStatusTip(_translate("wizard", "Click to toggle the Bpod\'s status LED")) self.uiPushStatusLED.setText(_translate("wizard", " Status &LED ")) self.uiGroupSessionControl.setTitle(_translate("wizard", "Session Control")) - self.uiCheckAppend.setStatusTip(_translate("wizard", "append to previous session")) - self.uiCheckAppend.setText(_translate("wizard", "append to previous Session")) self.uiPushStart.setStatusTip(_translate("wizard", "Click to start the session")) self.uiPushStart.setText(_translate("wizard", "Start")) self.uiPushPause.setStatusTip(_translate("wizard", "Click to pause the session after the current trial")) diff --git a/iblrig/gui/ui_wizard.ui b/iblrig/gui/ui_wizard.ui index a1792e7da..9c302935b 100644 --- a/iblrig/gui/ui_wizard.ui +++ b/iblrig/gui/ui_wizard.ui @@ -687,18 +687,14 @@ Session Control - - - - append to previous session - - - append to previous Session - - - + + + 0 + 0 + + Click to start the session @@ -715,6 +711,12 @@ false + + + 0 + 0 + + Click to pause the session after the current trial @@ -814,7 +816,6 @@ uiPushFlush uiPushReward uiPushStatusLED - uiCheckAppend uiPushStart uiPushPause diff --git a/iblrig/gui/wizard.py b/iblrig/gui/wizard.py index 1aeab0238..02c8d7585 100644 --- a/iblrig/gui/wizard.py +++ b/iblrig/gui/wizard.py @@ -286,6 +286,8 @@ class RigWizard(QtWidgets.QMainWindow, Ui_wizard): session_info: dict = {} task_parameters: dict | None = None new_subject_details = QtCore.pyqtSignal() + append_session: bool = False + previous_subject: str | None = None def __init__(self, debug: bool = False, remote_devices: bool = False): super().__init__() @@ -967,6 +969,18 @@ def start_stop(self): self.uiPushStart.setIcon(self.style().standardIcon(QStyle.SP_MediaStop)) self._enable_ui_elements() + # Manage appended session + self.append_session = False + if self.previous_subject == self.model.subject: + self.append_session = QtWidgets.QMessageBox.question( + self, + 'Appended Session', + 'Would you like to append to the previous session?', + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No + ) == QtWidgets.QMessageBox.Yes + + # Manage subject weight dlg = QtWidgets.QInputDialog() weight, ok = dlg.getDouble( self, @@ -986,7 +1000,7 @@ def start_stop(self): self.controller2model() logging.disable(logging.INFO) - task = EmptySession(subject=self.model.subject, append=self.uiCheckAppend.isChecked(), interactive=False) + task = EmptySession(subject=self.model.subject, append=self.append_session, interactive=False) logging.disable(logging.NOTSET) self.model.session_folder = task.paths['SESSION_FOLDER'] if self.model.session_folder.joinpath('.stop').exists(): @@ -1027,7 +1041,7 @@ def start_stop(self): cmd.extend(['--weight', f'{weight}']) cmd.extend(['--log-level', 'DEBUG' if self.debug else 'INFO']) cmd.append('--wizard') - if self.uiCheckAppend.isChecked(): + if self.append_session: cmd.append('--append') if self.running_task_process is None: self.tabLog.clear() @@ -1109,7 +1123,7 @@ def _on_task_finished(self, exit_code, exit_status): if ( (ntrials := session_data['NTRIALS']) < 42 and not any([x in self.model.task_name for x in ('spontaneous', 'passive')]) - and not self.uiCheckAppend.isChecked() + and not self.append_session ): answer = QtWidgets.QMessageBox.question( self, @@ -1120,7 +1134,9 @@ def _on_task_finished(self, exit_code, exit_status): ) if answer == QtWidgets.QMessageBox.Yes: shutil.rmtree(self.model.session_folder) + self.previous_subject = None return + self.previous_subject = self.model.subject # manage poop count dlg = QtWidgets.QInputDialog() @@ -1181,7 +1197,6 @@ def _enable_ui_elements(self): self.uiPushFlush.setEnabled(not is_running) self.uiPushReward.setEnabled(not is_running) self.uiPushStatusLED.setEnabled(not is_running) - self.uiCheckAppend.setEnabled(not is_running) self.uiGroupParameters.setEnabled(not is_running) self.uiGroupTaskParameters.setEnabled(not is_running) self.uiGroupTools.setEnabled(not is_running) From cb5eb44ee35678fcd627711833120a4ab813476a Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 11 Sep 2024 16:50:35 +0100 Subject: [PATCH 04/16] ruff --- iblrig/gui/wizard.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/iblrig/gui/wizard.py b/iblrig/gui/wizard.py index 02c8d7585..79ff54b88 100644 --- a/iblrig/gui/wizard.py +++ b/iblrig/gui/wizard.py @@ -972,13 +972,16 @@ def start_stop(self): # Manage appended session self.append_session = False if self.previous_subject == self.model.subject: - self.append_session = QtWidgets.QMessageBox.question( - self, - 'Appended Session', - 'Would you like to append to the previous session?', - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, - QtWidgets.QMessageBox.No - ) == QtWidgets.QMessageBox.Yes + self.append_session = ( + QtWidgets.QMessageBox.question( + self, + 'Appended Session', + 'Would you like to append to the previous session?', + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No, + ) + == QtWidgets.QMessageBox.Yes + ) # Manage subject weight dlg = QtWidgets.QInputDialog() From e890e645661aa54efecb544fb3b3514c7650a4b4 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Fri, 13 Sep 2024 11:07:47 +0100 Subject: [PATCH 05/16] only offer append when MAIN_SYNC == False --- iblrig/gui/wizard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iblrig/gui/wizard.py b/iblrig/gui/wizard.py index 79ff54b88..31f19e052 100644 --- a/iblrig/gui/wizard.py +++ b/iblrig/gui/wizard.py @@ -971,7 +971,7 @@ def start_stop(self): # Manage appended session self.append_session = False - if self.previous_subject == self.model.subject: + if self.previous_subject == self.model.subject and not self.model.hardware_settings.MAIN_SYNC: self.append_session = ( QtWidgets.QMessageBox.question( self, From 37330f30d9276f91bd40ac9b38af808470b1fa83 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Fri, 13 Sep 2024 14:28:33 +0100 Subject: [PATCH 06/16] Update base_choice_world.py --- iblrig/base_choice_world.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iblrig/base_choice_world.py b/iblrig/base_choice_world.py index fa1570e41..d52af876e 100644 --- a/iblrig/base_choice_world.py +++ b/iblrig/base_choice_world.py @@ -99,7 +99,7 @@ class ChoiceWorldTrialData(TrialDataModel): # 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 + trial_correct: Annotated[bool, Interval(ge=0, le=0)] = False class ChoiceWorldSession( From 2a6ca62a4f75657ecd59bbf8a2df02108602e050 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Mon, 16 Sep 2024 21:36:21 +0100 Subject: [PATCH 07/16] add test for passiveCW fixtures --- .../test/tasks/test_passive_choice_world.py | 59 +++++++++++++++++-- .../_iblrig_tasks_passiveChoiceWorld/task.py | 2 +- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/iblrig/test/tasks/test_passive_choice_world.py b/iblrig/test/tasks/test_passive_choice_world.py index b1d7f0aa0..0106b2c9e 100644 --- a/iblrig/test/tasks/test_passive_choice_world.py +++ b/iblrig/test/tasks/test_passive_choice_world.py @@ -1,4 +1,4 @@ -import numpy as np +import pandas as pd import ibllib.pipes.dynamic_pipeline as dyn from ibllib.pipes.behavior_tasks import PassiveTaskNidq @@ -8,18 +8,67 @@ class TestInstantiatePassiveChoiceWorld(BaseTestCases.CommonTestInstantiateTask): def setUp(self) -> None: - session_id = 7 + self.session_id = 7 self.get_task_kwargs() # NB: Passive choice world not supported with Bpod as main sync assert self.task_kwargs['hardware_settings']['MAIN_SYNC'] with self.assertLogs('iblrig.task', 40): - self.task = PassiveChoiceWorldSession(**self.task_kwargs, session_template_id=session_id) + self.task = PassiveChoiceWorldSession(**self.task_kwargs, session_template_id=self.session_id) self.task_kwargs['hardware_settings']['MAIN_SYNC'] = False with self.assertNoLogs('iblrig.task', 40): - self.task = PassiveChoiceWorldSession(**self.task_kwargs, session_template_id=session_id) + self.task = PassiveChoiceWorldSession(**self.task_kwargs, session_template_id=self.session_id) self.task.mock() - assert np.unique(self.task.trials_table['session_id']) == [session_id] + + def test_fixtures(self) -> None: + # assert that fixture are loaded correctly + trials_table = self.task.trials_table + assert trials_table.session_id.unique() == [self.session_id] + pqt_file = self.task.get_task_directory().joinpath('passiveChoiceWorld_trials_fixtures.pqt') + fixtures = pd.read_parquet(pqt_file) + assert fixtures.session_id.unique().tolist() == list(range(12)) + assert fixtures[fixtures.session_id == self.session_id].stim_type.equals(trials_table.stim_type) + + # loop through fixtures + for session_id in fixtures.session_id.unique(): + f = fixtures[fixtures.session_id == session_id] + + # The task stimuli replays consist of 300 stimulus presentations ordered randomly. + assert len(f) == 300 + assert f.stim_type.iloc[:10].nunique() > 1 + + # The pool of stimuli is generated as follows: + # 180 gabor patches with 300 ms duration, with 500-1900 ms uniform random delay before each stimulus + assert len(f[f.stim_type == 'G']) == 180 + assert all(f[f.stim_type == 'G'].stim_delay >= 0.5) + assert all(f[f.stim_type == 'G'].stim_delay <= 1.9) + + # - 20 gabor patches with 0% contrast + assert sum(f[f.stim_type == 'G'].contrast == 0.0) == 20 + + # - 20 gabor patches at 35 deg left side with 6.25%, 12.5%, 25%, 100% contrast (80 total) + # - 20 gabor patches at 35 deg right side with 6.25%, 12.5%, 25%, 100% contrast (80 total) + positions = f[f.stim_type == 'G'].position.unique() + assert set(positions) == {-35.0, 35.0} + for position in positions: + counts = f[(f.stim_type == 'G') & (f.position == position) & (f.contrast != 0.0)].contrast.value_counts() + assert set(counts.keys()) == {0.0625, 0.125, 0.25, 1.0} + assert all([v == 20 for v in counts.values]) + + # 40 openings of the water valve with a 1-11s delay drawn from a uniform distribution + assert len(f[f.stim_type == 'V']) == 40 + assert all(f[f.stim_type == 'V'].stim_delay >= 1.0) + assert all(f[f.stim_type == 'V'].stim_delay <= 11.0) + + # 40 go cues sounds with a 1-5s delay drawn from a uniform distribution + assert len(f[f.stim_type == 'T']) == 40 + assert all(f[f.stim_type == 'T'].stim_delay >= 1.0) + assert all(f[f.stim_type == 'T'].stim_delay <= 5.0) + + # 40 noise bursts sounds with a 1-5s delay drawn from a uniform distribution + assert len(f[f.stim_type == 'N']) == 40 + assert all(f[f.stim_type == 'N'].stim_delay >= 1.0) + assert all(f[f.stim_type == 'N'].stim_delay <= 5.0) def test_pipeline(self) -> None: """Test passive pipeline creation. diff --git a/iblrig_tasks/_iblrig_tasks_passiveChoiceWorld/task.py b/iblrig_tasks/_iblrig_tasks_passiveChoiceWorld/task.py index b8927e2bf..2e79f0086 100644 --- a/iblrig_tasks/_iblrig_tasks_passiveChoiceWorld/task.py +++ b/iblrig_tasks/_iblrig_tasks_passiveChoiceWorld/task.py @@ -121,7 +121,7 @@ def _run(self): # we need to make sure Bonsai is in a state to display stimuli self.send_trial_info_to_bonsai() self.bonsai_visual_udp_client.send_message(r'/re', byte_show_stim) - time.sleep(0.3) + time.sleep(0.3) # todo: this is a very inaccurate way of controlling stim duration! self.bonsai_visual_udp_client.send_message(r'/re', byte_hide_stim) if self.paths.SESSION_FOLDER.joinpath('.stop').exists(): self.paths.SESSION_FOLDER.joinpath('.stop').unlink() From eee1f18a3e84d286ba1acb1e019dca77bc198f1e Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Tue, 17 Sep 2024 10:21:38 +0200 Subject: [PATCH 08/16] add ks-test --- iblrig/test/tasks/test_passive_choice_world.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/iblrig/test/tasks/test_passive_choice_world.py b/iblrig/test/tasks/test_passive_choice_world.py index 0106b2c9e..696ccd387 100644 --- a/iblrig/test/tasks/test_passive_choice_world.py +++ b/iblrig/test/tasks/test_passive_choice_world.py @@ -1,4 +1,5 @@ import pandas as pd +from scipy.stats import kstest import ibllib.pipes.dynamic_pipeline as dyn from ibllib.pipes.behavior_tasks import PassiveTaskNidq @@ -42,6 +43,8 @@ def test_fixtures(self) -> None: assert len(f[f.stim_type == 'G']) == 180 assert all(f[f.stim_type == 'G'].stim_delay >= 0.5) assert all(f[f.stim_type == 'G'].stim_delay <= 1.9) + _, p = kstest(f[f.stim_type == 'G'].stim_delay, 'uniform', args=(0.5, 1.4)) + assert p > 0.05 # - 20 gabor patches with 0% contrast assert sum(f[f.stim_type == 'G'].contrast == 0.0) == 20 @@ -59,16 +62,22 @@ def test_fixtures(self) -> None: assert len(f[f.stim_type == 'V']) == 40 assert all(f[f.stim_type == 'V'].stim_delay >= 1.0) assert all(f[f.stim_type == 'V'].stim_delay <= 11.0) + _, p = kstest(f[f.stim_type == 'G'].stim_delay, 'uniform', args=(1.0, 10.0)) + assert p > 0.05 # 40 go cues sounds with a 1-5s delay drawn from a uniform distribution assert len(f[f.stim_type == 'T']) == 40 assert all(f[f.stim_type == 'T'].stim_delay >= 1.0) assert all(f[f.stim_type == 'T'].stim_delay <= 5.0) + _, p = kstest(f[f.stim_type == 'G'].stim_delay, 'uniform', args=(1.0, 4.0)) + assert p > 0.05 # 40 noise bursts sounds with a 1-5s delay drawn from a uniform distribution assert len(f[f.stim_type == 'N']) == 40 assert all(f[f.stim_type == 'N'].stim_delay >= 1.0) assert all(f[f.stim_type == 'N'].stim_delay <= 5.0) + _, p = kstest(f[f.stim_type == 'G'].stim_delay, 'uniform', args=(1.0, 4.0)) + assert p > 0.05 def test_pipeline(self) -> None: """Test passive pipeline creation. From aa43391d019c4618b9ce84de085ec7892fc111f1 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 18 Sep 2024 13:52:44 +0100 Subject: [PATCH 09/16] Update test_passive_choice_world.py --- .../test/tasks/test_passive_choice_world.py | 36 +++++-------------- 1 file changed, 8 insertions(+), 28 deletions(-) diff --git a/iblrig/test/tasks/test_passive_choice_world.py b/iblrig/test/tasks/test_passive_choice_world.py index 696ccd387..3be0290e0 100644 --- a/iblrig/test/tasks/test_passive_choice_world.py +++ b/iblrig/test/tasks/test_passive_choice_world.py @@ -37,47 +37,27 @@ def test_fixtures(self) -> None: # The task stimuli replays consist of 300 stimulus presentations ordered randomly. assert len(f) == 300 assert f.stim_type.iloc[:10].nunique() > 1 + assert set(f.stim_type.unique()) == {'G', 'N', 'T', 'V'} - # The pool of stimuli is generated as follows: - # 180 gabor patches with 300 ms duration, with 500-1900 ms uniform random delay before each stimulus - assert len(f[f.stim_type == 'G']) == 180 - assert all(f[f.stim_type == 'G'].stim_delay >= 0.5) - assert all(f[f.stim_type == 'G'].stim_delay <= 1.9) - _, p = kstest(f[f.stim_type == 'G'].stim_delay, 'uniform', args=(0.5, 1.4)) - assert p > 0.05 - + # 180 gabor patches with 300 ms duration # - 20 gabor patches with 0% contrast - assert sum(f[f.stim_type == 'G'].contrast == 0.0) == 20 - # - 20 gabor patches at 35 deg left side with 6.25%, 12.5%, 25%, 100% contrast (80 total) # - 20 gabor patches at 35 deg right side with 6.25%, 12.5%, 25%, 100% contrast (80 total) + # 40 openings of the water valve with a 1-11s delay drawn from a uniform distribution + # 40 go cues sounds with a 1-5s delay drawn from a uniform distribution + # 40 noise bursts sounds with a 1-5s delay drawn from a uniform distribution + assert len(f[f.stim_type == 'G']) == 180 + assert sum(f[f.stim_type == 'G'].contrast == 0.0) == 20 positions = f[f.stim_type == 'G'].position.unique() assert set(positions) == {-35.0, 35.0} for position in positions: counts = f[(f.stim_type == 'G') & (f.position == position) & (f.contrast != 0.0)].contrast.value_counts() assert set(counts.keys()) == {0.0625, 0.125, 0.25, 1.0} assert all([v == 20 for v in counts.values]) - - # 40 openings of the water valve with a 1-11s delay drawn from a uniform distribution assert len(f[f.stim_type == 'V']) == 40 - assert all(f[f.stim_type == 'V'].stim_delay >= 1.0) - assert all(f[f.stim_type == 'V'].stim_delay <= 11.0) - _, p = kstest(f[f.stim_type == 'G'].stim_delay, 'uniform', args=(1.0, 10.0)) - assert p > 0.05 - - # 40 go cues sounds with a 1-5s delay drawn from a uniform distribution assert len(f[f.stim_type == 'T']) == 40 - assert all(f[f.stim_type == 'T'].stim_delay >= 1.0) - assert all(f[f.stim_type == 'T'].stim_delay <= 5.0) - _, p = kstest(f[f.stim_type == 'G'].stim_delay, 'uniform', args=(1.0, 4.0)) - assert p > 0.05 - - # 40 noise bursts sounds with a 1-5s delay drawn from a uniform distribution assert len(f[f.stim_type == 'N']) == 40 - assert all(f[f.stim_type == 'N'].stim_delay >= 1.0) - assert all(f[f.stim_type == 'N'].stim_delay <= 5.0) - _, p = kstest(f[f.stim_type == 'G'].stim_delay, 'uniform', args=(1.0, 4.0)) - assert p > 0.05 + def test_pipeline(self) -> None: """Test passive pipeline creation. From 3f38c46e0bbfedf68209217f73e53f079999db0a Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 18 Sep 2024 13:53:38 +0100 Subject: [PATCH 10/16] Update test_passive_choice_world.py --- iblrig/test/tasks/test_passive_choice_world.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/iblrig/test/tasks/test_passive_choice_world.py b/iblrig/test/tasks/test_passive_choice_world.py index 3be0290e0..4e1bc7c6c 100644 --- a/iblrig/test/tasks/test_passive_choice_world.py +++ b/iblrig/test/tasks/test_passive_choice_world.py @@ -1,5 +1,4 @@ import pandas as pd -from scipy.stats import kstest import ibllib.pipes.dynamic_pipeline as dyn from ibllib.pipes.behavior_tasks import PassiveTaskNidq @@ -58,7 +57,6 @@ def test_fixtures(self) -> None: assert len(f[f.stim_type == 'T']) == 40 assert len(f[f.stim_type == 'N']) == 40 - def test_pipeline(self) -> None: """Test passive pipeline creation. From 01e088a95f91710ac181a84301bec42367391365 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Wed, 18 Sep 2024 13:56:12 +0100 Subject: [PATCH 11/16] Update test_passive_choice_world.py --- iblrig/test/tasks/test_passive_choice_world.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/iblrig/test/tasks/test_passive_choice_world.py b/iblrig/test/tasks/test_passive_choice_world.py index 4e1bc7c6c..3694ec947 100644 --- a/iblrig/test/tasks/test_passive_choice_world.py +++ b/iblrig/test/tasks/test_passive_choice_world.py @@ -42,9 +42,9 @@ def test_fixtures(self) -> None: # - 20 gabor patches with 0% contrast # - 20 gabor patches at 35 deg left side with 6.25%, 12.5%, 25%, 100% contrast (80 total) # - 20 gabor patches at 35 deg right side with 6.25%, 12.5%, 25%, 100% contrast (80 total) - # 40 openings of the water valve with a 1-11s delay drawn from a uniform distribution - # 40 go cues sounds with a 1-5s delay drawn from a uniform distribution - # 40 noise bursts sounds with a 1-5s delay drawn from a uniform distribution + # 40 openings of the water valve + # 40 go cues sounds + # 40 noise bursts sounds assert len(f[f.stim_type == 'G']) == 180 assert sum(f[f.stim_type == 'G'].contrast == 0.0) == 20 positions = f[f.stim_type == 'G'].position.unique() From adc1ba0b1363e047d0410864c4079864206f34f1 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Thu, 19 Sep 2024 10:41:55 +0100 Subject: [PATCH 12/16] Video QC: change log level from WARNING to INFO if less than 0.1% of frames have been dropped --- CHANGELOG.md | 1 + iblrig/video.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74d7e9f03..699f68f26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Changelog 8.24.1 ------ * change UI workflow for appending a session +* Video QC: changed log level from WARNING to INFO if less than 0.1% of frames have been dropped 8.24.0 ------ diff --git a/iblrig/video.py b/iblrig/video.py index 96e546cdd..4c0333d21 100644 --- a/iblrig/video.py +++ b/iblrig/video.py @@ -337,9 +337,9 @@ def validate_video(video_path, config): # Check frame data count, gpio = load_embedded_frame_data(video_path.parents[1], label_from_path(video_path)) dropped = count[-1] - (meta.length - 1) - if dropped != 0: # Log ERROR if > .1% frames dropped, otherwise log WARN + if dropped != 0: # Log ERROR if > .1% frames dropped, otherwise log INFO pct_dropped = dropped / (count[-1] + 1) * 100 - level = 30 if pct_dropped < 0.1 else 40 + level = logging.INFO if pct_dropped < 0.1 else logging.ERROR log.log(level, 'Missed frames (%.2f%%) - frame data N = %i; video file N = %i', pct_dropped, count[-1] + 1, meta.length) ok = False if len(count) != meta.length: From ca4ce3a40f1966a6784de7d28591c372f0c669da Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Mon, 23 Sep 2024 11:16:20 +0100 Subject: [PATCH 13/16] Use restricted quadratic fit when converting reward volume to valve opening time --- CHANGELOG.md | 1 + iblrig/base_tasks.py | 53 +++++++----------- iblrig/valve.py | 26 +++++++-- .../passiveChoiceWorld_trials_fixtures.pqt | Bin 72522 -> 72528 bytes 4 files changed, 42 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 699f68f26..ada666161 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Changelog ------ * change UI workflow for appending a session * Video QC: changed log level from WARNING to INFO if less than 0.1% of frames have been dropped +* Valve: Use restricted quadratic fit when converting reward volume to valve opening time 8.24.0 ------ diff --git a/iblrig/base_tasks.py b/iblrig/base_tasks.py index 1d64c571f..0833b3955 100644 --- a/iblrig/base_tasks.py +++ b/iblrig/base_tasks.py @@ -24,7 +24,6 @@ import numpy as np import pandas as pd -import scipy.interpolate import serial import yaml from pythonosc import udp_client @@ -43,6 +42,7 @@ from iblrig.pydantic_definitions import HardwareSettings, RigSettings, TrialDataModel from iblrig.tools import call_bonsai from iblrig.transfer_experiments import BehaviorCopier, VideoCopier +from iblrig.valve import Valve from iblutil.io.net.base import ExpMessage from iblutil.spacer import Spacer from iblutil.util import Bunch, flatten, setup_logger @@ -1025,43 +1025,32 @@ def start_mixin_rotary_encoder(self): 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 - # the assertion on calibration is thrown when starting the device - self.valve['is_calibrated'] = datetime.date.today() >= self.hardware_settings['device_valve']['WATER_CALIBRATION_DATE'] - self.valve['fcn_vol2time'] = scipy.interpolate.pchip( - self.hardware_settings['device_valve']['WATER_CALIBRATION_WEIGHT_PERDROP'], - self.hardware_settings['device_valve']['WATER_CALIBRATION_OPEN_TIMES'], - ) + self.valve = Valve(self.hardware_settings.device_valve) def start_mixin_valve(self): - # if the rig is not on manual settings, then the reward valve has to be calibrated to run the experiment - assert self.task_params.AUTOMATIC_CALIBRATION is False or self.valve['is_calibrated'], """ - ########################################## - NO CALIBRATION INFORMATION FOUND IN HARDWARE SETTINGS: - Calibrate the rig or use a manual calibration - PLEASE GO TO the task settings yaml file and set: - 'AUTOMATIC_CALIBRATION': false - 'CALIBRATION_VALUE' = - ##########################################""" + # assert that valve has been calibrated + assert self.valve.is_calibrated, """VALVE IS NOT CALIBRATED - PLEASE CALIBRATE THE VALVE""" + # regardless of the calibration method, the reward valve time has to be lower than 1 second - assert self.compute_reward_time(amount_ul=1.5) < 1, """ - ########################################## - REWARD VALVE TIME IS TOO HIGH! - Probably because of a BAD calibration file - Calibrate the rig or use a manual calibration - PLEASE GO TO the task settings yaml file and set: - AUTOMATIC_CALIBRATION = False - CALIBRATION_VALUE = - ##########################################""" + assert self.compute_reward_time(amount_ul=1.5) < 1, """VALVE IS NOT PROPERLY CALIBRATED - PLEASE RECALIBRATE""" log.info('Water valve module loaded: OK') - def compute_reward_time(self, amount_ul=None): + def compute_reward_time(self, amount_ul: float | None = None) -> float: + """ + Converts the valve opening time from a given volume. + + Parameters + ---------- + amount_ul : float, optional + The volume of liquid (μl) to be dispensed from the valve. Defaults to task_params.REWARD_AMOUNT_UL. + + Returns + ------- + float + Valve opening time in seconds. + """ amount_ul = self.task_params.REWARD_AMOUNT_UL if amount_ul is None else amount_ul - if self.task_params.AUTOMATIC_CALIBRATION: - return self.valve['fcn_vol2time'](amount_ul) / 1e3 - else: # this is the manual manual calibration value - return self.task_params.CALIBRATION_VALUE / 3 * amount_ul + return self.valve.values.ul2ms(amount_ul) / 1e3 def valve_open(self, reward_valve_time): """ diff --git a/iblrig/valve.py b/iblrig/valve.py index 751f779f7..19b52f1a6 100644 --- a/iblrig/valve.py +++ b/iblrig/valve.py @@ -1,11 +1,11 @@ import datetime import warnings -from collections.abc import Sequence +from collections.abc import Iterable, Sequence import numpy as np import scipy from numpy.polynomial import Polynomial -from pydantic import PositiveFloat, validate_call +from pydantic import NonNegativeFloat, PositiveFloat, validate_call from iblrig.pydantic_definitions import HardwareSettingsValve @@ -61,12 +61,22 @@ def _update_fit(self) -> None: self._polynomial = Polynomial(coef=c) @validate_call - def ul2ms(self, volume_ul: PositiveFloat) -> PositiveFloat: - return max((self._polynomial - volume_ul).roots()) + def ul2ms(self, volume_ul: NonNegativeFloat | Iterable[NonNegativeFloat]) -> NonNegativeFloat | np.ndarray: + if isinstance(volume_ul, Iterable): + return np.array([self.ul2ms(v) for v in volume_ul]) + elif volume_ul == 0.0: + return 0.0 + else: + return max(np.append((self._polynomial - volume_ul).roots(), 0.0)) @validate_call - def ms2ul(self, volume_ul: PositiveFloat | list[PositiveFloat]) -> PositiveFloat | np.ndarray: - return self._polynomial(np.array(volume_ul)) + def ms2ul(self, time_ms: NonNegativeFloat | Iterable[NonNegativeFloat]) -> NonNegativeFloat | np.ndarray: + if isinstance(time_ms, Iterable): + return np.array([self.ms2ul(t) for t in time_ms]) + elif time_ms == 0.0: + return 0.0 + else: + return self._polynomial(time_ms) class Valve: @@ -80,6 +90,10 @@ def __init__(self, settings: HardwareSettingsValve): def calibration_date(self) -> datetime.date: return self._settings.WATER_CALIBRATION_DATE + @property + def is_calibrated(self) -> bool: + return datetime.date.today() >= self.calibration_date + @property def calibration_range(self) -> list[float, float]: return self._settings.WATER_CALIBRATION_RANGE diff --git a/iblrig_tasks/_iblrig_tasks_passiveChoiceWorld/passiveChoiceWorld_trials_fixtures.pqt b/iblrig_tasks/_iblrig_tasks_passiveChoiceWorld/passiveChoiceWorld_trials_fixtures.pqt index 91ac690cb6bc67209912caabcd66594b15d59e7e..a55c726e0b4019d58f3481424bb2ed59e6e94621 100644 GIT binary patch delta 1624 zcmbVMZA=?w9KV0>j_V7ITSt#}ya=0|I5Gs|+!jsnh%+H^X4wY?$%Z7uNhWlHrcP{c z?!z$7WMYHYWLd)E7r#u6)m202fgj9Xz^ zFA-{`{fH^D0VHMEf zgOoKbKh!Np!X}&15p2$$B2oIK8P-i1K_CSMe9W@V8eRy?;v0+YAx7-Ec|vmYG&6t` zd#PRyz6Hi?OTmgTu@MgP=hF(pAv%tVAsjx2kQT<4=U>6T85*$^9aY3qo?(+LL)d)A z4+&udR5u1a%(_5jfCpR4_EMUr7L67!=_bXbSfx`U-t#Ilr{z$ZwpfnjOcEW{Whjl& zoET-@$kB(sbqO3`DVk+pCPHmjRqWz z%yRt0VV-m@hx%Xub&Tm$w;UF1EIMun9dcnudBn5qC_fen;t)oNBWy)1XJ$vKm7k^s`sFss`Tv7rT2pI(zp&-xY8vBiocKZe`J}9=Rf@(?1I3LF1we))`YS*n|z_WZ=ZT zuG-iCSk=IjZ}4ni*9)ub&t4VOgi*zVmY+Lza-+-l)%Cy2j2<3>(wml`&Sr)9#gm(j z&v@`BTSoTnuKQ(Dy2#=F8IK9DDY+wi-0l0= z#$q9_cbu@G`2JN1smUTd^6tae!MPsrF4^ZiU(Kty$AjtwnIk<9gzdVJmQVsO6=d-47gU z>21(7tqp(8k+!F{9B6CZnW`;?IbPaY-%#HWnl-A*pxU^v3<^A)Sk7^~wB3z=0pCqX A2><{9 delta 1523 zcmbVMYiJx*6uxKXPUcQFt2g8BWH;H=yPM6}V!P~WBR;^)tVv0s4NEGup=b!Ck)Vks zX$=H*XP0P}wwR4WoI;JH2t`yxy3&XSjS#RDE&UcGsL zIgjsr=eu|ArE~PcIa>Zu=A>qJ7aVkR^R&_ef#-ZjB{Q{)ZWocA5rsurxd2`Ot*4o1){Hhjy(H)=Nl3!~#zD{ptWb;~Uk5nymd`eed+anR8#94?a zAR^>ukhsMhv=lAbNP@t)MKM?pdXg(FG^rD{KwCJG1e@lVtzZb4fRC318crio^P0tUa=TJ0Taa{+ zt3>Y*MZVK2I~GR{IDtU|z^K(Kvoa?zxN{tpRrF^OIL=F$tY~>Gl>tFCK;Cj(hm+~Vrd;Sqb8L9mfu_@( z+nn*@ii)H#tFXndQEqz*N9QrpU^9?}WQk&j!%0JdOSEK(u3IG$e{UY~gPMgTECZLz z;S`(*X@w-`G=w7N#Uw9JE(K_UYx{A<6CB| zF!Pt|)V%1GWw0x``GKCm7Z=n&_`4rmZBmw}Rr3Y+^3|!@#ZNFoqOGLHJ-pfkXDcUG z_anv6tNGsZ)Tek+MT&1w1l)6dnr%O`jtTU4L9`>IL;a8UjT{~Rjl-EiKq zeW}JGuDyPXd;faF>@?`FJg@=&PmL$Rfd6(gQ+-|?fVTNDas0kI^Q-0Rc>ZB9=tn#} z10D(dX%)PT1jfHq-?IQh|DbyI@5Oe9=j?6fe-S%-L!Iq_`B$Oc6RXX2=8c8U{%?%~ z#*b$i?gf(>?=18y(5c2VsdOrxczU}?9PS?(=pXBQ^3dqm&=VtxdqpCXYD?YeeN+d} zMD5)+UY-8K?!`Mh-uqD7W4#CVE_4Usv?lG{=lx_rQtLe0y>I8l?veCDssVnGA>%bR OL5kJH2+7$!nO^~)ZC~U7 From 73b6a53cb4c8e014e81d285297d2dce4ec1e7d42 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Mon, 23 Sep 2024 11:49:59 +0100 Subject: [PATCH 14/16] valve calibration: plot shows value down to 0 ms opening time --- iblrig/gui/valve.py | 2 +- iblrig/valve.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/iblrig/gui/valve.py b/iblrig/gui/valve.py index 09d63595b..1352f34c5 100644 --- a/iblrig/gui/valve.py +++ b/iblrig/gui/valve.py @@ -49,7 +49,7 @@ def update(self): if len(self.values.open_times_ms) < 2: self._curve.setData(x=[], y=[]) else: - time_range = list(np.linspace(self.values.open_times_ms[0], self.values.open_times_ms[-1], 100)) + time_range = list(np.linspace(0, self.values.open_times_ms[-1], 100)) self._curve.setData(x=time_range, y=self.values.ms2ul(time_range)) def clear(self): diff --git a/iblrig/valve.py b/iblrig/valve.py index 19b52f1a6..14e58f42b 100644 --- a/iblrig/valve.py +++ b/iblrig/valve.py @@ -76,7 +76,7 @@ def ms2ul(self, time_ms: NonNegativeFloat | Iterable[NonNegativeFloat]) -> NonNe elif time_ms == 0.0: return 0.0 else: - return self._polynomial(time_ms) + return max(np.append(self._polynomial(time_ms), 0.0)) class Valve: From d7e21f847543baeaa5f82e78ece4faedfc541ea8 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Mon, 23 Sep 2024 12:40:03 +0100 Subject: [PATCH 15/16] unit test for valve --- iblrig/test/test_valve.py | 44 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 iblrig/test/test_valve.py diff --git a/iblrig/test/test_valve.py b/iblrig/test/test_valve.py new file mode 100644 index 000000000..a5c61b825 --- /dev/null +++ b/iblrig/test/test_valve.py @@ -0,0 +1,44 @@ +import unittest +from datetime import date + +import numpy as np +from pydantic import ValidationError + +from iblrig.pydantic_definitions import HardwareSettingsValve +from iblrig.valve import Valve + + +class TestValve(unittest.TestCase): + def test_valve(self): + range_t = range(50, 201, 50) + range_v = range(4, 21, 5) + assert len(range_t) == len(range_v) + + settings = HardwareSettingsValve( + WATER_CALIBRATION_DATE=date.today(), + WATER_CALIBRATION_RANGE=[min(range_t), max(range_t)], + WATER_CALIBRATION_N=len(range_t), + WATER_CALIBRATION_OPEN_TIMES=[t for t in range_t], + WATER_CALIBRATION_WEIGHT_PERDROP=[v for v in range_v], + FREE_REWARD_VOLUME_UL=1.5, + ) + valve = Valve(settings) + + t = np.arange(range_t[0], range_t[-1], 25) + v = np.arange(range_v[0], range_v[-1], 2.5) + for i in range(0, len(t)): + self.assertAlmostEqual(valve.values.ms2ul(t[i]), v[i], places=3) + self.assertAlmostEqual(valve.values.ul2ms(v[i]), t[i], places=3) + assert np.allclose(valve.values.ms2ul(t), v) + assert np.allclose(valve.values.ul2ms(v), t) + assert valve.values.ul2ms(0) == 0.0 + assert valve.values.ms2ul(0) == 0.0 + assert valve.values.ms2ul(5) == 0.0 + with self.assertRaises(ValidationError): + valve.values.ms2ul(-1) + with self.assertRaises(ValidationError): + valve.values.ms2ul([-1, 1]) + with self.assertRaises(ValidationError): + valve.values.ul2ms(-1) + with self.assertRaises(ValidationError): + valve.values.ul2ms([-2, 1]) From 84c77d6412b924d006eb290accc23cbdaa617114 Mon Sep 17 00:00:00 2001 From: Florian Rau Date: Mon, 23 Sep 2024 12:54:21 +0100 Subject: [PATCH 16/16] TrainingCW: add `signed_contrast` to trials_table definition --- CHANGELOG.md | 1 + iblrig/base_choice_world.py | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ada666161..7193dfefe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Changelog * change UI workflow for appending a session * Video QC: changed log level from WARNING to INFO if less than 0.1% of frames have been dropped * Valve: Use restricted quadratic fit when converting reward volume to valve opening time +* TrainingCW: add `signed_contrast` to trials_table definition 8.24.0 ------ diff --git a/iblrig/base_choice_world.py b/iblrig/base_choice_world.py index d52af876e..ea9be2a86 100644 --- a/iblrig/base_choice_world.py +++ b/iblrig/base_choice_world.py @@ -889,6 +889,7 @@ class TrainingChoiceWorldTrialData(ActiveChoiceWorldTrialData): training_phase: NonNegativeInt debias_trial: bool + signed_contrast: float | None = None class TrainingChoiceWorldSession(ActiveChoiceWorldSession):