The device → analyzer → experiment pipeline finally has its top tier. Commit 40d68e6 adds an asynchronous Experiment base class to plesty.lib.experiment: a run is a frozen plan of atomic steps recorded in a crash-safe journal, so any measurement can stop and resume exactly where it left off.

device analyzer experiment
For users Run a measurement — and resume it after a crash

If someone hands you an experiment, running it is two lines. A long scan that crashes, is canceled, or loses its instrument connection resumes without repeating a single completed step:

run, then resume
import asyncio
from my_experiments import LineScan

# start a fresh run — returns the run id
run_id = asyncio.run(LineScan(run_root="runs").run())

# interrupted? resume by id — only the unfinished steps execute
asyncio.run(LineScan(run_root="runs").run(resume=run_id))

Each run owns a self-describing directory — reproducible and inspectable long after it finishes:

<run_root>/<run_id>/
plan.json      # the frozen schedule + config + content hash
journal.jsonl  # append-only event log — the source of truth for status
data/          # per-step results: raw blob + JSON metadata
  step_0000.json
  step_0000.npy
run_started step_started step_completed step_failed run_canceled run_aborted run_completed

Resume is safe, not hopeful. On resume the stored plan.json hash is compared to the plan the experiment builds now — if the schedule or config changed, the run is refused with a PlanMismatchError rather than stitching two different experiments together.

For contributors Write a new experiment

To add an experiment to the hub, subclass Experiment, implement build_plan() (it must be deterministic — its content hash gates resume), and add one method per step op. Device access always goes through a CompositeDevice; an experiment receives Device instances, never raw hardware.

a resumable line scan (mock — no hardware needed to try it)
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:
        return PlestyArray([float(x)], name="signal", unit="a.u.")

Before it ships, an experiment must pass the enforced baseline: plan-step validation before a run starts (27a16c2) and the ExperimentPipeline standard contract test suite (lib#14). Verify it locally the same way as any module:

verify against the standard
uv run plesty check   # includes gate E1 — experiment contract validation
🔎 Under the hood
schedule.py
Plan + Step — a frozen, hashable list of atomic steps (id, op, params) plus run config; a SHA-256 content_hash() fingerprints the schedule.
journal.py
Journal — an append-only JSON-Lines event log. Every line is flushed + fsynced, so it survives a crash; a torn final line is tolerated on replay.
base_experiment.py
Experiment — the async ABC. setup()run()teardown(); run() is a template method that executes the plan with per-step retries and resume.
data/io.py
save_result — persists each step as a raw blob + JSON metadata under the run's data/ directory.

The checkpoint analogy in one line: plan = schedule, journal = log, completed steps + saved results = checkpoint. Stop any run, resume it, and only the unfinished steps execute.