Motion interlocks#
This page explains the motion-interlock mechanism in this repository: what it protects, what it does not protect, and why it is built the way it is.
The only concrete interlock today is between sample_stage.omega and
laser_optics, but the design generalizes to any “block motor A
unless device B is in some state” relationship.
What an interlock here actually is#
An interlock in id3c is a Python callable that returns True
(motion permitted) or False (motion blocked), evaluated at two
moments:
Pre-flight, just before any EPICS write – if the callable returns
False, the move raisesMotionInterlockand no PV is touched.Mid-flight, on every update of certain “watch” signals – if the callable returns
Falsewhile a move is in progress, the motor is stopped and the move’sMoveStatusis failed withMotionInterlock.
The implementation lives in
id3c.devices.interlocked_motor.InterlockedEpicsMotor,
an ophyd.EpicsMotor subclass. The protected motor instance is
configured at startup time with three attributes:
motor.interlock = lambda: laser_optics.is_out # the permit callable
motor.interlock_description = "laser_optics OUT" # for error messages
motor.interlock_watch = (laser_optics.us.user_readback,
laser_optics.ds.user_readback)
These are assigned after device construction, by a setup function
in id3c.devices.<pair>_interlock that startup.py calls after
make_devices(). This late-binding keeps the interlock class
generic (it does not need to know about laser_optics) and avoids
import cycles between mutually-interlocked devices.
What is protected today#
id3c.devices.omega_laser_interlock.setup_omega_laser_interlock
installs a bidirectional interlock:
sample_stage.omegablocked unlesslaser_optics.is_out. Reason: when the laser optics are not retracted, they could collide with the rotating sample stage. The protected motor isomega; the gating condition is “bothusanddsaxes are within tolerance ofout_position(-75 +/- 1 mm)”.laser_optics.usandlaser_optics.dsblocked whilesample_stage.omegais moving. Reason: pulling the laser optics in or out while the sample is rotating is also a collision risk. The gating condition is “omega.motor_is_movingis False”.
The second direction uses a deliberately broad rule: any omega motion blocks any laser-optics motion. An angle-aware rule (block only inside a danger zone) is intentionally not implemented in Python; see What is not protected below.
What is not protected#
The interlock lives entirely in the running Bluesky/Python session. It does not:
Write to any EPICS protection field (no
DISP, noSPMG=Stop, no sequencer record).Survive a Python process crash, exit, or
kill -9.Apply to motion commanded from MEDM screens.
Apply to
caputfrom a shell.Apply to a different Bluesky session running against the same IOCs.
Apply to SPEC or any other client.
If any of these scenarios matter for safety, the protection must be
implemented in the IOC – typically as a CALC/SCALC record, a state
notation sequencer, or a soft record that drives the motor’s DISP
field. The Python interlock is a convenience layer for the
Bluesky workflow; it is not a substitute for IOC-level protection.
The module docstring of
interlocked_motor.py
restates this honestly so anyone reading the code learns the limit at
the same time they learn the API.
Why not use a Suspender?#
bluesky.suspenders.SuspendBoolLow and friends are the standard
Bluesky mechanism for “pause the RunEngine when condition X goes
False.” They are a poor fit here for two reasons:
Suspenders pause everything, not just one motor. An entire plan is paused, with no obvious link to the device that caused the suspension. This is correct behaviour for global conditions (beam dump, shutter close) and surprising behaviour for local conditions (one motor’s interlock).
Suspenders are installed by the user, typically via
RE.install_suspender(...). New users routinely forget to do this, leaving the protection inert. AnInterlockedEpicsMotoris protective the moment it is instantiated; there is nothing to opt in to.
Why MotionInterlock is its own exception#
MotionInterlock(RuntimeError) is defined in interlocked_motor.py
alongside the class that raises it. The exception message is the
self-diagnostic the user sees at the bottom of an otherwise long
Bluesky traceback:
MotionInterlock: sample_stage_omega.move blocked by interlock
'laser_optics OUT'. See the device(s) referenced
by this interlock for state.
Bluesky tracebacks are routinely 100+ lines (asyncio frames, plan runner frames, status callback frames, RunEngine frames). The exception message is the only line most users will read; making it self-contained is what makes the error actionable.
Pre-flight versus mid-flight#
The two evaluations use different mechanisms and have different failure modes:
Phase |
Mechanism |
If interlock=False |
|---|---|---|
Pre-flight |
Direct call to |
|
Mid-flight |
Watcher on |
|
The mid-flight path is the subtle one. Raising an exception inside a
pyepics CA callback does not propagate to the RunEngine – the
exception is caught and logged by the CA dispatcher and the plan
continues happily. To actually halt the plan, the watcher must
fail the MoveStatus that the RunEngine is waiting on.
InterlockedEpicsMotor does this correctly: the watcher does
status.set_exception(MotionInterlock(...)), which causes the
status.wait() call inside the RunEngine to raise. The plan halts;
the RunEngine reports the exception; the user sees the diagnostic
message.