From aa3f17249e29e5b731de0da62041456afab3fa3c Mon Sep 17 00:00:00 2001 From: Pierre Schnizer Date: Sat, 31 Jan 2026 12:00:44 +0100 Subject: [PATCH 1/5] [TASK] added orbit model object modelled after BESSYII now using electronics which are frequently used --- src/accml_lib/core/model/output/orbit.py | 76 ++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 src/accml_lib/core/model/output/orbit.py diff --git a/src/accml_lib/core/model/output/orbit.py b/src/accml_lib/core/model/output/orbit.py new file mode 100644 index 0000000..5555ebd --- /dev/null +++ b/src/accml_lib/core/model/output/orbit.py @@ -0,0 +1,76 @@ +"""Orbit model as used at BESSY II + +Based on 4 button bpms. Button data made available next to +x and y + +Todo: + can that be the start for a more general orbit + model +""" +import functools +from dataclasses import dataclass +from typing import Hashable, Sequence, Dict + + +@dataclass +class BPMPosition: + """transversal position as read by a single bpm + + If not available marked as nan. 64 bits provide + enough data to store 32 bit int without conversion + loss. + + Note bpm data are in nm + + Todo: + naming that's more universal than x and y + e.g. hor(izontal) and vert(ical) + + Will be an interesting concept e.g. at + Novosibirsk's recovery linac. Then these + should be rather dispersion_plan / + non dispersion plane + """ + + x: float + y: float + + +@dataclass +class BPMButtons: + """ + todo: + consider renaming bpm buttons to give them more meaning + """ + + a: float + b: float + c: float + d: float + + +@dataclass +class BPMReading: + name: Hashable + pos: BPMPosition + btns: BPMButtons + + +@dataclass +class Orbit: + orbit: Sequence[BPMReading] + + def identifiers(self) -> Sequence[Hashable]: + return tuple(self._lut.keys()) + + def get_element(self, id_: Hashable) -> BPMReading: + """ + Todo: + consider to return a more descriptive Exception if + identifier is not found + """ + return self._lut[id_] + + @functools.cached_property + def _lut(self) -> Dict[Hashable, BPMReading]: + return {elem.name: elem for elem in self.orbit} From dccae85162bf2b4a03352e946aad9313a2811585 Mon Sep 17 00:00:00 2001 From: Pierre Schnizer Date: Sat, 31 Jan 2026 12:01:35 +0100 Subject: [PATCH 2/5] [FIX] following delta backend convention Store the property associated with what you got --- tests/test_custom/bessyii/test_simulator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_custom/bessyii/test_simulator.py b/tests/test_custom/bessyii/test_simulator.py index a405a53..142f8ea 100644 --- a/tests/test_custom/bessyii/test_simulator.py +++ b/tests/test_custom/bessyii/test_simulator.py @@ -72,7 +72,7 @@ async def test_quad_main_strength(simulator_backend): dk = await sim.read("Q1M1D1R", "delta_main_strength") assert dk == pytest.approx(0.0, abs=1e-8) - k = sim.cache.get(ReadCommand(id="Q1M1D1R", property="delta_main_strength")) + k = sim.cache.get(ReadCommand(id="Q1M1D1R", property="main_strength")) assert k == pytest.approx(2.436, abs=0.02) @@ -84,5 +84,5 @@ async def test_quad_delta_main_strength(simulator_backend): dk = await sim.read("Q1M1D1R", "delta_main_strength") assert dk == pytest.approx(5e-3, abs=1e-8) - k = sim.cache.get(ReadCommand(id="Q1M1D1R", property="delta_main_strength")) + k = sim.cache.get(ReadCommand(id="Q1M1D1R", property="main_strength")) assert k == pytest.approx(2.436, abs=0.001) From 80811c9c5dfa79ebd68dfaf7a545b6ad87dd3d63 Mon Sep 17 00:00:00 2001 From: Pierre Schnizer Date: Sat, 31 Jan 2026 13:50:30 +0100 Subject: [PATCH 3/5] [FIX] use orbit model / device for BESSY II in setup --- src/accml_lib/custom/bessyii/setup.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/accml_lib/custom/bessyii/setup.py b/src/accml_lib/custom/bessyii/setup.py index fad9585..2135f69 100644 --- a/src/accml_lib/custom/bessyii/setup.py +++ b/src/accml_lib/custom/bessyii/setup.py @@ -1,6 +1,13 @@ +""" + +Todo: + resolve dependency: orbit depends on custom epics + Should be a separate package +""" import logging import os +from accml.custom.epics.devices.orbit import Orbit from .liasion_translator_setup import load_managers from accml_lib.core.interfaces.utils.devices_facade import DevicesFacade as DevicesFacadeInterface from accml.core.utils.ophyd_async.multiplexer_for_settable_devices import ( @@ -13,7 +20,7 @@ from .facility_specific_constants import special_pvs # Todo: clarify with markus if this code will be contributed -from bact_bessyii_mls_ophyd.devices.pp.orbit import PPOrbit as Orbit + logger = logging.getLogger("accml_lib") From 667cf616b69595414f8c9fd1dd739c2c52b3a35d Mon Sep 17 00:00:00 2001 From: Pierre Schnizer Date: Sun, 1 Feb 2026 09:14:01 +0100 Subject: [PATCH 4/5] [TASK] use filter to process values from state cache then state cache can contain more information. --- src/accml_lib/core/bl/delta_backend.py | 27 ++- .../core/interfaces/backend/filter.py | 10 + tests/test_core/test_delta_backend.py | 198 ++++++++++++++++++ 3 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 src/accml_lib/core/interfaces/backend/filter.py create mode 100644 tests/test_core/test_delta_backend.py diff --git a/src/accml_lib/core/bl/delta_backend.py b/src/accml_lib/core/bl/delta_backend.py index 7688743..2644952 100644 --- a/src/accml_lib/core/bl/delta_backend.py +++ b/src/accml_lib/core/bl/delta_backend.py @@ -1,6 +1,7 @@ import logging from ..interfaces.backend.backend import BackendRW, BackendR +from ..interfaces.backend.filter import FilterInterface from ..model.utils.command import ReadCommand logger = logging.getLogger("accml") @@ -45,12 +46,18 @@ def delta_property(prop_id: str) -> (bool, str): return False, prop_id +class NOOPFilter(FilterInterface): + def process(self, input): + return input + + class DeltaBackendRProxy(BackendR): """handle delta properties""" - def __init__(self, *, backend: BackendR, cache: StateCache): + def __init__(self, *, backend: BackendR, cache: StateCache, filter:FilterInterface=NOOPFilter()): self.backend = backend self.cache = cache + self.filter = filter def __repr__(self): return f"{self.__class__.__name__}(backend={self.backend}, cache={self.cache})" @@ -80,16 +87,20 @@ def _calculate_delta_read(self, rcmd: ReadCommand, value): """ For overloading in derived classes e.g. for processing ophyd-async data """ - ref = self.cache.get(rcmd, None) - assert ref is not None - return value - ref + ref_cached = self.cache.get(rcmd, None) + assert ref_cached is not None + # Todo: check that both have the same name if so (for ophyd async e.g) + ref = self.filter.process(ref_cached) + v = self.filter.process(value) + r = v - ref + return r class DeltaBackendRWProxy(DeltaBackendRProxy, BackendRW): """handle delta properties""" - def __init__(self, backend: BackendRW, cache: StateCache): - super().__init__(backend=backend, cache=cache) + def __init__(self, backend: BackendRW, cache: StateCache, filter: FilterInterface=NOOPFilter()): + super().__init__(backend=backend, cache=cache, filter=filter) self.backend = backend async def set(self, dev_id: str, prop_id: str, value: object): @@ -98,7 +109,7 @@ async def set(self, dev_id: str, prop_id: str, value: object): return await self.backend.set(dev_id=dev_id, prop_id=prop_id, value=value) rcmd = ReadCommand(id=dev_id, property=orig_prop_id) - ref = self.cache.get(rcmd, None) + ref = self.filter.process(self.cache.get(rcmd, None)) if not ref: r = await self.backend.read(dev_id=dev_id, prop_id=orig_prop_id) # Todo: refactor the classes here so this does not need @@ -113,6 +124,6 @@ def _calculate_delta_set(self, rcmd: ReadCommand, value): """ For overloading in derived classes e.g. for processing ophyd-async data """ - ref = self.cache.get(rcmd, None) + ref = self.filter.process(self.cache.get(rcmd, None)) assert ref is not None, f"No reference stored for {rcmd}" return value + ref diff --git a/src/accml_lib/core/interfaces/backend/filter.py b/src/accml_lib/core/interfaces/backend/filter.py new file mode 100644 index 0000000..31b09ad --- /dev/null +++ b/src/accml_lib/core/interfaces/backend/filter.py @@ -0,0 +1,10 @@ +from abc import ABCMeta, abstractmethod +from typing import TypeVar + +T = TypeVar("T") + + +class FilterInterface(metaclass=ABCMeta): + @abstractmethod + def process(self, input: T) -> T: + raise NotImplementedError("use derived class instead") \ No newline at end of file diff --git a/tests/test_core/test_delta_backend.py b/tests/test_core/test_delta_backend.py new file mode 100644 index 0000000..b5ed078 --- /dev/null +++ b/tests/test_core/test_delta_backend.py @@ -0,0 +1,198 @@ +import asyncio +import dataclasses +import pytest +import types + +from accml_lib.core.bl import delta_backend +from accml_lib.core.bl.delta_backend import StateCache, NOOPFilter +from accml_lib.core.interfaces.backend.filter import FilterInterface +from accml_lib.core.model.utils.command import ReadCommand + +# pytest-asyncio is required for the async tests +pytest_plugins = ("pytest_asyncio",) + + +# ------------------------- +# Adjust this import if your code is in a different module +# ------------------------- +# from backend import ( +# BackendR, +# BackendRW, +# DeltaBackendRProxy, +# DeltaBackendRWProxy, +# NOOPFilter, +# FilterInterface, +# ReadCommand, +# StateCache, +# delta_property, +# ) +# + +backend_mod = delta_backend + +class BlockingFilter(FilterInterface): + """Filter that returns None for some values to simulate filtering out cache entries.""" + def __init__(self, block=False): + self.block = block + + def process(self, input): + if self.block: + return None + return input + + +class FakeBackend(backend_mod.BackendRW): + """Simple fake backend to capture calls and return preset values.""" + + def __init__(self): + self.calls = [] + # map of (dev_id, prop_id) -> value for read + self.read_map = {} + # last set recorded as tuple (dev_id, prop_id, value) + self.last_set = None + + def get_natural_view_name(self): + return "fake" + + async def trigger(self, dev_id: str, prop_id: str): + self.calls.append(("trigger", dev_id, prop_id)) + return f"triggered:{dev_id}:{prop_id}" + + async def read(self, dev_id: str, prop_id: str) -> object: + self.calls.append(("read", dev_id, prop_id)) + # default to 0 if not set explicitly + return self.read_map.get((dev_id, prop_id), 0) + + async def set(self, dev_id: str, prop_id: str, value: object): + self.calls.append(("set", dev_id, prop_id, value)) + self.last_set = (dev_id, prop_id, value) + return f"set:{dev_id}:{prop_id}:{value}" + + +# ------------------------- +# Monkeypatch helpers for delta_property +# ------------------------- +def delta_prop_flag(prefix="de.ta_"): + """ + Returns a function suitable to monkeypatch backend_mod.delta_property + The function marks properties starting with prefix as delta properties, + returning (True, original_prop_id_without_prefix). + """ + def _dp(prop_id: str): + if prop_id.startswith(prefix): + return True, prop_id[len(prefix) :] + return False, prop_id + return _dp + + +# ------------------------- +# Tests +# ------------------------- + +@pytest.mark.asyncio +async def test_trigger_forwards_to_backend(monkeypatch): + cache = StateCache(name="test_cache") + fake = FakeBackend() + proxy = backend_mod.DeltaBackendRProxy(backend=fake, cache=cache, filter=NOOPFilter()) + + # monkeypatch delta_property so no property is considered delta + monkeypatch.setattr(backend_mod, "delta_property", lambda pid: (False, pid)) + + res = await proxy.trigger(dev_id="dev1", prop_id="p1") + assert res == "triggered:dev1:p1" + assert ("trigger", "dev1", "p1") in fake.calls + + +@pytest.mark.asyncio +async def test_read_non_delta_fetches_backend(monkeypatch): + cache = StateCache(name="test_cache") + fake = FakeBackend() + fake.read_map[("devA", "pos")] = 42 + proxy = backend_mod.DeltaBackendRProxy(backend=fake, cache=cache) + + # no delta + monkeypatch.setattr(backend_mod, "delta_property", lambda pid: (False, pid)) + val = await proxy.read(dev_id="devA", prop_id="pos") + assert val == 42 + assert ("read", "devA", "pos") in fake.calls + + +@pytest.mark.asyncio +async def test_read_delta_uses_cache_and_returns_difference(monkeypatch): + cache = StateCache(name="test_cache") + fake = FakeBackend() + # backend returns 100 for original property 'pos' + fake.read_map[("devA", "pos")] = 100 + + proxy = backend_mod.DeltaBackendRProxy(backend=fake, cache=cache, filter=NOOPFilter()) + + # mark properties starting with 'de.ta_' as delta + monkeypatch.setattr(backend_mod, "delta_property", delta_prop_flag("de.ta_")) + + # First read: cache empty -> proxy should store reference and then compute value - ref + # In your code the sequence is: read backend, cache.set(rcmd, r), then _calculate_delta_read uses ref + res = await proxy.read(dev_id="devA", prop_id="de.ta_pos") + + # Because cache initially had no ref, implementation sets the reference to backend value (100) + # and then returns value - ref. Given code uses ref from cache (must be not None) and returns value - ref. + # Here that leads to 0. + assert res == 0 + + # ensure cache now has the stored ref + rcmd = ReadCommand(id="devA", property="pos") + # But our StateCache keys are our own ReadCommand class; proxy uses ReadCommand from your module. + # To keep test robust, check FakeBackend calls to ensure read happened. + assert ("read", "devA", "pos") in fake.calls + + +@pytest.mark.asyncio +async def test_set_delta_reads_cache_if_missing_and_calls_backend_with_total(monkeypatch): + cache = StateCache(name="test_cache") + fake = FakeBackend() + # backend returns 10 for original property 'val' + fake.read_map[("devX", "val")] = 10 + + proxy = backend_mod.DeltaBackendRWProxy(backend=fake, cache=cache, filter=NOOPFilter()) + + # mark properties with 'de.ta_' as delta + monkeypatch.setattr(backend_mod, "delta_property", delta_prop_flag("de.ta_")) + + # call set on delta property with desired delta = 5 should result in backend.set(..., value=15) + result = await proxy.set(dev_id="devX", prop_id="de.ta_val", value=5) + # verify the backend received set with orig_prop_id and total value + assert fake.last_set == ("devX", "val", 15) + assert ("read", "devX", "val") in fake.calls + assert ("set", "devX", "val", 15) in fake.calls + + +@pytest.mark.asyncio +async def test_set_non_delta_just_forwards(monkeypatch): + cache = StateCache(name="test_cache") + fake = FakeBackend() + proxy = backend_mod.DeltaBackendRWProxy(backend=fake, cache=cache, filter=NOOPFilter()) + + monkeypatch.setattr(backend_mod, "delta_property", lambda pid: (False, pid)) + await proxy.set(dev_id="d1", prop_id="p1", value=77) + + assert fake.last_set == ("d1", "p1", 77) + assert ("set", "d1", "p1", 77) in fake.calls + + +@pytest.mark.asyncio +async def test_filter_blocks_cache_and_raises_on_calculation(monkeypatch): + """ + If a filter blocks (returns None) then DeltaBackend*Proxy should behave consistently. + The code path currently asserts ref is not None in _calculate_delta_set/_calculate_delta_read, + so this test documents expected behavior (AssertionError). + """ + cache = StateCache(name="test_cache") + fake = FakeBackend() + proxy = backend_mod.DeltaBackendRWProxy(backend=fake, cache=cache, filter=BlockingFilter(block=True)) + + monkeypatch.setattr(backend_mod, "delta_property", delta_prop_flag("de.ta_")) + # backend returns 7 + fake.read_map[("dev1", "v")] = 7 + + with pytest.raises(AssertionError): + # this should raise because filter returns None and code asserts ref is not None + await proxy.set(dev_id="dev1", prop_id="de.ta_v", value=3) From 2f4f2af363bb4663777fcf5536a7f37669dfd418 Mon Sep 17 00:00:00 2001 From: Pierre Schnizer Date: Sun, 1 Feb 2026 11:57:28 +0100 Subject: [PATCH 5/5] [FIX] only apply filter after value retrieved --- src/accml_lib/core/bl/delta_backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/accml_lib/core/bl/delta_backend.py b/src/accml_lib/core/bl/delta_backend.py index 2644952..7d11afc 100644 --- a/src/accml_lib/core/bl/delta_backend.py +++ b/src/accml_lib/core/bl/delta_backend.py @@ -109,7 +109,7 @@ async def set(self, dev_id: str, prop_id: str, value: object): return await self.backend.set(dev_id=dev_id, prop_id=prop_id, value=value) rcmd = ReadCommand(id=dev_id, property=orig_prop_id) - ref = self.filter.process(self.cache.get(rcmd, None)) + ref = self.cache.get(rcmd, None) if not ref: r = await self.backend.read(dev_id=dev_id, prop_id=orig_prop_id) # Todo: refactor the classes here so this does not need