17. Create StandardMovable with Composition-Based Logic#
Date: 2026-03-10
Status#
Accepted
Context#
Several device types share a common movement pattern: write a setpoint, then observe a
readback until it converges to the target. Before this change, each such device —
Motor, DemoMotor, SimMotor, and any future device of the same kind — had to
independently implement:
gathering current position, calculating a timeout, and watching the readback
emitting
WatcherUpdateprogress eventshandling a
stop()that marks the move as failedthe
Locatable,Checkable,Stoppable, andSubscribablebluesky protocols
Motor also interleaved motor-record-specific logic (soft limits, velocity-based
timeout, STOP PV) with generic setpoint/readback logic, making neither reusable.
StandardReadable and StandardDetector (ADR 0012) established the precedent: a base
class provides the bluesky protocol surface, and a composed logic object provides
device-specific behaviour.
Three design choices were resolved:
How should logic be attached? An initial design provided an add_movable_logic()
method, mirroring add_children_as_readables. A StandardMovable with no logic has no
sensible set() behaviour, so an abstract @cached_property was chosen: it requires
subclasses to supply logic at class-definition time and is checkable by static analysis.
Should the logic class hold a back-reference to the device? An early prototype
passed the parent device into the logic class, creating a circular dependency. The
decision was to pass individual signals as @dataclass fields, making the wiring
visible at construction time and keeping the logic class reusable.
Where should DeviceMock logic live? Options were a mock hierarchy mirroring the
class hierarchy, a single mock at the topmost class, or one mock per concrete class.
One mock per concrete class was chosen: it keeps mock logic next to the device it mocks
and is easiest to read.
Decision#
StandardMovable[T] is added to ophyd_async.core. Subclasses must implement:
@cached_property
def movable_logic(self) -> MovableLogic: ...
MovableLogic[T] is a @dataclass with two required fields and five async hook
methods with safe defaults:
Field / Method |
Default |
|---|---|
|
required |
|
required |
|
no-op |
|
no-op |
|
|
|
reads from |
|
|
StandardMovable inherits Device and implements Locatable[T], Checkable[T], Stoppable, and
Subscribable[T]. Its set() reads the current position and units/precision in
parallel, calls check_move, resolves the timeout, runs movable_logic.move() inside
an AsyncStatus, emits WatcherUpdate events as the readback changes, and raises
RuntimeError if stop(success=False) was called.
InstantMovableMock is registered as the default mock class via @default_mock_class.
It installs a callback_on_mock_put on the setpoint that immediately mirrors any
written value to the readback, giving every subclass a working simulated move when
connected with mock=True.
Motor is updated to inherit from StandardMovable and StandardReadable, with a
MotorMoveLogic dataclass overriding all five hooks with motor-record-specific logic.
DemoMotor and SimMotor are updated the same way.
set_mock_units and set_mock_precision are added to ophyd_async.core so tests can
inject readback metadata without needing dedicated signal children.
Consequences#
Any device that moves to a target value can now inherit
StandardMovableand provide aMovableLogicsubclass, rather than reimplementing the bluesky protocol surface.Motor,DemoMotor, andSimMotorlose their duplicatedset(),stop(),locate(),subscribe_reading(), andclear_sub()implementations.The
unitsandprecisionSignalRchildren ofDemoMotorare removed; they no longer appear inread_configuration()(minor breaking change).Error messages from
Motornow include the motor name.set_mock_unitsandset_mock_precisionare added to the public API ofophyd_async.core.