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 methodmeasurewithwavelength=500.0, and so on.configis saved with the plan, so every run remembers the settings it was started with.CompositeDeviceholds all the devices an experiment uses, each under a short name; here the demo power meter is calledpm. The experiment reaches it asself.devices.call("pm", "write", "wavelength", 633)does the same aswrite("wavelength", 633)on the device, and if the device is on another computer and does not answer in time,callreconnects 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 sameconfig. 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.opmust 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())
Manifestdescribes the experiment: the name it is started by and a title.@cli.rigmarks 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 variablePM_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.envin the folder you start the script from, oneNAME=valueper line. Keeping the address there means the same code runs in any lab; The .env File explains more.@cli.buildmarks the function that creates the experiment.configis read from a YAML file,config/default.yamlunless 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 |
|
know what is checked when a stopped run is continued, and what is refused |
|
keep several sets of measurement settings and choose one at start |
|
check that every device is there before the first step, and continue without an optional one |
|
retry a device call that timed out, and reconnect |
|
use devices that run on several computers, described in one file |
|
let devices on other computers save large files, such as images and spectra, to a disk the whole lab shares |
|
watch the data in a window while the run goes on |
|
test your experiment automatically, without instruments |