Dynamic Limits for Two Motors#

Overview#

Some instrument designs need protection against accidental collision between moving parts during routine operations. In such cases, each of the axes may be operating between its valid range but an interim (in-motion) state can pose the possibility of a collision.

One such possible collision is when arms of a diffractometer (such as \(\theta\) and \(2\theta\), known here as theta and ttheta, respectively) collide causing damage to beam transport apparatus and consequential instrumental downtime.

To prevent the collision in this case, the \(2\theta\) axis must be at least \(\delta\) degrees above the \(\theta\) axis. Empirically, \(\delta\) of 3 degrees is sufficient protection.

From a controls safety view, we provide an EPICS PV calculation that is zero when the move is not permitted and one when: (\(2\theta - \theta) \ge \delta\). We’ll monitor that PV in Bluesky to add a suspender that can interrupt the scan (via the bluesky.RunEngine) if the permit is removed. When the RunEngine handles an interruption involving movable devices, it sends a stop to each of the movables involved. Thus, when the dynamic limit permit is removed, both motors are stopped and the scan pauses, waiting for external interaction to clear the condition.

Here is where bluesky tells the motors to stop:

if justification is not None:
    print("Justification for this suspension:\n%s" % justification)
for current_run in self._run_bundlers.values():
    current_run.record_interruption('resume')
# During suspend, all motors should be stopped. Call stop() on
# every object we ever set().
self._stop_movable_objects(success=True)

If the RunEngine is started while the dynamic limit permit calculation is zero, the RunEngine will pause immediately. Here is an example:

In [21]: uid = RE(th_tth_scan([noisy, th_tth_permit], 8, 6, points=4, min_sep=3))
At least one suspender has tripped. The plan will begin when all suspenders are ready. Justification:
    1. Signal th_tth_permit is low

Suspending... To get to the prompt, hit Ctrl-C twice to pause.

We might need to write our own suspender if none of the provided suspenders will do the job we want.

Summary#

Any time the motors are moved by the bluesky RunEngine, they will be stopped if the dynamic limit permit calculation goes to zero and the scan will pause.

EPICS setup#

We start with two motor axes defined in EPICS. Here, we run the docker image prjemian/synApps to make the EPICS IOC simulator run in a docker container with IOC prefix gp:. These are motor PVs: gp:m1 and gp:m2 as shown.

EPICS motor GUI screens

To compute a dynamic limit between the two motor axes, we use a userCalc (EPICS swait record), gp:userCalc1 with settings as shown.

  1. Set the description field to describe what this does.

  2. Monitor each motor’s readback (.RBV) value. The readback value is the motor record’s best knowledge of the actual motor position.

  3. gp:userCalc1.INAN = sky:m1.RBV, the value will be in A once the motor moves.

  4. gp:userCalc1.INBN = sky:m2.RBV, the value will be in B once the motor moves.

  5. Change the calculation’s .SCAN field from Passive (calculates only when requested) to I/O Intr (calculate when any input changes, based on each field’s TRIGGER? setting).

  6. Enter the angle of minimum approach (3) into the gp:userCalc1.C field. This will be the PV to change this number.

  7. Enter the permit calculation: (B-A)>=C

The calculated result (in gp:userCalc1.VAL, gp:userCalc1 for short) once either of the motors move.

motion permit calculation

To scan, we want a “detector”. Let’s use another userCalc (gp:userCalc2) to simulate a noisy detector with a random number generator. We’ll update this detector only when either motor moves (same as with the permit calculation) setting its .SCAN to I/O Intr. The setup is shown in the next screen view image:

noisy detector simulation

Bluesky setup#

[1]:
# put the instrument package into the path
import pathlib, sys
sys.path.append(str(pathlib.Path.home() / "bluesky"))

# start the instrument package for data collection
from instrument.collection import *
from bluesky import plans as bp
from bluesky import plan_stubs as bps
from ophyd import Component, Device, EpicsMotor, EpicsSignal

