Instrument Control

Control Multiple Instruments in a Test

Control several instruments in one test: a plug per instrument, shared sessions, and how to avoid concurrency bugs when phases run in parallel.

JJulien Buteau
intermediate10 min readMarch 14, 2026

A real test bench has more than one instrument: a supply, a meter, a scope, maybe a chamber and a bus interface. Getting several of them into one test is mostly about lifecycle and ownership, not about the SCPI. This guide covers the structure that scales, and the concurrency mistake that produces intermittent failures nobody can reproduce.

One Plug per Instrument

Each instrument gets its own plug: a plain Python class that owns its session. The procedure lists them, and phases receive the ones they name.

plugs/psu.py
27 lines
import pyvisaclass PowerSupply:    RESOURCE = "TCPIP::192.168.1.101::INSTR"    def __init__(self):        rm = pyvisa.ResourceManager("@py")        self.instr = rm.open_resource(self.RESOURCE)        self.instr.timeout = 5000        self.instr.write("*RST")    def apply(self, channel: int, volts: float, limit: float):        self.instr.write(f":APPL CH{channel},{volts},{limit}")        self.instr.write(f":OUTP CH{channel},ON")    def measure_current(self, channel: int) -> float:        return float(self.instr.query(f":MEAS:CURR? CH{channel}"))    def close(self):        """Always de-energise, whatever happened during the run."""        for ch in (1, 2, 3):            try:                self.instr.write(f":OUTP CH{ch},OFF")            except Exception:                pass        self.instr.close()
plugs/dmm.py
19 lines
import pyvisaclass Multimeter:    RESOURCE = "TCPIP::192.168.1.100::INSTR"    def __init__(self):        rm = pyvisa.ResourceManager("@py")        self.instr = rm.open_resource(self.RESOURCE)        self.instr.timeout = 10000        self.instr.write("*RST")    def dc_voltage(self, range_v: float = 10, nplc: float = 1) -> float:        self.instr.write(f":CONF:VOLT:DC {range_v}")        self.instr.write(f":VOLT:DC:NPLC {nplc}")        return float(self.instr.query(":READ?"))    def close(self):        self.instr.close()
plugs/scope.py
import pyvisaclass Oscilloscope:    RESOURCE = "TCPIP::192.168.1.102::INSTR"    def __init__(self):        rm = pyvisa.ResourceManager("@py")        self.instr = rm.open_resource(self.RESOURCE)        self.instr.timeout = 15000        self.instr.write("*RST")    def measure_frequency(self, channel: int = 1) -> float:        return float(self.instr.query(f":MEAS:FREQ? CHAN{channel}"))    def close(self):        self.instr.close()

Three separate classes, three separate sessions, three independent lifecycles. None of them knows about the others.

Declaring Them

procedure.yaml
50 lines
name: Multi-Instrument Functional Testversion: 1.0.0unit:  serial_number:    default_value: "PCBA000001"  part_number:    default_value: "PCBA-100"plugs:  - name: psu    python: plugs.psu:PowerSupply  - name: dmm    python: plugs.dmm:Multimeter  - name: scope    python: plugs.scope:Oscilloscopemain:  - name: Power Up    python: phases.power_up    measurements:      - name: Inrush Current        unit: A        validators:          - operator: "<="            expected_value: 0.45  - name: Rail Measurement    python: phases.rail_measurement    depends_on: [power_up]    measurements:      - name: Rail 3V3        unit: V        validators:          - operator: ">="            expected_value: 3.2          - operator: "<="            expected_value: 3.4  - name: Clock Check    python: phases.clock_check    depends_on: [power_up]    measurements:      - name: Clock Frequency        unit: Hz        validators:          - operator: ">="            expected_value: 7_990_000          - operator: "<="            expected_value: 8_010_000

A phase declares the instruments it needs by parameter name:

phases/power_up.py
def power_up(measurements, psu):    psu.apply(channel=1, volts=5.0, limit=0.5)    measurements.inrush_current = psu.measure_current(1)
phases/rail_measurement.py
def rail_measurement(measurements, dmm):    measurements.rail_3v3 = dmm.dc_voltage()
phases/clock_check.py
def clock_check(measurements, scope):    measurements.clock_frequency = scope.measure_frequency(1)

Run it with tofupilot run ./procedure.yaml.

Note what the phases do not do: no opening, no closing, no passing handles around. Each phase asks for what it needs and the engine supplies it.

The Concurrency Mistake

Independent phases run in parallel. In the procedure above, rail_measurement and clock_check both depend on power_up but not on each other, so they run concurrently, and the run is as fast as the slower of the two.

