From 69c38d67e4e9b8a30767f6f67defce6c5c2e5b16 Mon Sep 17 00:00:00 2001 From: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com> Date: Wed, 30 Aug 2023 13:06:01 +0200 Subject: [PATCH 01/14] feat: multi window interface created for extreme BL --- bec_widgets/examples/extreme/config.yaml | 3 + bec_widgets/examples/extreme/extreme.py | 282 +++++++++++++++++++++++ bec_widgets/examples/extreme/extreme.ui | 57 +++++ bec_widgets/examples/oneplot/oneplot.py | 1 + 4 files changed, 343 insertions(+) create mode 100644 bec_widgets/examples/extreme/config.yaml create mode 100644 bec_widgets/examples/extreme/extreme.py create mode 100644 bec_widgets/examples/extreme/extreme.ui diff --git a/bec_widgets/examples/extreme/config.yaml b/bec_widgets/examples/extreme/config.yaml new file mode 100644 index 00000000..f59651f8 --- /dev/null +++ b/bec_widgets/examples/extreme/config.yaml @@ -0,0 +1,3 @@ +xy_pairs: [["samx", ["gauss_bpm", "gauss_adc1"]], + ["samx", ["gauss_adc1", "gauss_adc2"]], + ["samx", ["gauss_adc2"]]] diff --git a/bec_widgets/examples/extreme/extreme.py b/bec_widgets/examples/extreme/extreme.py new file mode 100644 index 00000000..747fdd4e --- /dev/null +++ b/bec_widgets/examples/extreme/extreme.py @@ -0,0 +1,282 @@ +import os + +import numpy as np +import pyqtgraph as pg +from PyQt5.QtCore import pyqtSignal, pyqtSlot +from PyQt5.QtWidgets import QApplication, QWidget, QTableWidgetItem, QTableWidget +from pyqtgraph import mkBrush, mkColor, mkPen +from pyqtgraph.Qt import QtCore, uic + +from bec_lib.core import MessageEndpoints +from bec_widgets.qt_utils import Crosshair + + +# TODO implement: +# - implement scanID database for visualizing previous scans +# - change how dap is handled in bec_dispatcher to handle more workers + + +class PlotApp(QWidget): + """ + Main class for the PlotApp used to plot two signals from the BEC. + + Attributes: + update_signal (pyqtSignal): Signal to trigger plot updates. + update_dap_signal (pyqtSignal): Signal to trigger DAP updates. + + Args: + x_value (str): The x device/signal for plotting. + y_values (list of str): List of y device/signals for plotting. + dap_worker (str, optional): DAP process to specify. Set to None to disable. + parent (QWidget, optional): Parent widget. + """ + + update_signal = pyqtSignal() + update_dap_signal = pyqtSignal() + + def __init__(self, xy_pairs, parent=None): + super(PlotApp, self).__init__(parent) + current_path = os.path.dirname(__file__) + uic.loadUi(os.path.join(current_path, "extreme.ui"), self) + + # xy pairs for setting number of windows + self.xy_pairs = xy_pairs + + # Nested dictionary to hold x and y data for multiple plots + self.data = {} + + self.crosshairs = None + self.plots = None + self.curves_data = None + self.grid_coordinates = None + self.scanID = None + + # Initialize the UI + self.init_ui() + self.init_curves() + + # Connect the update signal to the update plot method + self.proxy_update_plot = pg.SignalProxy( + self.update_signal, rateLimit=25, slot=self.update_plot + ) + + def init_ui(self) -> None: + """ + Initialize the UI components, create plots and store their grid positions. + + This method initializes a dictionary `self.plots` to store the plot objects + along with their corresponding x and y signal names. It also keeps track of + the row and column grid positions for each plot in `self.grid_coordinates`. + """ + self.plots = {} + self.grid_coordinates = [] # List to keep track of grid positions for each plot + + for i, (x, ys) in enumerate(self.xy_pairs): + row, col = i // 2, i % 2 # Change these numbers based on your grid layout + plot = self.glw.addPlot(row=row, col=col) + plot.setLabel("bottom", x) + plot.setLabel("left", ", ".join(ys)) + plot.addLegend() + self.plots[(x, tuple(ys))] = plot + self.grid_coordinates.append((row, col)) # Store the grid position + + self.splitter.setSizes([200, 100]) + + def init_curves(self) -> None: + """ + Initialize curve data and properties, and update table row labels. + + This method initializes a nested dictionary `self.curves_data` to store + the curve objects for each x and y signal pair. It also updates the row labels + in `self.tableWidget_crosshair` to include the grid position for each y-value. + """ + self.curves_data = {} # Nested dictionary to hold curves + + row_labels = [] # List to keep track of row labels for the table + + for idx, ((x, ys), plot) in enumerate(self.plots.items()): + plot.clear() + self.curves_data[(x, tuple(ys))] = [] + colors_ys = PlotApp.golden_angle_color(colormap="CET-R2", num=len(ys)) + + row, col = self.grid_coordinates[idx] # Retrieve the grid position for this plot + + for i, (signal, color) in enumerate(zip(ys, colors_ys)): + pen_curve = mkPen(color=color, width=2, style=QtCore.Qt.DashLine) + brush_curve = mkBrush(color=color) + curve_data = pg.PlotDataItem( + symbolSize=5, + symbolBrush=brush_curve, + pen=pen_curve, + skipFiniteCheck=True, + name=f"{signal}", + ) + self.curves_data[(x, tuple(ys))].append(curve_data) + plot.addItem(curve_data) + row_labels.append(f"{signal} - [{row},{col}]") # Add row label with grid position + + self.tableWidget_crosshair.setRowCount(len(row_labels)) + self.tableWidget_crosshair.setVerticalHeaderLabels(row_labels) + self.hook_crosshair() + + def hook_crosshair(self): + """Attach crosshairs to each plot and connect them to the update_table method.""" + self.crosshairs = {} # Store crosshairs for each plot + for (x, ys), plot in self.plots.items(): + crosshair = Crosshair(plot, precision=3) + crosshair.coordinatesChanged1D.connect( + lambda x, y, plot=plot: self.update_table( + self.tableWidget_crosshair, x, y, column=0, plot=plot + ) + ) + crosshair.coordinatesClicked1D.connect( + lambda x, y, plot=plot: self.update_table( + self.tableWidget_crosshair, x, y, column=1, plot=plot + ) + ) + self.crosshairs[(x, tuple(ys))] = crosshair + + def update_table( + self, table_widget: QTableWidget, x: float, y_values: list, column: int, plot: pg.PlotItem + ) -> None: + """ + Update the table with coordinates based on cursor movements and clicks. + + Args: + table_widget (QTableWidget): The table to be updated. + x (float): The x-coordinate from the plot. + y_values (list): The y-coordinates from the plot. + column (int): The column in the table to be updated. + plot (PlotItem): The plot from which the coordinates are taken. + + This method calculates the correct row in the table for each y-value + and updates the cell at (row, column) with the new x and y coordinates. + """ + plot_key = [key for key, value in self.plots.items() if value == plot][0] + _, ys = plot_key # Extract the y-values for the current plot + + # Find the starting row for the ys of the current plot + starting_row = 0 + for _, other_ys in self.xy_pairs: + if other_ys == list(ys): + break + starting_row += len(other_ys) + + # Update the table rows corresponding to the ys of the current plot + for i, y in enumerate(y_values): + row = starting_row + i + table_widget.setItem(row, column, QTableWidgetItem(f"({x}, {y})")) + table_widget.resizeColumnsToContents() + + def update_plot(self) -> None: + """Update the plot data based on the stored data dictionary.""" + for (x, ys), curves in self.curves_data.items(): + data_x = self.data.get((x, tuple(ys)), {}).get("x", []) + for i, curve in enumerate(curves): + data_y = self.data.get((x, tuple(ys)), {}).get(ys[i], []) + curve.setData(data_x, data_y) + + @pyqtSlot(dict, dict) + def on_scan_segment(self, msg, metadata) -> None: + """ + Handle new scan segments and saves data to a dictionary. + + Args: + msg (dict): Message received with scan data. + metadata (dict): Metadata of the scan. + """ + current_scanID = msg.get("scanID", None) + if current_scanID is None: + return + + if current_scanID != self.scanID: + self.scanID = current_scanID + self.data = {} # Wipe the data for a new scan + self.init_curves() # Re-initialize the curves + + for x, ys in self.xy_pairs: + data_x = msg["data"].get(x, {}).get(x, {}).get("value", None) + if data_x is not None: + self.data.setdefault((x, tuple(ys)), {}).setdefault("x", []).append(data_x) + + for y in ys: + data_y = msg["data"].get(y, {}).get(y, {}).get("value", None) + if data_y is not None: + self.data.setdefault((x, tuple(ys)), {}).setdefault(y, []).append(data_y) + + self.update_signal.emit() + + @staticmethod + def golden_ratio(num: int) -> list: + """Calculate the golden ratio for a given number of angles. + + Args: + num (int): Number of angles + """ + phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2) + angles = [] + for ii in range(num): + x = np.cos(ii * phi) + y = np.sin(ii * phi) + angle = np.arctan2(y, x) + angles.append(angle) + return angles + + @staticmethod + def golden_angle_color(colormap: str, num: int) -> list: + """ + Extract num colors for from the specified colormap following golden angle distribution. + + Args: + colormap (str): Name of the colormap + num (int): Number of requested colors + + Returns: + list: List of colors with length + + Raises: + ValueError: If the number of requested colors is greater than the number of colors in the colormap. + """ + + cmap = pg.colormap.get(colormap) + cmap_colors = cmap.color + if num > len(cmap_colors): + raise ValueError( + f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})" + ) + angles = PlotApp.golden_ratio(len(cmap_colors)) + color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors)))) + colors = [ + mkColor(tuple((cmap_colors[int(ii)] * 255).astype(int))) for ii in color_selection[:num] + ] + return colors + + +if __name__ == "__main__": + import yaml + from bec_widgets import ctrl_c + from bec_widgets.bec_dispatcher import bec_dispatcher + + with open("config.yaml", "r") as file: + config = yaml.safe_load(file) + + xy_pairs = config["xy_pairs"] + + # BECclient global variables + client = bec_dispatcher.client + client.start() + + dev = client.device_manager.devices + scans = client.scans + queue = client.queue + + app = QApplication([]) + plotApp = PlotApp(xy_pairs=xy_pairs) + + # Connecting signals from bec_dispatcher + bec_dispatcher.connect_slot(plotApp.on_scan_segment, MessageEndpoints.scan_segment()) + ctrl_c.setup(app) + + window = plotApp + window.show() + app.exec_() diff --git a/bec_widgets/examples/extreme/extreme.ui b/bec_widgets/examples/extreme/extreme.ui new file mode 100644 index 00000000..2b7fb644 --- /dev/null +++ b/bec_widgets/examples/extreme/extreme.ui @@ -0,0 +1,57 @@ + + + MultiWindow + + + + 0 + 0 + 1248 + 564 + + + + MultiWindow + + + + + + Qt::Horizontal + + + + + Cursor + + + + + + + Moved + + + + + Clicked + + + + + + + + + + + + + GraphicsLayoutWidget + QGraphicsView +
pyqtgraph.h
+
+
+ + +
diff --git a/bec_widgets/examples/oneplot/oneplot.py b/bec_widgets/examples/oneplot/oneplot.py index 8b99e6a9..b5c94b5e 100644 --- a/bec_widgets/examples/oneplot/oneplot.py +++ b/bec_widgets/examples/oneplot/oneplot.py @@ -17,6 +17,7 @@ from bec_lib.core import MessageEndpoints # TODO implement: # - implement scanID database for visualizing previous scans # - multiple signals for different monitors +# - change how dap is handled in bec_dispatcher to handle more workers class PlotApp(QWidget): From 65bfccce8fce158150652fead769721de805d99e Mon Sep 17 00:00:00 2001 From: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com> Date: Wed, 30 Aug 2023 15:22:32 +0200 Subject: [PATCH 02/14] feat: number of columns can be dynamically changed --- bec_widgets/examples/extreme/config.yaml | 5 +- bec_widgets/examples/extreme/extreme.py | 37 +++++++++--- bec_widgets/examples/extreme/extreme.ui | 71 ++++++++++++++++++------ 3 files changed, 88 insertions(+), 25 deletions(-) diff --git a/bec_widgets/examples/extreme/config.yaml b/bec_widgets/examples/extreme/config.yaml index f59651f8..e5274faa 100644 --- a/bec_widgets/examples/extreme/config.yaml +++ b/bec_widgets/examples/extreme/config.yaml @@ -1,3 +1,6 @@ +xy_pairs_1: [["samx", ["gauss_bpm", "gauss_adc1"]]] xy_pairs: [["samx", ["gauss_bpm", "gauss_adc1"]], ["samx", ["gauss_adc1", "gauss_adc2"]], - ["samx", ["gauss_adc2"]]] + ["samx", ["gauss_adc2"]], + ["samx", ["gauss_bpm", "gauss_adc2"]], + ["samx", ["gauss_bpm"]]] diff --git a/bec_widgets/examples/extreme/extreme.py b/bec_widgets/examples/extreme/extreme.py index 747fdd4e..d09dd506 100644 --- a/bec_widgets/examples/extreme/extreme.py +++ b/bec_widgets/examples/extreme/extreme.py @@ -52,35 +52,56 @@ class PlotApp(QWidget): self.scanID = None # Initialize the UI - self.init_ui() - self.init_curves() + self.init_ui(self.spinBox_N_columns.value()) + self.splitter.setSizes([400, 100]) # Connect the update signal to the update plot method self.proxy_update_plot = pg.SignalProxy( self.update_signal, rateLimit=25, slot=self.update_plot ) + self.spinBox_N_columns.valueChanged.connect(lambda x: self.init_ui(x)) - def init_ui(self) -> None: + def init_ui(self, num_columns=3) -> None: """ Initialize the UI components, create plots and store their grid positions. + Args: + num_columns (int): Number of columns to wrap the layout. + This method initializes a dictionary `self.plots` to store the plot objects - along with their corresponding x and y signal names. It also keeps track of - the row and column grid positions for each plot in `self.grid_coordinates`. + along with their corresponding x and y signal names. It dynamically arranges + the plots in a grid layout with a given number of columns and stretches the + last plots to fit the remaining space. """ + + self.glw.clear() self.plots = {} self.grid_coordinates = [] # List to keep track of grid positions for each plot + num_plots = len(self.xy_pairs) + num_rows = num_plots // num_columns # Calculate the number of full rows + last_row_cols = num_plots % num_columns # Number of plots in the last row + for i, (x, ys) in enumerate(self.xy_pairs): - row, col = i // 2, i % 2 # Change these numbers based on your grid layout - plot = self.glw.addPlot(row=row, col=col) + row, col = i // num_columns, i % num_columns # Calculate grid position + + colspan = 1 # Default colspan + + # Check if we are in the last row + if row == num_rows: + if last_row_cols == 1: + colspan = num_columns # Stretch across all columns + elif last_row_cols == 2 and col == 1: # Special case for 5 plots + colspan = num_columns - 1 # Stretch to fill remaining space + + plot = self.glw.addPlot(row=row, col=col, colspan=colspan) plot.setLabel("bottom", x) plot.setLabel("left", ", ".join(ys)) plot.addLegend() self.plots[(x, tuple(ys))] = plot self.grid_coordinates.append((row, col)) # Store the grid position - self.splitter.setSizes([200, 100]) + self.init_curves() def init_curves(self) -> None: """ diff --git a/bec_widgets/examples/extreme/extreme.ui b/bec_widgets/examples/extreme/extreme.ui index 2b7fb644..51f6ab8a 100644 --- a/bec_widgets/examples/extreme/extreme.ui +++ b/bec_widgets/examples/extreme/extreme.ui @@ -20,23 +20,62 @@ Qt::Horizontal - - - Cursor - - + + + + + + Number of Columns: + + + + + + + 1 + + + 10 + + + 3 + + + - - - - Moved - - - - - Clicked - - + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Cursor + + + + + + + Moved + + + + + Clicked + + + + + From 2d851b6b4eb0002e32908c2effbfb79122f18c24 Mon Sep 17 00:00:00 2001 From: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com> Date: Wed, 30 Aug 2023 15:32:05 +0200 Subject: [PATCH 03/14] fix: columns span generalised for any number of columns --- bec_widgets/examples/extreme/extreme.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/bec_widgets/examples/extreme/extreme.py b/bec_widgets/examples/extreme/extreme.py index d09dd506..de4c7a22 100644 --- a/bec_widgets/examples/extreme/extreme.py +++ b/bec_widgets/examples/extreme/extreme.py @@ -70,36 +70,39 @@ class PlotApp(QWidget): This method initializes a dictionary `self.plots` to store the plot objects along with their corresponding x and y signal names. It dynamically arranges - the plots in a grid layout with a given number of columns and stretches the - last plots to fit the remaining space. + the plots in a grid layout based on the given number of columns and dynamically + stretches the last plots to fit the remaining space. """ - self.glw.clear() + self.plots = {} self.grid_coordinates = [] # List to keep track of grid positions for each plot num_plots = len(self.xy_pairs) num_rows = num_plots // num_columns # Calculate the number of full rows last_row_cols = num_plots % num_columns # Number of plots in the last row + remaining_space = num_columns - last_row_cols # Remaining space in the last row for i, (x, ys) in enumerate(self.xy_pairs): row, col = i // num_columns, i % num_columns # Calculate grid position colspan = 1 # Default colspan - # Check if we are in the last row - if row == num_rows: + # Check if we are in the last row and there's remaining space + if row == num_rows and remaining_space > 0: if last_row_cols == 1: colspan = num_columns # Stretch across all columns - elif last_row_cols == 2 and col == 1: # Special case for 5 plots - colspan = num_columns - 1 # Stretch to fill remaining space + else: + colspan = remaining_space // last_row_cols + 1 # Proportional stretch + remaining_space -= colspan - 1 # Update remaining space + last_row_cols -= 1 # Update remaining plots plot = self.glw.addPlot(row=row, col=col, colspan=colspan) plot.setLabel("bottom", x) plot.setLabel("left", ", ".join(ys)) plot.addLegend() self.plots[(x, tuple(ys))] = plot - self.grid_coordinates.append((row, col)) # Store the grid position + self.grid_coordinates.append((row, col)) self.init_curves() From 2f7c1b92a9624741f6dea44fc8f3c19a8a506fd9 Mon Sep 17 00:00:00 2001 From: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com> Date: Wed, 30 Aug 2023 15:42:22 +0200 Subject: [PATCH 04/14] docs: fixed documentation --- bec_widgets/examples/extreme/extreme.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/bec_widgets/examples/extreme/extreme.py b/bec_widgets/examples/extreme/extreme.py index de4c7a22..ed5ea4f9 100644 --- a/bec_widgets/examples/extreme/extreme.py +++ b/bec_widgets/examples/extreme/extreme.py @@ -18,16 +18,19 @@ from bec_widgets.qt_utils import Crosshair class PlotApp(QWidget): """ - Main class for the PlotApp used to plot two signals from the BEC. + Main class for PlotApp, designed to plot multiple signals in a grid layout. Attributes: update_signal (pyqtSignal): Signal to trigger plot updates. - update_dap_signal (pyqtSignal): Signal to trigger DAP updates. + xy_pairs (list of tuples): List of tuples containing x-y pairs for each plot. + Each tuple has the x-value as its first element and + a list of y-values as its second element. Args: - x_value (str): The x device/signal for plotting. - y_values (list of str): List of y device/signals for plotting. - dap_worker (str, optional): DAP process to specify. Set to None to disable. + xy_pairs (list of lists): List of x-y pairs specifying the signals to plot. + Each tuple consists of an x-value string and a list + of y-value strings. + Example: [["x1", ["y1", "y2"]], ["x2", ["y3"]]] parent (QWidget, optional): Parent widget. """ @@ -59,9 +62,11 @@ class PlotApp(QWidget): self.proxy_update_plot = pg.SignalProxy( self.update_signal, rateLimit=25, slot=self.update_plot ) + + # Change layout of plots when the number of columns is changed in GUI self.spinBox_N_columns.valueChanged.connect(lambda x: self.init_ui(x)) - def init_ui(self, num_columns=3) -> None: + def init_ui(self, num_columns: int = 3) -> None: """ Initialize the UI components, create plots and store their grid positions. From b8aa37321d6ac0ebd9f2237c8d2ed6594b614b57 Mon Sep 17 00:00:00 2001 From: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com> Date: Thu, 31 Aug 2023 13:56:24 +0200 Subject: [PATCH 05/14] fix: config.yaml can be passed as a console argument to extreme.py --- bec_widgets/examples/extreme/config.yaml | 4 ++-- bec_widgets/examples/extreme/extreme.py | 22 +++++++++++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/bec_widgets/examples/extreme/config.yaml b/bec_widgets/examples/extreme/config.yaml index e5274faa..a01184b7 100644 --- a/bec_widgets/examples/extreme/config.yaml +++ b/bec_widgets/examples/extreme/config.yaml @@ -1,6 +1,6 @@ -xy_pairs_1: [["samx", ["gauss_bpm", "gauss_adc1"]]] xy_pairs: [["samx", ["gauss_bpm", "gauss_adc1"]], ["samx", ["gauss_adc1", "gauss_adc2"]], ["samx", ["gauss_adc2"]], + ["samx", ["gauss_adc1"]], ["samx", ["gauss_bpm", "gauss_adc2"]], - ["samx", ["gauss_bpm"]]] + ["samx", ["gauss_bpm", "gauss_adc1", "gauss_adc2"]]] diff --git a/bec_widgets/examples/extreme/extreme.py b/bec_widgets/examples/extreme/extreme.py index ed5ea4f9..df4fcad1 100644 --- a/bec_widgets/examples/extreme/extreme.py +++ b/bec_widgets/examples/extreme/extreme.py @@ -283,13 +283,29 @@ class PlotApp(QWidget): if __name__ == "__main__": import yaml + import argparse + from bec_widgets import ctrl_c from bec_widgets.bec_dispatcher import bec_dispatcher - with open("config.yaml", "r") as file: - config = yaml.safe_load(file) + parser = argparse.ArgumentParser(description="Plotting App") + parser.add_argument( + "--config", "-c", help="Path to the .yaml configuration file", default="config.yaml" + ) + args = parser.parse_args() - xy_pairs = config["xy_pairs"] + try: + with open(args.config, "r") as file: + config = yaml.safe_load(file) + xy_pairs = config.get("xy_pairs", []) + except FileNotFoundError: + print(f"The file {args.config} was not found.") + exit(1) + except Exception as e: + print(f"An error occurred while loading the config file: {e}") + exit(1) + + # TODO PUT RAISE ERROR HERE to check for xy_pairs # BECclient global variables client = bec_dispatcher.client From 7bcf88d5eb139aa3cf491185b9fb3f45aa5e39a2 Mon Sep 17 00:00:00 2001 From: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com> Date: Thu, 31 Aug 2023 13:56:54 +0200 Subject: [PATCH 06/14] fix: bec_dispatcher.py can take multiple workers as a list --- bec_widgets/bec_dispatcher.py | 45 +++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/bec_widgets/bec_dispatcher.py b/bec_widgets/bec_dispatcher.py index fbf5db7d..96a9c937 100644 --- a/bec_widgets/bec_dispatcher.py +++ b/bec_widgets/bec_dispatcher.py @@ -128,31 +128,36 @@ class _BECDispatcher(QObject): self._connections[topic].consumer.shutdown() del self._connections[topic] - def connect_dap_slot(self, slot, dap_name): - if dap_name not in self._daps: - # create a new consumer and connect slot + def connect_dap_slot(self, slot, dap_names): + if not isinstance(dap_names, list): + dap_names = [dap_names] - def _dap_cb(msg): - msg = BECMessage.ProcessedDataMessage.loads(msg.value) - if not isinstance(msg, list): - msg = [msg] - for i in msg: - self.new_dap_data.emit(i.content["data"], i.metadata) + for dap_name in dap_names: + if dap_name not in self._daps: # create a new consumer and connect slot + self.add_new_dap_connection(slot, dap_name) - dap_ep = MessageEndpoints.processed_data(dap_name) - consumer = self.client.connector.consumer(topics=dap_ep, cb=_dap_cb) - consumer.start() + else: + # connect slot if it's not yet connected + if slot not in self._daps[dap_name].slots: + self.new_dap_data.connect(slot) + self._daps[dap_name].slots.add(slot) - self.new_dap_data.connect(slot) + def add_new_dap_connection(self, slot, dap_name): + def _dap_cb(msg): + msg = BECMessage.ProcessedDataMessage.loads(msg.value) + if not isinstance(msg, list): + msg = [msg] + for i in msg: + self.new_dap_data.emit(i.content["data"], i.metadata) - self._daps[dap_name] = _BECDap(consumer) - self._daps[dap_name].slots.add(slot) + dap_ep = MessageEndpoints.processed_data(dap_name) + consumer = self.client.connector.consumer(topics=dap_ep, cb=_dap_cb) + consumer.start() - else: - # connect slot if it's not yet connected - if slot not in self._daps[dap_name].slots: - self.new_dap_data.connect(slot) - self._daps[dap_name].slots.add(slot) + self.new_dap_data.connect(slot) + + self._daps[dap_name] = _BECDap(consumer) + self._daps[dap_name].slots.add(slot) def disconnect_dap_slot(self, slot, dap_name): if dap_name not in self._daps: From 1a06dd75346fb9e85e2c0392ce8f48021c84a6fd Mon Sep 17 00:00:00 2001 From: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com> Date: Thu, 31 Aug 2023 21:52:45 +0200 Subject: [PATCH 07/14] refactor: moved colormap related static methods to qt_utils colors.py --- bec_widgets/examples/extreme/config.yaml | 36 ++++++-- bec_widgets/examples/extreme/extreme.py | 100 ++++++++++------------- bec_widgets/qt_utils/__init__.py | 1 + bec_widgets/qt_utils/colors.py | 50 ++++++++++++ 4 files changed, 127 insertions(+), 60 deletions(-) create mode 100644 bec_widgets/qt_utils/colors.py diff --git a/bec_widgets/examples/extreme/config.yaml b/bec_widgets/examples/extreme/config.yaml index a01184b7..6a66bbd3 100644 --- a/bec_widgets/examples/extreme/config.yaml +++ b/bec_widgets/examples/extreme/config.yaml @@ -1,6 +1,32 @@ +plot_settings: + background_color: "white" + num_columns: 2 + xy_pairs: [["samx", ["gauss_bpm", "gauss_adc1"]], - ["samx", ["gauss_adc1", "gauss_adc2"]], - ["samx", ["gauss_adc2"]], - ["samx", ["gauss_adc1"]], - ["samx", ["gauss_bpm", "gauss_adc2"]], - ["samx", ["gauss_bpm", "gauss_adc1", "gauss_adc2"]]] + ["samx", ["gauss_adc1", "gauss_adc2"]]] + +plot_data: + - BPM plot: + - x: + - signal: + - name: "samx" + - entry: "samx" + - label: 'Motor X' # will serve as x label + - y: + - signal: + - name: "gauss_bpm" + - entry: "gauss_bpm" + - label: 'BPM' # will serve as y label + + - ADC plot: + - name: "gauss_adc1" + - x: + - signal: + - name: "samy" + - entry: "samy" + - label: 'Motor Y' + - y: + - signal: + - name: "gauss_adc" + - entry: ["gauss_adc1", "gauss_adc2"] + - label: 'ADC' \ No newline at end of file diff --git a/bec_widgets/examples/extreme/extreme.py b/bec_widgets/examples/extreme/extreme.py index df4fcad1..5c21f6b0 100644 --- a/bec_widgets/examples/extreme/extreme.py +++ b/bec_widgets/examples/extreme/extreme.py @@ -8,12 +8,15 @@ from pyqtgraph import mkBrush, mkColor, mkPen from pyqtgraph.Qt import QtCore, uic from bec_lib.core import MessageEndpoints -from bec_widgets.qt_utils import Crosshair +from bec_widgets.qt_utils import Crosshair, Colors # TODO implement: # - implement scanID database for visualizing previous scans # - change how dap is handled in bec_dispatcher to handle more workers +# - YAML config -> plot settings +# - YAML config -> xy pairs -> multiple subsignals for different devices +# - Internal logic -> if user specify class PlotApp(QWidget): @@ -37,14 +40,21 @@ class PlotApp(QWidget): update_signal = pyqtSignal() update_dap_signal = pyqtSignal() - def __init__(self, xy_pairs, parent=None): + def __init__(self, plot_settings: dict, xy_pairs: list, plot_data: dict, parent=None): super(PlotApp, self).__init__(parent) + + # YAML config + self.plot_settings = plot_settings + self.xy_pairs = xy_pairs + self.plot_data = plot_data + + # Setting global plot settings + self.init_plot_background(self.plot_settings["background_color"]) + + # Loading UI current_path = os.path.dirname(__file__) uic.loadUi(os.path.join(current_path, "extreme.ui"), self) - # xy pairs for setting number of windows - self.xy_pairs = xy_pairs - # Nested dictionary to hold x and y data for multiple plots self.data = {} @@ -55,7 +65,8 @@ class PlotApp(QWidget): self.scanID = None # Initialize the UI - self.init_ui(self.spinBox_N_columns.value()) + self.init_ui(self.plot_settings["num_columns"]) + self.spinBox_N_columns.setValue(self.plot_settings["num_columns"]) self.splitter.setSizes([400, 100]) # Connect the update signal to the update plot method @@ -66,6 +77,26 @@ class PlotApp(QWidget): # Change layout of plots when the number of columns is changed in GUI self.spinBox_N_columns.valueChanged.connect(lambda x: self.init_ui(x)) + def init_plot_background(self, background_color: str) -> None: + """ + Initialize plot settings based on the background color. + + Args: + background_color (str): The background color ('white' or 'black'). + + This method sets the background and foreground colors for pyqtgraph. + If the background is dark ('black'), the foreground will be set to 'white', + and vice versa. + """ + if background_color.lower() == "black": + pg.setConfigOption("background", "k") + pg.setConfigOption("foreground", "w") + elif background_color.lower() == "white": + pg.setConfigOption("background", "w") + pg.setConfigOption("foreground", "k") + else: + print(f"Warning: Unknown background color {background_color}. Using default settings.") + def init_ui(self, num_columns: int = 3) -> None: """ Initialize the UI components, create plots and store their grid positions. @@ -79,7 +110,6 @@ class PlotApp(QWidget): stretches the last plots to fit the remaining space. """ self.glw.clear() - self.plots = {} self.grid_coordinates = [] # List to keep track of grid positions for each plot @@ -102,7 +132,9 @@ class PlotApp(QWidget): remaining_space -= colspan - 1 # Update remaining space last_row_cols -= 1 # Update remaining plots - plot = self.glw.addPlot(row=row, col=col, colspan=colspan) + plot = self.glw.addPlot( + row=row, col=col, colspan=colspan, title=list(self.plot_data[i].keys())[0] + ) plot.setLabel("bottom", x) plot.setLabel("left", ", ".join(ys)) plot.addLegend() @@ -126,7 +158,7 @@ class PlotApp(QWidget): for idx, ((x, ys), plot) in enumerate(self.plots.items()): plot.clear() self.curves_data[(x, tuple(ys))] = [] - colors_ys = PlotApp.golden_angle_color(colormap="CET-R2", num=len(ys)) + colors_ys = Colors.golden_angle_color(colormap="plasma", num=len(ys)) row, col = self.grid_coordinates[idx] # Retrieve the grid position for this plot @@ -235,51 +267,6 @@ class PlotApp(QWidget): self.update_signal.emit() - @staticmethod - def golden_ratio(num: int) -> list: - """Calculate the golden ratio for a given number of angles. - - Args: - num (int): Number of angles - """ - phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2) - angles = [] - for ii in range(num): - x = np.cos(ii * phi) - y = np.sin(ii * phi) - angle = np.arctan2(y, x) - angles.append(angle) - return angles - - @staticmethod - def golden_angle_color(colormap: str, num: int) -> list: - """ - Extract num colors for from the specified colormap following golden angle distribution. - - Args: - colormap (str): Name of the colormap - num (int): Number of requested colors - - Returns: - list: List of colors with length - - Raises: - ValueError: If the number of requested colors is greater than the number of colors in the colormap. - """ - - cmap = pg.colormap.get(colormap) - cmap_colors = cmap.color - if num > len(cmap_colors): - raise ValueError( - f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})" - ) - angles = PlotApp.golden_ratio(len(cmap_colors)) - color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors)))) - colors = [ - mkColor(tuple((cmap_colors[int(ii)] * 255).astype(int))) for ii in color_selection[:num] - ] - return colors - if __name__ == "__main__": import yaml @@ -297,7 +284,10 @@ if __name__ == "__main__": try: with open(args.config, "r") as file: config = yaml.safe_load(file) + + plot_settings = config.get("plot_settings", {}) xy_pairs = config.get("xy_pairs", []) + plot_data = config.get("plot_data", {}) except FileNotFoundError: print(f"The file {args.config} was not found.") exit(1) @@ -316,7 +306,7 @@ if __name__ == "__main__": queue = client.queue app = QApplication([]) - plotApp = PlotApp(xy_pairs=xy_pairs) + plotApp = PlotApp(xy_pairs=xy_pairs, plot_settings=plot_settings, plot_data=plot_data) # Connecting signals from bec_dispatcher bec_dispatcher.connect_slot(plotApp.on_scan_segment, MessageEndpoints.scan_segment()) diff --git a/bec_widgets/qt_utils/__init__.py b/bec_widgets/qt_utils/__init__.py index 085baff8..5123d544 100644 --- a/bec_widgets/qt_utils/__init__.py +++ b/bec_widgets/qt_utils/__init__.py @@ -1 +1,2 @@ from .crosshair import Crosshair +from .colors import Colors diff --git a/bec_widgets/qt_utils/colors.py b/bec_widgets/qt_utils/colors.py new file mode 100644 index 00000000..45de8f4c --- /dev/null +++ b/bec_widgets/qt_utils/colors.py @@ -0,0 +1,50 @@ +import numpy as np +import pyqtgraph as pg +from pyqtgraph import mkColor + + +class Colors: + @staticmethod + def golden_ratio(num: int) -> list: + """Calculate the golden ratio for a given number of angles. + + Args: + num (int): Number of angles + """ + phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2) + angles = [] + for ii in range(num): + x = np.cos(ii * phi) + y = np.sin(ii * phi) + angle = np.arctan2(y, x) + angles.append(angle) + return angles + + @staticmethod + def golden_angle_color(colormap: str, num: int) -> list: + """ + Extract num colors for from the specified colormap following golden angle distribution. + + Args: + colormap (str): Name of the colormap + num (int): Number of requested colors + + Returns: + list: List of colors with length + + Raises: + ValueError: If the number of requested colors is greater than the number of colors in the colormap. + """ + + cmap = pg.colormap.get(colormap) + cmap_colors = cmap.color + if num > len(cmap_colors): + raise ValueError( + f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})" + ) + angles = Colors.golden_ratio(len(cmap_colors)) + color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors)))) + colors = [ + mkColor(tuple((cmap_colors[int(ii)] * 255).astype(int))) for ii in color_selection[:num] + ] + return colors From 96a88d23154d7f5578ee742c91feb658a74d7ede Mon Sep 17 00:00:00 2001 From: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com> Date: Fri, 1 Sep 2023 00:59:11 +0200 Subject: [PATCH 08/14] refactor: changed the .yaml structure and the logic of the whole app how to access --- bec_widgets/examples/extreme/config.yaml | 70 ++++--- bec_widgets/examples/extreme/extreme.py | 222 ++++++++++++++--------- 2 files changed, 183 insertions(+), 109 deletions(-) diff --git a/bec_widgets/examples/extreme/config.yaml b/bec_widgets/examples/extreme/config.yaml index 6a66bbd3..38c704ce 100644 --- a/bec_widgets/examples/extreme/config.yaml +++ b/bec_widgets/examples/extreme/config.yaml @@ -1,32 +1,46 @@ plot_settings: - background_color: "white" - num_columns: 2 - -xy_pairs: [["samx", ["gauss_bpm", "gauss_adc1"]], - ["samx", ["gauss_adc1", "gauss_adc2"]]] + background_color: "black" + num_columns: 3 + colormap: "plasma" #TODO has to be hooked up to the plot plot_data: - - BPM plot: - - x: - - signal: - - name: "samx" - - entry: "samx" - - label: 'Motor X' # will serve as x label - - y: - - signal: - - name: "gauss_bpm" - - entry: "gauss_bpm" - - label: 'BPM' # will serve as y label + - plot_name: "BPM plot" + x: + label: 'Motor X' + signals: + - name: "samx" + entry: "samx" + y: + label: 'BPM' + signals: + - name: "gauss_bpm" + entry: "gauss_bpm" + - name: "gauss_adc1" + entry: "gauss_adc1" - - ADC plot: - - name: "gauss_adc1" - - x: - - signal: - - name: "samy" - - entry: "samy" - - label: 'Motor Y' - - y: - - signal: - - name: "gauss_adc" - - entry: ["gauss_adc1", "gauss_adc2"] - - label: 'ADC' \ No newline at end of file + - plot_name: "ADC plot" + x: + label: 'Motor Y' + signals: + - name: "samy" +# entry: "samy" # here I also forgot to specify entry + y: + label: 'ADC' + signals: + - name: "gauss_bpm" + entry: "gauss_bpm" + - name: "samx" + # I will not specify entry, because I want to take hint from gauss_adc2 + - plot_name: "Multi" + x: + label: 'Motor X' + signals: + - name: "samx" + entry: "samx" + y: + label: 'Multi' + signals: + - name: "gauss_bpm" + entry: "gauss_bpm" + - name: "samx" + entry: ["samx","setpoint"] #multiple entries for one device \ No newline at end of file diff --git a/bec_widgets/examples/extreme/extreme.py b/bec_widgets/examples/extreme/extreme.py index 5c21f6b0..4d5cd430 100644 --- a/bec_widgets/examples/extreme/extreme.py +++ b/bec_widgets/examples/extreme/extreme.py @@ -14,38 +14,47 @@ from bec_widgets.qt_utils import Crosshair, Colors # TODO implement: # - implement scanID database for visualizing previous scans # - change how dap is handled in bec_dispatcher to handle more workers -# - YAML config -> plot settings -# - YAML config -> xy pairs -> multiple subsignals for different devices -# - Internal logic -> if user specify class PlotApp(QWidget): """ - Main class for PlotApp, designed to plot multiple signals in a grid layout. + Main class for PlotApp, designed to plot multiple signals in a grid layout + based on a flexible YAML configuration. Attributes: update_signal (pyqtSignal): Signal to trigger plot updates. - xy_pairs (list of tuples): List of tuples containing x-y pairs for each plot. - Each tuple has the x-value as its first element and - a list of y-values as its second element. + plot_data (list of dict): List of dictionaries containing plot configurations. + Each dictionary specifies x and y signals, including their + name and entry, for a particular plot. Args: - xy_pairs (list of lists): List of x-y pairs specifying the signals to plot. - Each tuple consists of an x-value string and a list - of y-value strings. - Example: [["x1", ["y1", "y2"]], ["x2", ["y3"]]] + plot_settings (dict): Dictionary containing global plot settings such as background color. + plot_data (list of dict): List of dictionaries specifying the signals to plot. + Each dictionary should contain: + - 'x': Dictionary specifying the x-axis settings including + a 'signals' list with 'name' and 'entry' fields. + If there are multiple entries for one device name, they can be passed as a list. + - 'y': Similar to 'x', but for the y-axis. + Example: + [ + { + 'plot_name': 'Plot 1', + 'x': {'label': 'X Label', 'signals': [{'name': 'x1', 'entry': 'x1_entry'}]}, + 'y': {'label': 'Y Label', 'signals': [{'name': 'y1', 'entry': 'y1_entry'}]} + }, + ... + ] parent (QWidget, optional): Parent widget. """ update_signal = pyqtSignal() update_dap_signal = pyqtSignal() - def __init__(self, plot_settings: dict, xy_pairs: list, plot_data: dict, parent=None): + def __init__(self, plot_settings: dict, plot_data: list, parent=None): super(PlotApp, self).__init__(parent) # YAML config self.plot_settings = plot_settings - self.xy_pairs = xy_pairs self.plot_data = plot_data # Setting global plot settings @@ -111,34 +120,35 @@ class PlotApp(QWidget): """ self.glw.clear() self.plots = {} - self.grid_coordinates = [] # List to keep track of grid positions for each plot + self.grid_coordinates = [] - num_plots = len(self.xy_pairs) - num_rows = num_plots // num_columns # Calculate the number of full rows - last_row_cols = num_plots % num_columns # Number of plots in the last row - remaining_space = num_columns - last_row_cols # Remaining space in the last row + num_plots = len(self.plot_data) + num_rows = num_plots // num_columns + last_row_cols = num_plots % num_columns + remaining_space = num_columns - last_row_cols - for i, (x, ys) in enumerate(self.xy_pairs): - row, col = i // num_columns, i % num_columns # Calculate grid position + for i, plot_config in enumerate(self.plot_data): + row, col = i // num_columns, i % num_columns + colspan = 1 - colspan = 1 # Default colspan - - # Check if we are in the last row and there's remaining space if row == num_rows and remaining_space > 0: if last_row_cols == 1: - colspan = num_columns # Stretch across all columns + colspan = num_columns else: - colspan = remaining_space // last_row_cols + 1 # Proportional stretch - remaining_space -= colspan - 1 # Update remaining space - last_row_cols -= 1 # Update remaining plots + colspan = remaining_space // last_row_cols + 1 + remaining_space -= colspan - 1 + last_row_cols -= 1 - plot = self.glw.addPlot( - row=row, col=col, colspan=colspan, title=list(self.plot_data[i].keys())[0] - ) - plot.setLabel("bottom", x) - plot.setLabel("left", ", ".join(ys)) + plot_name = plot_config.get("plot_name", "") + x_label = plot_config["x"].get("label", "") + y_label = plot_config["y"].get("label", "") + + plot = self.glw.addPlot(row=row, col=col, colspan=colspan, title=plot_name) + plot.setLabel("bottom", x_label) + plot.setLabel("left", y_label) plot.addLegend() - self.plots[(x, tuple(ys))] = plot + + self.plots[plot_name] = plot self.grid_coordinates.append((row, col)) self.init_curves() @@ -151,30 +161,44 @@ class PlotApp(QWidget): the curve objects for each x and y signal pair. It also updates the row labels in `self.tableWidget_crosshair` to include the grid position for each y-value. """ - self.curves_data = {} # Nested dictionary to hold curves + self.curves_data = {} + row_labels = [] - row_labels = [] # List to keep track of row labels for the table - - for idx, ((x, ys), plot) in enumerate(self.plots.items()): + for idx, plot_config in enumerate(self.plot_data): + plot_name = plot_config.get("plot_name", "") + plot = self.plots[plot_name] plot.clear() - self.curves_data[(x, tuple(ys))] = [] - colors_ys = Colors.golden_angle_color(colormap="plasma", num=len(ys)) - row, col = self.grid_coordinates[idx] # Retrieve the grid position for this plot + y_configs = plot_config["y"]["signals"] + colors_ys = Colors.golden_angle_color( + colormap=self.plot_settings["colormap"], num=len(y_configs) + ) - for i, (signal, color) in enumerate(zip(ys, colors_ys)): - pen_curve = mkPen(color=color, width=2, style=QtCore.Qt.DashLine) - brush_curve = mkBrush(color=color) - curve_data = pg.PlotDataItem( - symbolSize=5, - symbolBrush=brush_curve, - pen=pen_curve, - skipFiniteCheck=True, - name=f"{signal}", - ) - self.curves_data[(x, tuple(ys))].append(curve_data) - plot.addItem(curve_data) - row_labels.append(f"{signal} - [{row},{col}]") # Add row label with grid position + curve_list = [] + for i, (y_config, color) in enumerate(zip(y_configs, colors_ys)): + y_name = y_config["name"] + y_entries = y_config.get("entry", [y_name]) + + if not isinstance(y_entries, list): + y_entries = [y_entries] + + for y_entry in y_entries: + pen_curve = mkPen(color=color, width=2, style=QtCore.Qt.DashLine) + brush_curve = mkBrush(color=color) + + curve_data = pg.PlotDataItem( + symbolSize=5, + symbolBrush=brush_curve, + pen=pen_curve, + skipFiniteCheck=True, + name=f"{y_name} ({y_entry})", + ) + + curve_list.append((y_name, y_entry, curve_data)) + plot.addItem(curve_data) + row_labels.append(f"{y_name} ({y_entry}) - {plot_name}") + + self.curves_data[plot_name] = curve_list self.tableWidget_crosshair.setRowCount(len(row_labels)) self.tableWidget_crosshair.setVerticalHeaderLabels(row_labels) @@ -182,8 +206,8 @@ class PlotApp(QWidget): def hook_crosshair(self): """Attach crosshairs to each plot and connect them to the update_table method.""" - self.crosshairs = {} # Store crosshairs for each plot - for (x, ys), plot in self.plots.items(): + self.crosshairs = {} + for plot_name, plot in self.plots.items(): crosshair = Crosshair(plot, precision=3) crosshair.coordinatesChanged1D.connect( lambda x, y, plot=plot: self.update_table( @@ -195,7 +219,7 @@ class PlotApp(QWidget): self.tableWidget_crosshair, x, y, column=1, plot=plot ) ) - self.crosshairs[(x, tuple(ys))] = crosshair + self.crosshairs[plot_name] = crosshair def update_table( self, table_widget: QTableWidget, x: float, y_values: list, column: int, plot: pg.PlotItem @@ -213,17 +237,18 @@ class PlotApp(QWidget): This method calculates the correct row in the table for each y-value and updates the cell at (row, column) with the new x and y coordinates. """ - plot_key = [key for key, value in self.plots.items() if value == plot][0] - _, ys = plot_key # Extract the y-values for the current plot + plot_name = [name for name, value in self.plots.items() if value == plot][0] - # Find the starting row for the ys of the current plot starting_row = 0 - for _, other_ys in self.xy_pairs: - if other_ys == list(ys): + for plot_config in self.plot_data: + if plot_config.get("plot_name", "") == plot_name: break - starting_row += len(other_ys) + for y_config in plot_config.get("y", {}).get("signals", []): + y_entries = y_config.get("entry", [y_config.get("name", "")]) + if not isinstance(y_entries, list): + y_entries = [y_entries] + starting_row += len(y_entries) - # Update the table rows corresponding to the ys of the current plot for i, y in enumerate(y_values): row = starting_row + i table_widget.setItem(row, column, QTableWidgetItem(f"({x}, {y})")) @@ -231,10 +256,19 @@ class PlotApp(QWidget): def update_plot(self) -> None: """Update the plot data based on the stored data dictionary.""" - for (x, ys), curves in self.curves_data.items(): - data_x = self.data.get((x, tuple(ys)), {}).get("x", []) - for i, curve in enumerate(curves): - data_y = self.data.get((x, tuple(ys)), {}).get(ys[i], []) + for plot_name, curve_list in self.curves_data.items(): + for y_name, y_entry, curve in curve_list: + x_config = next( + (pc["x"] for pc in self.plot_data if pc.get("plot_name") == plot_name), {} + ) + x_signal_config = x_config["signals"][0] + x_name = x_signal_config.get("name", "") + x_entry = x_signal_config.get("entry", x_name) + + key = (x_name, x_entry, y_name, y_entry) + data_x = self.data.get(key, {}).get("x", []) + data_y = self.data.get(key, {}).get("y", []) + curve.setData(data_x, data_y) @pyqtSlot(dict, dict) @@ -252,18 +286,47 @@ class PlotApp(QWidget): if current_scanID != self.scanID: self.scanID = current_scanID - self.data = {} # Wipe the data for a new scan - self.init_curves() # Re-initialize the curves + self.data = {} + self.init_curves() - for x, ys in self.xy_pairs: - data_x = msg["data"].get(x, {}).get(x, {}).get("value", None) - if data_x is not None: - self.data.setdefault((x, tuple(ys)), {}).setdefault("x", []).append(data_x) + for plot_config in self.plot_data: + x_config = plot_config["x"] + x_signal_config = x_config["signals"][0] + x_name = x_signal_config.get("name", "") + x_entry_list = x_signal_config.get("entry", []) - for y in ys: - data_y = msg["data"].get(y, {}).get(y, {}).get("value", None) - if data_y is not None: - self.data.setdefault((x, tuple(ys)), {}).setdefault(y, []).append(data_y) + if not x_entry_list: + x_entry_list = dev[x_name]._hints if hasattr(dev[x_name], "_hints") else [x_name] + + if not isinstance(x_entry_list, list): + x_entry_list = [x_entry_list] + + y_configs = plot_config["y"]["signals"] + + for x_entry in x_entry_list: + for y_config in y_configs: + y_name = y_config.get("name", "") + y_entry_list = y_config.get("entry", []) + + if not y_entry_list: + y_entry_list = ( + dev[y_name]._hints if hasattr(dev[y_name], "_hints") else [y_name] + ) + + if not isinstance(y_entry_list, list): + y_entry_list = [y_entry_list] + + for y_entry in y_entry_list: + key = (x_name, x_entry, y_name, y_entry) + + data_x = msg["data"].get(x_name, {}).get(x_entry, {}).get("value", None) + data_y = msg["data"].get(y_name, {}).get(y_entry, {}).get("value", None) + + if data_x is not None: + self.data.setdefault(key, {}).setdefault("x", []).append(data_x) + + if data_y is not None: + self.data.setdefault(key, {}).setdefault("y", []).append(data_y) self.update_signal.emit() @@ -286,7 +349,6 @@ if __name__ == "__main__": config = yaml.safe_load(file) plot_settings = config.get("plot_settings", {}) - xy_pairs = config.get("xy_pairs", []) plot_data = config.get("plot_data", {}) except FileNotFoundError: print(f"The file {args.config} was not found.") @@ -295,8 +357,6 @@ if __name__ == "__main__": print(f"An error occurred while loading the config file: {e}") exit(1) - # TODO PUT RAISE ERROR HERE to check for xy_pairs - # BECclient global variables client = bec_dispatcher.client client.start() @@ -306,7 +366,7 @@ if __name__ == "__main__": queue = client.queue app = QApplication([]) - plotApp = PlotApp(xy_pairs=xy_pairs, plot_settings=plot_settings, plot_data=plot_data) + plotApp = PlotApp(plot_settings=plot_settings, plot_data=plot_data) # Connecting signals from bec_dispatcher bec_dispatcher.connect_slot(plotApp.on_scan_segment, MessageEndpoints.scan_segment()) From 415c4ee3f232c02ee5a00a82352c7fbb0d324449 Mon Sep 17 00:00:00 2001 From: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com> Date: Fri, 1 Sep 2023 01:15:19 +0200 Subject: [PATCH 09/14] feat: error messages if name or entry is wrong --- bec_widgets/examples/extreme/config.yaml | 6 ++++-- bec_widgets/examples/extreme/extreme.py | 24 ++++++++++++++++++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/bec_widgets/examples/extreme/config.yaml b/bec_widgets/examples/extreme/config.yaml index 38c704ce..fcab38b9 100644 --- a/bec_widgets/examples/extreme/config.yaml +++ b/bec_widgets/examples/extreme/config.yaml @@ -1,7 +1,9 @@ plot_settings: background_color: "black" num_columns: 3 - colormap: "plasma" #TODO has to be hooked up to the plot + colormap: "plasma" + #TODO add more settings + # - plot size plot_data: - plot_name: "BPM plot" @@ -43,4 +45,4 @@ plot_data: - name: "gauss_bpm" entry: "gauss_bpm" - name: "samx" - entry: ["samx","setpoint"] #multiple entries for one device \ No newline at end of file +# entry: ["samx","incorect"] #multiple entries for one device \ No newline at end of file diff --git a/bec_widgets/examples/extreme/extreme.py b/bec_widgets/examples/extreme/extreme.py index 4d5cd430..0c2bdf9c 100644 --- a/bec_widgets/examples/extreme/extreme.py +++ b/bec_widgets/examples/extreme/extreme.py @@ -291,10 +291,13 @@ class PlotApp(QWidget): for plot_config in self.plot_data: x_config = plot_config["x"] - x_signal_config = x_config["signals"][0] - x_name = x_signal_config.get("name", "") - x_entry_list = x_signal_config.get("entry", []) + x_signal_config = x_config["signals"][0] # Assuming there's at least one signal for x + x_name = x_signal_config.get("name", "") + if not x_name: + raise ValueError("Name for x signal must be specified.") + + x_entry_list = x_signal_config.get("entry", []) if not x_entry_list: x_entry_list = dev[x_name]._hints if hasattr(dev[x_name], "_hints") else [x_name] @@ -306,8 +309,10 @@ class PlotApp(QWidget): for x_entry in x_entry_list: for y_config in y_configs: y_name = y_config.get("name", "") - y_entry_list = y_config.get("entry", []) + if not y_name: + raise ValueError("Name for y signal must be specified.") + y_entry_list = y_config.get("entry", []) if not y_entry_list: y_entry_list = ( dev[y_name]._hints if hasattr(dev[y_name], "_hints") else [y_name] @@ -322,6 +327,17 @@ class PlotApp(QWidget): data_x = msg["data"].get(x_name, {}).get(x_entry, {}).get("value", None) data_y = msg["data"].get(y_name, {}).get(y_entry, {}).get("value", None) + if data_x is None: + raise ValueError(f"Incorrect entry specified for x: {x_entry}") + + if data_y is None: + if hasattr(dev[y_name], "_hints"): + raise ValueError(f"Incorrect entry specified for y: {y_entry}") + else: + raise ValueError( + f"No hints available for y, and name did not work as entry: {y_name}" + ) + if data_x is not None: self.data.setdefault(key, {}).setdefault("x", []).append(data_x) From 583e643dacac3d7aaa744751baef2da69f6f892e Mon Sep 17 00:00:00 2001 From: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com> Date: Fri, 1 Sep 2023 01:18:12 +0200 Subject: [PATCH 10/14] fix: more specific error messages --- bec_widgets/examples/extreme/extreme.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/bec_widgets/examples/extreme/extreme.py b/bec_widgets/examples/extreme/extreme.py index 0c2bdf9c..11dea6f5 100644 --- a/bec_widgets/examples/extreme/extreme.py +++ b/bec_widgets/examples/extreme/extreme.py @@ -290,12 +290,13 @@ class PlotApp(QWidget): self.init_curves() for plot_config in self.plot_data: + plot_name = plot_config.get("plot_name", "Unnamed Plot") x_config = plot_config["x"] x_signal_config = x_config["signals"][0] # Assuming there's at least one signal for x x_name = x_signal_config.get("name", "") if not x_name: - raise ValueError("Name for x signal must be specified.") + raise ValueError(f"Name for x signal must be specified in plot: {plot_name}.") x_entry_list = x_signal_config.get("entry", []) if not x_entry_list: @@ -310,7 +311,9 @@ class PlotApp(QWidget): for y_config in y_configs: y_name = y_config.get("name", "") if not y_name: - raise ValueError("Name for y signal must be specified.") + raise ValueError( + f"Name for y signal must be specified in plot: {plot_name}." + ) y_entry_list = y_config.get("entry", []) if not y_entry_list: @@ -328,14 +331,18 @@ class PlotApp(QWidget): data_y = msg["data"].get(y_name, {}).get(y_entry, {}).get("value", None) if data_x is None: - raise ValueError(f"Incorrect entry specified for x: {x_entry}") + raise ValueError( + f"Incorrect entry '{x_entry}' specified for x in plot: {plot_name}, x name: {x_name}" + ) if data_y is None: if hasattr(dev[y_name], "_hints"): - raise ValueError(f"Incorrect entry specified for y: {y_entry}") + raise ValueError( + f"Incorrect entry '{y_entry}' specified for y in plot: {plot_name}, y name: {y_name}" + ) else: raise ValueError( - f"No hints available for y, and name did not work as entry: {y_name}" + f"No hints available for y in plot: {plot_name}, and name '{y_name}' did not work as entry" ) if data_x is not None: From fbd71c131386508a9ec7bb5963afefc13f8b1618 Mon Sep 17 00:00:00 2001 From: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com> Date: Fri, 1 Sep 2023 09:24:36 +0200 Subject: [PATCH 11/14] fix: add max number of columns according to the number of plots --- bec_widgets/examples/extreme/extreme.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bec_widgets/examples/extreme/extreme.py b/bec_widgets/examples/extreme/extreme.py index 11dea6f5..b49154d3 100644 --- a/bec_widgets/examples/extreme/extreme.py +++ b/bec_widgets/examples/extreme/extreme.py @@ -76,6 +76,7 @@ class PlotApp(QWidget): # Initialize the UI self.init_ui(self.plot_settings["num_columns"]) self.spinBox_N_columns.setValue(self.plot_settings["num_columns"]) + self.spinBox_N_columns.setMaximum(len(self.plot_data)) self.splitter.setSizes([400, 100]) # Connect the update signal to the update plot method @@ -373,6 +374,7 @@ if __name__ == "__main__": plot_settings = config.get("plot_settings", {}) plot_data = config.get("plot_data", {}) + except FileNotFoundError: print(f"The file {args.config} was not found.") exit(1) From e5273539741a1261e69b1bf76af78c7c1ab0d901 Mon Sep 17 00:00:00 2001 From: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com> Date: Fri, 1 Sep 2023 09:38:32 +0200 Subject: [PATCH 12/14] feat: load and export configuration into .yaml from GUI --- bec_widgets/examples/extreme/extreme.py | 56 +++++++++++++++++++++++- bec_widgets/examples/extreme/extreme.ui | 58 +++++++++++++++---------- 2 files changed, 90 insertions(+), 24 deletions(-) diff --git a/bec_widgets/examples/extreme/extreme.py b/bec_widgets/examples/extreme/extreme.py index b49154d3..85eab541 100644 --- a/bec_widgets/examples/extreme/extreme.py +++ b/bec_widgets/examples/extreme/extreme.py @@ -3,7 +3,7 @@ import os import numpy as np import pyqtgraph as pg from PyQt5.QtCore import pyqtSignal, pyqtSlot -from PyQt5.QtWidgets import QApplication, QWidget, QTableWidgetItem, QTableWidget +from PyQt5.QtWidgets import QApplication, QWidget, QTableWidgetItem, QTableWidget, QFileDialog from pyqtgraph import mkBrush, mkColor, mkPen from pyqtgraph.Qt import QtCore, uic @@ -75,10 +75,16 @@ class PlotApp(QWidget): # Initialize the UI self.init_ui(self.plot_settings["num_columns"]) - self.spinBox_N_columns.setValue(self.plot_settings["num_columns"]) + self.spinBox_N_columns.setValue( + self.plot_settings["num_columns"] + ) # TODO has to be checked if it will not setup more columns than plots self.spinBox_N_columns.setMaximum(len(self.plot_data)) self.splitter.setSizes([400, 100]) + # Buttons + self.pushButton_save.clicked.connect(self.save_settings_to_yaml) + self.pushButton_load.clicked.connect(self.load_settings_from_yaml) + # Connect the update signal to the update plot method self.proxy_update_plot = pg.SignalProxy( self.update_signal, rateLimit=25, slot=self.update_plot @@ -354,6 +360,52 @@ class PlotApp(QWidget): self.update_signal.emit() + def save_settings_to_yaml(self): + """Save the current settings to a .yaml file using a file dialog.""" + options = QFileDialog.Options() + options |= QFileDialog.DontUseNativeDialog + file_path, _ = QFileDialog.getSaveFileName( + self, "Save Settings", "", "YAML Files (*.yaml);;All Files (*)", options=options + ) + + if file_path: + try: + if not file_path.endswith(".yaml"): + file_path += ".yaml" + + with open(file_path, "w") as file: + yaml.dump( + {"plot_settings": self.plot_settings, "plot_data": self.plot_data}, file + ) + print(f"Settings saved to {file_path}") + except Exception as e: + print(f"An error occurred while saving the settings to {file_path}: {e}") + + def load_settings_from_yaml(self): + """Load settings from a .yaml file using a file dialog and update the current settings.""" + options = QFileDialog.Options() + options |= QFileDialog.DontUseNativeDialog + file_path, _ = QFileDialog.getOpenFileName( + self, "Load Settings", "", "YAML Files (*.yaml);;All Files (*)", options=options + ) + + if file_path: + try: + with open(file_path, "r") as file: + config = yaml.safe_load(file) + + self.plot_settings = config.get("plot_settings", {}) + self.plot_data = config.get("plot_data", {}) + # Reinitialize the UI and plots + # self.init_plot_background(self.plot_settings["background_color"]) #TODO implement + self.init_ui(self.plot_settings["num_columns"]) + self.init_curves() + print(f"Settings loaded from {file_path}") + except FileNotFoundError: + print(f"The file {file_path} was not found.") + except Exception as e: + print(f"An error occurred while loading the settings from {file_path}: {e}") + if __name__ == "__main__": import yaml diff --git a/bec_widgets/examples/extreme/extreme.ui b/bec_widgets/examples/extreme/extreme.ui index 51f6ab8a..e22291c4 100644 --- a/bec_widgets/examples/extreme/extreme.ui +++ b/bec_widgets/examples/extreme/extreme.ui @@ -22,27 +22,7 @@ - - - - Number of Columns: - - - - - - - 1 - - - 10 - - - 3 - - - - + Qt::Horizontal @@ -55,7 +35,7 @@ - + Cursor @@ -78,6 +58,40 @@ + + + + Number of Columns: + + + + + + + 1 + + + 10 + + + 3 + + + + + + + Load Config + + + + + + + Save Config + + + From aac6e172f6e4583e751bee00db6f381aaff8ac69 Mon Sep 17 00:00:00 2001 From: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com> Date: Fri, 1 Sep 2023 09:41:32 +0200 Subject: [PATCH 13/14] fix: check if num_columns is not higher that actual number of plots --- bec_widgets/examples/extreme/extreme.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bec_widgets/examples/extreme/extreme.py b/bec_widgets/examples/extreme/extreme.py index 85eab541..fe7011df 100644 --- a/bec_widgets/examples/extreme/extreme.py +++ b/bec_widgets/examples/extreme/extreme.py @@ -130,6 +130,15 @@ class PlotApp(QWidget): self.grid_coordinates = [] num_plots = len(self.plot_data) + + # Check if num_columns exceeds the number of plots + if num_columns > num_plots: + num_columns = num_plots + self.plot_settings["num_columns"] = num_columns # Update the settings + print( + f"Warning: num_columns in the YAML file was greater than the number of plots. Resetting num_columns to {num_columns}." + ) + num_rows = num_plots // num_columns last_row_cols = num_plots % num_columns remaining_space = num_columns - last_row_cols From 0ebe35ac7a144db84c323f9ecb85dfdf6de66c21 Mon Sep 17 00:00:00 2001 From: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com> Date: Fri, 1 Sep 2023 10:16:07 +0200 Subject: [PATCH 14/14] docs: updated documentation and TODOs --- bec_widgets/examples/extreme/config.yaml | 1 + bec_widgets/examples/extreme/extreme.py | 4 ++-- bec_widgets/examples/motor_movement/motor_example.py | 1 - 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bec_widgets/examples/extreme/config.yaml b/bec_widgets/examples/extreme/config.yaml index fcab38b9..4f804a56 100644 --- a/bec_widgets/examples/extreme/config.yaml +++ b/bec_widgets/examples/extreme/config.yaml @@ -45,4 +45,5 @@ plot_data: - name: "gauss_bpm" entry: "gauss_bpm" - name: "samx" + entry: ["samx", "samx_setpoint"] # entry: ["samx","incorect"] #multiple entries for one device \ No newline at end of file diff --git a/bec_widgets/examples/extreme/extreme.py b/bec_widgets/examples/extreme/extreme.py index fe7011df..567136ea 100644 --- a/bec_widgets/examples/extreme/extreme.py +++ b/bec_widgets/examples/extreme/extreme.py @@ -13,7 +13,6 @@ from bec_widgets.qt_utils import Crosshair, Colors # TODO implement: # - implement scanID database for visualizing previous scans -# - change how dap is handled in bec_dispatcher to handle more workers class PlotApp(QWidget): @@ -406,7 +405,8 @@ class PlotApp(QWidget): self.plot_settings = config.get("plot_settings", {}) self.plot_data = config.get("plot_data", {}) # Reinitialize the UI and plots - # self.init_plot_background(self.plot_settings["background_color"]) #TODO implement + # TODO implement, change background works only before loading .ui file + # self.init_plot_background(self.plot_settings["background_color"]) self.init_ui(self.plot_settings["num_columns"]) self.init_curves() print(f"Settings loaded from {file_path}") diff --git a/bec_widgets/examples/motor_movement/motor_example.py b/bec_widgets/examples/motor_movement/motor_example.py index f926918f..b700ef14 100644 --- a/bec_widgets/examples/motor_movement/motor_example.py +++ b/bec_widgets/examples/motor_movement/motor_example.py @@ -16,7 +16,6 @@ from bec_lib.core import MessageEndpoints, BECMessage # TODO - General features -# - setting motor speed and frequency # - setting motor acceleration # - updating motor precision # - put motor status (moving, stopped, etc)