From 37278e363c701801b774e1533417bca0ea51019f Mon Sep 17 00:00:00 2001 From: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com> Date: Sat, 11 Nov 2023 00:24:02 +0100 Subject: [PATCH] refactor: configs for BECMonitor are validated by pydantic outside the main widget --- .../examples/modular_app/modular_app.py | 2 +- bec_widgets/validation/__init__.py | 2 + bec_widgets/validation/monitor_config.py | 106 ---------- .../validation/monitor_config_validator.py | 190 ++++++++++++++++++ bec_widgets/widgets/monitor/monitor.py | 150 +++++++++++++- 5 files changed, 333 insertions(+), 117 deletions(-) delete mode 100644 bec_widgets/validation/monitor_config.py create mode 100644 bec_widgets/validation/monitor_config_validator.py diff --git a/bec_widgets/examples/modular_app/modular_app.py b/bec_widgets/examples/modular_app/modular_app.py index 2793d9dc..3978a9b8 100644 --- a/bec_widgets/examples/modular_app/modular_app.py +++ b/bec_widgets/examples/modular_app/modular_app.py @@ -192,7 +192,7 @@ class ModularApp(QMainWindow): # hook plots, configs and buttons together for plot, config, button in zip(plots, configs, buttons): - plot.update_config(config) + plot.on_config_update(config) button.clicked.connect(plot.show_config_dialog) diff --git a/bec_widgets/validation/__init__.py b/bec_widgets/validation/__init__.py index e69de29b..b7cd8a37 100644 --- a/bec_widgets/validation/__init__.py +++ b/bec_widgets/validation/__init__.py @@ -0,0 +1,2 @@ +# from .monitor_config import validate_monitor_config, ValidationError +from .monitor_config_validator import MonitorConfigValidator diff --git a/bec_widgets/validation/monitor_config.py b/bec_widgets/validation/monitor_config.py deleted file mode 100644 index a9d8b80b..00000000 --- a/bec_widgets/validation/monitor_config.py +++ /dev/null @@ -1,106 +0,0 @@ -from pydantic import BaseModel, Field, ValidationError -from typing import List, Dict, Union, Optional - - -class Signal(BaseModel): - """ - Represents a signal in a plot configuration. - - Attributes: - name (str): The name of the signal. - entry (Optional[str]): The entry point of the signal, optional. - """ - - name: str - entry: Optional[str] - - -class PlotAxis(BaseModel): - """ - Represents an axis (X or Y) in a plot configuration. - - Attributes: - label (Optional[str]): The label for the axis. - signals (List[Signal]): A list of signals to be plotted on this axis. - """ - - label: Optional[str] - signals: List[Signal] - - -class PlotConfig(BaseModel): - """ - Configuration for a single plot. - - Attributes: - plot_name (Optional[str]): Name of the plot. - x (PlotAxis): Configuration for the X axis. - y (PlotAxis): Configuration for the Y axis. - """ - - plot_name: Optional[str] - x: PlotAxis - y: PlotAxis - - -class PlotSettings(BaseModel): - """ - Global settings for plotting. - - Attributes: - background_color (str): Color of the plot background. - num_columns (int): Number of columns in the plot layout. - colormap (str): Colormap to be used. - scan_types (bool): Indicates if the configuration is for different scan types. - """ - - background_color: str - num_columns: int - colormap: str - scan_types: bool - - -class DeviceMonitorConfig(BaseModel): - """ - Configuration model for the device monitor mode. - - Attributes: - plot_settings (PlotSettings): Global settings for plotting. - plot_data (List[PlotConfig]): List of plot configurations. - """ - - plot_settings: PlotSettings - plot_data: List[PlotConfig] - - -class ScanModeConfig(BaseModel): - """ - Configuration model for scan mode. - - Attributes: - plot_settings (PlotSettings): Global settings for plotting. - plot_data (Dict[str, List[PlotConfig]]): Dictionary of plot configurations, - keyed by scan type. - """ - - plot_settings: PlotSettings - plot_data: Dict[str, List[PlotConfig]] - - -def validate_config(config_data: dict) -> Union[DeviceMonitorConfig, ScanModeConfig]: - """ - Validates the configuration data based on the provided schema. - - Args: - config_data (dict): Configuration data to be validated. - - Returns: - Union[DeviceMonitorConfig, ScanModeConfig]: Validated configuration object. - - Raises: - ValidationError: If the configuration data does not conform to the schema. - """ - if config_data["plot_settings"]["scan_types"]: - return ScanModeConfig(**config_data) - else: - return DeviceMonitorConfig(**config_data) diff --git a/bec_widgets/validation/monitor_config_validator.py b/bec_widgets/validation/monitor_config_validator.py new file mode 100644 index 00000000..ff224e76 --- /dev/null +++ b/bec_widgets/validation/monitor_config_validator.py @@ -0,0 +1,190 @@ +from typing import List, Dict, Union, Optional + +from pydantic import ( + BaseModel, + Field, + field_validator, +) +from pydantic_core import PydanticCustomError + + +class Signal(BaseModel): + """ + Represents a signal in a plot configuration. + + Attributes: + name (str): The name of the signal. + entry (Optional[str]): The entry point of the signal, optional. + """ + + name: str + entry: Optional[str] = Field(None, validate_default=True) + + @field_validator("name") + @classmethod + def validate_name(cls, v): + device_manager = MonitorConfigValidator.device_manager + # Check if device name provided + if v is None: + raise PydanticCustomError( + "no_device_name", + "Device name must be provided", + dict(wrong_value=v), + ) + + # Check if device exists in BEC + try: + device = getattr(device_manager, v) + except: + raise PydanticCustomError( + "no_device_bec", + 'Device "{wrong_value}" not found in current BEC session', + dict(wrong_value=v), + ) + + # Check if device have signals #TODO not sure if devices can be defined also without signals + if not hasattr(device, "signals"): + raise PydanticCustomError( + "no_device_signals", + 'Device "{wrong_value}" do not have "signals" defined. Check device configuration.', + dict(wrong_value=v), + ) + + return v + + @field_validator("entry") + @classmethod + def set_and_validate_entry(cls, v, values): + device_manager = MonitorConfigValidator.device_manager + + device_name = values.data.get("name") + device = getattr(device_manager, device_name, None) + + # Set entry based on hints if not provided + if v is None and hasattr(device, "_hints"): + v = next(iter(device._hints), device_name) + elif v is None: + v = device_name + + # Validate that the entry exists in device signals + if v not in device.signals: + raise PydanticCustomError( + "no_entry_for_device", + "Entry '{wrong_value}' not found in device '{device_name}' signals", + dict(wrong_value=v, device_name=device_name), + ) + + return v + + +class PlotAxis(BaseModel): + """ + Represents an axis (X or Y) in a plot configuration. + + Attributes: + label (Optional[str]): The label for the axis. + signals (List[Signal]): A list of signals to be plotted on this axis. + """ + + label: Optional[str] + signals: List[Signal] = Field(default_factory=list) + + +class PlotConfig(BaseModel): + """ + Configuration for a single plot. + + Attributes: + plot_name (Optional[str]): Name of the plot. + x (PlotAxis): Configuration for the X axis. + y (PlotAxis): Configuration for the Y axis. + """ + + plot_name: Optional[str] + x: PlotAxis = Field(...) + y: PlotAxis = Field(...) + + @field_validator("x") + def validate_x_signals(cls, v): + if len(v.signals) != 1: + raise PydanticCustomError( + "no_entry_for_device", + "There must be exactly one signal for x axis. Number of x signals: '{wrong_value}'", + dict(wrong_value=v), + ) + + return v + + +class PlotSettings(BaseModel): + """ + Global settings for plotting. + + Attributes: + background_color (str): Color of the plot background. + num_columns (int): Number of columns in the plot layout. + colormap (str): Colormap to be used. + scan_types (bool): Indicates if the configuration is for different scan types. + """ + + background_color: str + num_columns: int + colormap: str + scan_types: bool + + +class DeviceMonitorConfig(BaseModel): + """ + Configuration model for the device monitor mode. + + Attributes: + plot_settings (PlotSettings): Global settings for plotting. + plot_data (List[PlotConfig]): List of plot configurations. + """ + + plot_settings: PlotSettings + plot_data: List[PlotConfig] + + +class ScanModeConfig(BaseModel): + """ + Configuration model for scan mode. + + Attributes: + plot_settings (PlotSettings): Global settings for plotting. + plot_data (Dict[str, List[PlotConfig]]): Dictionary of plot configurations, + keyed by scan type. + """ + + plot_settings: PlotSettings + plot_data: Dict[str, List[PlotConfig]] + + +class MonitorConfigValidator: + device_manager = None + + def __init__(self, device_manager): + # self.device_manager = device_manager + MonitorConfigValidator.device_manager = device_manager + + def validate_monitor_config( + self, config_data: dict + ) -> Union[DeviceMonitorConfig, ScanModeConfig]: + """ + Validates the configuration data based on the provided schema. + + Args: + config_data (dict): Configuration data to be validated. + + Returns: + Union[DeviceMonitorConfig, ScanModeConfig]: Validated configuration object. + + Raises: + ValidationError: If the configuration data does not conform to the schema. + """ + if config_data["plot_settings"]["scan_types"]: + validated_config = ScanModeConfig(**config_data) + else: + validated_config = DeviceMonitorConfig(**config_data) + + return validated_config diff --git a/bec_widgets/widgets/monitor/monitor.py b/bec_widgets/widgets/monitor/monitor.py index 60ace631..60d90f59 100644 --- a/bec_widgets/widgets/monitor/monitor.py +++ b/bec_widgets/widgets/monitor/monitor.py @@ -2,18 +2,134 @@ import os import time import pyqtgraph as pg -from bec_lib import MessageEndpoints +from pydantic import ValidationError + +from bec_lib.core import MessageEndpoints from PyQt5 import QtCore from PyQt5.QtCore import pyqtSignal, pyqtSlot -from PyQt5.QtWidgets import QApplication, QTableWidgetItem, QWidget +from PyQt5.QtWidgets import QApplication, QTableWidgetItem, QWidget, QMessageBox from pyqtgraph import mkPen, mkBrush from PyQt5 import uic from bec_widgets.bec_dispatcher import bec_dispatcher from bec_widgets.qt_utils import Crosshair, Colors -# just for demonstration purposes is script run directly +# from bec_widgets.validation import validate_monitor_config, ValidationError +from bec_widgets.validation import MonitorConfigValidator + +# just for demonstration purposes if script run directly +config_scan_mode = { + "plot_settings": { + "background_color": "white", + "num_columns": 3, + "colormap": "plasma", + "scan_types": True, + }, + "plot_data": { + "grid_scan": [ + { + "plot_name": "Grid plot 1", + "x": {"label": "Motor X", "signals": [{"name": "samx", "entry": "samx"}]}, + "y": { + "label": "BPM", + "signals": [ + {"name": "gauss_bpm", "entry": "gauss_bpm"}, + {"name": "gauss_adc1", "entry": "gauss_adc1"}, + ], + }, + }, + { + "plot_name": "Grid plot 2", + "x": {"label": "Motor X", "signals": [{"name": "samx", "entry": "samx"}]}, + "y": { + "label": "BPM", + "signals": [ + {"name": "gauss_bpm", "entry": "gauss_bpm"}, + {"name": "gauss_adc1", "entry": "gauss_adc1"}, + ], + }, + }, + { + "plot_name": "Grid plot 3", + "x": {"label": "Motor Y", "signals": [{"name": "samx", "entry": "samx"}]}, + "y": { + "label": "BPM", + "signals": [{"name": "gauss_bpm", "entry": "gauss_bpm"}], + }, + }, + { + "plot_name": "Grid plot 4", + "x": {"label": "Motor Y", "signals": [{"name": "samx", "entry": "samx"}]}, + "y": { + "label": "BPM", + "signals": [{"name": "gauss_adc3", "entry": "gauss_adc3"}], + }, + }, + ], + "line_scan": [ + { + "plot_name": "BPM plot", + "x": {"label": "Motor X", "signals": [{"name": "samx", "entry": "samx"}]}, + "y": { + "label": "BPM", + "signals": [ + {"name": "gauss_bpm", "entry": "gauss_bpm"}, + {"name": "gauss_adc1"}, + {"name": "gauss_adc2", "entry": "gauss_adc2"}, + ], + }, + }, + { + "plot_name": "Multi", + "x": {"label": "Motor X", "signals": [{"name": "samx", "entry": "samx"}]}, + "y": { + "label": "Multi", + "signals": [ + {"name": "gauss_bpm", "entry": "gauss_bpm"}, + {"name": "samx", "entry": "samx"}, + ], + }, + }, + ], + }, +} + config_simple = { + "plot_settings": { + "background_color": "black", + "num_columns": 2, + "colormap": "plasma", + "scan_types": False, + }, + "plot_data": [ + { + "plot_name": "BPM4i plots vs samx", + "x": { + "label": "Motor Y", + # "signals": [{"name": "samx", "entry": "samx"}], + "signals": [{"name": "samy"}], + }, + "y": { + "label": "bpm4i", + "signals": [{"name": "bpm4i", "entry": "bpm4i"}], + }, + }, + { + "plot_name": "Gauss plots vs samx", + "x": { + "label": "Motor X", + "signals": [{"name": "samx", "entry": "samx"}], + }, + "y": { + "label": "Gauss", + # "signals": [{"name": "gauss_bpm", "entry": "gauss_bpm"}], + "signals": [{"name": "gauss_bpm"}], + }, + }, + ], +} + +config_wrong = { "plot_settings": { "background_color": "black", "num_columns": 2, @@ -40,7 +156,7 @@ config_simple = { }, "y": { "label": "Gauss", - "signals": [{"name": "gauss_bpm", "entry": "gauss_bpm"}], + "signals": [{"name": "gauss_bpm", "entry": "BS"}], }, }, ], @@ -63,6 +179,9 @@ class BECMonitor(pg.GraphicsLayoutWidget): # Client and device manager from BEC self.client = bec_dispatcher.client if client is None else client self.dev = self.client.device_manager.devices + self.queue = self.client.queue + + self.validator = MonitorConfigValidator(self.dev) if gui_id is None: self.gui_id = self.__class__.__name__ + str(time.time()) # TODO still in discussion @@ -248,7 +367,9 @@ class BECMonitor(pg.GraphicsLayoutWidget): ) x_signal_config = x_config["signals"][0] x_name = x_signal_config.get("name", "") - x_entry = x_signal_config.get("entry", x_name) + x_entry = x_signal_config.get( + "entry", x_name + ) # TODO this is buggy if the entry is not specified in the config for x key = (x_name, x_entry, y_name, y_entry) data_x = self.data.get(key, {}).get("x", []) @@ -265,7 +386,7 @@ class BECMonitor(pg.GraphicsLayoutWidget): from .config_dialog import ConfigDialog dialog = ConfigDialog(default_config=self.config) - dialog.config_updated.connect(self.update_config) + dialog.config_updated.connect(self.on_config_update) dialog.show() def update_client(self, client) -> None: @@ -279,12 +400,21 @@ class BECMonitor(pg.GraphicsLayoutWidget): @pyqtSlot(dict) def on_config_update(self, config: dict) -> None: """ - Update the configuration settings for the PlotApp. + Validate and update the configuration settings for the PlotApp. Args: config(dict): Configuration settings """ - self.config = config - self._init_config() + + # self.config = config + # self._init_config() + try: + validated_config = self.validator.validate_monitor_config(config) + self.config = validated_config.model_dump() + self._init_config() + except ValidationError as e: + error_message = f"Monitor configuration validation error: {e}" + print(error_message) + # QMessageBox.critical(self, "Configuration Error", error_message) #TODO do better error popups @pyqtSlot(dict, dict) def on_scan_segment(self, msg, metadata): @@ -402,6 +532,6 @@ if __name__ == "__main__": # pragma: no cover client.start() app = QApplication(sys.argv) - monitor = BECMonitor(config=config_simple) + monitor = BECMonitor(config=config_wrong) monitor.show() sys.exit(app.exec_())