From 16ea7f410e799b74d1ab8d0f7cb39b4fa82ca4e6 Mon Sep 17 00:00:00 2001 From: appel_c Date: Tue, 10 Feb 2026 12:39:09 +0100 Subject: [PATCH 1/6] feat(galil-rio): Add di_out channels to GalilRIO --- csaxs_bec/devices/omny/galil/galil_ophyd.py | 19 ++- csaxs_bec/devices/omny/galil/galil_rio.py | 152 +++++++++++++++----- tests/tests_devices/test_galil.py | 26 +++- 3 files changed, 161 insertions(+), 36 deletions(-) diff --git a/csaxs_bec/devices/omny/galil/galil_ophyd.py b/csaxs_bec/devices/omny/galil/galil_ophyd.py index d0c9fa2..c1215b1 100644 --- a/csaxs_bec/devices/omny/galil/galil_ophyd.py +++ b/csaxs_bec/devices/omny/galil/galil_ophyd.py @@ -4,6 +4,7 @@ This module contains the base class for Galil controllers as well as the signals import functools import time +from typing import Any from bec_lib import bec_logger from ophyd.utils import ReadOnlyError @@ -347,7 +348,23 @@ class GalilSignalBase(SocketSignal): def __init__(self, signal_name, **kwargs): self.signal_name = signal_name super().__init__(**kwargs) - self.controller = self.parent.controller + self.controller: Controller = self._find_attribute_recursively("controller") + + def _find_attribute_recursively(self, attribute: str) -> Any: + """ + Find an attribute recursively for nested sub-devices. + This is needed to find for example the controller instance for DDC components, + thus nested devices. + """ + max_depth = 10 # to prevent infinite recursion + current_parent = self.parent + depth = 0 + while current_parent is not None and depth < max_depth: + if hasattr(current_parent, attribute): + return getattr(current_parent, attribute) + current_parent = getattr(current_parent, "parent", None) + depth += 1 + raise RuntimeError(f"Attribute '{attribute}' not found within maximum depth {max_depth}.") class GalilSignalRO(GalilSignalBase): diff --git a/csaxs_bec/devices/omny/galil/galil_rio.py b/csaxs_bec/devices/omny/galil/galil_rio.py index aa20fdf..0a3aabb 100644 --- a/csaxs_bec/devices/omny/galil/galil_rio.py +++ b/csaxs_bec/devices/omny/galil/galil_rio.py @@ -13,16 +13,19 @@ over TCP/IP. It also provides a device integration that interfaces to these from __future__ import annotations import time -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal, Type from bec_lib.logger import bec_logger -from ophyd import Component as Cpt +from ophyd import DynamicDeviceComponent as DDC +from ophyd import Kind +from ophyd.utils import ReadOnlyError from ophyd_devices import PSIDeviceBase from ophyd_devices.utils.controller import Controller, threadlocked from ophyd_devices.utils.socket import SocketIO from csaxs_bec.devices.omny.galil.galil_ophyd import ( GalilCommunicationError, + GalilSignalBase, GalilSignalRO, retry_once, ) @@ -64,7 +67,7 @@ class GalilRIOController(Controller): ) -class GalilRIOSignalRO(GalilSignalRO): +class GalilRIOSignal(GalilSignalBase): """ Read-only Signal for reading a single analog input channel from the Galil RIO controller. It always read all 8 analog channels at once, and updates the reabacks of all channels. @@ -77,37 +80,28 @@ class GalilRIOSignalRO(GalilSignalRO): parent (GalilRIO): Parent GalilRIO device. """ - _NUM_ANALOG_CHANNELS = 8 _READ_TIMEOUT = 0.1 # seconds def __init__(self, signal_name: str, channel: int, parent: GalilRIO, **kwargs): super().__init__(signal_name, parent=parent, **kwargs) + self._readback_metadata = self._find_attribute_recursively("_readback_metadata") self._channel = channel self._metadata["connected"] = False - def _socket_get(self) -> float: - """Get command for the readback signal""" - cmd = "MG@" + ",@".join([f"AN[{ii}]" for ii in range(self._NUM_ANALOG_CHANNELS)]) - ret = self.controller.socket_put_and_receive(cmd) - values = [float(val) for val in ret.strip().split(" ")] - # This updates all channels' readbacks, including self._readback - self._update_all_channels(values) - return self._readback - def get(self): """Get current analog channel values from the Galil RIO controller.""" # If the last readback has happend more than _READ_TIMEOUT seconds ago, read all channels again - if time.monotonic() - self.parent.last_readback > self._READ_TIMEOUT: + if time.monotonic() - self._readback_metadata["last_readback"] > self._READ_TIMEOUT: self._readback = self._socket_get() return self._readback # pylint: disable=protected-access - def _update_all_channels(self, values: list[float]) -> None: + def _update_all_channels(self, values: list[float], signal_cls: Type[GalilRIOSignal]) -> None: """ Update all analog channel readbacks based on the provided list of values. List of values must be in order from an_ch0 to an_ch7. - We first have to update the _last_readback timestamp of the GalilRIO parent device. + We first have to update the timestamp of the GalilRIO _readback_metadata device. Then we update all readbacks of all an_ch channels, before we run any subscriptions. This ensures that all readbacks are updated before any subscriptions are run, which may themselves read other channels. @@ -115,14 +109,15 @@ class GalilRIOSignalRO(GalilSignalRO): Args: values (list[float]): List of 8 float values corresponding to the analog channels. They must be in order from an_ch0 to an_ch7. + signal_cls (Type[GalilRIOSignal]): The class of the signal to update, used to identify which signals to update. """ timestamp = time.time() # Update parent's last readback before running subscriptions!! - self.parent._last_readback = time.monotonic() + self._readback_metadata["last_readback"] = time.monotonic() updates: dict[str, tuple[float, float]] = {} # attr_name -> (new_val, old_val) # Update all readbacks first for walk in self.parent.walk_signals(): - if walk.item.attr_name.startswith("an_ch"): + if isinstance(walk.item, signal_cls): idx = int(walk.item.attr_name[-1]) if 0 <= idx < len(values): old_val = walk.item._readback @@ -143,6 +138,96 @@ class GalilRIOSignalRO(GalilSignalRO): ) +class GalilRIOSignalRO(GalilRIOSignal): + + _NUM_ANALOG_CHANNELS = 8 + + def __init__(self, signal_name: str, channel: int, parent: GalilRIO, **kwargs): + super().__init__(signal_name=signal_name, channel=channel, parent=parent, **kwargs) + self._metadata["write_access"] = False + + def _socket_set(self, val): + raise ReadOnlyError(f"Signal {self.name} is read-only.") + + def _socket_get(self) -> float: + """Get command for the readback signal""" + cmd = "MG@" + ", @".join([f"AN[{ii}]" for ii in range(self._NUM_ANALOG_CHANNELS)]) + ret = self.controller.socket_put_and_receive(cmd) + values = [float(val) for val in ret.strip().split(" ")] + # This updates all channels' readbacks, including self._readback + self._update_all_channels(values, signal_cls=GalilRIOSignalRO) + return self._readback + + +class GalilRIODigitalOutSignal(GalilRIOSignal): # We reuse the logic implemented for Galil + """ + Signal for controlling digital outputs of the Galil RIO controller. + """ + + _NUM_DIGITAL_OUTPUT_CHANNELS = 24 + + def _socket_get(self) -> float: + """Get command for the readback signal""" + cmd = f"MG@OUT[{self._channel}]" + ret = self.controller.socket_put_and_receive(cmd) + self._readback = float(ret.strip()) + return self._readback + + def _socket_set(self, val: Literal[0, 1]) -> None: + """Set command for the digital output signal. Value should be 0 or 1.""" + + if val not in (0, 1): + raise ValueError("Digital output value must be 0 or 1.") + cmd = f"SB{self._channel}" if val == 1 else f"CB{self._channel}" + self.controller.socket_put_confirmed(cmd) + + +def _create_analog_channels(num_channels: int) -> dict[str, tuple]: + """ + Create a dictionary of analog channel definitions for the DynamicDeviceComponent. + Starts from channel 0 to num_channels - 1. + + Args: + num_channels (int): The number of analog channels to create. + """ + an_channels = {} + for i in range(0, num_channels): + an_channels[f"ch{i}"] = ( + GalilRIOSignalRO, + f"ch{i}", + { + "kind": Kind.normal, + "auto_monitor": True, + "channel": i, + "doc": f"Analog channel {i}.", + }, + ) + return an_channels + + +def _create_digital_output_channels(num_channels: int) -> dict[str, tuple]: + """ + Create a dictionary of digital output channel definitions for the DynamicDeviceComponent. + Starts from channel 0 to num_channels - 1. + + Args: + num_channels (int): The number of digital output channels to create. + """ + di_out_channels = {} + for i in range(0, num_channels): + di_out_channels[f"ch{i}"] = ( + GalilRIODigitalOutSignal, + f"ch{i}", + { + "kind": Kind.config, + "auto_monitor": True, + "channel": i, + "doc": f"Digital output channel {i}.", + }, + ) + return di_out_channels + + class GalilRIO(PSIDeviceBase): """ Galil RIO controller integration with 8 analog input channels. To implement the device, @@ -154,14 +239,16 @@ class GalilRIO(PSIDeviceBase): SUB_CONNECTION_CHANGE = "connection_change" - an_ch0 = Cpt(GalilRIOSignalRO, signal_name="an_ch0", channel=0, doc="Analog input channel 0") - an_ch1 = Cpt(GalilRIOSignalRO, signal_name="an_ch1", channel=1, doc="Analog input channel 1") - an_ch2 = Cpt(GalilRIOSignalRO, signal_name="an_ch2", channel=2, doc="Analog input channel 2") - an_ch3 = Cpt(GalilRIOSignalRO, signal_name="an_ch3", channel=3, doc="Analog input channel 3") - an_ch4 = Cpt(GalilRIOSignalRO, signal_name="an_ch4", channel=4, doc="Analog input channel 4") - an_ch5 = Cpt(GalilRIOSignalRO, signal_name="an_ch5", channel=5, doc="Analog input channel 5") - an_ch6 = Cpt(GalilRIOSignalRO, signal_name="an_ch6", channel=6, doc="Analog input channel 6") - an_ch7 = Cpt(GalilRIOSignalRO, signal_name="an_ch7", channel=7, doc="Analog input channel 7") + ############################# + ### Analog input channels ### + ############################# + + analog_in = DDC( + _create_analog_channels(GalilRIOSignalRO._NUM_ANALOG_CHANNELS) + ) # Creates ch0 to ch7 + digital_out = DDC( + _create_digital_output_channels(GalilRIODigitalOutSignal._NUM_DIGITAL_OUTPUT_CHANNELS) + ) # Creates ch0 to ch23 def __init__( self, @@ -177,19 +264,18 @@ class GalilRIO(PSIDeviceBase): if port is None: port = 23 # Default port for Galil RIO controller self.controller = GalilRIOController( - socket_cls=socket_cls, socket_host=host, socket_port=port, device_manager=device_manager + name=f"GalilRIOController_{name}", + socket_cls=socket_cls, + socket_host=host, + socket_port=port, + device_manager=device_manager, ) - self._last_readback: float = time.monotonic() + self._readback_metadata: dict[str, float] = {"last_readback": 0.0} super().__init__(name=name, device_manager=device_manager, scan_info=scan_info, **kwargs) self.controller.subscribe( self._update_connection_state, event_type=self.SUB_CONNECTION_CHANGE ) - @property - def last_readback(self) -> float: - """Return the time of the last readback from the controller.""" - return self._last_readback - # pylint: disable=arguments-differ def wait_for_connection(self, timeout: float = 30.0) -> None: """Wait for the RIO controller to be connected within timeout period.""" diff --git a/tests/tests_devices/test_galil.py b/tests/tests_devices/test_galil.py index 3024640..44c0476 100644 --- a/tests/tests_devices/test_galil.py +++ b/tests/tests_devices/test_galil.py @@ -274,11 +274,18 @@ def test_galil_rio_signal_read(galil_rio): assert galil_rio.an_ch0._READ_TIMEOUT == 0.1 # Default read timeout of 100ms # Mock the socket to return specific values - galil_rio.controller.sock.buffer_recv = [b" 1.234 2.345 3.456 4.567 5.678 6.789 7.890 8.901"] + an_buffer = b" 1.234 2.345 3.456 4.567 5.678 6.789 7.890 8.901\r\n" + di_buffer = b"0.0\r\n" + galil_rio.controller.sock.buffer_recv = [] # Clear any existing buffer + for name in galil_rio.component_names: + if name.startswith("an_ch"): + galil_rio.controller.sock.buffer_recv.append(an_buffer) + elif name.startswith("di_ou"): + galil_rio.controller.sock.buffer_recv.append(di_buffer) galil_rio._last_readback = 0 # Force read from controller read_values = galil_rio.read() - assert len(read_values) == 8 # 8 channels + assert len(read_values) == 16 # 16 channels expected_values = { galil_rio.an_ch0.name: {"value": 1.234}, galil_rio.an_ch1.name: {"value": 2.345}, @@ -356,3 +363,18 @@ def test_galil_rio_signal_read(galil_rio): for value in value_callback_buffer[0].values() ] ) + + +def test_galil_rio_digital_out_signal(galil_rio): + """ + Test that the Galil RIO digital output signal can be set correctly. + """ + # Set digital output channel 0 to high + galil_rio.controller.sock.buffer_recv = [b":"] # Mock response for readback] + galil_rio.di_out0.put(1) + assert galil_rio.controller.sock.buffer_put == [b"SB0\r"] + + galil_rio.controller.sock.buffer_recv = [b":"] # Mock response for readback + # Set digital output channel 0 to low + galil_rio.di_out0.put(0) + assert galil_rio.controller.sock.buffer_put == [b"SB0\r", b"CB0\r"] -- 2.49.1 From 5811e445fe2d617c6d22e536126896ac6aebe236 Mon Sep 17 00:00:00 2001 From: appel_c Date: Wed, 11 Feb 2026 08:33:53 +0100 Subject: [PATCH 2/6] tests: fix tests after refactoring --- tests/tests_devices/test_galil.py | 89 +++++++++++++++++++------------ 1 file changed, 54 insertions(+), 35 deletions(-) diff --git a/tests/tests_devices/test_galil.py b/tests/tests_devices/test_galil.py index 44c0476..10ff83d 100644 --- a/tests/tests_devices/test_galil.py +++ b/tests/tests_devices/test_galil.py @@ -272,33 +272,27 @@ def test_galil_rio_signal_read(galil_rio): ## Test read of all channels ########### - assert galil_rio.an_ch0._READ_TIMEOUT == 0.1 # Default read timeout of 100ms + assert galil_rio.analog_in.ch0._READ_TIMEOUT == 0.1 # Default read timeout of 100ms # Mock the socket to return specific values - an_buffer = b" 1.234 2.345 3.456 4.567 5.678 6.789 7.890 8.901\r\n" - di_buffer = b"0.0\r\n" + analog_bufffer = b" 1.234 2.345 3.456 4.567 5.678 6.789 7.890 8.901\r\n" galil_rio.controller.sock.buffer_recv = [] # Clear any existing buffer - for name in galil_rio.component_names: - if name.startswith("an_ch"): - galil_rio.controller.sock.buffer_recv.append(an_buffer) - elif name.startswith("di_ou"): - galil_rio.controller.sock.buffer_recv.append(di_buffer) - galil_rio._last_readback = 0 # Force read from controller - + galil_rio.controller.sock.buffer_recv.append(analog_bufffer) read_values = galil_rio.read() - assert len(read_values) == 16 # 16 channels + assert len(read_values) == 8 # 8 channels + expected_values = { - galil_rio.an_ch0.name: {"value": 1.234}, - galil_rio.an_ch1.name: {"value": 2.345}, - galil_rio.an_ch2.name: {"value": 3.456}, - galil_rio.an_ch3.name: {"value": 4.567}, - galil_rio.an_ch4.name: {"value": 5.678}, - galil_rio.an_ch5.name: {"value": 6.789}, - galil_rio.an_ch6.name: {"value": 7.890}, - galil_rio.an_ch7.name: {"value": 8.901}, + galil_rio.analog_in.ch0.name: {"value": 1.234}, + galil_rio.analog_in.ch1.name: {"value": 2.345}, + galil_rio.analog_in.ch2.name: {"value": 3.456}, + galil_rio.analog_in.ch3.name: {"value": 4.567}, + galil_rio.analog_in.ch4.name: {"value": 5.678}, + galil_rio.analog_in.ch5.name: {"value": 6.789}, + galil_rio.analog_in.ch6.name: {"value": 7.890}, + galil_rio.analog_in.ch7.name: {"value": 8.901}, } # All timestamps should be the same assert all( - ret["timestamp"] == read_values[galil_rio.an_ch0.name]["timestamp"] + ret["timestamp"] == read_values[galil_rio.analog_in.ch0.name]["timestamp"] for signal_name, ret in read_values.items() ) # Check values @@ -308,7 +302,7 @@ def test_galil_rio_signal_read(galil_rio): # Check communication command to socker assert galil_rio.controller.sock.buffer_put == [ - b"MG@AN[0],@AN[1],@AN[2],@AN[3],@AN[4],@AN[5],@AN[6],@AN[7]\r" + b"MG@AN[0], @AN[1], @AN[2], @AN[3], @AN[4], @AN[5], @AN[6], @AN[7]\r" ] ########### @@ -320,11 +314,11 @@ def test_galil_rio_signal_read(galil_rio): def value_callback(value, old_value, **kwargs): obj = kwargs.get("obj") - galil = obj.parent + galil = obj.parent.parent readback = galil.read() value_callback_buffer.append(readback) - galil_rio.an_ch0.subscribe(value_callback, run=False) + galil_rio.analog_in.ch0.subscribe(value_callback, run=False) galil_rio.controller.sock.buffer_recv = [b" 2.5 2.6 2.7 2.8 2.9 3.0 3.1 3.2"] expected_values = [2.5, 2.6, 2.7, 2.8, 2.9, 3.0, 3.1, 3.2] @@ -335,12 +329,14 @@ def test_galil_rio_signal_read(galil_rio): # Should have used the cached value for walk in galil_rio.walk_signals(): walk.item._READ_TIMEOUT = 10 # Make sure cached read is used - ret = galil_rio.an_ch0.read() + ret = galil_rio.analog_in.ch0.read() # Should not trigger callback since value did not change - assert np.isclose(ret[galil_rio.an_ch0.name]["value"], 1.234) + assert np.isclose(ret[galil_rio.analog_in.ch0.name]["value"], 1.234) # Same timestamp as for another channel as this is cached read - assert np.isclose(ret[galil_rio.an_ch0.name]["timestamp"], galil_rio.an_ch7.timestamp) + assert np.isclose( + ret[galil_rio.analog_in.ch0.name]["timestamp"], galil_rio.analog_in.ch7.timestamp + ) assert len(value_callback_buffer) == 0 ################## @@ -348,10 +344,10 @@ def test_galil_rio_signal_read(galil_rio): ################## # Now force a read from the controller - galil_rio._last_readback = 0 # Force read from controller - ret = galil_rio.an_ch0.read() + galil_rio._readback_metadata["last_readback"] = 0 # Force read from controller + ret = galil_rio.analog_in.ch0.read() - assert np.isclose(ret[galil_rio.an_ch0.name]["value"], 2.5) + assert np.isclose(ret[galil_rio.analog_in.ch0.name]["value"], 2.5) # Check callback invocation, but only 1 callback even with galil_rio.read() call in callback assert len(value_callback_buffer) == 1 @@ -359,7 +355,8 @@ def test_galil_rio_signal_read(galil_rio): assert np.isclose(values, expected_values).all() assert all( [ - value["timestamp"] == value_callback_buffer[0][galil_rio.an_ch0.name]["timestamp"] + value["timestamp"] + == value_callback_buffer[0][galil_rio.analog_in.ch0.name]["timestamp"] for value in value_callback_buffer[0].values() ] ) @@ -369,12 +366,34 @@ def test_galil_rio_digital_out_signal(galil_rio): """ Test that the Galil RIO digital output signal can be set correctly. """ + ## Test Read from digital output channels + buffer_receive = [] + excepted_put_buffer = [] + for ii in range(galil_rio.digital_out.ch0._NUM_DIGITAL_OUTPUT_CHANNELS): + cmd = f"MG@OUT[{ii}]\r".encode() + excepted_put_buffer.append(cmd) + recv = " 1.000".encode() + buffer_receive.append(recv) + + galil_rio.controller.sock.buffer_recv = buffer_receive # Mock response for readback + + digital_read = galil_rio.read_configuration() # Read to populate readback values + + for walk in galil_rio.digital_out.walk_signals(): + assert np.isclose(digital_read[walk.item.name]["value"], 1.0) + + assert galil_rio.controller.sock.buffer_put == excepted_put_buffer + + # Test writing to digital output channels + galil_rio.controller.sock.buffer_put = [] # Clear buffer put + galil_rio.controller.sock.buffer_recv = [b":"] # Mock response for readback + # Set digital output channel 0 to high - galil_rio.controller.sock.buffer_recv = [b":"] # Mock response for readback] - galil_rio.di_out0.put(1) + galil_rio.digital_out.ch0.put(1) assert galil_rio.controller.sock.buffer_put == [b"SB0\r"] - galil_rio.controller.sock.buffer_recv = [b":"] # Mock response for readback # Set digital output channel 0 to low - galil_rio.di_out0.put(0) - assert galil_rio.controller.sock.buffer_put == [b"SB0\r", b"CB0\r"] + galil_rio.controller.sock.buffer_put = [] # Clear buffer put + galil_rio.controller.sock.buffer_recv = [b":"] # Mock response for readback + galil_rio.digital_out.ch0.put(0) + assert galil_rio.controller.sock.buffer_put == [b"CB0\r"] -- 2.49.1 From f925a7c1dbcf5a897d94ba909081021ca701cdd7 Mon Sep 17 00:00:00 2001 From: appel_c Date: Wed, 11 Feb 2026 09:44:35 +0100 Subject: [PATCH 3/6] fix(galilsignalbase): fix root access for controller from parent. --- csaxs_bec/devices/omny/galil/galil_ophyd.py | 18 +----------------- csaxs_bec/devices/omny/galil/galil_rio.py | 6 +++++- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/csaxs_bec/devices/omny/galil/galil_ophyd.py b/csaxs_bec/devices/omny/galil/galil_ophyd.py index c1215b1..f05325e 100644 --- a/csaxs_bec/devices/omny/galil/galil_ophyd.py +++ b/csaxs_bec/devices/omny/galil/galil_ophyd.py @@ -348,23 +348,7 @@ class GalilSignalBase(SocketSignal): def __init__(self, signal_name, **kwargs): self.signal_name = signal_name super().__init__(**kwargs) - self.controller: Controller = self._find_attribute_recursively("controller") - - def _find_attribute_recursively(self, attribute: str) -> Any: - """ - Find an attribute recursively for nested sub-devices. - This is needed to find for example the controller instance for DDC components, - thus nested devices. - """ - max_depth = 10 # to prevent infinite recursion - current_parent = self.parent - depth = 0 - while current_parent is not None and depth < max_depth: - if hasattr(current_parent, attribute): - return getattr(current_parent, attribute) - current_parent = getattr(current_parent, "parent", None) - depth += 1 - raise RuntimeError(f"Attribute '{attribute}' not found within maximum depth {max_depth}.") + self.controller = self.root.controller if hasattr(self.root, "controller") else None class GalilSignalRO(GalilSignalBase): diff --git a/csaxs_bec/devices/omny/galil/galil_rio.py b/csaxs_bec/devices/omny/galil/galil_rio.py index 0a3aabb..49df491 100644 --- a/csaxs_bec/devices/omny/galil/galil_rio.py +++ b/csaxs_bec/devices/omny/galil/galil_rio.py @@ -84,7 +84,11 @@ class GalilRIOSignal(GalilSignalBase): def __init__(self, signal_name: str, channel: int, parent: GalilRIO, **kwargs): super().__init__(signal_name, parent=parent, **kwargs) - self._readback_metadata = self._find_attribute_recursively("_readback_metadata") + self._readback_metadata = ( + self.root._readback_metadata + if hasattr(self.root, "_readback_metadata") + else {"last_readback": 0.0} + ) self._channel = channel self._metadata["connected"] = False -- 2.49.1 From 9a249363fdf9acb740ed361c9c26a1179b137900 Mon Sep 17 00:00:00 2001 From: appel_c Date: Mon, 16 Feb 2026 15:14:50 +0100 Subject: [PATCH 4/6] refactor(galil-rio): fix socket-signal cached readings --- csaxs_bec/devices/omny/galil/galil_rio.py | 148 ++++++++++------------ tests/tests_devices/test_galil.py | 12 +- 2 files changed, 75 insertions(+), 85 deletions(-) diff --git a/csaxs_bec/devices/omny/galil/galil_rio.py b/csaxs_bec/devices/omny/galil/galil_rio.py index 49df491..05bba10 100644 --- a/csaxs_bec/devices/omny/galil/galil_rio.py +++ b/csaxs_bec/devices/omny/galil/galil_rio.py @@ -26,7 +26,6 @@ from ophyd_devices.utils.socket import SocketIO from csaxs_bec.devices.omny.galil.galil_ophyd import ( GalilCommunicationError, GalilSignalBase, - GalilSignalRO, retry_once, ) @@ -67,9 +66,10 @@ class GalilRIOController(Controller): ) -class GalilRIOSignal(GalilSignalBase): +class GalilRIOAnalogSignalRO(GalilSignalBase): """ Read-only Signal for reading a single analog input channel from the Galil RIO controller. + To make the readback more efficient, we will read all analog channels It always read all 8 analog channels at once, and updates the reabacks of all channels. New readbacks are only fetched from the controller if the last readback is older than _READ_TIMEOUT seconds, otherwise the last cached readback is returned to reduce network traffic. @@ -80,74 +80,12 @@ class GalilRIOSignal(GalilSignalBase): parent (GalilRIO): Parent GalilRIO device. """ - _READ_TIMEOUT = 0.1 # seconds - - def __init__(self, signal_name: str, channel: int, parent: GalilRIO, **kwargs): - super().__init__(signal_name, parent=parent, **kwargs) - self._readback_metadata = ( - self.root._readback_metadata - if hasattr(self.root, "_readback_metadata") - else {"last_readback": 0.0} - ) - self._channel = channel - self._metadata["connected"] = False - - def get(self): - """Get current analog channel values from the Galil RIO controller.""" - # If the last readback has happend more than _READ_TIMEOUT seconds ago, read all channels again - if time.monotonic() - self._readback_metadata["last_readback"] > self._READ_TIMEOUT: - self._readback = self._socket_get() - return self._readback - - # pylint: disable=protected-access - def _update_all_channels(self, values: list[float], signal_cls: Type[GalilRIOSignal]) -> None: - """ - Update all analog channel readbacks based on the provided list of values. - List of values must be in order from an_ch0 to an_ch7. - - We first have to update the timestamp of the GalilRIO _readback_metadata device. - Then we update all readbacks of all an_ch channels, before we run any subscriptions. - This ensures that all readbacks are updated before any subscriptions are run, which - may themselves read other channels. - - Args: - values (list[float]): List of 8 float values corresponding to the analog channels. - They must be in order from an_ch0 to an_ch7. - signal_cls (Type[GalilRIOSignal]): The class of the signal to update, used to identify which signals to update. - """ - timestamp = time.time() - # Update parent's last readback before running subscriptions!! - self._readback_metadata["last_readback"] = time.monotonic() - updates: dict[str, tuple[float, float]] = {} # attr_name -> (new_val, old_val) - # Update all readbacks first - for walk in self.parent.walk_signals(): - if isinstance(walk.item, signal_cls): - idx = int(walk.item.attr_name[-1]) - if 0 <= idx < len(values): - old_val = walk.item._readback - new_val = values[idx] - walk.item._metadata["timestamp"] = timestamp - walk.item._readback = new_val - updates[walk.item.attr_name] = (new_val, old_val) - - # Run subscriptions after all readbacks have been updated - for walk in self.parent.walk_signals(): - if walk.item.attr_name in updates: - new_val, old_val = updates[walk.item.attr_name] - walk.item._run_subs( - sub_type=walk.item.SUB_VALUE, - old_value=old_val, - value=new_val, - timestamp=timestamp, - ) - - -class GalilRIOSignalRO(GalilRIOSignal): - _NUM_ANALOG_CHANNELS = 8 def __init__(self, signal_name: str, channel: int, parent: GalilRIO, **kwargs): - super().__init__(signal_name=signal_name, channel=channel, parent=parent, **kwargs) + super().__init__(signal_name=signal_name, parent=parent, **kwargs) + self._channel = channel + self._metadata["connected"] = False self._metadata["write_access"] = False def _socket_set(self, val): @@ -158,17 +96,70 @@ class GalilRIOSignalRO(GalilRIOSignal): cmd = "MG@" + ", @".join([f"AN[{ii}]" for ii in range(self._NUM_ANALOG_CHANNELS)]) ret = self.controller.socket_put_and_receive(cmd) values = [float(val) for val in ret.strip().split(" ")] - # This updates all channels' readbacks, including self._readback - self._update_all_channels(values, signal_cls=GalilRIOSignalRO) + # This updates all channels' readbacks, including self._readback. Channels must be named following + # the convention ch0, ch1, ..., ch7 for this to work correctly. + self._update_all_channels(values) return self._readback + # pylint: disable=protected-access + def _update_all_channels(self, values: list[float]) -> None: + """ + This method updates the readback values of all analog channels based on the list of values provided. + It also runs the subscriptions for each channel after updating the readbacks. It relies on + _last_readback being updated before calling _socket_get to ensure that timestamps are properly updated. + This convention is implemented in the SocketIO class. We futher rely on the convention that the channels + are named ch0, ch1, ..., ch7 for this to work correctly. Only GalilRIOAnalogSignalRO signals will be considered + in this update logic. -class GalilRIODigitalOutSignal(GalilRIOSignal): # We reuse the logic implemented for Galil + Args: + values (list[float]): List of new readback values for all channels, where the + index corresponds to the channel number (0-7). + """ + updates: dict[str, tuple[float, float]] = {} # attr_name -> (new_val, old_val) + # Update all readbacks first + for walk in self.parent.walk_signals(): + if isinstance(walk.item, GalilRIOAnalogSignalRO): + idx = int(walk.item.attr_name[-1]) + if 0 <= idx < len(values): + old_val = walk.item._readback + new_val = values[idx] + walk.item._metadata["timestamp"] = self._last_readback + walk.item._last_readback = self._last_readback + walk.item._readback = new_val + if ( + idx != self._channel + ): # Only run subscriptions on other channels, not on itself + # as this is handled by the SocketSignal and we want to avoid running multiple + # subscriptions for the same channel update + updates[walk.item.attr_name] = (new_val, old_val) + else: + logger.warning( + f"Received {len(values)} values but found channel index {idx} in signal {walk.item.name}. Skipping update for this signal." + ) + + # Run subscriptions after all readbacks have been updated + for walk in self.parent.walk_signals(): + if walk.item.attr_name in updates: + new_val, old_val = updates[walk.item.attr_name] + walk.item._run_subs( + sub_type=walk.item.SUB_VALUE, + old_value=old_val, + value=new_val, + timestamp=self._last_readback, + ) + + +class GalilRIODigitalOutSignal(GalilSignalBase): # We reuse the logic implemented for Galil """ Signal for controlling digital outputs of the Galil RIO controller. """ - _NUM_DIGITAL_OUTPUT_CHANNELS = 24 + _NUM_DIGITAL_OUTPUT_CHANNELS = 16 + + def __init__(self, signal_name: str, channel: int, parent: GalilRIO, **kwargs): + super().__init__(signal_name, parent=parent, **kwargs) + self._channel = channel + self._metadata["connected"] = False def _socket_get(self) -> float: """Get command for the readback signal""" @@ -197,14 +188,9 @@ def _create_analog_channels(num_channels: int) -> dict[str, tuple]: an_channels = {} for i in range(0, num_channels): an_channels[f"ch{i}"] = ( - GalilRIOSignalRO, + GalilRIOAnalogSignalRO, f"ch{i}", - { - "kind": Kind.normal, - "auto_monitor": True, - "channel": i, - "doc": f"Analog channel {i}.", - }, + {"kind": Kind.normal, "notify_bec": True, "channel": i, "doc": f"Analog channel {i}."}, ) return an_channels @@ -224,7 +210,7 @@ def _create_digital_output_channels(num_channels: int) -> dict[str, tuple]: f"ch{i}", { "kind": Kind.config, - "auto_monitor": True, + "notify_bec": True, "channel": i, "doc": f"Digital output channel {i}.", }, @@ -248,7 +234,7 @@ class GalilRIO(PSIDeviceBase): ############################# analog_in = DDC( - _create_analog_channels(GalilRIOSignalRO._NUM_ANALOG_CHANNELS) + _create_analog_channels(GalilRIOAnalogSignalRO._NUM_ANALOG_CHANNELS) ) # Creates ch0 to ch7 digital_out = DDC( _create_digital_output_channels(GalilRIODigitalOutSignal._NUM_DIGITAL_OUTPUT_CHANNELS) @@ -297,7 +283,7 @@ class GalilRIO(PSIDeviceBase): if __name__ == "__main__": - HOST_NAME = "129.129.98.64" + HOST_NAME = "129.129.122.14" from bec_server.device_server.tests.utils import DMMock dm = DMMock() diff --git a/tests/tests_devices/test_galil.py b/tests/tests_devices/test_galil.py index 10ff83d..b47f271 100644 --- a/tests/tests_devices/test_galil.py +++ b/tests/tests_devices/test_galil.py @@ -9,7 +9,11 @@ from ophyd_devices.tests.utils import SocketMock from csaxs_bec.devices.npoint.npoint import NPointAxis, NPointController from csaxs_bec.devices.omny.galil.fgalil_ophyd import FlomniGalilController, FlomniGalilMotor from csaxs_bec.devices.omny.galil.fupr_ophyd import FuprGalilController, FuprGalilMotor -from csaxs_bec.devices.omny.galil.galil_rio import GalilRIO, GalilRIOController, GalilRIOSignalRO +from csaxs_bec.devices.omny.galil.galil_rio import ( + GalilRIO, + GalilRIOAnalogSignalRO, + GalilRIOController, +) from csaxs_bec.devices.omny.galil.lgalil_ophyd import LamniGalilController, LamniGalilMotor from csaxs_bec.devices.omny.galil.ogalil_ophyd import OMNYGalilController, OMNYGalilMotor from csaxs_bec.devices.omny.galil.sgalil_ophyd import GalilController, SGalilMotor @@ -272,7 +276,7 @@ def test_galil_rio_signal_read(galil_rio): ## Test read of all channels ########### - assert galil_rio.analog_in.ch0._READ_TIMEOUT == 0.1 # Default read timeout of 100ms + assert galil_rio.analog_in.ch0._readback_timeout == 0.1 # Default read timeout of 100ms # Mock the socket to return specific values analog_bufffer = b" 1.234 2.345 3.456 4.567 5.678 6.789 7.890 8.901\r\n" galil_rio.controller.sock.buffer_recv = [] # Clear any existing buffer @@ -328,7 +332,7 @@ def test_galil_rio_signal_read(galil_rio): # Should have used the cached value for walk in galil_rio.walk_signals(): - walk.item._READ_TIMEOUT = 10 # Make sure cached read is used + walk.item._readback_timeout = 10 # Make sure cached read is used ret = galil_rio.analog_in.ch0.read() # Should not trigger callback since value did not change @@ -344,7 +348,7 @@ def test_galil_rio_signal_read(galil_rio): ################## # Now force a read from the controller - galil_rio._readback_metadata["last_readback"] = 0 # Force read from controller + galil_rio.analog_in.ch0._last_readback = 0 # Force read from controller ret = galil_rio.analog_in.ch0.read() assert np.isclose(ret[galil_rio.analog_in.ch0.name]["value"], 2.5) -- 2.49.1 From 93384b87e09dada6c09b4df51efb6e7735255516 Mon Sep 17 00:00:00 2001 From: appel_c Date: Mon, 16 Feb 2026 17:37:54 +0100 Subject: [PATCH 5/6] fix(readback-timeout): fix monkeypatch for readback timeout; closes #140 --- tests/conftest.py | 14 -------------- tests/tests_devices/test_fupr_ophyd.py | 6 ++++++ 2 files changed, 6 insertions(+), 14 deletions(-) delete mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index cfe52ea..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -Conftest runs for all tests in this directory and subdirectories. Thereby, we know for -certain that the SocketSignal.READBACK_TIMEOUT is set to 0 for all tests, which prevents -hanging tests when a readback is attempted on a non-connected socket. -""" - -# conftest.py -import pytest -from ophyd_devices.utils.socket import SocketSignal - - -@pytest.fixture(autouse=True) -def patch_socket_timeout(monkeypatch): - monkeypatch.setattr(SocketSignal, "READBACK_TIMEOUT", 0.0) diff --git a/tests/tests_devices/test_fupr_ophyd.py b/tests/tests_devices/test_fupr_ophyd.py index 2683a14..9e56856 100644 --- a/tests/tests_devices/test_fupr_ophyd.py +++ b/tests/tests_devices/test_fupr_ophyd.py @@ -2,6 +2,7 @@ from unittest import mock import pytest from ophyd_devices.tests.utils import SocketMock +from ophyd_devices.utils.socket import SocketSignal from csaxs_bec.devices.omny.galil.fupr_ophyd import FuprGalilController, FuprGalilMotor @@ -17,6 +18,11 @@ def fsamroy(dm_with_devices): socket_cls=SocketMock, device_manager=dm_with_devices, ) + for walk in fsamroy_motor.walk_signals(): + if isinstance(walk.item, SocketSignal): + walk.item._readback_timeout = ( + 0.0 # Set the readback timeout to 0 to avoid waiting during tests + ) fsamroy_motor.controller.on() assert isinstance(fsamroy_motor.controller, FuprGalilController) yield fsamroy_motor -- 2.49.1 From 5d979139560879f8e927cdf89d75a88f844c11a0 Mon Sep 17 00:00:00 2001 From: appel_c Date: Mon, 16 Feb 2026 17:48:35 +0100 Subject: [PATCH 6/6] refactor(galil-rio): Improve docstring and general integration --- csaxs_bec/devices/omny/galil/galil_rio.py | 77 +++++++++++------------ 1 file changed, 37 insertions(+), 40 deletions(-) diff --git a/csaxs_bec/devices/omny/galil/galil_rio.py b/csaxs_bec/devices/omny/galil/galil_rio.py index 05bba10..dc599d7 100644 --- a/csaxs_bec/devices/omny/galil/galil_rio.py +++ b/csaxs_bec/devices/omny/galil/galil_rio.py @@ -6,14 +6,14 @@ Link to the Galil RIO vendor page: https://www.galil.com/plcs/remote-io/rio-471xx This module provides the GalilRIOController for communication with the RIO controller -over TCP/IP. It also provides a device integration that interfaces to these -8 analog channels. +over TCP/IP. It also provides a device integration that interfaces to its +8 analog channels, and 16 digital output channels. Some PLCs may have 24 digital output channels, +which can be easily supported by changing the _NUM_DIGITAL_OUTPUT_CHANNELS variable. """ from __future__ import annotations -import time -from typing import TYPE_CHECKING, Literal, Type +from typing import TYPE_CHECKING, Literal from bec_lib.logger import bec_logger from ophyd import DynamicDeviceComponent as DDC @@ -37,15 +37,11 @@ logger = bec_logger.logger class GalilRIOController(Controller): - """ - Controller Class for Galil RIO controller communication. - - Multiple controllers are in use at the cSAXS beamline: - - 129.129.98.64 (port 23) - """ + """Controller Class for Galil RIO controller communication.""" @threadlocked def socket_put(self, val: str) -> None: + """Socker put method.""" self.sock.put(f"{val}\r".encode()) @retry_once @@ -68,16 +64,17 @@ class GalilRIOController(Controller): class GalilRIOAnalogSignalRO(GalilSignalBase): """ - Read-only Signal for reading a single analog input channel from the Galil RIO controller. - To make the readback more efficient, we will read all analog channels - It always read all 8 analog channels at once, and updates the reabacks of all channels. - New readbacks are only fetched from the controller if the last readback is older than - _READ_TIMEOUT seconds, otherwise the last cached readback is returned to reduce network traffic. + Signal for reading analog input channels of the Galil RIO controller. This signal is read-only, so + the set method raises a ReadOnlyError. The get method retrieves the values of all analog + channels in a single socket command. The readback values of all channels are updated based + on the response, and subscriptions are run for all channels. Readings are cached as implemented + in the SocketSignal class, so that multiple reads of the same channel within an update cycle do + not result in multiple socket calls. Args: - signal_name (str): Name of the signal. - channel (int): Analog channel number (0-7). - parent (GalilRIO): Parent GalilRIO device. + signal_name (str): The name of the signal, e.g. "ch0", "ch1", ..., "ch7" + channel (int): The channel number corresponding to the signal, e.g. 0 for "ch0", 1 for "ch1", ... + parent (GalilRIO): The parent device instance that this signal belongs to. """ _NUM_ANALOG_CHANNELS = 8 @@ -89,6 +86,7 @@ class GalilRIOAnalogSignalRO(GalilSignalBase): self._metadata["write_access"] = False def _socket_set(self, val): + """Read-only signal, so set method raises an error.""" raise ReadOnlyError(f"Signal {self.name} is read-only.") def _socket_get(self) -> float: @@ -96,20 +94,19 @@ class GalilRIOAnalogSignalRO(GalilSignalBase): cmd = "MG@" + ", @".join([f"AN[{ii}]" for ii in range(self._NUM_ANALOG_CHANNELS)]) ret = self.controller.socket_put_and_receive(cmd) values = [float(val) for val in ret.strip().split(" ")] - # This updates all channels' readbacks, including self._readback. Channels must be named following - # the convention ch0, ch1, ..., ch7 for this to work correctly. + # Run updates for all channels. This also updates the _readback and metadata timestamp + # value of this channel. self._update_all_channels(values) return self._readback # pylint: disable=protected-access def _update_all_channels(self, values: list[float]) -> None: """ - This method updates the readback values of all analog channels based on the list of values provided. - It also runs the subscriptions for each channel after updating the readbacks. It relies on - _last_readback being updated before calling _socket_get to ensure that timestamps are properly updated. - This convention is implemented in the SocketIO class. We futher rely on the convention that the channels - are named ch0, ch1, ..., ch7 for this to work correctly. Only GalilRIOAnalogSignalRO signals will be considered - in this update logic. + Method to receive a list of readback values for channels 0 to 7. Updates for each channel idx + are applied to the corresponding GalilRIOAnalogSignalRO signal with matching attr_name "ch{idx}". + + We also update the _last_readback attribute of each of the signals, to avoid multiple socket calls, + but rather use the cached value of the combined reading for all channels. Args: values (list[float]): List of new readback values for all channels, where the @@ -138,6 +135,7 @@ class GalilRIOAnalogSignalRO(GalilSignalBase): ) # Run subscriptions after all readbacks have been updated + # on all channels except the one that triggered the update for walk in self.parent.walk_signals(): if walk.item.attr_name in updates: new_val, old_val = updates[walk.item.attr_name] @@ -149,10 +147,8 @@ class GalilRIOAnalogSignalRO(GalilSignalBase): ) -class GalilRIODigitalOutSignal(GalilSignalBase): # We reuse the logic implemented for Galil - """ - Signal for controlling digital outputs of the Galil RIO controller. - """ +class GalilRIODigitalOutSignal(GalilSignalBase): + """Signal for controlling digital outputs of the Galil RIO controller.""" _NUM_DIGITAL_OUTPUT_CHANNELS = 16 @@ -179,8 +175,7 @@ class GalilRIODigitalOutSignal(GalilSignalBase): # We reuse the logic implement def _create_analog_channels(num_channels: int) -> dict[str, tuple]: """ - Create a dictionary of analog channel definitions for the DynamicDeviceComponent. - Starts from channel 0 to num_channels - 1. + Helper method to create a dictionary of analog channel definitions for the DynamicDeviceComponent. Args: num_channels (int): The number of analog channels to create. @@ -197,8 +192,7 @@ def _create_analog_channels(num_channels: int) -> dict[str, tuple]: def _create_digital_output_channels(num_channels: int) -> dict[str, tuple]: """ - Create a dictionary of digital output channel definitions for the DynamicDeviceComponent. - Starts from channel 0 to num_channels - 1. + Helper method to create a dictionary of digital output channel definitions for the DynamicDeviceComponent. Args: num_channels (int): The number of digital output channels to create. @@ -220,11 +214,16 @@ def _create_digital_output_channels(num_channels: int) -> dict[str, tuple]: class GalilRIO(PSIDeviceBase): """ - Galil RIO controller integration with 8 analog input channels. To implement the device, - please provide the appropriate host and port (default port is 23). + Galil RIO controller integration with 16 digital output channels and 8 analog input channels. + The default port for the controller is 23. Args: host (str): Hostname or IP address of the Galil RIO controller. + port (int, optional): Port number for the TCP/IP connection. Defaults to 23. + socket_cls (type[SocketIO], optional): Socket class to use for communication. Defaults to SocketIO. + scan_info (ScanInfo, optional): ScanInfo object for the device. + device_manager (DeviceManagerDS): The device manager instance that manages this device. + **kwargs: Additional keyword arguments passed to the PSIDeviceBase constructor. """ SUB_CONNECTION_CHANGE = "connection_change" @@ -233,12 +232,10 @@ class GalilRIO(PSIDeviceBase): ### Analog input channels ### ############################# - analog_in = DDC( - _create_analog_channels(GalilRIOAnalogSignalRO._NUM_ANALOG_CHANNELS) - ) # Creates ch0 to ch7 + analog_in = DDC(_create_analog_channels(GalilRIOAnalogSignalRO._NUM_ANALOG_CHANNELS)) digital_out = DDC( _create_digital_output_channels(GalilRIODigitalOutSignal._NUM_DIGITAL_OUTPUT_CHANNELS) - ) # Creates ch0 to ch23 + ) def __init__( self, -- 2.49.1