bec.disable_plots()  # not interested in graphics in this notebook
RE.waiting_hook = None  # disable the progress bar, looks awful in notebooks
/home/prjemian/bluesky/instrument/_iconfig.py
Activating auto-logging. Current session state plus future input saved.
Filename       : /home/prjemian/Documents/projects/BCDA-APS/bluesky_training/docs/source/howto/.logs/ipython_console.log
Mode           : rotate
Output logging : True
Raw input log  : False
Timestamping   : True
State          : active
I Tue-18:47:27 - ############################################################ startup
I Tue-18:47:27 - logging started
I Tue-18:47:27 - logging level = 10
I Tue-18:47:27 - /home/prjemian/bluesky/instrument/session_logs.py
I Tue-18:47:27 - /home/prjemian/bluesky/instrument/collection.py
I Tue-18:47:27 - CONDA_PREFIX = /home/prjemian/.conda/envs/bluesky_2023_2
Exception reporting mode: Minimal
I Tue-18:47:27 - xmode exception level: 'Minimal'
I Tue-18:47:27 - /home/prjemian/bluesky/instrument/mpl/notebook.py
I Tue-18:47:27 - #### Bluesky Framework ####
I Tue-18:47:27 - /home/prjemian/bluesky/instrument/framework/check_python.py
I Tue-18:47:27 - /home/prjemian/bluesky/instrument/framework/check_bluesky.py
I Tue-18:47:27 - /home/prjemian/bluesky/instrument/framework/initialize.py
I Tue-18:47:27 - RunEngine metadata saved in directory: /home/prjemian/Bluesky_RunEngine_md
I Tue-18:47:28 - using databroker catalog 'training'
I Tue-18:47:28 - using ophyd control layer: pyepics
I Tue-18:47:28 - /home/prjemian/bluesky/instrument/framework/metadata.py
I Tue-18:47:28 - /home/prjemian/bluesky/instrument/epics_signal_config.py
I Tue-18:47:28 - Using RunEngine metadata for scan_id
I Tue-18:47:28 - #### Devices ####
I Tue-18:47:28 - /home/prjemian/bluesky/instrument/devices/area_detector.py
I Tue-18:47:28 - /home/prjemian/bluesky/instrument/devices/calculation_records.py
I Tue-18:47:30 - /home/prjemian/bluesky/instrument/devices/fourc_diffractometer.py
I Tue-18:47:30 - /home/prjemian/bluesky/instrument/devices/ioc_stats.py
I Tue-18:47:30 - /home/prjemian/bluesky/instrument/devices/kohzu_monochromator.py
I Tue-18:47:30 - /home/prjemian/bluesky/instrument/devices/motors.py
I Tue-18:47:30 - /home/prjemian/bluesky/instrument/devices/noisy_detector.py
I Tue-18:47:30 - /home/prjemian/bluesky/instrument/devices/scaler.py
I Tue-18:47:31 - /home/prjemian/bluesky/instrument/devices/shutter_simulator.py
I Tue-18:47:31 - /home/prjemian/bluesky/instrument/devices/simulated_fourc.py
I Tue-18:47:31 - /home/prjemian/bluesky/instrument/devices/simulated_kappa.py
I Tue-18:47:31 - /home/prjemian/bluesky/instrument/devices/sixc_diffractometer.py
I Tue-18:47:32 - /home/prjemian/bluesky/instrument/devices/temperature_signal.py
I Tue-18:47:32 - #### Callbacks ####
I Tue-18:47:32 - /home/prjemian/bluesky/instrument/callbacks/spec_data_file_writer.py
I Tue-18:47:32 - #### Plans ####
I Tue-18:47:32 - /home/prjemian/bluesky/instrument/plans/lup_plan.py
I Tue-18:47:32 - /home/prjemian/bluesky/instrument/plans/peak_finder_example.py
I Tue-18:47:32 - /home/prjemian/bluesky/instrument/utils/image_analysis.py
I Tue-18:47:32 - #### Utilities ####
I Tue-18:47:32 - writing to SPEC file: /home/prjemian/Documents/projects/BCDA-APS/bluesky_training/docs/source/howto/20230411-184732.dat
I Tue-18:47:32 -    >>>>   Using default SPEC file name   <<<<
I Tue-18:47:32 -    file will be created when bluesky ends its next scan
I Tue-18:47:32 -    to change SPEC file, use command:   newSpecFile('title')
I Tue-18:47:32 - #### Startup is complete. ####

symbol

PV (here)

meaning

theta

gp:m1

(th) EPICS motor record

ttheta

gp:m2

(tth) EPICS motor record

th_tth_permit

gp:userCalc1.VAL

result of EPICS calculation (swait record: 1=permit, 0=not), updates when either motor moves

th_tth_min

gp:userCalc1.C

minimum permitted tth-th

noisy

gp:userCalc2.VAL

detector (random number generator) - integer, updates when either motor moves

