Source code for secop_ophyd.SECoPSignal

import asyncio
import inspect
import warnings
from collections.abc import Awaitable, Callable
from functools import wraps
from typing import Any, cast

from bluesky.protocols import DataKey, Reading
from frappy.client import CacheItem
from frappy.datatypes import (
    ArrayOf,
    BLOBType,
    BoolType,
    CommandType,
    DataType,
    FloatRange,
    IntRange,
    ScaledInteger,
    StringType,
    StructOf,
    TupleOf,
)
from ophyd_async.core import (
    Callback,
    CommandBackend,
    SignalBackend,
    SignalDatatypeT,
    StandardReadableFormat,
    StrictEnum,
)

from secop_ophyd.AsyncFrappyClient import AsyncFrappyClient
from secop_ophyd.util import (
    Path,
    SECoPDataKey,
    SECoPdtype,
    SECoPReading,
    build_command_signature,
    deep_get,
)

atomic_dtypes = (
    StringType,
    ScaledInteger,
    IntRange,
    FloatRange,
    BoolType,
    BLOBType,
    ArrayOf,
)


# max depth for datatypes supported by tiled/databroker
MAX_DEPTH = 1


[docs] class AttributeType(StrictEnum): PARAMETER = "parameter" PROPERTY = "property"
def _is_concrete_enum_class(datatype: Any) -> bool: """True only for a genuine, member-carrying StrictEnum subclass produced by codegen (e.g. Cryostat_Mode_Enum) -- as opposed to the generic, member-less StrictEnum base class used when there is no class annotation (pure introspection instantiation).""" return ( isinstance(datatype, type) and issubclass(datatype, StrictEnum) and datatype is not StrictEnum )
[docs] class SECoPCommandBackend(CommandBackend[Any, Any]): """Backend for a SECoP command. Converts the argument/result between SECoP wire format and numpy/python values and calls `AsyncFrappyClient.exec_command` to execute the command. Supports deferred initialization (matching the `SECoPBackend` pattern used for Parameters/Properties): constructed empty by the `DeviceFiller` when a command is declared via a `Command`/`TriggerableCommand` class annotation, then bound to a concrete SECoP command via `init_command_from_introspection` once the SEC node has been introspected. """ def __init__(self, signature: inspect.Signature | None = None) -> None: """Initialize SECoPCommandBackend (optionally with a signature derived from a `Command[[ArgT], ResT]` class annotation).""" resolved_signature = signature or inspect.Signature() # Snapshot of the annotation-derived signature (e.g. carrying the # concrete `Cryostat_SetMode_Arg_Enum` class from a generated # `Command[[Cryostat_SetMode_Arg_Enum], ...]` annotation), captured # before init_command_from_introspection() rebuilds self.signature # from live introspection. Empty inspect.Signature() for pure # introspection instantiation (no class annotation). self._annotated_signature: inspect.Signature = resolved_signature super().__init__(signature=resolved_signature)
[docs] def init_command_from_introspection( self, cmd_datatype: CommandType, path: Path, secclient: AsyncFrappyClient, wait_for_idle_fn: Callable[[], Awaitable[None]] | None = None, ) -> None: """Bind this backend to a concrete SECoP command. :param cmd_datatype: SECoP command datatype, holding the argument and result datatypes (either of which may be None) :type cmd_datatype: CommandType :param path: Path to the command in the secclient module dict :type path: Path :param secclient: SECoP client providing communication to the SEC Node :type secclient: AsyncFrappyClient :param wait_for_idle_fn: Owning module's `wait_for_idle` coroutine (bound method), if it has one (i.e. it has a status signal); used to support the `wait_for_idle` kwarg on `execute()`. :type wait_for_idle_fn: Callable[[], Awaitable[None]] | None """ self._secclient: AsyncFrappyClient = secclient # module:acessible Path for reading/writing (module,accessible) self.path: Path = path self.raw_argument: DataType | None = cmd_datatype.argument self.raw_result: DataType | None = cmd_datatype.result self._arg_type: SECoPdtype | None = ( SECoPdtype(self.raw_argument) if self.raw_argument is not None else None ) self._res_type: SECoPdtype | None = ( SECoPdtype(self.raw_result) if self.raw_result is not None else None ) self._wait_for_idle_fn = wait_for_idle_fn self.source_name = self.path._module_name + ":" + self.path._accessible_name self.signature = build_command_signature( cmd_datatype, annotated_signature=self._annotated_signature )
[docs] def source(self, name: str) -> str: return self.source_name
[docs] async def connect(self, timeout: float) -> None: pass
[docs] async def execute(self, *args: Any, **kwargs: Any) -> Any: bound = self.signature.bind(*args, **kwargs) bound.apply_defaults() wait_for_idle = bound.arguments.pop("wait_for_idle") argument = None if self._arg_type is not None: if isinstance(self.raw_argument, StructOf): argument = self._arg_type.val2secop(dict(bound.arguments)) else: argument = self._arg_type.val2secop(bound.arguments["arg"]) res, _qualifiers = await self._secclient.exec_command( module=self.path._module_name, command=self.path._accessible_name, argument=argument, ) result = None if self._res_type is None else self._res_type.secop2val(res) if wait_for_idle: if self._wait_for_idle_fn is None: raise RuntimeError( f"wait_for_idle is not supported for command " f"'{self.source_name}': module has no status signal" ) await self._wait_for_idle_fn() return result
[docs] class SECoPBackend(SignalBackend[SignalDatatypeT]): """Unified backend for SECoP Parameters and Properties. This allows a single backend type to be used in signal_backend_factory, with deferred initialization based on annotation metadata. """ format: StandardReadableFormat attribute_type: str | None _module_name: str | None _attribute_name: str | None # parameter or property name _secclient: AsyncFrappyClient path_str: str SECoPdtype_obj: DataType SECoP_type_info: SECoPdtype describe_dict: dict def __init__( self, datatype: type[SignalDatatypeT] | None, path: str | None = None, attribute_type: str | None = None, secclient: AsyncFrappyClient | None = None, ): """Initialize backend (supports deferred initialization). Args: datatype: Optional datatype for the signal path: Optional path for immediate initialization (module:param or prop_key) secclient: Optional SECoP client for immediate initialization """ self._module_name = None self._attribute_name = None self.attribute_type = attribute_type if secclient: self._secclient = secclient self.path_str = path or "" if path and secclient: if path.count(":") == 0: self._module_name = None self._attribute_name = path else: self._module_name, self._attribute_name = path.split(":", maxsplit=1) # Snapshot of whatever datatype the class annotation provided (e.g. # `Cryostat_Mode_Enum` from `SignalRW[Cryostat_Mode_Enum]`), captured # before init_parameter_from_introspection()/init_property_from_introspection() # overwrite self.datatype with a throwaway string. None for pure # introspection instantiation (no class annotation). self._annotated_datatype: type | None = datatype super().__init__(datatype)
[docs] def init_parameter_from_introspection( self, datatype: type[SignalDatatypeT], path: str, secclient: AsyncFrappyClient, ): if self.attribute_type is not None: if secclient != self._secclient: raise RuntimeError( "Backend already initialized with a different SECoP client, cannot " "re-initialize" ) if self.attribute_type != AttributeType.PARAMETER: raise RuntimeError( f"Backend already initialized as {self.attribute_type}, " f"cannot re-initialize as PARAMETER" ) self.attribute_type = AttributeType.PARAMETER module_name, parameter_name = path.split(":", maxsplit=1) self._module_name = module_name self._attribute_name = parameter_name self._secclient = secclient self.datatype = datatype self.path_str = path
[docs] def init_property_from_introspection( self, datatype: type[SignalDatatypeT], path: str, secclient: AsyncFrappyClient ): if self.attribute_type is not None: if secclient != self._secclient: raise RuntimeError( "Backend already initialized with a different SECoP client, cannot " "re-initialize" ) if self.attribute_type != AttributeType.PROPERTY: raise RuntimeError( f"Backend already initialized as {self.attribute_type}, cannot " f"re-initialize as PROPERTY" ) self.attribute_type = AttributeType.PROPERTY if path.count(":") == 0: module_name = None property_name = path else: module_name, property_name = path.split(":", maxsplit=1) self._module_name = module_name self._attribute_name = property_name self._secclient = secclient self.datatype = datatype self.path_str = path
[docs] def source(self, name: str, read: bool) -> str: return self._secclient.host + ":" + self._secclient.port + ":" + self.path_str
[docs] async def connect(self, timeout: float): """Connect and initialize backend (handles both parameters and properties).""" await self._secclient.connect() match self.attribute_type: case AttributeType.PROPERTY: await self._init_property() case AttributeType.PARAMETER: await self._init_parameter()
async def _init_parameter(self): """Initialize as a parameter signal.""" self._param_description: dict = self._get_param_desc() if not hasattr(self, "format"): match self._param_description.get("_signal_format", None): case "HINTED_SIGNAL": self.format = StandardReadableFormat.HINTED_SIGNAL case "HINTED_UNCACHED_SIGNAL": self.format = StandardReadableFormat.HINTED_UNCACHED_SIGNAL case "UNCACHED_SIGNAL": self.format = StandardReadableFormat.UNCACHED_SIGNAL case _: self.format = StandardReadableFormat.CONFIG_SIGNAL # Root datainfo or memberinfo for nested datatypes self.datainfo: dict = self._param_description["datainfo"] self.readonly = self._param_description.get("readonly") self.SECoPdtype_obj = self._param_description["datatype"] self.SECoP_type_info = SECoPdtype(self.SECoPdtype_obj) if self.SECoP_type_info.max_depth > MAX_DEPTH: warnings.warn( f"The datatype of parameter '{self._attribute_name}' has a maximum " f"depth of {self.SECoP_type_info.max_depth}. Tiled & Databroker only " f"support a Depth upto {MAX_DEPTH} " f"dtype_descr: {self.SECoP_type_info.dtype_descr}" ) self.source_name = ( self._secclient.uri + ":" + self._secclient.nodename + ":" + self._module_name + ":" + self._attribute_name ) self.describe_dict = {} self.describe_dict["source"] = self.source_name self.describe_dict.update(self.SECoP_type_info.get_datakey()) for property_name, prop_val in self._param_description.items(): if property_name in ("datainfo", "datatype"): continue self.describe_dict[property_name] = prop_val for property_name, prop_val in self.datainfo.items(): if property_name == "type": property_name = "SECoP_dtype" if property_name == "unit": property_name = "units" self.describe_dict[property_name] = prop_val if _is_concrete_enum_class(self._annotated_datatype): self.datatype = cast(type, self._annotated_datatype) else: self.datatype = self.SECoP_type_info.np_datatype async def _init_property(self): """Initialize as a property signal.""" if self._module_name: module_desc = self._secclient.modules[self._module_name] self._property_dict = module_desc["properties"] else: self._property_dict = self._secclient.properties self._prop_value = self._property_dict[self._attribute_name] self.SECoPdtype_obj = secop_dtype_obj_from_json(self._prop_value) self.SECoP_type_info = SECoPdtype(self.SECoPdtype_obj) if self.SECoP_type_info.max_depth > MAX_DEPTH: warnings.warn( f"The datatype of property '{self._attribute_name}' has a maximum " f"depth of {self.SECoP_type_info.max_depth}. Tiled & Databroker only " f"support a Depth upto {MAX_DEPTH} " f"dtype_descr: {self.SECoP_type_info.dtype_descr}" ) self.describe_dict = {} self.describe_dict["source"] = self.path_str self.describe_dict.update(self.SECoP_type_info.get_datakey()) # Properties are always readonly self.format = StandardReadableFormat.CONFIG_SIGNAL self.readonly = True if _is_concrete_enum_class(self._annotated_datatype): self.datatype = cast(type, self._annotated_datatype) else: self.datatype = self.SECoP_type_info.np_datatype
[docs] async def put(self, value: Any | None): """Put a value to the parameter. Properties are readonly.""" if self.attribute_type == AttributeType.PROPERTY: # Properties are readonly raise RuntimeError( f"Cannot set property '{self._attribute_name}', properties are readonly" ) # convert to frappy compatible Format secop_val = self.SECoP_type_info.val2secop(value) await self._secclient.set_parameter(**self.get_param_path(), value=secop_val)
[docs] async def get_datakey(self, source: str) -> DataKey: """Metadata like source, dtype, shape, precision, units""" if self.attribute_type == AttributeType.PROPERTY: # Properties have static metadata return describedict_to_datakey(self.describe_dict) if self.SECoP_type_info._is_composite or isinstance( self.SECoPdtype_obj, ArrayOf ): # getlast cached value dataset = await self._secclient.get_parameter( **self.get_param_path(), trycache=True ) # this ensures the datakey is updated to the latest cached value SECoPReading(entry=dataset, secop_dt=self.SECoP_type_info) self.describe_dict.update(self.SECoP_type_info.get_datakey()) return describedict_to_datakey(self.describe_dict)
[docs] async def get_reading(self) -> Reading[SignalDatatypeT]: """Get reading, handling both parameters and properties.""" if self.attribute_type == AttributeType.PROPERTY: # Properties have static values dataset = CacheItem( value=self._prop_value, timestamp=self._secclient.conn_timestamp ) sec_reading = SECoPReading(entry=dataset, secop_dt=self.SECoP_type_info) return sec_reading.get_reading() else: # Parameters are fetched from SECoP dataset = await self._secclient.get_parameter( **self.get_param_path(), trycache=True ) sec_reading = SECoPReading(entry=dataset, secop_dt=self.SECoP_type_info) return sec_reading.get_reading()
[docs] async def get_value(self) -> SignalDatatypeT: dataset: Reading = await self.get_reading() return dataset["value"] # type: ignore
[docs] async def get_setpoint(self) -> SignalDatatypeT: return await self.get_value()
[docs] def set_callback(self, callback: Callback[Reading[SignalDatatypeT]] | None) -> None: if self.attribute_type == AttributeType.PROPERTY: # Properties are static, no callbacks return def awaitify(sync_func): """Wrap a synchronous callable to allow ``await``'ing it""" @wraps(sync_func) async def async_func(*args, **kwargs): return sync_func(*args, **kwargs) return async_func def updateItem(module, parameter, entry: CacheItem): # noqa: N802 data = SECoPReading(secop_dt=self.SECoP_type_info, entry=entry) async_callback = awaitify(callback) asyncio.run_coroutine_threadsafe( async_callback(reading=data.get_reading()), self._secclient.loop, ) if callback is not None: self._secclient.register_callback(self.get_path_tuple(), updateItem) else: self._secclient.unregister_callback(self.get_path_tuple(), updateItem)
def _get_param_desc(self) -> dict: return deep_get( self._secclient.modules, [self._module_name, "parameters", self._attribute_name], )
[docs] def get_param_path(self): return {"module": self._module_name, "parameter": self._attribute_name}
[docs] def get_path_tuple(self): return (self._module_name, self._attribute_name)
[docs] def secop_dtype_obj_from_json(prop_val): if isinstance(prop_val, str): return StringType() if isinstance(prop_val, (int, float)): return FloatRange() if isinstance(prop_val, bool): return BoolType() if isinstance(prop_val, dict): # SECoP Structs/tuples --> numpy ndarray members = {} for key, elem in prop_val.items(): members[key] = secop_dtype_obj_from_json(elem) return StructOf(**members) if isinstance(prop_val, list): # empty list, cannot infer proper type if not prop_val: return ArrayOf(FloatRange()) # check if all elements have same Type: if all(isinstance(elem, type(prop_val[0])) for elem in prop_val): members = secop_dtype_obj_from_json(prop_val[0]) return ArrayOf(members) else: members = [] # type: ignore for elem in prop_val: members.append(secop_dtype_obj_from_json(elem)) # type: ignore return TupleOf(*members) raise Exception( f"""unsupported datatype in Property: {str(prop_val.__class__.__name__)}\n propval: {prop_val}""" )
[docs] def describedict_to_datakey(describe_dict: dict) -> SECoPDataKey: """Convert a DataKey to a SECoPDataKey""" datakey = SECoPDataKey( dtype=describe_dict["dtype"], shape=describe_dict["shape"], source=describe_dict["source"], SECOP_datainfo=describe_dict["SECOP_datainfo"], ) if "units" in describe_dict: datakey["units"] = describe_dict["units"] if "dtype_str" in describe_dict: datakey["dtype_str"] = describe_dict["dtype_str"] if "dtype_descr" in describe_dict: datakey["dtype_descr"] = describe_dict["dtype_descr"] if "dtype_numpy" in describe_dict: datakey["dtype_numpy"] = describe_dict["dtype_numpy"] return datakey