Scripting Plans#

While the CLI can be used to query devices and run plans, it can be useful to combine multiple plans within a better interface than bash/shell scripting.

For this, blueapi can be used as a library with the BlueapiClient wrapping interactions with the server.

To run a standalone script, it should be possible to use uv directly.

$ uv run --with blueapi path/to/script.py

To include as part of an existing virtual environment, add blueapi using whichever tool is being used to manage the environment, eg uv add blueapi, pip install blueapi etc.

Login to blueapi#

The following steps require the user to have logged in blueapi. This can be done via the blueapi login command from a terminal before running the script.

$ blueapi login
$ python script.py # or however you are running the script

It is also possible to use the login() command on the client with the script although be aware this will cause the script to block waiting for the user to login which may not be required if being run without monitoring.

Create an instance of the client#

from blueapi.client import BlueapiClient

# A client can be created from either a config instance or the path to a config
# file. The minimal configuration required # is:
#  api:
#    url: https://address.of.blueapi:1234
#  stomp:
#    enabled: true
#    url: tcp://address.of.rabbitmq:61613
bc = BlueapiClient.from_config_file("/path/to/config.yaml")

If you are using the login() method in the script, it should be called before any further interactions. If the user is already logged in, the script will continue without prompting the user.

bc.login()

Plans and devices are available via the plans and devices attributes of the client. It can be useful to alias these locally to reduce the boilerplate in scripts.

plans = bc.plans
devices = bc.devices

Query devices#

The devices available on the server are accessible via the devices attribute of the client.

for device in bc.devices:
    print(device)

Individual devices can be accessed as attributes on the devices field. It can also be useful to alias these locally.

det = bc.devices.det
stage = bc.devices.stage

Child devices can be accessed via their parent devices

stage_x = stage.x

Trying to access a child device that does not exist will raise an AttributeError

Run a plan#

Running plans requires an instrument session. As this is unlikely to change from one plan to another, this can be set on the client to be used for all subsequent plans.

bc.instrument_session = "cm12345-1"

Plans are accessible via the plans attribute of the client instance. They can, for the most part, be treated as if they were local functions.

bc.plans.count([bc.devices.det], num=3, delay=4.2)

Running a plan in this way will block until the plan is complete. If the script is interrupted (eg via Ctrl-C) while a plan is running it will be aborted before the script exits.

Where parameters to a plan are optional, they can be omitted from the method call. Where parameters are required, they can be passed either as positional or named arguments.

Run multiple plans#

Plans can then be co-ordinated using standard python constructs, eg to run a plan multiple times

for temp in range(1, 5):
    plans.set_absolute({devices.temp: temp})
    plans.count([devices.det])

Passing more complex arguments#

Anything passed to a plan function will be serialized into JSON before being sent to the server. For many types you can pass the instance directly and the serialization should handle the conversion for you.

from scanspec.specs import Line

bc.plans.spec_scan(detectors=[det], spec=Line(bc.devices.stage.x, 0, 10, 11))

if a type does not serialize correctly, passing the JSON equivalent should be possible instead. For instance the above is equivalent to

bc.plans.spec_scan(detectors=[det], spec={
    "axis": "stage.x",
    "start": 0.0,
    "stop": 10.0,
    "num": 11,
    "type": "Line"})

Add callbacks#

By default there is no indication of progress while a scan is running however it is possible to subscribe to events so that updates can be provided.

A callback should accept a single parameter which will be the event from server. This will be one of WorkerEvent, ProgressEvent or DataEvent.

An example that prints data for each point could be something like

def feedback(evt):
    match evt:
        case DataEvent(name="start"):
            print("Run started")
        case DataEvent(name="stop", doc={"exit_status": status}):
            print("Run complete: ", status)
        case DataEvent(name="event", doc={"seq_num": point, "data": data}):
            print(f"    Point {point}: {data}")

bc.add_callback(feedback)

bc.plans.spec_scan([bc.devices.det], Line(bc.devices.stage.x, 0, 1, 11))

The above prints the following as the scan progresses

Run started
    Point 1: {'stage-x': 0.0}
    Point 2: {'stage-x': 0.1}
    Point 3: {'stage-x': 0.2}
    Point 4: {'stage-x': 0.3}
    Point 5: {'stage-x': 0.4}
    Point 6: {'stage-x': 0.5}
    Point 7: {'stage-x': 0.6}
    Point 8: {'stage-x': 0.7000000000000001}
    Point 9: {'stage-x': 0.8}
    Point 10: {'stage-x': 0.9}
    Point 11: {'stage-x': 1.0}
Run complete:  success

The add_callback method returns an ID that can be used to remove the callback

# Add the callback and record the handle
hnd = bc.add_callback(callback_function)

# remove the callback using the returned handle
bc.remove_callback(hnd)