That is a real benefit and also the trap. Two phases running concurrently must not share a VISA session. A single meter cannot serve two phases at once; you get interleaved commands, mismatched responses and timeouts that appear once a week.

Above, it is safe because the two concurrent phases use different instruments. If both needed the meter, you have two correct options.

Serialise them. Make one depend on the other:

procedure.yaml
- name: Clock Check    python: phases.clock_check    depends_on: [rail_measurement]

Or give each its own instrument. Two meters, two plugs, no contention.

What does not work is sharing one session and hoping. The symptom is intermittent VI_ERROR_TMO or a reading that belongs to the other phase's query, which is far worse than a timeout because it silently records a wrong number.

Instruments With Long Setup

A chamber that takes twenty minutes to reach setpoint should not be re-initialised per phase. The plug's __init__ runs once for the whole run, so expensive setup naturally belongs there:

plugs/chamber.py
28 lines
import timeclass Chamber:    def __init__(self):        self._connect()        self.set_temperature(25.0)        self._wait_for_setpoint()    def _connect(self):        pass  # Replace with the real interface    def set_temperature(self, celsius: float):        self._setpoint = celsius    def read_temperature(self) -> float:        return 25.1    def _wait_for_setpoint(self, timeout_s: float = 1800):        deadline = time.time() + timeout_s        while time.time() < deadline:            if abs(self.read_temperature() - self._setpoint) < 1.0:                return            time.sleep(5)        raise TimeoutError("Chamber did not reach setpoint")    def close(self):        self.set_temperature(25.0)

Shared Instruments Across Fixture Slots

When one rack supply feeds four fixture slots, scope matters. A slot-scoped teardown powering down a shared supply while a neighbouring slot is still measuring is the classic multi-slot bug.

Shared equipment belongs in execution-scoped setup and teardown, which run once for the whole execution rather than per slot. Per-slot resources stay slot-scoped.

Recording What Produced the Numbers

When benches disagree, the first question is which instruments were involved:

phases/record_equipment.py
def record_equipment(run, psu, dmm, scope):    run.metadata["psu_id"] = psu.identify()    run.metadata["dmm_id"] = dmm.identify()    run.metadata["scope_id"] = scope.identify()

Reading *IDN? rather than typing asset tags means the record cannot drift from reality.

Failure Isolation

One instrument failing to initialise fails the run, which is usually right: a test that silently skips the scope is worse than one that stops. If an instrument is genuinely optional, handle that inside the plug and record it as a measurement rather than letting the phase raise:

phases/optional_scope.py
def optional_scope(measurements, scope):    measurements.scope_available = scope.is_connected()

That way the absence is data rather than an exception, and you can decide later whether it should have failed the unit.

If You Already Run OpenHTF

Each instrument is a BasePlug, and phases declare what they need:

multi_instrument.py
41 lines
import openhtf as htffrom openhtf.plugs import BasePlugfrom openhtf.util import unitsfrom tofupilot.openhtf import uploadclass PsuPlug(BasePlug):    def setUp(self):        self.connected = True    def measure_current(self) -> float:        return 0.41    def tearDown(self):        self.connected = Falseclass DmmPlug(BasePlug):    def dc_voltage(self) -> float:        return 3.31@htf.measures(    htf.Measurement("inrush_current").in_range(maximum=0.45).with_units(units.AMPERE),)@htf.plug(psu=PsuPlug)def test_power_up(test, psu):    test.measurements.inrush_current = psu.measure_current()@htf.measures(    htf.Measurement("rail_3v3").in_range(3.2, 3.4).with_units(units.VOLT),)@htf.plug(dmm=DmmPlug)def test_rail(test, dmm):    test.measurements.rail_3v3 = dmm.dc_voltage()test = htf.Test(test_power_up, test_rail, part_number="PCBA-100")test.add_output_callbacks(upload())test.execute(test_start=lambda: input("Scan serial: "))

OpenHTF phases run sequentially, so the concurrency caution does not apply there. Install with pip install "tofupilot[openhtf]", or run it with tofupilot run.

Common Mistakes

MistakeConsequenceFix
One session shared by concurrent phasesInterleaved commands, wrong readingsSerialise with depends_on, or one instrument each
Opening the instrument inside each phaseSlow, and leaks sessions on failureOpen once in the plug's __init__
Power-down as a final phaseBoard stays energised when a phase raisesPut it in the plug's close()
Slot-scoped teardown on shared equipmentOne slot powers down its neighboursExecution scope for shared resources
Expensive setup repeated per phaseCycle time dominated by chamber rampsDo it once in the plug
No instrument identity on the runBench disagreements cannot be tracedRecord *IDN? as run metadata

More Guides

Put this guide into practice