noisy_scale

gp:userCalc2.C

scale factor for noisy

Get the IOC prefix from the instrument configuration (iconfig). If not available, use "gp:" as the default.

[2]:
GP_IOC = iconfig.get("GP_IOC_PREFIX", "gp:")

Define the objects we’ll use in this demo.

[3]:
noisy = EpicsSignal(f"{GP_IOC}userCalc2.VAL", name="noisy")
noisy_scale = EpicsSignal(f"{GP_IOC}userCalc2.C", name="noisy_scale")
th_tth_min = EpicsSignal(f"{GP_IOC}userCalc1.C", name="th_tth_min")
th_tth_permit = EpicsSignal(f"{GP_IOC}userCalc1.VAL", name="th_tth_permit")
noisy.wait_for_connection()
noisy_scale.wait_for_connection()
th_tth_min.wait_for_connection()
th_tth_permit.wait_for_connection()
[4]:
# We do this ONLY in the simulator.  Real instrument motors may need different settings!
# For this demo, we want to set the theta & ttheta motor backlash to the default condition
# when the IOC is first created.  We'll change this later in the demo.

class BacklashMotor(EpicsMotor):
    backlash_distance = Component(EpicsSignal, ".BDST")
    backlash_velocity = Component(EpicsSignal, ".BVEL")

theta = BacklashMotor(f"{GP_IOC}m1", name="theta", labels=["motors",])
ttheta = BacklashMotor(f"{GP_IOC}m2", name="ttheta", labels=["motors",])
theta.wait_for_connection()
ttheta.wait_for_connection()
%mov ttheta.backlash_distance 0  ttheta.backlash_velocity 2  ttheta.velocity 2
%mov theta.backlash_distance 0  theta.backlash_velocity 1  theta.velocity 2

Setup the two swait records as shown above.

[5]:
calcs.calc1.reset()
calcs.calc1.description.put("(tth-th) permit")
calcs.calc1.channels.A.input_pv.put(theta.user_readback.pvname)
calcs.calc1.channels.B.input_pv.put(ttheta.user_readback.pvname)
calcs.calc1.channels.C.input_value.put(2.8)
calcs.calc1.calculation.put("(B-A)>=C")
calcs.calc1.precision.put(5)
calcs.calc1.scanning_rate.put("I/O Intr")

calcs.calc2.reset()
calcs.calc2.description.put("noisy")
calcs.calc2.channels.A.input_pv.put(theta.user_readback.pvname)
calcs.calc2.channels.B.input_pv.put(ttheta.user_readback.pvname)
calcs.calc2.channels.C.input_value.put(100_000)
calcs.calc2.calculation.put("floor(RNDM*C+.5)")
calcs.calc2.precision.put(0)
calcs.calc2.scanning_rate.put("I/O Intr")

Custom Bluesky plan for \(\theta:2\theta\) scan#

Define a plan for a coupled theta:2theta scan.

[6]:
def th_tth_scan(detectors, tth_start, tth_end, points=11, min_sep=None):
    """
    run a coupled theta:2theta scan
    """
    min_sep = abs(min_sep or 2.4)
    old_sep = th_tth_min.get()

    # check end points first!
    if abs(tth_start/2) < min_sep:
        print(
            "Starting point below allowed minimum:"
            f" |{tth_start/2:.4f}| < |{min_sep:.4f}|")
        return
    if abs(tth_end/2) < min_sep:
        print(
            "Ending point below allowed minimum:"
            f" |{tth_end/2:.4f}| < |{min_sep:.4f}|")
        return

    yield from bps.mv(th_tth_min, min_sep)
    yield from bp.scan(
        detectors,
        ttheta, tth_start, tth_end,
        theta, tth_start/2, tth_end/2,
        points
    )

    # reset the previous minimum
    yield from bps.mv(th_tth_min, old_sep)

Try a scan that we know will fail the test for minimum separation.

[7]:
RE(th_tth_scan([noisy, th_tth_permit], 5, 25, points=11, min_sep=3))
Starting point below allowed minimum: |2.5000| < |3.0000|
[7]:
()

Swap the two end points, that also fails.

[8]:
RE(th_tth_scan([noisy, th_tth_permit], 25, 5, points=11, min_sep=3))
Ending point below allowed minimum: |2.5000| < |3.0000|
[8]:
()

This scan is successful.

[9]:
RE(th_tth_scan([noisy, th_tth_permit], 12, 8, points=11, min_sep=3))


