Experiment Framework
plesty.lib.experiment turns a measurement routine into a reproducible,
crash-tolerant run. The mental model is checkpointed training in deep
learning: the plan is the training schedule, the journal is the training
log, and the completed-step set plus persisted step results form the
checkpoint.
An Experiment orchestrates devices through a
CompositeDevice — it receives Device
instances, never raw hardware.
The pieces
Piece |
Role |
|---|---|
|
The smallest unit of work: a stable |
|
The frozen, ordered schedule of steps plus the run configuration. Written to disk at run start, never mutated; a SHA-256 |
|
A crash-safe, append-only JSONL event log ( |
|
One line per completed step: its result document ( |
|
The async ABC tying it together: validate → journal → execute with retries → persist results → resume. |
|
Read access to a stored run — plan, config, journal status, records, and the translation of device-written paths onto this machine ( |
Every run owns a directory:
<run_root>/<run_id>/ # run_id = <name>_<YYYYmmdd-HHMMSS>
├── plan.json # frozen schedule + config + content hash
├── journal.jsonl # append-only event log
├── records.jsonl # one line per completed step: its result document
└── data/ # blobs the records reference (arrays, images), if any
└── step_0003.npy
Each record line is a typed ResultDocument with
provenance (step_id, op, params) plus the step index; a JSON value
result is inlined, an array or image stays a raw blob in data/ that the
line references. The line is the commit — it is appended (and fsynced) only
after the blob is on disk, and a resumed run treats a committed record as a
completed step even if the crash came before its journal event. Runs
written by plesty-lib ≤ 0.3.4 (one data/step_*.json per step) are still
read by RunSource, records(), and Run.
Writing an experiment
Subclasses override exactly three lifecycle hooks — build_plan()
(required), setup()/teardown() (optional, extend the defaults which
connect/disconnect all devices) — plus one public method per step operation:
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 devices via self.devices, return the measurement
return PlestyArray([float(x)], name="signal", unit="a.u.")
run_id = asyncio.run(LineScan(run_root="runs").run())
Rules that keep runs reproducible:
build_plan()must be deterministic for a given configuration — resume compares the regenerated plan against the persisted one by content hash.Step ids must be stable (e.g.
"scan[x=3,y=5]") — never derived from run time or randomness; resume matches completed steps by id.Step operations are validated before anything runs: every
Step.opmust name an existing public method and may not be a reserved lifecycle method (build_plan,run,setup,teardown). Violations raiseInvalidOperationErrorlisting all offending steps — before any device is touched or journal written.
Step methods may be sync or async; results are awaited automatically. Each
step is retried (max_retries, retry_sleep constructor arguments) before
the run aborts.
Starting on a rig that is really there
An experiment’s __main__ builds the rig with connect_rig — the same
start for every experiment: connect the servers, run the composite’s
preflight() (each sub-device exposes the methods its configuration
requires and answers identity()), and if anything is missing, tell the
operator which device, at which address, from which variable — and ask:
from plesty.lib.experiment import connect_rig
rig, skipped = connect_rig(
lambda skip: PolPlRig(with_powermeter="pm" not in skip),
optional={"pm"}, # the experiment can run without it
)
The rig is not ready — 1 device(s) failed the preflight:
- pm: no answer from tcp://127.0.0.1:5557: TimeoutError('Server did not respond in time')
address tcp://127.0.0.1:5557 (from PM_ADDRESS in .env / the environment)
Check that the pm server(s) are running and that PM_ADDRESS point at them
(a wrong port connects fine but is not the expected device).
[r] retry (after fixing .env or starting the server) [a] abort [s] continue without pm
>
r rebuilds the rig (an edited .env or a freshly started server is picked
up), a raises RigNotReadyError, s — offered only when every failing
sub-device is in optional — returns the rig without them and names them in
skipped, so the experiment can drop the matching part of its plan. Without
a terminal on stdin nothing waits: the diagnosis is logged and
RigNotReadyError raised.
The check also runs inside Experiment.setup(): a run never starts on a
composite whose preflight() reports a problem, whether or not connect_rig
was used. Declare what each sub-device must be able to do in its
configuration — that is what turns “the port answers” into “it is the
stage”:
devices:
hwp: {timeout_ms: 5000, requires: [home_stage, move_absolute, get_position]}
spec: {timeout_ms: 5000, requires: [acquire, get_recent_file, write, query]}
pm: {timeout_ms: 5000, requires: [measure_power, write]}
Crash, cancel, resume
run() returns the run id. If the run is interrupted — Ctrl-C, a crash,
an aborting step — pass the id back to continue:
run_id = asyncio.run(LineScan(run_root="runs").run())
# ... interrupted?
asyncio.run(LineScan(run_root="runs").run(resume=run_id))
On resume the framework:
regenerates the plan and refuses to continue if its content hash differs from the stored one (
PlanMismatchError) — a silently changed schedule or configuration cannot corrupt a run;replays the journal to find completed steps and skips them;
journals the resume (
run_startedwithresumed: true) and continues.
teardown() is always awaited — on completion, failure, and cancellation —
so devices are released no matter how the run ends.
Contract tests without hardware
plesty.lib.test.experiment_pipeline.ExperimentPipeline verifies the
plan/checkpoint contract of an experiment module with no instrument
attached (it never calls run()). Five gates:
Gate |
Checks |
|---|---|
1 |
public |
2 |
|
3 |
every |
4 |
|
5 |
|
Experiment HUB modules expose one pytest function per gate (SDK gate E1 verifies their presence and passage — the experiment-tier mirror of device gate d1); see Test Helpers for the pattern.