A device module can pass every mock gate and still fail the first time it meets its instrument. August closed that gap: one shared on-hardware gate sequence in plesty-lib, generated per module by plesty init field-test, and a sanitised report committed beside the module saying what was verified and how fast it ran — with nothing that says where it ran.

Three things arrived together. The gates themselves (plesty.lib.test.field_test) — eight of them, run against a connected instrument, producing a timing profile and per-command failure statistics rather than a pass/fail. A second tier (plesty.lib.test.client_field_test) that drives the device the way an experiment does, over the wire. And the generator that writes both tests for your module from one interview, because the gates were never the hard part — the configuration was.

8
host-tier gates
8
client-tier gates
6
operation kinds
2
published artifacts

Two tiers, and the difference is not a detail

FieldTestPipelinedevicetraffic managerinstrument Host tier — in-process, on the machine the instrument is attached to.
ClientFieldTestDeviceTCPIPClientZMQDeviceTCPIPServerasync wrapperdevice Client tier — the path an experiment actually uses, and until now the untested one.
host tier

tests/field_test.py

Discovery, connect lifecycle, parameter round-trip and constraints, operations, buffer drain, stability, error recovery. Needs the instrument and the machine it is plugged into.

client tier

tests/field_test_client.py

Connect, describe, protocol fidelity, operations, session survival, timeout recovery, wrapper modes, resource allocation. Runs attached from any machine on the instrument network — or spawned on loopback, which needs no hardware and is the first field-test tier that runs in CI.

Why the second tier exists

A module could hold a green 8/8 host report and still break the moment an experiment talked to it. Several defects found during the August hardware round live above the device layer — in the socket handling, the wrapper, the resource manager — and every one of them was surfaced by running an experiment rather than by a test. Two of them need real network latency and do not reproduce on loopback at all, which is why attached is the mode whose timings mean anything.

For users: running one

You have a device module and the hardware in front of you.

1 · Generate the test — the interview

# in the module root, with the device importable
uv run plesty init field-test

Six to eight questions, depending on what your device offers. The schemas supply the candidates; you supply the judgement no inspection can make. Arrow keys move, space selects, Enter confirms.

The questionWhat the answer is used for
Which operations are safe to run on repeat, unsupervised?The allow-list for the functions and stability gates. Nothing you leave out is ever called.
Which of those act on the world rather than only reading?Separates a measurement from a move, an exposure or a reset.
Which parameter reports where op moves the part to? How far may the test move it?position_key and step — how a motion is driven out and put back.
Which settings can be safely written back their own value?only_keys for the round-trip gate.
Pick two settings whose values could never be mistaken for each other.The drain probe: a stale reply has to be unambiguous.
Which operation is slowest, and which argument makes it slower?The provocation. A call returning in two milliseconds cannot be abandoned.
Does connecting start a program, rather than just opening a port?expensive_connect — gates needing their own connection are skipped and say so.
Instrument model; anything interchangeable fitted?Names the published report's directory. A powermeter with a photodiode and the same module with a thermopile are different reports.
The answers are a file

Every answer is written to .plesty/field-test.json as you give it, with the reason you gave it. Ctrl-C mid-interview keeps what you answered; run the command again to carry on. Edit that file directly to change one answer without sitting through the rest, then regenerate with --force.

2 · Run it

# host tier — address comes from .env, the same file the server reads
uv run python tests/field_test.py
uv run python tests/field_test.py --address mock       # the simulator
uv run python tests/field_test.py --gates param_roundtrip stability
uv run python tests/field_test.py --keep-going         # don't stop at the first failure

# client tier — run the host tier first; this one reads its report
uv run python tests/field_test_client.py                     # attached, from .env
uv run python tests/field_test_client.py --address tcp://<instrument-pc>:5555
uv run python tests/field_test_client.py --spawn             # loopback, no hardware
The address is never in the test file

DEVICE_ADDRESS lives in .env, which is git-ignored, because an instrument address carries a serial number and tests/field_test.py is committed to a world-readable repository. One .env drives the device server and the field test alike. When neither --address nor DEVICE_ADDRESS is set, the run falls back to the simulator — a run that silently reached no instrument should measure nothing rather than something.

3 · What the run writes

reports/
  check.json                     plesty check             committed
  <instrument>/
    field-test.json            host tier            committed
    field-test-client.json    client tier      committed
  *.md  *.jsonl              full per-run record git-ignored

The markdown and JSONL are for the person who just ran the thing: they carry the host, the address, the serial and the lab's paths, because that reader needs to know which rig produced them. They never enter git — a hub repository is world-readable, and a field-test report is a small inventory of the lab. Set PLESTY_REPORT_ARCHIVE in .env to also copy every run to a shared location, each in its own timestamped folder; an unmounted share warns and does not fail the run.

