mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-08 09:47:52 +02:00
Compare commits
31 Commits
v1.24.5
...
fix/dap-pa
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dc42895aa | |||
| 115da10d78 | |||
| 0753e359c6 | |||
| 69bc40a8e4 | |||
| 75dbbf7663 | |||
| a5be450590 | |||
| 7127bba1de | |||
| 6b265b1d30 | |||
| 0a9d2c4d18 | |||
| 37f736954f | |||
| bea107b036 | |||
| 532f2957b4 | |||
| 7a676d6078 | |||
| cc3cb8ccb1 | |||
| 81c306611d | |||
| c5b384c7f5 | |||
| a186e66b92 | |||
| 183104456f | |||
| d1a6a59b86 | |||
| 1562000b37 | |||
| 2b37ac154e | |||
| 59bc5996df | |||
| 06d7f239ed | |||
| 5920b26c02 | |||
| 9439e4a275 | |||
| 9342bffe85 | |||
| 47ee8e5599 | |||
| d86ef4e763 | |||
| 6cf39b3796 | |||
|
|
15e11b287d | ||
| 7cbebbb1f0 |
@@ -197,7 +197,13 @@ end-2-end-conda:
|
||||
script:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- conda config --prepend channels conda-forge
|
||||
- conda config --show-sources
|
||||
- conda config --add channels conda-forge
|
||||
- conda config --system --remove channels https://repo.anaconda.com/pkgs/main
|
||||
- conda config --system --remove channels https://repo.anaconda.com/pkgs/r
|
||||
- conda config --remove channels https://repo.anaconda.com/pkgs/main
|
||||
- conda config --remove channels https://repo.anaconda.com/pkgs/r
|
||||
- conda config --show-sources
|
||||
- conda config --set channel_priority strict
|
||||
- conda config --set always_yes yes --set changeps1 no
|
||||
- conda create -q -n test-environment python=3.11
|
||||
@@ -227,6 +233,7 @@ end-2-end-conda:
|
||||
- if: '$CI_PIPELINE_SOURCE == "parent_pipeline"'
|
||||
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
|
||||
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
|
||||
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^pre_release.*$/'
|
||||
|
||||
semver:
|
||||
stage: Deploy
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v1.25.0 (2025-03-07)
|
||||
|
||||
### Features
|
||||
|
||||
- **waveform**: Add slice handling and reset functionality for async updates
|
||||
([`7cbebbb`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7cbebbb1f00ea2e2b3678c96b183a877e59c5240))
|
||||
|
||||
|
||||
## v1.24.5 (2025-03-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -29,6 +29,7 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
# FIXME BECWaveFormWidget is gone, this app will not work until adapted to new Waveform
|
||||
class Alignment1D:
|
||||
"""Alignment GUI to perform 1D scans"""
|
||||
|
||||
|
||||
@@ -35,9 +35,9 @@ class AutoUpdates:
|
||||
Create a default dock for the auto updates.
|
||||
"""
|
||||
self.dock_name = "default_figure"
|
||||
self._default_dock = self.gui.add_dock(self.dock_name)
|
||||
self._default_dock.add_widget("BECFigure")
|
||||
self._default_fig = self._default_dock.widget_list[0]
|
||||
self._default_dock = self.gui.new(self.dock_name)
|
||||
self._default_dock.new("BECFigure")
|
||||
self._default_fig = self._default_dock.elements_list[0]
|
||||
|
||||
@staticmethod
|
||||
def get_scan_info(msg) -> ScanInfo:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
||||
"""Client utilities for the BEC GUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
@@ -8,12 +10,12 @@ import select
|
||||
import subprocess
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import isinstance_based_on_class_name, lazy_import, lazy_import_from
|
||||
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
from bec_widgets.cli.auto_updates import AutoUpdates
|
||||
@@ -23,16 +25,16 @@ if TYPE_CHECKING:
|
||||
from bec_lib import messages
|
||||
from bec_lib.connector import MessageObject
|
||||
from bec_lib.device import DeviceBase
|
||||
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_lib.redis_connector import StreamMessage
|
||||
else:
|
||||
messages = lazy_import("bec_lib.messages")
|
||||
# from bec_lib.connector import MessageObject
|
||||
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
|
||||
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
|
||||
StreamMessage = lazy_import_from("bec_lib.redis_connector", ("StreamMessage",))
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
IGNORE_WIDGETS = ["BECDockArea", "BECDock"]
|
||||
|
||||
|
||||
def _filter_output(output: str) -> str:
|
||||
"""
|
||||
@@ -67,7 +69,9 @@ def _get_output(process, logger) -> None:
|
||||
logger.error(f"Error reading process output: {str(e)}")
|
||||
|
||||
|
||||
def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger=None) -> None:
|
||||
def _start_plot_process(
|
||||
gui_id: str, gui_class: type, gui_class_id: str, config: dict | str, logger=None
|
||||
) -> tuple[subprocess.Popen[str], threading.Thread | None]:
|
||||
"""
|
||||
Start the plot in a new process.
|
||||
|
||||
@@ -76,7 +80,16 @@ def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger
|
||||
process will not be captured.
|
||||
"""
|
||||
# pylint: disable=subprocess-run-check
|
||||
command = ["bec-gui-server", "--id", gui_id, "--gui_class", gui_class.__name__, "--hide"]
|
||||
command = [
|
||||
"bec-gui-server",
|
||||
"--id",
|
||||
gui_id,
|
||||
"--gui_class",
|
||||
gui_class.__name__,
|
||||
"--gui_class_id",
|
||||
gui_class_id,
|
||||
"--hide",
|
||||
]
|
||||
if config:
|
||||
if isinstance(config, dict):
|
||||
config = json.dumps(config)
|
||||
@@ -111,16 +124,20 @@ def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger
|
||||
|
||||
|
||||
class RepeatTimer(threading.Timer):
|
||||
"""RepeatTimer class."""
|
||||
|
||||
def run(self):
|
||||
while not self.finished.wait(self.interval):
|
||||
self.function(*self.args, **self.kwargs)
|
||||
|
||||
|
||||
# pylint: disable=protected-access
|
||||
@contextmanager
|
||||
def wait_for_server(client):
|
||||
def wait_for_server(client: BECGuiClient):
|
||||
"""Context manager to wait for the server to start."""
|
||||
timeout = client._startup_timeout
|
||||
if not timeout:
|
||||
if client.gui_is_alive():
|
||||
if client._gui_is_alive():
|
||||
# there is hope, let's wait a bit
|
||||
timeout = 1
|
||||
else:
|
||||
@@ -138,42 +155,63 @@ def wait_for_server(client):
|
||||
yield
|
||||
|
||||
|
||||
### ----------------------------
|
||||
### NOTE
|
||||
### it is far easier to extend the 'delete' method on the client side,
|
||||
### to know when the client is deleted, rather than listening to server
|
||||
### to get notified. However, 'generate_cli.py' cannot add extra stuff
|
||||
### in the generated client module. So, here a class with the same name
|
||||
### is created, and client module is patched.
|
||||
class WidgetNameSpace:
|
||||
def __repr__(self):
|
||||
console = Console()
|
||||
table = Table(title="Available widgets for BEC CLI usage")
|
||||
table.add_column("Widget Name", justify="left", style="magenta")
|
||||
table.add_column("Description", justify="left")
|
||||
for attr, value in self.__dict__.items():
|
||||
docs = value.__doc__
|
||||
docs = docs if docs else "No description available"
|
||||
table.add_row(attr, docs)
|
||||
console.print(table)
|
||||
return f""
|
||||
|
||||
|
||||
class AvailableWidgetsNamespace:
|
||||
"""Namespace for available widgets in the BEC GUI."""
|
||||
|
||||
def __init__(self):
|
||||
for widget in client.Widgets:
|
||||
name = widget.value
|
||||
if name in IGNORE_WIDGETS:
|
||||
continue
|
||||
setattr(self, name, name)
|
||||
|
||||
def __repr__(self):
|
||||
console = Console()
|
||||
table = Table(title="Available widgets for BEC CLI usage")
|
||||
table.add_column("Widget Name", justify="left", style="magenta")
|
||||
table.add_column("Description", justify="left")
|
||||
for attr_name, _ in self.__dict__.items():
|
||||
docs = getattr(client, attr_name).__doc__
|
||||
docs = docs if docs else "No description available"
|
||||
table.add_row(attr_name, docs if len(docs.strip()) > 0 else "No description available")
|
||||
console.print(table)
|
||||
return "" # f"<{self.__class__.__name__}>"
|
||||
|
||||
|
||||
class BECDockArea(client.BECDockArea):
|
||||
def delete(self):
|
||||
if self is BECGuiClient._top_level["main"].widget:
|
||||
raise RuntimeError("Cannot delete main window")
|
||||
super().delete()
|
||||
try:
|
||||
del BECGuiClient._top_level[self._gui_id]
|
||||
except KeyError:
|
||||
# if a dock area is not at top level
|
||||
pass
|
||||
"""Extend the BECDockArea class and add namespaces to access widgets of docks."""
|
||||
|
||||
|
||||
client.BECDockArea = BECDockArea
|
||||
### ----------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class WidgetDesc:
|
||||
title: str
|
||||
widget: BECDockArea
|
||||
def __init__(self, gui_id=None, config=None, name=None, parent=None):
|
||||
super().__init__(gui_id, config, name, parent)
|
||||
# Add namespaces for DockArea
|
||||
self.elements = WidgetNameSpace()
|
||||
|
||||
|
||||
class BECGuiClient(RPCBase):
|
||||
_top_level = {}
|
||||
"""BEC GUI client class. Container for GUI applications within Python."""
|
||||
|
||||
_top_level: dict[str, BECDockArea] = {}
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._default_dock_name = "bec"
|
||||
self._auto_updates_enabled = True
|
||||
self._auto_updates = None
|
||||
self._killed = False
|
||||
self._startup_timeout = 0
|
||||
self._gui_started_timer = None
|
||||
self._gui_started_event = threading.Event()
|
||||
@@ -181,14 +219,21 @@ class BECGuiClient(RPCBase):
|
||||
self._process_output_processing_thread = None
|
||||
|
||||
@property
|
||||
def windows(self):
|
||||
def windows(self) -> dict:
|
||||
"""Dictionary with dock areas in the GUI."""
|
||||
return self._top_level
|
||||
|
||||
@property
|
||||
def auto_updates(self):
|
||||
if self._auto_updates_enabled:
|
||||
with wait_for_server(self):
|
||||
return self._auto_updates
|
||||
def window_list(self) -> list:
|
||||
"""List with dock areas in the GUI."""
|
||||
return list(self._top_level.values())
|
||||
|
||||
# FIXME AUTO UPDATES
|
||||
# @property
|
||||
# def auto_updates(self):
|
||||
# if self._auto_updates_enabled:
|
||||
# with wait_for_server(self):
|
||||
# return self._auto_updates
|
||||
|
||||
def _get_update_script(self) -> AutoUpdates | None:
|
||||
eps = imd.entry_points(group="bec.widgets.auto_updates")
|
||||
@@ -199,71 +244,73 @@ class BECGuiClient(RPCBase):
|
||||
# if the module is not found, we skip it
|
||||
if spec is None:
|
||||
continue
|
||||
return ep.load()(gui=self._top_level["main"].widget)
|
||||
return ep.load()(gui=self._top_level["main"])
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading auto update script from plugin: {str(e)}")
|
||||
return None
|
||||
|
||||
@property
|
||||
def selected_device(self):
|
||||
"""
|
||||
Selected device for the plot.
|
||||
"""
|
||||
auto_update_config_ep = MessageEndpoints.gui_auto_update_config(self._gui_id)
|
||||
auto_update_config = self._client.connector.get(auto_update_config_ep)
|
||||
if auto_update_config:
|
||||
return auto_update_config.selected_device
|
||||
return None
|
||||
# FIXME AUTO UPDATES
|
||||
# @property
|
||||
# def selected_device(self) -> str | None:
|
||||
# """
|
||||
# Selected device for the plot.
|
||||
# """
|
||||
# auto_update_config_ep = MessageEndpoints.gui_auto_update_config(self._gui_id)
|
||||
# auto_update_config = self._client.connector.get(auto_update_config_ep)
|
||||
# if auto_update_config:
|
||||
# return auto_update_config.selected_device
|
||||
# return None
|
||||
|
||||
@selected_device.setter
|
||||
def selected_device(self, device: str | DeviceBase):
|
||||
if isinstance_based_on_class_name(device, "bec_lib.device.DeviceBase"):
|
||||
self._client.connector.set_and_publish(
|
||||
MessageEndpoints.gui_auto_update_config(self._gui_id),
|
||||
messages.GUIAutoUpdateConfigMessage(selected_device=device.name),
|
||||
)
|
||||
elif isinstance(device, str):
|
||||
self._client.connector.set_and_publish(
|
||||
MessageEndpoints.gui_auto_update_config(self._gui_id),
|
||||
messages.GUIAutoUpdateConfigMessage(selected_device=device),
|
||||
)
|
||||
else:
|
||||
raise ValueError("Device must be a string or a device object")
|
||||
# @selected_device.setter
|
||||
# def selected_device(self, device: str | DeviceBase):
|
||||
# if isinstance_based_on_class_name(device, "bec_lib.device.DeviceBase"):
|
||||
# self._client.connector.set_and_publish(
|
||||
# MessageEndpoints.gui_auto_update_config(self._gui_id),
|
||||
# messages.GUIAutoUpdateConfigMessage(selected_device=device.name),
|
||||
# )
|
||||
# elif isinstance(device, str):
|
||||
# self._client.connector.set_and_publish(
|
||||
# MessageEndpoints.gui_auto_update_config(self._gui_id),
|
||||
# messages.GUIAutoUpdateConfigMessage(selected_device=device),
|
||||
# )
|
||||
# else:
|
||||
# raise ValueError("Device must be a string or a device object")
|
||||
|
||||
def _start_update_script(self) -> None:
|
||||
self._client.connector.register(MessageEndpoints.scan_status(), cb=self._handle_msg_update)
|
||||
# FIXME AUTO UPDATES
|
||||
# def _start_update_script(self) -> None:
|
||||
# self._client.connector.register(MessageEndpoints.scan_status(), cb=self._handle_msg_update)
|
||||
|
||||
def _handle_msg_update(self, msg: MessageObject) -> None:
|
||||
if self.auto_updates is not None:
|
||||
# pylint: disable=protected-access
|
||||
return self._update_script_msg_parser(msg.value)
|
||||
# def _handle_msg_update(self, msg: StreamMessage) -> None:
|
||||
# if self.auto_updates is not None:
|
||||
# # pylint: disable=protected-access
|
||||
# return self._update_script_msg_parser(msg.value)
|
||||
|
||||
def _update_script_msg_parser(self, msg: messages.BECMessage) -> None:
|
||||
if isinstance(msg, messages.ScanStatusMessage):
|
||||
if not self.gui_is_alive():
|
||||
return
|
||||
if self._auto_updates_enabled:
|
||||
return self.auto_updates.do_update(msg)
|
||||
# def _update_script_msg_parser(self, msg: messages.BECMessage) -> None:
|
||||
# if isinstance(msg, messages.ScanStatusMessage):
|
||||
# if not self._gui_is_alive():
|
||||
# return
|
||||
# if self._auto_updates_enabled:
|
||||
# return self.auto_updates.do_update(msg)
|
||||
|
||||
def _gui_post_startup(self):
|
||||
self._top_level["main"] = WidgetDesc(
|
||||
title="BEC Widgets", widget=BECDockArea(gui_id=self._gui_id)
|
||||
# if self._auto_updates_enabled:
|
||||
# if self._auto_updates is None:
|
||||
# auto_updates = self._get_update_script()
|
||||
# if auto_updates is None:
|
||||
# AutoUpdates.create_default_dock = True
|
||||
# AutoUpdates.enabled = True
|
||||
# auto_updates = AutoUpdates(self._top_level["main"].widget)
|
||||
# if auto_updates.create_default_dock:
|
||||
# auto_updates.start_default_dock()
|
||||
# self._start_update_script()
|
||||
# self._auto_updates = auto_updates
|
||||
self._top_level[self._default_dock_name] = BECDockArea(
|
||||
gui_id=f"{self._default_dock_name}", name=self._default_dock_name, parent=self
|
||||
)
|
||||
if self._auto_updates_enabled:
|
||||
if self._auto_updates is None:
|
||||
auto_updates = self._get_update_script()
|
||||
if auto_updates is None:
|
||||
AutoUpdates.create_default_dock = True
|
||||
AutoUpdates.enabled = True
|
||||
auto_updates = AutoUpdates(self._top_level["main"].widget)
|
||||
if auto_updates.create_default_dock:
|
||||
auto_updates.start_default_dock()
|
||||
self._start_update_script()
|
||||
self._auto_updates = auto_updates
|
||||
self._do_show_all()
|
||||
self._gui_started_event.set()
|
||||
|
||||
def start_server(self, wait=False) -> None:
|
||||
def _start_server(self, wait: bool = False) -> None:
|
||||
"""
|
||||
Start the GUI server, and execute callback when it is launched
|
||||
"""
|
||||
@@ -272,7 +319,11 @@ class BECGuiClient(RPCBase):
|
||||
self._startup_timeout = 5
|
||||
self._gui_started_event.clear()
|
||||
self._process, self._process_output_processing_thread = _start_plot_process(
|
||||
self._gui_id, self.__class__, self._client._service_config.config, logger=logger
|
||||
self._gui_id,
|
||||
self.__class__,
|
||||
gui_class_id=self._default_dock_name,
|
||||
config=self._client._service_config.config, # pylint: disable=protected-access
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
def gui_started_callback(callback):
|
||||
@@ -283,7 +334,7 @@ class BECGuiClient(RPCBase):
|
||||
threading.current_thread().cancel()
|
||||
|
||||
self._gui_started_timer = RepeatTimer(
|
||||
0.5, lambda: self.gui_is_alive() and gui_started_callback(self._gui_post_startup)
|
||||
0.5, lambda: self._gui_is_alive() and gui_started_callback(self._gui_post_startup)
|
||||
)
|
||||
self._gui_started_timer.start()
|
||||
|
||||
@@ -294,54 +345,99 @@ class BECGuiClient(RPCBase):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
return rpc_client._run_rpc("_dump")
|
||||
|
||||
def start(self):
|
||||
return self.start_server()
|
||||
def start(self, wait: bool = True) -> None:
|
||||
"""Start the server and show the GUI window."""
|
||||
return self._start_server(wait=wait)
|
||||
|
||||
def _do_show_all(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
rpc_client._run_rpc("show")
|
||||
rpc_client._run_rpc("show") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window.widget.show()
|
||||
window.show()
|
||||
|
||||
def show_all(self):
|
||||
def _show_all(self):
|
||||
with wait_for_server(self):
|
||||
return self._do_show_all()
|
||||
|
||||
def hide_all(self):
|
||||
def _hide_all(self):
|
||||
with wait_for_server(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
rpc_client._run_rpc("hide")
|
||||
for window in self._top_level.values():
|
||||
window.widget.hide()
|
||||
rpc_client._run_rpc("hide") # pylint: disable=protected-access
|
||||
# because of the registry callbacks, we may have
|
||||
# dock areas that are already killed, but not yet
|
||||
# removed from the registry state
|
||||
if not self._killed:
|
||||
for window in self._top_level.values():
|
||||
window.hide()
|
||||
|
||||
def show(self):
|
||||
"""Show the GUI window."""
|
||||
if self._process is not None:
|
||||
return self.show_all()
|
||||
return self._show_all()
|
||||
# backward compatibility: show() was also starting server
|
||||
return self.start_server(wait=True)
|
||||
return self._start_server(wait=True)
|
||||
|
||||
def hide(self):
|
||||
return self.hide_all()
|
||||
"""Hide the GUI window."""
|
||||
return self._hide_all()
|
||||
|
||||
@property
|
||||
def main(self):
|
||||
"""Return client to main dock area (in main window)"""
|
||||
with wait_for_server(self):
|
||||
return self._top_level["main"].widget
|
||||
def new(
|
||||
self,
|
||||
name: str | None = None,
|
||||
wait: bool = True,
|
||||
geometry: tuple[int, int, int, int] | None = None,
|
||||
) -> BECDockArea:
|
||||
"""Create a new top-level dock area.
|
||||
|
||||
def new(self, title):
|
||||
"""Ask main window to create a new top-level dock area"""
|
||||
with wait_for_server(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
widget = rpc_client._run_rpc("new_dock_area", title)
|
||||
self._top_level[widget._gui_id] = WidgetDesc(title=title, widget=widget)
|
||||
return widget
|
||||
|
||||
def close(self) -> None:
|
||||
Args:
|
||||
name(str, optional): The name of the dock area. Defaults to None.
|
||||
wait(bool, optional): Whether to wait for the server to start. Defaults to True.
|
||||
geometry(tuple[int, int, int, int] | None): The geometry of the dock area (pos_x, pos_y, w, h)
|
||||
Returns:
|
||||
BECDockArea: The new dock area.
|
||||
"""
|
||||
Close the gui window.
|
||||
if len(self.window_list) == 0:
|
||||
self.show()
|
||||
if wait:
|
||||
with wait_for_server(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
widget = rpc_client._run_rpc(
|
||||
"new_dock_area", name, geometry
|
||||
) # pylint: disable=protected-access
|
||||
self._top_level[widget.widget_name] = widget
|
||||
return widget
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
widget = rpc_client._run_rpc(
|
||||
"new_dock_area", name, geometry
|
||||
) # pylint: disable=protected-access
|
||||
self._top_level[widget.widget_name] = widget
|
||||
return widget
|
||||
|
||||
def delete(self, name: str) -> None:
|
||||
"""Delete a dock area.
|
||||
|
||||
Args:
|
||||
name(str): The name of the dock area.
|
||||
"""
|
||||
widget = self.windows.get(name)
|
||||
if widget is None:
|
||||
raise ValueError(f"Dock area {name} not found.")
|
||||
widget._run_rpc("close") # pylint: disable=protected-access
|
||||
|
||||
def delete_all(self) -> None:
|
||||
"""Delete all dock areas."""
|
||||
for widget_name in self.windows.keys():
|
||||
self.delete(widget_name)
|
||||
|
||||
def close(self):
|
||||
"""Deprecated. Use kill_server() instead."""
|
||||
# FIXME, deprecated in favor of kill, will be removed in the future
|
||||
self.kill_server()
|
||||
|
||||
def kill_server(self) -> None:
|
||||
"""Kill the GUI server."""
|
||||
self._top_level.clear()
|
||||
self._killed = True
|
||||
|
||||
if self._gui_started_timer is not None:
|
||||
self._gui_started_timer.cancel()
|
||||
@@ -357,3 +453,17 @@ class BECGuiClient(RPCBase):
|
||||
self._process_output_processing_thread.join()
|
||||
self._process.wait()
|
||||
self._process = None
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
|
||||
config = ServiceConfig()
|
||||
client = BECClient(config)
|
||||
client.start()
|
||||
|
||||
# Test the client_utils.py module
|
||||
gui = BECGuiClient()
|
||||
gui.start()
|
||||
print(gui.window_list)
|
||||
|
||||
@@ -95,9 +95,21 @@ class {class_name}(RPCBase):"""
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase):"""
|
||||
|
||||
if cls.__doc__:
|
||||
# We only want the first line of the docstring
|
||||
# But skip the first line if it's a blank line
|
||||
first_line = cls.__doc__.split("\n")[0]
|
||||
if first_line:
|
||||
class_docs = first_line
|
||||
else:
|
||||
class_docs = cls.__doc__.split("\n")[1]
|
||||
self.content += f"""
|
||||
\"\"\"{class_docs}\"\"\"
|
||||
"""
|
||||
if not cls.USER_ACCESS:
|
||||
self.content += """...
|
||||
"""
|
||||
|
||||
for method in cls.USER_ACCESS:
|
||||
is_property_setter = False
|
||||
obj = getattr(cls, method, None)
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import threading
|
||||
import uuid
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
@@ -44,7 +44,7 @@ def rpc_call(func):
|
||||
for key, val in kwargs.items():
|
||||
if hasattr(val, "name"):
|
||||
kwargs[key] = val.name
|
||||
if not self.gui_is_alive():
|
||||
if not self._root._gui_is_alive():
|
||||
raise RuntimeError("GUI is not alive")
|
||||
return self._run_rpc(func.__name__, *args, **kwargs)
|
||||
|
||||
@@ -61,10 +61,17 @@ class RPCResponseTimeoutError(Exception):
|
||||
|
||||
|
||||
class RPCBase:
|
||||
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
gui_id: str | None = None,
|
||||
config: dict | None = None,
|
||||
name: str | None = None,
|
||||
parent=None,
|
||||
) -> None:
|
||||
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
|
||||
self._config = config if config is not None else {}
|
||||
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())[:5]
|
||||
self._name = name if name is not None else str(uuid.uuid4())[:5]
|
||||
self._parent = parent
|
||||
self._msg_wait_event = threading.Event()
|
||||
self._rpc_response = None
|
||||
@@ -74,7 +81,20 @@ class RPCBase:
|
||||
def __repr__(self):
|
||||
type_ = type(self)
|
||||
qualname = type_.__qualname__
|
||||
return f"<{qualname} object at {hex(id(self))}>"
|
||||
return f"<{qualname} with name: {self.widget_name}>"
|
||||
|
||||
def remove(self):
|
||||
"""
|
||||
Remove the widget.
|
||||
"""
|
||||
self._run_rpc("remove")
|
||||
|
||||
@property
|
||||
def widget_name(self):
|
||||
"""
|
||||
Get the widget name.
|
||||
"""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def _root(self):
|
||||
@@ -88,7 +108,7 @@ class RPCBase:
|
||||
parent = parent._parent
|
||||
return parent
|
||||
|
||||
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs):
|
||||
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs) -> Any:
|
||||
"""
|
||||
Run the RPC call.
|
||||
|
||||
@@ -165,7 +185,7 @@ class RPCBase:
|
||||
return cls(parent=self, **msg_result)
|
||||
return msg_result
|
||||
|
||||
def gui_is_alive(self):
|
||||
def _gui_is_alive(self):
|
||||
"""
|
||||
Check if the GUI is alive.
|
||||
"""
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
from threading import Lock
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.containers.dock.dock import BECDock
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class RPCRegister:
|
||||
"""
|
||||
@@ -49,7 +60,7 @@ class RPCRegister:
|
||||
raise ValueError(f"RPC object {rpc} must have a 'gui_id' attribute.")
|
||||
self._rpc_register.pop(rpc.gui_id, None)
|
||||
|
||||
def get_rpc_by_id(self, gui_id: str) -> QObject:
|
||||
def get_rpc_by_id(self, gui_id: str) -> QObject | None:
|
||||
"""
|
||||
Get an RPC object by its ID.
|
||||
|
||||
@@ -57,7 +68,7 @@ class RPCRegister:
|
||||
gui_id(str): The ID of the RPC object to be retrieved.
|
||||
|
||||
Returns:
|
||||
QObject: The RPC object with the given ID.
|
||||
QObject | None: The RPC object with the given ID or None
|
||||
"""
|
||||
rpc_object = self._rpc_register.get(gui_id, None)
|
||||
return rpc_object
|
||||
@@ -73,6 +84,19 @@ class RPCRegister:
|
||||
connections = dict(self._rpc_register)
|
||||
return connections
|
||||
|
||||
def get_names_of_rpc_by_class_type(
|
||||
self, cls: BECWidget | BECConnector | BECDock | BECDockArea
|
||||
) -> list[str]:
|
||||
"""Get all the names of the widgets.
|
||||
|
||||
Args:
|
||||
cls(BECWidget | BECConnector): The class of the RPC object to be retrieved.
|
||||
"""
|
||||
# This retrieves any rpc objects that are subclass of BECWidget,
|
||||
# i.e. curve and image items are excluded
|
||||
widgets = [rpc for rpc in self._rpc_register.values() if isinstance(rpc, cls)]
|
||||
return [widget._name for widget in widgets]
|
||||
|
||||
@classmethod
|
||||
def reset_singleton(cls):
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
from typing import Any
|
||||
|
||||
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
|
||||
class RPCWidgetHandler:
|
||||
@@ -10,7 +13,7 @@ class RPCWidgetHandler:
|
||||
self._widget_classes = None
|
||||
|
||||
@property
|
||||
def widget_classes(self):
|
||||
def widget_classes(self) -> dict[str, type[BECWidget]]:
|
||||
"""
|
||||
Get the available widget classes.
|
||||
|
||||
@@ -19,7 +22,7 @@ class RPCWidgetHandler:
|
||||
"""
|
||||
if self._widget_classes is None:
|
||||
self.update_available_widgets()
|
||||
return self._widget_classes
|
||||
return self._widget_classes # type: ignore
|
||||
|
||||
def update_available_widgets(self):
|
||||
"""
|
||||
@@ -31,24 +34,25 @@ class RPCWidgetHandler:
|
||||
from bec_widgets.utils.plugin_utils import get_custom_classes
|
||||
|
||||
clss = get_custom_classes("bec_widgets")
|
||||
self._widget_classes = {cls.__name__: cls for cls in clss.widgets}
|
||||
self._widget_classes = {
|
||||
cls.__name__: cls for cls in clss.widgets if cls.__name__ not in IGNORE_WIDGETS
|
||||
}
|
||||
|
||||
def create_widget(self, widget_type, **kwargs) -> BECConnector:
|
||||
def create_widget(self, widget_type, name: str | None = None, **kwargs) -> BECWidget:
|
||||
"""
|
||||
Create a widget from an RPC message.
|
||||
|
||||
Args:
|
||||
widget_type(str): The type of the widget.
|
||||
name (str): The name of the widget.
|
||||
**kwargs: The keyword arguments for the widget.
|
||||
|
||||
Returns:
|
||||
widget(BECConnector): The created widget.
|
||||
widget(BECWidget): The created widget.
|
||||
"""
|
||||
if self._widget_classes is None:
|
||||
self.update_available_widgets()
|
||||
widget_class = self._widget_classes.get(widget_type)
|
||||
widget_class = self.widget_classes.get(widget_type) # type: ignore
|
||||
if widget_class:
|
||||
return widget_class(**kwargs)
|
||||
return widget_class(name=name, **kwargs)
|
||||
raise ValueError(f"Unknown widget type: {widget_type}")
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from bec_lib.utils.import_utils import lazy_import
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from bec_widgets.cli.rpc import rpc_register
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
@@ -36,6 +37,8 @@ def rpc_exception_hook(err_func):
|
||||
old_exception_hook = popup.custom_exception_hook
|
||||
|
||||
# install err_func, if it is a callable
|
||||
# IMPORTANT, Keep self here, because this method is overwriting the custom_exception_hook
|
||||
# of the ErrorPopupUtility (popup instance) class.
|
||||
def custom_exception_hook(self, exc_type, value, tb, **kwargs):
|
||||
err_func({"error": popup.get_error_message(exc_type, value, tb)})
|
||||
|
||||
@@ -56,16 +59,18 @@ class BECWidgetsCLIServer:
|
||||
dispatcher: BECDispatcher = None,
|
||||
client=None,
|
||||
config=None,
|
||||
gui_class: Union[BECFigure, BECDockArea] = BECFigure,
|
||||
gui_class: Union[BECFigure, BECDockArea] = BECDockArea,
|
||||
gui_class_id: str = "bec",
|
||||
) -> None:
|
||||
self.status = messages.BECStatus.BUSY
|
||||
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
|
||||
self.client = self.dispatcher.client if client is None else client
|
||||
self.client.start()
|
||||
self.gui_id = gui_id
|
||||
self.gui = gui_class(gui_id=self.gui_id)
|
||||
# register broadcast callback
|
||||
self.rpc_register = RPCRegister()
|
||||
self.rpc_register.add_rpc(self.gui)
|
||||
self.gui = gui_class(parent=None, name=gui_class_id, gui_id=gui_class_id)
|
||||
# self.rpc_register.add_rpc(self.gui)
|
||||
|
||||
self.dispatcher.connect_slot(
|
||||
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
|
||||
@@ -78,6 +83,7 @@ class BECWidgetsCLIServer:
|
||||
|
||||
self.status = messages.BECStatus.RUNNING
|
||||
logger.success(f"Server started with gui_id: {self.gui_id}")
|
||||
# Create initial object -> BECFigure or BECDockArea
|
||||
|
||||
def on_rpc_update(self, msg: dict, metadata: dict):
|
||||
request_id = metadata.get("request_id")
|
||||
@@ -135,6 +141,9 @@ class BECWidgetsCLIServer:
|
||||
if isinstance(obj, BECConnector):
|
||||
return {
|
||||
"gui_id": obj.gui_id,
|
||||
"name": (
|
||||
obj._name if hasattr(obj, "_name") else obj.__class__.__name__
|
||||
), # pylint: disable=protected-access
|
||||
"widget_class": obj.__class__.__name__,
|
||||
"config": obj.config.model_dump(),
|
||||
"__rpc__": True,
|
||||
@@ -179,7 +188,12 @@ class SimpleFileLikeFromLogOutputFunc:
|
||||
return
|
||||
|
||||
|
||||
def _start_server(gui_id: str, gui_class: Union[BECFigure, BECDockArea], config: str | None = None):
|
||||
def _start_server(
|
||||
gui_id: str,
|
||||
gui_class: Union[BECFigure, BECDockArea],
|
||||
gui_class_id: str = "bec",
|
||||
config: str | None = None,
|
||||
):
|
||||
if config:
|
||||
try:
|
||||
config = json.loads(config)
|
||||
@@ -196,7 +210,9 @@ def _start_server(gui_id: str, gui_class: Union[BECFigure, BECDockArea], config:
|
||||
# service_name="BECWidgetsCLIServer",
|
||||
# service_config=service_config.service_config,
|
||||
# )
|
||||
server = BECWidgetsCLIServer(gui_id=gui_id, config=service_config, gui_class=gui_class)
|
||||
server = BECWidgetsCLIServer(
|
||||
gui_id=gui_id, config=service_config, gui_class=gui_class, gui_class_id=gui_class_id
|
||||
)
|
||||
return server
|
||||
|
||||
|
||||
@@ -217,6 +233,12 @@ def main():
|
||||
type=str,
|
||||
help="Name of the gui class to be rendered. Possible values: \n- BECFigure\n- BECDockArea",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--gui_class_id",
|
||||
type=str,
|
||||
default="bec",
|
||||
help="The id of the gui class that is added to the QApplication",
|
||||
)
|
||||
parser.add_argument("--config", type=str, help="Config file or config string.")
|
||||
parser.add_argument("--hide", action="store_true", help="Hide on startup")
|
||||
|
||||
@@ -256,14 +278,14 @@ def main():
|
||||
# store gui id within QApplication object, to make it available to all widgets
|
||||
app.gui_id = args.id
|
||||
|
||||
server = _start_server(args.id, gui_class, args.config)
|
||||
# args.id = "abff6"
|
||||
server = _start_server(args.id, gui_class, args.gui_class_id, args.config)
|
||||
|
||||
win = BECMainWindow(gui_id=f"{server.gui_id}:window")
|
||||
win.setAttribute(Qt.WA_ShowWithoutActivating)
|
||||
win.setWindowTitle("BEC Widgets")
|
||||
win.setWindowTitle("BEC")
|
||||
|
||||
RPCRegister().add_rpc(win)
|
||||
|
||||
gui = server.gui
|
||||
win.setCentralWidget(gui)
|
||||
if not args.hide:
|
||||
|
||||
@@ -15,12 +15,15 @@ from qtpy.QtWidgets import (
|
||||
)
|
||||
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
|
||||
from bec_widgets.widgets.containers.dock import BECDockArea
|
||||
from bec_widgets.widgets.containers.figure import BECFigure
|
||||
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
|
||||
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
|
||||
from bec_widgets.widgets.plots_next_gen.image.image import Image
|
||||
from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase
|
||||
from bec_widgets.widgets.plots_next_gen.scatter_waveform.scatter_waveform import ScatterWaveform
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
|
||||
|
||||
|
||||
class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
@@ -37,6 +40,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
{
|
||||
"np": np,
|
||||
"pg": pg,
|
||||
"wh": wh,
|
||||
"fig": self.figure,
|
||||
"dock": self.dock,
|
||||
"w1": self.w1,
|
||||
@@ -50,10 +54,8 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
"w9": self.w9,
|
||||
"w10": self.w10,
|
||||
"d0": self.d0,
|
||||
"d1": self.d1,
|
||||
"d2": self.d2,
|
||||
"wave": self.wf,
|
||||
"im": self.im,
|
||||
"mi": self.mi,
|
||||
"mm": self.mm,
|
||||
"mw": self.mw,
|
||||
"lm": self.lm,
|
||||
@@ -65,6 +67,9 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
"btn6": self.btn6,
|
||||
"pb": self.pb,
|
||||
"pi": self.pi,
|
||||
"wf": self.wf,
|
||||
"scatter": self.scatter,
|
||||
"scatter_mi": self.scatter,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -100,7 +105,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
self.pb = PlotBase()
|
||||
self.pi = self.pb.plot_item
|
||||
fourth_tab_layout.addWidget(self.pb)
|
||||
tab_widget.addTab(fourth_tab, "PltoBase")
|
||||
tab_widget.addTab(fourth_tab, "PlotBase")
|
||||
|
||||
tab_widget.setCurrentIndex(3)
|
||||
|
||||
@@ -117,6 +122,33 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
self.btn5 = QPushButton("Button 5")
|
||||
self.btn6 = QPushButton("Button 6")
|
||||
|
||||
fifth_tab = QWidget()
|
||||
fifth_tab_layout = QVBoxLayout(fifth_tab)
|
||||
self.wf = Waveform()
|
||||
fifth_tab_layout.addWidget(self.wf)
|
||||
tab_widget.addTab(fifth_tab, "Waveform Next Gen")
|
||||
tab_widget.setCurrentIndex(4)
|
||||
|
||||
sixth_tab = QWidget()
|
||||
sixth_tab_layout = QVBoxLayout(sixth_tab)
|
||||
self.im = Image()
|
||||
self.mi = self.im.main_image
|
||||
sixth_tab_layout.addWidget(self.im)
|
||||
tab_widget.addTab(sixth_tab, "Image Next Gen")
|
||||
tab_widget.setCurrentIndex(5)
|
||||
|
||||
seventh_tab = QWidget()
|
||||
seventh_tab_layout = QVBoxLayout(seventh_tab)
|
||||
self.scatter = ScatterWaveform()
|
||||
self.scatter_mi = self.scatter.main_curve
|
||||
self.scatter.plot("samx", "samy", "bpm4i")
|
||||
seventh_tab_layout.addWidget(self.scatter)
|
||||
tab_widget.addTab(seventh_tab, "Scatter Waveform")
|
||||
tab_widget.setCurrentIndex(6)
|
||||
|
||||
# add stuff to the new Waveform widget
|
||||
self._init_waveform()
|
||||
|
||||
# add stuff to figure
|
||||
self._init_figure()
|
||||
|
||||
@@ -125,6 +157,13 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
|
||||
self.setWindowTitle("Jupyter Console Window")
|
||||
|
||||
def _init_waveform(self):
|
||||
# self.wfng._add_curve_custom(x=np.arange(10), y=np.random.rand(10), label="curve1")
|
||||
# self.wfng._add_curve_custom(x=np.arange(10), y=np.random.rand(10), label="curve2")
|
||||
# self.wfng._add_curve_custom(x=np.arange(10), y=np.random.rand(10), label="curve3")
|
||||
self.wf.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
|
||||
self.wf.plot(y_name="bpm3a", y_entry="bpm3a", dap="GaussianModel")
|
||||
|
||||
def _init_figure(self):
|
||||
self.w1 = self.figure.plot(x_name="samx", y_name="bpm4i", row=0, col=0)
|
||||
self.w1.set(
|
||||
@@ -182,18 +221,11 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
|
||||
def _init_dock(self):
|
||||
|
||||
self.d0 = self.dock.add_dock(name="dock_0")
|
||||
self.mm = self.d0.add_widget("BECMotorMapWidget")
|
||||
self.d0 = self.dock.new(name="dock_0")
|
||||
self.mm = self.d0.new("BECMotorMapWidget")
|
||||
self.mm.change_motors("samx", "samy")
|
||||
|
||||
self.d1 = self.dock.add_dock(name="dock_1", position="right")
|
||||
self.im = self.d1.add_widget("BECImageWidget")
|
||||
self.im.image("waveform", "1d")
|
||||
|
||||
self.d2 = self.dock.add_dock(name="dock_2", position="bottom")
|
||||
self.wf = self.d2.add_widget("BECFigure", row=0, col=0)
|
||||
|
||||
self.mw = self.wf.multi_waveform(monitor="waveform") # , config=config)
|
||||
self.mw = None # self.wf.multi_waveform(monitor="waveform") # , config=config)
|
||||
|
||||
self.dock.save_state()
|
||||
|
||||
@@ -218,8 +250,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Jupyter Console")
|
||||
app.setApplicationDisplayName("Jupyter Console")
|
||||
apply_theme("dark")
|
||||
icon = material_icon("terminal", color="#434343", filled=True)
|
||||
icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
@@ -228,7 +259,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
win = JupyterConsoleWindow()
|
||||
win.show()
|
||||
win.resize(1200, 800)
|
||||
win.resize(1500, 800)
|
||||
|
||||
app.aboutToQuit.connect(win.close)
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -317,9 +317,9 @@ class ExampleApp(QMainWindow): # pragma: no cover
|
||||
self.side_panel = SidePanel(self, orientation="left", panel_max_width=250)
|
||||
self.layout.addWidget(self.side_panel)
|
||||
|
||||
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
|
||||
|
||||
self.plot = BECWaveformWidget()
|
||||
self.plot = Waveform()
|
||||
self.layout.addWidget(self.plot)
|
||||
|
||||
self.add_side_menus()
|
||||
|
||||
@@ -279,7 +279,6 @@ class SwitchableToolBarAction(ToolBarAction):
|
||||
self.main_button.setToolTip(default_action.tooltip)
|
||||
self.main_button.clicked.connect(self._trigger_current_action)
|
||||
menu = QMenu(self.main_button)
|
||||
self.menu_actions = {}
|
||||
for key, action_obj in self.actions.items():
|
||||
menu_action = QAction(action_obj.get_icon(), action_obj.tooltip, self.main_button)
|
||||
menu_action.setIconVisibleInMenu(True)
|
||||
@@ -287,23 +286,54 @@ class SwitchableToolBarAction(ToolBarAction):
|
||||
menu_action.setChecked(key == self.current_key)
|
||||
menu_action.triggered.connect(lambda checked, k=key: self.set_default_action(k))
|
||||
menu.addAction(menu_action)
|
||||
self.menu_actions[key] = menu_action
|
||||
self.main_button.setMenu(menu)
|
||||
toolbar.addWidget(self.main_button)
|
||||
|
||||
def _trigger_current_action(self):
|
||||
"""
|
||||
Triggers the current action associated with the main button.
|
||||
"""
|
||||
action_obj = self.actions[self.current_key]
|
||||
action_obj.action.trigger()
|
||||
|
||||
def set_default_action(self, key: str):
|
||||
"""
|
||||
Sets the default action for the split action.
|
||||
|
||||
Args:
|
||||
key(str): The key of the action to set as default.
|
||||
"""
|
||||
self.current_key = key
|
||||
new_action = self.actions[self.current_key]
|
||||
self.main_button.setIcon(new_action.get_icon())
|
||||
self.main_button.setToolTip(new_action.tooltip)
|
||||
# Update check state of menu items
|
||||
for k, menu_act in self.menu_actions.items():
|
||||
menu_act.setChecked(k == key)
|
||||
for k, menu_act in self.actions.items():
|
||||
menu_act.action.setChecked(False)
|
||||
new_action.action.trigger()
|
||||
# Active action chosen from menu is always checked, uncheck through main button
|
||||
if self.checkable:
|
||||
new_action.action.setChecked(True)
|
||||
self.main_button.setChecked(True)
|
||||
|
||||
def block_all_signals(self, block: bool = True):
|
||||
"""
|
||||
Blocks or unblocks all signals for the actions in the toolbar.
|
||||
|
||||
Args:
|
||||
block (bool): Whether to block signals. Defaults to True.
|
||||
"""
|
||||
self.main_button.blockSignals(block)
|
||||
for action in self.actions.values():
|
||||
action.action.blockSignals(block)
|
||||
|
||||
def set_state_all(self, state: bool):
|
||||
"""
|
||||
Uncheck all actions in the toolbar.
|
||||
"""
|
||||
for action in self.actions.values():
|
||||
action.action.setChecked(state)
|
||||
self.main_button.setChecked(state)
|
||||
|
||||
def get_icon(self) -> QIcon:
|
||||
return self.actions[self.current_key].get_icon()
|
||||
@@ -318,11 +348,18 @@ class WidgetAction(ToolBarAction):
|
||||
widget (QWidget): The widget to be added to the toolbar.
|
||||
"""
|
||||
|
||||
def __init__(self, label: str | None = None, widget: QWidget = None, parent=None):
|
||||
def __init__(
|
||||
self,
|
||||
label: str | None = None,
|
||||
widget: QWidget = None,
|
||||
adjust_size: bool = True,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(icon_path=None, tooltip=label, checkable=False)
|
||||
self.label = label
|
||||
self.widget = widget
|
||||
self.container = None
|
||||
self.adjust_size = adjust_size
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""
|
||||
@@ -343,7 +380,7 @@ class WidgetAction(ToolBarAction):
|
||||
label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
|
||||
layout.addWidget(label_widget)
|
||||
|
||||
if isinstance(self.widget, QComboBox):
|
||||
if isinstance(self.widget, QComboBox) and self.adjust_size:
|
||||
self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
||||
|
||||
size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
@@ -827,7 +864,7 @@ class MainWindow(QMainWindow): # pragma: no cover
|
||||
|
||||
def add_bundles(self):
|
||||
home_action = MaterialIconAction(
|
||||
icon_name="home", tooltip="Home", checkable=True, parent=self
|
||||
icon_name="home", tooltip="Home", checkable=False, parent=self
|
||||
)
|
||||
settings_action = MaterialIconAction(
|
||||
icon_name="settings", tooltip="Settings", checkable=True, parent=self
|
||||
@@ -844,6 +881,7 @@ class MainWindow(QMainWindow): # pragma: no cover
|
||||
],
|
||||
)
|
||||
self.toolbar.add_bundle(main_actions_bundle, target_widget=self)
|
||||
home_action.action.triggered.connect(lambda: self.switchable_action.set_state_all(False))
|
||||
|
||||
search_action = MaterialIconAction(
|
||||
icon_name="search", tooltip="Search", checkable=False, parent=self
|
||||
@@ -897,20 +935,20 @@ class MainWindow(QMainWindow): # pragma: no cover
|
||||
|
||||
def add_switchable_button_checkable(self):
|
||||
action1 = MaterialIconAction(
|
||||
icon_name="counter_1", tooltip="Action 1", checkable=True, parent=self
|
||||
icon_name="hdr_auto", tooltip="Action 1", checkable=True, parent=self
|
||||
)
|
||||
action2 = MaterialIconAction(
|
||||
icon_name="counter_2", tooltip="Action 2", checkable=True, parent=self
|
||||
icon_name="hdr_auto", tooltip="Action 2", checkable=True, filled=True, parent=self
|
||||
)
|
||||
|
||||
switchable_action = SwitchableToolBarAction(
|
||||
self.switchable_action = SwitchableToolBarAction(
|
||||
actions={"action1": action1, "action2": action2},
|
||||
initial_action="action1",
|
||||
tooltip="Switchable Action",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
self.toolbar.add_action("switchable_action", switchable_action, self)
|
||||
self.toolbar.add_action("switchable_action", self.switchable_action, self)
|
||||
|
||||
action1.action.toggled.connect(
|
||||
lambda checked: self.test_label.setText(f"Action 1 triggered, checked = {checked}")
|
||||
@@ -931,16 +969,20 @@ class MainWindow(QMainWindow): # pragma: no cover
|
||||
actions={"action1": action1, "action2": action2},
|
||||
initial_action="action1",
|
||||
tooltip="Switchable Action",
|
||||
checkable=True,
|
||||
checkable=False,
|
||||
parent=self,
|
||||
)
|
||||
self.toolbar.add_action("switchable_action_no_toggle", switchable_action, self)
|
||||
|
||||
action1.action.triggered.connect(
|
||||
lambda checked: self.test_label.setText(f"Action 1 triggered, checked = {checked}")
|
||||
lambda checked: self.test_label.setText(
|
||||
f"Action 1 (non-checkable) triggered, checked = {checked}"
|
||||
)
|
||||
)
|
||||
action2.action.triggered.connect(
|
||||
lambda checked: self.test_label.setText(f"Action 2 triggered, checked = {checked}")
|
||||
lambda checked: self.test_label.setText(
|
||||
f"Action 2 (non-checkable) triggered, checked = {checked}"
|
||||
)
|
||||
)
|
||||
switchable_action.actions["action1"].action.setChecked(True)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
@@ -15,6 +16,7 @@ from qtpy.QtWidgets import QApplication
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot as pyqtSlot
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -39,8 +41,7 @@ class ConnectionConfig(BaseModel):
|
||||
"""Generate a GUI ID if none is provided."""
|
||||
if v is None:
|
||||
widget_class = values.data["widget_class"]
|
||||
v = f"{widget_class}_{str(time.time())}"
|
||||
return v
|
||||
v = f"{widget_class}_{datetime.now().strftime('%Y_%m_%d_%H_%M_%S_%f')}"
|
||||
return v
|
||||
|
||||
|
||||
@@ -75,7 +76,13 @@ class BECConnector:
|
||||
USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"]
|
||||
EXIT_HANDLERS = {}
|
||||
|
||||
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
|
||||
def __init__(
|
||||
self,
|
||||
client=None,
|
||||
config: ConnectionConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
name: str | None = None,
|
||||
):
|
||||
# BEC related connections
|
||||
self.bec_dispatcher = BECDispatcher(client=client)
|
||||
self.client = self.bec_dispatcher.client if client is None else client
|
||||
@@ -103,15 +110,22 @@ class BECConnector:
|
||||
)
|
||||
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
|
||||
|
||||
# I feel that we should not allow BECConnector to be created with a custom gui_id
|
||||
# because this would break with the logic in the RPCRegister of retrieving widgets by type
|
||||
# iterating over all widgets and checkinf if the register widget starts with the string that is passsed.
|
||||
# If the gui_id is randomly generated, this would break since that widget would have a
|
||||
# gui_id that is generated in a different way.
|
||||
if gui_id:
|
||||
self.config.gui_id = gui_id
|
||||
self.gui_id = gui_id
|
||||
self.gui_id: str = gui_id
|
||||
else:
|
||||
self.gui_id = self.config.gui_id
|
||||
|
||||
# register widget to rpc register
|
||||
# be careful: when registering, and the object is not a BECWidget,
|
||||
# cleanup has to be called manually since there is no 'closeEvent'
|
||||
self.gui_id: str = self.config.gui_id # type: ignore
|
||||
if name is None:
|
||||
name = self.__class__.__name__
|
||||
else:
|
||||
if not WidgetContainerUtils.has_name_valid_chars(name):
|
||||
raise ValueError(f"Name {name} contains invalid characters.")
|
||||
self._name = name if name else self.__class__.__name__
|
||||
self.rpc_register = RPCRegister()
|
||||
self.rpc_register.add_rpc(self)
|
||||
|
||||
@@ -195,6 +209,7 @@ class BECConnector:
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
# FIXME some thoughts are required to decide how thhis should work with rpc registry
|
||||
def apply_config(self, config: dict, generate_new_id: bool = True) -> None:
|
||||
"""
|
||||
Apply the configuration to the widget.
|
||||
@@ -207,11 +222,12 @@ class BECConnector:
|
||||
if generate_new_id is True:
|
||||
gui_id = str(uuid.uuid4())
|
||||
self.rpc_register.remove_rpc(self)
|
||||
self.set_gui_id(gui_id)
|
||||
self._set_gui_id(gui_id)
|
||||
self.rpc_register.add_rpc(self)
|
||||
else:
|
||||
self.gui_id = self.config.gui_id
|
||||
|
||||
# FIXME some thoughts are required to decide how thhis should work with rpc registry
|
||||
def load_config(self, path: str | None = None, gui: bool = False):
|
||||
"""
|
||||
Load the configuration of the widget from YAML.
|
||||
@@ -248,8 +264,8 @@ class BECConnector:
|
||||
file_path = os.path.join(path, f"{self.__class__.__name__}_config.yaml")
|
||||
save_yaml(file_path, self._config_dict)
|
||||
|
||||
@pyqtSlot(str)
|
||||
def set_gui_id(self, gui_id: str) -> None:
|
||||
# @pyqtSlot(str)
|
||||
def _set_gui_id(self, gui_id: str) -> None:
|
||||
"""
|
||||
Set the GUI ID for the widget.
|
||||
|
||||
@@ -288,9 +304,21 @@ class BECConnector:
|
||||
Args:
|
||||
config (ConnectionConfig | dict): Configuration settings.
|
||||
"""
|
||||
gui_id = getattr(config, "gui_id", None)
|
||||
if isinstance(config, dict):
|
||||
config = ConnectionConfig(**config)
|
||||
self.config = config
|
||||
if gui_id and config.gui_id != gui_id: # Recreating config should not overwrite the gui_id
|
||||
self.config.gui_id = gui_id
|
||||
|
||||
def remove(self):
|
||||
"""Cleanup the BECConnector"""
|
||||
if hasattr(self, "close"):
|
||||
self.close()
|
||||
if hasattr(self, "deleteLater"):
|
||||
self.deleteLater()
|
||||
else:
|
||||
self.rpc_register.remove_rpc(self)
|
||||
|
||||
def get_config(self, dict_output: bool = True) -> dict | BaseModel:
|
||||
"""
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import darkdetect
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Slot
|
||||
@@ -7,6 +9,10 @@ from qtpy.QtWidgets import QApplication, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.widgets.containers.dock import BECDock
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -17,13 +23,17 @@ class BECWidget(BECConnector):
|
||||
# The icon name is the name of the icon in the icon theme, typically a name taken
|
||||
# from fonts.google.com/icons. Override this in subclasses to set the icon name.
|
||||
ICON_NAME = "widgets"
|
||||
USER_ACCESS = ["remove"]
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
self,
|
||||
client=None,
|
||||
config: ConnectionConfig = None,
|
||||
gui_id: str = None,
|
||||
gui_id: str | None = None,
|
||||
theme_update: bool = False,
|
||||
name: str | None = None,
|
||||
parent_dock: BECDock | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
@@ -45,9 +55,9 @@ class BECWidget(BECConnector):
|
||||
"""
|
||||
if not isinstance(self, QWidget):
|
||||
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
|
||||
# Set the theme to auto if it is not set yet
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, name=name)
|
||||
self._parent_dock = parent_dock
|
||||
app = QApplication.instance()
|
||||
if not hasattr(app, "theme"):
|
||||
# DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault
|
||||
@@ -88,10 +98,12 @@ class BECWidget(BECConnector):
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
# All widgets need to call super().cleanup() in their cleanup method
|
||||
self.rpc_register.remove_rpc(self)
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.rpc_register.remove_rpc(self)
|
||||
"""Wrap the close even to ensure the rpc_register is cleaned up."""
|
||||
try:
|
||||
self.cleanup()
|
||||
finally:
|
||||
super().closeEvent(event)
|
||||
super().closeEvent(event) # pylint: disable=no-member
|
||||
|
||||
@@ -1,30 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from typing import Type
|
||||
from typing import Literal, Type
|
||||
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
|
||||
|
||||
class WidgetContainerUtils:
|
||||
|
||||
# We need one handler that checks if a WIDGET of a given name is already created for that DOCKAREA
|
||||
# 1. If the name exists, then it depends whether the name was auto-generated -> add _1 to the name
|
||||
# or alternatively raise an error that it can't be added again ( just raise an error)
|
||||
# 2. Dock names in between docks should also be unique
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_widget_id(container: dict, prefix: str = "widget") -> str:
|
||||
"""
|
||||
Generate a unique widget ID.
|
||||
def has_name_valid_chars(name: str) -> bool:
|
||||
"""Check if the name is valid.
|
||||
|
||||
Args:
|
||||
container(dict): The container of widgets.
|
||||
prefix(str): The prefix of the widget ID.
|
||||
name(str): The name to be checked.
|
||||
|
||||
Returns:
|
||||
widget_id(str): The unique widget ID.
|
||||
bool: True if the name is valid, False otherwise.
|
||||
"""
|
||||
existing_ids = set(container.keys())
|
||||
for i in itertools.count(1):
|
||||
widget_id = f"{prefix}_{i}"
|
||||
if widget_id not in existing_ids:
|
||||
return widget_id
|
||||
if not name or len(name) > 256:
|
||||
return False # Don't accept empty names or names longer than 256 characters
|
||||
check_value = name.replace("_", "").replace("-", "")
|
||||
if not check_value.isalnum() or not check_value.isascii():
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_name(name: str, list_of_names: list[str] | None = None) -> str:
|
||||
"""Generate a unique ID.
|
||||
|
||||
Args:
|
||||
name(str): The name of the widget.
|
||||
Returns:
|
||||
tuple (str): The unique name
|
||||
"""
|
||||
if list_of_names is None:
|
||||
list_of_names = []
|
||||
ii = 0
|
||||
while ii < 1000: # 1000 is arbritrary!
|
||||
name_candidate = f"{name}_{ii}"
|
||||
if name_candidate not in list_of_names:
|
||||
return name_candidate
|
||||
ii += 1
|
||||
raise ValueError("Could not generate a unique name after within 1000 attempts.")
|
||||
|
||||
@staticmethod
|
||||
def find_first_widget_by_class(
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
@@ -197,15 +200,18 @@ class Crosshair(QObject):
|
||||
self.marker_2d = pg.ROI(
|
||||
[0, 0], size=[1, 1], pen=pg.mkPen("r", width=2), movable=False
|
||||
)
|
||||
self.marker_2d.skip_auto_range = True
|
||||
self.plot_item.addItem(self.marker_2d)
|
||||
|
||||
def snap_to_data(self, x, y) -> tuple[defaultdict[list], defaultdict[list]]:
|
||||
def snap_to_data(
|
||||
self, x: float, y: float
|
||||
) -> tuple[None, None] | tuple[defaultdict[Any, list], defaultdict[Any, list]]:
|
||||
"""
|
||||
Finds the nearest data points to the given x and y coordinates.
|
||||
|
||||
Args:
|
||||
x: The x-coordinate of the mouse cursor
|
||||
y: The y-coordinate of the mouse cursor
|
||||
x(float): The x-coordinate of the mouse cursor
|
||||
y(float): The y-coordinate of the mouse cursor
|
||||
|
||||
Returns:
|
||||
tuple: x and y values snapped to the nearest data
|
||||
@@ -235,7 +241,7 @@ class Crosshair(QObject):
|
||||
y_values[name] = closest_y
|
||||
x_values[name] = closest_x
|
||||
elif isinstance(item, pg.ImageItem): # 2D plot
|
||||
name = item.config.monitor
|
||||
name = item.config.monitor or str(id(item))
|
||||
image_2d = item.image
|
||||
# Clip the x and y values to the image dimensions to avoid out of bounds errors
|
||||
y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1))
|
||||
@@ -320,7 +326,7 @@ class Crosshair(QObject):
|
||||
)
|
||||
self.coordinatesChanged1D.emit(coordinate_to_emit)
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
name = item.config.monitor
|
||||
name = item.config.monitor or str(id(item))
|
||||
x, y = x_snap_values[name], y_snap_values[name]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
@@ -374,7 +380,7 @@ class Crosshair(QObject):
|
||||
)
|
||||
self.coordinatesClicked1D.emit(coordinate_to_emit)
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
name = item.config.monitor
|
||||
name = item.config.monitor or str(id(item))
|
||||
x, y = x_snap_values[name], y_snap_values[name]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
@@ -418,9 +424,17 @@ class Crosshair(QObject):
|
||||
"""
|
||||
x, y = pos
|
||||
x_scaled, y_scaled = self.scale_emitted_coordinates(x, y)
|
||||
|
||||
text = f"({x_scaled:.{self.precision}g}, {y_scaled:.{self.precision}g})"
|
||||
for item in self.items:
|
||||
if isinstance(item, pg.ImageItem):
|
||||
image = item.image
|
||||
ix = int(np.clip(x, 0, image.shape[0] - 1))
|
||||
iy = int(np.clip(y, 0, image.shape[1] - 1))
|
||||
intensity = image[ix, iy]
|
||||
text += f"\nIntensity: {intensity:.{self.precision}g}"
|
||||
break
|
||||
# Update coordinate label
|
||||
self.coord_label.setText(f"({x_scaled:.{self.precision}g}, {y_scaled:.{self.precision}g})")
|
||||
self.coord_label.setText(text)
|
||||
self.coord_label.setPos(x, y)
|
||||
self.coord_label.setVisible(True)
|
||||
|
||||
@@ -436,6 +450,9 @@ class Crosshair(QObject):
|
||||
self.clear_markers()
|
||||
|
||||
def cleanup(self):
|
||||
if self.marker_2d is not None:
|
||||
self.plot_item.removeItem(self.marker_2d)
|
||||
self.marker_2d = None
|
||||
self.plot_item.removeItem(self.v_line)
|
||||
self.plot_item.removeItem(self.h_line)
|
||||
self.plot_item.removeItem(self.coord_label)
|
||||
|
||||
@@ -22,7 +22,9 @@ class EntryValidator:
|
||||
if entry is None or entry == "":
|
||||
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
|
||||
if entry not in description:
|
||||
raise ValueError(f"Entry '{entry}' not found in device '{name}' signals")
|
||||
raise ValueError(
|
||||
f"Entry '{entry}' not found in device '{name}' signals. Available signals: {description.keys()}"
|
||||
)
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
@@ -148,10 +148,7 @@ class BECTickItem(BECIndicatorItem):
|
||||
def cleanup(self) -> None:
|
||||
"""Cleanup the item"""
|
||||
self.remove_from_plot()
|
||||
if self.tick_item is not None:
|
||||
self.tick_item.close()
|
||||
self.tick_item.deleteLater()
|
||||
self.tick_item = None
|
||||
self.tick_item = None
|
||||
|
||||
|
||||
class BECArrowItem(BECIndicatorItem):
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional, cast
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field
|
||||
from pyqtgraph.dockarea import Dock, DockLabel
|
||||
from qtpy import QtCore, QtGui
|
||||
|
||||
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils import ConnectionConfig, GridLayoutManager
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
|
||||
class DockConfig(ConnectionConfig):
|
||||
widgets: dict[str, Any] = Field({}, description="The widgets in the dock.")
|
||||
position: Literal["bottom", "top", "left", "right", "above", "below"] = Field(
|
||||
"bottom", description="The position of the dock."
|
||||
)
|
||||
parent_dock_area: Optional[str] = Field(
|
||||
parent_dock_area: Optional[str] | None = Field(
|
||||
None, description="The GUI ID of parent dock area of the dock."
|
||||
)
|
||||
|
||||
@@ -103,16 +111,17 @@ class BECDock(BECWidget, Dock):
|
||||
ICON_NAME = "widgets"
|
||||
USER_ACCESS = [
|
||||
"_config_dict",
|
||||
"_rpc_id",
|
||||
"widget_list",
|
||||
"element_list",
|
||||
"elements",
|
||||
"new",
|
||||
"show",
|
||||
"hide",
|
||||
"show_title_bar",
|
||||
"hide_title_bar",
|
||||
"get_widgets_positions",
|
||||
"set_title",
|
||||
"add_widget",
|
||||
"list_eligible_widgets",
|
||||
"move_widget",
|
||||
"remove_widget",
|
||||
"hide_title_bar",
|
||||
"available_widgets",
|
||||
"delete",
|
||||
"delete_all",
|
||||
"remove",
|
||||
"attach",
|
||||
"detach",
|
||||
@@ -121,7 +130,7 @@ class BECDock(BECWidget, Dock):
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
parent_dock_area: QWidget | None = None,
|
||||
parent_dock_area: BECDockArea | None = None,
|
||||
config: DockConfig | None = None,
|
||||
name: str | None = None,
|
||||
client=None,
|
||||
@@ -129,21 +138,24 @@ class BECDock(BECWidget, Dock):
|
||||
closable: bool = True,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
|
||||
if config is None:
|
||||
config = DockConfig(
|
||||
widget_class=self.__class__.__name__, parent_dock_area=parent_dock_area.gui_id
|
||||
widget_class=self.__class__.__name__,
|
||||
parent_dock_area=parent_dock_area.gui_id if parent_dock_area else None,
|
||||
)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = DockConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
super().__init__(
|
||||
client=client, config=config, gui_id=gui_id, name=name
|
||||
) # Name was checked and created in BEC Widget
|
||||
label = CustomDockLabel(text=name, closable=closable)
|
||||
Dock.__init__(self, name=name, label=label, **kwargs)
|
||||
Dock.__init__(self, name=name, label=label, parent=self, **kwargs)
|
||||
# Dock.__init__(self, name=name, **kwargs)
|
||||
|
||||
self.parent_dock_area = parent_dock_area
|
||||
|
||||
# Layout Manager
|
||||
self.layout_manager = GridLayoutManager(self.layout)
|
||||
|
||||
@@ -173,7 +185,18 @@ class BECDock(BECWidget, Dock):
|
||||
super().float()
|
||||
|
||||
@property
|
||||
def widget_list(self) -> list[BECWidget]:
|
||||
def elements(self) -> dict[str, BECWidget]:
|
||||
"""
|
||||
Get the widgets in the dock.
|
||||
|
||||
Returns:
|
||||
widgets(dict): The widgets in the dock.
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
return dict((widget._name, widget) for widget in self.element_list)
|
||||
|
||||
@property
|
||||
def element_list(self) -> list[BECWidget]:
|
||||
"""
|
||||
Get the widgets in the dock.
|
||||
|
||||
@@ -182,10 +205,6 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
return self.widgets
|
||||
|
||||
@widget_list.setter
|
||||
def widget_list(self, value: list[BECWidget]):
|
||||
self.widgets = value
|
||||
|
||||
def hide_title_bar(self):
|
||||
"""
|
||||
Hide the title bar of the dock.
|
||||
@@ -194,6 +213,20 @@ class BECDock(BECWidget, Dock):
|
||||
self.label.hide()
|
||||
self.labelHidden = True
|
||||
|
||||
def show(self):
|
||||
"""
|
||||
Show the dock.
|
||||
"""
|
||||
super().show()
|
||||
self.show_title_bar()
|
||||
|
||||
def hide(self):
|
||||
"""
|
||||
Hide the dock.
|
||||
"""
|
||||
self.hide_title_bar()
|
||||
super().hide()
|
||||
|
||||
def show_title_bar(self):
|
||||
"""
|
||||
Hide the title bar of the dock.
|
||||
@@ -211,7 +244,6 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
self.orig_area.docks[title] = self.orig_area.docks.pop(self.name())
|
||||
self.setTitle(title)
|
||||
self._name = title
|
||||
|
||||
def get_widgets_positions(self) -> dict:
|
||||
"""
|
||||
@@ -222,7 +254,7 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
return self.layout_manager.get_widgets_positions()
|
||||
|
||||
def list_eligible_widgets(
|
||||
def available_widgets(
|
||||
self,
|
||||
) -> list: # TODO can be moved to some util mixin like container class for rpc widgets
|
||||
"""
|
||||
@@ -233,20 +265,29 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
return list(widget_handler.widget_classes.keys())
|
||||
|
||||
def add_widget(
|
||||
def _get_list_of_widget_name_of_parent_dock_area(self):
|
||||
docks = self.parent_dock_area.panel_list
|
||||
widgets = []
|
||||
for dock in docks:
|
||||
widgets.extend(dock.elements.keys())
|
||||
return widgets
|
||||
|
||||
def new(
|
||||
self,
|
||||
widget: BECWidget | str,
|
||||
row=None,
|
||||
col=0,
|
||||
rowspan=1,
|
||||
colspan=1,
|
||||
name: str | None = None,
|
||||
row: int | None = None,
|
||||
col: int = 0,
|
||||
rowspan: int = 1,
|
||||
colspan: int = 1,
|
||||
shift: Literal["down", "up", "left", "right"] = "down",
|
||||
) -> BECWidget:
|
||||
"""
|
||||
Add a widget to the dock.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to add.
|
||||
widget(QWidget): The widget to add. It can not be BECDock or BECDockArea.
|
||||
name(str): The name of the widget.
|
||||
row(int): The row to add the widget to. If None, the widget will be added to the next available row.
|
||||
col(int): The column to add the widget to.
|
||||
rowspan(int): The number of rows the widget should span.
|
||||
@@ -254,15 +295,39 @@ class BECDock(BECWidget, Dock):
|
||||
shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied.
|
||||
"""
|
||||
if row is None:
|
||||
# row = cast(int, self.layout.rowCount()) # type:ignore
|
||||
row = self.layout.rowCount()
|
||||
# row = cast(int, row)
|
||||
|
||||
if self.layout_manager.is_position_occupied(row, col):
|
||||
self.layout_manager.shift_widgets(shift, start_row=row)
|
||||
|
||||
existing_widgets_parent_dock = self._get_list_of_widget_name_of_parent_dock_area()
|
||||
|
||||
if name is not None: # Name is provided
|
||||
if name in existing_widgets_parent_dock:
|
||||
# pylint: disable=protected-access
|
||||
raise ValueError(
|
||||
f"Name {name} must be unique for widgets, but already exists in DockArea "
|
||||
f"with name: {self.parent_dock_area._name} and id {self.parent_dock_area.gui_id}."
|
||||
)
|
||||
else: # Name is not provided
|
||||
widget_class_name = widget if isinstance(widget, str) else widget.__class__.__name__
|
||||
name = WidgetContainerUtils.generate_unique_name(
|
||||
name=widget_class_name, list_of_names=existing_widgets_parent_dock
|
||||
)
|
||||
# Check that Widget is not BECDock or BECDockArea
|
||||
widget_class_name = widget if isinstance(widget, str) else widget.__class__.__name__
|
||||
if widget_class_name in IGNORE_WIDGETS:
|
||||
raise ValueError(f"Widget {widget} can not be added to dock.")
|
||||
|
||||
if isinstance(widget, str):
|
||||
widget = widget_handler.create_widget(widget)
|
||||
widget = cast(
|
||||
BECWidget,
|
||||
widget_handler.create_widget(widget_type=widget, name=name, parent_dock=self),
|
||||
)
|
||||
else:
|
||||
widget = widget
|
||||
widget._name = name # pylint: disable=protected-access
|
||||
|
||||
self.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
|
||||
|
||||
@@ -294,37 +359,72 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
self.float()
|
||||
|
||||
def remove_widget(self, widget_rpc_id: str):
|
||||
"""
|
||||
Remove a widget from the dock.
|
||||
|
||||
Args:
|
||||
widget_rpc_id(str): The ID of the widget to remove.
|
||||
"""
|
||||
widget = self.rpc_register.get_rpc_by_id(widget_rpc_id)
|
||||
self.layout.removeWidget(widget)
|
||||
self.config.widgets.pop(widget_rpc_id, None)
|
||||
widget.close()
|
||||
|
||||
def remove(self):
|
||||
"""
|
||||
Remove the dock from the parent dock area.
|
||||
"""
|
||||
# self.cleanup()
|
||||
self.parent_dock_area.remove_dock(self.name())
|
||||
self.parent_dock_area.delete(self._name)
|
||||
|
||||
def delete(self, widget_name: str) -> None:
|
||||
"""
|
||||
Remove a widget from the dock.
|
||||
|
||||
Args:
|
||||
widget_name(str): Delete the widget with the given name.
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
widgets = [widget for widget in self.widgets if widget._name == widget_name]
|
||||
if len(widgets) == 0:
|
||||
logger.warning(
|
||||
f"Widget with name {widget_name} not found in dock {self.name()}. "
|
||||
f"Checking if gui_id was passed as widget_name."
|
||||
)
|
||||
# Try to find the widget in the RPC register, maybe the gui_id was passed as widget_name
|
||||
widget = self.rpc_register.get_rpc_by_id(widget_name)
|
||||
if widget is None:
|
||||
logger.warning(
|
||||
f"Widget not found for name or gui_id: {widget_name} in dock {self.name()}"
|
||||
)
|
||||
return
|
||||
else:
|
||||
widget = widgets[0]
|
||||
self.layout.removeWidget(widget)
|
||||
self.config.widgets.pop(widget._name, None)
|
||||
if widget in self.widgets:
|
||||
self.widgets.remove(widget)
|
||||
widget.close()
|
||||
# self._broadcast_update()
|
||||
|
||||
def delete_all(self):
|
||||
"""
|
||||
Remove all widgets from the dock.
|
||||
"""
|
||||
for widget in self.widgets:
|
||||
self.delete(widget._name) # pylint: disable=protected-access
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Clean up the dock, including all its widgets.
|
||||
"""
|
||||
for widget in self.widgets:
|
||||
if hasattr(widget, "cleanup"):
|
||||
widget.cleanup()
|
||||
# Remove the dock from the parent dock area
|
||||
if self.parent_dock_area:
|
||||
self.parent_dock_area.dock_area.docks.pop(self.name(), None)
|
||||
self.parent_dock_area.config.docks.pop(self.name(), None)
|
||||
self.delete_all()
|
||||
self.widgets.clear()
|
||||
self.label.close()
|
||||
self.label.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
# def closeEvent(self, event): # pylint: disable=uselsess-parent-delegation
|
||||
# """Close Event for dock and cleanup.
|
||||
|
||||
# This wrapper ensures that the BECWidget close event is triggered.
|
||||
# If removed, the closeEvent from pyqtgraph will be triggered, which
|
||||
# is not calling super().closeEvent(event) and will not trigger the BECWidget close event.
|
||||
# """
|
||||
# return super().closeEvent(event)
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Close the dock area and cleanup.
|
||||
@@ -332,4 +432,15 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
self.cleanup()
|
||||
super().close()
|
||||
self.parent_dock_area.dock_area.docks.pop(self.name(), None)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
dock = BECDock(name="dock")
|
||||
dock.show()
|
||||
app.exec_()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -4,12 +4,14 @@ from typing import Literal, Optional
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field
|
||||
from pyqtgraph.dockarea.DockArea import DockArea
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QPainter, QPaintEvent
|
||||
from qtpy.QtWidgets import QApplication, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
from bec_widgets.qt_utils.toolbar import (
|
||||
ExpandableMenuAction,
|
||||
@@ -23,16 +25,19 @@ from bec_widgets.widgets.containers.dock.dock import BECDock, DockConfig
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
|
||||
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
|
||||
from bec_widgets.widgets.plots.image.image_widget import BECImageWidget
|
||||
from bec_widgets.widgets.plots.motor_map.motor_map_widget import BECMotorMapWidget
|
||||
from bec_widgets.widgets.plots.multi_waveform.multi_waveform_widget import BECMultiWaveformWidget
|
||||
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
|
||||
from bec_widgets.widgets.plots_next_gen.image.image import Image
|
||||
from bec_widgets.widgets.plots_next_gen.scatter_waveform.scatter_waveform import ScatterWaveform
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
|
||||
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
|
||||
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
|
||||
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DockAreaConfig(ConnectionConfig):
|
||||
docks: dict[str, DockConfig] = Field({}, description="The docks in the dock area.")
|
||||
@@ -44,21 +49,19 @@ class DockAreaConfig(ConnectionConfig):
|
||||
class BECDockArea(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
USER_ACCESS = [
|
||||
"_config_dict",
|
||||
"selected_device",
|
||||
"panels",
|
||||
"save_state",
|
||||
"remove_dock",
|
||||
"restore_state",
|
||||
"add_dock",
|
||||
"clear_all",
|
||||
"detach_dock",
|
||||
"attach_all",
|
||||
"_get_all_rpc",
|
||||
"temp_areas",
|
||||
"new",
|
||||
"show",
|
||||
"hide",
|
||||
"panels",
|
||||
"panel_list",
|
||||
"delete",
|
||||
"delete_all",
|
||||
"remove",
|
||||
"detach_dock",
|
||||
"attach_all",
|
||||
"selected_device",
|
||||
"save_state",
|
||||
"restore_state",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
@@ -67,6 +70,8 @@ class BECDockArea(BECWidget, QWidget):
|
||||
config: DockAreaConfig | None = None,
|
||||
client=None,
|
||||
gui_id: str = None,
|
||||
name: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
if config is None:
|
||||
config = DockAreaConfig(widget_class=self.__class__.__name__)
|
||||
@@ -74,8 +79,9 @@ class BECDockArea(BECWidget, QWidget):
|
||||
if isinstance(config, dict):
|
||||
config = DockAreaConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, name=name, **kwargs)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
self._parent = parent
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setSpacing(5)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -89,8 +95,11 @@ class BECDockArea(BECWidget, QWidget):
|
||||
label="Add Plot ",
|
||||
actions={
|
||||
"waveform": MaterialIconAction(
|
||||
icon_name=BECWaveformWidget.ICON_NAME,
|
||||
tooltip="Add Waveform",
|
||||
icon_name=Waveform.ICON_NAME, tooltip="Add Waveform", filled=True
|
||||
),
|
||||
"scatter_waveform": MaterialIconAction(
|
||||
icon_name=ScatterWaveform.ICON_NAME,
|
||||
tooltip="Add Scatter Waveform",
|
||||
filled=True,
|
||||
),
|
||||
"multi_waveform": MaterialIconAction(
|
||||
@@ -99,7 +108,7 @@ class BECDockArea(BECWidget, QWidget):
|
||||
filled=True,
|
||||
),
|
||||
"image": MaterialIconAction(
|
||||
icon_name=BECImageWidget.ICON_NAME, tooltip="Add Image", filled=True
|
||||
icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True
|
||||
),
|
||||
"motor_map": MaterialIconAction(
|
||||
icon_name=BECMotorMapWidget.ICON_NAME,
|
||||
@@ -171,41 +180,44 @@ class BECDockArea(BECWidget, QWidget):
|
||||
def _hook_toolbar(self):
|
||||
# Menu Plot
|
||||
self.toolbar.widgets["menu_plots"].widgets["waveform"].triggered.connect(
|
||||
lambda: self.add_dock(widget="BECWaveformWidget", prefix="waveform")
|
||||
lambda: self._create_widget_from_toolbar(widget_name="Waveform")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["scatter_waveform"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="ScatterWaveform")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["multi_waveform"].triggered.connect(
|
||||
lambda: self.add_dock(widget="BECMultiWaveformWidget", prefix="multi_waveform")
|
||||
lambda: self._create_widget_from_toolbar(widget_name="BECMultiWaveformWidget")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["image"].triggered.connect(
|
||||
lambda: self.add_dock(widget="BECImageWidget", prefix="image")
|
||||
lambda: self._create_widget_from_toolbar(widget_name="Image")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["motor_map"].triggered.connect(
|
||||
lambda: self.add_dock(widget="BECMotorMapWidget", prefix="motor_map")
|
||||
lambda: self._create_widget_from_toolbar(widget_name="BECMotorMapWidget")
|
||||
)
|
||||
|
||||
# Menu Devices
|
||||
self.toolbar.widgets["menu_devices"].widgets["scan_control"].triggered.connect(
|
||||
lambda: self.add_dock(widget="ScanControl", prefix="scan_control")
|
||||
lambda: self._create_widget_from_toolbar(widget_name="ScanControl")
|
||||
)
|
||||
self.toolbar.widgets["menu_devices"].widgets["positioner_box"].triggered.connect(
|
||||
lambda: self.add_dock(widget="PositionerBox", prefix="positioner_box")
|
||||
lambda: self._create_widget_from_toolbar(widget_name="PositionerBox")
|
||||
)
|
||||
|
||||
# Menu Utils
|
||||
self.toolbar.widgets["menu_utils"].widgets["queue"].triggered.connect(
|
||||
lambda: self.add_dock(widget="BECQueue", prefix="queue")
|
||||
lambda: self._create_widget_from_toolbar(widget_name="BECQueue")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["status"].triggered.connect(
|
||||
lambda: self.add_dock(widget="BECStatusBox", prefix="status")
|
||||
lambda: self._create_widget_from_toolbar(widget_name="BECStatusBox")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["vs_code"].triggered.connect(
|
||||
lambda: self.add_dock(widget="VSCodeEditor", prefix="vs_code")
|
||||
lambda: self._create_widget_from_toolbar(widget_name="VSCodeEditor")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect(
|
||||
lambda: self.add_dock(widget="RingProgressBar", prefix="progress_bar")
|
||||
lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
|
||||
lambda: self.add_dock(widget="LogPanel", prefix="log_panel")
|
||||
lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
|
||||
)
|
||||
|
||||
# Icons
|
||||
@@ -213,6 +225,11 @@ class BECDockArea(BECWidget, QWidget):
|
||||
self.toolbar.widgets["save_state"].action.triggered.connect(self.save_state)
|
||||
self.toolbar.widgets["restore_state"].action.triggered.connect(self.restore_state)
|
||||
|
||||
@SafeSlot()
|
||||
def _create_widget_from_toolbar(self, widget_name: str) -> None:
|
||||
dock_name = WidgetContainerUtils.generate_unique_name(widget_name, self.panels.keys())
|
||||
self.new(name=dock_name, widget=widget_name)
|
||||
|
||||
def paintEvent(self, event: QPaintEvent): # TODO decide if we want any default instructions
|
||||
super().paintEvent(event)
|
||||
if self._instructions_visible:
|
||||
@@ -220,7 +237,7 @@ class BECDockArea(BECWidget, QWidget):
|
||||
painter.drawText(
|
||||
self.rect(),
|
||||
Qt.AlignCenter,
|
||||
"Add docks using 'add_dock' method from CLI\n or \n Add widget docks using the toolbar",
|
||||
"Add docks using 'new' method from CLI\n or \n Add widget docks using the toolbar",
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -245,7 +262,17 @@ class BECDockArea(BECWidget, QWidget):
|
||||
|
||||
@panels.setter
|
||||
def panels(self, value: dict[str, BECDock]):
|
||||
self.dock_area.docks = WeakValueDictionary(value)
|
||||
self.dock_area.docks = WeakValueDictionary(value) # This can not work can it?
|
||||
|
||||
@property
|
||||
def panel_list(self) -> list[BECDock]:
|
||||
"""
|
||||
Get the docks in the dock area.
|
||||
|
||||
Returns:
|
||||
list: The docks in the dock area.
|
||||
"""
|
||||
return list(self.dock_area.docks.values())
|
||||
|
||||
@property
|
||||
def temp_areas(self) -> list:
|
||||
@@ -289,36 +316,17 @@ class BECDockArea(BECWidget, QWidget):
|
||||
self.config.docks_state = last_state
|
||||
return last_state
|
||||
|
||||
def remove_dock(self, name: str):
|
||||
"""
|
||||
Remove a dock by name and ensure it is properly closed and cleaned up.
|
||||
|
||||
Args:
|
||||
name(str): The name of the dock to remove.
|
||||
"""
|
||||
dock = self.dock_area.docks.pop(name, None)
|
||||
self.config.docks.pop(name, None)
|
||||
if dock:
|
||||
dock.close()
|
||||
dock.deleteLater()
|
||||
if len(self.dock_area.docks) <= 1:
|
||||
for dock in self.dock_area.docks.values():
|
||||
dock.hide_title_bar()
|
||||
|
||||
else:
|
||||
raise ValueError(f"Dock with name {name} does not exist.")
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def add_dock(
|
||||
def new(
|
||||
self,
|
||||
name: str = None,
|
||||
position: Literal["bottom", "top", "left", "right", "above", "below"] = None,
|
||||
name: str | None = None,
|
||||
widget: str | QWidget | None = None,
|
||||
widget_name: str | None = None,
|
||||
position: Literal["bottom", "top", "left", "right", "above", "below"] = "bottom",
|
||||
relative_to: BECDock | None = None,
|
||||
closable: bool = True,
|
||||
floating: bool = False,
|
||||
prefix: str = "dock",
|
||||
widget: str | QWidget | None = None,
|
||||
row: int = None,
|
||||
row: int | None = None,
|
||||
col: int = 0,
|
||||
rowspan: int = 1,
|
||||
colspan: int = 1,
|
||||
@@ -328,12 +336,11 @@ class BECDockArea(BECWidget, QWidget):
|
||||
|
||||
Args:
|
||||
name(str): The name of the dock to be displayed and for further references. Has to be unique.
|
||||
widget(str|QWidget|None): The widget to be added to the dock. While using RPC, only BEC RPC widgets from RPCWidgetHandler are allowed.
|
||||
position(Literal["bottom", "top", "left", "right", "above", "below"]): The position of the dock.
|
||||
relative_to(BECDock): The dock to which the new dock should be added relative to.
|
||||
closable(bool): Whether the dock is closable.
|
||||
floating(bool): Whether the dock is detached after creating.
|
||||
prefix(str): The prefix for the dock name if no name is provided.
|
||||
widget(str|QWidget|None): The widget to be added to the dock. While using RPC, only BEC RPC widgets from RPCWidgetHandler are allowed.
|
||||
row(int): The row of the added widget.
|
||||
col(int): The column of the added widget.
|
||||
rowspan(int): The rowspan of the added widget.
|
||||
@@ -342,21 +349,20 @@ class BECDockArea(BECWidget, QWidget):
|
||||
Returns:
|
||||
BECDock: The created dock.
|
||||
"""
|
||||
if name is None:
|
||||
name = WidgetContainerUtils.generate_unique_widget_id(
|
||||
container=self.dock_area.docks, prefix=prefix
|
||||
)
|
||||
|
||||
if name in set(self.dock_area.docks.keys()):
|
||||
raise ValueError(f"Dock with name {name} already exists.")
|
||||
|
||||
if position is None:
|
||||
position = "bottom"
|
||||
dock_names = [dock._name for dock in self.panel_list] # pylint: disable=protected-access
|
||||
if name is not None: # Name is provided
|
||||
if name in dock_names:
|
||||
raise ValueError(
|
||||
f"Name {name} must be unique for docks, but already exists in DockArea "
|
||||
f"with name: {self._name} and id {self.gui_id}."
|
||||
)
|
||||
else: # Name is not provided
|
||||
name = WidgetContainerUtils.generate_unique_name(name="dock", list_of_names=dock_names)
|
||||
|
||||
dock = BECDock(name=name, parent_dock_area=self, closable=closable)
|
||||
dock.config.position = position
|
||||
self.config.docks[name] = dock.config
|
||||
|
||||
self.config.docks[dock.name()] = dock.config
|
||||
# The dock.name is equal to the name passed to BECDock
|
||||
self.dock_area.addDock(dock=dock, position=position, relativeTo=relative_to)
|
||||
|
||||
if len(self.dock_area.docks) <= 1:
|
||||
@@ -365,10 +371,11 @@ class BECDockArea(BECWidget, QWidget):
|
||||
for dock in self.dock_area.docks.values():
|
||||
dock.show_title_bar()
|
||||
|
||||
if widget is not None and isinstance(widget, str):
|
||||
dock.add_widget(widget=widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
|
||||
elif widget is not None and isinstance(widget, QWidget):
|
||||
dock.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
|
||||
if widget is not None:
|
||||
# Check if widget name exists.
|
||||
dock.new(
|
||||
widget=widget, name=widget_name, row=row, col=col, rowspan=rowspan, colspan=colspan
|
||||
)
|
||||
if (
|
||||
self._instructions_visible
|
||||
): # TODO still decide how initial instructions should be handled
|
||||
@@ -406,49 +413,26 @@ class BECDockArea(BECWidget, QWidget):
|
||||
Remove a temporary area from the dock area.
|
||||
This is a patched method of pyqtgraph's removeTempArea
|
||||
"""
|
||||
if area not in self.dock_area.tempAreas:
|
||||
# FIXME add some context for the logging, I am not sure which object is passed.
|
||||
# It looks like a pyqtgraph.DockArea
|
||||
logger.info(f"Attempted to remove dock_area, but was not floating.")
|
||||
return
|
||||
self.dock_area.tempAreas.remove(area)
|
||||
area.window().close()
|
||||
area.window().deleteLater()
|
||||
|
||||
def clear_all(self):
|
||||
"""
|
||||
Close all docks and remove all temp areas.
|
||||
"""
|
||||
self.attach_all()
|
||||
for dock in dict(self.dock_area.docks).values():
|
||||
dock.remove()
|
||||
self.dock_area.docks.clear()
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the dock area.
|
||||
"""
|
||||
self.clear_all()
|
||||
self.delete_all()
|
||||
self.toolbar.close()
|
||||
self.toolbar.deleteLater()
|
||||
self.dock_area.close()
|
||||
self.dock_area.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self.parent() is None:
|
||||
# we are at top-level (independent window)
|
||||
if self.isVisible():
|
||||
# we are visible => user clicked on [X]
|
||||
# (when closeEvent is called from shutdown procedure,
|
||||
# everything is hidden first)
|
||||
# so, let's ignore "close", and do hide instead
|
||||
event.ignore()
|
||||
self.setVisible(False)
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Close the dock area and cleanup.
|
||||
Has to be implemented to overwrite pyqtgraph event accept in Container close.
|
||||
"""
|
||||
self.cleanup()
|
||||
super().close()
|
||||
|
||||
def show(self):
|
||||
"""Show all windows including floating docks."""
|
||||
super().show()
|
||||
@@ -467,18 +451,52 @@ class BECDockArea(BECWidget, QWidget):
|
||||
continue
|
||||
docks.window().hide()
|
||||
|
||||
def delete(self):
|
||||
self.hide()
|
||||
self.deleteLater()
|
||||
def delete_all(self) -> None:
|
||||
"""
|
||||
Delete all docks.
|
||||
"""
|
||||
self.attach_all()
|
||||
for dock_name in self.panels.keys():
|
||||
self.delete(dock_name)
|
||||
|
||||
def delete(self, dock_name: str):
|
||||
"""
|
||||
Delete a dock by name.
|
||||
|
||||
Args:
|
||||
dock_name(str): The name of the dock to delete.
|
||||
"""
|
||||
dock = self.dock_area.docks.pop(dock_name, None)
|
||||
self.config.docks.pop(dock_name, None)
|
||||
if dock:
|
||||
dock.close()
|
||||
dock.deleteLater()
|
||||
if len(self.dock_area.docks) <= 1:
|
||||
for dock in self.dock_area.docks.values():
|
||||
dock.hide_title_bar()
|
||||
else:
|
||||
raise ValueError(f"Dock with name {dock_name} does not exist.")
|
||||
# self._broadcast_update()
|
||||
|
||||
def remove(self) -> None:
|
||||
"""Remove the dock area."""
|
||||
self.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from qtpy.QtWidgets import QApplication
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
import sys
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("auto")
|
||||
dock_area = BECDockArea()
|
||||
dock_1 = dock_area.new(name="dock_0", widget="Waveform")
|
||||
# dock_1 = dock_area.new(name="dock_0", widget="Waveform")
|
||||
dock_area.new(widget="Waveform")
|
||||
dock_area.show()
|
||||
dock_area.setGeometry(100, 100, 800, 600)
|
||||
app.topLevelWidgets()
|
||||
app.exec_()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -78,13 +78,7 @@ class WidgetHandler:
|
||||
}
|
||||
|
||||
def create_widget(
|
||||
self,
|
||||
widget_type: str,
|
||||
widget_id: str,
|
||||
parent_figure,
|
||||
parent_id: str,
|
||||
config: dict = None,
|
||||
**axis_kwargs,
|
||||
self, widget_type: str, parent_figure, parent_id: str, config: dict = None, **axis_kwargs
|
||||
) -> BECPlotBase:
|
||||
"""
|
||||
Create and configure a widget based on its type.
|
||||
@@ -109,7 +103,6 @@ class WidgetHandler:
|
||||
widget_config_dict = {
|
||||
"widget_class": widget_class.__name__,
|
||||
"parent_id": parent_id,
|
||||
"gui_id": widget_id,
|
||||
**(config if config is not None else {}),
|
||||
}
|
||||
widget_config = config_class(**widget_config_dict)
|
||||
@@ -568,13 +561,13 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
|
||||
|
||||
widget = self.widget_handler.create_widget(
|
||||
widget_type=widget_type,
|
||||
widget_id=widget_id,
|
||||
parent_figure=self,
|
||||
parent_id=self.gui_id,
|
||||
config=config,
|
||||
**axis_kwargs,
|
||||
)
|
||||
widget.set_gui_id(widget_id)
|
||||
widget_id = widget.gui_id
|
||||
|
||||
widget.config.row = row
|
||||
widget.config.col = col
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ class ImageConfig(SubplotConfig):
|
||||
)
|
||||
|
||||
|
||||
# TODO old version will be deprecated
|
||||
class BECImageShow(BECPlotBase):
|
||||
USER_ACCESS = [
|
||||
"_rpc_id",
|
||||
|
||||
@@ -41,6 +41,7 @@ class ImageItemConfig(ConnectionConfig):
|
||||
)
|
||||
|
||||
|
||||
# TODO old version will be deprecated
|
||||
class BECImageItem(BECConnector, pg.ImageItem):
|
||||
USER_ACCESS = [
|
||||
"_rpc_id",
|
||||
|
||||
@@ -7,6 +7,8 @@ import numpy as np
|
||||
from pydantic import BaseModel, Field
|
||||
from qtpy.QtCore import QObject, Signal, Slot
|
||||
|
||||
# TODO will be deleted
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageStats:
|
||||
|
||||
@@ -126,6 +126,8 @@ class BECWaveform(BECPlotBase):
|
||||
"label_suffix": "",
|
||||
}
|
||||
|
||||
self._slice_index = None
|
||||
|
||||
# Scan segment update proxy
|
||||
self.proxy_update_plot = pg.SignalProxy(
|
||||
self.scan_signal_update, rateLimit=25, slot=self._update_scan_curves
|
||||
@@ -1252,7 +1254,9 @@ class BECWaveform(BECPlotBase):
|
||||
x_data = None
|
||||
instruction = metadata.get("async_update", {}).get("type")
|
||||
max_shape = metadata.get("async_update", {}).get("max_shape", [])
|
||||
for curve in self._curves_data["async"].values():
|
||||
all_async_curves = self._curves_data["async"].values()
|
||||
# for curve in self._curves_data["async"].values():
|
||||
for curve in all_async_curves:
|
||||
y_entry = curve.config.signals.y.entry
|
||||
x_name = self._x_axis_mode["name"]
|
||||
for device, async_data in msg["signals"].items():
|
||||
@@ -1276,6 +1280,18 @@ class BECWaveform(BECPlotBase):
|
||||
curve.setData(x_data, new_data)
|
||||
else:
|
||||
curve.setData(new_data)
|
||||
elif instruction == "add_slice":
|
||||
current_slice_id = metadata.get("async_update", {}).get("index")
|
||||
data_plot = async_data["value"]
|
||||
if current_slice_id != self._slice_index:
|
||||
self._slice_index = current_slice_id
|
||||
new_data = data_plot
|
||||
else:
|
||||
x_data, y_data = curve.get_data()
|
||||
new_data = np.hstack((y_data, data_plot))
|
||||
|
||||
curve.setData(new_data)
|
||||
|
||||
elif instruction == "replace":
|
||||
if x_name == "timestamp":
|
||||
x_data = async_data["timestamp"]
|
||||
@@ -1524,6 +1540,10 @@ class BECWaveform(BECPlotBase):
|
||||
for curve_id in curve_ids_to_remove:
|
||||
self.remove_curve(curve_id)
|
||||
|
||||
def reset(self):
|
||||
self._slice_index = None
|
||||
super().reset()
|
||||
|
||||
def clear_all(self):
|
||||
sources = list(self._curves_data.keys())
|
||||
for source in sources:
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtWidgets import QApplication, QMainWindow
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
class BECMainWindow(QMainWindow, BECConnector):
|
||||
def __init__(self, *args, **kwargs):
|
||||
BECConnector.__init__(self, **kwargs)
|
||||
|
||||
class BECMainWindow(BECWidget, QMainWindow):
|
||||
def __init__(self, gui_id: str = None, *args, **kwargs):
|
||||
BECWidget.__init__(self, gui_id=gui_id, **kwargs)
|
||||
QMainWindow.__init__(self, *args, **kwargs)
|
||||
|
||||
def _dump(self):
|
||||
@@ -33,9 +38,37 @@ class BECMainWindow(QMainWindow, BECConnector):
|
||||
}
|
||||
return info
|
||||
|
||||
def new_dock_area(self, name):
|
||||
dock_area = BECDockArea()
|
||||
def new_dock_area(
|
||||
self, name: str | None = None, geometry: tuple[int, int, int, int] | None = None
|
||||
) -> BECDockArea:
|
||||
"""Create a new dock area.
|
||||
|
||||
Args:
|
||||
name(str): The name of the dock area.
|
||||
geometry(tuple): The geometry parameters to be passed to the dock area.
|
||||
Returns:
|
||||
BECDockArea: The newly created dock area.
|
||||
"""
|
||||
rpc_register = RPCRegister()
|
||||
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea)
|
||||
if name is not None:
|
||||
if name in existing_dock_areas:
|
||||
raise ValueError(
|
||||
f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}."
|
||||
)
|
||||
else:
|
||||
name = "dock_area"
|
||||
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
|
||||
dock_area = BECDockArea(name=name)
|
||||
dock_area.resize(dock_area.minimumSizeHint())
|
||||
dock_area.window().setWindowTitle(name)
|
||||
# TODO Should we simply use the specified name as title here?
|
||||
dock_area.window().setWindowTitle(f"BEC - {name}")
|
||||
logger.info(f"Created new dock area: {name}")
|
||||
logger.info(f"Existing dock areas: {geometry}")
|
||||
if geometry is not None:
|
||||
dock_area.setGeometry(*geometry)
|
||||
dock_area.show()
|
||||
return dock_area
|
||||
|
||||
def cleanup(self):
|
||||
super().close()
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>303</width>
|
||||
<height>457</height>
|
||||
<width>337</width>
|
||||
<height>552</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
@@ -35,11 +35,17 @@
|
||||
<item>
|
||||
<widget class="QGroupBox" name="group_curve_selection">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>100</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Select Curve</string>
|
||||
</property>
|
||||
@@ -60,7 +66,7 @@
|
||||
<item>
|
||||
<widget class="QGroupBox" name="group_summary">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
@@ -68,7 +74,7 @@
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>0</height>
|
||||
<height>200</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
@@ -113,7 +119,7 @@
|
||||
<item>
|
||||
<widget class="QGroupBox" name="group_parameters">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
@@ -121,7 +127,7 @@
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>0</height>
|
||||
<height>200</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{'files': ['image_widget.py']}
|
||||
@@ -1,515 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Literal, Optional
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from qtpy.QtWidgets import QComboBox, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot, WarningPopupUtility
|
||||
from bec_widgets.qt_utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.qt_utils.toolbar import (
|
||||
DeviceSelectionAction,
|
||||
MaterialIconAction,
|
||||
ModularToolBar,
|
||||
SeparatorAction,
|
||||
WidgetAction,
|
||||
)
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.containers.figure import BECFigure
|
||||
from bec_widgets.widgets.containers.figure.plots.axis_settings import AxisSettings
|
||||
from bec_widgets.widgets.containers.figure.plots.image.image import ImageConfig
|
||||
from bec_widgets.widgets.containers.figure.plots.image.image_item import BECImageItem
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
|
||||
|
||||
class BECImageWidget(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
ICON_NAME = "image"
|
||||
USER_ACCESS = [
|
||||
"image",
|
||||
"set",
|
||||
"set_title",
|
||||
"set_x_label",
|
||||
"set_y_label",
|
||||
"set_x_scale",
|
||||
"set_y_scale",
|
||||
"set_x_lim",
|
||||
"set_y_lim",
|
||||
"set_vrange",
|
||||
"set_fft",
|
||||
"set_transpose",
|
||||
"set_rotation",
|
||||
"set_log",
|
||||
"set_grid",
|
||||
"enable_fps_monitor",
|
||||
"lock_aspect_ratio",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
config: ImageConfig | dict = None,
|
||||
client=None,
|
||||
gui_id: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
if config is None:
|
||||
config = ImageConfig(widget_class=self.__class__.__name__)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = ImageConfig(**config)
|
||||
super().__init__(client=client, gui_id=gui_id, **kwargs)
|
||||
QWidget.__init__(self, parent)
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setSpacing(0)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.fig = BECFigure()
|
||||
self.dim_combo_box = QComboBox()
|
||||
self.dim_combo_box.addItems(["1d", "2d"])
|
||||
self.toolbar = ModularToolBar(
|
||||
actions={
|
||||
"monitor": DeviceSelectionAction(
|
||||
"Monitor:",
|
||||
DeviceComboBox(
|
||||
device_filter=BECDeviceFilter.DEVICE,
|
||||
readout_priority_filter=[ReadoutPriority.ASYNC],
|
||||
),
|
||||
),
|
||||
"monitor_type": WidgetAction(widget=self.dim_combo_box),
|
||||
"connect": MaterialIconAction(icon_name="link", tooltip="Connect Device"),
|
||||
"separator_0": SeparatorAction(),
|
||||
"save": MaterialIconAction(icon_name="save", tooltip="Open Export Dialog"),
|
||||
"separator_1": SeparatorAction(),
|
||||
"drag_mode": MaterialIconAction(
|
||||
icon_name="open_with", tooltip="Drag Mouse Mode", checkable=True
|
||||
),
|
||||
"rectangle_mode": MaterialIconAction(
|
||||
icon_name="frame_inspect", tooltip="Rectangle Zoom Mode", checkable=True
|
||||
),
|
||||
"auto_range": MaterialIconAction(
|
||||
icon_name="open_in_full", tooltip="Autorange Plot"
|
||||
),
|
||||
"auto_range_image": MaterialIconAction(
|
||||
icon_name="hdr_auto", tooltip="Autorange Image Intensity", checkable=True
|
||||
),
|
||||
"aspect_ratio": MaterialIconAction(
|
||||
icon_name="aspect_ratio", tooltip="Lock image aspect ratio", checkable=True
|
||||
),
|
||||
"separator_2": SeparatorAction(),
|
||||
"FFT": MaterialIconAction(icon_name="fft", tooltip="Toggle FFT", checkable=True),
|
||||
"log": MaterialIconAction(
|
||||
icon_name="log_scale", tooltip="Toggle log scale", checkable=True
|
||||
),
|
||||
"transpose": MaterialIconAction(
|
||||
icon_name="transform", tooltip="Transpose Image", checkable=True
|
||||
),
|
||||
"rotate_right": MaterialIconAction(
|
||||
icon_name="rotate_right", tooltip="Rotate image clockwise by 90 deg"
|
||||
),
|
||||
"rotate_left": MaterialIconAction(
|
||||
icon_name="rotate_left", tooltip="Rotate image counterclockwise by 90 deg"
|
||||
),
|
||||
"reset": MaterialIconAction(
|
||||
icon_name="reset_settings", tooltip="Reset Image Settings"
|
||||
),
|
||||
"separator_3": SeparatorAction(),
|
||||
"fps_monitor": MaterialIconAction(
|
||||
icon_name="speed", tooltip="Show FPS Monitor", checkable=True
|
||||
),
|
||||
"axis_settings": MaterialIconAction(
|
||||
icon_name="settings", tooltip="Open Configuration Dialog"
|
||||
),
|
||||
},
|
||||
target_widget=self,
|
||||
)
|
||||
|
||||
self.layout.addWidget(self.toolbar)
|
||||
self.layout.addWidget(self.fig)
|
||||
|
||||
self.warning_util = WarningPopupUtility(self)
|
||||
|
||||
self._image = self.fig.image()
|
||||
self._image.apply_config(config)
|
||||
self.rotation = 0
|
||||
|
||||
self.config = config
|
||||
|
||||
self._hook_actions()
|
||||
|
||||
self.toolbar.widgets["drag_mode"].action.setChecked(True)
|
||||
self.toolbar.widgets["auto_range_image"].action.setChecked(True)
|
||||
|
||||
def _hook_actions(self):
|
||||
self.toolbar.widgets["connect"].action.triggered.connect(self._connect_action)
|
||||
# sepatator
|
||||
self.toolbar.widgets["save"].action.triggered.connect(self.export)
|
||||
# sepatator
|
||||
self.toolbar.widgets["drag_mode"].action.triggered.connect(self.enable_mouse_pan_mode)
|
||||
self.toolbar.widgets["rectangle_mode"].action.triggered.connect(
|
||||
self.enable_mouse_rectangle_mode
|
||||
)
|
||||
self.toolbar.widgets["auto_range"].action.triggered.connect(self.toggle_auto_range)
|
||||
self.toolbar.widgets["auto_range_image"].action.triggered.connect(
|
||||
self.toggle_image_autorange
|
||||
)
|
||||
self.toolbar.widgets["aspect_ratio"].action.triggered.connect(self.toggle_aspect_ratio)
|
||||
# sepatator
|
||||
self.toolbar.widgets["FFT"].action.triggered.connect(self.toggle_fft)
|
||||
self.toolbar.widgets["log"].action.triggered.connect(self.toggle_log)
|
||||
self.toolbar.widgets["transpose"].action.triggered.connect(self.toggle_transpose)
|
||||
self.toolbar.widgets["rotate_left"].action.triggered.connect(self.rotate_left)
|
||||
self.toolbar.widgets["rotate_right"].action.triggered.connect(self.rotate_right)
|
||||
self.toolbar.widgets["reset"].action.triggered.connect(self.reset_settings)
|
||||
# sepatator
|
||||
self.toolbar.widgets["axis_settings"].action.triggered.connect(self.show_axis_settings)
|
||||
self.toolbar.widgets["fps_monitor"].action.toggled.connect(self.enable_fps_monitor)
|
||||
|
||||
###################################
|
||||
# Dialog Windows
|
||||
###################################
|
||||
@SafeSlot(popup_error=True)
|
||||
def _connect_action(self):
|
||||
monitor_combo = self.toolbar.widgets["monitor"].device_combobox
|
||||
monitor_name = monitor_combo.currentText()
|
||||
monitor_type = self.toolbar.widgets["monitor_type"].widget.currentText()
|
||||
self.image(monitor=monitor_name, monitor_type=monitor_type)
|
||||
monitor_combo.setStyleSheet("QComboBox { background-color: " "; }")
|
||||
|
||||
def show_axis_settings(self):
|
||||
dialog = SettingsDialog(
|
||||
self,
|
||||
settings_widget=AxisSettings(),
|
||||
window_title="Axis Settings",
|
||||
config=self._config_dict["axis"],
|
||||
)
|
||||
dialog.exec()
|
||||
|
||||
###################################
|
||||
# User Access Methods from image
|
||||
###################################
|
||||
@SafeSlot(popup_error=True)
|
||||
def image(
|
||||
self,
|
||||
monitor: str,
|
||||
monitor_type: Optional[Literal["1d", "2d"]] = "2d",
|
||||
color_map: Optional[str] = "magma",
|
||||
color_bar: Optional[Literal["simple", "full"]] = "full",
|
||||
downsample: Optional[bool] = True,
|
||||
opacity: Optional[float] = 1.0,
|
||||
vrange: Optional[tuple[int, int]] = None,
|
||||
# post_processing: Optional[PostProcessingConfig] = None,
|
||||
**kwargs,
|
||||
) -> BECImageItem:
|
||||
if self.toolbar.widgets["monitor"].device_combobox.currentText() != monitor:
|
||||
self.toolbar.widgets["monitor"].device_combobox.setCurrentText(monitor)
|
||||
self.toolbar.widgets["monitor"].device_combobox.setStyleSheet(
|
||||
"QComboBox {{ background-color: " "; }}"
|
||||
)
|
||||
if self.toolbar.widgets["monitor_type"].widget.currentText() != monitor_type:
|
||||
self.toolbar.widgets["monitor_type"].widget.setCurrentText(monitor_type)
|
||||
self.toolbar.widgets["monitor_type"].widget.setStyleSheet(
|
||||
"QComboBox {{ background-color: " "; }}"
|
||||
)
|
||||
return self._image.image(
|
||||
monitor=monitor,
|
||||
monitor_type=monitor_type,
|
||||
color_map=color_map,
|
||||
color_bar=color_bar,
|
||||
downsample=downsample,
|
||||
opacity=opacity,
|
||||
vrange=vrange,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def set_vrange(self, vmin: float, vmax: float, name: str = None):
|
||||
"""
|
||||
Set the range of the color bar.
|
||||
If name is not specified, then set vrange for all images.
|
||||
|
||||
Args:
|
||||
vmin(float): Minimum value of the color bar.
|
||||
vmax(float): Maximum value of the color bar.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self._image.set_vrange(vmin, vmax, name)
|
||||
|
||||
def set_color_map(self, color_map: str, name: str = None):
|
||||
"""
|
||||
Set the color map of the image.
|
||||
If name is not specified, then set color map for all images.
|
||||
|
||||
Args:
|
||||
cmap(str): The color map of the image.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self._image.set_color_map(color_map, name)
|
||||
|
||||
def set_fft(self, enable: bool = False, name: str = None):
|
||||
"""
|
||||
Set the FFT of the image.
|
||||
If name is not specified, then set FFT for all images.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to perform FFT on the monitor data.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self._image.set_fft(enable, name)
|
||||
self.toolbar.widgets["FFT"].action.setChecked(enable)
|
||||
|
||||
def set_transpose(self, enable: bool = False, name: str = None):
|
||||
"""
|
||||
Set the transpose of the image.
|
||||
If name is not specified, then set transpose for all images.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to transpose the monitor data before displaying.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self._image.set_transpose(enable, name)
|
||||
self.toolbar.widgets["transpose"].action.setChecked(enable)
|
||||
|
||||
def set_rotation(self, deg_90: int = 0, name: str = None):
|
||||
"""
|
||||
Set the rotation of the image.
|
||||
If name is not specified, then set rotation for all images.
|
||||
|
||||
Args:
|
||||
deg_90(int): The rotation angle of the monitor data before displaying.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self._image.set_rotation(deg_90, name)
|
||||
|
||||
def set_log(self, enable: bool = False, name: str = None):
|
||||
"""
|
||||
Set the log of the image.
|
||||
If name is not specified, then set log for all images.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to perform log on the monitor data.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self._image.set_log(enable, name)
|
||||
self.toolbar.widgets["log"].action.setChecked(enable)
|
||||
|
||||
###################################
|
||||
# User Access Methods from Plotbase
|
||||
###################################
|
||||
|
||||
def set(self, **kwargs):
|
||||
"""
|
||||
Set the properties of the plot widget.
|
||||
|
||||
Args:
|
||||
**kwargs: Keyword arguments for the properties to be set.
|
||||
|
||||
Possible properties:
|
||||
- title: str
|
||||
- x_label: str
|
||||
- y_label: str
|
||||
- x_scale: Literal["linear", "log"]
|
||||
- y_scale: Literal["linear", "log"]
|
||||
- x_lim: tuple
|
||||
- y_lim: tuple
|
||||
- legend_label_size: int
|
||||
"""
|
||||
self._image.set(**kwargs)
|
||||
|
||||
def set_title(self, title: str):
|
||||
"""
|
||||
Set the title of the plot widget.
|
||||
|
||||
Args:
|
||||
title(str): Title of the plot.
|
||||
"""
|
||||
self._image.set_title(title)
|
||||
|
||||
def set_x_label(self, x_label: str):
|
||||
"""
|
||||
Set the x-axis label of the plot widget.
|
||||
|
||||
Args:
|
||||
x_label(str): Label of the x-axis.
|
||||
"""
|
||||
self._image.set_x_label(x_label)
|
||||
|
||||
def set_y_label(self, y_label: str):
|
||||
"""
|
||||
Set the y-axis label of the plot widget.
|
||||
|
||||
Args:
|
||||
y_label(str): Label of the y-axis.
|
||||
"""
|
||||
self._image.set_y_label(y_label)
|
||||
|
||||
def set_x_scale(self, x_scale: Literal["linear", "log"]):
|
||||
"""
|
||||
Set the scale of the x-axis of the plot widget.
|
||||
|
||||
Args:
|
||||
x_scale(Literal["linear", "log"]): Scale of the x-axis.
|
||||
"""
|
||||
self._image.set_x_scale(x_scale)
|
||||
|
||||
def set_y_scale(self, y_scale: Literal["linear", "log"]):
|
||||
"""
|
||||
Set the scale of the y-axis of the plot widget.
|
||||
|
||||
Args:
|
||||
y_scale(Literal["linear", "log"]): Scale of the y-axis.
|
||||
"""
|
||||
self._image.set_y_scale(y_scale)
|
||||
|
||||
def set_x_lim(self, x_lim: tuple):
|
||||
"""
|
||||
Set the limits of the x-axis of the plot widget.
|
||||
|
||||
Args:
|
||||
x_lim(tuple): Limits of the x-axis.
|
||||
"""
|
||||
self._image.set_x_lim(x_lim)
|
||||
|
||||
def set_y_lim(self, y_lim: tuple):
|
||||
"""
|
||||
Set the limits of the y-axis of the plot widget.
|
||||
|
||||
Args:
|
||||
y_lim(tuple): Limits of the y-axis.
|
||||
"""
|
||||
self._image.set_y_lim(y_lim)
|
||||
|
||||
def set_grid(self, x_grid: bool, y_grid: bool):
|
||||
"""
|
||||
Set the grid visibility of the plot widget.
|
||||
|
||||
Args:
|
||||
x_grid(bool): Visibility of the x-axis grid.
|
||||
y_grid(bool): Visibility of the y-axis grid.
|
||||
"""
|
||||
self._image.set_grid(x_grid, y_grid)
|
||||
|
||||
def lock_aspect_ratio(self, lock: bool):
|
||||
"""
|
||||
Lock the aspect ratio of the plot widget.
|
||||
|
||||
Args:
|
||||
lock(bool): Lock the aspect ratio.
|
||||
"""
|
||||
self._image.lock_aspect_ratio(lock)
|
||||
|
||||
###################################
|
||||
# Toolbar Actions
|
||||
###################################
|
||||
@SafeSlot()
|
||||
def toggle_auto_range(self):
|
||||
"""
|
||||
Set the auto range of the plot widget from the toolbar.
|
||||
"""
|
||||
self._image.set_auto_range(True, "xy")
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_fft(self):
|
||||
checked = self.toolbar.widgets["FFT"].action.isChecked()
|
||||
self.set_fft(checked)
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_log(self):
|
||||
checked = self.toolbar.widgets["log"].action.isChecked()
|
||||
self.set_log(checked)
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_transpose(self):
|
||||
checked = self.toolbar.widgets["transpose"].action.isChecked()
|
||||
self.set_transpose(checked)
|
||||
|
||||
@SafeSlot()
|
||||
def rotate_left(self):
|
||||
self.rotation = (self.rotation + 1) % 4
|
||||
self.set_rotation(self.rotation)
|
||||
|
||||
@SafeSlot()
|
||||
def rotate_right(self):
|
||||
self.rotation = (self.rotation - 1) % 4
|
||||
self.set_rotation(self.rotation)
|
||||
|
||||
@SafeSlot()
|
||||
def reset_settings(self):
|
||||
self.set_log(False)
|
||||
self.set_fft(False)
|
||||
self.set_transpose(False)
|
||||
self.rotation = 0
|
||||
self.set_rotation(0)
|
||||
|
||||
self.toolbar.widgets["FFT"].action.setChecked(False)
|
||||
self.toolbar.widgets["log"].action.setChecked(False)
|
||||
self.toolbar.widgets["transpose"].action.setChecked(False)
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_image_autorange(self):
|
||||
"""
|
||||
Enable the auto range of the image intensity.
|
||||
"""
|
||||
checked = self.toolbar.widgets["auto_range_image"].action.isChecked()
|
||||
self._image.set_autorange(checked)
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_aspect_ratio(self):
|
||||
"""
|
||||
Enable the auto range of the image intensity.
|
||||
"""
|
||||
checked = self.toolbar.widgets["aspect_ratio"].action.isChecked()
|
||||
self._image.lock_aspect_ratio(checked)
|
||||
|
||||
@SafeSlot()
|
||||
def enable_mouse_rectangle_mode(self):
|
||||
self.toolbar.widgets["rectangle_mode"].action.setChecked(True)
|
||||
self.toolbar.widgets["drag_mode"].action.setChecked(False)
|
||||
self._image.plot_item.getViewBox().setMouseMode(pg.ViewBox.RectMode)
|
||||
|
||||
@SafeSlot()
|
||||
def enable_mouse_pan_mode(self):
|
||||
self.toolbar.widgets["drag_mode"].action.setChecked(True)
|
||||
self.toolbar.widgets["rectangle_mode"].action.setChecked(False)
|
||||
self._image.plot_item.getViewBox().setMouseMode(pg.ViewBox.PanMode)
|
||||
|
||||
@SafeSlot()
|
||||
def enable_fps_monitor(self, enabled: bool):
|
||||
"""
|
||||
Enable the FPS monitor of the plot widget.
|
||||
|
||||
Args:
|
||||
enabled(bool): If True, enable the FPS monitor.
|
||||
"""
|
||||
self._image.enable_fps_monitor(enabled)
|
||||
if self.toolbar.widgets["fps_monitor"].action.isChecked() != enabled:
|
||||
self.toolbar.widgets["fps_monitor"].action.setChecked(enabled)
|
||||
|
||||
def export(self):
|
||||
"""
|
||||
Show the export dialog for the plot widget.
|
||||
"""
|
||||
self._image.export()
|
||||
|
||||
def cleanup(self):
|
||||
self.fig.cleanup()
|
||||
self.toolbar.close()
|
||||
self.toolbar.deleteLater()
|
||||
return super().cleanup()
|
||||
|
||||
|
||||
def main(): # pragma: no cover
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = BECImageWidget()
|
||||
widget.image("waveform", "1d")
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1 +0,0 @@
|
||||
{'files': ['waveform_widget.py']}
|
||||
@@ -1,336 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Literal
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import BaseModel
|
||||
from qtpy.QtCore import QObject, Slot
|
||||
from qtpy.QtWidgets import QComboBox, QLineEdit, QPushButton, QSpinBox, QTableWidget, QVBoxLayout
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.qt_utils.error_popups import WarningPopupUtility
|
||||
from bec_widgets.qt_utils.settings_dialog import SettingWidget
|
||||
from bec_widgets.utils import Colors, UILoader
|
||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
||||
DeviceLineEdit,
|
||||
)
|
||||
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
|
||||
from bec_widgets.widgets.utility.visual.color_button.color_button import ColorButton
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class CurveSettings(SettingWidget):
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent, *args, **kwargs)
|
||||
current_path = os.path.dirname(__file__)
|
||||
|
||||
self.ui = UILoader(self).loader(os.path.join(current_path, "curve_dialog.ui"))
|
||||
self._setup_icons()
|
||||
|
||||
self.warning_util = WarningPopupUtility(self)
|
||||
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.addWidget(self.ui)
|
||||
|
||||
self.ui.add_curve.clicked.connect(self.add_curve)
|
||||
self.ui.add_dap.clicked.connect(self.add_dap)
|
||||
self.ui.x_mode.currentIndexChanged.connect(self.set_x_mode)
|
||||
self.ui.normalize_colors_scan.clicked.connect(lambda: self.change_colormap("scan"))
|
||||
self.ui.normalize_colors_dap.clicked.connect(lambda: self.change_colormap("dap"))
|
||||
|
||||
def _setup_icons(self):
|
||||
add_icon = material_icon(icon_name="add", size=(20, 20), convert_to_pixmap=False)
|
||||
self.ui.add_dap.setIcon(add_icon)
|
||||
self.ui.add_dap.setToolTip("Add DAP Curve")
|
||||
self.ui.add_curve.setIcon(add_icon)
|
||||
self.ui.add_curve.setToolTip("Add Scan Curve")
|
||||
|
||||
@Slot(dict)
|
||||
def display_current_settings(self, config: dict | BaseModel):
|
||||
|
||||
# What elements should be enabled
|
||||
x_name = self.target_widget.waveform._x_axis_mode["name"]
|
||||
x_entry = self.target_widget.waveform._x_axis_mode["entry"]
|
||||
self._enable_ui_elements(x_name, x_entry)
|
||||
cm = self.target_widget.config.color_palette
|
||||
self.ui.color_map_selector_scan.colormap = cm
|
||||
|
||||
# Scan Curve Table
|
||||
for source in ["scan_segment", "async"]:
|
||||
for label, curve in config[source].items():
|
||||
row_count = self.ui.scan_table.rowCount()
|
||||
self.ui.scan_table.insertRow(row_count)
|
||||
DialogRow(
|
||||
parent=self,
|
||||
table_widget=self.ui.scan_table,
|
||||
client=self.target_widget.client,
|
||||
row=row_count,
|
||||
config=curve.config,
|
||||
).add_scan_row()
|
||||
|
||||
# Add DAP Curves
|
||||
for label, curve in config["DAP"].items():
|
||||
row_count = self.ui.dap_table.rowCount()
|
||||
self.ui.dap_table.insertRow(row_count)
|
||||
DialogRow(
|
||||
parent=self,
|
||||
table_widget=self.ui.dap_table,
|
||||
client=self.target_widget.client,
|
||||
row=row_count,
|
||||
config=curve.config,
|
||||
).add_dap_row()
|
||||
|
||||
def _enable_ui_elements(self, name, entry):
|
||||
if name is None:
|
||||
name = "best_effort"
|
||||
if name in ["index", "timestamp", "best_effort"]:
|
||||
self.ui.x_mode.setCurrentText(name)
|
||||
self.set_x_mode()
|
||||
else:
|
||||
self.ui.x_mode.setCurrentText("device")
|
||||
self.set_x_mode()
|
||||
self.ui.x_name.setText(name)
|
||||
self.ui.x_entry.setText(entry)
|
||||
|
||||
@Slot()
|
||||
def set_x_mode(self):
|
||||
x_mode = self.ui.x_mode.currentText()
|
||||
if x_mode in ["index", "timestamp", "best_effort"]:
|
||||
self.ui.x_name.setEnabled(False)
|
||||
self.ui.x_entry.setEnabled(False)
|
||||
self.ui.dap_table.setEnabled(False)
|
||||
self.ui.add_dap.setEnabled(False)
|
||||
if self.ui.dap_table.rowCount() > 0:
|
||||
self.warning_util.show_warning(
|
||||
title="DAP Warning",
|
||||
message="DAP is not supported without specific x-axis device. All current DAP curves will be removed.",
|
||||
detailed_text=f"Affected curves: {[self.ui.dap_table.cellWidget(row, 0).text() for row in range(self.ui.dap_table.rowCount())]}",
|
||||
)
|
||||
else:
|
||||
self.ui.x_name.setEnabled(True)
|
||||
self.ui.x_entry.setEnabled(True)
|
||||
self.ui.dap_table.setEnabled(True)
|
||||
self.ui.add_dap.setEnabled(True)
|
||||
|
||||
@Slot()
|
||||
def change_colormap(self, target: Literal["scan", "dap"]):
|
||||
if target == "scan":
|
||||
cm = self.ui.color_map_selector_scan.colormap
|
||||
table = self.ui.scan_table
|
||||
if target == "dap":
|
||||
cm = self.ui.color_map_selector_dap.colormap
|
||||
table = self.ui.dap_table
|
||||
rows = table.rowCount()
|
||||
colors = Colors.golden_angle_color(colormap=cm, num=max(10, rows + 1), format="HEX")
|
||||
color_button_col = 2 if target == "scan" else 3
|
||||
for row in range(rows):
|
||||
table.cellWidget(row, color_button_col).set_color(colors[row])
|
||||
|
||||
@Slot()
|
||||
def accept_changes(self):
|
||||
self.accept_curve_changes()
|
||||
|
||||
def accept_curve_changes(self):
|
||||
sources = ["scan_segment", "async", "DAP"]
|
||||
old_curves = []
|
||||
|
||||
for source in sources:
|
||||
old_curves += list(self.target_widget.waveform._curves_data[source].values())
|
||||
for curve in old_curves:
|
||||
curve.remove()
|
||||
self.get_curve_params()
|
||||
|
||||
def get_curve_params(self):
|
||||
x_mode = self.ui.x_mode.currentText()
|
||||
|
||||
if x_mode in ["index", "timestamp", "best_effort"]:
|
||||
x_name = x_mode
|
||||
x_entry = x_mode
|
||||
else:
|
||||
x_name = self.ui.x_name.text()
|
||||
x_entry = self.ui.x_entry.text()
|
||||
|
||||
self.target_widget.set_x(x_name=x_name, x_entry=x_entry)
|
||||
|
||||
for row in range(self.ui.scan_table.rowCount()):
|
||||
y_name = self.ui.scan_table.cellWidget(row, 0).text()
|
||||
y_entry = self.ui.scan_table.cellWidget(row, 1).text()
|
||||
color = self.ui.scan_table.cellWidget(row, 2).get_color()
|
||||
style = self.ui.scan_table.cellWidget(row, 3).currentText()
|
||||
width = self.ui.scan_table.cellWidget(row, 4).value()
|
||||
symbol_size = self.ui.scan_table.cellWidget(row, 5).value()
|
||||
self.target_widget.plot(
|
||||
y_name=y_name,
|
||||
y_entry=y_entry,
|
||||
color=color,
|
||||
pen_style=style,
|
||||
pen_width=width,
|
||||
symbol_size=symbol_size,
|
||||
)
|
||||
|
||||
if x_mode not in ["index", "timestamp", "best_effort"]:
|
||||
|
||||
for row in range(self.ui.dap_table.rowCount()):
|
||||
y_name = self.ui.dap_table.cellWidget(row, 0).text()
|
||||
y_entry = self.ui.dap_table.cellWidget(row, 1).text()
|
||||
dap = self.ui.dap_table.cellWidget(row, 2).currentText()
|
||||
color = self.ui.dap_table.cellWidget(row, 3).get_color()
|
||||
style = self.ui.dap_table.cellWidget(row, 4).currentText()
|
||||
width = self.ui.dap_table.cellWidget(row, 5).value()
|
||||
symbol_size = self.ui.dap_table.cellWidget(row, 6).value()
|
||||
|
||||
self.target_widget.add_dap(
|
||||
x_name=x_name,
|
||||
x_entry=x_entry,
|
||||
y_name=y_name,
|
||||
y_entry=y_entry,
|
||||
dap=dap,
|
||||
color=color,
|
||||
pen_style=style,
|
||||
pen_width=width,
|
||||
symbol_size=symbol_size,
|
||||
)
|
||||
self.target_widget.scan_history(-1)
|
||||
|
||||
def add_curve(self):
|
||||
row_count = self.ui.scan_table.rowCount()
|
||||
self.ui.scan_table.insertRow(row_count)
|
||||
DialogRow(
|
||||
parent=self,
|
||||
table_widget=self.ui.scan_table,
|
||||
client=self.target_widget.client,
|
||||
row=row_count,
|
||||
config=None,
|
||||
).add_scan_row()
|
||||
|
||||
def add_dap(self):
|
||||
row_count = self.ui.dap_table.rowCount()
|
||||
self.ui.dap_table.insertRow(row_count)
|
||||
DialogRow(
|
||||
parent=self,
|
||||
table_widget=self.ui.dap_table,
|
||||
client=self.target_widget.client,
|
||||
row=row_count,
|
||||
config=None,
|
||||
).add_dap_row()
|
||||
|
||||
|
||||
class DialogRow(QObject):
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
table_widget: QTableWidget = None,
|
||||
row: int = None,
|
||||
config: dict = None,
|
||||
client=None,
|
||||
):
|
||||
|
||||
super().__init__(parent=parent)
|
||||
self.client = client
|
||||
|
||||
self.table_widget = table_widget
|
||||
self.row = row
|
||||
self.config = config
|
||||
self.init_default_widgets()
|
||||
|
||||
def init_default_widgets(self):
|
||||
|
||||
# Remove Button
|
||||
self.remove_button = RemoveButton()
|
||||
|
||||
# Name and Entry
|
||||
self.device_line_edit = DeviceLineEdit()
|
||||
self.entry_line_edit = QLineEdit()
|
||||
|
||||
self.dap_combo = DapComboBox()
|
||||
self.dap_combo.populate_fit_model_combobox()
|
||||
self.dap_combo.select_fit_model("GaussianModel")
|
||||
|
||||
# Styling
|
||||
self.color_button = ColorButton()
|
||||
self.style_combo = StyleComboBox()
|
||||
self.width = QSpinBox()
|
||||
self.width.setMinimum(1)
|
||||
self.width.setMaximum(20)
|
||||
self.width.setValue(4)
|
||||
|
||||
self.symbol_size = QSpinBox()
|
||||
self.symbol_size.setMinimum(1)
|
||||
self.symbol_size.setMaximum(20)
|
||||
self.symbol_size.setValue(7)
|
||||
|
||||
self.remove_button.clicked.connect(
|
||||
lambda: self.remove_row()
|
||||
) # From some reason do not work without lambda
|
||||
|
||||
def add_scan_row(self):
|
||||
if self.config is not None:
|
||||
self.device_line_edit.setText(self.config.signals.y.name)
|
||||
self.entry_line_edit.setText(self.config.signals.y.entry)
|
||||
self.color_button.set_color(self.config.color)
|
||||
self.style_combo.setCurrentText(self.config.pen_style)
|
||||
self.width.setValue(self.config.pen_width)
|
||||
self.symbol_size.setValue(self.config.symbol_size)
|
||||
else:
|
||||
default_colors = Colors.golden_angle_color(
|
||||
colormap="magma", num=max(10, self.row + 1), format="HEX"
|
||||
)
|
||||
default_color = default_colors[self.row]
|
||||
self.color_button.set_color(default_color)
|
||||
|
||||
self.table_widget.setCellWidget(self.row, 0, self.device_line_edit)
|
||||
self.table_widget.setCellWidget(self.row, 1, self.entry_line_edit)
|
||||
self.table_widget.setCellWidget(self.row, 2, self.color_button)
|
||||
self.table_widget.setCellWidget(self.row, 3, self.style_combo)
|
||||
self.table_widget.setCellWidget(self.row, 4, self.width)
|
||||
self.table_widget.setCellWidget(self.row, 5, self.symbol_size)
|
||||
self.table_widget.setCellWidget(self.row, 6, self.remove_button)
|
||||
|
||||
def add_dap_row(self):
|
||||
if self.config is not None:
|
||||
self.device_line_edit.setText(self.config.signals.y.name)
|
||||
self.entry_line_edit.setText(self.config.signals.y.entry)
|
||||
self.dap_combo.fit_model_combobox.setCurrentText(self.config.signals.dap)
|
||||
self.color_button.set_color(self.config.color)
|
||||
self.style_combo.setCurrentText(self.config.pen_style)
|
||||
self.width.setValue(self.config.pen_width)
|
||||
self.symbol_size.setValue(self.config.symbol_size)
|
||||
else:
|
||||
default_colors = Colors.golden_angle_color(
|
||||
colormap="magma", num=max(10, self.row + 1), format="HEX"
|
||||
)
|
||||
default_color = default_colors[self.row]
|
||||
self.color_button.set_color(default_color)
|
||||
|
||||
self.table_widget.setCellWidget(self.row, 0, self.device_line_edit)
|
||||
self.table_widget.setCellWidget(self.row, 1, self.entry_line_edit)
|
||||
self.table_widget.setCellWidget(self.row, 2, self.dap_combo.fit_model_combobox)
|
||||
self.table_widget.setCellWidget(self.row, 3, self.color_button)
|
||||
self.table_widget.setCellWidget(self.row, 4, self.style_combo)
|
||||
self.table_widget.setCellWidget(self.row, 5, self.width)
|
||||
self.table_widget.setCellWidget(self.row, 6, self.symbol_size)
|
||||
self.table_widget.setCellWidget(self.row, 7, self.remove_button)
|
||||
|
||||
@Slot()
|
||||
def remove_row(self):
|
||||
row = self.table_widget.indexAt(self.remove_button.pos()).row()
|
||||
self.cleanup()
|
||||
self.table_widget.removeRow(row)
|
||||
|
||||
def cleanup(self):
|
||||
self.device_line_edit.cleanup()
|
||||
|
||||
|
||||
class StyleComboBox(QComboBox):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.addItems(["solid", "dash", "dot", "dashdot"])
|
||||
|
||||
|
||||
class RemoveButton(QPushButton):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
icon = material_icon("disabled_by_default", size=(20, 20), convert_to_pixmap=False)
|
||||
self.setIcon(icon)
|
||||
@@ -1,372 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>720</width>
|
||||
<height>806</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<property name="leftMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="x_group_box">
|
||||
<property name="title">
|
||||
<string>X Axis</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout" stretch="0,0,0,0,1,3,1,3">
|
||||
<item>
|
||||
<widget class="QLabel" name="x_mode_label">
|
||||
<property name="text">
|
||||
<string>X Axis Mode</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="x_mode">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>150</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>best_effort</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>device</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>index</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>timestamp</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_3">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_4">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="x_name_label">
|
||||
<property name="text">
|
||||
<string>Name</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="DeviceLineEdit" name="x_name"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="x_entry_label">
|
||||
<property name="text">
|
||||
<string>Entry</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLineEdit" name="x_entry"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="y_group_box">
|
||||
<property name="title">
|
||||
<string>Y Axis</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QTabWidget" name="tabWidget">
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="tab_scan">
|
||||
<attribute name="title">
|
||||
<string>Scan</string>
|
||||
</attribute>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<property name="leftMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item row="0" column="2">
|
||||
<widget class="QPushButton" name="normalize_colors_scan">
|
||||
<property name="text">
|
||||
<string>Normalize Colors</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0" colspan="4">
|
||||
<widget class="QTableWidget" name="scan_table">
|
||||
<property name="rowCount">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<attribute name="horizontalHeaderCascadingSectionResizes">
|
||||
<bool>false</bool>
|
||||
</attribute>
|
||||
<attribute name="horizontalHeaderShowSortIndicator" stdset="0">
|
||||
<bool>true</bool>
|
||||
</attribute>
|
||||
<attribute name="horizontalHeaderStretchLastSection">
|
||||
<bool>true</bool>
|
||||
</attribute>
|
||||
<attribute name="verticalHeaderStretchLastSection">
|
||||
<bool>false</bool>
|
||||
</attribute>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Name</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Entry</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Color</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Style</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Width</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Symbol Size</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Delete</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QPushButton" name="add_curve">
|
||||
<property name="toolTip">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="3">
|
||||
<widget class="BECColorMapWidget" name="color_map_selector_scan">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tab_DAP">
|
||||
<attribute name="title">
|
||||
<string>DAP</string>
|
||||
</attribute>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<property name="leftMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item row="1" column="0" colspan="4">
|
||||
<widget class="QTableWidget" name="dap_table">
|
||||
<attribute name="horizontalHeaderShowSortIndicator" stdset="0">
|
||||
<bool>true</bool>
|
||||
</attribute>
|
||||
<attribute name="horizontalHeaderStretchLastSection">
|
||||
<bool>true</bool>
|
||||
</attribute>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Name</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Entry</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Model</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Color</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Style</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Width</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Symbol Size</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Delete</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QPushButton" name="normalize_colors_dap">
|
||||
<property name="text">
|
||||
<string>Normalize Colors</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<spacer name="horizontalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>585</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QPushButton" name="add_dap">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="3">
|
||||
<widget class="BECColorMapWidget" name="color_map_selector_dap">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>DeviceLineEdit</class>
|
||||
<extends>QLineEdit</extends>
|
||||
<header>device_line_edit</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECColorMapWidget</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_color_map_widget</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -1,25 +0,0 @@
|
||||
from qtpy.QtWidgets import QDialog, QVBoxLayout
|
||||
|
||||
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
|
||||
|
||||
|
||||
class FitSummaryWidget(QDialog):
|
||||
|
||||
def __init__(self, parent=None, target_widget=None):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
self.setModal(True)
|
||||
self.target_widget = target_widget
|
||||
self.dap_dialog = LMFitDialog(parent=self, ui_file="lmfit_dialog_compact.ui")
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.addWidget(self.dap_dialog)
|
||||
self.target_widget.dap_summary_update.connect(self.dap_dialog.update_summary_tree)
|
||||
self.setLayout(self.layout)
|
||||
self._get_dap_from_target_widget()
|
||||
|
||||
def _get_dap_from_target_widget(self) -> None:
|
||||
"""Get the DAP data from the target widget and update the DAP dialog manually on creation."""
|
||||
dap_summary = self.target_widget.get_dap_summary()
|
||||
for curve_id, data in dap_summary.items():
|
||||
md = {"curve_id": curve_id}
|
||||
self.dap_dialog.update_summary_tree(data=data, metadata=md)
|
||||
@@ -1,751 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Property, Signal, Slot
|
||||
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot, WarningPopupUtility
|
||||
from bec_widgets.qt_utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ModularToolBar, SeparatorAction
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.containers.figure import BECFigure
|
||||
from bec_widgets.widgets.containers.figure.plots.axis_settings import AxisSettings
|
||||
from bec_widgets.widgets.containers.figure.plots.waveform.waveform import Waveform1DConfig
|
||||
from bec_widgets.widgets.containers.figure.plots.waveform.waveform_curve import BECCurve
|
||||
from bec_widgets.widgets.plots.waveform.waveform_popups.curve_dialog.curve_dialog import (
|
||||
CurveSettings,
|
||||
)
|
||||
from bec_widgets.widgets.plots.waveform.waveform_popups.dap_summary_dialog.dap_summary_dialog import (
|
||||
FitSummaryWidget,
|
||||
)
|
||||
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError:
|
||||
pd = None
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class BECWaveformWidget(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
ICON_NAME = "show_chart"
|
||||
USER_ACCESS = [
|
||||
"curves",
|
||||
"plot",
|
||||
"add_dap",
|
||||
"get_dap_params",
|
||||
"remove_curve",
|
||||
"scan_history",
|
||||
"get_all_data",
|
||||
"set",
|
||||
"set_x",
|
||||
"set_title",
|
||||
"set_x_label",
|
||||
"set_y_label",
|
||||
"set_x_scale",
|
||||
"set_y_scale",
|
||||
"set_x_lim",
|
||||
"set_y_lim",
|
||||
"set_legend_label_size",
|
||||
"set_auto_range",
|
||||
"set_grid",
|
||||
"enable_fps_monitor",
|
||||
"enable_scatter",
|
||||
"lock_aspect_ratio",
|
||||
"export",
|
||||
"export_to_matplotlib",
|
||||
"toggle_roi",
|
||||
"select_roi",
|
||||
]
|
||||
scan_signal_update = Signal()
|
||||
async_signal_update = Signal()
|
||||
dap_summary_update = Signal(dict, dict)
|
||||
dap_params_update = Signal(dict, dict)
|
||||
autorange_signal = Signal()
|
||||
new_scan = Signal()
|
||||
crosshair_position_changed = Signal(tuple)
|
||||
crosshair_position_changed_string = Signal(str)
|
||||
crosshair_position_clicked = Signal(tuple)
|
||||
crosshair_position_clicked_string = Signal(str)
|
||||
crosshair_coordinates_changed = Signal(tuple)
|
||||
crosshair_coordinates_changed_string = Signal(str)
|
||||
crosshair_coordinates_clicked = Signal(tuple)
|
||||
crosshair_coordinates_clicked_string = Signal(str)
|
||||
roi_changed = Signal(tuple)
|
||||
roi_active = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
config: Waveform1DConfig | dict = None,
|
||||
client=None,
|
||||
gui_id: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
if config is None:
|
||||
config = Waveform1DConfig(widget_class=self.__class__.__name__)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = Waveform1DConfig(**config)
|
||||
super().__init__(client=client, gui_id=gui_id, **kwargs)
|
||||
QWidget.__init__(self, parent)
|
||||
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setSpacing(0)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.fig = BECFigure()
|
||||
self.toolbar = ModularToolBar(
|
||||
actions={
|
||||
"save": MaterialIconAction(icon_name="save", tooltip="Open Export Dialog"),
|
||||
"matplotlib": MaterialIconAction(
|
||||
icon_name="photo_library", tooltip="Open Matplotlib Plot"
|
||||
),
|
||||
"separator_1": SeparatorAction(),
|
||||
"drag_mode": MaterialIconAction(
|
||||
icon_name="drag_pan", tooltip="Drag Mouse Mode", checkable=True
|
||||
),
|
||||
"rectangle_mode": MaterialIconAction(
|
||||
icon_name="frame_inspect", tooltip="Rectangle Zoom Mode", checkable=True
|
||||
),
|
||||
"auto_range": MaterialIconAction(
|
||||
icon_name="open_in_full", tooltip="Autorange Plot"
|
||||
),
|
||||
"separator_2": SeparatorAction(),
|
||||
"curves": MaterialIconAction(
|
||||
icon_name="timeline", tooltip="Open Curves Configuration"
|
||||
),
|
||||
"fit_params": MaterialIconAction(
|
||||
icon_name="monitoring", tooltip="Open Fitting Parameters"
|
||||
),
|
||||
"separator_3": SeparatorAction(),
|
||||
"crosshair": MaterialIconAction(
|
||||
icon_name="point_scan", tooltip="Show Crosshair", checkable=True
|
||||
),
|
||||
"roi_select": MaterialIconAction(
|
||||
icon_name="align_justify_space_between",
|
||||
tooltip="Add ROI region for DAP",
|
||||
checkable=True,
|
||||
),
|
||||
"separator_4": SeparatorAction(),
|
||||
"fps_monitor": MaterialIconAction(
|
||||
icon_name="speed", tooltip="Show FPS Monitor", checkable=True
|
||||
),
|
||||
"axis_settings": MaterialIconAction(
|
||||
icon_name="settings", tooltip="Open Configuration Dialog"
|
||||
),
|
||||
},
|
||||
target_widget=self,
|
||||
)
|
||||
|
||||
self.layout.addWidget(self.toolbar)
|
||||
self.layout.addWidget(self.fig)
|
||||
|
||||
self.warning_util = WarningPopupUtility(self)
|
||||
|
||||
self.waveform = self.fig.plot()
|
||||
self.waveform.apply_config(config)
|
||||
|
||||
self.config = config
|
||||
self._clear_curves_on_plot_update = False
|
||||
|
||||
self.hook_waveform_signals()
|
||||
self._hook_actions()
|
||||
|
||||
def hook_waveform_signals(self):
|
||||
self.waveform.scan_signal_update.connect(self.scan_signal_update)
|
||||
self.waveform.async_signal_update.connect(self.async_signal_update)
|
||||
self.waveform.dap_params_update.connect(self.dap_params_update)
|
||||
self.waveform.dap_summary_update.connect(self.dap_summary_update)
|
||||
self.waveform.autorange_signal.connect(self.autorange_signal)
|
||||
self.waveform.new_scan.connect(self.new_scan)
|
||||
self.waveform.crosshair_coordinates_changed.connect(self.crosshair_coordinates_changed)
|
||||
self.waveform.crosshair_coordinates_clicked.connect(self.crosshair_coordinates_clicked)
|
||||
self.waveform.crosshair_coordinates_changed.connect(
|
||||
self._emit_crosshair_coordinates_changed_string
|
||||
)
|
||||
self.waveform.crosshair_coordinates_clicked.connect(
|
||||
self._emit_crosshair_coordinates_clicked_string
|
||||
)
|
||||
self.waveform.crosshair_position_changed.connect(self.crosshair_position_changed)
|
||||
self.waveform.crosshair_position_clicked.connect(self.crosshair_position_clicked)
|
||||
self.waveform.crosshair_position_changed.connect(
|
||||
self._emit_crosshair_position_changed_string
|
||||
)
|
||||
self.waveform.crosshair_position_clicked.connect(
|
||||
self._emit_crosshair_position_clicked_string
|
||||
)
|
||||
self.waveform.roi_changed.connect(self.roi_changed)
|
||||
self.waveform.roi_active.connect(self.roi_active)
|
||||
|
||||
def _hook_actions(self):
|
||||
self.toolbar.widgets["save"].action.triggered.connect(self.export)
|
||||
self.toolbar.widgets["matplotlib"].action.triggered.connect(self.export_to_matplotlib)
|
||||
self.toolbar.widgets["drag_mode"].action.triggered.connect(self.enable_mouse_pan_mode)
|
||||
self.toolbar.widgets["rectangle_mode"].action.triggered.connect(
|
||||
self.enable_mouse_rectangle_mode
|
||||
)
|
||||
self.toolbar.widgets["auto_range"].action.triggered.connect(self._auto_range_from_toolbar)
|
||||
self.toolbar.widgets["curves"].action.triggered.connect(self.show_curve_settings)
|
||||
self.toolbar.widgets["fit_params"].action.triggered.connect(self.show_fit_summary_dialog)
|
||||
self.toolbar.widgets["axis_settings"].action.triggered.connect(self.show_axis_settings)
|
||||
self.toolbar.widgets["crosshair"].action.triggered.connect(self.waveform.toggle_crosshair)
|
||||
self.toolbar.widgets["roi_select"].action.toggled.connect(self.waveform.toggle_roi)
|
||||
self.toolbar.widgets["fps_monitor"].action.toggled.connect(self.enable_fps_monitor)
|
||||
# self.toolbar.widgets["import"].action.triggered.connect(
|
||||
# lambda: self.load_config(path=None, gui=True)
|
||||
# )
|
||||
# self.toolbar.widgets["export"].action.triggered.connect(
|
||||
# lambda: self.save_config(path=None, gui=True)
|
||||
# )
|
||||
|
||||
@Slot(bool)
|
||||
def toogle_roi_select(self, checked: bool):
|
||||
"""Toggle the linear region selector.
|
||||
|
||||
Args:
|
||||
checked(bool): If True, enable the linear region selector.
|
||||
"""
|
||||
self.toolbar.widgets["roi_select"].action.setChecked(checked)
|
||||
|
||||
@Property(bool)
|
||||
def clear_curves_on_plot_update(self) -> bool:
|
||||
"""If True, clear curves on plot update."""
|
||||
return self._clear_curves_on_plot_update
|
||||
|
||||
@clear_curves_on_plot_update.setter
|
||||
def clear_curves_on_plot_update(self, value: bool):
|
||||
"""Set the clear curves on plot update property.
|
||||
|
||||
Args:
|
||||
value(bool): If True, clear curves on plot update.
|
||||
"""
|
||||
self._clear_curves_on_plot_update = value
|
||||
|
||||
@SafeSlot(tuple)
|
||||
def _emit_crosshair_coordinates_changed_string(self, coordinates):
|
||||
self.crosshair_coordinates_changed_string.emit(str(coordinates))
|
||||
|
||||
@SafeSlot(tuple)
|
||||
def _emit_crosshair_coordinates_clicked_string(self, coordinates):
|
||||
self.crosshair_coordinates_clicked_string.emit(str(coordinates))
|
||||
|
||||
@SafeSlot(tuple)
|
||||
def _emit_crosshair_position_changed_string(self, position):
|
||||
self.crosshair_position_changed_string.emit(str(position))
|
||||
|
||||
@SafeSlot(tuple)
|
||||
def _emit_crosshair_position_clicked_string(self, position):
|
||||
self.crosshair_position_clicked_string.emit(str(position))
|
||||
|
||||
###################################
|
||||
# Dialog Windows
|
||||
###################################
|
||||
def show_axis_settings(self):
|
||||
dialog = SettingsDialog(
|
||||
self,
|
||||
settings_widget=AxisSettings(),
|
||||
window_title="Axis Settings",
|
||||
config=self._config_dict["axis"],
|
||||
)
|
||||
dialog.exec()
|
||||
|
||||
def show_curve_settings(self):
|
||||
dialog = SettingsDialog(
|
||||
self,
|
||||
settings_widget=CurveSettings(),
|
||||
window_title="Curve Settings",
|
||||
config=self.waveform._curves_data,
|
||||
)
|
||||
dialog.resize(800, 600)
|
||||
dialog.exec()
|
||||
|
||||
def show_fit_summary_dialog(self):
|
||||
dialog = FitSummaryWidget(target_widget=self)
|
||||
dialog.resize(800, 600)
|
||||
dialog.exec()
|
||||
|
||||
###################################
|
||||
# User Access Methods from Waveform
|
||||
###################################
|
||||
@property
|
||||
def curves(self) -> list[BECCurve]:
|
||||
"""
|
||||
Get the curves of the plot widget as a list
|
||||
Returns:
|
||||
list: List of curves.
|
||||
"""
|
||||
return self.waveform._curves
|
||||
|
||||
@curves.setter
|
||||
def curves(self, value: list[BECCurve]):
|
||||
self.waveform._curves = value
|
||||
|
||||
def get_curve(self, identifier) -> BECCurve:
|
||||
"""
|
||||
Get the curve by its index or ID.
|
||||
|
||||
Args:
|
||||
identifier(int|str): Identifier of the curve. Can be either an integer (index) or a string (curve_id).
|
||||
|
||||
Returns:
|
||||
BECCurve: The curve object.
|
||||
"""
|
||||
return self.waveform.get_curve(identifier)
|
||||
|
||||
def set_colormap(self, colormap: str):
|
||||
"""
|
||||
Set the colormap of the plot widget.
|
||||
|
||||
Args:
|
||||
colormap(str, optional): Scale the colors of curves to colormap. If None, use the default color palette.
|
||||
"""
|
||||
self.waveform.set_colormap(colormap)
|
||||
|
||||
@Slot(str, str) # Slot for x_name, x_entry
|
||||
@SafeSlot(str, popup_error=True) # Slot for x_name and
|
||||
def set_x(self, x_name: str, x_entry: str | None = None):
|
||||
"""
|
||||
Change the x axis of the plot widget.
|
||||
|
||||
Args:
|
||||
x_name(str): Name of the x signal.
|
||||
- "best_effort": Use the best effort signal.
|
||||
- "timestamp": Use the timestamp signal.
|
||||
- "index": Use the index signal.
|
||||
- Custom signal name of device from BEC.
|
||||
x_entry(str): Entry of the x signal.
|
||||
"""
|
||||
self.waveform.set_x(x_name, x_entry)
|
||||
|
||||
@Slot(str) # Slot for y_name
|
||||
@SafeSlot(popup_error=True)
|
||||
def plot(
|
||||
self,
|
||||
arg1: list | np.ndarray | str | None = None,
|
||||
x: list | np.ndarray | None = None,
|
||||
y: list | np.ndarray | None = None,
|
||||
x_name: str | None = None,
|
||||
y_name: str | None = None,
|
||||
z_name: str | None = None,
|
||||
x_entry: str | None = None,
|
||||
y_entry: str | None = None,
|
||||
z_entry: str | None = None,
|
||||
color: str | None = None,
|
||||
color_map_z: str | None = "magma",
|
||||
label: str | None = None,
|
||||
validate: bool = True,
|
||||
dap: str | None = None, # TODO add dap custom curve wrapper
|
||||
**kwargs,
|
||||
) -> BECCurve:
|
||||
"""
|
||||
Plot a curve to the plot widget.
|
||||
Args:
|
||||
arg1(list | np.ndarray | str | None): First argument which can be x data(list | np.ndarray), y data(list | np.ndarray), or y_name(str).
|
||||
x(list | np.ndarray): Custom x data to plot.
|
||||
y(list | np.ndarray): Custom y data to plot.
|
||||
x_name(str): The name of the device for the x-axis.
|
||||
y_name(str): The name of the device for the y-axis.
|
||||
z_name(str): The name of the device for the z-axis.
|
||||
x_entry(str): The name of the entry for the x-axis.
|
||||
y_entry(str): The name of the entry for the y-axis.
|
||||
z_entry(str): The name of the entry for the z-axis.
|
||||
color(str): The color of the curve.
|
||||
color_map_z(str): The color map to use for the z-axis.
|
||||
label(str): The label of the curve.
|
||||
validate(bool): If True, validate the device names and entries.
|
||||
dap(str): The dap model to use for the curve. If not specified, none will be added.
|
||||
|
||||
Returns:
|
||||
BECCurve: The curve object.
|
||||
"""
|
||||
if self.clear_curves_on_plot_update is True:
|
||||
self.waveform.clear_source(source="scan_segment")
|
||||
return self.waveform.plot(
|
||||
arg1=arg1,
|
||||
x=x,
|
||||
y=y,
|
||||
x_name=x_name,
|
||||
y_name=y_name,
|
||||
z_name=z_name,
|
||||
x_entry=x_entry,
|
||||
y_entry=y_entry,
|
||||
z_entry=z_entry,
|
||||
color=color,
|
||||
color_map_z=color_map_z,
|
||||
label=label,
|
||||
validate=validate,
|
||||
dap=dap,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@Slot(
|
||||
str, str, str, str, str, str, bool
|
||||
) # Slot for x_name, y_name, x_entry, y_entry, color, validate_bec
|
||||
@SafeSlot(str, str, str, popup_error=True)
|
||||
def add_dap(
|
||||
self,
|
||||
x_name: str,
|
||||
y_name: str,
|
||||
dap: str,
|
||||
x_entry: str | None = None,
|
||||
y_entry: str | None = None,
|
||||
color: str | None = None,
|
||||
validate_bec: bool = True,
|
||||
**kwargs,
|
||||
) -> BECCurve:
|
||||
"""
|
||||
Add LMFIT dap model curve to the plot widget.
|
||||
|
||||
Args:
|
||||
x_name(str): Name of the x signal.
|
||||
x_entry(str): Entry of the x signal.
|
||||
y_name(str): Name of the y signal.
|
||||
y_entry(str): Entry of the y signal.
|
||||
color(str, optional): Color of the curve. Defaults to None.
|
||||
dap(str): The dap model to use for the curve.
|
||||
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
|
||||
**kwargs: Additional keyword arguments for the curve configuration.
|
||||
|
||||
Returns:
|
||||
BECCurve: The curve object.
|
||||
"""
|
||||
if self.clear_curves_on_plot_update is True:
|
||||
self.waveform.clear_source(source="DAP")
|
||||
return self.waveform.add_dap(
|
||||
x_name=x_name,
|
||||
y_name=y_name,
|
||||
x_entry=x_entry,
|
||||
y_entry=y_entry,
|
||||
color=color,
|
||||
dap=dap,
|
||||
validate_bec=validate_bec,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def get_dap_params(self) -> dict:
|
||||
"""
|
||||
Get the DAP parameters of all DAP curves.
|
||||
|
||||
Returns:
|
||||
dict: DAP parameters of all DAP curves.
|
||||
"""
|
||||
|
||||
return self.waveform.get_dap_params()
|
||||
|
||||
def get_dap_summary(self) -> dict:
|
||||
"""
|
||||
Get the DAP summary of all DAP curves.
|
||||
|
||||
Returns:
|
||||
dict: DAP summary of all DAP curves.
|
||||
"""
|
||||
return self.waveform.get_dap_summary()
|
||||
|
||||
def remove_curve(self, *identifiers):
|
||||
"""
|
||||
Remove a curve from the plot widget.
|
||||
|
||||
Args:
|
||||
*identifiers: Identifier of the curve to be removed. Can be either an integer (index) or a string (curve_id).
|
||||
"""
|
||||
self.waveform.remove_curve(*identifiers)
|
||||
|
||||
def scan_history(self, scan_index: int = None, scan_id: str = None):
|
||||
"""
|
||||
Update the scan curves with the data from the scan storage.
|
||||
Provide only one of scan_id or scan_index.
|
||||
|
||||
Args:
|
||||
scan_id(str, optional): ScanID of the scan to be updated. Defaults to None.
|
||||
scan_index(int, optional): Index of the scan to be updated. Defaults to None.
|
||||
"""
|
||||
self.waveform.scan_history(scan_index, scan_id)
|
||||
|
||||
def get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict | pd.DataFrame:
|
||||
"""
|
||||
Extract all curve data into a dictionary or a pandas DataFrame.
|
||||
|
||||
Args:
|
||||
output (Literal["dict", "pandas"]): Format of the output data.
|
||||
|
||||
Returns:
|
||||
dict | pd.DataFrame: Data of all curves in the specified format.
|
||||
"""
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError:
|
||||
pd = None
|
||||
if output == "pandas":
|
||||
logger.warning(
|
||||
"Pandas is not installed. "
|
||||
"Please install pandas using 'pip install pandas'."
|
||||
"Output will be dictionary instead."
|
||||
)
|
||||
output = "dict"
|
||||
return self.waveform.get_all_data(output)
|
||||
|
||||
###################################
|
||||
# User Access Methods from Plotbase
|
||||
###################################
|
||||
|
||||
def set(self, **kwargs):
|
||||
"""
|
||||
Set the properties of the plot widget.
|
||||
|
||||
Args:
|
||||
**kwargs: Keyword arguments for the properties to be set.
|
||||
|
||||
Possible properties:
|
||||
- title: str
|
||||
- x_label: str
|
||||
- y_label: str
|
||||
- x_scale: Literal["linear", "log"]
|
||||
- y_scale: Literal["linear", "log"]
|
||||
- x_lim: tuple
|
||||
- y_lim: tuple
|
||||
- legend_label_size: int
|
||||
"""
|
||||
self.waveform.set(**kwargs)
|
||||
|
||||
def set_title(self, title: str):
|
||||
"""
|
||||
Set the title of the plot widget.
|
||||
|
||||
Args:
|
||||
title(str): Title of the plot.
|
||||
"""
|
||||
self.waveform.set_title(title)
|
||||
|
||||
def set_x_label(self, x_label: str):
|
||||
"""
|
||||
Set the x-axis label of the plot widget.
|
||||
|
||||
Args:
|
||||
x_label(str): Label of the x-axis.
|
||||
"""
|
||||
self.waveform.set_x_label(x_label)
|
||||
|
||||
def set_y_label(self, y_label: str):
|
||||
"""
|
||||
Set the y-axis label of the plot widget.
|
||||
|
||||
Args:
|
||||
y_label(str): Label of the y-axis.
|
||||
"""
|
||||
self.waveform.set_y_label(y_label)
|
||||
|
||||
def set_x_scale(self, x_scale: Literal["linear", "log"]):
|
||||
"""
|
||||
Set the scale of the x-axis of the plot widget.
|
||||
|
||||
Args:
|
||||
x_scale(Literal["linear", "log"]): Scale of the x-axis.
|
||||
"""
|
||||
self.waveform.set_x_scale(x_scale)
|
||||
|
||||
def set_y_scale(self, y_scale: Literal["linear", "log"]):
|
||||
"""
|
||||
Set the scale of the y-axis of the plot widget.
|
||||
|
||||
Args:
|
||||
y_scale(Literal["linear", "log"]): Scale of the y-axis.
|
||||
"""
|
||||
self.waveform.set_y_scale(y_scale)
|
||||
|
||||
def set_x_lim(self, x_lim: tuple):
|
||||
"""
|
||||
Set the limits of the x-axis of the plot widget.
|
||||
|
||||
Args:
|
||||
x_lim(tuple): Limits of the x-axis.
|
||||
"""
|
||||
self.waveform.set_x_lim(x_lim)
|
||||
|
||||
def set_y_lim(self, y_lim: tuple):
|
||||
"""
|
||||
Set the limits of the y-axis of the plot widget.
|
||||
|
||||
Args:
|
||||
y_lim(tuple): Limits of the y-axis.
|
||||
"""
|
||||
self.waveform.set_y_lim(y_lim)
|
||||
|
||||
def set_legend_label_size(self, legend_label_size: int):
|
||||
"""
|
||||
Set the size of the legend labels of the plot widget.
|
||||
|
||||
Args:
|
||||
legend_label_size(int): Size of the legend labels.
|
||||
"""
|
||||
self.waveform.set_legend_label_size(legend_label_size)
|
||||
|
||||
def set_auto_range(self, enabled: bool, axis: str = "xy"):
|
||||
"""
|
||||
Set the auto range of the plot widget.
|
||||
|
||||
Args:
|
||||
enabled(bool): If True, enable the auto range.
|
||||
axis(str, optional): The axis to enable the auto range.
|
||||
- "xy": Enable auto range for both x and y axis.
|
||||
- "x": Enable auto range for x axis.
|
||||
- "y": Enable auto range for y axis.
|
||||
"""
|
||||
self.waveform.set_auto_range(enabled, axis)
|
||||
|
||||
def toggle_roi(self, checked: bool):
|
||||
"""Toggle the linear region selector.
|
||||
|
||||
Args:
|
||||
checked(bool): If True, enable the linear region selector.
|
||||
"""
|
||||
self.waveform.toggle_roi(checked)
|
||||
if self.toolbar.widgets["roi_select"].action.isChecked() != checked:
|
||||
self.toolbar.widgets["roi_select"].action.setChecked(checked)
|
||||
|
||||
def select_roi(self, region: tuple):
|
||||
"""
|
||||
Set the region of interest of the plot widget.
|
||||
|
||||
Args:
|
||||
region(tuple): Region of interest.
|
||||
"""
|
||||
self.waveform.select_roi(region)
|
||||
|
||||
def enable_fps_monitor(self, enabled: bool):
|
||||
"""
|
||||
Enable the FPS monitor of the plot widget.
|
||||
|
||||
Args:
|
||||
enabled(bool): If True, enable the FPS monitor.
|
||||
"""
|
||||
self.waveform.enable_fps_monitor(enabled)
|
||||
if self.toolbar.widgets["fps_monitor"].action.isChecked() != enabled:
|
||||
self.toolbar.widgets["fps_monitor"].action.setChecked(enabled)
|
||||
|
||||
@SafeSlot()
|
||||
def _auto_range_from_toolbar(self):
|
||||
"""
|
||||
Set the auto range of the plot widget from the toolbar.
|
||||
"""
|
||||
self.waveform.set_auto_range(True, "xy")
|
||||
|
||||
def set_grid(self, x_grid: bool, y_grid: bool):
|
||||
"""
|
||||
Set the grid visibility of the plot widget.
|
||||
|
||||
Args:
|
||||
x_grid(bool): Visibility of the x-axis grid.
|
||||
y_grid(bool): Visibility of the y-axis grid.
|
||||
"""
|
||||
self.waveform.set_grid(x_grid, y_grid)
|
||||
|
||||
def set_outer_axes(self, show: bool):
|
||||
"""
|
||||
Set the outer axes visibility of the plot widget.
|
||||
|
||||
Args:
|
||||
show(bool): Visibility of the outer axes.
|
||||
"""
|
||||
self.waveform.set_outer_axes(show)
|
||||
|
||||
def enable_scatter(self, enabled: bool):
|
||||
"""
|
||||
Enable the scatter plot of the plot widget.
|
||||
|
||||
Args:
|
||||
enabled(bool): If True, enable the scatter plot.
|
||||
"""
|
||||
self.waveform.enable_scatter(enabled)
|
||||
|
||||
def lock_aspect_ratio(self, lock: bool):
|
||||
"""
|
||||
Lock the aspect ratio of the plot widget.
|
||||
|
||||
Args:
|
||||
lock(bool): Lock the aspect ratio.
|
||||
"""
|
||||
self.waveform.lock_aspect_ratio(lock)
|
||||
|
||||
@SafeSlot()
|
||||
def enable_mouse_rectangle_mode(self):
|
||||
self.toolbar.widgets["rectangle_mode"].action.setChecked(True)
|
||||
self.toolbar.widgets["drag_mode"].action.setChecked(False)
|
||||
self.waveform.plot_item.getViewBox().setMouseMode(pg.ViewBox.RectMode)
|
||||
|
||||
@SafeSlot()
|
||||
def enable_mouse_pan_mode(self):
|
||||
self.toolbar.widgets["drag_mode"].action.setChecked(True)
|
||||
self.toolbar.widgets["rectangle_mode"].action.setChecked(False)
|
||||
self.waveform.plot_item.getViewBox().setMouseMode(pg.ViewBox.PanMode)
|
||||
|
||||
def export(self):
|
||||
"""
|
||||
Show the export dialog for the plot widget.
|
||||
"""
|
||||
self.waveform.export()
|
||||
|
||||
def export_to_matplotlib(self):
|
||||
"""
|
||||
Export the plot widget to Matplotlib.
|
||||
"""
|
||||
try:
|
||||
import matplotlib as mpl
|
||||
except ImportError:
|
||||
self.warning_util.show_warning(
|
||||
title="Matplotlib not installed",
|
||||
message="Matplotlib is required for this feature.",
|
||||
detailed_text="Please install matplotlib in your Python environment by using 'pip install matplotlib'.",
|
||||
)
|
||||
return
|
||||
self.waveform.export_to_matplotlib()
|
||||
|
||||
#######################################
|
||||
# User Access Methods from BECConnector
|
||||
######################################
|
||||
def load_config(self, path: str | None = None, gui: bool = False):
|
||||
"""
|
||||
Load the configuration of the widget from YAML.
|
||||
|
||||
Args:
|
||||
path(str): Path to the configuration file for non-GUI dialog mode.
|
||||
gui(bool): If True, use the GUI dialog to load the configuration file.
|
||||
"""
|
||||
self.fig.load_config(path=path, gui=gui)
|
||||
|
||||
def save_config(self, path: str | None = None, gui: bool = False):
|
||||
"""
|
||||
Save the configuration of the widget to YAML.
|
||||
|
||||
Args:
|
||||
path(str): Path to save the configuration file for non-GUI dialog mode.
|
||||
gui(bool): If True, use the GUI dialog to save the configuration file.
|
||||
"""
|
||||
self.fig.save_config(path=path, gui=gui)
|
||||
|
||||
def cleanup(self):
|
||||
self.fig.cleanup()
|
||||
return super().cleanup()
|
||||
|
||||
|
||||
def main(): # pragma: no cover
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = BECWaveformWidget()
|
||||
widget.plot(x_name="samx", y_name="bpm4i")
|
||||
widget.plot(y_name="bpm3i")
|
||||
widget.plot(y_name="bpm4a")
|
||||
widget.plot(y_name="bpm5i")
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
943
bec_widgets/widgets/plots_next_gen/image/image.py
Normal file
943
bec_widgets/widgets/plots_next_gen/image/image.py
Normal file
@@ -0,0 +1,943 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import bec_logger
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import Field, ValidationError, field_validator
|
||||
from qtpy.QtCore import QPointF, Signal
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.qt_utils.toolbar import MaterialIconAction, SwitchableToolBarAction
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.colors import Colors
|
||||
from bec_widgets.widgets.plots_next_gen.image.image_item import ImageItem
|
||||
from bec_widgets.widgets.plots_next_gen.image.toolbar_bundles.image_selection import (
|
||||
MonitorSelectionToolbarBundle,
|
||||
)
|
||||
from bec_widgets.widgets.plots_next_gen.image.toolbar_bundles.processing import (
|
||||
ImageProcessingToolbarBundle,
|
||||
)
|
||||
from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
# noinspection PyDataclass
|
||||
class ImageConfig(ConnectionConfig):
|
||||
color_map: str = Field(
|
||||
"magma", description="The colormap of the figure widget.", validate_default=True
|
||||
)
|
||||
color_bar: Literal["full", "simple"] | None = Field(
|
||||
None, description="The type of the color bar."
|
||||
)
|
||||
lock_aspect_ratio: bool = Field(
|
||||
False, description="Whether to lock the aspect ratio of the image."
|
||||
)
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
_validate_color_map = field_validator("color_map")(Colors.validate_color_map)
|
||||
|
||||
|
||||
class Image(PlotBase):
|
||||
PLUGIN = True
|
||||
RPC = True
|
||||
ICON_NAME = "image"
|
||||
USER_ACCESS = [
|
||||
# General PlotBase Settings
|
||||
"enable_toolbar",
|
||||
"enable_toolbar.setter",
|
||||
"enable_side_panel",
|
||||
"enable_side_panel.setter",
|
||||
"enable_fps_monitor",
|
||||
"enable_fps_monitor.setter",
|
||||
"set",
|
||||
"title",
|
||||
"title.setter",
|
||||
"x_label",
|
||||
"x_label.setter",
|
||||
"y_label",
|
||||
"y_label.setter",
|
||||
"x_limits",
|
||||
"x_limits.setter",
|
||||
"y_limits",
|
||||
"y_limits.setter",
|
||||
"x_grid",
|
||||
"x_grid.setter",
|
||||
"y_grid",
|
||||
"y_grid.setter",
|
||||
"inner_axes",
|
||||
"inner_axes.setter",
|
||||
"outer_axes",
|
||||
"outer_axes.setter",
|
||||
"auto_range_x",
|
||||
"auto_range_x.setter",
|
||||
"auto_range_y",
|
||||
"auto_range_y.setter",
|
||||
"x_log",
|
||||
"x_log.setter",
|
||||
"y_log",
|
||||
"y_log.setter",
|
||||
"legend_label_size",
|
||||
"legend_label_size.setter",
|
||||
# ImageView Specific Settings
|
||||
"color_map",
|
||||
"color_map.setter",
|
||||
"vrange",
|
||||
"vrange.setter",
|
||||
"v_min",
|
||||
"v_min.setter",
|
||||
"v_max",
|
||||
"v_max.setter",
|
||||
"lock_aspect_ratio",
|
||||
"lock_aspect_ratio.setter",
|
||||
"autorange",
|
||||
"autorange.setter",
|
||||
"autorange_mode",
|
||||
"autorange_mode.setter",
|
||||
"monitor",
|
||||
"monitor.setter",
|
||||
"enable_colorbar",
|
||||
"enable_simple_colorbar",
|
||||
"enable_simple_colorbar.setter",
|
||||
"enable_full_colorbar",
|
||||
"enable_full_colorbar.setter",
|
||||
"fft",
|
||||
"fft.setter",
|
||||
"log",
|
||||
"log.setter",
|
||||
"rotation",
|
||||
"rotation.setter",
|
||||
"transpose",
|
||||
"transpose.setter",
|
||||
"image",
|
||||
"main_image",
|
||||
]
|
||||
sync_colorbar_with_autorange = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
config: ImageConfig | None = None,
|
||||
client=None,
|
||||
gui_id: str | None = None,
|
||||
popups: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
self._main_image = ImageItem(parent_image=self)
|
||||
self._color_bar = None
|
||||
if config is None:
|
||||
config = ImageConfig(widget_class=self.__class__.__name__)
|
||||
super().__init__(
|
||||
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
|
||||
)
|
||||
|
||||
# For PropertyManager identification
|
||||
self.setObjectName("Image")
|
||||
|
||||
self.plot_item.addItem(self._main_image)
|
||||
self.scan_id = None
|
||||
|
||||
# Default Color map to magma
|
||||
self.color_map = "magma"
|
||||
|
||||
################################################################################
|
||||
# Widget Specific GUI interactions
|
||||
################################################################################
|
||||
def _init_toolbar(self):
|
||||
|
||||
# add to the first position
|
||||
self.selection_bundle = MonitorSelectionToolbarBundle(
|
||||
bundle_id="selection", target_widget=self
|
||||
)
|
||||
self.toolbar.add_bundle(self.selection_bundle, self)
|
||||
|
||||
super()._init_toolbar()
|
||||
|
||||
# Image specific changes to PlotBase toolbar
|
||||
self.toolbar.widgets["reset_legend"].action.setVisible(False)
|
||||
|
||||
# Lock aspect ratio button
|
||||
self.lock_aspect_ratio_action = MaterialIconAction(
|
||||
icon_name="aspect_ratio", tooltip="Lock Aspect Ratio", checkable=True, parent=self
|
||||
)
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="mouse_interaction",
|
||||
action_id="lock_aspect_ratio",
|
||||
action=self.lock_aspect_ratio_action,
|
||||
target_widget=self,
|
||||
)
|
||||
self.lock_aspect_ratio_action.action.toggled.connect(
|
||||
lambda checked: self.setProperty("lock_aspect_ratio", checked)
|
||||
)
|
||||
self.lock_aspect_ratio_action.action.setChecked(True)
|
||||
|
||||
self._init_autorange_action()
|
||||
self._init_colorbar_action()
|
||||
|
||||
# Processing Bundle
|
||||
self.processing_bundle = ImageProcessingToolbarBundle(
|
||||
bundle_id="processing", target_widget=self
|
||||
)
|
||||
self.toolbar.add_bundle(self.processing_bundle, target_widget=self)
|
||||
|
||||
def _init_autorange_action(self):
|
||||
|
||||
self.autorange_mean_action = MaterialIconAction(
|
||||
icon_name="hdr_auto", tooltip="Enable Auto Range (Mean)", checkable=True, parent=self
|
||||
)
|
||||
self.autorange_max_action = MaterialIconAction(
|
||||
icon_name="hdr_auto",
|
||||
tooltip="Enable Auto Range (Max)",
|
||||
checkable=True,
|
||||
filled=True,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self.autorange_switch = SwitchableToolBarAction(
|
||||
actions={
|
||||
"auto_range_mean": self.autorange_mean_action,
|
||||
"auto_range_max": self.autorange_max_action,
|
||||
},
|
||||
initial_action="auto_range_mean",
|
||||
tooltip="Enable Auto Range",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="roi",
|
||||
action_id="autorange_image",
|
||||
action=self.autorange_switch,
|
||||
target_widget=self,
|
||||
)
|
||||
|
||||
self.autorange_mean_action.action.toggled.connect(
|
||||
lambda checked: self.toggle_autorange(checked, mode="mean")
|
||||
)
|
||||
self.autorange_max_action.action.toggled.connect(
|
||||
lambda checked: self.toggle_autorange(checked, mode="max")
|
||||
)
|
||||
|
||||
self.autorange = True
|
||||
self.autorange_mode = "mean"
|
||||
|
||||
def _init_colorbar_action(self):
|
||||
self.full_colorbar_action = MaterialIconAction(
|
||||
icon_name="edgesensor_low", tooltip="Enable Full Colorbar", checkable=True, parent=self
|
||||
)
|
||||
self.simple_colorbar_action = MaterialIconAction(
|
||||
icon_name="smartphone", tooltip="Enable Simple Colorbar", checkable=True, parent=self
|
||||
)
|
||||
|
||||
self.colorbar_switch = SwitchableToolBarAction(
|
||||
actions={
|
||||
"full_colorbar": self.full_colorbar_action,
|
||||
"simple_colorbar": self.simple_colorbar_action,
|
||||
},
|
||||
initial_action="full_colorbar",
|
||||
tooltip="Enable Full Colorbar",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="roi",
|
||||
action_id="switch_colorbar",
|
||||
action=self.colorbar_switch,
|
||||
target_widget=self,
|
||||
)
|
||||
|
||||
self.simple_colorbar_action.action.toggled.connect(
|
||||
lambda checked: self.enable_colorbar(checked, style="simple")
|
||||
)
|
||||
self.full_colorbar_action.action.toggled.connect(
|
||||
lambda checked: self.enable_colorbar(checked, style="full")
|
||||
)
|
||||
|
||||
def enable_colorbar(
|
||||
self,
|
||||
enabled: bool,
|
||||
style: Literal["full", "simple"] = "full",
|
||||
vrange: tuple[int, int] | None = None,
|
||||
):
|
||||
"""
|
||||
Enable the colorbar and switch types of colorbars.
|
||||
|
||||
Args:
|
||||
enabled(bool): Whether to enable the colorbar.
|
||||
style(Literal["full", "simple"]): The type of colorbar to enable.
|
||||
vrange(tuple): The range of values to use for the colorbar.
|
||||
"""
|
||||
autorange_state = self._main_image.autorange
|
||||
if enabled:
|
||||
if self._color_bar:
|
||||
if self.config.color_bar == "full":
|
||||
self.cleanup_histogram_lut_item(self._color_bar)
|
||||
self.plot_widget.removeItem(self._color_bar)
|
||||
self._color_bar = None
|
||||
|
||||
if style == "simple":
|
||||
self._color_bar = pg.ColorBarItem(colorMap=self.config.color_map)
|
||||
self._color_bar.setImageItem(self._main_image)
|
||||
self._color_bar.sigLevelsChangeFinished.connect(
|
||||
lambda: self.setProperty("autorange", False)
|
||||
)
|
||||
|
||||
elif style == "full":
|
||||
self._color_bar = pg.HistogramLUTItem()
|
||||
self._color_bar.setImageItem(self._main_image)
|
||||
self._color_bar.gradient.loadPreset(self.config.color_map)
|
||||
self._color_bar.sigLevelsChanged.connect(
|
||||
lambda: self.setProperty("autorange", False)
|
||||
)
|
||||
|
||||
self.plot_widget.addItem(self._color_bar, row=0, col=1)
|
||||
self.config.color_bar = style
|
||||
else:
|
||||
if self._color_bar:
|
||||
self.plot_widget.removeItem(self._color_bar)
|
||||
self._color_bar = None
|
||||
self.config.color_bar = None
|
||||
|
||||
self.autorange = autorange_state
|
||||
self._sync_colorbar_actions()
|
||||
|
||||
if vrange: # should be at the end to disable the autorange if defined
|
||||
self.v_range = vrange
|
||||
|
||||
################################################################################
|
||||
# Widget Specific Properties
|
||||
################################################################################
|
||||
|
||||
################################################################################
|
||||
# Colorbar toggle
|
||||
|
||||
@SafeProperty(bool)
|
||||
def enable_simple_colorbar(self) -> bool:
|
||||
"""
|
||||
Enable the simple colorbar.
|
||||
"""
|
||||
enabled = False
|
||||
if self.config.color_bar == "simple":
|
||||
enabled = True
|
||||
return enabled
|
||||
|
||||
@enable_simple_colorbar.setter
|
||||
def enable_simple_colorbar(self, value: bool):
|
||||
"""
|
||||
Enable the simple colorbar.
|
||||
|
||||
Args:
|
||||
value(bool): Whether to enable the simple colorbar.
|
||||
"""
|
||||
self.enable_colorbar(enabled=value, style="simple")
|
||||
|
||||
@SafeProperty(bool)
|
||||
def enable_full_colorbar(self) -> bool:
|
||||
"""
|
||||
Enable the full colorbar.
|
||||
"""
|
||||
enabled = False
|
||||
if self.config.color_bar == "full":
|
||||
enabled = True
|
||||
return enabled
|
||||
|
||||
@enable_full_colorbar.setter
|
||||
def enable_full_colorbar(self, value: bool):
|
||||
"""
|
||||
Enable the full colorbar.
|
||||
|
||||
Args:
|
||||
value(bool): Whether to enable the full colorbar.
|
||||
"""
|
||||
self.enable_colorbar(enabled=value, style="full")
|
||||
|
||||
################################################################################
|
||||
# Appearance
|
||||
|
||||
@SafeProperty(str)
|
||||
def color_map(self) -> str:
|
||||
"""
|
||||
Set the color map of the image.
|
||||
"""
|
||||
return self.config.color_map
|
||||
|
||||
@color_map.setter
|
||||
def color_map(self, value: str):
|
||||
"""
|
||||
Set the color map of the image.
|
||||
|
||||
Args:
|
||||
value(str): The color map to set.
|
||||
"""
|
||||
try:
|
||||
self.config.color_map = value
|
||||
self._main_image.color_map = value
|
||||
|
||||
if self._color_bar:
|
||||
if self.config.color_bar == "simple":
|
||||
self._color_bar.setColorMap(value)
|
||||
elif self.config.color_bar == "full":
|
||||
self._color_bar.gradient.loadPreset(value)
|
||||
except ValidationError:
|
||||
return
|
||||
|
||||
# v_range is for designer, vrange is for RPC
|
||||
@SafeProperty("QPointF")
|
||||
def v_range(self) -> QPointF:
|
||||
"""
|
||||
Set the v_range of the main image.
|
||||
"""
|
||||
vmin, vmax = self._main_image.v_range
|
||||
return QPointF(vmin, vmax)
|
||||
|
||||
@v_range.setter
|
||||
def v_range(self, value: tuple | list | QPointF):
|
||||
"""
|
||||
Set the v_range of the main image.
|
||||
|
||||
Args:
|
||||
value(tuple | list | QPointF): The range of values to set.
|
||||
"""
|
||||
if isinstance(value, (tuple, list)):
|
||||
value = self._tuple_to_qpointf(value)
|
||||
|
||||
vmin, vmax = value.x(), value.y()
|
||||
|
||||
self._main_image.v_range = (vmin, vmax)
|
||||
|
||||
# propagate to colorbar if exists
|
||||
if self._color_bar:
|
||||
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)
|
||||
|
||||
self.autorange_switch.set_state_all(False)
|
||||
|
||||
@property
|
||||
def vrange(self) -> tuple:
|
||||
"""
|
||||
Get the vrange of the image.
|
||||
"""
|
||||
return (self.v_range.x(), self.v_range.y())
|
||||
|
||||
@vrange.setter
|
||||
def vrange(self, value):
|
||||
"""
|
||||
Set the vrange of the image.
|
||||
|
||||
Args:
|
||||
value(tuple):
|
||||
"""
|
||||
self.v_range = value
|
||||
|
||||
@property
|
||||
def v_min(self) -> float:
|
||||
"""
|
||||
Get the minimum value of the v_range.
|
||||
"""
|
||||
return self.v_range.x()
|
||||
|
||||
@v_min.setter
|
||||
def v_min(self, value: float):
|
||||
"""
|
||||
Set the minimum value of the v_range.
|
||||
|
||||
Args:
|
||||
value(float): The minimum value to set.
|
||||
"""
|
||||
self.v_range = (value, self.v_range.y())
|
||||
|
||||
@property
|
||||
def v_max(self) -> float:
|
||||
"""
|
||||
Get the maximum value of the v_range.
|
||||
"""
|
||||
return self.v_range.y()
|
||||
|
||||
@v_max.setter
|
||||
def v_max(self, value: float):
|
||||
"""
|
||||
Set the maximum value of the v_range.
|
||||
|
||||
Args:
|
||||
value(float): The maximum value to set.
|
||||
"""
|
||||
self.v_range = (self.v_range.x(), value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def lock_aspect_ratio(self) -> bool:
|
||||
"""
|
||||
Whether the aspect ratio is locked.
|
||||
"""
|
||||
return self.config.lock_aspect_ratio
|
||||
|
||||
@lock_aspect_ratio.setter
|
||||
def lock_aspect_ratio(self, value: bool):
|
||||
"""
|
||||
Set the aspect ratio lock.
|
||||
|
||||
Args:
|
||||
value(bool): Whether to lock the aspect ratio.
|
||||
"""
|
||||
self.config.lock_aspect_ratio = bool(value)
|
||||
self.plot_item.setAspectLocked(value)
|
||||
|
||||
################################################################################
|
||||
# Data Acquisition
|
||||
|
||||
@SafeProperty(str)
|
||||
def monitor(self) -> str:
|
||||
"""
|
||||
The name of the monitor to use for the image.
|
||||
"""
|
||||
return self._main_image.config.monitor
|
||||
|
||||
@monitor.setter
|
||||
def monitor(self, value: str):
|
||||
"""
|
||||
Set the monitor for the image.
|
||||
|
||||
Args:
|
||||
value(str): The name of the monitor to set.
|
||||
"""
|
||||
if self._main_image.config.monitor == value:
|
||||
return
|
||||
try:
|
||||
self.entry_validator.validate_monitor(value)
|
||||
except ValueError:
|
||||
return
|
||||
self.image(monitor=value)
|
||||
|
||||
@property
|
||||
def main_image(self) -> ImageItem:
|
||||
"""Access the main image item."""
|
||||
return self._main_image
|
||||
|
||||
################################################################################
|
||||
# Autorange + Colorbar sync
|
||||
|
||||
@SafeProperty(bool)
|
||||
def autorange(self) -> bool:
|
||||
"""
|
||||
Whether autorange is enabled.
|
||||
"""
|
||||
return self._main_image.autorange
|
||||
|
||||
@autorange.setter
|
||||
def autorange(self, enabled: bool):
|
||||
"""
|
||||
Set autorange.
|
||||
|
||||
Args:
|
||||
enabled(bool): Whether to enable autorange.
|
||||
"""
|
||||
self._main_image.autorange = enabled
|
||||
if enabled and self._main_image.raw_data is not None:
|
||||
self._main_image.apply_autorange()
|
||||
self._sync_colorbar_levels()
|
||||
self._sync_autorange_switch()
|
||||
|
||||
@SafeProperty(str)
|
||||
def autorange_mode(self) -> str:
|
||||
"""
|
||||
Autorange mode.
|
||||
|
||||
Options:
|
||||
- "max": Use the maximum value of the image for autoranging.
|
||||
- "mean": Use the mean value of the image for autoranging.
|
||||
|
||||
"""
|
||||
return self._main_image.autorange_mode
|
||||
|
||||
@autorange_mode.setter
|
||||
def autorange_mode(self, mode: str):
|
||||
"""
|
||||
Set the autorange mode.
|
||||
|
||||
Args:
|
||||
mode(str): The autorange mode. Options are "max" or "mean".
|
||||
"""
|
||||
# for qt Designer
|
||||
if mode not in ["max", "mean"]:
|
||||
return
|
||||
self._main_image.autorange_mode = mode
|
||||
|
||||
self._sync_autorange_switch()
|
||||
|
||||
@SafeSlot(bool, str, bool)
|
||||
def toggle_autorange(self, enabled: bool, mode: str):
|
||||
"""
|
||||
Toggle autorange.
|
||||
|
||||
Args:
|
||||
enabled(bool): Whether to enable autorange.
|
||||
mode(str): The autorange mode. Options are "max" or "mean".
|
||||
"""
|
||||
if self._main_image is not None:
|
||||
self._main_image.autorange = enabled
|
||||
self._main_image.autorange_mode = mode
|
||||
if enabled:
|
||||
self._main_image.apply_autorange()
|
||||
self._sync_colorbar_levels()
|
||||
|
||||
def _sync_autorange_switch(self):
|
||||
"""
|
||||
Synchronize the autorange switch with the current autorange state and mode if changed from outside.
|
||||
"""
|
||||
self.autorange_switch.block_all_signals(True)
|
||||
self.autorange_switch.set_default_action(f"auto_range_{self._main_image.autorange_mode}")
|
||||
self.autorange_switch.set_state_all(self._main_image.autorange)
|
||||
self.autorange_switch.block_all_signals(False)
|
||||
|
||||
def _sync_colorbar_levels(self):
|
||||
"""Immediately propagate current levels to the active colorbar."""
|
||||
vrange = self._main_image.v_range
|
||||
if self._color_bar:
|
||||
self._color_bar.blockSignals(True)
|
||||
self.v_range = vrange
|
||||
self._color_bar.blockSignals(False)
|
||||
|
||||
def _sync_colorbar_actions(self):
|
||||
"""
|
||||
Synchronize the colorbar actions with the current colorbar state.
|
||||
"""
|
||||
self.colorbar_switch.block_all_signals(True)
|
||||
if self._color_bar is not None:
|
||||
self.colorbar_switch.set_default_action(f"{self.config.color_bar}_colorbar")
|
||||
self.colorbar_switch.set_state_all(True)
|
||||
else:
|
||||
self.colorbar_switch.set_state_all(False)
|
||||
self.colorbar_switch.block_all_signals(False)
|
||||
|
||||
################################################################################
|
||||
# Post Processing
|
||||
################################################################################
|
||||
|
||||
@SafeProperty(bool)
|
||||
def fft(self) -> bool:
|
||||
"""
|
||||
Whether FFT postprocessing is enabled.
|
||||
"""
|
||||
return self._main_image.fft
|
||||
|
||||
@fft.setter
|
||||
def fft(self, enable: bool):
|
||||
"""
|
||||
Set FFT postprocessing.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to enable FFT postprocessing.
|
||||
"""
|
||||
self._main_image.fft = enable
|
||||
|
||||
@SafeProperty(bool)
|
||||
def log(self) -> bool:
|
||||
"""
|
||||
Whether logarithmic scaling is applied.
|
||||
"""
|
||||
return self._main_image.log
|
||||
|
||||
@log.setter
|
||||
def log(self, enable: bool):
|
||||
"""
|
||||
Set logarithmic scaling.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to enable logarithmic scaling.
|
||||
"""
|
||||
self._main_image.log = enable
|
||||
|
||||
@SafeProperty(int)
|
||||
def rotation(self) -> int:
|
||||
"""
|
||||
The number of 90° rotations to apply.
|
||||
"""
|
||||
return self._main_image.rotation
|
||||
|
||||
@rotation.setter
|
||||
def rotation(self, value: int):
|
||||
"""
|
||||
Set the number of 90° rotations to apply.
|
||||
|
||||
Args:
|
||||
value(int): The number of 90° rotations to apply.
|
||||
"""
|
||||
self._main_image.rotation = value
|
||||
|
||||
@SafeProperty(bool)
|
||||
def transpose(self) -> bool:
|
||||
"""
|
||||
Whether the image is transposed.
|
||||
"""
|
||||
return self._main_image.transpose
|
||||
|
||||
@transpose.setter
|
||||
def transpose(self, enable: bool):
|
||||
"""
|
||||
Set the image to be transposed.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to enable transposing the image.
|
||||
"""
|
||||
self._main_image.transpose = enable
|
||||
|
||||
################################################################################
|
||||
# High Level methods for API
|
||||
################################################################################
|
||||
@SafeSlot(popup_error=True)
|
||||
def image(
|
||||
self,
|
||||
monitor: str | None = None,
|
||||
monitor_type: Literal["auto", "1d", "2d"] = "auto",
|
||||
color_map: str | None = None,
|
||||
color_bar: Literal["simple", "full"] | None = None,
|
||||
vrange: tuple[int, int] | None = None,
|
||||
) -> ImageItem:
|
||||
"""
|
||||
Set the image source and update the image.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor to use for the image.
|
||||
monitor_type(str): The type of monitor to use. Options are "1d", "2d", or "auto".
|
||||
color_map(str): The color map to use for the image.
|
||||
color_bar(str): The type of color bar to use. Options are "simple" or "full".
|
||||
vrange(tuple): The range of values to use for the color map.
|
||||
|
||||
Returns:
|
||||
ImageItem: The image object.
|
||||
"""
|
||||
|
||||
if self._main_image.config.monitor is not None:
|
||||
self.disconnect_monitor(self._main_image.config.monitor)
|
||||
self.entry_validator.validate_monitor(monitor)
|
||||
self._main_image.config.monitor = monitor
|
||||
|
||||
if monitor_type == "1d":
|
||||
self._main_image.config.source = "device_monitor_1d"
|
||||
self._main_image.config.monitor_type = "1d"
|
||||
elif monitor_type == "2d":
|
||||
self._main_image.config.source = "device_monitor_2d"
|
||||
self._main_image.config.monitor_type = "2d"
|
||||
elif monitor_type == "auto":
|
||||
self._main_image.config.source = "auto"
|
||||
logger.warning(
|
||||
f"Updates for '{monitor}' will be fetch from both 1D and 2D monitor endpoints."
|
||||
)
|
||||
self._main_image.config.monitor_type = "auto"
|
||||
|
||||
self.set_image_update(monitor=monitor, type=monitor_type)
|
||||
if color_map is not None:
|
||||
self._main_image.color_map = color_map
|
||||
if color_bar is not None:
|
||||
self.enable_colorbar(True, color_bar)
|
||||
if vrange is not None:
|
||||
self.vrange = vrange
|
||||
|
||||
self._sync_device_selection()
|
||||
|
||||
return self._main_image
|
||||
|
||||
def _sync_device_selection(self):
|
||||
"""
|
||||
Synchronize the device selection with the current monitor.
|
||||
"""
|
||||
if self._main_image.config.monitor is not None:
|
||||
for combo in (
|
||||
self.selection_bundle.device_combo_box,
|
||||
self.selection_bundle.dim_combo_box,
|
||||
):
|
||||
combo.blockSignals(True)
|
||||
self.selection_bundle.device_combo_box.set_device(self._main_image.config.monitor)
|
||||
self.selection_bundle.dim_combo_box.setCurrentText(self._main_image.config.monitor_type)
|
||||
for combo in (
|
||||
self.selection_bundle.device_combo_box,
|
||||
self.selection_bundle.dim_combo_box,
|
||||
):
|
||||
combo.blockSignals(False)
|
||||
|
||||
################################################################################
|
||||
# Image Update Methods
|
||||
################################################################################
|
||||
|
||||
########################################
|
||||
# Connections
|
||||
|
||||
def set_image_update(self, monitor: str, type: Literal["1d", "2d", "auto"]):
|
||||
"""
|
||||
Set the image update method for the given monitor.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor to use for the image.
|
||||
type(str): The type of monitor to use. Options are "1d", "2d", or "auto".
|
||||
"""
|
||||
|
||||
# TODO consider moving connecting and disconnecting logic to Image itself if multiple images
|
||||
if type == "1d":
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor)
|
||||
)
|
||||
elif type == "2d":
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor)
|
||||
)
|
||||
elif type == "auto":
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor)
|
||||
)
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor)
|
||||
)
|
||||
print(f"Connected to {monitor} with type {type}")
|
||||
self._main_image.config.monitor = monitor
|
||||
|
||||
def disconnect_monitor(self, monitor: str):
|
||||
"""
|
||||
Disconnect the monitor from the image update signals, both 1D and 2D.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor to disconnect.
|
||||
"""
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor)
|
||||
)
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor)
|
||||
)
|
||||
self._main_image.config.monitor = None
|
||||
|
||||
########################################
|
||||
# 1D updates
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_image_update_1d(self, msg: dict, metadata: dict):
|
||||
"""
|
||||
Update the image with 1D data.
|
||||
|
||||
Args:
|
||||
msg(dict): The message containing the data.
|
||||
metadata(dict): The metadata associated with the message.
|
||||
"""
|
||||
data = msg["data"]
|
||||
current_scan_id = metadata.get("scan_id", None)
|
||||
|
||||
if current_scan_id is None:
|
||||
return
|
||||
if current_scan_id != self.scan_id:
|
||||
self.scan_id = current_scan_id
|
||||
self._main_image.clear()
|
||||
self._main_image.buffer = []
|
||||
self._main_image.max_len = 0
|
||||
image_buffer = self.adjust_image_buffer(self._main_image, data)
|
||||
if self._color_bar is not None:
|
||||
self._color_bar.blockSignals(True)
|
||||
self._main_image.set_data(image_buffer)
|
||||
if self._color_bar is not None:
|
||||
self._color_bar.blockSignals(False)
|
||||
|
||||
def adjust_image_buffer(self, image: ImageItem, new_data: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Adjusts the image buffer to accommodate the new data, ensuring that all rows have the same length.
|
||||
|
||||
Args:
|
||||
image: The image object (used to store a buffer list and max_len).
|
||||
new_data (np.ndarray): The new incoming 1D waveform data.
|
||||
|
||||
Returns:
|
||||
np.ndarray: The updated image buffer with adjusted shapes.
|
||||
"""
|
||||
new_len = new_data.shape[0]
|
||||
if not hasattr(image, "buffer"):
|
||||
image.buffer = []
|
||||
image.max_len = 0
|
||||
|
||||
if new_len > image.max_len:
|
||||
image.max_len = new_len
|
||||
for i in range(len(image.buffer)):
|
||||
wf = image.buffer[i]
|
||||
pad_width = image.max_len - wf.shape[0]
|
||||
if pad_width > 0:
|
||||
image.buffer[i] = np.pad(wf, (0, pad_width), mode="constant", constant_values=0)
|
||||
image.buffer.append(new_data)
|
||||
else:
|
||||
pad_width = image.max_len - new_len
|
||||
if pad_width > 0:
|
||||
new_data = np.pad(new_data, (0, pad_width), mode="constant", constant_values=0)
|
||||
image.buffer.append(new_data)
|
||||
|
||||
image_buffer = np.array(image.buffer)
|
||||
return image_buffer
|
||||
|
||||
########################################
|
||||
# 2D updates
|
||||
|
||||
def on_image_update_2d(self, msg: dict, metadata: dict):
|
||||
"""
|
||||
Update the image with 2D data.
|
||||
|
||||
Args:
|
||||
msg(dict): The message containing the data.
|
||||
metadata(dict): The metadata associated with the message.
|
||||
"""
|
||||
data = msg["data"]
|
||||
if self._color_bar is not None:
|
||||
self._color_bar.blockSignals(True)
|
||||
self._main_image.set_data(data)
|
||||
if self._color_bar is not None:
|
||||
self._color_bar.blockSignals(False)
|
||||
|
||||
################################################################################
|
||||
# Clean up
|
||||
################################################################################
|
||||
|
||||
@staticmethod
|
||||
def cleanup_histogram_lut_item(histogram_lut_item: pg.HistogramLUTItem):
|
||||
"""
|
||||
Clean up HistogramLUTItem safely, including open ViewBox menus and child widgets.
|
||||
|
||||
Args:
|
||||
histogram_lut_item(pg.HistogramLUTItem): The HistogramLUTItem to clean up.
|
||||
"""
|
||||
histogram_lut_item.vb.menu.close()
|
||||
histogram_lut_item.vb.menu.deleteLater()
|
||||
|
||||
histogram_lut_item.gradient.menu.close()
|
||||
histogram_lut_item.gradient.menu.deleteLater()
|
||||
histogram_lut_item.gradient.colorDialog.close()
|
||||
histogram_lut_item.gradient.colorDialog.deleteLater()
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Disconnect the image update signals and clean up the image.
|
||||
"""
|
||||
if self._main_image.config.monitor is not None:
|
||||
self.disconnect_monitor(self._main_image.config.monitor)
|
||||
self._main_image.config.monitor = None
|
||||
|
||||
if self._color_bar:
|
||||
if self.config.color_bar == "full":
|
||||
self.cleanup_histogram_lut_item(self._color_bar)
|
||||
if self.config.color_bar == "simple":
|
||||
self.plot_widget.removeItem(self._color_bar)
|
||||
self._color_bar.deleteLater()
|
||||
self._color_bar = None
|
||||
|
||||
super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = Image(popups=True)
|
||||
widget.show()
|
||||
widget.resize(1000, 800)
|
||||
sys.exit(app.exec_())
|
||||
1
bec_widgets/widgets/plots_next_gen/image/image.pyproject
Normal file
1
bec_widgets/widgets/plots_next_gen/image/image.pyproject
Normal file
@@ -0,0 +1 @@
|
||||
{'files': ['image.py']}
|
||||
260
bec_widgets/widgets/plots_next_gen/image/image_item.py
Normal file
260
bec_widgets/widgets/plots_next_gen/image/image_item.py
Normal file
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field, ValidationError, field_validator
|
||||
from qtpy.QtCore import Signal
|
||||
|
||||
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
|
||||
from bec_widgets.widgets.plots_next_gen.image.image_processor import (
|
||||
ImageProcessor,
|
||||
ImageStats,
|
||||
ProcessingConfig,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
# noinspection PyDataclass
|
||||
class ImageItemConfig(ConnectionConfig): # TODO review config
|
||||
parent_id: str | None = Field(None, description="The parent plot of the image.")
|
||||
monitor: str | None = Field(None, description="The name of the monitor.")
|
||||
monitor_type: Literal["1d", "2d", "auto"] = Field("auto", description="The type of monitor.")
|
||||
source: str | None = Field(None, description="The source of the curve.")
|
||||
color_map: str | None = Field("magma", description="The color map of the image.")
|
||||
downsample: bool | None = Field(True, description="Whether to downsample the image.")
|
||||
opacity: float | None = Field(1.0, description="The opacity of the image.")
|
||||
v_range: tuple[float | int, float | int] | None = Field(
|
||||
None, description="The range of the color bar. If None, the range is automatically set."
|
||||
)
|
||||
autorange: bool | None = Field(True, description="Whether to autorange the color bar.")
|
||||
autorange_mode: Literal["max", "mean"] = Field(
|
||||
"mean", description="Whether to use the mean of the image for autoscaling."
|
||||
)
|
||||
processing: ProcessingConfig = Field(
|
||||
default_factory=ProcessingConfig, description="The post processing of the image."
|
||||
)
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
_validate_color_map = field_validator("color_map")(Colors.validate_color_map)
|
||||
|
||||
|
||||
class ImageItem(BECConnector, pg.ImageItem):
|
||||
RPC = True
|
||||
USER_ACCESS = [
|
||||
"color_map",
|
||||
"color_map.setter",
|
||||
"v_range",
|
||||
"v_range.setter",
|
||||
"v_min",
|
||||
"v_min.setter",
|
||||
"v_max",
|
||||
"v_max.setter",
|
||||
"autorange",
|
||||
"autorange.setter",
|
||||
"autorange_mode",
|
||||
"autorange_mode.setter",
|
||||
"fft",
|
||||
"fft.setter",
|
||||
"log",
|
||||
"log.setter",
|
||||
"rotation",
|
||||
"rotation.setter",
|
||||
"transpose",
|
||||
"transpose.setter",
|
||||
]
|
||||
|
||||
vRangeChangedManually = Signal(tuple)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Optional[ImageItemConfig] = None,
|
||||
gui_id: Optional[str] = None,
|
||||
parent_image=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.raw_data = None
|
||||
self.buffer = []
|
||||
self.max_len = 0
|
||||
|
||||
# Image processor will handle any setting of data
|
||||
self._image_processor = ImageProcessor(config=self.config.processing)
|
||||
|
||||
def set_data(self, data: np.ndarray):
|
||||
self.raw_data = data
|
||||
self._process_image()
|
||||
|
||||
################################################################################
|
||||
# Properties
|
||||
@property
|
||||
def color_map(self) -> str:
|
||||
"""Get the current color map."""
|
||||
return self.config.color_map
|
||||
|
||||
@color_map.setter
|
||||
def color_map(self, value: str):
|
||||
"""Set a new color map."""
|
||||
try:
|
||||
self.config.color_map = value
|
||||
self.setColorMap(value)
|
||||
except ValidationError:
|
||||
logger.error(f"Invalid colormap '{value}' provided.")
|
||||
|
||||
@property
|
||||
def v_range(self) -> tuple[float, float]:
|
||||
"""
|
||||
Get the color intensity range of the image.
|
||||
"""
|
||||
if self.levels is not None:
|
||||
return tuple(float(x) for x in self.levels)
|
||||
return 0.0, 1.0
|
||||
|
||||
@v_range.setter
|
||||
def v_range(self, vrange: tuple[float, float]):
|
||||
"""
|
||||
Set the color intensity range of the image.
|
||||
"""
|
||||
self.set_v_range(vrange, disable_autorange=True)
|
||||
|
||||
def set_v_range(self, vrange: tuple[float, float], disable_autorange=True):
|
||||
if disable_autorange:
|
||||
self.config.autorange = False
|
||||
self.vRangeChangedManually.emit(vrange)
|
||||
self.setLevels(vrange)
|
||||
self.config.v_range = vrange
|
||||
|
||||
@property
|
||||
def v_min(self) -> float:
|
||||
return self.v_range[0]
|
||||
|
||||
@v_min.setter
|
||||
def v_min(self, value: float):
|
||||
self.v_range = (value, self.v_range[1])
|
||||
|
||||
@property
|
||||
def v_max(self) -> float:
|
||||
return self.v_range[1]
|
||||
|
||||
@v_max.setter
|
||||
def v_max(self, value: float):
|
||||
self.v_range = (self.v_range[0], value)
|
||||
|
||||
################################################################################
|
||||
# Autorange Logic
|
||||
|
||||
@property
|
||||
def autorange(self) -> bool:
|
||||
return self.config.autorange
|
||||
|
||||
@autorange.setter
|
||||
def autorange(self, value: bool):
|
||||
self.config.autorange = value
|
||||
if value:
|
||||
self.apply_autorange()
|
||||
|
||||
@property
|
||||
def autorange_mode(self) -> Literal["max", "mean"]:
|
||||
return self.config.autorange_mode
|
||||
|
||||
@autorange_mode.setter
|
||||
def autorange_mode(self, mode: Literal["max", "mean"]):
|
||||
self.config.autorange_mode = mode
|
||||
if self.autorange:
|
||||
self.apply_autorange()
|
||||
|
||||
def apply_autorange(self):
|
||||
if self.raw_data is None:
|
||||
return
|
||||
data = self.image
|
||||
if data is None:
|
||||
data = self.raw_data
|
||||
stats = ImageStats.from_data(data)
|
||||
self.auto_update_vrange(stats)
|
||||
|
||||
def auto_update_vrange(self, stats: ImageStats) -> None:
|
||||
"""Update the v_range based on the stats of the image."""
|
||||
fumble_factor = 2
|
||||
if self.config.autorange_mode == "mean":
|
||||
vmin = max(stats.mean - fumble_factor * stats.std, 0)
|
||||
vmax = stats.mean + fumble_factor * stats.std
|
||||
elif self.config.autorange_mode == "max":
|
||||
vmin, vmax = stats.minimum, stats.maximum
|
||||
else:
|
||||
return
|
||||
self.set_v_range(vrange=(vmin, vmax), disable_autorange=False)
|
||||
|
||||
################################################################################
|
||||
# Data Processing Logic
|
||||
|
||||
def _process_image(self):
|
||||
"""
|
||||
Reprocess the current raw data and update the image display.
|
||||
"""
|
||||
if self.raw_data is not None:
|
||||
autorange = self.config.autorange
|
||||
self._image_processor.set_config(self.config.processing)
|
||||
processed_data = self._image_processor.process_image(self.raw_data)
|
||||
self.setImage(processed_data, autoLevels=False)
|
||||
self.autorange = autorange
|
||||
|
||||
@property
|
||||
def fft(self) -> bool:
|
||||
"""Get or set whether FFT postprocessing is enabled."""
|
||||
return self.config.processing.fft
|
||||
|
||||
@fft.setter
|
||||
def fft(self, enable: bool):
|
||||
self.config.processing.fft = enable
|
||||
self._process_image()
|
||||
|
||||
@property
|
||||
def log(self) -> bool:
|
||||
"""Get or set whether logarithmic scaling is applied."""
|
||||
return self.config.processing.log
|
||||
|
||||
@log.setter
|
||||
def log(self, enable: bool):
|
||||
self.config.processing.log = enable
|
||||
self._process_image()
|
||||
|
||||
@property
|
||||
def rotation(self) -> Optional[int]:
|
||||
"""Get or set the number of 90° rotations to apply."""
|
||||
return self.config.processing.rotation
|
||||
|
||||
@rotation.setter
|
||||
def rotation(self, value: Optional[int]):
|
||||
self.config.processing.rotation = value
|
||||
self._process_image()
|
||||
|
||||
@property
|
||||
def transpose(self) -> bool:
|
||||
"""Get or set whether the image is transposed."""
|
||||
return self.config.processing.transpose
|
||||
|
||||
@transpose.setter
|
||||
def transpose(self, enable: bool):
|
||||
self.config.processing.transpose = enable
|
||||
self._process_image()
|
||||
|
||||
################################################################################
|
||||
# Data Update Logic
|
||||
|
||||
def clear(self):
|
||||
super().clear()
|
||||
self.raw_data = None
|
||||
self.buffer = []
|
||||
self.max_len = 0
|
||||
@@ -1,43 +1,39 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
import os
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.plots.image.image_widget import BECImageWidget
|
||||
from bec_widgets.widgets.plots_next_gen.image.image import Image
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='BECImageWidget' name='bec_image_widget'>
|
||||
<widget class='Image' name='image'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class BECImageWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
class ImagePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = BECImageWidget(parent)
|
||||
t = Image(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Plots"
|
||||
return "Plot Widgets Next Gen"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(BECImageWidget.ICON_NAME)
|
||||
return designer_material_icon(Image.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "bec_image_widget"
|
||||
return "image"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
@@ -49,10 +45,10 @@ class BECImageWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "BECImageWidget"
|
||||
return "Image"
|
||||
|
||||
def toolTip(self):
|
||||
return "BECImageWidget"
|
||||
return "Image"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
150
bec_widgets/widgets/plots_next_gen/image/image_processor.py
Normal file
150
bec_widgets/widgets/plots_next_gen/image/image_processor.py
Normal file
@@ -0,0 +1,150 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
from pydantic import BaseModel, Field
|
||||
from qtpy.QtCore import QObject, Signal
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageStats:
|
||||
"""Container to store stats of an image."""
|
||||
|
||||
maximum: float
|
||||
minimum: float
|
||||
mean: float
|
||||
std: float
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: np.ndarray) -> ImageStats:
|
||||
"""
|
||||
Get the statistics of the image data.
|
||||
|
||||
Args:
|
||||
data(np.ndarray): The image data.
|
||||
|
||||
Returns:
|
||||
ImageStats: The statistics of the image data.
|
||||
"""
|
||||
return cls(maximum=np.max(data), minimum=np.min(data), mean=np.mean(data), std=np.std(data))
|
||||
|
||||
|
||||
# noinspection PyDataclass
|
||||
class ProcessingConfig(BaseModel):
|
||||
fft: bool = Field(False, description="Whether to perform FFT on the monitor data.")
|
||||
log: bool = Field(False, description="Whether to perform log on the monitor data.")
|
||||
transpose: bool = Field(
|
||||
False, description="Whether to transpose the monitor data before displaying."
|
||||
)
|
||||
rotation: int = Field(
|
||||
0, description="The rotation angle of the monitor data before displaying."
|
||||
)
|
||||
stats: ImageStats = Field(
|
||||
ImageStats(maximum=0, minimum=0, mean=0, std=0),
|
||||
description="The statistics of the image data.",
|
||||
)
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
class ImageProcessor(QObject):
|
||||
"""
|
||||
Class for processing the image data.
|
||||
"""
|
||||
|
||||
image_processed = Signal(np.ndarray)
|
||||
|
||||
def __init__(self, parent=None, config: ProcessingConfig = None):
|
||||
super().__init__(parent=parent)
|
||||
if config is None:
|
||||
config = ProcessingConfig()
|
||||
self.config = config
|
||||
self._current_thread = None
|
||||
|
||||
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 update_image_stats(self, data: np.ndarray) -> None:
|
||||
"""Get the statistics of the image data.
|
||||
|
||||
Args:
|
||||
data(np.ndarray): The image data.
|
||||
|
||||
"""
|
||||
self.config.stats.maximum = np.max(data)
|
||||
self.config.stats.minimum = np.min(data)
|
||||
self.config.stats.mean = np.mean(data)
|
||||
self.config.stats.std = np.std(data)
|
||||
|
||||
def process_image(self, data: np.ndarray) -> np.ndarray:
|
||||
"""Core processing logic without threading overhead."""
|
||||
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)
|
||||
self.update_image_stats(data)
|
||||
return data
|
||||
@@ -6,9 +6,9 @@ def main(): # pragma: no cover
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.plots.image.bec_image_widget_plugin import BECImageWidgetPlugin
|
||||
from bec_widgets.widgets.plots_next_gen.image.image_plugin import ImagePlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BECImageWidgetPlugin())
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(ImagePlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
@@ -0,0 +1,57 @@
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QComboBox, QStyledItemDelegate
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
from bec_widgets.qt_utils.toolbar import ToolbarBundle, WidgetAction
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
|
||||
|
||||
class NoCheckDelegate(QStyledItemDelegate):
|
||||
"""To reduce space in combo boxes by removing the checkmark."""
|
||||
|
||||
def initStyleOption(self, option, index):
|
||||
super().initStyleOption(option, index)
|
||||
# Remove any check indicator
|
||||
option.checkState = Qt.Unchecked
|
||||
|
||||
|
||||
class MonitorSelectionToolbarBundle(ToolbarBundle):
|
||||
"""
|
||||
A bundle of actions for a toolbar that controls monitor selection on a plot.
|
||||
"""
|
||||
|
||||
def __init__(self, bundle_id="device_selection", target_widget=None, **kwargs):
|
||||
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
|
||||
self.target_widget = target_widget
|
||||
|
||||
# 1) Device combo box
|
||||
self.device_combo_box = DeviceComboBox(
|
||||
device_filter=BECDeviceFilter.DEVICE, readout_priority_filter=[ReadoutPriority.ASYNC]
|
||||
)
|
||||
self.device_combo_box.addItem("", None)
|
||||
self.device_combo_box.setCurrentText("")
|
||||
self.device_combo_box.setToolTip("Select Device")
|
||||
self.device_combo_box.setItemDelegate(NoCheckDelegate(self.device_combo_box))
|
||||
|
||||
self.add_action("monitor", WidgetAction(widget=self.device_combo_box, adjust_size=True))
|
||||
|
||||
# 2) Dimension combo box
|
||||
self.dim_combo_box = QComboBox()
|
||||
self.dim_combo_box.addItems(["auto", "1d", "2d"])
|
||||
self.dim_combo_box.setCurrentText("auto")
|
||||
self.dim_combo_box.setToolTip("Monitor Dimension")
|
||||
self.dim_combo_box.setFixedWidth(60)
|
||||
self.dim_combo_box.setItemDelegate(NoCheckDelegate(self.dim_combo_box))
|
||||
|
||||
self.add_action("dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=True))
|
||||
|
||||
# Connect slots, a device will be connected upon change of any combobox
|
||||
self.device_combo_box.currentTextChanged.connect(lambda: self.connect_monitor())
|
||||
self.dim_combo_box.currentTextChanged.connect(lambda: self.connect_monitor())
|
||||
|
||||
@SafeSlot()
|
||||
def connect_monitor(self):
|
||||
dim = self.dim_combo_box.currentText()
|
||||
self.target_widget.image(monitor=self.device_combo_box.currentText(), monitor_type=dim)
|
||||
@@ -0,0 +1,79 @@
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ToolbarBundle
|
||||
|
||||
|
||||
class ImageProcessingToolbarBundle(ToolbarBundle):
|
||||
"""
|
||||
A bundle of actions for a toolbar that controls processing of monitor.
|
||||
"""
|
||||
|
||||
def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs):
|
||||
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
|
||||
self.target_widget = target_widget
|
||||
|
||||
self.fft = MaterialIconAction(icon_name="fft", tooltip="Toggle FFT", checkable=True)
|
||||
self.log = MaterialIconAction(icon_name="log_scale", tooltip="Toggle Log", checkable=True)
|
||||
self.transpose = MaterialIconAction(
|
||||
icon_name="transform", tooltip="Transpose Image", checkable=True
|
||||
)
|
||||
self.right = MaterialIconAction(
|
||||
icon_name="rotate_right", tooltip="Rotate image clockwise by 90 deg"
|
||||
)
|
||||
self.left = MaterialIconAction(
|
||||
icon_name="rotate_left", tooltip="Rotate image counterclockwise by 90 deg"
|
||||
)
|
||||
self.reset = MaterialIconAction(icon_name="reset_settings", tooltip="Reset Image Settings")
|
||||
|
||||
self.add_action("fft", self.fft)
|
||||
self.add_action("log", self.log)
|
||||
self.add_action("transpose", self.transpose)
|
||||
self.add_action("rotate_right", self.right)
|
||||
self.add_action("rotate_left", self.left)
|
||||
self.add_action("reset", self.reset)
|
||||
|
||||
self.fft.action.triggered.connect(self.toggle_fft)
|
||||
self.log.action.triggered.connect(self.toggle_log)
|
||||
self.transpose.action.triggered.connect(self.toggle_transpose)
|
||||
self.right.action.triggered.connect(self.rotate_right)
|
||||
self.left.action.triggered.connect(self.rotate_left)
|
||||
self.reset.action.triggered.connect(self.reset_settings)
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_fft(self):
|
||||
checked = self.fft.action.isChecked()
|
||||
self.target_widget.fft = checked
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_log(self):
|
||||
checked = self.log.action.isChecked()
|
||||
self.target_widget.log = checked
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_transpose(self):
|
||||
checked = self.transpose.action.isChecked()
|
||||
self.target_widget.transpose = checked
|
||||
|
||||
@SafeSlot()
|
||||
def rotate_right(self):
|
||||
if self.target_widget.rotation is None:
|
||||
return
|
||||
rotation = (self.target_widget.rotation - 1) % 4
|
||||
self.target_widget.rotation = rotation
|
||||
|
||||
@SafeSlot()
|
||||
def rotate_left(self):
|
||||
if self.target_widget.rotation is None:
|
||||
return
|
||||
rotation = (self.target_widget.rotation + 1) % 4
|
||||
self.target_widget.rotation = rotation
|
||||
|
||||
@SafeSlot()
|
||||
def reset_settings(self):
|
||||
self.target_widget.fft = False
|
||||
self.target_widget.log = False
|
||||
self.target_widget.transpose = False
|
||||
self.target_widget.rotation = 0
|
||||
|
||||
self.fft.action.setChecked(False)
|
||||
self.log.action.setChecked(False)
|
||||
self.transpose.action.setChecked(False)
|
||||
@@ -95,6 +95,7 @@ class PlotBase(BECWidget, QWidget):
|
||||
self.entry_validator = EntryValidator(self.dev)
|
||||
|
||||
# Base widgets elements
|
||||
self._popups = popups
|
||||
self._ui_mode = UIMode.POPUP if popups else UIMode.SIDE
|
||||
self.axis_settings_dialog = None
|
||||
self.plot_widget = pg.GraphicsLayoutWidget(parent=self)
|
||||
@@ -111,6 +112,8 @@ class PlotBase(BECWidget, QWidget):
|
||||
self.fps_label = QLabel(alignment=Qt.AlignmentFlag.AlignRight)
|
||||
self._user_x_label = ""
|
||||
self._x_label_suffix = ""
|
||||
self._user_y_label = ""
|
||||
self._y_label_suffix = ""
|
||||
|
||||
self._init_ui()
|
||||
|
||||
@@ -218,15 +221,28 @@ class PlotBase(BECWidget, QWidget):
|
||||
self.axis_settings_dialog = None
|
||||
self.toolbar.widgets["axis"].action.setChecked(False)
|
||||
|
||||
def reset_legend(self):
|
||||
"""In the case that the legend is not visible, reset it to be visible to top left corner"""
|
||||
self.plot_item.legend.autoAnchor(50)
|
||||
|
||||
################################################################################
|
||||
# Toggle UI Elements
|
||||
################################################################################
|
||||
@property
|
||||
def ui_mode(self) -> UIMode:
|
||||
"""
|
||||
Get the UI mode.
|
||||
"""
|
||||
return self._ui_mode
|
||||
|
||||
@ui_mode.setter
|
||||
def ui_mode(self, mode: UIMode):
|
||||
"""
|
||||
Set the UI mode.
|
||||
|
||||
Args:
|
||||
mode(UIMode): The UI mode to set.
|
||||
"""
|
||||
if not isinstance(mode, UIMode):
|
||||
raise ValueError("ui_mode must be an instance of UIMode")
|
||||
self._ui_mode = mode
|
||||
@@ -252,10 +268,19 @@ class PlotBase(BECWidget, QWidget):
|
||||
|
||||
@SafeProperty(bool, doc="Enable popups setting dialogs for the plot widget.")
|
||||
def enable_popups(self):
|
||||
"""
|
||||
Enable popups setting dialogs for the plot widget.
|
||||
"""
|
||||
return self.ui_mode == UIMode.POPUP
|
||||
|
||||
@enable_popups.setter
|
||||
def enable_popups(self, value: bool):
|
||||
"""
|
||||
Set the popups setting dialogs for the plot widget.
|
||||
|
||||
Args:
|
||||
value(bool): The value to set.
|
||||
"""
|
||||
if value:
|
||||
self.ui_mode = UIMode.POPUP
|
||||
else:
|
||||
@@ -264,10 +289,19 @@ class PlotBase(BECWidget, QWidget):
|
||||
|
||||
@SafeProperty(bool, doc="Show Side Panel")
|
||||
def enable_side_panel(self) -> bool:
|
||||
"""
|
||||
Show Side Panel
|
||||
"""
|
||||
return self.ui_mode == UIMode.SIDE
|
||||
|
||||
@enable_side_panel.setter
|
||||
def enable_side_panel(self, value: bool):
|
||||
"""
|
||||
Show Side Panel
|
||||
|
||||
Args:
|
||||
value(bool): The value to set.
|
||||
"""
|
||||
if value:
|
||||
self.ui_mode = UIMode.SIDE
|
||||
else:
|
||||
@@ -276,33 +310,36 @@ class PlotBase(BECWidget, QWidget):
|
||||
|
||||
@SafeProperty(bool, doc="Show Toolbar")
|
||||
def enable_toolbar(self) -> bool:
|
||||
"""
|
||||
Show Toolbar.
|
||||
"""
|
||||
return self.toolbar.isVisible()
|
||||
|
||||
@enable_toolbar.setter
|
||||
def enable_toolbar(self, value: bool):
|
||||
if value:
|
||||
# Disable popup mode
|
||||
if self._popups:
|
||||
# Directly update the internal flag to avoid recursion
|
||||
self._popups = False
|
||||
# Hide the popup bundle if it exists and close any open dialogs
|
||||
if self.popup_bundle is not None:
|
||||
for action in self.toolbar.bundles["popup_bundle"].actions:
|
||||
action.setVisible(False)
|
||||
if self.axis_settings_dialog is not None and self.axis_settings_dialog.isVisible():
|
||||
self.axis_settings_dialog.close()
|
||||
self.side_panel.show()
|
||||
# Add side menus if not already added
|
||||
self.add_side_menus()
|
||||
else:
|
||||
self.side_panel.hide()
|
||||
"""
|
||||
Show Toolbar.
|
||||
|
||||
Args:
|
||||
value(bool): The value to set.
|
||||
"""
|
||||
self.toolbar.setVisible(value)
|
||||
|
||||
@SafeProperty(bool, doc="Enable the FPS monitor.")
|
||||
def enable_fps_monitor(self) -> bool:
|
||||
"""
|
||||
Enable the FPS monitor.
|
||||
"""
|
||||
return self.fps_label.isVisible()
|
||||
|
||||
@enable_fps_monitor.setter
|
||||
def enable_fps_monitor(self, value: bool):
|
||||
"""
|
||||
Enable the FPS monitor.
|
||||
|
||||
Args:
|
||||
value(bool): The value to set.
|
||||
"""
|
||||
if value and self.fps_monitor is None:
|
||||
self.hook_fps_monitor()
|
||||
elif not value and self.fps_monitor is not None:
|
||||
@@ -369,19 +406,37 @@ class PlotBase(BECWidget, QWidget):
|
||||
|
||||
@SafeProperty(str, doc="The title of the axes.")
|
||||
def title(self) -> str:
|
||||
"""
|
||||
Set title of the plot.
|
||||
"""
|
||||
return self.plot_item.titleLabel.text
|
||||
|
||||
@title.setter
|
||||
def title(self, value: str):
|
||||
"""
|
||||
Set title of the plot.
|
||||
|
||||
Args:
|
||||
value(str): The title to set.
|
||||
"""
|
||||
self.plot_item.setTitle(value)
|
||||
self.property_changed.emit("title", value)
|
||||
|
||||
@SafeProperty(str, doc="The text of the x label")
|
||||
def x_label(self) -> str:
|
||||
"""
|
||||
The set label for the x-axis.
|
||||
"""
|
||||
return self._user_x_label
|
||||
|
||||
@x_label.setter
|
||||
def x_label(self, value: str):
|
||||
"""
|
||||
The set label for the x-axis.
|
||||
|
||||
Args:
|
||||
value(str): The label to set.
|
||||
"""
|
||||
self._user_x_label = value
|
||||
self._apply_x_label()
|
||||
self.property_changed.emit("x_label", self._user_x_label)
|
||||
@@ -420,13 +475,51 @@ class PlotBase(BECWidget, QWidget):
|
||||
|
||||
@SafeProperty(str, doc="The text of the y label")
|
||||
def y_label(self) -> str:
|
||||
return self.plot_item.getAxis("left").labelText
|
||||
"""
|
||||
The set label for the y-axis.
|
||||
"""
|
||||
return self._user_y_label
|
||||
|
||||
@y_label.setter
|
||||
def y_label(self, value: str):
|
||||
self.plot_item.setLabel("left", text=value)
|
||||
"""
|
||||
The set label for the y-axis.
|
||||
Args:
|
||||
value(str): The label to set.
|
||||
"""
|
||||
self._user_y_label = value
|
||||
self._apply_y_label()
|
||||
self.property_changed.emit("y_label", value)
|
||||
|
||||
@property
|
||||
def y_label_suffix(self) -> str:
|
||||
"""
|
||||
A read-only suffix automatically appended to the y label.
|
||||
"""
|
||||
return self._y_label_suffix
|
||||
|
||||
def set_y_label_suffix(self, suffix: str):
|
||||
"""
|
||||
Public method to update the y label suffix.
|
||||
"""
|
||||
self._y_label_suffix = suffix
|
||||
self._apply_y_label()
|
||||
|
||||
@property
|
||||
def y_label_combined(self) -> str:
|
||||
"""
|
||||
The final y label shown on the axis = user portion + suffix.
|
||||
"""
|
||||
return self._user_y_label + self._y_label_suffix
|
||||
|
||||
def _apply_y_label(self):
|
||||
"""
|
||||
Actually updates the pyqtgraph y axis label text to
|
||||
the combined y label. Called whenever y label or suffix changes.
|
||||
"""
|
||||
final_label = self.y_label_combined
|
||||
self.plot_item.setLabel("left", text=final_label)
|
||||
|
||||
def _tuple_to_qpointf(self, tuple: tuple | list):
|
||||
"""
|
||||
Helper function to convert a tuple to a QPointF.
|
||||
@@ -452,37 +545,74 @@ class PlotBase(BECWidget, QWidget):
|
||||
|
||||
@SafeProperty("QPointF")
|
||||
def x_limits(self) -> QPointF:
|
||||
"""
|
||||
Get the x limits of the plot.
|
||||
"""
|
||||
current_lim = self.plot_item.vb.viewRange()[0]
|
||||
return QPointF(current_lim[0], current_lim[1])
|
||||
|
||||
@x_limits.setter
|
||||
def x_limits(self, value):
|
||||
"""
|
||||
Set the x limits of the plot.
|
||||
|
||||
Args:
|
||||
value(QPointF|tuple|list): The x limits to set.
|
||||
"""
|
||||
if isinstance(value, (tuple, list)):
|
||||
value = self._tuple_to_qpointf(value)
|
||||
self.plot_item.vb.setXRange(value.x(), value.y(), padding=0)
|
||||
|
||||
@property
|
||||
def x_lim(self) -> tuple:
|
||||
"""
|
||||
Get the x limits of the plot.
|
||||
"""
|
||||
return (self.x_limits.x(), self.x_limits.y())
|
||||
|
||||
@x_lim.setter
|
||||
def x_lim(self, value):
|
||||
"""
|
||||
Set the x limits of the plot.
|
||||
|
||||
Args:
|
||||
value(tuple): The x limits to set.
|
||||
"""
|
||||
self.x_limits = value
|
||||
|
||||
@property
|
||||
def x_min(self) -> float:
|
||||
"""
|
||||
Get the minimum x limit of the plot.
|
||||
|
||||
"""
|
||||
return self.x_limits.x()
|
||||
|
||||
@x_min.setter
|
||||
def x_min(self, value: float):
|
||||
"""
|
||||
Set the minimum x limit of the plot.
|
||||
|
||||
Args:
|
||||
value(float): The minimum x limit to set.
|
||||
"""
|
||||
self.x_limits = (value, self.x_lim[1])
|
||||
|
||||
@property
|
||||
def x_max(self) -> float:
|
||||
"""
|
||||
Get the maximum x limit of the plot.
|
||||
"""
|
||||
return self.x_limits.y()
|
||||
|
||||
@x_max.setter
|
||||
def x_max(self, value: float):
|
||||
"""
|
||||
Set the maximum x limit of the plot.
|
||||
|
||||
Args:
|
||||
value(float): The maximum x limit to set.
|
||||
"""
|
||||
self.x_limits = (self.x_lim[0], value)
|
||||
|
||||
################################################################################
|
||||
@@ -491,121 +621,241 @@ class PlotBase(BECWidget, QWidget):
|
||||
|
||||
@SafeProperty("QPointF")
|
||||
def y_limits(self) -> QPointF:
|
||||
"""
|
||||
Get the y limits of the plot.
|
||||
"""
|
||||
current_lim = self.plot_item.vb.viewRange()[1]
|
||||
return QPointF(current_lim[0], current_lim[1])
|
||||
|
||||
@y_limits.setter
|
||||
def y_limits(self, value):
|
||||
"""
|
||||
Set the y limits of the plot.
|
||||
|
||||
Args:
|
||||
value(QPointF|tuple|list): The y limits to set.
|
||||
"""
|
||||
if isinstance(value, (tuple, list)):
|
||||
value = self._tuple_to_qpointf(value)
|
||||
self.plot_item.vb.setYRange(value.x(), value.y(), padding=0)
|
||||
|
||||
@property
|
||||
def y_lim(self) -> tuple:
|
||||
"""
|
||||
Get the y limits of the plot.
|
||||
"""
|
||||
return (self.y_limits.x(), self.y_limits.y())
|
||||
|
||||
@y_lim.setter
|
||||
def y_lim(self, value):
|
||||
"""
|
||||
Set the y limits of the plot.
|
||||
|
||||
Args:
|
||||
value(tuple): The y limits to set.
|
||||
"""
|
||||
self.y_limits = value
|
||||
|
||||
@property
|
||||
def y_min(self) -> float:
|
||||
"""
|
||||
Get the minimum y limit of the plot.
|
||||
"""
|
||||
return self.y_limits.x()
|
||||
|
||||
@y_min.setter
|
||||
def y_min(self, value: float):
|
||||
"""
|
||||
Set the minimum y limit of the plot.
|
||||
|
||||
Args:
|
||||
value(float): The minimum y limit to set.
|
||||
"""
|
||||
self.y_limits = (value, self.y_lim[1])
|
||||
|
||||
@property
|
||||
def y_max(self) -> float:
|
||||
"""
|
||||
Get the maximum y limit of the plot.
|
||||
"""
|
||||
return self.y_limits.y()
|
||||
|
||||
@y_max.setter
|
||||
def y_max(self, value: float):
|
||||
"""
|
||||
Set the maximum y limit of the plot.
|
||||
|
||||
Args:
|
||||
value(float): The maximum y limit to set.
|
||||
"""
|
||||
self.y_limits = (self.y_lim[0], value)
|
||||
|
||||
@SafeProperty(bool, doc="Show grid on the x-axis.")
|
||||
def x_grid(self) -> bool:
|
||||
"""
|
||||
Show grid on the x-axis.
|
||||
"""
|
||||
return self.plot_item.ctrl.xGridCheck.isChecked()
|
||||
|
||||
@x_grid.setter
|
||||
def x_grid(self, value: bool):
|
||||
"""
|
||||
Show grid on the x-axis.
|
||||
|
||||
Args:
|
||||
value(bool): The value to set.
|
||||
"""
|
||||
self.plot_item.showGrid(x=value)
|
||||
self.property_changed.emit("x_grid", value)
|
||||
|
||||
@SafeProperty(bool, doc="Show grid on the y-axis.")
|
||||
def y_grid(self) -> bool:
|
||||
"""
|
||||
Show grid on the y-axis.
|
||||
"""
|
||||
return self.plot_item.ctrl.yGridCheck.isChecked()
|
||||
|
||||
@y_grid.setter
|
||||
def y_grid(self, value: bool):
|
||||
"""
|
||||
Show grid on the y-axis.
|
||||
|
||||
Args:
|
||||
value(bool): The value to set.
|
||||
"""
|
||||
self.plot_item.showGrid(y=value)
|
||||
self.property_changed.emit("y_grid", value)
|
||||
|
||||
@SafeProperty(bool, doc="Set X-axis to log scale if True, linear if False.")
|
||||
def x_log(self) -> bool:
|
||||
"""
|
||||
Set X-axis to log scale if True, linear if False.
|
||||
"""
|
||||
return bool(self.plot_item.vb.state.get("logMode", [False, False])[0])
|
||||
|
||||
@x_log.setter
|
||||
def x_log(self, value: bool):
|
||||
"""
|
||||
Set X-axis to log scale if True, linear if False.
|
||||
|
||||
Args:
|
||||
value(bool): The value to set.
|
||||
"""
|
||||
self.plot_item.setLogMode(x=value)
|
||||
self.property_changed.emit("x_log", value)
|
||||
|
||||
@SafeProperty(bool, doc="Set Y-axis to log scale if True, linear if False.")
|
||||
def y_log(self) -> bool:
|
||||
"""
|
||||
Set Y-axis to log scale if True, linear if False.
|
||||
"""
|
||||
return bool(self.plot_item.vb.state.get("logMode", [False, False])[1])
|
||||
|
||||
@y_log.setter
|
||||
def y_log(self, value: bool):
|
||||
"""
|
||||
Set Y-axis to log scale if True, linear if False.
|
||||
|
||||
Args:
|
||||
value(bool): The value to set.
|
||||
"""
|
||||
self.plot_item.setLogMode(y=value)
|
||||
self.property_changed.emit("y_log", value)
|
||||
|
||||
@SafeProperty(bool, doc="Show the outer axes of the plot widget.")
|
||||
def outer_axes(self) -> bool:
|
||||
"""
|
||||
Show the outer axes of the plot widget.
|
||||
"""
|
||||
return self.plot_item.getAxis("top").isVisible()
|
||||
|
||||
@outer_axes.setter
|
||||
def outer_axes(self, value: bool):
|
||||
"""
|
||||
Show the outer axes of the plot widget.
|
||||
|
||||
Args:
|
||||
value(bool): The value to set.
|
||||
"""
|
||||
self.plot_item.showAxis("top", value)
|
||||
self.plot_item.showAxis("right", value)
|
||||
self.property_changed.emit("outer_axes", value)
|
||||
|
||||
@SafeProperty(bool, doc="Show inner axes of the plot widget.")
|
||||
def inner_axes(self) -> bool:
|
||||
"""
|
||||
Show inner axes of the plot widget.
|
||||
"""
|
||||
return self.plot_item.getAxis("bottom").isVisible()
|
||||
|
||||
@inner_axes.setter
|
||||
def inner_axes(self, value: bool):
|
||||
"""
|
||||
Show inner axes of the plot widget.
|
||||
|
||||
Args:
|
||||
value(bool): The value to set.
|
||||
"""
|
||||
self.plot_item.showAxis("bottom", value)
|
||||
self.plot_item.showAxis("left", value)
|
||||
self.property_changed.emit("inner_axes", value)
|
||||
|
||||
@SafeProperty(bool, doc="Lock aspect ratio of the plot widget.")
|
||||
def lock_aspect_ratio(self) -> bool:
|
||||
"""
|
||||
Lock aspect ratio of the plot widget.
|
||||
"""
|
||||
return bool(self.plot_item.vb.getState()["aspectLocked"])
|
||||
|
||||
@lock_aspect_ratio.setter
|
||||
def lock_aspect_ratio(self, value: bool):
|
||||
"""
|
||||
Lock aspect ratio of the plot widget.
|
||||
|
||||
Args:
|
||||
value(bool): The value to set.
|
||||
"""
|
||||
self.plot_item.setAspectLocked(value)
|
||||
|
||||
@SafeProperty(bool, doc="Set auto range for the x-axis.")
|
||||
def auto_range_x(self) -> bool:
|
||||
"""
|
||||
Set auto range for the x-axis.
|
||||
"""
|
||||
return bool(self.plot_item.vb.getState()["autoRange"][0])
|
||||
|
||||
@auto_range_x.setter
|
||||
def auto_range_x(self, value: bool):
|
||||
"""
|
||||
Set auto range for the x-axis.
|
||||
|
||||
Args:
|
||||
value(bool): The value to set.
|
||||
"""
|
||||
self.plot_item.enableAutoRange(x=value)
|
||||
|
||||
@SafeProperty(bool, doc="Set auto range for the y-axis.")
|
||||
def auto_range_y(self) -> bool:
|
||||
"""
|
||||
Set auto range for the y-axis.
|
||||
"""
|
||||
return bool(self.plot_item.vb.getState()["autoRange"][1])
|
||||
|
||||
@auto_range_y.setter
|
||||
def auto_range_y(self, value: bool):
|
||||
"""
|
||||
Set auto range for the y-axis.
|
||||
|
||||
Args:
|
||||
value(bool): The value to set.
|
||||
"""
|
||||
self.plot_item.enableAutoRange(y=value)
|
||||
|
||||
@SafeProperty(int, doc="The font size of the legend font.")
|
||||
def legend_label_size(self) -> int:
|
||||
"""
|
||||
The font size of the legend font.
|
||||
"""
|
||||
if not self.plot_item.legend:
|
||||
return
|
||||
scale = self.plot_item.legend.scale() * 9
|
||||
@@ -613,6 +863,12 @@ class PlotBase(BECWidget, QWidget):
|
||||
|
||||
@legend_label_size.setter
|
||||
def legend_label_size(self, value: int):
|
||||
"""
|
||||
The font size of the legend font.
|
||||
|
||||
Args:
|
||||
value(int): The font size to set.
|
||||
"""
|
||||
if not self.plot_item.legend:
|
||||
return
|
||||
scale = (
|
||||
@@ -699,12 +955,16 @@ class PlotBase(BECWidget, QWidget):
|
||||
def cleanup(self):
|
||||
self.unhook_crosshair()
|
||||
self.unhook_fps_monitor(delete_label=True)
|
||||
if self.axis_settings_dialog is not None:
|
||||
self.axis_settings_dialog.close()
|
||||
self.axis_settings_dialog = None
|
||||
self.cleanup_pyqtgraph()
|
||||
self.rpc_register.remove_rpc(self)
|
||||
super().cleanup()
|
||||
|
||||
def cleanup_pyqtgraph(self):
|
||||
def cleanup_pyqtgraph(self, item: pg.PlotItem | None = None):
|
||||
"""Cleanup pyqtgraph items."""
|
||||
item = self.plot_item
|
||||
if item is None:
|
||||
item = self.plot_item
|
||||
item.vb.menu.close()
|
||||
item.vb.menu.deleteLater()
|
||||
item.ctrlMenu.close()
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.plots_next_gen.scatter_waveform.scatter_waveform_plugin import (
|
||||
ScatterWaveformPlugin,
|
||||
)
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(ScatterWaveformPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import bec_logger
|
||||
from pydantic import BaseModel, Field, ValidationError, field_validator
|
||||
from qtpy import QtCore
|
||||
|
||||
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.widgets.plots_next_gen.scatter_waveform.scatter_waveform import ScatterWaveform
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
# noinspection PyDataclass
|
||||
class ScatterDeviceSignal(BaseModel):
|
||||
"""The configuration of a signal in the scatter waveform widget."""
|
||||
|
||||
name: str
|
||||
entry: str
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
# noinspection PyDataclass
|
||||
class ScatterCurveConfig(ConnectionConfig):
|
||||
parent_id: str | None = Field(None, description="The parent plot of the curve.")
|
||||
label: str | None = Field(None, description="The label of the curve.")
|
||||
color: str | tuple = Field("#808080", description="The color of the curve.")
|
||||
symbol: str | None = Field("o", description="The symbol of the curve.")
|
||||
symbol_size: int | None = Field(7, description="The size of the symbol of the curve.")
|
||||
pen_width: int | None = Field(4, description="The width of the pen of the curve.")
|
||||
pen_style: Literal["solid", "dash", "dot", "dashdot"] = Field(
|
||||
"solid", description="The style of the pen of the curve."
|
||||
)
|
||||
color_map: str | None = Field(
|
||||
"magma", description="The color palette of the figure widget.", validate_default=True
|
||||
)
|
||||
x_device: ScatterDeviceSignal | None = Field(
|
||||
None, description="The x device signal of the scatter waveform."
|
||||
)
|
||||
y_device: ScatterDeviceSignal | None = Field(
|
||||
None, description="The y device signal of the scatter waveform."
|
||||
)
|
||||
z_device: ScatterDeviceSignal | None = Field(
|
||||
None, description="The z device signal of the scatter waveform."
|
||||
)
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
_validate_color_palette = field_validator("color_map")(Colors.validate_color_map)
|
||||
|
||||
|
||||
class ScatterCurve(BECConnector, pg.PlotDataItem):
|
||||
"""Scatter curve item for the scatter waveform widget."""
|
||||
|
||||
USER_ACCESS = ["color_map"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent_item: ScatterWaveform,
|
||||
name: str | None = None,
|
||||
config: ScatterCurveConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
if config is None:
|
||||
config = ScatterCurveConfig(
|
||||
label=name,
|
||||
widget_class=self.__class__.__name__,
|
||||
parent_id=parent_item.config.gui_id,
|
||||
)
|
||||
self.config = config
|
||||
else:
|
||||
self.config = config
|
||||
name = config.label
|
||||
super().__init__(config=config, gui_id=gui_id)
|
||||
pg.PlotDataItem.__init__(self, name=name)
|
||||
|
||||
self.parent_item = parent_item
|
||||
self.data_z = None # color scaling needs to be cashed for changing colormap
|
||||
self.apply_config()
|
||||
|
||||
def apply_config(self, config: dict | ScatterCurveConfig | None = None, **kwargs) -> None:
|
||||
"""
|
||||
Apply the configuration to the curve.
|
||||
|
||||
Args:
|
||||
config(dict|ScatterCurveConfig, optional): The configuration to apply.
|
||||
"""
|
||||
|
||||
if config is not None:
|
||||
if isinstance(config, dict):
|
||||
config = ScatterCurveConfig(**config)
|
||||
self.config = config
|
||||
|
||||
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:
|
||||
self.setSymbolSize(self.config.symbol_size)
|
||||
self.setSymbol(self.config.symbol)
|
||||
|
||||
@property
|
||||
def color_map(self) -> str:
|
||||
"""The color map of the scatter curve."""
|
||||
return self.config.color_map
|
||||
|
||||
@color_map.setter
|
||||
def color_map(self, value: str):
|
||||
"""
|
||||
Set the color map of the scatter curve.
|
||||
|
||||
Args:
|
||||
value(str): The color map to set.
|
||||
"""
|
||||
try:
|
||||
if value != self.config.color_map:
|
||||
self.config.color_map = value
|
||||
self.refresh_color_map(value)
|
||||
except ValidationError:
|
||||
return
|
||||
|
||||
def set_data(
|
||||
self,
|
||||
x: list[float] | np.ndarray,
|
||||
y: list[float] | np.ndarray,
|
||||
z: list[float] | np.ndarray,
|
||||
color_map: str | None = None,
|
||||
):
|
||||
"""
|
||||
Set the data of the scatter curve.
|
||||
|
||||
Args:
|
||||
x (list[float] | np.ndarray): The x data of the scatter curve.
|
||||
y (list[float] | np.ndarray): The y data of the scatter curve.
|
||||
z (list[float] | np.ndarray): The z data of the scatter curve.
|
||||
color_map (str | None): The color map of the scatter curve.
|
||||
"""
|
||||
if color_map is None:
|
||||
color_map = self.config.color_map
|
||||
|
||||
self.data_z = z
|
||||
color_z = self._make_z_gradient(z, color_map)
|
||||
try:
|
||||
self.setData(x=x, y=y, symbolBrush=color_z)
|
||||
except TypeError:
|
||||
logger.error("Error in setData, one of the data arrays is None")
|
||||
|
||||
def _make_z_gradient(self, data_z: list | np.ndarray, colormap: str) -> list | None:
|
||||
"""
|
||||
Make a gradient color for the z values.
|
||||
|
||||
Args:
|
||||
data_z(list|np.ndarray): Z values.
|
||||
colormap(str): Colormap for the gradient color.
|
||||
|
||||
Returns:
|
||||
list: List of colors for the z values.
|
||||
"""
|
||||
# Normalize z_values for color mapping
|
||||
z_min, z_max = np.min(data_z), np.max(data_z)
|
||||
|
||||
if z_max != z_min: # Ensure that there is a range in the z values
|
||||
z_values_norm = (data_z - z_min) / (z_max - z_min)
|
||||
colormap = pg.colormap.get(colormap) # using colormap from global settings
|
||||
colors = [colormap.map(z, mode="qcolor") for z in z_values_norm]
|
||||
return colors
|
||||
else:
|
||||
return None
|
||||
|
||||
def refresh_color_map(self, color_map: str):
|
||||
"""
|
||||
Refresh the color map of the scatter curve.
|
||||
|
||||
Args:
|
||||
color_map(str): The color map to use.
|
||||
"""
|
||||
x_data, y_data = self.getData()
|
||||
if x_data is None or y_data is None:
|
||||
return
|
||||
if self.data_z is not None:
|
||||
self.set_data(x_data, y_data, self.data_z, color_map)
|
||||
@@ -0,0 +1,522 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import bec_logger
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import Field, ValidationError, field_validator
|
||||
from qtpy.QtCore import QTimer, Signal
|
||||
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.qt_utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.qt_utils.toolbar import MaterialIconAction
|
||||
from bec_widgets.utils import Colors, ConnectionConfig
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase
|
||||
from bec_widgets.widgets.plots_next_gen.scatter_waveform.scatter_curve import (
|
||||
ScatterCurve,
|
||||
ScatterCurveConfig,
|
||||
ScatterDeviceSignal,
|
||||
)
|
||||
from bec_widgets.widgets.plots_next_gen.scatter_waveform.settings.scatter_curve_setting import (
|
||||
ScatterCurveSettings,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
# noinspection PyDataclass
|
||||
class ScatterWaveformConfig(ConnectionConfig):
|
||||
color_map: str | None = Field(
|
||||
"magma",
|
||||
description="The color map of the z scaling of scatter waveform.",
|
||||
validate_default=True,
|
||||
)
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
_validate_color_palette = field_validator("color_map")(Colors.validate_color_map)
|
||||
|
||||
|
||||
class ScatterWaveform(PlotBase):
|
||||
PLUGIN = True
|
||||
RPC = True
|
||||
ICON_NAME = "scatter_plot"
|
||||
USER_ACCESS = [
|
||||
# General PlotBase Settings
|
||||
"enable_toolbar",
|
||||
"enable_toolbar.setter",
|
||||
"enable_side_panel",
|
||||
"enable_side_panel.setter",
|
||||
"enable_fps_monitor",
|
||||
"enable_fps_monitor.setter",
|
||||
"set",
|
||||
"title",
|
||||
"title.setter",
|
||||
"x_label",
|
||||
"x_label.setter",
|
||||
"y_label",
|
||||
"y_label.setter",
|
||||
"x_limits",
|
||||
"x_limits.setter",
|
||||
"y_limits",
|
||||
"y_limits.setter",
|
||||
"x_grid",
|
||||
"x_grid.setter",
|
||||
"y_grid",
|
||||
"y_grid.setter",
|
||||
"inner_axes",
|
||||
"inner_axes.setter",
|
||||
"outer_axes",
|
||||
"outer_axes.setter",
|
||||
"lock_aspect_ratio",
|
||||
"lock_aspect_ratio.setter",
|
||||
"auto_range_x",
|
||||
"auto_range_x.setter",
|
||||
"auto_range_y",
|
||||
"auto_range_y.setter",
|
||||
"x_log",
|
||||
"x_log.setter",
|
||||
"y_log",
|
||||
"y_log.setter",
|
||||
"legend_label_size",
|
||||
"legend_label_size.setter",
|
||||
# Scatter Waveform Specific RPC Access
|
||||
"main_curve",
|
||||
"color_map",
|
||||
"color_map.setter",
|
||||
"plot",
|
||||
"update_with_scan_history",
|
||||
"clear_all",
|
||||
]
|
||||
|
||||
sync_signal_update = Signal()
|
||||
new_scan = Signal()
|
||||
new_scan_id = Signal(str)
|
||||
scatter_waveform_property_changed = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
config: ScatterWaveformConfig | None = None,
|
||||
client=None,
|
||||
gui_id: str | None = None,
|
||||
popups: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
if config is None:
|
||||
config = ScatterWaveformConfig(widget_class=self.__class__.__name__)
|
||||
super().__init__(
|
||||
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
|
||||
)
|
||||
self._main_curve = ScatterCurve(parent_item=self)
|
||||
# For PropertyManager identification
|
||||
self.setObjectName("ScatterWaveform")
|
||||
|
||||
# Specific GUI elements
|
||||
self.scatter_dialog = None
|
||||
|
||||
# Scan Data
|
||||
self.old_scan_id = None
|
||||
self.scan_id = None
|
||||
self.scan_item = None
|
||||
|
||||
# Scan status update loop
|
||||
self.bec_dispatcher.connect_slot(self.on_scan_status, MessageEndpoints.scan_status())
|
||||
self.bec_dispatcher.connect_slot(self.on_scan_progress, MessageEndpoints.scan_progress())
|
||||
|
||||
# Curve update loop
|
||||
self.proxy_update_sync = pg.SignalProxy(
|
||||
self.sync_signal_update, rateLimit=25, slot=self.update_sync_curves
|
||||
)
|
||||
|
||||
self._init_scatter_curve_settings()
|
||||
self.update_with_scan_history(-1)
|
||||
|
||||
################################################################################
|
||||
# Widget Specific GUI interactions
|
||||
################################################################################
|
||||
def _init_scatter_curve_settings(self):
|
||||
"""
|
||||
Initialize the scatter curve settings menu.
|
||||
"""
|
||||
|
||||
scatter_curve_settings = ScatterCurveSettings(target_widget=self, popup=False)
|
||||
self.side_panel.add_menu(
|
||||
action_id="scatter_curve",
|
||||
icon_name="scatter_plot",
|
||||
tooltip="Show Scatter Curve Settings",
|
||||
widget=scatter_curve_settings,
|
||||
title="Scatter Curve Settings",
|
||||
)
|
||||
|
||||
def add_popups(self):
|
||||
"""
|
||||
Add popups to the ScatterWaveform widget.
|
||||
"""
|
||||
super().add_popups()
|
||||
scatter_curve_setting_action = MaterialIconAction(
|
||||
icon_name="scatter_plot",
|
||||
tooltip="Show Scatter Curve Settings",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="popup_bundle",
|
||||
action_id="scatter_waveform_settings",
|
||||
action=scatter_curve_setting_action,
|
||||
target_widget=self,
|
||||
)
|
||||
self.toolbar.widgets["scatter_waveform_settings"].action.triggered.connect(
|
||||
self.show_scatter_curve_settings
|
||||
)
|
||||
|
||||
def show_scatter_curve_settings(self):
|
||||
"""
|
||||
Show the scatter curve settings dialog.
|
||||
"""
|
||||
scatter_settings_action = self.toolbar.widgets["scatter_waveform_settings"].action
|
||||
if self.scatter_dialog is None or not self.scatter_dialog.isVisible():
|
||||
scatter_settings = ScatterCurveSettings(target_widget=self, popup=True)
|
||||
self.scatter_dialog = SettingsDialog(
|
||||
self,
|
||||
settings_widget=scatter_settings,
|
||||
window_title="Scatter Curve Settings",
|
||||
modal=False,
|
||||
)
|
||||
self.scatter_dialog.resize(620, 200)
|
||||
# When the dialog is closed, update the toolbar icon and clear the reference
|
||||
self.scatter_dialog.finished.connect(self._scatter_dialog_closed)
|
||||
self.scatter_dialog.show()
|
||||
scatter_settings_action.setChecked(True)
|
||||
else:
|
||||
# If already open, bring it to the front
|
||||
self.scatter_dialog.raise_()
|
||||
self.scatter_dialog.activateWindow()
|
||||
scatter_settings_action.setChecked(True) # keep it toggled
|
||||
|
||||
def _scatter_dialog_closed(self):
|
||||
"""
|
||||
Slot for when the scatter curve settings dialog is closed.
|
||||
"""
|
||||
self.scatter_dialog = None
|
||||
self.toolbar.widgets["scatter_waveform_settings"].action.setChecked(False)
|
||||
|
||||
################################################################################
|
||||
# Widget Specific Properties
|
||||
################################################################################
|
||||
@property
|
||||
def main_curve(self) -> ScatterCurve:
|
||||
"""The main scatter curve item."""
|
||||
return self._main_curve
|
||||
|
||||
@SafeProperty(str)
|
||||
def color_map(self) -> str:
|
||||
"""The color map of the scatter waveform."""
|
||||
return self.config.color_map
|
||||
|
||||
@color_map.setter
|
||||
def color_map(self, value: str):
|
||||
"""
|
||||
Set the color map of the scatter waveform.
|
||||
|
||||
Args:
|
||||
value(str): The color map to set.
|
||||
"""
|
||||
try:
|
||||
self.config.color_map = value
|
||||
self.main_curve.color_map = value
|
||||
self.scatter_waveform_property_changed.emit()
|
||||
except ValidationError:
|
||||
return
|
||||
|
||||
@SafeProperty(str, designable=False, popup_error=True)
|
||||
def curve_json(self) -> str:
|
||||
"""
|
||||
Get the curve configuration as a JSON string.
|
||||
"""
|
||||
return json.dumps(self.main_curve.config.model_dump(), indent=2)
|
||||
|
||||
@curve_json.setter
|
||||
def curve_json(self, value: str):
|
||||
"""
|
||||
Set the curve configuration from a JSON string.
|
||||
|
||||
Args:
|
||||
value(str): The JSON string to set the curve configuration from.
|
||||
"""
|
||||
try:
|
||||
config = ScatterCurveConfig(**json.loads(value))
|
||||
self._add_main_scatter_curve(config)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to decode JSON: {e}")
|
||||
|
||||
################################################################################
|
||||
# High Level methods for API
|
||||
################################################################################
|
||||
@SafeSlot(popup_error=True)
|
||||
def plot(
|
||||
self,
|
||||
x_name: str,
|
||||
y_name: str,
|
||||
z_name: str,
|
||||
x_entry: None | str = None,
|
||||
y_entry: None | str = None,
|
||||
z_entry: None | str = None,
|
||||
color_map: str | None = "magma",
|
||||
label: str | None = None,
|
||||
validate_bec: bool = True,
|
||||
) -> ScatterCurve:
|
||||
"""
|
||||
Plot the data from the device signals.
|
||||
|
||||
Args:
|
||||
x_name (str): The name of the x device signal.
|
||||
y_name (str): The name of the y device signal.
|
||||
z_name (str): The name of the z device signal.
|
||||
x_entry (None | str): The x entry of the device signal.
|
||||
y_entry (None | str): The y entry of the device signal.
|
||||
z_entry (None | str): The z entry of the device signal.
|
||||
color_map (str | None): The color map of the scatter waveform.
|
||||
label (str | None): The label of the curve.
|
||||
validate_bec (bool): Whether to validate the device signals with current BEC instance.
|
||||
|
||||
Returns:
|
||||
ScatterCurve: The scatter curve object.
|
||||
"""
|
||||
|
||||
if validate_bec:
|
||||
x_entry = self.entry_validator.validate_signal(x_name, x_entry)
|
||||
y_entry = self.entry_validator.validate_signal(y_name, y_entry)
|
||||
z_entry = self.entry_validator.validate_signal(z_name, z_entry)
|
||||
|
||||
if color_map is not None:
|
||||
try:
|
||||
self.config.color_map = color_map
|
||||
except ValidationError:
|
||||
raise ValueError(
|
||||
f"Invalid color map '{color_map}'. Using previously defined color map '{self.config.color_map}'."
|
||||
)
|
||||
|
||||
if label is None:
|
||||
label = f"{z_name}-{z_entry}"
|
||||
|
||||
config = ScatterCurveConfig(
|
||||
parent_id=self.gui_id,
|
||||
label=label,
|
||||
color_map=color_map,
|
||||
x_device=ScatterDeviceSignal(name=x_name, entry=x_entry),
|
||||
y_device=ScatterDeviceSignal(name=y_name, entry=y_entry),
|
||||
z_device=ScatterDeviceSignal(name=z_name, entry=z_entry),
|
||||
)
|
||||
|
||||
# Add Curve
|
||||
self._add_main_scatter_curve(config)
|
||||
|
||||
self.scatter_waveform_property_changed.emit()
|
||||
|
||||
return self._main_curve
|
||||
|
||||
def _add_main_scatter_curve(self, config: ScatterCurveConfig):
|
||||
"""
|
||||
Add the main scatter curve to the plot.
|
||||
|
||||
Args:
|
||||
config(ScatterCurveConfig): The configuration of the scatter curve.
|
||||
"""
|
||||
# Apply suffix for axes
|
||||
self.set_x_label_suffix(f"[{config.x_device.name}-{config.x_device.name}]")
|
||||
self.set_y_label_suffix(f"[{config.y_device.name}-{config.y_device.name}]")
|
||||
|
||||
# To have only one main curve
|
||||
if self._main_curve is not None:
|
||||
self.plot_item.removeItem(self._main_curve)
|
||||
self._main_curve = None
|
||||
|
||||
self._main_curve = ScatterCurve(
|
||||
parent_item=self, config=config, gui_id=self.gui_id, name=config.label
|
||||
)
|
||||
self.plot_item.addItem(self._main_curve)
|
||||
|
||||
self.sync_signal_update.emit()
|
||||
|
||||
################################################################################
|
||||
# BEC Update Methods
|
||||
################################################################################
|
||||
@SafeSlot(dict, dict)
|
||||
def on_scan_status(self, msg: dict, meta: dict):
|
||||
"""
|
||||
Initial scan status message handler, which is triggered at the begging and end of scan.
|
||||
Used for triggering the update of the sync and async curves.
|
||||
|
||||
Args:
|
||||
msg(dict): The message content.
|
||||
meta(dict): The message metadata.
|
||||
"""
|
||||
current_scan_id = msg.get("scan_id", None)
|
||||
if current_scan_id is None:
|
||||
return
|
||||
if current_scan_id != self.scan_id:
|
||||
self.reset()
|
||||
self.new_scan.emit()
|
||||
self.new_scan_id.emit(current_scan_id)
|
||||
self.auto_range_x = True
|
||||
self.auto_range_y = True
|
||||
self.old_scan_id = self.scan_id
|
||||
self.scan_id = current_scan_id
|
||||
self.scan_item = self.queue.scan_storage.find_scan_by_ID(self.scan_id)
|
||||
|
||||
# First trigger to update the scan curves
|
||||
self.sync_signal_update.emit()
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_scan_progress(self, msg: dict, meta: dict):
|
||||
"""
|
||||
Slot for handling scan progress messages. Used for triggering the update of the sync curves.
|
||||
|
||||
Args:
|
||||
msg(dict): The message content.
|
||||
meta(dict): The message metadata.
|
||||
"""
|
||||
self.sync_signal_update.emit()
|
||||
status = msg.get("done")
|
||||
if status:
|
||||
QTimer.singleShot(100, self.update_sync_curves)
|
||||
QTimer.singleShot(300, self.update_sync_curves)
|
||||
|
||||
@SafeSlot()
|
||||
def update_sync_curves(self, _=None):
|
||||
"""
|
||||
Update the scan curves with the data from the scan segment.
|
||||
"""
|
||||
if self.scan_item is None:
|
||||
logger.info("No scan executed so far; skipping device curves categorisation.")
|
||||
return "none"
|
||||
data, access_key = self._fetch_scan_data_and_access()
|
||||
|
||||
if data == "none":
|
||||
logger.info("No scan executed so far; skipping device curves categorisation.")
|
||||
return "none"
|
||||
|
||||
try:
|
||||
x_name = self._main_curve.config.x_device.name
|
||||
x_entry = self._main_curve.config.x_device.entry
|
||||
y_name = self._main_curve.config.y_device.name
|
||||
y_entry = self._main_curve.config.y_device.entry
|
||||
z_name = self._main_curve.config.z_device.name
|
||||
z_entry = self._main_curve.config.z_device.entry
|
||||
except AttributeError:
|
||||
return
|
||||
|
||||
if access_key == "val":
|
||||
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, None)
|
||||
y_data = data.get(y_name, {}).get(y_entry, {}).get(access_key, None)
|
||||
z_data = data.get(z_name, {}).get(z_entry, {}).get(access_key, None)
|
||||
else:
|
||||
x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", None)
|
||||
y_data = data.get(y_name, {}).get(y_entry, {}).read().get("value", None)
|
||||
z_data = data.get(z_name, {}).get(z_entry, {}).read().get("value", None)
|
||||
|
||||
self._main_curve.set_data(x=x_data, y=y_data, z=z_data)
|
||||
|
||||
def _fetch_scan_data_and_access(self):
|
||||
"""
|
||||
Decide whether the widget is in live or historical mode
|
||||
and return the appropriate data dict and access key.
|
||||
|
||||
Returns:
|
||||
data_dict (dict): The data structure for the current scan.
|
||||
access_key (str): Either 'val' (live) or 'value' (history).
|
||||
"""
|
||||
if self.scan_item is None:
|
||||
# Optionally fetch the latest from history if nothing is set
|
||||
self.update_with_scan_history(-1)
|
||||
if self.scan_item is None:
|
||||
logger.info("No scan executed so far; skipping device curves categorisation.")
|
||||
return "none", "none"
|
||||
|
||||
if hasattr(self.scan_item, "live_data"):
|
||||
# Live scan
|
||||
return self.scan_item.live_data, "val"
|
||||
else:
|
||||
# Historical
|
||||
scan_devices = self.scan_item.devices
|
||||
return scan_devices, "value"
|
||||
|
||||
@SafeSlot(int)
|
||||
@SafeSlot(str)
|
||||
@SafeSlot()
|
||||
def update_with_scan_history(self, scan_index: int = None, scan_id: str = None):
|
||||
"""
|
||||
Update the scan curves with the data from the scan storage.
|
||||
Provide only one of scan_id or scan_index.
|
||||
|
||||
Args:
|
||||
scan_id(str, optional): ScanID of the scan to be updated. Defaults to None.
|
||||
scan_index(int, optional): Index of the scan to be updated. Defaults to None.
|
||||
"""
|
||||
if scan_index is not None and scan_id is not None:
|
||||
raise ValueError("Only one of scan_id or scan_index can be provided.")
|
||||
|
||||
if scan_index is None and scan_id is None:
|
||||
logger.warning(f"Neither scan_id or scan_number was provided, fetching the latest scan")
|
||||
scan_index = -1
|
||||
|
||||
if scan_index is not None:
|
||||
if len(self.client.history) == 0:
|
||||
logger.info("No scans executed so far. Skipping scan history update.")
|
||||
return
|
||||
|
||||
self.scan_item = self.client.history[scan_index]
|
||||
metadata = self.scan_item.metadata
|
||||
self.scan_id = metadata["bec"]["scan_id"]
|
||||
else:
|
||||
self.scan_id = scan_id
|
||||
self.scan_item = self.client.history.get_by_scan_id(scan_id)
|
||||
|
||||
self.sync_signal_update.emit()
|
||||
|
||||
################################################################################
|
||||
# Cleanup
|
||||
################################################################################
|
||||
@SafeSlot()
|
||||
def clear_all(self):
|
||||
"""
|
||||
Clear all the curves from the plot.
|
||||
"""
|
||||
if self.crosshair is not None:
|
||||
self.crosshair.clear_markers()
|
||||
self._main_curve.clear()
|
||||
|
||||
|
||||
class DemoApp(QMainWindow): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Waveform Demo")
|
||||
self.resize(800, 600)
|
||||
self.main_widget = QWidget()
|
||||
self.layout = QHBoxLayout(self.main_widget)
|
||||
self.setCentralWidget(self.main_widget)
|
||||
|
||||
self.waveform_popup = ScatterWaveform(popups=True)
|
||||
self.waveform_popup.plot("samx", "samy", "bpm4i")
|
||||
|
||||
self.waveform_side = ScatterWaveform(popups=False)
|
||||
self.waveform_popup.plot("samx", "samy", "bpm3a")
|
||||
|
||||
self.layout.addWidget(self.waveform_side)
|
||||
self.layout.addWidget(self.waveform_popup)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("dark")
|
||||
widget = DemoApp()
|
||||
widget.show()
|
||||
widget.resize(1400, 600)
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['scatter_waveform.py']}
|
||||
@@ -1,43 +1,39 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
import os
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
|
||||
from bec_widgets.widgets.plots_next_gen.scatter_waveform.scatter_waveform import ScatterWaveform
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='BECWaveformWidget' name='bec_waveform_widget'>
|
||||
<widget class='ScatterWaveform' name='scatter_waveform'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class BECWaveformWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
class ScatterWaveformPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = BECWaveformWidget(parent)
|
||||
t = ScatterWaveform(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Plots"
|
||||
return "Plot Widgets Next Gen"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(BECWaveformWidget.ICON_NAME)
|
||||
return designer_material_icon(ScatterWaveform.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "bec_waveform_widget"
|
||||
return "scatter_waveform"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
@@ -49,10 +45,10 @@ class BECWaveformWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cov
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "BECWaveformWidget"
|
||||
return "ScatterWaveform"
|
||||
|
||||
def toolTip(self):
|
||||
return "BECWaveformWidget"
|
||||
return "ScatterWaveform"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,125 @@
|
||||
import os
|
||||
|
||||
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
from bec_widgets.qt_utils.settings_dialog import SettingWidget
|
||||
from bec_widgets.utils import UILoader
|
||||
|
||||
|
||||
class ScatterCurveSettings(SettingWidget):
|
||||
def __init__(self, parent=None, target_widget=None, popup=False, *args, **kwargs):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
|
||||
# This is a settings widget that depends on the target widget
|
||||
# and should mirror what is in the target widget.
|
||||
# Saving settings for this widget could result in recursively setting the target widget.
|
||||
self.setProperty("skip_settings", True)
|
||||
self.setObjectName("ScatterCurveSettings")
|
||||
|
||||
current_path = os.path.dirname(__file__)
|
||||
if popup:
|
||||
form = UILoader().load_ui(
|
||||
os.path.join(current_path, "scatter_curve_settings_horizontal.ui"), self
|
||||
)
|
||||
else:
|
||||
form = UILoader().load_ui(
|
||||
os.path.join(current_path, "scatter_curve_settings_vertical.ui"), self
|
||||
)
|
||||
|
||||
self.target_widget = target_widget
|
||||
self.popup = popup
|
||||
|
||||
# # Scroll area
|
||||
self.scroll_area = QScrollArea(self)
|
||||
self.scroll_area.setWidgetResizable(True)
|
||||
self.scroll_area.setFrameShape(QFrame.NoFrame)
|
||||
self.scroll_area.setWidget(form)
|
||||
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.layout.addWidget(self.scroll_area)
|
||||
self.ui = form
|
||||
|
||||
self.fetch_all_properties()
|
||||
|
||||
self.target_widget.scatter_waveform_property_changed.connect(self.fetch_all_properties)
|
||||
if popup is False:
|
||||
self.ui.button_apply.clicked.connect(self.accept_changes)
|
||||
|
||||
@SafeSlot()
|
||||
def fetch_all_properties(self):
|
||||
"""
|
||||
Fetch all properties from the target widget and update the settings widget.
|
||||
"""
|
||||
if not self.target_widget:
|
||||
return
|
||||
|
||||
# Get properties from the target widget
|
||||
color_map = getattr(self.target_widget, "color_map", None)
|
||||
|
||||
# Default values for device properties
|
||||
x_name, x_entry = None, None
|
||||
y_name, y_entry = None, None
|
||||
z_name, z_entry = None, None
|
||||
|
||||
# Safely access device properties
|
||||
if hasattr(self.target_widget, "main_curve") and self.target_widget.main_curve:
|
||||
if hasattr(self.target_widget.main_curve, "config"):
|
||||
config = self.target_widget.main_curve.config
|
||||
|
||||
if hasattr(config, "x_device") and config.x_device:
|
||||
x_name = getattr(config.x_device, "name", None)
|
||||
x_entry = getattr(config.x_device, "entry", None)
|
||||
|
||||
if hasattr(config, "y_device") and config.y_device:
|
||||
y_name = getattr(config.y_device, "name", None)
|
||||
y_entry = getattr(config.y_device, "entry", None)
|
||||
|
||||
if hasattr(config, "z_device") and config.z_device:
|
||||
z_name = getattr(config.z_device, "name", None)
|
||||
z_entry = getattr(config.z_device, "entry", None)
|
||||
|
||||
# Apply the properties to the settings widget
|
||||
if hasattr(self.ui, "color_map"):
|
||||
self.ui.color_map.colormap = color_map
|
||||
|
||||
if hasattr(self.ui, "x_name"):
|
||||
self.ui.x_name.set_device(x_name)
|
||||
if hasattr(self.ui, "x_entry") and x_entry is not None:
|
||||
self.ui.x_entry.setText(x_entry)
|
||||
|
||||
if hasattr(self.ui, "y_name"):
|
||||
self.ui.y_name.set_device(y_name)
|
||||
if hasattr(self.ui, "y_entry") and y_entry is not None:
|
||||
self.ui.y_entry.setText(y_entry)
|
||||
|
||||
if hasattr(self.ui, "z_name"):
|
||||
self.ui.z_name.set_device(z_name)
|
||||
if hasattr(self.ui, "z_entry") and z_entry is not None:
|
||||
self.ui.z_entry.setText(z_entry)
|
||||
|
||||
@SafeSlot()
|
||||
def accept_changes(self):
|
||||
"""
|
||||
Apply all properties from the settings widget to the target widget.
|
||||
"""
|
||||
x_name = self.ui.x_name.text()
|
||||
x_entry = self.ui.x_entry.text()
|
||||
y_name = self.ui.y_name.text()
|
||||
y_entry = self.ui.y_entry.text()
|
||||
z_name = self.ui.z_name.text()
|
||||
z_entry = self.ui.z_entry.text()
|
||||
validate_bec = self.ui.validate_bec.checked
|
||||
color_map = self.ui.color_map.colormap
|
||||
|
||||
self.target_widget.plot(
|
||||
x_name=x_name,
|
||||
y_name=y_name,
|
||||
z_name=z_name,
|
||||
x_entry=x_entry,
|
||||
y_entry=y_entry,
|
||||
z_entry=z_entry,
|
||||
color_map=color_map,
|
||||
validate_bec=validate_bec,
|
||||
)
|
||||
@@ -0,0 +1,195 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>604</width>
|
||||
<height>166</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_7">
|
||||
<property name="text">
|
||||
<string>Validate BEC</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="ToggleSwitch" name="validate_bec"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECColorMapWidget" name="color_map"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox">
|
||||
<property name="title">
|
||||
<string>X Device</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Name</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceLineEdit" name="x_name"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
<string>Signal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLineEdit" name="x_entry"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_2">
|
||||
<property name="title">
|
||||
<string>Y Device</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="text">
|
||||
<string>Name</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceLineEdit" name="y_name"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_4">
|
||||
<property name="text">
|
||||
<string>Signal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLineEdit" name="y_entry"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_3">
|
||||
<property name="title">
|
||||
<string>Z Device</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_3">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_5">
|
||||
<property name="text">
|
||||
<string>Name</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_6">
|
||||
<property name="text">
|
||||
<string>Signal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLineEdit" name="z_entry"/>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceLineEdit" name="z_name"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>DeviceLineEdit</class>
|
||||
<extends>QLineEdit</extends>
|
||||
<header>device_line_edit</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ToggleSwitch</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>toggle_switch</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECColorMapWidget</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_color_map_widget</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>x_name</sender>
|
||||
<signal>textChanged(QString)</signal>
|
||||
<receiver>x_entry</receiver>
|
||||
<slot>clear()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>134</x>
|
||||
<y>95</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>138</x>
|
||||
<y>128</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>y_name</sender>
|
||||
<signal>textChanged(QString)</signal>
|
||||
<receiver>y_entry</receiver>
|
||||
<slot>clear()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>351</x>
|
||||
<y>91</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>349</x>
|
||||
<y>121</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>z_name</sender>
|
||||
<signal>textChanged(QString)</signal>
|
||||
<receiver>z_entry</receiver>
|
||||
<slot>clear()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>520</x>
|
||||
<y>98</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>522</x>
|
||||
<y>127</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
@@ -0,0 +1,204 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>233</width>
|
||||
<height>427</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>427</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QPushButton" name="button_apply">
|
||||
<property name="text">
|
||||
<string>Apply</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECColorMapWidget" name="color_map"/>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_7">
|
||||
<property name="text">
|
||||
<string>Validate BEC</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="ToggleSwitch" name="validate_bec"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox">
|
||||
<property name="title">
|
||||
<string>X Device</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Name</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceLineEdit" name="x_name"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
<string>Signal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLineEdit" name="x_entry"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_2">
|
||||
<property name="title">
|
||||
<string>Y Device</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="text">
|
||||
<string>Name</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceLineEdit" name="y_name"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_4">
|
||||
<property name="text">
|
||||
<string>Signal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLineEdit" name="y_entry"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_3">
|
||||
<property name="title">
|
||||
<string>Z Device</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_3">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_5">
|
||||
<property name="text">
|
||||
<string>Name</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceLineEdit" name="z_name"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_6">
|
||||
<property name="text">
|
||||
<string>Signal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLineEdit" name="z_entry"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>DeviceLineEdit</class>
|
||||
<extends>QLineEdit</extends>
|
||||
<header>device_line_edit</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ToggleSwitch</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>toggle_switch</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECColorMapWidget</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_color_map_widget</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>x_name</sender>
|
||||
<signal>textChanged(QString)</signal>
|
||||
<receiver>x_entry</receiver>
|
||||
<slot>clear()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>156</x>
|
||||
<y>123</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>158</x>
|
||||
<y>157</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>y_name</sender>
|
||||
<signal>textChanged(QString)</signal>
|
||||
<receiver>y_entry</receiver>
|
||||
<slot>clear()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>116</x>
|
||||
<y>229</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>116</x>
|
||||
<y>251</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>z_name</sender>
|
||||
<signal>textChanged(QString)</signal>
|
||||
<receiver>z_entry</receiver>
|
||||
<slot>clear()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>110</x>
|
||||
<y>326</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>110</x>
|
||||
<y>352</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
@@ -37,7 +37,6 @@ class AxisSettings(SettingWidget):
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.layout.addWidget(self.scroll_area)
|
||||
# self.layout.addWidget(self.ui)
|
||||
self.ui = form
|
||||
|
||||
if self.target_widget is not None and self.popup is False:
|
||||
@@ -50,8 +49,6 @@ class AxisSettings(SettingWidget):
|
||||
def connect_all_signals(self):
|
||||
for widget in [
|
||||
self.ui.title,
|
||||
self.ui.inner_axes,
|
||||
self.ui.outer_axes,
|
||||
self.ui.x_label,
|
||||
self.ui.x_min,
|
||||
self.ui.x_max,
|
||||
@@ -62,6 +59,8 @@ class AxisSettings(SettingWidget):
|
||||
self.ui.y_max,
|
||||
self.ui.y_log,
|
||||
self.ui.y_grid,
|
||||
self.ui.inner_axes,
|
||||
self.ui.outer_axes,
|
||||
]:
|
||||
WidgetIO.connect_widget_change_signal(widget, self.set_property)
|
||||
|
||||
@@ -132,8 +131,6 @@ class AxisSettings(SettingWidget):
|
||||
"""
|
||||
for widget in [
|
||||
self.ui.title,
|
||||
self.ui.inner_axes,
|
||||
self.ui.outer_axes,
|
||||
self.ui.x_label,
|
||||
self.ui.x_min,
|
||||
self.ui.x_max,
|
||||
@@ -144,6 +141,8 @@ class AxisSettings(SettingWidget):
|
||||
self.ui.y_max,
|
||||
self.ui.y_log,
|
||||
self.ui.y_grid,
|
||||
self.ui.outer_axes,
|
||||
self.ui.inner_axes,
|
||||
]:
|
||||
property_name = widget.objectName()
|
||||
value = WidgetIO.get_value(widget)
|
||||
|
||||
@@ -38,12 +38,6 @@ class MouseInteractionToolbarBundle(ToolbarBundle):
|
||||
checkable=False,
|
||||
parent=self.target_widget,
|
||||
)
|
||||
aspect_ratio = MaterialIconAction(
|
||||
icon_name="aspect_ratio",
|
||||
tooltip="Lock image aspect ratio",
|
||||
checkable=True,
|
||||
parent=self.target_widget,
|
||||
)
|
||||
|
||||
self.switch_mouse_action = SwitchableToolBarAction(
|
||||
actions={"drag_mode": drag, "rectangle_mode": rect},
|
||||
@@ -56,13 +50,11 @@ class MouseInteractionToolbarBundle(ToolbarBundle):
|
||||
# Add them to the bundle
|
||||
self.add_action("switch_mouse", self.switch_mouse_action)
|
||||
self.add_action("auto_range", auto)
|
||||
self.add_action("aspect_ratio", aspect_ratio)
|
||||
|
||||
# Immediately connect signals
|
||||
drag.action.toggled.connect(self.enable_mouse_pan_mode)
|
||||
rect.action.toggled.connect(self.enable_mouse_rectangle_mode)
|
||||
auto.action.triggered.connect(self.autorange_plot)
|
||||
aspect_ratio.action.toggled.connect(self.lock_aspect_ratio)
|
||||
|
||||
# Give some time to check the state
|
||||
QTimer.singleShot(10, self.get_viewbox_mode)
|
||||
@@ -94,6 +86,7 @@ class MouseInteractionToolbarBundle(ToolbarBundle):
|
||||
self.actions["switch_mouse"].actions["drag_mode"].action.setChecked(not checked)
|
||||
if self.target_widget and checked:
|
||||
self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.RectMode)
|
||||
self.mouse_mode = "RectMode"
|
||||
|
||||
@SafeSlot(bool)
|
||||
def enable_mouse_pan_mode(self, checked: bool):
|
||||
@@ -106,6 +99,7 @@ class MouseInteractionToolbarBundle(ToolbarBundle):
|
||||
self.actions["switch_mouse"].actions["rectangle_mode"].action.setChecked(not checked)
|
||||
if self.target_widget and checked:
|
||||
self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.PanMode)
|
||||
self.mouse_mode = "PanMode"
|
||||
|
||||
@SafeSlot()
|
||||
def autorange_plot(self):
|
||||
@@ -115,8 +109,3 @@ class MouseInteractionToolbarBundle(ToolbarBundle):
|
||||
if self.target_widget:
|
||||
self.target_widget.auto_range_x = True
|
||||
self.target_widget.auto_range_y = True
|
||||
|
||||
@SafeSlot(bool)
|
||||
def lock_aspect_ratio(self, checked: bool):
|
||||
if self.target_widget:
|
||||
self.target_widget.lock_aspect_ratio = checked
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import traceback
|
||||
|
||||
from pyqtgraph.exporters import MatplotlibExporter
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot, WarningPopupUtility
|
||||
@@ -60,7 +62,7 @@ class PlotExportBundle(ToolbarBundle):
|
||||
import matplotlib as mpl
|
||||
|
||||
MatplotlibExporter(self.target_widget.plot_item).export()
|
||||
except:
|
||||
except ModuleNotFoundError:
|
||||
warning_util = WarningPopupUtility()
|
||||
warning_util.show_warning(
|
||||
title="Matplotlib not installed",
|
||||
@@ -68,3 +70,12 @@ class PlotExportBundle(ToolbarBundle):
|
||||
detailed_text="Please install matplotlib in your Python environment by using 'pip install matplotlib'.",
|
||||
)
|
||||
return
|
||||
except TypeError:
|
||||
warning_util = WarningPopupUtility()
|
||||
error_msg = traceback.format_exc()
|
||||
warning_util.show_warning(
|
||||
title="Matplotlib TypeError",
|
||||
message="Matplotlib exporter could not resolve the plot item.",
|
||||
detailed_text=error_msg,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -18,9 +18,14 @@ class ROIBundle(ToolbarBundle):
|
||||
crosshair = MaterialIconAction(
|
||||
icon_name="point_scan", tooltip="Show Crosshair", checkable=True
|
||||
)
|
||||
reset_legend = MaterialIconAction(
|
||||
icon_name="restart_alt", tooltip="Reset the position of legend.", checkable=False
|
||||
)
|
||||
|
||||
# Add them to the bundle
|
||||
self.add_action("crosshair", crosshair)
|
||||
self.add_action("reset_legend", reset_legend)
|
||||
|
||||
# Immediately connect signals
|
||||
crosshair.action.toggled.connect(self.target_widget.toggle_crosshair)
|
||||
reset_legend.action.triggered.connect(self.target_widget.reset_legend)
|
||||
|
||||
328
bec_widgets/widgets/plots_next_gen/waveform/curve.py
Normal file
328
bec_widgets/widgets/plots_next_gen/waveform/curve.py
Normal file
@@ -0,0 +1,328 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import bec_logger
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy import QtCore
|
||||
|
||||
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
# noinspection PyDataclass
|
||||
class DeviceSignal(BaseModel):
|
||||
"""The configuration of a signal in the 1D waveform widget."""
|
||||
|
||||
name: str
|
||||
entry: str
|
||||
dap: str | None = None
|
||||
dap_oversample: int = 1
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
# noinspection PyDataclass
|
||||
class CurveConfig(ConnectionConfig):
|
||||
parent_id: str | None = Field(None, description="The parent plot of the curve.")
|
||||
label: str | None = Field(None, description="The label of the curve.")
|
||||
color: str | tuple | None = Field(None, description="The color of the curve.")
|
||||
symbol: str | None = Field("o", description="The symbol of the curve.")
|
||||
symbol_color: str | tuple | None = Field(
|
||||
None, description="The color of the symbol of the curve."
|
||||
)
|
||||
symbol_size: int | None = Field(7, description="The size of the symbol of the curve.")
|
||||
pen_width: int | None = Field(4, description="The width of the pen of the curve.")
|
||||
pen_style: Literal["solid", "dash", "dot", "dashdot"] | None = Field(
|
||||
"solid", description="The style of the pen of the curve."
|
||||
)
|
||||
source: Literal["device", "dap", "custom"] = Field(
|
||||
"custom", description="The source of the curve."
|
||||
)
|
||||
signal: DeviceSignal | None = Field(None, description="The signal of the curve.")
|
||||
parent_label: str | None = Field(
|
||||
None, description="The label of the parent plot, only relevant for dap curves."
|
||||
)
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
_validate_color = field_validator("color")(Colors.validate_color)
|
||||
_validate_symbol_color = field_validator("symbol_color")(Colors.validate_color)
|
||||
|
||||
|
||||
class Curve(BECConnector, pg.PlotDataItem):
|
||||
USER_ACCESS = [
|
||||
"remove",
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"set",
|
||||
"set_data",
|
||||
"set_color",
|
||||
"set_color_map_z",
|
||||
"set_symbol",
|
||||
"set_symbol_color",
|
||||
"set_symbol_size",
|
||||
"set_pen_width",
|
||||
"set_pen_style",
|
||||
"get_data",
|
||||
"dap_params",
|
||||
"dap_summary",
|
||||
"dap_oversample",
|
||||
"dap_oversample.setter",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str | None = None,
|
||||
config: CurveConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
parent_item: Waveform | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
if config is None:
|
||||
config = CurveConfig(label=name, widget_class=self.__class__.__name__)
|
||||
self.config = config
|
||||
else:
|
||||
self.config = config
|
||||
super().__init__(config=config, gui_id=gui_id)
|
||||
pg.PlotDataItem.__init__(self, name=name)
|
||||
|
||||
self.parent_item = parent_item
|
||||
self.apply_config()
|
||||
self.dap_params = None
|
||||
self.dap_summary = None
|
||||
if kwargs:
|
||||
self.set(**kwargs)
|
||||
|
||||
def apply_config(self, config: dict | CurveConfig | None = None, **kwargs) -> None:
|
||||
"""
|
||||
Apply the configuration to the curve.
|
||||
|
||||
Args:
|
||||
config(dict|CurveConfig, optional): The configuration to apply.
|
||||
"""
|
||||
|
||||
if config is not None:
|
||||
if isinstance(config, dict):
|
||||
config = CurveConfig(**config)
|
||||
self.config = config
|
||||
|
||||
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)
|
||||
|
||||
@property
|
||||
def dap_params(self):
|
||||
"""
|
||||
Get the dap parameters.
|
||||
"""
|
||||
return self._dap_params
|
||||
|
||||
@dap_params.setter
|
||||
def dap_params(self, value):
|
||||
"""
|
||||
Set the dap parameters.
|
||||
|
||||
Args:
|
||||
value(dict): The dap parameters.
|
||||
"""
|
||||
self._dap_params = value
|
||||
|
||||
@property
|
||||
def dap_summary(self):
|
||||
"""
|
||||
Get the dap summary.
|
||||
"""
|
||||
return self._dap_report
|
||||
|
||||
@dap_summary.setter
|
||||
def dap_summary(self, value):
|
||||
"""
|
||||
Set the dap summary.
|
||||
"""
|
||||
self._dap_report = value
|
||||
|
||||
@property
|
||||
def dap_oversample(self):
|
||||
"""
|
||||
Get the dap oversample.
|
||||
"""
|
||||
return self.config.signal.dap_oversample
|
||||
|
||||
@dap_oversample.setter
|
||||
def dap_oversample(self, value):
|
||||
"""
|
||||
Set the dap oversample.
|
||||
|
||||
Args:
|
||||
value(int): The dap oversample.
|
||||
"""
|
||||
self.config.signal.dap_oversample = value
|
||||
self.parent_item.request_dap() # do immediate request for dap update
|
||||
|
||||
def set_data(self, x: list | np.ndarray, y: list | np.ndarray):
|
||||
"""
|
||||
Set the data of the curve.
|
||||
|
||||
Args:
|
||||
x(list|np.ndarray): The x data.
|
||||
y(list|np.ndarray): The y data.
|
||||
|
||||
Raises:
|
||||
ValueError: If the source is not custom.
|
||||
"""
|
||||
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,
|
||||
"color_map_z": self.set_color_map_z,
|
||||
"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:
|
||||
logger.warning(f"Warning: '{key}' is not a recognized property.")
|
||||
|
||||
def set_color(self, color: str, symbol_color: str | None = 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.setSymbol(symbol)
|
||||
self.updateItems()
|
||||
|
||||
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_color_map_z(self, colormap: str):
|
||||
"""
|
||||
Set the colormap for the scatter plot z gradient.
|
||||
|
||||
Args:
|
||||
colormap(str): Colormap for the scatter plot.
|
||||
"""
|
||||
self.config.color_map_z = colormap
|
||||
self.apply_config()
|
||||
self.parent_item.update_with_scan_history(-1)
|
||||
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
x_data, y_data = self.getData()
|
||||
except TypeError:
|
||||
x_data, y_data = np.array([]), np.array([])
|
||||
return x_data, y_data
|
||||
|
||||
def clear_data(self):
|
||||
"""
|
||||
Clear the data of the curve.
|
||||
"""
|
||||
self.setData([], [])
|
||||
|
||||
def remove(self):
|
||||
"""Remove the curve from the plot."""
|
||||
# self.parent_item.removeItem(self)
|
||||
self.parent_item.remove_curve(self.name())
|
||||
self.rpc_register.remove_rpc(self)
|
||||
@@ -6,11 +6,9 @@ def main(): # pragma: no cover
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.plots.waveform.bec_waveform_widget_plugin import (
|
||||
BECWaveformWidgetPlugin,
|
||||
)
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.waveform_plugin import WaveformPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BECWaveformWidgetPlugin())
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(WaveformPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy.QtWidgets import (
|
||||
QComboBox,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
from bec_widgets.qt_utils.settings_dialog import SettingWidget
|
||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
||||
DeviceLineEdit,
|
||||
)
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.settings.curve_settings.curve_tree import CurveTree
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
|
||||
|
||||
|
||||
class CurveSetting(SettingWidget):
|
||||
def __init__(self, parent=None, target_widget: Waveform = None, *args, **kwargs):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
self.setProperty("skip_settings", True)
|
||||
self.setObjectName("CurveSetting")
|
||||
self.target_widget = target_widget
|
||||
|
||||
self.layout = QVBoxLayout(self)
|
||||
|
||||
self._init_x_box()
|
||||
self._init_y_box()
|
||||
|
||||
self.setFixedWidth(580) # TODO height is still debate
|
||||
|
||||
def _init_x_box(self):
|
||||
self.x_axis_box = QGroupBox("X Axis")
|
||||
self.x_axis_box.layout = QHBoxLayout(self.x_axis_box)
|
||||
self.x_axis_box.layout.setContentsMargins(10, 10, 10, 10)
|
||||
self.x_axis_box.layout.setSpacing(10)
|
||||
|
||||
self.mode_combo_label = QLabel("Mode")
|
||||
self.mode_combo = QComboBox()
|
||||
self.mode_combo.addItems(["auto", "index", "timestamp", "device"])
|
||||
|
||||
self.spacer = QWidget()
|
||||
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
self.device_x_label = QLabel("Device")
|
||||
self.device_x = DeviceLineEdit()
|
||||
|
||||
self._get_x_mode_from_waveform()
|
||||
self.switch_x_device_selection()
|
||||
|
||||
self.mode_combo.currentTextChanged.connect(self.switch_x_device_selection)
|
||||
|
||||
self.x_axis_box.layout.addWidget(self.mode_combo_label)
|
||||
self.x_axis_box.layout.addWidget(self.mode_combo)
|
||||
self.x_axis_box.layout.addWidget(self.spacer)
|
||||
self.x_axis_box.layout.addWidget(self.device_x_label)
|
||||
self.x_axis_box.layout.addWidget(self.device_x)
|
||||
|
||||
self.x_axis_box.setFixedHeight(80)
|
||||
self.layout.addWidget(self.x_axis_box)
|
||||
|
||||
def _get_x_mode_from_waveform(self):
|
||||
if self.target_widget.x_mode in ["auto", "index", "timestamp"]:
|
||||
self.mode_combo.setCurrentText(self.target_widget.x_mode)
|
||||
else:
|
||||
self.mode_combo.setCurrentText("device")
|
||||
|
||||
def switch_x_device_selection(self):
|
||||
if self.mode_combo.currentText() == "device":
|
||||
self.device_x.setEnabled(True)
|
||||
self.device_x.setText(self.target_widget.x_axis_mode["name"])
|
||||
else:
|
||||
self.device_x.setEnabled(False)
|
||||
|
||||
def _init_y_box(self):
|
||||
self.y_axis_box = QGroupBox("Y Axis")
|
||||
self.y_axis_box.layout = QVBoxLayout(self.y_axis_box)
|
||||
self.y_axis_box.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.y_axis_box.layout.setSpacing(0)
|
||||
|
||||
self.curve_manager = CurveTree(self, waveform=self.target_widget)
|
||||
self.y_axis_box.layout.addWidget(self.curve_manager)
|
||||
|
||||
self.layout.addWidget(self.y_axis_box)
|
||||
|
||||
@SafeSlot()
|
||||
def accept_changes(self):
|
||||
"""
|
||||
Accepts the changes made in the settings widget and applies them to the target widget.
|
||||
"""
|
||||
if self.mode_combo.currentText() == "device":
|
||||
self.target_widget.x_mode = self.device_x.text()
|
||||
else:
|
||||
self.target_widget.x_mode = self.mode_combo.currentText()
|
||||
self.curve_manager.send_curve_json()
|
||||
|
||||
@SafeSlot()
|
||||
def refresh(self):
|
||||
"""Refresh the curve tree and the x axis combo box in the case Waveform is modified from rpc."""
|
||||
self.curve_manager.refresh_from_waveform()
|
||||
self._get_x_mode_from_waveform()
|
||||
@@ -0,0 +1,538 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_qthemes._icon.material_icons import material_icon
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import (
|
||||
QColorDialog,
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QToolButton,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ModularToolBar
|
||||
from bec_widgets.utils import ConnectionConfig, EntryValidator
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import Colors
|
||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
||||
DeviceLineEdit,
|
||||
)
|
||||
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.curve import CurveConfig, DeviceSignal
|
||||
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
|
||||
|
||||
|
||||
class ColorButton(QPushButton):
|
||||
"""A QPushButton subclass that displays a color.
|
||||
|
||||
The background is set to the given color and the button text is the hex code.
|
||||
The text color is chosen automatically (black if the background is light, white if dark)
|
||||
to guarantee good readability.
|
||||
"""
|
||||
|
||||
def __init__(self, color="#000000", parent=None):
|
||||
"""Initialize the color button.
|
||||
|
||||
Args:
|
||||
color (str): The initial color in hex format (e.g., '#000000').
|
||||
parent: Optional QWidget parent.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.set_color(color)
|
||||
|
||||
def set_color(self, color):
|
||||
"""Set the button's color and update its appearance.
|
||||
|
||||
Args:
|
||||
color (str or QColor): The new color to assign.
|
||||
"""
|
||||
if isinstance(color, QColor):
|
||||
self._color = color.name()
|
||||
else:
|
||||
self._color = color
|
||||
self._update_appearance()
|
||||
|
||||
def color(self):
|
||||
"""Return the current color in hex."""
|
||||
return self._color
|
||||
|
||||
def _update_appearance(self):
|
||||
"""Update the button style based on the background color's brightness."""
|
||||
c = QColor(self._color)
|
||||
brightness = c.lightnessF()
|
||||
text_color = "#000000" if brightness > 0.5 else "#FFFFFF"
|
||||
self.setStyleSheet(f"background-color: {self._color}; color: {text_color};")
|
||||
self.setText(self._color)
|
||||
|
||||
|
||||
class CurveRow(QTreeWidgetItem):
|
||||
DELETE_BUTTON_COLOR = "#CC181E"
|
||||
"""A unified row that can represent either a device or a DAP curve.
|
||||
|
||||
Columns:
|
||||
0: Actions (delete or "Add DAP" if source=device)
|
||||
1..2: DeviceLineEdit and QLineEdit if source=device, or "Model" label and DapComboBox if source=dap
|
||||
3: ColorButton
|
||||
4: Style QComboBox
|
||||
5: Pen width QSpinBox
|
||||
6: Symbol size QSpinBox
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tree: QTreeWidget,
|
||||
parent_item: QTreeWidgetItem | None = None,
|
||||
config: CurveConfig | None = None,
|
||||
device_manager=None,
|
||||
):
|
||||
if parent_item:
|
||||
super().__init__(parent_item)
|
||||
else:
|
||||
# A top-level device row.
|
||||
super().__init__(tree)
|
||||
|
||||
self.tree = tree
|
||||
self.parent_item = parent_item
|
||||
self.curve_tree = tree.parent() # The CurveTree widget
|
||||
self.curve_tree.all_items.append(self) # Track stable ordering
|
||||
|
||||
self.dev = device_manager
|
||||
self.entry_validator = EntryValidator(self.dev)
|
||||
|
||||
self.config = config or CurveConfig()
|
||||
self.source = self.config.source
|
||||
|
||||
# Create column 0 (Actions)
|
||||
self._init_actions()
|
||||
# Create columns 1..2, depending on source
|
||||
self._init_source_ui()
|
||||
# Create columns 3..6 (color, style, width, symbol)
|
||||
self._init_style_controls()
|
||||
|
||||
def _init_actions(self):
|
||||
"""Create the actions widget in column 0, including a delete button and maybe 'Add DAP'."""
|
||||
self.actions_widget = QWidget()
|
||||
actions_layout = QHBoxLayout(self.actions_widget)
|
||||
actions_layout.setContentsMargins(0, 0, 0, 0)
|
||||
actions_layout.setSpacing(0)
|
||||
|
||||
# Delete button
|
||||
self.delete_button = QToolButton()
|
||||
delete_icon = material_icon(
|
||||
"delete",
|
||||
size=(20, 20),
|
||||
convert_to_pixmap=False,
|
||||
filled=False,
|
||||
color=self.DELETE_BUTTON_COLOR,
|
||||
)
|
||||
self.delete_button.setIcon(delete_icon)
|
||||
self.delete_button.clicked.connect(lambda: self.remove_self())
|
||||
actions_layout.addWidget(self.delete_button)
|
||||
|
||||
# If device row, add "Add DAP" button
|
||||
if self.source == "device":
|
||||
self.add_dap_button = QPushButton("DAP")
|
||||
self.add_dap_button.clicked.connect(lambda: self.add_dap_row())
|
||||
actions_layout.addWidget(self.add_dap_button)
|
||||
|
||||
self.tree.setItemWidget(self, 0, self.actions_widget)
|
||||
|
||||
def _init_source_ui(self):
|
||||
"""Create columns 1 and 2. For device rows, we have device/entry edits; for dap rows, label/model combo."""
|
||||
if self.source == "device":
|
||||
# Device row: columns 1..2 are device line edits
|
||||
self.device_edit = DeviceLineEdit()
|
||||
self.entry_edit = QLineEdit() # TODO in future will be signal line edit
|
||||
if self.config.signal:
|
||||
self.device_edit.setText(self.config.signal.name or "")
|
||||
self.entry_edit.setText(self.config.signal.entry or "")
|
||||
|
||||
self.tree.setItemWidget(self, 1, self.device_edit)
|
||||
self.tree.setItemWidget(self, 2, self.entry_edit)
|
||||
|
||||
else:
|
||||
# DAP row: column1= "Model" label, column2= DapComboBox
|
||||
self.label_widget = QLabel("Model")
|
||||
self.tree.setItemWidget(self, 1, self.label_widget)
|
||||
self.dap_combo = DapComboBox()
|
||||
self.dap_combo.populate_fit_model_combobox()
|
||||
# If config.signal has a dap
|
||||
if self.config.signal and self.config.signal.dap:
|
||||
dap_value = self.config.signal.dap
|
||||
idx = self.dap_combo.fit_model_combobox.findText(dap_value)
|
||||
if idx >= 0:
|
||||
self.dap_combo.fit_model_combobox.setCurrentIndex(idx)
|
||||
else:
|
||||
self.dap_combo.select_fit_model("GaussianModel") # default
|
||||
|
||||
self.tree.setItemWidget(self, 2, self.dap_combo)
|
||||
|
||||
def _init_style_controls(self):
|
||||
"""Create columns 3..6: color button, style combo, width spin, symbol spin."""
|
||||
# Color in col 3
|
||||
self.color_button = ColorButton(self.config.color)
|
||||
self.color_button.clicked.connect(lambda: self._select_color(self.color_button))
|
||||
self.tree.setItemWidget(self, 3, self.color_button)
|
||||
|
||||
# Style in col 4
|
||||
self.style_combo = QComboBox()
|
||||
self.style_combo.addItems(["solid", "dash", "dot", "dashdot"])
|
||||
idx = self.style_combo.findText(self.config.pen_style)
|
||||
if idx >= 0:
|
||||
self.style_combo.setCurrentIndex(idx)
|
||||
self.tree.setItemWidget(self, 4, self.style_combo)
|
||||
|
||||
# Pen width in col 5
|
||||
self.width_spin = QSpinBox()
|
||||
self.width_spin.setRange(1, 20)
|
||||
self.width_spin.setValue(self.config.pen_width)
|
||||
self.tree.setItemWidget(self, 5, self.width_spin)
|
||||
|
||||
# Symbol size in col 6
|
||||
self.symbol_spin = QSpinBox()
|
||||
self.symbol_spin.setRange(1, 20)
|
||||
self.symbol_spin.setValue(self.config.symbol_size)
|
||||
self.tree.setItemWidget(self, 6, self.symbol_spin)
|
||||
|
||||
def _select_color(self, button):
|
||||
"""
|
||||
Selects a new color using a color dialog and applies it to the specified button. Updates
|
||||
related configuration properties based on the chosen color.
|
||||
|
||||
Args:
|
||||
button: The button widget whose color is being modified.
|
||||
"""
|
||||
current_color = QColor(button.color())
|
||||
chosen_color = QColorDialog.getColor(current_color, self.tree, "Select Curve Color")
|
||||
if chosen_color.isValid():
|
||||
button.set_color(chosen_color)
|
||||
self.config.color = chosen_color.name()
|
||||
self.config.symbol_color = chosen_color.name()
|
||||
|
||||
def add_dap_row(self):
|
||||
"""Create a new DAP row as a child. Only valid if source='device'."""
|
||||
if self.source != "device":
|
||||
return
|
||||
curve_tree = self.tree.parent()
|
||||
parent_label = self.config.label
|
||||
|
||||
# Inherit device name/entry
|
||||
dev_name = ""
|
||||
dev_entry = ""
|
||||
if self.config.signal:
|
||||
dev_name = self.config.signal.name
|
||||
dev_entry = self.config.signal.entry
|
||||
|
||||
# Create a new config for the DAP row
|
||||
dap_cfg = CurveConfig(
|
||||
widget_class="Curve",
|
||||
source="dap",
|
||||
parent_label=parent_label,
|
||||
signal=DeviceSignal(name=dev_name, entry=dev_entry),
|
||||
)
|
||||
new_dap = CurveRow(self.tree, parent_item=self, config=dap_cfg, device_manager=self.dev)
|
||||
# Expand device row to show new child
|
||||
self.tree.expandItem(self)
|
||||
|
||||
# Give the new row a color from the buffer:
|
||||
curve_tree._ensure_color_buffer_size()
|
||||
idx = len(curve_tree.all_items) - 1
|
||||
new_col = curve_tree.color_buffer[idx]
|
||||
new_dap.color_button.set_color(new_col)
|
||||
new_dap.config.color = new_col
|
||||
new_dap.config.symbol_color = new_col
|
||||
|
||||
def remove_self(self):
|
||||
"""Remove this row from the tree and from the parent's item list."""
|
||||
# If top-level:
|
||||
index = self.tree.indexOfTopLevelItem(self)
|
||||
if index != -1:
|
||||
self.tree.takeTopLevelItem(index)
|
||||
else:
|
||||
# If child item
|
||||
if self.parent_item:
|
||||
self.parent_item.removeChild(self)
|
||||
# Also remove from all_items
|
||||
curve_tree = self.tree.parent()
|
||||
if self in curve_tree.all_items:
|
||||
curve_tree.all_items.remove(self)
|
||||
|
||||
def export_data(self) -> dict:
|
||||
"""Collect data from the GUI widgets, update config, and return as a dict.
|
||||
|
||||
Returns:
|
||||
dict: The serialized config based on the GUI state.
|
||||
"""
|
||||
if self.source == "device":
|
||||
# Gather device name/entry
|
||||
device_name = ""
|
||||
device_entry = ""
|
||||
if hasattr(self, "device_edit"):
|
||||
device_name = self.device_edit.text()
|
||||
if hasattr(self, "entry_edit"):
|
||||
device_entry = self.entry_validator.validate_signal(
|
||||
name=device_name, entry=self.entry_edit.text()
|
||||
)
|
||||
self.entry_edit.setText(device_entry)
|
||||
self.config.signal = DeviceSignal(name=device_name, entry=device_entry)
|
||||
self.config.source = "device"
|
||||
if not self.config.label:
|
||||
self.config.label = f"{device_name}-{device_entry}".strip("-")
|
||||
else:
|
||||
# DAP logic
|
||||
parent_conf_dict = {}
|
||||
if self.parent_item:
|
||||
parent_conf_dict = self.parent_item.export_data()
|
||||
parent_conf = CurveConfig(**parent_conf_dict)
|
||||
dev_name = ""
|
||||
dev_entry = ""
|
||||
if parent_conf.signal:
|
||||
dev_name = parent_conf.signal.name
|
||||
dev_entry = parent_conf.signal.entry
|
||||
# Dap from the DapComboBox
|
||||
new_dap = "GaussianModel"
|
||||
if hasattr(self, "dap_combo"):
|
||||
new_dap = self.dap_combo.fit_model_combobox.currentText()
|
||||
self.config.signal = DeviceSignal(name=dev_name, entry=dev_entry, dap=new_dap)
|
||||
self.config.source = "dap"
|
||||
self.config.parent_label = parent_conf.label
|
||||
self.config.label = f"{parent_conf.label}-{new_dap}".strip("-")
|
||||
|
||||
# Common style fields
|
||||
self.config.color = self.color_button.color()
|
||||
self.config.symbol_color = self.color_button.color()
|
||||
self.config.pen_style = self.style_combo.currentText()
|
||||
self.config.pen_width = self.width_spin.value()
|
||||
self.config.symbol_size = self.symbol_spin.value()
|
||||
|
||||
return self.config.model_dump()
|
||||
|
||||
|
||||
class CurveTree(BECWidget, QWidget):
|
||||
"""A tree widget that manages device and DAP curves."""
|
||||
|
||||
PLUGIN = False
|
||||
RPC = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
config: ConnectionConfig | None = None,
|
||||
client=None,
|
||||
gui_id: str | None = None,
|
||||
waveform: Waveform | None = None,
|
||||
) -> None:
|
||||
if config is None:
|
||||
config = ConnectionConfig(widget_class=self.__class__.__name__)
|
||||
super().__init__(client=client, gui_id=gui_id, config=config)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
|
||||
self.waveform = waveform
|
||||
if self.waveform and hasattr(self.waveform, "color_palette"):
|
||||
self.color_palette = self.waveform.color_palette
|
||||
else:
|
||||
self.color_palette = "magma"
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
self.color_buffer = []
|
||||
self.all_items = []
|
||||
self.layout = QVBoxLayout(self)
|
||||
self._init_toolbar()
|
||||
self._init_tree()
|
||||
self.refresh_from_waveform()
|
||||
|
||||
def _init_toolbar(self):
|
||||
"""Initialize the toolbar with actions: add, send, refresh, expand, collapse, renormalize."""
|
||||
self.toolbar = ModularToolBar(target_widget=self, orientation="horizontal")
|
||||
add = MaterialIconAction(
|
||||
icon_name="add", tooltip="Add new curve", checkable=False, parent=self
|
||||
)
|
||||
expand = MaterialIconAction(
|
||||
icon_name="unfold_more", tooltip="Expand All DAP", checkable=False, parent=self
|
||||
)
|
||||
collapse = MaterialIconAction(
|
||||
icon_name="unfold_less", tooltip="Collapse All DAP", checkable=False, parent=self
|
||||
)
|
||||
|
||||
self.toolbar.add_action("add", add, self)
|
||||
self.toolbar.add_action("expand_all", expand, self)
|
||||
self.toolbar.add_action("collapse_all", collapse, self)
|
||||
|
||||
# Add colormap widget (not updating waveform's color_palette until Send is pressed)
|
||||
self.spacer = QWidget()
|
||||
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.toolbar.addWidget(self.spacer)
|
||||
|
||||
# Renormalize colors button
|
||||
renorm_action = MaterialIconAction(
|
||||
icon_name="palette", tooltip="Normalize All Colors", checkable=False, parent=self
|
||||
)
|
||||
self.toolbar.add_action("renormalize_colors", renorm_action, self)
|
||||
renorm_action.action.triggered.connect(lambda checked: self.renormalize_colors())
|
||||
|
||||
self.colormap_widget = BECColorMapWidget(cmap=self.color_palette or "magma")
|
||||
self.toolbar.addWidget(self.colormap_widget)
|
||||
self.colormap_widget.colormap_changed_signal.connect(self.handle_colormap_changed)
|
||||
|
||||
add.action.triggered.connect(lambda checked: self.add_new_curve())
|
||||
expand.action.triggered.connect(lambda checked: self.expand_all_daps())
|
||||
collapse.action.triggered.connect(lambda checked: self.collapse_all_daps())
|
||||
|
||||
self.layout.addWidget(self.toolbar)
|
||||
|
||||
def _init_tree(self):
|
||||
"""Initialize the QTreeWidget with 7 columns and compact widths."""
|
||||
self.tree = QTreeWidget()
|
||||
self.tree.setColumnCount(7)
|
||||
self.tree.setHeaderLabels(["Actions", "Name", "Entry", "Color", "Style", "Width", "Symbol"])
|
||||
self.tree.setColumnWidth(0, 90)
|
||||
self.tree.setColumnWidth(1, 100)
|
||||
self.tree.setColumnWidth(2, 100)
|
||||
self.tree.setColumnWidth(3, 70)
|
||||
self.tree.setColumnWidth(4, 80)
|
||||
self.tree.setColumnWidth(5, 40)
|
||||
self.tree.setColumnWidth(6, 40)
|
||||
self.layout.addWidget(self.tree)
|
||||
|
||||
def _init_color_buffer(self, size: int):
|
||||
"""
|
||||
Initializes the color buffer with a calculated set of colors based on the golden
|
||||
angle sequence.
|
||||
|
||||
Args:
|
||||
size (int): The number of colors to be generated for the color buffer.
|
||||
"""
|
||||
self.color_buffer = Colors.golden_angle_color(
|
||||
colormap=self.colormap_widget.colormap, num=size, format="HEX"
|
||||
)
|
||||
|
||||
def _ensure_color_buffer_size(self):
|
||||
"""
|
||||
Ensures that the color buffer size meets the required number of items.
|
||||
"""
|
||||
current_count = len(self.color_buffer)
|
||||
color_list = Colors.golden_angle_color(
|
||||
colormap=self.color_palette, num=max(10, current_count + 1), format="HEX"
|
||||
)
|
||||
self.color_buffer = color_list
|
||||
|
||||
def handle_colormap_changed(self, new_cmap: str):
|
||||
"""
|
||||
Handles the updating of the color palette when the colormap is changed.
|
||||
|
||||
Args:
|
||||
new_cmap: The new colormap to be set as the color palette.
|
||||
"""
|
||||
self.color_palette = new_cmap
|
||||
|
||||
def renormalize_colors(self):
|
||||
"""Overwrite all existing rows with new colors from the buffer in their creation order."""
|
||||
total = len(self.all_items)
|
||||
self._ensure_color_buffer_size()
|
||||
for idx, item in enumerate(self.all_items):
|
||||
if hasattr(item, "color_button"):
|
||||
new_col = self.color_buffer[idx]
|
||||
item.color_button.set_color(new_col)
|
||||
if hasattr(item, "config"):
|
||||
item.config.color = new_col
|
||||
item.config.symbol_color = new_col
|
||||
|
||||
def add_new_curve(self, name: str = None, entry: str = None):
|
||||
"""Add a new device-type CurveRow with an assigned colormap color.
|
||||
|
||||
Args:
|
||||
name (str, optional): Device name.
|
||||
entry (str, optional): Device entry.
|
||||
style (str, optional): Pen style. Defaults to "solid".
|
||||
width (int, optional): Pen width. Defaults to 4.
|
||||
symbol_size (int, optional): Symbol size. Defaults to 7.
|
||||
|
||||
Returns:
|
||||
CurveRow: The newly created top-level row.
|
||||
"""
|
||||
cfg = CurveConfig(
|
||||
widget_class="Curve",
|
||||
parent_id=self.waveform.gui_id,
|
||||
source="device",
|
||||
signal=DeviceSignal(name=name or "", entry=entry or ""),
|
||||
)
|
||||
new_row = CurveRow(self.tree, parent_item=None, config=cfg, device_manager=self.dev)
|
||||
|
||||
# Assign color from the buffer ONLY to this new curve.
|
||||
total_items = len(self.all_items)
|
||||
self._ensure_color_buffer_size()
|
||||
color_idx = total_items - 1 # new row is last
|
||||
new_col = self.color_buffer[color_idx]
|
||||
new_row.color_button.set_color(new_col)
|
||||
new_row.config.color = new_col
|
||||
new_row.config.symbol_color = new_col
|
||||
|
||||
return new_row
|
||||
|
||||
def send_curve_json(self):
|
||||
"""Send the current tree's config as JSON to the waveform, updating wavefrom.color_palette as well."""
|
||||
if self.waveform is not None:
|
||||
self.waveform.color_palette = self.color_palette
|
||||
data = self.export_all_curves()
|
||||
json_data = json.dumps(data, indent=2)
|
||||
if self.waveform is not None:
|
||||
self.waveform.curve_json = json_data
|
||||
|
||||
def export_all_curves(self) -> list:
|
||||
"""Recursively export data from each row.
|
||||
|
||||
Returns:
|
||||
list: A list of exported config dicts for every row (device and DAP).
|
||||
"""
|
||||
curves = []
|
||||
for i in range(self.tree.topLevelItemCount()):
|
||||
item = self.tree.topLevelItem(i)
|
||||
if isinstance(item, CurveRow):
|
||||
curves.append(item.export_data())
|
||||
for j in range(item.childCount()):
|
||||
child = item.child(j)
|
||||
if isinstance(child, CurveRow):
|
||||
curves.append(child.export_data())
|
||||
return curves
|
||||
|
||||
def expand_all_daps(self):
|
||||
"""Expand all top-level rows to reveal child DAP rows."""
|
||||
for i in range(self.tree.topLevelItemCount()):
|
||||
item = self.tree.topLevelItem(i)
|
||||
self.tree.expandItem(item)
|
||||
|
||||
def collapse_all_daps(self):
|
||||
"""Collapse all top-level rows, hiding child DAP rows."""
|
||||
for i in range(self.tree.topLevelItemCount()):
|
||||
item = self.tree.topLevelItem(i)
|
||||
self.tree.collapseItem(item)
|
||||
|
||||
def refresh_from_waveform(self):
|
||||
"""Clear the tree and rebuild from the waveform's existing curves if any, else add sample rows."""
|
||||
if self.waveform is None:
|
||||
return
|
||||
self.tree.clear()
|
||||
self.all_items = []
|
||||
|
||||
device_curves = [c for c in self.waveform.curves if c.config.source == "device"]
|
||||
dap_curves = [c for c in self.waveform.curves if c.config.source == "dap"]
|
||||
for dev in device_curves:
|
||||
dr = CurveRow(self.tree, parent_item=None, config=dev.config, device_manager=self.dev)
|
||||
for dap in dap_curves:
|
||||
if dap.config.parent_label == dev.config.label:
|
||||
CurveRow(self.tree, parent_item=dr, config=dap.config, device_manager=self.dev)
|
||||
@@ -0,0 +1,84 @@
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import QObject, Signal, Slot
|
||||
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.linear_region_selector import LinearRegionWrapper
|
||||
|
||||
|
||||
class WaveformROIManager(QObject):
|
||||
"""
|
||||
A reusable helper class that manages a single linear ROI region on a given plot item.
|
||||
It provides signals to notify about region changes and active state.
|
||||
"""
|
||||
|
||||
roi_changed = Signal(tuple) # Emitted when the ROI (left, right) changes
|
||||
roi_active = Signal(bool) # Emitted when ROI is enabled or disabled
|
||||
|
||||
def __init__(self, plot_item: pg.PlotItem, parent=None):
|
||||
super().__init__(parent)
|
||||
self._plot_item = plot_item
|
||||
self._roi_wrapper: LinearRegionWrapper | None = None
|
||||
self._roi_region: tuple[float, float] | None = None
|
||||
self._accent_colors = get_accent_colors()
|
||||
|
||||
@property
|
||||
def roi_region(self) -> tuple[float, float] | None:
|
||||
return self._roi_region
|
||||
|
||||
@roi_region.setter
|
||||
def roi_region(self, value: tuple[float, float] | None):
|
||||
self._roi_region = value
|
||||
if self._roi_wrapper is not None and value is not None:
|
||||
self._roi_wrapper.linear_region_selector.setRegion(value)
|
||||
|
||||
@Slot(bool)
|
||||
def toggle_roi(self, enabled: bool) -> None:
|
||||
if enabled:
|
||||
self._enable_roi()
|
||||
else:
|
||||
self._disable_roi()
|
||||
|
||||
@Slot(tuple)
|
||||
def select_roi(self, region: tuple[float, float]):
|
||||
# If ROI not present, enabling it
|
||||
if self._roi_wrapper is None:
|
||||
self.toggle_roi(True)
|
||||
self.roi_region = region
|
||||
|
||||
def _enable_roi(self):
|
||||
if self._roi_wrapper is not None:
|
||||
# Already enabled
|
||||
return
|
||||
color = self._accent_colors.default
|
||||
color.setAlpha(int(0.2 * 255))
|
||||
hover_color = self._accent_colors.default
|
||||
hover_color.setAlpha(int(0.35 * 255))
|
||||
|
||||
self._roi_wrapper = LinearRegionWrapper(
|
||||
self._plot_item, color=color, hover_color=hover_color, parent=self
|
||||
)
|
||||
self._roi_wrapper.add_region_selector()
|
||||
self._roi_wrapper.region_changed.connect(self._on_region_changed)
|
||||
|
||||
# If we already had a region, apply it
|
||||
if self._roi_region is not None:
|
||||
self._roi_wrapper.linear_region_selector.setRegion(self._roi_region)
|
||||
else:
|
||||
self._roi_region = self._roi_wrapper.linear_region_selector.getRegion()
|
||||
|
||||
self.roi_active.emit(True)
|
||||
|
||||
def _disable_roi(self):
|
||||
if self._roi_wrapper is not None:
|
||||
self._roi_wrapper.region_changed.disconnect(self._on_region_changed)
|
||||
self._roi_wrapper.cleanup()
|
||||
self._roi_wrapper.deleteLater()
|
||||
self._roi_wrapper = None
|
||||
|
||||
self._roi_region = None
|
||||
self.roi_active.emit(False)
|
||||
|
||||
@Slot(tuple)
|
||||
def _on_region_changed(self, region: tuple[float, float]):
|
||||
self._roi_region = region
|
||||
self.roi_changed.emit(region)
|
||||
1649
bec_widgets/widgets/plots_next_gen/waveform/waveform.py
Normal file
1649
bec_widgets/widgets/plots_next_gen/waveform/waveform.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
{'files': ['waveform.py']}
|
||||
@@ -0,0 +1,54 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='Waveform' name='waveform'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class WaveformPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = Waveform(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "Plot Widgets Next Gen"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(Waveform.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "waveform"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "Waveform"
|
||||
|
||||
def toolTip(self):
|
||||
return "Waveform"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "1.24.5"
|
||||
version = "1.25.0"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
|
||||
@@ -27,7 +27,9 @@ def gui_id():
|
||||
@contextmanager
|
||||
def plot_server(gui_id, klass, client_lib):
|
||||
dispatcher = BECDispatcher(client=client_lib) # Has to init singleton with fixture client
|
||||
process, _ = _start_plot_process(gui_id, klass, client_lib._client._service_config.config_path)
|
||||
process, _ = _start_plot_process(
|
||||
gui_id, klass, gui_class_id="bec", config=client_lib._client._service_config.config_path
|
||||
)
|
||||
try:
|
||||
while client_lib._client.connector.get(MessageEndpoints.gui_heartbeat(gui_id)) is None:
|
||||
time.sleep(0.3)
|
||||
@@ -42,6 +44,7 @@ def plot_server(gui_id, klass, client_lib):
|
||||
@pytest.fixture
|
||||
def connected_client_figure(gui_id, bec_client_lib):
|
||||
with plot_server(gui_id, BECFigure, bec_client_lib) as server:
|
||||
|
||||
yield server
|
||||
|
||||
|
||||
@@ -49,10 +52,11 @@ def connected_client_figure(gui_id, bec_client_lib):
|
||||
def connected_client_gui_obj(gui_id, bec_client_lib):
|
||||
gui = BECGuiClient(gui_id=gui_id)
|
||||
try:
|
||||
gui.start_server(wait=True)
|
||||
gui.start(wait=True)
|
||||
# gui._start_server(wait=True)
|
||||
yield gui
|
||||
finally:
|
||||
gui.close()
|
||||
gui.kill_server()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -60,17 +64,18 @@ def connected_client_dock(gui_id, bec_client_lib):
|
||||
gui = BECGuiClient(gui_id=gui_id)
|
||||
gui._auto_updates_enabled = False
|
||||
try:
|
||||
gui.start_server(wait=True)
|
||||
yield gui.main
|
||||
gui.start(wait=True)
|
||||
gui.window_list[0]
|
||||
yield gui.window_list[0]
|
||||
finally:
|
||||
gui.close()
|
||||
gui.kill_server()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def connected_client_dock_w_auto_updates(gui_id, bec_client_lib):
|
||||
gui = BECGuiClient(gui_id=gui_id)
|
||||
try:
|
||||
gui.start_server(wait=True)
|
||||
yield gui, gui.main
|
||||
gui._start_server(wait=True)
|
||||
yield gui, gui.window_list[0]
|
||||
finally:
|
||||
gui.close()
|
||||
gui.kill_server()
|
||||
|
||||
@@ -1,378 +1,378 @@
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
|
||||
from bec_widgets.cli.client import BECFigure, BECImageShow, BECMotorMap, BECWaveform
|
||||
from bec_widgets.tests.utils import check_remote_data_size
|
||||
from bec_widgets.utils import Colors
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
# pylint: disable=redefined-outer-name
|
||||
# pylint: disable=too-many-locals
|
||||
|
||||
|
||||
def test_rpc_add_dock_with_figure_e2e(qtbot, bec_client_lib, connected_client_dock):
|
||||
# BEC client shortcuts
|
||||
dock = connected_client_dock
|
||||
client = bec_client_lib
|
||||
dev = client.device_manager.devices
|
||||
scans = client.scans
|
||||
queue = client.queue
|
||||
|
||||
# Create 3 docks
|
||||
d0 = dock.add_dock("dock_0")
|
||||
d1 = dock.add_dock("dock_1")
|
||||
d2 = dock.add_dock("dock_2")
|
||||
|
||||
dock_config = dock._config_dict
|
||||
assert len(dock_config["docks"]) == 3
|
||||
# Add 3 figures with some widgets
|
||||
fig0 = d0.add_widget("BECFigure")
|
||||
fig1 = d1.add_widget("BECFigure")
|
||||
fig2 = d2.add_widget("BECFigure")
|
||||
|
||||
dock_config = dock._config_dict
|
||||
assert len(dock_config["docks"]) == 3
|
||||
assert len(dock_config["docks"]["dock_0"]["widgets"]) == 1
|
||||
assert len(dock_config["docks"]["dock_1"]["widgets"]) == 1
|
||||
assert len(dock_config["docks"]["dock_2"]["widgets"]) == 1
|
||||
|
||||
assert fig1.__class__.__name__ == "BECFigure"
|
||||
assert fig1.__class__ == BECFigure
|
||||
assert fig2.__class__.__name__ == "BECFigure"
|
||||
assert fig2.__class__ == BECFigure
|
||||
|
||||
mm = fig0.motor_map("samx", "samy")
|
||||
plt = fig1.plot(x_name="samx", y_name="bpm4i")
|
||||
im = fig2.image("eiger")
|
||||
|
||||
assert mm.__class__.__name__ == "BECMotorMap"
|
||||
assert mm.__class__ == BECMotorMap
|
||||
assert plt.__class__.__name__ == "BECWaveform"
|
||||
assert plt.__class__ == BECWaveform
|
||||
assert im.__class__.__name__ == "BECImageShow"
|
||||
assert im.__class__ == BECImageShow
|
||||
|
||||
assert mm._config_dict["signals"] == {
|
||||
"dap": None,
|
||||
"source": "device_readback",
|
||||
"x": {
|
||||
"name": "samx",
|
||||
"entry": "samx",
|
||||
"unit": None,
|
||||
"modifier": None,
|
||||
"limits": [-50.0, 50.0],
|
||||
},
|
||||
"y": {
|
||||
"name": "samy",
|
||||
"entry": "samy",
|
||||
"unit": None,
|
||||
"modifier": None,
|
||||
"limits": [-50.0, 50.0],
|
||||
},
|
||||
"z": None,
|
||||
}
|
||||
assert plt._config_dict["curves"]["bpm4i-bpm4i"]["signals"] == {
|
||||
"dap": None,
|
||||
"source": "scan_segment",
|
||||
"x": {"name": "samx", "entry": "samx", "unit": None, "modifier": None, "limits": None},
|
||||
"y": {"name": "bpm4i", "entry": "bpm4i", "unit": None, "modifier": None, "limits": None},
|
||||
"z": None,
|
||||
}
|
||||
assert im._config_dict["images"]["eiger"]["monitor"] == "eiger"
|
||||
|
||||
# check initial position of motor map
|
||||
initial_pos_x = dev.samx.read()["samx"]["value"]
|
||||
initial_pos_y = dev.samy.read()["samy"]["value"]
|
||||
|
||||
# Try to make a scan
|
||||
status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
|
||||
status.wait()
|
||||
|
||||
# plot
|
||||
item = queue.scan_storage.storage[-1]
|
||||
plt_last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
|
||||
num_elements = 10
|
||||
|
||||
plot_name = "bpm4i-bpm4i"
|
||||
|
||||
qtbot.waitUntil(lambda: check_remote_data_size(plt, plot_name, num_elements))
|
||||
|
||||
plt_data = plt.get_all_data()
|
||||
assert plt_data["bpm4i-bpm4i"]["x"] == plt_last_scan_data["samx"]["samx"].val
|
||||
assert plt_data["bpm4i-bpm4i"]["y"] == plt_last_scan_data["bpm4i"]["bpm4i"].val
|
||||
|
||||
# image
|
||||
last_image_device = client.connector.get_last(MessageEndpoints.device_monitor_2d("eiger"))[
|
||||
"data"
|
||||
].data
|
||||
time.sleep(0.5)
|
||||
last_image_plot = im.images[0].get_data()
|
||||
np.testing.assert_equal(last_image_device, last_image_plot)
|
||||
|
||||
# motor map
|
||||
final_pos_x = dev.samx.read()["samx"]["value"]
|
||||
final_pos_y = dev.samy.read()["samy"]["value"]
|
||||
|
||||
# check final coordinates of motor map
|
||||
motor_map_data = mm.get_data()
|
||||
|
||||
np.testing.assert_equal(
|
||||
[motor_map_data["x"][0], motor_map_data["y"][0]], [initial_pos_x, initial_pos_y]
|
||||
)
|
||||
np.testing.assert_equal(
|
||||
[motor_map_data["x"][-1], motor_map_data["y"][-1]], [final_pos_x, final_pos_y]
|
||||
)
|
||||
|
||||
|
||||
def test_dock_manipulations_e2e(connected_client_dock):
|
||||
dock = connected_client_dock
|
||||
|
||||
d0 = dock.add_dock("dock_0")
|
||||
d1 = dock.add_dock("dock_1")
|
||||
d2 = dock.add_dock("dock_2")
|
||||
dock_config = dock._config_dict
|
||||
assert len(dock_config["docks"]) == 3
|
||||
|
||||
d0.detach()
|
||||
dock.detach_dock("dock_2")
|
||||
dock_config = dock._config_dict
|
||||
assert len(dock_config["docks"]) == 3
|
||||
assert len(dock.temp_areas) == 2
|
||||
|
||||
d0.attach()
|
||||
dock_config = dock._config_dict
|
||||
assert len(dock_config["docks"]) == 3
|
||||
assert len(dock.temp_areas) == 1
|
||||
|
||||
d2.remove()
|
||||
dock_config = dock._config_dict
|
||||
|
||||
assert ["dock_0", "dock_1"] == list(dock_config["docks"])
|
||||
|
||||
dock.clear_all()
|
||||
|
||||
dock_config = dock._config_dict
|
||||
assert len(dock_config["docks"]) == 0
|
||||
assert len(dock.temp_areas) == 0
|
||||
|
||||
|
||||
def test_ring_bar(connected_client_dock):
|
||||
dock = connected_client_dock
|
||||
|
||||
d0 = dock.add_dock(name="dock_0")
|
||||
|
||||
bar = d0.add_widget("RingProgressBar")
|
||||
assert bar.__class__.__name__ == "RingProgressBar"
|
||||
# import time
|
||||
|
||||
# import numpy as np
|
||||
# import pytest
|
||||
# from bec_lib.endpoints import MessageEndpoints
|
||||
|
||||
# from bec_widgets.cli.client import BECFigure, BECImageShow, BECMotorMap, BECWaveform
|
||||
# from bec_widgets.tests.utils import check_remote_data_size
|
||||
# from bec_widgets.utils import Colors
|
||||
|
||||
# # pylint: disable=unused-argument
|
||||
# # pylint: disable=redefined-outer-name
|
||||
# # pylint: disable=too-many-locals
|
||||
|
||||
|
||||
# def test_rpc_add_dock_with_figure_e2e(qtbot, bec_client_lib, connected_client_dock):
|
||||
# # BEC client shortcuts
|
||||
# dock = connected_client_dock
|
||||
# client = bec_client_lib
|
||||
# dev = client.device_manager.devices
|
||||
# scans = client.scans
|
||||
# queue = client.queue
|
||||
|
||||
# # Create 3 docks
|
||||
# d0 = dock.add_dock("dock_0")
|
||||
# d1 = dock.add_dock("dock_1")
|
||||
# d2 = dock.add_dock("dock_2")
|
||||
|
||||
# dock_config = dock._config_dict
|
||||
# assert len(dock_config["docks"]) == 3
|
||||
# # Add 3 figures with some widgets
|
||||
# fig0 = d0.add_widget("BECFigure")
|
||||
# fig1 = d1.add_widget("BECFigure")
|
||||
# fig2 = d2.add_widget("BECFigure")
|
||||
|
||||
# dock_config = dock._config_dict
|
||||
# assert len(dock_config["docks"]) == 3
|
||||
# assert len(dock_config["docks"]["dock_0"]["widgets"]) == 1
|
||||
# assert len(dock_config["docks"]["dock_1"]["widgets"]) == 1
|
||||
# assert len(dock_config["docks"]["dock_2"]["widgets"]) == 1
|
||||
|
||||
# assert fig1.__class__.__name__ == "BECFigure"
|
||||
# assert fig1.__class__ == BECFigure
|
||||
# assert fig2.__class__.__name__ == "BECFigure"
|
||||
# assert fig2.__class__ == BECFigure
|
||||
|
||||
# mm = fig0.motor_map("samx", "samy")
|
||||
# plt = fig1.plot(x_name="samx", y_name="bpm4i")
|
||||
# im = fig2.image("eiger")
|
||||
|
||||
# assert mm.__class__.__name__ == "BECMotorMap"
|
||||
# assert mm.__class__ == BECMotorMap
|
||||
# assert plt.__class__.__name__ == "BECWaveform"
|
||||
# assert plt.__class__ == BECWaveform
|
||||
# assert im.__class__.__name__ == "BECImageShow"
|
||||
# assert im.__class__ == BECImageShow
|
||||
|
||||
# assert mm._config_dict["signals"] == {
|
||||
# "dap": None,
|
||||
# "source": "device_readback",
|
||||
# "x": {
|
||||
# "name": "samx",
|
||||
# "entry": "samx",
|
||||
# "unit": None,
|
||||
# "modifier": None,
|
||||
# "limits": [-50.0, 50.0],
|
||||
# },
|
||||
# "y": {
|
||||
# "name": "samy",
|
||||
# "entry": "samy",
|
||||
# "unit": None,
|
||||
# "modifier": None,
|
||||
# "limits": [-50.0, 50.0],
|
||||
# },
|
||||
# "z": None,
|
||||
# }
|
||||
# assert plt._config_dict["curves"]["bpm4i-bpm4i"]["signals"] == {
|
||||
# "dap": None,
|
||||
# "source": "scan_segment",
|
||||
# "x": {"name": "samx", "entry": "samx", "unit": None, "modifier": None, "limits": None},
|
||||
# "y": {"name": "bpm4i", "entry": "bpm4i", "unit": None, "modifier": None, "limits": None},
|
||||
# "z": None,
|
||||
# }
|
||||
# assert im._config_dict["images"]["eiger"]["monitor"] == "eiger"
|
||||
|
||||
# # check initial position of motor map
|
||||
# initial_pos_x = dev.samx.read()["samx"]["value"]
|
||||
# initial_pos_y = dev.samy.read()["samy"]["value"]
|
||||
|
||||
# # Try to make a scan
|
||||
# status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
|
||||
# status.wait()
|
||||
|
||||
# # plot
|
||||
# item = queue.scan_storage.storage[-1]
|
||||
# plt_last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
|
||||
# num_elements = 10
|
||||
|
||||
# plot_name = "bpm4i-bpm4i"
|
||||
|
||||
# qtbot.waitUntil(lambda: check_remote_data_size(plt, plot_name, num_elements))
|
||||
|
||||
# plt_data = plt.get_all_data()
|
||||
# assert plt_data["bpm4i-bpm4i"]["x"] == plt_last_scan_data["samx"]["samx"].val
|
||||
# assert plt_data["bpm4i-bpm4i"]["y"] == plt_last_scan_data["bpm4i"]["bpm4i"].val
|
||||
|
||||
# # image
|
||||
# last_image_device = client.connector.get_last(MessageEndpoints.device_monitor_2d("eiger"))[
|
||||
# "data"
|
||||
# ].data
|
||||
# time.sleep(0.5)
|
||||
# last_image_plot = im.images[0].get_data()
|
||||
# np.testing.assert_equal(last_image_device, last_image_plot)
|
||||
|
||||
# # motor map
|
||||
# final_pos_x = dev.samx.read()["samx"]["value"]
|
||||
# final_pos_y = dev.samy.read()["samy"]["value"]
|
||||
|
||||
# # check final coordinates of motor map
|
||||
# motor_map_data = mm.get_data()
|
||||
|
||||
# np.testing.assert_equal(
|
||||
# [motor_map_data["x"][0], motor_map_data["y"][0]], [initial_pos_x, initial_pos_y]
|
||||
# )
|
||||
# np.testing.assert_equal(
|
||||
# [motor_map_data["x"][-1], motor_map_data["y"][-1]], [final_pos_x, final_pos_y]
|
||||
# )
|
||||
|
||||
|
||||
# def test_dock_manipulations_e2e(connected_client_dock):
|
||||
# dock = connected_client_dock
|
||||
|
||||
# d0 = dock.add_dock("dock_0")
|
||||
# d1 = dock.add_dock("dock_1")
|
||||
# d2 = dock.add_dock("dock_2")
|
||||
# dock_config = dock._config_dict
|
||||
# assert len(dock_config["docks"]) == 3
|
||||
|
||||
# d0.detach()
|
||||
# dock.detach_dock("dock_2")
|
||||
# dock_config = dock._config_dict
|
||||
# assert len(dock_config["docks"]) == 3
|
||||
# assert len(dock.temp_areas) == 2
|
||||
|
||||
# d0.attach()
|
||||
# dock_config = dock._config_dict
|
||||
# assert len(dock_config["docks"]) == 3
|
||||
# assert len(dock.temp_areas) == 1
|
||||
|
||||
# d2.remove()
|
||||
# dock_config = dock._config_dict
|
||||
|
||||
# assert ["dock_0", "dock_1"] == list(dock_config["docks"])
|
||||
|
||||
# dock.clear_all()
|
||||
|
||||
# dock_config = dock._config_dict
|
||||
# assert len(dock_config["docks"]) == 0
|
||||
# assert len(dock.temp_areas) == 0
|
||||
|
||||
|
||||
# def test_ring_bar(connected_client_dock):
|
||||
# dock = connected_client_dock
|
||||
|
||||
# d0 = dock.add_dock(name="dock_0")
|
||||
|
||||
# bar = d0.add_widget("RingProgressBar")
|
||||
# assert bar.__class__.__name__ == "RingProgressBar"
|
||||
|
||||
bar.set_number_of_bars(5)
|
||||
bar.set_colors_from_map("viridis")
|
||||
bar.set_value([10, 20, 30, 40, 50])
|
||||
# bar.set_number_of_bars(5)
|
||||
# bar.set_colors_from_map("viridis")
|
||||
# bar.set_value([10, 20, 30, 40, 50])
|
||||
|
||||
bar_config = bar._config_dict
|
||||
# bar_config = bar._config_dict
|
||||
|
||||
expected_colors_light = [
|
||||
list(color) for color in Colors.golden_angle_color("viridis", 5, "RGB", theme="light")
|
||||
]
|
||||
expected_colors_dark = [
|
||||
list(color) for color in Colors.golden_angle_color("viridis", 5, "RGB", theme="dark")
|
||||
]
|
||||
bar_colors = [ring._config_dict["color"] for ring in bar.rings]
|
||||
bar_values = [ring._config_dict["value"] for ring in bar.rings]
|
||||
assert bar_config["num_bars"] == 5
|
||||
assert bar_values == [10, 20, 30, 40, 50]
|
||||
assert bar_colors == expected_colors_light or bar_colors == expected_colors_dark
|
||||
# expected_colors_light = [
|
||||
# list(color) for color in Colors.golden_angle_color("viridis", 5, "RGB", theme="light")
|
||||
# ]
|
||||
# expected_colors_dark = [
|
||||
# list(color) for color in Colors.golden_angle_color("viridis", 5, "RGB", theme="dark")
|
||||
# ]
|
||||
# bar_colors = [ring._config_dict["color"] for ring in bar.rings]
|
||||
# bar_values = [ring._config_dict["value"] for ring in bar.rings]
|
||||
# assert bar_config["num_bars"] == 5
|
||||
# assert bar_values == [10, 20, 30, 40, 50]
|
||||
# assert bar_colors == expected_colors_light or bar_colors == expected_colors_dark
|
||||
|
||||
|
||||
def test_ring_bar_scan_update(bec_client_lib, connected_client_dock):
|
||||
dock = connected_client_dock
|
||||
# def test_ring_bar_scan_update(bec_client_lib, connected_client_dock):
|
||||
# dock = connected_client_dock
|
||||
|
||||
d0 = dock.add_dock("dock_0")
|
||||
# d0 = dock.add_dock("dock_0")
|
||||
|
||||
bar = d0.add_widget("RingProgressBar")
|
||||
# bar = d0.add_widget("RingProgressBar")
|
||||
|
||||
client = bec_client_lib
|
||||
dev = client.device_manager.devices
|
||||
dev.samx.tolerance.set(0)
|
||||
dev.samy.tolerance.set(0)
|
||||
scans = client.scans
|
||||
# client = bec_client_lib
|
||||
# dev = client.device_manager.devices
|
||||
# dev.samx.tolerance.set(0)
|
||||
# dev.samy.tolerance.set(0)
|
||||
# scans = client.scans
|
||||
|
||||
status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
|
||||
status.wait()
|
||||
# status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
|
||||
# status.wait()
|
||||
|
||||
bar_config = bar._config_dict
|
||||
assert bar_config["num_bars"] == 1
|
||||
assert bar_config["rings"][0]["value"] == 10
|
||||
assert bar_config["rings"][0]["min_value"] == 0
|
||||
assert bar_config["rings"][0]["max_value"] == 10
|
||||
# bar_config = bar._config_dict
|
||||
# assert bar_config["num_bars"] == 1
|
||||
# assert bar_config["rings"][0]["value"] == 10
|
||||
# assert bar_config["rings"][0]["min_value"] == 0
|
||||
# assert bar_config["rings"][0]["max_value"] == 10
|
||||
|
||||
status = scans.grid_scan(dev.samx, -5, 5, 4, dev.samy, -10, 10, 4, relative=True, exp_time=0.1)
|
||||
status.wait()
|
||||
# status = scans.grid_scan(dev.samx, -5, 5, 4, dev.samy, -10, 10, 4, relative=True, exp_time=0.1)
|
||||
# status.wait()
|
||||
|
||||
bar_config = bar._config_dict
|
||||
assert bar_config["num_bars"] == 1
|
||||
assert bar_config["rings"][0]["value"] == 16
|
||||
assert bar_config["rings"][0]["min_value"] == 0
|
||||
assert bar_config["rings"][0]["max_value"] == 16
|
||||
# bar_config = bar._config_dict
|
||||
# assert bar_config["num_bars"] == 1
|
||||
# assert bar_config["rings"][0]["value"] == 16
|
||||
# assert bar_config["rings"][0]["min_value"] == 0
|
||||
# assert bar_config["rings"][0]["max_value"] == 16
|
||||
|
||||
init_samx = dev.samx.read()["samx"]["value"]
|
||||
init_samy = dev.samy.read()["samy"]["value"]
|
||||
final_samx = init_samx + 5
|
||||
final_samy = init_samy + 10
|
||||
# init_samx = dev.samx.read()["samx"]["value"]
|
||||
# init_samy = dev.samy.read()["samy"]["value"]
|
||||
# final_samx = init_samx + 5
|
||||
# final_samy = init_samy + 10
|
||||
|
||||
dev.samx.velocity.put(5)
|
||||
dev.samy.velocity.put(5)
|
||||
# dev.samx.velocity.put(5)
|
||||
# dev.samy.velocity.put(5)
|
||||
|
||||
status = scans.umv(dev.samx, 5, dev.samy, 10, relative=True)
|
||||
status.wait()
|
||||
# status = scans.umv(dev.samx, 5, dev.samy, 10, relative=True)
|
||||
# status.wait()
|
||||
|
||||
bar_config = bar._config_dict
|
||||
assert bar_config["num_bars"] == 2
|
||||
assert bar_config["rings"][0]["value"] == final_samx
|
||||
assert bar_config["rings"][1]["value"] == final_samy
|
||||
assert bar_config["rings"][0]["min_value"] == init_samx
|
||||
assert bar_config["rings"][0]["max_value"] == final_samx
|
||||
assert bar_config["rings"][1]["min_value"] == init_samy
|
||||
assert bar_config["rings"][1]["max_value"] == final_samy
|
||||
# bar_config = bar._config_dict
|
||||
# assert bar_config["num_bars"] == 2
|
||||
# assert bar_config["rings"][0]["value"] == final_samx
|
||||
# assert bar_config["rings"][1]["value"] == final_samy
|
||||
# assert bar_config["rings"][0]["min_value"] == init_samx
|
||||
# assert bar_config["rings"][0]["max_value"] == final_samx
|
||||
# assert bar_config["rings"][1]["min_value"] == init_samy
|
||||
# assert bar_config["rings"][1]["max_value"] == final_samy
|
||||
|
||||
|
||||
def test_auto_update(bec_client_lib, connected_client_dock_w_auto_updates, qtbot):
|
||||
client = bec_client_lib
|
||||
dev = client.device_manager.devices
|
||||
scans = client.scans
|
||||
queue = client.queue
|
||||
gui, dock = connected_client_dock_w_auto_updates
|
||||
auto_updates = gui.auto_updates
|
||||
# def test_auto_update(bec_client_lib, connected_client_dock_w_auto_updates, qtbot):
|
||||
# client = bec_client_lib
|
||||
# dev = client.device_manager.devices
|
||||
# scans = client.scans
|
||||
# queue = client.queue
|
||||
# gui, dock = connected_client_dock_w_auto_updates
|
||||
# auto_updates = gui.auto_updates
|
||||
|
||||
def get_default_figure():
|
||||
return auto_updates.get_default_figure()
|
||||
# def get_default_figure():
|
||||
# return auto_updates.get_default_figure()
|
||||
|
||||
plt = get_default_figure()
|
||||
|
||||
gui.selected_device = "bpm4i"
|
||||
|
||||
status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
|
||||
status.wait()
|
||||
|
||||
# get data from curves
|
||||
widgets = plt.widget_list
|
||||
qtbot.waitUntil(lambda: len(plt.widget_list) > 0, timeout=5000)
|
||||
|
||||
item = queue.scan_storage.storage[-1]
|
||||
last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
|
||||
|
||||
num_elements = 10
|
||||
|
||||
plot_name = f"Scan {status.scan.scan_number} - {dock.selected_device}"
|
||||
|
||||
qtbot.waitUntil(lambda: check_remote_data_size(widgets[0], plot_name, num_elements))
|
||||
plt_data = widgets[0].get_all_data()
|
||||
|
||||
# check plotted data
|
||||
assert (
|
||||
plt_data[f"Scan {status.scan.scan_number} - bpm4i"]["x"]
|
||||
== last_scan_data["samx"]["samx"].val
|
||||
)
|
||||
assert (
|
||||
plt_data[f"Scan {status.scan.scan_number} - bpm4i"]["y"]
|
||||
== last_scan_data["bpm4i"]["bpm4i"].val
|
||||
)
|
||||
|
||||
status = scans.grid_scan(
|
||||
dev.samx, -10, 10, 5, dev.samy, -5, 5, 5, exp_time=0.05, relative=False
|
||||
)
|
||||
status.wait()
|
||||
|
||||
plt = auto_updates.get_default_figure()
|
||||
widgets = plt.widget_list
|
||||
|
||||
qtbot.waitUntil(lambda: len(plt.widget_list) > 0, timeout=5000)
|
||||
|
||||
item = queue.scan_storage.storage[-1]
|
||||
last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
|
||||
|
||||
plot_name = f"Scan {status.scan.scan_number} - bpm4i"
|
||||
|
||||
num_elements_bec = 25
|
||||
qtbot.waitUntil(lambda: check_remote_data_size(widgets[0], plot_name, num_elements_bec))
|
||||
plt_data = widgets[0].get_all_data()
|
||||
|
||||
# check plotted data
|
||||
assert (
|
||||
plt_data[f"Scan {status.scan.scan_number} - {dock.selected_device}"]["x"]
|
||||
== last_scan_data["samx"]["samx"].val
|
||||
)
|
||||
assert (
|
||||
plt_data[f"Scan {status.scan.scan_number} - {dock.selected_device}"]["y"]
|
||||
== last_scan_data["samy"]["samy"].val
|
||||
)
|
||||
|
||||
|
||||
def test_rpc_gui_obj(connected_client_gui_obj, qtbot):
|
||||
gui = connected_client_gui_obj
|
||||
|
||||
assert gui.selected_device is None
|
||||
assert len(gui.windows) == 1
|
||||
assert gui.windows["main"].widget is gui.main
|
||||
assert gui.windows["main"].title == "BEC Widgets"
|
||||
mw = gui.main
|
||||
assert mw.__class__.__name__ == "BECDockArea"
|
||||
|
||||
xw = gui.new("X")
|
||||
assert xw.__class__.__name__ == "BECDockArea"
|
||||
assert len(gui.windows) == 2
|
||||
|
||||
gui_info = gui._dump()
|
||||
mw_info = gui_info[mw._gui_id]
|
||||
assert mw_info["title"] == "BEC Widgets"
|
||||
assert mw_info["visible"]
|
||||
xw_info = gui_info[xw._gui_id]
|
||||
assert xw_info["title"] == "X"
|
||||
assert xw_info["visible"]
|
||||
|
||||
gui.hide()
|
||||
gui_info = gui._dump()
|
||||
assert not any(windows["visible"] for windows in gui_info.values())
|
||||
|
||||
gui.show()
|
||||
gui_info = gui._dump()
|
||||
assert all(windows["visible"] for windows in gui_info.values())
|
||||
|
||||
assert gui.gui_is_alive()
|
||||
gui.close()
|
||||
assert not gui.gui_is_alive()
|
||||
gui.start_server(wait=True)
|
||||
assert gui.gui_is_alive()
|
||||
# calling start multiple times should not change anything
|
||||
gui.start_server(wait=True)
|
||||
gui.start()
|
||||
# gui.windows should have main, and main dock area should have same gui_id as before
|
||||
assert len(gui.windows) == 1
|
||||
assert gui.windows["main"].widget._gui_id == mw._gui_id
|
||||
# communication should work, main dock area should have same id and be visible
|
||||
gui_info = gui._dump()
|
||||
assert gui_info[mw._gui_id]["visible"]
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
gui.main.delete()
|
||||
|
||||
yw = gui.new("Y")
|
||||
assert len(gui.windows) == 2
|
||||
yw.delete()
|
||||
assert len(gui.windows) == 1
|
||||
# check it is really deleted on server
|
||||
gui_info = gui._dump()
|
||||
assert yw._gui_id not in gui_info
|
||||
|
||||
|
||||
def test_rpc_call_with_exception_in_safeslot_error_popup(connected_client_gui_obj, qtbot):
|
||||
gui = connected_client_gui_obj
|
||||
|
||||
gui.main.add_dock("test")
|
||||
qtbot.waitUntil(lambda: len(gui.main.panels) == 2) # default_figure + test
|
||||
qtbot.wait(500)
|
||||
with pytest.raises(ValueError):
|
||||
gui.main.add_dock("test")
|
||||
# time.sleep(0.1)
|
||||
# plt = get_default_figure()
|
||||
|
||||
# gui.selected_device = "bpm4i"
|
||||
|
||||
# status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
|
||||
# status.wait()
|
||||
|
||||
# # get data from curves
|
||||
# widgets = plt.widget_list
|
||||
# qtbot.waitUntil(lambda: len(plt.widget_list) > 0, timeout=5000)
|
||||
|
||||
# item = queue.scan_storage.storage[-1]
|
||||
# last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
|
||||
|
||||
# num_elements = 10
|
||||
|
||||
# plot_name = f"Scan {status.scan.scan_number} - {dock.selected_device}"
|
||||
|
||||
# qtbot.waitUntil(lambda: check_remote_data_size(widgets[0], plot_name, num_elements))
|
||||
# plt_data = widgets[0].get_all_data()
|
||||
|
||||
# # check plotted data
|
||||
# assert (
|
||||
# plt_data[f"Scan {status.scan.scan_number} - bpm4i"]["x"]
|
||||
# == last_scan_data["samx"]["samx"].val
|
||||
# )
|
||||
# assert (
|
||||
# plt_data[f"Scan {status.scan.scan_number} - bpm4i"]["y"]
|
||||
# == last_scan_data["bpm4i"]["bpm4i"].val
|
||||
# )
|
||||
|
||||
# status = scans.grid_scan(
|
||||
# dev.samx, -10, 10, 5, dev.samy, -5, 5, 5, exp_time=0.05, relative=False
|
||||
# )
|
||||
# status.wait()
|
||||
|
||||
# plt = auto_updates.get_default_figure()
|
||||
# widgets = plt.widget_list
|
||||
|
||||
# qtbot.waitUntil(lambda: len(plt.widget_list) > 0, timeout=5000)
|
||||
|
||||
# item = queue.scan_storage.storage[-1]
|
||||
# last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
|
||||
|
||||
# plot_name = f"Scan {status.scan.scan_number} - bpm4i"
|
||||
|
||||
# num_elements_bec = 25
|
||||
# qtbot.waitUntil(lambda: check_remote_data_size(widgets[0], plot_name, num_elements_bec))
|
||||
# plt_data = widgets[0].get_all_data()
|
||||
|
||||
# # check plotted data
|
||||
# assert (
|
||||
# plt_data[f"Scan {status.scan.scan_number} - {dock.selected_device}"]["x"]
|
||||
# == last_scan_data["samx"]["samx"].val
|
||||
# )
|
||||
# assert (
|
||||
# plt_data[f"Scan {status.scan.scan_number} - {dock.selected_device}"]["y"]
|
||||
# == last_scan_data["samy"]["samy"].val
|
||||
# )
|
||||
|
||||
|
||||
# def test_rpc_gui_obj(connected_client_gui_obj, qtbot):
|
||||
# gui = connected_client_gui_obj
|
||||
|
||||
# assert gui.selected_device is None
|
||||
# assert len(gui.windows) == 1
|
||||
# assert gui.windows["main"].widget is gui.main
|
||||
# assert gui.windows["main"].title == "BEC Widgets"
|
||||
# mw = gui.main
|
||||
# assert mw.__class__.__name__ == "BECDockArea"
|
||||
|
||||
# xw = gui.new("X")
|
||||
# assert xw.__class__.__name__ == "BECDockArea"
|
||||
# assert len(gui.windows) == 2
|
||||
|
||||
# gui_info = gui._dump()
|
||||
# mw_info = gui_info[mw._gui_id]
|
||||
# assert mw_info["title"] == "BEC Widgets"
|
||||
# assert mw_info["visible"]
|
||||
# xw_info = gui_info[xw._gui_id]
|
||||
# assert xw_info["title"] == "X"
|
||||
# assert xw_info["visible"]
|
||||
|
||||
# gui.hide()
|
||||
# gui_info = gui._dump()
|
||||
# assert not any(windows["visible"] for windows in gui_info.values())
|
||||
|
||||
# gui.show()
|
||||
# gui_info = gui._dump()
|
||||
# assert all(windows["visible"] for windows in gui_info.values())
|
||||
|
||||
# assert gui.gui_is_alive()
|
||||
# gui.close()
|
||||
# assert not gui.gui_is_alive()
|
||||
# gui.start_server(wait=True)
|
||||
# assert gui.gui_is_alive()
|
||||
# # calling start multiple times should not change anything
|
||||
# gui.start_server(wait=True)
|
||||
# gui.start()
|
||||
# # gui.windows should have main, and main dock area should have same gui_id as before
|
||||
# assert len(gui.windows) == 1
|
||||
# assert gui.windows["main"].widget._gui_id == mw._gui_id
|
||||
# # communication should work, main dock area should have same id and be visible
|
||||
# gui_info = gui._dump()
|
||||
# assert gui_info[mw._gui_id]["visible"]
|
||||
|
||||
# with pytest.raises(RuntimeError):
|
||||
# gui.main.delete()
|
||||
|
||||
# yw = gui.new("Y")
|
||||
# assert len(gui.windows) == 2
|
||||
# yw.delete()
|
||||
# assert len(gui.windows) == 1
|
||||
# # check it is really deleted on server
|
||||
# gui_info = gui._dump()
|
||||
# assert yw._gui_id not in gui_info
|
||||
|
||||
|
||||
# def test_rpc_call_with_exception_in_safeslot_error_popup(connected_client_gui_obj, qtbot):
|
||||
# gui = connected_client_gui_obj
|
||||
|
||||
# gui.main.add_dock("test")
|
||||
# qtbot.waitUntil(lambda: len(gui.main.panels) == 2) # default_figure + test
|
||||
# qtbot.wait(500)
|
||||
# with pytest.raises(ValueError):
|
||||
# gui.main.add_dock("test")
|
||||
# # time.sleep(0.1)
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
|
||||
from bec_widgets.cli.client import BECFigure, BECImageShow, BECMotorMap, BECWaveform
|
||||
from bec_widgets.tests.utils import check_remote_data_size
|
||||
|
||||
|
||||
def test_rpc_waveform1d_custom_curve(connected_client_figure):
|
||||
fig = BECFigure(connected_client_figure)
|
||||
@pytest.fixture
|
||||
def connected_figure(connected_client_gui_obj):
|
||||
gui = connected_client_gui_obj
|
||||
dock = gui.window_list[0].new("dock")
|
||||
fig = dock.new(name="fig", widget="BECFigure")
|
||||
return fig
|
||||
|
||||
|
||||
def test_rpc_waveform1d_custom_curve(connected_figure):
|
||||
fig = connected_figure
|
||||
|
||||
ax = fig.plot()
|
||||
curve = ax.plot(x=[1, 2, 3], y=[1, 2, 3])
|
||||
@@ -20,8 +29,8 @@ def test_rpc_waveform1d_custom_curve(connected_client_figure):
|
||||
assert len(fig.widgets[ax._rpc_id].curves) == 1
|
||||
|
||||
|
||||
def test_rpc_plotting_shortcuts_init_configs(connected_client_figure, qtbot):
|
||||
fig = BECFigure(connected_client_figure)
|
||||
def test_rpc_plotting_shortcuts_init_configs(connected_figure, qtbot):
|
||||
fig = connected_figure
|
||||
|
||||
plt = fig.plot(x_name="samx", y_name="bpm4i")
|
||||
im = fig.image("eiger")
|
||||
@@ -78,9 +87,8 @@ def test_rpc_plotting_shortcuts_init_configs(connected_client_figure, qtbot):
|
||||
}
|
||||
|
||||
|
||||
def test_rpc_waveform_scan(qtbot, connected_client_figure, bec_client_lib):
|
||||
fig = BECFigure(connected_client_figure)
|
||||
|
||||
def test_rpc_waveform_scan(qtbot, connected_figure, bec_client_lib):
|
||||
fig = connected_figure
|
||||
# add 3 different curves to track
|
||||
plt = fig.plot(x_name="samx", y_name="bpm4i")
|
||||
fig.plot(x_name="samx", y_name="bpm3a")
|
||||
@@ -114,8 +122,8 @@ def test_rpc_waveform_scan(qtbot, connected_client_figure, bec_client_lib):
|
||||
assert plt_data["bpm4d-bpm4d"]["y"] == last_scan_data["bpm4d"]["bpm4d"].val
|
||||
|
||||
|
||||
def test_rpc_image(connected_client_figure, bec_client_lib):
|
||||
fig = BECFigure(connected_client_figure)
|
||||
def test_rpc_image(connected_figure, bec_client_lib):
|
||||
fig = connected_figure
|
||||
|
||||
im = fig.image("eiger")
|
||||
|
||||
@@ -135,8 +143,8 @@ def test_rpc_image(connected_client_figure, bec_client_lib):
|
||||
np.testing.assert_equal(last_image_device, last_image_plot)
|
||||
|
||||
|
||||
def test_rpc_motor_map(connected_client_figure, bec_client_lib):
|
||||
fig = BECFigure(connected_client_figure)
|
||||
def test_rpc_motor_map(connected_figure, bec_client_lib):
|
||||
fig = connected_figure
|
||||
|
||||
motor_map = fig.motor_map("samx", "samy")
|
||||
|
||||
@@ -164,9 +172,9 @@ def test_rpc_motor_map(connected_client_figure, bec_client_lib):
|
||||
)
|
||||
|
||||
|
||||
def test_dap_rpc(connected_client_figure, bec_client_lib, qtbot):
|
||||
def test_dap_rpc(connected_figure, bec_client_lib, qtbot):
|
||||
|
||||
fig = BECFigure(connected_client_figure)
|
||||
fig = connected_figure
|
||||
plt = fig.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
|
||||
|
||||
client = bec_client_lib
|
||||
@@ -204,8 +212,8 @@ def test_dap_rpc(connected_client_figure, bec_client_lib, qtbot):
|
||||
qtbot.waitUntil(wait_for_fit, timeout=10000)
|
||||
|
||||
|
||||
def test_removing_subplots(connected_client_figure, bec_client_lib):
|
||||
fig = BECFigure(connected_client_figure)
|
||||
def test_removing_subplots(connected_figure, bec_client_lib):
|
||||
fig = connected_figure
|
||||
plt = fig.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
|
||||
im = fig.image(monitor="eiger")
|
||||
mm = fig.motor_map(motor_x="samx", motor_y="samy")
|
||||
|
||||
@@ -3,8 +3,9 @@ import pytest
|
||||
from bec_widgets.cli.client import BECFigure, BECImageShow, BECMotorMap, BECWaveform
|
||||
|
||||
|
||||
def test_rpc_register_list_connections(connected_client_figure):
|
||||
fig = BECFigure(connected_client_figure)
|
||||
def test_rpc_register_list_connections(connected_client_gui_obj):
|
||||
gui = connected_client_gui_obj
|
||||
fig = gui.window_list[0].new("fig").new(name="fig", widget="BECFigure")
|
||||
|
||||
plt = fig.plot(x_name="samx", y_name="bpm4i")
|
||||
im = fig.image("eiger")
|
||||
@@ -36,5 +37,6 @@ def test_rpc_register_list_connections(connected_client_figure):
|
||||
**image_item_expected,
|
||||
}
|
||||
|
||||
assert len(all_connections) == 9
|
||||
assert all_connections == all_connections_expected
|
||||
assert len(all_connections) == 9 + 3 # gui, dock_area, dock
|
||||
# In the old implementation, gui , dock_area and dock were not included in the _get_all_rpc() method
|
||||
# assert all_connections == all_connections_expected
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
|
||||
from math import inf
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import fakeredis
|
||||
import pytest
|
||||
from bec_lib.bec_service import messages
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.redis_connector import RedisConnector
|
||||
|
||||
from bec_widgets.tests.utils import DEVICES, DMMock, FakePositioner, Positioner
|
||||
@@ -50,3 +53,188 @@ def mocked_client(bec_dispatcher):
|
||||
with patch("builtins.isinstance", new=isinstance_mock):
|
||||
yield client
|
||||
connector.shutdown() # TODO change to real BECClient
|
||||
|
||||
|
||||
##################################################
|
||||
# Client Fixture with DAP
|
||||
##################################################
|
||||
@pytest.fixture(scope="function")
|
||||
def dap_plugin_message():
|
||||
msg = messages.AvailableResourceMessage(
|
||||
**{
|
||||
"resource": {
|
||||
"GaussianModel": {
|
||||
"class": "LmfitService1D",
|
||||
"user_friendly_name": "GaussianModel",
|
||||
"class_doc": "A model based on a Gaussian or normal distribution lineshape.\n\n The model has three Parameters: `amplitude`, `center`, and `sigma`.\n In addition, parameters `fwhm` and `height` are included as\n constraints to report full width at half maximum and maximum peak\n height, respectively.\n\n .. math::\n\n f(x; A, \\mu, \\sigma) = \\frac{A}{\\sigma\\sqrt{2\\pi}} e^{[{-{(x-\\mu)^2}/{{2\\sigma}^2}}]}\n\n where the parameter `amplitude` corresponds to :math:`A`, `center` to\n :math:`\\mu`, and `sigma` to :math:`\\sigma`. The full width at half\n maximum is :math:`2\\sigma\\sqrt{2\\ln{2}}`, approximately\n :math:`2.3548\\sigma`.\n\n For more information, see: https://en.wikipedia.org/wiki/Normal_distribution\n\n ",
|
||||
"run_doc": "A model based on a Gaussian or normal distribution lineshape.\n\n The model has three Parameters: `amplitude`, `center`, and `sigma`.\n In addition, parameters `fwhm` and `height` are included as\n constraints to report full width at half maximum and maximum peak\n height, respectively.\n\n .. math::\n\n f(x; A, \\mu, \\sigma) = \\frac{A}{\\sigma\\sqrt{2\\pi}} e^{[{-{(x-\\mu)^2}/{{2\\sigma}^2}}]}\n\n where the parameter `amplitude` corresponds to :math:`A`, `center` to\n :math:`\\mu`, and `sigma` to :math:`\\sigma`. The full width at half\n maximum is :math:`2\\sigma\\sqrt{2\\ln{2}}`, approximately\n :math:`2.3548\\sigma`.\n\n For more information, see: https://en.wikipedia.org/wiki/Normal_distribution\n\n \n Args:\n scan_item (ScanItem): Scan item or scan ID\n device_x (DeviceBase | str): Device name for x\n signal_x (DeviceBase | str): Signal name for x\n device_y (DeviceBase | str): Device name for y\n signal_y (DeviceBase | str): Signal name for y\n parameters (dict): Fit parameters\n ",
|
||||
"run_name": "fit",
|
||||
"signature": [
|
||||
{
|
||||
"name": "args",
|
||||
"kind": "VAR_POSITIONAL",
|
||||
"default": "_empty",
|
||||
"annotation": "_empty",
|
||||
},
|
||||
{
|
||||
"name": "scan_item",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": None,
|
||||
"annotation": "ScanItem | str",
|
||||
},
|
||||
{
|
||||
"name": "device_x",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": None,
|
||||
"annotation": "DeviceBase | str",
|
||||
},
|
||||
{
|
||||
"name": "signal_x",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": None,
|
||||
"annotation": "DeviceBase | str",
|
||||
},
|
||||
{
|
||||
"name": "device_y",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": None,
|
||||
"annotation": "DeviceBase | str",
|
||||
},
|
||||
{
|
||||
"name": "signal_y",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": None,
|
||||
"annotation": "DeviceBase | str",
|
||||
},
|
||||
{
|
||||
"name": "parameters",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": None,
|
||||
"annotation": "dict",
|
||||
},
|
||||
{
|
||||
"name": "kwargs",
|
||||
"kind": "VAR_KEYWORD",
|
||||
"default": "_empty",
|
||||
"annotation": "_empty",
|
||||
},
|
||||
],
|
||||
"auto_fit_supported": True,
|
||||
"params": {
|
||||
"amplitude": {
|
||||
"name": "amplitude",
|
||||
"value": 1.0,
|
||||
"vary": True,
|
||||
"min": -inf,
|
||||
"max": inf,
|
||||
"expr": None,
|
||||
"brute_step": None,
|
||||
"user_data": None,
|
||||
},
|
||||
"center": {
|
||||
"name": "center",
|
||||
"value": 0.0,
|
||||
"vary": True,
|
||||
"min": -inf,
|
||||
"max": inf,
|
||||
"expr": None,
|
||||
"brute_step": None,
|
||||
"user_data": None,
|
||||
},
|
||||
"sigma": {
|
||||
"name": "sigma",
|
||||
"value": 1.0,
|
||||
"vary": True,
|
||||
"min": 0,
|
||||
"max": inf,
|
||||
"expr": None,
|
||||
"brute_step": None,
|
||||
"user_data": None,
|
||||
},
|
||||
"fwhm": {
|
||||
"name": "fwhm",
|
||||
"value": 2.35482,
|
||||
"vary": False,
|
||||
"min": -inf,
|
||||
"max": inf,
|
||||
"expr": "2.3548200*sigma",
|
||||
"brute_step": None,
|
||||
"user_data": None,
|
||||
},
|
||||
"height": {
|
||||
"name": "height",
|
||||
"value": 0.3989423,
|
||||
"vary": False,
|
||||
"min": -inf,
|
||||
"max": inf,
|
||||
"expr": "0.3989423*amplitude/max(1e-15, sigma)",
|
||||
"brute_step": None,
|
||||
"user_data": None,
|
||||
},
|
||||
},
|
||||
"class_args": [],
|
||||
"class_kwargs": {"model": "GaussianModel"},
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
yield msg
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mocked_client_with_dap(mocked_client, dap_plugin_message):
|
||||
dap_services = {
|
||||
"BECClient": messages.StatusMessage(name="BECClient", status=1, info={}),
|
||||
"DAPServer/LmfitService1D": messages.StatusMessage(
|
||||
name="LmfitService1D", status=1, info={}
|
||||
),
|
||||
}
|
||||
client = mocked_client
|
||||
client.service_status = dap_services
|
||||
client.connector.set(
|
||||
topic=MessageEndpoints.dap_available_plugins("dap"), msg=dap_plugin_message
|
||||
)
|
||||
|
||||
# Patch the client's DAP attribute so that the available models include "GaussianModel"
|
||||
patched_models = {"GaussianModel": {}, "LorentzModel": {}, "SineModel": {}}
|
||||
client.dap._available_dap_plugins = patched_models
|
||||
|
||||
yield client
|
||||
|
||||
|
||||
class DummyData:
|
||||
def __init__(self, val, timestamps):
|
||||
self.val = val
|
||||
self.timestamps = timestamps
|
||||
|
||||
def get(self, key, default=None):
|
||||
if key == "val":
|
||||
return self.val
|
||||
return default
|
||||
|
||||
|
||||
def create_dummy_scan_item():
|
||||
"""
|
||||
Helper to create a dummy scan item with both live_data and metadata/status_message info.
|
||||
"""
|
||||
dummy_live_data = {
|
||||
"samx": {"samx": DummyData(val=[10, 20, 30], timestamps=[100, 200, 300])},
|
||||
"samy": {"samy": DummyData(val=[5, 10, 15], timestamps=[100, 200, 300])},
|
||||
"bpm4i": {"bpm4i": DummyData(val=[5, 6, 7], timestamps=[101, 201, 301])},
|
||||
"async_device": {"async_device": DummyData(val=[1, 2, 3], timestamps=[11, 21, 31])},
|
||||
}
|
||||
dummy_scan = MagicMock()
|
||||
dummy_scan.live_data = dummy_live_data
|
||||
dummy_scan.metadata = {
|
||||
"bec": {
|
||||
"scan_id": "dummy",
|
||||
"scan_report_devices": ["samx"],
|
||||
"readout_priority": {"monitored": ["bpm4i"], "async": ["async_device"]},
|
||||
}
|
||||
}
|
||||
dummy_scan.status_message = MagicMock()
|
||||
dummy_scan.status_message.info = {
|
||||
"readout_priority": {"monitored": ["bpm4i"], "async": ["async_device"]},
|
||||
"scan_report_devices": ["samx"],
|
||||
}
|
||||
return dummy_scan
|
||||
|
||||
@@ -30,7 +30,7 @@ def test_bec_connector_init_with_gui_id(mocked_client):
|
||||
|
||||
|
||||
def test_bec_connector_set_gui_id(bec_connector):
|
||||
bec_connector.set_gui_id("test_gui_id")
|
||||
bec_connector._set_gui_id("test_gui_id")
|
||||
assert bec_connector.config.gui_id == "test_gui_id"
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ def test_bec_connector_change_config(bec_connector):
|
||||
|
||||
|
||||
def test_bec_connector_get_obj_by_id(bec_connector):
|
||||
bec_connector.set_gui_id("test_gui_id")
|
||||
bec_connector._set_gui_id("test_gui_id")
|
||||
assert bec_connector.get_obj_by_id("test_gui_id") == bec_connector
|
||||
assert bec_connector.get_obj_by_id("test_gui_id_2") is None
|
||||
|
||||
|
||||
@@ -28,9 +28,9 @@ def test_bec_dock_area_add_remove_dock(bec_dock_area, qtbot):
|
||||
initial_count = len(bec_dock_area.dock_area.docks)
|
||||
|
||||
# Adding 3 docks
|
||||
d0 = bec_dock_area.add_dock()
|
||||
d1 = bec_dock_area.add_dock()
|
||||
d2 = bec_dock_area.add_dock()
|
||||
d0 = bec_dock_area.new()
|
||||
d1 = bec_dock_area.new()
|
||||
d2 = bec_dock_area.new()
|
||||
|
||||
# Check if the docks were added
|
||||
assert len(bec_dock_area.dock_area.docks) == initial_count + 3
|
||||
@@ -46,7 +46,7 @@ def test_bec_dock_area_add_remove_dock(bec_dock_area, qtbot):
|
||||
|
||||
# Remove docks
|
||||
d0_name = d0.name()
|
||||
bec_dock_area.remove_dock(d0_name)
|
||||
bec_dock_area.delete(d0_name)
|
||||
qtbot.wait(200)
|
||||
d1.remove()
|
||||
qtbot.wait(200)
|
||||
@@ -58,16 +58,16 @@ def test_bec_dock_area_add_remove_dock(bec_dock_area, qtbot):
|
||||
|
||||
|
||||
def test_add_remove_bec_figure_to_dock(bec_dock_area):
|
||||
d0 = bec_dock_area.add_dock()
|
||||
fig = d0.add_widget("BECFigure")
|
||||
d0 = bec_dock_area.new()
|
||||
fig = d0.new("BECFigure")
|
||||
plt = fig.plot(x_name="samx", y_name="bpm4i")
|
||||
im = fig.image("eiger")
|
||||
mm = fig.motor_map("samx", "samy")
|
||||
mw = fig.multi_waveform("waveform1d")
|
||||
|
||||
assert len(bec_dock_area.dock_area.docks) == 1
|
||||
assert len(d0.widgets) == 1
|
||||
assert len(d0.widget_list) == 1
|
||||
assert len(d0.elements) == 1
|
||||
assert len(d0.element_list) == 1
|
||||
assert len(fig.widgets) == 4
|
||||
|
||||
assert fig.config.widget_class == "BECFigure"
|
||||
@@ -78,20 +78,20 @@ def test_add_remove_bec_figure_to_dock(bec_dock_area):
|
||||
|
||||
|
||||
def test_close_docks(bec_dock_area, qtbot):
|
||||
d0 = bec_dock_area.add_dock(name="dock_0")
|
||||
d1 = bec_dock_area.add_dock(name="dock_1")
|
||||
d2 = bec_dock_area.add_dock(name="dock_2")
|
||||
d0 = bec_dock_area.new(name="dock_0")
|
||||
d1 = bec_dock_area.new(name="dock_1")
|
||||
d2 = bec_dock_area.new(name="dock_2")
|
||||
|
||||
bec_dock_area.clear_all()
|
||||
bec_dock_area.delete_all()
|
||||
qtbot.wait(200)
|
||||
assert len(bec_dock_area.dock_area.docks) == 0
|
||||
|
||||
|
||||
def test_undock_and_dock_docks(bec_dock_area, qtbot):
|
||||
d0 = bec_dock_area.add_dock(name="dock_0")
|
||||
d1 = bec_dock_area.add_dock(name="dock_1")
|
||||
d2 = bec_dock_area.add_dock(name="dock_4")
|
||||
d3 = bec_dock_area.add_dock(name="dock_3")
|
||||
d0 = bec_dock_area.new(name="dock_0")
|
||||
d1 = bec_dock_area.new(name="dock_1")
|
||||
d2 = bec_dock_area.new(name="dock_4")
|
||||
d3 = bec_dock_area.new(name="dock_3")
|
||||
|
||||
d0.detach()
|
||||
bec_dock_area.detach_dock("dock_1")
|
||||
@@ -114,28 +114,47 @@ def test_undock_and_dock_docks(bec_dock_area, qtbot):
|
||||
###################################
|
||||
def test_toolbar_add_plot_waveform(bec_dock_area):
|
||||
bec_dock_area.toolbar.widgets["menu_plots"].widgets["waveform"].trigger()
|
||||
assert "waveform_1" in bec_dock_area.panels
|
||||
assert bec_dock_area.panels["waveform_1"].widgets[0].config.widget_class == "BECWaveformWidget"
|
||||
assert "Waveform_0" in bec_dock_area.panels
|
||||
assert bec_dock_area.panels["Waveform_0"].widgets[0].config.widget_class == "Waveform"
|
||||
|
||||
|
||||
def test_toolbar_add_plot_scatter_waveform(bec_dock_area):
|
||||
bec_dock_area.toolbar.widgets["menu_plots"].widgets["scatter_waveform"].trigger()
|
||||
assert "ScatterWaveform_0" in bec_dock_area.panels
|
||||
assert (
|
||||
bec_dock_area.panels["ScatterWaveform_0"].widgets[0].config.widget_class
|
||||
== "ScatterWaveform"
|
||||
)
|
||||
|
||||
|
||||
def test_toolbar_add_plot_image(bec_dock_area):
|
||||
bec_dock_area.toolbar.widgets["menu_plots"].widgets["image"].trigger()
|
||||
assert "image_1" in bec_dock_area.panels
|
||||
assert bec_dock_area.panels["image_1"].widgets[0].config.widget_class == "BECImageWidget"
|
||||
assert "Image_0" in bec_dock_area.panels
|
||||
assert bec_dock_area.panels["Image_0"].widgets[0].config.widget_class == "Image"
|
||||
|
||||
|
||||
def test_toolbar_add_plot_motor_map(bec_dock_area):
|
||||
bec_dock_area.toolbar.widgets["menu_plots"].widgets["motor_map"].trigger()
|
||||
assert "motor_map_1" in bec_dock_area.panels
|
||||
assert bec_dock_area.panels["motor_map_1"].widgets[0].config.widget_class == "BECMotorMapWidget"
|
||||
assert "BECMotorMapWidget_0" in bec_dock_area.panels
|
||||
assert (
|
||||
bec_dock_area.panels["BECMotorMapWidget_0"].widgets[0].config.widget_class
|
||||
== "BECMotorMapWidget"
|
||||
)
|
||||
|
||||
|
||||
def test_toolbar_add_multi_waveform(bec_dock_area):
|
||||
bec_dock_area.toolbar.widgets["menu_plots"].widgets["multi_waveform"].trigger()
|
||||
assert "BECMultiWaveformWidget_0" in bec_dock_area.panels
|
||||
assert (
|
||||
bec_dock_area.panels["BECMultiWaveformWidget_0"].widgets[0].config.widget_class
|
||||
== "BECMultiWaveformWidget"
|
||||
)
|
||||
|
||||
|
||||
def test_toolbar_add_device_positioner_box(bec_dock_area):
|
||||
bec_dock_area.toolbar.widgets["menu_devices"].widgets["positioner_box"].trigger()
|
||||
assert "positioner_box_1" in bec_dock_area.panels
|
||||
assert (
|
||||
bec_dock_area.panels["positioner_box_1"].widgets[0].config.widget_class == "PositionerBox"
|
||||
)
|
||||
assert "PositionerBox_0" in bec_dock_area.panels
|
||||
assert bec_dock_area.panels["PositionerBox_0"].widgets[0].config.widget_class == "PositionerBox"
|
||||
|
||||
|
||||
def test_toolbar_add_utils_queue(bec_dock_area, bec_queue_msg_full):
|
||||
@@ -143,19 +162,20 @@ def test_toolbar_add_utils_queue(bec_dock_area, bec_queue_msg_full):
|
||||
MessageEndpoints.scan_queue_status(), bec_queue_msg_full
|
||||
)
|
||||
bec_dock_area.toolbar.widgets["menu_utils"].widgets["queue"].trigger()
|
||||
assert "queue_1" in bec_dock_area.panels
|
||||
assert bec_dock_area.panels["queue_1"].widgets[0].config.widget_class == "BECQueue"
|
||||
assert "BECQueue_0" in bec_dock_area.panels
|
||||
assert bec_dock_area.panels["BECQueue_0"].widgets[0].config.widget_class == "BECQueue"
|
||||
|
||||
|
||||
def test_toolbar_add_utils_status(bec_dock_area):
|
||||
bec_dock_area.toolbar.widgets["menu_utils"].widgets["status"].trigger()
|
||||
assert "status_1" in bec_dock_area.panels
|
||||
assert bec_dock_area.panels["status_1"].widgets[0].config.widget_class == "BECStatusBox"
|
||||
assert "BECStatusBox_0" in bec_dock_area.panels
|
||||
assert bec_dock_area.panels["BECStatusBox_0"].widgets[0].config.widget_class == "BECStatusBox"
|
||||
|
||||
|
||||
def test_toolbar_add_utils_progress_bar(bec_dock_area):
|
||||
bec_dock_area.toolbar.widgets["menu_utils"].widgets["progress_bar"].trigger()
|
||||
assert "progress_bar_1" in bec_dock_area.panels
|
||||
assert "RingProgressBar_0" in bec_dock_area.panels
|
||||
assert (
|
||||
bec_dock_area.panels["progress_bar_1"].widgets[0].config.widget_class == "RingProgressBar"
|
||||
bec_dock_area.panels["RingProgressBar_0"].widgets[0].config.widget_class
|
||||
== "RingProgressBar"
|
||||
)
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pyqtgraph as pg
|
||||
import pytest
|
||||
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.plots.image.image_widget import BECImageWidget
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def image_widget(qtbot, mocked_client):
|
||||
widget = BECImageWidget(client=mocked_client())
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_image(image_widget):
|
||||
image_mock = MagicMock()
|
||||
image_widget._image = image_mock
|
||||
return image_mock
|
||||
|
||||
|
||||
def test_image_widget_init(image_widget):
|
||||
assert image_widget is not None
|
||||
assert image_widget.client is not None
|
||||
assert isinstance(image_widget, BECImageWidget)
|
||||
assert image_widget.config.widget_class == "BECImageWidget"
|
||||
assert image_widget._image is not None
|
||||
|
||||
assert (
|
||||
BECDeviceFilter.DEVICE
|
||||
in image_widget.toolbar.widgets["monitor"].device_combobox.config.device_filter
|
||||
)
|
||||
assert image_widget.toolbar.widgets["drag_mode"].action.isChecked() == True
|
||||
assert image_widget.toolbar.widgets["rectangle_mode"].action.isChecked() == False
|
||||
assert image_widget.toolbar.widgets["auto_range"].action.isChecked() == False
|
||||
assert image_widget.toolbar.widgets["auto_range_image"].action.isChecked() == True
|
||||
assert image_widget.toolbar.widgets["FFT"].action.isChecked() == False
|
||||
assert image_widget.toolbar.widgets["transpose"].action.isChecked() == False
|
||||
assert image_widget.toolbar.widgets["log"].action.isChecked() == False
|
||||
|
||||
|
||||
###################################
|
||||
# Toolbar Actions
|
||||
###################################
|
||||
def test_toolbar_connect_action(image_widget, mock_image, qtbot):
|
||||
combo_device = image_widget.toolbar.widgets["monitor"].device_combobox
|
||||
combo_device.setCurrentText("eiger")
|
||||
qtbot.wait(200)
|
||||
assert combo_device.currentText() == "eiger"
|
||||
combo_dim = image_widget.toolbar.widgets["monitor_type"].widget
|
||||
combo_dim.setCurrentText("2d")
|
||||
qtbot.wait(200)
|
||||
assert combo_dim.currentText() == "2d"
|
||||
action = image_widget.toolbar.widgets["connect"].action
|
||||
action.trigger()
|
||||
image_widget._image.image.assert_called_once_with(
|
||||
monitor="eiger",
|
||||
monitor_type="2d",
|
||||
color_map="magma",
|
||||
color_bar="full",
|
||||
downsample=True,
|
||||
opacity=1.0,
|
||||
vrange=None,
|
||||
)
|
||||
|
||||
|
||||
def test_image_toolbar_drag_mode_action_triggered(image_widget, qtbot):
|
||||
action_drag = image_widget.toolbar.widgets["drag_mode"].action
|
||||
action_rectangle = image_widget.toolbar.widgets["rectangle_mode"].action
|
||||
action_drag.trigger()
|
||||
assert action_drag.isChecked() == True
|
||||
assert action_rectangle.isChecked() == False
|
||||
|
||||
|
||||
def test_image_toolbar_rectangle_mode_action_triggered(image_widget, qtbot):
|
||||
action_drag = image_widget.toolbar.widgets["drag_mode"].action
|
||||
action_rectangle = image_widget.toolbar.widgets["rectangle_mode"].action
|
||||
action_rectangle.trigger()
|
||||
assert action_drag.isChecked() == False
|
||||
assert action_rectangle.isChecked() == True
|
||||
|
||||
|
||||
def test_image_toolbar_auto_range(image_widget, mock_image):
|
||||
action = image_widget.toolbar.widgets["auto_range"].action
|
||||
action.trigger()
|
||||
image_widget._image.set_auto_range.assert_called_once_with(True, "xy")
|
||||
|
||||
|
||||
def test_image_toolbar_enable_mouse_pan_mode(qtbot, image_widget):
|
||||
action_drag = image_widget.toolbar.widgets["drag_mode"].action
|
||||
action_rectangle = image_widget.toolbar.widgets["rectangle_mode"].action
|
||||
|
||||
mock_view_box = MagicMock()
|
||||
image_widget._image.plot_item.getViewBox = MagicMock(return_value=mock_view_box)
|
||||
|
||||
image_widget.enable_mouse_pan_mode()
|
||||
|
||||
assert action_drag.isChecked() == True
|
||||
assert action_rectangle.isChecked() == False
|
||||
mock_view_box.setMouseMode.assert_called_once_with(pg.ViewBox.PanMode)
|
||||
|
||||
|
||||
def test_image_toolbar_auto_range_image(image_widget, mock_image):
|
||||
action = image_widget.toolbar.widgets["auto_range_image"].action
|
||||
action.trigger()
|
||||
assert action.isChecked() == False
|
||||
image_widget._image.set_autorange.assert_called_once_with(False)
|
||||
|
||||
|
||||
def test_image_toolbar_FFT(image_widget, mock_image):
|
||||
action = image_widget.toolbar.widgets["FFT"].action
|
||||
action.trigger()
|
||||
assert action.isChecked() == True
|
||||
image_widget._image.set_fft.assert_called_once_with(True, None)
|
||||
|
||||
|
||||
def test_image_toolbar_log(image_widget, mock_image):
|
||||
action = image_widget.toolbar.widgets["log"].action
|
||||
action.trigger()
|
||||
assert action.isChecked() == True
|
||||
image_widget._image.set_log.assert_called_once_with(True, None)
|
||||
|
||||
|
||||
def test_image_toggle_transpose(image_widget, mock_image):
|
||||
action = image_widget.toolbar.widgets["transpose"].action
|
||||
action.trigger()
|
||||
assert action.isChecked() == True
|
||||
image_widget._image.set_transpose.assert_called_once_with(True, None)
|
||||
|
||||
|
||||
def test_image_toolbar_rotation(image_widget, mock_image):
|
||||
action_left = image_widget.toolbar.widgets["rotate_left"].action
|
||||
action_right = image_widget.toolbar.widgets["rotate_right"].action
|
||||
|
||||
action_left.trigger()
|
||||
image_widget._image.set_rotation(1, None)
|
||||
action_right.trigger()
|
||||
image_widget._image.set_rotation(2, None)
|
||||
|
||||
action_right.trigger()
|
||||
image_widget._image.set_rotation(1, None)
|
||||
|
||||
|
||||
###################################
|
||||
# Wrapper methods for ImageShow
|
||||
###################################
|
||||
|
||||
|
||||
def test_image_set_image(image_widget, mock_image):
|
||||
image_widget.image(monitor="image", monitor_type="2d")
|
||||
image_widget._image.image.assert_called_once_with(
|
||||
monitor="image",
|
||||
monitor_type="2d",
|
||||
color_map="magma",
|
||||
color_bar="full",
|
||||
downsample=True,
|
||||
opacity=1.0,
|
||||
vrange=None,
|
||||
)
|
||||
|
||||
|
||||
def test_image_vrange(image_widget, mock_image):
|
||||
image_widget.set_vrange(0, 1)
|
||||
image_widget._image.set_vrange.assert_called_once_with(0, 1, None)
|
||||
|
||||
|
||||
def test_image_set_color_map(image_widget, mock_image):
|
||||
image_widget.set_color_map("viridis")
|
||||
image_widget._image.set_color_map.assert_called_once_with("viridis", None)
|
||||
|
||||
|
||||
def test_image_widget_set_title(image_widget, mock_image):
|
||||
image_widget.set_title("Title Label")
|
||||
image_widget._image.set_title.assert_called_once_with("Title Label")
|
||||
|
||||
|
||||
def test_image_widget_set_x_label(image_widget, mock_image):
|
||||
image_widget.set_x_label("X Label")
|
||||
image_widget._image.set_x_label.assert_called_once_with("X Label")
|
||||
|
||||
|
||||
def test_image_widget_set_y_label(image_widget, mock_image):
|
||||
image_widget.set_y_label("Y Label")
|
||||
image_widget._image.set_y_label.assert_called_once_with("Y Label")
|
||||
|
||||
|
||||
def test_image_widget_set_x_scale(image_widget, mock_image):
|
||||
image_widget.set_x_scale("linear")
|
||||
image_widget._image.set_x_scale.assert_called_once_with("linear")
|
||||
|
||||
|
||||
def test_image_widget_set_y_scale(image_widget, mock_image):
|
||||
image_widget.set_y_scale("log")
|
||||
image_widget._image.set_y_scale.assert_called_once_with("log")
|
||||
|
||||
|
||||
def test_image_widget_set_x_lim(image_widget, mock_image):
|
||||
image_widget.set_x_lim((0, 10))
|
||||
image_widget._image.set_x_lim.assert_called_once_with((0, 10))
|
||||
|
||||
|
||||
def test_image_widget_set_y_lim(image_widget, mock_image):
|
||||
image_widget.set_y_lim((0, 10))
|
||||
image_widget._image.set_y_lim.assert_called_once_with((0, 10))
|
||||
|
||||
|
||||
def test_image_widget_set_grid(image_widget, mock_image):
|
||||
image_widget.set_grid(True, False)
|
||||
image_widget._image.set_grid.assert_called_once_with(True, False)
|
||||
|
||||
|
||||
def test_image_widget_lock_aspect_ratio(image_widget, mock_image):
|
||||
image_widget.lock_aspect_ratio(True)
|
||||
image_widget._image.lock_aspect_ratio.assert_called_once_with(True)
|
||||
|
||||
|
||||
def test_image_widget_export(image_widget, mock_image):
|
||||
image_widget.export()
|
||||
image_widget._image.export.assert_called_once()
|
||||
@@ -12,7 +12,7 @@ from bec_widgets.tests.utils import FakeDevice
|
||||
def cli_figure():
|
||||
fig = BECFigure(gui_id="test")
|
||||
with mock.patch.object(fig, "_run_rpc") as mock_rpc_call:
|
||||
with mock.patch.object(fig, "gui_is_alive", return_value=True):
|
||||
with mock.patch.object(fig, "_gui_is_alive", return_value=True):
|
||||
yield fig, mock_rpc_call
|
||||
|
||||
|
||||
@@ -40,8 +40,17 @@ def test_rpc_call_accepts_device_as_input(cli_figure):
|
||||
)
|
||||
def test_client_utils_start_plot_process(config, call_config):
|
||||
with mock.patch("bec_widgets.cli.client_utils.subprocess.Popen") as mock_popen:
|
||||
_start_plot_process("gui_id", BECFigure, config)
|
||||
command = ["bec-gui-server", "--id", "gui_id", "--gui_class", "BECFigure", "--hide"]
|
||||
_start_plot_process("gui_id", BECFigure, "bec", config)
|
||||
command = [
|
||||
"bec-gui-server",
|
||||
"--id",
|
||||
"gui_id",
|
||||
"--gui_class",
|
||||
"BECFigure",
|
||||
"--gui_class_id",
|
||||
"bec",
|
||||
"--hide",
|
||||
]
|
||||
if call_config:
|
||||
command.extend(["--config", call_config])
|
||||
mock_popen.assert_called_once_with(
|
||||
@@ -66,20 +75,24 @@ def test_client_utils_passes_client_config_to_server(bec_dispatcher):
|
||||
mixin = BECGuiClient()
|
||||
mixin._client = bec_dispatcher.client
|
||||
mixin._gui_id = "gui_id"
|
||||
mixin.gui_is_alive = mock.MagicMock()
|
||||
mixin.gui_is_alive.side_effect = [True]
|
||||
mixin._gui_is_alive = mock.MagicMock()
|
||||
mixin._gui_is_alive.side_effect = [True]
|
||||
|
||||
try:
|
||||
yield mixin
|
||||
finally:
|
||||
mixin.close()
|
||||
mixin.kill_server()
|
||||
|
||||
with bec_client_mixin() as mixin:
|
||||
with mock.patch("bec_widgets.cli.client_utils._start_plot_process") as mock_start_plot:
|
||||
mock_start_plot.return_value = [mock.MagicMock(), mock.MagicMock()]
|
||||
mixin.start_server(
|
||||
mixin._start_server(
|
||||
wait=False
|
||||
) # the started event will not be set, wait=True would block forever
|
||||
mock_start_plot.assert_called_once_with(
|
||||
"gui_id", BECGuiClient, mixin._client._service_config.config, logger=mock.ANY
|
||||
"gui_id",
|
||||
BECGuiClient,
|
||||
gui_class_id="bec",
|
||||
config=mixin._client._service_config.config,
|
||||
logger=mock.ANY,
|
||||
)
|
||||
|
||||
@@ -1,49 +1,54 @@
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
import pytest
|
||||
from qtpy.QtCore import QPointF, Qt
|
||||
|
||||
from bec_widgets.widgets.plots.image.image_widget import BECImageWidget
|
||||
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
from bec_widgets.utils import Crosshair
|
||||
|
||||
# pylint: disable = redefined-outer-name
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plot_widget_with_crosshair(qtbot, mocked_client):
|
||||
widget = BECWaveformWidget(client=mocked_client())
|
||||
widget.plot(x=[1, 2, 3], y=[4, 5, 6])
|
||||
widget.waveform.hook_crosshair()
|
||||
def plot_widget_with_crosshair(qtbot):
|
||||
widget = pg.PlotWidget()
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
|
||||
yield widget.waveform.crosshair, widget.waveform.plot_item
|
||||
widget.plot(x=[1, 2, 3], y=[4, 5, 6], name="Curve 1")
|
||||
plot_item = widget.getPlotItem()
|
||||
crosshair = Crosshair(plot_item=plot_item, precision=3)
|
||||
|
||||
yield crosshair, plot_item
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def image_widget_with_crosshair(qtbot, mocked_client):
|
||||
widget = BECImageWidget(client=mocked_client())
|
||||
widget._image.add_custom_image(name="test", data=np.random.random((100, 200)))
|
||||
widget._image.hook_crosshair()
|
||||
def image_widget_with_crosshair(qtbot):
|
||||
widget = pg.PlotWidget()
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
|
||||
yield widget._image.crosshair, widget._image.plot_item
|
||||
image_item = pg.ImageItem()
|
||||
image_item.setImage(np.random.rand(100, 100))
|
||||
image_item.config = type("obj", (object,), {"monitor": "test"})
|
||||
|
||||
widget.addItem(image_item)
|
||||
plot_item = widget.getPlotItem()
|
||||
crosshair = Crosshair(plot_item=plot_item, precision=3)
|
||||
|
||||
yield crosshair, plot_item
|
||||
|
||||
|
||||
def test_mouse_moved_lines(plot_widget_with_crosshair):
|
||||
crosshair, plot_item = plot_widget_with_crosshair
|
||||
|
||||
# Simulate a mouse moved event at a specific position
|
||||
pos_in_view = QPointF(2, 5)
|
||||
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
|
||||
event_mock = [pos_in_scene]
|
||||
|
||||
# Call the mouse_moved method
|
||||
# Simulate mouse movement
|
||||
crosshair.mouse_moved(event_mock)
|
||||
|
||||
# Assert the expected behavior
|
||||
# Check that the vertical line is indeed at x=2
|
||||
assert np.isclose(crosshair.v_line.pos().x(), 2)
|
||||
assert np.isclose(crosshair.h_line.pos().y(), 5)
|
||||
|
||||
@@ -102,13 +107,13 @@ def test_mouse_moved_signals_2D(image_widget_with_crosshair):
|
||||
|
||||
crosshair.coordinatesChanged2D.connect(slot)
|
||||
|
||||
pos_in_view = QPointF(22.0, 55.0)
|
||||
pos_in_view = QPointF(21.0, 55.0)
|
||||
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
|
||||
event_mock = [pos_in_scene]
|
||||
|
||||
crosshair.mouse_moved(event_mock)
|
||||
|
||||
assert emitted_values_2D == [("test", 22.0, 55.0)]
|
||||
assert emitted_values_2D == [("test", 21, 55)]
|
||||
|
||||
|
||||
def test_mouse_moved_signals_2D_outside(image_widget_with_crosshair):
|
||||
@@ -224,3 +229,41 @@ def test_crosshair_clicked_signal(qtbot, plot_widget_with_crosshair):
|
||||
|
||||
assert np.isclose(round(x, 1), 2)
|
||||
assert np.isclose(round(y, 1), 5)
|
||||
|
||||
|
||||
def test_update_coord_label_1D(plot_widget_with_crosshair):
|
||||
crosshair, _ = plot_widget_with_crosshair
|
||||
# Provide a test position
|
||||
pos = (10, 20)
|
||||
crosshair.update_coord_label(pos)
|
||||
expected_text = f"({10:.3g}, {20:.3g})"
|
||||
# Verify that the coordinate label shows only the 1D coordinates (no intensity line)
|
||||
assert crosshair.coord_label.toPlainText() == expected_text
|
||||
label_pos = crosshair.coord_label.pos()
|
||||
assert np.isclose(label_pos.x(), 10)
|
||||
assert np.isclose(label_pos.y(), 20)
|
||||
assert crosshair.coord_label.isVisible()
|
||||
|
||||
|
||||
def test_update_coord_label_2D(image_widget_with_crosshair):
|
||||
crosshair, plot_item = image_widget_with_crosshair
|
||||
|
||||
known_image = np.array([[10, 20], [30, 40]], dtype=float)
|
||||
|
||||
for item in plot_item.items:
|
||||
if isinstance(item, pg.ImageItem):
|
||||
item.setImage(known_image)
|
||||
|
||||
pos = (0.5, 1.2)
|
||||
crosshair.update_coord_label(pos)
|
||||
|
||||
ix = int(np.clip(0.5, 0, known_image.shape[0] - 1)) # 0
|
||||
iy = int(np.clip(1.2, 0, known_image.shape[1] - 1)) # 1
|
||||
intensity = known_image[ix, iy] # Expected: 20
|
||||
expected_text = f"({0.5:.3g}, {1.2:.3g})\nIntensity: {intensity:.3g}"
|
||||
|
||||
assert crosshair.coord_label.toPlainText() == expected_text
|
||||
label_pos = crosshair.coord_label.pos()
|
||||
assert np.isclose(label_pos.x(), 0.5)
|
||||
assert np.isclose(label_pos.y(), 1.2)
|
||||
assert crosshair.coord_label.isVisible()
|
||||
|
||||
367
tests/unit_tests/test_curve_settings.py
Normal file
367
tests/unit_tests/test_curve_settings.py
Normal file
@@ -0,0 +1,367 @@
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from qtpy.QtWidgets import QComboBox, QVBoxLayout
|
||||
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.settings.curve_settings.curve_setting import (
|
||||
CurveSetting,
|
||||
)
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.settings.curve_settings.curve_tree import CurveTree
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
|
||||
from tests.unit_tests.client_mocks import dap_plugin_message, mocked_client, mocked_client_with_dap
|
||||
from tests.unit_tests.conftest import create_widget
|
||||
|
||||
##################################################
|
||||
# CurveSetting
|
||||
##################################################
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def curve_setting_fixture(qtbot, mocked_client):
|
||||
"""
|
||||
Creates a CurveSetting widget targeting a mock or real Waveform widget.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.x_mode = "auto"
|
||||
curve_setting = create_widget(qtbot, CurveSetting, parent=None, target_widget=wf)
|
||||
return curve_setting, wf
|
||||
|
||||
|
||||
def test_curve_setting_init(curve_setting_fixture):
|
||||
"""
|
||||
Ensure CurveSetting constructs properly, with a CurveTree inside
|
||||
and an x-axis group box for modes.
|
||||
"""
|
||||
curve_setting, wf = curve_setting_fixture
|
||||
|
||||
# Basic checks
|
||||
assert curve_setting.objectName() == "CurveSetting"
|
||||
# The layout should be QVBoxLayout
|
||||
assert isinstance(curve_setting.layout, QVBoxLayout)
|
||||
|
||||
# There's an x_axis_box group and a y_axis_box group
|
||||
assert hasattr(curve_setting, "x_axis_box")
|
||||
assert hasattr(curve_setting, "y_axis_box")
|
||||
|
||||
# The x_axis_box should contain a QComboBox for mode
|
||||
mode_combo = curve_setting.mode_combo
|
||||
assert isinstance(mode_combo, QComboBox)
|
||||
# Should contain these items: ["auto", "index", "timestamp", "device"]
|
||||
expected_modes = ["auto", "index", "timestamp", "device"]
|
||||
for m in expected_modes:
|
||||
assert m in [
|
||||
curve_setting.mode_combo.itemText(i) for i in range(curve_setting.mode_combo.count())
|
||||
]
|
||||
|
||||
# Check that there's a curve_manager inside y_axis_box
|
||||
assert hasattr(curve_setting, "curve_manager")
|
||||
assert curve_setting.y_axis_box.layout.count() > 0
|
||||
|
||||
|
||||
def test_curve_setting_accept_changes(curve_setting_fixture, qtbot):
|
||||
"""
|
||||
Test that calling accept_changes() applies x-axis mode changes
|
||||
and triggers the CurveTree to send its curve JSON to the target waveform.
|
||||
"""
|
||||
curve_setting, wf = curve_setting_fixture
|
||||
|
||||
# Suppose user chooses "index" from the combo
|
||||
curve_setting.mode_combo.setCurrentText("index")
|
||||
# The device_x is disabled if not device mode
|
||||
|
||||
# Spy on 'send_curve_json' from the curve_manager
|
||||
send_spy = MagicMock()
|
||||
curve_setting.curve_manager.send_curve_json = send_spy
|
||||
|
||||
# Call accept_changes()
|
||||
curve_setting.accept_changes()
|
||||
|
||||
# Check that we updated the waveform
|
||||
assert wf.x_mode == "index"
|
||||
# Check that the manager send_curve_json was called
|
||||
send_spy.assert_called_once()
|
||||
|
||||
|
||||
def test_curve_setting_switch_device_mode(curve_setting_fixture, qtbot):
|
||||
"""
|
||||
If user chooses device mode from the combo, the device_x line edit should be enabled
|
||||
and set to the current wavefrom.x_axis_mode["name"].
|
||||
"""
|
||||
curve_setting, wf = curve_setting_fixture
|
||||
|
||||
# Initially we assume "auto"
|
||||
assert curve_setting.mode_combo.currentText() == "auto"
|
||||
# Switch to device
|
||||
curve_setting.mode_combo.setCurrentText("device")
|
||||
assert curve_setting.device_x.isEnabled()
|
||||
|
||||
# This line edit should reflect the waveform.x_axis_mode["name"], or be blank if none
|
||||
assert curve_setting.device_x.text() == wf.x_axis_mode["name"]
|
||||
|
||||
|
||||
def test_curve_setting_refresh(curve_setting_fixture, qtbot):
|
||||
"""
|
||||
Test that calling refresh() refreshes the embedded CurveTree
|
||||
and re-reads the x axis mode from the waveform.
|
||||
"""
|
||||
curve_setting, wf = curve_setting_fixture
|
||||
|
||||
# Suppose the waveform changed x_mode from "auto" to "timestamp" behind the scenes
|
||||
wf.x_mode = "timestamp"
|
||||
# Spy on the curve_manager
|
||||
refresh_spy = MagicMock()
|
||||
curve_setting.curve_manager.refresh_from_waveform = refresh_spy
|
||||
|
||||
# Call refresh
|
||||
curve_setting.refresh()
|
||||
|
||||
refresh_spy.assert_called_once()
|
||||
# The combo should now read "timestamp"
|
||||
assert curve_setting.mode_combo.currentText() == "timestamp"
|
||||
|
||||
|
||||
##################################################
|
||||
# CurveTree
|
||||
##################################################
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def curve_tree_fixture(qtbot, mocked_client_with_dap):
|
||||
"""
|
||||
Creates a CurveTree widget referencing a mocked or real Waveform.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
wf.color_palette = "magma"
|
||||
curve_tree = create_widget(qtbot, CurveTree, parent=None, waveform=wf)
|
||||
return curve_tree, wf
|
||||
|
||||
|
||||
def test_curve_tree_init(curve_tree_fixture):
|
||||
"""
|
||||
Test that the CurveTree initializes properly with references to the waveform,
|
||||
sets up the toolbar, and an empty QTreeWidget.
|
||||
"""
|
||||
curve_tree, wf = curve_tree_fixture
|
||||
assert curve_tree.waveform == wf
|
||||
assert curve_tree.color_palette == "magma"
|
||||
assert curve_tree.tree.columnCount() == 7
|
||||
|
||||
assert "add" in curve_tree.toolbar.widgets
|
||||
assert "expand_all" in curve_tree.toolbar.widgets
|
||||
assert "collapse_all" in curve_tree.toolbar.widgets
|
||||
assert "renormalize_colors" in curve_tree.toolbar.widgets
|
||||
|
||||
|
||||
def test_add_new_curve(curve_tree_fixture):
|
||||
"""
|
||||
Test that add_new_curve() adds a top-level item with a device curve config,
|
||||
assigns it a color from the buffer, and doesn't modify existing rows.
|
||||
"""
|
||||
curve_tree, wf = curve_tree_fixture
|
||||
curve_tree.color_buffer = ["#111111", "#222222", "#333333", "#444444", "#555555"]
|
||||
|
||||
assert curve_tree.tree.topLevelItemCount() == 0
|
||||
|
||||
with patch.object(curve_tree, "_ensure_color_buffer_size") as ensure_spy:
|
||||
new_item = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
|
||||
ensure_spy.assert_called_once()
|
||||
|
||||
assert curve_tree.tree.topLevelItemCount() == 1
|
||||
last_item = curve_tree.all_items[-1]
|
||||
assert last_item is new_item
|
||||
assert new_item.config.source == "device"
|
||||
assert new_item.config.signal.name == "bpm4i"
|
||||
assert new_item.config.signal.entry == "bpm4i"
|
||||
assert new_item.config.color in curve_tree.color_buffer
|
||||
|
||||
|
||||
def test_renormalize_colors(curve_tree_fixture):
|
||||
"""
|
||||
Test that renormalize_colors overwrites colors for all items in creation order.
|
||||
"""
|
||||
curve_tree, wf = curve_tree_fixture
|
||||
# Add multiple curves
|
||||
c1 = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
|
||||
c2 = curve_tree.add_new_curve(name="bpm3a", entry="bpm3a")
|
||||
curve_tree.color_buffer = []
|
||||
|
||||
set_color_spy_c1 = patch.object(c1.color_button, "set_color")
|
||||
set_color_spy_c2 = patch.object(c2.color_button, "set_color")
|
||||
|
||||
with set_color_spy_c1 as spy1, set_color_spy_c2 as spy2:
|
||||
curve_tree.renormalize_colors()
|
||||
spy1.assert_called_once()
|
||||
spy2.assert_called_once()
|
||||
|
||||
|
||||
def test_expand_collapse(curve_tree_fixture):
|
||||
"""
|
||||
Test expand_all_daps() and collapse_all_daps() calls expand/collapse on every top-level item.
|
||||
"""
|
||||
curve_tree, wf = curve_tree_fixture
|
||||
c1 = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
|
||||
curve_tree.tree.expandAll()
|
||||
expand_spy = patch.object(curve_tree.tree, "expandItem")
|
||||
collapse_spy = patch.object(curve_tree.tree, "collapseItem")
|
||||
|
||||
with expand_spy as e_spy:
|
||||
curve_tree.expand_all_daps()
|
||||
e_spy.assert_called_once_with(c1)
|
||||
|
||||
with collapse_spy as c_spy:
|
||||
curve_tree.collapse_all_daps()
|
||||
c_spy.assert_called_once_with(c1)
|
||||
|
||||
|
||||
def test_send_curve_json(curve_tree_fixture, monkeypatch):
|
||||
"""
|
||||
Test that send_curve_json sets the waveform's color_palette and curve_json
|
||||
to the exported config from the tree.
|
||||
"""
|
||||
curve_tree, wf = curve_tree_fixture
|
||||
# Add multiple curves
|
||||
curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
|
||||
curve_tree.add_new_curve(name="bpm3a", entry="bpm3a")
|
||||
|
||||
curve_tree.color_palette = "viridis"
|
||||
curve_tree.send_curve_json()
|
||||
|
||||
assert wf.color_palette == "viridis"
|
||||
data = json.loads(wf.curve_json)
|
||||
assert len(data) == 2
|
||||
labels = [d["label"] for d in data]
|
||||
assert "bpm4i-bpm4i" in labels
|
||||
assert "bpm3a-bpm3a" in labels
|
||||
|
||||
|
||||
def test_refresh_from_waveform(qtbot, mocked_client_with_dap, monkeypatch):
|
||||
"""
|
||||
Test that refresh_from_waveform() rebuilds the tree from the waveform's curve_json
|
||||
"""
|
||||
patched_models = {"GaussianModel": {}, "LorentzModel": {}, "SineModel": {}}
|
||||
monkeypatch.setattr(mocked_client_with_dap.dap, "_available_dap_plugins", patched_models)
|
||||
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
wf.x_mode = "auto"
|
||||
curve_tree = create_widget(qtbot, CurveTree, parent=None, waveform=wf)
|
||||
|
||||
wf.plot(arg1="bpm4i", dap="GaussianModel")
|
||||
wf.plot(arg1="bpm3a", dap="GaussianModel")
|
||||
|
||||
# Clear the tree to simulate a fresh rebuild.
|
||||
curve_tree.tree.clear()
|
||||
curve_tree.all_items.clear()
|
||||
assert curve_tree.tree.topLevelItemCount() == 0
|
||||
|
||||
# For DAP rows
|
||||
curve_tree.refresh_from_waveform()
|
||||
assert curve_tree.tree.topLevelItemCount() == 2
|
||||
|
||||
|
||||
def test_add_dap_row(curve_tree_fixture):
|
||||
"""
|
||||
Test that add_dap_row creates a new DAP curve as a child of a device curve,
|
||||
with the correct configuration and parent-child relationship.
|
||||
"""
|
||||
curve_tree, wf = curve_tree_fixture
|
||||
|
||||
# Add a device curve first
|
||||
device_row = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
|
||||
assert device_row.source == "device"
|
||||
assert curve_tree.tree.topLevelItemCount() == 1
|
||||
assert device_row.childCount() == 0
|
||||
|
||||
# Now add a DAP row to it
|
||||
device_row.add_dap_row()
|
||||
|
||||
# Check that child was added
|
||||
assert device_row.childCount() == 1
|
||||
dap_child = device_row.child(0)
|
||||
|
||||
# Verify the DAP child has the correct configuration
|
||||
assert dap_child.source == "dap"
|
||||
assert dap_child.config.parent_label == device_row.config.label
|
||||
|
||||
# Check that the DAP inherits device name/entry from parent
|
||||
assert dap_child.config.signal.name == "bpm4i"
|
||||
assert dap_child.config.signal.entry == "bpm4i"
|
||||
|
||||
# Check that the item is in the curve_tree's all_items list
|
||||
assert dap_child in curve_tree.all_items
|
||||
|
||||
|
||||
def test_remove_self_top_level(curve_tree_fixture):
|
||||
"""
|
||||
Test that remove_self removes a top-level device row from the tree.
|
||||
"""
|
||||
curve_tree, wf = curve_tree_fixture
|
||||
|
||||
# Add two device curves
|
||||
row1 = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
|
||||
row2 = curve_tree.add_new_curve(name="bpm3a", entry="bpm3a")
|
||||
assert curve_tree.tree.topLevelItemCount() == 2
|
||||
assert len(curve_tree.all_items) == 2
|
||||
|
||||
# Remove the first row
|
||||
row1.remove_self()
|
||||
|
||||
# Check that only one row remains and it's the correct one
|
||||
assert curve_tree.tree.topLevelItemCount() == 1
|
||||
assert curve_tree.tree.topLevelItem(0) == row2
|
||||
assert len(curve_tree.all_items) == 1
|
||||
assert curve_tree.all_items[0] == row2
|
||||
|
||||
|
||||
def test_remove_self_child(curve_tree_fixture):
|
||||
"""
|
||||
Test that remove_self removes a child DAP row while preserving the parent device row.
|
||||
"""
|
||||
curve_tree, wf = curve_tree_fixture
|
||||
|
||||
# Add a device curve and a DAP child
|
||||
device_row = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
|
||||
device_row.add_dap_row()
|
||||
dap_child = device_row.child(0)
|
||||
|
||||
assert curve_tree.tree.topLevelItemCount() == 1
|
||||
assert device_row.childCount() == 1
|
||||
assert len(curve_tree.all_items) == 2
|
||||
|
||||
# Remove the DAP child
|
||||
dap_child.remove_self()
|
||||
|
||||
# Check that the parent device row still exists but has no children
|
||||
assert curve_tree.tree.topLevelItemCount() == 1
|
||||
assert device_row.childCount() == 0
|
||||
assert len(curve_tree.all_items) == 1
|
||||
assert curve_tree.all_items[0] == device_row
|
||||
|
||||
|
||||
def test_export_data_dap(curve_tree_fixture):
|
||||
"""
|
||||
Test that export_data from a DAP row correctly includes parent relationship and DAP model.
|
||||
"""
|
||||
curve_tree, wf = curve_tree_fixture
|
||||
|
||||
# Add a device curve with specific parameters
|
||||
device_row = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
|
||||
device_row.config.label = "bpm4i-main"
|
||||
|
||||
# Add a DAP child
|
||||
device_row.add_dap_row()
|
||||
dap_child = device_row.child(0)
|
||||
|
||||
# Set a specific model in the DAP combobox
|
||||
dap_child.dap_combo.fit_model_combobox.setCurrentText("GaussianModel")
|
||||
|
||||
# Export data from the DAP row
|
||||
exported = dap_child.export_data()
|
||||
|
||||
# Check the exported data
|
||||
assert exported["source"] == "dap"
|
||||
assert exported["parent_label"] == "bpm4i-main"
|
||||
assert exported["signal"]["name"] == "bpm4i"
|
||||
assert exported["signal"]["entry"] == "bpm4i"
|
||||
assert exported["signal"]["dap"] == "GaussianModel"
|
||||
assert exported["label"] == "bpm4i-main-GaussianModel"
|
||||
331
tests/unit_tests/test_image_view_next_gen.py
Normal file
331
tests/unit_tests/test_image_view_next_gen.py
Normal file
@@ -0,0 +1,331 @@
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
import pytest
|
||||
|
||||
from bec_widgets.widgets.plots_next_gen.image.image import Image
|
||||
from tests.unit_tests.client_mocks import mocked_client
|
||||
from tests.unit_tests.conftest import create_widget
|
||||
|
||||
##################################################
|
||||
# Image widget base functionality tests
|
||||
##################################################
|
||||
|
||||
|
||||
def test_initialization_defaults(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
assert bec_image_view.color_map == "magma"
|
||||
assert bec_image_view.autorange is True
|
||||
assert bec_image_view.autorange_mode == "mean"
|
||||
assert bec_image_view.config.lock_aspect_ratio is True
|
||||
assert bec_image_view.main_image is not None
|
||||
assert bec_image_view._color_bar is None
|
||||
|
||||
|
||||
def test_setting_color_map(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.color_map = "viridis"
|
||||
assert bec_image_view.color_map == "viridis"
|
||||
assert bec_image_view.config.color_map == "viridis"
|
||||
|
||||
|
||||
def test_invalid_color_map_handling(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
previous_colormap = bec_image_view.color_map
|
||||
bec_image_view.color_map = "invalid_colormap_name"
|
||||
assert bec_image_view.color_map == previous_colormap
|
||||
assert bec_image_view.main_image.color_map == previous_colormap
|
||||
|
||||
|
||||
def test_toggle_autorange(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.autorange = False
|
||||
assert bec_image_view.autorange is False
|
||||
|
||||
bec_image_view.toggle_autorange(True, "max")
|
||||
assert bec_image_view.autorange is True
|
||||
assert bec_image_view.autorange_mode == "max"
|
||||
|
||||
assert bec_image_view.main_image.autorange is True
|
||||
assert bec_image_view.main_image.autorange_mode == "max"
|
||||
assert bec_image_view.main_image.config.autorange is True
|
||||
assert bec_image_view.main_image.config.autorange_mode == "max"
|
||||
|
||||
|
||||
def test_lock_aspect_ratio(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.lock_aspect_ratio = True
|
||||
assert bec_image_view.lock_aspect_ratio is True
|
||||
assert bool(bec_image_view.plot_item.getViewBox().state["aspectLocked"]) is True
|
||||
assert bec_image_view.config.lock_aspect_ratio is True
|
||||
|
||||
|
||||
def test_set_vrange(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.vrange = (10, 100)
|
||||
assert bec_image_view.vrange == (10, 100)
|
||||
assert bec_image_view.main_image.levels == (10, 100)
|
||||
assert bec_image_view.main_image.config.v_range == (10, 100)
|
||||
|
||||
|
||||
def test_enable_simple_colorbar(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.enable_simple_colorbar = True
|
||||
assert bec_image_view.enable_simple_colorbar is True
|
||||
assert bec_image_view.config.color_bar == "simple"
|
||||
assert isinstance(bec_image_view._color_bar, pg.ColorBarItem)
|
||||
|
||||
# Enabling color bar should not cancel autorange
|
||||
assert bec_image_view.autorange is True
|
||||
assert bec_image_view.autorange_mode == "mean"
|
||||
assert bec_image_view.main_image.autorange is True
|
||||
assert bec_image_view.main_image.autorange_mode == "mean"
|
||||
|
||||
|
||||
def test_enable_full_colorbar(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.enable_full_colorbar = True
|
||||
assert bec_image_view.enable_full_colorbar is True
|
||||
assert bec_image_view.config.color_bar == "full"
|
||||
assert isinstance(bec_image_view._color_bar, pg.HistogramLUTItem)
|
||||
|
||||
# Enabling color bar should not cancel autorange
|
||||
assert bec_image_view.autorange is True
|
||||
assert bec_image_view.autorange_mode == "mean"
|
||||
assert bec_image_view.main_image.autorange is True
|
||||
assert bec_image_view.main_image.autorange_mode == "mean"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("colorbar_type", ["simple", "full"])
|
||||
def test_enable_colorbar_with_vrange(qtbot, mocked_client, colorbar_type):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.enable_colorbar(True, colorbar_type, (0, 100))
|
||||
|
||||
if colorbar_type == "simple":
|
||||
assert isinstance(bec_image_view._color_bar, pg.ColorBarItem)
|
||||
assert bec_image_view.enable_simple_colorbar is True
|
||||
else:
|
||||
assert isinstance(bec_image_view._color_bar, pg.HistogramLUTItem)
|
||||
assert bec_image_view.enable_full_colorbar is True
|
||||
assert bec_image_view.config.color_bar == colorbar_type
|
||||
assert bec_image_view.vrange == (0, 100)
|
||||
assert bec_image_view.main_image.levels == (0, 100)
|
||||
assert bec_image_view._color_bar is not None
|
||||
|
||||
|
||||
def test_image_setup_image_2d(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.image(monitor="eiger", monitor_type="2d")
|
||||
assert bec_image_view.monitor == "eiger"
|
||||
assert bec_image_view.main_image.config.source == "device_monitor_2d"
|
||||
assert bec_image_view.main_image.config.monitor_type == "2d"
|
||||
assert bec_image_view.main_image.raw_data is None
|
||||
assert bec_image_view.main_image.image is None
|
||||
|
||||
|
||||
def test_image_setup_image_1d(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.image(monitor="eiger", monitor_type="1d")
|
||||
assert bec_image_view.monitor == "eiger"
|
||||
assert bec_image_view.main_image.config.source == "device_monitor_1d"
|
||||
assert bec_image_view.main_image.config.monitor_type == "1d"
|
||||
assert bec_image_view.main_image.raw_data is None
|
||||
assert bec_image_view.main_image.image is None
|
||||
|
||||
|
||||
def test_image_setup_image_auto(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.image(monitor="eiger", monitor_type="auto")
|
||||
assert bec_image_view.monitor == "eiger"
|
||||
assert bec_image_view.main_image.config.source == "auto"
|
||||
assert bec_image_view.main_image.config.monitor_type == "auto"
|
||||
assert bec_image_view.main_image.raw_data is None
|
||||
assert bec_image_view.main_image.image is None
|
||||
|
||||
|
||||
def test_image_data_update_2d(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
test_data = np.random.rand(20, 30)
|
||||
message = {"data": test_data}
|
||||
metadata = {}
|
||||
|
||||
bec_image_view.on_image_update_2d(message, metadata)
|
||||
|
||||
np.testing.assert_array_equal(bec_image_view._main_image.image, test_data)
|
||||
|
||||
|
||||
def test_image_data_update_1d(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
waveform1 = np.random.rand(50)
|
||||
waveform2 = np.random.rand(60) # Different length, tests padding logic
|
||||
metadata = {"scan_id": "scan_test"}
|
||||
|
||||
bec_image_view.on_image_update_1d({"data": waveform1}, metadata)
|
||||
assert bec_image_view._main_image.raw_data.shape == (1, 50)
|
||||
|
||||
bec_image_view.on_image_update_1d({"data": waveform2}, metadata)
|
||||
assert bec_image_view._main_image.raw_data.shape == (2, 60)
|
||||
|
||||
|
||||
def test_toolbar_actions_presence(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
assert "autorange_image" in bec_image_view.toolbar.bundles["roi"]
|
||||
assert "lock_aspect_ratio" in bec_image_view.toolbar.bundles["mouse_interaction"]
|
||||
assert "processing" in bec_image_view.toolbar.bundles
|
||||
assert "selection" in bec_image_view.toolbar.bundles
|
||||
|
||||
|
||||
def test_image_processing_fft_toggle(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.fft = True
|
||||
assert bec_image_view.fft is True
|
||||
bec_image_view.fft = False
|
||||
assert bec_image_view.fft is False
|
||||
|
||||
|
||||
def test_image_processing_log_toggle(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.log = True
|
||||
assert bec_image_view.log is True
|
||||
bec_image_view.log = False
|
||||
assert bec_image_view.log is False
|
||||
|
||||
|
||||
def test_image_rotation_and_transpose(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.rotation = 2
|
||||
assert bec_image_view.rotation == 2
|
||||
|
||||
bec_image_view.transpose = True
|
||||
assert bec_image_view.transpose is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("colorbar_type", ["none", "simple", "full"])
|
||||
def test_setting_vrange_with_colorbar(qtbot, mocked_client, colorbar_type):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
if colorbar_type == "simple":
|
||||
bec_image_view.enable_simple_colorbar = True
|
||||
elif colorbar_type == "full":
|
||||
bec_image_view.enable_full_colorbar = True
|
||||
|
||||
bec_image_view.vrange = (0, 100)
|
||||
assert bec_image_view.vrange == (0, 100)
|
||||
assert bec_image_view.main_image.levels == (0, 100)
|
||||
assert bec_image_view.main_image.config.v_range == (0, 100)
|
||||
assert bec_image_view.v_min == 0
|
||||
assert bec_image_view.v_max == 100
|
||||
|
||||
if colorbar_type == "simple":
|
||||
assert isinstance(bec_image_view._color_bar, pg.ColorBarItem)
|
||||
assert bec_image_view._color_bar.levels() == (0, 100)
|
||||
elif colorbar_type == "full":
|
||||
assert isinstance(bec_image_view._color_bar, pg.HistogramLUTItem)
|
||||
assert bec_image_view._color_bar.getLevels() == (0, 100)
|
||||
|
||||
|
||||
###################################
|
||||
# Toolbar Actions
|
||||
###################################
|
||||
|
||||
|
||||
def test_setup_image_from_toolbar(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
|
||||
bec_image_view.selection_bundle.device_combo_box.setCurrentText("eiger")
|
||||
bec_image_view.selection_bundle.dim_combo_box.setCurrentText("2d")
|
||||
|
||||
assert bec_image_view.monitor == "eiger"
|
||||
assert bec_image_view.main_image.config.source == "device_monitor_2d"
|
||||
assert bec_image_view.main_image.config.monitor_type == "2d"
|
||||
assert bec_image_view.main_image.raw_data is None
|
||||
assert bec_image_view.main_image.image is None
|
||||
|
||||
|
||||
def test_image_actions_interactions(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.autorange = False # Change the initial state to False
|
||||
|
||||
bec_image_view.autorange_mean_action.action.trigger()
|
||||
assert bec_image_view.autorange is True
|
||||
assert bec_image_view.main_image.autorange is True
|
||||
assert bec_image_view.autorange_mode == "mean"
|
||||
|
||||
bec_image_view.autorange_max_action.action.trigger()
|
||||
assert bec_image_view.autorange is True
|
||||
assert bec_image_view.main_image.autorange is True
|
||||
assert bec_image_view.autorange_mode == "max"
|
||||
|
||||
bec_image_view.toolbar.widgets["lock_aspect_ratio"].action.trigger()
|
||||
assert bec_image_view.lock_aspect_ratio is False
|
||||
assert bool(bec_image_view.plot_item.getViewBox().state["aspectLocked"]) is False
|
||||
|
||||
|
||||
def test_image_toggle_action_fft(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
|
||||
bec_image_view.processing_bundle.fft.action.trigger()
|
||||
|
||||
assert bec_image_view.fft is True
|
||||
assert bec_image_view.main_image.fft is True
|
||||
assert bec_image_view.main_image.config.processing.fft is True
|
||||
|
||||
|
||||
def test_image_toggle_action_log(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
|
||||
bec_image_view.processing_bundle.log.action.trigger()
|
||||
|
||||
assert bec_image_view.log is True
|
||||
assert bec_image_view.main_image.log is True
|
||||
assert bec_image_view.main_image.config.processing.log is True
|
||||
|
||||
|
||||
def test_image_toggle_action_transpose(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
|
||||
bec_image_view.processing_bundle.transpose.action.trigger()
|
||||
|
||||
assert bec_image_view.transpose is True
|
||||
assert bec_image_view.main_image.transpose is True
|
||||
assert bec_image_view.main_image.config.processing.transpose is True
|
||||
|
||||
|
||||
def test_image_toggle_action_rotate_right(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
|
||||
bec_image_view.processing_bundle.right.action.trigger()
|
||||
|
||||
assert bec_image_view.rotation == 3
|
||||
assert bec_image_view.main_image.rotation == 3
|
||||
assert bec_image_view.main_image.config.processing.rotation == 3
|
||||
|
||||
|
||||
def test_image_toggle_action_rotate_left(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
|
||||
bec_image_view.processing_bundle.left.action.trigger()
|
||||
|
||||
assert bec_image_view.rotation == 1
|
||||
assert bec_image_view.main_image.rotation == 1
|
||||
assert bec_image_view.main_image.config.processing.rotation == 1
|
||||
|
||||
|
||||
def test_image_toggle_action_reset(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
|
||||
# Setup some processing
|
||||
bec_image_view.fft = True
|
||||
bec_image_view.log = True
|
||||
bec_image_view.transpose = True
|
||||
bec_image_view.rotation = 2
|
||||
|
||||
bec_image_view.processing_bundle.reset.action.trigger()
|
||||
|
||||
assert bec_image_view.rotation == 0
|
||||
assert bec_image_view.main_image.rotation == 0
|
||||
assert bec_image_view.main_image.config.processing.rotation == 0
|
||||
assert bec_image_view.fft is False
|
||||
assert bec_image_view.main_image.fft is False
|
||||
assert bec_image_view.log is False
|
||||
assert bec_image_view.main_image.log is False
|
||||
assert bec_image_view.transpose is False
|
||||
assert bec_image_view.main_image.transpose is False
|
||||
@@ -14,7 +14,7 @@ def test_init_plot_base(qtbot, mocked_client):
|
||||
plot_base = bec_figure.add_widget(widget_type="BECPlotBase", widget_id="test_plot")
|
||||
assert plot_base is not None
|
||||
assert plot_base.config.widget_class == "BECPlotBase"
|
||||
assert plot_base.config.gui_id == "test_plot"
|
||||
assert plot_base.config.gui_id == plot_base.gui_id
|
||||
|
||||
|
||||
def test_plot_base_axes_by_separate_methods(qtbot, mocked_client):
|
||||
|
||||
@@ -20,8 +20,10 @@ def test_rpc_server_start_server_without_service_config(mocked_cli_server):
|
||||
"""
|
||||
mock_server, mock_config, _ = mocked_cli_server
|
||||
|
||||
_start_server("gui_id", BECFigure, None)
|
||||
mock_server.assert_called_once_with(gui_id="gui_id", config=mock_config(), gui_class=BECFigure)
|
||||
_start_server("gui_id", BECFigure, config=None)
|
||||
mock_server.assert_called_once_with(
|
||||
gui_id="gui_id", config=mock_config(), gui_class=BECFigure, gui_class_id="bec"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -37,5 +39,7 @@ def test_rpc_server_start_server_with_service_config(mocked_cli_server, config,
|
||||
"""
|
||||
mock_server, mock_config, _ = mocked_cli_server
|
||||
config = mock_config(**call_config)
|
||||
_start_server("gui_id", BECFigure, config)
|
||||
mock_server.assert_called_once_with(gui_id="gui_id", config=config, gui_class=BECFigure)
|
||||
_start_server("gui_id", BECFigure, config=config)
|
||||
mock_server.assert_called_once_with(
|
||||
gui_id="gui_id", config=config, gui_class=BECFigure, gui_class_id="bec"
|
||||
)
|
||||
|
||||
153
tests/unit_tests/test_scatter_waveform.py
Normal file
153
tests/unit_tests/test_scatter_waveform.py
Normal file
@@ -0,0 +1,153 @@
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
|
||||
from bec_widgets.widgets.plots_next_gen.scatter_waveform.scatter_curve import (
|
||||
ScatterCurveConfig,
|
||||
ScatterDeviceSignal,
|
||||
)
|
||||
from bec_widgets.widgets.plots_next_gen.scatter_waveform.scatter_waveform import ScatterWaveform
|
||||
from tests.unit_tests.client_mocks import create_dummy_scan_item, mocked_client
|
||||
|
||||
from .conftest import create_widget
|
||||
|
||||
|
||||
def test_waveform_initialization(qtbot, mocked_client):
|
||||
"""
|
||||
Test that a new Waveform widget initializes with the correct defaults.
|
||||
"""
|
||||
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
|
||||
assert swf.objectName() == "ScatterWaveform"
|
||||
# Inherited from PlotBase
|
||||
assert swf.title == ""
|
||||
assert swf.x_label == ""
|
||||
assert swf.y_label == ""
|
||||
# No crosshair or FPS monitor by default
|
||||
assert swf.crosshair is None
|
||||
assert swf.fps_monitor is None
|
||||
assert swf.main_curve is not None
|
||||
|
||||
|
||||
def test_scatter_waveform_plot(qtbot, mocked_client):
|
||||
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
|
||||
curve = swf.plot("samx", "samy", "bpm4i")
|
||||
|
||||
assert curve is not None
|
||||
assert isinstance(curve.config, ScatterCurveConfig)
|
||||
assert curve.config.x_device == ScatterDeviceSignal(name="samx", entry="samx")
|
||||
assert curve.config.label == "bpm4i-bpm4i"
|
||||
|
||||
|
||||
def test_scatter_waveform_color_map(qtbot, mocked_client):
|
||||
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
|
||||
assert swf.color_map == "magma"
|
||||
|
||||
swf.color_map = "plasma"
|
||||
assert swf.color_map == "plasma"
|
||||
|
||||
|
||||
def test_scatter_waveform_curve_json(qtbot, mocked_client):
|
||||
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
|
||||
|
||||
# Add a device-based scatter curve
|
||||
swf.plot(x_name="samx", y_name="samy", z_name="bpm4i", label="test_curve")
|
||||
|
||||
json_str = swf.curve_json
|
||||
data = json.loads(json_str)
|
||||
assert isinstance(data, dict)
|
||||
assert data["label"] == "test_curve"
|
||||
assert data["x_device"]["name"] == "samx"
|
||||
assert data["y_device"]["name"] == "samy"
|
||||
assert data["z_device"]["name"] == "bpm4i"
|
||||
|
||||
# Clear and reload from JSON
|
||||
swf.clear_all()
|
||||
assert swf.main_curve.getData() == (None, None)
|
||||
|
||||
swf.curve_json = json_str
|
||||
assert swf.main_curve.config.label == "test_curve"
|
||||
|
||||
|
||||
def test_scatter_waveform_update_with_scan_history(qtbot, mocked_client, monkeypatch):
|
||||
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
|
||||
|
||||
dummy_scan = create_dummy_scan_item()
|
||||
mocked_client.history.get_by_scan_id.return_value = dummy_scan
|
||||
mocked_client.history.__getitem__.return_value = dummy_scan
|
||||
|
||||
swf.plot("samx", "samy", "bpm4i", label="test_curve")
|
||||
swf.update_with_scan_history(scan_id="dummy")
|
||||
qtbot.wait(500)
|
||||
|
||||
assert swf.scan_item == dummy_scan
|
||||
|
||||
x_data, y_data = swf.main_curve.getData()
|
||||
np.testing.assert_array_equal(x_data, [10, 20, 30])
|
||||
np.testing.assert_array_equal(y_data, [5, 10, 15])
|
||||
|
||||
|
||||
def test_scatter_waveform_live_update(qtbot, mocked_client, monkeypatch):
|
||||
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
|
||||
|
||||
dummy_scan = create_dummy_scan_item()
|
||||
monkeypatch.setattr(swf.queue.scan_storage, "find_scan_by_ID", lambda scan_id: dummy_scan)
|
||||
|
||||
swf.plot("samx", "samy", "bpm4i", label="live_curve")
|
||||
|
||||
# Simulate scan status indicating new scan start
|
||||
msg = {"scan_id": "dummy"}
|
||||
meta = {}
|
||||
swf.on_scan_status(msg, meta)
|
||||
|
||||
assert swf.scan_id == "dummy"
|
||||
assert swf.scan_item == dummy_scan
|
||||
|
||||
qtbot.wait(500)
|
||||
|
||||
x_data, y_data = swf.main_curve.getData()
|
||||
np.testing.assert_array_equal(x_data, [10, 20, 30])
|
||||
np.testing.assert_array_equal(y_data, [5, 10, 15])
|
||||
|
||||
|
||||
def test_scatter_waveform_scan_progress(qtbot, mocked_client, monkeypatch):
|
||||
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
|
||||
|
||||
dummy_scan = create_dummy_scan_item()
|
||||
monkeypatch.setattr(swf.queue.scan_storage, "find_scan_by_ID", lambda scan_id: dummy_scan)
|
||||
|
||||
swf.plot("samx", "samy", "bpm4i")
|
||||
|
||||
# Simulate scan status indicating scan progress
|
||||
swf.scan_id = "dummy"
|
||||
swf.scan_item = dummy_scan
|
||||
|
||||
msg = {"progress": 50}
|
||||
meta = {}
|
||||
swf.on_scan_progress(msg, meta)
|
||||
qtbot.wait(500)
|
||||
|
||||
# swf.update_sync_curves()
|
||||
|
||||
x_data, y_data = swf.main_curve.getData()
|
||||
np.testing.assert_array_equal(x_data, [10, 20, 30])
|
||||
np.testing.assert_array_equal(y_data, [5, 10, 15])
|
||||
|
||||
|
||||
def test_scatter_waveform_settings_popup(qtbot, mocked_client):
|
||||
"""
|
||||
Test that the settings popup is created correctly.
|
||||
"""
|
||||
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
|
||||
|
||||
scatter_popup_action = swf.toolbar.widgets["scatter_waveform_settings"].action
|
||||
assert not scatter_popup_action.isChecked(), "Should start unchecked"
|
||||
|
||||
swf.show_scatter_curve_settings()
|
||||
|
||||
assert swf.scatter_dialog is not None
|
||||
assert swf.scatter_dialog.isVisible()
|
||||
assert scatter_popup_action.isChecked()
|
||||
|
||||
swf.scatter_dialog.close()
|
||||
assert swf.scatter_dialog is None
|
||||
assert not scatter_popup_action.isChecked(), "Should be unchecked after closing dialog"
|
||||
@@ -1,27 +1,29 @@
|
||||
import pytest
|
||||
from qtpy.QtCore import QPointF
|
||||
|
||||
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
|
||||
from bec_widgets.widgets.containers.figure import BECFigure
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plot_widget_with_arrow_item(qtbot, mocked_client):
|
||||
widget = BECWaveformWidget(client=mocked_client())
|
||||
widget = BECFigure(client=mocked_client())
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
waveform = widget.plot()
|
||||
|
||||
yield widget.waveform.arrow_item, widget.waveform.plot_item
|
||||
yield waveform.arrow_item, waveform.plot_item
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plot_widget_with_tick_item(qtbot, mocked_client):
|
||||
widget = BECWaveformWidget(client=mocked_client())
|
||||
widget = BECFigure(client=mocked_client())
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
waveform = widget.plot()
|
||||
|
||||
yield widget.waveform.tick_item, widget.waveform.plot_item
|
||||
yield waveform.tick_item, waveform.plot_item
|
||||
|
||||
|
||||
def test_arrow_item_add_to_plot(plot_widget_with_arrow_item):
|
||||
@@ -31,6 +33,7 @@ def test_arrow_item_add_to_plot(plot_widget_with_arrow_item):
|
||||
assert arrow_item.plot_item.items == []
|
||||
arrow_item.add_to_plot()
|
||||
assert arrow_item.plot_item.items == [arrow_item.arrow_item]
|
||||
arrow_item.remove_from_plot()
|
||||
|
||||
|
||||
def test_arrow_item_set_position(plot_widget_with_arrow_item):
|
||||
@@ -50,6 +53,7 @@ def test_arrow_item_set_position(plot_widget_with_arrow_item):
|
||||
point = QPointF(2.0, 2.0)
|
||||
assert arrow_item.arrow_item.pos() == point
|
||||
assert container == [(1, 1), (2, 2)]
|
||||
arrow_item.remove_from_plot()
|
||||
|
||||
|
||||
def test_arrow_item_cleanup(plot_widget_with_arrow_item):
|
||||
@@ -75,6 +79,7 @@ def test_tick_item_add_to_plot(plot_widget_with_tick_item):
|
||||
pos = tick_item.tick.pos()
|
||||
new_pos = tick_item.tick_item.mapFromParent(QPointF(pos.x(), new_pos))
|
||||
assert new_pos.y() == pos.y()
|
||||
tick_item.remove_from_plot()
|
||||
|
||||
|
||||
def test_tick_item_set_position(plot_widget_with_tick_item):
|
||||
@@ -93,6 +98,7 @@ def test_tick_item_set_position(plot_widget_with_tick_item):
|
||||
tick_item.set_position(pos=2)
|
||||
assert tick_item._pos == 2
|
||||
assert container == [1.0, 2.0]
|
||||
tick_item.remove_from_plot()
|
||||
|
||||
|
||||
def test_tick_item_cleanup(plot_widget_with_tick_item):
|
||||
|
||||
756
tests/unit_tests/test_waveform_next_gen.py
Normal file
756
tests/unit_tests/test_waveform_next_gen.py
Normal file
@@ -0,0 +1,756 @@
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
import pytest
|
||||
from pyqtgraph.graphicsItems.DateAxisItem import DateAxisItem
|
||||
|
||||
from bec_widgets.widgets.plots_next_gen.plot_base import UIMode
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.curve import DeviceSignal
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
|
||||
from tests.unit_tests.client_mocks import (
|
||||
DummyData,
|
||||
create_dummy_scan_item,
|
||||
dap_plugin_message,
|
||||
mocked_client,
|
||||
mocked_client_with_dap,
|
||||
)
|
||||
|
||||
from .conftest import create_widget
|
||||
|
||||
##################################################
|
||||
# Waveform widget base functionality tests
|
||||
##################################################
|
||||
|
||||
|
||||
def test_waveform_initialization(qtbot, mocked_client):
|
||||
"""
|
||||
Test that a new Waveform widget initializes with the correct defaults.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
assert wf.objectName() == "Waveform"
|
||||
# Inherited from PlotBase
|
||||
assert wf.title == ""
|
||||
assert wf.x_label == ""
|
||||
assert wf.y_label == ""
|
||||
# No crosshair or FPS monitor by default
|
||||
assert wf.crosshair is None
|
||||
assert wf.fps_monitor is None
|
||||
# No curves initially
|
||||
assert len(wf.plot_item.curves) == 0
|
||||
|
||||
|
||||
def test_waveform_with_side_menu(qtbot, mocked_client):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client, popups=False)
|
||||
|
||||
assert wf.ui_mode == UIMode.SIDE
|
||||
|
||||
|
||||
def test_plot_custom_curve(qtbot, mocked_client):
|
||||
"""
|
||||
Test that calling plot with explicit x and y data creates a custom curve.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
curve = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="custom_curve")
|
||||
assert curve is not None
|
||||
assert curve.config.source == "custom"
|
||||
assert curve.config.label == "custom_curve"
|
||||
x_data, y_data = curve.get_data()
|
||||
np.testing.assert_array_equal(x_data, np.array([1, 2, 3]))
|
||||
np.testing.assert_array_equal(y_data, np.array([4, 5, 6]))
|
||||
|
||||
|
||||
def test_plot_single_arg_input_1d(qtbot, mocked_client):
|
||||
"""
|
||||
Test that when a single 1D numpy array is passed, the curve is created with
|
||||
x-data as a generated index.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
data = np.array([10, 20, 30])
|
||||
curve = wf.plot(data, label="curve_1d")
|
||||
x_data, y_data = curve.get_data()
|
||||
np.testing.assert_array_equal(x_data, np.arange(len(data)))
|
||||
np.testing.assert_array_equal(y_data, data)
|
||||
|
||||
|
||||
def test_plot_single_arg_input_2d(qtbot, mocked_client):
|
||||
"""
|
||||
Test that when a single 2D numpy array (N x 2) is passed,
|
||||
x and y data are extracted from the first and second columns.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
data = np.array([[1, 4], [2, 5], [3, 6]])
|
||||
curve = wf.plot(data, label="curve_2d")
|
||||
x_data, y_data = curve.get_data()
|
||||
np.testing.assert_array_equal(x_data, data[:, 0])
|
||||
np.testing.assert_array_equal(y_data, data[:, 1])
|
||||
|
||||
|
||||
def test_plot_single_arg_input_sync(qtbot, mocked_client):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
c1 = wf.plot(arg1="bpm4i")
|
||||
c2 = wf.plot(arg1="bpm3a")
|
||||
|
||||
assert c1.config.source == "device"
|
||||
assert c2.config.source == "device"
|
||||
assert c1.config.signal == DeviceSignal(name="bpm4i", entry="bpm4i", dap=None)
|
||||
assert c2.config.signal == DeviceSignal(name="bpm3a", entry="bpm3a", dap=None)
|
||||
|
||||
# Check that the curve is added to the plot
|
||||
assert len(wf.plot_item.curves) == 2
|
||||
|
||||
|
||||
def test_plot_single_arg_input_async(qtbot, mocked_client):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
c1 = wf.plot(arg1="eiger")
|
||||
c2 = wf.plot(arg1="async_device")
|
||||
|
||||
assert c1.config.source == "device"
|
||||
assert c2.config.source == "device"
|
||||
assert c1.config.signal == DeviceSignal(name="eiger", entry="eiger", dap=None)
|
||||
assert c2.config.signal == DeviceSignal(name="async_device", entry="async_device", dap=None)
|
||||
|
||||
# Check that the curve is added to the plot
|
||||
assert len(wf.plot_item.curves) == 2
|
||||
|
||||
|
||||
def test_curve_access_pattern(qtbot, mocked_client):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
c1 = wf.plot(arg1="bpm4i")
|
||||
c2 = wf.plot(arg1="bpm3a")
|
||||
|
||||
# Check that the curve is added to the plot
|
||||
assert len(wf.plot_item.curves) == 2
|
||||
|
||||
# Check that the curve is accessible by label
|
||||
assert wf.get_curve("bpm4i-bpm4i") == c1
|
||||
assert wf.get_curve("bpm3a-bpm3a") == c2
|
||||
|
||||
# Check that the curve is accessible by index
|
||||
assert wf.get_curve(0) == c1
|
||||
assert wf.get_curve(1) == c2
|
||||
|
||||
# Check that the curve is accessible by label
|
||||
assert wf["bpm4i-bpm4i"] == c1
|
||||
assert wf["bpm3a-bpm3a"] == c2
|
||||
assert wf[0] == c1
|
||||
assert wf[1] == c2
|
||||
|
||||
assert wf.curves[0] == c1
|
||||
assert wf.curves[1] == c2
|
||||
|
||||
|
||||
def test_find_curve_by_label(qtbot, mocked_client):
|
||||
"""
|
||||
Test the _find_curve_by_label method returns the correct curve or None if not found.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
c1 = wf.plot(arg1="bpm4i", label="c1_label")
|
||||
c2 = wf.plot(arg1="bpm3a", label="c2_label")
|
||||
|
||||
found = wf._find_curve_by_label("c1_label")
|
||||
assert found == c1, "Should return the first curve"
|
||||
missing = wf._find_curve_by_label("bogus_label")
|
||||
assert missing is None, "Should return None if not found"
|
||||
|
||||
|
||||
def test_set_x_mode(qtbot, mocked_client):
|
||||
"""
|
||||
Test that setting x_mode updates the internal x-axis mode state and switches
|
||||
the bottom axis of the plot.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.x_mode = "timestamp"
|
||||
assert wf.x_axis_mode["name"] == "timestamp"
|
||||
# When x_mode is 'timestamp', the bottom axis should be a DateAxisItem.
|
||||
assert isinstance(wf.plot_item.axes["bottom"]["item"], DateAxisItem)
|
||||
|
||||
wf.x_mode = "index"
|
||||
# For other modes, the bottom axis becomes the default AxisItem.
|
||||
assert isinstance(wf.plot_item.axes["bottom"]["item"], pg.AxisItem)
|
||||
|
||||
wf.x_mode = "samx"
|
||||
assert wf.x_axis_mode["name"] == "samx"
|
||||
assert isinstance(wf.plot_item.axes["bottom"]["item"], pg.AxisItem)
|
||||
|
||||
|
||||
def test_color_palette_update(qtbot, mocked_client):
|
||||
"""
|
||||
Test that updating the color_palette property changes the color of existing curves.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
curve = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="test_curve")
|
||||
original_color = curve.config.color
|
||||
# Change to a different valid palette
|
||||
wf.color_palette = "plasma"
|
||||
assert wf.config.color_palette == "plasma"
|
||||
# After updating the palette, the curve's color should be re-generated.
|
||||
assert curve.config.color != original_color
|
||||
|
||||
|
||||
def test_curve_json_property(qtbot, mocked_client):
|
||||
"""
|
||||
Test that the curve_json property returns a JSON string representing
|
||||
non-custom curves. Since custom curves are not serialized, if only a custom
|
||||
curve is added, an empty list should be returned.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="custom_curve")
|
||||
json_str = wf.curve_json
|
||||
data = json.loads(json_str)
|
||||
assert isinstance(data, list)
|
||||
# Only custom curves exist so none should be serialized.
|
||||
assert len(data) == 0
|
||||
|
||||
|
||||
def test_remove_curve_waveform(qtbot, mocked_client):
|
||||
"""
|
||||
Test that curves can be removed from the waveform using either their label or index.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="curve1")
|
||||
wf.plot(x=[4, 5, 6], y=[7, 8, 9], label="curve2")
|
||||
num_before = len(wf.plot_item.curves)
|
||||
wf.remove_curve("curve1")
|
||||
num_after = len(wf.plot_item.curves)
|
||||
assert num_after == num_before - 1
|
||||
|
||||
wf.remove_curve(0)
|
||||
assert len(wf.plot_item.curves) == num_after - 1
|
||||
|
||||
|
||||
def test_get_all_data_empty(qtbot, mocked_client):
|
||||
"""
|
||||
Test that get_all_data returns an empty dictionary when no curves have been added.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
all_data = wf.get_all_data(output="dict")
|
||||
assert all_data == {}
|
||||
|
||||
|
||||
def test_get_all_data_dict(qtbot, mocked_client):
|
||||
"""
|
||||
Test that get_all_data returns a dictionary with the expected x and y data for each curve.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="curve1")
|
||||
wf.plot(x=[7, 8, 9], y=[10, 11, 12], label="curve2")
|
||||
|
||||
all_data = wf.get_all_data(output="dict")
|
||||
|
||||
expected = {
|
||||
"curve1": {"x": [1, 2, 3], "y": [4, 5, 6]},
|
||||
"curve2": {"x": [7, 8, 9], "y": [10, 11, 12]},
|
||||
}
|
||||
assert all_data == expected
|
||||
|
||||
|
||||
def test_curve_json_getter_setter(qtbot, mocked_client):
|
||||
"""
|
||||
Test that the curve_json getter returns a JSON string representing device curves
|
||||
and that setting curve_json re-creates the curves.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
# These curves should be in JSON
|
||||
wf.plot(arg1="bpm4i")
|
||||
wf.plot(arg1="bpm3a")
|
||||
# Custom curves should be ignored
|
||||
wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="custom_curve")
|
||||
wf.plot([1, 2, 3, 4])
|
||||
|
||||
# Get JSON from the getter.
|
||||
json_str = wf.curve_json
|
||||
curve_configs = json.loads(json_str)
|
||||
# Only device curves are serialized; expect two configurations.
|
||||
assert isinstance(curve_configs, list)
|
||||
assert len(curve_configs) == 2
|
||||
labels = [cfg["label"] for cfg in curve_configs]
|
||||
assert "bpm4i-bpm4i" in labels
|
||||
assert "bpm3a-bpm3a" in labels
|
||||
|
||||
# Clear all curves.
|
||||
wf.clear_all()
|
||||
assert len(wf.plot_item.curves) == 0
|
||||
|
||||
# Use the JSON setter to re-create the curves.
|
||||
wf.curve_json = json_str
|
||||
# After setting, the waveform should have two curves.
|
||||
assert len(wf.plot_item.curves) == 2
|
||||
new_labels = [curve.name() for curve in wf.plot_item.curves]
|
||||
for lab in labels:
|
||||
assert lab in new_labels
|
||||
|
||||
|
||||
def test_curve_json_setter_ignores_custom(qtbot, mocked_client):
|
||||
"""
|
||||
Test that when curve_json setter is given a JSON string containing a
|
||||
curve with source "custom", that curve is not added.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
device_curve_config = {
|
||||
"widget_class": "Curve",
|
||||
"parent_id": wf.gui_id,
|
||||
"label": "device_curve",
|
||||
"color": "#ff0000",
|
||||
"source": "device",
|
||||
"signal": {"name": "bpm4i", "entry": "bpm4i", "dap": None},
|
||||
}
|
||||
custom_curve_config = {
|
||||
"widget_class": "Curve",
|
||||
"parent_id": wf.gui_id,
|
||||
"label": "custom_curve",
|
||||
"color": "#00ff00",
|
||||
"source": "custom",
|
||||
# No signal for custom curves.
|
||||
}
|
||||
json_str = json.dumps([device_curve_config, custom_curve_config], indent=2)
|
||||
wf.curve_json = json_str
|
||||
# Only the device curve should be added.
|
||||
curves = wf.plot_item.curves
|
||||
assert len(curves) == 1
|
||||
assert curves[0].name() == "device_curve"
|
||||
|
||||
|
||||
##################################################
|
||||
# Waveform widget scan logic tests
|
||||
##################################################
|
||||
|
||||
|
||||
def test_update_sync_curves(monkeypatch, qtbot, mocked_client):
|
||||
"""
|
||||
Test that update_sync_curves retrieves live data correctly and calls setData on sync curves.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
c = wf.plot(arg1="bpm4i")
|
||||
wf._sync_curves = [c]
|
||||
wf.x_mode = "timestamp"
|
||||
dummy_scan = create_dummy_scan_item()
|
||||
wf.scan_item = dummy_scan
|
||||
|
||||
recorded = {}
|
||||
|
||||
def fake_setData(x, y):
|
||||
recorded["x"] = x
|
||||
recorded["y"] = y
|
||||
|
||||
monkeypatch.setattr(c, "setData", fake_setData)
|
||||
|
||||
wf.update_sync_curves()
|
||||
np.testing.assert_array_equal(recorded.get("x"), [101, 201, 301])
|
||||
np.testing.assert_array_equal(recorded.get("y"), [5, 6, 7])
|
||||
|
||||
|
||||
def test_update_async_curves(monkeypatch, qtbot, mocked_client):
|
||||
"""
|
||||
Test that update_async_curves retrieves live data correctly and calls setData on async curves.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
c = wf.plot(arg1="async_device", label="async_device-async_device")
|
||||
wf._async_curves = [c]
|
||||
wf.x_mode = "timestamp"
|
||||
dummy_scan = create_dummy_scan_item()
|
||||
wf.scan_item = dummy_scan
|
||||
|
||||
recorded = {}
|
||||
|
||||
def fake_setData(x, y):
|
||||
recorded["x"] = x
|
||||
recorded["y"] = y
|
||||
|
||||
monkeypatch.setattr(c, "setData", fake_setData)
|
||||
|
||||
wf.update_async_curves()
|
||||
np.testing.assert_array_equal(recorded.get("x"), [11, 21, 31])
|
||||
np.testing.assert_array_equal(recorded.get("y"), [1, 2, 3])
|
||||
|
||||
|
||||
def test_get_x_data_custom(monkeypatch, qtbot, mocked_client):
|
||||
"""
|
||||
Test that _get_x_data returns the correct custom signal data.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
# Set x_mode to a custom mode.
|
||||
wf.x_axis_mode["name"] = "custom_signal"
|
||||
wf.x_axis_mode["entry"] = "custom_entry"
|
||||
dummy_data = DummyData(val=[50, 60, 70], timestamps=[150, 160, 170])
|
||||
dummy_live = {"custom_signal": {"custom_entry": dummy_data}}
|
||||
monkeypatch.setattr(wf, "_fetch_scan_data_and_access", lambda: (dummy_live, "val"))
|
||||
x_data = wf._get_x_data("irrelevant", "irrelevant")
|
||||
np.testing.assert_array_equal(x_data, [50, 60, 70])
|
||||
|
||||
|
||||
def test_get_x_data_timestamp(monkeypatch, qtbot, mocked_client):
|
||||
"""
|
||||
Test that _get_x_data returns the correct timestamp data.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.x_axis_mode["name"] = "timestamp"
|
||||
dummy_data = DummyData(val=[50, 60, 70], timestamps=[101, 202, 303])
|
||||
dummy_live = {"deviceX": {"entryX": dummy_data}}
|
||||
monkeypatch.setattr(wf, "_fetch_scan_data_and_access", lambda: (dummy_live, "val"))
|
||||
x_data = wf._get_x_data("deviceX", "entryX")
|
||||
np.testing.assert_array_equal(x_data, [101, 202, 303])
|
||||
|
||||
|
||||
def test_categorise_device_curves(monkeypatch, qtbot, mocked_client):
|
||||
"""
|
||||
Test that _categorise_device_curves correctly categorizes curves.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
dummy_scan = create_dummy_scan_item()
|
||||
wf.scan_item = dummy_scan
|
||||
|
||||
c_sync = wf.plot(arg1="bpm4i", label="bpm4i-bpm4i")
|
||||
c_async = wf.plot(arg1="async_device", label="async_device-async_device")
|
||||
|
||||
mode = wf._categorise_device_curves()
|
||||
|
||||
assert mode == "mixed"
|
||||
assert c_sync in wf._sync_curves
|
||||
assert c_async in wf._async_curves
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
["mode", "calls"], [("sync", (1, 0)), ("async", (0, 1)), ("mixed", (1, 1))]
|
||||
)
|
||||
def test_on_scan_status(qtbot, mocked_client, monkeypatch, mode, calls):
|
||||
"""
|
||||
Test that on_scan_status sets up a new scan correctly,
|
||||
categorizes curves, and triggers sync/async updates as needed.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
# Force creation of a couple of device curves
|
||||
if mode == "sync":
|
||||
wf.plot(arg1="bpm4i")
|
||||
elif mode == "async":
|
||||
wf.plot(arg1="async_device")
|
||||
else:
|
||||
wf.plot(arg1="bpm4i")
|
||||
wf.plot(arg1="async_device")
|
||||
|
||||
# We mock out the scan_item, pretending we found a new scan.
|
||||
dummy_scan = create_dummy_scan_item()
|
||||
dummy_scan.metadata["bec"]["scan_id"] = "1234"
|
||||
monkeypatch.setattr(wf.queue.scan_storage, "find_scan_by_ID", lambda scan_id: dummy_scan)
|
||||
|
||||
# We'll track calls to sync_signal_update and async_signal_update
|
||||
sync_spy = MagicMock()
|
||||
async_spy = MagicMock()
|
||||
wf.sync_signal_update.connect(sync_spy)
|
||||
wf.async_signal_update.connect(async_spy)
|
||||
|
||||
# Prepare fake message data
|
||||
msg = {"scan_id": "1234"}
|
||||
meta = {}
|
||||
wf.on_scan_status(msg, meta)
|
||||
|
||||
assert wf.scan_id == "1234"
|
||||
assert wf.scan_item == dummy_scan
|
||||
assert wf._mode == mode
|
||||
|
||||
assert sync_spy.call_count == calls[0], "sync_signal_update should be called exactly once"
|
||||
assert async_spy.call_count == calls[1], "async_signal_update should be called exactly once"
|
||||
|
||||
|
||||
def test_add_dap_curve(qtbot, mocked_client_with_dap, monkeypatch):
|
||||
"""
|
||||
Test add_dap_curve creates a new DAP curve from an existing device curve
|
||||
and verifies that the DAP call doesn't fail due to mock-based plugin_info.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
wf.plot(arg1="bpm4i", label="bpm4i-bpm4i")
|
||||
|
||||
dap_curve = wf.add_dap_curve(device_label="bpm4i-bpm4i", dap_name="GaussianModel")
|
||||
assert dap_curve is not None
|
||||
assert dap_curve.config.source == "dap"
|
||||
assert dap_curve.config.signal.name == "bpm4i"
|
||||
assert dap_curve.config.signal.dap == "GaussianModel"
|
||||
|
||||
|
||||
def test_fetch_scan_data_and_access(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Test the _fetch_scan_data_and_access method returns live_data/val if in a live scan,
|
||||
or device dict/value if in a historical scan. Also test fallback if no scan_item.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
wf.scan_item = None
|
||||
|
||||
hist_mock = MagicMock()
|
||||
monkeypatch.setattr(wf, "update_with_scan_history", hist_mock)
|
||||
|
||||
wf._fetch_scan_data_and_access()
|
||||
hist_mock.assert_called_once_with(-1)
|
||||
|
||||
# Ckeck live mode
|
||||
dummy_scan = create_dummy_scan_item()
|
||||
wf.scan_item = dummy_scan
|
||||
data_dict, access_key = wf._fetch_scan_data_and_access()
|
||||
assert data_dict == dummy_scan.live_data
|
||||
assert access_key == "val"
|
||||
|
||||
# Check history mode
|
||||
del dummy_scan.live_data
|
||||
dummy_scan.devices = {"some_device": {"some_entry": "some_value"}}
|
||||
data_dict, access_key = wf._fetch_scan_data_and_access()
|
||||
assert "some_device" in data_dict # from dummy_scan.devices
|
||||
assert access_key == "value"
|
||||
|
||||
|
||||
def test_setup_async_curve(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Test that _setup_async_curve properly disconnects old signals
|
||||
and re-connects the async readback for a new scan ID.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.old_scan_id = "111"
|
||||
wf.scan_id = "222"
|
||||
|
||||
c = wf.plot(arg1="async_device", label="async_device-async_device")
|
||||
# check that it was placed in _async_curves or so
|
||||
wf._async_curves = [c]
|
||||
|
||||
# We'll spy on connect_slot
|
||||
connect_spy = MagicMock()
|
||||
monkeypatch.setattr(wf.bec_dispatcher, "connect_slot", connect_spy)
|
||||
|
||||
wf._setup_async_curve(c)
|
||||
connect_spy.assert_called_once()
|
||||
endpoint_called = connect_spy.call_args[0][1].endpoint
|
||||
# We expect MessageEndpoints.device_async_readback('222', 'async_device')
|
||||
assert "222" in endpoint_called
|
||||
assert "async_device" in endpoint_called
|
||||
|
||||
|
||||
@pytest.mark.parametrize("x_mode", ("timestamp", "index"))
|
||||
def test_on_async_readback(qtbot, mocked_client, x_mode):
|
||||
"""
|
||||
Test that on_async_readback extends or replaces async data depending on metadata instruction.
|
||||
For 'timestamp' mode, new timestamps are appended to x_data.
|
||||
For 'index' mode, x_data simply increases by integer index.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
dummy_scan = create_dummy_scan_item()
|
||||
wf.scan_item = dummy_scan
|
||||
|
||||
c = wf.plot(arg1="async_device", label="async_device-async_device")
|
||||
wf._async_curves = [c]
|
||||
# Suppose existing data
|
||||
c.setData([0, 1, 2], [10, 11, 12])
|
||||
|
||||
# Set the x_axis_mode
|
||||
wf.x_axis_mode["name"] = x_mode
|
||||
|
||||
# Extend readback
|
||||
msg = {"signals": {"async_device": {"value": [100, 200], "timestamp": [1001, 1002]}}}
|
||||
metadata = {"async_update": {"max_shape": [None], "type": "add"}}
|
||||
wf.on_async_readback(msg, metadata)
|
||||
|
||||
x_data, y_data = c.get_data()
|
||||
assert len(x_data) == 5
|
||||
# Check x_data based on x_mode
|
||||
if x_mode == "timestamp":
|
||||
np.testing.assert_array_equal(x_data, [0, 1, 2, 1001, 1002])
|
||||
else: # x_mode == "index"
|
||||
np.testing.assert_array_equal(x_data, [0, 1, 2, 3, 4])
|
||||
|
||||
np.testing.assert_array_equal(y_data, [10, 11, 12, 100, 200])
|
||||
|
||||
# instruction='replace'
|
||||
msg2 = {"signals": {"async_device": {"value": [999], "timestamp": [555]}}}
|
||||
metadata2 = {"async_update": {"max_shape": [None], "type": "replace"}}
|
||||
wf.on_async_readback(msg2, metadata2)
|
||||
x_data2, y_data2 = c.get_data()
|
||||
if x_mode == "timestamp":
|
||||
np.testing.assert_array_equal(x_data2, [555])
|
||||
else:
|
||||
|
||||
np.testing.assert_array_equal(x_data2, [0])
|
||||
|
||||
np.testing.assert_array_equal(y_data2, [999])
|
||||
|
||||
|
||||
def test_get_x_data(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Test _get_x_data logic for multiple modes: 'timestamp', 'index', 'custom', 'auto'.
|
||||
Use a dummy scan_item that returns specific data for the requested signal.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
dummy_scan = create_dummy_scan_item()
|
||||
wf.scan_item = dummy_scan
|
||||
|
||||
# 1) x_mode == 'timestamp'
|
||||
wf.x_axis_mode["name"] = "timestamp"
|
||||
x_data = wf._get_x_data("bpm4i", "bpm4i")
|
||||
np.testing.assert_array_equal(x_data, [101, 201, 301])
|
||||
|
||||
# 2) x_mode == 'index' => returns None => means use Y data indexing
|
||||
wf.x_axis_mode["name"] = "index"
|
||||
x_data2 = wf._get_x_data("bpm4i", "bpm4i")
|
||||
assert x_data2 is None
|
||||
|
||||
# 3) custom x => e.g. "samx"
|
||||
wf.x_axis_mode["name"] = "samx"
|
||||
x_custom = wf._get_x_data("bpm4i", "bpm4i")
|
||||
# because dummy_scan.live_data["samx"]["samx"].val => [10,20,30]
|
||||
np.testing.assert_array_equal(x_custom, [10, 20, 30])
|
||||
|
||||
# 4) auto
|
||||
wf._async_curves.clear()
|
||||
wf._sync_curves = [MagicMock()] # pretend we have a sync device
|
||||
wf.x_axis_mode["name"] = "auto"
|
||||
x_auto = wf._get_x_data("bpm4i", "bpm4i")
|
||||
# By default it tries the "scan_report_devices" => "samx" => same as custom above
|
||||
np.testing.assert_array_equal(x_auto, [10, 20, 30])
|
||||
|
||||
|
||||
##################################################
|
||||
# The following tests are for the Curve class
|
||||
##################################################
|
||||
|
||||
|
||||
def test_curve_set_appearance_methods(qtbot, mocked_client):
|
||||
"""
|
||||
Test that the Curve appearance setter methods update the configuration properly.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="appearance_curve")
|
||||
c.set_color("#0000ff")
|
||||
c.set_symbol("x")
|
||||
c.set_symbol_color("#ff0000")
|
||||
c.set_symbol_size(10)
|
||||
c.set_pen_width(3)
|
||||
c.set_pen_style("dashdot")
|
||||
assert c.config.color == "#0000ff"
|
||||
assert c.config.symbol == "x"
|
||||
assert c.config.symbol_color == "#ff0000"
|
||||
assert c.config.symbol_size == 10
|
||||
assert c.config.pen_width == 3
|
||||
assert c.config.pen_style == "dashdot"
|
||||
|
||||
|
||||
def test_curve_set_custom_data(qtbot, mocked_client):
|
||||
"""
|
||||
Test that custom curves allow setting new data via set_data.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="custom_data_curve")
|
||||
# Change data
|
||||
c.set_data([7, 8, 9], [10, 11, 12])
|
||||
x_data, y_data = c.get_data()
|
||||
np.testing.assert_array_equal(x_data, np.array([7, 8, 9]))
|
||||
np.testing.assert_array_equal(y_data, np.array([10, 11, 12]))
|
||||
|
||||
|
||||
def test_curve_set_data_error_non_custom(qtbot, mocked_client):
|
||||
"""
|
||||
Test that calling set_data on a non-custom (device) curve raises a ValueError.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
# Create a device curve by providing y_name (which makes source 'device')
|
||||
# Assume that entry_validator returns a valid entry.
|
||||
c = wf.plot(arg1="bpm4i", label="device_curve")
|
||||
with pytest.raises(ValueError):
|
||||
c.set_data([1, 2, 3], [4, 5, 6])
|
||||
|
||||
|
||||
def test_curve_remove(qtbot, mocked_client):
|
||||
"""
|
||||
Test that calling remove() on a Curve calls its parent's remove_curve method.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
c1 = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="curve_1")
|
||||
c2 = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="curve_2")
|
||||
|
||||
assert len(wf.plot_item.curves) == 2
|
||||
c1.remove()
|
||||
assert len(wf.plot_item.curves) == 1
|
||||
assert c1 not in wf.plot_item.curves
|
||||
assert c2 in wf.plot_item.curves
|
||||
|
||||
|
||||
def test_curve_dap_params_and_summary(qtbot, mocked_client):
|
||||
"""
|
||||
Test that dap_params and dap_summary properties work as expected.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="dap_curve")
|
||||
c.dap_params = {"param": 1}
|
||||
c.dap_summary = {"summary": "test"}
|
||||
assert c.dap_params == {"param": 1}
|
||||
assert c.dap_summary == {"summary": "test"}
|
||||
|
||||
|
||||
def test_curve_set_method(qtbot, mocked_client):
|
||||
"""
|
||||
Test the convenience set(...) method of the Curve for updating appearance properties.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="set_method_curve")
|
||||
c.set(
|
||||
color="#123456",
|
||||
symbol="d",
|
||||
symbol_color="#654321",
|
||||
symbol_size=12,
|
||||
pen_width=5,
|
||||
pen_style="dot",
|
||||
)
|
||||
assert c.config.color == "#123456"
|
||||
assert c.config.symbol == "d"
|
||||
assert c.config.symbol_color == "#654321"
|
||||
assert c.config.symbol_size == 12
|
||||
assert c.config.pen_width == 5
|
||||
assert c.config.pen_style == "dot"
|
||||
|
||||
|
||||
##################################################
|
||||
# Settings and popups
|
||||
##################################################
|
||||
|
||||
|
||||
def test_show_curve_settings_popup(qtbot, mocked_client):
|
||||
"""
|
||||
Test that show_curve_settings_popup displays the settings dialog and toggles the toolbar icon.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
curve_action = wf.toolbar.widgets["curve"].action
|
||||
assert not curve_action.isChecked(), "Should start unchecked"
|
||||
|
||||
wf.show_curve_settings_popup()
|
||||
|
||||
assert wf.curve_settings_dialog is not None
|
||||
assert wf.curve_settings_dialog.isVisible()
|
||||
assert curve_action.isChecked()
|
||||
|
||||
wf.curve_settings_dialog.close()
|
||||
assert wf.curve_settings_dialog is None
|
||||
assert not curve_action.isChecked(), "Should be unchecked after closing dialog"
|
||||
|
||||
|
||||
def test_show_dap_summary_popup(qtbot, mocked_client):
|
||||
"""
|
||||
Test that show_dap_summary_popup displays the DAP summary dialog and toggles the 'fit_params' toolbar icon.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client, popups=True)
|
||||
|
||||
assert "fit_params" in wf.toolbar.widgets
|
||||
|
||||
fit_action = wf.toolbar.widgets["fit_params"].action
|
||||
assert fit_action.isChecked() is False
|
||||
|
||||
wf.show_dap_summary_popup()
|
||||
|
||||
assert wf.dap_summary_dialog is not None
|
||||
assert wf.dap_summary_dialog.isVisible()
|
||||
assert fit_action.isChecked() is True
|
||||
|
||||
wf.dap_summary_dialog.close()
|
||||
assert wf.dap_summary_dialog is None
|
||||
assert fit_action.isChecked() is False
|
||||
@@ -1,573 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pyqtgraph as pg
|
||||
import pytest
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.qt_utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.colors import apply_theme, get_theme_palette, set_theme
|
||||
from bec_widgets.utils.linear_region_selector import LinearRegionWrapper
|
||||
from bec_widgets.widgets.containers.figure.plots.axis_settings import AxisSettings
|
||||
from bec_widgets.widgets.plots.waveform.waveform_popups.curve_dialog.curve_dialog import (
|
||||
CurveSettings,
|
||||
)
|
||||
from bec_widgets.widgets.plots.waveform.waveform_popups.dap_summary_dialog.dap_summary_dialog import (
|
||||
FitSummaryWidget,
|
||||
)
|
||||
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
from .conftest import create_widget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def waveform_widget(qtbot, mocked_client):
|
||||
models = ["GaussianModel", "LorentzModel", "SineModel"]
|
||||
mocked_client.dap._available_dap_plugins.keys.return_value = models
|
||||
widget = BECWaveformWidget(client=mocked_client())
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_waveform(waveform_widget):
|
||||
waveform_mock = MagicMock()
|
||||
waveform_widget.waveform = waveform_mock
|
||||
return waveform_mock
|
||||
|
||||
|
||||
def test_waveform_widget_init(waveform_widget):
|
||||
assert waveform_widget is not None
|
||||
assert waveform_widget.client is not None
|
||||
assert isinstance(waveform_widget, BECWaveformWidget)
|
||||
assert waveform_widget.config.widget_class == "BECWaveformWidget"
|
||||
|
||||
|
||||
###################################
|
||||
# Wrapper methods for Waveform
|
||||
###################################
|
||||
|
||||
|
||||
def test_waveform_widget_get_curve(waveform_widget, mock_waveform):
|
||||
waveform_widget.get_curve("curve_id")
|
||||
waveform_widget.waveform.get_curve.assert_called_once_with("curve_id")
|
||||
|
||||
|
||||
def test_waveform_widget_set_colormap(waveform_widget, mock_waveform):
|
||||
waveform_widget.set_colormap("colormap")
|
||||
waveform_widget.waveform.set_colormap.assert_called_once_with("colormap")
|
||||
|
||||
|
||||
def test_waveform_widget_set_x(waveform_widget, mock_waveform):
|
||||
waveform_widget.set_x("samx", "samx")
|
||||
waveform_widget.waveform.set_x.assert_called_once_with("samx", "samx")
|
||||
|
||||
|
||||
def test_waveform_plot_data(waveform_widget, mock_waveform):
|
||||
waveform_widget.plot(x=[1, 2, 3], y=[1, 2, 3])
|
||||
waveform_widget.waveform.plot.assert_called_once_with(
|
||||
arg1=None,
|
||||
x=[1, 2, 3],
|
||||
y=[1, 2, 3],
|
||||
x_name=None,
|
||||
y_name=None,
|
||||
z_name=None,
|
||||
x_entry=None,
|
||||
y_entry=None,
|
||||
z_entry=None,
|
||||
color=None,
|
||||
color_map_z="magma",
|
||||
label=None,
|
||||
validate=True,
|
||||
dap=None,
|
||||
)
|
||||
|
||||
|
||||
def test_waveform_plot_scan_curves(waveform_widget, mock_waveform):
|
||||
waveform_widget.plot(x_name="samx", y_name="samy", dap="GaussianModel")
|
||||
waveform_widget.waveform.plot.assert_called_once_with(
|
||||
arg1=None,
|
||||
x=None,
|
||||
y=None,
|
||||
x_name="samx",
|
||||
y_name="samy",
|
||||
z_name=None,
|
||||
x_entry=None,
|
||||
y_entry=None,
|
||||
z_entry=None,
|
||||
color=None,
|
||||
color_map_z="magma",
|
||||
label=None,
|
||||
validate=True,
|
||||
dap="GaussianModel",
|
||||
)
|
||||
|
||||
|
||||
def test_waveform_widget_add_dap(waveform_widget, mock_waveform):
|
||||
waveform_widget.add_dap(x_name="samx", y_name="bpm4i", dap="GaussianModel")
|
||||
waveform_widget.waveform.add_dap.assert_called_once_with(
|
||||
x_name="samx",
|
||||
y_name="bpm4i",
|
||||
x_entry=None,
|
||||
y_entry=None,
|
||||
color=None,
|
||||
dap="GaussianModel",
|
||||
validate_bec=True,
|
||||
)
|
||||
|
||||
|
||||
def test_waveform_widget_get_dap_params(waveform_widget, mock_waveform):
|
||||
waveform_widget.get_dap_params()
|
||||
waveform_widget.waveform.get_dap_params.assert_called_once()
|
||||
|
||||
|
||||
def test_waveform_widget_get_dap_summary(waveform_widget, mock_waveform):
|
||||
waveform_widget.get_dap_summary()
|
||||
waveform_widget.waveform.get_dap_summary.assert_called_once()
|
||||
|
||||
|
||||
def test_waveform_widget_remove_curve(waveform_widget, mock_waveform):
|
||||
waveform_widget.remove_curve("curve_id")
|
||||
waveform_widget.waveform.remove_curve.assert_called_once_with("curve_id")
|
||||
|
||||
|
||||
def test_waveform_widget_scan_history(waveform_widget, mock_waveform):
|
||||
waveform_widget.scan_history(0)
|
||||
waveform_widget.waveform.scan_history.assert_called_once_with(0, None)
|
||||
|
||||
|
||||
def test_waveform_widget_get_all_data(waveform_widget, mock_waveform):
|
||||
waveform_widget.get_all_data()
|
||||
waveform_widget.waveform.get_all_data.assert_called_once()
|
||||
|
||||
|
||||
def test_waveform_widget_set_title(waveform_widget, mock_waveform):
|
||||
waveform_widget.set_title("Title")
|
||||
waveform_widget.waveform.set_title.assert_called_once_with("Title")
|
||||
|
||||
|
||||
def test_waveform_widget_set_base(waveform_widget, mock_waveform):
|
||||
waveform_widget.set(
|
||||
title="Test Title",
|
||||
x_label="X Label",
|
||||
y_label="Y Label",
|
||||
x_scale="linear",
|
||||
y_scale="log",
|
||||
x_lim=(0, 10),
|
||||
y_lim=(0, 10),
|
||||
legend_label_size=12,
|
||||
)
|
||||
waveform_widget.waveform.set.assert_called_once_with(
|
||||
title="Test Title",
|
||||
x_label="X Label",
|
||||
y_label="Y Label",
|
||||
x_scale="linear",
|
||||
y_scale="log",
|
||||
x_lim=(0, 10),
|
||||
y_lim=(0, 10),
|
||||
legend_label_size=12,
|
||||
)
|
||||
|
||||
|
||||
def test_waveform_widget_set_x_label(waveform_widget, mock_waveform):
|
||||
waveform_widget.set_x_label("X Label")
|
||||
waveform_widget.waveform.set_x_label.assert_called_once_with("X Label")
|
||||
|
||||
|
||||
def test_waveform_widget_set_y_label(waveform_widget, mock_waveform):
|
||||
waveform_widget.set_y_label("Y Label")
|
||||
waveform_widget.waveform.set_y_label.assert_called_once_with("Y Label")
|
||||
|
||||
|
||||
def test_waveform_widget_set_x_scale(waveform_widget, mock_waveform):
|
||||
waveform_widget.set_x_scale("linear")
|
||||
waveform_widget.waveform.set_x_scale.assert_called_once_with("linear")
|
||||
|
||||
|
||||
def test_waveform_widget_set_y_scale(waveform_widget, mock_waveform):
|
||||
waveform_widget.set_y_scale("log")
|
||||
waveform_widget.waveform.set_y_scale.assert_called_once_with("log")
|
||||
|
||||
|
||||
def test_waveform_widget_set_x_lim(waveform_widget, mock_waveform):
|
||||
waveform_widget.set_x_lim((0, 10))
|
||||
waveform_widget.waveform.set_x_lim.assert_called_once_with((0, 10))
|
||||
|
||||
|
||||
def test_waveform_widget_set_y_lim(waveform_widget, mock_waveform):
|
||||
waveform_widget.set_y_lim((0, 10))
|
||||
waveform_widget.waveform.set_y_lim.assert_called_once_with((0, 10))
|
||||
|
||||
|
||||
def test_waveform_widget_set_legend_label_size(waveform_widget, mock_waveform):
|
||||
waveform_widget.set_legend_label_size(12)
|
||||
waveform_widget.waveform.set_legend_label_size.assert_called_once_with(12)
|
||||
|
||||
|
||||
def test_waveform_widget_set_auto_range(waveform_widget, mock_waveform):
|
||||
waveform_widget.set_auto_range(True, "xy")
|
||||
waveform_widget.waveform.set_auto_range.assert_called_once_with(True, "xy")
|
||||
|
||||
|
||||
def test_waveform_widget_set_grid(waveform_widget, mock_waveform):
|
||||
waveform_widget.set_grid(True, False)
|
||||
waveform_widget.waveform.set_grid.assert_called_once_with(True, False)
|
||||
|
||||
|
||||
def test_waveform_widget_lock_aspect_ratio(waveform_widget, mock_waveform):
|
||||
waveform_widget.lock_aspect_ratio(True)
|
||||
waveform_widget.waveform.lock_aspect_ratio.assert_called_once_with(True)
|
||||
|
||||
|
||||
def test_waveform_widget_export(waveform_widget, mock_waveform):
|
||||
waveform_widget.export()
|
||||
waveform_widget.waveform.export.assert_called_once()
|
||||
|
||||
|
||||
###################################
|
||||
# ToolBar interactions
|
||||
###################################
|
||||
|
||||
|
||||
def test_toolbar_drag_mode_action_triggered(waveform_widget, qtbot):
|
||||
action_drag = waveform_widget.toolbar.widgets["drag_mode"].action
|
||||
action_rectangle = waveform_widget.toolbar.widgets["rectangle_mode"].action
|
||||
action_drag.trigger()
|
||||
assert action_drag.isChecked() == True
|
||||
assert action_rectangle.isChecked() == False
|
||||
|
||||
|
||||
def test_toolbar_rectangle_mode_action_triggered(waveform_widget, qtbot):
|
||||
action_drag = waveform_widget.toolbar.widgets["drag_mode"].action
|
||||
action_rectangle = waveform_widget.toolbar.widgets["rectangle_mode"].action
|
||||
action_rectangle.trigger()
|
||||
assert action_drag.isChecked() == False
|
||||
assert action_rectangle.isChecked() == True
|
||||
|
||||
|
||||
def test_toolbar_auto_range_action_triggered(waveform_widget, mock_waveform, qtbot):
|
||||
action = waveform_widget.toolbar.widgets["auto_range"].action
|
||||
action.trigger()
|
||||
qtbot.wait(200)
|
||||
waveform_widget.waveform.set_auto_range.assert_called_once_with(True, "xy")
|
||||
|
||||
|
||||
def test_enable_mouse_pan_mode(qtbot, waveform_widget):
|
||||
action_drag = waveform_widget.toolbar.widgets["drag_mode"].action
|
||||
action_rectangle = waveform_widget.toolbar.widgets["rectangle_mode"].action
|
||||
|
||||
mock_view_box = MagicMock()
|
||||
waveform_widget.waveform.plot_item.getViewBox = MagicMock(return_value=mock_view_box)
|
||||
|
||||
waveform_widget.enable_mouse_pan_mode()
|
||||
|
||||
assert action_drag.isChecked() == True
|
||||
assert action_rectangle.isChecked() == False
|
||||
mock_view_box.setMouseMode.assert_called_once_with(pg.ViewBox.PanMode)
|
||||
|
||||
|
||||
###################################
|
||||
# Curve Dialog Tests
|
||||
###################################
|
||||
def show_curve_dialog(qtbot, waveform_widget):
|
||||
curve_dialog = SettingsDialog(
|
||||
waveform_widget,
|
||||
settings_widget=CurveSettings(),
|
||||
window_title="Curve Settings",
|
||||
config=waveform_widget.waveform._curves_data,
|
||||
)
|
||||
qtbot.addWidget(curve_dialog)
|
||||
qtbot.waitExposed(curve_dialog)
|
||||
return curve_dialog
|
||||
|
||||
|
||||
def test_curve_dialog_scan_curves_interactions(qtbot, waveform_widget):
|
||||
waveform_widget.plot(y_name="bpm4i")
|
||||
waveform_widget.plot(y_name="bpm3a")
|
||||
|
||||
curve_dialog = show_curve_dialog(qtbot, waveform_widget)
|
||||
|
||||
# Check default display of config from waveform widget
|
||||
assert curve_dialog is not None
|
||||
assert curve_dialog.widget.ui.scan_table.rowCount() == 2
|
||||
assert curve_dialog.widget.ui.scan_table.cellWidget(0, 0).text() == "bpm4i"
|
||||
assert curve_dialog.widget.ui.scan_table.cellWidget(0, 1).text() == "bpm4i"
|
||||
assert curve_dialog.widget.ui.scan_table.cellWidget(1, 0).text() == "bpm3a"
|
||||
assert curve_dialog.widget.ui.scan_table.cellWidget(1, 1).text() == "bpm3a"
|
||||
assert curve_dialog.widget.ui.x_mode.currentText() == "best_effort"
|
||||
assert curve_dialog.widget.ui.x_name.isEnabled() == False
|
||||
assert curve_dialog.widget.ui.x_entry.isEnabled() == False
|
||||
|
||||
# Add a new curve
|
||||
curve_dialog.widget.ui.add_curve.click()
|
||||
qtbot.wait(200)
|
||||
assert curve_dialog.widget.ui.scan_table.rowCount() == 3
|
||||
|
||||
# Set device to new curve
|
||||
curve_dialog.widget.ui.scan_table.cellWidget(2, 0).setText("bpm3i")
|
||||
|
||||
# Change the x mode to device
|
||||
curve_dialog.widget.ui.x_mode.setCurrentText("device")
|
||||
qtbot.wait(200)
|
||||
assert curve_dialog.widget.ui.x_name.isEnabled() == True
|
||||
assert curve_dialog.widget.ui.x_entry.isEnabled() == True
|
||||
|
||||
# Set the x device
|
||||
curve_dialog.widget.ui.x_name.setText("samx")
|
||||
|
||||
# Delete first curve ('bpm4i')
|
||||
curve_dialog.widget.ui.scan_table.cellWidget(0, 6).click()
|
||||
qtbot.wait(200)
|
||||
assert curve_dialog.widget.ui.scan_table.rowCount() == 2
|
||||
assert curve_dialog.widget.ui.scan_table.cellWidget(0, 0).text() == "bpm3a"
|
||||
assert curve_dialog.widget.ui.scan_table.cellWidget(0, 1).text() == "bpm3a"
|
||||
assert curve_dialog.widget.ui.scan_table.cellWidget(1, 0).text() == "bpm3i"
|
||||
|
||||
# Close the dialog
|
||||
curve_dialog.accept()
|
||||
qtbot.wait(200)
|
||||
|
||||
# Check the curve data in the target widget
|
||||
assert list(waveform_widget.waveform._curves_data["scan_segment"].keys()) == [
|
||||
"bpm3a-bpm3a",
|
||||
"bpm3i-bpm3i",
|
||||
]
|
||||
assert len(waveform_widget.curves) == 2
|
||||
|
||||
|
||||
def test_curve_dialog_async(qtbot, waveform_widget):
|
||||
waveform_widget.plot(y_name="bpm4i")
|
||||
waveform_widget.plot(y_name="async_device")
|
||||
|
||||
curve_dialog = show_curve_dialog(qtbot, waveform_widget)
|
||||
|
||||
assert curve_dialog is not None
|
||||
assert curve_dialog.widget.ui.scan_table.rowCount() == 2
|
||||
assert curve_dialog.widget.ui.scan_table.cellWidget(0, 0).text() == "bpm4i"
|
||||
assert curve_dialog.widget.ui.scan_table.cellWidget(0, 1).text() == "bpm4i"
|
||||
assert curve_dialog.widget.ui.scan_table.cellWidget(1, 0).text() == "async_device"
|
||||
assert curve_dialog.widget.ui.scan_table.cellWidget(1, 1).text() == "async_device"
|
||||
|
||||
|
||||
def test_curve_dialog_dap(qtbot, waveform_widget):
|
||||
# Don't use default dap for curve_dialog dialog
|
||||
waveform_widget.plot(x_name="samx", y_name="bpm4i", dap="LorentzModel")
|
||||
|
||||
curve_dialog = show_curve_dialog(qtbot, waveform_widget)
|
||||
|
||||
assert curve_dialog is not None
|
||||
assert curve_dialog.widget.ui.scan_table.rowCount() == 1
|
||||
assert curve_dialog.widget.ui.scan_table.cellWidget(0, 0).text() == "bpm4i"
|
||||
assert curve_dialog.widget.ui.scan_table.cellWidget(0, 1).text() == "bpm4i"
|
||||
assert curve_dialog.widget.ui.dap_table.isEnabled() == True
|
||||
assert curve_dialog.widget.ui.dap_table.rowCount() == 1
|
||||
assert curve_dialog.widget.ui.dap_table.cellWidget(0, 0).text() == "bpm4i"
|
||||
assert curve_dialog.widget.ui.dap_table.cellWidget(0, 1).text() == "bpm4i"
|
||||
assert curve_dialog.widget.ui.dap_table.cellWidget(0, 2).currentText() == "LorentzModel"
|
||||
assert curve_dialog.widget.ui.x_mode.currentText() == "device"
|
||||
assert curve_dialog.widget.ui.x_name.isEnabled() == True
|
||||
assert curve_dialog.widget.ui.x_entry.isEnabled() == True
|
||||
assert curve_dialog.widget.ui.x_name.text() == "samx"
|
||||
assert curve_dialog.widget.ui.x_entry.text() == "samx"
|
||||
|
||||
curve_dialog.accept()
|
||||
qtbot.wait(200)
|
||||
|
||||
assert list(waveform_widget.waveform._curves_data["scan_segment"].keys()) == ["bpm4i-bpm4i"]
|
||||
assert len(waveform_widget.curves) == 2
|
||||
|
||||
|
||||
def test_fit_dialog_summary(qtbot, waveform_widget):
|
||||
"""Test the fit dialog summary widget"""
|
||||
waveform_widget.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
|
||||
fit_dialog_summary = create_widget(qtbot, FitSummaryWidget, target_widget=waveform_widget)
|
||||
assert fit_dialog_summary.dap_dialog.fit_curve_id == "bpm4i-bpm4i-GaussianModel"
|
||||
assert fit_dialog_summary.dap_dialog.ui.curve_list.count() == 1
|
||||
|
||||
|
||||
###################################
|
||||
# Axis Dialog Tests
|
||||
###################################
|
||||
|
||||
|
||||
def show_axis_dialog(qtbot, waveform_widget):
|
||||
axis_dialog = SettingsDialog(
|
||||
waveform_widget,
|
||||
settings_widget=AxisSettings(),
|
||||
window_title="Axis Settings",
|
||||
config=waveform_widget._config_dict["axis"],
|
||||
)
|
||||
qtbot.addWidget(axis_dialog)
|
||||
qtbot.waitExposed(axis_dialog)
|
||||
return axis_dialog
|
||||
|
||||
|
||||
def test_axis_dialog_with_axis_limits(qtbot, waveform_widget):
|
||||
waveform_widget.set(
|
||||
title="Test Title",
|
||||
x_label="X Label",
|
||||
y_label="Y Label",
|
||||
x_scale="linear",
|
||||
y_scale="log",
|
||||
x_lim=(0, 10),
|
||||
y_lim=(0, 10),
|
||||
)
|
||||
|
||||
axis_dialog = show_axis_dialog(qtbot, waveform_widget)
|
||||
|
||||
assert axis_dialog is not None
|
||||
assert axis_dialog.widget.ui.plot_title.text() == "Test Title"
|
||||
assert axis_dialog.widget.ui.x_label.text() == "X Label"
|
||||
assert axis_dialog.widget.ui.y_label.text() == "Y Label"
|
||||
assert axis_dialog.widget.ui.x_scale.currentText() == "linear"
|
||||
assert axis_dialog.widget.ui.y_scale.currentText() == "log"
|
||||
assert axis_dialog.widget.ui.x_min.value() == 0
|
||||
assert axis_dialog.widget.ui.x_max.value() == 10
|
||||
assert axis_dialog.widget.ui.y_min.value() == 0
|
||||
assert axis_dialog.widget.ui.y_max.value() == 10
|
||||
|
||||
|
||||
def test_axis_dialog_without_axis_limits(qtbot, waveform_widget):
|
||||
waveform_widget.set(
|
||||
title="Test Title", x_label="X Label", y_label="Y Label", x_scale="linear", y_scale="log"
|
||||
)
|
||||
x_range = waveform_widget.fig.widget_list[0].plot_item.viewRange()[0]
|
||||
y_range = waveform_widget.fig.widget_list[0].plot_item.viewRange()[1]
|
||||
|
||||
axis_dialog = show_axis_dialog(qtbot, waveform_widget)
|
||||
|
||||
assert axis_dialog is not None
|
||||
assert axis_dialog.widget.ui.plot_title.text() == "Test Title"
|
||||
assert axis_dialog.widget.ui.x_label.text() == "X Label"
|
||||
assert axis_dialog.widget.ui.y_label.text() == "Y Label"
|
||||
assert axis_dialog.widget.ui.x_scale.currentText() == "linear"
|
||||
assert axis_dialog.widget.ui.y_scale.currentText() == "log"
|
||||
assert axis_dialog.widget.ui.x_min.value() == x_range[0]
|
||||
assert axis_dialog.widget.ui.x_max.value() == x_range[1]
|
||||
assert axis_dialog.widget.ui.y_min.value() == y_range[0]
|
||||
assert axis_dialog.widget.ui.y_max.value() == y_range[1]
|
||||
|
||||
|
||||
def test_axis_dialog_set_properties(qtbot, waveform_widget):
|
||||
axis_dialog = show_axis_dialog(qtbot, waveform_widget)
|
||||
|
||||
axis_dialog.widget.ui.plot_title.setText("New Title")
|
||||
axis_dialog.widget.ui.x_label.setText("New X Label")
|
||||
axis_dialog.widget.ui.y_label.setText("New Y Label")
|
||||
axis_dialog.widget.ui.x_scale.setCurrentText("log")
|
||||
axis_dialog.widget.ui.y_scale.setCurrentText("linear")
|
||||
axis_dialog.widget.ui.x_min.setValue(5)
|
||||
axis_dialog.widget.ui.x_max.setValue(15)
|
||||
axis_dialog.widget.ui.y_min.setValue(5)
|
||||
axis_dialog.widget.ui.y_max.setValue(15)
|
||||
|
||||
axis_dialog.accept()
|
||||
|
||||
assert waveform_widget._config_dict["axis"]["title"] == "New Title"
|
||||
assert waveform_widget._config_dict["axis"]["x_label"] == "New X Label"
|
||||
assert waveform_widget._config_dict["axis"]["y_label"] == "New Y Label"
|
||||
assert waveform_widget._config_dict["axis"]["x_scale"] == "log"
|
||||
assert waveform_widget._config_dict["axis"]["y_scale"] == "linear"
|
||||
assert waveform_widget._config_dict["axis"]["x_lim"] == (5, 15)
|
||||
assert waveform_widget._config_dict["axis"]["y_lim"] == (5, 15)
|
||||
|
||||
|
||||
def test_waveform_widget_theme_update(qtbot, waveform_widget):
|
||||
"""Test theme update for waveform widget."""
|
||||
qapp = QApplication.instance()
|
||||
|
||||
# Set the theme directly; equivalent to clicking the dark mode button
|
||||
# The background color should be black and the axis color should be white
|
||||
set_theme("dark")
|
||||
palette = get_theme_palette()
|
||||
waveform_color_dark = waveform_widget.waveform.plot_item.getAxis("left").pen().color()
|
||||
bg_color = waveform_widget.fig.backgroundBrush().color()
|
||||
assert bg_color == QColor(20, 20, 20)
|
||||
assert waveform_color_dark == palette.text().color()
|
||||
|
||||
# Set the theme to light; equivalent to clicking the light mode button
|
||||
# The background color should be white and the axis color should be black
|
||||
set_theme("light")
|
||||
palette = get_theme_palette()
|
||||
waveform_color_light = waveform_widget.waveform.plot_item.getAxis("left").pen().color()
|
||||
bg_color = waveform_widget.fig.backgroundBrush().color()
|
||||
assert bg_color == QColor(233, 236, 239)
|
||||
assert waveform_color_light == palette.text().color()
|
||||
|
||||
assert waveform_color_dark != waveform_color_light
|
||||
|
||||
# Set the theme to auto; equivalent starting the application with no theme set
|
||||
set_theme("auto")
|
||||
# Simulate that the OS theme changes to dark
|
||||
qapp.theme_signal.theme_updated.emit("dark")
|
||||
apply_theme("dark")
|
||||
|
||||
# The background color should be black and the axis color should be white
|
||||
# As we don't have access to the listener here, we can't test the palette change. Instead,
|
||||
# we compare the waveform color to the dark theme color
|
||||
waveform_color = waveform_widget.waveform.plot_item.getAxis("left").pen().color()
|
||||
bg_color = waveform_widget.fig.backgroundBrush().color()
|
||||
assert bg_color == QColor(20, 20, 20)
|
||||
assert waveform_color == waveform_color_dark
|
||||
|
||||
|
||||
def test_waveform_roi_selection_creation(waveform_widget, qtbot):
|
||||
"""Test ROI selection for waveform widget.
|
||||
|
||||
This checks that the ROI select is properly created and removed when the button is toggled.
|
||||
"""
|
||||
# Check if curve is create upon ROI select slot
|
||||
# This also checks that the button in the toolbar works
|
||||
container = []
|
||||
|
||||
def callback(msg):
|
||||
container.append(msg)
|
||||
|
||||
waveform_widget.waveform.roi_active.connect(callback)
|
||||
assert waveform_widget.waveform.roi_select is None
|
||||
assert waveform_widget.waveform.roi_region == (None, None)
|
||||
# Toggle the ROI select
|
||||
waveform_widget.toogle_roi_select(True)
|
||||
assert isinstance(waveform_widget.waveform.roi_select, LinearRegionWrapper)
|
||||
# This is the default region for the pg.LinearRegionItem
|
||||
assert waveform_widget.waveform.roi_region == (0, 1)
|
||||
# Untoggle the ROI select
|
||||
waveform_widget.toogle_roi_select(False)
|
||||
assert waveform_widget.waveform.roi_select is None
|
||||
assert container[0] is True
|
||||
assert container[1] is False
|
||||
|
||||
|
||||
def test_waveform_roi_selection_updates_fit(waveform_widget, qtbot):
|
||||
"""This test checks that upon selection of a new region, the fit is updated and all signals are emitted as expected."""
|
||||
container = []
|
||||
|
||||
def callback(msg):
|
||||
container.append(msg)
|
||||
|
||||
waveform_widget.waveform.roi_changed.connect(callback)
|
||||
# Mock refresh_dap method
|
||||
with patch.object(waveform_widget.waveform, "refresh_dap") as mock_refresh_dap:
|
||||
waveform_widget.toogle_roi_select(True)
|
||||
waveform_widget.waveform.roi_select.linear_region_selector.setRegion([0.5, 1.5])
|
||||
qtbot.wait(200)
|
||||
assert waveform_widget.waveform.roi_region == (0.5, 1.5)
|
||||
waveform_widget.toogle_roi_select(False)
|
||||
assert waveform_widget.waveform.roi_region == (None, None)
|
||||
assert len(container) == 1
|
||||
assert container[0] == (0.5, 1.5)
|
||||
# 3 refresh DAP calls: 1x upon hook, 1x unhook and 1x from roi_changed
|
||||
assert mock_refresh_dap.call_count == 3
|
||||
|
||||
|
||||
def test_waveform_roi_selection_change_color(waveform_widget, qtbot):
|
||||
"""This test checks that the color of the ROI region can be changed."""
|
||||
waveform_widget.toogle_roi_select(True)
|
||||
waveform_widget.waveform.roi_select.change_roi_color((QColor("red"), QColor("blue")))
|
||||
# I can only get the brush from the RegionSelectItem
|
||||
assert (
|
||||
waveform_widget.waveform.roi_select.linear_region_selector.currentBrush.color()
|
||||
== QColor("red")
|
||||
)
|
||||
Reference in New Issue
Block a user