diff --git a/tango/pyaml/__init__.py b/tango/pyaml/__init__.py index a73e0e9..885fbfd 100644 --- a/tango/pyaml/__init__.py +++ b/tango/pyaml/__init__.py @@ -1,3 +1,26 @@ +""" +Tango backend for pyAML. + +This package bridges `Tango Controls `_ and +the pyAML abstraction layer. It exposes Tango attributes and attribute groups +as pyAML :class:`~pyaml.control.deviceaccess.DeviceAccess` objects, provides a +:class:`~tango.pyaml.controlsystem.TangoControlSystem` implementation and +catalogs that resolve pyAML device keys into Tango attribute references. + +The package registers its configuration schemas with pyAML through the +``pyaml.schemas`` entry point, so the classes below can be used directly in +pyAML YAML configuration files. + +Logging is configured at import time from two optional environment variables: + +``TANGO_PYAML_LOG_CONFIG`` + Path to a :mod:`logging.config` file (default ``tango_pyaml_logging.conf`` + in the current directory). Loaded only if the file exists. +``TANGO_PYAML_LOG_LEVEL`` + Level name (``DEBUG``, ``INFO``, ``WARNING``, ``ERROR``, ``CRITICAL``) + applied to the ``tango.pyaml`` logger. +""" + import logging.config import os diff --git a/tango/pyaml/attribute.py b/tango/pyaml/attribute.py index 36d90fe..34727fa 100644 --- a/tango/pyaml/attribute.py +++ b/tango/pyaml/attribute.py @@ -1,3 +1,11 @@ +""" +Scalar Tango attribute access. + +This module maps a single Tango attribute (or one element of a SPECTRUM +attribute) onto the pyAML :class:`~pyaml.control.deviceaccess.DeviceAccess` +interface. +""" + import copy import logging @@ -48,6 +56,10 @@ class Attribute(DeviceAccess, InitializableElement, DynamicValidation): """ Tango attribute that can be written to. + The Tango device proxy is obtained lazily from + :class:`~tango.pyaml.device_factory.DeviceFactory` on first access, so + building an ``Attribute`` never contacts the control system. + Parameters ---------- attribute : str @@ -63,6 +75,56 @@ class Attribute(DeviceAccess, InitializableElement, DynamicValidation): writable : bool, optional If the attribute should be writable. Default is True. + Attributes + ---------- + _attribute : str + Full path of the Tango attribute. + _unit : str + Unit of the attribute. + _range : tuple of (float or None, float or None) or None + Configured range, or ``None`` to query Tango on first use. + _index : int or None + Index into a SPECTRUM attribute, or ``None`` for scalar access. + _writable : bool + ``True`` if writes are allowed (always ``False`` when indexed). + _attribute_dev : tango.DeviceProxy or None + Proxy of the device owning the attribute, set on initialization. + _attr_config : tango.AttributeInfoEx or None + Tango attribute configuration, set on initialization. + _attribute_dev_name : str or None + Device part of the attribute path, set on initialization. + _attr_name : str or None + Attribute part of the attribute path, set on initialization. + + Methods + ------- + initialize() + Connect to the Tango device and check the attribute configuration. + is_writable() + Tell whether the attribute accepts writes. + set(value) + Write a value asynchronously to the Tango attribute. + set_and_wait(value) + Write a value synchronously to the Tango attribute. + get() + Get the last written value of the attribute. + readback() + Return the readback value with metadata. + unit() + Return the unit of the attribute. + name() + Return the full attribute name. + measure_name() + Return the short attribute name (last component). + get_tango_attribute() + Return the raw Tango attribute path without index decoration. + clone_with_tango_attribute(attribute) + Return a shallow copy configured with another Tango attribute path. + get_range() + Return the valid range of the attribute. + check_device_availability() + Check whether the Tango device answers to a ping. + Raises ------ pyaml.PyAMLException @@ -92,6 +154,20 @@ def __init__( self._attr_name: str = None def initialize(self): + """ + Connect to the Tango device and check the attribute configuration. + + Splits the attribute path into device and attribute names, obtains + the device proxy from :class:`~tango.pyaml.device_factory.DeviceFactory` + and reads the attribute configuration. + + Raises + ------ + pyaml.PyAMLException + If the device proxy cannot be created, if an indexed attribute is + not a SPECTRUM, or if a writable attribute is not writable in + Tango. + """ super().initialize() try: self._attribute_dev_name, self._attr_name = self._attribute.rsplit("/", 1) @@ -122,6 +198,14 @@ def initialize(self): ) def is_writable(self): + """ + Tell whether the attribute accepts writes. + + Returns + ------- + bool + ``True`` if :meth:`set` and :meth:`set_and_wait` are allowed. + """ return self._writable def set(self, value: float): @@ -253,6 +337,11 @@ def clone_with_tango_attribute(self, attribute: str) -> "Attribute": ---------- attribute : str Tango attribute path to store in the cloned instance. + + Returns + ------- + Attribute + Copy of this instance pointing to ``attribute``. """ new_obj = copy.copy(self) new_obj._attribute = attribute @@ -300,6 +389,23 @@ def get(self) -> float: raise tango_to_PyAMLException(df) def get_range(self) -> list[float]: + """ + Return the valid range of the attribute. + + The configured ``range`` takes precedence; otherwise the ``min_value`` + and ``max_value`` limits of the Tango attribute configuration are + used, which requires initialization. + + Returns + ------- + list of float or None + ``[min, max]`` where an unbounded limit is ``None``. + + Raises + ------ + pyaml.PyAMLException + If no range is configured and initialization fails. + """ attr_range: list[float] = [None, None] if self._range is not None: attr_range[0] = self._range[0] if self._range[0] is not None else None @@ -314,6 +420,15 @@ def get_range(self) -> list[float]: return attr_range def check_device_availability(self) -> bool: + """ + Check whether the Tango device answers to a ping. + + Returns + ------- + bool + ``True`` if the device is reachable, ``False`` if initialization + or the ping fails. + """ available = True try: self._ensure_initialized() diff --git a/tango/pyaml/attribute_list.py b/tango/pyaml/attribute_list.py index 6e39a62..ed61b7f 100644 --- a/tango/pyaml/attribute_list.py +++ b/tango/pyaml/attribute_list.py @@ -1,3 +1,11 @@ +""" +Grouped Tango attribute access. + +This module handles a list of Tango attributes through :class:`tango.Group` +objects, one per distinct attribute name, so that a value can be written to or +read from many devices in a single call. +""" + import logging from numpy import array @@ -42,6 +50,10 @@ class AttributeList(DeviceAccess, InitializableElement, DynamicValidation): """ Handle a list of Tango attributes using Tango Groups. + Attributes are grouped by attribute name: one :class:`tango.Group` is + created per distinct attribute name and holds every device exposing it. + Groups are created lazily on first access. + Parameters ---------- attributes : list of str @@ -50,6 +62,44 @@ class AttributeList(DeviceAccess, InitializableElement, DynamicValidation): Group name. unit : str, optional Unit of the attributes. + + Attributes + ---------- + _attributes : list of str + Tango attribute paths in configured order. + _name : str + Group name. + _unit : str + Unit of the attributes. + _tango_groups : dict of str to tango.Group + Tango groups indexed by attribute name, created on initialization. + _attr_dev : dict of str to list of str + Device names indexed by attribute name. + + Methods + ------- + initialize() + Create one Tango group per attribute name. + name() + Return the group name. + measure_name() + Return the group name (alias for measurement name). + get_tango_attributes() + Return the raw Tango attribute paths stored in the configuration. + set(value) + Write a value asynchronously to all Tango attributes. + set_and_wait(value) + Write a value synchronously to all Tango attributes. + get() + Return the last written values of all attributes. + readback() + Return readback values with metadata for all attributes. + unit() + Return the unit for the attribute list. + get_range() + Return the valid ranges of the attributes. + check_device_availability() + Check whether every device of the groups answers to a ping. """ def __init__(self, attributes: list[str], name: str = "", unit: str = ""): @@ -70,6 +120,12 @@ def __init__(self, attributes: list[str], name: str = "", unit: str = ""): self._attr_dev[attr_name].append(attribute_dev_name) def initialize(self): + """ + Create one Tango group per attribute name. + + Each group is named after the list and populated with the devices + exposing that attribute. + """ super().initialize() for attr_name, dev_list in self._attr_dev.items(): self._tango_groups[attr_name] = tango.Group(self._name) @@ -103,7 +159,7 @@ def get_tango_attributes(self) -> list[str]: Returns ------- - list[str] + list of str Tango attribute paths in configured order. """ return self._attributes @@ -148,8 +204,9 @@ def get(self) -> array: Returns ------- - numpy.array - Array of last written values ordered as in configuration. + numpy.ndarray + Array of last written values ordered as in configuration. Entries + whose read failed are ``None``. """ self._ensure_initialized() result = {} @@ -172,8 +229,10 @@ def readback(self) -> array: Returns ------- - numpy.array - Array of Value objects ordered as in configuration. + numpy.ndarray + Array of :class:`~pyaml.control.readback_value.Value` objects + ordered as in configuration. Entries whose read failed are + ``None``. """ self._ensure_initialized() logger.log(logging.DEBUG, f"Reading list {self.name()}") @@ -210,6 +269,19 @@ def unit(self) -> str: return self._unit def get_range(self) -> list[float]: + """ + Return the valid ranges of the attributes. + + If a ``_range`` is configured it is returned as ``[min, max]``. + Otherwise the limits are read from the Tango attribute configuration + of every device and returned flattened as + ``[min0, max0, min1, max1, ...]``, in group order. + + Returns + ------- + list of float or None + Range limits, where an unbounded limit is ``None``. + """ attr_range: list[float] = [None, None] if self._range is not None: attr_range[0] = self._range[0] if self._range[0] is not None else None @@ -230,6 +302,15 @@ def get_range(self) -> list[float]: return attr_range def check_device_availability(self) -> bool: + """ + Check whether every device of the groups answers to a ping. + + Returns + ------- + bool + ``True`` if all devices are reachable, ``False`` if initialization + or any ping fails. + """ available = True try: self._ensure_initialized() diff --git a/tango/pyaml/attribute_list_read_only.py b/tango/pyaml/attribute_list_read_only.py index fec7d89..3b12a1e 100644 --- a/tango/pyaml/attribute_list_read_only.py +++ b/tango/pyaml/attribute_list_read_only.py @@ -1,3 +1,5 @@ +"""Read-only list of Tango attributes handled through Tango groups.""" + import logging import pyaml @@ -10,13 +12,17 @@ logger = logging.getLogger(__name__) -class AttributeListReadOnlyConfig(AttributeListConfig): ... +class AttributeListReadOnlyConfig(AttributeListConfig): + """Configuration model for a read-only list of Tango attributes.""" @register_schema class AttributeListReadOnly(AttributeList, DynamicValidation): """ - Handle a list of Tango attributes using Tango Groups. + Handle a read-only list of Tango attributes using Tango Groups. + + Same as :class:`~tango.pyaml.attribute_list.AttributeList`, except that + asynchronous writes through :meth:`set` are rejected. Parameters ---------- @@ -26,6 +32,22 @@ class AttributeListReadOnly(AttributeList, DynamicValidation): Group name. unit : str, optional Unit of the attributes. + + Attributes + ---------- + _attributes : list of str + Tango attribute paths in configured order. + _name : str + Group name. + _unit : str + Unit of the attributes. + + Methods + ------- + set(value) + Disallowed asynchronous write operation. + set_and_wait(value) + Write a value synchronously to all Tango attributes. """ def __init__(self, attributes: list[str], name: str = "", unit: str = ""): @@ -37,12 +59,17 @@ def __init__(self, attributes: list[str], name: str = "", unit: str = ""): def set(self, value: float): """ - Write a value asynchronously to all Tango attributes. + Disallowed asynchronous write operation. Parameters ---------- value : float - Value to write. + Ignored. + + Raises + ------ + pyaml.PyAMLException + Always raised because the attribute list is read-only. """ raise pyaml.PyAMLException( f"Tango attribute list {self.name()} is not writable." diff --git a/tango/pyaml/attribute_read_only.py b/tango/pyaml/attribute_read_only.py index a8e6f65..40d3a9a 100644 --- a/tango/pyaml/attribute_read_only.py +++ b/tango/pyaml/attribute_read_only.py @@ -1,3 +1,5 @@ +"""Read-only scalar Tango attribute.""" + import logging import pyaml @@ -19,6 +21,11 @@ class AttributeReadOnly(Attribute, DynamicValidation): """ Read-only Tango attribute. + Behaves like :class:`~tango.pyaml.attribute.Attribute` but rejects every + write and never checks the Tango attribute writability on + initialization. :meth:`get` returns the readback value instead of the + last written value. + Parameters ---------- attribute : str @@ -31,6 +38,26 @@ class AttributeReadOnly(Attribute, DynamicValidation): Zero-based index into a SPECTRUM attribute. When set, the instance behaves as a read-only scalar view of one vector element; writes are always rejected and a SPECTRUM data_format is enforced on init. + + Attributes + ---------- + _attribute : str + Full path of the Tango attribute. + _unit : str + Unit of the attribute. + _range : tuple of (float or None, float or None) or None + Configured range, or ``None`` to query Tango. + _index : int or None + Index into a SPECTRUM attribute, or ``None`` for scalar access. + + Methods + ------- + set(value) + Disallowed write operation. + set_and_wait(value) + Disallowed synchronous write operation. + get() + Return the current readback value of the attribute. """ def __init__( @@ -53,6 +80,11 @@ def set(self, value: float): """ Disallowed write operation. + Parameters + ---------- + value : float + Ignored. + Raises ------ pyaml.PyAMLException @@ -66,6 +98,11 @@ def set_and_wait(self, value: float): """ Disallowed synchronous write operation. + Parameters + ---------- + value : float + Ignored. + Raises ------ pyaml.PyAMLException @@ -76,4 +113,20 @@ def set_and_wait(self, value: float): ) def get(self) -> float: + """ + Return the current readback value of the attribute. + + A read-only attribute has no setpoint, so this is equivalent to + ``readback().value``. + + Returns + ------- + float + The readback value. + + Raises + ------ + pyaml.PyAMLException + If the Tango read fails. + """ return self.readback().value diff --git a/tango/pyaml/catalog.py b/tango/pyaml/catalog.py index 4805952..9e37d2a 100644 --- a/tango/pyaml/catalog.py +++ b/tango/pyaml/catalog.py @@ -9,6 +9,11 @@ class Catalog(metaclass=ABCMeta): r""" Abstract class for backend catalog configuration objects. + Methods + ------- + resolve(key) + Return a configuration model for a DeviceAccess. + Notes ----- Concrete catalogs live in each control-system package. They may expose @@ -19,5 +24,16 @@ class Catalog(metaclass=ABCMeta): @abstractmethod def resolve(self, key: str) -> BaseModel: """ - Return a configuration model for a DeviceAccess + Return a configuration model for a DeviceAccess. + + Parameters + ---------- + key : str + Catalog key to resolve. + + Returns + ------- + pydantic.BaseModel + Configuration model describing the device registered under + ``key``. """ diff --git a/tango/pyaml/controlsystem.py b/tango/pyaml/controlsystem.py index 729a8f7..79ae6b9 100644 --- a/tango/pyaml/controlsystem.py +++ b/tango/pyaml/controlsystem.py @@ -1,3 +1,11 @@ +""" +Tango implementation of the pyAML control system. + +:class:`TangoControlSystem` resolves pyAML device references through a +:class:`~tango.pyaml.catalog.Catalog`, prefixes Tango attribute paths with the +configured Tango host and caches the resulting device access objects. +""" + import logging from pydantic import BaseModel @@ -30,18 +38,55 @@ class TangoControlSystem(ControlSystem, DynamicValidation): ---------- name : str Name of the control system. - tango_host : str - Tango host URL. Default is the TANGO_HOST variable. - catalog : Catalog | None + tango_host : str, optional + Tango host URL (``host:port``). Default is ``None``, meaning the + ``TANGO_HOST`` environment variable is used by PyTango. + catalog : Catalog, optional Catalog instance used to resolve PyAML device keys. - debug_level : str | int | None - Debug verbosity level. Such as INFO, DEBUG, WARNING, ERROR, CRITICAL. Or 10, 20, 30, 40, 50. - scalar_aggregator : str - Aggregator module for scalar values. If none specified, writings and readings of sclar value are serialized. - vector_aggregator : str - Aggregator module for vecrors. If none specified, writings and readings of vector are serialized. - timeout_ms : int - Device timeout in milli seconds. + debug_level : str or int, optional + Debug verbosity level. Such as INFO, DEBUG, WARNING, ERROR, CRITICAL. + Or 10, 20, 30, 40, 50. + lazy_devices : bool, optional + Reserved for lazy device creation. Default is True. + timeout_ms : int, optional + Device timeout in milliseconds. Default is 3000. + + Attributes + ---------- + _name : str + Name of the control system. + _tango_host : str or None + Configured Tango host. + _catalog : Catalog or None + Catalog used to resolve device keys. + _debug_level : str or int or None + Requested log level. + _lazy_devices : bool + Lazy device creation flag. + _timeout_ms : int + Device timeout in milliseconds. + + Methods + ------- + attach(devs) + Attach a list of device accesses to this control system. + attach_array(devs) + Attach a list of device accesses to this control system. + get_device_access(ref) + Resolve a public device reference for this Tango control system. + name() + Return the name of the control system. + get_tango_host() + Return the Tango host configured for this control system. + get_aggregator() + Return a new empty aggregator of device accesses. + scalar_aggregator() + Return the module name used for handling aggregator of DeviceAccess. + vector_aggregator() + Return the module name used for handling aggregator of + DeviceVectorAccess. + get_catalog() + Return the catalog that references all control system devices. """ def __init__( @@ -77,12 +122,60 @@ def __init__( ) def attach_array(self, devs: list[DeviceAccess]) -> list[DeviceAccess]: + """ + Attach a list of device accesses to this control system. + + Parameters + ---------- + devs : list of DeviceAccess + Tango attributes to attach. ``None`` entries are preserved. + + Returns + ------- + list of DeviceAccess + Attached device accesses, in the same order as ``devs``. + """ return self._attach(devs) def attach(self, devs: list[DeviceAccess]) -> list[DeviceAccess]: + """ + Attach a list of device accesses to this control system. + + Parameters + ---------- + devs : list of DeviceAccess + Tango attributes to attach. ``None`` entries are preserved. + + Returns + ------- + list of DeviceAccess + Attached device accesses, in the same order as ``devs``. + """ return self._attach(devs) def _attach(self, devs: list[DeviceAccess]) -> list[DeviceAccess]: + """ + Prefix attribute paths with the Tango host and cache the results. + + Each device is cloned with its full attribute name + (``//tango_host/attribute``) the first time it is seen; subsequent + calls return the cached clone. + + Parameters + ---------- + devs : list of DeviceAccess + Tango attributes to attach. ``None`` entries are preserved. + + Returns + ------- + list of DeviceAccess + Attached device accesses, in the same order as ``devs``. + + Raises + ------ + pyaml.PyAMLException + If a device does not expose ``get_tango_attribute()``. + """ # Concatenate the tango_host prefix newDevs = [] for d in devs: @@ -114,6 +207,28 @@ def get_device_access(self, ref: str | BaseModel | None) -> DeviceAccess | None: catalog. Public Python APIs may pass Tango backend configuration models. Already constructed DeviceAccess instances are intentionally rejected: attach() remains the internal compatibility API for those. + + Parameters + ---------- + ref : str or pydantic.BaseModel or None + Catalog key, Tango configuration model + (:class:`~tango.pyaml.attribute.AttributeConfig`, + :class:`~tango.pyaml.attribute_read_only.AttributeReadOnlyConfig`, + :class:`~tango.pyaml.attribute_list.AttributeListConfig` or + :class:`~tango.pyaml.attribute_list_read_only.AttributeListReadOnlyConfig`), + or ``None``. + + Returns + ------- + DeviceAccess or None + Attached device access, or ``None`` if ``ref`` is ``None``. + + Raises + ------ + pyaml.PyAMLException + If ``ref`` is an already constructed DeviceAccess, if no usable + catalog is configured for a string key, or if ``ref`` has an + unsupported type. """ if ref is None: return None @@ -174,6 +289,20 @@ def get_device_access(self, ref: str | BaseModel | None) -> DeviceAccess | None: def _attach_attribute_list_config( self, cfg: AttributeListConfig ) -> AttributeListConfig: + """ + Return a copy of ``cfg`` with attribute paths prefixed by the Tango host. + + Parameters + ---------- + cfg : AttributeListConfig + Configuration to adapt. + + Returns + ------- + AttributeListConfig + ``cfg`` itself when no Tango host is configured, otherwise a copy + whose ``attributes`` are ``//tango_host/attribute`` paths. + """ tango_host = self.get_tango_host() if not tango_host: return cfg @@ -203,45 +332,55 @@ def get_tango_host(self) -> str | None: Returns ------- - str | None + str or None Tango host URL, or ``None`` when unconfigured. """ return self._tango_host def get_aggregator(self) -> MultiAttribute | None: - """Returns a new empty DeviceAccessList. If None is returned serialized readings/writtings are performed""" + """ + Return a new empty aggregator of device accesses. + + If ``None`` were returned, serialized readings/writings would be + performed by the pyAML core instead. + + Returns + ------- + MultiAttribute + New empty :class:`~tango.pyaml.multi_attribute.MultiAttribute`. + """ return MultiAttribute() def scalar_aggregator(self) -> str | None: """ - Returns the module name used for handling aggregator of DeviceAccess + Return the module name used for handling aggregator of DeviceAccess. Returns ------- - str - Aggregator module name + str or None + Aggregator module name. Always ``None`` for Tango. """ return None def vector_aggregator(self) -> str | None: """ - Returns the module name used for handling aggregator of DeviceVectorAccess + Return the module name used for handling aggregator of DeviceVectorAccess. Returns ------- - str - Aggregator module name + str or None + Aggregator module name. Always ``None`` for Tango. """ return None def get_catalog(self) -> Catalog | None: """ - Returns the catalog that references all control systems devices. + Return the catalog that references all control system devices. Returns ------- - Catalog - The catalog + Catalog or None + The catalog, or ``None`` if none was configured. """ return self._catalog diff --git a/tango/pyaml/device_factory.py b/tango/pyaml/device_factory.py index 167fb3f..93cfd10 100644 --- a/tango/pyaml/device_factory.py +++ b/tango/pyaml/device_factory.py @@ -1,3 +1,5 @@ +"""Shared cache of Tango device proxies.""" + from collections import defaultdict from threading import Lock @@ -5,14 +7,51 @@ class DeviceFactory: - """Singleton factory to build PyAML elements with future compatibility logic.""" + """ + Singleton factory to build PyAML elements with future compatibility logic. + + The factory caches one :class:`tango.DeviceProxy` per device name so that + every :class:`~tango.pyaml.attribute.Attribute` of the same device shares + the same connection. It also holds the client timeout applied to every + proxy it creates. + + Attributes + ---------- + _instance : DeviceFactory or None + The unique instance, created on first call. + _lock : threading.Lock + Lock protecting the instance creation. + _elements : dict of str to tango.DeviceProxy + Cache of device proxies indexed by device name. + _timeout : int + Client timeout in milliseconds applied to new proxies. + + Methods + ------- + set_timeout_ms(timeout) + Set the timeout applied to newly created device proxies. + get_timeout_ms() + Return the timeout applied to device proxies. + get_device(device_name) + Return the cached device proxy for a device, creating it if needed. + clear() + Drop all cached device proxies. + """ _instance = None _lock = Lock() def __new__(cls): """ - No matter how many times you call DeviceFactory(), it will be created only once. + Return the unique factory instance. + + No matter how many times you call ``DeviceFactory()``, it will be + created only once. + + Returns + ------- + DeviceFactory + The singleton instance. """ with cls._lock: if cls._instance is None: @@ -22,12 +61,49 @@ def __new__(cls): return cls._instance def set_timeout_ms(self, timeout: int): + """ + Set the timeout applied to newly created device proxies. + + Proxies already in the cache keep their current timeout. + + Parameters + ---------- + timeout : int + Timeout in milliseconds. + """ self._timeout = timeout def get_timeout_ms(self) -> int: + """ + Return the timeout applied to device proxies. + + Returns + ------- + int + Timeout in milliseconds. + """ return self._timeout def get_device(self, device_name: str) -> tango.DeviceProxy: + """ + Return the cached device proxy for a device, creating it if needed. + + Parameters + ---------- + device_name : str + Tango device name (``domain/family/member``), optionally prefixed + by ``//host:port/``. + + Returns + ------- + tango.DeviceProxy + Device proxy configured with the factory timeout. + + Raises + ------ + tango.DevFailed + If the device proxy cannot be created. + """ if device_name not in self._elements: dp = tango.DeviceProxy(device_name) dp.set_timeout_millis(self._timeout) @@ -35,4 +111,5 @@ def get_device(self, device_name: str) -> tango.DeviceProxy: return self._elements[device_name] def clear(self): + """Drop all cached device proxies.""" self._elements.clear() diff --git a/tango/pyaml/initializable_element.py b/tango/pyaml/initializable_element.py index 4a9b43e..44c9911 100644 --- a/tango/pyaml/initializable_element.py +++ b/tango/pyaml/initializable_element.py @@ -1,21 +1,74 @@ +""" +Lazy initialization support for Tango-backed elements. + +Tango connections are expensive and may fail when the control system is not +reachable. Elements derived from :class:`InitializableElement` postpone any +Tango call until the first access, through :meth:`_ensure_initialized`. +""" + from abc import ABCMeta, abstractmethod class InitializableElement(metaclass=ABCMeta): + """ + Base class for elements whose Tango resources are created lazily. + + Subclasses implement :meth:`initialize` to open the Tango connections + they need and call :meth:`_ensure_initialized` at the beginning of every + method that requires them. + + Attributes + ---------- + _initialized : bool + ``True`` once :meth:`initialize` has been called. + + Methods + ------- + initialize() + Create the Tango resources needed by the element. + name() + Return the element name. + is_initialized() + Tell whether the element has already been initialized. + """ + def __init__(self): self._initialized = False @abstractmethod def initialize(self): + """ + Create the Tango resources needed by the element. + + Subclasses must call ``super().initialize()`` so that the + initialization flag is set. + """ self._initialized = True @abstractmethod def name(self) -> str: + """ + Return the element name. + + Returns + ------- + str + Element name. + """ return "" def is_initialized(self) -> bool: + """ + Tell whether the element has already been initialized. + + Returns + ------- + bool + ``True`` if :meth:`initialize` has been called. + """ return self._initialized def _ensure_initialized(self): + """Call :meth:`initialize` if it has not been called yet.""" if not self.is_initialized(): self.initialize() diff --git a/tango/pyaml/multi_attribute.py b/tango/pyaml/multi_attribute.py index 807b5e4..274f965 100644 --- a/tango/pyaml/multi_attribute.py +++ b/tango/pyaml/multi_attribute.py @@ -1,3 +1,12 @@ +""" +Aggregated access to several scalar Tango attributes. + +:class:`MultiAttribute` is the Tango implementation of the pyAML +:class:`~pyaml.control.deviceaccesslist.DeviceAccessList` aggregator: reads and +writes on the managed :class:`~tango.pyaml.attribute.Attribute` objects are +issued asynchronously and collected afterwards. +""" + import logging import numpy as np @@ -42,6 +51,71 @@ class MultiAttributeConfig(BaseModel): @register_schema class MultiAttribute(DeviceAccessList, DynamicValidation): + """ + Aggregate several scalar Tango attributes into one vector access. + + Each managed item is an :class:`~tango.pyaml.attribute.Attribute`. + Items can be created from the ``attributes`` paths at construction time + or appended later with :meth:`add_devices`; the latter is how + :meth:`~tango.pyaml.controlsystem.TangoControlSystem.get_aggregator` + uses this class. + + Parameters + ---------- + attributes : list of str, optional + List of Tango attribute paths. Default is an empty list. + name : str, optional + Group name. + unit : str, optional + Unit shared by all attributes. + range : tuple(min, max), optional + Range of valid values applied to every attribute. Use null for -∞ + or +∞. + + Attributes + ---------- + _attributes : list of str + Tango attribute paths given at construction time. + _name : str + Group name. + _unit : str + Unit shared by all attributes. + _range : tuple of (float or None, float or None) or None + Range applied to every attribute built from ``_attributes``. + _items : list of Attribute + Managed attributes, in order. + + Methods + ------- + len() + Return the number of managed attributes. + get_device_at(index) + Return the managed attribute at a given position. + add_devices(devices) + Append one or several attributes to the aggregate. + set(value) + Write one value per attribute, asynchronously. + set_and_wait(value) + Not implemented. + get() + Return the last written value of every attribute. + readback() + Return the readback value of every attribute. + get_range() + Return the valid ranges of all attributes. + check_device_availability() + Check whether every managed device is reachable. + unit() + Return the unit shared by the attributes. + + Notes + ----- + Reads and writes are issued with the PyTango asynchronous API + (``read_attribute_asynch`` / ``write_attribute_asynch``) on every item + first, and the replies are then collected in order. The reply timeout is + the one of :class:`~tango.pyaml.device_factory.DeviceFactory`. + """ + def __init__( self, attributes: list[str] | None = None, @@ -64,12 +138,47 @@ def __init__( self._items.append(attr) def len(self) -> int: + """ + Return the number of managed attributes. + + Returns + ------- + int + Number of items. + """ return len(self._items) def get_device_at(self, index: int) -> DeviceAccess: + """ + Return the managed attribute at a given position. + + Parameters + ---------- + index : int + Zero-based position in the aggregate. + + Returns + ------- + DeviceAccess + The :class:`~tango.pyaml.attribute.Attribute` at ``index``. + """ return self._items[index] def add_devices(self, devices: DeviceAccess | list[DeviceAccess]): + """ + Append one or several attributes to the aggregate. + + Parameters + ---------- + devices : DeviceAccess or list of DeviceAccess + Attribute(s) to append. Each one must be an instance of + :class:`~tango.pyaml.attribute.Attribute`. + + Raises + ------ + pyaml.PyAMLException + If any device is not an ``Attribute``. + """ if isinstance(devices, list): if any(not isinstance(device, Attribute) for device in devices): raise pyaml.PyAMLException( @@ -84,6 +193,22 @@ def add_devices(self, devices: DeviceAccess | list[DeviceAccess]): self._items.append(devices) def set(self, value: npt.NDArray[np.float64]): + """ + Write one value per attribute, asynchronously. + + All writes are issued first, then every reply is awaited so that the + call returns once all devices acknowledged the write. + + Parameters + ---------- + value : numpy.ndarray of float + Values to write, one per managed attribute, in order. + + Raises + ------ + pyaml.PyAMLException + If the size of ``value`` does not match the number of items. + """ if len(value) != len(self._items): raise pyaml.PyAMLException( f"Size of value ({len(value)} do not match the number of managed devices ({len(self._items)})" @@ -103,9 +228,33 @@ def set(self, value: npt.NDArray[np.float64]): self._items[index]._attribute_dev.write_attribute_reply(call_id, timeout) def set_and_wait(self, value: npt.NDArray[np.float64]): + """ + Not implemented. + + Parameters + ---------- + value : numpy.ndarray of float + Values to write, one per managed attribute. + + Raises + ------ + NotImplementedError + Always. + """ raise NotImplementedError("Not implemented yet.") def get(self) -> npt.NDArray[np.float64]: + """ + Return the last written value of every attribute. + + For writable attributes the Tango ``w_value`` (setpoint) is returned; + for read-only ones the readback ``value`` is used instead. + + Returns + ------- + numpy.ndarray of float + Setpoints, one per managed attribute, in order. + """ values = [] asynch_call_ids = [] timeout = DeviceFactory().get_timeout_ms() @@ -129,6 +278,14 @@ def get(self) -> npt.NDArray[np.float64]: return np.array(values) def readback(self) -> np.array: + """ + Return the readback value of every attribute. + + Returns + ------- + numpy.ndarray of float + Readback values, one per managed attribute, in order. + """ values = [] asynch_call_ids = [] timeout = DeviceFactory().get_timeout_ms() @@ -150,12 +307,30 @@ def readback(self) -> np.array: return np.array(values) def get_range(self) -> list[float]: + """ + Return the valid ranges of all attributes. + + Returns + ------- + list of float or None + Flattened ``[min0, max0, min1, max1, ...]`` list, one pair per + managed attribute, where an unbounded limit is ``None``. + """ attr_range: list[float] = [] for device in self._items: attr_range.extend(device.get_range()) return attr_range def check_device_availability(self) -> bool: + """ + Check whether every managed device is reachable. + + Returns + ------- + bool + ``True`` if all devices answer, ``False`` at the first + unreachable one (or if there is no item). + """ available = False for device in self._items: available = device.check_device_availability() @@ -164,6 +339,14 @@ def check_device_availability(self) -> bool: return available def unit(self) -> str: + """ + Return the unit shared by the attributes. + + Returns + ------- + str + Unit string. + """ return self._unit def __repr__(self): diff --git a/tango/pyaml/static_catalog.py b/tango/pyaml/static_catalog.py index 70d8b19..7eaf3cb 100644 --- a/tango/pyaml/static_catalog.py +++ b/tango/pyaml/static_catalog.py @@ -1,3 +1,5 @@ +"""Catalog backed by an explicit list of key-to-device mappings.""" + from pyaml import PyAMLException from pyaml.control.deviceaccess import DeviceAccess from pyaml.validation import DynamicValidation, register_schema @@ -19,16 +21,26 @@ class StaticCatalog(Catalog, DynamicValidation): Parameters ---------- - name : str - Catalog identifier. - entries : list[StaticCatalogEntry] + entries : list of StaticCatalogEntry Explicit list of key-to-device mappings. Must contain at least one entry, and keys must be unique within the catalog. + Attributes + ---------- + _entries : list of StaticCatalogEntry + Entries given at construction time, in configured order. + _refs : dict of str to DeviceAccess + Lookup table built from ``_entries``, indexed by catalog key. + + Methods + ------- + resolve(key, control_system=None) + Return the device associated with ``key``. + Raises ------ pyaml.PyAMLException - If ``cfg.entries`` is empty or contains duplicate keys. + If ``entries`` is empty or contains duplicate keys. """ def __init__(self, entries: list[StaticCatalogEntry]): @@ -56,7 +68,7 @@ def resolve(self, key: str, control_system: object | None = None) -> DeviceAcces ---------- key : str Catalog key to resolve. - control_system : object | None + control_system : object, optional Optional backend context. Static catalogs do not need it, but the argument keeps the backend catalog API uniform. diff --git a/tango/pyaml/static_catalog_entry.py b/tango/pyaml/static_catalog_entry.py index a62509b..3c3ed9d 100644 --- a/tango/pyaml/static_catalog_entry.py +++ b/tango/pyaml/static_catalog_entry.py @@ -1,3 +1,5 @@ +"""Single entry of a :class:`~tango.pyaml.static_catalog.StaticCatalog`.""" + from pyaml.control.deviceaccess import DeviceAccess from pyaml.validation import DynamicValidation, register_schema @@ -15,6 +17,20 @@ class StaticCatalogEntry(DynamicValidation): Catalog key used to look up the device. device : DeviceAccess Device access object returned when the key is resolved. + + Attributes + ---------- + key : str + Catalog key used to look up the device. + device : DeviceAccess + Device access object returned when the key is resolved. + + Methods + ------- + get_key() + Return the catalog key for this entry. + get_device() + Return the device access object associated with this entry. """ def __init__(self, key: str, device: DeviceAccess): @@ -22,9 +38,23 @@ def __init__(self, key: str, device: DeviceAccess): self.device = device def get_key(self) -> str: - """Return the catalog key for this entry.""" + """ + Return the catalog key for this entry. + + Returns + ------- + str + Catalog key. + """ return self.key def get_device(self) -> DeviceAccess: - """Return the device access object associated with this entry.""" + """ + Return the device access object associated with this entry. + + Returns + ------- + DeviceAccess + Device access object. + """ return self.device diff --git a/tango/pyaml/tango_catalog.py b/tango/pyaml/tango_catalog.py index 5e4d9df..bf8b30e 100644 --- a/tango/pyaml/tango_catalog.py +++ b/tango/pyaml/tango_catalog.py @@ -1,3 +1,12 @@ +""" +Catalog resolving keys that are direct Tango attribute references. + +Unlike :class:`~tango.pyaml.static_catalog.StaticCatalog`, no explicit mapping +is needed: the catalog key *is* the Tango attribute path. In connected mode the +attribute configuration (unit, limits, writability, data format) is fetched +from Tango to build the appropriate device access object. +""" + from typing import ClassVar import pyaml @@ -22,8 +31,38 @@ class TangoCatalog(Catalog, DynamicValidation): or indexed references into a SPECTRUM attribute (``domain/family/member/attribute@index``). - disconnected : bool + Parameters + ---------- + disconnected : bool, optional If true, resolve Tango attribute names without querying Tango. + Default is False. + + Attributes + ---------- + _WRITABLE_TYPES : set of tango.AttrWriteType + Tango write types for which a writable + :class:`~tango.pyaml.attribute.Attribute` is built. + _disconnected : bool + ``True`` when Tango is never queried. + _refs : dict of (int, str) to DeviceAccess + Cache of resolved devices indexed by ``(id(control_system), key)``. + _data_formats : dict of (int, str) to tango.AttrDataFormat + Data format of each resolved key, with the same indexing as + ``_refs``. + + Methods + ------- + resolve(key, control_system=None) + Resolve a Tango attribute reference into a DeviceAccess. + is_disconnected() + Tell whether the catalog works without querying Tango. + get_data_format(key, control_system=None) + Return the Tango data format for a resolved attribute. + + Notes + ----- + Resolved DeviceAccess objects are bound to one control-system context + because metadata lookup depends on that control system's Tango host. """ _WRITABLE_TYPES: ClassVar[set[tango.AttrWriteType]] = { @@ -51,9 +90,9 @@ def resolve(self, key: str, control_system: object | None = None) -> DeviceAcces :class:`~tango.pyaml.attribute.Attribute` or :class:`~tango.pyaml.attribute_read_only.AttributeReadOnly`. - ``domain/family/member/attribute@index`` — resolves to a scalar view - of one element in a SPECTRUM attribute - (:class:`~tango.pyaml.attribute_indexed.AttributeIndexed` or - :class:`~tango.pyaml.attribute_indexed_read_only.AttributeIndexedReadOnly`). + of one element in a SPECTRUM attribute (an indexed + :class:`~tango.pyaml.attribute.Attribute` or + :class:`~tango.pyaml.attribute_read_only.AttributeReadOnly`). In connected mode (``disconnected=False``) indexed keys additionally verify that the Tango attribute is a SPECTRUM. @@ -103,6 +142,14 @@ def resolve(self, key: str, control_system: object | None = None) -> DeviceAcces return self._refs[cache_key] def is_disconnected(self) -> bool: + """ + Tell whether the catalog works without querying Tango. + + Returns + ------- + bool + ``True`` in disconnected mode. + """ return self._disconnected def get_data_format( @@ -129,6 +176,15 @@ def get_data_format( return self._data_formats[(id(control_system), key)] def _validate_control_system(self, control_system: object | None, key: str) -> None: + """ + Check that ``control_system`` is a usable TangoControlSystem. + + Raises + ------ + pyaml.PyAMLException + If ``control_system`` is ``None`` or not a + :class:`~tango.pyaml.controlsystem.TangoControlSystem`. + """ from .controlsystem import TangoControlSystem if control_system is None: @@ -186,6 +242,7 @@ def _parse_key(self, key: str) -> tuple[str, int | None]: def _build_disconnected_attribute( self, cache_key: tuple[int, str], key: str ) -> DeviceAccess: + """Build a writable attribute without querying Tango.""" # In disconnected mode, keep all metadata local. In particular, setting # range avoids Attribute.get_range() from lazily querying Tango later. self._data_formats[cache_key] = tango.AttrDataFormat.FMT_UNKNOWN @@ -194,6 +251,7 @@ def _build_disconnected_attribute( def _build_disconnected_indexed( self, cache_key: tuple[int, str], attr_path: str, index: int ) -> DeviceAccess: + """Build an indexed attribute without querying Tango.""" # Cannot verify SPECTRUM in disconnected mode; store FMT_UNKNOWN. self._data_formats[cache_key] = tango.AttrDataFormat.FMT_UNKNOWN return Attribute(attribute=attr_path, index=index, range=(None, None)) @@ -201,6 +259,14 @@ def _build_disconnected_indexed( def _build_connected_attribute( self, cache_key: tuple[int, str], control_system: object, key: str ) -> DeviceAccess: + """ + Build a scalar attribute from the Tango attribute configuration. + + Raises + ------ + pyaml.PyAMLException + If the Tango call fails. + """ tango_attr_name = self._tango_attribute_name(control_system, key) try: # AttributeProxy.get_config() is the most direct way to retrieve @@ -273,6 +339,14 @@ def _read_config_metadata( tango.AttrDataFormat, tango.AttrWriteType, ]: + """ + Extract ``(unit, range, data_format, writable)`` from a Tango config. + + Raises + ------ + pyaml.PyAMLException + If ``attr_config`` lacks one of the expected fields. + """ try: unit = attr_config.unit or "" attr_range = ( @@ -290,6 +364,7 @@ def _read_config_metadata( return unit, attr_range, data_format, writable def _tango_attribute_name(self, control_system: object, attr_path: str) -> str: + """Prefix ``attr_path`` with the control-system Tango host, if any.""" tango_host = control_system.get_tango_host() if tango_host: return f"//{tango_host}/{attr_path}" diff --git a/tango/pyaml/tango_pyaml_utils.py b/tango/pyaml/tango_pyaml_utils.py index be20027..1770e38 100644 --- a/tango/pyaml/tango_pyaml_utils.py +++ b/tango/pyaml/tango_pyaml_utils.py @@ -1,8 +1,26 @@ +"""Small helpers shared by the Tango pyAML classes.""" + import pyaml import tango def to_float_or_none(s): + """ + Convert a value to ``float``, returning ``None`` when impossible. + + Tango reports unset attribute limits as the string ``"Not specified"``; + this helper maps such values to ``None``. + + Parameters + ---------- + s : object + Value to convert (typically a string or a number). + + Returns + ------- + float or None + The converted value, or ``None`` if ``s`` cannot be converted. + """ try: return float(s) except (TypeError, ValueError):