mirror of
https://github.com/bec-project/ophyd_devices.git
synced 2026-10-21 16:16:19 +02:00
feat(tests): add tolerance checks for PSISimplePositioner movements
This commit is contained in:
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]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,6 +44,10 @@ dev = [
|
||||
"pytest-random-order~=1.1",
|
||||
]
|
||||
|
||||
ci = [
|
||||
"caproto ~= 1.3",
|
||||
]
|
||||
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user