# 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](device.md#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. ```python # 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": } 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 ```bash python power_scan.py # prints the run's id, e.g. powerscan_20260925-101534 ``` The run's folder: ```text 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: ```python 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`: ```python 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: ```python 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](device.md#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](../common/env_file.md) 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: ```yaml # config/default.yaml wavelengths: [500.0, 600.0, 700.0] ``` The command line then offers: ```bash 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 # 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](terminal.md) 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](../experiment/reading_runs.md) | | know what is checked when a stopped run is continued, and what is refused | [Stopping and Continuing a Run](../experiment/stopping.md) | | keep several sets of measurement settings and choose one at start | [The run configuration: `config/*.yaml`](../common/yaml_config.md#the-run-configuration-configyaml) | | check that every device is there before the first step, and continue without an optional one | [Checking the Devices Before a Run](../experiment/checking_devices.md) | | retry a device call that timed out, and reconnect | [Resilient remote calls](../device/composite_device.md#resilient-remote-calls) | | use devices that run on several computers, described in one file | [The Setup File](../experiment/setup_file.md) | | 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](../experiment/shared_disk.md) | | watch the data in a window while the run goes on | [The Viewer: Viz](../viewing/viz.md) | | test your experiment automatically, without instruments | [Testing an Experiment](../testing/experiment.md) |