Writing an Experiment

An experiment is a class based on Experiment (from plesty.lib.experiment). You write two kinds of method: build_plan(), which lists the steps, and one method per kind of step, which does the work. The parts introduces the words used here.

The plan and the step methods

import asyncio

from plesty.lib.data import PlestyArray
from plesty.lib.experiment import Experiment, Plan, Step


class LineScan(Experiment):
    def build_plan(self) -> Plan:
        steps = [Step(id=f"scan[x={x}]", op="scan_point", params={"x": x}) for x in range(5)]
        return Plan(steps, config={"points": 5})

    def scan_point(self, x: int) -> PlestyArray:
        # drive the devices through self.devices, and return the measurement
        return PlestyArray([float(x)], name="signal", unit="a.u.")


run_id = asyncio.run(LineScan(run_root="runs").run())
  • build_plan() returns a Plan: the steps in order, and config, the settings the plan was built from. Both are saved with the run.

  • Each Step names the method to call (op) and the keyword arguments to call it with (params).

  • A step method can be an ordinary function or an async one; the framework awaits it either way. What it returns is saved as the step’s record.

  • run() runs the plan and returns the run’s id. It is async, so start it with asyncio.run(...), or use The Command Line, which does that for you.

Three rules keep a run safe to continue after it stops:

  • build_plan() returns the same plan for the same settings. When a run continues, the plan is built again and compared, by fingerprint, with the one saved; a different plan is refused (PlanMismatchError).

  • A step’s id comes from its values, such as scan[x=3,y=5], never from the time or a random number, because finished steps are recognised by their id.

  • Every op names a public method of the class, and not one of the methods the framework itself calls (build_plan, run, setup, teardown). All steps are checked before the first one runs; a wrong one raises InvalidOperationError listing every offending step, before any device is touched.

Devices

The experiment reaches its devices through self.devices, a CompositeDevice passed to the constructor:

experiment = LineScan(devices=CompositeDevice(["stage", "camera"]), run_root="runs")

Inside a step method, self.devices.call("stage", "write", "position", 3.0) calls a method of the device named stage; for a device on another computer, a call that times out is retried after reconnecting.

Setting up and tearing down

Two more methods run around the steps:

  • setup() runs before the first step. By default it connects every device.

  • teardown() runs after the last step. By default it disconnects every device. It always runs, whether the run finished, failed or was stopped, so the devices are released however it ends.

Both are async. To add your own work, override them and call the default:

async def setup(self) -> None:
    await super().setup()
    self.devices.call("stage", "home")

When a step fails

A step that raises an exception is tried again, max_retries attempts in all (3 by default), waiting retry_sleep seconds (3 by default) in between. If every attempt fails, the run stops and is marked as aborted in the journal. Both are arguments of the constructor:

LineScan(devices=devices, run_root="runs", max_retries=5, retry_sleep=10.0)

Reporting progress within a step

A step that takes minutes, such as a long exposure or a slow magnet ramp, looks the same from outside as one that hangs. Call self.progress() inside it to say how far it has got:

def acquire(self, frames: int) -> dict:
    for i in range(frames):
        self.devices.call("camera", "expose")
        self.progress(i + 1, frames, "exposing")
    return {"frames": frames}

This writes progress.json in the run folder, where a viewer or a console can show it. It holds only the present moment and is not part of the run’s results.

Other constructor arguments

Argument

Default

Meaning

devices

None

the CompositeDevice the steps use

run_root

the shared disk, or runs without one

the folder that runs are saved in; a relative path is taken inside the shared disk when there is one

name

the class name, in lower case

the name that starts each run’s id

max_retries, retry_sleep

3, 3.0

see When a step fails

data_root

from PLESTY_DATA

the shared disk; see Data on a Shared Disk