Share Instruments Across Parallel Slots
Parallel testing assumes each position has its own instruments. Real stations rarely do. One DMM behind a multiplexer serves eight positions, one thermal chamber holds the whole tray, one power supply feeds a rack.
Shared instruments are where parallel test stations lose the throughput they were built for, and where they produce measurements that are wrong in ways that pass. This guide covers how sharing works, how to size the benefit, and the failure modes worth designing against.
It assumes a multi-position fixture; for what a position is and how to declare one, see What Are Test Sockets and How to Use Them.
Why Sharing Is a Problem
An instrument is a single piece of hardware with state. Configure a DMM for a 10V DC range, and that is its range until something changes it.
Two slots measuring at once creates two failures that look nothing alike:
Interleaved commands. Slot 1 sets the range to 10V, slot 2 sets it to 100mV, then slot 1 triggers a reading. Slot 1 measures its unit on slot 2's range. The instrument reports no error, the measurement is a plausible number, and the unit either fails for no reason or passes when it should not.
Serialization. Two slots cannot physically read one DMM simultaneously. The calls queue whether or not you designed for it.
The first failure is the dangerous one, because it produces bad data rather than an error.
Scope Decides How Many Instances Exist
In the TofuPilot Framework, plug scope controls instance count and lifetime:
| Scope | Instance | Use for |
|---|---|---|
slot (default) | One per slot | Per-position PSU channel, per-position serial port |
execution | One shared by all slots | A DMM behind a mux, a chamber, a rack supply |
station | One per station, held across runs | Slow-to-open VISA or TCP sessions |
execution: slots: 8plugs: - name: dut_serial python: plugs.serial_port:DutSerial scope: slot - name: dmm python: plugs.dmm:Keysight34461A scope: execution config: address: "192.168.1.50" - name: chamber python: plugs.chamber:ThermalChamber scope: station config: address: "192.168.1.60"Declaring a shared instrument as execution or station is what makes it safe. A plug instance runs one method call at a time. When several slots call the same shared plug, their calls queue and execute one at a time, like commands on a SCPI socket. The interleaving failure above cannot happen through a shared plug, because there is one instance and one call in flight.
The corresponding mistake is declaring a shared instrument as slot scope. Eight slots then open eight sessions to one physical instrument, with no serialization at all, and you get exactly the interleaving problem — intermittently, under load, in production.
Keep the Critical Section Inside One Call
Serialization is per method call, not per phase. A shared measurement must therefore be one call that configures, triggers, and reads:
import pyvisaclass Keysight34461A: def __init__(self, address: str): self._rm = pyvisa.ResourceManager() self._inst = self._rm.open_resource(f"TCPIP::{address}::INSTR") self._inst.timeout = 5000 def measure_dc_volts(self, channel: int, expected_range: float) -> float: """Select the channel, configure, and read as one indivisible call.""" self._inst.write(f"ROUT:CLOS (@{channel})") self._inst.write(f"CONF:VOLT:DC {expected_range}") return float(self._inst.query("READ?")) def __del__(self): self._inst.close()Split across three calls, another slot's call can land between the configure and the read:
class DmmWrong: def select_channel(self, channel: int): self._inst.write(f"ROUT:CLOS (@{channel})") def configure(self, expected_range: float): self._inst.write(f"CONF:VOLT:DC {expected_range}") def read(self) -> float: # Another slot may have re-routed the mux by now. return float(self._inst.query("READ?"))The phase then passes its own channel, and the shared instance keeps every sequence intact. A phase reads its position from run.slot_id, the key declared in execution.slots:
CHANNELS = {"s1": 101, "s2": 102, "s3": 103, "s4": 104}def measure_rail(measurements, run, dmm): measurements.rail_voltage = dmm.measure_dc_volts( channel=CHANNELS[run.slot_id], expected_range=10.0, )Mapping slot keys to channels explicitly, rather than deriving a channel from a slot number, keeps the wiring readable and survives renumbering the fixture.
Keep Shared Calls Short
Queuing has a direct throughput cost, and it is the reason parallel stations underperform their spec.
A shared plug method that sleeps holds every other slot's calls to that plug for as long as it runs. Settling time is the usual culprit:
import timeclass DmmSlow: def measure_after_settle(self, channel: int) -> float: self._inst.write(f"ROUT:CLOS (@{channel})") time.sleep(2.0) # blocks all eight slots return float(self._inst.query("READ?"))With eight slots, that two-second settle costs sixteen seconds of station time per cycle, and seven slots spend it idle. Put the wait in the phase, where slots wait concurrently:
import timeCHANNELS = {"s1": 101, "s2": 102, "s3": 103, "s4": 104}def measure_rail(measurements, run, psu, dmm): channel = CHANNELS[run.slot_id] psu.enable(channel) time.sleep(2.0) # each slot waits in parallel measurements.rail_voltage = dmm.measure_dc_volts(channel, 10.0)The rule: shared plug methods should contain instrument I/O and nothing else. Waiting, computation, and retry loops belong in phases.
Sizing the Benefit Before Building
Whether sharing is acceptable is arithmetic, worth doing before the fixture is built.
Let N be slot count, S the shared instrument time per unit, and P the per-slot time that runs in parallel. Cycle time is roughly:
cycle ≈ max(P, N × S)Eight slots, 25s of parallel work, 1s of shared measurement:
max(25, 8 × 1) = 25s shared instrument is freeThe same station with a 4-second shared measurement:
max(25, 8 × 4) = 32s the DMM is now the bottleneckPast N × S > P, adding slots buys nothing: the ninth slot waits on the DMM. That is the point to either buy a second instrument, shorten the shared measurement, or stop adding positions.
Use phase_first for Shared Instruments
With slots configured, execution strategy affects how shared work clusters:
execution: strategy: phase_first slots: 8phase_first, the default, runs the same phase across all slots before advancing. Every slot's DMM measurement happens in the same window, so the instrument stays in one configuration for the group.
slot_first completes one slot before starting the next, which reconfigures the shared instrument on every switch. For a station whose sharing is expensive to reconfigure, phase_first is the better default.
Shared Setup and Teardown
Some shared hardware is not measured per unit at all: a chamber ramps once, a rack supply powers on once. Those are phases scoped to the execution:
setup: - name: Ramp Chamber to 85C python: phases.ramp_chamber scope: executionmain: - name: Measure Leakage python: phases.measure_leakageteardown: - name: Discharge This Position python: phases.discharge - name: Return Chamber to Ambient python: phases.cool_chamber scope: executionExecution-scoped setup runs once before any slot starts; execution-scoped teardown runs once after every slot is done.
This scoping is a correctness requirement, not a convenience. A slot-scoped teardown can run while its neighbours are still testing. Return Chamber to Ambient as a slot-scoped phase would start cooling as soon as the first unit finished, and every remaining unit would be measured on a falling temperature ramp. Those units fail intermittently, the failures do not reproduce on the bench, and nothing in the data points at the chamber.
The rule follows directly: if a teardown touches something another slot can still be using, it must be scope: execution.
Failure Behavior
A shared plug that fails to initialize stops every slot, and so does a failing execution-scoped setup phase. That is correct — eight units testing without a chamber at temperature would produce eight meaningless passes — but it makes shared setup the highest-consequence code on the station.
Give those phases retries and error messages that name the instrument:
setup: - name: Ramp Chamber to 85C python: phases.ramp_chamber scope: execution timeout: 15m retry: limit: 2 delay: 30sIf the instrument connection drops mid-test, handle the reconnect inside the plug. The engine guarantees a long-lived plug process, not a live instrument session; it health-checks a held station plug before each reuse and respawns it if the process died, but a dead TCP session inside a living process is yours to notice.
Common Pitfalls
Declaring a shared instrument as slot scope. N sessions to one instrument, no serialization, interleaved commands. This is the failure that produces wrong numbers instead of errors.
Splitting a configure-and-read across several plug methods. Another slot can land in the gap.
Sleeping inside a shared plug method. Every other slot waits. Move the wait to the phase.
Cleaning up shared hardware in slot teardown. The first slot to finish disturbs the rest.
Sizing the fixture without the shared-instrument term. A 16-slot fixture with a 3-second shared measurement is a 48-second cycle no matter how fast the units are.
Key Points
- Shared instruments fail two ways: interleaved commands producing wrong data, and queuing destroying throughput.
scope: executionorscope: stationgives one instance whose calls serialize, which prevents interleaving by construction.- A shared plug method must be the whole critical section: configure, trigger, and read in one call.
- Keep waits out of shared methods; a sleeping method blocks every slot.
- Cycle time is roughly
max(P, N × S)— past that crossover, more slots buy nothing. - Anything a finishing slot could disturb belongs in an execution-scoped teardown.