An Experiment

An experiment runs a measurement: a list of steps that use devices, with every result saved as it arrives. This page writes a scan that reads a power meter at five wavelengths, runs it, stops and continues it, and gives it a command line.

It uses DemoDevice as the power meter, so you can run everything here without an instrument. DemoDevice is explained in Try it without the instrument.

How plesty-lib runs a measurement

A long measurement should not be lost when the program crashes or the computer restarts halfway through. plesty-lib therefore splits every experiment into steps and records each one as it finishes:

  • The plan is the full list of steps, decided before the first one runs. Each step has a name (its id), the method to call, and the values to call it with.

  • A run is one execution of the plan. Each run gets its own folder, where plesty-lib saves the plan, a log of what happened (the journal), and what every finished step returned (the records).

Because the plan and the finished steps are on disk, an interrupted run can be continued: plesty-lib skips the steps already done and runs the rest.

Write the scan

An experiment is a class based on Experiment. It needs two things:

  • build_plan(), which returns the plan;

  • one method per kind of step. Here there is one kind, measure, which sets the wavelength and reads the power.

# power_scan.py
import asyncio

from plesty.lib.device.composite_device import CompositeDevice
from plesty.lib.experiment import Experiment, Plan, Step
from plesty.lib.sim.demo_device import MOCK_ADDRESS, DemoDevice


class PowerScan(Experiment):
    def __init__(self, config: dict, **kwargs) -> None:
        super().__init__(**kwargs)
        self.config = config

    def build_plan(self) -> Plan:
        steps = [
            Step(id=f"power[wl={wl:g}]", op="measure", params={"wavelength": wl})
            for wl in self.config["wavelengths"]
        ]
        return Plan(steps, config=self.config)

    def measure(self, wavelength: float) -> dict:
        self.devices.call("pm", "write", "wavelength", wavelength)
        reading = self.devices.call("pm", "measure_power", samples=10)  # {"power": <W>}
        return {"wavelength_nm": wavelength, "power_w": reading["power"]}


if __name__ == "__main__":
    devices = CompositeDevice({"pm": DemoDevice(MOCK_ADDRESS)})
    config = {"wavelengths": [500.0, 550.0, 600.0, 650.0, 700.0]}
    run_id = asyncio.run(PowerScan(config, devices=devices, run_root="runs").run())
    print(run_id)

What each part does:

  • Step(id=..., op="measure", params={...}) is one step: call the method measure with wavelength=500.0, and so on. config is saved with the plan, so every run remembers the settings it was started with.

  • CompositeDevice holds all the devices an experiment uses, each under a short name; here the demo power meter is called pm. The experiment reaches it as self.devices. call("pm", "write", "wavelength", 633) does the same as write("wavelength", 633) on the device, and if the device is on another computer and does not answer in time, call reconnects and tries again.

  • run_root="runs" is the folder the runs are saved in.

  • Whatever a step method returns is saved as that step’s record.

Three rules keep a stopped run safe to continue:

  • build_plan() must return the same plan every time for the same config. plesty-lib compares the plan it builds when continuing with the one it saved, and refuses to continue if they differ.

  • A step id must come from the step’s values (power[wl=500]), never from the time or a random number, because finished steps are recognised by their id.

  • op must name a method of the class. plesty-lib checks every step before the first one runs, so a typo stops the run before any instrument is touched.

Run it and read the result

python power_scan.py            # prints the run's id, e.g. powerscan_20260925-101534

The run’s folder:

runs/powerscan_20260925-101534/
├── plan.json        # the plan and the config it was built from
├── journal.jsonl    # the journal: one line per event (run started, step finished, …)
└── records.jsonl    # the records: one line per finished step, holding what it returned

Run reads a run folder back, from any program. latest_run finds the newest run of an experiment:

from plesty.lib.experiment import Run, latest_run

run = Run(latest_run("runs", name="powerscan"))
print(run.status()["state"])                   # "completed"
for record in run.records():
    print(record["wavelength_nm"], record["power_w"])

Stop it and continue

Press Ctrl-C while it runs, or let it crash. To continue, pass the run’s id to run() as resume:

asyncio.run(PowerScan(config, devices=devices, run_root="runs").run(resume=run_id))

The finished steps are skipped and the rest run. The devices are always disconnected at the end, however the run stops.

Give it a command line

Most experiments are started from a terminal, with the settings in a file. ExperimentCLI provides that command line, so every experiment is started the same way. You give it two functions:

  • one that creates the devices the experiment uses,

  • one that creates the experiment itself.

Replace the if __name__ == "__main__": block above with:

import sys

from plesty.lib.experiment import ExperimentCLI, Manifest

cli = ExperimentCLI(Manifest(module="power_scan", title="Power scan"))


@cli.rig
def rig(args, config, skip):
    return CompositeDevice(["pm"])


@cli.build
def build(args, config, run_root, devices):
    return PowerScan(config, devices=devices, run_root=run_root)


if __name__ == "__main__":
    sys.exit(cli.main())
  • Manifest describes the experiment: the name it is started by and a title.

  • @cli.rig marks the function that creates the devices. (PLESTY calls the set of devices one experiment uses its rig.) CompositeDevice(["pm"]) no longer holds a device object: it connects to a power meter that a server offers over the network (see Use it from another computer). It finds the server’s address in the environment variable PM_ADDRESS: the device’s name in capitals, followed by _ADDRESS. An environment variable is a named value set outside the program, in the shell (export PM_ADDRESS=…) or in a file called .env in the folder you start the script from, one NAME=value per line. Keeping the address there means the same code runs in any lab; The .env File explains more.

  • @cli.build marks the function that creates the experiment. config is read from a YAML file, config/default.yaml unless you name another:

# config/default.yaml
wavelengths: [500.0, 600.0, 700.0]

The command line then offers:

python power_scan.py --dry-run          # print the plan without connecting anything
python power_scan.py                    # run with config/default.yaml
python power_scan.py -c other.yaml      # run with another settings file
python power_scan.py --resume <run-id>  # continue a stopped run

Before the first step, the run checks that every device answers. If one does not, it tells you which one, the address it tried, and where that address came from, and asks whether to try again or stop.

This version needs the power meter’s server to be running first. In a Terminal starts both.

Advanced topics

You want to …

Read

know exactly what a run’s folder holds, and how to read it back

Reading a Run

know what is checked when a stopped run is continued, and what is refused

Stopping and Continuing a Run

keep several sets of measurement settings and choose one at start

The run configuration: config/*.yaml

check that every device is there before the first step, and continue without an optional one

Checking the Devices Before a Run

retry a device call that timed out, and reconnect

Resilient remote calls

use devices that run on several computers, described in one file

The Setup File

let devices on other computers save large files, such as images and spectra, to a disk the whole lab shares

Data on a Shared Disk

watch the data in a window while the run goes on

The Viewer: Viz

test your experiment automatically, without instruments

Testing an Experiment