Synchronizing Multiple Instruments with Python

PyVISA Advanced · Advanced · ~25 min

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.

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 ResourceManager and query()

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.
Rule of thumb: Never rely on 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
One session per instrument: Do not open the same VISA resource string twice in the same script. Most VISA backends will raise an error or silently corrupt communication. If two parts of your code need the same instrument, pass the resource object as a parameter rather than opening it again.

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.

When to use *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 results dict is written from multiple threads, so protect it with a threading.Lock().
  • Always call join(): Without join(), the main thread can read results before the worker threads have written to it.
GIL limitation: Python's Global Interpreter Lock means threads don't run truly in parallel on CPU-bound tasks. But instrument I/O is network/USB bound — threads spend most of their time waiting for the instrument to respond, so the GIL is released and real parallelism occurs. Threading is the right tool here.

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
Why does voltage drop at higher frequencies? The DMM's AC bandwidth and the cable's capacitive loading both attenuate the signal at higher frequencies. This is a real measurement — not a bug. The sweep lets you characterize this rolloff.

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: