From a8396c6e9cf1f60bd12f8cba25277c67d816bb5a Mon Sep 17 00:00:00 2001 From: Olivier Winter Date: Wed, 5 Nov 2025 13:47:45 +0000 Subject: [PATCH 01/11] micromanipulator NP2 compute with reversed reference shank --- iblrig/ephys.py | 17 +++++++++++++++-- iblrig/test/test_ephys.py | 23 ++++++++++++++++++----- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/iblrig/ephys.py b/iblrig/ephys.py index a08d95747..b38a27b7f 100644 --- a/iblrig/ephys.py +++ b/iblrig/ephys.py @@ -37,7 +37,7 @@ def prepare_ephys_session(subject_name: str, nprobes: int = 2): copier.initialize_experiment(nprobes=nprobes) -def neuropixel24_micromanipulator_coordinates(ref_shank, pname, ba=None, shank_spacings_um=(0, 200, 400, 600)): +def neuropixel24_micromanipulator_coordinates(ref_shank, pname, ba=None, shank_spacings_um=(0, 200, 400, 600), pivot_shank='a'): """ Provide the micro-manipulator coordinates of the first shank. @@ -52,9 +52,20 @@ def neuropixel24_micromanipulator_coordinates(ref_shank, pname, ba=None, shank_s """ # this only works if the roll is 0, ie. the probe is facing upwards assert ref_shank['roll'] == 0 + assert ref_shank['roll'] == 0, 'roll should be 0 for autoassignment of shank coordinates' + if pivot_shank == 'd': + shank_letters = 'dcba' + spacing_factor = -1 + elif pivot_shank == 'a': + shank_letters = 'abcd' + spacing_factor = 1 + else: + ValueError("reference_shank parameter should be either 'a' or 'd'") + ba = atlas.NeedlesAtlas() if ba is None else ba trajectories = {} for i, d in enumerate(shank_spacings_um): + d *= spacing_factor # flip the direction if reference_shank is 'd' x = ref_shank['x'] + np.sin(ref_shank['phi'] / 180 * np.pi) * d y = ref_shank['y'] - np.cos(ref_shank['phi'] / 180 * np.pi) * d shank = { @@ -72,5 +83,7 @@ def neuropixel24_micromanipulator_coordinates(ref_shank, pname, ba=None, shank_s xyz_ref = xyz_entry shank['z'] = xyz_entry[2] * 1e6 shank['depth'] = ref_shank['depth'] + (xyz_entry[2] - xyz_ref[2]) * 1e6 - trajectories[f'{pname}{string.ascii_lowercase[i]}'] = shank + trajectories[f'{pname}{shank_letters[i]}'] = shank return trajectories + + diff --git a/iblrig/test/test_ephys.py b/iblrig/test/test_ephys.py index 8a9a54195..b2059f49e 100644 --- a/iblrig/test/test_ephys.py +++ b/iblrig/test/test_ephys.py @@ -1,13 +1,15 @@ import unittest +import pandas as pd +import numpy as np + import iblrig.ephys class TestFinalizeEphysSession(unittest.TestCase): - def test_neuropixel24_micromanipulator(self): - probe_dict = {'x': 2594.2, 'y': -3123.7, 'z': -711, 'phi': 0 + 15, 'theta': 15, 'depth': 1250.4, 'roll': 0} - trajectories = iblrig.ephys.neuropixel24_micromanipulator_coordinates(probe_dict, 'probe01') - a = { + + def setUp(self): + self.actual = { 'probe01a': {'x': 2594.2, 'y': -3123.7, 'z': -231.33599999999996, 'phi': 15, 'theta': 15, 'depth': 1250.4, 'roll': 0}, 'probe01b': { 'x': 2645.963809020504, @@ -37,4 +39,15 @@ def test_neuropixel24_micromanipulator(self): 'roll': 0, }, } - assert trajectories == a + self.actual = pd.DataFrame(self.actual) + + def test_neuropixel24_micromanipulator(self): + probe_dict = {'x': 2594.2, 'y': -3123.7, 'z': -711, 'phi': 0 + 15, 'theta': 15, 'depth': 1250.4, 'roll': 0} + trajectories = iblrig.ephys.neuropixel24_micromanipulator_coordinates(probe_dict, 'probe01') + np.testing.assert_array_almost_equal(self.actual.to_numpy(), pd.DataFrame(trajectories).sort_index(axis=1).to_numpy()) + + def test_neuropixel24_micromanipulator_reversed(self): + probe_dict = {'x': 2749.4914270615122, 'y': -3703.255495773441, 'z': -350.336, 'phi': 15, 'theta': 15, 'depth': 1131.4, 'roll': 0} + trajectories = iblrig.ephys.neuropixel24_micromanipulator_coordinates(probe_dict, 'probe01', pivot_shank='d') + np.testing.assert_array_almost_equal(self.actual.to_numpy(), pd.DataFrame(trajectories).sort_index(axis=1).to_numpy()) + From fdd4807732b058b9c3aa4b4d87a3fd123451b16a Mon Sep 17 00:00:00 2001 From: Olivier Winter Date: Wed, 5 Nov 2025 13:47:58 +0000 Subject: [PATCH 02/11] micromanipulator GUI wip --- iblrig/gui/micromanipulator.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/iblrig/gui/micromanipulator.py b/iblrig/gui/micromanipulator.py index edfc14a1d..19356988c 100644 --- a/iblrig/gui/micromanipulator.py +++ b/iblrig/gui/micromanipulator.py @@ -1,9 +1,11 @@ # convert_uis *micro* import argparse +import numpy as np import matplotlib as mpl import matplotlib.pyplot as plt from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as Canvas +from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar from qtpy.QtCore import QCoreApplication from qtpy.QtWidgets import QApplication, QMainWindow, QSizePolicy, QVBoxLayout, QWidget @@ -50,13 +52,26 @@ def on_push_np24(self): self.model.trajectories = neuropixel24_micromanipulator_coordinates( self.model.trajectory, self.model.pname, ba=self.atlas ) + self.on_push_show() def on_push_show(self): + for ax in self.uiMpl.canvas.ax: + [h.remove() for h in ax.lines] + [h.remove() for h in ax.texts] self.uiMpl.canvas.ax[1].clear() + self.uiMpl.canvas.ax[0].plot(self.model.trajectory['x'], self.model.trajectory['y'], '>', color='k') for shank, traj in self.model.trajectories.items(): self.uiMpl.canvas.ax[0].plot(traj['x'], traj['y'], 'xr', label=shank) self.uiMpl.canvas.ax[0].text(traj['x'], traj['y'], shank[-1], color='w', fontweight=800) + + self.atlas.plot_sslice(ml_coordinate=traj['x'] / 1e6, ax=self.uiMpl.canvas.ax[1], volume='annotation') + self.uiMpl.canvas.ax[1].plot(self.model.trajectory['y'], self.model.trajectory['z'], '>', color='k') + for shank, traj in self.model.trajectories.items(): + self.uiMpl.canvas.ax[1].plot(traj['y'], traj['z'], 'xr', label=shank) + self.uiMpl.canvas.ax[1].text(traj['y'], traj['z'], shank[-1], color='w', fontweight=800) + self.uiMpl.canvas.ax[1].set(ylim=np.sort(self.atlas.bc.zlim * 1e6)) + self.uiMpl.canvas.ax[1].set(xlim=self.atlas.bc.ylim * 1e6) self.uiMpl.canvas.fig.tight_layout() self.uiMpl.canvas.draw() @@ -75,14 +90,16 @@ class MplWidget(QWidget): def __init__(self, parent=None): QWidget.__init__(self, parent) # Inherit from QWidget self.canvas = MplCanvas() # Create canvas object + self.toolbar = NavigationToolbar(self.canvas, self) self.vbl = QVBoxLayout() # Set box for plotting + self.vbl.addWidget(self.toolbar) self.vbl.addWidget(self.canvas) self.setLayout(self.vbl) def main(): parser = argparse.ArgumentParser() - parser.add_argument('subject') + parser.add_argument('--subject', default='anonymous', help='Subject name') args = parser.parse_args() QCoreApplication.setOrganizationName('International Brain Laboratory') QCoreApplication.setOrganizationDomain('internationalbrainlab.org') From 27b0dbaf38c7be1093c5877f235edd2e280ec4aa Mon Sep 17 00:00:00 2001 From: Olivier Date: Mon, 17 Nov 2025 17:59:07 +0000 Subject: [PATCH 03/11] WIP np2.4 --- iblrig/test/test_ephys.py | 53 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/iblrig/test/test_ephys.py b/iblrig/test/test_ephys.py index b2059f49e..5603163bc 100644 --- a/iblrig/test/test_ephys.py +++ b/iblrig/test/test_ephys.py @@ -51,3 +51,56 @@ def test_neuropixel24_micromanipulator_reversed(self): trajectories = iblrig.ephys.neuropixel24_micromanipulator_coordinates(probe_dict, 'probe01', pivot_shank='d') np.testing.assert_array_almost_equal(self.actual.to_numpy(), pd.DataFrame(trajectories).sort_index(axis=1).to_numpy()) + def test_probe_creation(self): + pass + +# %% +from pathlib import Path + +import iblrig.ephys +from ibllib.ephys.spikes import create_insertion +from one.api import ONE +from one.webclient import no_cache as no_cache_context +one = ONE(base_url='https://test.alyx.internationalbrainlab.org') +probe_dict = {'x': 2594.2, 'y': -3123.7, 'z': -711, 'phi': 0 + 15, 'theta': 15, 'depth': 1250.4, 'roll': 0} +trajectories = iblrig.ephys.neuropixel24_micromanipulator_coordinates(probe_dict, 'probe01') +# +# from iblrig.test.base import TASK_KWARGS +# from iblrig.test.test_base_tasks import EmptyHardwareSession +# TASK_KWARGS['subject'] = 'algernon' +# task = EmptyHardwareSession(one=one, **TASK_KWARGS) +# task.register_to_alyx() + + +import ibllib.time +import datetime +ses_dict = { + 'subject': 'algernon', + 'start_time': ibllib.time.date2isostr(datetime.datetime.now()), + 'number': 1, + 'users': ['test_user']} +ses = one.alyx.rest('sessions', 'create', data=ses_dict) + + +# is it ok to add another test hitting the alyx test database ? +# %% +import neuropixel +import ibllib.pipes.histology +neuropixel.trace_header(version=2, nshank=4) +from iblutil.util import Bunch +metadata = Bunch({'neuropixelVersion': 'NP2.4', 'fileName': '/path/to/file.bin', 'serial': 813867658}) +rest_insertions = {} +with no_cache_context(one.alyx): + traj_extra = {} + for pname, traj in trajectories.items(): + _, rest_insertion = create_insertion(one, metadata, pname, eid=ses['id']) + traj_extra['probe_insertion'] = rest_insertion['id'] + traj_extra['chronic_insertion'] = None + traj_extra['provenance'] = 'Micro-manipulator' + traj_extra['coordinate_system'] = 'Needles-Allen' + rest_trajectory = one.alyx.rest('trajectories', 'list', probe_insertion=rest_insertion['id'], provenance='Micro-manipulator') + if len(rest_trajectory) == 0: + rest_trajectory = one.alyx.rest('trajectories', 'create', data=traj | traj_extra) + else: + rest_trajectory = one.alyx.rest('trajectories', 'update', id=rest_trajectory[0]['id'], data=traj | traj_extra) + From f7e71848cd6fd848e7bb114e65dc94988540c48b Mon Sep 17 00:00:00 2001 From: Olivier Winter Date: Tue, 18 Nov 2025 13:41:07 +0000 Subject: [PATCH 04/11] function to register probe insertions and trajectories to alyx --- iblrig/ephys.py | 38 +++++++++++++--- iblrig/gui/micromanipulator.py | 2 +- iblrig/test/test_ephys.py | 81 ++++++++++++---------------------- 3 files changed, 60 insertions(+), 61 deletions(-) diff --git a/iblrig/ephys.py b/iblrig/ephys.py index b38a27b7f..319249270 100644 --- a/iblrig/ephys.py +++ b/iblrig/ephys.py @@ -1,12 +1,13 @@ import argparse -import string import numpy as np from iblatlas import atlas +from ibllib.ephys.spikes import create_insertion from iblrig.base_tasks import EmptySession from iblrig.transfer_experiments import EphysCopier from iblutil.util import setup_logger +from one.webclient import no_cache as no_cache_context def prepare_ephys_session_cmd(): @@ -55,19 +56,19 @@ def neuropixel24_micromanipulator_coordinates(ref_shank, pname, ba=None, shank_s assert ref_shank['roll'] == 0, 'roll should be 0 for autoassignment of shank coordinates' if pivot_shank == 'd': shank_letters = 'dcba' - spacing_factor = -1 + spacing_sign = -1 elif pivot_shank == 'a': shank_letters = 'abcd' - spacing_factor = 1 + spacing_sign = 1 else: - ValueError("reference_shank parameter should be either 'a' or 'd'") + raise ValueError("reference_shank parameter should be either 'a' or 'd'") ba = atlas.NeedlesAtlas() if ba is None else ba trajectories = {} for i, d in enumerate(shank_spacings_um): - d *= spacing_factor # flip the direction if reference_shank is 'd' - x = ref_shank['x'] + np.sin(ref_shank['phi'] / 180 * np.pi) * d - y = ref_shank['y'] - np.cos(ref_shank['phi'] / 180 * np.pi) * d + spacing_multiplier = d * spacing_sign # flip the direction if reference_shank is 'd' + x = ref_shank['x'] + np.sin(ref_shank['phi'] / 180 * np.pi) * spacing_multiplier + y = ref_shank['y'] - np.cos(ref_shank['phi'] / 180 * np.pi) * spacing_multiplier shank = { 'x': x, 'y': y, @@ -87,3 +88,26 @@ def neuropixel24_micromanipulator_coordinates(ref_shank, pname, ba=None, shank_s return trajectories +def register_micromanipulator_coordinates(one=None, trajectories=None, eid=None): + assert one is not None, 'An ONE instance is required to register/create micromanipulator coordinates' + assert eid is not None, 'An session ID is required to register/create micromanipulator coordinates' + # if we do not have access to the fileName or any of the metadata, it will be patched later + metadata = {'neuropixelVersion': 'NP2.4', 'fileName': None, 'serial': -1} + rest_trajectories = {} + rest_insertions = {} + with no_cache_context(one.alyx): + traj_extra = {} + for pname, traj in trajectories.items(): + _, rest_insertions[pname] = create_insertion(one, metadata, pname, eid=eid) + pid = rest_insertions[pname]['id'] + traj_extra['probe_insertion'] = pid + traj_extra['chronic_insertion'] = None + traj_extra['provenance'] = 'Micro-manipulator' + traj_extra['coordinate_system'] = 'Needles-Allen' + rest_trajectory = one.alyx.rest('trajectories', 'list', probe_insertion=pid, provenance='Micro-manipulator') + if len(rest_trajectory) == 0: + rest_trajectories[pname] = one.alyx.rest('trajectories', 'create', data=traj | traj_extra) + else: + rest_trajectories[pname] = one.alyx.rest('trajectories', 'update', id=rest_trajectory[0]['id'], + data=traj | traj_extra) + return rest_insertions, rest_trajectories diff --git a/iblrig/gui/micromanipulator.py b/iblrig/gui/micromanipulator.py index 19356988c..b8361fd2d 100644 --- a/iblrig/gui/micromanipulator.py +++ b/iblrig/gui/micromanipulator.py @@ -1,9 +1,9 @@ # convert_uis *micro* import argparse -import numpy as np import matplotlib as mpl import matplotlib.pyplot as plt +import numpy as np from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as Canvas from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar from qtpy.QtCore import QCoreApplication diff --git a/iblrig/test/test_ephys.py b/iblrig/test/test_ephys.py index 5603163bc..a8c27b7ff 100644 --- a/iblrig/test/test_ephys.py +++ b/iblrig/test/test_ephys.py @@ -1,12 +1,16 @@ +import datetime import unittest -import pandas as pd import numpy as np +import pandas as pd +import ibllib.time import iblrig.ephys +from ibllib.tests import TEST_DB +from one.api import ONE -class TestFinalizeEphysSession(unittest.TestCase): +class TestMicromanipulatorCompute(unittest.TestCase): def setUp(self): self.actual = { @@ -42,65 +46,36 @@ def setUp(self): self.actual = pd.DataFrame(self.actual) def test_neuropixel24_micromanipulator(self): - probe_dict = {'x': 2594.2, 'y': -3123.7, 'z': -711, 'phi': 0 + 15, 'theta': 15, 'depth': 1250.4, 'roll': 0} + probe_dict = {'x': 2594.2, 'y': -3123.7, 'z': -711, + 'phi': 0 + 15, 'theta': 15, 'depth': 1250.4, 'roll': 0} trajectories = iblrig.ephys.neuropixel24_micromanipulator_coordinates(probe_dict, 'probe01') np.testing.assert_array_almost_equal(self.actual.to_numpy(), pd.DataFrame(trajectories).sort_index(axis=1).to_numpy()) def test_neuropixel24_micromanipulator_reversed(self): - probe_dict = {'x': 2749.4914270615122, 'y': -3703.255495773441, 'z': -350.336, 'phi': 15, 'theta': 15, 'depth': 1131.4, 'roll': 0} + probe_dict = {'x': 2749.4914270615122, 'y': -3703.255495773441, + 'z': -350.336, 'phi': 15, 'theta': 15, 'depth': 1131.4, 'roll': 0} trajectories = iblrig.ephys.neuropixel24_micromanipulator_coordinates(probe_dict, 'probe01', pivot_shank='d') np.testing.assert_array_almost_equal(self.actual.to_numpy(), pd.DataFrame(trajectories).sort_index(axis=1).to_numpy()) - def test_probe_creation(self): - pass - -# %% -from pathlib import Path - -import iblrig.ephys -from ibllib.ephys.spikes import create_insertion -from one.api import ONE -from one.webclient import no_cache as no_cache_context -one = ONE(base_url='https://test.alyx.internationalbrainlab.org') -probe_dict = {'x': 2594.2, 'y': -3123.7, 'z': -711, 'phi': 0 + 15, 'theta': 15, 'depth': 1250.4, 'roll': 0} -trajectories = iblrig.ephys.neuropixel24_micromanipulator_coordinates(probe_dict, 'probe01') -# -# from iblrig.test.base import TASK_KWARGS -# from iblrig.test.test_base_tasks import EmptyHardwareSession -# TASK_KWARGS['subject'] = 'algernon' -# task = EmptyHardwareSession(one=one, **TASK_KWARGS) -# task.register_to_alyx() +class TestMicromanipulatorRegister2Alyx(unittest.TestCase): -import ibllib.time -import datetime -ses_dict = { - 'subject': 'algernon', - 'start_time': ibllib.time.date2isostr(datetime.datetime.now()), - 'number': 1, - 'users': ['test_user']} -ses = one.alyx.rest('sessions', 'create', data=ses_dict) - + def setUp(self): + self.one = ONE(**TEST_DB) + ses_dict = { + 'subject': 'algernon', + 'start_time': ibllib.time.date2isostr(datetime.datetime.now()), + 'number': 1, + 'users': ['test_user']} + self.rest_session = self.one.alyx.rest('sessions', 'create', data=ses_dict) -# is it ok to add another test hitting the alyx test database ? -# %% -import neuropixel -import ibllib.pipes.histology -neuropixel.trace_header(version=2, nshank=4) -from iblutil.util import Bunch -metadata = Bunch({'neuropixelVersion': 'NP2.4', 'fileName': '/path/to/file.bin', 'serial': 813867658}) -rest_insertions = {} -with no_cache_context(one.alyx): - traj_extra = {} - for pname, traj in trajectories.items(): - _, rest_insertion = create_insertion(one, metadata, pname, eid=ses['id']) - traj_extra['probe_insertion'] = rest_insertion['id'] - traj_extra['chronic_insertion'] = None - traj_extra['provenance'] = 'Micro-manipulator' - traj_extra['coordinate_system'] = 'Needles-Allen' - rest_trajectory = one.alyx.rest('trajectories', 'list', probe_insertion=rest_insertion['id'], provenance='Micro-manipulator') - if len(rest_trajectory) == 0: - rest_trajectory = one.alyx.rest('trajectories', 'create', data=traj | traj_extra) - else: - rest_trajectory = one.alyx.rest('trajectories', 'update', id=rest_trajectory[0]['id'], data=traj | traj_extra) + def tearDown(self): + self.one.alyx.rest('sessions', 'delete', id=self.rest_session['id']) + def test_probe_and_trajectories_creation(self): + probe_dict = {'x': 2594.2, 'y': -3123.7, 'z': -711, 'phi': 0 + 15, + 'theta': 15, 'depth': 1250.4, 'roll': 0} + trajectories = iblrig.ephys.neuropixel24_micromanipulator_coordinates(probe_dict, 'probe01') + iblrig.ephys.register_micromanipulator_coordinates(one=self.one, trajectories=trajectories, eid=self.rest_session['id']) + # do it twice to make sure both the get and create cases work + iblrig.ephys.register_micromanipulator_coordinates(one=self.one, trajectories=trajectories, eid=self.rest_session['id']) From 79533e2ef3cb70b60bbf508c3ee743fb219b32f0 Mon Sep 17 00:00:00 2001 From: Olivier Winter Date: Wed, 19 Nov 2025 16:33:48 +0000 Subject: [PATCH 05/11] micromanipulator GUI update --- iblrig/ephys.py | 22 +- iblrig/gui/ui_micromanipulator.py | 443 +++++++++++++++++++++++------- iblrig/gui/ui_micromanipulator.ui | 131 --------- iblrig/test/test_ephys.py | 4 +- 4 files changed, 352 insertions(+), 248 deletions(-) delete mode 100644 iblrig/gui/ui_micromanipulator.ui diff --git a/iblrig/ephys.py b/iblrig/ephys.py index 319249270..bf8a4c219 100644 --- a/iblrig/ephys.py +++ b/iblrig/ephys.py @@ -51,9 +51,7 @@ def neuropixel24_micromanipulator_coordinates(ref_shank, pname, ba=None, shank_s :param shank_spacings_um: list of shank spacings in micrometers :return: """ - # this only works if the roll is 0, ie. the probe is facing upwards - assert ref_shank['roll'] == 0 - assert ref_shank['roll'] == 0, 'roll should be 0 for autoassignment of shank coordinates' + ref_shank['roll'] = 0 # this is a constant and has no nmeaning for 4 shanks probes if pivot_shank == 'd': shank_letters = 'dcba' spacing_sign = -1 @@ -88,26 +86,26 @@ def neuropixel24_micromanipulator_coordinates(ref_shank, pname, ba=None, shank_s return trajectories -def register_micromanipulator_coordinates(one=None, trajectories=None, eid=None): - assert one is not None, 'An ONE instance is required to register/create micromanipulator coordinates' +def register_micromanipulator_coordinates(alyx=None, trajectories=None, eid=None, metadata=None): + assert alyx is not None, 'An alyx client is required to register/create micromanipulator coordinates' assert eid is not None, 'An session ID is required to register/create micromanipulator coordinates' # if we do not have access to the fileName or any of the metadata, it will be patched later - metadata = {'neuropixelVersion': 'NP2.4', 'fileName': None, 'serial': -1} + metadata = {'neuropixelVersion': 'NP2.4', 'fileName': None, 'serial': -1} if metadata is None else metadata rest_trajectories = {} rest_insertions = {} - with no_cache_context(one.alyx): + with no_cache_context(alyx): traj_extra = {} for pname, traj in trajectories.items(): - _, rest_insertions[pname] = create_insertion(one, metadata, pname, eid=eid) + _, rest_insertions[pname] = create_insertion(alyx, metadata, pname, eid=eid) pid = rest_insertions[pname]['id'] traj_extra['probe_insertion'] = pid traj_extra['chronic_insertion'] = None traj_extra['provenance'] = 'Micro-manipulator' traj_extra['coordinate_system'] = 'Needles-Allen' - rest_trajectory = one.alyx.rest('trajectories', 'list', probe_insertion=pid, provenance='Micro-manipulator') + rest_trajectory = alyx.rest('trajectories', 'list', probe_insertion=pid, provenance='Micro-manipulator') if len(rest_trajectory) == 0: - rest_trajectories[pname] = one.alyx.rest('trajectories', 'create', data=traj | traj_extra) + rest_trajectories[pname] = alyx.rest('trajectories', 'create', data=traj | traj_extra) else: - rest_trajectories[pname] = one.alyx.rest('trajectories', 'update', id=rest_trajectory[0]['id'], - data=traj | traj_extra) + rest_trajectories[pname] = alyx.rest('trajectories', 'update', id=rest_trajectory[0]['id'], + data=traj | traj_extra) return rest_insertions, rest_trajectories diff --git a/iblrig/gui/ui_micromanipulator.py b/iblrig/gui/ui_micromanipulator.py index 314854f4d..319664714 100644 --- a/iblrig/gui/ui_micromanipulator.py +++ b/iblrig/gui/ui_micromanipulator.py @@ -1,108 +1,345 @@ -# -*- coding: utf-8 -*- - -# Form implementation generated from reading ui file 'iblrig/gui/ui_micromanipulator.ui' -# -# Created by: PyQt5 UI code generator 5.15.9 -# -# WARNING: Any manual changes made to this file will be lost when pyuic5 is -# run again. Do not edit this file unless you know what you are doing. - - -from PyQt5 import QtCore, QtGui, QtWidgets - - -class Ui_MainWindow(object): - def setupUi(self, MainWindow): - MainWindow.setObjectName("MainWindow") - MainWindow.resize(869, 443) - icon = QtGui.QIcon() - icon.addPixmap(QtGui.QPixmap(":/images/iblrig_logo"), QtGui.QIcon.Normal, QtGui.QIcon.Off) - MainWindow.setWindowIcon(icon) - self.centralwidget = QtWidgets.QWidget(MainWindow) - self.centralwidget.setObjectName("centralwidget") - self.gridLayout = QtWidgets.QGridLayout(self.centralwidget) - self.gridLayout.setObjectName("gridLayout") - self.uiLine_phi = QtWidgets.QLineEdit(self.centralwidget) - self.uiLine_phi.setObjectName("uiLine_phi") - self.gridLayout.addWidget(self.uiLine_phi, 1, 5, 1, 1) - self.uiLine_y = QtWidgets.QLineEdit(self.centralwidget) - self.uiLine_y.setObjectName("uiLine_y") - self.gridLayout.addWidget(self.uiLine_y, 1, 1, 1, 1) - self.uiLine_depth = QtWidgets.QLineEdit(self.centralwidget) - self.uiLine_depth.setObjectName("uiLine_depth") - self.gridLayout.addWidget(self.uiLine_depth, 1, 3, 1, 1) - self.uiLine_theta = QtWidgets.QLineEdit(self.centralwidget) - self.uiLine_theta.setObjectName("uiLine_theta") - self.gridLayout.addWidget(self.uiLine_theta, 1, 4, 1, 1) - self.label_5 = QtWidgets.QLabel(self.centralwidget) - self.label_5.setObjectName("label_5") - self.gridLayout.addWidget(self.label_5, 0, 4, 1, 1) - self.label = QtWidgets.QLabel(self.centralwidget) - self.label.setObjectName("label") - self.gridLayout.addWidget(self.label, 0, 0, 1, 1) - self.label_6 = QtWidgets.QLabel(self.centralwidget) - self.label_6.setObjectName("label_6") - self.gridLayout.addWidget(self.label_6, 0, 5, 1, 1) - self.label_3 = QtWidgets.QLabel(self.centralwidget) - self.label_3.setObjectName("label_3") - self.gridLayout.addWidget(self.label_3, 0, 2, 1, 1) - self.label_2 = QtWidgets.QLabel(self.centralwidget) - self.label_2.setObjectName("label_2") - self.gridLayout.addWidget(self.label_2, 0, 1, 1, 1) - self.uiLine_z = QtWidgets.QLineEdit(self.centralwidget) - self.uiLine_z.setObjectName("uiLine_z") - self.gridLayout.addWidget(self.uiLine_z, 1, 2, 1, 1) - self.uiPush_submit = QtWidgets.QPushButton(self.centralwidget) - self.uiPush_submit.setObjectName("uiPush_submit") - self.gridLayout.addWidget(self.uiPush_submit, 4, 5, 1, 1) - self.uiPush_np24 = QtWidgets.QPushButton(self.centralwidget) - self.uiPush_np24.setObjectName("uiPush_np24") - self.gridLayout.addWidget(self.uiPush_np24, 4, 3, 1, 1) - self.uiPush_show = QtWidgets.QPushButton(self.centralwidget) - self.uiPush_show.setObjectName("uiPush_show") - self.gridLayout.addWidget(self.uiPush_show, 4, 4, 1, 1) - self.uiLine_x = QtWidgets.QLineEdit(self.centralwidget) - self.uiLine_x.setObjectName("uiLine_x") - self.gridLayout.addWidget(self.uiLine_x, 1, 0, 1, 1) - self.label_4 = QtWidgets.QLabel(self.centralwidget) - self.label_4.setObjectName("label_4") - self.gridLayout.addWidget(self.label_4, 0, 3, 1, 1) - self.uiMpl = MplWidget(self.centralwidget) - self.uiMpl.setObjectName("uiMpl") - self.gridLayout.addWidget(self.uiMpl, 3, 0, 1, 6) - MainWindow.setCentralWidget(self.centralwidget) - self.menubar = QtWidgets.QMenuBar(MainWindow) - self.menubar.setGeometry(QtCore.QRect(0, 0, 869, 24)) - self.menubar.setObjectName("menubar") - MainWindow.setMenuBar(self.menubar) - self.statusbar = QtWidgets.QStatusBar(MainWindow) - self.statusbar.setObjectName("statusbar") - MainWindow.setStatusBar(self.statusbar) - - self.retranslateUi(MainWindow) - QtCore.QMetaObject.connectSlotsByName(MainWindow) - - def retranslateUi(self, MainWindow): - _translate = QtCore.QCoreApplication.translate - MainWindow.setWindowTitle(_translate("MainWindow", "Micro-Manipulator coordinates")) - self.label_5.setText(_translate("MainWindow", "THETA (degrees)")) - self.label.setText(_translate("MainWindow", "X-ML (UM)")) - self.label_6.setText(_translate("MainWindow", "PHI (degrees)")) - self.label_3.setText(_translate("MainWindow", "Z-DV (UM)")) - self.label_2.setText(_translate("MainWindow", "Y-AP (UM)")) - self.uiPush_submit.setText(_translate("MainWindow", "Submit")) - self.uiPush_np24.setText(_translate("MainWindow", "NP2.4 compute")) - self.uiPush_show.setText(_translate("MainWindow", "Show location")) - self.label_4.setText(_translate("MainWindow", "DEPTH (UM)")) -from iblrig.gui.micromanipulator import MplWidget -from iblrig.gui import resources_rc +import sys +import re +from datetime import date +from pathlib import Path +import traceback + +import numpy as np +from qtpy import QtWidgets, QtCore +from pydantic import BaseModel, Field, field_validator, ValidationError +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas # Fixme qt5 +from matplotlib.figure import Figure + +import iblrig.ephys +import one.alf.path as alfpath +import spikeglx +from iblatlas.atlas import NeedlesAtlas +from iblrig.ephys import neuropixel24_micromanipulator_coordinates +from iblrig.gui.wizard import RigWizardModel, LoginWindow + +default_trajectory = {'x': -1200.1, 'y': -4131.3, 'z': 901.1, 'phi': 270, 'theta': 15, 'depth': 3300.7, 'roll': 0, 'shanks': 4} + +PROBE_MODELS = ('NP2.4', 'NP2.4 QB', 'NP2.1', '3B2', '3A') + +class ProbeInsertion(BaseModel): + """Pydantic model for validating probe insertion data.""" + pname: str = Field(..., title='Probe Name') + x: float = Field(..., title='X-ML (um)') + y: float = Field(..., title='Y-AP (um)') + z: float = Field(..., title='Z-DV (um)') + depth: float = Field(..., title='Depth (um)') + theta: float = Field(..., ge=-90, le=90, title='Theta-Elevation (deg)') + phi: float = Field(..., ge=-180, le=360, title='Phi-Azimuth (deg)') + shanks: int = Field(..., ge=1, le=4, title='# Shanks') + + @field_validator('pname') + @classmethod + def pname_no_special_chars(cls, v: str) -> str: + if not re.match(r'^[a-zA-Z0-9_-]*$', v): + raise ValueError('must not contain spaces or special characters') + return v + + +class MplCanvas(FigureCanvas): + """Matplotlib canvas widget to embed in a Qt application.""" + + def __init__(self, parent=None, width=5, height=4, dpi=100): + self.fig = Figure(figsize=(width, height), dpi=dpi) + self.axes1 = self.fig.add_subplot(1, 1, 1) + self.fig.tight_layout() + super(MplCanvas, self).__init__(self.fig) + self.setParent(parent) + + +class MainWindow(QtWidgets.QMainWindow): + def __init__(self): + super().__init__() + self.settings = QtCore.QSettings("IBL", "MicroManipulatorGUI") + self.model = RigWizardModel() + + self.setWindowTitle("Micro-Manipulator GUI") + self.setGeometry(150, 150, 1100, 900) + + self.atlas = NeedlesAtlas() + + # Main widget and layout + main_widget = QtWidgets.QWidget(self) + self.setCentralWidget(main_widget) + layout = QtWidgets.QVBoxLayout(main_widget) + + # --- Create Form on top --- + self.line_edits = {} + form_widget = QtWidgets.QWidget() + form_layout = QtWidgets.QGridLayout(form_widget) + + self.column_info = {key: field.title for key, field in ProbeInsertion.model_fields.items()} + self.column_keys = list(self.column_info.keys()) + for i, key in enumerate(self.column_keys): + label_text = self.column_info[key] + label = QtWidgets.QLabel(label_text) + line_edit = QtWidgets.QLineEdit() + default_value = str(default_trajectory.get(key, '')) + line_edit.setText(self.settings.value(key, default_value)) + line_edit.setPlaceholderText(label_text) + self.line_edits[key] = line_edit + form_layout.addWidget(label, 0, i) + form_layout.addWidget(line_edit, 1, i) + + compute_button = QtWidgets.QPushButton("Compute") + compute_button.clicked.connect(self.compute) + form_layout.addWidget(compute_button, 1, len(self.column_keys)) + + clear_button = QtWidgets.QPushButton("Clear") + clear_button.clicked.connect(self.clear_table) + form_layout.addWidget(clear_button, 1, len(self.column_keys) + 1) + + # --- Create Table --- + self.table = QtWidgets.QTableWidget(0, len(self.column_keys)) # 0 rows initially + self.table.setHorizontalHeaderLabels([self.column_info[key] for key in self.column_keys]) + self.table.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Stretch) + + # Create Matplotlib canvas + self.canvas = MplCanvas(self, width=4, height=4, dpi=100) + + # --- Create Registration Form at the bottom --- + self.reg_line_edits = {} + reg_form_widget = QtWidgets.QWidget() + reg_form_widget.setMaximumWidth(500) + reg_form_widget.setMaximumHeight(500) + reg_form_layout = QtWidgets.QGridLayout(reg_form_widget) + reg_form_layout.setRowStretch(0, 1) # Add stretch to push content down + + # Column 0: Manual mode and labels + self.manual_mode_checkbox = QtWidgets.QCheckBox("Manual Input") + self.manual_mode_checkbox.toggled.connect(self.toggle_manual_mode) + reg_form_layout.addWidget(self.manual_mode_checkbox, 1, 0) + + reg_fields = {"subject": "Subject", "date": "Date", "number": "Number", "serial": "Serial", "version": "Version"} + for i, (key, label_text) in enumerate(reg_fields.items()): + label = QtWidgets.QLabel(label_text) + reg_form_layout.addWidget(label, i + 2, 0) + + # Column 1: Browse button and input widgets + self.browse_button = QtWidgets.QPushButton("AP File...") + self.browse_button.clicked.connect(self.browse_file) + reg_form_layout.addWidget(self.browse_button, 1, 1) + + # Subject, Date, Number LineEdits + for i, key in enumerate(["subject", "date", "number"]): + line_edit = QtWidgets.QLineEdit() + if key == 'date': + line_edit.setText(date.today().isoformat()) + self.reg_line_edits[key] = line_edit + reg_form_layout.addWidget(line_edit, i + 2, 1) + + # Serial LineEdit + self.reg_line_edits['serial'] = QtWidgets.QLineEdit() + reg_form_layout.addWidget(self.reg_line_edits['serial'], 5, 1) + + # Version ComboBox + self.probe_model_combo = QtWidgets.QComboBox() + self.probe_model_combo.addItems(PROBE_MODELS) + reg_form_layout.addWidget(self.probe_model_combo, 6, 1) + + # Register button below everything + register_button = QtWidgets.QPushButton("Register") + register_button.clicked.connect(self.register) + reg_form_layout.addWidget(register_button, len(reg_fields) + 2, 0, 1, 2) # Span across columns + + # Add widgets to layout + layout.addWidget(form_widget) + layout.addWidget(self.table) + + # Create a horizontal layout for the canvas and the registration form + canvas_and_form_layout = QtWidgets.QHBoxLayout() + canvas_and_form_layout.addWidget(self.canvas, 1) # Give stretch factor of 1 to canvas + canvas_and_form_layout.addWidget(reg_form_widget, 0, QtCore.Qt.AlignTop) # Give stretch factor of 0 to form + layout.addLayout(canvas_and_form_layout) + + self.init_images() + self.toggle_manual_mode(False) # Set initial state to non-manual + + def closeEvent(self, event): + """Save settings when the window is closed.""" + for key, line_edit in self.line_edits.items(): + self.settings.setValue(key, line_edit.text()) + super().closeEvent(event) + + def compute(self): + """ + Triggered by the 'Compute' button. + Validates input fields using a Pydantic model and, if valid, adds a new row to the table. + """ + raw_trajectory = {key: line_edit.text().strip() for key, line_edit in self.line_edits.items()} + + try: + # Validate the data using the Pydantic model + trajectory = ProbeInsertion(**raw_trajectory) + print("All fields are valid. Adding to table.") + trajectory = trajectory.model_dump() + shanks_trajectories = neuropixel24_micromanipulator_coordinates( + trajectory, pname=trajectory['pname'], ba=self.atlas + ) + for k in shanks_trajectories.keys(): + shank_data = shanks_trajectories[k] + shank_data['pname'] = k + shank_data['shanks'] = 1 + self.add_row_to_table(shank_data) + + # we compute the text labels coordinates so they are legible on the overall plot + x = np.array([s['x'] for s in shanks_trajectories.values()]) + y = np.array([s['y'] for s in shanks_trajectories.values()]) + + # this is the angle of the labels from the x-axis positive direction, mathematical direction + angle = np.arctan((y[-1] - y[0]) / (x[-1] - x[0]) ) - np.pi / 2 + # we dilate the labels by 2.5 and move them orthogonal to the shank alignment + xlabels = (x - np.mean(x)) * 2.5 + 400 * np.cos(angle) + np.mean(x) + ylabels = (y - np.mean(y)) * 2.5 + 400 * np.sin(angle) + np.mean(y) + i = 0 + for shank, traj in shanks_trajectories.items(): + self.canvas.axes1.plot(traj['x'], traj['y'], 'xr', label=shank) + self.canvas.axes1.text(xlabels[i], ylabels[i], shank[-1], color='k', fontweight=800) + i += 1 + self.canvas.draw() + + # self.add_row_to_table(validated_data.model_dump()) + except ValidationError as e: + # Display validation errors to the user + error_messages = [] + for error in e.errors(): + field_name = error['loc'][0] + label = self.column_info.get(field_name, field_name) + error_messages.append(f"Error in '{label}': {error['msg']}") + error_dialog = QtWidgets.QMessageBox() + error_dialog.setIcon(QtWidgets.QMessageBox.Warning) + error_dialog.setText("Invalid input") + error_dialog.setInformativeText("\n".join(error_messages)) + error_dialog.setWindowTitle("Validation Error") + error_dialog.exec_() + print("\n".join(error_messages)) + + def add_row_to_table(self, data): + """Adds a new row to the table with the given data.""" + row_position = self.table.rowCount() + self.table.insertRow(row_position) + for i, key in enumerate(self.column_keys): + item = QtWidgets.QTableWidgetItem(str(data.get(key, ''))) + self.table.setItem(row_position, i, item) + + def clear_table(self): + """Clears all rows from the table and resets the plot.""" + self.table.setRowCount(0) + # Clear the lines and labels on the plot + for ax in [self.canvas.axes1]: + [h.remove() for h in ax.lines] + [h.remove() for h in ax.texts] + self.canvas.draw() + + def init_images(self): + # Plot images + self.atlas.compute_surface() + self.atlas.plot_top(volume='image', ax=self.canvas.axes1) + self.canvas.axes1.set_axis_off() + self.canvas.fig.tight_layout() + self.canvas.draw() + + def toggle_manual_mode(self, checked): + """Enable or disable manual input fields.""" + self.browse_button.setEnabled(not checked) + for key, widget in self.reg_line_edits.items(): + widget.setReadOnly(not checked) + widget.setStyleSheet("background-color: lightgray;" if not checked else "") + self.probe_model_combo.setEnabled(checked) + + def browse_file(self): + """Opens a file dialog to select a file and populates fields from its path.""" + options = QtWidgets.QFileDialog.Options() + start_path = str(self.model.iblrig_settings.iblrig_local_subjects_path) + fileName, _ = QtWidgets.QFileDialog.getOpenFileName( + self, "Select AP Binary File", start_path, "AP Binary Files (*.ap.*bin);;All Files (*)", options=options) + if fileName: + print(f"File selected: {fileName}") + binfile = Path(fileName) + session_path = alfpath.get_session_path(binfile) + self.model.session_folder = session_path + try: + # Assumes path structure .../subject/date/number/... + self.reg_line_edits['subject'].setText(session_path.parts[-3]) + self.reg_line_edits['date'].setText(session_path.parts[-2]) + self.reg_line_edits['number'].setText(session_path.parts[-1]) + sr = spikeglx.Reader(binfile) + self.probe_model_combo.setCurrentText(sr.meta['neuropixelVersion']) + self.reg_line_edits['serial'].setText(str(sr.meta['serial'])) + except IndexError: + print("Could not parse subject/date/number from path. Please check the directory structure.") + + def read_table(self): + trajectories = [] + for row in range(self.table.rowCount()): + row_data = {} + for col_idx, key in enumerate(self.column_keys): + item = self.table.item(row, col_idx) + if item: + row_data[key] = item.text() + # Validate and format each row using the Pydantic model + trajectory = ProbeInsertion(**row_data) + trajectories.append(trajectory.model_dump()) # format as a dictionary for Pydantic model validation) + return trajectories + + @property + def iblrig_settings(self): + return self.model.iblrig_settings + + def register(self): + """Placeholder method for the 'Register' button action.""" + subject = self.reg_line_edits['subject'].text() + date_str = self.reg_line_edits['date'].text() + number = self.reg_line_edits['number'].text() + serial = self.reg_line_edits['serial'].text() + version = self.probe_model_combo.currentText() + trajectories = self.read_table() + print(f"Registering: Subject={subject}, Date={date_str}, Number={number}, " + f"Serial={serial}, Version={version}") + + try: + assert subject is not '', "Subject cannot be empty" + assert number is not '', "Number cannot be empty" + assert date is not '', "Date cannot be empty" + self.model.alyx.rest('subjects', 'list', nickname=subject, no_cache=True) + if not self.model.alyx.is_logged_in: + dlg = LoginWindow(parent=self, username='self.iblrig_settings.ALYX_USER', password='', remember=True) + if dlg.result(): + username = dlg.lineEditUsername.text() + password = dlg.lineEditPassword.text() + remember = dlg.checkBoxRememberMe.isChecked() + dlg.deleteLater() + self.model.alyx.authenticate(username=username, password=password, do_cache=remember) + else: + raise ConnectionError('Unable to authenticate with Alyx, check your settings or internet connection') + rest_session = self.model.alyx.rest('sessions', 'list', subject=subject, date_range=[date_str, date_str], number=number) + if len(rest_session) == 1: + eid = rest_session[0]['id'] + elif len(rest_session) == 0: + raise ValueError(f"No session found for subject={subject}, date={date_str}, number={number}") + elif len(rest_session) > 1: + raise ValueError(f"Multiple sessions found for subject={subject}, date={date_str}, number={number}") + iblrig.ephys.register_micromanipulator_coordinates(alyx=self.model.alyx, trajectories=trajectories, eid=None, + metadata=None) + except Exception as e: + # Display validation errors to the user + full_error_message = ''.join(traceback.format_exception(e)) + error_message = str(e) + error_dialog = QtWidgets.QMessageBox() + error_dialog.setIcon(QtWidgets.QMessageBox.Warning) + error_dialog.setText("Invalid input") + error_dialog.setInformativeText(error_message) + error_dialog.setWindowTitle("Validation Error") + error_dialog.exec_() + print(full_error_message) if __name__ == "__main__": - import sys app = QtWidgets.QApplication(sys.argv) - MainWindow = QtWidgets.QMainWindow() - ui = Ui_MainWindow() - ui.setupUi(MainWindow) - MainWindow.show() + main_win = MainWindow() + main_win.show() sys.exit(app.exec_()) diff --git a/iblrig/gui/ui_micromanipulator.ui b/iblrig/gui/ui_micromanipulator.ui deleted file mode 100644 index b2c133232..000000000 --- a/iblrig/gui/ui_micromanipulator.ui +++ /dev/null @@ -1,131 +0,0 @@ - - - MainWindow - - - - 0 - 0 - 869 - 443 - - - - Micro-Manipulator coordinates - - - - :/images/iblrig_logo:/images/iblrig_logo - - - - - - - - - - - - - - - - - - - THETA (degrees) - - - - - - - X-ML (UM) - - - - - - - PHI (degrees) - - - - - - - Z-DV (UM) - - - - - - - Y-AP (UM) - - - - - - - - - - Submit - - - - - - - NP2.4 compute - - - - - - - Show location - - - - - - - - - - DEPTH (UM) - - - - - - - - - - - - 0 - 0 - 869 - 24 - - - - - - - - MplWidget - QGraphicsView -
iblrig.gui.micromanipulator
-
-
- - - - -
diff --git a/iblrig/test/test_ephys.py b/iblrig/test/test_ephys.py index a8c27b7ff..c4a34b73d 100644 --- a/iblrig/test/test_ephys.py +++ b/iblrig/test/test_ephys.py @@ -76,6 +76,6 @@ def test_probe_and_trajectories_creation(self): probe_dict = {'x': 2594.2, 'y': -3123.7, 'z': -711, 'phi': 0 + 15, 'theta': 15, 'depth': 1250.4, 'roll': 0} trajectories = iblrig.ephys.neuropixel24_micromanipulator_coordinates(probe_dict, 'probe01') - iblrig.ephys.register_micromanipulator_coordinates(one=self.one, trajectories=trajectories, eid=self.rest_session['id']) + iblrig.ephys.register_micromanipulator_coordinates(one=self.one.alyx, trajectories=trajectories, eid=self.rest_session['id']) # do it twice to make sure both the get and create cases work - iblrig.ephys.register_micromanipulator_coordinates(one=self.one, trajectories=trajectories, eid=self.rest_session['id']) + iblrig.ephys.register_micromanipulator_coordinates(one=self.one.alyx, trajectories=trajectories, eid=self.rest_session['id']) From 0f9a2116f46b89c9225e1f68a57befded40d0f7d Mon Sep 17 00:00:00 2001 From: Olivier Date: Mon, 24 Nov 2025 12:03:50 +0000 Subject: [PATCH 06/11] changelog and create entrypoint --- CHANGELOG.md | 4 ++ iblrig/gui/micromanipulator.py | 116 ------------------------------ iblrig/gui/ui_micromanipulator.py | 12 ++-- iblrig/test/test_ephys.py | 6 +- pyproject.toml | 1 + 5 files changed, 17 insertions(+), 122 deletions(-) delete mode 100644 iblrig/gui/micromanipulator.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f67427c24..8830541b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ Changelog ========= +UNRELEASED +------ +* added: Neuropixel coordinate registration tool for NP2.4 + 8.30.0 ------ * added: Online Plots are stored as PNG and added to Alyx as a Session Note diff --git a/iblrig/gui/micromanipulator.py b/iblrig/gui/micromanipulator.py deleted file mode 100644 index b8361fd2d..000000000 --- a/iblrig/gui/micromanipulator.py +++ /dev/null @@ -1,116 +0,0 @@ -# convert_uis *micro* -import argparse - -import matplotlib as mpl -import matplotlib.pyplot as plt -import numpy as np -from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as Canvas -from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar -from qtpy.QtCore import QCoreApplication -from qtpy.QtWidgets import QApplication, QMainWindow, QSizePolicy, QVBoxLayout, QWidget - -from iblatlas.atlas import NeedlesAtlas -from iblrig.ephys import neuropixel24_micromanipulator_coordinates -from iblrig.gui.ui_micromanipulator import Ui_MainWindow -from iblrig.gui.wizard import RigWizardModel - -mpl.use('QT5Agg') - - -class GuiMicroManipulator(QMainWindow, Ui_MainWindow): - def __init__(self, **kwargs): - super().__init__() - self.setupUi(self) - self.model = RigWizardModel() - self.model.trajectory = {'x': -1200.1, 'y': -4131.3, 'z': 901.1, 'phi': 270, 'theta': 15, 'depth': 3300.7, 'roll': 0} - self.model.trajectories = {} - self.model.pname = 'probe01' - self.atlas = NeedlesAtlas() - self.atlas.plot_top(ax=self.uiMpl.canvas.ax[0]) - self.atlas.plot_sslice(ml_coordinate=0, ax=self.uiMpl.canvas.ax[1], volume='annotation') - self.uiMpl.canvas.fig.tight_layout() - self.uiPush_np24.clicked.connect(self.on_push_np24) - self.update_view() - - def update_view(self): - self.uiLine_x.setText(str(self.model.trajectory['x'])) - self.uiLine_y.setText(str(self.model.trajectory['y'])) - self.uiLine_z.setText(str(self.model.trajectory['z'])) - self.uiLine_phi.setText(str(self.model.trajectory['phi'])) - self.uiLine_depth.setText(str(self.model.trajectory['depth'])) - self.uiLine_theta.setText(str(self.model.trajectory['theta'])) - - def update_model(self): - self.model.trajectory['x'] = float(self.uiLine_x.text()) - self.model.trajectory['y'] = float(self.uiLine_y.text()) - self.model.trajectory['z'] = float(self.uiLine_z.text()) - self.model.trajectory['phi'] = float(self.uiLine_phi.text()) - self.model.trajectory['depth'] = float(self.uiLine_depth.text()) - self.model.trajectory['theta'] = float(self.uiLine_theta.text()) - - def on_push_np24(self): - self.model.trajectories = neuropixel24_micromanipulator_coordinates( - self.model.trajectory, self.model.pname, ba=self.atlas - ) - self.on_push_show() - - def on_push_show(self): - for ax in self.uiMpl.canvas.ax: - [h.remove() for h in ax.lines] - [h.remove() for h in ax.texts] - self.uiMpl.canvas.ax[1].clear() - - self.uiMpl.canvas.ax[0].plot(self.model.trajectory['x'], self.model.trajectory['y'], '>', color='k') - for shank, traj in self.model.trajectories.items(): - self.uiMpl.canvas.ax[0].plot(traj['x'], traj['y'], 'xr', label=shank) - self.uiMpl.canvas.ax[0].text(traj['x'], traj['y'], shank[-1], color='w', fontweight=800) - - self.atlas.plot_sslice(ml_coordinate=traj['x'] / 1e6, ax=self.uiMpl.canvas.ax[1], volume='annotation') - self.uiMpl.canvas.ax[1].plot(self.model.trajectory['y'], self.model.trajectory['z'], '>', color='k') - for shank, traj in self.model.trajectories.items(): - self.uiMpl.canvas.ax[1].plot(traj['y'], traj['z'], 'xr', label=shank) - self.uiMpl.canvas.ax[1].text(traj['y'], traj['z'], shank[-1], color='w', fontweight=800) - self.uiMpl.canvas.ax[1].set(ylim=np.sort(self.atlas.bc.zlim * 1e6)) - self.uiMpl.canvas.ax[1].set(xlim=self.atlas.bc.ylim * 1e6) - self.uiMpl.canvas.fig.tight_layout() - self.uiMpl.canvas.draw() - - -class MplCanvas(Canvas): - """Matplotlib canvas class to create figure.""" - - def __init__(self): - self.fig, self.ax = plt.subplots(1, 2, gridspec_kw={'width_ratios': [1, 2]}) - Canvas.__init__(self, self.fig) - Canvas.setSizePolicy(self, QSizePolicy.Expanding, QSizePolicy.Expanding) - Canvas.updateGeometry(self) - - -class MplWidget(QWidget): - def __init__(self, parent=None): - QWidget.__init__(self, parent) # Inherit from QWidget - self.canvas = MplCanvas() # Create canvas object - self.toolbar = NavigationToolbar(self.canvas, self) - self.vbl = QVBoxLayout() # Set box for plotting - self.vbl.addWidget(self.toolbar) - self.vbl.addWidget(self.canvas) - self.setLayout(self.vbl) - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument('--subject', default='anonymous', help='Subject name') - args = parser.parse_args() - QCoreApplication.setOrganizationName('International Brain Laboratory') - QCoreApplication.setOrganizationDomain('internationalbrainlab.org') - QCoreApplication.setApplicationName('IBLRIG MicroManipulator') - - app = QApplication(['', '--no-sandbox']) - app.setStyle('Fusion') - w = GuiMicroManipulator(subject=args.subject) - w.show() - app.exec() - - -if __name__ == '__main__': - main() diff --git a/iblrig/gui/ui_micromanipulator.py b/iblrig/gui/ui_micromanipulator.py index 319664714..d6dce398d 100644 --- a/iblrig/gui/ui_micromanipulator.py +++ b/iblrig/gui/ui_micromanipulator.py @@ -303,9 +303,9 @@ def register(self): f"Serial={serial}, Version={version}") try: - assert subject is not '', "Subject cannot be empty" - assert number is not '', "Number cannot be empty" - assert date is not '', "Date cannot be empty" + assert subject != '', "Subject cannot be empty" + assert number != '', "Number cannot be empty" + assert date != '', "Date cannot be empty" self.model.alyx.rest('subjects', 'list', nickname=subject, no_cache=True) if not self.model.alyx.is_logged_in: dlg = LoginWindow(parent=self, username='self.iblrig_settings.ALYX_USER', password='', remember=True) @@ -338,8 +338,12 @@ def register(self): error_dialog.exec_() print(full_error_message) -if __name__ == "__main__": +def main(): app = QtWidgets.QApplication(sys.argv) main_win = MainWindow() main_win.show() sys.exit(app.exec_()) + + +if __name__ == "__main__": + main() diff --git a/iblrig/test/test_ephys.py b/iblrig/test/test_ephys.py index c4a34b73d..db59d8b44 100644 --- a/iblrig/test/test_ephys.py +++ b/iblrig/test/test_ephys.py @@ -76,6 +76,8 @@ def test_probe_and_trajectories_creation(self): probe_dict = {'x': 2594.2, 'y': -3123.7, 'z': -711, 'phi': 0 + 15, 'theta': 15, 'depth': 1250.4, 'roll': 0} trajectories = iblrig.ephys.neuropixel24_micromanipulator_coordinates(probe_dict, 'probe01') - iblrig.ephys.register_micromanipulator_coordinates(one=self.one.alyx, trajectories=trajectories, eid=self.rest_session['id']) + iblrig.ephys.register_micromanipulator_coordinates( + one=self.one.alyx, trajectories=trajectories, eid=self.rest_session['id']) # do it twice to make sure both the get and create cases work - iblrig.ephys.register_micromanipulator_coordinates(one=self.one.alyx, trajectories=trajectories, eid=self.rest_session['id']) + iblrig.ephys.register_micromanipulator_coordinates( + one=self.one.alyx, trajectories=trajectories, eid=self.rest_session['id']) diff --git a/pyproject.toml b/pyproject.toml index 23cc19462..3fbadbf3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,7 @@ start_photometry_task = 'iblrig.neurophotometrics:initialize_subject_cli' convert_uis = "iblrig.gui.tools:convert_uis" validate_iblrig = "iblrig.hardware_validation:run_all_validators_cli" validate_video = "iblrig.video:validate_video_cmd" +neuropixel_coordinates = "iblrig.gui.ui_micromanipulator:main" [tool.pdm.version] From 61eebfac872315353f498e7b8840bb8117ef58cb Mon Sep 17 00:00:00 2001 From: Olivier Date: Mon, 24 Nov 2025 12:11:56 +0000 Subject: [PATCH 07/11] add window icon ! --- iblrig/gui/ui_micromanipulator.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/iblrig/gui/ui_micromanipulator.py b/iblrig/gui/ui_micromanipulator.py index d6dce398d..bb22885de 100644 --- a/iblrig/gui/ui_micromanipulator.py +++ b/iblrig/gui/ui_micromanipulator.py @@ -6,7 +6,7 @@ import traceback import numpy as np -from qtpy import QtWidgets, QtCore +from qtpy import QtWidgets, QtCore, QtGui from pydantic import BaseModel, Field, field_validator, ValidationError from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas # Fixme qt5 from matplotlib.figure import Figure @@ -17,6 +17,8 @@ from iblatlas.atlas import NeedlesAtlas from iblrig.ephys import neuropixel24_micromanipulator_coordinates from iblrig.gui.wizard import RigWizardModel, LoginWindow +from iblrig.gui import resources_rc # noqa: F401 + default_trajectory = {'x': -1200.1, 'y': -4131.3, 'z': 901.1, 'phi': 270, 'theta': 15, 'depth': 3300.7, 'roll': 0, 'shanks': 4} @@ -55,6 +57,8 @@ def __init__(self, parent=None, width=5, height=4, dpi=100): class MainWindow(QtWidgets.QMainWindow): def __init__(self): super().__init__() + + self.settings = QtCore.QSettings("IBL", "MicroManipulatorGUI") self.model = RigWizardModel() @@ -68,6 +72,10 @@ def __init__(self): self.setCentralWidget(main_widget) layout = QtWidgets.QVBoxLayout(main_widget) + icon = QtGui.QIcon() + icon.addPixmap(QtGui.QPixmap(":/images/iblrig_logo"), QtGui.QIcon.Normal, QtGui.QIcon.Off) + self.setWindowIcon(icon) + # --- Create Form on top --- self.line_edits = {} form_widget = QtWidgets.QWidget() From 500241411177d89d4462e505eb5cda0976e0bdcc Mon Sep 17 00:00:00 2001 From: Olivier Date: Mon, 24 Nov 2025 12:15:13 +0000 Subject: [PATCH 08/11] ruff format on top of check --- iblrig/ephys.py | 7 ++++--- iblrig/test/test_ephys.py | 28 +++++++++++++++++----------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/iblrig/ephys.py b/iblrig/ephys.py index bf8a4c219..48220cbbc 100644 --- a/iblrig/ephys.py +++ b/iblrig/ephys.py @@ -64,7 +64,7 @@ def neuropixel24_micromanipulator_coordinates(ref_shank, pname, ba=None, shank_s ba = atlas.NeedlesAtlas() if ba is None else ba trajectories = {} for i, d in enumerate(shank_spacings_um): - spacing_multiplier = d * spacing_sign # flip the direction if reference_shank is 'd' + spacing_multiplier = d * spacing_sign # flip the direction if reference_shank is 'd' x = ref_shank['x'] + np.sin(ref_shank['phi'] / 180 * np.pi) * spacing_multiplier y = ref_shank['y'] - np.cos(ref_shank['phi'] / 180 * np.pi) * spacing_multiplier shank = { @@ -106,6 +106,7 @@ def register_micromanipulator_coordinates(alyx=None, trajectories=None, eid=None if len(rest_trajectory) == 0: rest_trajectories[pname] = alyx.rest('trajectories', 'create', data=traj | traj_extra) else: - rest_trajectories[pname] = alyx.rest('trajectories', 'update', id=rest_trajectory[0]['id'], - data=traj | traj_extra) + rest_trajectories[pname] = alyx.rest( + 'trajectories', 'update', id=rest_trajectory[0]['id'], data=traj | traj_extra + ) return rest_insertions, rest_trajectories diff --git a/iblrig/test/test_ephys.py b/iblrig/test/test_ephys.py index db59d8b44..e64e823c4 100644 --- a/iblrig/test/test_ephys.py +++ b/iblrig/test/test_ephys.py @@ -11,7 +11,6 @@ class TestMicromanipulatorCompute(unittest.TestCase): - def setUp(self): self.actual = { 'probe01a': {'x': 2594.2, 'y': -3123.7, 'z': -231.33599999999996, 'phi': 15, 'theta': 15, 'depth': 1250.4, 'roll': 0}, @@ -46,38 +45,45 @@ def setUp(self): self.actual = pd.DataFrame(self.actual) def test_neuropixel24_micromanipulator(self): - probe_dict = {'x': 2594.2, 'y': -3123.7, 'z': -711, - 'phi': 0 + 15, 'theta': 15, 'depth': 1250.4, 'roll': 0} + probe_dict = {'x': 2594.2, 'y': -3123.7, 'z': -711, 'phi': 0 + 15, 'theta': 15, 'depth': 1250.4, 'roll': 0} trajectories = iblrig.ephys.neuropixel24_micromanipulator_coordinates(probe_dict, 'probe01') np.testing.assert_array_almost_equal(self.actual.to_numpy(), pd.DataFrame(trajectories).sort_index(axis=1).to_numpy()) def test_neuropixel24_micromanipulator_reversed(self): - probe_dict = {'x': 2749.4914270615122, 'y': -3703.255495773441, - 'z': -350.336, 'phi': 15, 'theta': 15, 'depth': 1131.4, 'roll': 0} + probe_dict = { + 'x': 2749.4914270615122, + 'y': -3703.255495773441, + 'z': -350.336, + 'phi': 15, + 'theta': 15, + 'depth': 1131.4, + 'roll': 0, + } trajectories = iblrig.ephys.neuropixel24_micromanipulator_coordinates(probe_dict, 'probe01', pivot_shank='d') np.testing.assert_array_almost_equal(self.actual.to_numpy(), pd.DataFrame(trajectories).sort_index(axis=1).to_numpy()) class TestMicromanipulatorRegister2Alyx(unittest.TestCase): - def setUp(self): self.one = ONE(**TEST_DB) ses_dict = { 'subject': 'algernon', 'start_time': ibllib.time.date2isostr(datetime.datetime.now()), 'number': 1, - 'users': ['test_user']} + 'users': ['test_user'], + } self.rest_session = self.one.alyx.rest('sessions', 'create', data=ses_dict) def tearDown(self): self.one.alyx.rest('sessions', 'delete', id=self.rest_session['id']) def test_probe_and_trajectories_creation(self): - probe_dict = {'x': 2594.2, 'y': -3123.7, 'z': -711, 'phi': 0 + 15, - 'theta': 15, 'depth': 1250.4, 'roll': 0} + probe_dict = {'x': 2594.2, 'y': -3123.7, 'z': -711, 'phi': 0 + 15, 'theta': 15, 'depth': 1250.4, 'roll': 0} trajectories = iblrig.ephys.neuropixel24_micromanipulator_coordinates(probe_dict, 'probe01') iblrig.ephys.register_micromanipulator_coordinates( - one=self.one.alyx, trajectories=trajectories, eid=self.rest_session['id']) + one=self.one.alyx, trajectories=trajectories, eid=self.rest_session['id'] + ) # do it twice to make sure both the get and create cases work iblrig.ephys.register_micromanipulator_coordinates( - one=self.one.alyx, trajectories=trajectories, eid=self.rest_session['id']) + one=self.one.alyx, trajectories=trajectories, eid=self.rest_session['id'] + ) From 7b08d200137615cc043d1fdc2db2817d46bcd5d4 Mon Sep 17 00:00:00 2001 From: Olivier Winter Date: Tue, 25 Nov 2025 09:02:28 +0000 Subject: [PATCH 09/11] bump ibllib version number --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3fbadbf3f..c70b397c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ # # IBL packages "iblatlas>=0.9.0", - "ibllib>=3.4.0", + "ibllib>=3.4.2", "iblpybpod-no-gui>=3.1.1", "iblutil>=1.20.0", "iblqt>=0.8.0", From 5a8c018ef063946ce93ad773d0e12c7e3dc274ca Mon Sep 17 00:00:00 2001 From: Olivier Winter Date: Tue, 25 Nov 2025 10:53:23 +0000 Subject: [PATCH 10/11] fix the tests ! --- iblrig/test/test_ephys.py | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/iblrig/test/test_ephys.py b/iblrig/test/test_ephys.py index e64e823c4..a81c0e140 100644 --- a/iblrig/test/test_ephys.py +++ b/iblrig/test/test_ephys.py @@ -81,9 +81,9 @@ def test_probe_and_trajectories_creation(self): probe_dict = {'x': 2594.2, 'y': -3123.7, 'z': -711, 'phi': 0 + 15, 'theta': 15, 'depth': 1250.4, 'roll': 0} trajectories = iblrig.ephys.neuropixel24_micromanipulator_coordinates(probe_dict, 'probe01') iblrig.ephys.register_micromanipulator_coordinates( - one=self.one.alyx, trajectories=trajectories, eid=self.rest_session['id'] + alyx=self.one.alyx, trajectories=trajectories, eid=self.rest_session['id'] ) # do it twice to make sure both the get and create cases work iblrig.ephys.register_micromanipulator_coordinates( - one=self.one.alyx, trajectories=trajectories, eid=self.rest_session['id'] + alyx=self.one.alyx, trajectories=trajectories, eid=self.rest_session['id'] ) diff --git a/pyproject.toml b/pyproject.toml index c70b397c8..8c02b4a82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ # # IBL packages "iblatlas>=0.9.0", - "ibllib>=3.4.2", + "ibllib>=3.4.3", "iblpybpod-no-gui>=3.1.1", "iblutil>=1.20.0", "iblqt>=0.8.0", From d30b499ddf20365aca860f4315d9e8719a054b71 Mon Sep 17 00:00:00 2001 From: Olivier Winter Date: Fri, 28 Nov 2025 11:03:53 +0000 Subject: [PATCH 11/11] gui micromanipulator: single shank support --- iblrig/gui/ui_micromanipulator.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/iblrig/gui/ui_micromanipulator.py b/iblrig/gui/ui_micromanipulator.py index bb22885de..44239092c 100644 --- a/iblrig/gui/ui_micromanipulator.py +++ b/iblrig/gui/ui_micromanipulator.py @@ -184,11 +184,16 @@ def compute(self): try: # Validate the data using the Pydantic model trajectory = ProbeInsertion(**raw_trajectory) - print("All fields are valid. Adding to table.") trajectory = trajectory.model_dump() - shanks_trajectories = neuropixel24_micromanipulator_coordinates( - trajectory, pname=trajectory['pname'], ba=self.atlas - ) + + if int(raw_trajectory['shanks']) == 1: + _traj = {k:trajectory[k] for k in ['x', 'y', 'z', 'depth', 'theta', 'phi']} + _traj['roll'] = 0 + shanks_trajectories = {trajectory['pname']:_traj} + else: + shanks_trajectories = neuropixel24_micromanipulator_coordinates( + trajectory, pname=trajectory['pname'], ba=self.atlas) + for k in shanks_trajectories.keys(): shank_data = shanks_trajectories[k] shank_data['pname'] = k @@ -205,10 +210,11 @@ def compute(self): xlabels = (x - np.mean(x)) * 2.5 + 400 * np.cos(angle) + np.mean(x) ylabels = (y - np.mean(y)) * 2.5 + 400 * np.sin(angle) + np.mean(y) i = 0 + self.canvas.axes1.plot(x, y, 'x', label=trajectory['pname']) for shank, traj in shanks_trajectories.items(): - self.canvas.axes1.plot(traj['x'], traj['y'], 'xr', label=shank) self.canvas.axes1.text(xlabels[i], ylabels[i], shank[-1], color='k', fontweight=800) i += 1 + self.canvas.axes1.legend() self.canvas.draw() # self.add_row_to_table(validated_data.model_dump())