When should a device extend movable#
The Movable protocol indicates that a device has a set method which can be called in bluesky through the plan stubs bps.abs_set and bps.mv. The RunEngine treats this set as an atomic operation. A Movable device is appropriate when:
The
setinvolves changing multiple signals in parallel alongside a desired ordering of the setting of signals - having this logic inside an asyncio function can provide speedup.The
RunEngineshould not be altering any other devices while thissetis taking place.There is only one clear interpretation of what it means to set the device. For example, setting a motor is fairly unambiguous whereas setting a detector could mean a number of different things.
You are doing some logic that a user will almost always want to do with this device.
The set method, in general, should be used with primitive values rather than more complex types, for example, dataclasses. Using the latter here leads to extra boilerplate at the plan level. An exception to this is where using set will provide speedup - in this case it could be worth the extra boilerplate.
What to use instead#
If the device doesn’t satisfy the above criteria, it is generally more suitable to use combinations of bps.mv and bps.abs_set on individual signals of a plan’s devices. This avoids adding unnecessary complexity to the device whilst giving the plan more flexibility.
Using StandardMovable#
If your device moves to a target value and waits for a readback signal to confirm it
has arrived, use StandardMovable rather than implementing Movable directly.
This covers a wide range of hardware:
Motor-like stages that use a non-motor IOC (e.g. piezo stages, hexapods)
Temperature controllers, beam attenuators, and similar “set value, wait for readback” devices
Simulation motors
Any Tango or EPICS device that follows the same setpoint/readback pattern
StandardMovable handles the full bluesky protocol surface (Locatable, Checkable,
Stoppable, Subscribable) as well as the WatcherUpdate machinery for progress bars.
You only need to describe what is device-specific.
Implementing MovableLogic#
Create a @dataclass subclass of MovableLogic that adds any extra signals your
device needs, and override the hook methods that differ from the defaults:
@dataclass
class DemoMotorMoveLogic(MovableLogic[float]):
velocity: SignalRW[float]
stop_: TriggerableCommand
async def stop(self):
await self.stop_.trigger()
async def calculate_timeout(
self, old_position: float, new_position: float
) -> float:
velocity = await self.velocity.get_value()
return abs(new_position - old_position) / velocity + DEFAULT_TIMEOUT
async def move(self, new_position: float, timeout: float | None) -> None:
# If we are close to the desired position then break
await set_and_wait_for_other_value(
self.setpoint,
new_position,
self.readback,
lambda v: bool(np.isclose(v, new_position)),
timeout=timeout,
)
The available hooks are:
Method |
Default behaviour |
Override to… |
|---|---|---|
|
no-op |
trigger a stop signal or cancel an in-flight command |
|
no-op |
validate soft limits before the move begins |
|
|
derive a velocity-based or distance-based timeout |
|
reads from |
supply units and precision from dedicated signals |
|
|
write the setpoint and wait in a device-specific way |
Wiring it into the Device#
Attach the logic via a @cached_property in the Device subclass. Multiple inheritance
with StandardReadable is supported and is the normal pattern:
class DemoMotor(EpicsDevice, StandardReadable, StandardMovable):
"""A demo movable that moves based on velocity."""
# Define some signals
readback: A[SignalR[float], PvSuffix("Readback"), Format.HINTED_SIGNAL]
velocity: A[SignalRW[float], PvSuffix("Velocity"), Format.CONFIG_SIGNAL]
setpoint: A[SignalRW[float], PvSuffix("Setpoint")]
# If a signal name clashes with a bluesky verb add _ to the attribute name
stop_: A[TriggerableCommand, PvSuffix("Stop.PROC")]
@cached_property
def movable_logic(self) -> MovableLogic:
return DemoMotorMoveLogic(
readback=self.readback,
setpoint=self.setpoint,
velocity=self.velocity,
stop_=self.stop_,
)
StandardMovable.set_name automatically renames the readback signal to match the
device name, so read() reports stage.x rather than stage.x.readback.
Testing a StandardMovable#
Connecting a StandardMovable subclass with mock=True automatically installs
InstantMovableMock, which mirrors every setpoint write immediately to the
readback. This means most tests work without any extra setup:
async with init_devices(mock=True):
motor = MyMotor("PREFIX:")
await motor.set(10.0)
assert (await motor.locate())["readback"] == 10.0
See also
Writing Tests for Devices for how to write tests against a
StandardMovable device, including how to define custom automatic mock behaviour and
how to opt out of the default mock for fine-grained control.