From 4548aa03e3d3ba4d231d631797b6b6672cf3a74b Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Wed, 22 Apr 2026 13:53:33 +0200 Subject: [PATCH 01/32] Adapt OA control system to new pyaml API --- pyaml_cs_oa/controlsystem.py | 80 ++++++++++++++++++++---------------- 1 file changed, 44 insertions(+), 36 deletions(-) diff --git a/pyaml_cs_oa/controlsystem.py b/pyaml_cs_oa/controlsystem.py index e8280d3..70b37f1 100644 --- a/pyaml_cs_oa/controlsystem.py +++ b/pyaml_cs_oa/controlsystem.py @@ -1,12 +1,11 @@ -import os import logging -import copy -from pyaml.control.controlsystem import ControlSystem -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from pyaml.common.exception import PyAMLException +from pyaml.configuration.catalog import Catalog +from pyaml.control.controlsystem import ControlSystem -PYAMLCLASS : str = "OphydAsyncControlSystem" +PYAMLCLASS: str = "OphydAsyncControlSystem" logger = logging.getLogger(__name__) @@ -27,7 +26,6 @@ from . import __version__ class ConfigModel(BaseModel): - """ Configuration model for an OA Control System. @@ -36,21 +34,26 @@ class ConfigModel(BaseModel): name : str Name of the control system. prefix : str - Prefix added to the PV or attribute name. It can be a + Prefix added to the PV or attribute name. It can be a for instance, TANGO_HOST, or a PV prefix. - debug_level : int + catalog : Catalog | str | None + Catalog instance or catalog name used to resolve PyAML device keys. + debug_level : str Debug verbosity level. scalar_aggregator : str - Aggregator module for scalar values. If none specified, writings and - readings of sclar value are serialized. + Aggregator module for scalar values. If none specified, writings and + readings of scalar values are serialized. vector_aggregator : str Aggregator module for vecrors. If none specified, writings and readings of vector are serialized, """ + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + name: str prefix: str = "" - debug_level: str=None + catalog: Catalog | str | None = None + debug_level: str | None = None scalar_aggregator: str | None = "pyaml_cs_oa.scalar_aggregator" vector_aggregator: str | None = None @@ -61,48 +64,53 @@ class OphydAsyncControlSystem(ControlSystem): def __init__(self, cfg: ConfigModel): super().__init__() self._cfg = cfg - self._devices = {} # Dict containing all attached DeviceAccess + self._devices = {} # Dict containing all attached DeviceAccess if self._cfg.debug_level: - log_level = getattr(logging, self._cfg.debug_level, logging.WARNING) - logger.parent.setLevel(log_level) - logger.setLevel(log_level) + log_level = getattr(logging, self._cfg.debug_level, logging.WARNING) + logger.parent.setLevel(log_level) + logger.setLevel(log_level) - logger.log(logging.WARNING, f"PyAML OA control system binding ({__version__}) initialized with name '{self._cfg.name}'" - f" and prefix='{self._cfg.prefix}'") - - def attach(self, devs: list[OASignal]) -> list[OASignal]: - return self._attach(devs,False) + logger.log( + logging.WARNING, + f"PyAML OA control system binding ({__version__}) initialized with name '{self._cfg.name}'" + f" and prefix='{self._cfg.prefix}'", + ) - def attach_array(self, devs: list[OASignal]) -> list[OASignal]: - return self._attach(devs,True) + def attach(self, devs: list[OASignal | None]) -> list[OASignal | None]: + return self._attach(devs, False) - def _attach(self, devs: list[OASignal],is_array:bool) -> list[OASignal]: + def attach_array(self, devs: list[OASignal | None]) -> list[OASignal | None]: + return self._attach(devs, True) + + def _attach(self, devs: list[OASignal | None], is_array: bool) -> list[OASignal | None]: # Concatenate the prefix newDevs = [] - for d in devs: + for d in devs: if d is not None: - sig_cfg = d._cfg sig_cfg_cls = sig_cfg.__class__ - - if isinstance(d._cfg,EpicsConfigR): + + if isinstance(d._cfg, EpicsConfigR): key = self._cfg.prefix + d._cfg.read_pvname sig_cls = EpicsR config = dict(read_pvname=key) - elif isinstance(d._cfg,EpicsConfigW): + elif isinstance(d._cfg, EpicsConfigW): key = self._cfg.prefix + d._cfg.write_pvname sig_cls = EpicsW config = dict(write_pvname=key) - elif isinstance(d._cfg,EpicsConfigRW): + elif isinstance(d._cfg, EpicsConfigRW): key = self._cfg.prefix + d._cfg.read_pvname + d._cfg.write_pvname sig_cls = EpicsRW - config = dict(read_pvname=self._cfg.prefix + d._cfg.read_pvname, write_pvname=self._cfg.prefix + d._cfg.write_pvname) - elif isinstance(d._cfg,TangoConfigR): + config = dict( + read_pvname=self._cfg.prefix + d._cfg.read_pvname, + write_pvname=self._cfg.prefix + d._cfg.write_pvname, + ) + elif isinstance(d._cfg, TangoConfigR): key = self._cfg.prefix + d._cfg.attribute sig_cls = TangoR config = dict(attribute=key) - elif isinstance(d._cfg,TangoConfigRW): + elif isinstance(d._cfg, TangoConfigRW): key = self._cfg.prefix + d._cfg.attribute sig_cls = TangoRW config = dict(attribute=key) @@ -110,8 +118,8 @@ def _attach(self, devs: list[OASignal],is_array:bool) -> list[OASignal]: raise PyAMLException(f"OphydAsyncControlSystem: Unsupported type {type(sig_cfg)}") if key not in self._devices: - n_conf = dict(d._cfg) | config - nr = sig_cls(sig_cfg_cls(**n_conf),is_array) + n_conf = dict(d._cfg) | config + nr = sig_cls(sig_cfg_cls(**n_conf), is_array) nr.build() self._devices[key] = nr @@ -130,7 +138,7 @@ def name(self) -> str: Name of the control system. """ return self._cfg.name - + def scalar_aggregator(self) -> str | None: """ Returns the module name used for handling aggregator of DeviceAccess @@ -154,4 +162,4 @@ def vector_aggregator(self) -> str | None: return self._cfg.vector_aggregator def __repr__(self): - return repr(self._cfg).replace("ConfigModel",self.__class__.__name__) \ No newline at end of file + return repr(self._cfg).replace("ConfigModel", self.__class__.__name__) From 1d4956fc47c3c5866edc0650131502c93cf1f706 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Wed, 22 Apr 2026 13:54:07 +0200 Subject: [PATCH 02/32] .gitignore update --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 9c882b7..eee6252 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ __pycache__/ *.egg-info/ +.idea +build From e8e6d44fd96c1beaf3bd2b3a46ef71b5140b7a95 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Fri, 24 Apr 2026 19:16:25 +0200 Subject: [PATCH 03/32] Refactor catalog and signal index support: merge OAReadbackIndexed/OASetpointIndexed into OAReadback/OASetpoint with optional index, add read_index/write_index to EpicsConfigRW, and replace string-based static catalog entries with typed EpicsStaticCatalogEntry objects. Add EpicsCatalog dynamic catalog (key = PV spec string) and EpicsStaticCatalog with structured per-entry signal configs; remove _epics_pv_builder module by inlining its logic into EpicsCatalog. --- epics_catalog_exemple.yaml | 29 ++++ pyaml_cs_oa/container.py | 92 ++++++++--- pyaml_cs_oa/epicsR.py | 4 +- pyaml_cs_oa/epicsRW.py | 4 +- pyaml_cs_oa/epicsW.py | 4 +- pyaml_cs_oa/epics_catalog.py | 130 ++++++++++++++++ pyaml_cs_oa/epics_static_catalog.py | 84 ++++++++++ pyaml_cs_oa/epics_static_catalog_entry.py | 23 +++ pyaml_cs_oa/indexed_signal.py | 61 ++++++++ pyaml_cs_oa/signal.py | 75 +++++---- pyaml_cs_oa/static_catalog.py | 44 ++++++ pyaml_cs_oa/static_catalog_entry.py | 23 +++ pyaml_cs_oa/tangoR.py | 4 +- pyaml_cs_oa/tangoRW.py | 4 +- pyaml_cs_oa/tango_catalog.py | 177 ++++++++++++++++++++++ pyaml_cs_oa/types.py | 12 +- 16 files changed, 707 insertions(+), 63 deletions(-) create mode 100644 epics_catalog_exemple.yaml create mode 100644 pyaml_cs_oa/epics_catalog.py create mode 100644 pyaml_cs_oa/epics_static_catalog.py create mode 100644 pyaml_cs_oa/epics_static_catalog_entry.py create mode 100644 pyaml_cs_oa/indexed_signal.py create mode 100644 pyaml_cs_oa/static_catalog.py create mode 100644 pyaml_cs_oa/static_catalog_entry.py create mode 100644 pyaml_cs_oa/tango_catalog.py diff --git a/epics_catalog_exemple.yaml b/epics_catalog_exemple.yaml new file mode 100644 index 0000000..5f00de0 --- /dev/null +++ b/epics_catalog_exemple.yaml @@ -0,0 +1,29 @@ +catalogs: + - type: pyaml_cs_oa.epics_static_catalog + name: device-catalog + entries: + - type: pyaml_cs_oa.epics_static_catalog_entry + key: a_string_key + device: + type: pyaml_cs_oa.epicsRW + read_pvname: HS4P2D1R:rdbk + write_pvname: HS4P2D1R:set + unit: A + - type: pyaml_cs_oa.epics_static_catalog_entry + key: a_string_key_with_specific_indexes + device: + type: pyaml_cs_oa.epicsRW + read_pvname: HS4P2D1R:rdbk + read_index: 24 + write_pvname: HS4P2D1R:set + write_index: 30 + unit: A + - type: pyaml_cs_oa.epics_static_catalog_entry + key: a_string_key_with_common_indexes + device: + type: pyaml_cs_oa.epicsRW + read_pvname: HS4P2D1R:rdbk + write_pvname: HS4P2D1R:set + index: 30 + unit: A + diff --git a/pyaml_cs_oa/container.py b/pyaml_cs_oa/container.py index 0500572..6cd3a09 100644 --- a/pyaml_cs_oa/container.py +++ b/pyaml_cs_oa/container.py @@ -46,16 +46,31 @@ async def _recover_once( raise -class OAReadback(): - """A readback object.""" - - def __init__(self, r_signal: SignalR[SignalDatatypeT]): - self._r_sig = r_signal +class OAReadback: + """Readback wrapper with optional element extraction for array signals. + + Parameters + ---------- + r_signal: + A raw ``SignalR`` **or** an existing ``OAReadback`` (its underlying + signal is reused — useful for adding an index after the fact). + index: + When set, ``get()`` returns ``float(array[index])`` instead of the + full array value. + """ + + def __init__(self, r_signal: 'SignalR | OAReadback', index: int | None = None): + if isinstance(r_signal, OAReadback): + self._r_sig = r_signal._r_sig + else: + self._r_sig = r_signal + self._index = index async def _run_get(self) -> SignalDatatypeT: await self._r_sig.connect() backend = self._r_sig._connector.backend - return await backend.get_value() + value = await backend.get_value() + return float(value[self._index]) if self._index is not None else value async def async_get(self) -> SignalDatatypeT: return await _recover_once( @@ -80,20 +95,42 @@ def get(self) -> SignalDatatypeT: """Synchronous wrapper around `async_get()`.""" return arun(self.async_get()) -class OASetpoint(): + +class OASetpoint: + """Setpoint wrapper with optional read-modify-write for array signals. + + Parameters + ---------- + w_signal: + A raw ``SignalW`` **or** an existing ``OASetpoint`` (its underlying + signal is reused). + r_signal: + Optional readback signal, used only by ``set_and_wait()``. + index: + When set, ``get()`` returns ``float(array[index])`` and ``set(v)`` + performs a read-modify-write on element ``index``. + """ + def __init__( self, - w_signal: SignalW[SignalDatatypeT], - r_signal: SignalR[SignalDatatypeT] | None = None, + w_signal: 'SignalW | OASetpoint', + r_signal: 'SignalR | None' = None, + index: int | None = None, ): - self._w_sig = w_signal - self._r_sig = r_signal # used only for `set_and_wait()` - self._has_r_sig = (r_signal is not None) + if isinstance(w_signal, OASetpoint): + self._w_sig = w_signal._w_sig + self._r_sig = w_signal._r_sig if r_signal is None else r_signal + else: + self._w_sig = w_signal + self._r_sig = r_signal + self._has_r_sig = (self._r_sig is not None) + self._index = index async def _run_get(self) -> SignalDatatypeT: await self._w_sig.connect() backend = self._w_sig._connector.backend - return await backend.get_setpoint() + value = await backend.get_setpoint() + return float(value[self._index]) if self._index is not None else value async def async_get(self) -> SignalDatatypeT: return await _recover_once( @@ -114,14 +151,27 @@ async def async_read(self) -> SignalDatatypeT: getattr(self._w_sig, "__peer__", None), ) - async def _run_set(self, value): + async def _run_set(self, value) -> None: await self._w_sig.connect() - status = self._w_sig.set(value) - return status + return self._w_sig.set(value) + + async def _run_set_indexed(self, value) -> None: + import numpy as np + await self._w_sig.connect() + backend = self._w_sig._connector.backend + arr = np.array(await backend.get_setpoint(), dtype=float) + arr = arr.copy() + arr[self._index] = value + return self._w_sig.set(arr) async def async_set(self, value): + run = ( + (lambda: self._run_set_indexed(value)) + if self._index is not None + else (lambda: self._run_set(value)) + ) return await _recover_once( - lambda: self._run_set(value), + run, self._w_sig.connect, getattr(self._w_sig, "__peer__", None), ) @@ -153,9 +203,9 @@ async def async_set_and_wait(self, value) -> None: ) async def _complete_set(self, value): - status = await self.async_set(value) - await status # Wait for completion before returning - return status + status = await self.async_set(value) + await status + return status def set(self, value): """Synchronous wrapper around `async_set()`.""" @@ -167,4 +217,4 @@ def get(self) -> SignalDatatypeT: def set_and_wait(self, value) -> None: """Synchronous wrapper around `async_set_and_wait()`.""" - return arun(self.async_set_and_wait(value)) \ No newline at end of file + return arun(self.async_set_and_wait(value)) diff --git a/pyaml_cs_oa/epicsR.py b/pyaml_cs_oa/epicsR.py index 8b56468..95f0ac7 100644 --- a/pyaml_cs_oa/epicsR.py +++ b/pyaml_cs_oa/epicsR.py @@ -7,7 +7,7 @@ class ConfigModel(EpicsConfigR): unit: str = "" class EpicsR(FloatSignalContainer): - def __init__(self, cfg: ConfigModel, is_array=False): - super().__init__(cfg,is_array) + def __init__(self, cfg: ConfigModel, is_array: bool = False): + super().__init__(cfg, is_array) def get_cs(self) -> str: return "epics" \ No newline at end of file diff --git a/pyaml_cs_oa/epicsRW.py b/pyaml_cs_oa/epicsRW.py index b9cc603..e22f647 100644 --- a/pyaml_cs_oa/epicsRW.py +++ b/pyaml_cs_oa/epicsRW.py @@ -7,7 +7,7 @@ class ConfigModel(EpicsConfigRW): unit: str = "" class EpicsRW(FloatSignalContainer): - def __init__(self, cfg: ConfigModel, is_array=False): - super().__init__(cfg,is_array) + def __init__(self, cfg: ConfigModel, is_array: bool = False): + super().__init__(cfg, is_array) def get_cs(self) -> str: return "epics" \ No newline at end of file diff --git a/pyaml_cs_oa/epicsW.py b/pyaml_cs_oa/epicsW.py index c124ae7..71b6026 100644 --- a/pyaml_cs_oa/epicsW.py +++ b/pyaml_cs_oa/epicsW.py @@ -7,7 +7,7 @@ class ConfigModel(EpicsConfigW): unit: str = "" class EpicsW(FloatSignalContainer): - def __init__(self, cfg: ConfigModel, is_array=False): - super().__init__(cfg,is_array) + def __init__(self, cfg: ConfigModel, is_array: bool = False): + super().__init__(cfg, is_array) def get_cs(self) -> str: return "epics" \ No newline at end of file diff --git a/pyaml_cs_oa/epics_catalog.py b/pyaml_cs_oa/epics_catalog.py new file mode 100644 index 0000000..9f9c006 --- /dev/null +++ b/pyaml_cs_oa/epics_catalog.py @@ -0,0 +1,130 @@ +from pydantic import ConfigDict + +from pyaml.common.exception import PyAMLException +from pyaml.configuration.catalog import Catalog, CatalogConfigModel +from pyaml.control.deviceaccess import DeviceAccess + +PYAMLCLASS = "EpicsCatalog" + + +class ConfigModel(CatalogConfigModel): + """ + Dynamic EPICS catalog. + + The key passed to ``resolve()`` IS the PV specification string — no + ``entries`` list required. Resolutions are cached after the first call. + + Supported key formats:: + + "PV" → scalar read-only + "PV@n" → array PV, element n, read-only + "R_PV, W_PV" → scalar read-write + "R_PV@n, W_PV@m" → indexed read + indexed write (read-modify-write) + "R:PV@n, W:PV@m" → same (colons are part of the PV name) + "R_PV@n, W_PV" → indexed read + scalar write + "R_PV, W_PV@m" → scalar read + indexed write + + The ``prefix`` is prepended to every PV name extracted from the key. + + Example + ------- + .. code-block:: yaml + + type: pyaml_cs_oa.epics_catalog + name: id-catalog + prefix: "" + timeout_ms: 3000 + """ + + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + + prefix: str = "" + timeout_ms: int = 3000 + + +class EpicsCatalog(Catalog): + def __init__(self, cfg: ConfigModel): + super().__init__(cfg) + self._refs: dict[str, DeviceAccess] = {} + + def resolve(self, key: str) -> DeviceAccess: + if key not in self._refs: + try: + self._refs[key] = _build_device(key, self._cfg.prefix, self._cfg.timeout_ms) + except PyAMLException: + raise + except Exception as exc: + raise PyAMLException( + f"EpicsCatalog '{self.get_name()}' cannot resolve key '{key}': {exc}" + ) from exc + return self._refs[key] + + +# ── PV spec parser ──────────────────────────────────────────────────────────── + +def _parse_pv(token: str) -> tuple[str, int | None]: + """Split ``'PV_NAME[@index]'`` into ``(pv_name, index_or_None)``.""" + token = token.strip() + if "@" in token: + pv_name, idx_str = token.rsplit("@", 1) + try: + return pv_name.strip(), int(idx_str.strip()) + except ValueError: + raise PyAMLException(f"EpicsCatalog: invalid index in PV token '{token}'") + return token, None + + +def _build_device(pv_str: str, prefix: str, timeout_ms: int) -> DeviceAccess: + tokens = [t.strip() for t in pv_str.split(",")] + if len(tokens) == 1: + return _build_single(tokens[0], prefix, timeout_ms) + if len(tokens) == 2: + return _build_pair(tokens[0], tokens[1], prefix, timeout_ms) + raise PyAMLException( + f"EpicsCatalog: too many comma-separated tokens in key '{pv_str}' (max 2)" + ) + + +def _build_single(token: str, prefix: str, timeout_ms: int) -> DeviceAccess: + from .epicsR import EpicsR, ConfigModel as EpicsRConfig + + pv_name, index = _parse_pv(token) + sig = EpicsR(EpicsRConfig(read_pvname=prefix + pv_name, timeout_ms=timeout_ms, index=index)) + sig.build() + return sig + + +def _build_pair(read_token: str, write_token: str, prefix: str, timeout_ms: int) -> DeviceAccess: + read_pv, read_idx = _parse_pv(read_token) + write_pv, write_idx = _parse_pv(write_token) + full_read = prefix + read_pv + full_write = prefix + write_pv + + if read_idx is None and write_idx is None: + from .epicsRW import EpicsRW, ConfigModel as EpicsRWConfig + sig = EpicsRW(EpicsRWConfig(read_pvname=full_read, write_pvname=full_write, + timeout_ms=timeout_ms)) + sig.build() + return sig + + if read_idx is not None and write_idx is not None: + # Both indexed (same or different): EpicsRW carries both indexes. + from .epicsRW import EpicsRW, ConfigModel as EpicsRWConfig + sig = EpicsRW(EpicsRWConfig(read_pvname=full_read, write_pvname=full_write, + timeout_ms=timeout_ms, + read_index=read_idx, write_index=write_idx)) + sig.build() + return sig + + # Mixed (one side array, one side scalar): build independently and combine. + from .epicsR import EpicsR, ConfigModel as EpicsRConfig + from .epicsW import EpicsW, ConfigModel as EpicsWConfig + from .indexed_signal import IndexedFloatSignal + + r_sig = EpicsR(EpicsRConfig(read_pvname=full_read, timeout_ms=timeout_ms, index=read_idx)) + r_sig.build() + w_sig = EpicsW(EpicsWConfig(write_pvname=full_write, timeout_ms=timeout_ms, index=write_idx)) + w_sig.build() + + measure = f"{read_pv}[{read_idx}]" if read_idx is not None else read_pv + return IndexedFloatSignal(r_sig.RB, w_sig.SP, measure_name=measure) diff --git a/pyaml_cs_oa/epics_static_catalog.py b/pyaml_cs_oa/epics_static_catalog.py new file mode 100644 index 0000000..feb8ead --- /dev/null +++ b/pyaml_cs_oa/epics_static_catalog.py @@ -0,0 +1,84 @@ +from pydantic import ConfigDict + +from pyaml.common.exception import PyAMLException +from pyaml.configuration.catalog import Catalog, CatalogConfigModel +from pyaml.control.deviceaccess import DeviceAccess + +from .epics_static_catalog_entry import EpicsStaticCatalogEntry + +PYAMLCLASS = "EpicsStaticCatalog" + + +class ConfigModel(CatalogConfigModel): + """ + Static EPICS catalog — ordered list of typed entries. + + Each entry is an ``EpicsStaticCatalogEntry`` whose ``device`` is one of + ``EpicsR``, ``EpicsW``, or ``EpicsRW``. Index fields on the device config + control array-element access: + + * ``index`` — common index applied to both read and write sides of ``EpicsRW``. + * ``read_index`` / ``write_index`` — per-side overrides (``EpicsRW`` only). + + Example + ------- + .. code-block:: yaml + + type: pyaml_cs_oa.epics_static_catalog + name: device-catalog + entries: + - type: pyaml_cs_oa.epics_static_catalog_entry + key: magnet_current + device: + type: pyaml_cs_oa.epicsRW + read_pvname: HS4P2D1R:rdbk + write_pvname: HS4P2D1R:set + unit: A + - type: pyaml_cs_oa.epics_static_catalog_entry + key: id_gap + device: + type: pyaml_cs_oa.epicsRW + read_pvname: R:GAP:ALL_ID + write_pvname: W:GAP:ID_SET + read_index: 24 + write_index: 30 + unit: mm + - type: pyaml_cs_oa.epics_static_catalog_entry + key: phase + device: + type: pyaml_cs_oa.epicsR + read_pvname: R:PHASE:ALL + index: 24 + unit: mm + """ + + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + + entries: list[EpicsStaticCatalogEntry] + + +class EpicsStaticCatalog(Catalog): + def __init__(self, cfg: ConfigModel): + super().__init__(cfg) + if not cfg.entries: + raise PyAMLException( + "EpicsStaticCatalog.entries must contain at least one entry" + ) + self._refs: dict[str, DeviceAccess] = {} + for entry in cfg.entries: + key = entry.get_key() + if key in self._refs: + raise PyAMLException( + f"EpicsStaticCatalog '{self.get_name()}': duplicate key '{key}'" + ) + sig = entry.get_device() + sig.build() + self._refs[key] = sig + + def resolve(self, key: str) -> DeviceAccess: + try: + return self._refs[key] + except KeyError as exc: + raise PyAMLException( + f"Catalog '{self.get_name()}' cannot resolve key '{key}'" + ) from exc diff --git a/pyaml_cs_oa/epics_static_catalog_entry.py b/pyaml_cs_oa/epics_static_catalog_entry.py new file mode 100644 index 0000000..989e2c9 --- /dev/null +++ b/pyaml_cs_oa/epics_static_catalog_entry.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel, ConfigDict + +from pyaml.control.deviceaccess import DeviceAccess + +PYAMLCLASS = "EpicsStaticCatalogEntry" + + +class ConfigModel(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + + key: str + device: DeviceAccess # EpicsR, EpicsW, or EpicsRW instance (build() not yet called) + + +class EpicsStaticCatalogEntry: + def __init__(self, cfg: ConfigModel): + self._cfg = cfg + + def get_key(self) -> str: + return self._cfg.key + + def get_device(self) -> DeviceAccess: + return self._cfg.device diff --git a/pyaml_cs_oa/indexed_signal.py b/pyaml_cs_oa/indexed_signal.py new file mode 100644 index 0000000..70b395a --- /dev/null +++ b/pyaml_cs_oa/indexed_signal.py @@ -0,0 +1,61 @@ +from pyaml.common.exception import PyAMLException +from pyaml.control.deviceaccess import DeviceAccess + +from .container import OAReadback, OASetpoint + + +class IndexedFloatSignal(DeviceAccess): + """DeviceAccess that combines independently-built read and write signals. + + Used when at least one side is an indexed array element, or when the + read and write PVs must be built as separate signals (mixed scalar/array). + """ + + def __init__( + self, + rb: OAReadback | None, + sp: OASetpoint | None, + unit: str = "", + range: list | None = None, + measure_name: str = "", + ): + self._rb = rb + self._sp = sp + self._unit = unit + self._range = range or [None, None] + self._measure_name = measure_name + self._writable = sp is not None + self._readable = rb is not None + + def get(self): + if self._writable: + return self._sp.get() + return self._rb.get() + + def readback(self): + return self._rb.get() + + def set(self, value): + if not self._writable: + raise PyAMLException("IndexedFloatSignal is read-only: individual element writes are not supported.") + return self._sp.set(value) + + def set_and_wait(self, value): + if not self._writable: + raise PyAMLException("IndexedFloatSignal is read-only: individual element writes are not supported.") + return self._sp.set(value) + + def unit(self) -> str: + return self._unit + + def get_range(self) -> list: + return self._range + + def measure_name(self) -> str: + return self._measure_name + + def name(self) -> str: + return self._measure_name + + def check_device_availability(self) -> bool: + return True diff --git a/pyaml_cs_oa/signal.py b/pyaml_cs_oa/signal.py index 155ea16..bfa3b31 100644 --- a/pyaml_cs_oa/signal.py +++ b/pyaml_cs_oa/signal.py @@ -9,20 +9,42 @@ TangoConfigRW, ) + +def _effective_indexes(cfg: ControlSysConfig) -> tuple[int | None, int | None]: + """Return ``(eff_read_idx, eff_write_idx)`` for *cfg*. + + For ``EpicsConfigRW``, ``read_index``/``write_index`` override the common + ``index`` independently. For single-side configs, the unused side is + always ``None``. + """ + if isinstance(cfg, EpicsConfigRW): + common = cfg.index + return ( + cfg.read_index if cfg.read_index is not None else common, + cfg.write_index if cfg.write_index is not None else common, + ) + if isinstance(cfg, (EpicsConfigR, TangoConfigR)): + return cfg.index, None + if isinstance(cfg, EpicsConfigW): + return None, cfg.index + if isinstance(cfg, TangoConfigRW): + return cfg.index, cfg.index + return None, None + + class OASignal(DeviceAccess): """ Class that implements a PyAML Signal using ophyd_async Signals. """ - def __init__(self, cfg: ControlSysConfig,is_array:bool): + def __init__(self, cfg: ControlSysConfig, is_array: bool = False): self._cfg = cfg - self.is_array = is_array + eff_read, eff_write = _effective_indexes(cfg) + # is_array is forced True whenever any index is specified. + self.is_array = is_array or (eff_read is not None) or (eff_write is not None) def build(self): - - self._readable: bool = isinstance( - self._cfg, (EpicsConfigR, TangoConfigR) - ) + self._readable: bool = isinstance(self._cfg, (EpicsConfigR, TangoConfigR)) self._writable: bool = isinstance( self._cfg, (EpicsConfigRW, EpicsConfigW, TangoConfigRW) ) @@ -35,7 +57,16 @@ def build(self): else: raise ValueError(f"Unsupported cs_name: {cs_name}") - self.SP, self.RB = get_SP_RB(self._cfg,self.is_array) + self.SP, self.RB = get_SP_RB(self._cfg, self.is_array) + + eff_read, eff_write = _effective_indexes(self._cfg) + if eff_read is not None or eff_write is not None: + from .container import OAReadback, OASetpoint + if self.RB is not None and eff_read is not None: + self.RB = OAReadback(self.RB, index=eff_read) + if self.SP is not None and eff_write is not None: + self.SP = OASetpoint(self.SP, index=eff_write) + if self.SP: self.SP.__peer__ = self if self.RB: @@ -45,40 +76,23 @@ def get_cs(self) -> str: raise Exception("get_cs() not implemented") def name(self) -> str: - """ - Return the name of the signal. - """ return self._signal.name def measure_name(self) -> str: - """ - Return the short attribute name (last component). - - Returns - ------- - str - The attribute name (e.g., 'current'). - """ - - # TODO override measure name in sub classes if isinstance(self._cfg, (EpicsConfigR, EpicsConfigRW)): - return self._cfg.read_pvname + base = self._cfg.read_pvname + elif isinstance(self._cfg, EpicsConfigW): + base = self._cfg.write_pvname elif isinstance(self._cfg, (TangoConfigR, TangoConfigRW)): - return self._cfg.attribute + base = self._cfg.attribute else: raise ValueError( f"Unsupported control system config type: {type(self._cfg)!r}" ) + eff_read, _ = _effective_indexes(self._cfg) + return f"{base}[{eff_read}]" if eff_read is not None else base def unit(self) -> str: - """ - Return the unit of the attribute. - - Returns - ------- - str - The unit string. - """ return self._cfg.unit def get_range(self) -> list: @@ -93,6 +107,5 @@ def check_device_availability(self) -> bool: def __repr__(self): cfg_str = repr(self._cfg) - # Replace the pydantic config class by the class itself idx = cfg_str.find("(") return f"{self.__class__.__name__}{cfg_str[idx:]}" diff --git a/pyaml_cs_oa/static_catalog.py b/pyaml_cs_oa/static_catalog.py new file mode 100644 index 0000000..fc65153 --- /dev/null +++ b/pyaml_cs_oa/static_catalog.py @@ -0,0 +1,44 @@ +from pydantic import ConfigDict + +from pyaml.common.exception import PyAMLException +from pyaml.configuration.catalog import Catalog, CatalogConfigModel +from pyaml.control.deviceaccess import DeviceAccess + +from .static_catalog_entry import StaticCatalogEntry + +PYAMLCLASS = "StaticCatalog" + + +class ConfigModel(CatalogConfigModel): + """ + Static catalog: a fixed mapping of keys to DeviceAccess instances. + + Works with any DeviceAccess (EpicsR, TangoRW, IndexedFloatSignal, …). + Keys are resolved at construction time; no control-system connection is required. + The catalog instance is shared across all control systems that reference it. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + + entries: list[StaticCatalogEntry] + + +class StaticCatalog(Catalog): + def __init__(self, cfg: ConfigModel): + super().__init__(cfg) + if not cfg.entries: + raise PyAMLException("StaticCatalog.entries must contain at least one entry") + self._refs: dict[str, DeviceAccess] = {} + for entry in cfg.entries: + key = entry.get_key() + if key in self._refs: + raise PyAMLException(f"StaticCatalog.entries contains duplicate key '{key}'") + self._refs[key] = entry.get_device() + + def resolve(self, key: str) -> DeviceAccess: + try: + return self._refs[key] + except KeyError as exc: + raise PyAMLException( + f"Catalog '{self.get_name()}' cannot resolve key '{key}'" + ) from exc diff --git a/pyaml_cs_oa/static_catalog_entry.py b/pyaml_cs_oa/static_catalog_entry.py new file mode 100644 index 0000000..b9ed8c9 --- /dev/null +++ b/pyaml_cs_oa/static_catalog_entry.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel, ConfigDict + +from pyaml.control.deviceaccess import DeviceAccess + +PYAMLCLASS = "StaticCatalogEntry" + + +class ConfigModel(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + + key: str + device: DeviceAccess + + +class StaticCatalogEntry: + def __init__(self, cfg: ConfigModel): + self._cfg = cfg + + def get_key(self) -> str: + return self._cfg.key + + def get_device(self) -> DeviceAccess: + return self._cfg.device diff --git a/pyaml_cs_oa/tangoR.py b/pyaml_cs_oa/tangoR.py index a0294cd..d542aff 100644 --- a/pyaml_cs_oa/tangoR.py +++ b/pyaml_cs_oa/tangoR.py @@ -7,7 +7,7 @@ class ConfigModel(TangoConfigR): unit: str = "" class TangoR(FloatSignalContainer): - def __init__(self, cfg: ConfigModel, is_array=False): - super().__init__(cfg,is_array) + def __init__(self, cfg: ConfigModel, is_array: bool = False): + super().__init__(cfg, is_array) def get_cs(self) -> str: return "tango" \ No newline at end of file diff --git a/pyaml_cs_oa/tangoRW.py b/pyaml_cs_oa/tangoRW.py index 420ec7a..abd9b02 100644 --- a/pyaml_cs_oa/tangoRW.py +++ b/pyaml_cs_oa/tangoRW.py @@ -7,7 +7,7 @@ class ConfigModel(TangoConfigRW): unit: str = "" class TangoRW(FloatSignalContainer): - def __init__(self, cfg: ConfigModel, is_array=False): - super().__init__(cfg,is_array) + def __init__(self, cfg: ConfigModel, is_array: bool = False): + super().__init__(cfg, is_array) def get_cs(self) -> str: return "tango" \ No newline at end of file diff --git a/pyaml_cs_oa/tango_catalog.py b/pyaml_cs_oa/tango_catalog.py new file mode 100644 index 0000000..675b5c0 --- /dev/null +++ b/pyaml_cs_oa/tango_catalog.py @@ -0,0 +1,177 @@ +okfrom pydantic import ConfigDict + +import pyaml +from pyaml.common.exception import PyAMLException +from pyaml.configuration.catalog import Catalog, CatalogConfigModel, CatalogResolver +from pyaml.control.deviceaccess import DeviceAccess + +PYAMLCLASS = "TangoCatalog" + + +class ConfigModel(CatalogConfigModel): + """ + Dynamic Tango catalog resolving keys that are Tango attribute references. + + Supported key formats: + - ``domain/family/member/attribute`` → scalar signal + - ``domain/family/member/attribute@index`` → one element of a SPECTRUM signal + + In *disconnected* mode (``disconnected: true``) the catalog builds signals + without querying Tango (writability and data-format are not verified). + In *connected* mode the catalog queries ``tango.AttributeProxy`` at first + resolution to detect writability and, for indexed keys, to verify SPECTRUM. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + + disconnected: bool = False + timeout_ms: int = 3000 + + +class TangoCatalog(Catalog): + def resolve(self, key: str) -> DeviceAccess: + raise PyAMLException( + f"OA Tango catalog '{self.get_name()}' must be attached to a control " + f"system before resolving key '{key}'" + ) + + def attach_control_system(self, control_system): + from .controlsystem import OphydAsyncControlSystem + + if not isinstance(control_system, OphydAsyncControlSystem): + raise PyAMLException( + f"OA Tango catalog '{self.get_name()}' can only be attached to " + "OphydAsyncControlSystem" + ) + return _TangoCatalogResolver(self, control_system) + + +class _TangoCatalogResolver(CatalogResolver): + _WRITABLE_TYPES: set # populated lazily after tango is imported + + def __init__(self, catalog: TangoCatalog, control_system): + self._catalog = catalog + self._control_system = control_system + self._refs: dict[str, DeviceAccess] = {} + + def resolve(self, key: str) -> DeviceAccess: + if key not in self._refs: + attr_path, index = self._parse_key(key) + if index is not None: + self._refs[key] = self._build_indexed(attr_path, index) + else: + self._refs[key] = self._build_attribute(attr_path) + return self._refs[key] + + # ── key parsing ────────────────────────────────────────────────────────── + + def _parse_key(self, key: str) -> tuple[str, int | None]: + if not isinstance(key, str): + raise PyAMLException( + f"OA Tango catalog '{self._catalog.get_name()}' expects string keys, " + f"got {type(key).__name__}" + ) + if "@" in key: + attr_path, idx_str = key.rsplit("@", 1) + try: + index = int(idx_str) + except ValueError: + raise PyAMLException( + f"OA Tango catalog '{self._catalog.get_name()}': invalid index " + f"'{idx_str}' in key '{key}'" + ) + else: + attr_path = key + index = None + + parts = attr_path.split("/") + if len(parts) != 4 or any(p == "" for p in parts): + raise PyAMLException( + f"OA Tango catalog '{self._catalog.get_name()}' cannot resolve '{key}'. " + "Expected 'domain/family/member/attribute[@index]'." + ) + return attr_path, index + + # ── signal builders ────────────────────────────────────────────────────── + + def _timeout(self) -> int: + return self._catalog._cfg.timeout_ms + + def _build_attribute(self, attr_path: str) -> DeviceAccess: + if self._catalog._cfg.disconnected: + return self._make_rw(attr_path, is_array=False) + + try: + import tango + except ImportError as exc: + raise PyAMLException( + "pytango is required for TangoCatalog in connected mode" + ) from exc + + try: + attr_cfg = tango.AttributeProxy(attr_path).get_config() + except tango.DevFailed as df: + raise PyAMLException( + f"OA Tango catalog '{self._catalog.get_name()}' cannot resolve " + f"'{attr_path}': {df}" + ) from df + + writable_types = { + tango.AttrWriteType.READ_WRITE, + tango.AttrWriteType.WRITE, + tango.AttrWriteType.READ_WITH_WRITE, + } + is_writable = ( + getattr(attr_cfg, "writable", tango.AttrWriteType.WT_UNKNOWN) + in writable_types + ) + is_array = ( + getattr(attr_cfg, "data_format", None) == tango.AttrDataFormat.SPECTRUM + ) + + if is_writable: + return self._make_rw(attr_path, is_array=is_array) + return self._make_r(attr_path, is_array=is_array) + + def _build_indexed(self, attr_path: str, index: int) -> DeviceAccess: + if not self._catalog._cfg.disconnected: + try: + import tango + except ImportError as exc: + raise PyAMLException( + "pytango is required for TangoCatalog in connected mode" + ) from exc + + try: + attr_cfg = tango.AttributeProxy(attr_path).get_config() + except tango.DevFailed as df: + raise PyAMLException( + f"OA Tango catalog '{self._catalog.get_name()}' cannot resolve " + f"'{attr_path}@{index}': {df}" + ) from df + + data_format = getattr(attr_cfg, "data_format", None) + if data_format != tango.AttrDataFormat.SPECTRUM: + raise PyAMLException( + f"OA Tango catalog '{self._catalog.get_name()}': '{attr_path}' " + "is not a SPECTRUM; indexed access requires a vector attribute." + ) + + # index is embedded in the config; build() applies it automatically. + return self._make_r(attr_path, index=index) + + # ── helpers ─────────────────────────────────────────────────────────────── + + def _make_r(self, attr_path: str, is_array: bool = False, index: int | None = None): + from .tangoR import TangoR, ConfigModel as TangoRConfig + + sig = TangoR(TangoRConfig(attribute=attr_path, timeout_ms=self._timeout(), index=index), is_array) + sig.build() + return sig + + def _make_rw(self, attr_path: str, is_array: bool = False): + from .tangoRW import TangoRW, ConfigModel as TangoRWConfig + + sig = TangoRW(TangoRWConfig(attribute=attr_path, timeout_ms=self._timeout()), is_array) + sig.build() + return sig diff --git a/pyaml_cs_oa/types.py b/pyaml_cs_oa/types.py index 09e8ad1..d4c660a 100644 --- a/pyaml_cs_oa/types.py +++ b/pyaml_cs_oa/types.py @@ -1,30 +1,40 @@ -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict class EpicsConfigR(BaseModel): + model_config = ConfigDict(extra="forbid") read_pvname: str timeout_ms: int = 3000 + index: int | None = None class EpicsConfigW(BaseModel): + model_config = ConfigDict(extra="forbid") write_pvname: str timeout_ms: int = 3000 range: list[float] | None = None + index: int | None = None class EpicsConfigRW(BaseModel): + model_config = ConfigDict(extra="forbid") read_pvname: str write_pvname: str timeout_ms: int = 3000 range: list[float] | None = None + index: int | None = None # shorthand: applies to both sides when set + read_index: int | None = None # overrides index for the read side + write_index: int | None = None # overrides index for the write side class TangoConfigR(BaseModel): attribute: str timeout_ms: int = 3000 + index: int | None = None class TangoConfigRW(BaseModel): attribute: str timeout_ms: int = 3000 range: list[float] | None = None + index: int | None = None ControlSysConfig = ( From 052ab00587be91018bb68f53df5f9086f9e2bfe2 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Fri, 24 Apr 2026 19:17:48 +0200 Subject: [PATCH 04/32] Correction of errors in previous commit. --- epics_catalog_exemple.yaml | 29 ----------------------------- pyaml_cs_oa/tango_catalog.py | 2 +- 2 files changed, 1 insertion(+), 30 deletions(-) delete mode 100644 epics_catalog_exemple.yaml diff --git a/epics_catalog_exemple.yaml b/epics_catalog_exemple.yaml deleted file mode 100644 index 5f00de0..0000000 --- a/epics_catalog_exemple.yaml +++ /dev/null @@ -1,29 +0,0 @@ -catalogs: - - type: pyaml_cs_oa.epics_static_catalog - name: device-catalog - entries: - - type: pyaml_cs_oa.epics_static_catalog_entry - key: a_string_key - device: - type: pyaml_cs_oa.epicsRW - read_pvname: HS4P2D1R:rdbk - write_pvname: HS4P2D1R:set - unit: A - - type: pyaml_cs_oa.epics_static_catalog_entry - key: a_string_key_with_specific_indexes - device: - type: pyaml_cs_oa.epicsRW - read_pvname: HS4P2D1R:rdbk - read_index: 24 - write_pvname: HS4P2D1R:set - write_index: 30 - unit: A - - type: pyaml_cs_oa.epics_static_catalog_entry - key: a_string_key_with_common_indexes - device: - type: pyaml_cs_oa.epicsRW - read_pvname: HS4P2D1R:rdbk - write_pvname: HS4P2D1R:set - index: 30 - unit: A - diff --git a/pyaml_cs_oa/tango_catalog.py b/pyaml_cs_oa/tango_catalog.py index 675b5c0..2da2035 100644 --- a/pyaml_cs_oa/tango_catalog.py +++ b/pyaml_cs_oa/tango_catalog.py @@ -1,4 +1,4 @@ -okfrom pydantic import ConfigDict +from pydantic import ConfigDict import pyaml from pyaml.common.exception import PyAMLException From c380e1fe3f618e34c85b276eeca7487fb5a92bc5 Mon Sep 17 00:00:00 2001 From: PONS Date: Wed, 29 Apr 2026 09:13:54 +0200 Subject: [PATCH 05/32] Restored container, added a trace for debugging --- pyaml_cs_oa/container.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/pyaml_cs_oa/container.py b/pyaml_cs_oa/container.py index 6cd3a09..cda2601 100644 --- a/pyaml_cs_oa/container.py +++ b/pyaml_cs_oa/container.py @@ -59,17 +59,15 @@ class OAReadback: full array value. """ - def __init__(self, r_signal: 'SignalR | OAReadback', index: int | None = None): - if isinstance(r_signal, OAReadback): - self._r_sig = r_signal._r_sig - else: - self._r_sig = r_signal + def __init__(self, r_signal: SignalR[SignalDatatypeT], index: int | None = None): + self._r_sig = r_signal self._index = index async def _run_get(self) -> SignalDatatypeT: await self._r_sig.connect() backend = self._r_sig._connector.backend value = await backend.get_value() + print(f"Read {self._r_sig.name} index={self._index}") return float(value[self._index]) if self._index is not None else value async def async_get(self) -> SignalDatatypeT: From d76ff9d67927855a3630b92885b73b6a11b4c41b Mon Sep 17 00:00:00 2001 From: PONS Date: Wed, 29 Apr 2026 09:14:11 +0200 Subject: [PATCH 06/32] Adde index in key --- pyaml_cs_oa/controlsystem.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/pyaml_cs_oa/controlsystem.py b/pyaml_cs_oa/controlsystem.py index 70b37f1..d717c85 100644 --- a/pyaml_cs_oa/controlsystem.py +++ b/pyaml_cs_oa/controlsystem.py @@ -90,30 +90,31 @@ def _attach(self, devs: list[OASignal | None], is_array: bool) -> list[OASignal if d is not None: sig_cfg = d._cfg sig_cfg_cls = sig_cfg.__class__ + index_str = "" if sig_cfg.index is None else str(sig_cfg.index) if isinstance(d._cfg, EpicsConfigR): - key = self._cfg.prefix + d._cfg.read_pvname + key = self._cfg.prefix + d._cfg.read_pvname + index_str sig_cls = EpicsR - config = dict(read_pvname=key) + config = dict(read_pvname=self._cfg.prefix + d._cfg.read_pvname) elif isinstance(d._cfg, EpicsConfigW): - key = self._cfg.prefix + d._cfg.write_pvname + key = self._cfg.prefix + d._cfg.write_pvname + index_str sig_cls = EpicsW - config = dict(write_pvname=key) + config = dict(write_pvname=self._cfg.prefix + d._cfg.write_pvname) elif isinstance(d._cfg, EpicsConfigRW): - key = self._cfg.prefix + d._cfg.read_pvname + d._cfg.write_pvname + key = self._cfg.prefix + d._cfg.read_pvname + d._cfg.write_pvname + index_str sig_cls = EpicsRW config = dict( read_pvname=self._cfg.prefix + d._cfg.read_pvname, write_pvname=self._cfg.prefix + d._cfg.write_pvname, ) elif isinstance(d._cfg, TangoConfigR): - key = self._cfg.prefix + d._cfg.attribute + key = self._cfg.prefix + d._cfg.attribute + index_str sig_cls = TangoR - config = dict(attribute=key) + config = dict(attribute=self._cfg.prefix + d._cfg.attribute) elif isinstance(d._cfg, TangoConfigRW): - key = self._cfg.prefix + d._cfg.attribute + key = self._cfg.prefix + d._cfg.attribute + index_str sig_cls = TangoRW - config = dict(attribute=key) + config = dict(attribute=self._cfg.prefix + d._cfg.attribute) else: raise PyAMLException(f"OphydAsyncControlSystem: Unsupported type {type(sig_cfg)}") From d6e4500ccd6c9ea7199d9315459099e3d3d2d632 Mon Sep 17 00:00:00 2001 From: PONS Date: Wed, 29 Apr 2026 09:15:47 +0200 Subject: [PATCH 07/32] Handling index (no factoring) --- pyaml_cs_oa/epics.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pyaml_cs_oa/epics.py b/pyaml_cs_oa/epics.py index 8d2be3e..8773059 100644 --- a/pyaml_cs_oa/epics.py +++ b/pyaml_cs_oa/epics.py @@ -22,32 +22,32 @@ def get_SP_RB(cfg: ControlSysConfig,is_array:bool) -> tuple[Setpoint | None, Rea r_sig = epics_signal_r( datatype=float if not is_array else Array1D[numpy.float64], read_pv=cfg.read_pvname, - name="", + name=cfg.read_pvname, # Use name for debugging timeout = cfg.timeout_ms / 1000., ) - readback = Readback(r_sig) + readback = Readback(r_sig,cfg.index) setpoint = None if isinstance(cfg, EpicsConfigW): w_sig = epics_signal_w( datatype=float if not is_array else Array1D[numpy.float64], write_pv=cfg.write_pvname, - name="", + name=cfg.write_pvname, # Use name for debugging timeout = cfg.timeout_ms / 1000., ) readback = None - setpoint = Setpoint(w_sig) + setpoint = Setpoint(w_sig,cfg.index) if isinstance(cfg, EpicsConfigRW): w_sig = epics_signal_rw( datatype=float if not is_array else Array1D[numpy.float64], read_pv=cfg.read_pvname, write_pv=cfg.write_pvname, - name="", + name=cfg.read_pvname, # Use name for debugging timeout = cfg.timeout_ms / 1000., ) - readback = Readback(w_sig) - setpoint = Setpoint(w_sig) + readback = Readback(w_sig,cfg.index) + setpoint = Setpoint(w_sig,cfg.index) return setpoint, readback From fd6834cf0049e8e748c07a153a8e062db5db4404 Mon Sep 17 00:00:00 2001 From: PONS Date: Wed, 29 Apr 2026 09:16:53 +0200 Subject: [PATCH 08/32] Removed confusing index managenet --- pyaml_cs_oa/signal.py | 34 +--------------------------------- 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/pyaml_cs_oa/signal.py b/pyaml_cs_oa/signal.py index bfa3b31..e3d1f72 100644 --- a/pyaml_cs_oa/signal.py +++ b/pyaml_cs_oa/signal.py @@ -9,29 +9,6 @@ TangoConfigRW, ) - -def _effective_indexes(cfg: ControlSysConfig) -> tuple[int | None, int | None]: - """Return ``(eff_read_idx, eff_write_idx)`` for *cfg*. - - For ``EpicsConfigRW``, ``read_index``/``write_index`` override the common - ``index`` independently. For single-side configs, the unused side is - always ``None``. - """ - if isinstance(cfg, EpicsConfigRW): - common = cfg.index - return ( - cfg.read_index if cfg.read_index is not None else common, - cfg.write_index if cfg.write_index is not None else common, - ) - if isinstance(cfg, (EpicsConfigR, TangoConfigR)): - return cfg.index, None - if isinstance(cfg, EpicsConfigW): - return None, cfg.index - if isinstance(cfg, TangoConfigRW): - return cfg.index, cfg.index - return None, None - - class OASignal(DeviceAccess): """ Class that implements a PyAML Signal using ophyd_async Signals. @@ -39,9 +16,8 @@ class OASignal(DeviceAccess): def __init__(self, cfg: ControlSysConfig, is_array: bool = False): self._cfg = cfg - eff_read, eff_write = _effective_indexes(cfg) # is_array is forced True whenever any index is specified. - self.is_array = is_array or (eff_read is not None) or (eff_write is not None) + self.is_array = is_array or cfg.index is not None def build(self): self._readable: bool = isinstance(self._cfg, (EpicsConfigR, TangoConfigR)) @@ -59,14 +35,6 @@ def build(self): self.SP, self.RB = get_SP_RB(self._cfg, self.is_array) - eff_read, eff_write = _effective_indexes(self._cfg) - if eff_read is not None or eff_write is not None: - from .container import OAReadback, OASetpoint - if self.RB is not None and eff_read is not None: - self.RB = OAReadback(self.RB, index=eff_read) - if self.SP is not None and eff_write is not None: - self.SP = OASetpoint(self.SP, index=eff_write) - if self.SP: self.SP.__peer__ = self if self.RB: From 6c0716f1c864afb27927705f0a04df31e29b2a16 Mon Sep 17 00:00:00 2001 From: PONS Date: Wed, 29 Apr 2026 09:17:02 +0200 Subject: [PATCH 09/32] Removed confusing index managenet --- pyaml_cs_oa/types.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pyaml_cs_oa/types.py b/pyaml_cs_oa/types.py index d4c660a..91066f0 100644 --- a/pyaml_cs_oa/types.py +++ b/pyaml_cs_oa/types.py @@ -20,10 +20,7 @@ class EpicsConfigRW(BaseModel): write_pvname: str timeout_ms: int = 3000 range: list[float] | None = None - index: int | None = None # shorthand: applies to both sides when set - read_index: int | None = None # overrides index for the read side - write_index: int | None = None # overrides index for the write side - + index: int | None = None class TangoConfigR(BaseModel): attribute: str From eb7a8b0795b7ce27027558baf6744077ea0b0825 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Wed, 29 Apr 2026 11:14:15 +0200 Subject: [PATCH 10/32] Validate indexed readback values before element access --- pyaml_cs_oa/container.py | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/pyaml_cs_oa/container.py b/pyaml_cs_oa/container.py index cda2601..eb618f3 100644 --- a/pyaml_cs_oa/container.py +++ b/pyaml_cs_oa/container.py @@ -2,7 +2,6 @@ import inspect from collections.abc import Awaitable, Callable from typing import TypeVar -from .signal import OASignal from ophyd_async.core import ( SignalDatatypeT, @@ -10,10 +9,37 @@ SignalW, set_and_wait_for_other_value, ) +from pyaml.common.exception import PyAMLException + +from . import arun +from .signal import OASignal T = TypeVar("T") -from . import arun + +def _indexed_float(signal_name: str, value: SignalDatatypeT, index: int) -> float: + if not hasattr(value, "__getitem__"): + raise PyAMLException( + f"{signal_name}: backend.get_value() returned a non-indexable " + f"value of type {type(value).__name__}; cannot read index {index}." + ) + + try: + indexed_value = value[index] + except (IndexError, KeyError, TypeError) as exc: + raise PyAMLException( + f"{signal_name}: cannot read index {index} from " + f"backend.get_value() result of type {type(value).__name__}." + ) from exc + + try: + return float(indexed_value) + except (TypeError, ValueError) as exc: + raise PyAMLException( + f"{signal_name}: backend.get_value()[{index}] cannot be converted " + f"to float; got {type(indexed_value).__name__}." + ) from exc + def _looks_disconnected(exc: BaseException) -> bool: # Keep it generic: ophyd-async wraps cancellations in TimeoutError; @@ -68,7 +94,11 @@ async def _run_get(self) -> SignalDatatypeT: backend = self._r_sig._connector.backend value = await backend.get_value() print(f"Read {self._r_sig.name} index={self._index}") - return float(value[self._index]) if self._index is not None else value + return ( + _indexed_float(self._r_sig.name, value, self._index) + if self._index is not None + else value + ) async def async_get(self) -> SignalDatatypeT: return await _recover_once( From faa5ff982261e402936e312b7fbcfd729fe83010 Mon Sep 17 00:00:00 2001 From: PONS Date: Wed, 29 Apr 2026 11:36:32 +0200 Subject: [PATCH 11/32] Removed no needed file --- pyaml_cs_oa/indexed_signal.py | 61 ----------------------------------- 1 file changed, 61 deletions(-) delete mode 100644 pyaml_cs_oa/indexed_signal.py diff --git a/pyaml_cs_oa/indexed_signal.py b/pyaml_cs_oa/indexed_signal.py deleted file mode 100644 index 70b395a..0000000 --- a/pyaml_cs_oa/indexed_signal.py +++ /dev/null @@ -1,61 +0,0 @@ -from pyaml.common.exception import PyAMLException -from pyaml.control.deviceaccess import DeviceAccess - -from .container import OAReadback, OASetpoint - - -class IndexedFloatSignal(DeviceAccess): - """DeviceAccess that combines independently-built read and write signals. - - Used when at least one side is an indexed array element, or when the - read and write PVs must be built as separate signals (mixed scalar/array). - """ - - def __init__( - self, - rb: OAReadback | None, - sp: OASetpoint | None, - unit: str = "", - range: list | None = None, - measure_name: str = "", - ): - self._rb = rb - self._sp = sp - self._unit = unit - self._range = range or [None, None] - self._measure_name = measure_name - self._writable = sp is not None - self._readable = rb is not None - - def get(self): - if self._writable: - return self._sp.get() - return self._rb.get() - - def readback(self): - return self._rb.get() - - def set(self, value): - if not self._writable: - raise PyAMLException("IndexedFloatSignal is read-only: individual element writes are not supported.") - return self._sp.set(value) - - def set_and_wait(self, value): - if not self._writable: - raise PyAMLException("IndexedFloatSignal is read-only: individual element writes are not supported.") - return self._sp.set(value) - - def unit(self) -> str: - return self._unit - - def get_range(self) -> list: - return self._range - - def measure_name(self) -> str: - return self._measure_name - - def name(self) -> str: - return self._measure_name - - def check_device_availability(self) -> bool: - return True From 6800220e5daa662bbd8ffea929545a292e1533f4 Mon Sep 17 00:00:00 2001 From: PONS Date: Wed, 29 Apr 2026 11:36:53 +0200 Subject: [PATCH 12/32] Fix comment --- pyaml_cs_oa/container.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyaml_cs_oa/container.py b/pyaml_cs_oa/container.py index eb618f3..a7ffc73 100644 --- a/pyaml_cs_oa/container.py +++ b/pyaml_cs_oa/container.py @@ -78,8 +78,7 @@ class OAReadback: Parameters ---------- r_signal: - A raw ``SignalR`` **or** an existing ``OAReadback`` (its underlying - signal is reused — useful for adding an index after the fact). + Source Ophyd signal index: When set, ``get()`` returns ``float(array[index])`` instead of the full array value. From dd48aef04a2dea7967722c6e06a12c0b7fa8a313 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Wed, 29 Apr 2026 12:28:27 +0200 Subject: [PATCH 13/32] Simplify indexed readback validation --- pyaml_cs_oa/container.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pyaml_cs_oa/container.py b/pyaml_cs_oa/container.py index a7ffc73..98a6c60 100644 --- a/pyaml_cs_oa/container.py +++ b/pyaml_cs_oa/container.py @@ -18,12 +18,6 @@ def _indexed_float(signal_name: str, value: SignalDatatypeT, index: int) -> float: - if not hasattr(value, "__getitem__"): - raise PyAMLException( - f"{signal_name}: backend.get_value() returned a non-indexable " - f"value of type {type(value).__name__}; cannot read index {index}." - ) - try: indexed_value = value[index] except (IndexError, KeyError, TypeError) as exc: From 7932dac58a7498f362e214aa04327088fcd27088 Mon Sep 17 00:00:00 2001 From: PONS Date: Wed, 29 Apr 2026 12:58:25 +0200 Subject: [PATCH 14/32] Retored original OASeptoint contructor --- pyaml_cs_oa/container.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/pyaml_cs_oa/container.py b/pyaml_cs_oa/container.py index 98a6c60..771d792 100644 --- a/pyaml_cs_oa/container.py +++ b/pyaml_cs_oa/container.py @@ -123,10 +123,9 @@ class OASetpoint: Parameters ---------- w_signal: - A raw ``SignalW`` **or** an existing ``OASetpoint`` (its underlying - signal is reused). + Ophyd source write signal r_signal: - Optional readback signal, used only by ``set_and_wait()``. + Optional Ophyd source signal readback signal, used only by ``set_and_wait()``. index: When set, ``get()`` returns ``float(array[index])`` and ``set(v)`` performs a read-modify-write on element ``index``. @@ -134,16 +133,12 @@ class OASetpoint: def __init__( self, - w_signal: 'SignalW | OASetpoint', - r_signal: 'SignalR | None' = None, + w_signal: SignalW[SignalDatatypeT], + r_signal: SignalR[SignalDatatypeT] | None = None, index: int | None = None, ): - if isinstance(w_signal, OASetpoint): - self._w_sig = w_signal._w_sig - self._r_sig = w_signal._r_sig if r_signal is None else r_signal - else: - self._w_sig = w_signal - self._r_sig = r_signal + self._w_sig = w_signal + self._r_sig = r_signal self._has_r_sig = (self._r_sig is not None) self._index = index From 6457626ed4d34b7740f18df064cb53f13d083a59 Mon Sep 17 00:00:00 2001 From: PONS Date: Wed, 29 Apr 2026 13:58:15 +0200 Subject: [PATCH 15/32] Avoid duplication of Ophyd signal --- pyaml_cs_oa/epics.py | 71 ++++++++++++++++++++++++++++++-------------- 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/pyaml_cs_oa/epics.py b/pyaml_cs_oa/epics.py index 8773059..5c52cdc 100644 --- a/pyaml_cs_oa/epics.py +++ b/pyaml_cs_oa/epics.py @@ -1,6 +1,5 @@ from ophyd_async.epics.signal import epics_signal_r, epics_signal_w, epics_signal_rw -from ophyd_async.core import Array1D -import numpy +from ophyd_async.core import SignalR, SignalW, SignalRW from .container import OAReadback as Readback from .container import OASetpoint as Setpoint @@ -11,6 +10,47 @@ EpicsConfigW, ) +ALL_R = {} +ALL_W = {} +ALL_RW = {} + +def create_signal_r(read_pv:str,timeout:float) -> SignalR: + if read_pv not in ALL_R: + # Do not create same signal several times + r_sig = epics_signal_r( + datatype = None, + read_pv = read_pv, + name = read_pv, + timeout = timeout, + ) + ALL_R[read_pv] = r_sig + return ALL_R[read_pv] + +def create_signal_w(write_pv:str,timeout:float) -> SignalR: + if write_pv not in ALL_W: + # Do not create same signal several times + w_sig = epics_signal_w( + datatype = None, + write_pv = write_pv, + name = write_pv, + timeout = timeout, + ) + ALL_W[write_pv] = w_sig + return ALL_W[write_pv] + +def create_signal_rw(read_pv:str,write_pv:str,timeout:float) -> SignalR: + key = read_pv + write_pv + if key not in ALL_RW: + # Do not create same signal several times + rw_sig = epics_signal_rw( + datatype = None, + read_pv = read_pv, + write_pv = write_pv, + name = read_pv, + timeout = timeout, + ) + ALL_RW[key] = rw_sig + return ALL_RW[key] def get_SP_RB(cfg: ControlSysConfig,is_array:bool) -> tuple[Setpoint | None, Readback | None]: setpoint: Setpoint | None = None @@ -19,35 +59,20 @@ def get_SP_RB(cfg: ControlSysConfig,is_array:bool) -> tuple[Setpoint | None, Rea assert isinstance(cfg, (EpicsConfigRW, EpicsConfigR, EpicsConfigW)) if isinstance(cfg, EpicsConfigR): - r_sig = epics_signal_r( - datatype=float if not is_array else Array1D[numpy.float64], - read_pv=cfg.read_pvname, - name=cfg.read_pvname, # Use name for debugging - timeout = cfg.timeout_ms / 1000., - ) + r_sig = create_signal_r(cfg.read_pvname,cfg.timeout_ms / 1000.0) + print(f"{cfg.read_pvname}:{r_sig}") readback = Readback(r_sig,cfg.index) setpoint = None if isinstance(cfg, EpicsConfigW): - w_sig = epics_signal_w( - datatype=float if not is_array else Array1D[numpy.float64], - write_pv=cfg.write_pvname, - name=cfg.write_pvname, # Use name for debugging - timeout = cfg.timeout_ms / 1000., - ) + w_sig = create_signal_w(cfg.write_pvname,cfg.timeout_ms / 1000.0) readback = None setpoint = Setpoint(w_sig,cfg.index) if isinstance(cfg, EpicsConfigRW): - w_sig = epics_signal_rw( - datatype=float if not is_array else Array1D[numpy.float64], - read_pv=cfg.read_pvname, - write_pv=cfg.write_pvname, - name=cfg.read_pvname, # Use name for debugging - timeout = cfg.timeout_ms / 1000., - ) - readback = Readback(w_sig,cfg.index) - setpoint = Setpoint(w_sig,cfg.index) + rw_sig = create_signal_rw(cfg.read_pvname,cfg.write_pvname,cfg.timeout_ms / 1000.0) + readback = Readback(rw_sig,cfg.index) + setpoint = Setpoint(rw_sig,cfg.index) return setpoint, readback From e14b4f39df4f57143a4295813c80dacba2281671 Mon Sep 17 00:00:00 2001 From: PONS Date: Wed, 29 Apr 2026 14:02:51 +0200 Subject: [PATCH 16/32] Removed trace --- pyaml_cs_oa/epics.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyaml_cs_oa/epics.py b/pyaml_cs_oa/epics.py index 5c52cdc..1399232 100644 --- a/pyaml_cs_oa/epics.py +++ b/pyaml_cs_oa/epics.py @@ -60,7 +60,6 @@ def get_SP_RB(cfg: ControlSysConfig,is_array:bool) -> tuple[Setpoint | None, Rea if isinstance(cfg, EpicsConfigR): r_sig = create_signal_r(cfg.read_pvname,cfg.timeout_ms / 1000.0) - print(f"{cfg.read_pvname}:{r_sig}") readback = Readback(r_sig,cfg.index) setpoint = None From 49f8e3d8418fc74676b99f55fd6df3e2ecfe183e Mon Sep 17 00:00:00 2001 From: PONS Date: Wed, 29 Apr 2026 14:18:54 +0200 Subject: [PATCH 17/32] restored container.py --- pyaml_cs_oa/container.py | 102 +++++++-------------------------------- 1 file changed, 18 insertions(+), 84 deletions(-) diff --git a/pyaml_cs_oa/container.py b/pyaml_cs_oa/container.py index 771d792..0500572 100644 --- a/pyaml_cs_oa/container.py +++ b/pyaml_cs_oa/container.py @@ -2,6 +2,7 @@ import inspect from collections.abc import Awaitable, Callable from typing import TypeVar +from .signal import OASignal from ophyd_async.core import ( SignalDatatypeT, @@ -9,31 +10,10 @@ SignalW, set_and_wait_for_other_value, ) -from pyaml.common.exception import PyAMLException - -from . import arun -from .signal import OASignal T = TypeVar("T") - -def _indexed_float(signal_name: str, value: SignalDatatypeT, index: int) -> float: - try: - indexed_value = value[index] - except (IndexError, KeyError, TypeError) as exc: - raise PyAMLException( - f"{signal_name}: cannot read index {index} from " - f"backend.get_value() result of type {type(value).__name__}." - ) from exc - - try: - return float(indexed_value) - except (TypeError, ValueError) as exc: - raise PyAMLException( - f"{signal_name}: backend.get_value()[{index}] cannot be converted " - f"to float; got {type(indexed_value).__name__}." - ) from exc - +from . import arun def _looks_disconnected(exc: BaseException) -> bool: # Keep it generic: ophyd-async wraps cancellations in TimeoutError; @@ -66,32 +46,16 @@ async def _recover_once( raise -class OAReadback: - """Readback wrapper with optional element extraction for array signals. - - Parameters - ---------- - r_signal: - Source Ophyd signal - index: - When set, ``get()`` returns ``float(array[index])`` instead of the - full array value. - """ +class OAReadback(): + """A readback object.""" - def __init__(self, r_signal: SignalR[SignalDatatypeT], index: int | None = None): + def __init__(self, r_signal: SignalR[SignalDatatypeT]): self._r_sig = r_signal - self._index = index async def _run_get(self) -> SignalDatatypeT: await self._r_sig.connect() backend = self._r_sig._connector.backend - value = await backend.get_value() - print(f"Read {self._r_sig.name} index={self._index}") - return ( - _indexed_float(self._r_sig.name, value, self._index) - if self._index is not None - else value - ) + return await backend.get_value() async def async_get(self) -> SignalDatatypeT: return await _recover_once( @@ -116,37 +80,20 @@ def get(self) -> SignalDatatypeT: """Synchronous wrapper around `async_get()`.""" return arun(self.async_get()) - -class OASetpoint: - """Setpoint wrapper with optional read-modify-write for array signals. - - Parameters - ---------- - w_signal: - Ophyd source write signal - r_signal: - Optional Ophyd source signal readback signal, used only by ``set_and_wait()``. - index: - When set, ``get()`` returns ``float(array[index])`` and ``set(v)`` - performs a read-modify-write on element ``index``. - """ - +class OASetpoint(): def __init__( self, w_signal: SignalW[SignalDatatypeT], r_signal: SignalR[SignalDatatypeT] | None = None, - index: int | None = None, ): self._w_sig = w_signal - self._r_sig = r_signal - self._has_r_sig = (self._r_sig is not None) - self._index = index + self._r_sig = r_signal # used only for `set_and_wait()` + self._has_r_sig = (r_signal is not None) async def _run_get(self) -> SignalDatatypeT: await self._w_sig.connect() backend = self._w_sig._connector.backend - value = await backend.get_setpoint() - return float(value[self._index]) if self._index is not None else value + return await backend.get_setpoint() async def async_get(self) -> SignalDatatypeT: return await _recover_once( @@ -167,27 +114,14 @@ async def async_read(self) -> SignalDatatypeT: getattr(self._w_sig, "__peer__", None), ) - async def _run_set(self, value) -> None: + async def _run_set(self, value): await self._w_sig.connect() - return self._w_sig.set(value) - - async def _run_set_indexed(self, value) -> None: - import numpy as np - await self._w_sig.connect() - backend = self._w_sig._connector.backend - arr = np.array(await backend.get_setpoint(), dtype=float) - arr = arr.copy() - arr[self._index] = value - return self._w_sig.set(arr) + status = self._w_sig.set(value) + return status async def async_set(self, value): - run = ( - (lambda: self._run_set_indexed(value)) - if self._index is not None - else (lambda: self._run_set(value)) - ) return await _recover_once( - run, + lambda: self._run_set(value), self._w_sig.connect, getattr(self._w_sig, "__peer__", None), ) @@ -219,9 +153,9 @@ async def async_set_and_wait(self, value) -> None: ) async def _complete_set(self, value): - status = await self.async_set(value) - await status - return status + status = await self.async_set(value) + await status # Wait for completion before returning + return status def set(self, value): """Synchronous wrapper around `async_set()`.""" @@ -233,4 +167,4 @@ def get(self) -> SignalDatatypeT: def set_and_wait(self, value) -> None: """Synchronous wrapper around `async_set_and_wait()`.""" - return arun(self.async_set_and_wait(value)) + return arun(self.async_set_and_wait(value)) \ No newline at end of file From 482d2fc69707c489c2793287796a66b94819f8f7 Mon Sep 17 00:00:00 2001 From: PONS Date: Wed, 29 Apr 2026 14:24:39 +0200 Subject: [PATCH 18/32] Moved index management to DeviceAccess sub class --- pyaml_cs_oa/epics.py | 8 ++++---- pyaml_cs_oa/float_signal.py | 28 +++++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/pyaml_cs_oa/epics.py b/pyaml_cs_oa/epics.py index 1399232..fa56087 100644 --- a/pyaml_cs_oa/epics.py +++ b/pyaml_cs_oa/epics.py @@ -60,18 +60,18 @@ def get_SP_RB(cfg: ControlSysConfig,is_array:bool) -> tuple[Setpoint | None, Rea if isinstance(cfg, EpicsConfigR): r_sig = create_signal_r(cfg.read_pvname,cfg.timeout_ms / 1000.0) - readback = Readback(r_sig,cfg.index) + readback = Readback(r_sig) setpoint = None if isinstance(cfg, EpicsConfigW): w_sig = create_signal_w(cfg.write_pvname,cfg.timeout_ms / 1000.0) readback = None - setpoint = Setpoint(w_sig,cfg.index) + setpoint = Setpoint(w_sig) if isinstance(cfg, EpicsConfigRW): rw_sig = create_signal_rw(cfg.read_pvname,cfg.write_pvname,cfg.timeout_ms / 1000.0) - readback = Readback(rw_sig,cfg.index) - setpoint = Setpoint(rw_sig,cfg.index) + readback = Readback(rw_sig) + setpoint = Setpoint(rw_sig) return setpoint, readback diff --git a/pyaml_cs_oa/float_signal.py b/pyaml_cs_oa/float_signal.py index 6eb6ddc..33887b6 100644 --- a/pyaml_cs_oa/float_signal.py +++ b/pyaml_cs_oa/float_signal.py @@ -1,5 +1,6 @@ from .signal import OASignal from .types import ControlSysConfig +from pyaml.common.exception import PyAMLException class FloatSignalContainer(OASignal): """ @@ -9,6 +10,26 @@ class FloatSignalContainer(OASignal): def __init__(self, cfg: ControlSysConfig,is_array:bool): super().__init__(cfg,is_array) + def _indexed_float(self,signal_name: str, value, index: int) -> float: + + if self._cfg.index is None: + try: + return float(indexed_value) + except (TypeError, ValueError) as exc: + raise PyAMLException( + f"{signal_name}: backend.get_value()[{index}] cannot be converted " + f"to float; got {type(indexed_value).__name__}." + ) from exc + + + try: + indexed_value = value[index] + except (IndexError, KeyError, TypeError) as exc: + raise PyAMLException( + f"{signal_name}: cannot read index {index} from " + f"backend.get_value() result of type {type(value).__name__}." + ) from exc + def get(self): """ Get the last written value(s) of the attribute. @@ -20,9 +41,9 @@ def get(self): """ if self._writable: - return self.SP.get() + return self._indexed_float(self.SP.get()) else: - return self.RB.get() + return self._indexed_float(self.RB.get()) def readback(self): """ @@ -34,7 +55,7 @@ def readback(self): The readback value(s) including quality and timestamp. """ - return self.RB.get() + return self._indexed_float(self.RB.get()) def set(self, value): """ @@ -46,6 +67,7 @@ def set(self, value): Value(s) to write to the attribute. """ + # TODO handle indexed write return self.SP.set(value) def set_and_wait(self, value): From ab26f8c11c874e13e25f2ba5d86c10dba05362d9 Mon Sep 17 00:00:00 2001 From: PONS Date: Wed, 29 Apr 2026 16:11:10 +0200 Subject: [PATCH 19/32] Update for aggregator --- pyaml_cs_oa/container.py | 1 + pyaml_cs_oa/scalar_aggregator.py | 75 +++++++++++++++++++++++--------- 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/pyaml_cs_oa/container.py b/pyaml_cs_oa/container.py index 0500572..ca59a73 100644 --- a/pyaml_cs_oa/container.py +++ b/pyaml_cs_oa/container.py @@ -55,6 +55,7 @@ def __init__(self, r_signal: SignalR[SignalDatatypeT]): async def _run_get(self) -> SignalDatatypeT: await self._r_sig.connect() backend = self._r_sig._connector.backend + print(f"Read {self._r_sig.name}") return await backend.get_value() async def async_get(self) -> SignalDatatypeT: diff --git a/pyaml_cs_oa/scalar_aggregator.py b/pyaml_cs_oa/scalar_aggregator.py index f5a5544..4a59315 100644 --- a/pyaml_cs_oa/scalar_aggregator.py +++ b/pyaml_cs_oa/scalar_aggregator.py @@ -20,16 +20,43 @@ class OAScalarAggregator(DeviceAccessList): def __init__(self, cfg:ConfigModel=None): super().__init__() + self._r_signal_list = {} # List of signal to read + self._w_signal_list = {} # List of signal to read/write + self._writable = None + + def _add_to_dev_list(self,d:FloatSignalContainer): + + # Check type and read/write + if not isinstance(d,FloatSignalContainer): + raise PyAMLException("All devices must be instances of FloatSignalContainer.") + + if self._writable is None: + self._writable = d._writable + else: + if self._writable != d._writable: + raise PyAMLException("Cannot mix read only and read/write signal in a same aggreagator") + + # Construct structure to avoid duplicate reading + # The shared part is the source Ophyd signal + if d.RB._r_sig not in self._r_signal_list: + self._r_signal_list[d.RB._r_sig] = {"source":d.RB,"indices":[[d._cfg.index,len(self)]]} + else: + self._r_signal_list[d.RB._r_sig]["indices"].append([d._cfg.index,len(self)]) + + if self._writable: + if d.SP._w_sig not in self._w_signal_list: + self._w_signal_list[d.SP._w_sig] = {"source":d.SP,"indices":[[d._cfg.index,len(self)]]} + else: + self._w_signal_list[d.SP._w_sig]["indices"].append([d._cfg.index,len(self)]) + + self.append(d) def add_devices(self, devices: DeviceAccess | list[DeviceAccess]): if isinstance(devices, list): - if any([not isinstance(device, FloatSignalContainer) for device in devices]): - raise PyAMLException("All devices must be instances of FloatSignalContainer.") - super().extend(devices) + for d in devices: + self._add_to_dev_list(d) else: - if not isinstance(devices, FloatSignalContainer): - raise PyAMLException("Device must be an instance of FloatSignalContainer.") - super().append(devices) + self._add_to_dev_list(devices) def get_devices(self) -> DeviceAccess | list[DeviceAccess]: if len(self)==1: @@ -37,7 +64,6 @@ def get_devices(self) -> DeviceAccess | list[DeviceAccess]: else: return self - def set(self, value: npt.NDArray[np.float64]): if len(value)!=len(self): @@ -52,26 +78,35 @@ def set(self, value: npt.NDArray[np.float64]): def set_and_wait(self, value: npt.NDArray[np.float64]): raise NotImplemented("Not implemented yet.") - def get(self) -> npt.NDArray[np.float64]: + def _read(self,signal_list:dict) -> npt.NDArray[np.float64]: d: FloatSignalContainer requests = [] # list of status to await - for d in self: - if d._writable: - requests.append( d.SP.async_get() ) - else: - requests.append( d.RB.async_get() ) + for d,dc in signal_list.items(): + requests.append( dc["source"].async_get() ) values = arun(asyncio.gather(*requests)) - return np.array(values) + rvalues = np.zeros(len(self)) + sIdx = 0 + for _,dc in signal_list.items(): + for i in dc["indices"]: + if i[0] is None: + rvalues[i[1]] = values[sIdx] # non indexed scalar value + else: + rvalues[i[1]] = values[sIdx][i[0]] + sIdx+=1 + + return rvalues + + def get(self) -> npt.NDArray[np.float64]: + + if self._writable: + return self._read(self._w_signal_list) + else: + return self._read(self._r_signal_list) def readback(self) -> np.array: - d: FloatSignalContainer - requests = [] # list of status to await - for d in self: - requests.append( d.RB.async_get() ) - values = arun(asyncio.gather(*requests)) - return np.array(values) + return self._read(self._r_signal_list) def get_range(self) -> list[float]: attr_range: list[float] = [] From 614b9c35a49f7290c54aaad24c2f98352526097e Mon Sep 17 00:00:00 2001 From: PONS Date: Wed, 29 Apr 2026 16:26:10 +0200 Subject: [PATCH 20/32] Fix --- pyaml_cs_oa/float_signal.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/pyaml_cs_oa/float_signal.py b/pyaml_cs_oa/float_signal.py index 33887b6..00efb80 100644 --- a/pyaml_cs_oa/float_signal.py +++ b/pyaml_cs_oa/float_signal.py @@ -10,25 +10,27 @@ class FloatSignalContainer(OASignal): def __init__(self, cfg: ControlSysConfig,is_array:bool): super().__init__(cfg,is_array) - def _indexed_float(self,signal_name: str, value, index: int) -> float: + def _indexed_float(self, value) -> float: if self._cfg.index is None: + try: - return float(indexed_value) + return float(value) except (TypeError, ValueError) as exc: raise PyAMLException( - f"{signal_name}: backend.get_value()[{index}] cannot be converted " - f"to float; got {type(indexed_value).__name__}." + f"{self.name()}: backend.get_value()[{index}] cannot be converted " + f"to float; got {type(value).__name__}." ) from exc + + else: - - try: - indexed_value = value[index] - except (IndexError, KeyError, TypeError) as exc: - raise PyAMLException( - f"{signal_name}: cannot read index {index} from " - f"backend.get_value() result of type {type(value).__name__}." - ) from exc + try: + return value[self._cfg.index] + except (IndexError, KeyError, TypeError) as exc: + raise PyAMLException( + f"{self.name()}: cannot read index {index} from " + f"backend.get_value() result of type {type(value).__name__}." + ) from exc def get(self): """ From ddc61f445a760355589050221570c09ab49185c4 Mon Sep 17 00:00:00 2001 From: PONS Date: Thu, 30 Apr 2026 09:16:29 +0200 Subject: [PATCH 21/32] Fix --- pyaml_cs_oa/container.py | 5 +- pyaml_cs_oa/epics.py | 82 +++++++++++----------- pyaml_cs_oa/epicsR.py | 4 +- pyaml_cs_oa/epicsRW.py | 4 +- pyaml_cs_oa/epicsW.py | 4 +- pyaml_cs_oa/epics_catalog.py | 127 +++++++++++------------------------ pyaml_cs_oa/float_signal.py | 17 ++--- pyaml_cs_oa/signal.py | 30 ++++----- pyaml_cs_oa/tangoR.py | 4 +- pyaml_cs_oa/tangoRW.py | 4 +- pyaml_cs_oa/types.py | 6 ++ 11 files changed, 120 insertions(+), 167 deletions(-) diff --git a/pyaml_cs_oa/container.py b/pyaml_cs_oa/container.py index f33bef3..15c3ce5 100644 --- a/pyaml_cs_oa/container.py +++ b/pyaml_cs_oa/container.py @@ -25,7 +25,7 @@ def _looks_disconnected(exc: BaseException) -> bool: async def _recover_once( run: Callable[[], Awaitable[T]], reconnect: Callable[[], Awaitable[None]], - peer: OASignal, + peer, ) -> T: try: return await run() @@ -130,7 +130,8 @@ async def async_set(self, value): ) async def _reconnect_both(self) -> None: - await asyncio.gather(self._w_sig.connect(), self._r_sig.connect()) + if self._r_sig: + await asyncio.gather(self._w_sig.connect(), self._r_sig.connect()) async def _rebuild_both(self) -> None: w_rebuild = getattr(self._w_sig, "__peer__", None) diff --git a/pyaml_cs_oa/epics.py b/pyaml_cs_oa/epics.py index 3c31f28..603723e 100644 --- a/pyaml_cs_oa/epics.py +++ b/pyaml_cs_oa/epics.py @@ -1,5 +1,5 @@ import numpy -from ophyd_async.core import Array1D +from ophyd_async.core import Array1D, SignalR, SignalRW, SignalW from ophyd_async.epics.signal import epics_signal_r, epics_signal_rw, epics_signal_w from .container import OAReadback as Readback @@ -15,43 +15,47 @@ ALL_W = {} ALL_RW = {} -def create_signal_r(read_pv:str,timeout:float) -> SignalR: - if read_pv not in ALL_R: - # Do not create same signal several times - r_sig = epics_signal_r( - datatype = None, - read_pv = read_pv, - name = read_pv, - timeout = timeout, - ) - ALL_R[read_pv] = r_sig - return ALL_R[read_pv] -def create_signal_w(write_pv:str,timeout:float) -> SignalR: - if write_pv not in ALL_W: - # Do not create same signal several times - w_sig = epics_signal_w( - datatype = None, - write_pv = write_pv, - name = write_pv, - timeout = timeout, - ) - ALL_W[write_pv] = w_sig - return ALL_W[write_pv] +def create_signal_r(read_pv: str, timeout: float) -> SignalR: + if read_pv not in ALL_R: + # Do not create same signal several times + r_sig = epics_signal_r( + datatype=None, + read_pv=read_pv, + name=read_pv, + timeout=timeout, + ) + ALL_R[read_pv] = r_sig + return ALL_R[read_pv] + + +def create_signal_w(write_pv: str, timeout: float) -> SignalR: + if write_pv not in ALL_W: + # Do not create same signal several times + w_sig = epics_signal_w( + datatype=None, + write_pv=write_pv, + name=write_pv, + timeout=timeout, + ) + ALL_W[write_pv] = w_sig + return ALL_W[write_pv] + + +def create_signal_rw(read_pv: str, write_pv: str, timeout: float) -> SignalR: + key = read_pv + write_pv + if key not in ALL_RW: + # Do not create same signal several times + rw_sig = epics_signal_rw( + datatype=None, + read_pv=read_pv, + write_pv=write_pv, + name=read_pv, + timeout=timeout, + ) + ALL_RW[key] = rw_sig + return ALL_RW[key] -def create_signal_rw(read_pv:str,write_pv:str,timeout:float) -> SignalR: - key = read_pv + write_pv - if key not in ALL_RW: - # Do not create same signal several times - rw_sig = epics_signal_rw( - datatype = None, - read_pv = read_pv, - write_pv = write_pv, - name = read_pv, - timeout = timeout, - ) - ALL_RW[key] = rw_sig - return ALL_RW[key] def get_SP_RB(cfg: ControlSysConfig, is_array: bool) -> tuple[Setpoint | None, Readback | None]: setpoint: Setpoint | None = None @@ -60,17 +64,17 @@ def get_SP_RB(cfg: ControlSysConfig, is_array: bool) -> tuple[Setpoint | None, R assert isinstance(cfg, (EpicsConfigRW, EpicsConfigR, EpicsConfigW)) if isinstance(cfg, EpicsConfigR): - r_sig = create_signal_r(cfg.read_pvname,cfg.timeout_ms / 1000.0) + r_sig = create_signal_r(cfg.read_pvname, cfg.timeout_ms / 1000.0) readback = Readback(r_sig) setpoint = None if isinstance(cfg, EpicsConfigW): - w_sig = create_signal_w(cfg.write_pvname,cfg.timeout_ms / 1000.0) + w_sig = create_signal_w(cfg.write_pvname, cfg.timeout_ms / 1000.0) readback = None setpoint = Setpoint(w_sig) if isinstance(cfg, EpicsConfigRW): - rw_sig = create_signal_rw(cfg.read_pvname,cfg.write_pvname,cfg.timeout_ms / 1000.0) + rw_sig = create_signal_rw(cfg.read_pvname, cfg.write_pvname, cfg.timeout_ms / 1000.0) readback = Readback(rw_sig) setpoint = Setpoint(rw_sig) diff --git a/pyaml_cs_oa/epicsR.py b/pyaml_cs_oa/epicsR.py index 60940f4..41102c3 100644 --- a/pyaml_cs_oa/epicsR.py +++ b/pyaml_cs_oa/epicsR.py @@ -4,12 +4,12 @@ PYAMLCLASS: str = "EpicsR" -class ConfigModel(EpicsConfigR): - unit: str = "" +class ConfigModel(EpicsConfigR): ... class EpicsR(FloatSignalContainer): def __init__(self, cfg: ConfigModel, is_array: bool = False): super().__init__(cfg, is_array) + def get_cs(self) -> str: return "epics" diff --git a/pyaml_cs_oa/epicsRW.py b/pyaml_cs_oa/epicsRW.py index a9a471d..6359ca5 100644 --- a/pyaml_cs_oa/epicsRW.py +++ b/pyaml_cs_oa/epicsRW.py @@ -4,12 +4,12 @@ PYAMLCLASS: str = "EpicsRW" -class ConfigModel(EpicsConfigRW): - unit: str = "" +class ConfigModel(EpicsConfigRW): ... class EpicsRW(FloatSignalContainer): def __init__(self, cfg: ConfigModel, is_array: bool = False): super().__init__(cfg, is_array) + def get_cs(self) -> str: return "epics" diff --git a/pyaml_cs_oa/epicsW.py b/pyaml_cs_oa/epicsW.py index 377e84c..07a76e5 100644 --- a/pyaml_cs_oa/epicsW.py +++ b/pyaml_cs_oa/epicsW.py @@ -4,12 +4,12 @@ PYAMLCLASS: str = "EpicsW" -class ConfigModel(EpicsConfigW): - unit: str = "" +class ConfigModel(EpicsConfigW): ... class EpicsW(FloatSignalContainer): def __init__(self, cfg: ConfigModel, is_array: bool = False): super().__init__(cfg, is_array) + def get_cs(self) -> str: return "epics" diff --git a/pyaml_cs_oa/epics_catalog.py b/pyaml_cs_oa/epics_catalog.py index 9f9c006..a875bf2 100644 --- a/pyaml_cs_oa/epics_catalog.py +++ b/pyaml_cs_oa/epics_catalog.py @@ -1,8 +1,7 @@ -from pydantic import ConfigDict - from pyaml.common.exception import PyAMLException from pyaml.configuration.catalog import Catalog, CatalogConfigModel from pyaml.control.deviceaccess import DeviceAccess +from pydantic import ConfigDict PYAMLCLASS = "EpicsCatalog" @@ -15,16 +14,12 @@ class ConfigModel(CatalogConfigModel): ``entries`` list required. Resolutions are cached after the first call. Supported key formats:: + (TODO write only) "PV" → scalar read-only "PV@n" → array PV, element n, read-only - "R_PV, W_PV" → scalar read-write - "R_PV@n, W_PV@m" → indexed read + indexed write (read-modify-write) - "R:PV@n, W:PV@m" → same (colons are part of the PV name) - "R_PV@n, W_PV" → indexed read + scalar write - "R_PV, W_PV@m" → scalar read + indexed write - - The ``prefix`` is prepended to every PV name extracted from the key. + "(R_PV, W_PV)" → scalar read-write + "(R_PV, W_PV)@n" → scalar indexed read-write Example ------- @@ -32,99 +27,57 @@ class ConfigModel(CatalogConfigModel): type: pyaml_cs_oa.epics_catalog name: id-catalog - prefix: "" timeout_ms: 3000 """ model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") - - prefix: str = "" timeout_ms: int = 3000 + prefix: str = "" # Ignored class EpicsCatalog(Catalog): def __init__(self, cfg: ConfigModel): super().__init__(cfg) - self._refs: dict[str, DeviceAccess] = {} def resolve(self, key: str) -> DeviceAccess: - if key not in self._refs: - try: - self._refs[key] = _build_device(key, self._cfg.prefix, self._cfg.timeout_ms) - except PyAMLException: - raise - except Exception as exc: - raise PyAMLException( - f"EpicsCatalog '{self.get_name()}' cannot resolve key '{key}': {exc}" - ) from exc - return self._refs[key] + return _build_device(key, self._cfg.timeout_ms) # ── PV spec parser ──────────────────────────────────────────────────────────── -def _parse_pv(token: str) -> tuple[str, int | None]: - """Split ``'PV_NAME[@index]'`` into ``(pv_name, index_or_None)``.""" + +def _parse_pv(token: str) -> tuple[list[str], int | None]: token = token.strip() + + # Extract index (if any) + index = None if "@" in token: - pv_name, idx_str = token.rsplit("@", 1) - try: - return pv_name.strip(), int(idx_str.strip()) - except ValueError: - raise PyAMLException(f"EpicsCatalog: invalid index in PV token '{token}'") - return token, None - - -def _build_device(pv_str: str, prefix: str, timeout_ms: int) -> DeviceAccess: - tokens = [t.strip() for t in pv_str.split(",")] - if len(tokens) == 1: - return _build_single(tokens[0], prefix, timeout_ms) - if len(tokens) == 2: - return _build_pair(tokens[0], tokens[1], prefix, timeout_ms) - raise PyAMLException( - f"EpicsCatalog: too many comma-separated tokens in key '{pv_str}' (max 2)" - ) - - -def _build_single(token: str, prefix: str, timeout_ms: int) -> DeviceAccess: - from .epicsR import EpicsR, ConfigModel as EpicsRConfig - - pv_name, index = _parse_pv(token) - sig = EpicsR(EpicsRConfig(read_pvname=prefix + pv_name, timeout_ms=timeout_ms, index=index)) - sig.build() - return sig - - -def _build_pair(read_token: str, write_token: str, prefix: str, timeout_ms: int) -> DeviceAccess: - read_pv, read_idx = _parse_pv(read_token) - write_pv, write_idx = _parse_pv(write_token) - full_read = prefix + read_pv - full_write = prefix + write_pv - - if read_idx is None and write_idx is None: - from .epicsRW import EpicsRW, ConfigModel as EpicsRWConfig - sig = EpicsRW(EpicsRWConfig(read_pvname=full_read, write_pvname=full_write, - timeout_ms=timeout_ms)) - sig.build() - return sig - - if read_idx is not None and write_idx is not None: - # Both indexed (same or different): EpicsRW carries both indexes. - from .epicsRW import EpicsRW, ConfigModel as EpicsRWConfig - sig = EpicsRW(EpicsRWConfig(read_pvname=full_read, write_pvname=full_write, - timeout_ms=timeout_ms, - read_index=read_idx, write_index=write_idx)) - sig.build() - return sig - - # Mixed (one side array, one side scalar): build independently and combine. - from .epicsR import EpicsR, ConfigModel as EpicsRConfig - from .epicsW import EpicsW, ConfigModel as EpicsWConfig - from .indexed_signal import IndexedFloatSignal - - r_sig = EpicsR(EpicsRConfig(read_pvname=full_read, timeout_ms=timeout_ms, index=read_idx)) - r_sig.build() - w_sig = EpicsW(EpicsWConfig(write_pvname=full_write, timeout_ms=timeout_ms, index=write_idx)) - w_sig.build() - - measure = f"{read_pv}[{read_idx}]" if read_idx is not None else read_pv - return IndexedFloatSignal(r_sig.RB, w_sig.SP, measure_name=measure) + token, idx_str = token.rsplit("@", 1) + try: + index = int(idx_str.strip()) + except ValueError: + raise PyAMLException(f"EpicsCatalog: invalid index in PV token '{token}'") from None + + # Extract pv name(s) + if token.startswith("(") and token.endswith(")"): + # We have a list + names = token[1:-1] + name_list = [names.strip() for n in names.split(",")] + else: + name_list = [token.strip()] + + return name_list, index + + +def _build_device(pv_str: str, timeout_ms: int) -> DeviceAccess: + from .epicsR import ConfigModel as EpicsRConfig + from .epicsR import EpicsR + from .epicsRW import ConfigModel as EpicsRWConfig + from .epicsRW import EpicsRW + + pv_names, index = _parse_pv(pv_str) + if len(pv_names) == 1: + return EpicsR(EpicsRConfig(read_pvname=pv_names[0], timeout_ms=timeout_ms, index=index)) + if len(pv_names) == 2: + return EpicsRW(EpicsRWConfig(read_pvname=pv_names[0], write_pvname=pv_names[1], timeout_ms=timeout_ms, index=index)) + raise PyAMLException(f"EpicsCatalog: too many comma-separated tokens in key '{pv_str}' (max 2)") diff --git a/pyaml_cs_oa/float_signal.py b/pyaml_cs_oa/float_signal.py index 03e5187..d43aa6b 100644 --- a/pyaml_cs_oa/float_signal.py +++ b/pyaml_cs_oa/float_signal.py @@ -1,6 +1,7 @@ +from pyaml.common.exception import PyAMLException + from .signal import OASignal from .types import ControlSysConfig -from pyaml.common.exception import PyAMLException class FloatSignalContainer(OASignal): @@ -12,24 +13,18 @@ def __init__(self, cfg: ControlSysConfig, is_array: bool): super().__init__(cfg, is_array) def _indexed_float(self, value) -> float: - if self._cfg.index is None: - try: - return float(value) + return float(value) except (TypeError, ValueError) as exc: - raise PyAMLException( - f"{self.name()}: backend.get_value()[{index}] cannot be converted " - f"to float; got {type(value).__name__}." - ) from exc - + raise PyAMLException(f"{self.name()}: cannot be converted to float; got {type(value).__name__}.") from exc + else: - try: return value[self._cfg.index] except (IndexError, KeyError, TypeError) as exc: raise PyAMLException( - f"{self.name()}: cannot read index {index} from " + f"{self.name()}: cannot read index {self._cfg.index} from " f"backend.get_value() result of type {type(value).__name__}." ) from exc diff --git a/pyaml_cs_oa/signal.py b/pyaml_cs_oa/signal.py index e518372..95907f1 100644 --- a/pyaml_cs_oa/signal.py +++ b/pyaml_cs_oa/signal.py @@ -22,9 +22,7 @@ def __init__(self, cfg: ControlSysConfig, is_array: bool = False): def build(self): self._readable: bool = isinstance(self._cfg, (EpicsConfigR, TangoConfigR)) - self._writable: bool = isinstance( - self._cfg, (EpicsConfigRW, EpicsConfigW, TangoConfigRW) - ) + self._writable: bool = isinstance(self._cfg, (EpicsConfigRW, EpicsConfigW, TangoConfigRW)) cs_name = self.get_cs() if cs_name == "tango": @@ -49,32 +47,28 @@ def name(self) -> str: def measure_name(self) -> str: if isinstance(self._cfg, (EpicsConfigR, EpicsConfigRW)): - base = self._cfg.read_pvname + return self._cfg.read_pvname elif isinstance(self._cfg, EpicsConfigW): - base = self._cfg.write_pvname + return self._cfg.write_pvname elif isinstance(self._cfg, (TangoConfigR, TangoConfigRW)): - base = self._cfg.attribute + return self._cfg.attribute else: - raise ValueError( - f"Unsupported control system config type: {type(self._cfg)!r}" - ) - eff_read, _ = _effective_indexes(self._cfg) - return f"{base}[{eff_read}]" if eff_read is not None else base + raise ValueError(f"Unsupported control system config type: {type(self._cfg)!r}") def unit(self) -> str: return self._cfg.unit def get_range(self) -> list: - if self._writable and self._cfg.range: - return self._cfg.range - else: - return [None, None] + if isinstance(self._cfg, (EpicsConfigW, EpicsConfigRW, TangoConfigRW)): + if self._cfg.range: + return self._cfg.range + return [None, None] def check_device_availability(self) -> bool: # TODO return True def __repr__(self): - cfg_str = repr(self._cfg) - idx = cfg_str.find("(") - return f"{self.__class__.__name__}{cfg_str[idx:]}" + cfg_str = repr(self._cfg) + idx = cfg_str.find("(") + return f"{self.__class__.__name__}{cfg_str[idx:]}" diff --git a/pyaml_cs_oa/tangoR.py b/pyaml_cs_oa/tangoR.py index 0f97c8c..5f6cdee 100644 --- a/pyaml_cs_oa/tangoR.py +++ b/pyaml_cs_oa/tangoR.py @@ -4,12 +4,12 @@ PYAMLCLASS: str = "TangoR" -class ConfigModel(TangoConfigR): - unit: str = "" +class ConfigModel(TangoConfigR): ... class TangoR(FloatSignalContainer): def __init__(self, cfg: ConfigModel, is_array: bool = False): super().__init__(cfg, is_array) + def get_cs(self) -> str: return "tango" diff --git a/pyaml_cs_oa/tangoRW.py b/pyaml_cs_oa/tangoRW.py index 3b78172..3410dc4 100644 --- a/pyaml_cs_oa/tangoRW.py +++ b/pyaml_cs_oa/tangoRW.py @@ -4,12 +4,12 @@ PYAMLCLASS: str = "TangoRW" -class ConfigModel(TangoConfigRW): - unit: str = "" +class ConfigModel(TangoConfigRW): ... class TangoRW(FloatSignalContainer): def __init__(self, cfg: ConfigModel, is_array: bool = False): super().__init__(cfg, is_array) + def get_cs(self) -> str: return "tango" diff --git a/pyaml_cs_oa/types.py b/pyaml_cs_oa/types.py index b5b68eb..c837547 100644 --- a/pyaml_cs_oa/types.py +++ b/pyaml_cs_oa/types.py @@ -6,6 +6,7 @@ class EpicsConfigR(BaseModel): read_pvname: str timeout_ms: int = 3000 index: int | None = None + unit: str = "" class EpicsConfigW(BaseModel): @@ -14,6 +15,7 @@ class EpicsConfigW(BaseModel): timeout_ms: int = 3000 range: list[float] | None = None index: int | None = None + unit: str = "" class EpicsConfigRW(BaseModel): @@ -23,11 +25,14 @@ class EpicsConfigRW(BaseModel): timeout_ms: int = 3000 range: list[float] | None = None index: int | None = None + unit: str = "" + class TangoConfigR(BaseModel): attribute: str timeout_ms: int = 3000 index: int | None = None + unit: str = "" class TangoConfigRW(BaseModel): @@ -35,6 +40,7 @@ class TangoConfigRW(BaseModel): timeout_ms: int = 3000 range: list[float] | None = None index: int | None = None + unit: str = "" ControlSysConfig = EpicsConfigR | EpicsConfigW | EpicsConfigRW | TangoConfigR | TangoConfigRW From fc90f5132d1a6562a1c047b8078cf454dbf9ea34 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Thu, 30 Apr 2026 11:02:59 +0200 Subject: [PATCH 22/32] Adapt BPM tests to PyAML catalog-based device resolution Update live tune integration configs for current PyAML tune API --- tests/EBSTune-ophyd.yaml | 6 +-- tests/bessy2tune-KL.yaml | 6 +-- tests/test_bpm_orbit.py | 87 ++++++++++++++++++++++++++------- tests/test_controlsystem.py | 5 +- tests/test_scalar_aggregator.py | 10 ++-- tests/test_signal.py | 13 +++-- tests/test_tune.py | 2 +- tests/test_tune_bessy.py | 2 +- tests/tunemat-bessy.json | 2 +- tests/tunemat.json | 2 +- 10 files changed, 96 insertions(+), 39 deletions(-) diff --git a/tests/EBSTune-ophyd.yaml b/tests/EBSTune-ophyd.yaml index ce7a6d0..43be563 100644 --- a/tests/EBSTune-ophyd.yaml +++ b/tests/EBSTune-ophyd.yaml @@ -1888,6 +1888,6 @@ devices: unit: mm - type: pyaml.tuning_tools.tune name: TUNE - quad_array: QForTune - betatron_tune: BETATRON_TUNE - delta: 1e-4 + quad_array_name: QForTune + betatron_tune_name: BETATRON_TUNE + response_matrix: tunemat.json diff --git a/tests/bessy2tune-KL.yaml b/tests/bessy2tune-KL.yaml index f83635c..6075c0b 100644 --- a/tests/bessy2tune-KL.yaml +++ b/tests/bessy2tune-KL.yaml @@ -510,6 +510,6 @@ devices: unit: '' - type: pyaml.tuning_tools.tune name: DEFAULT_TUNE_CORRECTION - quad_array: QForTune - betatron_tune: BETATRON_TUNE - delta: 1e-4 + quad_array_name: QForTune + betatron_tune_name: BETATRON_TUNE + response_matrix: tunemat-bessy.json diff --git a/tests/test_bpm_orbit.py b/tests/test_bpm_orbit.py index c9fd979..5947783 100644 --- a/tests/test_bpm_orbit.py +++ b/tests/test_bpm_orbit.py @@ -8,10 +8,13 @@ from pyaml.bpm.bpm import ConfigModel as BPMConfig from pyaml.bpm.bpm_simple_model import BPMSimpleModel from pyaml.bpm.bpm_simple_model import ConfigModel as BPMSimpleModelConfig +from pyaml.configuration.catalog import Catalog, CatalogConfigModel from pyaml.control.abstract_impl import RBpmArray from pyaml.control.deviceaccess import DeviceAccess from pyaml_cs_oa.controlsystem import ConfigModel, OphydAsyncControlSystem +from pyaml_cs_oa.float_signal import FloatSignalContainer +from pyaml_cs_oa.types import EpicsConfigR class VectorDevice(DeviceAccess): @@ -51,6 +54,41 @@ def check_device_availability(self) -> bool: return True +class _VectorReadSide: + def __init__(self, source: VectorDevice) -> None: + self._r_sig = source + self._source = source + + async def async_get(self) -> np.ndarray: + return self._source.get() + + def get(self) -> np.ndarray: + return self._source.get() + + +class IndexedVectorSignal(FloatSignalContainer): + """FloatSignalContainer fake resolving one indexed value from a shared vector.""" + + def __init__(self, source: VectorDevice, index: int) -> None: + super().__init__(EpicsConfigR(read_pvname=source.name(), index=index, unit=source.unit()), is_array=True) + self._readable = True + self._writable = False + self.RB = _VectorReadSide(source) + self.SP = None + + def name(self) -> str: + return f"{self._cfg.read_pvname}[{self._cfg.index}]" + + +class StaticCatalog(Catalog): + def __init__(self, devices: dict[str, DeviceAccess]) -> None: + super().__init__(CatalogConfigModel(name="test-catalog")) + self._devices = devices + + def resolve(self, key: str) -> DeviceAccess: + return self._devices[key] + + class IdentityAttachControlSystem(OphydAsyncControlSystem): """Control system fake that keeps pre-built DeviceAccess objects attached.""" @@ -63,35 +101,50 @@ def attach_array(self, devs: list[DeviceAccess]) -> list[DeviceAccess]: def _attached_indexed_bpm( control_system: IdentityAttachControlSystem, - orbit_device: VectorDevice, bpm_index: int, ) -> BPM: model = BPMSimpleModel( BPMSimpleModelConfig( - x_pos=orbit_device, - y_pos=orbit_device, - x_pos_index=2 * bpm_index, - y_pos_index=(2 * bpm_index) + 1, + x_pos=f"BPM{bpm_index}:X", + y_pos=f"BPM{bpm_index}:Y", ), ) + x_pos, y_pos = control_system.get_devices(model.get_pos_devices()) bpm = BPM(BPMConfig(name=f"BPM{bpm_index}", model=model)) return bpm.attach( control_system, - RBpmArray(model, orbit_device, orbit_device), + RBpmArray(x_pos, y_pos), offset=None, tilt=None, ) +def _control_system_with_indexed_orbit(orbit_device: VectorDevice, bpm_count: int) -> IdentityAttachControlSystem: + control_system = IdentityAttachControlSystem(ConfigModel(name="live")) + control_system.set_catalog( + StaticCatalog( + { + f"BPM{bpm_index}:X": IndexedVectorSignal(orbit_device, 2 * bpm_index) + for bpm_index in range(bpm_count) + } + | { + f"BPM{bpm_index}:Y": IndexedVectorSignal(orbit_device, (2 * bpm_index) + 1) + for bpm_index in range(bpm_count) + }, + ), + ) + return control_system + + def test_indexed_bpm_orbit_aggregator_keeps_distinct_positions() -> None: """Regression test for BPM orbit reads backed by one indexed vector PV.""" - control_system = IdentityAttachControlSystem(ConfigModel(name="live")) orbit_device = VectorDevice("BPM:ORBIT", [3285.83564, 0.0, 2195.12518, 0.0]) + control_system = _control_system_with_indexed_orbit(orbit_device, bpm_count=2) bpms = [ - _attached_indexed_bpm(control_system, orbit_device, bpm_index=0), - _attached_indexed_bpm(control_system, orbit_device, bpm_index=1), + _attached_indexed_bpm(control_system, bpm_index=0), + _attached_indexed_bpm(control_system, bpm_index=1), ] positions = BPMArray("BPM", bpms, use_aggregator=True).positions @@ -110,15 +163,15 @@ def test_indexed_bpm_orbit_aggregator_keeps_distinct_positions() -> None: def test_indexed_bpm_orbit_aggregator_reads_all_bpms_at_once() -> None: """A shared vector orbit read should feed every BPM in the array.""" - control_system = IdentityAttachControlSystem(ConfigModel(name="live")) orbit_device = VectorDevice( "BPM:ORBIT", [3285.83564, 0.0, 2195.12518, 0.0, 11202.0909, 0.0], ) + control_system = _control_system_with_indexed_orbit(orbit_device, bpm_count=3) bpms = [ - _attached_indexed_bpm(control_system, orbit_device, bpm_index=0), - _attached_indexed_bpm(control_system, orbit_device, bpm_index=1), - _attached_indexed_bpm(control_system, orbit_device, bpm_index=2), + _attached_indexed_bpm(control_system, bpm_index=0), + _attached_indexed_bpm(control_system, bpm_index=1), + _attached_indexed_bpm(control_system, bpm_index=2), ] positions = BPMArray("BPM", bpms, use_aggregator=True).positions.get() @@ -139,15 +192,15 @@ def test_indexed_bpm_orbit_aggregator_reads_all_bpms_at_once() -> None: def test_indexed_bpm_orbit_axis_aggregators_keep_distinct_positions() -> None: """The horizontal and vertical accessors must keep the same index mapping.""" - control_system = IdentityAttachControlSystem(ConfigModel(name="live")) orbit_device = VectorDevice( "BPM:ORBIT", [3285.83564, 0.0, 2195.12518, 0.0, 11202.0909, 0.0], ) + control_system = _control_system_with_indexed_orbit(orbit_device, bpm_count=3) bpms = [ - _attached_indexed_bpm(control_system, orbit_device, bpm_index=0), - _attached_indexed_bpm(control_system, orbit_device, bpm_index=1), - _attached_indexed_bpm(control_system, orbit_device, bpm_index=2), + _attached_indexed_bpm(control_system, bpm_index=0), + _attached_indexed_bpm(control_system, bpm_index=1), + _attached_indexed_bpm(control_system, bpm_index=2), ] bpm_array = BPMArray("BPM", bpms, use_aggregator=True) diff --git a/tests/test_controlsystem.py b/tests/test_controlsystem.py index 17ea87f..71f7805 100644 --- a/tests/test_controlsystem.py +++ b/tests/test_controlsystem.py @@ -99,8 +99,11 @@ def test_attach_preserves_none_entries() -> None: def test_attach_rejects_unsupported_config_type() -> None: + class UnsupportedConfig: + index = None + class UnsupportedDevice: - _cfg = object() + _cfg = UnsupportedConfig() control_system = OphydAsyncControlSystem(ConfigModel(name="live")) diff --git a/tests/test_scalar_aggregator.py b/tests/test_scalar_aggregator.py index 098fdb5..ce034a5 100644 --- a/tests/test_scalar_aggregator.py +++ b/tests/test_scalar_aggregator.py @@ -16,16 +16,18 @@ class FakeScalarSignal(FloatSignalContainer): def __init__(self, value: float, writable: bool = True) -> None: super().__init__(EpicsConfigR(read_pvname="PV:RB"), is_array=False) self._writable = writable - self.SP = _FakeSide(value) - self.RB = _FakeSide(value) + self.SP = _FakeSide(value, signal_key=object()) + self.RB = _FakeSide(value, signal_key=object()) def get_range(self) -> list[float | None]: return [None, None] class _FakeSide: - def __init__(self, value: float) -> None: + def __init__(self, value: float, signal_key: object) -> None: self.value = value + self._r_sig = signal_key + self._w_sig = signal_key self.completed_values: list[float] = [] async def async_get(self) -> float: @@ -69,7 +71,7 @@ def check_device_availability(self) -> bool: def test_add_devices_rejects_single_wrong_device() -> None: aggregator = OAScalarAggregator() - with pytest.raises(PyAMLException, match="Device must be an instance"): + with pytest.raises(PyAMLException, match="All devices must be instances"): aggregator.add_devices(WrongDevice()) diff --git a/tests/test_signal.py b/tests/test_signal.py index a20fe45..afac9fe 100644 --- a/tests/test_signal.py +++ b/tests/test_signal.py @@ -70,11 +70,10 @@ def test_measure_name_uses_tango_attribute() -> None: assert signal.measure_name() == "sys/tg_test/1/value" -def test_measure_name_for_write_only_epics_config_is_currently_unsupported() -> None: +def test_measure_name_uses_epics_write_pv_for_write_only_signal() -> None: signal = EpicsW(EpicsWConfig(write_pvname="PV:SP")) - with pytest.raises(ValueError, match="Unsupported control system config type"): - signal.measure_name() + assert signal.measure_name() == "PV:SP" def test_get_range_returns_configured_range_for_writable_signal() -> None: @@ -84,14 +83,14 @@ def test_get_range_returns_configured_range_for_writable_signal() -> None: assert signal.get_range() == [0.0, 10.0] -def test_get_range_returns_empty_range_when_not_writable() -> None: +def test_get_range_returns_configured_range_from_write_config() -> None: signal = EpicsW(EpicsWConfig(write_pvname="PV:SP", range=[0.0, 10.0])) signal._writable = False - assert signal.get_range() == [None, None] + assert signal.get_range() == [0.0, 10.0] -def test_base_epics_write_config_has_no_unit_field() -> None: +def test_base_epics_write_config_has_default_unit_field() -> None: cfg = EpicsConfigW(write_pvname="PV:SP") - assert not hasattr(cfg, "unit") + assert cfg.unit == "" diff --git a/tests/test_tune.py b/tests/test_tune.py index 3bfc737..b502536 100644 --- a/tests/test_tune.py +++ b/tests/test_tune.py @@ -15,7 +15,7 @@ def test_esrf_tune_correction_live(monkeypatch) -> None: accelerator = Accelerator.load("EBSTune-ophyd.yaml", use_fast_loader=False) tune = accelerator.live.get_tune_tuning("TUNE") - tune.response.load_json("tunemat.json") + assert tune.response_matrix is not None assert_tune_pair(tune.readback()) tune.set([0.17, 0.32]) diff --git a/tests/test_tune_bessy.py b/tests/test_tune_bessy.py index e971f30..29a0f36 100644 --- a/tests/test_tune_bessy.py +++ b/tests/test_tune_bessy.py @@ -15,7 +15,7 @@ def test_bessy_tune_correction_live(monkeypatch) -> None: accelerator = Accelerator.load("bessy2tune-KL.yaml") tune = accelerator.live.tune - tune.response.load_json("tunemat-bessy.json") + assert tune.response_matrix is not None assert_tune_pair(tune.readback()) tune.set([0.83, 0.84], iter=2, wait_time=3) diff --git a/tests/tunemat-bessy.json b/tests/tunemat-bessy.json index afd9395..ff5e595 100644 --- a/tests/tunemat-bessy.json +++ b/tests/tunemat-bessy.json @@ -1 +1 @@ -{"date": "01/09/2026, 11:02:30", "input_names": ["Q3MD1R", "Q3MD2R", "Q3MD3R", "Q3MD4R", "Q3MD5R", "Q3MD6R", "Q3MD7R", "Q3MD8R", "Q3M1T1R", "Q3M2T1R", "Q3MT2R", "Q3MT3R", "Q3MT4R", "Q3MT5R", "Q3M1T6R", "Q3M2T6R", "Q3MT7R", "Q3M1T8R", "Q3M2T8R", "Q4MD1R", "Q4MD2R", "Q4MD3R", "Q4MD4R", "Q4MD5R", "Q4MD6R", "Q4MD7R", "Q4MD8R", "Q4M1T1R", "Q4M2T1R", "Q4MT2R", "Q4MT3R", "Q4MT4R", "Q4MT5R", "Q4M1T6R", "Q4M2T6R", "Q4MT7R", "Q4M1T8R", "Q4M2T8R"], "input_delta": 0.0001, "matrix": [[0.3023091666509714, -1.5544279246293424], [0.20417894713631313, -1.6300333354346552], [0.17404006414589723, -1.6322502377608128], [0.18305450944167134, -1.6339385836305897], [0.18525465940610886, -1.6161780654255775], [0.18605718523856396, -1.5480706816928258], [0.18004301319041183, -1.6152359468579736], [0.20031336819714696, -1.6336557856577727], [0.3142105457354383, -1.6259541802055022], [0.3967579426145118, -1.4884545911331148], [0.39161789240571565, -1.4675302336064622], [0.41323573325757756, -1.5128697822563986], [0.4007059832422666, -1.358486143266946], [0.38324284090451854, -1.4637059653954676], [0.41306127061657705, -1.5740003281383697], [0.3106758533699683, -1.372642657008205], [0.47171256985167886, -1.469289642211935], [0.3893202924531991, -1.396931068783358], [0.2890437971458937, -1.5719886998943888], [1.2539046265780396, -0.6081925650247566], [0.9044748859843299, -0.6077368392909399], [0.7334642133294267, -0.6084486491897412], [0.8073020662546782, -0.6114799366740975], [0.7961516897336818, -0.6127763521546203], [0.8065421164082931, -0.5769727090354504], [0.7484346629471617, -0.6120240386320308], [0.9090535734701533, -0.6099080471166918], [1.288237038995499, -0.4981266976600285], [1.5351628664750372, -0.46178696407994657], [1.4836858108646656, -0.46277288187956955], [1.5420211849537235, -0.4784139517699515], [1.5109138425639657, -0.43254315633012297], [1.4500743004752792, -0.4622388604058614], [1.6649770622045867, -0.40790874170970604], [1.1424428165862643, -0.4684107307484364], [1.7630870572515889, -0.4638295358450062], [1.5243777939188963, -0.4336360393641936], [1.1936630000186632, -0.483663966071024]]} \ No newline at end of file +{"type":"pyaml.tuning_tools.response_matrix_data","matrix":[[0.3023091666509714,0.20417894713631313,0.17404006414589723,0.18305450944167134,0.18525465940610886,0.18605718523856396,0.18004301319041183,0.20031336819714696,0.3142105457354383,0.3967579426145118,0.39161789240571565,0.41323573325757756,0.4007059832422666,0.38324284090451854,0.41306127061657705,0.3106758533699683,0.47171256985167886,0.3893202924531991,0.2890437971458937,1.2539046265780396,0.9044748859843299,0.7334642133294267,0.8073020662546782,0.7961516897336818,0.8065421164082931,0.7484346629471617,0.9090535734701533,1.288237038995499,1.5351628664750372,1.4836858108646656,1.5420211849537235,1.5109138425639657,1.4500743004752792,1.6649770622045867,1.1424428165862643,1.7630870572515889,1.5243777939188963,1.1936630000186632],[-1.5544279246293424,-1.6300333354346552,-1.6322502377608128,-1.6339385836305897,-1.6161780654255775,-1.5480706816928258,-1.6152359468579736,-1.6336557856577727,-1.6259541802055022,-1.4884545911331148,-1.4675302336064622,-1.5128697822563986,-1.358486143266946,-1.4637059653954676,-1.5740003281383697,-1.372642657008205,-1.469289642211935,-1.396931068783358,-1.5719886998943888,-0.6081925650247566,-0.6077368392909399,-0.6084486491897412,-0.6114799366740975,-0.6127763521546203,-0.5769727090354504,-0.6120240386320308,-0.6099080471166918,-0.4981266976600285,-0.46178696407994657,-0.46277288187956955,-0.4784139517699515,-0.43254315633012297,-0.4622388604058614,-0.40790874170970604,-0.4684107307484364,-0.4638295358450062,-0.4336360393641936,-0.483663966071024]],"variable_names":["Q3MD1R","Q3MD2R","Q3MD3R","Q3MD4R","Q3MD5R","Q3MD6R","Q3MD7R","Q3MD8R","Q3M1T1R","Q3M2T1R","Q3MT2R","Q3MT3R","Q3MT4R","Q3MT5R","Q3M1T6R","Q3M2T6R","Q3MT7R","Q3M1T8R","Q3M2T8R","Q4MD1R","Q4MD2R","Q4MD3R","Q4MD4R","Q4MD5R","Q4MD6R","Q4MD7R","Q4MD8R","Q4M1T1R","Q4M2T1R","Q4MT2R","Q4MT3R","Q4MT4R","Q4MT5R","Q4M1T6R","Q4M2T6R","Q4MT7R","Q4M1T8R","Q4M2T8R"],"observable_names":["Qh","Qv"]} diff --git a/tests/tunemat.json b/tests/tunemat.json index 6063ab8..d34e6e6 100644 --- a/tests/tunemat.json +++ b/tests/tunemat.json @@ -1 +1 @@ -{"date": "12/15/2025, 10:29:29", "input_names": ["QD2E-C04", "QD2A-C05", "QD2E-C05", "QD2A-C06", "QD2E-C06", "QD2A-C07", "QD2E-C07", "QD2A-C08", "QD2E-C08", "QD2A-C09", "QD2E-C09", "QD2A-C10", "QD2E-C10", "QD2A-C11", "QD2E-C11", "QD2A-C12", "QD2E-C12", "QD2A-C13", "QD2E-C13", "QD2A-C14", "QD2E-C14", "QD2A-C15", "QD2E-C15", "QD2A-C16", "QD2E-C16", "QD2A-C17", "QD2E-C17", "QD2A-C18", "QD2E-C18", "QD2A-C19", "QD2E-C19", "QD2A-C20", "QD2E-C20", "QD2A-C21", "QD2E-C21", "QD2A-C22", "QD2E-C22", "QD2A-C23", "QD2E-C23", "QD2A-C24", "QD2E-C24", "QD2A-C25", "QD2E-C25", "QD2A-C26", "QD2E-C26", "QD2A-C27", "QD2E-C27", "QD2A-C28", "QD2E-C28", "QD2A-C29", "QD2E-C29", "QD2A-C30", "QD2E-C30", "QD2A-C31", "QD2E-C31", "QD2A-C32", "QD2E-C32", "QD2A-C01", "QD2E-C01", "QD2A-C02", "QD2E-C02", "QD2A-C03", "QF1E-C04", "QF1A-C05", "QF1E-C05", "QF1A-C06", "QF1E-C06", "QF1A-C07", "QF1E-C07", "QF1A-C08", "QF1E-C08", "QF1A-C09", "QF1E-C09", "QF1A-C10", "QF1E-C10", "QF1A-C11", "QF1E-C11", "QF1A-C12", "QF1E-C12", "QF1A-C13", "QF1E-C13", "QF1A-C14", "QF1E-C14", "QF1A-C15", "QF1E-C15", "QF1A-C16", "QF1E-C16", "QF1A-C17", "QF1E-C17", "QF1A-C18", "QF1E-C18", "QF1A-C19", "QF1E-C19", "QF1A-C20", "QF1E-C20", "QF1A-C21", "QF1E-C21", "QF1A-C22", "QF1E-C22", "QF1A-C23", "QF1E-C23", "QF1A-C24", "QF1E-C24", "QF1A-C25", "QF1E-C25", "QF1A-C26", "QF1E-C26", "QF1A-C27", "QF1E-C27", "QF1A-C28", "QF1E-C28", "QF1A-C29", "QF1E-C29", "QF1A-C30", "QF1E-C30", "QF1A-C31", "QF1E-C31", "QF1A-C32", "QF1E-C32", "QF1A-C01", "QF1E-C01", "QF1A-C02", "QF1E-C02", "QF1A-C03"], "input_delta": 0.0001, "matrix": [[0.1785172563129045, -1.259935893661579], [0.1788951355818913, -1.2596911516576936], [0.17916570352105587, -1.2591792721383666], [0.1784165995072362, -1.260207528265278], [0.17930640736879555, -1.25918912484424], [0.17883467886198323, -1.2609815144659642], [0.17867571714846875, -1.2608076571413163], [0.17948802757350446, -1.2585830887507088], [0.17835015932166076, -1.2593882005462742], [0.17901469455600116, -1.2604281613237678], [0.17905819753943897, -1.2596410616144693], [0.17843940545964054, -1.2605316302688463], [0.1793547171993759, -1.2608336924180286], [0.1787176380563249, -1.2591065096845266], [0.1787892958660109, -1.259732105216016], [0.1793280525644314, -1.259726593696997], [0.17842177655602587, -1.2591039586534736], [0.17912653431623182, -1.260833965485153], [0.17894093487041962, -1.2605274260996113], [0.17849079059689688, -1.2596464333869406], [0.15842543166583178, -1.2865379737281302], [0.15752022716725156, -1.2851530663676725], [0.15236942660173947, -1.2948529490430793], [0.15249663112087974, -1.2952536190458108], [0.17829009666014972, -1.259611090613788], [0.17954227271105294, -1.2605667733317505], [0.1784443553096149, -1.260476981352343], [0.17879063086562175, -1.259568327184879], [0.17935206778257884, -1.2601461507050216], [0.17823547327905365, -1.259454422283257], [0.17940395638155193, -1.259248121097123], [0.17903962069909518, -1.2602329232241916], [0.17839713189027329, -1.2603260340093847], [0.17955866263269504, -1.2601305836107413], [0.17833713144010943, -1.2607246407114747], [0.17895708864207327, -1.2590310893706436], [0.1792181388984848, -1.2593466261462405], [0.17824000768135173, -1.2600906155324498], [0.17949408639000852, -1.2591467841888138], [0.1787516712975501, -1.2604575256841555], [0.17864797283023703, -1.2597191598406887], [0.17953266724296535, -1.2604534508509069], [0.1782651300291649, -1.2605652651498378], [0.17924172278971362, -1.2586035006628693], [0.17895742552703764, -1.2589766869386398], [0.17828642593392674, -1.2609507118288565], [0.17954664767227957, -1.2600971926712834], [0.17871655922707674, -1.2599649824585057], [0.1787028381464162, -1.2613068193001453], [0.1794673524299628, -1.2587702670796563], [0.15576357470681312, -1.2881347494075879], [0.1566161640403907, -1.2884846209193501], [0.17891891407606497, -1.259680554023812], [0.1784608675728383, -1.2605416900990374], [0.17935783568817643, -1.2602982692699882], [0.17880356294491806, -1.2589847764193918], [0.17872615331593344, -1.259371580116797], [0.17930224585405163, -1.2606597161363142], [0.1784232255996887, -1.259932445998313], [0.17928135525036026, -1.2595010465771272], [0.17879203197818105, -1.259897920823927], [0.1785208164378771, -1.2602767775338197], [0.5906751093856522, -0.4942070018121303], [0.5916587335785817, -0.49410910022973376], [0.5925973779200011, -0.4939368262257826], [0.5906472892805437, -0.4943481474750655], [0.5937333590635974, -0.49383517392309617], [0.5925053252411883, -0.4945521626220639], [0.5919541360135949, -0.4945502224801279], [0.593767804694112, -0.4937560841677957], [0.5905790890495255, -0.4939110948148917], [0.5920498276693054, -0.4944112921978494], [0.5922006968819837, -0.4940156156840736], [0.5905898873703741, -0.4943718595340485], [0.5937644367809414, -0.4945240095882708], [0.5921060603517048, -0.49383310426309723], [0.5923546029865112, -0.49414821928162045], [0.5937570272146475, -0.49414601464958796], [0.5906136856378974, -0.4938323907283104], [0.5924483250616119, -0.494524425712628], [0.5918047603631349, -0.4943700206033741], [0.5906329069915728, -0.4940176073714442], [0.5240000382053522, -0.4957116256587568], [0.5215167426800282, -0.4951674122433136], [0.5023775922438634, -0.4957548667317013], [0.5025550879991791, -0.4959701078055545], [0.59101385633753, -0.49396126475420576], [0.5940476837851261, -0.4944123480898899], [0.590239711236451, -0.4943671194868049], [0.5911411662856692, -0.4940036410083781], [0.5930883737059744, -0.49429469313189145], [0.59018166351954, -0.49401798915271744], [0.5942343376275083, -0.49391407415499966], [0.5929855222372438, -0.4944039156257185], [0.5915143720780502, -0.49419998008726207], [0.5942788495033602, -0.49420600395255754], [0.5900421880158935, -0.49450523314453765], [0.5916560559793282, -0.49382778113893355], [0.5925614614962771, -0.4939867186087854], [0.5900151983811996, -0.494284331920003], [0.594364898435229, -0.4938944573323889], [0.5924322521752567, -0.49441877814693225], [0.5920725913463598, -0.4940468602548931], [0.5943756188470095, -0.4943405902030351], [0.5899792904123791, -0.49448320648126654], [0.592221265187376, -0.49379421225459463], [0.5919937128387454, -0.4937315359343497], [0.5899867106326506, -0.49460545720469984], [0.5943575627234687, -0.4941755360221567], [0.591896790072266, -0.49421854586839675], [0.5926070153364216, -0.49464337511906376], [0.5943370472136222, -0.49371312318746696], [0.514561793338586, -0.4954433231402211], [0.5167057312074896, -0.4956495136537731], [0.5920447850765731, -0.4939802040410113], [0.5906160612750844, -0.4943979997751313], [0.5937271440711878, -0.4943609197566312], [0.5919845580293348, -0.49393123123187], [0.5924738075108182, -0.4938753901212456], [0.593713217244829, -0.4944749940277715], [0.5906644536340933, -0.494194226277056], [0.5925979618814914, -0.4941174560146866], [0.5916591804636107, -0.4940665589359128], [0.5906930777091479, -0.4943024484699299]]} \ No newline at end of file +{"type":"pyaml.tuning_tools.response_matrix_data","matrix":[[0.1785172563129045,0.1788951355818913,0.17916570352105587,0.1784165995072362,0.17930640736879555,0.17883467886198323,0.17867571714846875,0.17948802757350446,0.17835015932166076,0.17901469455600116,0.17905819753943897,0.17843940545964054,0.1793547171993759,0.1787176380563249,0.1787892958660109,0.1793280525644314,0.17842177655602587,0.17912653431623182,0.17894093487041962,0.17849079059689688,0.15842543166583178,0.15752022716725156,0.15236942660173947,0.15249663112087974,0.17829009666014972,0.17954227271105294,0.1784443553096149,0.17879063086562175,0.17935206778257884,0.17823547327905365,0.17940395638155193,0.17903962069909518,0.17839713189027329,0.17955866263269504,0.17833713144010943,0.17895708864207327,0.1792181388984848,0.17824000768135173,0.17949408639000852,0.1787516712975501,0.17864797283023703,0.17953266724296535,0.1782651300291649,0.17924172278971362,0.17895742552703764,0.17828642593392674,0.17954664767227957,0.17871655922707674,0.1787028381464162,0.1794673524299628,0.15576357470681312,0.1566161640403907,0.17891891407606497,0.1784608675728383,0.17935783568817643,0.17880356294491806,0.17872615331593344,0.17930224585405163,0.1784232255996887,0.17928135525036026,0.17879203197818105,0.1785208164378771,0.5906751093856522,0.5916587335785817,0.5925973779200011,0.5906472892805437,0.5937333590635974,0.5925053252411883,0.5919541360135949,0.593767804694112,0.5905790890495255,0.5920498276693054,0.5922006968819837,0.5905898873703741,0.5937644367809414,0.5921060603517048,0.5923546029865112,0.5937570272146475,0.5906136856378974,0.5924483250616119,0.5918047603631349,0.5906329069915728,0.5240000382053522,0.5215167426800282,0.5023775922438634,0.5025550879991791,0.59101385633753,0.5940476837851261,0.590239711236451,0.5911411662856692,0.5930883737059744,0.59018166351954,0.5942343376275083,0.5929855222372438,0.5915143720780502,0.5942788495033602,0.5900421880158935,0.5916560559793282,0.5925614614962771,0.5900151983811996,0.594364898435229,0.5924322521752567,0.5920725913463598,0.5943756188470095,0.5899792904123791,0.592221265187376,0.5919937128387454,0.5899867106326506,0.5943575627234687,0.591896790072266,0.5926070153364216,0.5943370472136222,0.514561793338586,0.5167057312074896,0.5920447850765731,0.5906160612750844,0.5937271440711878,0.5919845580293348,0.5924738075108182,0.593713217244829,0.5906644536340933,0.5925979618814914,0.5916591804636107,0.5906930777091479],[-1.259935893661579,-1.2596911516576936,-1.2591792721383666,-1.260207528265278,-1.25918912484424,-1.2609815144659642,-1.2608076571413163,-1.2585830887507088,-1.2593882005462742,-1.2604281613237678,-1.2596410616144693,-1.2605316302688463,-1.2608336924180286,-1.2591065096845266,-1.259732105216016,-1.259726593696997,-1.2591039586534736,-1.260833965485153,-1.2605274260996113,-1.2596464333869406,-1.2865379737281302,-1.2851530663676725,-1.2948529490430793,-1.2952536190458108,-1.259611090613788,-1.2605667733317505,-1.260476981352343,-1.259568327184879,-1.2601461507050216,-1.259454422283257,-1.259248121097123,-1.2602329232241916,-1.2603260340093847,-1.2601305836107413,-1.2607246407114747,-1.2590310893706436,-1.2593466261462405,-1.2600906155324498,-1.2591467841888138,-1.2604575256841555,-1.2597191598406887,-1.2604534508509069,-1.2605652651498378,-1.2586035006628693,-1.2589766869386398,-1.2609507118288565,-1.2600971926712834,-1.2599649824585057,-1.2613068193001453,-1.2587702670796563,-1.2881347494075879,-1.2884846209193501,-1.259680554023812,-1.2605416900990374,-1.2602982692699882,-1.2589847764193918,-1.259371580116797,-1.2606597161363142,-1.259932445998313,-1.2595010465771272,-1.259897920823927,-1.2602767775338197,-0.4942070018121303,-0.49410910022973376,-0.4939368262257826,-0.4943481474750655,-0.49383517392309617,-0.4945521626220639,-0.4945502224801279,-0.4937560841677957,-0.4939110948148917,-0.4944112921978494,-0.4940156156840736,-0.4943718595340485,-0.4945240095882708,-0.49383310426309723,-0.49414821928162045,-0.49414601464958796,-0.4938323907283104,-0.494524425712628,-0.4943700206033741,-0.4940176073714442,-0.4957116256587568,-0.4951674122433136,-0.4957548667317013,-0.4959701078055545,-0.49396126475420576,-0.4944123480898899,-0.4943671194868049,-0.4940036410083781,-0.49429469313189145,-0.49401798915271744,-0.49391407415499966,-0.4944039156257185,-0.49419998008726207,-0.49420600395255754,-0.49450523314453765,-0.49382778113893355,-0.4939867186087854,-0.494284331920003,-0.4938944573323889,-0.49441877814693225,-0.4940468602548931,-0.4943405902030351,-0.49448320648126654,-0.49379421225459463,-0.4937315359343497,-0.49460545720469984,-0.4941755360221567,-0.49421854586839675,-0.49464337511906376,-0.49371312318746696,-0.4954433231402211,-0.4956495136537731,-0.4939802040410113,-0.4943979997751313,-0.4943609197566312,-0.49393123123187,-0.4938753901212456,-0.4944749940277715,-0.494194226277056,-0.4941174560146866,-0.4940665589359128,-0.4943024484699299]],"variable_names":["QD2E-C04","QD2A-C05","QD2E-C05","QD2A-C06","QD2E-C06","QD2A-C07","QD2E-C07","QD2A-C08","QD2E-C08","QD2A-C09","QD2E-C09","QD2A-C10","QD2E-C10","QD2A-C11","QD2E-C11","QD2A-C12","QD2E-C12","QD2A-C13","QD2E-C13","QD2A-C14","QD2E-C14","QD2A-C15","QD2E-C15","QD2A-C16","QD2E-C16","QD2A-C17","QD2E-C17","QD2A-C18","QD2E-C18","QD2A-C19","QD2E-C19","QD2A-C20","QD2E-C20","QD2A-C21","QD2E-C21","QD2A-C22","QD2E-C22","QD2A-C23","QD2E-C23","QD2A-C24","QD2E-C24","QD2A-C25","QD2E-C25","QD2A-C26","QD2E-C26","QD2A-C27","QD2E-C27","QD2A-C28","QD2E-C28","QD2A-C29","QD2E-C29","QD2A-C30","QD2E-C30","QD2A-C31","QD2E-C31","QD2A-C32","QD2E-C32","QD2A-C01","QD2E-C01","QD2A-C02","QD2E-C02","QD2A-C03","QF1E-C04","QF1A-C05","QF1E-C05","QF1A-C06","QF1E-C06","QF1A-C07","QF1E-C07","QF1A-C08","QF1E-C08","QF1A-C09","QF1E-C09","QF1A-C10","QF1E-C10","QF1A-C11","QF1E-C11","QF1A-C12","QF1E-C12","QF1A-C13","QF1E-C13","QF1A-C14","QF1E-C14","QF1A-C15","QF1E-C15","QF1A-C16","QF1E-C16","QF1A-C17","QF1E-C17","QF1A-C18","QF1E-C18","QF1A-C19","QF1E-C19","QF1A-C20","QF1E-C20","QF1A-C21","QF1E-C21","QF1A-C22","QF1E-C22","QF1A-C23","QF1E-C23","QF1A-C24","QF1E-C24","QF1A-C25","QF1E-C25","QF1A-C26","QF1E-C26","QF1A-C27","QF1E-C27","QF1A-C28","QF1E-C28","QF1A-C29","QF1E-C29","QF1A-C30","QF1E-C30","QF1A-C31","QF1E-C31","QF1A-C32","QF1E-C32","QF1A-C01","QF1E-C01","QF1A-C02","QF1E-C02","QF1A-C03"],"observable_names":["Qh","Qv"]} From 87497dca611e039a6e35f0c848561cec96af912e Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Thu, 30 Apr 2026 11:18:33 +0200 Subject: [PATCH 23/32] Fix EPICS catalog PV parsing and cover scalar/indexed keys --- pyaml_cs_oa/epics_catalog.py | 15 ++++++------ tests/test_epics_catalog.py | 45 ++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 tests/test_epics_catalog.py diff --git a/pyaml_cs_oa/epics_catalog.py b/pyaml_cs_oa/epics_catalog.py index a875bf2..04d6cd8 100644 --- a/pyaml_cs_oa/epics_catalog.py +++ b/pyaml_cs_oa/epics_catalog.py @@ -49,20 +49,19 @@ def resolve(self, key: str) -> DeviceAccess: def _parse_pv(token: str) -> tuple[list[str], int | None]: token = token.strip() - # Extract index (if any) + # No suffix means scalar access to the full PV value. index = None if "@" in token: token, idx_str = token.rsplit("@", 1) - try: - index = int(idx_str.strip()) - except ValueError: - raise PyAMLException(f"EpicsCatalog: invalid index in PV token '{token}'") from None + try: + index = int(idx_str.strip()) + except ValueError: + raise PyAMLException(f"EpicsCatalog: invalid index in PV token '{token}'") from None - # Extract pv name(s) + # Parenthesized keys describe one read PV and one write PV. if token.startswith("(") and token.endswith(")"): - # We have a list names = token[1:-1] - name_list = [names.strip() for n in names.split(",")] + name_list = [name.strip() for name in names.split(",")] else: name_list = [token.strip()] diff --git a/tests/test_epics_catalog.py b/tests/test_epics_catalog.py new file mode 100644 index 0000000..c170009 --- /dev/null +++ b/tests/test_epics_catalog.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import pytest + +from pyaml_cs_oa.epics_catalog import ConfigModel, EpicsCatalog +from pyaml_cs_oa.epicsR import EpicsR +from pyaml_cs_oa.epicsRW import EpicsRW + + +def test_dynamic_epics_catalog_resolves_scalar_read_key_without_index() -> None: + catalog = EpicsCatalog(ConfigModel(name="epics")) + + device = catalog.resolve("PV:RB") + + assert isinstance(device, EpicsR) + assert device._cfg.read_pvname == "PV:RB" + assert device._cfg.index is None + + +def test_dynamic_epics_catalog_resolves_indexed_read_key() -> None: + catalog = EpicsCatalog(ConfigModel(name="epics")) + + device = catalog.resolve("PV:ARRAY@3") + + assert isinstance(device, EpicsR) + assert device._cfg.read_pvname == "PV:ARRAY" + assert device._cfg.index == 3 + + +def test_dynamic_epics_catalog_resolves_read_write_key() -> None: + catalog = EpicsCatalog(ConfigModel(name="epics")) + + device = catalog.resolve("(PV:RB, PV:SP)") + + assert isinstance(device, EpicsRW) + assert device._cfg.read_pvname == "PV:RB" + assert device._cfg.write_pvname == "PV:SP" + assert device._cfg.index is None + + +def test_dynamic_epics_catalog_rejects_invalid_index() -> None: + catalog = EpicsCatalog(ConfigModel(name="epics")) + + with pytest.raises(Exception, match="invalid index"): + catalog.resolve("PV:ARRAY@not-an-index") From 3f174186ab1c134289d08e0669b83bb700156c4a Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Thu, 30 Apr 2026 11:33:53 +0200 Subject: [PATCH 24/32] Fix EPICS catalog parsing for scalar and whitespace-indexed keys Add EPICS catalog tests for dynamic and static resolution Removing useless imports --- pyaml_cs_oa/epics_catalog.py | 1 + tests/fakes.py | 2 - tests/live_tune_helpers.py | 2 - tests/test_bpm_orbit.py | 2 - tests/test_container.py | 2 - tests/test_controlsystem.py | 2 - tests/test_epics_catalog.py | 134 +++++++++++++++++++++++++++++--- tests/test_factories.py | 2 - tests/test_scalar_aggregator.py | 2 - tests/test_signal.py | 2 - tests/test_tune.py | 2 - tests/test_tune_bessy.py | 2 - 12 files changed, 126 insertions(+), 29 deletions(-) diff --git a/pyaml_cs_oa/epics_catalog.py b/pyaml_cs_oa/epics_catalog.py index 04d6cd8..5112b09 100644 --- a/pyaml_cs_oa/epics_catalog.py +++ b/pyaml_cs_oa/epics_catalog.py @@ -53,6 +53,7 @@ def _parse_pv(token: str) -> tuple[list[str], int | None]: index = None if "@" in token: token, idx_str = token.rsplit("@", 1) + token = token.strip() try: index = int(idx_str.strip()) except ValueError: diff --git a/tests/fakes.py b/tests/fakes.py index 856afaf..f0c51f5 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from collections import deque from types import SimpleNamespace from typing import Any diff --git a/tests/live_tune_helpers.py b/tests/live_tune_helpers.py index e12c27b..48f0ec0 100644 --- a/tests/live_tune_helpers.py +++ b/tests/live_tune_helpers.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import os from pathlib import Path diff --git a/tests/test_bpm_orbit.py b/tests/test_bpm_orbit.py index 5947783..757cd32 100644 --- a/tests/test_bpm_orbit.py +++ b/tests/test_bpm_orbit.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Any import numpy as np diff --git a/tests/test_container.py b/tests/test_container.py index 55ada1a..8c8f07e 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import pytest from pyaml_cs_oa import arun diff --git a/tests/test_controlsystem.py b/tests/test_controlsystem.py index 71f7805..c8d9073 100644 --- a/tests/test_controlsystem.py +++ b/tests/test_controlsystem.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import pytest from pyaml.common.exception import PyAMLException diff --git a/tests/test_epics_catalog.py b/tests/test_epics_catalog.py index c170009..eeef191 100644 --- a/tests/test_epics_catalog.py +++ b/tests/test_epics_catalog.py @@ -1,24 +1,41 @@ -from __future__ import annotations - import pytest - -from pyaml_cs_oa.epics_catalog import ConfigModel, EpicsCatalog +from pyaml.common.exception import PyAMLException + +from pyaml_cs_oa.epics_catalog import ConfigModel as DynamicCatalogConfig +from pyaml_cs_oa.epics_catalog import EpicsCatalog +from pyaml_cs_oa.epics_static_catalog import ConfigModel as StaticCatalogConfig +from pyaml_cs_oa.epics_static_catalog import EpicsStaticCatalog +from pyaml_cs_oa.epics_static_catalog_entry import ConfigModel as StaticCatalogEntryConfig +from pyaml_cs_oa.epics_static_catalog_entry import EpicsStaticCatalogEntry +from pyaml_cs_oa.epicsR import ConfigModel as EpicsRConfig from pyaml_cs_oa.epicsR import EpicsR +from pyaml_cs_oa.epicsRW import ConfigModel as EpicsRWConfig from pyaml_cs_oa.epicsRW import EpicsRW +from pyaml_cs_oa.epicsW import ConfigModel as EpicsWConfig +from pyaml_cs_oa.epicsW import EpicsW + + +def _entry(key: str, device) -> EpicsStaticCatalogEntry: + return EpicsStaticCatalogEntry(StaticCatalogEntryConfig(key=key, device=device)) + + +def _record_build(self) -> None: + self.build_calls = getattr(self, "build_calls", 0) + 1 def test_dynamic_epics_catalog_resolves_scalar_read_key_without_index() -> None: - catalog = EpicsCatalog(ConfigModel(name="epics")) + catalog = EpicsCatalog(DynamicCatalogConfig(name="epics", timeout_ms=1234)) device = catalog.resolve("PV:RB") assert isinstance(device, EpicsR) assert device._cfg.read_pvname == "PV:RB" + assert device._cfg.timeout_ms == 1234 assert device._cfg.index is None def test_dynamic_epics_catalog_resolves_indexed_read_key() -> None: - catalog = EpicsCatalog(ConfigModel(name="epics")) + catalog = EpicsCatalog(DynamicCatalogConfig(name="epics")) device = catalog.resolve("PV:ARRAY@3") @@ -28,7 +45,7 @@ def test_dynamic_epics_catalog_resolves_indexed_read_key() -> None: def test_dynamic_epics_catalog_resolves_read_write_key() -> None: - catalog = EpicsCatalog(ConfigModel(name="epics")) + catalog = EpicsCatalog(DynamicCatalogConfig(name="epics")) device = catalog.resolve("(PV:RB, PV:SP)") @@ -38,8 +55,107 @@ def test_dynamic_epics_catalog_resolves_read_write_key() -> None: assert device._cfg.index is None +def test_dynamic_epics_catalog_resolves_indexed_read_write_key() -> None: + catalog = EpicsCatalog(DynamicCatalogConfig(name="epics")) + + device = catalog.resolve("(PV:RB, PV:SP)@5") + + assert isinstance(device, EpicsRW) + assert device._cfg.read_pvname == "PV:RB" + assert device._cfg.write_pvname == "PV:SP" + assert device._cfg.index == 5 + + +def test_dynamic_epics_catalog_strips_whitespace_from_pv_names_and_index() -> None: + catalog = EpicsCatalog(DynamicCatalogConfig(name="epics")) + + device = catalog.resolve(" ( PV:RB , PV:SP ) @ 7 ") + + assert isinstance(device, EpicsRW) + assert device._cfg.read_pvname == "PV:RB" + assert device._cfg.write_pvname == "PV:SP" + assert device._cfg.index == 7 + + def test_dynamic_epics_catalog_rejects_invalid_index() -> None: - catalog = EpicsCatalog(ConfigModel(name="epics")) + catalog = EpicsCatalog(DynamicCatalogConfig(name="epics")) - with pytest.raises(Exception, match="invalid index"): + with pytest.raises(PyAMLException, match="invalid index"): catalog.resolve("PV:ARRAY@not-an-index") + + +def test_dynamic_epics_catalog_rejects_too_many_read_write_tokens() -> None: + catalog = EpicsCatalog(DynamicCatalogConfig(name="epics")) + + with pytest.raises(PyAMLException, match="too many comma-separated tokens"): + catalog.resolve("(PV:ONE, PV:TWO, PV:THREE)") + + +def test_static_epics_catalog_entry_exposes_key_and_device() -> None: + device = EpicsR(EpicsRConfig(read_pvname="PV:RB")) + entry = _entry("readback", device) + + assert entry.get_key() == "readback" + assert entry.get_device() is device + + +def test_static_epics_catalog_resolves_read_write_and_write_entries(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(EpicsR, "build", _record_build) + monkeypatch.setattr(EpicsRW, "build", _record_build) + monkeypatch.setattr(EpicsW, "build", _record_build) + + read = EpicsR(EpicsRConfig(read_pvname="PV:RB", index=2)) + read_write = EpicsRW(EpicsRWConfig(read_pvname="PV:RW:RB", write_pvname="PV:RW:SP")) + write = EpicsW(EpicsWConfig(write_pvname="PV:SP")) + catalog = EpicsStaticCatalog( + StaticCatalogConfig( + name="static-epics", + entries=[ + _entry("read", read), + _entry("read_write", read_write), + _entry("write", write), + ], + ), + ) + + assert catalog.resolve("read") is read + assert catalog.resolve("read_write") is read_write + assert catalog.resolve("write") is write + assert read.build_calls == 1 + assert read_write.build_calls == 1 + assert write.build_calls == 1 + + +def test_static_epics_catalog_rejects_empty_entries() -> None: + with pytest.raises(PyAMLException, match="must contain at least one entry"): + EpicsStaticCatalog(StaticCatalogConfig(name="static-epics", entries=[])) + + +def test_static_epics_catalog_rejects_duplicate_keys(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(EpicsR, "build", _record_build) + first = EpicsR(EpicsRConfig(read_pvname="PV:FIRST")) + second = EpicsR(EpicsRConfig(read_pvname="PV:SECOND")) + + with pytest.raises(PyAMLException, match="duplicate key 'read'"): + EpicsStaticCatalog( + StaticCatalogConfig( + name="static-epics", + entries=[ + _entry("read", first), + _entry("read", second), + ], + ), + ) + + +def test_static_epics_catalog_rejects_unknown_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(EpicsR, "build", _record_build) + catalog = EpicsStaticCatalog( + StaticCatalogConfig( + name="static-epics", + entries=[_entry("read", EpicsR(EpicsRConfig(read_pvname="PV:RB")))], + ), + ) + + with pytest.raises(PyAMLException, match="cannot resolve key 'missing'"): + catalog.resolve("missing") diff --git a/tests/test_factories.py b/tests/test_factories.py index 7444e46..4191780 100644 --- a/tests/test_factories.py +++ b/tests/test_factories.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Any import pytest diff --git a/tests/test_scalar_aggregator.py b/tests/test_scalar_aggregator.py index ce034a5..c9dd28d 100644 --- a/tests/test_scalar_aggregator.py +++ b/tests/test_scalar_aggregator.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import numpy as np import pytest from pyaml import PyAMLException diff --git a/tests/test_signal.py b/tests/test_signal.py index afac9fe..41721ce 100644 --- a/tests/test_signal.py +++ b/tests/test_signal.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import pytest from pyaml_cs_oa.epicsR import ConfigModel as EpicsRConfig diff --git a/tests/test_tune.py b/tests/test_tune.py index b502536..c7e2c28 100644 --- a/tests/test_tune.py +++ b/tests/test_tune.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import time from pyaml.accelerator import Accelerator diff --git a/tests/test_tune_bessy.py b/tests/test_tune_bessy.py index 29a0f36..8af7119 100644 --- a/tests/test_tune_bessy.py +++ b/tests/test_tune_bessy.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import time from pyaml.accelerator import Accelerator From 9996302a2a351079c479602d3d2c36c609fc2f05 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Mon, 4 May 2026 11:35:08 +0200 Subject: [PATCH 25/32] Adapt OA catalogs to PyAML backend-owned device resolution. Add coverage for catalog config, EPICS/Tango lookups, indexed devices, and get_device error paths. --- pyaml_cs_oa/controlsystem.py | 40 +++++++-- pyaml_cs_oa/epics_static_catalog.py | 7 +- pyaml_cs_oa/epics_static_catalog_entry.py | 3 +- pyaml_cs_oa/scalar_aggregator.py | 9 +-- pyaml_cs_oa/static_catalog.py | 3 +- pyaml_cs_oa/static_catalog_entry.py | 3 +- pyaml_cs_oa/tango_catalog.py | 71 ++++++---------- tests/test_controlsystem.py | 98 +++++++++++++++++++++++ tests/test_epics_catalog.py | 10 ++- tests/test_tango_catalog.py | 51 ++++++++++++ 10 files changed, 221 insertions(+), 74 deletions(-) create mode 100644 tests/test_tango_catalog.py diff --git a/pyaml_cs_oa/controlsystem.py b/pyaml_cs_oa/controlsystem.py index bc17c37..1e1facf 100644 --- a/pyaml_cs_oa/controlsystem.py +++ b/pyaml_cs_oa/controlsystem.py @@ -1,13 +1,10 @@ import logging -from pydantic import BaseModel, ConfigDict from pyaml.common.exception import PyAMLException from pyaml.configuration.catalog import Catalog from pyaml.control.controlsystem import ControlSystem - -PYAMLCLASS: str = "OphydAsyncControlSystem" - -logger = logging.getLogger(__name__) +from pyaml.control.deviceaccess import DeviceAccess +from pydantic import BaseModel, ConfigDict from . import __version__ from .epicsR import EpicsR @@ -28,6 +25,7 @@ logger = logging.getLogger(__name__) + class ConfigModel(BaseModel): """ Configuration model for an OA Control System. @@ -86,6 +84,35 @@ def attach(self, devs: list[OASignal | None]) -> list[OASignal | None]: def attach_array(self, devs: list[OASignal | None]) -> list[OASignal | None]: return self._attach(devs, True) + def get_catalog_config(self) -> Catalog | str | None: + return self._cfg.catalog + + def get_device(self, ref: str | BaseModel | None) -> DeviceAccess | None: + if ref is None: + return None + + if isinstance(ref, str): + if self._catalog is None: + raise PyAMLException(f"Control system '{self.name()}' has no catalog configured for key '{ref}'") + try: + dev = self._catalog.resolve(ref) + except AttributeError as exc: + raise PyAMLException(f"Control system '{self.name()}' catalog cannot resolve key '{ref}'") from exc + return self.attach([dev])[0] + + if isinstance(ref, EpicsConfigR): + return self.attach([EpicsR(ref)])[0] + if isinstance(ref, EpicsConfigW): + return self.attach([EpicsW(ref)])[0] + if isinstance(ref, EpicsConfigRW): + return self.attach([EpicsRW(ref)])[0] + if isinstance(ref, TangoConfigR): + return self.attach([TangoR(ref)])[0] + if isinstance(ref, TangoConfigRW): + return self.attach([TangoRW(ref)])[0] + + raise PyAMLException(f"Control system '{self.name()}' cannot build a device from {type(ref).__name__}") + def _attach(self, devs: list[OASignal | None], is_array: bool) -> list[OASignal | None]: # Concatenate the prefix newDevs = [] @@ -93,6 +120,7 @@ def _attach(self, devs: list[OASignal | None], is_array: bool) -> list[OASignal if d is not None: sig_cfg = d._cfg sig_cfg_cls = sig_cfg.__class__ + effective_is_array = is_array or getattr(d, "is_array", False) index_str = "" if sig_cfg.index is None else str(sig_cfg.index) if isinstance(d._cfg, EpicsConfigR): @@ -123,7 +151,7 @@ def _attach(self, devs: list[OASignal | None], is_array: bool) -> list[OASignal if key not in self._devices: n_conf = dict(d._cfg) | config - nr = sig_cls(sig_cfg_cls(**n_conf), is_array) + nr = sig_cls(sig_cfg_cls(**n_conf), effective_is_array) nr.build() self._devices[key] = nr diff --git a/pyaml_cs_oa/epics_static_catalog.py b/pyaml_cs_oa/epics_static_catalog.py index feb8ead..1213559 100644 --- a/pyaml_cs_oa/epics_static_catalog.py +++ b/pyaml_cs_oa/epics_static_catalog.py @@ -1,8 +1,7 @@ -from pydantic import ConfigDict - from pyaml.common.exception import PyAMLException from pyaml.configuration.catalog import Catalog, CatalogConfigModel from pyaml.control.deviceaccess import DeviceAccess +from pydantic import ConfigDict from .epics_static_catalog_entry import EpicsStaticCatalogEntry @@ -71,9 +70,7 @@ def __init__(self, cfg: ConfigModel): raise PyAMLException( f"EpicsStaticCatalog '{self.get_name()}': duplicate key '{key}'" ) - sig = entry.get_device() - sig.build() - self._refs[key] = sig + self._refs[key] = entry.get_device() def resolve(self, key: str) -> DeviceAccess: try: diff --git a/pyaml_cs_oa/epics_static_catalog_entry.py b/pyaml_cs_oa/epics_static_catalog_entry.py index 989e2c9..9673280 100644 --- a/pyaml_cs_oa/epics_static_catalog_entry.py +++ b/pyaml_cs_oa/epics_static_catalog_entry.py @@ -1,6 +1,5 @@ -from pydantic import BaseModel, ConfigDict - from pyaml.control.deviceaccess import DeviceAccess +from pydantic import BaseModel, ConfigDict PYAMLCLASS = "EpicsStaticCatalogEntry" diff --git a/pyaml_cs_oa/scalar_aggregator.py b/pyaml_cs_oa/scalar_aggregator.py index 005761f..33546e9 100644 --- a/pyaml_cs_oa/scalar_aggregator.py +++ b/pyaml_cs_oa/scalar_aggregator.py @@ -35,14 +35,14 @@ def _add_to_dev_list(self,d:FloatSignalContainer): else: if self._writable != d._writable: raise PyAMLException("Cannot mix read only and read/write signal in a same aggreagator") - + # Construct structure to avoid duplicate reading # The shared part is the source Ophyd signal if d.RB._r_sig not in self._r_signal_list: self._r_signal_list[d.RB._r_sig] = {"source":d.RB,"indices":[[d._cfg.index,len(self)]]} else: self._r_signal_list[d.RB._r_sig]["indices"].append([d._cfg.index,len(self)]) - + if self._writable: if d.SP._w_sig not in self._w_signal_list: self._w_signal_list[d.SP._w_sig] = {"source":d.SP,"indices":[[d._cfg.index,len(self)]]} @@ -80,9 +80,8 @@ def set_and_wait(self, value: npt.NDArray[np.float64]): def _read(self,signal_list:dict) -> npt.NDArray[np.float64]: - d: FloatSignalContainer requests = [] # list of status to await - for d,dc in signal_list.items(): + for _d,dc in signal_list.items(): requests.append( dc["source"].async_get() ) values = arun(asyncio.gather(*requests)) rvalues = np.zeros(len(self)) @@ -107,7 +106,7 @@ def get(self) -> npt.NDArray[np.float64]: def readback(self) -> np.array: return self._read(self._r_signal_list) - + def get_range(self) -> list[float]: attr_range: list[float] = [] for device in self: diff --git a/pyaml_cs_oa/static_catalog.py b/pyaml_cs_oa/static_catalog.py index fc65153..dc39134 100644 --- a/pyaml_cs_oa/static_catalog.py +++ b/pyaml_cs_oa/static_catalog.py @@ -1,8 +1,7 @@ -from pydantic import ConfigDict - from pyaml.common.exception import PyAMLException from pyaml.configuration.catalog import Catalog, CatalogConfigModel from pyaml.control.deviceaccess import DeviceAccess +from pydantic import ConfigDict from .static_catalog_entry import StaticCatalogEntry diff --git a/pyaml_cs_oa/static_catalog_entry.py b/pyaml_cs_oa/static_catalog_entry.py index b9ed8c9..79eab0c 100644 --- a/pyaml_cs_oa/static_catalog_entry.py +++ b/pyaml_cs_oa/static_catalog_entry.py @@ -1,6 +1,5 @@ -from pydantic import BaseModel, ConfigDict - from pyaml.control.deviceaccess import DeviceAccess +from pydantic import BaseModel, ConfigDict PYAMLCLASS = "StaticCatalogEntry" diff --git a/pyaml_cs_oa/tango_catalog.py b/pyaml_cs_oa/tango_catalog.py index 2da2035..19fe4a3 100644 --- a/pyaml_cs_oa/tango_catalog.py +++ b/pyaml_cs_oa/tango_catalog.py @@ -1,9 +1,7 @@ -from pydantic import ConfigDict - -import pyaml from pyaml.common.exception import PyAMLException -from pyaml.configuration.catalog import Catalog, CatalogConfigModel, CatalogResolver +from pyaml.configuration.catalog import Catalog, CatalogConfigModel from pyaml.control.deviceaccess import DeviceAccess +from pydantic import ConfigDict PYAMLCLASS = "TangoCatalog" @@ -29,29 +27,8 @@ class ConfigModel(CatalogConfigModel): class TangoCatalog(Catalog): - def resolve(self, key: str) -> DeviceAccess: - raise PyAMLException( - f"OA Tango catalog '{self.get_name()}' must be attached to a control " - f"system before resolving key '{key}'" - ) - - def attach_control_system(self, control_system): - from .controlsystem import OphydAsyncControlSystem - - if not isinstance(control_system, OphydAsyncControlSystem): - raise PyAMLException( - f"OA Tango catalog '{self.get_name()}' can only be attached to " - "OphydAsyncControlSystem" - ) - return _TangoCatalogResolver(self, control_system) - - -class _TangoCatalogResolver(CatalogResolver): - _WRITABLE_TYPES: set # populated lazily after tango is imported - - def __init__(self, catalog: TangoCatalog, control_system): - self._catalog = catalog - self._control_system = control_system + def __init__(self, cfg: ConfigModel): + super().__init__(cfg) self._refs: dict[str, DeviceAccess] = {} def resolve(self, key: str) -> DeviceAccess: @@ -63,12 +40,12 @@ def resolve(self, key: str) -> DeviceAccess: self._refs[key] = self._build_attribute(attr_path) return self._refs[key] - # ── key parsing ────────────────────────────────────────────────────────── + # key parsing def _parse_key(self, key: str) -> tuple[str, int | None]: if not isinstance(key, str): raise PyAMLException( - f"OA Tango catalog '{self._catalog.get_name()}' expects string keys, " + f"OA Tango catalog '{self.get_name()}' expects string keys, " f"got {type(key).__name__}" ) if "@" in key: @@ -77,9 +54,9 @@ def _parse_key(self, key: str) -> tuple[str, int | None]: index = int(idx_str) except ValueError: raise PyAMLException( - f"OA Tango catalog '{self._catalog.get_name()}': invalid index " + f"OA Tango catalog '{self.get_name()}': invalid index " f"'{idx_str}' in key '{key}'" - ) + ) from None else: attr_path = key index = None @@ -87,18 +64,18 @@ def _parse_key(self, key: str) -> tuple[str, int | None]: parts = attr_path.split("/") if len(parts) != 4 or any(p == "" for p in parts): raise PyAMLException( - f"OA Tango catalog '{self._catalog.get_name()}' cannot resolve '{key}'. " + f"OA Tango catalog '{self.get_name()}' cannot resolve '{key}'. " "Expected 'domain/family/member/attribute[@index]'." ) return attr_path, index - # ── signal builders ────────────────────────────────────────────────────── + # signal builders def _timeout(self) -> int: - return self._catalog._cfg.timeout_ms + return self._cfg.timeout_ms def _build_attribute(self, attr_path: str) -> DeviceAccess: - if self._catalog._cfg.disconnected: + if self._cfg.disconnected: return self._make_rw(attr_path, is_array=False) try: @@ -112,7 +89,7 @@ def _build_attribute(self, attr_path: str) -> DeviceAccess: attr_cfg = tango.AttributeProxy(attr_path).get_config() except tango.DevFailed as df: raise PyAMLException( - f"OA Tango catalog '{self._catalog.get_name()}' cannot resolve " + f"OA Tango catalog '{self.get_name()}' cannot resolve " f"'{attr_path}': {df}" ) from df @@ -134,7 +111,7 @@ def _build_attribute(self, attr_path: str) -> DeviceAccess: return self._make_r(attr_path, is_array=is_array) def _build_indexed(self, attr_path: str, index: int) -> DeviceAccess: - if not self._catalog._cfg.disconnected: + if not self._cfg.disconnected: try: import tango except ImportError as exc: @@ -146,32 +123,30 @@ def _build_indexed(self, attr_path: str, index: int) -> DeviceAccess: attr_cfg = tango.AttributeProxy(attr_path).get_config() except tango.DevFailed as df: raise PyAMLException( - f"OA Tango catalog '{self._catalog.get_name()}' cannot resolve " + f"OA Tango catalog '{self.get_name()}' cannot resolve " f"'{attr_path}@{index}': {df}" ) from df data_format = getattr(attr_cfg, "data_format", None) if data_format != tango.AttrDataFormat.SPECTRUM: raise PyAMLException( - f"OA Tango catalog '{self._catalog.get_name()}': '{attr_path}' " + f"OA Tango catalog '{self.get_name()}': '{attr_path}' " "is not a SPECTRUM; indexed access requires a vector attribute." ) # index is embedded in the config; build() applies it automatically. return self._make_r(attr_path, index=index) - # ── helpers ─────────────────────────────────────────────────────────────── + # helpers def _make_r(self, attr_path: str, is_array: bool = False, index: int | None = None): - from .tangoR import TangoR, ConfigModel as TangoRConfig + from .tangoR import ConfigModel as TangoRConfig + from .tangoR import TangoR - sig = TangoR(TangoRConfig(attribute=attr_path, timeout_ms=self._timeout(), index=index), is_array) - sig.build() - return sig + return TangoR(TangoRConfig(attribute=attr_path, timeout_ms=self._timeout(), index=index), is_array) def _make_rw(self, attr_path: str, is_array: bool = False): - from .tangoRW import TangoRW, ConfigModel as TangoRWConfig + from .tangoRW import ConfigModel as TangoRWConfig + from .tangoRW import TangoRW - sig = TangoRW(TangoRWConfig(attribute=attr_path, timeout_ms=self._timeout()), is_array) - sig.build() - return sig + return TangoRW(TangoRWConfig(attribute=attr_path, timeout_ms=self._timeout()), is_array) diff --git a/tests/test_controlsystem.py b/tests/test_controlsystem.py index c8d9073..a486aeb 100644 --- a/tests/test_controlsystem.py +++ b/tests/test_controlsystem.py @@ -1,5 +1,8 @@ import pytest from pyaml.common.exception import PyAMLException +from pyaml.configuration.catalog import Catalog, CatalogConfigModel +from pyaml.control.deviceaccess import DeviceAccess +from pydantic import BaseModel from pyaml_cs_oa.controlsystem import ConfigModel, OphydAsyncControlSystem from pyaml_cs_oa.epicsR import ConfigModel as EpicsRConfig @@ -17,6 +20,18 @@ def _no_connect_build(self) -> None: self._writable = False +class _Catalog(Catalog): + def __init__(self, devices: dict[str, DeviceAccess]) -> None: + super().__init__(CatalogConfigModel(name="catalog")) + self._devices = devices + + def resolve(self, key: str) -> DeviceAccess: + try: + return self._devices[key] + except KeyError as exc: + raise PyAMLException(f"missing key {key}") from exc + + def test_controlsystem_exposes_name_and_aggregator_modules() -> None: cfg = ConfigModel(name="live", prefix="PREFIX:", vector_aggregator="vector.mod") control_system = OphydAsyncControlSystem(cfg) @@ -26,6 +41,19 @@ def test_controlsystem_exposes_name_and_aggregator_modules() -> None: assert control_system.vector_aggregator() == "vector.mod" +def test_controlsystem_exposes_catalog_config_object() -> None: + catalog = _Catalog({}) + control_system = OphydAsyncControlSystem(ConfigModel(name="live", catalog=catalog)) + + assert control_system.get_catalog_config() is catalog + + +def test_controlsystem_exposes_catalog_config_name() -> None: + control_system = OphydAsyncControlSystem(ConfigModel(name="live", catalog="machine-catalog")) + + assert control_system.get_catalog_config() == "machine-catalog" + + def test_attach_prefixes_epics_read_device(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(EpicsR, "build", _no_connect_build) control_system = OphydAsyncControlSystem(ConfigModel(name="live", prefix="P:")) @@ -107,3 +135,73 @@ class UnsupportedDevice: with pytest.raises(PyAMLException, match="Unsupported type"): control_system.attach([UnsupportedDevice()]) + + +def test_get_device_returns_none_for_none_reference() -> None: + control_system = OphydAsyncControlSystem(ConfigModel(name="live")) + + assert control_system.get_device(None) is None + + +def test_get_device_constructs_and_attaches_epics_config(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(EpicsR, "build", _no_connect_build) + control_system = OphydAsyncControlSystem(ConfigModel(name="live", prefix="P:")) + + device = control_system.get_device(EpicsRConfig(read_pvname="RB")) + + assert isinstance(device, EpicsR) + assert device._cfg.read_pvname == "P:RB" + + +def test_get_device_resolves_string_through_catalog(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(EpicsR, "build", _no_connect_build) + control_system = OphydAsyncControlSystem(ConfigModel(name="live", prefix="P:")) + control_system.set_catalog(_Catalog({"read": EpicsR(EpicsRConfig(read_pvname="RB"))})) + + device = control_system.get_device("read") + + assert isinstance(device, EpicsR) + assert device._cfg.read_pvname == "P:RB" + + +def test_get_device_reuses_attached_catalog_device(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(EpicsR, "build", _no_connect_build) + control_system = OphydAsyncControlSystem(ConfigModel(name="live", prefix="P:")) + control_system.set_catalog(_Catalog({"read": EpicsR(EpicsRConfig(read_pvname="RB"))})) + + first = control_system.get_device("read") + second = control_system.get_device("read") + + assert first is second + + +def test_get_device_rejects_string_without_catalog() -> None: + control_system = OphydAsyncControlSystem(ConfigModel(name="live")) + + with pytest.raises(PyAMLException, match="has no catalog configured"): + control_system.get_device("read") + + +def test_get_device_rejects_catalog_without_resolve() -> None: + control_system = OphydAsyncControlSystem(ConfigModel(name="live")) + control_system.set_catalog(object()) + + with pytest.raises(PyAMLException, match="catalog cannot resolve"): + control_system.get_device("read") + + +def test_get_device_rejects_unknown_reference_type() -> None: + class UnknownConfig(BaseModel): + value: str + + control_system = OphydAsyncControlSystem(ConfigModel(name="live")) + + with pytest.raises(PyAMLException, match="cannot build a device from UnknownConfig"): + control_system.get_device(UnknownConfig(value="x")) + + +def test_get_device_rejects_already_constructed_device() -> None: + control_system = OphydAsyncControlSystem(ConfigModel(name="live")) + + with pytest.raises(PyAMLException, match="cannot build a device from EpicsR"): + control_system.get_device(EpicsR(EpicsRConfig(read_pvname="RB"))) diff --git a/tests/test_epics_catalog.py b/tests/test_epics_catalog.py index eeef191..b4dbc2b 100644 --- a/tests/test_epics_catalog.py +++ b/tests/test_epics_catalog.py @@ -99,7 +99,9 @@ def test_static_epics_catalog_entry_exposes_key_and_device() -> None: assert entry.get_device() is device -def test_static_epics_catalog_resolves_read_write_and_write_entries(monkeypatch: pytest.MonkeyPatch) -> None: +def test_static_epics_catalog_resolves_read_write_and_write_entries_without_building( + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setattr(EpicsR, "build", _record_build) monkeypatch.setattr(EpicsRW, "build", _record_build) monkeypatch.setattr(EpicsW, "build", _record_build) @@ -121,9 +123,9 @@ def test_static_epics_catalog_resolves_read_write_and_write_entries(monkeypatch: assert catalog.resolve("read") is read assert catalog.resolve("read_write") is read_write assert catalog.resolve("write") is write - assert read.build_calls == 1 - assert read_write.build_calls == 1 - assert write.build_calls == 1 + assert not hasattr(read, "build_calls") + assert not hasattr(read_write, "build_calls") + assert not hasattr(write, "build_calls") def test_static_epics_catalog_rejects_empty_entries() -> None: diff --git a/tests/test_tango_catalog.py b/tests/test_tango_catalog.py new file mode 100644 index 0000000..1a01580 --- /dev/null +++ b/tests/test_tango_catalog.py @@ -0,0 +1,51 @@ +import pytest +from pyaml.common.exception import PyAMLException + +from pyaml_cs_oa.tango_catalog import ConfigModel, TangoCatalog +from pyaml_cs_oa.tangoR import TangoR +from pyaml_cs_oa.tangoRW import TangoRW + + +def test_disconnected_tango_catalog_resolves_scalar_attribute_as_read_write() -> None: + catalog = TangoCatalog(ConfigModel(name="tango", disconnected=True, timeout_ms=1234)) + + device = catalog.resolve("sys/tg_test/1/value") + + assert isinstance(device, TangoRW) + assert device._cfg.attribute == "sys/tg_test/1/value" + assert device._cfg.timeout_ms == 1234 + assert device._cfg.index is None + + +def test_disconnected_tango_catalog_resolves_indexed_attribute_as_read_only() -> None: + catalog = TangoCatalog(ConfigModel(name="tango", disconnected=True)) + + device = catalog.resolve("sys/tg_test/1/spectrum@4") + + assert isinstance(device, TangoR) + assert device._cfg.attribute == "sys/tg_test/1/spectrum" + assert device._cfg.index == 4 + assert device.is_array is True + + +def test_tango_catalog_reuses_resolved_device() -> None: + catalog = TangoCatalog(ConfigModel(name="tango", disconnected=True)) + + first = catalog.resolve("sys/tg_test/1/value") + second = catalog.resolve("sys/tg_test/1/value") + + assert first is second + + +def test_tango_catalog_rejects_invalid_attribute_key() -> None: + catalog = TangoCatalog(ConfigModel(name="tango", disconnected=True)) + + with pytest.raises(PyAMLException, match="Expected 'domain/family/member/attribute"): + catalog.resolve("sys/tg_test/value") + + +def test_tango_catalog_rejects_invalid_index() -> None: + catalog = TangoCatalog(ConfigModel(name="tango", disconnected=True)) + + with pytest.raises(PyAMLException, match="invalid index"): + catalog.resolve("sys/tg_test/1/spectrum@bad") From 799e4120f88b54b7e10a1cbcc0dc21c691e2dfa6 Mon Sep 17 00:00:00 2001 From: PONS Date: Tue, 2 Jun 2026 16:53:29 +0200 Subject: [PATCH 26/32] Updated tests, moved bach catalog from pyaml core --- pyaml_cs_oa/catalog.py | 55 +++++++++++++++++++++++++++++ pyaml_cs_oa/controlsystem.py | 9 ++--- pyaml_cs_oa/epics_catalog.py | 3 +- pyaml_cs_oa/epics_static_catalog.py | 14 +++----- pyaml_cs_oa/static_catalog.py | 6 ++-- pyaml_cs_oa/tango_catalog.py | 37 ++++++------------- tests/test_bpm_orbit.py | 16 +++------ tests/test_controlsystem.py | 40 +++------------------ 8 files changed, 86 insertions(+), 94 deletions(-) create mode 100644 pyaml_cs_oa/catalog.py diff --git a/pyaml_cs_oa/catalog.py b/pyaml_cs_oa/catalog.py new file mode 100644 index 0000000..72f1d02 --- /dev/null +++ b/pyaml_cs_oa/catalog.py @@ -0,0 +1,55 @@ +"""Configuration helpers for backend-provided catalogs.""" + +from abc import ABCMeta, abstractmethod + +from pyaml.control.deviceaccess import DeviceAccess +from pydantic import BaseModel, ConfigDict + + +class CatalogConfigModel(BaseModel): + r""" + Base configuration model for named catalogs. + + Parameters + ---------- + name : str + Unique catalog identifier used in accelerator and control-system + configuration. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + + name: str + + +class Catalog(metaclass=ABCMeta): + r""" + Abstract class for backend catalog configuration objects. + + Notes + ----- + Concrete catalogs live in each control-system package. They may expose + backend-specific resolution APIs, but those APIs are not called by the + PyAML core. + """ + + def __init__(self, cfg: CatalogConfigModel): + self._cfg = cfg + + def get_name(self) -> str: + r""" + Return the catalog name. + + Returns + ------- + str + Catalog identifier. + """ + return self._cfg.name + + @abstractmethod + def resolve(self, key: str) -> DeviceAccess: + """ + Return + """ + pass diff --git a/pyaml_cs_oa/controlsystem.py b/pyaml_cs_oa/controlsystem.py index 1e1facf..0a3fbca 100644 --- a/pyaml_cs_oa/controlsystem.py +++ b/pyaml_cs_oa/controlsystem.py @@ -1,12 +1,12 @@ import logging from pyaml.common.exception import PyAMLException -from pyaml.configuration.catalog import Catalog from pyaml.control.controlsystem import ControlSystem from pyaml.control.deviceaccess import DeviceAccess from pydantic import BaseModel, ConfigDict from . import __version__ +from .catalog import Catalog from .epicsR import EpicsR from .epicsRW import EpicsRW from .epicsW import EpicsW @@ -84,18 +84,15 @@ def attach(self, devs: list[OASignal | None]) -> list[OASignal | None]: def attach_array(self, devs: list[OASignal | None]) -> list[OASignal | None]: return self._attach(devs, True) - def get_catalog_config(self) -> Catalog | str | None: - return self._cfg.catalog - def get_device(self, ref: str | BaseModel | None) -> DeviceAccess | None: if ref is None: return None if isinstance(ref, str): - if self._catalog is None: + if self._cfg.catalog is None: raise PyAMLException(f"Control system '{self.name()}' has no catalog configured for key '{ref}'") try: - dev = self._catalog.resolve(ref) + dev = self._cfg.catalog.resolve(ref) except AttributeError as exc: raise PyAMLException(f"Control system '{self.name()}' catalog cannot resolve key '{ref}'") from exc return self.attach([dev])[0] diff --git a/pyaml_cs_oa/epics_catalog.py b/pyaml_cs_oa/epics_catalog.py index 5112b09..6f53a20 100644 --- a/pyaml_cs_oa/epics_catalog.py +++ b/pyaml_cs_oa/epics_catalog.py @@ -1,8 +1,9 @@ from pyaml.common.exception import PyAMLException -from pyaml.configuration.catalog import Catalog, CatalogConfigModel from pyaml.control.deviceaccess import DeviceAccess from pydantic import ConfigDict +from .catalog import Catalog, CatalogConfigModel + PYAMLCLASS = "EpicsCatalog" diff --git a/pyaml_cs_oa/epics_static_catalog.py b/pyaml_cs_oa/epics_static_catalog.py index 1213559..332577a 100644 --- a/pyaml_cs_oa/epics_static_catalog.py +++ b/pyaml_cs_oa/epics_static_catalog.py @@ -1,8 +1,8 @@ from pyaml.common.exception import PyAMLException -from pyaml.configuration.catalog import Catalog, CatalogConfigModel from pyaml.control.deviceaccess import DeviceAccess from pydantic import ConfigDict +from .catalog import Catalog, CatalogConfigModel from .epics_static_catalog_entry import EpicsStaticCatalogEntry PYAMLCLASS = "EpicsStaticCatalog" @@ -60,22 +60,16 @@ class EpicsStaticCatalog(Catalog): def __init__(self, cfg: ConfigModel): super().__init__(cfg) if not cfg.entries: - raise PyAMLException( - "EpicsStaticCatalog.entries must contain at least one entry" - ) + raise PyAMLException("EpicsStaticCatalog.entries must contain at least one entry") self._refs: dict[str, DeviceAccess] = {} for entry in cfg.entries: key = entry.get_key() if key in self._refs: - raise PyAMLException( - f"EpicsStaticCatalog '{self.get_name()}': duplicate key '{key}'" - ) + raise PyAMLException(f"EpicsStaticCatalog '{self.get_name()}': duplicate key '{key}'") self._refs[key] = entry.get_device() def resolve(self, key: str) -> DeviceAccess: try: return self._refs[key] except KeyError as exc: - raise PyAMLException( - f"Catalog '{self.get_name()}' cannot resolve key '{key}'" - ) from exc + raise PyAMLException(f"Catalog '{self.get_name()}' cannot resolve key '{key}'") from exc diff --git a/pyaml_cs_oa/static_catalog.py b/pyaml_cs_oa/static_catalog.py index dc39134..14530c9 100644 --- a/pyaml_cs_oa/static_catalog.py +++ b/pyaml_cs_oa/static_catalog.py @@ -1,8 +1,8 @@ from pyaml.common.exception import PyAMLException -from pyaml.configuration.catalog import Catalog, CatalogConfigModel from pyaml.control.deviceaccess import DeviceAccess from pydantic import ConfigDict +from .catalog import Catalog, CatalogConfigModel from .static_catalog_entry import StaticCatalogEntry PYAMLCLASS = "StaticCatalog" @@ -38,6 +38,4 @@ def resolve(self, key: str) -> DeviceAccess: try: return self._refs[key] except KeyError as exc: - raise PyAMLException( - f"Catalog '{self.get_name()}' cannot resolve key '{key}'" - ) from exc + raise PyAMLException(f"Catalog '{self.get_name()}' cannot resolve key '{key}'") from exc diff --git a/pyaml_cs_oa/tango_catalog.py b/pyaml_cs_oa/tango_catalog.py index 19fe4a3..18b77ea 100644 --- a/pyaml_cs_oa/tango_catalog.py +++ b/pyaml_cs_oa/tango_catalog.py @@ -1,8 +1,9 @@ from pyaml.common.exception import PyAMLException -from pyaml.configuration.catalog import Catalog, CatalogConfigModel from pyaml.control.deviceaccess import DeviceAccess from pydantic import ConfigDict +from .catalog import Catalog, CatalogConfigModel + PYAMLCLASS = "TangoCatalog" @@ -27,6 +28,7 @@ class ConfigModel(CatalogConfigModel): class TangoCatalog(Catalog): + def __init__(self, cfg: ConfigModel): super().__init__(cfg) self._refs: dict[str, DeviceAccess] = {} @@ -44,18 +46,14 @@ def resolve(self, key: str) -> DeviceAccess: def _parse_key(self, key: str) -> tuple[str, int | None]: if not isinstance(key, str): - raise PyAMLException( - f"OA Tango catalog '{self.get_name()}' expects string keys, " - f"got {type(key).__name__}" - ) + raise PyAMLException(f"OA Tango catalog '{self.get_name()}' expects string keys, got {type(key).__name__}") if "@" in key: attr_path, idx_str = key.rsplit("@", 1) try: index = int(idx_str) except ValueError: raise PyAMLException( - f"OA Tango catalog '{self.get_name()}': invalid index " - f"'{idx_str}' in key '{key}'" + f"OA Tango catalog '{self.get_name()}': invalid index '{idx_str}' in key '{key}'" ) from None else: attr_path = key @@ -81,30 +79,20 @@ def _build_attribute(self, attr_path: str) -> DeviceAccess: try: import tango except ImportError as exc: - raise PyAMLException( - "pytango is required for TangoCatalog in connected mode" - ) from exc + raise PyAMLException("pytango is required for TangoCatalog in connected mode") from exc try: attr_cfg = tango.AttributeProxy(attr_path).get_config() except tango.DevFailed as df: - raise PyAMLException( - f"OA Tango catalog '{self.get_name()}' cannot resolve " - f"'{attr_path}': {df}" - ) from df + raise PyAMLException(f"OA Tango catalog '{self.get_name()}' cannot resolve '{attr_path}': {df}") from df writable_types = { tango.AttrWriteType.READ_WRITE, tango.AttrWriteType.WRITE, tango.AttrWriteType.READ_WITH_WRITE, } - is_writable = ( - getattr(attr_cfg, "writable", tango.AttrWriteType.WT_UNKNOWN) - in writable_types - ) - is_array = ( - getattr(attr_cfg, "data_format", None) == tango.AttrDataFormat.SPECTRUM - ) + is_writable = getattr(attr_cfg, "writable", tango.AttrWriteType.WT_UNKNOWN) in writable_types + is_array = getattr(attr_cfg, "data_format", None) == tango.AttrDataFormat.SPECTRUM if is_writable: return self._make_rw(attr_path, is_array=is_array) @@ -115,16 +103,13 @@ def _build_indexed(self, attr_path: str, index: int) -> DeviceAccess: try: import tango except ImportError as exc: - raise PyAMLException( - "pytango is required for TangoCatalog in connected mode" - ) from exc + raise PyAMLException("pytango is required for TangoCatalog in connected mode") from exc try: attr_cfg = tango.AttributeProxy(attr_path).get_config() except tango.DevFailed as df: raise PyAMLException( - f"OA Tango catalog '{self.get_name()}' cannot resolve " - f"'{attr_path}@{index}': {df}" + f"OA Tango catalog '{self.get_name()}' cannot resolve '{attr_path}@{index}': {df}" ) from df data_format = getattr(attr_cfg, "data_format", None) diff --git a/tests/test_bpm_orbit.py b/tests/test_bpm_orbit.py index 757cd32..e89bb1d 100644 --- a/tests/test_bpm_orbit.py +++ b/tests/test_bpm_orbit.py @@ -6,10 +6,10 @@ from pyaml.bpm.bpm import ConfigModel as BPMConfig from pyaml.bpm.bpm_simple_model import BPMSimpleModel from pyaml.bpm.bpm_simple_model import ConfigModel as BPMSimpleModelConfig -from pyaml.configuration.catalog import Catalog, CatalogConfigModel from pyaml.control.abstract_impl import RBpmArray from pyaml.control.deviceaccess import DeviceAccess +from pyaml_cs_oa.catalog import Catalog, CatalogConfigModel from pyaml_cs_oa.controlsystem import ConfigModel, OphydAsyncControlSystem from pyaml_cs_oa.float_signal import FloatSignalContainer from pyaml_cs_oa.types import EpicsConfigR @@ -120,17 +120,9 @@ def _attached_indexed_bpm( def _control_system_with_indexed_orbit(orbit_device: VectorDevice, bpm_count: int) -> IdentityAttachControlSystem: control_system = IdentityAttachControlSystem(ConfigModel(name="live")) - control_system.set_catalog( - StaticCatalog( - { - f"BPM{bpm_index}:X": IndexedVectorSignal(orbit_device, 2 * bpm_index) - for bpm_index in range(bpm_count) - } - | { - f"BPM{bpm_index}:Y": IndexedVectorSignal(orbit_device, (2 * bpm_index) + 1) - for bpm_index in range(bpm_count) - }, - ), + control_system._cfg.catalog = StaticCatalog( + {f"BPM{bpm_index}:X": IndexedVectorSignal(orbit_device, 2 * bpm_index) for bpm_index in range(bpm_count)} + | {f"BPM{bpm_index}:Y": IndexedVectorSignal(orbit_device, (2 * bpm_index) + 1) for bpm_index in range(bpm_count)}, ) return control_system diff --git a/tests/test_controlsystem.py b/tests/test_controlsystem.py index a486aeb..cb1381c 100644 --- a/tests/test_controlsystem.py +++ b/tests/test_controlsystem.py @@ -1,6 +1,5 @@ import pytest from pyaml.common.exception import PyAMLException -from pyaml.configuration.catalog import Catalog, CatalogConfigModel from pyaml.control.deviceaccess import DeviceAccess from pydantic import BaseModel @@ -9,6 +8,7 @@ from pyaml_cs_oa.epicsR import EpicsR from pyaml_cs_oa.epicsRW import ConfigModel as EpicsRWConfig from pyaml_cs_oa.epicsW import ConfigModel as EpicsWConfig +from pyaml_cs_oa.static_catalog import Catalog, CatalogConfigModel from pyaml_cs_oa.tangoR import ConfigModel as TangoRConfig from pyaml_cs_oa.tangoRW import ConfigModel as TangoRWConfig @@ -41,19 +41,6 @@ def test_controlsystem_exposes_name_and_aggregator_modules() -> None: assert control_system.vector_aggregator() == "vector.mod" -def test_controlsystem_exposes_catalog_config_object() -> None: - catalog = _Catalog({}) - control_system = OphydAsyncControlSystem(ConfigModel(name="live", catalog=catalog)) - - assert control_system.get_catalog_config() is catalog - - -def test_controlsystem_exposes_catalog_config_name() -> None: - control_system = OphydAsyncControlSystem(ConfigModel(name="live", catalog="machine-catalog")) - - assert control_system.get_catalog_config() == "machine-catalog" - - def test_attach_prefixes_epics_read_device(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(EpicsR, "build", _no_connect_build) control_system = OphydAsyncControlSystem(ConfigModel(name="live", prefix="P:")) @@ -155,19 +142,17 @@ def test_get_device_constructs_and_attaches_epics_config(monkeypatch: pytest.Mon def test_get_device_resolves_string_through_catalog(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(EpicsR, "build", _no_connect_build) - control_system = OphydAsyncControlSystem(ConfigModel(name="live", prefix="P:")) - control_system.set_catalog(_Catalog({"read": EpicsR(EpicsRConfig(read_pvname="RB"))})) - + cat = _Catalog({"read": EpicsR(EpicsRConfig(read_pvname="RB"))}) + control_system = OphydAsyncControlSystem(ConfigModel(name="live", prefix="P:", catalog=cat)) device = control_system.get_device("read") - assert isinstance(device, EpicsR) assert device._cfg.read_pvname == "P:RB" def test_get_device_reuses_attached_catalog_device(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(EpicsR, "build", _no_connect_build) - control_system = OphydAsyncControlSystem(ConfigModel(name="live", prefix="P:")) - control_system.set_catalog(_Catalog({"read": EpicsR(EpicsRConfig(read_pvname="RB"))})) + cat = _Catalog({"read": EpicsR(EpicsRConfig(read_pvname="RB"))}) + control_system = OphydAsyncControlSystem(ConfigModel(name="live", prefix="P:", catalog=cat)) first = control_system.get_device("read") second = control_system.get_device("read") @@ -175,21 +160,6 @@ def test_get_device_reuses_attached_catalog_device(monkeypatch: pytest.MonkeyPat assert first is second -def test_get_device_rejects_string_without_catalog() -> None: - control_system = OphydAsyncControlSystem(ConfigModel(name="live")) - - with pytest.raises(PyAMLException, match="has no catalog configured"): - control_system.get_device("read") - - -def test_get_device_rejects_catalog_without_resolve() -> None: - control_system = OphydAsyncControlSystem(ConfigModel(name="live")) - control_system.set_catalog(object()) - - with pytest.raises(PyAMLException, match="catalog cannot resolve"): - control_system.get_device("read") - - def test_get_device_rejects_unknown_reference_type() -> None: class UnknownConfig(BaseModel): value: str From bfb4c733dec4bb1635ebaa7c50bdc62414f745a7 Mon Sep 17 00:00:00 2001 From: PONS Date: Wed, 3 Jun 2026 15:48:34 +0200 Subject: [PATCH 27/32] Remove unrlevant tests, New management for Tango attribute and dynamic catalog --- pyaml_cs_oa/catalog.py | 39 +---- pyaml_cs_oa/controlsystem.py | 77 +++++----- pyaml_cs_oa/epics_catalog.py | 84 ---------- pyaml_cs_oa/epics_static_catalog.py | 75 --------- pyaml_cs_oa/epics_static_catalog_entry.py | 22 --- pyaml_cs_oa/signal.py | 11 +- pyaml_cs_oa/static_catalog.py | 12 +- pyaml_cs_oa/tango.py | 31 ++-- pyaml_cs_oa/tangoR.py | 15 -- pyaml_cs_oa/tangoRW.py | 15 -- pyaml_cs_oa/tango_catalog.py | 137 ----------------- pyaml_cs_oa/types.py | 12 +- tests/test_bpm_orbit.py | 38 +++-- tests/test_controlsystem.py | 177 ---------------------- tests/test_epics_catalog.py | 163 -------------------- tests/test_factories.py | 22 +-- tests/test_signal.py | 9 +- tests/test_tango_catalog.py | 51 ------- 18 files changed, 88 insertions(+), 902 deletions(-) delete mode 100644 pyaml_cs_oa/epics_catalog.py delete mode 100644 pyaml_cs_oa/epics_static_catalog.py delete mode 100644 pyaml_cs_oa/epics_static_catalog_entry.py delete mode 100644 pyaml_cs_oa/tangoR.py delete mode 100644 pyaml_cs_oa/tangoRW.py delete mode 100644 pyaml_cs_oa/tango_catalog.py delete mode 100644 tests/test_controlsystem.py delete mode 100644 tests/test_epics_catalog.py delete mode 100644 tests/test_tango_catalog.py diff --git a/pyaml_cs_oa/catalog.py b/pyaml_cs_oa/catalog.py index 72f1d02..356f788 100644 --- a/pyaml_cs_oa/catalog.py +++ b/pyaml_cs_oa/catalog.py @@ -1,26 +1,7 @@ """Configuration helpers for backend-provided catalogs.""" from abc import ABCMeta, abstractmethod - -from pyaml.control.deviceaccess import DeviceAccess -from pydantic import BaseModel, ConfigDict - - -class CatalogConfigModel(BaseModel): - r""" - Base configuration model for named catalogs. - - Parameters - ---------- - name : str - Unique catalog identifier used in accelerator and control-system - configuration. - """ - - model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") - - name: str - +from pydantic import BaseModel class Catalog(metaclass=ABCMeta): r""" @@ -33,23 +14,9 @@ class Catalog(metaclass=ABCMeta): PyAML core. """ - def __init__(self, cfg: CatalogConfigModel): - self._cfg = cfg - - def get_name(self) -> str: - r""" - Return the catalog name. - - Returns - ------- - str - Catalog identifier. - """ - return self._cfg.name - @abstractmethod - def resolve(self, key: str) -> DeviceAccess: + def resolve(self, key: str) -> BaseModel: """ - Return + Return a configuration model for a DeviceAccess """ pass diff --git a/pyaml_cs_oa/controlsystem.py b/pyaml_cs_oa/controlsystem.py index 0a3fbca..ba10a19 100644 --- a/pyaml_cs_oa/controlsystem.py +++ b/pyaml_cs_oa/controlsystem.py @@ -11,14 +11,12 @@ from .epicsRW import EpicsRW from .epicsW import EpicsW from .signal import OASignal -from .tangoR import TangoR -from .tangoRW import TangoRW +from .tangoAtt import TangoAtt from .types import ( EpicsConfigR, EpicsConfigRW, EpicsConfigW, - TangoConfigR, - TangoConfigRW, + TangoConfigAtt, ) PYAMLCLASS: str = "OphydAsyncControlSystem" @@ -37,8 +35,9 @@ class ConfigModel(BaseModel): prefix : str Prefix added to the PV or attribute name. It can be a for instance, TANGO_HOST, or a PV prefix. - catalog : Catalog | str | None + catalog : Catalog | None Catalog instance or catalog name used to resolve PyAML device keys. + If None specified a dynamic catalog is used. debug_level : str Debug verbosity level. scalar_aggregator : str @@ -53,7 +52,7 @@ class ConfigModel(BaseModel): name: str prefix: str = "" - catalog: Catalog | str | None = None + catalog: Catalog | None = None debug_level: str | None = None scalar_aggregator: str | None = "pyaml_cs_oa.scalar_aggregator" vector_aggregator: str | None = None @@ -79,76 +78,68 @@ def __init__(self, cfg: ConfigModel): ) def attach(self, devs: list[OASignal | None]) -> list[OASignal | None]: - return self._attach(devs, False) + return self._attach(devs._cfg, False) def attach_array(self, devs: list[OASignal | None]) -> list[OASignal | None]: - return self._attach(devs, True) + return self._attach(devs._cfg, True) def get_device(self, ref: str | BaseModel | None) -> DeviceAccess | None: if ref is None: return None + # Build config from a string using using a DynamicCatalog if isinstance(ref, str): if self._cfg.catalog is None: - raise PyAMLException(f"Control system '{self.name()}' has no catalog configured for key '{ref}'") + raise PyAMLException(f"Control system '{self.name()}' has no catalog when trying to resolve '{ref}'") try: - dev = self._cfg.catalog.resolve(ref) + ref = self._cfg.catalog.resolve(ref) except AttributeError as exc: raise PyAMLException(f"Control system '{self.name()}' catalog cannot resolve key '{ref}'") from exc - return self.attach([dev])[0] if isinstance(ref, EpicsConfigR): - return self.attach([EpicsR(ref)])[0] + return self._attach([ref],ref.index is not None)[0] if isinstance(ref, EpicsConfigW): - return self.attach([EpicsW(ref)])[0] + return self._attach([ref],ref.index is not None)[0] if isinstance(ref, EpicsConfigRW): - return self.attach([EpicsRW(ref)])[0] - if isinstance(ref, TangoConfigR): - return self.attach([TangoR(ref)])[0] - if isinstance(ref, TangoConfigRW): - return self.attach([TangoRW(ref)])[0] + return self._attach([ref],ref.index is not None)[0] + if isinstance(ref, TangoConfigAtt): + return self._attach([ref],ref.index is not None)[0] raise PyAMLException(f"Control system '{self.name()}' cannot build a device from {type(ref).__name__}") - def _attach(self, devs: list[OASignal | None], is_array: bool) -> list[OASignal | None]: + def _attach(self, configs: list[BaseModel | None], is_array: bool) -> list[OASignal | None]: # Concatenate the prefix newDevs = [] - for d in devs: - if d is not None: - sig_cfg = d._cfg + for sig_cfg in configs: + if sig_cfg is not None: sig_cfg_cls = sig_cfg.__class__ - effective_is_array = is_array or getattr(d, "is_array", False) index_str = "" if sig_cfg.index is None else str(sig_cfg.index) - if isinstance(d._cfg, EpicsConfigR): - key = self._cfg.prefix + d._cfg.read_pvname + index_str + if isinstance(sig_cfg, EpicsConfigR): + key = self._cfg.prefix + sig_cfg.read_pvname + index_str sig_cls = EpicsR - config = dict(read_pvname=self._cfg.prefix + d._cfg.read_pvname) - elif isinstance(d._cfg, EpicsConfigW): - key = self._cfg.prefix + d._cfg.write_pvname + index_str + config = dict(read_pvname=self._cfg.prefix + sig_cfg.read_pvname) + elif isinstance(sig_cfg, EpicsConfigW): + key = self._cfg.prefix + sig_cfg.write_pvname + index_str sig_cls = EpicsW - config = dict(write_pvname=self._cfg.prefix + d._cfg.write_pvname) - elif isinstance(d._cfg, EpicsConfigRW): - key = self._cfg.prefix + d._cfg.read_pvname + d._cfg.write_pvname + index_str + config = dict(write_pvname=self._cfg.prefix + sig_cfg.write_pvname) + elif isinstance(sig_cfg, EpicsConfigRW): + key = self._cfg.prefix + sig_cfg.read_pvname + sig_cfg.write_pvname + index_str sig_cls = EpicsRW config = dict( - read_pvname=self._cfg.prefix + d._cfg.read_pvname, - write_pvname=self._cfg.prefix + d._cfg.write_pvname, + read_pvname=self._cfg.prefix + sig_cfg.read_pvname, + write_pvname=self._cfg.prefix + sig_cfg.write_pvname, ) - elif isinstance(d._cfg, TangoConfigR): - key = self._cfg.prefix + d._cfg.attribute + index_str - sig_cls = TangoR - config = dict(attribute=self._cfg.prefix + d._cfg.attribute) - elif isinstance(d._cfg, TangoConfigRW): - key = self._cfg.prefix + d._cfg.attribute + index_str - sig_cls = TangoRW - config = dict(attribute=self._cfg.prefix + d._cfg.attribute) + elif isinstance(sig_cfg, TangoConfigAtt): + key = self._cfg.prefix + sig_cfg.attribute + index_str + sig_cls = TangoAtt + config = dict(attribute=self._cfg.prefix + sig_cfg.attribute) else: raise PyAMLException(f"OphydAsyncControlSystem: Unsupported type {type(sig_cfg)}") if key not in self._devices: - n_conf = dict(d._cfg) | config - nr = sig_cls(sig_cfg_cls(**n_conf), effective_is_array) + n_conf = dict(sig_cfg) | config + nr = sig_cls(sig_cfg_cls(**n_conf), is_array) nr.build() self._devices[key] = nr diff --git a/pyaml_cs_oa/epics_catalog.py b/pyaml_cs_oa/epics_catalog.py deleted file mode 100644 index 6f53a20..0000000 --- a/pyaml_cs_oa/epics_catalog.py +++ /dev/null @@ -1,84 +0,0 @@ -from pyaml.common.exception import PyAMLException -from pyaml.control.deviceaccess import DeviceAccess -from pydantic import ConfigDict - -from .catalog import Catalog, CatalogConfigModel - -PYAMLCLASS = "EpicsCatalog" - - -class ConfigModel(CatalogConfigModel): - """ - Dynamic EPICS catalog. - - The key passed to ``resolve()`` IS the PV specification string — no - ``entries`` list required. Resolutions are cached after the first call. - - Supported key formats:: - (TODO write only) - - "PV" → scalar read-only - "PV@n" → array PV, element n, read-only - "(R_PV, W_PV)" → scalar read-write - "(R_PV, W_PV)@n" → scalar indexed read-write - - Example - ------- - .. code-block:: yaml - - type: pyaml_cs_oa.epics_catalog - name: id-catalog - timeout_ms: 3000 - """ - - model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") - timeout_ms: int = 3000 - prefix: str = "" # Ignored - - -class EpicsCatalog(Catalog): - def __init__(self, cfg: ConfigModel): - super().__init__(cfg) - - def resolve(self, key: str) -> DeviceAccess: - return _build_device(key, self._cfg.timeout_ms) - - -# ── PV spec parser ──────────────────────────────────────────────────────────── - - -def _parse_pv(token: str) -> tuple[list[str], int | None]: - token = token.strip() - - # No suffix means scalar access to the full PV value. - index = None - if "@" in token: - token, idx_str = token.rsplit("@", 1) - token = token.strip() - try: - index = int(idx_str.strip()) - except ValueError: - raise PyAMLException(f"EpicsCatalog: invalid index in PV token '{token}'") from None - - # Parenthesized keys describe one read PV and one write PV. - if token.startswith("(") and token.endswith(")"): - names = token[1:-1] - name_list = [name.strip() for name in names.split(",")] - else: - name_list = [token.strip()] - - return name_list, index - - -def _build_device(pv_str: str, timeout_ms: int) -> DeviceAccess: - from .epicsR import ConfigModel as EpicsRConfig - from .epicsR import EpicsR - from .epicsRW import ConfigModel as EpicsRWConfig - from .epicsRW import EpicsRW - - pv_names, index = _parse_pv(pv_str) - if len(pv_names) == 1: - return EpicsR(EpicsRConfig(read_pvname=pv_names[0], timeout_ms=timeout_ms, index=index)) - if len(pv_names) == 2: - return EpicsRW(EpicsRWConfig(read_pvname=pv_names[0], write_pvname=pv_names[1], timeout_ms=timeout_ms, index=index)) - raise PyAMLException(f"EpicsCatalog: too many comma-separated tokens in key '{pv_str}' (max 2)") diff --git a/pyaml_cs_oa/epics_static_catalog.py b/pyaml_cs_oa/epics_static_catalog.py deleted file mode 100644 index 332577a..0000000 --- a/pyaml_cs_oa/epics_static_catalog.py +++ /dev/null @@ -1,75 +0,0 @@ -from pyaml.common.exception import PyAMLException -from pyaml.control.deviceaccess import DeviceAccess -from pydantic import ConfigDict - -from .catalog import Catalog, CatalogConfigModel -from .epics_static_catalog_entry import EpicsStaticCatalogEntry - -PYAMLCLASS = "EpicsStaticCatalog" - - -class ConfigModel(CatalogConfigModel): - """ - Static EPICS catalog — ordered list of typed entries. - - Each entry is an ``EpicsStaticCatalogEntry`` whose ``device`` is one of - ``EpicsR``, ``EpicsW``, or ``EpicsRW``. Index fields on the device config - control array-element access: - - * ``index`` — common index applied to both read and write sides of ``EpicsRW``. - * ``read_index`` / ``write_index`` — per-side overrides (``EpicsRW`` only). - - Example - ------- - .. code-block:: yaml - - type: pyaml_cs_oa.epics_static_catalog - name: device-catalog - entries: - - type: pyaml_cs_oa.epics_static_catalog_entry - key: magnet_current - device: - type: pyaml_cs_oa.epicsRW - read_pvname: HS4P2D1R:rdbk - write_pvname: HS4P2D1R:set - unit: A - - type: pyaml_cs_oa.epics_static_catalog_entry - key: id_gap - device: - type: pyaml_cs_oa.epicsRW - read_pvname: R:GAP:ALL_ID - write_pvname: W:GAP:ID_SET - read_index: 24 - write_index: 30 - unit: mm - - type: pyaml_cs_oa.epics_static_catalog_entry - key: phase - device: - type: pyaml_cs_oa.epicsR - read_pvname: R:PHASE:ALL - index: 24 - unit: mm - """ - - model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") - - entries: list[EpicsStaticCatalogEntry] - - -class EpicsStaticCatalog(Catalog): - def __init__(self, cfg: ConfigModel): - super().__init__(cfg) - if not cfg.entries: - raise PyAMLException("EpicsStaticCatalog.entries must contain at least one entry") - self._refs: dict[str, DeviceAccess] = {} - for entry in cfg.entries: - key = entry.get_key() - if key in self._refs: - raise PyAMLException(f"EpicsStaticCatalog '{self.get_name()}': duplicate key '{key}'") - self._refs[key] = entry.get_device() - - def resolve(self, key: str) -> DeviceAccess: - try: - return self._refs[key] - except KeyError as exc: - raise PyAMLException(f"Catalog '{self.get_name()}' cannot resolve key '{key}'") from exc diff --git a/pyaml_cs_oa/epics_static_catalog_entry.py b/pyaml_cs_oa/epics_static_catalog_entry.py deleted file mode 100644 index 9673280..0000000 --- a/pyaml_cs_oa/epics_static_catalog_entry.py +++ /dev/null @@ -1,22 +0,0 @@ -from pyaml.control.deviceaccess import DeviceAccess -from pydantic import BaseModel, ConfigDict - -PYAMLCLASS = "EpicsStaticCatalogEntry" - - -class ConfigModel(BaseModel): - model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") - - key: str - device: DeviceAccess # EpicsR, EpicsW, or EpicsRW instance (build() not yet called) - - -class EpicsStaticCatalogEntry: - def __init__(self, cfg: ConfigModel): - self._cfg = cfg - - def get_key(self) -> str: - return self._cfg.key - - def get_device(self) -> DeviceAccess: - return self._cfg.device diff --git a/pyaml_cs_oa/signal.py b/pyaml_cs_oa/signal.py index 95907f1..8aac593 100644 --- a/pyaml_cs_oa/signal.py +++ b/pyaml_cs_oa/signal.py @@ -5,8 +5,7 @@ EpicsConfigR, EpicsConfigRW, EpicsConfigW, - TangoConfigR, - TangoConfigRW, + TangoConfigAtt, ) @@ -21,8 +20,8 @@ def __init__(self, cfg: ControlSysConfig, is_array: bool = False): self.is_array = is_array or cfg.index is not None def build(self): - self._readable: bool = isinstance(self._cfg, (EpicsConfigR, TangoConfigR)) - self._writable: bool = isinstance(self._cfg, (EpicsConfigRW, EpicsConfigW, TangoConfigRW)) + self._readable: bool = isinstance(self._cfg, (EpicsConfigR, TangoConfigAtt)) + self._writable: bool = isinstance(self._cfg, (EpicsConfigRW, EpicsConfigW, TangoConfigAtt)) cs_name = self.get_cs() if cs_name == "tango": @@ -50,7 +49,7 @@ def measure_name(self) -> str: return self._cfg.read_pvname elif isinstance(self._cfg, EpicsConfigW): return self._cfg.write_pvname - elif isinstance(self._cfg, (TangoConfigR, TangoConfigRW)): + elif isinstance(self._cfg, TangoConfigAtt): return self._cfg.attribute else: raise ValueError(f"Unsupported control system config type: {type(self._cfg)!r}") @@ -59,7 +58,7 @@ def unit(self) -> str: return self._cfg.unit def get_range(self) -> list: - if isinstance(self._cfg, (EpicsConfigW, EpicsConfigRW, TangoConfigRW)): + if isinstance(self._cfg, (EpicsConfigW, EpicsConfigRW, TangoConfigAtt)): if self._cfg.range: return self._cfg.range return [None, None] diff --git a/pyaml_cs_oa/static_catalog.py b/pyaml_cs_oa/static_catalog.py index 14530c9..3c774fc 100644 --- a/pyaml_cs_oa/static_catalog.py +++ b/pyaml_cs_oa/static_catalog.py @@ -1,18 +1,16 @@ from pyaml.common.exception import PyAMLException from pyaml.control.deviceaccess import DeviceAccess -from pydantic import ConfigDict +from pydantic import ConfigDict, BaseModel -from .catalog import Catalog, CatalogConfigModel +from .catalog import Catalog from .static_catalog_entry import StaticCatalogEntry PYAMLCLASS = "StaticCatalog" -class ConfigModel(CatalogConfigModel): +class ConfigModel(BaseModel): """ Static catalog: a fixed mapping of keys to DeviceAccess instances. - - Works with any DeviceAccess (EpicsR, TangoRW, IndexedFloatSignal, …). Keys are resolved at construction time; no control-system connection is required. The catalog instance is shared across all control systems that reference it. """ @@ -34,8 +32,8 @@ def __init__(self, cfg: ConfigModel): raise PyAMLException(f"StaticCatalog.entries contains duplicate key '{key}'") self._refs[key] = entry.get_device() - def resolve(self, key: str) -> DeviceAccess: + def resolve(self, key: str) -> BaseModel: try: - return self._refs[key] + return self._refs[key]._cfg except KeyError as exc: raise PyAMLException(f"Catalog '{self.get_name()}' cannot resolve key '{key}'") from exc diff --git a/pyaml_cs_oa/tango.py b/pyaml_cs_oa/tango.py index e022a4d..6060815 100644 --- a/pyaml_cs_oa/tango.py +++ b/pyaml_cs_oa/tango.py @@ -6,8 +6,7 @@ from .container import OASetpoint as Setpoint from .types import ( ControlSysConfig, - TangoConfigR, - TangoConfigRW, + TangoConfigAtt, ) @@ -15,25 +14,15 @@ def get_SP_RB(cfg: ControlSysConfig, is_array: bool) -> tuple[Setpoint | None, R setpoint: Setpoint | None = None readback: Readback | None = None - assert isinstance(cfg, (TangoConfigRW, TangoConfigR)) + assert isinstance(cfg, TangoConfigAtt) - if isinstance(cfg, (TangoConfigR)): - r_sig = tango_signal_r( - datatype=float if not is_array else Array1D[numpy.float64], - read_trl=cfg.attribute, - timeout=cfg.timeout_ms, - ) - readback = Readback(r_sig) - setpoint = None - - elif isinstance(cfg, (TangoConfigRW)): - rw_sig = tango_signal_rw( - datatype=float if not is_array else Array1D[numpy.float64], - read_trl=cfg.attribute, - write_trl=cfg.attribute, - timeout=cfg.timeout_ms, - ) - readback = Readback(rw_sig) - setpoint = Setpoint(rw_sig) + rw_sig = tango_signal_rw( + datatype=float if not is_array else Array1D[numpy.float64], + read_trl=cfg.attribute, + write_trl=cfg.attribute, + timeout=cfg.timeout_ms, + ) + readback = Readback(rw_sig) + setpoint = Setpoint(rw_sig) return setpoint, readback diff --git a/pyaml_cs_oa/tangoR.py b/pyaml_cs_oa/tangoR.py deleted file mode 100644 index 5f6cdee..0000000 --- a/pyaml_cs_oa/tangoR.py +++ /dev/null @@ -1,15 +0,0 @@ -from .float_signal import FloatSignalContainer -from .types import TangoConfigR - -PYAMLCLASS: str = "TangoR" - - -class ConfigModel(TangoConfigR): ... - - -class TangoR(FloatSignalContainer): - def __init__(self, cfg: ConfigModel, is_array: bool = False): - super().__init__(cfg, is_array) - - def get_cs(self) -> str: - return "tango" diff --git a/pyaml_cs_oa/tangoRW.py b/pyaml_cs_oa/tangoRW.py deleted file mode 100644 index 3410dc4..0000000 --- a/pyaml_cs_oa/tangoRW.py +++ /dev/null @@ -1,15 +0,0 @@ -from .float_signal import FloatSignalContainer -from .types import TangoConfigRW - -PYAMLCLASS: str = "TangoRW" - - -class ConfigModel(TangoConfigRW): ... - - -class TangoRW(FloatSignalContainer): - def __init__(self, cfg: ConfigModel, is_array: bool = False): - super().__init__(cfg, is_array) - - def get_cs(self) -> str: - return "tango" diff --git a/pyaml_cs_oa/tango_catalog.py b/pyaml_cs_oa/tango_catalog.py deleted file mode 100644 index 18b77ea..0000000 --- a/pyaml_cs_oa/tango_catalog.py +++ /dev/null @@ -1,137 +0,0 @@ -from pyaml.common.exception import PyAMLException -from pyaml.control.deviceaccess import DeviceAccess -from pydantic import ConfigDict - -from .catalog import Catalog, CatalogConfigModel - -PYAMLCLASS = "TangoCatalog" - - -class ConfigModel(CatalogConfigModel): - """ - Dynamic Tango catalog resolving keys that are Tango attribute references. - - Supported key formats: - - ``domain/family/member/attribute`` → scalar signal - - ``domain/family/member/attribute@index`` → one element of a SPECTRUM signal - - In *disconnected* mode (``disconnected: true``) the catalog builds signals - without querying Tango (writability and data-format are not verified). - In *connected* mode the catalog queries ``tango.AttributeProxy`` at first - resolution to detect writability and, for indexed keys, to verify SPECTRUM. - """ - - model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") - - disconnected: bool = False - timeout_ms: int = 3000 - - -class TangoCatalog(Catalog): - - def __init__(self, cfg: ConfigModel): - super().__init__(cfg) - self._refs: dict[str, DeviceAccess] = {} - - def resolve(self, key: str) -> DeviceAccess: - if key not in self._refs: - attr_path, index = self._parse_key(key) - if index is not None: - self._refs[key] = self._build_indexed(attr_path, index) - else: - self._refs[key] = self._build_attribute(attr_path) - return self._refs[key] - - # key parsing - - def _parse_key(self, key: str) -> tuple[str, int | None]: - if not isinstance(key, str): - raise PyAMLException(f"OA Tango catalog '{self.get_name()}' expects string keys, got {type(key).__name__}") - if "@" in key: - attr_path, idx_str = key.rsplit("@", 1) - try: - index = int(idx_str) - except ValueError: - raise PyAMLException( - f"OA Tango catalog '{self.get_name()}': invalid index '{idx_str}' in key '{key}'" - ) from None - else: - attr_path = key - index = None - - parts = attr_path.split("/") - if len(parts) != 4 or any(p == "" for p in parts): - raise PyAMLException( - f"OA Tango catalog '{self.get_name()}' cannot resolve '{key}'. " - "Expected 'domain/family/member/attribute[@index]'." - ) - return attr_path, index - - # signal builders - - def _timeout(self) -> int: - return self._cfg.timeout_ms - - def _build_attribute(self, attr_path: str) -> DeviceAccess: - if self._cfg.disconnected: - return self._make_rw(attr_path, is_array=False) - - try: - import tango - except ImportError as exc: - raise PyAMLException("pytango is required for TangoCatalog in connected mode") from exc - - try: - attr_cfg = tango.AttributeProxy(attr_path).get_config() - except tango.DevFailed as df: - raise PyAMLException(f"OA Tango catalog '{self.get_name()}' cannot resolve '{attr_path}': {df}") from df - - writable_types = { - tango.AttrWriteType.READ_WRITE, - tango.AttrWriteType.WRITE, - tango.AttrWriteType.READ_WITH_WRITE, - } - is_writable = getattr(attr_cfg, "writable", tango.AttrWriteType.WT_UNKNOWN) in writable_types - is_array = getattr(attr_cfg, "data_format", None) == tango.AttrDataFormat.SPECTRUM - - if is_writable: - return self._make_rw(attr_path, is_array=is_array) - return self._make_r(attr_path, is_array=is_array) - - def _build_indexed(self, attr_path: str, index: int) -> DeviceAccess: - if not self._cfg.disconnected: - try: - import tango - except ImportError as exc: - raise PyAMLException("pytango is required for TangoCatalog in connected mode") from exc - - try: - attr_cfg = tango.AttributeProxy(attr_path).get_config() - except tango.DevFailed as df: - raise PyAMLException( - f"OA Tango catalog '{self.get_name()}' cannot resolve '{attr_path}@{index}': {df}" - ) from df - - data_format = getattr(attr_cfg, "data_format", None) - if data_format != tango.AttrDataFormat.SPECTRUM: - raise PyAMLException( - f"OA Tango catalog '{self.get_name()}': '{attr_path}' " - "is not a SPECTRUM; indexed access requires a vector attribute." - ) - - # index is embedded in the config; build() applies it automatically. - return self._make_r(attr_path, index=index) - - # helpers - - def _make_r(self, attr_path: str, is_array: bool = False, index: int | None = None): - from .tangoR import ConfigModel as TangoRConfig - from .tangoR import TangoR - - return TangoR(TangoRConfig(attribute=attr_path, timeout_ms=self._timeout(), index=index), is_array) - - def _make_rw(self, attr_path: str, is_array: bool = False): - from .tangoRW import ConfigModel as TangoRWConfig - from .tangoRW import TangoRW - - return TangoRW(TangoRWConfig(attribute=attr_path, timeout_ms=self._timeout()), is_array) diff --git a/pyaml_cs_oa/types.py b/pyaml_cs_oa/types.py index c837547..15d6f70 100644 --- a/pyaml_cs_oa/types.py +++ b/pyaml_cs_oa/types.py @@ -27,15 +27,7 @@ class EpicsConfigRW(BaseModel): index: int | None = None unit: str = "" - -class TangoConfigR(BaseModel): - attribute: str - timeout_ms: int = 3000 - index: int | None = None - unit: str = "" - - -class TangoConfigRW(BaseModel): +class TangoConfigAtt(BaseModel): attribute: str timeout_ms: int = 3000 range: list[float] | None = None @@ -43,4 +35,4 @@ class TangoConfigRW(BaseModel): unit: str = "" -ControlSysConfig = EpicsConfigR | EpicsConfigW | EpicsConfigRW | TangoConfigR | TangoConfigRW +ControlSysConfig = EpicsConfigR | EpicsConfigW | EpicsConfigRW | TangoConfigAtt diff --git a/tests/test_bpm_orbit.py b/tests/test_bpm_orbit.py index e89bb1d..c63f799 100644 --- a/tests/test_bpm_orbit.py +++ b/tests/test_bpm_orbit.py @@ -1,4 +1,5 @@ from typing import Any +from pydantic import BaseModel, ConfigDict import numpy as np from pyaml.arrays.bpm_array import BPMArray @@ -9,7 +10,7 @@ from pyaml.control.abstract_impl import RBpmArray from pyaml.control.deviceaccess import DeviceAccess -from pyaml_cs_oa.catalog import Catalog, CatalogConfigModel +from pyaml_cs_oa.catalog import Catalog from pyaml_cs_oa.controlsystem import ConfigModel, OphydAsyncControlSystem from pyaml_cs_oa.float_signal import FloatSignalContainer from pyaml_cs_oa.types import EpicsConfigR @@ -63,15 +64,18 @@ async def async_get(self) -> np.ndarray: def get(self) -> np.ndarray: return self._source.get() +class IndexedVectorSignalConfig(EpicsConfigR): + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + source : VectorDevice class IndexedVectorSignal(FloatSignalContainer): """FloatSignalContainer fake resolving one indexed value from a shared vector.""" - def __init__(self, source: VectorDevice, index: int) -> None: - super().__init__(EpicsConfigR(read_pvname=source.name(), index=index, unit=source.unit()), is_array=True) + def __init__(self, config: IndexedVectorSignalConfig) -> None: + super().__init__(config, is_array=True) self._readable = True self._writable = False - self.RB = _VectorReadSide(source) + self.RB = _VectorReadSide(config.source) self.SP = None def name(self) -> str: @@ -80,22 +84,20 @@ def name(self) -> str: class StaticCatalog(Catalog): def __init__(self, devices: dict[str, DeviceAccess]) -> None: - super().__init__(CatalogConfigModel(name="test-catalog")) self._devices = devices - def resolve(self, key: str) -> DeviceAccess: + def resolve(self, key: str) -> BaseModel: return self._devices[key] class IdentityAttachControlSystem(OphydAsyncControlSystem): """Control system fake that keeps pre-built DeviceAccess objects attached.""" - def attach(self, devs: list[DeviceAccess]) -> list[DeviceAccess]: - return devs - - def attach_array(self, devs: list[DeviceAccess]) -> list[DeviceAccess]: - return devs + # attach public methods are depecrated + def get_device(self, ref: str | BaseModel | None) -> DeviceAccess | None: + config = self._cfg.catalog.resolve(ref) + return IndexedVectorSignal(config) def _attached_indexed_bpm( control_system: IdentityAttachControlSystem, @@ -119,11 +121,17 @@ def _attached_indexed_bpm( def _control_system_with_indexed_orbit(orbit_device: VectorDevice, bpm_count: int) -> IdentityAttachControlSystem: - control_system = IdentityAttachControlSystem(ConfigModel(name="live")) - control_system._cfg.catalog = StaticCatalog( - {f"BPM{bpm_index}:X": IndexedVectorSignal(orbit_device, 2 * bpm_index) for bpm_index in range(bpm_count)} - | {f"BPM{bpm_index}:Y": IndexedVectorSignal(orbit_device, (2 * bpm_index) + 1) for bpm_index in range(bpm_count)}, + catalog = StaticCatalog( + {f"BPM{bpm_index}:X": IndexedVectorSignalConfig(source=orbit_device, + read_pvname=orbit_device.name(), + unit=orbit_device.unit(), + index=2 * bpm_index) for bpm_index in range(bpm_count)} + | {f"BPM{bpm_index}:Y": IndexedVectorSignalConfig(source=orbit_device, + read_pvname=orbit_device.name(), + unit=orbit_device.unit(), + index=2 * bpm_index + 1) for bpm_index in range(bpm_count)}, ) + control_system = IdentityAttachControlSystem(ConfigModel(name="live",catalog=catalog)) return control_system diff --git a/tests/test_controlsystem.py b/tests/test_controlsystem.py deleted file mode 100644 index cb1381c..0000000 --- a/tests/test_controlsystem.py +++ /dev/null @@ -1,177 +0,0 @@ -import pytest -from pyaml.common.exception import PyAMLException -from pyaml.control.deviceaccess import DeviceAccess -from pydantic import BaseModel - -from pyaml_cs_oa.controlsystem import ConfigModel, OphydAsyncControlSystem -from pyaml_cs_oa.epicsR import ConfigModel as EpicsRConfig -from pyaml_cs_oa.epicsR import EpicsR -from pyaml_cs_oa.epicsRW import ConfigModel as EpicsRWConfig -from pyaml_cs_oa.epicsW import ConfigModel as EpicsWConfig -from pyaml_cs_oa.static_catalog import Catalog, CatalogConfigModel -from pyaml_cs_oa.tangoR import ConfigModel as TangoRConfig -from pyaml_cs_oa.tangoRW import ConfigModel as TangoRWConfig - - -def _no_connect_build(self) -> None: - self.SP = None - self.RB = None - self._readable = True - self._writable = False - - -class _Catalog(Catalog): - def __init__(self, devices: dict[str, DeviceAccess]) -> None: - super().__init__(CatalogConfigModel(name="catalog")) - self._devices = devices - - def resolve(self, key: str) -> DeviceAccess: - try: - return self._devices[key] - except KeyError as exc: - raise PyAMLException(f"missing key {key}") from exc - - -def test_controlsystem_exposes_name_and_aggregator_modules() -> None: - cfg = ConfigModel(name="live", prefix="PREFIX:", vector_aggregator="vector.mod") - control_system = OphydAsyncControlSystem(cfg) - - assert control_system.name() == "live" - assert control_system.scalar_aggregator() == "pyaml_cs_oa.scalar_aggregator" - assert control_system.vector_aggregator() == "vector.mod" - - -def test_attach_prefixes_epics_read_device(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(EpicsR, "build", _no_connect_build) - control_system = OphydAsyncControlSystem(ConfigModel(name="live", prefix="P:")) - device = EpicsR(EpicsRConfig(read_pvname="RB")) - - attached = control_system.attach([device])[0] - - assert isinstance(attached, EpicsR) - assert attached._cfg.read_pvname == "P:RB" - - -def test_attach_prefixes_epics_read_write_device( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr("pyaml_cs_oa.epicsRW.EpicsRW.build", _no_connect_build) - control_system = OphydAsyncControlSystem(ConfigModel(name="live", prefix="P:")) - device = EpicsR(EpicsRWConfig(read_pvname="RB", write_pvname="SP")) - - attached = control_system.attach([device])[0] - - assert attached._cfg.read_pvname == "P:RB" - assert attached._cfg.write_pvname == "P:SP" - - -def test_attach_prefixes_epics_write_device(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("pyaml_cs_oa.epicsW.EpicsW.build", _no_connect_build) - control_system = OphydAsyncControlSystem(ConfigModel(name="live", prefix="P:")) - device = EpicsR(EpicsWConfig(write_pvname="SP")) - - attached = control_system.attach([device])[0] - - assert attached._cfg.write_pvname == "P:SP" - - -def test_attach_prefixes_tango_devices(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("pyaml_cs_oa.tangoR.TangoR.build", _no_connect_build) - monkeypatch.setattr("pyaml_cs_oa.tangoRW.TangoRW.build", _no_connect_build) - control_system = OphydAsyncControlSystem( - ConfigModel(name="live", prefix="//tango-host:10000/"), - ) - - read_only, read_write = control_system.attach( - [ - EpicsR(TangoRConfig(attribute="sys/tg_test/1/value")), - EpicsR(TangoRWConfig(attribute="sys/tg_test/2/value")), - ], - ) - - assert read_only._cfg.attribute == "//tango-host:10000/sys/tg_test/1/value" - assert read_write._cfg.attribute == "//tango-host:10000/sys/tg_test/2/value" - - -def test_attach_reuses_existing_device_for_same_key( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(EpicsR, "build", _no_connect_build) - control_system = OphydAsyncControlSystem(ConfigModel(name="live", prefix="P:")) - - first = control_system.attach([EpicsR(EpicsRConfig(read_pvname="RB"))])[0] - second = control_system.attach([EpicsR(EpicsRConfig(read_pvname="RB"))])[0] - - assert first is second - - -def test_attach_preserves_none_entries() -> None: - control_system = OphydAsyncControlSystem(ConfigModel(name="live")) - - assert control_system.attach([None]) == [None] - - -def test_attach_rejects_unsupported_config_type() -> None: - class UnsupportedConfig: - index = None - - class UnsupportedDevice: - _cfg = UnsupportedConfig() - - control_system = OphydAsyncControlSystem(ConfigModel(name="live")) - - with pytest.raises(PyAMLException, match="Unsupported type"): - control_system.attach([UnsupportedDevice()]) - - -def test_get_device_returns_none_for_none_reference() -> None: - control_system = OphydAsyncControlSystem(ConfigModel(name="live")) - - assert control_system.get_device(None) is None - - -def test_get_device_constructs_and_attaches_epics_config(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(EpicsR, "build", _no_connect_build) - control_system = OphydAsyncControlSystem(ConfigModel(name="live", prefix="P:")) - - device = control_system.get_device(EpicsRConfig(read_pvname="RB")) - - assert isinstance(device, EpicsR) - assert device._cfg.read_pvname == "P:RB" - - -def test_get_device_resolves_string_through_catalog(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(EpicsR, "build", _no_connect_build) - cat = _Catalog({"read": EpicsR(EpicsRConfig(read_pvname="RB"))}) - control_system = OphydAsyncControlSystem(ConfigModel(name="live", prefix="P:", catalog=cat)) - device = control_system.get_device("read") - assert isinstance(device, EpicsR) - assert device._cfg.read_pvname == "P:RB" - - -def test_get_device_reuses_attached_catalog_device(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(EpicsR, "build", _no_connect_build) - cat = _Catalog({"read": EpicsR(EpicsRConfig(read_pvname="RB"))}) - control_system = OphydAsyncControlSystem(ConfigModel(name="live", prefix="P:", catalog=cat)) - - first = control_system.get_device("read") - second = control_system.get_device("read") - - assert first is second - - -def test_get_device_rejects_unknown_reference_type() -> None: - class UnknownConfig(BaseModel): - value: str - - control_system = OphydAsyncControlSystem(ConfigModel(name="live")) - - with pytest.raises(PyAMLException, match="cannot build a device from UnknownConfig"): - control_system.get_device(UnknownConfig(value="x")) - - -def test_get_device_rejects_already_constructed_device() -> None: - control_system = OphydAsyncControlSystem(ConfigModel(name="live")) - - with pytest.raises(PyAMLException, match="cannot build a device from EpicsR"): - control_system.get_device(EpicsR(EpicsRConfig(read_pvname="RB"))) diff --git a/tests/test_epics_catalog.py b/tests/test_epics_catalog.py deleted file mode 100644 index b4dbc2b..0000000 --- a/tests/test_epics_catalog.py +++ /dev/null @@ -1,163 +0,0 @@ -import pytest -from pyaml.common.exception import PyAMLException - -from pyaml_cs_oa.epics_catalog import ConfigModel as DynamicCatalogConfig -from pyaml_cs_oa.epics_catalog import EpicsCatalog -from pyaml_cs_oa.epics_static_catalog import ConfigModel as StaticCatalogConfig -from pyaml_cs_oa.epics_static_catalog import EpicsStaticCatalog -from pyaml_cs_oa.epics_static_catalog_entry import ConfigModel as StaticCatalogEntryConfig -from pyaml_cs_oa.epics_static_catalog_entry import EpicsStaticCatalogEntry -from pyaml_cs_oa.epicsR import ConfigModel as EpicsRConfig -from pyaml_cs_oa.epicsR import EpicsR -from pyaml_cs_oa.epicsRW import ConfigModel as EpicsRWConfig -from pyaml_cs_oa.epicsRW import EpicsRW -from pyaml_cs_oa.epicsW import ConfigModel as EpicsWConfig -from pyaml_cs_oa.epicsW import EpicsW - - -def _entry(key: str, device) -> EpicsStaticCatalogEntry: - return EpicsStaticCatalogEntry(StaticCatalogEntryConfig(key=key, device=device)) - - -def _record_build(self) -> None: - self.build_calls = getattr(self, "build_calls", 0) + 1 - - -def test_dynamic_epics_catalog_resolves_scalar_read_key_without_index() -> None: - catalog = EpicsCatalog(DynamicCatalogConfig(name="epics", timeout_ms=1234)) - - device = catalog.resolve("PV:RB") - - assert isinstance(device, EpicsR) - assert device._cfg.read_pvname == "PV:RB" - assert device._cfg.timeout_ms == 1234 - assert device._cfg.index is None - - -def test_dynamic_epics_catalog_resolves_indexed_read_key() -> None: - catalog = EpicsCatalog(DynamicCatalogConfig(name="epics")) - - device = catalog.resolve("PV:ARRAY@3") - - assert isinstance(device, EpicsR) - assert device._cfg.read_pvname == "PV:ARRAY" - assert device._cfg.index == 3 - - -def test_dynamic_epics_catalog_resolves_read_write_key() -> None: - catalog = EpicsCatalog(DynamicCatalogConfig(name="epics")) - - device = catalog.resolve("(PV:RB, PV:SP)") - - assert isinstance(device, EpicsRW) - assert device._cfg.read_pvname == "PV:RB" - assert device._cfg.write_pvname == "PV:SP" - assert device._cfg.index is None - - -def test_dynamic_epics_catalog_resolves_indexed_read_write_key() -> None: - catalog = EpicsCatalog(DynamicCatalogConfig(name="epics")) - - device = catalog.resolve("(PV:RB, PV:SP)@5") - - assert isinstance(device, EpicsRW) - assert device._cfg.read_pvname == "PV:RB" - assert device._cfg.write_pvname == "PV:SP" - assert device._cfg.index == 5 - - -def test_dynamic_epics_catalog_strips_whitespace_from_pv_names_and_index() -> None: - catalog = EpicsCatalog(DynamicCatalogConfig(name="epics")) - - device = catalog.resolve(" ( PV:RB , PV:SP ) @ 7 ") - - assert isinstance(device, EpicsRW) - assert device._cfg.read_pvname == "PV:RB" - assert device._cfg.write_pvname == "PV:SP" - assert device._cfg.index == 7 - - -def test_dynamic_epics_catalog_rejects_invalid_index() -> None: - catalog = EpicsCatalog(DynamicCatalogConfig(name="epics")) - - with pytest.raises(PyAMLException, match="invalid index"): - catalog.resolve("PV:ARRAY@not-an-index") - - -def test_dynamic_epics_catalog_rejects_too_many_read_write_tokens() -> None: - catalog = EpicsCatalog(DynamicCatalogConfig(name="epics")) - - with pytest.raises(PyAMLException, match="too many comma-separated tokens"): - catalog.resolve("(PV:ONE, PV:TWO, PV:THREE)") - - -def test_static_epics_catalog_entry_exposes_key_and_device() -> None: - device = EpicsR(EpicsRConfig(read_pvname="PV:RB")) - entry = _entry("readback", device) - - assert entry.get_key() == "readback" - assert entry.get_device() is device - - -def test_static_epics_catalog_resolves_read_write_and_write_entries_without_building( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(EpicsR, "build", _record_build) - monkeypatch.setattr(EpicsRW, "build", _record_build) - monkeypatch.setattr(EpicsW, "build", _record_build) - - read = EpicsR(EpicsRConfig(read_pvname="PV:RB", index=2)) - read_write = EpicsRW(EpicsRWConfig(read_pvname="PV:RW:RB", write_pvname="PV:RW:SP")) - write = EpicsW(EpicsWConfig(write_pvname="PV:SP")) - catalog = EpicsStaticCatalog( - StaticCatalogConfig( - name="static-epics", - entries=[ - _entry("read", read), - _entry("read_write", read_write), - _entry("write", write), - ], - ), - ) - - assert catalog.resolve("read") is read - assert catalog.resolve("read_write") is read_write - assert catalog.resolve("write") is write - assert not hasattr(read, "build_calls") - assert not hasattr(read_write, "build_calls") - assert not hasattr(write, "build_calls") - - -def test_static_epics_catalog_rejects_empty_entries() -> None: - with pytest.raises(PyAMLException, match="must contain at least one entry"): - EpicsStaticCatalog(StaticCatalogConfig(name="static-epics", entries=[])) - - -def test_static_epics_catalog_rejects_duplicate_keys(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(EpicsR, "build", _record_build) - first = EpicsR(EpicsRConfig(read_pvname="PV:FIRST")) - second = EpicsR(EpicsRConfig(read_pvname="PV:SECOND")) - - with pytest.raises(PyAMLException, match="duplicate key 'read'"): - EpicsStaticCatalog( - StaticCatalogConfig( - name="static-epics", - entries=[ - _entry("read", first), - _entry("read", second), - ], - ), - ) - - -def test_static_epics_catalog_rejects_unknown_key(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(EpicsR, "build", _record_build) - catalog = EpicsStaticCatalog( - StaticCatalogConfig( - name="static-epics", - entries=[_entry("read", EpicsR(EpicsRConfig(read_pvname="PV:RB")))], - ), - ) - - with pytest.raises(PyAMLException, match="cannot resolve key 'missing'"): - catalog.resolve("missing") diff --git a/tests/test_factories.py b/tests/test_factories.py index 4191780..92154cf 100644 --- a/tests/test_factories.py +++ b/tests/test_factories.py @@ -8,8 +8,7 @@ EpicsConfigR, EpicsConfigRW, EpicsConfigW, - TangoConfigR, - TangoConfigRW, + TangoConfigAtt, ) @@ -72,23 +71,6 @@ def test_epics_read_write_factory_reuses_single_rw_signal( assert spy.calls[0]["write_pv"] == "PV:SP" -def test_tango_read_only_factory_builds_readback( - monkeypatch: pytest.MonkeyPatch, -) -> None: - spy = SignalFactorySpy("tango-r") - monkeypatch.setattr(tango, "tango_signal_r", spy) - - setpoint, readback = tango.get_SP_RB( - TangoConfigR(attribute="sys/tg_test/1/value"), - False, - ) - - assert setpoint is None - assert isinstance(readback, OAReadback) - assert readback._r_sig == "tango-r" - assert spy.calls[0]["read_trl"] == "sys/tg_test/1/value" - - def test_tango_read_write_factory_reuses_single_rw_signal( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -96,7 +78,7 @@ def test_tango_read_write_factory_reuses_single_rw_signal( monkeypatch.setattr(tango, "tango_signal_rw", spy) setpoint, readback = tango.get_SP_RB( - TangoConfigRW(attribute="sys/tg_test/1/value"), + TangoConfigAtt(attribute="sys/tg_test/1/value"), False, ) diff --git a/tests/test_signal.py b/tests/test_signal.py index 41721ce..d2f35ea 100644 --- a/tests/test_signal.py +++ b/tests/test_signal.py @@ -5,9 +5,8 @@ from pyaml_cs_oa.epicsRW import ConfigModel as EpicsRWConfig from pyaml_cs_oa.epicsW import ConfigModel as EpicsWConfig from pyaml_cs_oa.epicsW import EpicsW -from pyaml_cs_oa.tangoR import ConfigModel as TangoRConfig -from pyaml_cs_oa.tangoR import TangoR -from pyaml_cs_oa.tangoRW import ConfigModel as TangoRWConfig +from pyaml_cs_oa.tangoAtt import ConfigModel as TangoAttConfig +from pyaml_cs_oa.tangoAtt import TangoAtt from pyaml_cs_oa.types import EpicsConfigW @@ -48,7 +47,7 @@ def fake_get_sp_rb(cfg, is_array): monkeypatch.setattr("pyaml_cs_oa.tango.get_SP_RB", fake_get_sp_rb) - signal = TangoR(TangoRConfig(attribute="sys/tg_test/1/value"), is_array=True) + signal = TangoAtt(TangoAttConfig(attribute="sys/tg_test/1/value"), is_array=True) signal.build() assert signal.SP is None @@ -63,7 +62,7 @@ def test_measure_name_uses_epics_read_pv_for_readable_signal() -> None: def test_measure_name_uses_tango_attribute() -> None: - signal = TangoR(TangoRConfig(attribute="sys/tg_test/1/value")) + signal = TangoAtt(TangoAttConfig(attribute="sys/tg_test/1/value")) assert signal.measure_name() == "sys/tg_test/1/value" diff --git a/tests/test_tango_catalog.py b/tests/test_tango_catalog.py deleted file mode 100644 index 1a01580..0000000 --- a/tests/test_tango_catalog.py +++ /dev/null @@ -1,51 +0,0 @@ -import pytest -from pyaml.common.exception import PyAMLException - -from pyaml_cs_oa.tango_catalog import ConfigModel, TangoCatalog -from pyaml_cs_oa.tangoR import TangoR -from pyaml_cs_oa.tangoRW import TangoRW - - -def test_disconnected_tango_catalog_resolves_scalar_attribute_as_read_write() -> None: - catalog = TangoCatalog(ConfigModel(name="tango", disconnected=True, timeout_ms=1234)) - - device = catalog.resolve("sys/tg_test/1/value") - - assert isinstance(device, TangoRW) - assert device._cfg.attribute == "sys/tg_test/1/value" - assert device._cfg.timeout_ms == 1234 - assert device._cfg.index is None - - -def test_disconnected_tango_catalog_resolves_indexed_attribute_as_read_only() -> None: - catalog = TangoCatalog(ConfigModel(name="tango", disconnected=True)) - - device = catalog.resolve("sys/tg_test/1/spectrum@4") - - assert isinstance(device, TangoR) - assert device._cfg.attribute == "sys/tg_test/1/spectrum" - assert device._cfg.index == 4 - assert device.is_array is True - - -def test_tango_catalog_reuses_resolved_device() -> None: - catalog = TangoCatalog(ConfigModel(name="tango", disconnected=True)) - - first = catalog.resolve("sys/tg_test/1/value") - second = catalog.resolve("sys/tg_test/1/value") - - assert first is second - - -def test_tango_catalog_rejects_invalid_attribute_key() -> None: - catalog = TangoCatalog(ConfigModel(name="tango", disconnected=True)) - - with pytest.raises(PyAMLException, match="Expected 'domain/family/member/attribute"): - catalog.resolve("sys/tg_test/value") - - -def test_tango_catalog_rejects_invalid_index() -> None: - catalog = TangoCatalog(ConfigModel(name="tango", disconnected=True)) - - with pytest.raises(PyAMLException, match="invalid index"): - catalog.resolve("sys/tg_test/1/spectrum@bad") From 767ef1ec63e0777a21fcd163b887beb1937f41ca Mon Sep 17 00:00:00 2001 From: PONS Date: Wed, 3 Jun 2026 16:17:17 +0200 Subject: [PATCH 28/32] Added missing files --- pyaml_cs_oa/dynamic_catalog.py | 135 +++++++++++++++++++++++++++++++++ pyaml_cs_oa/tangoAtt.py | 15 ++++ tests/test_dynamic_catalog.py | 111 +++++++++++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 pyaml_cs_oa/dynamic_catalog.py create mode 100644 pyaml_cs_oa/tangoAtt.py create mode 100644 tests/test_dynamic_catalog.py diff --git a/pyaml_cs_oa/dynamic_catalog.py b/pyaml_cs_oa/dynamic_catalog.py new file mode 100644 index 0000000..dd29c3e --- /dev/null +++ b/pyaml_cs_oa/dynamic_catalog.py @@ -0,0 +1,135 @@ +from pyaml.common.exception import PyAMLException +from pyaml.control.deviceaccess import DeviceAccess +from pydantic import ConfigDict,BaseModel +from typing import Tuple + +from .catalog import Catalog + +PYAMLCLASS = "DynamicCatalog" + +class ConfigModel(BaseModel): + """ + Default dynamic catalog. + + The key passed to ``resolve()`` is the PV or Tango attribute specification string — no + ``entries`` list required. Resolutions are cached after the first call. + + Supported key formats:: + + For EPCIS:: + + - ``READ_PV[unit]`` → scalar read-only + - ``READ_PV@index[unit]`` → array PV with element index, read-only + - ``(READ_PV, WRITE_PV)[unit]`` → scalar read-write + - ``(READ_PV, WRITE_PV)@index[unit]`` → array PV with element index read-write + + For Tango:: + + - ``domain/family/member/attribute[unit]`` → scalar signal + - ``domain/family/member/attribute@index[unit]`` → one element of a SPECTRUM signal + + Example + ------- + .. code-block:: yaml + backend: "Tango" or "Epics" + timeout_ms: 3000 + """ + + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + timeout_ms: int = 3000 + backend: str = "" + +class DynamicCatalog(Catalog): + + def __init__(self, cfg: ConfigModel): + self._cfg = cfg + self._dp = {} # Device proxy cache (Tango Only) + if cfg.backend.lower() != "tango" and cfg.backend.lower() != "epics": + raise PyAMLException(f"backend must be `epics` or `tango` but got '{cfg.backend}'") from None + + + def resolve(self, key: str) -> BaseModel: + if self._cfg.backend.lower() == "epics": + return _build_epics_config(key, self._cfg.timeout_ms) + elif self._cfg.backend.lower() == "tango": + return _build_tango_config(key, self._cfg.timeout_ms) + else: + return None + + +def _extract_unit(token: str) -> Tuple[str,str]: + + # Extract unit block + try: + start_idx = token.index('[') + 1 + end_idx = token.index(']', start_idx) + return (token[start_idx:end_idx],token[:start_idx-1]) + except ValueError: + return "" # No unit + + +# ── PV spec parser ──────────────────────────────────────────────────────────── + + +def _parse_pv(token: str) -> tuple[list[str], int | None, str]: + + token = token.strip() + + unit,token = _extract_unit(token) + + # No suffix means scalar access to the full PV value. + index = None + if "@" in token: + token, idx_str = token.rsplit("@", 1) + token = token.strip() + try: + index = int(idx_str.strip()) + except ValueError: + raise PyAMLException(f"Invalid index for {token}, cannot parse '{idx_str}' as number") from None + + # Parenthesized keys describe one read PV and one write PV. + if token.startswith("(") and token.endswith(")"): + names = token[1:-1] + name_list = [name.strip() for name in names.split(",")] + else: + name_list = [token.strip()] + + return name_list, index, unit + + +def _build_epics_config(pv_str: str, timeout_ms: int) -> DeviceAccess: + from .epicsR import ConfigModel as EpicsRConfig + from .epicsRW import ConfigModel as EpicsRWConfig + + pv_names, index, unit = _parse_pv(pv_str) + if len(pv_names) == 1: + return EpicsRConfig(read_pvname=pv_names[0], timeout_ms=timeout_ms, index=index, unit=unit) + if len(pv_names) == 2: + return EpicsRWConfig(read_pvname=pv_names[0], write_pvname=pv_names[1], timeout_ms=timeout_ms, index=index, unit=unit) + raise PyAMLException(f"Too many comma-separated tokens in key '{pv_str}' (max 2)") + +# ── Tango spec parser ──────────────────────────────────────────────────────────── + +def _parse_attribute(token: str) -> tuple[list[str], int | None, str]: + + token = token.strip() + + unit,token = _extract_unit(token) + + # No suffix means scalar access to the full PV value. + index = None + if "@" in token: + token, idx_str = token.rsplit("@", 1) + token = token.strip() + try: + index = int(idx_str.strip()) + except ValueError: + raise PyAMLException(f"Invalid index for {token}, cannot parse '{idx_str}' as number") from None + + return token, index, unit + +def _build_tango_config(att_name: str, timeout_ms: int) -> BaseModel: + + from .tangoAtt import ConfigModel as TangoAtt + att_name, index, unit = _parse_attribute(att_name) + return TangoAtt(attribute=att_name, timeout_ms=timeout_ms, index=index, unit=unit) diff --git a/pyaml_cs_oa/tangoAtt.py b/pyaml_cs_oa/tangoAtt.py new file mode 100644 index 0000000..0865cb6 --- /dev/null +++ b/pyaml_cs_oa/tangoAtt.py @@ -0,0 +1,15 @@ +from .float_signal import FloatSignalContainer +from .types import TangoConfigAtt + +PYAMLCLASS: str = "TangoRW" + + +class ConfigModel(TangoConfigAtt): ... + + +class TangoAtt(FloatSignalContainer): + def __init__(self, cfg: ConfigModel, is_array: bool = False): + super().__init__(cfg, is_array) + + def get_cs(self) -> str: + return "tango" diff --git a/tests/test_dynamic_catalog.py b/tests/test_dynamic_catalog.py new file mode 100644 index 0000000..94e8edf --- /dev/null +++ b/tests/test_dynamic_catalog.py @@ -0,0 +1,111 @@ +import pytest +from pyaml.common.exception import PyAMLException + +from pyaml_cs_oa.dynamic_catalog import ConfigModel as DynamicCatalogConfig +from pyaml_cs_oa.dynamic_catalog import DynamicCatalog +from pyaml_cs_oa.epicsR import ConfigModel as EpicsRConfig +from pyaml_cs_oa.epicsRW import ConfigModel as EpicsRWConfig +from pyaml_cs_oa.tangoAtt import ConfigModel as TangoAttConfig + + +def test_dynamic_epics_catalog_resolves_scalar_read_key_without_index() -> None: + catalog = DynamicCatalog(DynamicCatalogConfig(backend="epics", timeout_ms=1234)) + + device = catalog.resolve("PV:RB[m]") + + assert isinstance(device, EpicsRConfig) + assert device.read_pvname == "PV:RB" + assert device.timeout_ms == 1234 + assert device.unit == "m" + assert device.index is None + + +def test_dynamic_epics_catalog_resolves_indexed_read_key() -> None: + catalog = DynamicCatalog(DynamicCatalogConfig(backend="epics")) + + device = catalog.resolve("PV:ARRAY@3[m]") + + assert isinstance(device, EpicsRConfig) + assert device.read_pvname == "PV:ARRAY" + assert device.unit == "m" + assert device.index == 3 + + +def test_dynamic_epics_catalog_resolves_read_write_key() -> None: + catalog = DynamicCatalog(DynamicCatalogConfig(backend="epics")) + + device = catalog.resolve("(PV:RB, PV:SP)[m]") + + assert isinstance(device, EpicsRWConfig) + assert device.read_pvname == "PV:RB" + assert device.write_pvname == "PV:SP" + assert device.unit == "m" + assert device.index is None + + +def test_dynamic_epics_catalog_resolves_indexed_read_write_key() -> None: + catalog = DynamicCatalog(DynamicCatalogConfig(backend="epics")) + + device = catalog.resolve("(PV:RB, PV:SP)@5[m]") + + assert isinstance(device, EpicsRWConfig) + assert device.read_pvname == "PV:RB" + assert device.write_pvname == "PV:SP" + assert device.unit == "m" + assert device.index == 5 + + +def test_dynamic_epics_catalog_strips_whitespace_from_pv_names_and_index() -> None: + catalog = DynamicCatalog(DynamicCatalogConfig(backend="epics")) + + device = catalog.resolve(" ( PV:RB , PV:SP ) @ 7 [m]") + + assert isinstance(device, EpicsRWConfig) + assert device.read_pvname == "PV:RB" + assert device.write_pvname == "PV:SP" + assert device.index == 7 + assert device.unit == "m" + + +def test_dynamic_epics_catalog_rejects_invalid_index() -> None: + catalog = DynamicCatalog(DynamicCatalogConfig(backend="epics")) + + with pytest.raises(PyAMLException, match="Invalid index"): + catalog.resolve("PV:ARRAY@not-an-index[m]") + + +def test_dynamic_epics_catalog_rejects_too_many_read_write_tokens() -> None: + catalog = DynamicCatalog(DynamicCatalogConfig(backend="epics")) + + with pytest.raises(PyAMLException, match="Too many comma-separated tokens"): + catalog.resolve("(PV:ONE, PV:TWO, PV:THREE)[m]") + + +def test_tango_catalog_resolves_scalar_attribute() -> None: + catalog = DynamicCatalog(DynamicCatalogConfig(backend="tango", timeout_ms=1234)) + + device = catalog.resolve("sys/tg_test/1/value[m]") + + assert isinstance(device, TangoAttConfig) + assert device.attribute == "sys/tg_test/1/value" + assert device.timeout_ms == 1234 + assert device.unit == "m" + assert device.index is None + + +def test_disconnected_tango_catalog_resolves_indexed_attribute() -> None: + catalog = DynamicCatalog(DynamicCatalogConfig(backend="tango")) + + device = catalog.resolve("sys/tg_test/1/spectrum@4[m]") + + assert isinstance(device, TangoAttConfig) + assert device.attribute == "sys/tg_test/1/spectrum" + assert device.index == 4 + + +def test_tango_catalog_rejects_invalid_index() -> None: + catalog = DynamicCatalog(DynamicCatalogConfig(backend="tango")) + + with pytest.raises(PyAMLException, match="Invalid index"): + catalog.resolve("sys/tg_test/1/spectrum@bad[m]") + From eb18bc2142ffbc93428c863f0b1d6aa0cc83bc48 Mon Sep 17 00:00:00 2001 From: PONS Date: Wed, 3 Jun 2026 16:56:00 +0200 Subject: [PATCH 29/32] Fix --- pyaml_cs_oa/controlsystem.py | 12 ++++++++---- pyaml_cs_oa/dynamic_catalog.py | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/pyaml_cs_oa/controlsystem.py b/pyaml_cs_oa/controlsystem.py index ba10a19..668760f 100644 --- a/pyaml_cs_oa/controlsystem.py +++ b/pyaml_cs_oa/controlsystem.py @@ -77,22 +77,26 @@ def __init__(self, cfg: ConfigModel): f" and prefix='{self._cfg.prefix}'", ) + def attach(self, devs: list[OASignal | None]) -> list[OASignal | None]: - return self._attach(devs._cfg, False) + # Deprecated function + return self._attach([d._cfg for d in devs], False) def attach_array(self, devs: list[OASignal | None]) -> list[OASignal | None]: - return self._attach(devs._cfg, True) - + # Deprecated function + return self._attach([d._cfg for d in devs], True) + def get_device(self, ref: str | BaseModel | None) -> DeviceAccess | None: if ref is None: return None - # Build config from a string using using a DynamicCatalog if isinstance(ref, str): + # Retrieve a config from a key using using a Catalog if self._cfg.catalog is None: raise PyAMLException(f"Control system '{self.name()}' has no catalog when trying to resolve '{ref}'") try: ref = self._cfg.catalog.resolve(ref) + print(f"Resolve:{ref}") except AttributeError as exc: raise PyAMLException(f"Control system '{self.name()}' catalog cannot resolve key '{ref}'") from exc diff --git a/pyaml_cs_oa/dynamic_catalog.py b/pyaml_cs_oa/dynamic_catalog.py index dd29c3e..c017ff0 100644 --- a/pyaml_cs_oa/dynamic_catalog.py +++ b/pyaml_cs_oa/dynamic_catalog.py @@ -65,7 +65,7 @@ def _extract_unit(token: str) -> Tuple[str,str]: end_idx = token.index(']', start_idx) return (token[start_idx:end_idx],token[:start_idx-1]) except ValueError: - return "" # No unit + return "",token # No unit # ── PV spec parser ──────────────────────────────────────────────────────────── From e775bdb21c8f72469f39f6f145cdec4d4de4ec48 Mon Sep 17 00:00:00 2001 From: PONS Date: Wed, 3 Jun 2026 17:17:20 +0200 Subject: [PATCH 30/32] Ruff formating --- .github/workflows/ruff_formatting.yml | 24 +++++++++++++++++++ .github/workflows/tests.yml | 10 +++++--- pyaml_cs_oa/catalog.py | 2 ++ pyaml_cs_oa/controlsystem.py | 15 ++++++------ pyaml_cs_oa/dynamic_catalog.py | 33 +++++++++++++++------------ pyaml_cs_oa/scalar_aggregator.py | 30 ++++++++++++------------ pyaml_cs_oa/static_catalog.py | 2 +- pyaml_cs_oa/types.py | 1 + tests/test_bpm_orbit.py | 33 ++++++++++++++++----------- tests/test_dynamic_catalog.py | 1 - 10 files changed, 96 insertions(+), 55 deletions(-) create mode 100644 .github/workflows/ruff_formatting.yml diff --git a/.github/workflows/ruff_formatting.yml b/.github/workflows/ruff_formatting.yml new file mode 100644 index 0000000..bbce2cc --- /dev/null +++ b/.github/workflows/ruff_formatting.yml @@ -0,0 +1,24 @@ +name: Ruff Format Check + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + ruff-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" # or your preferred version + + - name: Install Ruff + run: pip install ruff + + - name: Run Ruff Check + run: ruff check --diff . diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d2209d3..d7da928 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,10 +28,14 @@ jobs: python -m pip install --upgrade pip python -m pip install "git+https://github.com/python-accelerator-middle-layer/pyaml.git" python -m pip install -e ".[test,epics,tango]" - python -m pip install ruff + pip install flake8 - - name: Lint with Ruff - run: ruff check --diff . + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with pytest run: python -m pytest -q diff --git a/pyaml_cs_oa/catalog.py b/pyaml_cs_oa/catalog.py index 356f788..91d2e5c 100644 --- a/pyaml_cs_oa/catalog.py +++ b/pyaml_cs_oa/catalog.py @@ -1,8 +1,10 @@ """Configuration helpers for backend-provided catalogs.""" from abc import ABCMeta, abstractmethod + from pydantic import BaseModel + class Catalog(metaclass=ABCMeta): r""" Abstract class for backend catalog configuration objects. diff --git a/pyaml_cs_oa/controlsystem.py b/pyaml_cs_oa/controlsystem.py index 668760f..5204357 100644 --- a/pyaml_cs_oa/controlsystem.py +++ b/pyaml_cs_oa/controlsystem.py @@ -77,15 +77,14 @@ def __init__(self, cfg: ConfigModel): f" and prefix='{self._cfg.prefix}'", ) - def attach(self, devs: list[OASignal | None]) -> list[OASignal | None]: - # Deprecated function + # Deprecated function return self._attach([d._cfg for d in devs], False) def attach_array(self, devs: list[OASignal | None]) -> list[OASignal | None]: - # Deprecated function + # Deprecated function return self._attach([d._cfg for d in devs], True) - + def get_device(self, ref: str | BaseModel | None) -> DeviceAccess | None: if ref is None: return None @@ -101,13 +100,13 @@ def get_device(self, ref: str | BaseModel | None) -> DeviceAccess | None: raise PyAMLException(f"Control system '{self.name()}' catalog cannot resolve key '{ref}'") from exc if isinstance(ref, EpicsConfigR): - return self._attach([ref],ref.index is not None)[0] + return self._attach([ref], ref.index is not None)[0] if isinstance(ref, EpicsConfigW): - return self._attach([ref],ref.index is not None)[0] + return self._attach([ref], ref.index is not None)[0] if isinstance(ref, EpicsConfigRW): - return self._attach([ref],ref.index is not None)[0] + return self._attach([ref], ref.index is not None)[0] if isinstance(ref, TangoConfigAtt): - return self._attach([ref],ref.index is not None)[0] + return self._attach([ref], ref.index is not None)[0] raise PyAMLException(f"Control system '{self.name()}' cannot build a device from {type(ref).__name__}") diff --git a/pyaml_cs_oa/dynamic_catalog.py b/pyaml_cs_oa/dynamic_catalog.py index c017ff0..b595016 100644 --- a/pyaml_cs_oa/dynamic_catalog.py +++ b/pyaml_cs_oa/dynamic_catalog.py @@ -1,12 +1,14 @@ +from typing import Tuple + from pyaml.common.exception import PyAMLException from pyaml.control.deviceaccess import DeviceAccess -from pydantic import ConfigDict,BaseModel -from typing import Tuple +from pydantic import BaseModel, ConfigDict from .catalog import Catalog PYAMLCLASS = "DynamicCatalog" + class ConfigModel(BaseModel): """ Default dynamic catalog. @@ -32,22 +34,21 @@ class ConfigModel(BaseModel): ------- .. code-block:: yaml backend: "Tango" or "Epics" - timeout_ms: 3000 + timeout_ms: 3000 """ model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") timeout_ms: int = 3000 backend: str = "" -class DynamicCatalog(Catalog): +class DynamicCatalog(Catalog): def __init__(self, cfg: ConfigModel): self._cfg = cfg - self._dp = {} # Device proxy cache (Tango Only) + self._dp = {} # Device proxy cache (Tango Only) if cfg.backend.lower() != "tango" and cfg.backend.lower() != "epics": raise PyAMLException(f"backend must be `epics` or `tango` but got '{cfg.backend}'") from None - def resolve(self, key: str) -> BaseModel: if self._cfg.backend.lower() == "epics": return _build_epics_config(key, self._cfg.timeout_ms) @@ -57,25 +58,25 @@ def resolve(self, key: str) -> BaseModel: return None -def _extract_unit(token: str) -> Tuple[str,str]: +def _extract_unit(token: str) -> Tuple[str, str]: # Extract unit block try: - start_idx = token.index('[') + 1 - end_idx = token.index(']', start_idx) - return (token[start_idx:end_idx],token[:start_idx-1]) + start_idx = token.index("[") + 1 + end_idx = token.index("]", start_idx) + return (token[start_idx:end_idx], token[: start_idx - 1]) except ValueError: - return "",token # No unit + return "", token # No unit # ── PV spec parser ──────────────────────────────────────────────────────────── - + def _parse_pv(token: str) -> tuple[list[str], int | None, str]: token = token.strip() - unit,token = _extract_unit(token) + unit, token = _extract_unit(token) # No suffix means scalar access to the full PV value. index = None @@ -108,13 +109,15 @@ def _build_epics_config(pv_str: str, timeout_ms: int) -> DeviceAccess: return EpicsRWConfig(read_pvname=pv_names[0], write_pvname=pv_names[1], timeout_ms=timeout_ms, index=index, unit=unit) raise PyAMLException(f"Too many comma-separated tokens in key '{pv_str}' (max 2)") + # ── Tango spec parser ──────────────────────────────────────────────────────────── + def _parse_attribute(token: str) -> tuple[list[str], int | None, str]: token = token.strip() - unit,token = _extract_unit(token) + unit, token = _extract_unit(token) # No suffix means scalar access to the full PV value. index = None @@ -128,8 +131,10 @@ def _parse_attribute(token: str) -> tuple[list[str], int | None, str]: return token, index, unit + def _build_tango_config(att_name: str, timeout_ms: int) -> BaseModel: from .tangoAtt import ConfigModel as TangoAtt + att_name, index, unit = _parse_attribute(att_name) return TangoAtt(attribute=att_name, timeout_ms=timeout_ms, index=index, unit=unit) diff --git a/pyaml_cs_oa/scalar_aggregator.py b/pyaml_cs_oa/scalar_aggregator.py index 33546e9..48c2716 100644 --- a/pyaml_cs_oa/scalar_aggregator.py +++ b/pyaml_cs_oa/scalar_aggregator.py @@ -20,14 +20,14 @@ class ConfigModel(BaseModel): class OAScalarAggregator(DeviceAccessList): def __init__(self, cfg: ConfigModel = None): super().__init__() - self._r_signal_list = {} # List of signal to read - self._w_signal_list = {} # List of signal to read/write + self._r_signal_list = {} # List of signal to read + self._w_signal_list = {} # List of signal to read/write self._writable = None - def _add_to_dev_list(self,d:FloatSignalContainer): + def _add_to_dev_list(self, d: FloatSignalContainer): # Check type and read/write - if not isinstance(d,FloatSignalContainer): + if not isinstance(d, FloatSignalContainer): raise PyAMLException("All devices must be instances of FloatSignalContainer.") if self._writable is None: @@ -39,15 +39,15 @@ def _add_to_dev_list(self,d:FloatSignalContainer): # Construct structure to avoid duplicate reading # The shared part is the source Ophyd signal if d.RB._r_sig not in self._r_signal_list: - self._r_signal_list[d.RB._r_sig] = {"source":d.RB,"indices":[[d._cfg.index,len(self)]]} + self._r_signal_list[d.RB._r_sig] = {"source": d.RB, "indices": [[d._cfg.index, len(self)]]} else: - self._r_signal_list[d.RB._r_sig]["indices"].append([d._cfg.index,len(self)]) + self._r_signal_list[d.RB._r_sig]["indices"].append([d._cfg.index, len(self)]) if self._writable: if d.SP._w_sig not in self._w_signal_list: - self._w_signal_list[d.SP._w_sig] = {"source":d.SP,"indices":[[d._cfg.index,len(self)]]} + self._w_signal_list[d.SP._w_sig] = {"source": d.SP, "indices": [[d._cfg.index, len(self)]]} else: - self._w_signal_list[d.SP._w_sig]["indices"].append([d._cfg.index,len(self)]) + self._w_signal_list[d.SP._w_sig]["indices"].append([d._cfg.index, len(self)]) self.append(d) @@ -78,21 +78,21 @@ def set(self, value: npt.NDArray[np.float64]): def set_and_wait(self, value: npt.NDArray[np.float64]): raise NotImplementedError("Not implemented yet.") - def _read(self,signal_list:dict) -> npt.NDArray[np.float64]: + def _read(self, signal_list: dict) -> npt.NDArray[np.float64]: - requests = [] # list of status to await - for _d,dc in signal_list.items(): - requests.append( dc["source"].async_get() ) + requests = [] # list of status to await + for _d, dc in signal_list.items(): + requests.append(dc["source"].async_get()) values = arun(asyncio.gather(*requests)) rvalues = np.zeros(len(self)) sIdx = 0 - for _,dc in signal_list.items(): + for _, dc in signal_list.items(): for i in dc["indices"]: if i[0] is None: - rvalues[i[1]] = values[sIdx] # non indexed scalar value + rvalues[i[1]] = values[sIdx] # non indexed scalar value else: rvalues[i[1]] = values[sIdx][i[0]] - sIdx+=1 + sIdx += 1 return rvalues diff --git a/pyaml_cs_oa/static_catalog.py b/pyaml_cs_oa/static_catalog.py index 3c774fc..4745057 100644 --- a/pyaml_cs_oa/static_catalog.py +++ b/pyaml_cs_oa/static_catalog.py @@ -1,6 +1,6 @@ from pyaml.common.exception import PyAMLException from pyaml.control.deviceaccess import DeviceAccess -from pydantic import ConfigDict, BaseModel +from pydantic import BaseModel, ConfigDict from .catalog import Catalog from .static_catalog_entry import StaticCatalogEntry diff --git a/pyaml_cs_oa/types.py b/pyaml_cs_oa/types.py index 15d6f70..356f2b0 100644 --- a/pyaml_cs_oa/types.py +++ b/pyaml_cs_oa/types.py @@ -27,6 +27,7 @@ class EpicsConfigRW(BaseModel): index: int | None = None unit: str = "" + class TangoConfigAtt(BaseModel): attribute: str timeout_ms: int = 3000 diff --git a/tests/test_bpm_orbit.py b/tests/test_bpm_orbit.py index c63f799..7085964 100644 --- a/tests/test_bpm_orbit.py +++ b/tests/test_bpm_orbit.py @@ -1,5 +1,4 @@ from typing import Any -from pydantic import BaseModel, ConfigDict import numpy as np from pyaml.arrays.bpm_array import BPMArray @@ -9,6 +8,7 @@ from pyaml.bpm.bpm_simple_model import ConfigModel as BPMSimpleModelConfig from pyaml.control.abstract_impl import RBpmArray from pyaml.control.deviceaccess import DeviceAccess +from pydantic import BaseModel, ConfigDict from pyaml_cs_oa.catalog import Catalog from pyaml_cs_oa.controlsystem import ConfigModel, OphydAsyncControlSystem @@ -64,9 +64,11 @@ async def async_get(self) -> np.ndarray: def get(self) -> np.ndarray: return self._source.get() + class IndexedVectorSignalConfig(EpicsConfigR): model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") - source : VectorDevice + source: VectorDevice + class IndexedVectorSignal(FloatSignalContainer): """FloatSignalContainer fake resolving one indexed value from a shared vector.""" @@ -96,8 +98,9 @@ class IdentityAttachControlSystem(OphydAsyncControlSystem): # attach public methods are depecrated def get_device(self, ref: str | BaseModel | None) -> DeviceAccess | None: - config = self._cfg.catalog.resolve(ref) - return IndexedVectorSignal(config) + config = self._cfg.catalog.resolve(ref) + return IndexedVectorSignal(config) + def _attached_indexed_bpm( control_system: IdentityAttachControlSystem, @@ -122,16 +125,20 @@ def _attached_indexed_bpm( def _control_system_with_indexed_orbit(orbit_device: VectorDevice, bpm_count: int) -> IdentityAttachControlSystem: catalog = StaticCatalog( - {f"BPM{bpm_index}:X": IndexedVectorSignalConfig(source=orbit_device, - read_pvname=orbit_device.name(), - unit=orbit_device.unit(), - index=2 * bpm_index) for bpm_index in range(bpm_count)} - | {f"BPM{bpm_index}:Y": IndexedVectorSignalConfig(source=orbit_device, - read_pvname=orbit_device.name(), - unit=orbit_device.unit(), - index=2 * bpm_index + 1) for bpm_index in range(bpm_count)}, + { + f"BPM{bpm_index}:X": IndexedVectorSignalConfig( + source=orbit_device, read_pvname=orbit_device.name(), unit=orbit_device.unit(), index=2 * bpm_index + ) + for bpm_index in range(bpm_count) + } + | { + f"BPM{bpm_index}:Y": IndexedVectorSignalConfig( + source=orbit_device, read_pvname=orbit_device.name(), unit=orbit_device.unit(), index=2 * bpm_index + 1 + ) + for bpm_index in range(bpm_count) + }, ) - control_system = IdentityAttachControlSystem(ConfigModel(name="live",catalog=catalog)) + control_system = IdentityAttachControlSystem(ConfigModel(name="live", catalog=catalog)) return control_system diff --git a/tests/test_dynamic_catalog.py b/tests/test_dynamic_catalog.py index 94e8edf..da41233 100644 --- a/tests/test_dynamic_catalog.py +++ b/tests/test_dynamic_catalog.py @@ -108,4 +108,3 @@ def test_tango_catalog_rejects_invalid_index() -> None: with pytest.raises(PyAMLException, match="Invalid index"): catalog.resolve("sys/tg_test/1/spectrum@bad[m]") - From 1b7ccadf57b6a39ace4ea51275d36b1534a3b6db Mon Sep 17 00:00:00 2001 From: PONS Date: Wed, 3 Jun 2026 18:11:55 +0200 Subject: [PATCH 31/32] Removed trace --- .pre-commit-config.yaml | 14 +++++++------- pyaml_cs_oa/controlsystem.py | 19 +++++++------------ 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 38805b4..75f5e97 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,10 +12,10 @@ repos: args: [--fix] - id: ruff-format - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.18.2 - hooks: - - id: mypy - additional_dependencies: [ - "pydantic>=2.0", - ] +# - repo: https://github.com/pre-commit/mirrors-mypy +# rev: v1.18.2 +# hooks: +# - id: mypy +# additional_dependencies: [ +# "pydantic>=2.0", +# ] diff --git a/pyaml_cs_oa/controlsystem.py b/pyaml_cs_oa/controlsystem.py index 5204357..28003a0 100644 --- a/pyaml_cs_oa/controlsystem.py +++ b/pyaml_cs_oa/controlsystem.py @@ -1,4 +1,5 @@ import logging +from typing import Type from pyaml.common.exception import PyAMLException from pyaml.control.controlsystem import ControlSystem @@ -12,12 +13,7 @@ from .epicsW import EpicsW from .signal import OASignal from .tangoAtt import TangoAtt -from .types import ( - EpicsConfigR, - EpicsConfigRW, - EpicsConfigW, - TangoConfigAtt, -) +from .types import ControlSysConfig, EpicsConfigR, EpicsConfigRW, EpicsConfigW, TangoConfigAtt PYAMLCLASS: str = "OphydAsyncControlSystem" @@ -64,11 +60,10 @@ class OphydAsyncControlSystem(ControlSystem): def __init__(self, cfg: ConfigModel): super().__init__() self._cfg = cfg - self._devices = {} # Dict containing all attached DeviceAccess + self._devices: dict[str, DeviceAccess] = {} # Dict containing all attached DeviceAccess if self._cfg.debug_level: log_level = getattr(logging, self._cfg.debug_level, logging.WARNING) - logger.parent.setLevel(log_level) logger.setLevel(log_level) logger.log( @@ -79,11 +74,11 @@ def __init__(self, cfg: ConfigModel): def attach(self, devs: list[OASignal | None]) -> list[OASignal | None]: # Deprecated function - return self._attach([d._cfg for d in devs], False) + return self._attach([d._cfg if d is not None else None for d in devs], False) def attach_array(self, devs: list[OASignal | None]) -> list[OASignal | None]: # Deprecated function - return self._attach([d._cfg for d in devs], True) + return self._attach([d._cfg if d is not None else None for d in devs], True) def get_device(self, ref: str | BaseModel | None) -> DeviceAccess | None: if ref is None: @@ -95,7 +90,6 @@ def get_device(self, ref: str | BaseModel | None) -> DeviceAccess | None: raise PyAMLException(f"Control system '{self.name()}' has no catalog when trying to resolve '{ref}'") try: ref = self._cfg.catalog.resolve(ref) - print(f"Resolve:{ref}") except AttributeError as exc: raise PyAMLException(f"Control system '{self.name()}' catalog cannot resolve key '{ref}'") from exc @@ -110,13 +104,14 @@ def get_device(self, ref: str | BaseModel | None) -> DeviceAccess | None: raise PyAMLException(f"Control system '{self.name()}' cannot build a device from {type(ref).__name__}") - def _attach(self, configs: list[BaseModel | None], is_array: bool) -> list[OASignal | None]: + def _attach(self, configs: list[ControlSysConfig | None], is_array: bool) -> list[OASignal | None]: # Concatenate the prefix newDevs = [] for sig_cfg in configs: if sig_cfg is not None: sig_cfg_cls = sig_cfg.__class__ index_str = "" if sig_cfg.index is None else str(sig_cfg.index) + sig_cls: Type[OASignal] | None = None if isinstance(sig_cfg, EpicsConfigR): key = self._cfg.prefix + sig_cfg.read_pvname + index_str From 450484ef866f11e46f6b70266639cf5a9c22f32f Mon Sep 17 00:00:00 2001 From: PONS Date: Wed, 3 Jun 2026 18:16:17 +0200 Subject: [PATCH 32/32] Removed unecessary type check --- pyaml_cs_oa/controlsystem.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyaml_cs_oa/controlsystem.py b/pyaml_cs_oa/controlsystem.py index 28003a0..e543642 100644 --- a/pyaml_cs_oa/controlsystem.py +++ b/pyaml_cs_oa/controlsystem.py @@ -1,5 +1,4 @@ import logging -from typing import Type from pyaml.common.exception import PyAMLException from pyaml.control.controlsystem import ControlSystem @@ -111,7 +110,6 @@ def _attach(self, configs: list[ControlSysConfig | None], is_array: bool) -> lis if sig_cfg is not None: sig_cfg_cls = sig_cfg.__class__ index_str = "" if sig_cfg.index is None else str(sig_cfg.index) - sig_cls: Type[OASignal] | None = None if isinstance(sig_cfg, EpicsConfigR): key = self._cfg.prefix + sig_cfg.read_pvname + index_str