field-test.json is the published projection, and sanitisation is the library's job rather than yours: every field is either derived from the module's own source and schemas — already public — or is a measurement. The host name is deliberately not among the environment keys it may carry; which Windows, which Python and which library version explain a difference between two runs, while which machine locates a lab. A document that fails validation is refused rather than published.

4 · Reading the result

A real host-tier run of a powermeter module, as published:

outcome   { passed: 8, failed: 0, skipped: 0, ok: true }
gates     discovery pass · connect_lifecycle pass · param_roundtrip pass
          param_constraints pass · functions pass · buffer_drain pass
          stability pass · error_recovery pass
coverage  7 parameters, 10 operations — each with attempts, failures,
          failure_rate and latency {min, median, p95, max}
lifecycle connect 34.2 ms · identity · disconnect · reconnect
findings  []

Three things to read, in order:

Failures are classified rather than merely counted — timeout, wrong_response, device_error, exception — per command, so a flaky query becomes a rate you can point at instead of an anecdote. The stability gate never retries: measuring how often a command fails is its entire purpose. The run stops at the first failed gate by default, because on real hardware a failure usually leaves the instrument in a state where the remaining gates measure nothing.

The p95 is the point

Timeouts and poll intervals used to be guesses. The stability gate repeats every queryable parameter and safe operation (20 times by default) and reports min / median / p95 / max per command, so the timeout you set is a measured value. For a transport with weak request/response correlation, the p95 of a position read is the empirical basis for the reply timeout.

For contributors: making a module field-testable

You are writing or maintaining a device module and want the gates to drive it correctly.

Declare what each operation does

This is the August addition that made unattended runs possible (plesty-lib#39): an operation now records its kind, and the gates act on the kind instead of asking about every operation individually.

from plesty.lib.device.device_utils import expose_to_api

@expose_to_api(kind="motion", position_key="MO.Position")
def move_absolute(self, position: float) -> float: ...

@expose_to_api(kind="motion", position_key="MO.Position", relative=True, step=1.0)
def move_relative(self, step: float) -> float: ...

@expose_to_api(kind="lifecycle")
def home_stage(self) -> bool: ...

A schema-registered operation declares the same fields in its schema entry — kind, position_key, target, relative, step — validated at registration, so a typo fails when the module loads rather than during a test on hardware. The declaration travels: it reaches FuncDoc.kind, function_docs() and the describe payload, so the generated documentation and every tool reading the API see it too.

KindWhat it meansHow the gates drive it
readReturns a value, changes nothingCalled as it is. The default when undeclared.
motionMoves a part whose position a parameter reportsDriven one step out from what position_key reports and put back; the functions gate checks it came back, the stability gate repeats out-and-back.
acquireEmits or exposes and produces dataCalled only when allow-listed.
lifecycleHoming, reset, calibrationOnce when allow-listed — never on repeat.
controlStop, abort — meaningful only mid-motionOnce, at rest, after the motions. Skipped when nothing moved.
configureChanges a setting that outlives the callNever called, and never even offered by the interview — the round-trip gate is where a setter is verified.

A motion declared without a position_key, a target and a step fails its gate rather than guessing. The client tier is handed the same declarations explicitly, because describe() flattens the served methods and the client cannot read the module's own registration — one set of kinds, not two that can drift apart.

Nothing is assumed safe

An operation you do not classify is in neither list, and its gate skips saying so. A generated allow-list that quietly included a motion command would be worse than no generator at all: on real rigs the wrong answer has moved a stage, fired an exposure and rewritten a sensor's zero calibration. The client tier inherits the host tier's judgement rather than making a second one that could disagree with it.

Give the gates what they need

Retries are off by default

max_retries=0. The transport already retries a failed command three times, escalating through a device clear and a session reopen; retrying on top of that turns one unanswerable query into nine attempts and three session teardowns. On an instrument that is already struggling, another attempt is not a second chance — it is more load on the thing that is failing. Raise it only for a device with a demonstrated transient fault.

The gate it satisfies

Gate f1 — Field Test Findings now runs inside plesty check. It reads every published reports/<instrument>/field-test.json and fails the module if one holds an error finding: the module is telling every caller that something exists which does not, and must not be published in that state.

✓ Field Test Findings (f1 — 1 report(s): S130C)

A module that has never been field-tested publishes no report and the gate is skipped as not applicable. Absence is the honest rendering — "never run" and "ran and found nothing" are different facts, and treating them alike is how an untested module reads as a verified one.

Regenerating

uv run plesty init field-test           # resumes from .plesty/field-test.json
uv run plesty init field-test --force   # replace an existing tests/field_test.py

An existing field test is never replaced silently: it holds answers somebody thought about, and a hardware run may since have corrected them. One interview writes both tiers.

Where to start

No hardware to hand? plesty init field-test against a module and then tests/field_test_client.py --spawn gives you a full client-tier run over loopback — real protocol, real wrapper, real resource manager, no instrument. It is the cheapest way to see the whole shape of a report before you take the module to a bench.