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/.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/catalog.py b/pyaml_cs_oa/catalog.py new file mode 100644 index 0000000..91d2e5c --- /dev/null +++ b/pyaml_cs_oa/catalog.py @@ -0,0 +1,24 @@ +"""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. + + 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. + """ + + @abstractmethod + def resolve(self, key: str) -> BaseModel: + """ + Return a configuration model for a DeviceAccess + """ + pass diff --git a/pyaml_cs_oa/container.py b/pyaml_cs_oa/container.py index a02319e..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() @@ -56,6 +56,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: @@ -129,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/controlsystem.py b/pyaml_cs_oa/controlsystem.py index 312b4e4..e543642 100644 --- a/pyaml_cs_oa/controlsystem.py +++ b/pyaml_cs_oa/controlsystem.py @@ -1,25 +1,18 @@ -import copy import logging -import os from pyaml.common.exception import PyAMLException from pyaml.control.controlsystem import ControlSystem -from pydantic import BaseModel +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 from .signal import OASignal -from .tangoR import TangoR -from .tangoRW import TangoRW -from .types import ( - EpicsConfigR, - EpicsConfigRW, - EpicsConfigW, - TangoConfigR, - TangoConfigRW, -) +from .tangoAtt import TangoAtt +from .types import ControlSysConfig, EpicsConfigR, EpicsConfigRW, EpicsConfigW, TangoConfigAtt PYAMLCLASS: str = "OphydAsyncControlSystem" @@ -37,7 +30,10 @@ 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. - debug_level : int + 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 Aggregator module for scalar values. If none specified, writings and @@ -47,9 +43,12 @@ class ConfigModel(BaseModel): of vector are serialized, """ + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + name: str prefix: str = "" - debug_level: str = None + catalog: Catalog | None = None + debug_level: str | None = None scalar_aggregator: str | None = "pyaml_cs_oa.scalar_aggregator" vector_aggregator: str | None = None @@ -60,11 +59,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( @@ -73,48 +71,70 @@ def __init__(self, cfg: ConfigModel): f" and prefix='{self._cfg.prefix}'", ) - def attach(self, devs: list[OASignal]) -> list[OASignal]: - return self._attach(devs, False) - - def attach_array(self, devs: list[OASignal]) -> list[OASignal]: - return self._attach(devs, True) - - def _attach(self, devs: list[OASignal], is_array: bool) -> list[OASignal]: + def attach(self, devs: list[OASignal | None]) -> list[OASignal | None]: + # Deprecated function + 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 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: + return None + + 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) + except AttributeError as exc: + 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] + if isinstance(ref, EpicsConfigW): + return self._attach([ref], ref.index is not None)[0] + if isinstance(ref, EpicsConfigRW): + 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, configs: list[ControlSysConfig | 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__ + 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 + if isinstance(sig_cfg, EpicsConfigR): + key = self._cfg.prefix + sig_cfg.read_pvname + index_str sig_cls = EpicsR - config = dict(read_pvname=key) - elif isinstance(d._cfg, EpicsConfigW): - key = self._cfg.prefix + d._cfg.write_pvname + 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=key) - elif isinstance(d._cfg, EpicsConfigRW): - key = self._cfg.prefix + d._cfg.read_pvname + d._cfg.write_pvname + 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 - sig_cls = TangoR - config = dict(attribute=key) - elif isinstance(d._cfg, TangoConfigRW): - key = self._cfg.prefix + d._cfg.attribute - sig_cls = TangoRW - config = dict(attribute=key) + 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 + 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/dynamic_catalog.py b/pyaml_cs_oa/dynamic_catalog.py new file mode 100644 index 0000000..b595016 --- /dev/null +++ b/pyaml_cs_oa/dynamic_catalog.py @@ -0,0 +1,140 @@ +from typing import Tuple + +from pyaml.common.exception import PyAMLException +from pyaml.control.deviceaccess import DeviceAccess +from pydantic import BaseModel, ConfigDict + +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 "", 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) + + # 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/epics.py b/pyaml_cs_oa/epics.py index 62db001..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 @@ -11,6 +11,51 @@ 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,34 +64,18 @@ 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 = epics_signal_r( - datatype=float if not is_array else Array1D[numpy.float64], - read_pv=cfg.read_pvname, - name="", - timeout=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 = epics_signal_w( - datatype=float if not is_array else Array1D[numpy.float64], - write_pv=cfg.write_pvname, - name="", - timeout=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): - 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="", - timeout=cfg.timeout_ms / 1000.0, - ) - readback = Readback(w_sig) - setpoint = Setpoint(w_sig) + rw_sig = create_signal_rw(cfg.read_pvname, cfg.write_pvname, cfg.timeout_ms / 1000.0) + readback = Readback(rw_sig) + setpoint = Setpoint(rw_sig) return setpoint, readback diff --git a/pyaml_cs_oa/epicsR.py b/pyaml_cs_oa/epicsR.py index de645a5..41102c3 100644 --- a/pyaml_cs_oa/epicsR.py +++ b/pyaml_cs_oa/epicsR.py @@ -4,12 +4,11 @@ PYAMLCLASS: str = "EpicsR" -class ConfigModel(EpicsConfigR): - unit: str = "" +class ConfigModel(EpicsConfigR): ... class EpicsR(FloatSignalContainer): - def __init__(self, cfg: ConfigModel, is_array=False): + def __init__(self, cfg: ConfigModel, is_array: bool = False): super().__init__(cfg, is_array) def get_cs(self) -> str: diff --git a/pyaml_cs_oa/epicsRW.py b/pyaml_cs_oa/epicsRW.py index e38a4b4..6359ca5 100644 --- a/pyaml_cs_oa/epicsRW.py +++ b/pyaml_cs_oa/epicsRW.py @@ -4,12 +4,11 @@ PYAMLCLASS: str = "EpicsRW" -class ConfigModel(EpicsConfigRW): - unit: str = "" +class ConfigModel(EpicsConfigRW): ... class EpicsRW(FloatSignalContainer): - def __init__(self, cfg: ConfigModel, is_array=False): + def __init__(self, cfg: ConfigModel, is_array: bool = False): super().__init__(cfg, is_array) def get_cs(self) -> str: diff --git a/pyaml_cs_oa/epicsW.py b/pyaml_cs_oa/epicsW.py index f6f3f56..07a76e5 100644 --- a/pyaml_cs_oa/epicsW.py +++ b/pyaml_cs_oa/epicsW.py @@ -4,12 +4,11 @@ PYAMLCLASS: str = "EpicsW" -class ConfigModel(EpicsConfigW): - unit: str = "" +class ConfigModel(EpicsConfigW): ... class EpicsW(FloatSignalContainer): - def __init__(self, cfg: ConfigModel, is_array=False): + def __init__(self, cfg: ConfigModel, is_array: bool = False): super().__init__(cfg, is_array) def get_cs(self) -> str: diff --git a/pyaml_cs_oa/float_signal.py b/pyaml_cs_oa/float_signal.py index 5ebcd6e..d43aa6b 100644 --- a/pyaml_cs_oa/float_signal.py +++ b/pyaml_cs_oa/float_signal.py @@ -1,3 +1,5 @@ +from pyaml.common.exception import PyAMLException + from .signal import OASignal from .types import ControlSysConfig @@ -10,6 +12,22 @@ class FloatSignalContainer(OASignal): 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) + except (TypeError, ValueError) as 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 {self._cfg.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. @@ -21,9 +39,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): """ @@ -35,7 +53,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): """ @@ -47,6 +65,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): diff --git a/pyaml_cs_oa/scalar_aggregator.py b/pyaml_cs_oa/scalar_aggregator.py index 319e6fb..48c2716 100644 --- a/pyaml_cs_oa/scalar_aggregator.py +++ b/pyaml_cs_oa/scalar_aggregator.py @@ -20,16 +20,43 @@ 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._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: @@ -51,26 +78,34 @@ def set(self, value: npt.NDArray[np.float64]): def set_and_wait(self, value: npt.NDArray[np.float64]): raise NotImplementedError("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] = [] diff --git a/pyaml_cs_oa/signal.py b/pyaml_cs_oa/signal.py index 6e88a5b..8aac593 100644 --- a/pyaml_cs_oa/signal.py +++ b/pyaml_cs_oa/signal.py @@ -5,8 +5,7 @@ EpicsConfigR, EpicsConfigRW, EpicsConfigW, - TangoConfigR, - TangoConfigRW, + TangoConfigAtt, ) @@ -15,14 +14,14 @@ 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 + # is_array is forced True whenever any index is specified. + 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": @@ -33,6 +32,7 @@ def build(self): raise ValueError(f"Unsupported cs_name: {cs_name}") self.SP, self.RB = get_SP_RB(self._cfg, self.is_array) + if self.SP: self.SP.__peer__ = self if self.RB: @@ -42,45 +42,26 @@ 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 - elif isinstance(self._cfg, (TangoConfigR, TangoConfigRW)): + elif isinstance(self._cfg, EpicsConfigW): + return self._cfg.write_pvname + elif isinstance(self._cfg, TangoConfigAtt): return self._cfg.attribute else: raise ValueError(f"Unsupported control system config type: {type(self._cfg)!r}") def unit(self) -> str: - """ - Return the unit of the attribute. - - Returns - ------- - str - The unit string. - """ 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, TangoConfigAtt)): + if self._cfg.range: + return self._cfg.range + return [None, None] def check_device_availability(self) -> bool: # TODO @@ -88,6 +69,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..4745057 --- /dev/null +++ b/pyaml_cs_oa/static_catalog.py @@ -0,0 +1,39 @@ +from pyaml.common.exception import PyAMLException +from pyaml.control.deviceaccess import DeviceAccess +from pydantic import BaseModel, ConfigDict + +from .catalog import Catalog +from .static_catalog_entry import StaticCatalogEntry + +PYAMLCLASS = "StaticCatalog" + + +class ConfigModel(BaseModel): + """ + Static catalog: a fixed mapping of keys to DeviceAccess instances. + 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) -> BaseModel: + try: + 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/static_catalog_entry.py b/pyaml_cs_oa/static_catalog_entry.py new file mode 100644 index 0000000..79eab0c --- /dev/null +++ b/pyaml_cs_oa/static_catalog_entry.py @@ -0,0 +1,22 @@ +from pyaml.control.deviceaccess import DeviceAccess +from pydantic import BaseModel, ConfigDict + +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/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/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/pyaml_cs_oa/tangoR.py b/pyaml_cs_oa/tangoR.py deleted file mode 100644 index 4ff4ee2..0000000 --- a/pyaml_cs_oa/tangoR.py +++ /dev/null @@ -1,16 +0,0 @@ -from .float_signal import FloatSignalContainer -from .types import TangoConfigR - -PYAMLCLASS: str = "TangoR" - - -class ConfigModel(TangoConfigR): - unit: str = "" - - -class TangoR(FloatSignalContainer): - def __init__(self, cfg: ConfigModel, is_array=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 37a9458..0000000 --- a/pyaml_cs_oa/tangoRW.py +++ /dev/null @@ -1,16 +0,0 @@ -from .float_signal import FloatSignalContainer -from .types import TangoConfigRW - -PYAMLCLASS: str = "TangoRW" - - -class ConfigModel(TangoConfigRW): - unit: str = "" - - -class TangoRW(FloatSignalContainer): - def __init__(self, cfg: ConfigModel, is_array=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 f50d439..356f2b0 100644 --- a/pyaml_cs_oa/types.py +++ b/pyaml_cs_oa/types.py @@ -1,33 +1,39 @@ -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 + unit: str = "" class EpicsConfigW(BaseModel): + model_config = ConfigDict(extra="forbid") write_pvname: str timeout_ms: int = 3000 range: list[float] | None = None + index: int | None = None + unit: str = "" 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 + unit: str = "" -class TangoConfigR(BaseModel): - attribute: str - timeout_ms: int = 3000 - - -class TangoConfigRW(BaseModel): +class TangoConfigAtt(BaseModel): attribute: str timeout_ms: int = 3000 range: list[float] | None = None + index: int | None = None + unit: str = "" -ControlSysConfig = EpicsConfigR | EpicsConfigW | EpicsConfigRW | TangoConfigR | TangoConfigRW +ControlSysConfig = EpicsConfigR | EpicsConfigW | EpicsConfigRW | TangoConfigAtt 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/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 c9fd979..7085964 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 @@ -10,8 +8,12 @@ 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 +from pyaml_cs_oa.float_signal import FloatSignalContainer +from pyaml_cs_oa.types import EpicsConfigR class VectorDevice(DeviceAccess): @@ -51,47 +53,103 @@ 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 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, config: IndexedVectorSignalConfig) -> None: + super().__init__(config, is_array=True) + self._readable = True + self._writable = False + self.RB = _VectorReadSide(config.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: + self._devices = devices + + 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 + # attach public methods are depecrated - def attach_array(self, devs: list[DeviceAccess]) -> list[DeviceAccess]: - return devs + 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, - 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: + 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 + + 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 +168,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 +197,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_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 deleted file mode 100644 index 17ea87f..0000000 --- a/tests/test_controlsystem.py +++ /dev/null @@ -1,108 +0,0 @@ -from __future__ import annotations - -import pytest -from pyaml.common.exception import PyAMLException - -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.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 - - -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 UnsupportedDevice: - _cfg = object() - - control_system = OphydAsyncControlSystem(ConfigModel(name="live")) - - with pytest.raises(PyAMLException, match="Unsupported type"): - control_system.attach([UnsupportedDevice()]) diff --git a/tests/test_dynamic_catalog.py b/tests/test_dynamic_catalog.py new file mode 100644 index 0000000..da41233 --- /dev/null +++ b/tests/test_dynamic_catalog.py @@ -0,0 +1,110 @@ +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]") diff --git a/tests/test_factories.py b/tests/test_factories.py index 7444e46..92154cf 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 @@ -10,8 +8,7 @@ EpicsConfigR, EpicsConfigRW, EpicsConfigW, - TangoConfigR, - TangoConfigRW, + TangoConfigAtt, ) @@ -74,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: @@ -98,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_scalar_aggregator.py b/tests/test_scalar_aggregator.py index 098fdb5..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 @@ -16,16 +14,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 +69,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..d2f35ea 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 @@ -7,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 @@ -50,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 @@ -65,16 +62,15 @@ 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" -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 +80,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..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 @@ -15,7 +13,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..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 @@ -15,7 +13,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"]}