Synchronizing Multiple Instruments with Python
Control a signal generator, DMM, and power supply simultaneously from a single Python script. Learn resource management, OPC-based synchronization, threading patterns, and how to avoid race conditions in multi-instrument test systems.
Contents
Prerequisites
You'll need
- PyVISA installed — see PyVISA Quickstart if you haven't done this yet
- Two or more VISA-compatible instruments (any combination of LAN, USB, or GPIB)
- Python 3.7 or later
- Basic familiarity with PyVISA's
ResourceManagerandquery()
This guide uses a signal generator and DMM as the primary example pair. The patterns shown apply to any combination of instruments — oscilloscope + power supply, two DMMs, spectrum analyzer + signal generator, and so on.
The Problem with Sequential Control
The naive approach to multi-instrument control is to send commands one at a time, instrument by instrument:
import pyvisa, time
rm = pyvisa.ResourceManager()
siggen = rm.open_resource('TCPIP0::192.168.1.10::hislip0::INSTR')
dmm = rm.open_resource('TCPIP0::192.168.1.11::hislip0::INSTR')
# Naive sequential approach
siggen.write(':FREQ 1E6')
siggen.write(':AMPL 0.5 VPP')
siggen.write(':OUTP ON')
time.sleep(0.1) # hope the signal is stable by now
v = float(dmm.query(':MEAS:VOLT:AC?'))
print(v)
This breaks in three common ways:
- Timing drift:
time.sleep()is a guess. Too short and the instrument hasn't settled; too long and you're wasting test time. The correct delay varies by instrument, frequency, and cable. - No completion guarantee: A
write()call returns as soon as the command is sent over the interface — not when the instrument has finished executing it. For commands that take measurable time (like a self-calibration or a long sweep), the next command may arrive before the previous one finishes. - Silent failures: If the signal generator is still ramping up when the DMM triggers, you'll get a measurement but it will be wrong — and there's no error to tell you so.
time.sleep() alone for synchronization. Use *OPC? to confirm the instrument has finished, then add a small settle delay only if the physics of the measurement requires it (e.g., thermal stabilization).
Opening Multiple Resources
A single ResourceManager instance can hold connections to as many instruments as you need. Open each one with open_resource() and set an appropriate timeout:
import pyvisa
rm = pyvisa.ResourceManager()
siggen = rm.open_resource('TCPIP0::192.168.1.10::hislip0::INSTR')
dmm = rm.open_resource('TCPIP0::192.168.1.11::hislip0::INSTR')
psu = rm.open_resource('USB0::0x2A8D::0x1602::MY12345::INSTR')
for inst in (siggen, dmm, psu):
inst.timeout = 10000 # 10-second timeout for all instruments
print(inst.query('*IDN?').strip())
Running *IDN? on each instrument at startup serves two purposes: it confirms connectivity before the test begins, and it logs which hardware is actually in use — important when instrument firmware versions affect behavior.
| Interface | Example Resource String | Notes |
|---|---|---|
| LAN (HiSLIP) | TCPIP0::192.168.1.10::hislip0::INSTR |
Preferred for modern Keysight, R&S instruments |
| LAN (VXI-11) | TCPIP0::192.168.1.10::inst0::INSTR |
Broader compatibility, slower setup time |
| USB | USB0::0x2A8D::0x1602::MY12345::INSTR |
Use rm.list_resources() to find the exact string |
| GPIB | GPIB0::22::INSTR |
Address 0–30; requires NI-VISA or linux-gpib |
OPC Synchronization
The IEEE 488.2 standard defines *OPC? (Operation Complete Query) as the reliable way to wait for an instrument to finish what it's doing. It blocks the query until all pending operations are complete, then returns "1".
# Set frequency and amplitude, then WAIT for the siggen to settle
siggen.write(':FREQ 1E6')
siggen.write(':AMPL 0.5 VPP')
siggen.write(':OUTP ON')
siggen.query('*OPC?') # blocks until all pending operations complete
# Now trigger the DMM and wait for it to finish before reading
dmm.write(':INIT')
dmm.query('*OPC?')
result = float(dmm.query(':FETCH?'))
print(f'AC voltage: {result:.6f} Vrms')
The sequence matters: :INIT arms the DMM trigger, *OPC? waits for the measurement to complete, and :FETCH? retrieves the result without triggering a new measurement. Using :MEAS:VOLT:AC? would trigger a new measurement each time, which is fine for single readings but inefficient in a loop.
*OPC? vs *WAI: *OPC? (query form) blocks the interface bus — the script waits. *OPC (command form, no ?) sets bit 0 of the ESR register when done, useful for interrupt-driven designs. For most test scripts, *OPC? is the right choice.
For instruments that support it, you can also chain configuration commands before a single *OPC?:
# Multiple settings — one OPC wait covers all of them
siggen.write(':FREQ 5E6; :AMPL 1.0 VPP; :OUTP:LOAD 50')
siggen.query('*OPC?') # waits for all three commands to complete
Threaded Control for True Parallelism
OPC synchronization is sequential — the script waits for instrument A before talking to instrument B. For measurements that are truly independent, Python's threading module lets you query both instruments at the same time:
import threading
results = {}
lock = threading.Lock()
def measure_voltage():
val = float(dmm.query(':MEAS:VOLT:DC?'))
with lock:
results['voltage_v'] = val
def measure_current():
val = float(psu.query(':MEAS:CURR?'))
with lock:
results['current_a'] = val
t1 = threading.Thread(target=measure_voltage)
t2 = threading.Thread(target=measure_current)
t1.start()
t2.start()
t1.join()
t2.join()
print(results)
# {'voltage_v': 5.0003, 'current_a': 0.2145}
Key points for threaded instrument control:
- One thread per instrument: Never share a VISA resource object between threads. Each instrument object is not thread-safe — concurrent calls will corrupt the communication stream.
- Use a Lock for shared data: The
resultsdict is written from multiple threads, so protect it with athreading.Lock(). - Always call
join(): Withoutjoin(), the main thread can readresultsbefore the worker threads have written to it.
Full Example: Frequency Sweep with CSV Output
This script sweeps a signal generator across five frequencies, measures the AC voltage at each point with a DMM, and saves the results to a CSV file. It demonstrates OPC synchronization, proper resource cleanup, and basic data logging.
import pyvisa
import csv
import time
# --- Configuration ---
SIGGEN_ADDR = 'TCPIP0::192.168.1.10::hislip0::INSTR'
DMM_ADDR = 'TCPIP0::192.168.1.11::hislip0::INSTR'
AMPLITUDE = '0.5 VPP'
FREQUENCIES = [100e3, 500e3, 1e6, 5e6, 10e6] # Hz
OUTPUT_FILE = 'freq_sweep.csv'
# --- Main script ---
rm = pyvisa.ResourceManager()
siggen = rm.open_resource(SIGGEN_ADDR)
dmm = rm.open_resource(DMM_ADDR)
siggen.timeout = 10000
dmm.timeout = 10000
try:
# Verify connectivity
print('Signal generator:', siggen.query('*IDN?').strip())
print('DMM: ', dmm.query('*IDN?').strip())
# Configure instruments
siggen.write(f':AMPL {AMPLITUDE}')
siggen.write(':OUTP ON')
siggen.query('*OPC?')
dmm.write(':CONF:VOLT:AC') # configure for AC voltage measurement
rows = []
for freq in FREQUENCIES:
# Set frequency and wait for it to take effect
siggen.write(f':FREQ {freq}')
siggen.query('*OPC?')
time.sleep(0.05) # allow signal to settle at new frequency
# Trigger a single measurement and wait for it to complete
dmm.write(':INIT')
dmm.query('*OPC?')
voltage = float(dmm.query(':FETCH?'))
rows.append({'freq_hz': freq, 'voltage_vrms': voltage})
print(f' {freq/1e6:6.2f} MHz → {voltage:.5f} Vrms')
# Save results
with open(OUTPUT_FILE, 'w', newline='') as f:
writer = csv.DictWriter(f, fieldnames=['freq_hz', 'voltage_vrms'])
writer.writeheader()
writer.writerows(rows)
print(f'\nResults saved to {OUTPUT_FILE}')
finally:
# Always clean up, even if an exception occurred
siggen.write(':OUTP OFF')
siggen.close()
dmm.close()
rm.close()
Sample output:
Signal generator: Keysight Technologies,33622A,MY12345678,A.02.05-3.15-3.15
DMM: Keysight Technologies,34461A,MY98765432,A.02.17-02.40
0.10 MHz → 0.35329 Vrms
0.50 MHz → 0.35318 Vrms
1.00 MHz → 0.35301 Vrms
5.00 MHz → 0.35244 Vrms
10.00 MHz → 0.35187 Vrms
Results saved to freq_sweep.csv
Error Handling in Multi-Instrument Systems
When one instrument in a multi-instrument system fails, you need to ensure the others are left in a safe state. A try/finally block guarantees cleanup runs even when an exception occurs — but writing a separate finally for every instrument gets verbose. contextlib.ExitStack handles this cleanly:
from contextlib import ExitStack
import pyvisa
rm = pyvisa.ResourceManager()
with ExitStack() as stack:
siggen = stack.enter_context(rm.open_resource('TCPIP0::192.168.1.10::hislip0::INSTR'))
dmm = stack.enter_context(rm.open_resource('TCPIP0::192.168.1.11::hislip0::INSTR'))
psu = stack.enter_context(rm.open_resource('USB0::0x2A8D::0x1602::MY12345::INSTR'))
# All instruments automatically closed when the with-block exits,
# whether normally or due to an exception.
For instruments that need to be put into a safe state before closing (e.g., turning off an output), register a cleanup function explicitly:
from contextlib import ExitStack
import pyvisa
rm = pyvisa.ResourceManager()
with ExitStack() as stack:
siggen = stack.enter_context(rm.open_resource('TCPIP0::192.168.1.10::hislip0::INSTR'))
psu = stack.enter_context(rm.open_resource('USB0::0x2A8D::0x1602::MY12345::INSTR'))
# Register safe-shutdown callbacks — run in LIFO order on exit
stack.callback(lambda: siggen.write(':OUTP OFF'))
stack.callback(lambda: psu.write(':OUTP OFF'))
# Your test code here
siggen.write(':OUTP ON')
psu.write(':VOLT 5.0')
psu.write(':OUTP ON')
# ... measurements ...
# On any exception, outputs are turned off before resources close
To catch and log instrument errors without stopping the entire sweep, check the instrument's error queue after each measurement:
def check_errors(inst, label='instrument'):
"""Query the error queue and raise if any errors are present."""
err = inst.query(':SYST:ERR?').strip()
if not err.startswith('+0') and not err.startswith('0,'):
raise RuntimeError(f'{label} error: {err}')
# After each command block:
siggen.write(':FREQ 1E6; :AMPL 0.5 VPP')
check_errors(siggen, 'siggen')
Troubleshooting
Timeout errors accumulate across instruments
If instrument A times out, the exception propagates and instrument B is never closed. Always use try/finally or ExitStack. Also, set a realistic timeout for each instrument — a DMM taking a 10-PLC measurement at 50 Hz needs at least 200 ms; give it 2000 ms to be safe.
VI_ERROR_RSRC_BUSY or resource locked
This usually means another process (NI MAX, Keysight IO Monitor, an IDE debugger) has the instrument open. Close any VISA utility applications before running your script. On Linux, check that no other Python process has the resource open with lsof | grep usbtmc.
Commands to one instrument affect another
This shouldn't happen with separate VISA sessions, but can occur with GPIB if address collisions exist (two instruments sharing the same address). Run rm.list_resources() and confirm each instrument has a unique address.
*OPC? never returns
The instrument is executing a command that takes longer than your timeout. Increase inst.timeout for that specific operation. Long operations include: self-calibration (*CAL?), averaging with many samples, and slow sweep modes. You can temporarily increase the timeout just for that call:
dmm.timeout = 60000 # 60 seconds for a slow measurement
dmm.write(':INIT')
dmm.query('*OPC?')
dmm.timeout = 10000 # restore normal timeout
Results are inconsistent between runs
Likely a settling time issue. Add a small time.sleep() after *OPC? returns — the instrument may have technically completed the command while the signal is still stabilizing electrically. 50–100 ms is usually sufficient for most signal generators.
Next Steps
With multi-instrument sync in hand, you have the building blocks for a full automated test system: