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.py27 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.py19 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()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.yaml50 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_000A phase declares the instruments it needs by parameter name:
def power_up(measurements, psu): psu.apply(channel=1, volts=5.0, limit=0.5) measurements.inrush_current = psu.measure_current(1)def rail_measurement(measurements, dmm): measurements.rail_3v3 = dmm.dc_voltage()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:
- 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.py28 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:
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:
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.py41 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
| Mistake | Consequence | Fix |
|---|---|---|
| One session shared by concurrent phases | Interleaved commands, wrong readings | Serialise with depends_on, or one instrument each |
| Opening the instrument inside each phase | Slow, and leaks sessions on failure | Open once in the plug's __init__ |
| Power-down as a final phase | Board stays energised when a phase raises | Put it in the plug's close() |
| Slot-scoped teardown on shared equipment | One slot powers down its neighbours | Execution scope for shared resources |
| Expensive setup repeated per phase | Cycle time dominated by chamber ramps | Do it once in the plug |
| No instrument identity on the run | Bench disagreements cannot be traced | Record *IDN? as run metadata |
