diff --git a/.github/actions/ophyd_devices_install/action.yml b/.github/actions/ophyd_devices_install/action.yml index a8a6cfe..71ecacc 100644 --- a/.github/actions/ophyd_devices_install/action.yml +++ b/.github/actions/ophyd_devices_install/action.yml @@ -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] diff --git a/.github/workflows/pytest-matrix.yml b/.github/workflows/pytest-matrix.yml index f2b21b9..283a8b4 100644 --- a/.github/workflows/pytest-matrix.yml +++ b/.github/workflows/pytest-matrix.yml @@ -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 diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index b31bf6c..a5a75cb 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 18a84cc..aeac963 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,10 @@ dev = [ "pytest-random-order~=1.1", ] +ci = [ + "caproto ~= 1.3", +] + [build-system] requires = ["hatchling"] diff --git a/tests/caproto_simple_positioner_ioc.py b/tests/caproto_simple_positioner_ioc.py new file mode 100644 index 0000000..ef3f502 --- /dev/null +++ b/tests/caproto_simple_positioner_ioc.py @@ -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() diff --git a/tests/test_psi_positioner_live.py b/tests/test_psi_positioner_live.py new file mode 100644 index 0000000..a4cd46f --- /dev/null +++ b/tests/test_psi_positioner_live.py @@ -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)