Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions src/accml_lib/core/bl/delta_backend.py
Original file line number Diff line number Diff line change
@@ -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")
Expand Down Expand Up @@ -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})"
Expand Down Expand Up @@ -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):
Expand All @@ -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
10 changes: 10 additions & 0 deletions src/accml_lib/core/interfaces/backend/filter.py
Original file line number Diff line number Diff line change
@@ -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")
76 changes: 76 additions & 0 deletions src/accml_lib/core/model/output/orbit.py
Original file line number Diff line number Diff line change
@@ -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}
9 changes: 8 additions & 1 deletion src/accml_lib/custom/bessyii/setup.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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")

Expand Down
198 changes: 198 additions & 0 deletions tests/test_core/test_delta_backend.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 2 additions & 2 deletions tests/test_custom/bessyii/test_simulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand All @@ -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)
Loading