fix(mo1_bragg): stop constant polling of ACS config signals

This commit is contained in:
2026-08-05 14:11:55 +02:00
parent cdd208618e
commit 5db31fb322
5 changed files with 274 additions and 33 deletions
+84 -18
View File
@@ -2,7 +2,9 @@
ACS controller device exposing plain read/write variables (no motion).
Uses the same BEC building blocks as before:
- ophyd_devices.utils.controller.Controller -> shared TCP/IP communicator
- debye_bec.devices.utils.term_trail_controller.TermTrailController
-> shared TCP/IP communicator (vendored term/trail extension of
ophyd_devices.utils.controller.Controller)
- ophyd_devices.utils.socket.SocketIO -> raw socket helper
- ophyd_devices.utils.socket.SocketSignal -> Signal base talking through it
@@ -14,18 +16,19 @@ Protocol:
from __future__ import annotations
import time
import traceback
from enum import Enum
import numpy as np
from bec_lib.logger import bec_logger
from ophyd_devices.utils.controller import Controller, threadlocked
from ophyd_devices.utils.controller import threadlocked
from ophyd_devices.utils.socket import SocketSignal
from debye_bec.devices.utils.term_trail_controller import TermTrailController
logger = bec_logger.logger
class ACSController(Controller):
class ACSController(TermTrailController):
"""
Shared TCP/IP communicator for one ACS controller.
@@ -37,7 +40,6 @@ class ACSController(Controller):
_axes_per_controller = 0 # not used for plain variables, no motion axes
def __init__(self, *, socket_cls, socket_host, socket_port, device_manager):
socket_cls.socket_timeout = 5
super().__init__(
socket_cls=socket_cls,
socket_host=socket_host,
@@ -72,7 +74,6 @@ class ACSController(Controller):
if self.sock is None:
self.on()
idx = f",{idx:0.0f}" if idx is not None else ""
# logger.info(f"Send request: SETVAR({np.round(value, prec)},{tag}{idx})")
reply = self.socket_put_and_receive(f"SETVAR({np.round(value, prec)},{tag}{idx})")
if reply.startswith("?"):
@@ -81,34 +82,99 @@ class ACSController(Controller):
class AcsSignal(SocketSignal):
"""Read/write ACS controller variable, identified by its tag number."""
"""Read/write ACS controller variable, identified by its tag number.
def __init__(self, *args, tag: int, prec: int, num_el: int = 1, enum: Enum = None, **kwargs):
Args:
tag (int): ACS variable tag number.
prec (int): Decimal precision used for reads and writes.
num_el (int): Number of elements for array variables.
enum (Enum): Optional enum mapping raw values to names.
cache_ttl (float | None): If set, `get()` serves the cached value for up to
this many seconds instead of querying the controller. The cache is
dropped on every `put()` (and via `invalidate_cache()`), so a fresh
hardware read follows each real configuration change. Use this for
config-kind signals: the BEC device server re-reads the full device
configuration whenever any auto-monitored signal of the device updates,
and without a cache each of those reads is a blocking GETVAR round trip.
None (default) disables caching.
"""
def __init__(
self,
*args,
tag: int,
prec: int,
num_el: int = 1,
enum: Enum = None,
cache_ttl: float | None = None,
**kwargs,
):
self.tag = tag
self.prec = prec
self.num_el = num_el
self.enum = enum
self.last_get = time.time()
self.cache_ttl = cache_ttl
self._last_hw_read: float | None = None
super().__init__(*args, **kwargs)
@property
def controller(self) -> ACSController:
return self.root.controller
def _socket_get(self):
now = time.time()
interval = now - self.last_get
self.last_get = now
logger.info(f"Get signal with tag {self.tag}, time to last get: {interval*1e3} ms")
# logger.info(f"socket_get called from: {traceback.format_stack()}")
def get(self, **kwargs):
"""Return the cached value while it is fresh; otherwise query the controller.
Deviates from `SocketSignal.get` in two ways: reads within `cache_ttl` of the
last hardware read are served from `self._readback` without touching the
socket, and the value callbacks only run when the value actually changed.
Unconditional callbacks would re-trigger the device server's auto-monitor
machinery on every read and turn polling into a self-sustaining loop.
"""
if self.SUB_VALUE in self._active_socket_callbacks:
return self._readback
if self._cache_is_fresh():
return self._readback
old_value = self._readback
self._readback = self._socket_get()
timestamp = time.time()
self._metadata["timestamp"] = timestamp
self._last_hw_read = timestamp
if not self._values_equal(old_value, self._readback):
self._run_subs(
sub_type=self.SUB_VALUE,
old_value=old_value,
value=self._readback,
timestamp=timestamp,
)
return self._readback
def put(self, value, connection_timeout=1, **kwargs):
super().put(value, connection_timeout=connection_timeout, **kwargs)
# the controller may quantize the written value, so drop the cache and let
# the next read report what the hardware actually stored
self.invalidate_cache()
def invalidate_cache(self) -> None:
"""Force the next `get()` to read from the controller."""
self._last_hw_read = None
def _cache_is_fresh(self) -> bool:
if self.cache_ttl is None or self._last_hw_read is None:
return False
return (time.time() - self._last_hw_read) < self.cache_ttl
@staticmethod
def _values_equal(old_value, new_value) -> bool:
if isinstance(old_value, np.ndarray) or isinstance(new_value, np.ndarray):
return np.array_equal(old_value, new_value)
return old_value == new_value
def _socket_get(self):
def convert(val):
return self.enum(val).name if self.enum is not None else val
if self.num_el <= 1:
val = convert(self.controller.get_var(self.tag, self.prec))
logger.info(f"Get signal with tag {self.tag}, time to last get: {interval*1e3} ms")
return val
return convert(self.controller.get_var(self.tag, self.prec))
return np.array(
[convert(self.controller.get_var(self.tag, self.prec, i)) for i in range(self.num_el)]
)
@@ -69,7 +69,10 @@ class Mo1BraggStatus(Device):
enc_initialized = Cpt(
EpicsSignalRO, suffix="enc_initialized_RBV", kind="config", auto_monitor=True
)
heartbeat = Cpt(EpicsSignalRO, suffix="heartbeat_RBV", kind="config", auto_monitor=True)
# kind="omitted": the heartbeat ticks continuously, and any auto-monitored
# config-kind signal update makes the device server re-read the full device
# configuration (incl. the ACS socket signals) on every tick
heartbeat = Cpt(EpicsSignalRO, suffix="heartbeat_RBV", kind="omitted", auto_monitor=True)
class Mo1BraggEncoder(Device):
@@ -148,9 +151,17 @@ class Mo1BraggScanSettings(Device):
# EpicsSignalWithRBV, suffix="s_scan_scantime", kind="config", auto_monitor=True
# )
s_scan_energy_lo = Cpt(AcsSignal, tag=53003, prec=6, kind="config", auto_monitor=False)
s_scan_energy_hi = Cpt(AcsSignal, tag=53004, prec=6, kind="config", auto_monitor=False)
s_scan_scantime = Cpt(AcsSignal, tag=53002, prec=3, kind="config", auto_monitor=False)
# cache_ttl: baseline/config reads are served from cache; every put() drops the
# cache, so a fresh hardware read follows each real configuration change
s_scan_energy_lo = Cpt(
AcsSignal, tag=53003, prec=6, kind="config", auto_monitor=False, cache_ttl=30.0
)
s_scan_energy_hi = Cpt(
AcsSignal, tag=53004, prec=6, kind="config", auto_monitor=False, cache_ttl=30.0
)
s_scan_scantime = Cpt(
AcsSignal, tag=53002, prec=3, kind="config", auto_monitor=False, cache_ttl=30.0
)
# XAS advanced scan settings
a_scan_pos = Cpt(EpicsSignalWithRBV, suffix="a_scan_pos", kind="config", auto_monitor=False)
@@ -214,26 +225,33 @@ class Mo1BraggScanControl(Device):
EpicsSignalWithRBV, suffix="scan_duration", kind="config", auto_monitor=True
)
scan_load = Cpt(EpicsSignal, suffix="scan_load", kind="config", put_complete=True)
scan_msg = Cpt(EpicsSignalRO, suffix="scan_msg_ENUM_RBV", kind="config", auto_monitor=True)
# The live scan-status PVs below are kind="omitted": they change during every scan
# (some, like the progress/time counters, every second), and each update of an
# auto-monitored config-kind signal makes the device server re-read and republish
# the full device configuration. They are still fully usable via get()/subscribe()
# (CompareStatus/TransitionStatus waits, progress forwarding), which ignore kind.
scan_msg = Cpt(EpicsSignalRO, suffix="scan_msg_ENUM_RBV", kind="omitted", auto_monitor=True)
scan_start_infinite = Cpt(
EpicsSignal, suffix="scan_start_infinite", kind="config", put_complete=True
)
scan_start_timer = Cpt(EpicsSignal, suffix="scan_start_timer", kind="config", put_complete=True)
scan_stop = Cpt(EpicsSignal, suffix="scan_stop", kind="config", put_complete=True)
scan_status = Cpt(
EpicsSignalRO, suffix="scan_status_ENUM_RBV", kind="config", auto_monitor=True
EpicsSignalRO, suffix="scan_status_ENUM_RBV", kind="omitted", auto_monitor=True
)
scan_time_left = Cpt(
EpicsSignalRO, suffix="scan_time_left_RBV", kind="config", auto_monitor=True
EpicsSignalRO, suffix="scan_time_left_RBV", kind="omitted", auto_monitor=True
)
scan_done = Cpt(EpicsSignalRO, suffix="scan_done_RBV", kind="config", auto_monitor=True)
scan_done = Cpt(EpicsSignalRO, suffix="scan_done_RBV", kind="omitted", auto_monitor=True)
scan_val_reset = Cpt(EpicsSignal, suffix="scan_val_reset", kind="config", put_complete=True)
scan_progress = Cpt(EpicsSignalRO, suffix="scan_progress_RBV", kind="config", auto_monitor=True)
scan_progress = Cpt(
EpicsSignalRO, suffix="scan_progress_RBV", kind="omitted", auto_monitor=True
)
scan_spectra_done = Cpt(
EpicsSignalRO, suffix="scan_n_osc_RBV", kind="config", auto_monitor=True
EpicsSignalRO, suffix="scan_n_osc_RBV", kind="omitted", auto_monitor=True
)
scan_spectra_left = Cpt(
EpicsSignalRO, suffix="scan_n_osc_left_RBV", kind="config", auto_monitor=True
EpicsSignalRO, suffix="scan_n_osc_left_RBV", kind="omitted", auto_monitor=True
)
+108
View File
@@ -0,0 +1,108 @@
"""Tests for the AcsSignal config-read cache and change-only value callbacks."""
import time
from unittest import mock
import numpy as np
import pytest
from ophyd import Component as Cpt
from ophyd import Device
from debye_bec.devices.mo1_bragg.acs import AcsSignal
# pylint: disable=protected-access
class _AcsDevice(Device):
"""Minimal host device providing the controller attribute AcsSignal expects."""
cached = Cpt(AcsSignal, tag=100, prec=3, kind="config", cache_ttl=30.0)
uncached = Cpt(AcsSignal, tag=200, prec=3, kind="config")
array = Cpt(AcsSignal, tag=300, prec=3, num_el=3, kind="config", cache_ttl=30.0)
def __init__(self, *args, controller=None, **kwargs):
self.controller = controller
super().__init__(*args, **kwargs)
@pytest.fixture
def acs_device():
controller = mock.MagicMock()
controller.get_var.return_value = 1.5
yield _AcsDevice(name="acs", controller=controller)
def test_cached_get_reads_hardware_once(acs_device):
assert acs_device.cached.get() == 1.5
assert acs_device.cached.get() == 1.5
acs_device.controller.get_var.assert_called_once_with(100, 3)
def test_uncached_get_reads_hardware_every_time(acs_device):
acs_device.uncached.get()
acs_device.uncached.get()
assert acs_device.controller.get_var.call_count == 2
def test_cache_expires_after_ttl(acs_device):
acs_device.cached.cache_ttl = 0.05
acs_device.cached.get()
time.sleep(0.06)
acs_device.cached.get()
assert acs_device.controller.get_var.call_count == 2
def test_put_invalidates_cache(acs_device):
acs_device.cached.get()
acs_device.cached.put(2.0)
acs_device.controller.set_var.assert_called_once_with(100, 2.0, 3)
acs_device.controller.get_var.return_value = 2.0
assert acs_device.cached.get() == 2.0
assert acs_device.controller.get_var.call_count == 2
def test_invalidate_cache_forces_fresh_read(acs_device):
acs_device.cached.get()
acs_device.cached.invalidate_cache()
acs_device.cached.get()
assert acs_device.controller.get_var.call_count == 2
def test_value_callbacks_fire_only_on_change(acs_device):
events = []
acs_device.cached.subscribe(lambda **kwargs: events.append(kwargs), run=False)
acs_device.cached.get() # None -> 1.5: fires
assert len(events) == 1
acs_device.cached.invalidate_cache()
acs_device.cached.get() # 1.5 -> 1.5: hardware read, but no callback
assert acs_device.controller.get_var.call_count == 2
assert len(events) == 1
acs_device.controller.get_var.return_value = 2.5
acs_device.cached.invalidate_cache()
acs_device.cached.get() # 1.5 -> 2.5: fires
assert len(events) == 2
assert events[-1]["value"] == 2.5
def test_array_signal_caches_and_compares_by_content(acs_device):
acs_device.controller.get_var.side_effect = lambda tag, prec, idx=None: float(idx)
events = []
acs_device.array.subscribe(lambda **kwargs: events.append(kwargs), run=False)
assert np.array_equal(acs_device.array.get(), np.array([0.0, 1.0, 2.0]))
assert acs_device.controller.get_var.call_count == 3 # one call per element
assert len(events) == 1
# cache hit: no additional hardware reads
acs_device.array.get()
assert acs_device.controller.get_var.call_count == 3
# fresh read with identical content: no callback
acs_device.array.invalidate_cache()
acs_device.array.get()
assert acs_device.controller.get_var.call_count == 6
assert len(events) == 1
+48 -3
View File
@@ -8,6 +8,7 @@ from unittest import mock
import ophyd
import pytest
from bec_lib.messages import ScanQueueMessage, ScanStatusMessage
from bec_server.device_server.tests.utils import DMMock
from bec_server.scan_server.scan_assembler import ScanAssembler
from bec_server.scan_server.scan_queue import RequestBlock
from bec_server.scan_server.scan_worker import ScanWorker
@@ -15,7 +16,7 @@ from bec_server.scan_server.tests.fixtures import scan_server_mock
from ophyd.utils import LimitError
from ophyd_devices.tests.utils import MockPV
# from bec_server.device_server.tests.utils import DMMock
from debye_bec.devices.mo1_bragg.acs import ACSController
from debye_bec.devices.mo1_bragg.mo1_bragg import (
Mo1Bragg,
Mo1BraggError,
@@ -40,12 +41,28 @@ def scan_worker_mock(scan_server_mock):
def mock_bragg():
name = "bragg"
prefix = "X01DA-OP-MO1:BRAGG:"
with mock.patch.object(ophyd, "cl") as mock_cl:
# dict-backed stand-in for the ACS controller so AcsSignal set/get round-trips
# work without a socket
acs_store = {}
def _set_var(tag, value, prec, idx=None):
acs_store[(tag, idx)] = value
def _get_var(tag, prec, idx=None):
return acs_store.get((tag, idx), 0.0)
ACSController._reset_controller()
with (
mock.patch.object(ACSController, "get_var", side_effect=_get_var),
mock.patch.object(ACSController, "set_var", side_effect=_set_var),
mock.patch.object(ophyd, "cl") as mock_cl,
):
mock_cl.get_pv = MockPV
mock_cl.thread_class = threading.Thread
dev = Mo1Bragg(name=name, prefix=prefix)
dev = Mo1Bragg(name=name, prefix=prefix, device_manager=DMMock())
patch_dual_pvs(dev)
yield dev
ACSController._reset_controller()
def test_init(mock_bragg):
@@ -119,6 +136,34 @@ def test_set_xtal(mock_bragg):
assert dev.crystal.xtal_enum.get() == 1
def test_read_configuration_uses_acs_cache_and_omits_status_pvs(mock_bragg):
"""The device server re-reads the full configuration whenever any auto-monitored
signal updates; the ticking status PVs must not be part of it, and repeated
config reads must not hit the ACS controller again while the cache is fresh."""
dev = mock_bragg
config = dev.read_configuration()
assert "bragg_scan_settings_s_scan_energy_lo" in config
assert "bragg_scan_settings_s_scan_energy_hi" in config
assert "bragg_scan_settings_s_scan_scantime" in config
# live status/progress PVs are omitted from the configuration
assert "bragg_status_heartbeat" not in config
assert "bragg_scan_control_scan_msg" not in config
assert "bragg_scan_control_scan_status" not in config
assert "bragg_scan_control_scan_progress" not in config
assert "bragg_scan_control_scan_time_left" not in config
assert "bragg_scan_control_scan_done" not in config
acs_reads = dev.controller.get_var.call_count
dev.read_configuration()
assert dev.controller.get_var.call_count == acs_reads # served from cache
# a config change drops the cache for exactly that signal
dev.scan_settings.s_scan_energy_lo.put(7000.0)
dev.read_configuration()
assert dev.controller.get_var.call_count == acs_reads + 1
assert dev.scan_settings.s_scan_energy_lo.get() == 7000.0
def test_set_xas_settings(mock_bragg):
dev = mock_bragg
dev.set_xas_settings(low=0.5, high=1, scan_time=0.1)
+5 -1
View File
@@ -5,8 +5,10 @@ from unittest import mock
import ophyd
import pytest
from bec_server.device_server.tests.utils import DMMock
from ophyd_devices.tests.utils import MockPV, patch_dual_pvs
from debye_bec.devices.mo1_bragg.acs import ACSController
from debye_bec.devices.mo1_bragg.mo1_bragg_angle import Mo1BraggAngle
from debye_bec.devices.mo1_bragg.mo1_bragg_devices import Mo1BraggStoppedError
@@ -18,12 +20,14 @@ def mock_bragg() -> Mo1BraggAngle:
"""Fixture for the Mo1BraggAngle device."""
name = "bragg"
prefix = "X01DA-OP-MO1:BRAGG:"
ACSController._reset_controller()
with mock.patch.object(ophyd, "cl") as mock_cl:
mock_cl.get_pv = MockPV
mock_cl.thread_class = threading.Thread
dev = Mo1BraggAngle(name=name, prefix=prefix)
dev = Mo1BraggAngle(name=name, prefix=prefix, device_manager=DMMock())
patch_dual_pvs(dev)
yield dev
ACSController._reset_controller()
def test_mo1_bragg_angle_init(mock_bragg):