From 6b4a175f78ddd13d6dd43ae546bef521484f6c6f Mon Sep 17 00:00:00 2001 From: gac-x06da Date: Fri, 31 Jan 2025 17:53:59 +0100 Subject: [PATCH] Another SmarGon client approach --- pxiii_bec/devices/NDArrayPreview.py | 20 +++ pxiii_bec/devices/SmarGon.py | 19 ++- pxiii_bec/devices/SmarGon2.py | 233 ++++++++++++++++++++++++++++ 3 files changed, 266 insertions(+), 6 deletions(-) create mode 100644 pxiii_bec/devices/SmarGon2.py diff --git a/pxiii_bec/devices/NDArrayPreview.py b/pxiii_bec/devices/NDArrayPreview.py index 94d2bc3..0689bea 100644 --- a/pxiii_bec/devices/NDArrayPreview.py +++ b/pxiii_bec/devices/NDArrayPreview.py @@ -19,6 +19,26 @@ from bec_lib import bec_logger logger = bec_logger.logger +class SilentNDDerivedSignal(NDDerivedSignal): + def inverse(self, value): + """Shape the flat array to send as a result of ``.get``""" + array_shape = self.derived_shape[: self.derived_ndims] + if not any(array_shape): + raise RuntimeWarning(f"Invalid array size {self.derived_shape}") + return self._readback + + array_len = np.prod(array_shape) + if len(value) < array_len: + raise RuntimeWarning( + f"cannot reshape array of size {len(value)} " + f"into shape {tuple(array_shape)}. Check IOC configuration." + ) + return self._readback + + return np.asarray(value[:array_len]).reshape(array_shape) + + + class NDArrayPreview(Device): """Wrapper class around AreaDetector's NDStdArray plugins diff --git a/pxiii_bec/devices/SmarGon.py b/pxiii_bec/devices/SmarGon.py index 94395d2..9742bba 100644 --- a/pxiii_bec/devices/SmarGon.py +++ b/pxiii_bec/devices/SmarGon.py @@ -9,6 +9,7 @@ The SmarGon axes are interfaced as positioners. import time from threading import Thread, Lock import requests +from requests.adapters import HTTPAdapter, Retry from ophyd import Component, Kind, Signal, PVPositioner from ophyd.status import SubscriptionStatus @@ -167,6 +168,12 @@ class SmarGonAxis(PVPositioner): self.__class__.__dict__["setpoint"].kwargs["low_limit"] = low_limit self.__class__.__dict__["setpoint"].kwargs["high_limit"] = high_limit self.__class__.__dict__["sg_url"].kwargs["value"] = sg_url + # Fine-tune HTTP connection behavior + # NOTE: SmarGon has a few failed requests every one in a while + self._s = requests.Session() + retries = Retry(total=5, backoff_factor=0.05, status_forcelist=[ 500, 502, 503, 504 ]) + self._s.mount('http://', HTTPAdapter(max_retries=retries)) + super().__init__( prefix=prefix, name=name, @@ -210,16 +217,16 @@ class SmarGonAxis(PVPositioner): cmd = f"{self.sg_url.get()}/{address}" try: with mutex: - r = requests.get(cmd, timeout=1, **kwargs) + r = self._s.get(cmd, timeout=1, **kwargs) except TimeoutError: try: time.sleep(0.05) with mutex: - r = requests.get(cmd, timeout=0.5, **kwargs) + r = self._s.get(cmd, timeout=0.5, **kwargs) except TimeoutError: time.sleep(0.05) with mutex: - r = requests.get(cmd, timeout=0.5, **kwargs) + r = self._s.get(cmd, timeout=0.5, **kwargs) if not r.ok: raise RuntimeError( f"[{self.name}] Error getting {address}; reply was {r.status_code} => {r.reason}" @@ -231,16 +238,16 @@ class SmarGonAxis(PVPositioner): cmd = f"{self.sg_url.get()}/{address}" try: with mutex: - r = requests.put(cmd, timeout=1, **kwargs) + r = self._s.put(cmd, timeout=1, **kwargs) except TimeoutError: try: time.sleep(0.05) with mutex: - r = requests.put(cmd, timeout=0.5, **kwargs) + r = self._s.put(cmd, timeout=0.5, **kwargs) except TimeoutError: time.sleep(0.05) with mutex: - r = requests.put(cmd, timeout=0.5, **kwargs) + r = self._s.put(cmd, timeout=0.5, **kwargs) if not r.ok: raise RuntimeError( f"[{self.name}] Error putting {address}; reply was {r.status_code} => {r.reason}" diff --git a/pxiii_bec/devices/SmarGon2.py b/pxiii_bec/devices/SmarGon2.py new file mode 100644 index 0000000..0d188ee --- /dev/null +++ b/pxiii_bec/devices/SmarGon2.py @@ -0,0 +1,233 @@ +""" +``SmarGon`` --- SmarGon control software +****************************************** + +The module provides an object to control the SmarGon goniometer axes at PX III. +The SmarGon axes are interfaced as positioners. +""" + +import time +import threading +from threading import Thread, Lock +import requests +from requests.adapters import HTTPAdapter, Retry +from ophyd import Component, Kind, Signal, PVPositioner +from ophyd.status import SubscriptionStatus + +try: + from bec_lib import bec_logger + + logger = bec_logger.logger +except ModuleNotFoundError: + import logging + + logger = logging.getLogger("SmarGon") + + +# SmarGon contoller can't really handle multiple connections +# Use this mutex to ensure one access at a time +mutex = Lock() + + +class LimitedSmarGonSignal(Signal): + """SmarGonSignal (R/W) + + Small helper class to read/write parameters from SmarGon. As there is no + motion status readback from smargopolo, this should be substituted with + setting with 'settle_time'. + """ + + def __init__(self, *args, write_addr="targetSCS", low_limit=None, high_limit=None, **kwargs): + self._limits = (low_limit, high_limit) + super().__init__(*args, **kwargs) + self.write_addr = write_addr + + @property + def limits(self): + return self._limits + + def check_value(self, value, **kwargs): + """Check if value falls within limits""" + lol = self.limits[0] + if lol is not None: + if value < lol: + raise ValueError(f"Target {value} outside of limits {self.limits}") + hil = self.limits[1] + if hil is not None: + if value > hil: + raise ValueError(f"Target {value} outside of limits {self.limits}") + + def put(self, value, *, timestamp=None, **kwargs): + """Overriden put to add communication with smargopolo""" + # Validate new value and get timestamp + self.check_value(value) + if timestamp is None: + timestamp = time.time() + + # Perform the actual write to SmargoPolo + # pylint: disable=protected-access + r = self.parent._go_n_put(f"{self.write_addr}?{self.parent.name.upper()}={value}") + + # pylint: disable=attribute-defined-outside-init + old_value = self._readback + self._timestamp = timestamp + self._readback = r[self.parent.name.upper()] + self._value = r[self.parent.name.upper()] + + # Notify subscribers + self._run_subs( + sub_type=self.SUB_VALUE, old_value=old_value, value=value, timestamp=self._timestamp + ) + + +class SmarGonAxis(PVPositioner): + """SmarGon client deice + + This class controls the SmarGon goniometer via the REST interface. All + SmarGon axes share a common mutex to manage actual HW access. + """ + USER_ACCESS = ["omove"] + + # Status attributes + sg_url = Component(Signal, kind=Kind.config, metadata={"write_access": False}) + + # Axis parameters + readback = Component(Signal, kind=Kind.hinted, metadata={"write_access": False}) + setpoint = Component(LimitedSmarGonSignal, kind=Kind.normal) + done = Component(Signal, value=1, kind=Kind.normal, metadata={"write_access": False}) + _tol = 0.001 + + # pylint: disable=too-many-arguments + def __init__( + self, + prefix="SCS", + *, + name, + kind=None, + read_attrs=None, + configuration_attrs=None, + parent=None, + sg_url: str = "http://x06da-smargopolo.psi.ch:3000", + low_limit=None, + high_limit=None, + **kwargs, + ) -> None: + # self.__class__.__dict__["setpoint"].kwargs["write_addr"] = f"target{prefix}" + self.__class__.__dict__["setpoint"].kwargs["low_limit"] = low_limit + self.__class__.__dict__["setpoint"].kwargs["high_limit"] = high_limit + self.__class__.__dict__["sg_url"].kwargs["value"] = sg_url + # Fine-tune HTTP connection behavior + # NOTE: SmarGon has a few failed requests every one in a while + self._s = requests.Session() + retries = Retry(total=5, backoff_factor=0.05, status_forcelist=[ 500, 502, 503, 504 ]) + self._s.mount('http://', HTTPAdapter(max_retries=retries)) + + super().__init__( + prefix=prefix, + name=name, + kind=kind, + read_attrs=read_attrs, + configuration_attrs=configuration_attrs, + parent=parent, + **kwargs, + ) + + def on_target(): + """NOTE: This assumes that both readback and setpoint is always up to date""" + time.sleep(2) + while True: + # Read back target and setpoint values + # pylint: disable=protected-access + r = self._go_n_get("readbackSCS") + rb = r[self.name.upper()] + self.readback.set(rb, force=True).wait() + r = self._go_n_get("targetSCS") + sp = r[self.name.upper()] + self.setpoint._value = sp + # print(f"Readback: {rb}\tSetpoint: {sp}") + # Check if they're within tolerance + distance = abs(rb - sp) + done = 1 if distance {r.reason}" + ) + return r.json() + + def _go_n_put(self, address, **kwargs): + """Helper function to connect to smargopolo""" + cmd = f"{self.sg_url.get()}/{address}" + try: + with mutex: + r = self._s.put(cmd, timeout=1, **kwargs) + except TimeoutError: + try: + time.sleep(0.05) + with mutex: + r = self._s.put(cmd, timeout=0.5, **kwargs) + except TimeoutError: + time.sleep(0.05) + with mutex: + r = self._s.put(cmd, timeout=0.5, **kwargs) + if not r.ok: + raise RuntimeError( + f"[{self.name}] Error putting {address}; reply was {r.status_code} => {r.reason}" + ) + return r.json() + + +if __name__ == "__main__": + shx = SmarGonAxis(prefix="SCS", name="shx", sg_url="http://x06da-smargopolo.psi.ch:3000") + shy = SmarGonAxis(prefix="SCS", name="shy", sg_url="http://x06da-smargopolo.psi.ch:3000") + shz = SmarGonAxis( + prefix="SCS", + name="shz", + low_limit=10, + high_limit=22, + sg_url="http://x06da-smargopolo.psi.ch:3000", + ) + shx.wait_for_connection() + shy.wait_for_connection() + shz.wait_for_connection()