diff --git a/README.md b/README.md index 91e5901aa16647c964b4bc40da5bb7d99f50896b..bc579103e6e432f0230cb42997b2f8f2a300a648 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ tox -e debug tests.requests.test_prometheus ``` ## Releasenotes +- 0.13 - Added lazy connection behavior to `devices.LofarDeviceProxy` class - 0.12. * Added `HDF5Writer` class for writing HDF5 statistics file * Added `Receiver` class for creating a file/TCP receiver for retrieving statistics data diff --git a/VERSION b/VERSION index 34a83616bb5aa9a70c5713bc45cd45498a50ba24..f3040840fd7058ec0e224314c609184fd4ec53f2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.12.1 +0.13 diff --git a/lofar_station_client/devices.py b/lofar_station_client/devices.py index aac73f1b8f89f2c859c5f5fd42f9450b8116ad1f..b49468809fece20bba9ffd2ba86eb6b3e5c333ec 100644 --- a/lofar_station_client/devices.py +++ b/lofar_station_client/devices.py @@ -1,77 +1,116 @@ """ Enhanced interfaces towards the station's Tango devices. """ +# inconsistent-return-statements +# pylint: disable=R1710 + import ast from functools import lru_cache +import logging import numpy -from tango import DeviceProxy +from tango import DeviceProxy, DevFailed from tango import ExtractAs +logger = logging.getLogger() + class LofarDeviceProxy(DeviceProxy): """A LOFAR-specific tango.DeviceProxy that provides a richer experience.""" + def __init__(self, *args): + """Do not connect on device construction""" + self.__dict__["dev_name"] = str(args[0]) + self.dev_name = str(args[0]) + self.__dict__["connected"] = False + self.connected = False + logger.info("LOFARDeviceProxy %s not connected", self.dev_name) + + def __str__(self): + if not self.connected: + return f"<LOFARDeviceProxy({self.dev_name})" + return super().__str__() + + __repr__ = __str__ + repr = __str__ + + def connect(self): + """Try to estabilish a connection when a device operation is called""" + if not self.connected: + try: + super().__init__(self.dev_name) + self.__dict__["connected"] = True + self.connected = True + except DevFailed as excep: + self.__dict__["connected"] = False + self.connected = False + reason = excep.args[0].desc.replace("\n", " ") + logger.warning("LOFARDeviceProxy not connected: %s", reason) + @lru_cache() def get_attribute_config(self, name): """Get cached attribute configurations, as they are not expected to change.""" - - return super().get_attribute_config(name) + self.connect() + if self.connected: + return super().get_attribute_config(name) @lru_cache() def get_attribute_shape(self, name): """Get the shape of the requested attribute, as a tuple.""" - - config = self.get_attribute_config(name) - - if config.format and config.format[0] == "(": - # For >2D arrays, the "format" property describes actual - # the dimensions as a tuple (x, y, z, ...), - # so reshape the value accordingly. - shape = ast.literal_eval(config.format) - elif config.max_dim_y > 0: - # 2D array - shape = (config.max_dim_y, config.max_dim_x) - elif config.max_dim_x > 1: - # 1D array - shape = (config.max_dim_x,) - else: - # scalar - shape = () - - return shape + self.connect() + if self.connected: + config = self.get_attribute_config(name) + + if config.format and config.format[0] == "(": + # For >2D arrays, the "format" property describes actual + # the dimensions as a tuple (x, y, z, ...), + # so reshape the value accordingly. + shape = ast.literal_eval(config.format) + elif config.max_dim_y > 0: + # 2D array + shape = (config.max_dim_y, config.max_dim_x) + elif config.max_dim_x > 1: + # 1D array + shape = (config.max_dim_x,) + else: + # scalar + shape = () + + return shape def read_attribute(self, name, extract_as=ExtractAs.Numpy): """Read an attribute from the server.""" + self.connect() + if self.connected: + attr = super().read_attribute(name, extract_as) - attr = super().read_attribute(name, extract_as) - - # convert non-scalar values into their actual shape - shape = self.get_attribute_shape(name) - if shape != (): - attr.value = attr.value.reshape(shape) + # convert non-scalar values into their actual shape + shape = self.get_attribute_shape(name) + if shape != (): + attr.value = attr.value.reshape(shape) - return attr + return attr def write_attribute(self, name, value): """Write an attribute to the server.""" - - config = self.get_attribute_config(name) - shape = self.get_attribute_shape(name) - - # 2D arrays also represent arrays of higher dimensionality. reshape them - # to fit their original Tango shape before writing. - if shape != (): - value = numpy.array(value) - - if value.shape != shape: - raise ValueError( - f"Invalid shape. Given: {value.shape} Expected: {shape}" - ) - - if len(shape) > 2: - # >2D arrays collapse into 2D - value = value.reshape((config.max_dim_y, config.max_dim_x)) - - return super().write_attribute(name, value) + self.connect() + if self.connected: + config = self.get_attribute_config(name) + shape = self.get_attribute_shape(name) + + # 2D arrays also represent arrays of higher dimensionality. reshape them + # to fit their original Tango shape before writing. + if shape != (): + value = numpy.array(value) + + if value.shape != shape: + raise ValueError( + f"Invalid shape. Given: {value.shape} Expected: {shape}" + ) + + if len(shape) > 2: + # >2D arrays collapse into 2D + value = value.reshape((config.max_dim_y, config.max_dim_x)) + + return super().write_attribute(name, value) diff --git a/tests/test_devices.py b/tests/test_devices.py index 8db4f39178562a94ee6ddbadc151c728a173ddde..9cbf97dd0480b312f373c57e2c8e0c5e53dd9c9f 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -75,6 +75,7 @@ class LofarDeviceProxyTest(base.TestCase): cls.context.start() cls.proxy = LofarDeviceProxy(cls.context.get_device_access("STAT/MyDevice/1")) + cls.proxy.connect() # necessary in the DeviceTestContext @classmethod def tearDownClass(cls): @@ -137,6 +138,63 @@ class LofarDeviceProxyTest(base.TestCase): self.assertEqual((2, 3, 4), value.shape) +class LazyLofarDeviceProxyTest(base.TestCase): + TEST_DEVICE_INFO = [ + { + "class": MyDevice, + "devices": [{"name": "STAT/MyDevice/2", "properties": {}, "memorized": {}}], + } + ] + + @classmethod + def setUpClass(cls): + # setting up the TestContext takes ~1 second, so do it only once + cls.context = MultiDeviceTestContext( + cls.TEST_DEVICE_INFO, + process=True, + ) + + cls.context.start() + cls.proxy = LofarDeviceProxy(cls.context.get_device_access("STAT/MyDevice/2")) + cls.proxy.connected = False + + @classmethod + def tearDownClass(cls): + # In Python3.8+, we can use addClassCleanup instead + cls.context.stop() + + @classmethod + def connect(cls): + # Simulate that a connection with the DB has been estabilished, + # i.e. Device has been created in the TangoDB + cls.proxy.connect() + + @classmethod + def disconnect(cls): + # Simulate a disconnected device + cls.proxy.connected = False + + def test_lazy_read_scalar(self): + self.disconnect() + # DeviceProxy not yet initialised + with self.assertRaises(AttributeError): + value = self.proxy.scalar + # Simulate connection with DB + self.connect() + value = self.proxy.scalar + self.assertEqual(bool, type(value)) + + def test_lazy_write_scalar(self): + self.disconnect() + with self.assertRaises(AttributeError): + self.proxy.scalar = True + # Simulate connection with DB + self.connect() + self.assertEqual(False, self.proxy.scalar) + self.proxy.scalar = True + self.assertEqual(True, self.proxy.scalar) + + class RecvDeviceTest(MyDevice): RCU_attenuator_dB_R = attribute(