mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-09-06 16:40:56 +02:00
feat(multi_waveform): select device and signal with class-based routing
This commit is contained in:
@@ -4536,14 +4536,28 @@ class MultiWaveform(RPCBase):
|
||||
@rpc_call
|
||||
def monitor(self) -> "str":
|
||||
"""
|
||||
The monitor of the figure widget.
|
||||
The monitored device of the figure widget (alias of ``device``).
|
||||
"""
|
||||
|
||||
@monitor.setter
|
||||
@rpc_call
|
||||
def monitor(self) -> "str":
|
||||
"""
|
||||
The monitor of the figure widget.
|
||||
The monitored device of the figure widget (alias of ``device``).
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def monitor_signal(self) -> "str":
|
||||
"""
|
||||
The monitored signal/entry on the device (alias of ``signal``).
|
||||
"""
|
||||
|
||||
@monitor_signal.setter
|
||||
@rpc_call
|
||||
def monitor_signal(self) -> "str":
|
||||
"""
|
||||
The monitored signal/entry on the device (alias of ``signal``).
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
@@ -4557,11 +4571,18 @@ class MultiWaveform(RPCBase):
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def plot(self, monitor: "str", color_palette: "str | None" = "plasma"):
|
||||
def plot(
|
||||
self, monitor: "str", signal: "str | None" = None, color_palette: "str | None" = "plasma"
|
||||
):
|
||||
"""
|
||||
Create a plot for the given monitor.
|
||||
Create a plot for the given monitor device and signal.
|
||||
|
||||
Args:
|
||||
monitor (str): The monitor to set.
|
||||
monitor (str): The device to monitor.
|
||||
signal (str|None): The signal/entry to monitor on the device. The reserved
|
||||
entry ``"monitor_1d"`` selects the scan-less device_monitor_1d stream.
|
||||
If None, the signal is auto-selected: the device's only 1D-capable
|
||||
preview/async signal if unambiguous, the monitor_1d stream otherwise.
|
||||
color_palette (str|None): The color palette to use for the plot.
|
||||
"""
|
||||
|
||||
|
||||
@@ -44,6 +44,9 @@ class DeviceInputConfig(ConnectionConfig):
|
||||
editable-combobox completer.
|
||||
include_signals_with_write_access: Whether to additionally list signals that report
|
||||
write access, in addition to the devices selected by the other filters.
|
||||
include_async_readout_devices: Whether to additionally list devices with async
|
||||
readout priority (potential ``device_monitor_1d`` publishers), in addition to
|
||||
the devices selected by the other filters.
|
||||
"""
|
||||
|
||||
device_filter: list[str] = Field(default_factory=list)
|
||||
@@ -55,6 +58,7 @@ class DeviceInputConfig(ConnectionConfig):
|
||||
signal_class_filter: list[str] = Field(default_factory=list)
|
||||
autocomplete: bool = False
|
||||
include_signals_with_write_access: bool = False
|
||||
include_async_readout_devices: bool = False
|
||||
|
||||
@field_validator("device_filter")
|
||||
@classmethod
|
||||
@@ -120,6 +124,9 @@ class DeviceComboBox(BECWidget, QComboBox):
|
||||
Qt's default editable-combobox completion behavior.
|
||||
include_signals_with_write_access: If True, additionally list signals that report
|
||||
write access on top of the devices selected by the other filters.
|
||||
include_async_readout_devices: If True, additionally list devices with async readout
|
||||
priority (potential ``device_monitor_1d`` publishers) on top of the devices
|
||||
selected by the other filters.
|
||||
**kwargs: Additional keyword arguments passed to ``BECWidget``.
|
||||
"""
|
||||
|
||||
@@ -152,6 +159,7 @@ class DeviceComboBox(BECWidget, QComboBox):
|
||||
signal_class_filter: list[str] | None = None,
|
||||
autocomplete: bool | None = None,
|
||||
include_signals_with_write_access: bool | None = None,
|
||||
include_async_readout_devices: bool | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
self.config = self._process_config(config)
|
||||
@@ -191,6 +199,8 @@ class DeviceComboBox(BECWidget, QComboBox):
|
||||
self.autocomplete = True
|
||||
if include_signals_with_write_access is not None:
|
||||
self.config.include_signals_with_write_access = include_signals_with_write_access
|
||||
if include_async_readout_devices is not None:
|
||||
self.config.include_async_readout_devices = include_async_readout_devices
|
||||
|
||||
if available_devices is not None:
|
||||
self.set_available_devices(available_devices)
|
||||
@@ -273,6 +283,14 @@ class DeviceComboBox(BECWidget, QComboBox):
|
||||
if name not in seen:
|
||||
device_names.append(name)
|
||||
seen.add(name)
|
||||
if self.include_async_readout_devices:
|
||||
seen = set(device_names)
|
||||
for device in enabled_devices:
|
||||
if device.name in seen:
|
||||
continue
|
||||
if getattr(device, "readout_priority", None) == ReadoutPriority.ASYNC:
|
||||
device_names.append(device.name)
|
||||
seen.add(device.name)
|
||||
self.devices = device_names
|
||||
|
||||
@SafeSlot(list)
|
||||
@@ -336,6 +354,16 @@ class DeviceComboBox(BECWidget, QComboBox):
|
||||
self.config.include_signals_with_write_access = value
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def include_async_readout_devices(self) -> bool:
|
||||
"""Whether async-readout devices are listed in addition to the other filters."""
|
||||
return self.config.include_async_readout_devices
|
||||
|
||||
@include_async_readout_devices.setter
|
||||
def include_async_readout_devices(self, value: bool):
|
||||
self.config.include_async_readout_devices = value
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def filter_to_device(self):
|
||||
"""Include generic Device objects."""
|
||||
|
||||
@@ -66,6 +66,8 @@ class SignalComboBox(BECWidget, QComboBox):
|
||||
require_device: If True, class-based signal filtering requires a valid selected device.
|
||||
autocomplete: If True, use the explicit line-edit style completer. If False, keep
|
||||
Qt's default editable-combobox completion behavior.
|
||||
extra_class_signals: Static ``(display_name, signal_config)`` entries prepended to
|
||||
class-based signal lists, e.g. reserved stream names such as ``"monitor_1d"``.
|
||||
**kwargs: Additional keyword arguments passed to ``BECWidget``.
|
||||
"""
|
||||
|
||||
@@ -93,6 +95,7 @@ class SignalComboBox(BECWidget, QComboBox):
|
||||
store_signal_config: bool = True,
|
||||
require_device: bool = False,
|
||||
autocomplete: bool | None = None,
|
||||
extra_class_signals: list[tuple[str, dict]] | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
self.config = self._process_config(config)
|
||||
@@ -109,6 +112,7 @@ class SignalComboBox(BECWidget, QComboBox):
|
||||
self._signal_class_filter = signal_class_filter or []
|
||||
self._store_signal_config = store_signal_config
|
||||
self._require_device = require_device
|
||||
self._extra_class_signals = list(extra_class_signals or [])
|
||||
self._is_valid_input = False
|
||||
self._completer_model = QStringListModel(self)
|
||||
|
||||
@@ -500,7 +504,12 @@ class SignalComboBox(BECWidget, QComboBox):
|
||||
"""
|
||||
if not self._store_signal_config:
|
||||
return None
|
||||
signal_info = self.itemData(self.currentIndex())
|
||||
# Resolve by the displayed text first: on editable comboboxes the current
|
||||
# index does not follow programmatic text changes.
|
||||
index = self._find_signal_index(self.currentText())
|
||||
if index < 0:
|
||||
index = self.currentIndex()
|
||||
signal_info = self.itemData(index)
|
||||
return signal_info if isinstance(signal_info, dict) else None
|
||||
|
||||
def update_signals_from_signal_classes(self, ndim_filter: int | list[int] | None = None):
|
||||
@@ -528,6 +537,11 @@ class SignalComboBox(BECWidget, QComboBox):
|
||||
|
||||
combo_items: list[str | tuple[str, dict]] = []
|
||||
item_tooltips: dict[int, str] = {}
|
||||
for entry_name, entry_config in self._extra_class_signals:
|
||||
if self._store_signal_config:
|
||||
combo_items.append((entry_name, dict(entry_config)))
|
||||
else:
|
||||
combo_items.append(entry_name)
|
||||
for device_name, signal_name, signal_config in signals:
|
||||
if self._device and device_name != self._device:
|
||||
continue
|
||||
|
||||
@@ -6,25 +6,57 @@ from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
|
||||
|
||||
#: Signal classes supported by the DataAPI-backed plot widgets.
|
||||
DEFAULT_SIGNAL_CLASSES = ["PreviewSignal", "AsyncSignal", "AsyncMultiSignal", "DynamicSignal"]
|
||||
|
||||
#: Reserved entry name of the scan-less device_monitor_1d stream (DataAPI device plugin).
|
||||
MONITOR_1D_ENTRY = "monitor_1d"
|
||||
|
||||
#: Signal-config shape offered for the monitor_1d sentinel entry. The capability is not
|
||||
#: introspectable from device info, so the entry is offered for every selected device.
|
||||
MONITOR_1D_SIGNAL_CONFIG = {
|
||||
"component_name": MONITOR_1D_ENTRY,
|
||||
"obj_name": MONITOR_1D_ENTRY,
|
||||
"signal_class": "DeviceMonitor1D",
|
||||
"describe": {"signal_info": {"ndim": 1}},
|
||||
}
|
||||
|
||||
|
||||
class DeviceSelection(QWidget):
|
||||
"""Device and signal selection widget for image toolbar."""
|
||||
"""Device and signal selection widget for plot widget toolbars.
|
||||
|
||||
def __init__(self, parent=None, client=None):
|
||||
Args:
|
||||
parent: Optional parent widget.
|
||||
client: The BEC client instance.
|
||||
signal_classes: Signal class names offered in the comboboxes. Defaults to
|
||||
``DEFAULT_SIGNAL_CLASSES``.
|
||||
ndim_filter: Dimensionality filter for the signal combobox. Defaults to ``[1, 2]``.
|
||||
include_monitor_1d: If True, offer the reserved ``"monitor_1d"`` stream entry in the
|
||||
signal combobox and additionally list async-readout devices in the device
|
||||
combobox (potential ``device_monitor_1d`` publishers).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
client=None,
|
||||
signal_classes: list[str] | None = None,
|
||||
ndim_filter: list[int] | None = None,
|
||||
include_monitor_1d: bool = False,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
self.client = client
|
||||
self.supported_signals = [
|
||||
"PreviewSignal",
|
||||
"AsyncSignal",
|
||||
"AsyncMultiSignal",
|
||||
"DynamicSignal",
|
||||
]
|
||||
self.supported_signals = list(signal_classes or DEFAULT_SIGNAL_CLASSES)
|
||||
ndim_filter = list(ndim_filter or [1, 2])
|
||||
|
||||
# Create device combobox with signal class filter
|
||||
# This will only show devices that have signals matching the supported signal classes
|
||||
self.device_combo_box = DeviceComboBox(
|
||||
parent=self, client=self.client, signal_class_filter=self.supported_signals
|
||||
parent=self,
|
||||
client=self.client,
|
||||
signal_class_filter=self.supported_signals,
|
||||
include_async_readout_devices=include_monitor_1d,
|
||||
)
|
||||
self.device_combo_box.setToolTip("Select Device")
|
||||
self.device_combo_box.setEditable(True)
|
||||
@@ -32,20 +64,18 @@ class DeviceSelection(QWidget):
|
||||
self.device_combo_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
self.device_combo_box.lineEdit().setPlaceholderText("Select Device")
|
||||
|
||||
# Configure SignalComboBox to filter by PreviewSignal and supported async signals
|
||||
# Also filter by ndim (1D and 2D only) for Image widget
|
||||
# Configure SignalComboBox to filter by the supported signal classes and the
|
||||
# requested dimensionality; optionally offer the reserved monitor_1d stream entry.
|
||||
self.signal_combo_box = SignalComboBox(
|
||||
parent=self,
|
||||
client=self.client,
|
||||
signal_class_filter=[
|
||||
"PreviewSignal",
|
||||
"AsyncSignal",
|
||||
"AsyncMultiSignal",
|
||||
"DynamicSignal",
|
||||
],
|
||||
ndim_filter=[1, 2], # Only show 1D and 2D signals for Image widget
|
||||
signal_class_filter=self.supported_signals,
|
||||
ndim_filter=ndim_filter,
|
||||
store_signal_config=True,
|
||||
require_device=True,
|
||||
extra_class_signals=(
|
||||
[(MONITOR_1D_ENTRY, MONITOR_1D_SIGNAL_CONFIG)] if include_monitor_1d else None
|
||||
),
|
||||
)
|
||||
self.signal_combo_box.setToolTip("Select Signal")
|
||||
self.signal_combo_box.setEditable(True)
|
||||
@@ -90,7 +120,7 @@ class DeviceSelection(QWidget):
|
||||
|
||||
# Sync signal combobox selection
|
||||
if signal:
|
||||
# Try to find the signal by component_name (which is what's displayed)
|
||||
# Try to find the signal by display text, component_name or obj_name
|
||||
found = False
|
||||
for i in range(self.signal_combo_box.count()):
|
||||
text = self.signal_combo_box.itemText(i)
|
||||
@@ -99,7 +129,8 @@ class DeviceSelection(QWidget):
|
||||
# Check if this matches our signal
|
||||
if config_data:
|
||||
component_name = config_data.get("component_name", "")
|
||||
if text == component_name or text == signal:
|
||||
obj_name = config_data.get("obj_name", "")
|
||||
if signal in (text, component_name, obj_name):
|
||||
self.signal_combo_box.setCurrentIndex(i)
|
||||
found = True
|
||||
break
|
||||
@@ -144,9 +175,11 @@ class DeviceSelection(QWidget):
|
||||
self.signal_combo_box.deleteLater()
|
||||
|
||||
|
||||
def device_selection_bundle(components: ToolbarComponents, client=None) -> ToolbarBundle:
|
||||
def device_selection_bundle(
|
||||
components: ToolbarComponents, client=None, **selection_kwargs
|
||||
) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a device selection toolbar bundle for Image widget.
|
||||
Creates a device selection toolbar bundle for plot widgets.
|
||||
|
||||
Includes a resizable splitter after the device selection. All subsequent bundles'
|
||||
actions will appear compactly after the splitter with no gaps.
|
||||
@@ -154,11 +187,15 @@ def device_selection_bundle(components: ToolbarComponents, client=None) -> Toolb
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
client: The BEC client instance.
|
||||
**selection_kwargs: Additional keyword arguments passed to ``DeviceSelection``
|
||||
(e.g. ``ndim_filter``, ``include_monitor_1d``).
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The device selection toolbar bundle.
|
||||
"""
|
||||
device_selection_widget = DeviceSelection(parent=components.toolbar, client=client)
|
||||
device_selection_widget = DeviceSelection(
|
||||
parent=components.toolbar, client=client, **selection_kwargs
|
||||
)
|
||||
components.add_safe(
|
||||
"device_selection", WidgetAction(widget=device_selection_widget, adjust_size=False)
|
||||
)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from typing import Literal
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field, ValidationError, field_validator
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtCore import QTimer, Signal
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
@@ -14,13 +14,16 @@ from bec_widgets.utils.colors import Colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.qt_data_subscription import QtDataSubscription
|
||||
from bec_widgets.utils.side_panel import SidePanel
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.utils.toolbars.actions import WidgetAction
|
||||
from bec_widgets.widgets.plots.image.toolbar_components.device_selection import (
|
||||
MONITOR_1D_ENTRY,
|
||||
DeviceSelection,
|
||||
DeviceSelectionConnection,
|
||||
device_selection_bundle,
|
||||
)
|
||||
from bec_widgets.widgets.plots.multi_waveform.settings.control_panel import (
|
||||
MultiWaveformControlPanel,
|
||||
)
|
||||
from bec_widgets.widgets.plots.multi_waveform.toolbar_components.monitor_selection import (
|
||||
monitor_selection_bundle,
|
||||
)
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
|
||||
|
||||
@@ -37,7 +40,18 @@ class MultiWaveformConfig(ConnectionConfig):
|
||||
flush_buffer: bool | None = Field(
|
||||
False, description="Flush the buffer of the plot widget when the curve limit is reached."
|
||||
)
|
||||
monitor: str | None = Field(None, description="The monitor to set for the plot widget.")
|
||||
monitor: str | None = Field(None, description="The device to monitor for the plot widget.")
|
||||
monitor_signal: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"The signal/entry to monitor on the device. The reserved entry "
|
||||
f"'{MONITOR_1D_ENTRY}' selects the scan-less device_monitor_1d stream."
|
||||
),
|
||||
)
|
||||
connection_status: Literal["connected", "disconnected", "error"] = Field(
|
||||
"disconnected", description="Current connection status."
|
||||
)
|
||||
connection_error: str | None = Field(None, description="Last connection error, if any.")
|
||||
curve_width: int | None = Field(1, description="The width of the curve on the plot.")
|
||||
opacity: int | None = Field(50, description="The opacity of the curve on the plot.")
|
||||
highlight_last_curve: bool | None = Field(
|
||||
@@ -73,12 +87,17 @@ class MultiWaveform(PlotBase):
|
||||
"max_trace.setter",
|
||||
"monitor",
|
||||
"monitor.setter",
|
||||
"monitor_signal",
|
||||
"monitor_signal.setter",
|
||||
"set_curve_limit",
|
||||
"plot",
|
||||
"set_curve_highlight",
|
||||
"clear_curves",
|
||||
]
|
||||
|
||||
#: Async signal classes routed through the scan-scoped (live-follow) DataAPI path.
|
||||
SUPPORTED_SIGNALS = ["AsyncSignal", "AsyncMultiSignal", "DynamicSignal"]
|
||||
|
||||
monitor_signal_updated = Signal()
|
||||
highlighted_curve_index_changed = Signal(int)
|
||||
|
||||
@@ -106,33 +125,53 @@ class MultiWaveform(PlotBase):
|
||||
self.visible_curves = []
|
||||
self.number_of_visible_curves = 0
|
||||
|
||||
# Data delivery through the DataAPI device stream (monitor_1d).
|
||||
# Data delivery through the DataAPI: scan-less device streams
|
||||
# (monitor_1d, preview signals) or scan-scoped async signals.
|
||||
self._data_bridge: QtDataSubscription | None = None
|
||||
self._source_key: tuple[str, str] | None = None
|
||||
self._last_ordinal: int | None = None
|
||||
self._signal_config: dict | None = None
|
||||
self._device_selection_updating = False
|
||||
|
||||
self._init_multiwaveform_toolbar()
|
||||
|
||||
@property
|
||||
def _config(self) -> MultiWaveformConfig:
|
||||
"""Connection-state view consumed by the shared DeviceSelectionConnection."""
|
||||
return self.config
|
||||
|
||||
################################################################################
|
||||
# Widget Specific GUI interactions
|
||||
################################################################################
|
||||
def _init_multiwaveform_toolbar(self):
|
||||
self.toolbar.add_bundle(
|
||||
monitor_selection_bundle(self.toolbar.components, target_widget=self)
|
||||
bundle = device_selection_bundle(
|
||||
self.toolbar.components, client=self.client, ndim_filter=[1], include_monitor_1d=True
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"color_map",
|
||||
WidgetAction(
|
||||
widget=BECColorMapWidget(cmap=self.config.color_palette), adjust_size=False
|
||||
),
|
||||
)
|
||||
bundle.add_action("color_map")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
self.toolbar.connect_bundle(
|
||||
"device_selection",
|
||||
DeviceSelectionConnection(self.toolbar.components, target_widget=self),
|
||||
)
|
||||
self.toolbar.toggle_action_visibility("reset_legend", visible=False)
|
||||
|
||||
combobox = self.toolbar.components.get_action("monitor_selection").widget
|
||||
combobox.currentTextChanged.connect(self.connect_monitor)
|
||||
|
||||
cmap = self.toolbar.components.get_action("color_map").widget
|
||||
cmap.colormap_changed_signal.connect(self.change_colormap)
|
||||
|
||||
bundles = self.toolbar.shown_bundles
|
||||
bundles.insert(0, "monitor_selection")
|
||||
bundles.insert(0, "device_selection")
|
||||
self.toolbar.show_bundles(bundles)
|
||||
|
||||
self._init_control_panel()
|
||||
|
||||
QTimer.singleShot(0, self._sync_device_selection)
|
||||
|
||||
def _init_control_panel(self):
|
||||
control_panel = SidePanel(self, orientation="top", panel_max_width=90)
|
||||
self.layout_manager.add_widget_relative(control_panel, self.round_plot_widget, "bottom")
|
||||
@@ -147,13 +186,67 @@ class MultiWaveform(PlotBase):
|
||||
control_panel.toolbar.components.get_action("control").action.trigger()
|
||||
|
||||
@SafeSlot()
|
||||
def connect_monitor(self, _):
|
||||
combobox = self.toolbar.components.get_action("monitor_selection").widget
|
||||
monitor = combobox.currentText()
|
||||
def on_device_selection_changed(self, _):
|
||||
"""
|
||||
Called when device or signal selection changes in the toolbar.
|
||||
This reads from the toolbar and updates the widget properties.
|
||||
"""
|
||||
if self._device_selection_updating:
|
||||
return
|
||||
|
||||
if monitor != "":
|
||||
if monitor != self.config.monitor:
|
||||
self.config.monitor = monitor
|
||||
self._device_selection_updating = True
|
||||
try:
|
||||
try:
|
||||
action = self.toolbar.components.get_action("device_selection")
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if action is None:
|
||||
return
|
||||
|
||||
device_selection: DeviceSelection = action.widget
|
||||
device = device_selection.device_combo_box.currentText()
|
||||
signal_text = device_selection.signal_combo_box.currentText()
|
||||
|
||||
if not device:
|
||||
self.device = ""
|
||||
return
|
||||
if not device_selection.device_combo_box.is_valid_input:
|
||||
return
|
||||
|
||||
if not device_selection.signal_combo_box.is_valid_input:
|
||||
if self.config.monitor_signal:
|
||||
self.signal = ""
|
||||
if device != (self.config.monitor or ""):
|
||||
self.device = device
|
||||
return
|
||||
|
||||
if device == self.config.monitor and signal_text == self.config.monitor_signal:
|
||||
return
|
||||
|
||||
# Get the signal config stored in the combobox
|
||||
signal_config = device_selection.signal_combo_box.get_signal_config()
|
||||
|
||||
if not signal_config and signal_text != MONITOR_1D_ENTRY:
|
||||
# Fallback: try to get config from device
|
||||
try:
|
||||
device_obj = self.dev[device]
|
||||
signal_config = device_obj._info["signals"].get(signal_text, {})
|
||||
except (KeyError, AttributeError):
|
||||
logger.warning(f"Could not get signal config for {device}.{signal_text}")
|
||||
signal_config = None
|
||||
|
||||
# Store signal config and set properties which will trigger the connection
|
||||
pending_config = signal_config if isinstance(signal_config, dict) else None
|
||||
if device != (self.config.monitor or ""):
|
||||
self._signal_config = pending_config
|
||||
self.device = device
|
||||
# The device setter may replace the pending config while reconnecting
|
||||
# the previous signal; restore it for the signal connection.
|
||||
self._signal_config = pending_config
|
||||
self.signal = signal_text
|
||||
finally:
|
||||
self._device_selection_updating = False
|
||||
|
||||
@SafeSlot(str)
|
||||
def change_colormap(self, colormap: str):
|
||||
@@ -221,7 +314,7 @@ class MultiWaveform(PlotBase):
|
||||
except ValidationError:
|
||||
return
|
||||
self.set_curve_highlight(self._current_highlight_index)
|
||||
self._sync_monitor_selection_toolbar()
|
||||
self._sync_colormap_toolbar()
|
||||
|
||||
@SafeProperty(int)
|
||||
def opacity(self) -> int:
|
||||
@@ -278,41 +371,195 @@ class MultiWaveform(PlotBase):
|
||||
max_trace=self.config.curve_limit, flush_buffer=self.config.flush_buffer
|
||||
)
|
||||
|
||||
################################################################################
|
||||
# Data Acquisition
|
||||
################################################################################
|
||||
|
||||
@SafeProperty(str, auto_emit=True, designable=False)
|
||||
def device(self) -> str:
|
||||
"""
|
||||
The name of the device to monitor for waveform data.
|
||||
"""
|
||||
return self.config.monitor or ""
|
||||
|
||||
@device.setter
|
||||
def device(self, value: str):
|
||||
"""
|
||||
Set the device name for the plot. This should be used together with signal.
|
||||
When both device and signal are set, the widget connects to that device signal.
|
||||
|
||||
Args:
|
||||
value(str): The name of the device to monitor.
|
||||
"""
|
||||
if not value:
|
||||
# Clear the monitor if empty device name
|
||||
if self.config.monitor:
|
||||
self._cleanup_data_api_subscription()
|
||||
self.config.monitor = None
|
||||
self.config.monitor_signal = None
|
||||
self._signal_config = None
|
||||
self._set_connection_status("disconnected")
|
||||
return
|
||||
|
||||
old_device = self.config.monitor
|
||||
if old_device and self.config.monitor_signal and old_device != value:
|
||||
self._cleanup_data_api_subscription()
|
||||
self.config.monitor = value
|
||||
|
||||
# If we have a signal, reconnect with the new device
|
||||
signal = self.config.monitor_signal
|
||||
if not signal:
|
||||
return
|
||||
if signal == MONITOR_1D_ENTRY:
|
||||
self._signal_config = None
|
||||
self._setup_data_api_subscription()
|
||||
return
|
||||
try:
|
||||
device_obj = self.dev[value]
|
||||
signal_config = device_obj._info.get("signals", {}).get(signal)
|
||||
except (KeyError, AttributeError):
|
||||
logger.warning(f"Device '{value}' not found")
|
||||
self._cleanup_data_api_subscription()
|
||||
self._set_connection_status("error", f"Device '{value}' not found")
|
||||
return
|
||||
if isinstance(signal_config, dict) and signal_config.get("signal_class"):
|
||||
self._signal_config = signal_config
|
||||
self._setup_data_api_subscription()
|
||||
else:
|
||||
logger.warning(f"Signal '{signal}' doesn't exist on device '{value}'")
|
||||
self._cleanup_data_api_subscription()
|
||||
self.config.monitor_signal = None
|
||||
self._signal_config = None
|
||||
self._set_connection_status("error", f"Signal '{signal}' doesn't exist")
|
||||
|
||||
@SafeProperty(str, auto_emit=True, designable=False)
|
||||
def signal(self) -> str:
|
||||
"""
|
||||
The signal/entry name to monitor on the device.
|
||||
"""
|
||||
return self.config.monitor_signal or ""
|
||||
|
||||
@signal.setter
|
||||
def signal(self, value: str):
|
||||
"""
|
||||
Set the device signal for the plot. This should be used together with device.
|
||||
When set, it will connect to updates from that device signal.
|
||||
|
||||
Args:
|
||||
value(str): The signal name to monitor. The reserved entry ``"monitor_1d"``
|
||||
selects the scan-less device_monitor_1d stream.
|
||||
"""
|
||||
if not value:
|
||||
if self.config.monitor_signal:
|
||||
self._cleanup_data_api_subscription()
|
||||
self.config.monitor_signal = None
|
||||
self._signal_config = None
|
||||
self._set_connection_status("disconnected")
|
||||
return
|
||||
|
||||
old_signal = self.config.monitor_signal
|
||||
if self.config.monitor and old_signal and old_signal != value:
|
||||
self._cleanup_data_api_subscription()
|
||||
self.config.monitor_signal = value
|
||||
|
||||
if not self.config.monitor:
|
||||
logger.debug(f"signal setter: No device set yet for signal '{value}'")
|
||||
return
|
||||
|
||||
if value == MONITOR_1D_ENTRY:
|
||||
self._signal_config = None
|
||||
self._setup_data_api_subscription()
|
||||
return
|
||||
|
||||
try:
|
||||
device_obj = self.dev[self.config.monitor]
|
||||
signal_config = device_obj._info["signals"].get(value)
|
||||
except (KeyError, AttributeError):
|
||||
signal_config = None
|
||||
if not isinstance(signal_config, dict) or not signal_config.get("signal_class"):
|
||||
# Fall back to a combobox-provided config for this entry, if any
|
||||
signal_config = self._signal_config
|
||||
if not self._signal_config_matches(signal_config, value):
|
||||
logger.warning(
|
||||
f"Could not find valid configuration for signal '{value}' "
|
||||
f"on device '{self.config.monitor}'."
|
||||
)
|
||||
self._signal_config = None
|
||||
self._set_connection_status("error", f"Signal '{value}' not found")
|
||||
return
|
||||
self._signal_config = signal_config
|
||||
self._setup_data_api_subscription()
|
||||
|
||||
@SafeProperty(str)
|
||||
def monitor(self) -> str:
|
||||
"""
|
||||
The monitor of the figure widget.
|
||||
The monitored device of the figure widget (alias of ``device``).
|
||||
"""
|
||||
return self.config.monitor
|
||||
|
||||
@monitor.setter
|
||||
def monitor(self, value: str):
|
||||
"""
|
||||
Set the monitor of the figure widget.
|
||||
Set the monitored device and connect to it, auto-selecting the signal.
|
||||
|
||||
Args:
|
||||
value(str): The monitor to set.
|
||||
value(str): The device to monitor.
|
||||
"""
|
||||
self.plot(value)
|
||||
|
||||
@SafeProperty(str)
|
||||
def monitor_signal(self) -> str:
|
||||
"""
|
||||
The monitored signal/entry on the device (alias of ``signal``).
|
||||
"""
|
||||
return self.config.monitor_signal
|
||||
|
||||
@monitor_signal.setter
|
||||
def monitor_signal(self, value: str):
|
||||
"""
|
||||
Set the monitored signal/entry on the device.
|
||||
|
||||
Args:
|
||||
value(str): The signal to monitor.
|
||||
"""
|
||||
self.signal = value
|
||||
|
||||
################################################################################
|
||||
# High Level methods for API
|
||||
################################################################################
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def plot(self, monitor: str, color_palette: str | None = "plasma"):
|
||||
def plot(self, monitor: str, signal: str | None = None, color_palette: str | None = "plasma"):
|
||||
"""
|
||||
Create a plot for the given monitor.
|
||||
Create a plot for the given monitor device and signal.
|
||||
|
||||
Args:
|
||||
monitor (str): The monitor to set.
|
||||
monitor (str): The device to monitor.
|
||||
signal (str|None): The signal/entry to monitor on the device. The reserved
|
||||
entry ``"monitor_1d"`` selects the scan-less device_monitor_1d stream.
|
||||
If None, the signal is auto-selected: the device's only 1D-capable
|
||||
preview/async signal if unambiguous, the monitor_1d stream otherwise.
|
||||
color_palette (str|None): The color palette to use for the plot.
|
||||
"""
|
||||
if not monitor:
|
||||
self.device = ""
|
||||
return
|
||||
self.entry_validator.validate_monitor(monitor)
|
||||
self.config.monitor = monitor
|
||||
self._setup_data_api_subscription()
|
||||
if signal is None:
|
||||
signal = self._default_signal_for(monitor)
|
||||
if self.config.monitor_signal and self.config.monitor != monitor:
|
||||
# Clear the old entry first to avoid reconnect attempts on the new device
|
||||
self.signal = ""
|
||||
# Guard the device update so the toolbar sync does not pull the
|
||||
# auto-selected first combobox entry before the requested signal is set.
|
||||
self._device_selection_updating = True
|
||||
try:
|
||||
self.device = monitor
|
||||
finally:
|
||||
self._device_selection_updating = False
|
||||
self.signal = signal
|
||||
if color_palette is not None:
|
||||
self.color_palette = color_palette
|
||||
self._sync_monitor_selection_toolbar()
|
||||
|
||||
@SafeSlot(int, bool)
|
||||
def set_curve_limit(self, max_trace: int, flush_buffer: bool):
|
||||
@@ -354,31 +601,49 @@ class MultiWaveform(PlotBase):
|
||||
@SafeSlot(object)
|
||||
def _on_data_update(self, update) -> None:
|
||||
"""
|
||||
Render one columnar DataAPI update of the monitor_1d device stream.
|
||||
Render one columnar DataAPI update of the monitored source.
|
||||
|
||||
Each value of the source is one 1-D trace (newest last); ordinals are
|
||||
arrival counters, so only traces newer than the last rendered ordinal
|
||||
are appended to the curve deque.
|
||||
arrival counters (device streams) or per-scan async ordinals, so only
|
||||
traces newer than the last rendered ordinal are appended to the curve
|
||||
deque. ``"replace"`` async sources expose a single current state which
|
||||
replaces the trace set.
|
||||
|
||||
Args:
|
||||
update (SubscriptionUpdate): Full-state snapshot of the monitor
|
||||
stream (standalone group).
|
||||
update (SubscriptionUpdate): Full-state snapshot of the monitored
|
||||
source.
|
||||
"""
|
||||
monitor = self.config.monitor
|
||||
if not monitor:
|
||||
if self._source_key is None:
|
||||
return
|
||||
source = update.get(monitor, "monitor_1d")
|
||||
source = update.get(*self._source_key)
|
||||
if source is None or not source.values:
|
||||
return
|
||||
|
||||
current_scan_id = source.metadata.get("scan_id", None)
|
||||
current_scan_id = self._effective_scan_id(update, source)
|
||||
if current_scan_id != self.scan_id:
|
||||
self.old_scan_id = self.scan_id
|
||||
self.scan_id = current_scan_id
|
||||
self.clear_curves()
|
||||
self.curves.clear()
|
||||
if update.scan_id:
|
||||
# Scan-scoped async ordinals restart with every scan; the
|
||||
# arrival counters of scan-less device streams do not.
|
||||
self._last_ordinal = None
|
||||
if self.crosshair:
|
||||
self.crosshair.clear_markers()
|
||||
|
||||
if source.metadata.get("async_update_type") == "replace":
|
||||
# A replace source exposes one point: its current full state.
|
||||
self.clear_curves()
|
||||
self.curves.clear()
|
||||
curve = pg.PlotDataItem()
|
||||
curve.setData(source.values[-1])
|
||||
self.plot_item.addItem(curve)
|
||||
self.curves.append(curve)
|
||||
self._last_ordinal = None
|
||||
self.set_curve_limit(self.config.curve_limit, self.config.flush_buffer)
|
||||
return
|
||||
|
||||
last_ordinal = self._last_ordinal
|
||||
new_traces = [
|
||||
(ordinal, data)
|
||||
@@ -398,6 +663,24 @@ class MultiWaveform(PlotBase):
|
||||
# Max Trace and scale colors
|
||||
self.set_curve_limit(self.config.curve_limit, self.config.flush_buffer)
|
||||
|
||||
@staticmethod
|
||||
def _effective_scan_id(update, source) -> str | None:
|
||||
"""
|
||||
The scan id an update belongs to: the bound scan for scan-scoped
|
||||
subscriptions, the last-seen scan id from the stream metadata for
|
||||
scan-less device streams.
|
||||
|
||||
Args:
|
||||
update (SubscriptionUpdate): The update snapshot.
|
||||
source (SourceData): The rendered source of the update.
|
||||
|
||||
Returns:
|
||||
str | None: The scan id, or None if not known (yet).
|
||||
"""
|
||||
if update.scan_id:
|
||||
return update.scan_id
|
||||
return source.metadata.get("scan_id")
|
||||
|
||||
@SafeSlot(int)
|
||||
def set_curve_highlight(self, index: int):
|
||||
"""
|
||||
@@ -435,29 +718,143 @@ class MultiWaveform(PlotBase):
|
||||
|
||||
self.highlighted_curve_index_changed.emit(self._current_highlight_index)
|
||||
|
||||
################################################################################
|
||||
# Signal classification and DataAPI routing
|
||||
################################################################################
|
||||
|
||||
def _default_signal_for(self, device: str) -> str:
|
||||
"""
|
||||
Auto-select a signal for a device-only selection.
|
||||
|
||||
Returns the device's only 1D-capable preview/async signal if it is
|
||||
unambiguous; the reserved monitor_1d stream entry otherwise (legacy
|
||||
monitor devices, or devices with several 1D-capable signals).
|
||||
|
||||
Args:
|
||||
device (str): The device name.
|
||||
|
||||
Returns:
|
||||
str: The signal/entry to monitor.
|
||||
"""
|
||||
try:
|
||||
device_obj = self.dev[device]
|
||||
signals = device_obj._info.get("signals", {})
|
||||
except (KeyError, AttributeError):
|
||||
signals = {}
|
||||
supported = {"PreviewSignal", *self.SUPPORTED_SIGNALS}
|
||||
candidates = []
|
||||
for name, signal_config in signals.items():
|
||||
if not isinstance(signal_config, dict):
|
||||
continue
|
||||
if signal_config.get("signal_class") not in supported:
|
||||
continue
|
||||
describe = signal_config.get("describe") or {}
|
||||
signal_info = describe.get("signal_info") or {}
|
||||
if signal_info.get("ndim") == 1:
|
||||
candidates.append(name)
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
if candidates:
|
||||
logger.warning(
|
||||
f"Device '{device}' has multiple 1D-capable signals ({candidates}); "
|
||||
f"defaulting to the '{MONITOR_1D_ENTRY}' stream. Select a signal "
|
||||
"explicitly to plot one of them."
|
||||
)
|
||||
return MONITOR_1D_ENTRY
|
||||
|
||||
@staticmethod
|
||||
def _signal_config_matches(signal_config: dict | None, signal: str) -> bool:
|
||||
"""Whether a stored signal config describes the given signal/entry."""
|
||||
if not isinstance(signal_config, dict) or not signal_config.get("signal_class"):
|
||||
return False
|
||||
return signal in (signal_config.get("component_name"), signal_config.get("obj_name"))
|
||||
|
||||
def _resolve_route(self, device: str, signal: str) -> tuple[str | None, str, int | None] | None:
|
||||
"""
|
||||
Classify the selected (device, signal) into a DataAPI route.
|
||||
|
||||
Routing table:
|
||||
monitor_1d sentinel -> scan=None, entry "monitor_1d" (device stream)
|
||||
PreviewSignal (1D) -> scan=None, entry = signal (device stream)
|
||||
Async*/DynamicSignal (1D) -> scan="live", entry = obj_name (live-follow)
|
||||
|
||||
Args:
|
||||
device (str): The device name.
|
||||
signal (str): The selected signal/entry.
|
||||
|
||||
Returns:
|
||||
tuple | None: ``(scan, entry, max_points)`` or None if the selection
|
||||
cannot be served (connection status is set accordingly).
|
||||
"""
|
||||
if signal == MONITOR_1D_ENTRY:
|
||||
return (None, MONITOR_1D_ENTRY, self.config.curve_limit)
|
||||
|
||||
signal_config = self._signal_config or {}
|
||||
signal_class = signal_config.get("signal_class")
|
||||
supported_classes = ["PreviewSignal"] + self.SUPPORTED_SIGNALS
|
||||
if signal_class not in supported_classes:
|
||||
logger.warning(
|
||||
f"Signal '{device}.{signal}' has unsupported signal class '{signal_class}'. "
|
||||
f"Supported classes: {supported_classes}"
|
||||
)
|
||||
self._set_connection_status("error", f"Unsupported signal class '{signal_class}'")
|
||||
return None
|
||||
|
||||
describe = signal_config.get("describe") or {}
|
||||
signal_info = describe.get("signal_info") or {}
|
||||
ndim = signal_info.get("ndim", None)
|
||||
if ndim != 1:
|
||||
logger.warning(f"Unsupported ndim '{ndim}' for monitor '{device}.{signal}'.")
|
||||
self._set_connection_status("error", f"Unsupported ndim '{ndim}'")
|
||||
return None
|
||||
|
||||
if signal_class == "PreviewSignal":
|
||||
# Scan-less device stream served by the DataAPI device plugin.
|
||||
return (None, signal, self.config.curve_limit)
|
||||
|
||||
# Scan-scoped async stream; the DataAPI rebinds on new scans and hands
|
||||
# terminal scans over to history automatically (live-follow).
|
||||
entry = signal_config.get("obj_name") or f"{device}_{signal}"
|
||||
return ("live", entry, None)
|
||||
|
||||
def _setup_data_api_subscription(self):
|
||||
"""(Re)create the scan-less DataAPI subscription for the configured monitor."""
|
||||
"""(Re)create the DataAPI subscription for the configured device and signal."""
|
||||
self._cleanup_data_api_subscription()
|
||||
monitor = self.config.monitor
|
||||
if not monitor:
|
||||
device = self.config.monitor
|
||||
signal = self.config.monitor_signal
|
||||
if not device or not signal:
|
||||
self._set_connection_status("disconnected")
|
||||
return
|
||||
route = self._resolve_route(device, signal)
|
||||
if route is None:
|
||||
return
|
||||
scan, entry, max_points = route
|
||||
try:
|
||||
self._data_bridge = QtDataSubscription(
|
||||
self.client,
|
||||
sources=[(monitor, "monitor_1d")],
|
||||
scan=None,
|
||||
sources=[(device, entry)],
|
||||
scan=scan,
|
||||
parent=self,
|
||||
min_emit_interval=0.1,
|
||||
max_points=self.config.curve_limit,
|
||||
max_points=max_points,
|
||||
)
|
||||
self._data_bridge.updated.connect(self._on_data_update)
|
||||
self.connected = True
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to configure multi waveform data subscription: {exc}")
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.warning(
|
||||
f"Failed to configure multi waveform data subscription for "
|
||||
f"{device}.{signal}: {exc}"
|
||||
)
|
||||
self._cleanup_data_api_subscription()
|
||||
self._set_connection_status("error", str(exc))
|
||||
return
|
||||
self._source_key = (device, entry)
|
||||
self.connected = True
|
||||
self._set_connection_status("connected")
|
||||
logger.info(f"Connected to {device}.{signal} via {'live' if scan else 'device'} scope")
|
||||
|
||||
def _cleanup_data_api_subscription(self):
|
||||
self._last_ordinal = None
|
||||
self._source_key = None
|
||||
self.connected = False
|
||||
if self._data_bridge is None:
|
||||
return
|
||||
@@ -466,6 +863,12 @@ class MultiWaveform(PlotBase):
|
||||
finally:
|
||||
self._data_bridge = None
|
||||
|
||||
def _set_connection_status(self, status: str, message: str | None = None) -> None:
|
||||
self.config.connection_status = status
|
||||
self.config.connection_error = message
|
||||
self.property_changed.emit("connection_status", status)
|
||||
self.property_changed.emit("connection_error", message or "")
|
||||
|
||||
################################################################################
|
||||
# Utility Methods
|
||||
################################################################################
|
||||
@@ -500,29 +903,81 @@ class MultiWaveform(PlotBase):
|
||||
for item in items_to_remove:
|
||||
self.plot_item.removeItem(item)
|
||||
|
||||
def _sync_monitor_selection_toolbar(self):
|
||||
def _sync_device_selection(self):
|
||||
"""
|
||||
Sync the motor map selection toolbar with the current motor map.
|
||||
Synchronize the device and signal comboboxes with the current monitor state.
|
||||
This ensures the toolbar reflects the device and signal properties.
|
||||
"""
|
||||
try:
|
||||
device_selection_action = self.toolbar.components.get_action("device_selection")
|
||||
except Exception: # noqa: BLE001 - toolbar might not be ready during early init
|
||||
logger.warning(f"MultiWaveform ({self.object_name}) toolbar was not ready during init.")
|
||||
return
|
||||
|
||||
combobox_widget: DeviceComboBox = cast(
|
||||
DeviceComboBox, self.toolbar.components.get_action("monitor_selection").widget
|
||||
)
|
||||
cmap_widget: BECColorMapWidget = cast(
|
||||
BECColorMapWidget, self.toolbar.components.get_action("color_map").widget
|
||||
)
|
||||
if device_selection_action is None:
|
||||
return
|
||||
|
||||
monitor = combobox_widget.currentText()
|
||||
color_palette = cmap_widget.colormap
|
||||
device_selection: DeviceSelection = device_selection_action.widget
|
||||
target_device = self.config.monitor or ""
|
||||
target_entry = self.config.monitor_signal or ""
|
||||
|
||||
if monitor != self.config.monitor:
|
||||
combobox_widget.setCurrentText(monitor)
|
||||
combobox_widget.blockSignals(True)
|
||||
combobox_widget.set_device(self.config.monitor)
|
||||
combobox_widget.check_validity(self.config.monitor)
|
||||
combobox_widget.blockSignals(False)
|
||||
# Check if already synced
|
||||
if (
|
||||
device_selection.device_combo_box.currentText() == target_device
|
||||
and device_selection.signal_combo_box.currentText() == target_entry
|
||||
):
|
||||
return
|
||||
|
||||
if color_palette != self.config.color_palette:
|
||||
device_selection.set_device_and_signal(target_device, target_entry)
|
||||
|
||||
def _sync_signal_from_toolbar(self) -> None:
|
||||
"""
|
||||
Pull the signal selection from the toolbar if it differs from the current signal.
|
||||
This keeps CLI-driven device updates in sync with the signal combobox state.
|
||||
"""
|
||||
if self._device_selection_updating:
|
||||
return
|
||||
|
||||
if not self.config.monitor:
|
||||
return
|
||||
|
||||
try:
|
||||
device_selection_action = self.toolbar.components.get_action("device_selection")
|
||||
except Exception: # noqa: BLE001 - toolbar might not be ready during early init
|
||||
return
|
||||
|
||||
if device_selection_action is None:
|
||||
return
|
||||
|
||||
device_selection: DeviceSelection = device_selection_action.widget
|
||||
if device_selection.device_combo_box.currentText() != self.config.monitor:
|
||||
return
|
||||
|
||||
signal_text = device_selection.signal_combo_box.currentText()
|
||||
if not signal_text or signal_text == self.config.monitor_signal:
|
||||
return
|
||||
|
||||
signal_config = device_selection.signal_combo_box.get_signal_config()
|
||||
if signal_config:
|
||||
self._signal_config = signal_config
|
||||
elif signal_text != MONITOR_1D_ENTRY:
|
||||
return
|
||||
|
||||
self._device_selection_updating = True
|
||||
try:
|
||||
self.signal = signal_text
|
||||
finally:
|
||||
self._device_selection_updating = False
|
||||
|
||||
def _sync_colormap_toolbar(self):
|
||||
"""
|
||||
Sync the colormap toolbar widget with the current color palette.
|
||||
"""
|
||||
try:
|
||||
cmap_widget: BECColorMapWidget = self.toolbar.components.get_action("color_map").widget
|
||||
except Exception: # noqa: BLE001 - toolbar might not be ready during early init
|
||||
return
|
||||
if cmap_widget.colormap != self.config.color_palette:
|
||||
cmap_widget.blockSignals(True)
|
||||
cmap_widget.colormap = self.config.color_palette
|
||||
cmap_widget.blockSignals(False)
|
||||
@@ -530,4 +985,8 @@ class MultiWaveform(PlotBase):
|
||||
def cleanup(self):
|
||||
self._cleanup_data_api_subscription()
|
||||
self.clear_curves()
|
||||
try:
|
||||
self.toolbar.disconnect_bundle("device_selection")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
super().cleanup()
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QStyledItemDelegate, QWidget
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import DeviceComboBoxAction, WidgetAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.toolbar import ToolbarBundle
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
|
||||
BECDeviceFilter,
|
||||
DeviceComboBox,
|
||||
)
|
||||
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def monitor_selection_bundle(
|
||||
components: ToolbarComponents, target_widget: QWidget
|
||||
) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a monitor selection toolbar bundle.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The monitor selection toolbar bundle.
|
||||
"""
|
||||
components.add_safe(
|
||||
"monitor_selection",
|
||||
DeviceComboBoxAction(
|
||||
target_widget=target_widget,
|
||||
device_filter=[BECDeviceFilter.DEVICE],
|
||||
readout_priority_filter=ReadoutPriority.ASYNC,
|
||||
add_empty_item=True,
|
||||
no_check_delegate=True,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"color_map", WidgetAction(widget=BECColorMapWidget(cmap="plasma"), adjust_size=False)
|
||||
)
|
||||
bundle = ToolbarBundle("monitor_selection", components)
|
||||
bundle.add_action("monitor_selection")
|
||||
bundle.add_action("color_map")
|
||||
return bundle
|
||||
|
||||
|
||||
class MultiWaveformSelectionToolbarBundle(ToolbarBundle):
|
||||
"""
|
||||
A bundle of actions for a toolbar that selects motors.
|
||||
"""
|
||||
|
||||
def __init__(self, bundle_id="monitor_selection", target_widget=None, **kwargs):
|
||||
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
|
||||
self.target_widget = target_widget
|
||||
|
||||
# Monitor Selection
|
||||
self.monitor = DeviceComboBox(
|
||||
device_filter=BECDeviceFilter.DEVICE, readout_priority_filter=ReadoutPriority.ASYNC
|
||||
)
|
||||
self.monitor.addItem("", None)
|
||||
self.monitor.setCurrentText("")
|
||||
self.monitor.setToolTip("Select Monitor")
|
||||
self.monitor.setItemDelegate(NoCheckDelegate(self.monitor))
|
||||
self.add_action("monitor", WidgetAction(widget=self.monitor, adjust_size=False))
|
||||
|
||||
# Colormap Selection
|
||||
self.colormap_widget = BECColorMapWidget(cmap="plasma")
|
||||
self.add_action("color_map", WidgetAction(widget=self.colormap_widget, adjust_size=False))
|
||||
|
||||
# Connect slots, a device will be connected upon change of any combobox
|
||||
self.monitor.currentTextChanged.connect(lambda: self.connect())
|
||||
self.colormap_widget.colormap_changed_signal.connect(self.change_colormap)
|
||||
|
||||
@SafeSlot()
|
||||
def connect(self):
|
||||
monitor = self.monitor.currentText()
|
||||
|
||||
if monitor != "":
|
||||
if monitor != self.target_widget.config.monitor:
|
||||
self.target_widget.monitor = monitor
|
||||
|
||||
@SafeSlot(str)
|
||||
def change_colormap(self, colormap: str):
|
||||
self.target_widget.color_palette = colormap
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the toolbar bundle.
|
||||
"""
|
||||
self.monitor.close()
|
||||
self.monitor.deleteLater()
|
||||
@@ -13,6 +13,22 @@ from .conftest import create_widget
|
||||
##################################################
|
||||
|
||||
|
||||
def _set_signal_config(
|
||||
client, device: str, signal_name: str, signal_class: str, ndim: int, obj_name: str | None = None
|
||||
):
|
||||
device = client.device_manager.devices[device]
|
||||
device._info["signals"][signal_name] = {
|
||||
"obj_name": obj_name or signal_name,
|
||||
"signal_class": signal_class,
|
||||
"component_name": signal_name,
|
||||
"describe": {"signal_info": {"ndim": ndim}},
|
||||
}
|
||||
|
||||
|
||||
def _clear_signal_config(client, device: str, signal_name: str):
|
||||
client.device_manager.devices[device]._info["signals"].pop(signal_name, None)
|
||||
|
||||
|
||||
def _fake_bridge_factory(monkeypatch):
|
||||
created = []
|
||||
|
||||
@@ -65,6 +81,31 @@ def _monitor_update(traces, scan_id="scan_1", monitor="waveform1d", start=0):
|
||||
)
|
||||
|
||||
|
||||
def _async_update(
|
||||
traces, scan_id="scan_1", device="eiger", entry="eiger_data", start=0, update_type="add"
|
||||
):
|
||||
"""Build a full-state scan-scoped async snapshot: one trace per ordinal."""
|
||||
ordinals = tuple(range(start, start + len(traces)))
|
||||
source = SourceData(
|
||||
device=device,
|
||||
entry=entry,
|
||||
kind="async",
|
||||
ordinals=ordinals,
|
||||
values=tuple(traces),
|
||||
timestamps=tuple(float(ordinal) for ordinal in ordinals),
|
||||
complete=True,
|
||||
metadata={"async_update_type": update_type},
|
||||
)
|
||||
return SubscriptionUpdate(
|
||||
scan_id=scan_id,
|
||||
reason="live",
|
||||
sources={(device, entry): source},
|
||||
aligned_ordinals=ordinals,
|
||||
complete=True,
|
||||
metadata={"group": "standalone"},
|
||||
)
|
||||
|
||||
|
||||
##################################################
|
||||
# MultiWaveform widget base functionality tests
|
||||
##################################################
|
||||
@@ -85,6 +126,7 @@ def test_multiwaveform_initialization(qtbot, mocked_client):
|
||||
assert len(mw.plot_item.curves) == 0
|
||||
# Multiwaveform specific
|
||||
assert mw.monitor is None
|
||||
assert mw.monitor_signal is None
|
||||
assert mw.color_palette == "plasma"
|
||||
assert mw.max_trace == 200
|
||||
assert mw.flush_buffer is False
|
||||
@@ -93,17 +135,22 @@ def test_multiwaveform_initialization(qtbot, mocked_client):
|
||||
assert mw.scan_id is None
|
||||
assert mw.highlighted_index == 0
|
||||
assert mw._data_bridge is None
|
||||
assert mw.config.connection_status == "disconnected"
|
||||
|
||||
|
||||
def test_multiwaveform_set_monitor(qtbot, mocked_client):
|
||||
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
|
||||
assert mw.monitor is None
|
||||
|
||||
# Set a monitor; data flows through a scan-less DataAPI subscription.
|
||||
# Set a monitor without a signal; the device has no 1D-capable class signals,
|
||||
# so the selection falls back to the scan-less monitor_1d stream.
|
||||
mw.plot("waveform1d")
|
||||
assert mw.monitor == "waveform1d"
|
||||
assert mw.monitor_signal == "monitor_1d"
|
||||
assert mw.config.monitor == "waveform1d"
|
||||
assert mw.config.monitor_signal == "monitor_1d"
|
||||
assert mw.connected is True
|
||||
assert mw.config.connection_status == "connected"
|
||||
assert mw._data_bridge is not None
|
||||
assert mw._data_bridge.sources == [("waveform1d", "monitor_1d")]
|
||||
assert mw._data_bridge.scan_id == "" # device scope
|
||||
@@ -132,6 +179,172 @@ def test_multiwaveform_bridge_lifecycle(qtbot, mocked_client, monkeypatch):
|
||||
assert mw._data_bridge is None
|
||||
|
||||
|
||||
##################################################
|
||||
# Signal classification and routing
|
||||
##################################################
|
||||
|
||||
|
||||
def test_multiwaveform_async_signal_routes_live(qtbot, mocked_client, monkeypatch):
|
||||
"""Selecting an AsyncSignal creates a scan="live" bridge with the obj_name entry.
|
||||
|
||||
This is the simulated-waveform case: the device publishes via
|
||||
device_async_signal (scan-scoped), not via the device_monitor_1d stream.
|
||||
"""
|
||||
bridges = _fake_bridge_factory(monkeypatch)
|
||||
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
|
||||
_set_signal_config(
|
||||
mocked_client, "eiger", "data", signal_class="AsyncSignal", ndim=1, obj_name="eiger_data"
|
||||
)
|
||||
|
||||
mw.plot("eiger", "data")
|
||||
assert mw.monitor == "eiger"
|
||||
assert mw.monitor_signal == "data"
|
||||
assert mw.connected is True
|
||||
assert bridges[-1].scan == "live"
|
||||
assert bridges[-1].sources == [("eiger", "eiger_data")]
|
||||
assert bridges[-1].max_points is None
|
||||
_clear_signal_config(mocked_client, "eiger", "data")
|
||||
|
||||
|
||||
def test_multiwaveform_device_only_picks_unambiguous_async_signal(
|
||||
qtbot, mocked_client, monkeypatch
|
||||
):
|
||||
"""A device-only plot() picks the device's only 1D-capable class signal."""
|
||||
bridges = _fake_bridge_factory(monkeypatch)
|
||||
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
|
||||
_set_signal_config(
|
||||
mocked_client, "eiger", "data", signal_class="AsyncSignal", ndim=1, obj_name="eiger_data"
|
||||
)
|
||||
|
||||
mw.plot("eiger")
|
||||
assert mw.monitor_signal == "data"
|
||||
assert bridges[-1].scan == "live"
|
||||
assert bridges[-1].sources == [("eiger", "eiger_data")]
|
||||
_clear_signal_config(mocked_client, "eiger", "data")
|
||||
|
||||
|
||||
def test_multiwaveform_device_only_ambiguous_falls_back_to_monitor(
|
||||
qtbot, mocked_client, monkeypatch
|
||||
):
|
||||
"""A device with several 1D-capable signals falls back to the monitor_1d stream."""
|
||||
bridges = _fake_bridge_factory(monkeypatch)
|
||||
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
|
||||
_set_signal_config(
|
||||
mocked_client, "eiger", "data", signal_class="AsyncSignal", ndim=1, obj_name="eiger_data"
|
||||
)
|
||||
_set_signal_config(
|
||||
mocked_client, "eiger", "img", signal_class="PreviewSignal", ndim=1, obj_name="eiger_img"
|
||||
)
|
||||
|
||||
mw.plot("eiger")
|
||||
assert mw.monitor_signal == "monitor_1d"
|
||||
assert bridges[-1].scan is None
|
||||
assert bridges[-1].sources == [("eiger", "monitor_1d")]
|
||||
_clear_signal_config(mocked_client, "eiger", "data")
|
||||
_clear_signal_config(mocked_client, "eiger", "img")
|
||||
|
||||
|
||||
def test_multiwaveform_preview_signal_routes_scanless(qtbot, mocked_client, monkeypatch):
|
||||
"""Selecting a PreviewSignal creates a scan-less bridge with the signal entry."""
|
||||
bridges = _fake_bridge_factory(monkeypatch)
|
||||
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
|
||||
_set_signal_config(
|
||||
mocked_client,
|
||||
"waveform1d",
|
||||
"wave_prev",
|
||||
signal_class="PreviewSignal",
|
||||
ndim=1,
|
||||
obj_name="waveform1d_wave_prev",
|
||||
)
|
||||
|
||||
mw.plot("waveform1d", "wave_prev")
|
||||
assert mw.monitor_signal == "wave_prev"
|
||||
assert bridges[-1].scan is None
|
||||
assert bridges[-1].sources == [("waveform1d", "wave_prev")]
|
||||
assert bridges[-1].max_points == mw.config.curve_limit
|
||||
_clear_signal_config(mocked_client, "waveform1d", "wave_prev")
|
||||
|
||||
|
||||
def test_multiwaveform_rejects_unsupported_signal(qtbot, mocked_client, monkeypatch):
|
||||
"""A signal with an unsupported class does not create a bridge and flags an error."""
|
||||
bridges = _fake_bridge_factory(monkeypatch)
|
||||
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
|
||||
_set_signal_config(mocked_client, "eiger", "plain", signal_class="Signal", ndim=1)
|
||||
|
||||
mw.plot("eiger", "plain")
|
||||
assert bridges == []
|
||||
assert mw.connected is False
|
||||
assert mw.config.connection_status == "error"
|
||||
_clear_signal_config(mocked_client, "eiger", "plain")
|
||||
|
||||
|
||||
def test_multiwaveform_rejects_2d_signal(qtbot, mocked_client, monkeypatch):
|
||||
"""A 2D async signal is rejected for the 1D multi waveform plot."""
|
||||
bridges = _fake_bridge_factory(monkeypatch)
|
||||
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
|
||||
_set_signal_config(mocked_client, "eiger", "img2d", signal_class="AsyncSignal", ndim=2)
|
||||
|
||||
mw.plot("eiger", "img2d")
|
||||
assert bridges == []
|
||||
assert mw.config.connection_status == "error"
|
||||
_clear_signal_config(mocked_client, "eiger", "img2d")
|
||||
|
||||
|
||||
def test_multiwaveform_switching_device_replaces_async_bridge(qtbot, mocked_client, monkeypatch):
|
||||
bridges = _fake_bridge_factory(monkeypatch)
|
||||
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
|
||||
_set_signal_config(
|
||||
mocked_client, "eiger", "data", signal_class="AsyncSignal", ndim=1, obj_name="async_obj"
|
||||
)
|
||||
_set_signal_config(
|
||||
mocked_client,
|
||||
"async_device",
|
||||
"data",
|
||||
signal_class="AsyncSignal",
|
||||
ndim=1,
|
||||
obj_name="async_obj",
|
||||
)
|
||||
|
||||
def fake_get(signal_class_filter):
|
||||
# Production-like discovery so the signal combobox lists the async entry
|
||||
# for both devices when the toolbar re-populates on device change.
|
||||
return [
|
||||
(device, "data", mocked_client.device_manager.devices[device]._info["signals"]["data"])
|
||||
for device in ("eiger", "async_device")
|
||||
]
|
||||
|
||||
monkeypatch.setattr(mocked_client.device_manager, "get_bec_signals", fake_get)
|
||||
|
||||
mw.plot("eiger", "data")
|
||||
first = bridges[-1]
|
||||
assert first.sources == [("eiger", "async_obj")]
|
||||
|
||||
mw.device = "async_device"
|
||||
assert first.closed is True
|
||||
assert bridges[-1].sources == [("async_device", "async_obj")]
|
||||
_clear_signal_config(mocked_client, "eiger", "data")
|
||||
_clear_signal_config(mocked_client, "async_device", "data")
|
||||
|
||||
|
||||
def test_multiwaveform_disconnect_via_empty_device(qtbot, mocked_client, monkeypatch):
|
||||
bridges = _fake_bridge_factory(monkeypatch)
|
||||
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
|
||||
mw.plot("waveform1d")
|
||||
assert mw.connected is True
|
||||
|
||||
mw.device = ""
|
||||
assert bridges[-1].closed is True
|
||||
assert mw.connected is False
|
||||
assert mw.monitor is None
|
||||
assert mw.monitor_signal is None
|
||||
assert mw.config.connection_status == "disconnected"
|
||||
|
||||
|
||||
##################################################
|
||||
# Rendering
|
||||
##################################################
|
||||
|
||||
|
||||
def test_multiwaveform_set_properties(qtbot, mocked_client):
|
||||
"""Check that MultiWaveform properties can be set and retrieved correctly."""
|
||||
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
|
||||
@@ -253,6 +466,58 @@ def test_multiwaveform_scan_change_clears_curves(qtbot, mocked_client, monkeypat
|
||||
assert np.array_equal(y_data, new_trace)
|
||||
|
||||
|
||||
def test_multiwaveform_async_scan_rollover_clears_curves(qtbot, mocked_client, monkeypatch):
|
||||
"""For scan-scoped async sources the update.scan_id drives the rollover and
|
||||
resets the ordinal delta-append (async ordinals restart per scan)."""
|
||||
_fake_bridge_factory(monkeypatch)
|
||||
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
|
||||
_set_signal_config(
|
||||
mocked_client, "eiger", "data", signal_class="AsyncSignal", ndim=1, obj_name="eiger_data"
|
||||
)
|
||||
mw.plot("eiger", "data")
|
||||
|
||||
first = np.array([1, 2, 3])
|
||||
second = np.array([4, 5, 6])
|
||||
mw._on_data_update(_async_update([first, second], scan_id="scan_1"))
|
||||
assert mw.scan_id == "scan_1"
|
||||
assert len(mw.curves) == 2
|
||||
assert mw._last_ordinal == 1
|
||||
|
||||
# New scan: ordinals restart at 0; curves are cleared and the delta-append reset.
|
||||
rollover = np.array([7, 8, 9])
|
||||
mw._on_data_update(_async_update([rollover], scan_id="scan_2"))
|
||||
assert mw.scan_id == "scan_2"
|
||||
assert len(mw.curves) == 1
|
||||
assert mw._last_ordinal == 0
|
||||
_, y_data = mw.curves[-1].getData()
|
||||
assert np.array_equal(y_data, rollover)
|
||||
_clear_signal_config(mocked_client, "eiger", "data")
|
||||
|
||||
|
||||
def test_multiwaveform_async_replace_updates_single_trace(qtbot, mocked_client, monkeypatch):
|
||||
"""A "replace" async source exposes one current state that replaces the trace set."""
|
||||
_fake_bridge_factory(monkeypatch)
|
||||
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
|
||||
_set_signal_config(
|
||||
mocked_client, "eiger", "data", signal_class="AsyncSignal", ndim=1, obj_name="eiger_data"
|
||||
)
|
||||
mw.plot("eiger", "data")
|
||||
|
||||
first = np.array([1, 2, 3])
|
||||
second = np.array([4, 5, 6])
|
||||
mw._on_data_update(_async_update([first], scan_id="scan_1", update_type="replace"))
|
||||
assert len(mw.curves) == 1
|
||||
_, y_data = mw.curves[-1].getData()
|
||||
assert np.array_equal(y_data, first)
|
||||
|
||||
# The replace source keeps ordinal 0; the new state still replaces the trace.
|
||||
mw._on_data_update(_async_update([second], scan_id="scan_1", update_type="replace"))
|
||||
assert len(mw.curves) == 1
|
||||
_, y_data = mw.curves[-1].getData()
|
||||
assert np.array_equal(y_data, second)
|
||||
_clear_signal_config(mocked_client, "eiger", "data")
|
||||
|
||||
|
||||
def test_multiwaveform_highlight_last_curve(qtbot, mocked_client, monkeypatch):
|
||||
"""Check highlight_last_curve behavior."""
|
||||
_fake_bridge_factory(monkeypatch)
|
||||
@@ -309,7 +574,7 @@ def test_multiwaveform_set_colormap(qtbot, mocked_client, monkeypatch):
|
||||
traces.append(np.array([i, i + 1, i + 2]))
|
||||
mw._on_data_update(_monitor_update(list(traces)))
|
||||
|
||||
# Default color_palette is "magma"
|
||||
# Default color_palette is "plasma"
|
||||
assert mw.color_palette == "plasma"
|
||||
# Now change to a new colormap
|
||||
mw.color_palette = "viridis"
|
||||
@@ -388,23 +653,108 @@ def test_widget_updates_control_panel(qtbot, mocked_client):
|
||||
assert mw.controls.ui.highlight_last_curve.isChecked() is False
|
||||
|
||||
|
||||
def test_selection_toolbar_updates_widget(qtbot, mocked_client):
|
||||
"""
|
||||
Confirm that selecting a monitor and a colormap from the selection toolbar
|
||||
updates the widget properties.
|
||||
"""
|
||||
def test_selection_toolbar_builds_device_and_signal_comboboxes(qtbot, mocked_client):
|
||||
"""The toolbar carries the shared device+signal selection with the width splitter."""
|
||||
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
|
||||
monitor_selection_action = mw.toolbar.components.get_action("monitor_selection")
|
||||
cmap_action = mw.toolbar.components.get_action("color_map")
|
||||
|
||||
monitor_selection_action.combobox.addItem("waveform1d")
|
||||
monitor_selection_action.combobox.setCurrentText("waveform1d")
|
||||
device_selection = mw.toolbar.components.get_action("device_selection").widget
|
||||
assert device_selection.device_combo_box is not None
|
||||
assert device_selection.signal_combo_box is not None
|
||||
# The section-width splitter of the shared component is registered as well
|
||||
assert mw.toolbar.components.exists("device_selection_splitter")
|
||||
# The color map widget remains available
|
||||
assert mw.toolbar.components.get_action("color_map").widget is not None
|
||||
|
||||
|
||||
def test_selection_toolbar_updates_widget(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Selecting a device and signal from the toolbar comboboxes connects the widget,
|
||||
and selecting a colormap updates the color palette.
|
||||
"""
|
||||
bridges = _fake_bridge_factory(monkeypatch)
|
||||
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
|
||||
|
||||
def fake_get(signal_class_filter):
|
||||
signal_classes = (
|
||||
signal_class_filter
|
||||
if isinstance(signal_class_filter, (list, tuple, set))
|
||||
else [signal_class_filter]
|
||||
)
|
||||
if "PreviewSignal" in signal_classes:
|
||||
return [
|
||||
(
|
||||
"waveform1d",
|
||||
"wave",
|
||||
{
|
||||
"obj_name": "waveform1d_wave",
|
||||
"component_name": "wave",
|
||||
"signal_class": "PreviewSignal",
|
||||
"describe": {"signal_info": {"ndim": 1}},
|
||||
},
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(mw.client.device_manager, "get_bec_signals", fake_get)
|
||||
device_selection = mw.toolbar.components.get_action("device_selection").widget
|
||||
device_selection.device_combo_box.update_devices_from_filters()
|
||||
|
||||
device_selection.device_combo_box.setCurrentText("waveform1d")
|
||||
assert mw.monitor == "waveform1d"
|
||||
|
||||
# The signal combobox offers the monitor_1d sentinel and the preview signal
|
||||
signal_items = [
|
||||
device_selection.signal_combo_box.itemText(i)
|
||||
for i in range(device_selection.signal_combo_box.count())
|
||||
]
|
||||
assert "monitor_1d" in signal_items
|
||||
assert "wave" in signal_items
|
||||
|
||||
device_selection.signal_combo_box.setCurrentText("wave")
|
||||
assert mw.monitor_signal == "wave"
|
||||
assert bridges[-1].scan is None
|
||||
assert bridges[-1].sources == [("waveform1d", "wave")]
|
||||
|
||||
cmap_action = mw.toolbar.components.get_action("color_map")
|
||||
cmap_action.widget.colormap = "viridis"
|
||||
assert mw.color_palette == "viridis"
|
||||
|
||||
|
||||
def test_selection_toolbar_monitor_1d_entry(qtbot, mocked_client, monkeypatch):
|
||||
"""Selecting the monitor_1d sentinel routes to the scan-less device stream."""
|
||||
bridges = _fake_bridge_factory(monkeypatch)
|
||||
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
|
||||
|
||||
def fake_get(signal_class_filter):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(mw.client.device_manager, "get_bec_signals", fake_get)
|
||||
device_selection = mw.toolbar.components.get_action("device_selection").widget
|
||||
device_selection.device_combo_box.update_devices_from_filters()
|
||||
|
||||
# Async-readout devices are listed even without matching class signals
|
||||
assert "eiger" in device_selection.device_combo_box.devices
|
||||
|
||||
device_selection.device_combo_box.setCurrentText("eiger")
|
||||
device_selection.signal_combo_box.setCurrentText("monitor_1d")
|
||||
assert mw.monitor == "eiger"
|
||||
assert mw.monitor_signal == "monitor_1d"
|
||||
assert bridges[-1].scan is None
|
||||
assert bridges[-1].sources == [("eiger", "monitor_1d")]
|
||||
|
||||
|
||||
def test_toolbar_syncs_from_properties(qtbot, mocked_client, monkeypatch):
|
||||
"""Programmatic plot() calls are mirrored into the selection comboboxes."""
|
||||
_fake_bridge_factory(monkeypatch)
|
||||
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
|
||||
|
||||
mw.plot("waveform1d")
|
||||
qtbot.wait(100)
|
||||
device_selection = mw.toolbar.components.get_action("device_selection").widget
|
||||
assert device_selection.device_combo_box.currentText() == "waveform1d"
|
||||
assert device_selection.signal_combo_box.currentText() == "monitor_1d"
|
||||
|
||||
|
||||
def test_control_panel_opacity_slider_spinbox(qtbot, mocked_client):
|
||||
"""
|
||||
Verify that when the user moves the opacity slider or spinbox, the widget's
|
||||
|
||||
Reference in New Issue
Block a user