feat(tests): add tolerance checks for PSISimplePositioner movements

This commit is contained in:
wakonig_k committed 2026-09-28 17:57:48 +02:00
1 parent 20918c398f
commit 3fc249143d
6 files changed
+274 -1

No files matched your search

@@ -40,6 +40,6 @@ runs:
shell: bash
run: |
pip install uv
uv pip install --system -e ./ophyd_devices[dev]
uv pip install --system -e ./ophyd_devices[dev,ci]
uv pip install --system -e ./bec/bec_lib[dev]
uv pip install --system -e ./bec/bec_server[dev]
+5
View File
@@ -35,6 +35,11 @@ jobs:
PYTHON_VERSION: ${{ matrix.python-version }}
- name: Run Pytest
env:
EPICS_CA_ADDR_LIST: 127.0.0.1
EPICS_CA_AUTO_ADDR_LIST: "NO"
EPICS_CAS_AUTO_BEACON_ADDR_LIST: "NO"
EPICS_CAS_BEACON_ADDR_LIST: 127.0.0.1
run: |
cd ./ophyd_devices
pip install pytest pytest-random-order
+5
View File
@@ -39,6 +39,11 @@ jobs:
- name: Run Pytest with Coverage
id: coverage
env:
EPICS_CA_ADDR_LIST: 127.0.0.1
EPICS_CA_AUTO_ADDR_LIST: "NO"
EPICS_CAS_AUTO_BEACON_ADDR_LIST: "NO"
EPICS_CAS_BEACON_ADDR_LIST: 127.0.0.1
run: |
cd ./ophyd_devices
coverage run --source=./ophyd_devices --omit=*/ophyd_devices/tests/* -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests
+4
View File
@@ -44,6 +44,10 @@ dev = [
"pytest-random-order~=1.1",
]
ci = [
"caproto ~= 1.3",
]
[build-system]
requires = ["hatchling"]
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
import argparse
import threading
import time
from caproto.server import PVGroup, SubGroup, pvproperty, run
from caproto.server.records import MotorFields
from caproto.sync.repeater import RepeaterAlreadyRunning, check_for_running_repeater
from caproto.sync.repeater import run as run_repeater
def ensure_repeater() -> None:
try:
sock = check_for_running_repeater(("0.0.0.0", 5065))
except RepeaterAlreadyRunning:
return
else:
sock.close()
thread = threading.Thread(target=run_repeater, kwargs={"host": "0.0.0.0"}, daemon=True)
thread.start()
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
try:
sock = check_for_running_repeater(("0.0.0.0", 5065))
except RepeaterAlreadyRunning:
return
else:
sock.close()
time.sleep(0.05)
async def broadcast_precision_to_fields(record: pvproperty) -> None:
precision = record.precision
for prop in record.field_inst.pvdb.values():
if hasattr(prop, "precision"):
await prop.write_metadata(precision=precision)
async def motor_record_simulator(
instance: pvproperty,
async_lib,
*,
velocity: float,
precision: int,
acceleration: float,
resolution: float,
user_limits: tuple[float, float],
tick_rate_hz: float,
final_offset_pv: pvproperty,
) -> None:
fields: MotorFields = instance.field_inst
have_new_position = False
async def value_write_hook(_fields, value):
nonlocal have_new_position
have_new_position = True
fields.value_write_hook = value_write_hook
await instance.write_metadata(precision=precision)
await broadcast_precision_to_fields(instance)
await fields.velocity.write(velocity)
await fields.seconds_to_velocity.write(acceleration)
await fields.motor_step_size.write(resolution)
await fields.user_low_limit.write(user_limits[0])
await fields.user_high_limit.write(user_limits[1])
await fields.done_moving_to_value.write(1)
while True:
dwell = 1.0 / tick_rate_hz
if not have_new_position:
await async_lib.library.sleep(dwell)
continue
target = instance.value
readback = fields.user_readback_value.value
delta = target - readback
await fields.done_moving_to_value.write(0)
await fields.motor_is_moving.write(1)
total_time = abs(delta / fields.velocity.value) if fields.velocity.value else 0.0
num_steps = int(total_time // dwell)
step_size = delta / num_steps if num_steps > 0 else 0.0
raw_resolution = max(fields.motor_step_size.value, 1e-10)
for _ in range(num_steps):
readback += step_size
await fields.user_readback_value.write(readback)
await fields.dial_readback_value.write(readback)
await fields.raw_readback_value.write(readback / raw_resolution)
await async_lib.library.sleep(dwell)
final_value = target + final_offset_pv.value
await fields.user_readback_value.write(final_value)
await fields.dial_readback_value.write(final_value)
await fields.raw_readback_value.write(final_value / raw_resolution)
await fields.motor_is_moving.write(0)
await fields.done_moving_to_value.write(1)
have_new_position = False
class FakeMotor(PVGroup):
final_offset = pvproperty(value=0.0, name="final_offset")
motor = pvproperty(value=0.0, name="", record="motor", precision=3)
def __init__(
self,
*args,
velocity: float = 2.0,
precision: int = 3,
acceleration: float = 0.5,
resolution: float = 1e-3,
user_limits: tuple[float, float] = (-100.0, 100.0),
tick_rate_hz: float = 20.0,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.velocity = velocity
self.precision = precision
self.acceleration = acceleration
self.resolution = resolution
self.user_limits = user_limits
self.tick_rate_hz = tick_rate_hz
@motor.startup
async def motor(self, instance, async_lib):
await motor_record_simulator(
instance,
async_lib,
velocity=self.velocity,
precision=self.precision,
acceleration=self.acceleration,
resolution=self.resolution,
user_limits=self.user_limits,
tick_rate_hz=self.tick_rate_hz,
final_offset_pv=self.final_offset,
)
class MotorIOC(PVGroup):
mtr1 = SubGroup(FakeMotor, prefix="mtr1")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--prefix", default="SIM:MOTOR:")
parser.add_argument("--interface", default="127.0.0.1")
args = parser.parse_args()
ensure_repeater()
ioc = MotorIOC(prefix=args.prefix)
run(ioc.pvdb, interfaces=[args.interface])
if __name__ == "__main__":
main()
+99
View File
@@ -0,0 +1,99 @@
import threading
import time
from importlib.util import find_spec, module_from_spec, spec_from_file_location
from pathlib import Path
import pytest
from ophyd import Component as Cpt
from ophyd import Signal
from ophyd_devices.devices.simple_positioner import PSISimplePositioner
if find_spec("caproto") is None:
pytest.skip("caproto is not installed", allow_module_level=True)
from caproto.server import run
IOC_PREFIX = "SIM:MOTOR:"
MOTOR_PREFIX = f"{IOC_PREFIX}mtr1"
IOC_SCRIPT = Path(__file__).with_name("caproto_simple_positioner_ioc.py")
SUFFIXES = {
"user_readback": ".RBV",
"user_setpoint": ".VAL",
"velocity": ".VELO",
"motor_done_move": ".DMOV",
}
def _load_ioc_module():
ioc_spec = spec_from_file_location("caproto_simple_positioner_ioc", IOC_SCRIPT)
assert ioc_spec is not None and ioc_spec.loader is not None
ioc_module = module_from_spec(ioc_spec)
ioc_spec.loader.exec_module(ioc_module)
return ioc_module
def _caput(*args, **kwargs):
from epics import caput
return caput(*args, **kwargs)
class TolerantSimplePositioner(PSISimplePositioner):
tolerance = Cpt(Signal, value=0.001)
def wait_for(condition, timeout: float = 5.0, interval: float = 0.05, label: str = "condition"):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if condition():
return
time.sleep(interval)
raise TimeoutError(f"Timed out waiting for {label}")
@pytest.fixture(scope="module")
def caproto_simple_positioner_ioc():
ioc_module = _load_ioc_module()
ioc_module.ensure_repeater()
ioc = ioc_module.MotorIOC(prefix=IOC_PREFIX)
thread = threading.Thread(
target=run, args=(ioc.pvdb,), kwargs={"interfaces": ["127.0.0.1"]}, daemon=True
)
thread.start()
time.sleep(0.5)
yield ioc
@pytest.fixture()
def live_simple_positioner(caproto_simple_positioner_ioc):
pos = TolerantSimplePositioner(name="test", prefix=MOTOR_PREFIX, override_suffixes=SUFFIXES)
pos.wait_for_connection(timeout=10)
wait_for(
lambda: pos.motor_done_move.get(use_monitor=False) == 1, timeout=10, label="initial DMOV=1"
)
yield pos
def test_live_simple_positioner_move_succeeds_within_tolerance(live_simple_positioner):
_caput(f"{MOTOR_PREFIX}final_offset", 0.0005, wait=True, timeout=1)
status = live_simple_positioner.move(1.0, wait=False, timeout=5)
status.wait(timeout=5)
assert status.done
assert status.success
assert live_simple_positioner.user_readback.get() == pytest.approx(1.0005)
def test_live_simple_positioner_move_fails_outside_tolerance(live_simple_positioner):
_caput(f"{MOTOR_PREFIX}final_offset", 0.005, wait=True, timeout=1)
status = live_simple_positioner.move(2.0, wait=False, timeout=5)
with pytest.raises(RuntimeError, match="outside of tolerance"):
status.wait(timeout=5)
assert status.done
assert not status.success
assert live_simple_positioner.user_readback.get() == pytest.approx(2.005)