diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index b140f63c..7f82ef2e 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -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.""" diff --git a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py index 28ae9d7f..39736535 100644 --- a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py +++ b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py @@ -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() diff --git a/tests/unit_tests/test_scatter_waveform.py b/tests/unit_tests/test_scatter_waveform.py index 8476ebe5..95d950a6 100644 --- a/tests/unit_tests/test_scatter_waveform.py +++ b/tests/unit_tests/test_scatter_waveform.py @@ -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