Transient Scan ID: 926     Time: 2023-04-11 18:47:33
Persistent Unique Scan ID: '6ddca39f-503d-4481-bd83-03ecfbe21ae7'
New stream: 'label_start_motor'
New stream: 'primary'
+-----------+------------+------------+------------+------------+---------------+
|   seq_num |       time |     ttheta |      theta |      noisy | th_tth_permit |
+-----------+------------+------------+------------+------------+---------------+
|         1 | 18:47:36.5 |    12.0000 |     6.0000 |      47659 |       1.00000 |
|         2 | 18:47:36.9 |    11.6000 |     5.8000 |      13759 |       1.00000 |
|         3 | 18:47:37.4 |    11.2000 |     5.6000 |        963 |       1.00000 |
|         4 | 18:47:37.9 |    10.8000 |     5.4000 |      10716 |       1.00000 |
|         5 | 18:47:38.4 |    10.4000 |     5.2000 |      62858 |       1.00000 |
|         6 | 18:47:38.9 |    10.0000 |     5.0000 |      79985 |       1.00000 |
|         7 | 18:47:39.4 |     9.6000 |     4.8000 |      18714 |       1.00000 |
|         8 | 18:47:39.9 |     9.2000 |     4.6000 |      65821 |       1.00000 |
|         9 | 18:47:40.4 |     8.8000 |     4.4000 |      12596 |       1.00000 |
|        10 | 18:47:40.9 |     8.4000 |     4.2000 |      86804 |       1.00000 |
|        11 | 18:47:41.4 |     8.0000 |     4.0000 |       6307 |       1.00000 |
+-----------+------------+------------+------------+------------+---------------+
generator scan ['6ddca39f'] (scan num: 926)
[9]:
('6ddca39f-503d-4481-bd83-03ecfbe21ae7',)

So far, have not registered a permit denied. Have not encountered a condition where permit would be denied.

Try to provoke a permit denied#

Check the backlash parameters for 2theta motor. Use the custom motor class that provides the backlash parameters.

[10]:
# these two motors have backlash support
print(f"{theta.backlash_distance.get()=} ({theta.motor_egu.get()})")
print(f"{theta.backlash_velocity.get()=} ({theta.motor_egu.get()}/s)")

print(f"{ttheta.backlash_distance.get()=} ({ttheta.motor_egu.get()})")
print(f"{ttheta.backlash_velocity.get()=} ({ttheta.motor_egu.get()}/s)")
theta.backlash_distance.get()=0.0 (degrees)
theta.backlash_velocity.get()=1.0 (degrees/s)
ttheta.backlash_distance.get()=0.0 (degrees)
ttheta.backlash_velocity.get()=2.0 (degrees/s)

Change (just) the backlash velocity and set a backlash distance for ttheta.

[11]:
%mov ttheta.backlash_distance 0.5 ttheta.backlash_velocity 0.2
print(f"{ttheta.backlash_distance.get()=} ({ttheta.motor_egu.get()})")
print(f"{ttheta.backlash_velocity.get()=} ({ttheta.motor_egu.get()}/s)")
ttheta.backlash_distance.get()=0.5 (degrees)
ttheta.backlash_velocity.get()=0.2 (degrees/s)

Scan again over a shorter range.

[12]:
RE(th_tth_scan([noisy, th_tth_permit], 7, 6, points=4, min_sep=3))


Transient Scan ID: 927     Time: 2023-04-11 18:47:42
Persistent Unique Scan ID: '1d45b5ef-9268-494b-8fd7-37af3599b833'
New stream: 'label_start_motor'
New stream: 'primary'
+-----------+------------+------------+------------+------------+---------------+
|   seq_num |       time |     ttheta |      theta |      noisy | th_tth_permit |
+-----------+------------+------------+------------+------------+---------------+
|         1 | 18:47:46.0 |     7.0000 |     3.5000 |      47643 |       1.00000 |
|         2 | 18:47:49.6 |     6.6667 |     3.3333 |      20673 |       1.00000 |
|         3 | 18:47:53.2 |     6.3333 |     3.1667 |      79528 |       1.00000 |
|         4 | 18:47:56.8 |     6.0000 |     3.0000 |      96895 |       0.00000 |
+-----------+------------+------------+------------+------------+---------------+
generator scan ['1d45b5ef'] (scan num: 927)
[12]:
('1d45b5ef-9268-494b-8fd7-37af3599b833',)

