From 1bb30207038f3a54c0e96dbbbcd1ea7f6c70eca2 Mon Sep 17 00:00:00 2001 From: Ivan Usov Date: Wed, 19 Jul 2023 09:12:53 +0200 Subject: [PATCH 01/10] feat: emit the full bec message to slots * some widgets will require metadata for their operation --- bec_widgets/cli.py | 12 ++++++------ bec_widgets/scan_plot.py | 10 ++++++---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/bec_widgets/cli.py b/bec_widgets/cli.py index d395854e..6db1c7fe 100644 --- a/bec_widgets/cli.py +++ b/bec_widgets/cli.py @@ -11,9 +11,9 @@ from .scan_plot import BECScanPlot class BEC_UI(QMainWindow): - new_scan_data = pyqtSignal(dict) + new_scan_data = pyqtSignal("PyQt_PyObject") new_dap_data = pyqtSignal(dict) # signal per proc instance? - new_scan = pyqtSignal() + new_scan = pyqtSignal("PyQt_PyObject") def __init__(self, uipath): super().__init__() @@ -47,13 +47,13 @@ class BEC_UI(QMainWindow): scan_lock = RLock() def _scan_cb(msg): - msg = BECMessage.ScanMessage.loads(msg.value) + msg = BECMessage.ScanMessage.loads(msg.value)[0] with scan_lock: - scan_id = msg[0].content["scanID"] + scan_id = msg.content["scanID"] if self._scan_id != scan_id: self._scan_id = scan_id - self.new_scan.emit() - self.new_scan_data.emit(msg[0].content["data"]) + self.new_scan.emit(msg) + self.new_scan_data.emit(msg) bec_connector = RedisConnector("localhost:6379") diff --git a/bec_widgets/scan_plot.py b/bec_widgets/scan_plot.py index 2b48592b..2f4b8570 100644 --- a/bec_widgets/scan_plot.py +++ b/bec_widgets/scan_plot.py @@ -41,16 +41,18 @@ class BECScanPlot(pg.PlotWidget): if len(self.scan_curves) == 1: plot_item.setLabel("left", next(iter(self.scan_curves))) - @pyqtSlot() - def clearData(self): + @pyqtSlot("PyQt_PyObject") + def clearData(self, _msg): for plot_curve in {**self.scan_curves, **self.dap_curves}.values(): plot_curve.setData(x=[], y=[]) - @pyqtSlot(dict) - def redraw_scan(self, data): + @pyqtSlot("PyQt_PyObject") + def redraw_scan(self, msg): if not self.x_channel: return + data = msg.content["data"] + if self.x_channel not in data: logger.warning(f"Unknown channel `{self.x_channel}` for X data in {self.objectName()}") return From 67905e896c81383f57c268db544b3314104bda38 Mon Sep 17 00:00:00 2001 From: Ivan Usov Date: Wed, 19 Jul 2023 17:25:18 +0200 Subject: [PATCH 02/10] feat: add BECScanPlot2D --- bec_widgets/cli.py | 10 +++ bec_widgets/scan2d_plot.py | 124 ++++++++++++++++++++++++++++++ bec_widgets/scan2d_plot_plugin.py | 56 ++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 bec_widgets/scan2d_plot.py create mode 100644 bec_widgets/scan2d_plot_plugin.py diff --git a/bec_widgets/cli.py b/bec_widgets/cli.py index 6db1c7fe..bcca01ef 100644 --- a/bec_widgets/cli.py +++ b/bec_widgets/cli.py @@ -6,6 +6,7 @@ from bec_lib.core import BECMessage, MessageEndpoints, RedisConnector from PyQt5 import uic from PyQt5.QtCore import pyqtSignal from PyQt5.QtWidgets import QApplication, QMainWindow +from scan2d_plot import BECScanPlot2D from .scan_plot import BECScanPlot @@ -42,6 +43,15 @@ class BEC_UI(QMainWindow): self.new_dap_data.connect(sp.redraw_dap) self.new_scan.connect(sp.clearData) + for sp in ui.findChildren(BECScanPlot2D): + for chan in (sp.x_channel, sp.y_channel, sp.z_channel): + self._scan_channels.add(chan) + + sp.initialize() + + self.new_scan_data.connect(sp.redraw_scan) + self.new_scan.connect(sp.clearData) + # Scan setup self._scan_id = None scan_lock = RLock() diff --git a/bec_widgets/scan2d_plot.py b/bec_widgets/scan2d_plot.py new file mode 100644 index 00000000..a9d20181 --- /dev/null +++ b/bec_widgets/scan2d_plot.py @@ -0,0 +1,124 @@ +import numpy as np +import pyqtgraph as pg +from bec_lib.core.logger import bec_logger +from PyQt5.QtCore import pyqtProperty, pyqtSlot + +logger = bec_logger.logger + + +pg.setConfigOptions(background="w", foreground="k", antialias=True) + + +class BECScanPlot2D(pg.GraphicsView): + def __init__(self, parent=None, background="default"): + super().__init__(parent, background) + + self._x_channel = "" + self._y_channel = "" + self._z_channel = "" + + self._xpos = [] + self._ypos = [] + + self._x_ind = None + self._y_ind = None + + self.plot_item = pg.PlotItem() + self.setCentralItem(self.plot_item) + self.plot_item.setAspectLocked(True) + + self.imageItem = pg.ImageItem() + self.plot_item.addItem(self.imageItem) + + def initialize(self): + self.plot_item.setLabel("bottom", self.x_channel) + self.plot_item.setLabel("left", self.y_channel) + + @pyqtSlot("PyQt_PyObject") + def clearData(self, msg): + # TODO: Do we reset in case of a scan type change? + self.imageItem.clear() + + # TODO: better to check the number of coordinates in metadata["positions"]? + if msg.metadata["scan_name"] != "grid_scan": + return + + positions = [sorted(set(pos)) for pos in zip(*msg.metadata["positions"])] + + motors = msg.metadata["scan_motors"] + if self.x_channel and self.y_channel: + self._x_ind = motors.index(self.x_channel) if self.x_channel in motors else None + self._y_ind = motors.index(self.y_channel) if self.y_channel in motors else None + elif not self.x_channel and not self.y_channel: + # Plot the first and second motors along x and y axes respectively + self._x_ind = 0 + self._y_ind = 1 + else: + logger.warning( + f"X and Y channels should be either both empty or both set in {self.objectName()}" + ) + + if self._x_ind is None or self._y_ind is None: + return + + xpos = positions[self._x_ind] + ypos = positions[self._y_ind] + + self._xpos = xpos + self._ypos = ypos + + self.imageItem.setImage(np.zeros(shape=(len(xpos), len(ypos)))) + + w = max(xpos) - min(xpos) + h = max(ypos) - min(ypos) + w_pix = w / (len(xpos) - 1) + h_pix = h / (len(ypos) - 1) + self.imageItem.setRect(min(xpos) - w_pix / 2, min(ypos) - h_pix / 2, w + w_pix, h + h_pix) + + self.plot_item.setLabel("bottom", motors[self._x_ind]) + self.plot_item.setLabel("left", motors[self._y_ind]) + + @pyqtSlot("PyQt_PyObject") + def redraw_scan(self, msg): + if not self.z_channel or msg.metadata["scan_name"] != "grid_scan": + return + + if self._x_ind is None or self._y_ind is None: + return + + point_id = msg.content["point_id"] + point_coord = msg.metadata["positions"][point_id] + + x_coord_ind = self._xpos.index(point_coord[self._x_ind]) + y_coord_ind = self._ypos.index(point_coord[self._y_ind]) + + data = msg.content["data"] + z_new = data[self.z_channel][self.z_channel]["value"] + + image = self.imageItem.image + image[x_coord_ind, y_coord_ind] = z_new + self.imageItem.setImage() + + @pyqtProperty(str) + def x_channel(self): + return self._x_channel + + @x_channel.setter + def x_channel(self, new_val): + self._x_channel = new_val + + @pyqtProperty(str) + def y_channel(self): + return self._y_channel + + @y_channel.setter + def y_channel(self, new_val): + self._y_channel = new_val + + @pyqtProperty(str) + def z_channel(self): + return self._z_channel + + @z_channel.setter + def z_channel(self, new_val): + self._z_channel = new_val diff --git a/bec_widgets/scan2d_plot_plugin.py b/bec_widgets/scan2d_plot_plugin.py new file mode 100644 index 00000000..4a6dcf19 --- /dev/null +++ b/bec_widgets/scan2d_plot_plugin.py @@ -0,0 +1,56 @@ +from PyQt5.QtDesigner import QPyDesignerCustomWidgetPlugin +from PyQt5.QtGui import QIcon + +from scan2d_plot import BECScanPlot2D + + +class BECScanPlot2DPlugin(QPyDesignerCustomWidgetPlugin): + def __init__(self, parent=None): + super().__init__(parent) + + self._initialized = False + + def initialize(self, formEditor): + if self._initialized: + return + + self._initialized = True + + def isInitialized(self): + return self._initialized + + def createWidget(self, parent): + return BECScanPlot2D(parent) + + def name(self): + return "BECScanPlot2D" + + def group(self): + return "BEC widgets" + + def icon(self): + return QIcon() + + def toolTip(self): + return "BEC plot for 2D scans" + + def whatsThis(self): + return "BEC plot for 2D scans" + + def isContainer(self): + return False + + def domXml(self): + return ( + '\n' + ' \n' + " BEC plot for 2D scans\n" + " \n" + ' \n' + " BEC plot for 2D scans in Python using PyQt.\n" + " \n" + "\n" + ) + + def includeFile(self): + return "scan2d_plot" From d8c101cdd7f960a152a1f318911cac6eecf6bad4 Mon Sep 17 00:00:00 2001 From: Ivan Usov Date: Wed, 19 Jul 2023 17:20:53 +0200 Subject: [PATCH 03/10] feat: inherit from GraphicsView for consistency with 2D plot --- bec_widgets/scan_plot.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/bec_widgets/scan_plot.py b/bec_widgets/scan_plot.py index 2f4b8570..3e68b200 100644 --- a/bec_widgets/scan_plot.py +++ b/bec_widgets/scan_plot.py @@ -11,10 +11,13 @@ pg.setConfigOptions(background="w", foreground="k", antialias=True) COLORS = ["#fd7f6f", "#7eb0d5", "#b2e061", "#bd7ebe", "#ffb55a"] -class BECScanPlot(pg.PlotWidget): +class BECScanPlot(pg.GraphicsView): def __init__(self, parent=None, background="default"): super().__init__(parent, background) + self.view = pg.PlotItem() + self.setCentralItem(self.view) + self._x_channel = "" self._y_channel_list = [] @@ -22,8 +25,7 @@ class BECScanPlot(pg.PlotWidget): self.dap_curves = {} def initialize(self): - plot_item = self.getPlotItem() - plot_item.addLegend() + self.view.addLegend() colors = itertools.cycle(COLORS) for y_chan in self.y_channel_list: @@ -33,13 +35,13 @@ class BECScanPlot(pg.PlotWidget): else: curves = self.scan_curves - curves[y_chan] = plot_item.plot( + curves[y_chan] = self.view.plot( x=[], y=[], pen=pg.mkPen(color=next(colors), width=2), name=y_chan ) - plot_item.setLabel("bottom", self._x_channel) + self.view.setLabel("bottom", self._x_channel) if len(self.scan_curves) == 1: - plot_item.setLabel("left", next(iter(self.scan_curves))) + self.view.setLabel("left", next(iter(self.scan_curves))) @pyqtSlot("PyQt_PyObject") def clearData(self, _msg): From e7f644c5079a8665d7d872eb0b27ed7da6cbd078 Mon Sep 17 00:00:00 2001 From: Ivan Usov Date: Wed, 19 Jul 2023 17:35:33 +0200 Subject: [PATCH 04/10] fix: module paths --- bec_widgets/cli.py | 3 +-- bec_widgets/scan_plot_plugin.py | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/bec_widgets/cli.py b/bec_widgets/cli.py index bcca01ef..0ce775eb 100644 --- a/bec_widgets/cli.py +++ b/bec_widgets/cli.py @@ -7,8 +7,7 @@ from PyQt5 import uic from PyQt5.QtCore import pyqtSignal from PyQt5.QtWidgets import QApplication, QMainWindow from scan2d_plot import BECScanPlot2D - -from .scan_plot import BECScanPlot +from scan_plot import BECScanPlot class BEC_UI(QMainWindow): diff --git a/bec_widgets/scan_plot_plugin.py b/bec_widgets/scan_plot_plugin.py index 9c405621..5d1fbe20 100644 --- a/bec_widgets/scan_plot_plugin.py +++ b/bec_widgets/scan_plot_plugin.py @@ -1,12 +1,11 @@ from PyQt5.QtDesigner import QPyDesignerCustomWidgetPlugin from PyQt5.QtGui import QIcon - -from .scan_plot import BECScanPlot +from scan_plot import BECScanPlot class BECScanPlotPlugin(QPyDesignerCustomWidgetPlugin): def __init__(self, parent=None): - super(BECScanPlotPlugin, self).__init__(parent) + super().__init__(parent) self._initialized = False From 87163fde32cdc4fad3404cb6ac2bc3767db6f953 Mon Sep 17 00:00:00 2001 From: Ivan Usov Date: Fri, 21 Jul 2023 09:58:35 +0200 Subject: [PATCH 05/10] refactor: widgets setup their own connections --- bec_widgets/bec_dispatcher.py | 73 +++++++++++++++++++++++ bec_widgets/cli.py | 107 ---------------------------------- bec_widgets/scan2d_plot.py | 13 +++-- bec_widgets/scan_plot.py | 49 +++++++++------- 4 files changed, 108 insertions(+), 134 deletions(-) create mode 100644 bec_widgets/bec_dispatcher.py delete mode 100644 bec_widgets/cli.py diff --git a/bec_widgets/bec_dispatcher.py b/bec_widgets/bec_dispatcher.py new file mode 100644 index 00000000..d60b3c4b --- /dev/null +++ b/bec_widgets/bec_dispatcher.py @@ -0,0 +1,73 @@ +from collections import defaultdict +from threading import RLock + +from bec_lib.core import BECMessage, MessageEndpoints, RedisConnector +from PyQt5.QtCore import QObject, pyqtSignal + +bec_connector = RedisConnector("localhost:6379") + + +class _BECDispatcher(QObject): + scan_segment = pyqtSignal("PyQt_PyObject") + new_dap_data = pyqtSignal(dict) + new_scan = pyqtSignal("PyQt_PyObject") + + def __init__(self): + super().__init__() + # TODO: dap might not be a good fit to predefined slots, fix this inconsistency + self._slot_signal_map = { + "on_scan_segment": self.scan_segment, + "on_new_scan": self.new_scan, + } + self._daps = defaultdict(set) + + self._scan_id = None + scan_lock = RLock() + self._dap_threads = [] + + def _scan_cb(msg): + msg = BECMessage.ScanMessage.loads(msg.value)[0] + with scan_lock: + # TODO: use ScanStatusMessage instead? + scan_id = msg.content["scanID"] + if self._scan_id != scan_id: + self._scan_id = scan_id + self.new_scan.emit(msg) + self.scan_segment.emit(msg) + + scan_readback = MessageEndpoints.scan_segment() + self._scan_thread = bec_connector.consumer( + topics=scan_readback, + cb=_scan_cb, + ) + self._scan_thread.start() + + def connect(self, widget): + for slot_name, signal in self._slot_signal_map.items(): + slot = getattr(widget, slot_name, None) + if callable(slot): + signal.connect(slot) + + def connect_dap(self, slot, dap_name): + if dap_name not in self._daps: + + def _dap_cb(msg): + msg = BECMessage.ProcessedDataMessage.loads(msg.value) + self.new_dap_data.emit(msg.content["data"]) + + dap_ep = MessageEndpoints.processed_data(dap_name) + dap_thread = bec_connector.consumer(topics=dap_ep, cb=_dap_cb) + dap_thread.start() + self._dap_threads.append(dap_thread) + + self.new_dap_data.connect(slot) + self._daps[dap_name].add(slot) + + else: + # connect slot if it's not yet connected + if slot not in self._daps[dap_name]: + self._daps[dap_name].add(slot) + self.new_dap_data.connect(slot) + + +bec_dispatcher = _BECDispatcher() diff --git a/bec_widgets/cli.py b/bec_widgets/cli.py deleted file mode 100644 index 0ce775eb..00000000 --- a/bec_widgets/cli.py +++ /dev/null @@ -1,107 +0,0 @@ -import argparse -import os -from threading import RLock - -from bec_lib.core import BECMessage, MessageEndpoints, RedisConnector -from PyQt5 import uic -from PyQt5.QtCore import pyqtSignal -from PyQt5.QtWidgets import QApplication, QMainWindow -from scan2d_plot import BECScanPlot2D -from scan_plot import BECScanPlot - - -class BEC_UI(QMainWindow): - new_scan_data = pyqtSignal("PyQt_PyObject") - new_dap_data = pyqtSignal(dict) # signal per proc instance? - new_scan = pyqtSignal("PyQt_PyObject") - - def __init__(self, uipath): - super().__init__() - self._scan_channels = set() - self._dap_channels = set() - - self._scan_thread = None - self._dap_threads = [] - - ui = uic.loadUi(uipath, self) - - _, fname = os.path.split(uipath) - self.setWindowTitle(fname) - - for sp in ui.findChildren(BECScanPlot): - for chan in (sp.x_channel, *sp.y_channel_list): - if chan.startswith("dap."): - chan = chan.partition("dap.")[-1] - self._dap_channels.add(chan) - else: - self._scan_channels.add(chan) - - sp.initialize() # TODO: move this elsewhere? - - self.new_scan_data.connect(sp.redraw_scan) # TODO: merge - self.new_dap_data.connect(sp.redraw_dap) - self.new_scan.connect(sp.clearData) - - for sp in ui.findChildren(BECScanPlot2D): - for chan in (sp.x_channel, sp.y_channel, sp.z_channel): - self._scan_channels.add(chan) - - sp.initialize() - - self.new_scan_data.connect(sp.redraw_scan) - self.new_scan.connect(sp.clearData) - - # Scan setup - self._scan_id = None - scan_lock = RLock() - - def _scan_cb(msg): - msg = BECMessage.ScanMessage.loads(msg.value)[0] - with scan_lock: - scan_id = msg.content["scanID"] - if self._scan_id != scan_id: - self._scan_id = scan_id - self.new_scan.emit(msg) - self.new_scan_data.emit(msg) - - bec_connector = RedisConnector("localhost:6379") - - if self._scan_channels: - scan_readback = MessageEndpoints.scan_segment() - self._scan_thread = bec_connector.consumer( - topics=scan_readback, - cb=_scan_cb, - ) - self._scan_thread.start() - - # DAP setup - def _proc_cb(msg): - msg = BECMessage.ProcessedDataMessage.loads(msg.value) - self.new_dap_data.emit(msg.content["data"]) - - if self._dap_channels: - for chan in self._dap_channels: - proc_ep = MessageEndpoints.processed_data(chan) - dap_thread = bec_connector.consumer(topics=proc_ep, cb=_proc_cb) - dap_thread.start() - self._dap_threads.append(dap_thread) - - self.show() - - -def main(): - parser = argparse.ArgumentParser( - prog="bec-pyqt", formatter_class=argparse.ArgumentDefaultsHelpFormatter - ) - - parser.add_argument("uipath", type=str, help="Path to a BEC ui file") - - args, rem = parser.parse_known_args() - - app = QApplication(rem) - BEC_UI(args.uipath) - app.exec_() - - -if __name__ == "__main__": - main() diff --git a/bec_widgets/scan2d_plot.py b/bec_widgets/scan2d_plot.py index a9d20181..2d84ed2f 100644 --- a/bec_widgets/scan2d_plot.py +++ b/bec_widgets/scan2d_plot.py @@ -3,6 +3,8 @@ import pyqtgraph as pg from bec_lib.core.logger import bec_logger from PyQt5.QtCore import pyqtProperty, pyqtSlot +from bec_widgets.bec_dispatcher import bec_dispatcher + logger = bec_logger.logger @@ -12,6 +14,7 @@ pg.setConfigOptions(background="w", foreground="k", antialias=True) class BECScanPlot2D(pg.GraphicsView): def __init__(self, parent=None, background="default"): super().__init__(parent, background) + bec_dispatcher.connect(self) self._x_channel = "" self._y_channel = "" @@ -30,12 +33,8 @@ class BECScanPlot2D(pg.GraphicsView): self.imageItem = pg.ImageItem() self.plot_item.addItem(self.imageItem) - def initialize(self): - self.plot_item.setLabel("bottom", self.x_channel) - self.plot_item.setLabel("left", self.y_channel) - @pyqtSlot("PyQt_PyObject") - def clearData(self, msg): + def on_new_scan(self, msg): # TODO: Do we reset in case of a scan type change? self.imageItem.clear() @@ -79,7 +78,7 @@ class BECScanPlot2D(pg.GraphicsView): self.plot_item.setLabel("left", motors[self._y_ind]) @pyqtSlot("PyQt_PyObject") - def redraw_scan(self, msg): + def on_scan_segment(self, msg): if not self.z_channel or msg.metadata["scan_name"] != "grid_scan": return @@ -106,6 +105,7 @@ class BECScanPlot2D(pg.GraphicsView): @x_channel.setter def x_channel(self, new_val): self._x_channel = new_val + self.plot_item.setLabel("bottom", new_val) @pyqtProperty(str) def y_channel(self): @@ -114,6 +114,7 @@ class BECScanPlot2D(pg.GraphicsView): @y_channel.setter def y_channel(self, new_val): self._y_channel = new_val + self.plot_item.setLabel("left", new_val) @pyqtProperty(str) def z_channel(self): diff --git a/bec_widgets/scan_plot.py b/bec_widgets/scan_plot.py index 3e68b200..4b9b410f 100644 --- a/bec_widgets/scan_plot.py +++ b/bec_widgets/scan_plot.py @@ -4,6 +4,8 @@ import pyqtgraph as pg from bec_lib.core.logger import bec_logger from PyQt5.QtCore import pyqtProperty, pyqtSlot +from bec_widgets.bec_dispatcher import bec_dispatcher + logger = bec_logger.logger @@ -14,6 +16,7 @@ COLORS = ["#fd7f6f", "#7eb0d5", "#b2e061", "#bd7ebe", "#ffb55a"] class BECScanPlot(pg.GraphicsView): def __init__(self, parent=None, background="default"): super().__init__(parent, background) + bec_dispatcher.connect(self) self.view = pg.PlotItem() self.setCentralItem(self.view) @@ -24,32 +27,13 @@ class BECScanPlot(pg.GraphicsView): self.scan_curves = {} self.dap_curves = {} - def initialize(self): - self.view.addLegend() - colors = itertools.cycle(COLORS) - - for y_chan in self.y_channel_list: - if y_chan.startswith("dap."): - y_chan = y_chan.partition("dap.")[-1] - curves = self.dap_curves - else: - curves = self.scan_curves - - curves[y_chan] = self.view.plot( - x=[], y=[], pen=pg.mkPen(color=next(colors), width=2), name=y_chan - ) - - self.view.setLabel("bottom", self._x_channel) - if len(self.scan_curves) == 1: - self.view.setLabel("left", next(iter(self.scan_curves))) - @pyqtSlot("PyQt_PyObject") - def clearData(self, _msg): + def on_new_scan(self, _msg): for plot_curve in {**self.scan_curves, **self.dap_curves}.values(): plot_curve.setData(x=[], y=[]) @pyqtSlot("PyQt_PyObject") - def redraw_scan(self, msg): + def on_scan_segment(self, msg): if not self.x_channel: return @@ -100,6 +84,28 @@ class BECScanPlot(pg.GraphicsView): def y_channel_list(self, new_list): self._y_channel_list = new_list + # Prepare plot for a potentially different list of y channels + self.view.clear() + + self.view.addLegend() + colors = itertools.cycle(COLORS) + + for y_chan in new_list: + # TODO: ideally, we dont want to care about dap/not dap here + if y_chan.startswith("dap."): + y_chan = y_chan.partition("dap.")[-1] + curves = self.dap_curves + bec_dispatcher.connect_dap(self.redraw_dap, y_chan) + else: + curves = self.scan_curves + + curves[y_chan] = self.view.plot( + x=[], y=[], pen=pg.mkPen(color=next(colors), width=2), name=y_chan + ) + + if len(new_list) == 1: + self.view.setLabel("left", new_list[0]) + @pyqtProperty(str) def x_channel(self): return self._x_channel @@ -107,6 +113,7 @@ class BECScanPlot(pg.GraphicsView): @x_channel.setter def x_channel(self, new_val): self._x_channel = new_val + self.view.setLabel("bottom", new_val) if __name__ == "__main__": From ff534ad67ffea1e7ebf94366a7c8f7b336cb8776 Mon Sep 17 00:00:00 2001 From: Ivan Usov Date: Mon, 24 Jul 2023 10:16:23 +0200 Subject: [PATCH 06/10] refactor: use BECClient for cb on scan_segment --- bec_widgets/bec_dispatcher.py | 30 +++++++++++++----------------- bec_widgets/scan2d_plot.py | 21 ++++++++++----------- bec_widgets/scan_plot.py | 10 +++++----- 3 files changed, 28 insertions(+), 33 deletions(-) diff --git a/bec_widgets/bec_dispatcher.py b/bec_widgets/bec_dispatcher.py index d60b3c4b..fc72a8d2 100644 --- a/bec_widgets/bec_dispatcher.py +++ b/bec_widgets/bec_dispatcher.py @@ -1,19 +1,21 @@ from collections import defaultdict from threading import RLock -from bec_lib.core import BECMessage, MessageEndpoints, RedisConnector +from bec_lib import BECClient +from bec_lib.core import BECMessage, MessageEndpoints from PyQt5.QtCore import QObject, pyqtSignal -bec_connector = RedisConnector("localhost:6379") - class _BECDispatcher(QObject): - scan_segment = pyqtSignal("PyQt_PyObject") + new_scan = pyqtSignal(dict, dict) + scan_segment = pyqtSignal(dict, dict) new_dap_data = pyqtSignal(dict) - new_scan = pyqtSignal("PyQt_PyObject") def __init__(self): super().__init__() + self.client = BECClient() + self.client.start() + # TODO: dap might not be a good fit to predefined slots, fix this inconsistency self._slot_signal_map = { "on_scan_segment": self.scan_segment, @@ -25,22 +27,16 @@ class _BECDispatcher(QObject): scan_lock = RLock() self._dap_threads = [] - def _scan_cb(msg): - msg = BECMessage.ScanMessage.loads(msg.value)[0] + def _scan_segment_cb(scan_segment, metadata): with scan_lock: # TODO: use ScanStatusMessage instead? - scan_id = msg.content["scanID"] + scan_id = metadata["scanID"] if self._scan_id != scan_id: self._scan_id = scan_id - self.new_scan.emit(msg) - self.scan_segment.emit(msg) + self.new_scan.emit(scan_segment, metadata) + self.scan_segment.emit(scan_segment, metadata) - scan_readback = MessageEndpoints.scan_segment() - self._scan_thread = bec_connector.consumer( - topics=scan_readback, - cb=_scan_cb, - ) - self._scan_thread.start() + self.client.callbacks.register("scan_segment", _scan_segment_cb, sync=False) def connect(self, widget): for slot_name, signal in self._slot_signal_map.items(): @@ -56,7 +52,7 @@ class _BECDispatcher(QObject): self.new_dap_data.emit(msg.content["data"]) dap_ep = MessageEndpoints.processed_data(dap_name) - dap_thread = bec_connector.consumer(topics=dap_ep, cb=_dap_cb) + dap_thread = self.client.connector.consumer(topics=dap_ep, cb=_dap_cb) dap_thread.start() self._dap_threads.append(dap_thread) diff --git a/bec_widgets/scan2d_plot.py b/bec_widgets/scan2d_plot.py index 2d84ed2f..4c1bedaf 100644 --- a/bec_widgets/scan2d_plot.py +++ b/bec_widgets/scan2d_plot.py @@ -33,18 +33,18 @@ class BECScanPlot2D(pg.GraphicsView): self.imageItem = pg.ImageItem() self.plot_item.addItem(self.imageItem) - @pyqtSlot("PyQt_PyObject") - def on_new_scan(self, msg): + @pyqtSlot(dict, dict) + def on_new_scan(self, _scan_segment, metadata): # TODO: Do we reset in case of a scan type change? self.imageItem.clear() # TODO: better to check the number of coordinates in metadata["positions"]? - if msg.metadata["scan_name"] != "grid_scan": + if metadata["scan_name"] != "grid_scan": return - positions = [sorted(set(pos)) for pos in zip(*msg.metadata["positions"])] + positions = [sorted(set(pos)) for pos in zip(*metadata["positions"])] - motors = msg.metadata["scan_motors"] + motors = metadata["scan_motors"] if self.x_channel and self.y_channel: self._x_ind = motors.index(self.x_channel) if self.x_channel in motors else None self._y_ind = motors.index(self.y_channel) if self.y_channel in motors else None @@ -77,21 +77,20 @@ class BECScanPlot2D(pg.GraphicsView): self.plot_item.setLabel("bottom", motors[self._x_ind]) self.plot_item.setLabel("left", motors[self._y_ind]) - @pyqtSlot("PyQt_PyObject") - def on_scan_segment(self, msg): - if not self.z_channel or msg.metadata["scan_name"] != "grid_scan": + @pyqtSlot(dict, dict) + def on_scan_segment(self, scan_segment, metadata): + if not self.z_channel or metadata["scan_name"] != "grid_scan": return if self._x_ind is None or self._y_ind is None: return - point_id = msg.content["point_id"] - point_coord = msg.metadata["positions"][point_id] + point_coord = metadata["positions"][scan_segment["point_id"]] x_coord_ind = self._xpos.index(point_coord[self._x_ind]) y_coord_ind = self._ypos.index(point_coord[self._y_ind]) - data = msg.content["data"] + data = scan_segment["data"] z_new = data[self.z_channel][self.z_channel]["value"] image = self.imageItem.image diff --git a/bec_widgets/scan_plot.py b/bec_widgets/scan_plot.py index 4b9b410f..2e554ce5 100644 --- a/bec_widgets/scan_plot.py +++ b/bec_widgets/scan_plot.py @@ -27,17 +27,17 @@ class BECScanPlot(pg.GraphicsView): self.scan_curves = {} self.dap_curves = {} - @pyqtSlot("PyQt_PyObject") - def on_new_scan(self, _msg): + @pyqtSlot(dict, dict) + def on_new_scan(self, _scan_segment, _metadata): for plot_curve in {**self.scan_curves, **self.dap_curves}.values(): plot_curve.setData(x=[], y=[]) - @pyqtSlot("PyQt_PyObject") - def on_scan_segment(self, msg): + @pyqtSlot(dict, dict) + def on_scan_segment(self, scan_segment, _metadata): if not self.x_channel: return - data = msg.content["data"] + data = scan_segment["data"] if self.x_channel not in data: logger.warning(f"Unknown channel `{self.x_channel}` for X data in {self.objectName()}") From b16406a7fc1a6c169e3db466a0cdc2be50d74f5b Mon Sep 17 00:00:00 2001 From: Ivan Usov Date: Mon, 24 Jul 2023 11:56:31 +0200 Subject: [PATCH 07/10] refactor: move plugins into a separate folder --- bec_widgets/qtdesigner_plugins/__init__.py | 0 bec_widgets/{ => qtdesigner_plugins}/scan2d_plot_plugin.py | 2 +- bec_widgets/{ => qtdesigner_plugins}/scan_plot_plugin.py | 3 ++- bec_widgets/readme.md | 7 ++++++- 4 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 bec_widgets/qtdesigner_plugins/__init__.py rename bec_widgets/{ => qtdesigner_plugins}/scan2d_plot_plugin.py (96%) rename bec_widgets/{ => qtdesigner_plugins}/scan_plot_plugin.py (96%) diff --git a/bec_widgets/qtdesigner_plugins/__init__.py b/bec_widgets/qtdesigner_plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/scan2d_plot_plugin.py b/bec_widgets/qtdesigner_plugins/scan2d_plot_plugin.py similarity index 96% rename from bec_widgets/scan2d_plot_plugin.py rename to bec_widgets/qtdesigner_plugins/scan2d_plot_plugin.py index 4a6dcf19..1ff71401 100644 --- a/bec_widgets/scan2d_plot_plugin.py +++ b/bec_widgets/qtdesigner_plugins/scan2d_plot_plugin.py @@ -1,7 +1,7 @@ from PyQt5.QtDesigner import QPyDesignerCustomWidgetPlugin from PyQt5.QtGui import QIcon -from scan2d_plot import BECScanPlot2D +from bec_widgets.scan2d_plot import BECScanPlot2D class BECScanPlot2DPlugin(QPyDesignerCustomWidgetPlugin): diff --git a/bec_widgets/scan_plot_plugin.py b/bec_widgets/qtdesigner_plugins/scan_plot_plugin.py similarity index 96% rename from bec_widgets/scan_plot_plugin.py rename to bec_widgets/qtdesigner_plugins/scan_plot_plugin.py index 5d1fbe20..38412190 100644 --- a/bec_widgets/scan_plot_plugin.py +++ b/bec_widgets/qtdesigner_plugins/scan_plot_plugin.py @@ -1,6 +1,7 @@ from PyQt5.QtDesigner import QPyDesignerCustomWidgetPlugin from PyQt5.QtGui import QIcon -from scan_plot import BECScanPlot + +from bec_widgets.scan_plot import BECScanPlot class BECScanPlotPlugin(QPyDesignerCustomWidgetPlugin): diff --git a/bec_widgets/readme.md b/bec_widgets/readme.md index 84ed3b49..aed334a1 100644 --- a/bec_widgets/readme.md +++ b/bec_widgets/readme.md @@ -1,6 +1,11 @@ Add/modify the path in the following variable to make the plugin avaiable in Qt Designer: ``` -$ export PYQTDESIGNERPATH=//bec/bec_qtplugin:$PYQTDESIGNERPATH +$ export PYQTDESIGNERPATH=//bec_widgets/qtdesigner_plugins +``` + +It can be done when activating a conda environment (run with the corresponding env already activated): +``` +$ conda env config vars set PYQTDESIGNERPATH=//bec_widgets/qtdesigner_plugins ``` All the available conda-forge `pyqt >=5.15` packages don't seem to support loading Qt Designer From cd11ee51c1c725255e748a32b89a74487e84a631 Mon Sep 17 00:00:00 2001 From: Ivan Usov Date: Mon, 24 Jul 2023 15:06:50 +0200 Subject: [PATCH 08/10] fix: fix examples when run directly as a script --- bec_widgets/scan2d_plot.py | 16 ++++++++++++++++ bec_widgets/scan_plot.py | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/bec_widgets/scan2d_plot.py b/bec_widgets/scan2d_plot.py index 4c1bedaf..57a7f4af 100644 --- a/bec_widgets/scan2d_plot.py +++ b/bec_widgets/scan2d_plot.py @@ -122,3 +122,19 @@ class BECScanPlot2D(pg.GraphicsView): @z_channel.setter def z_channel(self, new_val): self._z_channel = new_val + + +if __name__ == "__main__": + import sys + + from PyQt5.QtWidgets import QApplication + + app = QApplication(sys.argv) + + plot = BECScanPlot2D() + # If x_channel and y_channel are both omitted, they will be inferred from each running grid scan + plot.z_channel = "bpm3y" + + plot.show() + + sys.exit(app.exec_()) diff --git a/bec_widgets/scan_plot.py b/bec_widgets/scan_plot.py index 2e554ce5..8905c588 100644 --- a/bec_widgets/scan_plot.py +++ b/bec_widgets/scan_plot.py @@ -124,9 +124,9 @@ if __name__ == "__main__": app = QApplication(sys.argv) plot = BECScanPlot() - plot.y_channel_list = ["a", "b", "c"] + plot.x_channel = "samx" + plot.y_channel_list = ["bpm3y", "bpm6y"] - plot.initialize() plot.show() sys.exit(app.exec_()) From 1325704750ebab897e3dcae80c9d455bfbbf886f Mon Sep 17 00:00:00 2001 From: Ivan Usov Date: Mon, 31 Jul 2023 16:52:04 +0200 Subject: [PATCH 09/10] feat: add disconnect_dap_slot --- bec_widgets/bec_dispatcher.py | 46 ++++++++++++++++++++++++++--------- bec_widgets/scan_plot.py | 9 +++++-- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/bec_widgets/bec_dispatcher.py b/bec_widgets/bec_dispatcher.py index fc72a8d2..4a89cf5e 100644 --- a/bec_widgets/bec_dispatcher.py +++ b/bec_widgets/bec_dispatcher.py @@ -1,11 +1,20 @@ -from collections import defaultdict +from dataclasses import dataclass from threading import RLock from bec_lib import BECClient from bec_lib.core import BECMessage, MessageEndpoints +from bec_lib.core.redis_connector import RedisConsumerThreaded from PyQt5.QtCore import QObject, pyqtSignal +@dataclass +class _BECDap: + """Utility class to keep track of slots associated with a particular dap redis consumer""" + + consumer: RedisConsumerThreaded + slots = set() + + class _BECDispatcher(QObject): new_scan = pyqtSignal(dict, dict) scan_segment = pyqtSignal(dict, dict) @@ -16,16 +25,14 @@ class _BECDispatcher(QObject): self.client = BECClient() self.client.start() - # TODO: dap might not be a good fit to predefined slots, fix this inconsistency self._slot_signal_map = { "on_scan_segment": self.scan_segment, "on_new_scan": self.new_scan, } - self._daps = defaultdict(set) + self._daps = {} self._scan_id = None scan_lock = RLock() - self._dap_threads = [] def _scan_segment_cb(scan_segment, metadata): with scan_lock: @@ -44,26 +51,43 @@ class _BECDispatcher(QObject): if callable(slot): signal.connect(slot) - def connect_dap(self, slot, dap_name): + def connect_dap_slot(self, slot, dap_name): if dap_name not in self._daps: + # create a new consumer and connect slot def _dap_cb(msg): msg = BECMessage.ProcessedDataMessage.loads(msg.value) self.new_dap_data.emit(msg.content["data"]) dap_ep = MessageEndpoints.processed_data(dap_name) - dap_thread = self.client.connector.consumer(topics=dap_ep, cb=_dap_cb) - dap_thread.start() - self._dap_threads.append(dap_thread) + consumer = self.client.connector.consumer(topics=dap_ep, cb=_dap_cb) + consumer.start() self.new_dap_data.connect(slot) - self._daps[dap_name].add(slot) + + self._daps[dap_name] = _BECDap(consumer) + self._daps[dap_name].slots.add(slot) else: # connect slot if it's not yet connected - if slot not in self._daps[dap_name]: - self._daps[dap_name].add(slot) + if slot not in self._daps[dap_name].slots: self.new_dap_data.connect(slot) + self._daps[dap_name].slots.add(slot) + + def disconnect_dap_slot(self, slot, dap_name): + if dap_name not in self._daps: + return + + if slot not in self._daps[dap_name].slots: + return + + self.new_dap_data.disconnect(slot) + self._daps[dap_name].slots.remove(slot) + + if not self._daps[dap_name].slots: + # shutdown consumer if there are no more connected slots + self._daps[dap_name].consumer.shutdown() + del self._daps[dap_name] bec_dispatcher = _BECDispatcher() diff --git a/bec_widgets/scan_plot.py b/bec_widgets/scan_plot.py index 8905c588..b565283f 100644 --- a/bec_widgets/scan_plot.py +++ b/bec_widgets/scan_plot.py @@ -82,6 +82,12 @@ class BECScanPlot(pg.GraphicsView): @y_channel_list.setter def y_channel_list(self, new_list): + # TODO: do we want to care about dap/not dap here? + chan_removed = [chan for chan in self._y_channel_list if chan not in new_list] + if chan_removed and chan_removed[0].startswith("dap."): + chan_removed = chan_removed[0].partition("dap.")[-1] + bec_dispatcher.disconnect_dap_slot(self.redraw_dap, chan_removed) + self._y_channel_list = new_list # Prepare plot for a potentially different list of y channels @@ -91,11 +97,10 @@ class BECScanPlot(pg.GraphicsView): colors = itertools.cycle(COLORS) for y_chan in new_list: - # TODO: ideally, we dont want to care about dap/not dap here if y_chan.startswith("dap."): y_chan = y_chan.partition("dap.")[-1] curves = self.dap_curves - bec_dispatcher.connect_dap(self.redraw_dap, y_chan) + bec_dispatcher.connect_dap_slot(self.redraw_dap, y_chan) else: curves = self.scan_curves From 91d8ffacffcbeebdf7623caf62e07244c4dcee16 Mon Sep 17 00:00:00 2001 From: Ivan Usov Date: Mon, 31 Jul 2023 17:57:15 +0200 Subject: [PATCH 10/10] feat: add display_ui_file.py --- bec_widgets/display_ui_file.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 bec_widgets/display_ui_file.py diff --git a/bec_widgets/display_ui_file.py b/bec_widgets/display_ui_file.py new file mode 100644 index 00000000..b791c481 --- /dev/null +++ b/bec_widgets/display_ui_file.py @@ -0,0 +1,33 @@ +import os +import sys + +from PyQt5 import QtWidgets, uic + + +class UI(QtWidgets.QWidget): + def __init__(self, uipath): + super().__init__() + + self.ui = uic.loadUi(uipath, self) + + _, fname = os.path.split(uipath) + self.setWindowTitle(fname) + + self.show() + + +def main(): + """A basic script to display UI file + + Run the script, passing UI file path as an argument, e.g. + $ python bec_widgets/display_ui_file.py bec_widgets/line_plot.ui + """ + app = QtWidgets.QApplication(sys.argv) + + UI(sys.argv[1]) + + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main()