synApps busy record#

Objective

In this notebook, we show how to use the EPICS busy record.

The synApps busy record is used to signal the completion of an operation. Generally, the busy record is used for operations that have no inherent means of reporting that a long or complex operation has completed. Two cases come to mind immediately, both involving waiting for completion of some operation):

  • arbitrary operation

  • movement of a positioner (or set of positioners such as a diffractometer or hexapod)

One type of positioner, the EPICS motor record, already has such a means to report done moving, via the .DMOV field, so the busy record provides no additional benefit. But a set of simpler PVs (using ao, ai, bo, & bi records), which together implement the main features of a positioner, would benefit from having a done moving signal. This is a case for use of a busy record.

This notebook expects an EPICS IOC with prefix gp: that provides several PVs:

PV

record type

description

{IOC}gp:bit1

bo

general purpose binary output (bit) variable

{IOC}gp:float1

ao

general purpose analog output (floating-point) variable

{IOC}gp:float2

ao

general purpose floating-point (floating-point) variable

{IOC}mybusy1

busy

general purpose busy record

{IOC}mybusy2

busy

general purpose busy record

The instrument package is not necessary. This notebook will use a temporary databroker catalog.

[1]:
%xmode Minimal
from apstools.synApps import BusyRecord
from apstools.utils import run_in_thread
from bluesky import plans as bp
from bluesky import plan_stubs as bps
from bluesky.callbacks.best_effort import BestEffortCallback
from enum import Enum
from ophyd import Component
from ophyd import Device
from ophyd import EpicsSignal
from ophyd import PVPositioner
from ophyd import Signal
from ophyd.status import DeviceStatus

import bluesky
import databroker
import logging
import time

IOC = "gp:"

cat = databroker.temp()
logging.basicConfig()
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
RE = bluesky.RunEngine({})
RE.subscribe(cat.v1.insert)
RE.subscribe(BestEffortCallback())
Exception reporting mode: Minimal
[1]:
1

arbitrary operation#

Use a busy record to indicate the state of some arbitrary operation. A fly scan is one example. In this example, a user-adjustable time delay is sued to simulate the arbitrary operation.

The apstools package provides support for the busy record, apstools.synApps.BusyRecord.

operation uses trigger() method#

Let’s start with a Device with a busy PV and a user-settable delay time. The operation is run (in an external thread) from the device’s .trigger() method.

Follow example from https://blueskyproject.io/ophyd/explanations/status.html?highlight=devicestatus

[2]:
class OperatorBase(Device):
    busy = Component(BusyRecord, "mybusy1", kind="omitted")
    delay_time_s = Component(Signal, value=2, kind="hinted")

    def trigger(self):
        def check_busy(*, old_value, value, **kwargs):
            "Mark as finished when *busy* changes from Busy to Done."
            if old_value in (1, "Busy") and value in (0, "Done"):
                self.busy.state.clear_sub(check_busy)
                status.set_finished()

        @run_in_thread
        def simulated_operation():
            # simulate how the external process works
            self.busy.state.set("Busy")
            time.sleep(self.delay_time_s.get())
            self.busy.state.set("Done")

        status = DeviceStatus(self.busy.state)
        self.busy.state.subscribe(check_busy)
        simulated_operation()
        return status
[3]:
operator = OperatorBase(IOC, name="operator")
operator.wait_for_connection()
operator.stage_sigs["delay_time_s"] = 1.0
operator.read()
[3]:
OrderedDict([('operator_delay_time_s',
              {'value': 2, 'timestamp': 1681482822.6680264})])

Run the operation by calling the operation.trigger() method. Since that method returns a status object (used by the RunEngine to wait for the trigger method to complete), grab that status object. Use that to wait for the trigger method to complete. Report elapsed time, as well.

[4]:
t0 = time.time()  # time the trigger()
st = operator.trigger()  # trigger() returns a status object
print(f"{time.time()-t0:.3f} {st = }")
st.wait()
print(f"{time.time()-t0:.3f} {st = }")  # default time, since device was not staged
0.013 st = DeviceStatus(device=operator_busy_state, done=False, success=False)
2.020 st = DeviceStatus(device=operator_busy_state, done=True, success=True)

Now, run count() (one of the bluesky.plans) with the operator device as a “detector”. The standard plans take care of staging, triggering, reading, and unstaging the device. A RunEngine subscription by the BestEffortCallback is responsible for generating the LiveTable view.

[5]:
RE(bp.count([operator]))


Transient Scan ID: 1     Time: 2023-04-14 09:33:44
Persistent Unique Scan ID: 'fb42dc41-560a-4ef1-8830-c06d44103fad'
New stream: 'primary'
+-----------+------------+-----------------------+
|   seq_num |       time | operator_delay_time_s |
+-----------+------------+-----------------------+
|         1 | 09:33:45.9 |                 1.000 |
+-----------+------------+-----------------------+
generator count ['fb42dc41'] (scan num: 1)



[5]:
('fb42dc41-560a-4ef1-8830-c06d44103fad',)

positioner movement#

Use a busy record to signal done moving for a positioner built from separate PVs (using ophyd.PVPositioner).

PV

PVPositioner attribute

{IOC}gp:bit1

stop_signal

{IOC}gp:float1

setpoint

{IOC}gp:float2

readback

{IOC}gp:float2.PREC

precision

{IOC}mybusy2

done

TODO: Discuss the implementation (Here, we connect to mybusy2 as EpicsSignal)

[6]:
import math

