From 3455c602361d3b5cc3ff9190f9d2870474becf8a Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 21 May 2024 16:27:19 +0200 Subject: [PATCH] refactor(reconstruction): repository structure is changed to separate assets needed for each widget --- .../{cli => assets}/bec_widgets_icon.png | Bin .../terminal_icon.png | Bin bec_widgets/cli/client_utils.py | 3 - bec_widgets/cli/generate_cli.py | 12 +- bec_widgets/cli/server.py | 12 +- .../jupyter_console/jupyter_console_window.py | 6 +- .../motor_control_compilations.py | 13 +- bec_widgets/widgets/__init__.py | 8 - bec_widgets/widgets/figure/figure.py | 14 +- bec_widgets/widgets/figure/plots/__init__.py | 0 .../widgets/figure/plots/image/__init__.py | 0 .../{plots => figure/plots/image}/image.py | 422 +------- .../widgets/figure/plots/image/image_item.py | 277 +++++ .../figure/plots/image/image_processor.py | 152 +++ .../figure/plots/motor_map/__init__.py | 0 .../plots/motor_map}/motor_map.py | 4 +- .../widgets/{ => figure}/plots/plot_base.py | 0 .../widgets/figure/plots/waveform/__init__.py | 0 .../plots/waveform}/waveform.py | 231 +---- .../figure/plots/waveform/waveform_curve.py | 227 +++++ bec_widgets/widgets/motor_control/__init__.py | 7 - .../widgets/motor_control/motor_control.py | 950 +----------------- .../motor_control/motor_table/__init__.py | 0 .../motor_control/motor_table/motor_table.py | 483 +++++++++ .../motor_table.ui} | 0 .../movement_absolute/__init__.py | 0 .../movement_absolute/movement_absolute.py | 157 +++ .../movement_absolute.ui} | 0 .../movement_relative/__init__.py | 0 .../movement_relative/movement_relative.py | 227 +++++ .../movement_relative.ui} | 0 .../motor_control/selection/__init__.py | 0 .../motor_control/selection/selection.py | 110 ++ .../selection.ui} | 0 bec_widgets/widgets/plots/__init__.py | 4 - tests/unit_tests/test_bec_figure.py | 6 +- tests/unit_tests/test_bec_motor_map.py | 5 +- tests/unit_tests/test_motor_control.py | 13 +- tests/unit_tests/test_waveform1d.py | 2 +- 39 files changed, 1694 insertions(+), 1651 deletions(-) rename bec_widgets/{cli => assets}/bec_widgets_icon.png (100%) rename bec_widgets/{examples/jupyter_console => assets}/terminal_icon.png (100%) create mode 100644 bec_widgets/widgets/figure/plots/__init__.py create mode 100644 bec_widgets/widgets/figure/plots/image/__init__.py rename bec_widgets/widgets/{plots => figure/plots/image}/image.py (59%) create mode 100644 bec_widgets/widgets/figure/plots/image/image_item.py create mode 100644 bec_widgets/widgets/figure/plots/image/image_processor.py create mode 100644 bec_widgets/widgets/figure/plots/motor_map/__init__.py rename bec_widgets/widgets/{plots => figure/plots/motor_map}/motor_map.py (98%) rename bec_widgets/widgets/{ => figure}/plots/plot_base.py (100%) create mode 100644 bec_widgets/widgets/figure/plots/waveform/__init__.py rename bec_widgets/widgets/{plots => figure/plots/waveform}/waveform.py (74%) create mode 100644 bec_widgets/widgets/figure/plots/waveform/waveform_curve.py create mode 100644 bec_widgets/widgets/motor_control/motor_table/__init__.py create mode 100644 bec_widgets/widgets/motor_control/motor_table/motor_table.py rename bec_widgets/widgets/motor_control/{motor_control_table.ui => motor_table/motor_table.ui} (100%) create mode 100644 bec_widgets/widgets/motor_control/movement_absolute/__init__.py create mode 100644 bec_widgets/widgets/motor_control/movement_absolute/movement_absolute.py rename bec_widgets/widgets/motor_control/{motor_control_absolute.ui => movement_absolute/movement_absolute.ui} (100%) create mode 100644 bec_widgets/widgets/motor_control/movement_relative/__init__.py create mode 100644 bec_widgets/widgets/motor_control/movement_relative/movement_relative.py rename bec_widgets/widgets/motor_control/{motor_control_relative.ui => movement_relative/movement_relative.ui} (100%) create mode 100644 bec_widgets/widgets/motor_control/selection/__init__.py create mode 100644 bec_widgets/widgets/motor_control/selection/selection.py rename bec_widgets/widgets/motor_control/{motor_control_selection.ui => selection/selection.ui} (100%) delete mode 100644 bec_widgets/widgets/plots/__init__.py diff --git a/bec_widgets/cli/bec_widgets_icon.png b/bec_widgets/assets/bec_widgets_icon.png similarity index 100% rename from bec_widgets/cli/bec_widgets_icon.png rename to bec_widgets/assets/bec_widgets_icon.png diff --git a/bec_widgets/examples/jupyter_console/terminal_icon.png b/bec_widgets/assets/terminal_icon.png similarity index 100% rename from bec_widgets/examples/jupyter_console/terminal_icon.png rename to bec_widgets/assets/terminal_icon.png diff --git a/bec_widgets/cli/client_utils.py b/bec_widgets/cli/client_utils.py index 9f9a6a94..52765678 100644 --- a/bec_widgets/cli/client_utils.py +++ b/bec_widgets/cli/client_utils.py @@ -13,7 +13,6 @@ from functools import wraps from typing import TYPE_CHECKING from bec_lib.endpoints import MessageEndpoints -from bec_lib.service_config import ServiceConfig from bec_lib.utils.import_utils import isinstance_based_on_class_name, lazy_import, lazy_import_from from qtpy.QtCore import QCoreApplication @@ -22,8 +21,6 @@ import bec_widgets.cli.client as client if TYPE_CHECKING: from bec_lib.device import DeviceBase - from bec_widgets.cli.client import BECDockArea, BECFigure - messages = lazy_import("bec_lib.messages") # from bec_lib.connector import MessageObject MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",)) diff --git a/bec_widgets/cli/generate_cli.py b/bec_widgets/cli/generate_cli.py index d00609d8..c9f8dd61 100644 --- a/bec_widgets/cli/generate_cli.py +++ b/bec_widgets/cli/generate_cli.py @@ -109,11 +109,13 @@ if __name__ == "__main__": # pragma: no cover import os from bec_widgets.utils import BECConnector - from bec_widgets.widgets.dock import BECDock, BECDockArea - from bec_widgets.widgets.figure import BECFigure - from bec_widgets.widgets.plots import BECImageShow, BECMotorMap, BECPlotBase, BECWaveform - from bec_widgets.widgets.plots.image import BECImageItem - from bec_widgets.widgets.plots.waveform import BECCurve + from bec_widgets.widgets import BECDock, BECDockArea, BECFigure + from bec_widgets.widgets.figure.plots.image.image import BECImageShow + from bec_widgets.widgets.figure.plots.image.image_item import BECImageItem + from bec_widgets.widgets.figure.plots.motor_map.motor_map import BECMotorMap + from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase + from bec_widgets.widgets.figure.plots.waveform.waveform import BECWaveform + from bec_widgets.widgets.figure.plots.waveform.waveform_curve import BECCurve current_path = os.path.dirname(__file__) client_path = os.path.join(current_path, "client.py") diff --git a/bec_widgets/cli/server.py b/bec_widgets/cli/server.py index 5d76d134..2d39ccf3 100644 --- a/bec_widgets/cli/server.py +++ b/bec_widgets/cli/server.py @@ -1,7 +1,5 @@ import inspect -import threading -import time -from typing import Literal, Union +from typing import Union from bec_lib.endpoints import MessageEndpoints from bec_lib.utils.import_utils import lazy_import @@ -12,13 +10,11 @@ from bec_widgets.utils import BECDispatcher from bec_widgets.utils.bec_connector import BECConnector from bec_widgets.widgets.dock.dock_area import BECDockArea from bec_widgets.widgets.figure import BECFigure -from bec_widgets.widgets.plots import BECCurve, BECImageShow, BECWaveform messages = lazy_import("bec_lib.messages") class BECWidgetsCLIServer: - WIDGETS = [BECWaveform, BECFigure, BECCurve, BECImageShow] def __init__( self, @@ -127,11 +123,13 @@ if __name__ == "__main__": # pragma: no cover from qtpy.QtGui import QIcon from qtpy.QtWidgets import QApplication, QMainWindow + import bec_widgets + app = QApplication(sys.argv) app.setApplicationName("BEC Figure") - current_path = os.path.dirname(__file__) + module_path = os.path.dirname(bec_widgets.__file__) icon = QIcon() - icon.addFile(os.path.join(current_path, "bec_widgets_icon.png"), size=QSize(48, 48)) + icon.addFile(os.path.join(module_path, "assets", "bec_widgets_icon.png"), size=QSize(48, 48)) app.setWindowIcon(icon) win = QMainWindow() diff --git a/bec_widgets/examples/jupyter_console/jupyter_console_window.py b/bec_widgets/examples/jupyter_console/jupyter_console_window.py index 4ea3f0c9..45fd5761 100644 --- a/bec_widgets/examples/jupyter_console/jupyter_console_window.py +++ b/bec_widgets/examples/jupyter_console/jupyter_console_window.py @@ -142,6 +142,10 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover: if __name__ == "__main__": # pragma: no cover import sys + import bec_widgets + + module_path = os.path.dirname(bec_widgets.__file__) + bec_dispatcher = BECDispatcher() client = bec_dispatcher.client client.start() @@ -150,7 +154,7 @@ if __name__ == "__main__": # pragma: no cover app.setApplicationName("Jupyter Console") app.setApplicationDisplayName("Jupyter Console") icon = QIcon() - icon.addFile("terminal_icon.png", size=QSize(48, 48)) + icon.addFile(os.path.join(module_path, "assets", "terminal_icon.png"), size=QSize(48, 48)) app.setWindowIcon(icon) win = JupyterConsoleWindow() win.show() diff --git a/bec_widgets/examples/motor_movement/motor_control_compilations.py b/bec_widgets/examples/motor_movement/motor_control_compilations.py index 0a182ece..d715e02b 100644 --- a/bec_widgets/examples/motor_movement/motor_control_compilations.py +++ b/bec_widgets/examples/motor_movement/motor_control_compilations.py @@ -5,14 +5,15 @@ from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication, QSplitter, QVBoxLayout, QWidget from bec_widgets.utils.bec_dispatcher import BECDispatcher -from bec_widgets.widgets import ( +from bec_widgets.widgets.motor_control.motor_control import MotorThread +from bec_widgets.widgets.motor_control.motor_table.motor_table import MotorCoordinateTable +from bec_widgets.widgets.motor_control.movement_absolute.movement_absolute import ( MotorControlAbsolute, - MotorControlRelative, - MotorControlSelection, - MotorCoordinateTable, - # MotorMap, - MotorThread, ) +from bec_widgets.widgets.motor_control.movement_relative.movement_relative import ( + MotorControlRelative, +) +from bec_widgets.widgets.motor_control.selection.selection import MotorControlSelection CONFIG_DEFAULT = { "motor_control": { diff --git a/bec_widgets/widgets/__init__.py b/bec_widgets/widgets/__init__.py index ebe79459..268d685a 100644 --- a/bec_widgets/widgets/__init__.py +++ b/bec_widgets/widgets/__init__.py @@ -1,11 +1,3 @@ from .dock import BECDock, BECDockArea from .figure import BECFigure, FigureConfig -from .motor_control import ( - MotorControlAbsolute, - MotorControlRelative, - MotorControlSelection, - MotorCoordinateTable, - MotorThread, -) -from .plots import BECCurve, BECMotorMap, BECWaveform from .scan_control import ScanControl diff --git a/bec_widgets/widgets/figure/figure.py b/bec_widgets/widgets/figure/figure.py index 8cc1e894..9b7abef7 100644 --- a/bec_widgets/widgets/figure/figure.py +++ b/bec_widgets/widgets/figure/figure.py @@ -13,16 +13,10 @@ from qtpy.QtCore import Signal as pyqtSignal from qtpy.QtWidgets import QWidget from bec_widgets.utils import BECConnector, ConnectionConfig, WidgetContainerUtils -from bec_widgets.widgets.plots import ( - BECImageShow, - BECMotorMap, - BECPlotBase, - BECWaveform, - SubplotConfig, - Waveform1DConfig, -) -from bec_widgets.widgets.plots.image import ImageConfig -from bec_widgets.widgets.plots.motor_map import MotorMapConfig +from bec_widgets.widgets.figure.plots.image.image import BECImageShow, ImageConfig +from bec_widgets.widgets.figure.plots.motor_map.motor_map import BECMotorMap, MotorMapConfig +from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig +from bec_widgets.widgets.figure.plots.waveform.waveform import BECWaveform, Waveform1DConfig class FigureConfig(ConnectionConfig): diff --git a/bec_widgets/widgets/figure/plots/__init__.py b/bec_widgets/widgets/figure/plots/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/figure/plots/image/__init__.py b/bec_widgets/widgets/figure/plots/image/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/plots/image.py b/bec_widgets/widgets/figure/plots/image/image.py similarity index 59% rename from bec_widgets/widgets/plots/image.py rename to bec_widgets/widgets/figure/plots/image/image.py index c78e32e7..b734e058 100644 --- a/bec_widgets/widgets/plots/image.py +++ b/bec_widgets/widgets/figure/plots/image/image.py @@ -4,50 +4,16 @@ from collections import defaultdict from typing import Any, Literal, Optional import numpy as np -import pyqtgraph as pg from bec_lib.endpoints import MessageEndpoints -from pydantic import BaseModel, Field, ValidationError -from qtpy.QtCore import QObject, QThread -from qtpy.QtCore import Signal as pyqtSignal +from pydantic import Field, ValidationError +from qtpy.QtCore import QThread from qtpy.QtCore import Slot as pyqtSlot from qtpy.QtWidgets import QWidget -from bec_widgets.utils import BECConnector, ConnectionConfig, EntryValidator - -from .plot_base import BECPlotBase, SubplotConfig - - -class ProcessingConfig(BaseModel): - fft: Optional[bool] = Field(False, description="Whether to perform FFT on the monitor data.") - log: Optional[bool] = Field(False, description="Whether to perform log on the monitor data.") - center_of_mass: Optional[bool] = Field( - False, description="Whether to calculate the center of mass of the monitor data." - ) - transpose: Optional[bool] = Field( - False, description="Whether to transpose the monitor data before displaying." - ) - rotation: Optional[int] = Field( - None, description="The rotation angle of the monitor data before displaying." - ) - - -class ImageItemConfig(ConnectionConfig): - parent_id: Optional[str] = Field(None, description="The parent plot of the image.") - monitor: Optional[str] = Field(None, description="The name of the monitor.") - source: Optional[str] = Field(None, description="The source of the curve.") - color_map: Optional[str] = Field("magma", description="The color map of the image.") - downsample: Optional[bool] = Field(True, description="Whether to downsample the image.") - opacity: Optional[float] = Field(1.0, description="The opacity of the image.") - vrange: Optional[tuple[int, int]] = Field( - None, description="The range of the color bar. If None, the range is automatically set." - ) - color_bar: Optional[Literal["simple", "full"]] = Field( - "simple", description="The type of the color bar." - ) - autorange: Optional[bool] = Field(True, description="Whether to autorange the color bar.") - processing: ProcessingConfig = Field( - default_factory=ProcessingConfig, description="The post processing of the image." - ) +from bec_widgets.utils import EntryValidator +from bec_widgets.widgets.figure.plots.image.image_item import BECImageItem, ImageItemConfig +from bec_widgets.widgets.figure.plots.image.image_processor import ImageProcessor, ProcessorWorker +from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig class ImageConfig(SubplotConfig): @@ -57,251 +23,6 @@ class ImageConfig(SubplotConfig): ) -class BECImageItem(BECConnector, pg.ImageItem): - USER_ACCESS = [ - "rpc_id", - "config_dict", - "set", - "set_fft", - "set_log", - "set_rotation", - "set_transpose", - "set_opacity", - "set_autorange", - "set_color_map", - "set_auto_downsample", - "set_monitor", - "set_vrange", - "get_data", - ] - - def __init__( - self, - config: Optional[ImageItemConfig] = None, - gui_id: Optional[str] = None, - parent_image: Optional[BECImageItem] = None, - **kwargs, - ): - if config is None: - config = ImageItemConfig(widget_class=self.__class__.__name__) - self.config = config - else: - self.config = config - super().__init__(config=config, gui_id=gui_id) - pg.ImageItem.__init__(self) - - self.parent_image = parent_image - self.colorbar_bar = None - - self._add_color_bar( - self.config.color_bar, self.config.vrange - ) # TODO can also support None to not have any colorbar - self.apply_config() - if kwargs: - self.set(**kwargs) - - def apply_config(self): - """ - Apply current configuration. - """ - self.set_color_map(self.config.color_map) - self.set_auto_downsample(self.config.downsample) - if self.config.vrange is not None: - self.set_vrange(vrange=self.config.vrange) - - def set(self, **kwargs): - """ - Set the properties of the image. - - Args: - **kwargs: Keyword arguments for the properties to be set. - - Possible properties: - - downsample - - color_map - - monitor - - opacity - - vrange - - fft - - log - - rot - - transpose - """ - method_map = { - "downsample": self.set_auto_downsample, - "color_map": self.set_color_map, - "monitor": self.set_monitor, - "opacity": self.set_opacity, - "vrange": self.set_vrange, - "fft": self.set_fft, - "log": self.set_log, - "rot": self.set_rotation, - "transpose": self.set_transpose, - } - for key, value in kwargs.items(): - if key in method_map: - method_map[key](value) - else: - print(f"Warning: '{key}' is not a recognized property.") - - def set_fft(self, enable: bool = False): - """ - Set the FFT of the image. - - Args: - enable(bool): Whether to perform FFT on the monitor data. - """ - self.config.processing.fft = enable - - def set_log(self, enable: bool = False): - """ - Set the log of the image. - - Args: - enable(bool): Whether to perform log on the monitor data. - """ - self.config.processing.log = enable - if enable and self.color_bar and self.config.color_bar == "full": - self.color_bar.autoHistogramRange() - - def set_rotation(self, deg_90: int = 0): - """ - Set the rotation of the image. - - Args: - deg_90(int): The rotation angle of the monitor data before displaying. - """ - self.config.processing.rotation = deg_90 - - def set_transpose(self, enable: bool = False): - """ - Set the transpose of the image. - - Args: - enable(bool): Whether to transpose the image. - """ - self.config.processing.transpose = enable - - def set_opacity(self, opacity: float = 1.0): - """ - Set the opacity of the image. - - Args: - opacity(float): The opacity of the image. - """ - self.setOpacity(opacity) - self.config.opacity = opacity - - def set_autorange(self, autorange: bool = False): - """ - Set the autorange of the color bar. - - Args: - autorange(bool): Whether to autorange the color bar. - """ - self.config.autorange = autorange - if self.color_bar is not None: - self.color_bar.autoHistogramRange() - - def set_color_map(self, cmap: str = "magma"): - """ - Set the color map of the image. - - Args: - cmap(str): The color map of the image. - """ - self.setColorMap(cmap) - if self.color_bar is not None: - if self.config.color_bar == "simple": - self.color_bar.setColorMap(cmap) - elif self.config.color_bar == "full": - self.color_bar.gradient.loadPreset(cmap) - self.config.color_map = cmap - - def set_auto_downsample(self, auto: bool = True): - """ - Set the auto downsample of the image. - - Args: - auto(bool): Whether to downsample the image. - """ - self.setAutoDownsample(auto) - self.config.downsample = auto - - def set_monitor(self, monitor: str): - """ - Set the monitor of the image. - - Args: - monitor(str): The name of the monitor. - """ - self.config.monitor = monitor - - def set_vrange(self, vmin: float = None, vmax: float = None, vrange: tuple[int, int] = None): - """ - Set the range of the color bar. - - Args: - vmin(float): Minimum value of the color bar. - vmax(float): Maximum value of the color bar. - """ - if vrange is not None: - vmin, vmax = vrange - self.setLevels([vmin, vmax]) - self.config.vrange = (vmin, vmax) - self.config.autorange = False - if self.color_bar is not None: - if self.config.color_bar == "simple": - self.color_bar.setLevels(low=vmin, high=vmax) - elif self.config.color_bar == "full": - self.color_bar.setLevels(min=vmin, max=vmax) - self.color_bar.setHistogramRange(vmin - 0.1 * vmin, vmax + 0.1 * vmax) - - def get_data(self) -> np.ndarray: - """ - Get the data of the image. - Returns: - np.ndarray: The data of the image. - """ - return self.image - - def _add_color_bar( - self, color_bar_style: str = "simple", vrange: Optional[tuple[int, int]] = None - ): - """ - Add color bar to the layout. - - Args: - style(Literal["simple,full"]): The style of the color bar. - vrange(tuple[int,int]): The range of the color bar. - """ - if color_bar_style == "simple": - self.color_bar = pg.ColorBarItem(colorMap=self.config.color_map) - if vrange is not None: - self.color_bar.setLevels(low=vrange[0], high=vrange[1]) - self.color_bar.setImageItem(self) - self.parent_image.addItem(self.color_bar) # , row=0, col=1) - self.config.color_bar = "simple" - elif color_bar_style == "full": - # Setting histogram - self.color_bar = pg.HistogramLUTItem() - self.color_bar.setImageItem(self) - self.color_bar.gradient.loadPreset(self.config.color_map) - if vrange is not None: - self.color_bar.setLevels(min=vrange[0], max=vrange[1]) - self.color_bar.setHistogramRange( - vrange[0] - 0.1 * vrange[0], vrange[1] + 0.1 * vrange[1] - ) - - # Adding histogram to the layout - self.parent_image.addItem(self.color_bar) # , row=0, col=1) - - # save settings - self.config.color_bar = "full" - else: - raise ValueError("style should be 'simple' or 'full'") - - class BECImageShow(BECPlotBase): USER_ACCESS = [ "rpc_id", @@ -837,134 +558,3 @@ class BECImageShow(BECPlotBase): image.cleanup() super().cleanup() - - -class ImageProcessor: - """ - Class for processing the image data. - """ - - def __init__(self, config: ProcessingConfig = None): - if config is None: - config = ProcessingConfig() - self.config = config - - def set_config(self, config: ProcessingConfig): - """ - Set the configuration of the processor. - - Args: - config(ProcessingConfig): The configuration of the processor. - """ - self.config = config - - def FFT(self, data: np.ndarray) -> np.ndarray: - """ - Perform FFT on the data. - - Args: - data(np.ndarray): The data to be processed. - - Returns: - np.ndarray: The processed data. - """ - return np.abs(np.fft.fftshift(np.fft.fft2(data))) - - def rotation(self, data: np.ndarray, rotate_90: int) -> np.ndarray: - """ - Rotate the data by 90 degrees n times. - - Args: - data(np.ndarray): The data to be processed. - rotate_90(int): The number of 90 degree rotations. - - Returns: - np.ndarray: The processed data. - """ - return np.rot90(data, k=rotate_90, axes=(0, 1)) - - def transpose(self, data: np.ndarray) -> np.ndarray: - """ - Transpose the data. - - Args: - data(np.ndarray): The data to be processed. - - Returns: - np.ndarray: The processed data. - """ - return np.transpose(data) - - def log(self, data: np.ndarray) -> np.ndarray: - """ - Perform log on the data. - - Args: - data(np.ndarray): The data to be processed. - - Returns: - np.ndarray: The processed data. - """ - # TODO this is not final solution -> data should stay as int16 - data = data.astype(np.float32) - offset = 1e-6 - data_offset = data + offset - return np.log10(data_offset) - - # def center_of_mass(self, data: np.ndarray) -> tuple: # TODO check functionality - # return np.unravel_index(np.argmax(data), data.shape) - - def process_image(self, data: np.ndarray) -> np.ndarray: - """ - Process the data according to the configuration. - - Args: - data(np.ndarray): The data to be processed. - - Returns: - np.ndarray: The processed data. - """ - if self.config.fft: - data = self.FFT(data) - if self.config.rotation is not None: - data = self.rotation(data, self.config.rotation) - if self.config.transpose: - data = self.transpose(data) - if self.config.log: - data = self.log(data) - return data - - -class ProcessorWorker(QObject): - """ - Worker for processing the image data. - """ - - processed = pyqtSignal(str, np.ndarray) - stopRequested = pyqtSignal() - finished = pyqtSignal() - - def __init__(self, processor): - super().__init__() - self.processor = processor - self._isRunning = False - self.stopRequested.connect(self.stop) - - @pyqtSlot(str, np.ndarray) - def process_image(self, device: str, image: np.ndarray): - """ - Process the image data. - - Args: - device(str): The name of the device. - image(np.ndarray): The image data. - """ - self._isRunning = True - processed_image = self.processor.process_image(image) - self._isRunning = False - if not self._isRunning: - self.processed.emit(device, processed_image) - self.finished.emit() - - def stop(self): - self._isRunning = False diff --git a/bec_widgets/widgets/figure/plots/image/image_item.py b/bec_widgets/widgets/figure/plots/image/image_item.py new file mode 100644 index 00000000..30d06761 --- /dev/null +++ b/bec_widgets/widgets/figure/plots/image/image_item.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal, Optional + +import numpy as np +import pyqtgraph as pg +from pydantic import Field + +from bec_widgets.utils import BECConnector, ConnectionConfig +from bec_widgets.widgets.figure.plots.image.image_processor import ProcessingConfig + +if TYPE_CHECKING: + from bec_widgets.widgets.figure.plots.image.image import BECImageShow + + +class ImageItemConfig(ConnectionConfig): + parent_id: Optional[str] = Field(None, description="The parent plot of the image.") + monitor: Optional[str] = Field(None, description="The name of the monitor.") + source: Optional[str] = Field(None, description="The source of the curve.") + color_map: Optional[str] = Field("magma", description="The color map of the image.") + downsample: Optional[bool] = Field(True, description="Whether to downsample the image.") + opacity: Optional[float] = Field(1.0, description="The opacity of the image.") + vrange: Optional[tuple[int, int]] = Field( + None, description="The range of the color bar. If None, the range is automatically set." + ) + color_bar: Optional[Literal["simple", "full"]] = Field( + "simple", description="The type of the color bar." + ) + autorange: Optional[bool] = Field(True, description="Whether to autorange the color bar.") + processing: ProcessingConfig = Field( + default_factory=ProcessingConfig, description="The post processing of the image." + ) + + +class BECImageItem(BECConnector, pg.ImageItem): + USER_ACCESS = [ + "rpc_id", + "config_dict", + "set", + "set_fft", + "set_log", + "set_rotation", + "set_transpose", + "set_opacity", + "set_autorange", + "set_color_map", + "set_auto_downsample", + "set_monitor", + "set_vrange", + "get_data", + ] + + def __init__( + self, + config: Optional[ImageItemConfig] = None, + gui_id: Optional[str] = None, + parent_image: Optional[BECImageShow] = None, + **kwargs, + ): + if config is None: + config = ImageItemConfig(widget_class=self.__class__.__name__) + self.config = config + else: + self.config = config + super().__init__(config=config, gui_id=gui_id) + pg.ImageItem.__init__(self) + + self.parent_image = parent_image + self.colorbar_bar = None + + self._add_color_bar( + self.config.color_bar, self.config.vrange + ) # TODO can also support None to not have any colorbar + self.apply_config() + if kwargs: + self.set(**kwargs) + + def apply_config(self): + """ + Apply current configuration. + """ + self.set_color_map(self.config.color_map) + self.set_auto_downsample(self.config.downsample) + if self.config.vrange is not None: + self.set_vrange(vrange=self.config.vrange) + + def set(self, **kwargs): + """ + Set the properties of the image. + + Args: + **kwargs: Keyword arguments for the properties to be set. + + Possible properties: + - downsample + - color_map + - monitor + - opacity + - vrange + - fft + - log + - rot + - transpose + """ + method_map = { + "downsample": self.set_auto_downsample, + "color_map": self.set_color_map, + "monitor": self.set_monitor, + "opacity": self.set_opacity, + "vrange": self.set_vrange, + "fft": self.set_fft, + "log": self.set_log, + "rot": self.set_rotation, + "transpose": self.set_transpose, + } + for key, value in kwargs.items(): + if key in method_map: + method_map[key](value) + else: + print(f"Warning: '{key}' is not a recognized property.") + + def set_fft(self, enable: bool = False): + """ + Set the FFT of the image. + + Args: + enable(bool): Whether to perform FFT on the monitor data. + """ + self.config.processing.fft = enable + + def set_log(self, enable: bool = False): + """ + Set the log of the image. + + Args: + enable(bool): Whether to perform log on the monitor data. + """ + self.config.processing.log = enable + if enable and self.color_bar and self.config.color_bar == "full": + self.color_bar.autoHistogramRange() + + def set_rotation(self, deg_90: int = 0): + """ + Set the rotation of the image. + + Args: + deg_90(int): The rotation angle of the monitor data before displaying. + """ + self.config.processing.rotation = deg_90 + + def set_transpose(self, enable: bool = False): + """ + Set the transpose of the image. + + Args: + enable(bool): Whether to transpose the image. + """ + self.config.processing.transpose = enable + + def set_opacity(self, opacity: float = 1.0): + """ + Set the opacity of the image. + + Args: + opacity(float): The opacity of the image. + """ + self.setOpacity(opacity) + self.config.opacity = opacity + + def set_autorange(self, autorange: bool = False): + """ + Set the autorange of the color bar. + + Args: + autorange(bool): Whether to autorange the color bar. + """ + self.config.autorange = autorange + if self.color_bar is not None: + self.color_bar.autoHistogramRange() + + def set_color_map(self, cmap: str = "magma"): + """ + Set the color map of the image. + + Args: + cmap(str): The color map of the image. + """ + self.setColorMap(cmap) + if self.color_bar is not None: + if self.config.color_bar == "simple": + self.color_bar.setColorMap(cmap) + elif self.config.color_bar == "full": + self.color_bar.gradient.loadPreset(cmap) + self.config.color_map = cmap + + def set_auto_downsample(self, auto: bool = True): + """ + Set the auto downsample of the image. + + Args: + auto(bool): Whether to downsample the image. + """ + self.setAutoDownsample(auto) + self.config.downsample = auto + + def set_monitor(self, monitor: str): + """ + Set the monitor of the image. + + Args: + monitor(str): The name of the monitor. + """ + self.config.monitor = monitor + + def set_vrange(self, vmin: float = None, vmax: float = None, vrange: tuple[int, int] = None): + """ + Set the range of the color bar. + + Args: + vmin(float): Minimum value of the color bar. + vmax(float): Maximum value of the color bar. + """ + if vrange is not None: + vmin, vmax = vrange + self.setLevels([vmin, vmax]) + self.config.vrange = (vmin, vmax) + self.config.autorange = False + if self.color_bar is not None: + if self.config.color_bar == "simple": + self.color_bar.setLevels(low=vmin, high=vmax) + elif self.config.color_bar == "full": + self.color_bar.setLevels(min=vmin, max=vmax) + self.color_bar.setHistogramRange(vmin - 0.1 * vmin, vmax + 0.1 * vmax) + + def get_data(self) -> np.ndarray: + """ + Get the data of the image. + Returns: + np.ndarray: The data of the image. + """ + return self.image + + def _add_color_bar( + self, color_bar_style: str = "simple", vrange: Optional[tuple[int, int]] = None + ): + """ + Add color bar to the layout. + + Args: + style(Literal["simple,full"]): The style of the color bar. + vrange(tuple[int,int]): The range of the color bar. + """ + if color_bar_style == "simple": + self.color_bar = pg.ColorBarItem(colorMap=self.config.color_map) + if vrange is not None: + self.color_bar.setLevels(low=vrange[0], high=vrange[1]) + self.color_bar.setImageItem(self) + self.parent_image.addItem(self.color_bar) # , row=0, col=1) + self.config.color_bar = "simple" + elif color_bar_style == "full": + # Setting histogram + self.color_bar = pg.HistogramLUTItem() + self.color_bar.setImageItem(self) + self.color_bar.gradient.loadPreset(self.config.color_map) + if vrange is not None: + self.color_bar.setLevels(min=vrange[0], max=vrange[1]) + self.color_bar.setHistogramRange( + vrange[0] - 0.1 * vrange[0], vrange[1] + 0.1 * vrange[1] + ) + + # Adding histogram to the layout + self.parent_image.addItem(self.color_bar) # , row=0, col=1) + + # save settings + self.config.color_bar = "full" + else: + raise ValueError("style should be 'simple' or 'full'") diff --git a/bec_widgets/widgets/figure/plots/image/image_processor.py b/bec_widgets/widgets/figure/plots/image/image_processor.py new file mode 100644 index 00000000..52dc5b7b --- /dev/null +++ b/bec_widgets/widgets/figure/plots/image/image_processor.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from typing import Optional + +import numpy as np +from pydantic import BaseModel, Field +from qtpy.QtCore import QObject, Signal, Slot + + +class ProcessingConfig(BaseModel): + fft: Optional[bool] = Field(False, description="Whether to perform FFT on the monitor data.") + log: Optional[bool] = Field(False, description="Whether to perform log on the monitor data.") + center_of_mass: Optional[bool] = Field( + False, description="Whether to calculate the center of mass of the monitor data." + ) + transpose: Optional[bool] = Field( + False, description="Whether to transpose the monitor data before displaying." + ) + rotation: Optional[int] = Field( + None, description="The rotation angle of the monitor data before displaying." + ) + + +class ImageProcessor: + """ + Class for processing the image data. + """ + + def __init__(self, config: ProcessingConfig = None): + if config is None: + config = ProcessingConfig() + self.config = config + + def set_config(self, config: ProcessingConfig): + """ + Set the configuration of the processor. + + Args: + config(ProcessingConfig): The configuration of the processor. + """ + self.config = config + + def FFT(self, data: np.ndarray) -> np.ndarray: + """ + Perform FFT on the data. + + Args: + data(np.ndarray): The data to be processed. + + Returns: + np.ndarray: The processed data. + """ + return np.abs(np.fft.fftshift(np.fft.fft2(data))) + + def rotation(self, data: np.ndarray, rotate_90: int) -> np.ndarray: + """ + Rotate the data by 90 degrees n times. + + Args: + data(np.ndarray): The data to be processed. + rotate_90(int): The number of 90 degree rotations. + + Returns: + np.ndarray: The processed data. + """ + return np.rot90(data, k=rotate_90, axes=(0, 1)) + + def transpose(self, data: np.ndarray) -> np.ndarray: + """ + Transpose the data. + + Args: + data(np.ndarray): The data to be processed. + + Returns: + np.ndarray: The processed data. + """ + return np.transpose(data) + + def log(self, data: np.ndarray) -> np.ndarray: + """ + Perform log on the data. + + Args: + data(np.ndarray): The data to be processed. + + Returns: + np.ndarray: The processed data. + """ + # TODO this is not final solution -> data should stay as int16 + data = data.astype(np.float32) + offset = 1e-6 + data_offset = data + offset + return np.log10(data_offset) + + # def center_of_mass(self, data: np.ndarray) -> tuple: # TODO check functionality + # return np.unravel_index(np.argmax(data), data.shape) + + def process_image(self, data: np.ndarray) -> np.ndarray: + """ + Process the data according to the configuration. + + Args: + data(np.ndarray): The data to be processed. + + Returns: + np.ndarray: The processed data. + """ + if self.config.fft: + data = self.FFT(data) + if self.config.rotation is not None: + data = self.rotation(data, self.config.rotation) + if self.config.transpose: + data = self.transpose(data) + if self.config.log: + data = self.log(data) + return data + + +class ProcessorWorker(QObject): + """ + Worker for processing the image data. + """ + + processed = Signal(str, np.ndarray) + stopRequested = Signal() + finished = Signal() + + def __init__(self, processor): + super().__init__() + self.processor = processor + self._isRunning = False + self.stopRequested.connect(self.stop) + + @Slot(str, np.ndarray) + def process_image(self, device: str, image: np.ndarray): + """ + Process the image data. + + Args: + device(str): The name of the device. + image(np.ndarray): The image data. + """ + self._isRunning = True + processed_image = self.processor.process_image(image) + self._isRunning = False + if not self._isRunning: + self.processed.emit(device, processed_image) + self.finished.emit() + + def stop(self): + self._isRunning = False diff --git a/bec_widgets/widgets/figure/plots/motor_map/__init__.py b/bec_widgets/widgets/figure/plots/motor_map/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/plots/motor_map.py b/bec_widgets/widgets/figure/plots/motor_map/motor_map.py similarity index 98% rename from bec_widgets/widgets/plots/motor_map.py rename to bec_widgets/widgets/figure/plots/motor_map/motor_map.py index b2324238..3d865ef9 100644 --- a/bec_widgets/widgets/plots/motor_map.py +++ b/bec_widgets/widgets/figure/plots/motor_map/motor_map.py @@ -13,8 +13,8 @@ from qtpy.QtCore import Slot as pyqtSlot from qtpy.QtWidgets import QWidget from bec_widgets.utils import EntryValidator -from bec_widgets.widgets.plots.plot_base import BECPlotBase, SubplotConfig -from bec_widgets.widgets.plots.waveform import Signal, SignalData +from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig +from bec_widgets.widgets.figure.plots.waveform.waveform import Signal, SignalData class MotorMapConfig(SubplotConfig): diff --git a/bec_widgets/widgets/plots/plot_base.py b/bec_widgets/widgets/figure/plots/plot_base.py similarity index 100% rename from bec_widgets/widgets/plots/plot_base.py rename to bec_widgets/widgets/figure/plots/plot_base.py diff --git a/bec_widgets/widgets/figure/plots/waveform/__init__.py b/bec_widgets/widgets/figure/plots/waveform/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/plots/waveform.py b/bec_widgets/widgets/figure/plots/waveform/waveform.py similarity index 74% rename from bec_widgets/widgets/plots/waveform.py rename to bec_widgets/widgets/figure/plots/waveform/waveform.py index 5ec8ed53..46f75878 100644 --- a/bec_widgets/widgets/plots/waveform.py +++ b/bec_widgets/widgets/figure/plots/waveform/waveform.py @@ -7,50 +7,19 @@ import numpy as np import pyqtgraph as pg from bec_lib.endpoints import MessageEndpoints from bec_lib.scan_data import ScanData -from pydantic import BaseModel, Field, ValidationError -from pyqtgraph import mkBrush -from qtpy import QtCore +from pydantic import Field, ValidationError from qtpy.QtCore import Signal as pyqtSignal from qtpy.QtCore import Slot as pyqtSlot from qtpy.QtWidgets import QWidget -from bec_widgets.utils import BECConnector, Colors, ConnectionConfig, EntryValidator -from bec_widgets.widgets.plots.plot_base import BECPlotBase, SubplotConfig - - -class SignalData(BaseModel): - """The data configuration of a signal in the 1D waveform widget for x and y axis.""" - - name: str - entry: str - unit: Optional[str] = None # todo implement later - modifier: Optional[str] = None # todo implement later - limits: Optional[list[float]] = None # todo implement later - - -class Signal(BaseModel): - """The configuration of a signal in the 1D waveform widget.""" - - source: str - x: SignalData # TODO maybe add metadata for config gui later - y: SignalData - z: Optional[SignalData] = None - - -class CurveConfig(ConnectionConfig): - parent_id: Optional[str] = Field(None, description="The parent plot of the curve.") - label: Optional[str] = Field(None, description="The label of the curve.") - color: Optional[Any] = Field(None, description="The color of the curve.") - symbol: Optional[str] = Field("o", description="The symbol of the curve.") - symbol_color: Optional[str] = Field(None, description="The color of the symbol of the curve.") - symbol_size: Optional[int] = Field(5, description="The size of the symbol of the curve.") - pen_width: Optional[int] = Field(2, description="The width of the pen of the curve.") - pen_style: Optional[Literal["solid", "dash", "dot", "dashdot"]] = Field( - "solid", description="The style of the pen of the curve." - ) - source: Optional[str] = Field(None, description="The source of the curve.") - signals: Optional[Signal] = Field(None, description="The signal of the curve.") - colormap: Optional[str] = Field("plasma", description="The colormap of the curves z gradient.") +from bec_widgets.utils import Colors, EntryValidator +from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig +from bec_widgets.widgets.figure.plots.waveform.waveform_curve import ( + BECCurve, + CurveConfig, + Signal, + SignalData, +) class Waveform1DConfig(SubplotConfig): @@ -62,188 +31,6 @@ class Waveform1DConfig(SubplotConfig): ) -class BECCurve(BECConnector, pg.PlotDataItem): - USER_ACCESS = [ - "remove", - "rpc_id", - "config_dict", - "set", - "set_data", - "set_color", - "set_colormap", - "set_symbol", - "set_symbol_color", - "set_symbol_size", - "set_pen_width", - "set_pen_style", - "get_data", - ] - - def __init__( - self, - name: Optional[str] = None, - config: Optional[CurveConfig] = None, - gui_id: Optional[str] = None, - parent_item: Optional[pg.PlotItem] = None, - **kwargs, - ): - if config is None: - config = CurveConfig(label=name, widget_class=self.__class__.__name__) - self.config = config - else: - self.config = config - # config.widget_class = self.__class__.__name__ - super().__init__(config=config, gui_id=gui_id) - pg.PlotDataItem.__init__(self, name=name) - - self.parent_item = parent_item - self.apply_config() - if kwargs: - self.set(**kwargs) - - def apply_config(self): - pen_style_map = { - "solid": QtCore.Qt.SolidLine, - "dash": QtCore.Qt.DashLine, - "dot": QtCore.Qt.DotLine, - "dashdot": QtCore.Qt.DashDotLine, - } - pen_style = pen_style_map.get(self.config.pen_style, QtCore.Qt.SolidLine) - - pen = pg.mkPen(color=self.config.color, width=self.config.pen_width, style=pen_style) - self.setPen(pen) - - if self.config.symbol: - symbol_color = self.config.symbol_color or self.config.color - brush = mkBrush(color=symbol_color) - self.setSymbolBrush(brush) - self.setSymbolSize(self.config.symbol_size) - self.setSymbol(self.config.symbol) - - def set_data(self, x, y): - if self.config.source == "custom": - self.setData(x, y) - else: - raise ValueError(f"Source {self.config.source} do not allow custom data setting.") - - def set(self, **kwargs): - """ - Set the properties of the curve. - - Args: - **kwargs: Keyword arguments for the properties to be set. - - Possible properties: - - color: str - - symbol: str - - symbol_color: str - - symbol_size: int - - pen_width: int - - pen_style: Literal["solid", "dash", "dot", "dashdot"] - """ - - # Mapping of keywords to setter methods - method_map = { - "color": self.set_color, - "colormap": self.set_colormap, - "symbol": self.set_symbol, - "symbol_color": self.set_symbol_color, - "symbol_size": self.set_symbol_size, - "pen_width": self.set_pen_width, - "pen_style": self.set_pen_style, - } - for key, value in kwargs.items(): - if key in method_map: - method_map[key](value) - else: - print(f"Warning: '{key}' is not a recognized property.") - - def set_color(self, color: str, symbol_color: Optional[str] = None): - """ - Change the color of the curve. - - Args: - color(str): Color of the curve. - symbol_color(str, optional): Color of the symbol. Defaults to None. - """ - self.config.color = color - self.config.symbol_color = symbol_color or color - self.apply_config() - - def set_symbol(self, symbol: str): - """ - Change the symbol of the curve. - - Args: - symbol(str): Symbol of the curve. - """ - self.config.symbol = symbol - self.apply_config() - - def set_symbol_color(self, symbol_color: str): - """ - Change the symbol color of the curve. - - Args: - symbol_color(str): Color of the symbol. - """ - self.config.symbol_color = symbol_color - self.apply_config() - - def set_symbol_size(self, symbol_size: int): - """ - Change the symbol size of the curve. - - Args: - symbol_size(int): Size of the symbol. - """ - self.config.symbol_size = symbol_size - self.apply_config() - - def set_pen_width(self, pen_width: int): - """ - Change the pen width of the curve. - - Args: - pen_width(int): Width of the pen. - """ - self.config.pen_width = pen_width - self.apply_config() - - def set_pen_style(self, pen_style: Literal["solid", "dash", "dot", "dashdot"]): - """ - Change the pen style of the curve. - - Args: - pen_style(Literal["solid", "dash", "dot", "dashdot"]): Style of the pen. - """ - self.config.pen_style = pen_style - self.apply_config() - - def set_colormap(self, colormap: str): - """ - Set the colormap for the scatter plot z gradient. - - Args: - colormap(str): Colormap for the scatter plot. - """ - self.config.colormap = colormap - - def get_data(self) -> tuple[np.ndarray, np.ndarray]: - """ - Get the data of the curve. - Returns: - tuple[np.ndarray,np.ndarray]: X and Y data of the curve. - """ - x_data, y_data = self.getData() - return x_data, y_data - - def remove(self): - """Remove the curve from the plot.""" - self.parent_item.removeItem(self) - self.cleanup() - - class BECWaveform(BECPlotBase): USER_ACCESS = [ "rpc_id", diff --git a/bec_widgets/widgets/figure/plots/waveform/waveform_curve.py b/bec_widgets/widgets/figure/plots/waveform/waveform_curve.py new file mode 100644 index 00000000..59a89b76 --- /dev/null +++ b/bec_widgets/widgets/figure/plots/waveform/waveform_curve.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +from typing import Any, Literal, Optional + +import pyqtgraph as pg +from pydantic import BaseModel, Field +from qtpy import QtCore + +from bec_widgets.utils import BECConnector, ConnectionConfig + + +class SignalData(BaseModel): + """The data configuration of a signal in the 1D waveform widget for x and y axis.""" + + name: str + entry: str + unit: Optional[str] = None # todo implement later + modifier: Optional[str] = None # todo implement later + limits: Optional[list[float]] = None # todo implement later + + +class Signal(BaseModel): + """The configuration of a signal in the 1D waveform widget.""" + + source: str + x: SignalData # TODO maybe add metadata for config gui later + y: SignalData + z: Optional[SignalData] = None + + +class CurveConfig(ConnectionConfig): + parent_id: Optional[str] = Field(None, description="The parent plot of the curve.") + label: Optional[str] = Field(None, description="The label of the curve.") + color: Optional[Any] = Field(None, description="The color of the curve.") + symbol: Optional[str] = Field("o", description="The symbol of the curve.") + symbol_color: Optional[str] = Field(None, description="The color of the symbol of the curve.") + symbol_size: Optional[int] = Field(5, description="The size of the symbol of the curve.") + pen_width: Optional[int] = Field(2, description="The width of the pen of the curve.") + pen_style: Optional[Literal["solid", "dash", "dot", "dashdot"]] = Field( + "solid", description="The style of the pen of the curve." + ) + source: Optional[str] = Field(None, description="The source of the curve.") + signals: Optional[Signal] = Field(None, description="The signal of the curve.") + colormap: Optional[str] = Field("plasma", description="The colormap of the curves z gradient.") + + +class BECCurve(BECConnector, pg.PlotDataItem): + USER_ACCESS = [ + "remove", + "rpc_id", + "config_dict", + "set", + "set_data", + "set_color", + "set_colormap", + "set_symbol", + "set_symbol_color", + "set_symbol_size", + "set_pen_width", + "set_pen_style", + "get_data", + ] + + def __init__( + self, + name: Optional[str] = None, + config: Optional[CurveConfig] = None, + gui_id: Optional[str] = None, + parent_item: Optional[pg.PlotItem] = None, + **kwargs, + ): + if config is None: + config = CurveConfig(label=name, widget_class=self.__class__.__name__) + self.config = config + else: + self.config = config + # config.widget_class = self.__class__.__name__ + super().__init__(config=config, gui_id=gui_id) + pg.PlotDataItem.__init__(self, name=name) + + self.parent_item = parent_item + self.apply_config() + if kwargs: + self.set(**kwargs) + + def apply_config(self): + pen_style_map = { + "solid": QtCore.Qt.SolidLine, + "dash": QtCore.Qt.DashLine, + "dot": QtCore.Qt.DotLine, + "dashdot": QtCore.Qt.DashDotLine, + } + pen_style = pen_style_map.get(self.config.pen_style, QtCore.Qt.SolidLine) + + pen = pg.mkPen(color=self.config.color, width=self.config.pen_width, style=pen_style) + self.setPen(pen) + + if self.config.symbol: + symbol_color = self.config.symbol_color or self.config.color + brush = pg.mkBrush(color=symbol_color) + + self.setSymbolBrush(brush) + self.setSymbolSize(self.config.symbol_size) + self.setSymbol(self.config.symbol) + + def set_data(self, x, y): + if self.config.source == "custom": + self.setData(x, y) + else: + raise ValueError(f"Source {self.config.source} do not allow custom data setting.") + + def set(self, **kwargs): + """ + Set the properties of the curve. + + Args: + **kwargs: Keyword arguments for the properties to be set. + + Possible properties: + - color: str + - symbol: str + - symbol_color: str + - symbol_size: int + - pen_width: int + - pen_style: Literal["solid", "dash", "dot", "dashdot"] + """ + + # Mapping of keywords to setter methods + method_map = { + "color": self.set_color, + "colormap": self.set_colormap, + "symbol": self.set_symbol, + "symbol_color": self.set_symbol_color, + "symbol_size": self.set_symbol_size, + "pen_width": self.set_pen_width, + "pen_style": self.set_pen_style, + } + for key, value in kwargs.items(): + if key in method_map: + method_map[key](value) + else: + print(f"Warning: '{key}' is not a recognized property.") + + def set_color(self, color: str, symbol_color: Optional[str] = None): + """ + Change the color of the curve. + + Args: + color(str): Color of the curve. + symbol_color(str, optional): Color of the symbol. Defaults to None. + """ + self.config.color = color + self.config.symbol_color = symbol_color or color + self.apply_config() + + def set_symbol(self, symbol: str): + """ + Change the symbol of the curve. + + Args: + symbol(str): Symbol of the curve. + """ + self.config.symbol = symbol + self.apply_config() + + def set_symbol_color(self, symbol_color: str): + """ + Change the symbol color of the curve. + + Args: + symbol_color(str): Color of the symbol. + """ + self.config.symbol_color = symbol_color + self.apply_config() + + def set_symbol_size(self, symbol_size: int): + """ + Change the symbol size of the curve. + + Args: + symbol_size(int): Size of the symbol. + """ + self.config.symbol_size = symbol_size + self.apply_config() + + def set_pen_width(self, pen_width: int): + """ + Change the pen width of the curve. + + Args: + pen_width(int): Width of the pen. + """ + self.config.pen_width = pen_width + self.apply_config() + + def set_pen_style(self, pen_style: Literal["solid", "dash", "dot", "dashdot"]): + """ + Change the pen style of the curve. + + Args: + pen_style(Literal["solid", "dash", "dot", "dashdot"]): Style of the pen. + """ + self.config.pen_style = pen_style + self.apply_config() + + def set_colormap(self, colormap: str): + """ + Set the colormap for the scatter plot z gradient. + + Args: + colormap(str): Colormap for the scatter plot. + """ + self.config.colormap = colormap + + def get_data(self) -> tuple[np.ndarray, np.ndarray]: + """ + Get the data of the curve. + Returns: + tuple[np.ndarray,np.ndarray]: X and Y data of the curve. + """ + x_data, y_data = self.getData() + return x_data, y_data + + def remove(self): + """Remove the curve from the plot.""" + self.parent_item.removeItem(self) + self.cleanup() diff --git a/bec_widgets/widgets/motor_control/__init__.py b/bec_widgets/widgets/motor_control/__init__.py index ce52ec53..e69de29b 100644 --- a/bec_widgets/widgets/motor_control/__init__.py +++ b/bec_widgets/widgets/motor_control/__init__.py @@ -1,7 +0,0 @@ -from .motor_control import ( - MotorControlAbsolute, - MotorControlRelative, - MotorControlSelection, - MotorCoordinateTable, - MotorThread, -) diff --git a/bec_widgets/widgets/motor_control/motor_control.py b/bec_widgets/widgets/motor_control/motor_control.py index 309f1075..45aa334a 100644 --- a/bec_widgets/widgets/motor_control/motor_control.py +++ b/bec_widgets/widgets/motor_control/motor_control.py @@ -1,26 +1,12 @@ # pylint: disable = no-name-in-module,missing-module-docstring -import os from enum import Enum from bec_lib.alarm_handler import AlarmBase from bec_lib.device import Positioner -from qtpy import uic -from qtpy.QtCore import Qt, QThread +from qtpy.QtCore import QThread from qtpy.QtCore import Signal as pyqtSignal from qtpy.QtCore import Slot as pyqtSlot -from qtpy.QtGui import QDoubleValidator, QKeySequence -from qtpy.QtWidgets import ( - QCheckBox, - QComboBox, - QDoubleSpinBox, - QLineEdit, - QMessageBox, - QPushButton, - QShortcut, - QTableWidget, - QTableWidgetItem, - QWidget, -) +from qtpy.QtWidgets import QMessageBox, QWidget from bec_widgets.utils.bec_dispatcher import BECDispatcher @@ -77,938 +63,6 @@ class MotorControlWidget(QWidget): self._init_ui() -class MotorControlSelection(MotorControlWidget): - """ - Widget for selecting the motors to control. - - Signals: - selected_motors_signal (pyqtSignal(str,str)): Signal to emit the selected motors. - Slots: - get_available_motors (pyqtSlot): Slot to populate the available motors in the combo boxes and set the index based on the configuration. - enable_motor_controls (pyqtSlot(bool)): Slot to enable/disable the motor controls GUI. - on_config_update (pyqtSlot(dict)): Slot to update the config dict. - """ - - selected_motors_signal = pyqtSignal(str, str) - - def _load_ui(self): - """Load the UI from the .ui file.""" - current_path = os.path.dirname(__file__) - uic.loadUi(os.path.join(current_path, "motor_control_selection.ui"), self) - - def _init_ui(self): - """Initialize the UI.""" - # Lock GUI while motors are moving - self.motor_thread.lock_gui.connect(self.enable_motor_controls) - - self.pushButton_connecMotors.clicked.connect(self.select_motor) - self.get_available_motors() - - # Connect change signals to change color - self.comboBox_motor_x.currentIndexChanged.connect( - lambda: self.set_combobox_style(self.comboBox_motor_x, "#ffa700") - ) - self.comboBox_motor_y.currentIndexChanged.connect( - lambda: self.set_combobox_style(self.comboBox_motor_y, "#ffa700") - ) - - @pyqtSlot(dict) - def on_config_update(self, config: dict) -> None: - """ - Update config dict - Args: - config(dict): New config dict - """ - self.config = config - - # Get motor names - self.motor_x, self.motor_y = ( - self.config["motor_control"]["motor_x"], - self.config["motor_control"]["motor_y"], - ) - - self._init_ui() - - @pyqtSlot(bool) - def enable_motor_controls(self, enable: bool) -> None: - """ - Enable or disable the motor controls. - Args: - enable(bool): True to enable, False to disable. - """ - self.motorSelection.setEnabled(enable) - - @pyqtSlot() - def get_available_motors(self) -> None: - """ - Slot to populate the available motors in the combo boxes and set the index based on the configuration. - """ - # Get all available motors - self.motor_list = self.motor_thread.get_all_motors_names() - - # Populate the combo boxes - self.comboBox_motor_x.addItems(self.motor_list) - self.comboBox_motor_y.addItems(self.motor_list) - - # Set the index based on the config if provided - if self.config: - index_x = self.comboBox_motor_x.findText(self.motor_x) - index_y = self.comboBox_motor_y.findText(self.motor_y) - self.comboBox_motor_x.setCurrentIndex(index_x if index_x != -1 else 0) - self.comboBox_motor_y.setCurrentIndex(index_y if index_y != -1 else 0) - - def set_combobox_style(self, combobox: QComboBox, color: str) -> None: - """ - Set the combobox style to a specific color. - Args: - combobox(QComboBox): Combobox to change the color. - color(str): Color to set the combobox to. - """ - combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}") - - def select_motor(self): - """Emit the selected motors""" - motor_x = self.comboBox_motor_x.currentText() - motor_y = self.comboBox_motor_y.currentText() - - # Reset the combobox color to normal after selection - self.set_combobox_style(self.comboBox_motor_x, "") - self.set_combobox_style(self.comboBox_motor_y, "") - - self.selected_motors_signal.emit(motor_x, motor_y) - - -class MotorControlAbsolute(MotorControlWidget): - """ - Widget for controlling the motors to absolute coordinates. - - Signals: - coordinates_signal (pyqtSignal(tuple)): Signal to emit the coordinates. - Slots: - change_motors (pyqtSlot): Slot to change the active motors. - enable_motor_controls (pyqtSlot(bool)): Slot to enable/disable the motor controls. - """ - - coordinates_signal = pyqtSignal(tuple) - - def _load_ui(self): - """Load the UI from the .ui file.""" - current_path = os.path.dirname(__file__) - uic.loadUi(os.path.join(current_path, "motor_control_absolute.ui"), self) - - def _init_ui(self): - """Initialize the UI.""" - - # Check if there are any motors connected - if self.motor_x is None or self.motor_y is None: - self.motorControl_absolute.setEnabled(False) - return - - # Move to absolute coordinates - self.pushButton_go_absolute.clicked.connect( - lambda: self.move_motor_absolute( - self.spinBox_absolute_x.value(), self.spinBox_absolute_y.value() - ) - ) - - self.pushButton_set.clicked.connect(self.save_absolute_coordinates) - self.pushButton_save.clicked.connect(self.save_current_coordinates) - self.pushButton_stop.clicked.connect(self.motor_thread.stop_movement) - - # Enable/Disable GUI - self.motor_thread.lock_gui.connect(self.enable_motor_controls) - - # Error messages - self.motor_thread.motor_error.connect( - lambda error: MotorControlErrors.display_error_message(error) - ) - - # Keyboard shortcuts - self._init_keyboard_shortcuts() - - @pyqtSlot(dict) - def on_config_update(self, config: dict) -> None: - """Update config dict""" - self.config = config - - # Get motor names - self.motor_x, self.motor_y = ( - self.config["motor_control"]["motor_x"], - self.config["motor_control"]["motor_y"], - ) - - # Update step precision - self.precision = self.config["motor_control"]["precision"] - - self._init_ui() - - @pyqtSlot(bool) - def enable_motor_controls(self, enable: bool) -> None: - """ - Enable or disable the motor controls. - Args: - enable(bool): True to enable, False to disable. - """ - - # Disable or enable all controls within the motorControl_absolute group box - for widget in self.motorControl_absolute.findChildren(QWidget): - widget.setEnabled(enable) - - # Enable the pushButton_stop if the motor is moving - self.pushButton_stop.setEnabled(True) - - @pyqtSlot(str, str) - def change_motors(self, motor_x: str, motor_y: str): - """ - Change the active motors and update config. - Can be connected to the selected_motors_signal from MotorControlSelection. - Args: - motor_x(str): New motor X to be controlled. - motor_y(str): New motor Y to be controlled. - """ - self.motor_x = motor_x - self.motor_y = motor_y - self.config["motor_control"]["motor_x"] = motor_x - self.config["motor_control"]["motor_y"] = motor_y - - @pyqtSlot(int) - def set_precision(self, precision: int) -> None: - """ - Set the precision of the coordinates. - Args: - precision(int): Precision of the coordinates. - """ - self.precision = precision - self.config["motor_control"]["precision"] = precision - self.spinBox_absolute_x.setDecimals(precision) - self.spinBox_absolute_y.setDecimals(precision) - - def move_motor_absolute(self, x: float, y: float) -> None: - """ - Move the motor to the target coordinates. - Args: - x(float): Target x coordinate. - y(float): Target y coordinate. - """ - # self._enable_motor_controls(False) - target_coordinates = (x, y) - self.motor_thread.move_absolute(self.motor_x, self.motor_y, target_coordinates) - if self.checkBox_save_with_go.isChecked(): - self.save_absolute_coordinates() - - def _init_keyboard_shortcuts(self): - """Initialize the keyboard shortcuts.""" - # Go absolute button - self.pushButton_go_absolute.setShortcut("Ctrl+G") - self.pushButton_go_absolute.setToolTip("Ctrl+G") - - # Set absolute coordinates - self.pushButton_set.setShortcut("Ctrl+D") - self.pushButton_set.setToolTip("Ctrl+D") - - # Save Current coordinates - self.pushButton_save.setShortcut("Ctrl+S") - self.pushButton_save.setToolTip("Ctrl+S") - - # Stop Button - self.pushButton_stop.setShortcut("Ctrl+X") - self.pushButton_stop.setToolTip("Ctrl+X") - - def save_absolute_coordinates(self): - """Emit the setup coordinates from the spinboxes""" - - x, y = round(self.spinBox_absolute_x.value(), self.precision), round( - self.spinBox_absolute_y.value(), self.precision - ) - self.coordinates_signal.emit((x, y)) - - def save_current_coordinates(self): - """Emit the current coordinates from the motor thread""" - x, y = self.motor_thread.get_coordinates(self.motor_x, self.motor_y) - self.coordinates_signal.emit((round(x, self.precision), round(y, self.precision))) - - -class MotorControlRelative(MotorControlWidget): - """ - Widget for controlling the motors to relative coordinates. - - Signals: - precision_signal (pyqtSignal): Signal to emit the precision of the coordinates. - Slots: - change_motors (pyqtSlot(str,str)): Slot to change the active motors. - enable_motor_controls (pyqtSlot): Slot to enable/disable the motor controls. - """ - - precision_signal = pyqtSignal(int) - - def _load_ui(self): - """Load the UI from the .ui file.""" - # Loading UI - current_path = os.path.dirname(__file__) - uic.loadUi(os.path.join(current_path, "motor_control_relative.ui"), self) - - def _init_ui(self): - """Initialize the UI.""" - self._init_ui_motor_control() - self._init_keyboard_shortcuts() - - @pyqtSlot(dict) - def on_config_update(self, config: dict) -> None: - """ - Update config dict - Args: - config(dict): New config dict - """ - self.config = config - - # Get motor names - self.motor_x, self.motor_y = ( - self.config["motor_control"]["motor_x"], - self.config["motor_control"]["motor_y"], - ) - - # Update step precision - self.precision = self.config["motor_control"]["precision"] - self.spinBox_precision.setValue(self.precision) - - # Update step sizes - self.spinBox_step_x.setValue(self.config["motor_control"]["step_size_x"]) - self.spinBox_step_y.setValue(self.config["motor_control"]["step_size_y"]) - - # Checkboxes for keyboard shortcuts and x/y step size link - self.checkBox_same_xy.setChecked(self.config["motor_control"]["step_x_y_same"]) - self.checkBox_enableArrows.setChecked(self.config["motor_control"]["move_with_arrows"]) - - self._init_ui() - - def _init_ui_motor_control(self) -> None: - """Initialize the motor control elements""" - - # Connect checkbox and spinBoxes - self.checkBox_same_xy.stateChanged.connect(self._sync_step_sizes) - self.spinBox_step_x.valueChanged.connect(self._update_step_size_x) - self.spinBox_step_y.valueChanged.connect(self._update_step_size_y) - - self.toolButton_right.clicked.connect( - lambda: self.move_motor_relative(self.motor_x, "x", 1) - ) - self.toolButton_left.clicked.connect( - lambda: self.move_motor_relative(self.motor_x, "x", -1) - ) - self.toolButton_up.clicked.connect(lambda: self.move_motor_relative(self.motor_y, "y", 1)) - self.toolButton_down.clicked.connect( - lambda: self.move_motor_relative(self.motor_y, "y", -1) - ) - - # Switch between key shortcuts active - self.checkBox_enableArrows.stateChanged.connect(self._update_arrow_key_shortcuts) - self._update_arrow_key_shortcuts() - - # Enable/Disable GUI - self.motor_thread.lock_gui.connect(self.enable_motor_controls) - - # Precision update - self.spinBox_precision.valueChanged.connect(lambda x: self._update_precision(x)) - - # Error messages - self.motor_thread.motor_error.connect( - lambda error: MotorControlErrors.display_error_message(error) - ) - - # Stop Button - self.pushButton_stop.clicked.connect(self.motor_thread.stop_movement) - - def _init_keyboard_shortcuts(self) -> None: - """Initialize the keyboard shortcuts""" - - # Increase/decrease step size for X motor - increase_x_shortcut = QShortcut(QKeySequence("Ctrl+A"), self) - decrease_x_shortcut = QShortcut(QKeySequence("Ctrl+Z"), self) - increase_x_shortcut.activated.connect( - lambda: self._change_step_size(self.spinBox_step_x, 2) - ) - decrease_x_shortcut.activated.connect( - lambda: self._change_step_size(self.spinBox_step_x, 0.5) - ) - self.spinBox_step_x.setToolTip("Increase step size: Ctrl+A\nDecrease step size: Ctrl+Z") - - # Increase/decrease step size for Y motor - increase_y_shortcut = QShortcut(QKeySequence("Alt+A"), self) - decrease_y_shortcut = QShortcut(QKeySequence("Alt+Z"), self) - increase_y_shortcut.activated.connect( - lambda: self._change_step_size(self.spinBox_step_y, 2) - ) - decrease_y_shortcut.activated.connect( - lambda: self._change_step_size(self.spinBox_step_y, 0.5) - ) - self.spinBox_step_y.setToolTip("Increase step size: Alt+A\nDecrease step size: Alt+Z") - - # Stop Button - self.pushButton_stop.setShortcut("Ctrl+X") - self.pushButton_stop.setToolTip("Ctrl+X") - - def _update_arrow_key_shortcuts(self) -> None: - """Update the arrow key shortcuts based on the checkbox state.""" - if self.checkBox_enableArrows.isChecked(): - # Set the arrow key shortcuts for motor movement - self.toolButton_right.setShortcut(Qt.Key_Right) - self.toolButton_left.setShortcut(Qt.Key_Left) - self.toolButton_up.setShortcut(Qt.Key_Up) - self.toolButton_down.setShortcut(Qt.Key_Down) - else: - # Clear the shortcuts - self.toolButton_right.setShortcut("") - self.toolButton_left.setShortcut("") - self.toolButton_up.setShortcut("") - self.toolButton_down.setShortcut("") - - def _update_precision(self, precision: int) -> None: - """ - Update the precision of the coordinates. - Args: - precision(int): Precision of the coordinates. - """ - self.spinBox_step_x.setDecimals(precision) - self.spinBox_step_y.setDecimals(precision) - self.precision_signal.emit(precision) - - def _change_step_size(self, spinBox: QDoubleSpinBox, factor: float) -> None: - """ - Change the step size of the spinbox. - Args: - spinBox(QDoubleSpinBox): Spinbox to change the step size. - factor(float): Factor to change the step size. - """ - old_step = spinBox.value() - new_step = old_step * factor - spinBox.setValue(new_step) - - def _sync_step_sizes(self): - """Sync step sizes based on checkbox state.""" - if self.checkBox_same_xy.isChecked(): - value = self.spinBox_step_x.value() - self.spinBox_step_y.setValue(value) - - def _update_step_size_x(self): - """Update step size for x if checkbox is checked.""" - if self.checkBox_same_xy.isChecked(): - value = self.spinBox_step_x.value() - self.spinBox_step_y.setValue(value) - - def _update_step_size_y(self): - """Update step size for y if checkbox is checked.""" - if self.checkBox_same_xy.isChecked(): - value = self.spinBox_step_y.value() - self.spinBox_step_x.setValue(value) - - @pyqtSlot(str, str) - def change_motors(self, motor_x: str, motor_y: str): - """ - Change the active motors and update config. - Can be connected to the selected_motors_signal from MotorControlSelection. - Args: - motor_x(str): New motor X to be controlled. - motor_y(str): New motor Y to be controlled. - """ - self.motor_x = motor_x - self.motor_y = motor_y - self.config["motor_control"]["motor_x"] = motor_x - self.config["motor_control"]["motor_y"] = motor_y - - @pyqtSlot(bool) - def enable_motor_controls(self, disable: bool) -> None: - """ - Enable or disable the motor controls. - Args: - disable(bool): True to disable, False to enable. - """ - - # Disable or enable all controls within the motorControl_absolute group box - for widget in self.motorControl.findChildren(QWidget): - widget.setEnabled(disable) - - # Enable the pushButton_stop if the motor is moving - self.pushButton_stop.setEnabled(True) - - def move_motor_relative(self, motor, axis: str, direction: int) -> None: - """ - Move the motor relative to the current position. - Args: - motor: Motor to move. - axis(str): Axis to move. - direction(int): Direction to move. 1 for positive, -1 for negative. - """ - if axis == "x": - step = direction * self.spinBox_step_x.value() - elif axis == "y": - step = direction * self.spinBox_step_y.value() - self.motor_thread.move_relative(motor, step) - - -class MotorCoordinateTable(MotorControlWidget): - """ - Widget to save coordinates from motor, display them in the table and move back to them. - There are two modes of operation: - - Individual: Each row is a single coordinate. - - Start/Stop: Each pair of rows is a start and end coordinate. - Signals: - plot_coordinates_signal (pyqtSignal(list, str, str)): Signal to plot the coordinates in the MotorMap. - Slots: - add_coordinate (pyqtSlot(tuple)): Slot to add a coordinate to the table. - mode_switch (pyqtSlot): Slot to switch between individual and start/stop mode. - """ - - plot_coordinates_signal = pyqtSignal(list, str, str) - - def _load_ui(self): - """Load the UI for the coordinate table.""" - current_path = os.path.dirname(__file__) - uic.loadUi(os.path.join(current_path, "motor_control_table.ui"), self) - - def _init_ui(self): - """Initialize the UI""" - # Setup table behaviour - self._setup_table() - self.table.setSelectionBehavior(QTableWidget.SelectRows) - - # for tag columns default tag - self.tag_counter = 1 - - # Connect signals and slots - self.checkBox_resize_auto.stateChanged.connect(self.resize_table_auto) - self.comboBox_mode.currentIndexChanged.connect(self.mode_switch) - - # Keyboard shortcuts for deleting a row - self.delete_shortcut = QShortcut(QKeySequence(Qt.Key_Delete), self.table) - self.delete_shortcut.activated.connect(self.delete_selected_row) - self.backspace_shortcut = QShortcut(QKeySequence(Qt.Key_Backspace), self.table) - self.backspace_shortcut.activated.connect(self.delete_selected_row) - - # Warning message for mode switch enable/disable - self.warning_message = True - - @pyqtSlot(dict) - def on_config_update(self, config: dict) -> None: - """ - Update config dict - Args: - config(dict): New config dict - """ - self.config = config - - # Get motor names - self.motor_x, self.motor_y = ( - self.config["motor_control"]["motor_x"], - self.config["motor_control"]["motor_y"], - ) - - # Decimal precision of the table coordinates - self.precision = self.config["motor_control"].get("precision", 3) - - # Mode switch default option - self.mode = self.config["motor_control"].get("mode", "Individual") - - # Set combobox to default mode - self.comboBox_mode.setCurrentText(self.mode) - - self._init_ui() - - def _setup_table(self): - """Setup the table with appropriate headers and configurations.""" - mode = self.comboBox_mode.currentText() - - if mode == "Individual": - self._setup_individual_mode() - elif mode == "Start/Stop": - self._setup_start_stop_mode() - self.start_stop_counter = 0 # TODO: remove this?? - - self.wipe_motor_map_coordinates() - - def _setup_individual_mode(self): - """Setup the table for individual mode.""" - self.table.setColumnCount(5) - self.table.setHorizontalHeaderLabels(["Show", "Move", "Tag", "X", "Y"]) - self.table.verticalHeader().setVisible(False) - - def _setup_start_stop_mode(self): - """Setup the table for start/stop mode.""" - self.table.setColumnCount(8) - self.table.setHorizontalHeaderLabels( - [ - "Show", - "Move [start]", - "Move [end]", - "Tag", - "X [start]", - "Y [start]", - "X [end]", - "Y [end]", - ] - ) - self.table.verticalHeader().setVisible(False) - # Set flag to track if the coordinate is stat or the end of the entry - self.is_next_entry_end = False - - def mode_switch(self): - """Switch between individual and start/stop mode.""" - last_selected_index = self.comboBox_mode.currentIndex() - - if self.table.rowCount() > 0 and self.warning_message is True: - msgBox = QMessageBox() - msgBox.setIcon(QMessageBox.Critical) - msgBox.setText( - "Switching modes will delete all table entries. Do you want to continue?" - ) - msgBox.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) - returnValue = msgBox.exec() - - if returnValue is QMessageBox.Cancel: - self.comboBox_mode.blockSignals(True) # Block signals - self.comboBox_mode.setCurrentIndex(last_selected_index) - self.comboBox_mode.blockSignals(False) # Unblock signals - return - - # Wipe table - self.wipe_motor_map_coordinates() - - # Initiate new table with new mode - self._setup_table() - - @pyqtSlot(tuple) - def add_coordinate(self, coordinates: tuple): - """ - Add a coordinate to the table. - Args: - coordinates(tuple): Coordinates (x,y) to add to the table. - """ - tag = f"Pos {self.tag_counter}" - self.tag_counter += 1 - x, y = coordinates - self._add_row(tag, x, y) - - def _add_row(self, tag: str, x: float, y: float) -> None: - """ - Add a row to the table. - Args: - tag(str): Tag of the coordinate. - x(float): X coordinate. - y(float): Y coordinate. - """ - - mode = self.comboBox_mode.currentText() - if mode == "Individual": - checkbox_pos = 0 - button_pos = 1 - tag_pos = 2 - x_pos = 3 - y_pos = 4 - coordinate_reference = "Individual" - color = "green" - - # Add new row -> new entry - row_count = self.table.rowCount() - self.table.insertRow(row_count) - - # Add Widgets - self._add_widgets( - tag, - x, - y, - row_count, - checkbox_pos, - tag_pos, - button_pos, - x_pos, - y_pos, - coordinate_reference, - color, - ) - - if mode == "Start/Stop": - # These positions are always fixed - checkbox_pos = 0 - tag_pos = 3 - - if self.is_next_entry_end is False: # It is the start position of the entry - print("Start position") - button_pos = 1 - x_pos = 4 - y_pos = 5 - coordinate_reference = "Start" - color = "blue" - - # Add new row -> new entry - row_count = self.table.rowCount() - self.table.insertRow(row_count) - - # Add Widgets - self._add_widgets( - tag, - x, - y, - row_count, - checkbox_pos, - tag_pos, - button_pos, - x_pos, - y_pos, - coordinate_reference, - color, - ) - - # Next entry will be the end of the current entry - self.is_next_entry_end = True - - elif self.is_next_entry_end is True: # It is the end position of the entry - print("End position") - row_count = self.table.rowCount() - 1 # Current row - button_pos = 2 - x_pos = 6 - y_pos = 7 - coordinate_reference = "Stop" - color = "red" - - # Add Widgets - self._add_widgets( - tag, - x, - y, - row_count, - checkbox_pos, - tag_pos, - button_pos, - x_pos, - y_pos, - coordinate_reference, - color, - ) - self.is_next_entry_end = False # Next entry will be the start of the new entry - - # Auto table resize - self.resize_table_auto() - - def _add_widgets( - self, - tag: str, - x: float, - y: float, - row: int, - checkBox_pos: int, - tag_pos: int, - button_pos: int, - x_pos: int, - y_pos: int, - coordinate_reference: str, - color: str, - ) -> None: - """ - Add widgets to the table. - Args: - tag(str): Tag of the coordinate. - x(float): X coordinate. - y(float): Y coordinate. - row(int): Row of the QTableWidget where to add the widgets. - checkBox_pos(int): Column where to put CheckBox. - tag_pos(int): Column where to put Tag. - button_pos(int): Column where to put Move button. - x_pos(int): Column where to link x coordinate. - y_pos(int): Column where to link y coordinate. - coordinate_reference(str): Reference to the coordinate for MotorMap. - color(str): Color of the coordinate for MotorMap. - """ - # Add widgets - self._add_checkbox(row, checkBox_pos, x_pos, y_pos) - self._add_move_button(row, button_pos, x_pos, y_pos) - self.table.setItem(row, tag_pos, QTableWidgetItem(tag)) - self._add_line_edit(x, row, x_pos, x_pos, y_pos, coordinate_reference, color) - self._add_line_edit(y, row, y_pos, x_pos, y_pos, coordinate_reference, color) - - # # Emit the coordinates to be plotted - self.emit_plot_coordinates(x_pos, y_pos, coordinate_reference, color) - - # Connect item edit to emit coordinates - self.table.itemChanged.connect( - lambda: print(f"item changed from {coordinate_reference} slot \n {x}-{y}-{color}") - ) - self.table.itemChanged.connect( - lambda: self.emit_plot_coordinates(x_pos, y_pos, coordinate_reference, color) - ) - - def _add_checkbox(self, row: int, checkBox_pos: int, x_pos: int, y_pos: int): - """ - Add a checkbox to the table. - Args: - row(int): Row of QTableWidget where to add the checkbox. - checkBox_pos(int): Column where to put CheckBox. - x_pos(int): Column where to link x coordinate. - y_pos(int): Column where to link y coordinate. - """ - show_checkbox = QCheckBox() - show_checkbox.setChecked(True) - show_checkbox.stateChanged.connect(lambda: self.emit_plot_coordinates(x_pos, y_pos)) - self.table.setCellWidget(row, checkBox_pos, show_checkbox) - - def _add_move_button(self, row: int, button_pos: int, x_pos: int, y_pos: int) -> None: - """ - Add a move button to the table. - Args: - row(int): Row of QTableWidget where to add the move button. - button_pos(int): Column where to put move button. - x_pos(int): Column where to link x coordinate. - y_pos(int): Column where to link y coordinate. - """ - move_button = QPushButton("Move") - move_button.clicked.connect(lambda: self.handle_move_button_click(x_pos, y_pos)) - self.table.setCellWidget(row, button_pos, move_button) - - def _add_line_edit( - self, - value: float, - row: int, - line_pos: int, - x_pos: int, - y_pos: int, - coordinate_reference: str, - color: str, - ) -> None: - """ - Add a QLineEdit to the table. - Args: - value(float): Initial value of the QLineEdit. - row(int): Row of QTableWidget where to add the QLineEdit. - line_pos(int): Column where to put QLineEdit. - x_pos(int): Column where to link x coordinate. - y_pos(int): Column where to link y coordinate. - coordinate_reference(str): Reference to the coordinate for MotorMap. - color(str): Color of the coordinate for MotorMap. - """ - # Adding validator - validator = QDoubleValidator() - validator.setDecimals(self.precision) - - # Create line edit - edit = QLineEdit(str(f"{value:.{self.precision}f}")) - edit.setValidator(validator) - edit.setAlignment(Qt.AlignmentFlag.AlignCenter) - - # Add line edit to the table - self.table.setCellWidget(row, line_pos, edit) - edit.textChanged.connect( - lambda: self.emit_plot_coordinates(x_pos, y_pos, coordinate_reference, color) - ) - - def wipe_motor_map_coordinates(self): - """Wipe the motor map coordinates.""" - try: - self.table.itemChanged.disconnect() # Disconnect all previous connections - except TypeError: - print("No previous connections to disconnect") - self.table.setRowCount(0) - reference_tags = ["Individual", "Start", "Stop"] - for reference_tag in reference_tags: - self.plot_coordinates_signal.emit([], reference_tag, "green") - - def handle_move_button_click(self, x_pos: int, y_pos: int) -> None: - """ - Handle the move button click. - Args: - x_pos(int): X position of the coordinate. - y_pos(int): Y position of the coordinate. - """ - button = self.sender() - row = self.table.indexAt(button.pos()).row() - - x = self.get_coordinate(row, x_pos) - y = self.get_coordinate(row, y_pos) - self.move_motor(x, y) - - def emit_plot_coordinates(self, x_pos: float, y_pos: float, reference_tag: str, color: str): - """ - Emit the coordinates to be plotted. - Args: - x_pos(float): X position of the coordinate. - y_pos(float): Y position of the coordinate. - reference_tag(str): Reference tag of the coordinate. - color(str): Color of the coordinate. - """ - print( - f"Emitting plot coordinates: x_pos={x_pos}, y_pos={y_pos}, reference_tag={reference_tag}, color={color}" - ) - coordinates = [] - for row in range(self.table.rowCount()): - show = self.table.cellWidget(row, 0).isChecked() - x = self.get_coordinate(row, x_pos) - y = self.get_coordinate(row, y_pos) - - coordinates.append((x, y, show)) # (x, y, show_flag) - self.plot_coordinates_signal.emit(coordinates, reference_tag, color) - - def get_coordinate(self, row: int, column: int) -> float: - """ - Helper function to get the coordinate from the table QLineEdit cells. - Args: - row(int): Row of the table. - column(int): Column of the table. - Returns: - float: Value of the coordinate. - """ - edit = self.table.cellWidget(row, column) - value = float(edit.text()) if edit and edit.text() != "" else None - if value: - return value - - def delete_selected_row(self): - """Delete the selected row from the table.""" - selected_rows = self.table.selectionModel().selectedRows() - for row in selected_rows: - self.table.removeRow(row.row()) - if self.comboBox_mode.currentText() == "Start/Stop": - self.emit_plot_coordinates(x_pos=4, y_pos=5, reference_tag="Start", color="blue") - self.emit_plot_coordinates(x_pos=6, y_pos=7, reference_tag="Stop", color="red") - self.is_next_entry_end = False - elif self.comboBox_mode.currentText() == "Individual": - self.emit_plot_coordinates(x_pos=3, y_pos=4, reference_tag="Individual", color="green") - - def resize_table_auto(self): - """Resize the table to fit the contents.""" - if self.checkBox_resize_auto.isChecked(): - self.table.resizeColumnsToContents() - - def move_motor(self, x: float, y: float) -> None: - """ - Move the motor to the target coordinates. - Args: - x(float): Target x coordinate. - y(float): Target y coordinate. - """ - self.motor_thread.move_absolute(self.motor_x, self.motor_y, (x, y)) - - @pyqtSlot(str, str) - def change_motors(self, motor_x: str, motor_y: str) -> None: - """ - Change the active motors and update config. - Can be connected to the selected_motors_signal from MotorControlSelection. - Args: - motor_x(str): New motor X to be controlled. - motor_y(str): New motor Y to be controlled. - """ - self.motor_x = motor_x - self.motor_y = motor_y - self.config["motor_control"]["motor_x"] = motor_x - self.config["motor_control"]["motor_y"] = motor_y - - @pyqtSlot(int) - def set_precision(self, precision: int) -> None: - """ - Set the precision of the coordinates. - Args: - precision(int): Precision of the coordinates. - """ - self.precision = precision - self.config["motor_control"]["precision"] = precision - - class MotorControlErrors: """Class for displaying formatted error messages.""" diff --git a/bec_widgets/widgets/motor_control/motor_table/__init__.py b/bec_widgets/widgets/motor_control/motor_table/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/motor_control/motor_table/motor_table.py b/bec_widgets/widgets/motor_control/motor_table/motor_table.py new file mode 100644 index 00000000..d48b8883 --- /dev/null +++ b/bec_widgets/widgets/motor_control/motor_table/motor_table.py @@ -0,0 +1,483 @@ +# pylint: disable = no-name-in-module,missing-module-docstring +import os + +from qtpy import uic +from qtpy.QtCore import Qt +from qtpy.QtCore import Signal as pyqtSignal +from qtpy.QtCore import Slot as pyqtSlot +from qtpy.QtGui import QDoubleValidator, QKeySequence +from qtpy.QtWidgets import ( + QCheckBox, + QLineEdit, + QMessageBox, + QPushButton, + QShortcut, + QTableWidget, + QTableWidgetItem, +) + +from bec_widgets.widgets.motor_control.motor_control import MotorControlWidget + + +class MotorCoordinateTable(MotorControlWidget): + """ + Widget to save coordinates from motor, display them in the table and move back to them. + There are two modes of operation: + - Individual: Each row is a single coordinate. + - Start/Stop: Each pair of rows is a start and end coordinate. + Signals: + plot_coordinates_signal (pyqtSignal(list, str, str)): Signal to plot the coordinates in the MotorMap. + Slots: + add_coordinate (pyqtSlot(tuple)): Slot to add a coordinate to the table. + mode_switch (pyqtSlot): Slot to switch between individual and start/stop mode. + """ + + plot_coordinates_signal = pyqtSignal(list, str, str) + + def _load_ui(self): + """Load the UI for the coordinate table.""" + current_path = os.path.dirname(__file__) + uic.loadUi(os.path.join(current_path, "motor_table.ui"), self) + + def _init_ui(self): + """Initialize the UI""" + # Setup table behaviour + self._setup_table() + self.table.setSelectionBehavior(QTableWidget.SelectRows) + + # for tag columns default tag + self.tag_counter = 1 + + # Connect signals and slots + self.checkBox_resize_auto.stateChanged.connect(self.resize_table_auto) + self.comboBox_mode.currentIndexChanged.connect(self.mode_switch) + + # Keyboard shortcuts for deleting a row + self.delete_shortcut = QShortcut(QKeySequence(Qt.Key_Delete), self.table) + self.delete_shortcut.activated.connect(self.delete_selected_row) + self.backspace_shortcut = QShortcut(QKeySequence(Qt.Key_Backspace), self.table) + self.backspace_shortcut.activated.connect(self.delete_selected_row) + + # Warning message for mode switch enable/disable + self.warning_message = True + + @pyqtSlot(dict) + def on_config_update(self, config: dict) -> None: + """ + Update config dict + Args: + config(dict): New config dict + """ + self.config = config + + # Get motor names + self.motor_x, self.motor_y = ( + self.config["motor_control"]["motor_x"], + self.config["motor_control"]["motor_y"], + ) + + # Decimal precision of the table coordinates + self.precision = self.config["motor_control"].get("precision", 3) + + # Mode switch default option + self.mode = self.config["motor_control"].get("mode", "Individual") + + # Set combobox to default mode + self.comboBox_mode.setCurrentText(self.mode) + + self._init_ui() + + def _setup_table(self): + """Setup the table with appropriate headers and configurations.""" + mode = self.comboBox_mode.currentText() + + if mode == "Individual": + self._setup_individual_mode() + elif mode == "Start/Stop": + self._setup_start_stop_mode() + self.start_stop_counter = 0 # TODO: remove this?? + + self.wipe_motor_map_coordinates() + + def _setup_individual_mode(self): + """Setup the table for individual mode.""" + self.table.setColumnCount(5) + self.table.setHorizontalHeaderLabels(["Show", "Move", "Tag", "X", "Y"]) + self.table.verticalHeader().setVisible(False) + + def _setup_start_stop_mode(self): + """Setup the table for start/stop mode.""" + self.table.setColumnCount(8) + self.table.setHorizontalHeaderLabels( + [ + "Show", + "Move [start]", + "Move [end]", + "Tag", + "X [start]", + "Y [start]", + "X [end]", + "Y [end]", + ] + ) + self.table.verticalHeader().setVisible(False) + # Set flag to track if the coordinate is stat or the end of the entry + self.is_next_entry_end = False + + def mode_switch(self): + """Switch between individual and start/stop mode.""" + last_selected_index = self.comboBox_mode.currentIndex() + + if self.table.rowCount() > 0 and self.warning_message is True: + msgBox = QMessageBox() + msgBox.setIcon(QMessageBox.Critical) + msgBox.setText( + "Switching modes will delete all table entries. Do you want to continue?" + ) + msgBox.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) + returnValue = msgBox.exec() + + if returnValue is QMessageBox.Cancel: + self.comboBox_mode.blockSignals(True) # Block signals + self.comboBox_mode.setCurrentIndex(last_selected_index) + self.comboBox_mode.blockSignals(False) # Unblock signals + return + + # Wipe table + self.wipe_motor_map_coordinates() + + # Initiate new table with new mode + self._setup_table() + + @pyqtSlot(tuple) + def add_coordinate(self, coordinates: tuple): + """ + Add a coordinate to the table. + Args: + coordinates(tuple): Coordinates (x,y) to add to the table. + """ + tag = f"Pos {self.tag_counter}" + self.tag_counter += 1 + x, y = coordinates + self._add_row(tag, x, y) + + def _add_row(self, tag: str, x: float, y: float) -> None: + """ + Add a row to the table. + Args: + tag(str): Tag of the coordinate. + x(float): X coordinate. + y(float): Y coordinate. + """ + + mode = self.comboBox_mode.currentText() + if mode == "Individual": + checkbox_pos = 0 + button_pos = 1 + tag_pos = 2 + x_pos = 3 + y_pos = 4 + coordinate_reference = "Individual" + color = "green" + + # Add new row -> new entry + row_count = self.table.rowCount() + self.table.insertRow(row_count) + + # Add Widgets + self._add_widgets( + tag, + x, + y, + row_count, + checkbox_pos, + tag_pos, + button_pos, + x_pos, + y_pos, + coordinate_reference, + color, + ) + + if mode == "Start/Stop": + # These positions are always fixed + checkbox_pos = 0 + tag_pos = 3 + + if self.is_next_entry_end is False: # It is the start position of the entry + print("Start position") + button_pos = 1 + x_pos = 4 + y_pos = 5 + coordinate_reference = "Start" + color = "blue" + + # Add new row -> new entry + row_count = self.table.rowCount() + self.table.insertRow(row_count) + + # Add Widgets + self._add_widgets( + tag, + x, + y, + row_count, + checkbox_pos, + tag_pos, + button_pos, + x_pos, + y_pos, + coordinate_reference, + color, + ) + + # Next entry will be the end of the current entry + self.is_next_entry_end = True + + elif self.is_next_entry_end is True: # It is the end position of the entry + print("End position") + row_count = self.table.rowCount() - 1 # Current row + button_pos = 2 + x_pos = 6 + y_pos = 7 + coordinate_reference = "Stop" + color = "red" + + # Add Widgets + self._add_widgets( + tag, + x, + y, + row_count, + checkbox_pos, + tag_pos, + button_pos, + x_pos, + y_pos, + coordinate_reference, + color, + ) + self.is_next_entry_end = False # Next entry will be the start of the new entry + + # Auto table resize + self.resize_table_auto() + + def _add_widgets( + self, + tag: str, + x: float, + y: float, + row: int, + checkBox_pos: int, + tag_pos: int, + button_pos: int, + x_pos: int, + y_pos: int, + coordinate_reference: str, + color: str, + ) -> None: + """ + Add widgets to the table. + Args: + tag(str): Tag of the coordinate. + x(float): X coordinate. + y(float): Y coordinate. + row(int): Row of the QTableWidget where to add the widgets. + checkBox_pos(int): Column where to put CheckBox. + tag_pos(int): Column where to put Tag. + button_pos(int): Column where to put Move button. + x_pos(int): Column where to link x coordinate. + y_pos(int): Column where to link y coordinate. + coordinate_reference(str): Reference to the coordinate for MotorMap. + color(str): Color of the coordinate for MotorMap. + """ + # Add widgets + self._add_checkbox(row, checkBox_pos, x_pos, y_pos) + self._add_move_button(row, button_pos, x_pos, y_pos) + self.table.setItem(row, tag_pos, QTableWidgetItem(tag)) + self._add_line_edit(x, row, x_pos, x_pos, y_pos, coordinate_reference, color) + self._add_line_edit(y, row, y_pos, x_pos, y_pos, coordinate_reference, color) + + # # Emit the coordinates to be plotted + self.emit_plot_coordinates(x_pos, y_pos, coordinate_reference, color) + + # Connect item edit to emit coordinates + self.table.itemChanged.connect( + lambda: print(f"item changed from {coordinate_reference} slot \n {x}-{y}-{color}") + ) + self.table.itemChanged.connect( + lambda: self.emit_plot_coordinates(x_pos, y_pos, coordinate_reference, color) + ) + + def _add_checkbox(self, row: int, checkBox_pos: int, x_pos: int, y_pos: int): + """ + Add a checkbox to the table. + Args: + row(int): Row of QTableWidget where to add the checkbox. + checkBox_pos(int): Column where to put CheckBox. + x_pos(int): Column where to link x coordinate. + y_pos(int): Column where to link y coordinate. + """ + show_checkbox = QCheckBox() + show_checkbox.setChecked(True) + show_checkbox.stateChanged.connect(lambda: self.emit_plot_coordinates(x_pos, y_pos)) + self.table.setCellWidget(row, checkBox_pos, show_checkbox) + + def _add_move_button(self, row: int, button_pos: int, x_pos: int, y_pos: int) -> None: + """ + Add a move button to the table. + Args: + row(int): Row of QTableWidget where to add the move button. + button_pos(int): Column where to put move button. + x_pos(int): Column where to link x coordinate. + y_pos(int): Column where to link y coordinate. + """ + move_button = QPushButton("Move") + move_button.clicked.connect(lambda: self.handle_move_button_click(x_pos, y_pos)) + self.table.setCellWidget(row, button_pos, move_button) + + def _add_line_edit( + self, + value: float, + row: int, + line_pos: int, + x_pos: int, + y_pos: int, + coordinate_reference: str, + color: str, + ) -> None: + """ + Add a QLineEdit to the table. + Args: + value(float): Initial value of the QLineEdit. + row(int): Row of QTableWidget where to add the QLineEdit. + line_pos(int): Column where to put QLineEdit. + x_pos(int): Column where to link x coordinate. + y_pos(int): Column where to link y coordinate. + coordinate_reference(str): Reference to the coordinate for MotorMap. + color(str): Color of the coordinate for MotorMap. + """ + # Adding validator + validator = QDoubleValidator() + validator.setDecimals(self.precision) + + # Create line edit + edit = QLineEdit(str(f"{value:.{self.precision}f}")) + edit.setValidator(validator) + edit.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Add line edit to the table + self.table.setCellWidget(row, line_pos, edit) + edit.textChanged.connect( + lambda: self.emit_plot_coordinates(x_pos, y_pos, coordinate_reference, color) + ) + + def wipe_motor_map_coordinates(self): + """Wipe the motor map coordinates.""" + try: + self.table.itemChanged.disconnect() # Disconnect all previous connections + except TypeError: + print("No previous connections to disconnect") + self.table.setRowCount(0) + reference_tags = ["Individual", "Start", "Stop"] + for reference_tag in reference_tags: + self.plot_coordinates_signal.emit([], reference_tag, "green") + + def handle_move_button_click(self, x_pos: int, y_pos: int) -> None: + """ + Handle the move button click. + Args: + x_pos(int): X position of the coordinate. + y_pos(int): Y position of the coordinate. + """ + button = self.sender() + row = self.table.indexAt(button.pos()).row() + + x = self.get_coordinate(row, x_pos) + y = self.get_coordinate(row, y_pos) + self.move_motor(x, y) + + def emit_plot_coordinates(self, x_pos: float, y_pos: float, reference_tag: str, color: str): + """ + Emit the coordinates to be plotted. + Args: + x_pos(float): X position of the coordinate. + y_pos(float): Y position of the coordinate. + reference_tag(str): Reference tag of the coordinate. + color(str): Color of the coordinate. + """ + print( + f"Emitting plot coordinates: x_pos={x_pos}, y_pos={y_pos}, reference_tag={reference_tag}, color={color}" + ) + coordinates = [] + for row in range(self.table.rowCount()): + show = self.table.cellWidget(row, 0).isChecked() + x = self.get_coordinate(row, x_pos) + y = self.get_coordinate(row, y_pos) + + coordinates.append((x, y, show)) # (x, y, show_flag) + self.plot_coordinates_signal.emit(coordinates, reference_tag, color) + + def get_coordinate(self, row: int, column: int) -> float: + """ + Helper function to get the coordinate from the table QLineEdit cells. + Args: + row(int): Row of the table. + column(int): Column of the table. + Returns: + float: Value of the coordinate. + """ + edit = self.table.cellWidget(row, column) + value = float(edit.text()) if edit and edit.text() != "" else None + if value: + return value + + def delete_selected_row(self): + """Delete the selected row from the table.""" + selected_rows = self.table.selectionModel().selectedRows() + for row in selected_rows: + self.table.removeRow(row.row()) + if self.comboBox_mode.currentText() == "Start/Stop": + self.emit_plot_coordinates(x_pos=4, y_pos=5, reference_tag="Start", color="blue") + self.emit_plot_coordinates(x_pos=6, y_pos=7, reference_tag="Stop", color="red") + self.is_next_entry_end = False + elif self.comboBox_mode.currentText() == "Individual": + self.emit_plot_coordinates(x_pos=3, y_pos=4, reference_tag="Individual", color="green") + + def resize_table_auto(self): + """Resize the table to fit the contents.""" + if self.checkBox_resize_auto.isChecked(): + self.table.resizeColumnsToContents() + + def move_motor(self, x: float, y: float) -> None: + """ + Move the motor to the target coordinates. + Args: + x(float): Target x coordinate. + y(float): Target y coordinate. + """ + self.motor_thread.move_absolute(self.motor_x, self.motor_y, (x, y)) + + @pyqtSlot(str, str) + def change_motors(self, motor_x: str, motor_y: str) -> None: + """ + Change the active motors and update config. + Can be connected to the selected_motors_signal from MotorControlSelection. + Args: + motor_x(str): New motor X to be controlled. + motor_y(str): New motor Y to be controlled. + """ + self.motor_x = motor_x + self.motor_y = motor_y + self.config["motor_control"]["motor_x"] = motor_x + self.config["motor_control"]["motor_y"] = motor_y + + @pyqtSlot(int) + def set_precision(self, precision: int) -> None: + """ + Set the precision of the coordinates. + Args: + precision(int): Precision of the coordinates. + """ + self.precision = precision + self.config["motor_control"]["precision"] = precision diff --git a/bec_widgets/widgets/motor_control/motor_control_table.ui b/bec_widgets/widgets/motor_control/motor_table/motor_table.ui similarity index 100% rename from bec_widgets/widgets/motor_control/motor_control_table.ui rename to bec_widgets/widgets/motor_control/motor_table/motor_table.ui diff --git a/bec_widgets/widgets/motor_control/movement_absolute/__init__.py b/bec_widgets/widgets/motor_control/movement_absolute/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/motor_control/movement_absolute/movement_absolute.py b/bec_widgets/widgets/motor_control/movement_absolute/movement_absolute.py new file mode 100644 index 00000000..09222894 --- /dev/null +++ b/bec_widgets/widgets/motor_control/movement_absolute/movement_absolute.py @@ -0,0 +1,157 @@ +import os + +from qtpy import uic +from qtpy.QtCore import Signal as pyqtSignal +from qtpy.QtCore import Slot as pyqtSlot + +from bec_widgets.widgets.motor_control.motor_control import MotorControlWidget + + +class MotorControlAbsolute(MotorControlWidget): + """ + Widget for controlling the motors to absolute coordinates. + + Signals: + coordinates_signal (pyqtSignal(tuple)): Signal to emit the coordinates. + Slots: + change_motors (pyqtSlot): Slot to change the active motors. + enable_motor_controls (pyqtSlot(bool)): Slot to enable/disable the motor controls. + """ + + coordinates_signal = pyqtSignal(tuple) + + def _load_ui(self): + """Load the UI from the .ui file.""" + current_path = os.path.dirname(__file__) + uic.loadUi(os.path.join(current_path, "movement_absolute.ui"), self) + + def _init_ui(self): + """Initialize the UI.""" + + # Check if there are any motors connected + if self.motor_x is None or self.motor_y is None: + self.motorControl_absolute.setEnabled(False) + return + + # Move to absolute coordinates + self.pushButton_go_absolute.clicked.connect( + lambda: self.move_motor_absolute( + self.spinBox_absolute_x.value(), self.spinBox_absolute_y.value() + ) + ) + + self.pushButton_set.clicked.connect(self.save_absolute_coordinates) + self.pushButton_save.clicked.connect(self.save_current_coordinates) + self.pushButton_stop.clicked.connect(self.motor_thread.stop_movement) + + # Enable/Disable GUI + self.motor_thread.lock_gui.connect(self.enable_motor_controls) + + # Error messages + self.motor_thread.motor_error.connect( + lambda error: MotorControlErrors.display_error_message(error) + ) + + # Keyboard shortcuts + self._init_keyboard_shortcuts() + + @pyqtSlot(dict) + def on_config_update(self, config: dict) -> None: + """Update config dict""" + self.config = config + + # Get motor names + self.motor_x, self.motor_y = ( + self.config["motor_control"]["motor_x"], + self.config["motor_control"]["motor_y"], + ) + + # Update step precision + self.precision = self.config["motor_control"]["precision"] + + self._init_ui() + + @pyqtSlot(bool) + def enable_motor_controls(self, enable: bool) -> None: + """ + Enable or disable the motor controls. + Args: + enable(bool): True to enable, False to disable. + """ + + # Disable or enable all controls within the motorControl_absolute group box + for widget in self.motorControl_absolute.findChildren(QWidget): + widget.setEnabled(enable) + + # Enable the pushButton_stop if the motor is moving + self.pushButton_stop.setEnabled(True) + + @pyqtSlot(str, str) + def change_motors(self, motor_x: str, motor_y: str): + """ + Change the active motors and update config. + Can be connected to the selected_motors_signal from MotorControlSelection. + Args: + motor_x(str): New motor X to be controlled. + motor_y(str): New motor Y to be controlled. + """ + self.motor_x = motor_x + self.motor_y = motor_y + self.config["motor_control"]["motor_x"] = motor_x + self.config["motor_control"]["motor_y"] = motor_y + + @pyqtSlot(int) + def set_precision(self, precision: int) -> None: + """ + Set the precision of the coordinates. + Args: + precision(int): Precision of the coordinates. + """ + self.precision = precision + self.config["motor_control"]["precision"] = precision + self.spinBox_absolute_x.setDecimals(precision) + self.spinBox_absolute_y.setDecimals(precision) + + def move_motor_absolute(self, x: float, y: float) -> None: + """ + Move the motor to the target coordinates. + Args: + x(float): Target x coordinate. + y(float): Target y coordinate. + """ + # self._enable_motor_controls(False) + target_coordinates = (x, y) + self.motor_thread.move_absolute(self.motor_x, self.motor_y, target_coordinates) + if self.checkBox_save_with_go.isChecked(): + self.save_absolute_coordinates() + + def _init_keyboard_shortcuts(self): + """Initialize the keyboard shortcuts.""" + # Go absolute button + self.pushButton_go_absolute.setShortcut("Ctrl+G") + self.pushButton_go_absolute.setToolTip("Ctrl+G") + + # Set absolute coordinates + self.pushButton_set.setShortcut("Ctrl+D") + self.pushButton_set.setToolTip("Ctrl+D") + + # Save Current coordinates + self.pushButton_save.setShortcut("Ctrl+S") + self.pushButton_save.setToolTip("Ctrl+S") + + # Stop Button + self.pushButton_stop.setShortcut("Ctrl+X") + self.pushButton_stop.setToolTip("Ctrl+X") + + def save_absolute_coordinates(self): + """Emit the setup coordinates from the spinboxes""" + + x, y = round(self.spinBox_absolute_x.value(), self.precision), round( + self.spinBox_absolute_y.value(), self.precision + ) + self.coordinates_signal.emit((x, y)) + + def save_current_coordinates(self): + """Emit the current coordinates from the motor thread""" + x, y = self.motor_thread.get_coordinates(self.motor_x, self.motor_y) + self.coordinates_signal.emit((round(x, self.precision), round(y, self.precision))) diff --git a/bec_widgets/widgets/motor_control/motor_control_absolute.ui b/bec_widgets/widgets/motor_control/movement_absolute/movement_absolute.ui similarity index 100% rename from bec_widgets/widgets/motor_control/motor_control_absolute.ui rename to bec_widgets/widgets/motor_control/movement_absolute/movement_absolute.ui diff --git a/bec_widgets/widgets/motor_control/movement_relative/__init__.py b/bec_widgets/widgets/motor_control/movement_relative/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/motor_control/movement_relative/movement_relative.py b/bec_widgets/widgets/motor_control/movement_relative/movement_relative.py new file mode 100644 index 00000000..44130ff3 --- /dev/null +++ b/bec_widgets/widgets/motor_control/movement_relative/movement_relative.py @@ -0,0 +1,227 @@ +import os + +from qtpy import uic +from qtpy.QtCore import Qt +from qtpy.QtCore import Signal as pyqtSignal +from qtpy.QtCore import Slot as pyqtSlot +from qtpy.QtGui import QKeySequence +from qtpy.QtWidgets import QDoubleSpinBox, QShortcut, QWidget + +from bec_widgets.widgets.motor_control.motor_control import MotorControlWidget + + +class MotorControlRelative(MotorControlWidget): + """ + Widget for controlling the motors to relative coordinates. + + Signals: + precision_signal (pyqtSignal): Signal to emit the precision of the coordinates. + Slots: + change_motors (pyqtSlot(str,str)): Slot to change the active motors. + enable_motor_controls (pyqtSlot): Slot to enable/disable the motor controls. + """ + + precision_signal = pyqtSignal(int) + + def _load_ui(self): + """Load the UI from the .ui file.""" + # Loading UI + current_path = os.path.dirname(__file__) + uic.loadUi(os.path.join(current_path, "movement_relative.ui"), self) + + def _init_ui(self): + """Initialize the UI.""" + self._init_ui_motor_control() + self._init_keyboard_shortcuts() + + @pyqtSlot(dict) + def on_config_update(self, config: dict) -> None: + """ + Update config dict + Args: + config(dict): New config dict + """ + self.config = config + + # Get motor names + self.motor_x, self.motor_y = ( + self.config["motor_control"]["motor_x"], + self.config["motor_control"]["motor_y"], + ) + + # Update step precision + self.precision = self.config["motor_control"]["precision"] + self.spinBox_precision.setValue(self.precision) + + # Update step sizes + self.spinBox_step_x.setValue(self.config["motor_control"]["step_size_x"]) + self.spinBox_step_y.setValue(self.config["motor_control"]["step_size_y"]) + + # Checkboxes for keyboard shortcuts and x/y step size link + self.checkBox_same_xy.setChecked(self.config["motor_control"]["step_x_y_same"]) + self.checkBox_enableArrows.setChecked(self.config["motor_control"]["move_with_arrows"]) + + self._init_ui() + + def _init_ui_motor_control(self) -> None: + """Initialize the motor control elements""" + + # Connect checkbox and spinBoxes + self.checkBox_same_xy.stateChanged.connect(self._sync_step_sizes) + self.spinBox_step_x.valueChanged.connect(self._update_step_size_x) + self.spinBox_step_y.valueChanged.connect(self._update_step_size_y) + + self.toolButton_right.clicked.connect( + lambda: self.move_motor_relative(self.motor_x, "x", 1) + ) + self.toolButton_left.clicked.connect( + lambda: self.move_motor_relative(self.motor_x, "x", -1) + ) + self.toolButton_up.clicked.connect(lambda: self.move_motor_relative(self.motor_y, "y", 1)) + self.toolButton_down.clicked.connect( + lambda: self.move_motor_relative(self.motor_y, "y", -1) + ) + + # Switch between key shortcuts active + self.checkBox_enableArrows.stateChanged.connect(self._update_arrow_key_shortcuts) + self._update_arrow_key_shortcuts() + + # Enable/Disable GUI + self.motor_thread.lock_gui.connect(self.enable_motor_controls) + + # Precision update + self.spinBox_precision.valueChanged.connect(lambda x: self._update_precision(x)) + + # Error messages + self.motor_thread.motor_error.connect( + lambda error: MotorControlErrors.display_error_message(error) + ) + + # Stop Button + self.pushButton_stop.clicked.connect(self.motor_thread.stop_movement) + + def _init_keyboard_shortcuts(self) -> None: + """Initialize the keyboard shortcuts""" + + # Increase/decrease step size for X motor + increase_x_shortcut = QShortcut(QKeySequence("Ctrl+A"), self) + decrease_x_shortcut = QShortcut(QKeySequence("Ctrl+Z"), self) + increase_x_shortcut.activated.connect( + lambda: self._change_step_size(self.spinBox_step_x, 2) + ) + decrease_x_shortcut.activated.connect( + lambda: self._change_step_size(self.spinBox_step_x, 0.5) + ) + self.spinBox_step_x.setToolTip("Increase step size: Ctrl+A\nDecrease step size: Ctrl+Z") + + # Increase/decrease step size for Y motor + increase_y_shortcut = QShortcut(QKeySequence("Alt+A"), self) + decrease_y_shortcut = QShortcut(QKeySequence("Alt+Z"), self) + increase_y_shortcut.activated.connect( + lambda: self._change_step_size(self.spinBox_step_y, 2) + ) + decrease_y_shortcut.activated.connect( + lambda: self._change_step_size(self.spinBox_step_y, 0.5) + ) + self.spinBox_step_y.setToolTip("Increase step size: Alt+A\nDecrease step size: Alt+Z") + + # Stop Button + self.pushButton_stop.setShortcut("Ctrl+X") + self.pushButton_stop.setToolTip("Ctrl+X") + + def _update_arrow_key_shortcuts(self) -> None: + """Update the arrow key shortcuts based on the checkbox state.""" + if self.checkBox_enableArrows.isChecked(): + # Set the arrow key shortcuts for motor movement + self.toolButton_right.setShortcut(Qt.Key_Right) + self.toolButton_left.setShortcut(Qt.Key_Left) + self.toolButton_up.setShortcut(Qt.Key_Up) + self.toolButton_down.setShortcut(Qt.Key_Down) + else: + # Clear the shortcuts + self.toolButton_right.setShortcut("") + self.toolButton_left.setShortcut("") + self.toolButton_up.setShortcut("") + self.toolButton_down.setShortcut("") + + def _update_precision(self, precision: int) -> None: + """ + Update the precision of the coordinates. + Args: + precision(int): Precision of the coordinates. + """ + self.spinBox_step_x.setDecimals(precision) + self.spinBox_step_y.setDecimals(precision) + self.precision_signal.emit(precision) + + def _change_step_size(self, spinBox: QDoubleSpinBox, factor: float) -> None: + """ + Change the step size of the spinbox. + Args: + spinBox(QDoubleSpinBox): Spinbox to change the step size. + factor(float): Factor to change the step size. + """ + old_step = spinBox.value() + new_step = old_step * factor + spinBox.setValue(new_step) + + def _sync_step_sizes(self): + """Sync step sizes based on checkbox state.""" + if self.checkBox_same_xy.isChecked(): + value = self.spinBox_step_x.value() + self.spinBox_step_y.setValue(value) + + def _update_step_size_x(self): + """Update step size for x if checkbox is checked.""" + if self.checkBox_same_xy.isChecked(): + value = self.spinBox_step_x.value() + self.spinBox_step_y.setValue(value) + + def _update_step_size_y(self): + """Update step size for y if checkbox is checked.""" + if self.checkBox_same_xy.isChecked(): + value = self.spinBox_step_y.value() + self.spinBox_step_x.setValue(value) + + @pyqtSlot(str, str) + def change_motors(self, motor_x: str, motor_y: str): + """ + Change the active motors and update config. + Can be connected to the selected_motors_signal from MotorControlSelection. + Args: + motor_x(str): New motor X to be controlled. + motor_y(str): New motor Y to be controlled. + """ + self.motor_x = motor_x + self.motor_y = motor_y + self.config["motor_control"]["motor_x"] = motor_x + self.config["motor_control"]["motor_y"] = motor_y + + @pyqtSlot(bool) + def enable_motor_controls(self, disable: bool) -> None: + """ + Enable or disable the motor controls. + Args: + disable(bool): True to disable, False to enable. + """ + + # Disable or enable all controls within the motorControl_absolute group box + for widget in self.motorControl.findChildren(QWidget): + widget.setEnabled(disable) + + # Enable the pushButton_stop if the motor is moving + self.pushButton_stop.setEnabled(True) + + def move_motor_relative(self, motor, axis: str, direction: int) -> None: + """ + Move the motor relative to the current position. + Args: + motor: Motor to move. + axis(str): Axis to move. + direction(int): Direction to move. 1 for positive, -1 for negative. + """ + if axis == "x": + step = direction * self.spinBox_step_x.value() + elif axis == "y": + step = direction * self.spinBox_step_y.value() + self.motor_thread.move_relative(motor, step) diff --git a/bec_widgets/widgets/motor_control/motor_control_relative.ui b/bec_widgets/widgets/motor_control/movement_relative/movement_relative.ui similarity index 100% rename from bec_widgets/widgets/motor_control/motor_control_relative.ui rename to bec_widgets/widgets/motor_control/movement_relative/movement_relative.ui diff --git a/bec_widgets/widgets/motor_control/selection/__init__.py b/bec_widgets/widgets/motor_control/selection/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/motor_control/selection/selection.py b/bec_widgets/widgets/motor_control/selection/selection.py new file mode 100644 index 00000000..1f48c1a1 --- /dev/null +++ b/bec_widgets/widgets/motor_control/selection/selection.py @@ -0,0 +1,110 @@ +# pylint: disable = no-name-in-module,missing-module-docstring +import os + +from qtpy import uic +from qtpy.QtCore import Signal as pyqtSignal +from qtpy.QtCore import Slot as pyqtSlot +from qtpy.QtWidgets import QComboBox + +from bec_widgets.widgets.motor_control.motor_control import MotorControlWidget + + +class MotorControlSelection(MotorControlWidget): + """ + Widget for selecting the motors to control. + + Signals: + selected_motors_signal (pyqtSignal(str,str)): Signal to emit the selected motors. + Slots: + get_available_motors (pyqtSlot): Slot to populate the available motors in the combo boxes and set the index based on the configuration. + enable_motor_controls (pyqtSlot(bool)): Slot to enable/disable the motor controls GUI. + on_config_update (pyqtSlot(dict)): Slot to update the config dict. + """ + + selected_motors_signal = pyqtSignal(str, str) + + def _load_ui(self): + """Load the UI from the .ui file.""" + current_path = os.path.dirname(__file__) + uic.loadUi(os.path.join(current_path, "selection.ui"), self) + + def _init_ui(self): + """Initialize the UI.""" + # Lock GUI while motors are moving + self.motor_thread.lock_gui.connect(self.enable_motor_controls) + + self.pushButton_connecMotors.clicked.connect(self.select_motor) + self.get_available_motors() + + # Connect change signals to change color + self.comboBox_motor_x.currentIndexChanged.connect( + lambda: self.set_combobox_style(self.comboBox_motor_x, "#ffa700") + ) + self.comboBox_motor_y.currentIndexChanged.connect( + lambda: self.set_combobox_style(self.comboBox_motor_y, "#ffa700") + ) + + @pyqtSlot(dict) + def on_config_update(self, config: dict) -> None: + """ + Update config dict + Args: + config(dict): New config dict + """ + self.config = config + + # Get motor names + self.motor_x, self.motor_y = ( + self.config["motor_control"]["motor_x"], + self.config["motor_control"]["motor_y"], + ) + + self._init_ui() + + @pyqtSlot(bool) + def enable_motor_controls(self, enable: bool) -> None: + """ + Enable or disable the motor controls. + Args: + enable(bool): True to enable, False to disable. + """ + self.motorSelection.setEnabled(enable) + + @pyqtSlot() + def get_available_motors(self) -> None: + """ + Slot to populate the available motors in the combo boxes and set the index based on the configuration. + """ + # Get all available motors + self.motor_list = self.motor_thread.get_all_motors_names() + + # Populate the combo boxes + self.comboBox_motor_x.addItems(self.motor_list) + self.comboBox_motor_y.addItems(self.motor_list) + + # Set the index based on the config if provided + if self.config: + index_x = self.comboBox_motor_x.findText(self.motor_x) + index_y = self.comboBox_motor_y.findText(self.motor_y) + self.comboBox_motor_x.setCurrentIndex(index_x if index_x != -1 else 0) + self.comboBox_motor_y.setCurrentIndex(index_y if index_y != -1 else 0) + + def set_combobox_style(self, combobox, color: str) -> None: + """ + Set the combobox style to a specific color. + Args: + combobox(QComboBox): Combobox to change the color. + color(str): Color to set the combobox to. + """ + combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}") + + def select_motor(self): + """Emit the selected motors""" + motor_x = self.comboBox_motor_x.currentText() + motor_y = self.comboBox_motor_y.currentText() + + # Reset the combobox color to normal after selection + self.set_combobox_style(self.comboBox_motor_x, "") + self.set_combobox_style(self.comboBox_motor_y, "") + + self.selected_motors_signal.emit(motor_x, motor_y) diff --git a/bec_widgets/widgets/motor_control/motor_control_selection.ui b/bec_widgets/widgets/motor_control/selection/selection.ui similarity index 100% rename from bec_widgets/widgets/motor_control/motor_control_selection.ui rename to bec_widgets/widgets/motor_control/selection/selection.ui diff --git a/bec_widgets/widgets/plots/__init__.py b/bec_widgets/widgets/plots/__init__.py deleted file mode 100644 index 8d73cb20..00000000 --- a/bec_widgets/widgets/plots/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .image import BECImageItem, BECImageShow, ImageItemConfig -from .motor_map import BECMotorMap, MotorMapConfig -from .plot_base import AxisConfig, BECPlotBase, SubplotConfig -from .waveform import BECCurve, BECWaveform, Waveform1DConfig diff --git a/tests/unit_tests/test_bec_figure.py b/tests/unit_tests/test_bec_figure.py index caec4d55..dd8ab594 100644 --- a/tests/unit_tests/test_bec_figure.py +++ b/tests/unit_tests/test_bec_figure.py @@ -3,8 +3,10 @@ import numpy as np import pytest -from bec_widgets.widgets import BECFigure, BECMotorMap, BECWaveform -from bec_widgets.widgets.plots import BECImageShow +from bec_widgets.widgets import BECFigure +from bec_widgets.widgets.figure.plots.image.image import BECImageShow +from bec_widgets.widgets.figure.plots.motor_map.motor_map import BECMotorMap +from bec_widgets.widgets.figure.plots.waveform.waveform import BECWaveform from .client_mocks import mocked_client diff --git a/tests/unit_tests/test_bec_motor_map.py b/tests/unit_tests/test_bec_motor_map.py index 22b47139..423e6604 100644 --- a/tests/unit_tests/test_bec_motor_map.py +++ b/tests/unit_tests/test_bec_motor_map.py @@ -1,8 +1,7 @@ import pytest -from bec_widgets.widgets import BECMotorMap -from bec_widgets.widgets.plots.motor_map import MotorMapConfig -from bec_widgets.widgets.plots.waveform import Signal, SignalData +from bec_widgets.widgets.figure.plots.motor_map.motor_map import BECMotorMap, MotorMapConfig +from bec_widgets.widgets.figure.plots.waveform.waveform_curve import SignalData from .client_mocks import mocked_client diff --git a/tests/unit_tests/test_motor_control.py b/tests/unit_tests/test_motor_control.py index f8418ae7..3cfa7375 100644 --- a/tests/unit_tests/test_motor_control.py +++ b/tests/unit_tests/test_motor_control.py @@ -11,14 +11,15 @@ from bec_widgets.examples import ( MotorControlPanelAbsolute, MotorControlPanelRelative, ) -from bec_widgets.widgets import ( +from bec_widgets.widgets.motor_control.motor_control import MotorActions, MotorThread +from bec_widgets.widgets.motor_control.motor_table.motor_table import MotorCoordinateTable +from bec_widgets.widgets.motor_control.movement_absolute.movement_absolute import ( MotorControlAbsolute, - MotorControlRelative, - MotorControlSelection, - MotorCoordinateTable, - MotorThread, ) -from bec_widgets.widgets.motor_control.motor_control import MotorActions +from bec_widgets.widgets.motor_control.movement_relative.movement_relative import ( + MotorControlRelative, +) +from bec_widgets.widgets.motor_control.selection.selection import MotorControlSelection from .client_mocks import mocked_client diff --git a/tests/unit_tests/test_waveform1d.py b/tests/unit_tests/test_waveform1d.py index 30f877b2..7ede7a31 100644 --- a/tests/unit_tests/test_waveform1d.py +++ b/tests/unit_tests/test_waveform1d.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock import numpy as np import pytest -from bec_widgets.widgets.plots.waveform import CurveConfig, Signal, SignalData +from bec_widgets.widgets.figure.plots.waveform.waveform_curve import CurveConfig, Signal, SignalData from .client_mocks import mocked_client from .test_bec_figure import bec_figure