diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 7ae6767f..b140f63c 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -2039,6 +2039,90 @@ class Heatmap(RPCBase): reload (bool): Whether to reload the heatmap with new data. """ + @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 Image(RPCBase): """Image widget for displaying 2D data.""" diff --git a/bec_widgets/widgets/plots/heatmap/heatmap.py b/bec_widgets/widgets/plots/heatmap/heatmap.py index d715772d..dc80269e 100644 --- a/bec_widgets/widgets/plots/heatmap/heatmap.py +++ b/bec_widgets/widgets/plots/heatmap/heatmap.py @@ -203,6 +203,19 @@ class Heatmap(ImageBase): "remove_roi", "rois", "plot", + # 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", ] PLUGIN = True @@ -413,9 +426,15 @@ class Heatmap(ImageBase): """ if self._image_config is None: return - x_name = self._image_config.x_device.name - y_name = self._image_config.y_device.name - z_name = self._image_config.z_device.name + + # Safely get device names (might be None if not yet configured) + x_device = self._image_config.x_device + y_device = self._image_config.y_device + z_device = self._image_config.z_device + + x_name = x_device.name if x_device else None + y_name = y_device.name if y_device else None + z_name = z_device.name if z_device else None if x_name is not None: self.x_label = x_name # type: ignore @@ -1136,6 +1155,244 @@ class Heatmap(ImageBase): self.crosshair.reset() super().reset() + ################################################################################ + # Widget Specific Properties + ################################################################################ + + @SafeProperty(str) + def x_device_name(self) -> str: + """Device name for the X axis.""" + if self._image_config.x_device is None: + return "" + return self._image_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 "" + + # Get current entry or validate + if device_name: + try: + entry = self.entry_validator.validate_signal(device_name, None) + self._image_config.x_device = HeatmapDeviceSignal(name=device_name, entry=entry) + self.property_changed.emit("x_device_name", device_name) + self.update_labels() # Update axis labels + self._try_auto_plot() + except Exception: + pass # Silently fail if device is not available yet + else: + self._image_config.x_device = None + self.property_changed.emit("x_device_name", "") + self.update_labels() # Clear axis labels + + @SafeProperty(str) + def x_device_entry(self) -> str: + """Signal entry for the X axis device.""" + if self._image_config.x_device is None: + return "" + return self._image_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._image_config.x_device is None: + logger.warning("Cannot set x_device_entry without x_device_name set first.") + return + + device_name = self._image_config.x_device.name + try: + # Validate the entry for this device + validated_entry = self.entry_validator.validate_signal(device_name, entry) + self._image_config.x_device = HeatmapDeviceSignal( + name=device_name, entry=validated_entry + ) + self.property_changed.emit("x_device_entry", validated_entry) + self.update_labels() # Update axis 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._image_config.y_device is None: + return "" + return self._image_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 "" + + # Get current entry or validate + if device_name: + try: + entry = self.entry_validator.validate_signal(device_name, None) + self._image_config.y_device = HeatmapDeviceSignal(name=device_name, entry=entry) + self.property_changed.emit("y_device_name", device_name) + self.update_labels() # Update axis labels + self._try_auto_plot() + except Exception: + pass # Silently fail if device is not available yet + else: + self._image_config.y_device = None + self.property_changed.emit("y_device_name", "") + self.update_labels() # Clear axis labels + + @SafeProperty(str) + def y_device_entry(self) -> str: + """Signal entry for the Y axis device.""" + if self._image_config.y_device is None: + return "" + return self._image_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._image_config.y_device is None: + logger.warning("Cannot set y_device_entry without y_device_name set first.") + return + + device_name = self._image_config.y_device.name + try: + # Validate the entry for this device + validated_entry = self.entry_validator.validate_signal(device_name, entry) + self._image_config.y_device = HeatmapDeviceSignal( + name=device_name, entry=validated_entry + ) + self.property_changed.emit("y_device_entry", validated_entry) + self.update_labels() # Update axis labels + self._try_auto_plot() + except Exception as e: + logger.debug(f"Y device entry validation failed: {e}") + pass # Silently fail if validation fails + + @SafeProperty(str) + def z_device_name(self) -> str: + """Device name for the Z (color) axis.""" + if self._image_config.z_device is None: + return "" + return self._image_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 "" + + # Get current entry or validate + if device_name: + try: + entry = self.entry_validator.validate_signal(device_name, None) + self._image_config.z_device = HeatmapDeviceSignal(name=device_name, entry=entry) + self.property_changed.emit("z_device_name", device_name) + self.update_labels() # Update axis labels (title) + self._try_auto_plot() + except Exception as e: + logger.debug(f"Z device name validation failed: {e}") + pass # Silently fail if device is not available yet + else: + self._image_config.z_device = None + self.property_changed.emit("z_device_name", "") + self.update_labels() # Clear axis labels + + @SafeProperty(str) + def z_device_entry(self) -> str: + """Signal entry for the Z (color) axis device.""" + if self._image_config.z_device is None: + return "" + return self._image_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._image_config.z_device is None: + logger.warning("Cannot set z_device_entry without z_device_name set first.") + return + + device_name = self._image_config.z_device.name + try: + # Validate the entry for this device + validated_entry = self.entry_validator.validate_signal(device_name, entry) + self._image_config.z_device = HeatmapDeviceSignal( + name=device_name, entry=validated_entry + ) + self.property_changed.emit("z_device_entry", validated_entry) + self.update_labels() # Update axis labels (title) + self._try_auto_plot() + except Exception as e: + logger.debug(f"Z device entry validation failed: {e}") + pass # Silently fail if validation fails + + def _try_auto_plot(self) -> None: + """ + Attempt to automatically call plot() if all three devices are set. + Similar to waveform's approach but requires all three devices. + """ + has_x = self._image_config.x_device is not None + has_y = self._image_config.y_device is not None + has_z = self._image_config.z_device is not None + + if has_x and has_y and has_z: + x_name = self._image_config.x_device.name + x_entry = self._image_config.x_device.entry + y_name = self._image_config.y_device.name + y_entry = self._image_config.y_device.entry + z_name = self._image_config.z_device.name + z_entry = self._image_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 + @SafeProperty(str) def interpolation_method(self) -> str: """ diff --git a/tests/unit_tests/test_heatmap_widget.py b/tests/unit_tests/test_heatmap_widget.py index 517e9f05..38a7b67a 100644 --- a/tests/unit_tests/test_heatmap_widget.py +++ b/tests/unit_tests/test_heatmap_widget.py @@ -597,3 +597,277 @@ def test_finish_interpolation_thread_cleans_references(heatmap_widget): thread_mock.deleteLater.assert_called_once() assert heatmap_widget._interpolation_worker is None assert heatmap_widget._interpolation_thread is None + + +def test_device_safe_properties_get(heatmap_widget): + """Test that device SafeProperty getters work correctly.""" + # Initially devices should be empty + assert heatmap_widget.x_device_name == "" + assert heatmap_widget.x_device_entry == "" + assert heatmap_widget.y_device_name == "" + assert heatmap_widget.y_device_entry == "" + assert heatmap_widget.z_device_name == "" + assert heatmap_widget.z_device_entry == "" + + # Set devices via plot + heatmap_widget.plot(x_name="samx", y_name="samy", z_name="bpm4i") + + # Check properties return device names and entries separately + assert heatmap_widget.x_device_name == "samx" + assert heatmap_widget.x_device_entry # Should have some entry + assert heatmap_widget.y_device_name == "samy" + assert heatmap_widget.y_device_entry # Should have some entry + assert heatmap_widget.z_device_name == "bpm4i" + assert heatmap_widget.z_device_entry # Should have some entry + + +def test_device_safe_properties_set_name(heatmap_widget): + """Test that device SafeProperty setters work for device names.""" + # Set x_device_name - should auto-validate entry + heatmap_widget.x_device_name = "samx" + assert heatmap_widget._image_config.x_device is not None + assert heatmap_widget._image_config.x_device.name == "samx" + assert heatmap_widget._image_config.x_device.entry is not None # Entry should be validated + assert heatmap_widget.x_device_name == "samx" + + # Set y_device_name + heatmap_widget.y_device_name = "samy" + assert heatmap_widget._image_config.y_device is not None + assert heatmap_widget._image_config.y_device.name == "samy" + assert heatmap_widget._image_config.y_device.entry is not None + assert heatmap_widget.y_device_name == "samy" + + # Set z_device_name + heatmap_widget.z_device_name = "bpm4i" + assert heatmap_widget._image_config.z_device is not None + assert heatmap_widget._image_config.z_device.name == "bpm4i" + assert heatmap_widget._image_config.z_device.entry is not None + assert heatmap_widget.z_device_name == "bpm4i" + + +def test_device_safe_properties_set_entry(heatmap_widget): + """Test that device entry properties can override default entries.""" + # Set device name first - this auto-validates entry + heatmap_widget.x_device_name = "samx" + initial_entry = heatmap_widget.x_device_entry + assert initial_entry # Should have auto-validated entry + + # Override with specific entry + heatmap_widget.x_device_entry = "samx" + assert heatmap_widget._image_config.x_device.entry == "samx" + assert heatmap_widget.x_device_entry == "samx" + + # Same for y device + heatmap_widget.y_device_name = "samy" + heatmap_widget.y_device_entry = "samy_setpoint" + assert heatmap_widget._image_config.y_device.entry == "samy_setpoint" + + # Same for z device + heatmap_widget.z_device_name = "bpm4i" + heatmap_widget.z_device_entry = "bpm4i" + assert heatmap_widget._image_config.z_device.entry == "bpm4i" + + +def test_device_entry_cannot_be_set_without_name(heatmap_widget): + """Test that setting entry without device name logs warning and does nothing.""" + # Try to set entry without device name + heatmap_widget.x_device_entry = "some_entry" + # Should not crash, entry should remain empty + assert heatmap_widget.x_device_entry == "" + assert heatmap_widget._image_config.x_device is None + + +def test_device_safe_properties_set_empty(heatmap_widget): + """Test that device SafeProperty setters handle empty strings.""" + # Set device first + heatmap_widget.x_device_name = "samx" + assert heatmap_widget._image_config.x_device is not None + + # Set to empty string - should clear the device + heatmap_widget.x_device_name = "" + assert heatmap_widget.x_device_name == "" + assert heatmap_widget._image_config.x_device is None + + +def test_device_safe_properties_auto_plot(heatmap_widget): + """Test that setting all three devices triggers auto-plot.""" + # Set all three devices + heatmap_widget.x_device_name = "samx" + heatmap_widget.y_device_name = "samy" + heatmap_widget.z_device_name = "bpm4i" + + # Check that plot was called (image_config should be updated) + assert heatmap_widget._image_config.x_device is not None + assert heatmap_widget._image_config.y_device is not None + assert heatmap_widget._image_config.z_device is not None + + +def test_device_properties_update_labels(heatmap_widget): + """Test that setting device properties updates axis labels.""" + # Set x device - should update x label + heatmap_widget.x_device_name = "samx" + assert heatmap_widget.x_label == "samx" + + # Set y device - should update y label + heatmap_widget.y_device_name = "samy" + assert heatmap_widget.y_label == "samy" + + # Set z device - should update title + heatmap_widget.z_device_name = "bpm4i" + assert heatmap_widget.title == "bpm4i" + + +def test_device_properties_partial_configuration(heatmap_widget): + """Test that widget handles partial device configuration gracefully.""" + # Set only x device + heatmap_widget.x_device_name = "samx" + assert heatmap_widget.x_device_name == "samx" + assert heatmap_widget.y_device_name == "" + assert heatmap_widget.z_device_name == "" + + # Set only y device (x already set) + heatmap_widget.y_device_name = "samy" + assert heatmap_widget.x_device_name == "samx" + assert heatmap_widget.y_device_name == "samy" + assert heatmap_widget.z_device_name == "" + + # Auto-plot should not trigger yet (z missing) + # But devices should be configured + assert heatmap_widget._image_config.x_device is not None + assert heatmap_widget._image_config.y_device is not None + + +def test_device_properties_in_user_access(heatmap_widget): + """Test that device properties are exposed in USER_ACCESS for RPC.""" + from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap + + assert "x_device_name" in Heatmap.USER_ACCESS + assert "x_device_name.setter" in Heatmap.USER_ACCESS + assert "x_device_entry" in Heatmap.USER_ACCESS + assert "x_device_entry.setter" in Heatmap.USER_ACCESS + assert "y_device_name" in Heatmap.USER_ACCESS + assert "y_device_name.setter" in Heatmap.USER_ACCESS + assert "y_device_entry" in Heatmap.USER_ACCESS + assert "y_device_entry.setter" in Heatmap.USER_ACCESS + assert "z_device_name" in Heatmap.USER_ACCESS + assert "z_device_name.setter" in Heatmap.USER_ACCESS + assert "z_device_entry" in Heatmap.USER_ACCESS + assert "z_device_entry.setter" in Heatmap.USER_ACCESS + + +def test_device_properties_validation(heatmap_widget): + """Test that device entries are validated through entry_validator.""" + # Set device name - entry should be auto-validated + heatmap_widget.x_device_name = "samx" + initial_entry = heatmap_widget.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 + heatmap_widget.x_device_entry = "samx" # Use same name as validated entry + assert heatmap_widget.x_device_entry == "samx" + + +def test_device_properties_with_plot_method(heatmap_widget): + """Test that device properties reflect values set via plot() method.""" + # Use plot method + heatmap_widget.plot(x_name="samx", y_name="samy", z_name="bpm4i") + + # Properties should reflect the plotted devices + assert heatmap_widget.x_device_name == "samx" + assert heatmap_widget.y_device_name == "samy" + assert heatmap_widget.z_device_name == "bpm4i" + + # Entries should be validated + assert heatmap_widget.x_device_entry == "samx" + assert heatmap_widget.y_device_entry == "samy" + assert heatmap_widget.z_device_entry == "bpm4i" + + +def test_device_properties_overwrite_via_properties(heatmap_widget): + """Test that device properties can overwrite values set via plot().""" + # First set via plot + heatmap_widget.plot(x_name="samx", y_name="samy", z_name="bpm4i") + + # Overwrite x device via properties + heatmap_widget.x_device_name = "samz" + assert heatmap_widget.x_device_name == "samz" + assert heatmap_widget._image_config.x_device.name == "samz" + + # Overwrite y device entry + heatmap_widget.y_device_entry = "samy" + assert heatmap_widget.y_device_entry == "samy" + + +def test_device_properties_clearing_devices(heatmap_widget): + """Test clearing devices by setting to empty string.""" + # Set all devices + heatmap_widget.x_device_name = "samx" + heatmap_widget.y_device_name = "samy" + heatmap_widget.z_device_name = "bpm4i" + + # Clear x device + heatmap_widget.x_device_name = "" + assert heatmap_widget.x_device_name == "" + assert heatmap_widget._image_config.x_device is None + + # Y and Z should still be set + assert heatmap_widget.y_device_name == "samy" + assert heatmap_widget.z_device_name == "bpm4i" + + +def test_device_properties_property_changed_signal(heatmap_widget): + """Test that property_changed signal is emitted when devices are set.""" + from unittest.mock import Mock + + # Connect mock to property_changed signal + mock_handler = Mock() + heatmap_widget.property_changed.connect(mock_handler) + + # Set device name + heatmap_widget.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(heatmap_widget): + """Test that invalid device names are handled gracefully.""" + # Try to set invalid device name + heatmap_widget.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(heatmap_widget): + """Test changing device entry multiple times.""" + # Set device + heatmap_widget.x_device_name = "samx" + + # Change entry multiple times + heatmap_widget.x_device_entry = "samx_velocity" + assert heatmap_widget.x_device_entry == "samx_velocity" + + heatmap_widget.x_device_entry = "samx_setpoint" + assert heatmap_widget.x_device_entry == "samx_setpoint" + + heatmap_widget.x_device_entry = "samx" + assert heatmap_widget.x_device_entry == "samx" + + +def test_device_properties_with_none_values(heatmap_widget): + """Test that None values are handled as empty strings.""" + # Device name None should be treated as empty + heatmap_widget.x_device_name = None + assert heatmap_widget.x_device_name == "" + + # Set a device first + heatmap_widget.y_device_name = "samy" + + # Entry None should not change anything + heatmap_widget.y_device_entry = None + assert heatmap_widget.y_device_entry # Should still have validated entry