Let’s monitor the signal during the scan so we can see if it changes and how often.

[13]:
sd.monitors.append(th_tth_permit)

Repeat the scan, collecting the new info. We’ll inspect the monitors after the scan is done. (The RunEngine returns a list of all the run uids created by the plan. Capture this list.)

[14]:
uids = RE(th_tth_scan([noisy, th_tth_permit], 7, 6, points=3, min_sep=3))


Transient Scan ID: 928     Time: 2023-04-11 18:47:57
Persistent Unique Scan ID: '991bdcb1-f8d4-4e2f-a0eb-1dc16330481b'
New stream: 'label_start_motor'
New stream: 'th_tth_permit_monitor'
New stream: 'primary'
+-----------+------------+------------+------------+------------+---------------+
|   seq_num |       time |     ttheta |      theta |      noisy | th_tth_permit |
+-----------+------------+------------+------------+------------+---------------+
|         1 | 18:48:00.6 |     7.0000 |     3.5000 |      61886 |       1.00000 |
|         2 | 18:48:04.3 |     6.5000 |     3.2500 |      45252 |       1.00000 |
|         3 | 18:48:08.0 |     6.0000 |     3.0000 |      14221 |       0.00000 |
+-----------+------------+------------+------------+------------+---------------+
generator scan ['991bdcb1'] (scan num: 928)
[15]:
cat[uids[0]].th_tth_permit_monitor.read()["th_tth_permit"].plot.scatter()
[15]:
<matplotlib.collections.PathCollection at 0x7f34d8ff9c60>
../_images/howto__dynamic_limits_2motor_35_1.png

Aha! The first plot shows the monitored values. It is clear the signal does go to 0 and then come back to 1.

Look at the monitored data. First get the run object from databroker (indexed by the first uid in the list). From the run object, return the monitored data in a table. Python prints this object if it is not assigned. All this in one step.

[16]:
cat.v1[uids[0]].table("th_tth_permit_monitor")
[16]:
time th_tth_permit
seq_num
1 2023-04-11 23:47:57.239300013 0.0
2 2023-04-11 23:47:57.804551840 1.0
3 2023-04-11 23:48:01.110384226 0.0
4 2023-04-11 23:48:02.918014526 1.0
5 2023-04-11 23:48:04.721409321 0.0

We see some zero values (and then return to one) indicating occasional removal of the dynamic limit calculation permit. Since we only saw these when we added a backlash correction, we understand these dynamic violations of the limits are exactly what we hoped to intercept.

Create a suspender#

Block the RunEngine when the permit fails.

N.B. Might need to consider the special case where the permit fails when first starting the run. Why did that fail? (We’ll answer that soon.)

The Bluesky project shows how to suspend a plan when the source X-ray intensity drops too low.

Following this example, we’ll do similar. In our case, the signal is a boolean that indicates no permit when low. The suspender interrupts the RE as long as the signal is invalid and automatically resumes if the signal becomes valid again. Let’s see how this works here.

[17]:
from bluesky.suspenders import SuspendBoolLow
sus = SuspendBoolLow(th_tth_permit)
RE.install_suspender(sus)

Repeat the scan. Set the minimum separation low enough that the motor backlash will not trip the suspender near the end of the scan. After the previous move, the two motors are close enough that the suspender may trip at the start of the next scan, as the motors are sent to the first position. Let’s move theta a little lower to avoid that.

[18]:
# Set a smaller minimum separation
%xmode Minimal
print(f"{theta.position=} {ttheta.position=}")
%movr theta -0.2
print(f"{theta.position=} {ttheta.position=}")
uids = RE(th_tth_scan([noisy, th_tth_permit], 7, 6, points=3, min_sep=2.4))
Exception reporting mode: Minimal
theta.position=3.0000000000000004 ttheta.position=6.0
Suspender SuspendBoolLow(EpicsSignal(read_pv='gp:userCalc1.VAL', name='th_tth_permit', value=1.0, timestamp=1681256888.82624, tolerance=1e-05, auto_monitor=True, string=False, write_pv='gp:userCalc1.VAL', limits=False, put_complete=False), sleep=0, pre_plan=None, post_plan=None,tripped_message=) reports a return to nominal conditions. Will sleep for 0 seconds and then release suspension at 2023-04-11 18:48:08.
theta:  81%|████████████████████▏    | 0.1616/0.2 [00:00<00:00,  1.11degrees/s]
theta: 100%|████████████████████████████| 0.2/0.2 [00:00<00:00,  1.23s/degrees]
theta [In progress. No progress bar available.]

