1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-03-04 16:02:51 +01:00

fix(scatter_waveform): devices and entries saved as properties

This commit is contained in:
2026-01-16 16:37:21 +01:00
committed by Jan Wyzula
parent 5209f4c210
commit 7e9cc20e59
3 changed files with 687 additions and 4 deletions

View File

@@ -5441,6 +5441,90 @@ class ScatterWaveform(RPCBase):
Clear all the curves from the plot.
"""
@property
@rpc_call
def x_device_name(self) -> "str":
"""
Device name for the X axis.
"""
@x_device_name.setter
@rpc_call
def x_device_name(self) -> "str":
"""
Device name for the X axis.
"""
@property
@rpc_call
def x_device_entry(self) -> "str":
"""
Signal entry for the X axis device.
"""
@x_device_entry.setter
@rpc_call
def x_device_entry(self) -> "str":
"""
Signal entry for the X axis device.
"""
@property
@rpc_call
def y_device_name(self) -> "str":
"""
Device name for the Y axis.
"""
@y_device_name.setter
@rpc_call
def y_device_name(self) -> "str":
"""
Device name for the Y axis.
"""
@property
@rpc_call
def y_device_entry(self) -> "str":
"""
Signal entry for the Y axis device.
"""
@y_device_entry.setter
@rpc_call
def y_device_entry(self) -> "str":
"""
Signal entry for the Y axis device.
"""
@property
@rpc_call
def z_device_name(self) -> "str":
"""
Device name for the Z (color) axis.
"""
@z_device_name.setter
@rpc_call
def z_device_name(self) -> "str":
"""
Device name for the Z (color) axis.
"""
@property
@rpc_call
def z_device_entry(self) -> "str":
"""
Signal entry for the Z (color) axis device.
"""
@z_device_entry.setter
@rpc_call
def z_device_entry(self) -> "str":
"""
Signal entry for the Z (color) axis device.
"""
class SignalComboBox(RPCBase):
"""Line edit widget for device input with autocomplete for device names."""

View File

@@ -51,6 +51,19 @@ class ScatterWaveform(PlotBase):
"plot",
"update_with_scan_history",
"clear_all",
# Device properties
"x_device_name",
"x_device_name.setter",
"x_device_entry",
"x_device_entry.setter",
"y_device_name",
"y_device_name.setter",
"y_device_entry",
"y_device_entry.setter",
"z_device_name",
"z_device_name.setter",
"z_device_entry",
"z_device_entry.setter",
]
sync_signal_update = Signal()
@@ -285,10 +298,6 @@ class ScatterWaveform(PlotBase):
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.rpc_register.remove_rpc(self._main_curve)
@@ -298,6 +307,9 @@ class ScatterWaveform(PlotBase):
self._main_curve = None
self._main_curve = ScatterCurve(parent_item=self, config=config, name=config.label)
# Update axis labels (matching Heatmap's label policy)
self.update_labels()
self.plot_item.addItem(self._main_curve)
self.sync_signal_update.emit()
@@ -405,6 +417,284 @@ class ScatterWaveform(PlotBase):
scan_devices = self.scan_item.devices
return scan_devices, "value"
################################################################################
# Widget Specific Properties
################################################################################
@SafeProperty(str)
def x_device_name(self) -> str:
"""Device name for the X axis."""
if self._main_curve is None or self._main_curve.config.x_device is None:
return ""
return self._main_curve.config.x_device.name or ""
@x_device_name.setter
def x_device_name(self, device_name: str) -> None:
"""
Set the X device name.
Args:
device_name(str): Device name for the X axis
"""
device_name = device_name or ""
if device_name:
try:
entry = self.entry_validator.validate_signal(device_name, None)
# Update or create config
if self._main_curve.config.x_device is None:
self._main_curve.config.x_device = ScatterDeviceSignal(
name=device_name, entry=entry
)
else:
self._main_curve.config.x_device.name = device_name
self._main_curve.config.x_device.entry = entry
self.property_changed.emit("x_device_name", device_name)
self.update_labels()
self._try_auto_plot()
except Exception:
pass # Silently fail if device is not available yet
else:
if self._main_curve.config.x_device is not None:
self._main_curve.config.x_device = None
self.property_changed.emit("x_device_name", "")
self.update_labels()
@SafeProperty(str)
def x_device_entry(self) -> str:
"""Signal entry for the X axis device."""
if self._main_curve is None or self._main_curve.config.x_device is None:
return ""
return self._main_curve.config.x_device.entry or ""
@x_device_entry.setter
def x_device_entry(self, entry: str) -> None:
"""
Set the X device entry.
Args:
entry(str): Signal entry for the X axis device
"""
if not entry:
return
if self._main_curve.config.x_device is None:
logger.warning("Cannot set x_device_entry without x_device_name set first.")
return
device_name = self._main_curve.config.x_device.name
try:
validated_entry = self.entry_validator.validate_signal(device_name, entry)
self._main_curve.config.x_device.entry = validated_entry
self.property_changed.emit("x_device_entry", validated_entry)
self.update_labels()
self._try_auto_plot()
except Exception:
pass # Silently fail if validation fails
@SafeProperty(str)
def y_device_name(self) -> str:
"""Device name for the Y axis."""
if self._main_curve is None or self._main_curve.config.y_device is None:
return ""
return self._main_curve.config.y_device.name or ""
@y_device_name.setter
def y_device_name(self, device_name: str) -> None:
"""
Set the Y device name.
Args:
device_name(str): Device name for the Y axis
"""
device_name = device_name or ""
if device_name:
try:
entry = self.entry_validator.validate_signal(device_name, None)
# Update or create config
if self._main_curve.config.y_device is None:
self._main_curve.config.y_device = ScatterDeviceSignal(
name=device_name, entry=entry
)
else:
self._main_curve.config.y_device.name = device_name
self._main_curve.config.y_device.entry = entry
self.property_changed.emit("y_device_name", device_name)
self.update_labels()
self._try_auto_plot()
except Exception:
pass # Silently fail if device is not available yet
else:
if self._main_curve.config.y_device is not None:
self._main_curve.config.y_device = None
self.property_changed.emit("y_device_name", "")
self.update_labels()
@SafeProperty(str)
def y_device_entry(self) -> str:
"""Signal entry for the Y axis device."""
if self._main_curve is None or self._main_curve.config.y_device is None:
return ""
return self._main_curve.config.y_device.entry or ""
@y_device_entry.setter
def y_device_entry(self, entry: str) -> None:
"""
Set the Y device entry.
Args:
entry(str): Signal entry for the Y axis device
"""
if not entry:
return
if self._main_curve.config.y_device is None:
logger.warning("Cannot set y_device_entry without y_device_name set first.")
return
device_name = self._main_curve.config.y_device.name
try:
validated_entry = self.entry_validator.validate_signal(device_name, entry)
self._main_curve.config.y_device.entry = validated_entry
self.property_changed.emit("y_device_entry", validated_entry)
self.update_labels()
self._try_auto_plot()
except Exception:
pass # Silently fail if validation fails
@SafeProperty(str)
def z_device_name(self) -> str:
"""Device name for the Z (color) axis."""
if self._main_curve is None or self._main_curve.config.z_device is None:
return ""
return self._main_curve.config.z_device.name or ""
@z_device_name.setter
def z_device_name(self, device_name: str) -> None:
"""
Set the Z device name.
Args:
device_name(str): Device name for the Z axis
"""
device_name = device_name or ""
if device_name:
try:
entry = self.entry_validator.validate_signal(device_name, None)
# Update or create config
if self._main_curve.config.z_device is None:
self._main_curve.config.z_device = ScatterDeviceSignal(
name=device_name, entry=entry
)
else:
self._main_curve.config.z_device.name = device_name
self._main_curve.config.z_device.entry = entry
self.property_changed.emit("z_device_name", device_name)
self.update_labels()
self._try_auto_plot()
except Exception:
pass # Silently fail if device is not available yet
else:
if self._main_curve.config.z_device is not None:
self._main_curve.config.z_device = None
self.property_changed.emit("z_device_name", "")
self.update_labels()
@SafeProperty(str)
def z_device_entry(self) -> str:
"""Signal entry for the Z (color) axis device."""
if self._main_curve is None or self._main_curve.config.z_device is None:
return ""
return self._main_curve.config.z_device.entry or ""
@z_device_entry.setter
def z_device_entry(self, entry: str) -> None:
"""
Set the Z device entry.
Args:
entry(str): Signal entry for the Z axis device
"""
if not entry:
return
if self._main_curve.config.z_device is None:
logger.warning("Cannot set z_device_entry without z_device_name set first.")
return
device_name = self._main_curve.config.z_device.name
try:
validated_entry = self.entry_validator.validate_signal(device_name, entry)
self._main_curve.config.z_device.entry = validated_entry
self.property_changed.emit("z_device_entry", validated_entry)
self.update_labels()
self._try_auto_plot()
except Exception:
pass # Silently fail if validation fails
def _try_auto_plot(self) -> None:
"""
Attempt to automatically call plot() if all three devices are set.
"""
has_x = self._main_curve.config.x_device is not None
has_y = self._main_curve.config.y_device is not None
has_z = self._main_curve.config.z_device is not None
if has_x and has_y and has_z:
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
try:
self.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,
validate_bec=False, # Don't validate - entries already validated
)
except Exception as e:
logger.debug(f"Auto-plot failed: {e}")
pass # Silently fail if plot cannot be called yet
def update_labels(self):
"""
Update the labels of the x and y axes based on current device configuration.
"""
if self._main_curve is None:
return
config = self._main_curve.config
# Safely get device names
x_device = config.x_device
y_device = config.y_device
x_name = x_device.name if x_device else None
y_name = y_device.name if y_device else None
if x_name is not None:
self.x_label = x_name # type: ignore
x_dev = self.dev.get(x_name)
if x_dev and hasattr(x_dev, "egu"):
self.x_label_units = x_dev.egu()
if y_name is not None:
self.y_label = y_name # type: ignore
y_dev = self.dev.get(y_name)
if y_dev and hasattr(y_dev, "egu"):
self.y_label_units = y_dev.egu()
################################################################################
# Scan History
################################################################################
@SafeSlot(int)
@SafeSlot(str)
@SafeSlot()

View File

@@ -151,3 +151,312 @@ def test_scatter_waveform_scan_progress(qtbot, mocked_client, monkeypatch):
# swf.scatter_dialog.close()
# assert swf.scatter_dialog is None
# assert not scatter_popup_action.isChecked(), "Should be unchecked after closing dialog"
################################################################################
# Device Property Tests
################################################################################
def test_device_safe_properties_get(qtbot, mocked_client):
"""Test that device SafeProperty getters work correctly."""
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
# Initially devices should be empty
assert swf.x_device_name == ""
assert swf.x_device_entry == ""
assert swf.y_device_name == ""
assert swf.y_device_entry == ""
assert swf.z_device_name == ""
assert swf.z_device_entry == ""
# Set devices via plot
swf.plot(x_name="samx", y_name="samy", z_name="bpm4i")
# Check properties return device names and entries separately
assert swf.x_device_name == "samx"
assert swf.x_device_entry # Should have some entry
assert swf.y_device_name == "samy"
assert swf.y_device_entry # Should have some entry
assert swf.z_device_name == "bpm4i"
assert swf.z_device_entry # Should have some entry
def test_device_safe_properties_set_name(qtbot, mocked_client):
"""Test that device SafeProperty setters work for device names."""
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
# Set x_device_name - should auto-validate entry
swf.x_device_name = "samx"
assert swf._main_curve.config.x_device is not None
assert swf._main_curve.config.x_device.name == "samx"
assert swf._main_curve.config.x_device.entry is not None # Entry should be validated
assert swf.x_device_name == "samx"
# Set y_device_name
swf.y_device_name = "samy"
assert swf._main_curve.config.y_device is not None
assert swf._main_curve.config.y_device.name == "samy"
assert swf._main_curve.config.y_device.entry is not None
assert swf.y_device_name == "samy"
# Set z_device_name
swf.z_device_name = "bpm4i"
assert swf._main_curve.config.z_device is not None
assert swf._main_curve.config.z_device.name == "bpm4i"
assert swf._main_curve.config.z_device.entry is not None
assert swf.z_device_name == "bpm4i"
def test_device_safe_properties_set_entry(qtbot, mocked_client):
"""Test that device entry properties can override default entries."""
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
# Set device name first - this auto-validates entry
swf.x_device_name = "samx"
initial_entry = swf.x_device_entry
assert initial_entry # Should have auto-validated entry
# Override with specific entry
swf.x_device_entry = "samx"
assert swf._main_curve.config.x_device.entry == "samx"
assert swf.x_device_entry == "samx"
# Same for y device
swf.y_device_name = "samy"
swf.y_device_entry = "samy_setpoint"
assert swf._main_curve.config.y_device.entry == "samy_setpoint"
# Same for z device
swf.z_device_name = "bpm4i"
swf.z_device_entry = "bpm4i"
assert swf._main_curve.config.z_device.entry == "bpm4i"
def test_device_entry_cannot_be_set_without_name(qtbot, mocked_client):
"""Test that setting entry without device name logs warning and does nothing."""
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
# Try to set entry without device name
swf.x_device_entry = "some_entry"
# Should not crash, entry should remain empty
assert swf.x_device_entry == ""
assert swf._main_curve.config.x_device is None
def test_device_safe_properties_set_empty(qtbot, mocked_client):
"""Test that device SafeProperty setters handle empty strings."""
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
# Set device first
swf.x_device_name = "samx"
assert swf._main_curve.config.x_device is not None
# Set to empty string - should clear the device
swf.x_device_name = ""
assert swf.x_device_name == ""
assert swf._main_curve.config.x_device is None
def test_device_safe_properties_auto_plot(qtbot, mocked_client):
"""Test that setting all three devices triggers auto-plot."""
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
# Set all three devices
swf.x_device_name = "samx"
swf.y_device_name = "samy"
swf.z_device_name = "bpm4i"
# Check that plot was called (config should be updated)
assert swf._main_curve.config.x_device is not None
assert swf._main_curve.config.y_device is not None
assert swf._main_curve.config.z_device is not None
def test_device_properties_update_labels(qtbot, mocked_client):
"""Test that setting device properties updates axis labels."""
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
# Set x device - should update x label
swf.x_device_name = "samx"
assert swf.x_label == "samx"
# Set y device - should update y label
swf.y_device_name = "samy"
assert swf.y_label == "samy"
# Note: ScatterWaveform doesn't have a title like Heatmap does for z_device
def test_device_properties_partial_configuration(qtbot, mocked_client):
"""Test that widget handles partial device configuration gracefully."""
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
# Set only x device
swf.x_device_name = "samx"
assert swf.x_device_name == "samx"
assert swf.y_device_name == ""
assert swf.z_device_name == ""
# Set only y device (x already set)
swf.y_device_name = "samy"
assert swf.x_device_name == "samx"
assert swf.y_device_name == "samy"
assert swf.z_device_name == ""
# Auto-plot should not trigger yet (z missing)
# But devices should be configured
assert swf._main_curve.config.x_device is not None
assert swf._main_curve.config.y_device is not None
def test_device_properties_in_user_access(qtbot, mocked_client):
"""Test that device properties are exposed in USER_ACCESS for RPC."""
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
assert "x_device_name" in ScatterWaveform.USER_ACCESS
assert "x_device_name.setter" in ScatterWaveform.USER_ACCESS
assert "x_device_entry" in ScatterWaveform.USER_ACCESS
assert "x_device_entry.setter" in ScatterWaveform.USER_ACCESS
assert "y_device_name" in ScatterWaveform.USER_ACCESS
assert "y_device_name.setter" in ScatterWaveform.USER_ACCESS
assert "y_device_entry" in ScatterWaveform.USER_ACCESS
assert "y_device_entry.setter" in ScatterWaveform.USER_ACCESS
assert "z_device_name" in ScatterWaveform.USER_ACCESS
assert "z_device_name.setter" in ScatterWaveform.USER_ACCESS
assert "z_device_entry" in ScatterWaveform.USER_ACCESS
assert "z_device_entry.setter" in ScatterWaveform.USER_ACCESS
def test_device_properties_validation(qtbot, mocked_client):
"""Test that device entries are validated through entry_validator."""
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
# Set device name - entry should be auto-validated
swf.x_device_name = "samx"
initial_entry = swf.x_device_entry
# The entry should be validated (will be "samx" in the mock)
assert initial_entry == "samx"
# Set a different entry - should also be validated
swf.x_device_entry = "samx" # Use same name as validated entry
assert swf.x_device_entry == "samx"
def test_device_properties_with_plot_method(qtbot, mocked_client):
"""Test that device properties reflect values set via plot() method."""
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
# Use plot method
swf.plot(x_name="samx", y_name="samy", z_name="bpm4i")
# Properties should reflect the plotted devices
assert swf.x_device_name == "samx"
assert swf.y_device_name == "samy"
assert swf.z_device_name == "bpm4i"
# Entries should be validated
assert swf.x_device_entry == "samx"
assert swf.y_device_entry == "samy"
assert swf.z_device_entry == "bpm4i"
def test_device_properties_overwrite_via_properties(qtbot, mocked_client):
"""Test that device properties can overwrite values set via plot()."""
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
# First set via plot
swf.plot(x_name="samx", y_name="samy", z_name="bpm4i")
# Overwrite x device via properties
swf.x_device_name = "samz"
assert swf.x_device_name == "samz"
assert swf._main_curve.config.x_device.name == "samz"
# Overwrite y device entry
swf.y_device_entry = "samy"
assert swf.y_device_entry == "samy"
def test_device_properties_clearing_devices(qtbot, mocked_client):
"""Test clearing devices by setting to empty string."""
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
# Set all devices
swf.x_device_name = "samx"
swf.y_device_name = "samy"
swf.z_device_name = "bpm4i"
# Clear x device
swf.x_device_name = ""
assert swf.x_device_name == ""
assert swf._main_curve.config.x_device is None
# Y and Z should still be set
assert swf.y_device_name == "samy"
assert swf.z_device_name == "bpm4i"
def test_device_properties_property_changed_signal(qtbot, mocked_client):
"""Test that property_changed signal is emitted when devices are set."""
from unittest.mock import Mock
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
# Connect mock to property_changed signal
mock_handler = Mock()
swf.property_changed.connect(mock_handler)
# Set device name
swf.x_device_name = "samx"
# Signal should have been emitted
assert mock_handler.called
# Check it was called with correct arguments
mock_handler.assert_any_call("x_device_name", "samx")
def test_device_entry_validation_with_invalid_device(qtbot, mocked_client):
"""Test that invalid device names are handled gracefully."""
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
# Try to set invalid device name
swf.x_device_name = "nonexistent_device"
# Should not crash, but device might not be set if validation fails
# The implementation silently fails, so we just check it doesn't crash
def test_device_properties_sequential_entry_changes(qtbot, mocked_client):
"""Test changing device entry multiple times."""
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
# Set device
swf.x_device_name = "samx"
# Change entry multiple times
swf.x_device_entry = "samx_velocity"
assert swf.x_device_entry == "samx_velocity"
swf.x_device_entry = "samx_setpoint"
assert swf.x_device_entry == "samx_setpoint"
swf.x_device_entry = "samx"
assert swf.x_device_entry == "samx"
def test_device_properties_with_none_values(qtbot, mocked_client):
"""Test that None values are handled as empty strings."""
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
# Device name None should be treated as empty
swf.x_device_name = None
assert swf.x_device_name == ""
# Set a device first
swf.y_device_name = "samy"
# Entry None should not change anything
swf.y_device_entry = None
assert swf.y_device_entry # Should still have validated entry