class Mover(PVPositioner):
    setpoint = Component(EpicsSignal, "gp:float1")
    readback = Component(EpicsSignal, "gp:float2")
    done = Component(EpicsSignal, "mybusy2")
    done_value = 0
    stop_signal = Component(EpicsSignal, "gp:bit1")
    stop_value = 1
    precision = Component(EpicsSignal, "gp:float2.PREC")

    simulator_sleep_s = 0.1
    tolerance = 0.001

    @property
    def in_position(self):
        return math.isclose(self.setpoint.get(), self.readback.get(), abs_tol=self.tolerance)

    @property
    def is_done(self):
        return self.done.get() == self.done_value

    @run_in_thread
    def setpoint_watch(self, *args, **kwargs):
        if self.is_done:
            self.done.put(1 - self.done_value)

    @run_in_thread
    def motion_simulator(self):
        """Simulate the motion using Python code."""
        reset_stop_value = 1 - self.stop_value

        while True:
            if self.in_position and not self.is_done:
                # finish the move to the exact setpoint value
                self.readback.put(self.setpoint.get())
                self.done.put(self.done_value)
                logger.info(f"simulator: {self.readback.get() = } {self.is_done = } end")

            if not self.in_position:
                if self.stop_signal.get() == self.stop_value:
                    # must STOP the move now, stay at current position
                    self.setpoint.put(self.readback.get())
                    self.stop_signal.put(reset_stop_value)
                    self.done.put(self.done_value)
                    logger.info(f"simulator: {self.readback.get() = } stopped")

                diff = self.setpoint.get() - self.readback.get()

                if abs(diff) > self.tolerance:
                    # move closer to the setpoint
                    step = diff * 0.5  # novel step size
                    value = step + self.readback.get()
                    self.readback.put(value)
                    logger.info(f"simulator:  {self.tolerance=}  {self.is_done=}  {diff=}  {value=}")

            time.sleep(self.simulator_sleep_s)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        # update tolerance based on display precision
        self.tolerance = 10**(-self.precision.get())
        self.setpoint.subscribe(self.setpoint_watch)
        self.motion_simulator()
[7]:
mover = Mover(IOC, name="mover")
mover.wait_for_connection()
INFO:__main__:simulator: self.readback.get() = 1.0 self.is_done = True end
[8]:
if mover.position == 0:
    mover.move(1)
for i in range(3):
    st = mover.move(-mover.position)
    print(f"{i}  {mover.position = } {st.elapsed = }")
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-2.0  value=0.0
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-1.0  value=-0.5
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.5  value=-0.75
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.25  value=-0.875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.125  value=-0.9375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.0625  value=-0.96875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.03125  value=-0.984375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.015625  value=-0.9921875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.0078125  value=-0.99609375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.00390625  value=-0.998046875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.001953125  value=-0.9990234375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.0009765625  value=-0.99951171875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.00048828125  value=-0.999755859375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.000244140625  value=-0.9998779296875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.0001220703125  value=-0.99993896484375
0  mover.position = -1.0 st.elapsed = 1.6434977054595947
INFO:__main__:simulator: self.readback.get() = -1.0 self.is_done = True end
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=2.0  value=0.0
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=1.0  value=0.5
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.5  value=0.75
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.25  value=0.875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.125  value=0.9375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.0625  value=0.96875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.03125  value=0.984375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.015625  value=0.9921875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.0078125  value=0.99609375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.00390625  value=0.998046875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.001953125  value=0.9990234375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.0009765625  value=0.99951171875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.00048828125  value=0.999755859375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.000244140625  value=0.9998779296875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.0001220703125  value=0.99993896484375
1  mover.position = 1.0 st.elapsed = 1.5773115158081055
INFO:__main__:simulator: self.readback.get() = 1.0 self.is_done = True end
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-2.0  value=0.0
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-1.0  value=-0.5
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.5  value=-0.75
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.25  value=-0.875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.125  value=-0.9375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.0625  value=-0.96875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.03125  value=-0.984375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.015625  value=-0.9921875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.0078125  value=-0.99609375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.00390625  value=-0.998046875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.001953125  value=-0.9990234375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.0009765625  value=-0.99951171875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.00048828125  value=-0.999755859375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.000244140625  value=-0.9998779296875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.0001220703125  value=-0.99993896484375
2  mover.position = -1.0 st.elapsed = 1.5689060688018799
INFO:__main__:simulator: self.readback.get() = -1.0 self.is_done = True end
[9]:
def n_moves(n=2):
    for _ in range(n):
        yield from bps.mv(mover, -mover.position)

RE(n_moves())
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=2.0  value=0.0
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=1.0  value=0.5
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.5  value=0.75
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.25  value=0.875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.125  value=0.9375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.0625  value=0.96875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.03125  value=0.984375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.015625  value=0.9921875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.0078125  value=0.99609375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.00390625  value=0.998046875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.001953125  value=0.9990234375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.0009765625  value=0.99951171875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.00048828125  value=0.999755859375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.000244140625  value=0.9998779296875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=0.0001220703125  value=0.99993896484375
INFO:__main__:simulator: self.readback.get() = 1.0 self.is_done = True end
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-2.0  value=0.0
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-1.0  value=-0.5
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.5  value=-0.75
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.25  value=-0.875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.125  value=-0.9375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.0625  value=-0.96875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.03125  value=-0.984375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.015625  value=-0.9921875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.0078125  value=-0.99609375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.00390625  value=-0.998046875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.001953125  value=-0.9990234375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.0009765625  value=-0.99951171875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.00048828125  value=-0.999755859375
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.000244140625  value=-0.9998779296875
INFO:__main__:simulator:  self.tolerance=0.0001  self.is_done=False  diff=-0.0001220703125  value=-0.99993896484375
[9]:
()
INFO:__main__:simulator: self.readback.get() = -1.0 self.is_done = True end