theta.position=2.8000000000000003 ttheta.position=6.0


Transient Scan ID: 929     Time: 2023-04-11 18:48:09
Persistent Unique Scan ID: '52b208be-f1a3-42df-8cb4-9c790954e96c'
New stream: 'label_start_motor'
New stream: 'th_tth_permit_monitor'
New stream: 'primary'
+-----------+------------+------------+------------+------------+---------------+
|   seq_num |       time |     ttheta |      theta |      noisy | th_tth_permit |
+-----------+------------+------------+------------+------------+---------------+
|         1 | 18:48:12.5 |     7.0000 |     3.5000 |      76393 |       1.00000 |
|         2 | 18:48:16.2 |     6.5000 |     3.2500 |      33643 |       1.00000 |
|         3 | 18:48:19.9 |     6.0000 |     3.0000 |      73523 |       1.00000 |
+-----------+------------+------------+------------+------------+---------------+
generator scan ['52b208be'] (scan num: 929)

This succeeded because the minimum separation distance was set smaller than usual, to 2.4, which allows for a backlash correction (of 0.5 in the ttheta motor) at the last point.

[19]:
# As in the previous step:
%movr theta -0.2

# set the minimum separation to 2.5
# Likely the scan will fail due to the suspender when the m2 motor is taking a backlash correction and (m2-m1)<3.
uids = RE(th_tth_scan([noisy, th_tth_permit], 7, 6, points=3, min_sep=2.5))
theta: 100%|████████████████████████████| 0.2/0.2 [00:00<00:00,  1.08s/degrees]
theta [In progress. No progress bar available.]



Transient Scan ID: 930     Time: 2023-04-11 18:48:34
Persistent Unique Scan ID: '64c1bf12-041a-4e1c-ba92-0aef01a1e4e7'
New stream: 'label_start_motor'
New stream: 'th_tth_permit_monitor'
New stream: 'primary'
+-----------+------------+------------+------------+------------+---------------+
|   seq_num |       time |     ttheta |      theta |      noisy | th_tth_permit |
+-----------+------------+------------+------------+------------+---------------+
|         1 | 18:48:37.5 |     7.0000 |     3.5000 |      29638 |       1.00000 |
|         2 | 18:48:41.3 |     6.5000 |     3.2500 |       4031 |       1.00000 |
Suspending....To get prompt hit Ctrl-C twice to pause.
Suspension occurred at 2023-04-11 18:48:42.
Justification for this suspension:
Signal th_tth_permit is low
Suspender SuspendBoolLow(EpicsSignal(read_pv='gp:userCalc1.VAL', name='th_tth_permit', value=1.0, timestamp=1681256930.19391, tolerance=1e-05, auto_monitor=True, string=False, write_pv='gp:userCalc1.VAL', limits=False, put_complete=False), sleep=0, pre_plan=None, post_plan=None,tripped_message=) reports a return to nominal conditions. Will sleep for 0 seconds and then release suspension at 2023-04-11 18:48:50.
|         3 | 18:48:53.1 |     6.0000 |     3.0000 |      32567 |       1.00000 |
+-----------+------------+------------+------------+------------+---------------+
generator scan ['64c1bf12'] (scan num: 930)

The suspender tripped during the scan at the last move when ttheta was performing its backlash correction. Exactly what we wanted to happen. While moving the motors to the last point of the scan, (m2-m1)<2.5 was satisfied and the suspender tripped. The command line was not responsive. To allow the scan to progress, the ``theta`` motor was moved from some other EPICS client outside of bluesky, allowing the scan to progress. If the suspender delays the scan long enough, the scan will timeout with a FailedStatus exception.

When the suspender trips, motion (in bluesky) is not permitted due to our computation of dynamic limits. We must move the motors so that the limit permit is restored before we can scan. We can move the motor from the command line or a GUI application. (This works since other EPICS clients such as the command line do not use this RunEngine instance. The suspender is checked only by this RunEngine.)

The signal (for the suspender) does not automatically clear since it is only computed when the motor readback value changes. We can clear this manually by moving the theta motor away from the ttheta motor. Yet, still, we encounter the problem when the two motors are close together either, as in this example.

Conclusion#

We can avoid an anticipated collision of instrument hardware by providing a RunEngine suspender tied to the value of an EPICS PV.