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: diff --git a/bec_widgets/examples/extreme/config.yaml b/bec_widgets/examples/extreme/config.yaml new file mode 100644 index 00000000..4f804a56 --- /dev/null +++ b/bec_widgets/examples/extreme/config.yaml @@ -0,0 +1,49 @@ +plot_settings: + background_color: "black" + num_columns: 3 + colormap: "plasma" + #TODO add more settings + # - plot size + +plot_data: + - 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" + + - 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", "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 new file mode 100644 index 00000000..567136ea --- /dev/null +++ b/bec_widgets/examples/extreme/extreme.py @@ -0,0 +1,463 @@ +import os + +import numpy as np +import pyqtgraph as pg +from PyQt5.QtCore import pyqtSignal, pyqtSlot +from PyQt5.QtWidgets import QApplication, QWidget, QTableWidgetItem, QTableWidget, QFileDialog +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, Colors + + +# TODO implement: +# - implement scanID database for visualizing previous scans + + +class PlotApp(QWidget): + """ + 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. + 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: + 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, plot_data: list, parent=None): + super(PlotApp, self).__init__(parent) + + # YAML config + self.plot_settings = plot_settings + 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) + + # 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.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 + ) + + # 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. + + 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 dynamically arranges + 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 = [] + + 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 + + for i, plot_config in enumerate(self.plot_data): + row, col = i // num_columns, i % num_columns + colspan = 1 + + if row == num_rows and remaining_space > 0: + if last_row_cols == 1: + colspan = num_columns + else: + colspan = remaining_space // last_row_cols + 1 + remaining_space -= colspan - 1 + last_row_cols -= 1 + + 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[plot_name] = plot + self.grid_coordinates.append((row, col)) + + self.init_curves() + + 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 = {} + row_labels = [] + + for idx, plot_config in enumerate(self.plot_data): + plot_name = plot_config.get("plot_name", "") + plot = self.plots[plot_name] + plot.clear() + + y_configs = plot_config["y"]["signals"] + colors_ys = Colors.golden_angle_color( + colormap=self.plot_settings["colormap"], num=len(y_configs) + ) + + 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) + self.hook_crosshair() + + def hook_crosshair(self): + """Attach crosshairs to each plot and connect them to the update_table method.""" + 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( + 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[plot_name] = 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_name = [name for name, value in self.plots.items() if value == plot][0] + + starting_row = 0 + for plot_config in self.plot_data: + if plot_config.get("plot_name", "") == plot_name: + break + 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) + + 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 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) + 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 = {} + 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(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: + 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", "") + if not y_name: + 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: + 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 None: + 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 '{y_entry}' specified for y in plot: {plot_name}, y name: {y_name}" + ) + else: + raise ValueError( + 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: + 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() + + 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 + # 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}") + 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 + import argparse + + from bec_widgets import ctrl_c + from bec_widgets.bec_dispatcher import bec_dispatcher + + 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() + + try: + with open(args.config, "r") as file: + config = yaml.safe_load(file) + + 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) + except Exception as e: + print(f"An error occurred while loading the config file: {e}") + exit(1) + + # BECclient global variables + client = bec_dispatcher.client + client.start() + + dev = client.device_manager.devices + scans = client.scans + queue = client.queue + + app = QApplication([]) + 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()) + 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..e22291c4 --- /dev/null +++ b/bec_widgets/examples/extreme/extreme.ui @@ -0,0 +1,110 @@ + + + MultiWindow + + + + 0 + 0 + 1248 + 564 + + + + MultiWindow + + + + + + Qt::Horizontal + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Cursor + + + + + + + Moved + + + + + Clicked + + + + + + + + + + + Number of Columns: + + + + + + + 1 + + + 10 + + + 3 + + + + + + + Load Config + + + + + + + Save Config + + + + + + + + + + + + GraphicsLayoutWidget + QGraphicsView +
pyqtgraph.h
+
+
+ + +
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) 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): 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