Building an Automated RF Test System

PyVISA LAN · Advanced · ~40 min

Build a complete RF test system from scratch: a signal generator drives the DUT, a spectrum analyzer measures the output, and a power supply provides the bias — all orchestrated from a single Python script with error handling, OPC synchronization, and CSV data logging.

Prerequisites

You'll need

  • PyVISA installed — see PyVISA Quickstart
  • Familiarity with OPC synchronization — see Multi-Instrument Sync
  • A spectrum analyzer, signal generator, and power supply (see hardware table below)
  • RF cables (SMA/N-type), a DUT (device under test), and a LAN switch or direct Ethernet
  • Python 3.7+, csv (stdlib)

The guide uses Keysight instruments as the example (X-Series SA, 33600A signal generator, E36300 PSU) but every pattern applies to equivalent instruments from other vendors — adjust the SCPI commands per your programming manual.

System Overview

The test system in this guide measures the gain compression of a low-noise amplifier (LNA) across a range of input power levels at a fixed frequency. The same structure applies to any swept scalar RF measurement: gain, insertion loss, return loss (with a coupler), noise figure, etc.

RoleInstrumentConnectionVISA Address
Signal sourceKeysight 33622A (or any CW generator)LAN / HiSLIPTCPIP0::192.168.1.10::hislip0::INSTR
Spectrum analyzerKeysight N9020B MXA (or X-Series)LAN / HiSLIPTCPIP0::192.168.1.11::hislip0::INSTR
Bias supplyKeysight E36312AUSBUSB0::0x2A8D::0x1602::MY12345::INSTR

Signal flow: SigGen output → DUT RF input → DUT RF output → SA input. The PSU provides DC bias to the DUT through a bias tee or direct supply pins.

Check input power limits before connecting. Spectrum analyzers are typically rated for +30 dBm maximum input. Use an attenuator pad if your DUT output could exceed this. Exceeding the limit damages the input mixer — an expensive repair.

Connecting the Instruments

Open all three VISA sessions at startup, verify connectivity with *IDN?, and reset each instrument to a known state:

import pyvisa

SIGGEN_ADDR = 'TCPIP0::192.168.1.10::hislip0::INSTR'
SA_ADDR     = 'TCPIP0::192.168.1.11::hislip0::INSTR'
PSU_ADDR    = 'USB0::0x2A8D::0x1602::MY12345::INSTR'

rm = pyvisa.ResourceManager()

siggen = rm.open_resource(SIGGEN_ADDR)
sa     = rm.open_resource(SA_ADDR)
psu    = rm.open_resource(PSU_ADDR)

for name, inst in [('SigGen', siggen), ('SA', sa), ('PSU', psu)]:
    inst.timeout = 15000
    print(f'{name}: {inst.query("*IDN?").strip()}')

# Reset all instruments to factory defaults
for inst in (siggen, sa, psu):
    inst.write('*RST')
for inst in (siggen, sa, psu):
    inst.query('*OPC?')
Why reset? Previous test sessions may have left non-default settings active — averaging on, unusual trigger modes, output enabled. *RST puts every instrument into a known baseline so your configuration commands have predictable results.

Configuring Each Instrument

Signal Generator

Configure for continuous wave (CW) output at the test frequency. Output is enabled separately after configuration to avoid transients during setup:

TEST_FREQ_HZ  = 2.4e9       # 2.4 GHz
START_PWR_DBM = -30.0       # dBm, sweep start
STOP_PWR_DBM  =   0.0       # dBm, sweep stop
STEP_PWR_DB   =   2.0       # dB per step

siggen.write(f':FREQ {TEST_FREQ_HZ}')
siggen.write(f':POW {START_PWR_DBM} DBM')
siggen.write(':OUTP:LOAD 50')     # 50-ohm output impedance
siggen.query('*OPC?')
# Output is OFF until the sweep starts

Spectrum Analyzer

Configure for a narrow span around the test frequency. Using a narrow span and RBW improves measurement accuracy and reduces sweep time:

SPAN_HZ    = 10e6      # 10 MHz span
RBW_HZ     = 100e3    # 100 kHz RBW
VBW_HZ     = 10e3     # 10 kHz VBW (smoothing)
SA_REF_DBM = 10.0     # reference level — set just above expected max output

sa.write(':INST:SEL SA')                    # spectrum analyzer mode
sa.write(f':SENS:FREQ:CENT {TEST_FREQ_HZ}')
sa.write(f':SENS:FREQ:SPAN {SPAN_HZ}')
sa.write(f':SENS:BAND:RES {RBW_HZ}')
sa.write(f':SENS:BAND:VID {VBW_HZ}')
sa.write(f':DISP:WIND:TRAC:Y:RLEV {SA_REF_DBM}')
sa.write(':SENS:SWE:TIME:AUTO ON')          # auto sweep time
sa.write(':INIT:CONT OFF')                  # single-sweep mode
sa.query('*OPC?')

Power Supply

Configure the bias voltage and current limit before enabling the output. Always set the current limit first to protect the DUT:

BIAS_VOLT = 3.3     # V
BIAS_CURR = 0.2     # A (current limit)

psu.write(f':CURR {BIAS_CURR}')
psu.write(f':VOLT {BIAS_VOLT}')
psu.write(':OUTP ON')
psu.query('*OPC?')
print(f'PSU output: {float(psu.query(":MEAS:VOLT?")):.3f} V, '
      f'{float(psu.query(":MEAS:CURR?")) * 1000:.1f} mA')

The Measurement Sweep Loop

Step the signal generator power from START_PWR_DBM to STOP_PWR_DBM, trigger a single sweep on the SA at each step, and read the peak marker power:

import numpy as np
import time

# Build power array
powers = np.arange(START_PWR_DBM, STOP_PWR_DBM + STEP_PWR_DB, STEP_PWR_DB)

# Place a peak marker at the test frequency
sa.write(':CALC:MARK1:STAT ON')
sa.write(f':CALC:MARK1:X {TEST_FREQ_HZ}')
sa.write(':CALC:MARK1:FUNC:BPOW:STAT OFF')   # single-frequency marker

siggen.write(':OUTP ON')
siggen.query('*OPC?')

rows = []
for pwr in powers:
    # Set input power
    siggen.write(f':POW {pwr:.1f} DBM')
    siggen.query('*OPC?')
    time.sleep(0.02)      # 20 ms settle

    # Trigger one sweep and wait for completion
    sa.write(':INIT:IMM')
    sa.query('*OPC?')

    # Read marker power (dBm)
    output_pwr = float(sa.query(':CALC:MARK1:Y?'))
    gain       = output_pwr - pwr

    rows.append({'input_dbm': pwr, 'output_dbm': output_pwr, 'gain_db': gain})
    print(f'Pin={pwr:+6.1f} dBm  Pout={output_pwr:+6.2f} dBm  G={gain:+5.2f} dB')

siggen.write(':OUTP OFF')
Reading the peak marker vs. channel power: :CALC:MARK1:Y? returns the marker's amplitude at its current X position. For a CW signal this is the correct approach. For a modulated signal, use channel power (:CALC:MARK1:FUNC:BPOW:STAT ON) with an appropriate integration bandwidth instead.

Data Logging and CSV Output

Save the sweep results to a timestamped CSV file. Including a timestamp in the filename avoids overwriting previous runs:

import csv
from datetime import datetime

timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename  = f'rf_sweep_{timestamp}.csv'

with open(filename, 'w', newline='') as f:
    fieldnames = ['input_dbm', 'output_dbm', 'gain_db']
    writer = csv.DictWriter(f, fieldnames=fieldnames)
    writer.writeheader()
    writer.writerows(rows)

print(f'\nSaved {len(rows)} points to {filename}')

To add a quick summary to the console, find the 1 dB compression point — where gain has dropped 1 dB from its small-signal value:

gains      = [r['gain_db']    for r in rows]
input_pwrs = [r['input_dbm'] for r in rows]

small_signal_gain = gains[0]   # gain at lowest input power
p1db = None

for i, g in enumerate(gains):
    if g < small_signal_gain - 1.0:
        p1db = input_pwrs[i]
        break

if p1db is not None:
    print(f'P1dB ≈ {p1db:.1f} dBm input')
else:
    print('P1dB not reached in sweep range — increase STOP_PWR_DBM')

Error Handling and Safe Shutdown

When an instrument fails mid-sweep, you must still turn off the signal generator and power supply to protect the DUT. Wrap the entire sweep in a try/finally block:

def check_scpi_errors(inst, label):
    """Raise if the instrument reports an error."""
    err = inst.query(':SYST:ERR?').strip()
    # Error queue returns "+0,No Error" or "0,No Error" when clean
    if not (err.startswith('+0,') or err.startswith('0,')):
        raise RuntimeError(f'{label} SCPI error: {err}')

try:
    # ... all configuration and sweep code here ...
    for pwr in powers:
        siggen.write(f':POW {pwr:.1f} DBM')
        siggen.query('*OPC?')
        check_scpi_errors(siggen, 'SigGen')

        sa.write(':INIT:IMM')
        sa.query('*OPC?')
        check_scpi_errors(sa, 'SA')

        output_pwr = float(sa.query(':CALC:MARK1:Y?'))
        # ... log result ...

except Exception as e:
    print(f'\nTest aborted: {e}')

finally:
    # Always executed — safe shutdown
    try: siggen.write(':OUTP OFF')
    except: pass
    try: psu.write(':OUTP OFF')
    except: pass

    siggen.close()
    sa.close()
    psu.close()
    rm.close()
    print('Instruments closed.')
Nested try/except in finally: The try/except: pass blocks inside finally prevent a second exception (e.g., instrument disconnected) from hiding the original error. Always suppress cleanup exceptions individually, never with a blanket catch on the outer block.

Full Script

Everything assembled into one runnable file. Edit the configuration block at the top to match your hardware:

"""
automated_rf_test.py
Swept power gain compression measurement.
Edit the CONFIGURATION block before running.
"""

import pyvisa
import csv
import time
import numpy as np
from datetime import datetime

# ===================== CONFIGURATION =====================
SIGGEN_ADDR   = 'TCPIP0::192.168.1.10::hislip0::INSTR'
SA_ADDR       = 'TCPIP0::192.168.1.11::hislip0::INSTR'
PSU_ADDR      = 'USB0::0x2A8D::0x1602::MY12345::INSTR'

TEST_FREQ_HZ  = 2.4e9       # Hz
START_PWR_DBM = -30.0       # dBm
STOP_PWR_DBM  =   0.0       # dBm
STEP_PWR_DB   =   2.0       # dB
SPAN_HZ       = 10e6
RBW_HZ        = 100e3
VBW_HZ        = 10e3
SA_REF_DBM    = 10.0
BIAS_VOLT     = 3.3         # V
BIAS_CURR     = 0.2         # A
# =========================================================

def check_errors(inst, label):
    err = inst.query(':SYST:ERR?').strip()
    if not (err.startswith('+0,') or err.startswith('0,')):
        raise RuntimeError(f'{label}: {err}')

rm = pyvisa.ResourceManager()

siggen = rm.open_resource(SIGGEN_ADDR)
sa     = rm.open_resource(SA_ADDR)
psu    = rm.open_resource(PSU_ADDR)

for name, inst in [('SigGen', siggen), ('SA', sa), ('PSU', psu)]:
    inst.timeout = 15000
    print(f'{name}: {inst.query("*IDN?").strip()}')

rows = []

try:
    # Reset
    for inst in (siggen, sa, psu):
        inst.write('*RST')
    for inst in (siggen, sa, psu):
        inst.query('*OPC?')

    # Configure signal generator
    siggen.write(f':FREQ {TEST_FREQ_HZ}')
    siggen.write(f':POW {START_PWR_DBM} DBM')
    siggen.write(':OUTP:LOAD 50')
    siggen.query('*OPC?')

    # Configure spectrum analyzer
    sa.write(':INST:SEL SA')
    sa.write(f':SENS:FREQ:CENT {TEST_FREQ_HZ}')
    sa.write(f':SENS:FREQ:SPAN {SPAN_HZ}')
    sa.write(f':SENS:BAND:RES {RBW_HZ}')
    sa.write(f':SENS:BAND:VID {VBW_HZ}')
    sa.write(f':DISP:WIND:TRAC:Y:RLEV {SA_REF_DBM}')
    sa.write(':SENS:SWE:TIME:AUTO ON')
    sa.write(':INIT:CONT OFF')
    sa.write(':CALC:MARK1:STAT ON')
    sa.write(f':CALC:MARK1:X {TEST_FREQ_HZ}')
    sa.query('*OPC?')

    # Enable PSU bias
    psu.write(f':CURR {BIAS_CURR}')
    psu.write(f':VOLT {BIAS_VOLT}')
    psu.write(':OUTP ON')
    psu.query('*OPC?')
    print(f'Bias: {float(psu.query(":MEAS:VOLT?")):.3f} V  '
          f'{float(psu.query(":MEAS:CURR?"))*1000:.1f} mA')

    # Sweep
    powers = np.arange(START_PWR_DBM, STOP_PWR_DBM + STEP_PWR_DB, STEP_PWR_DB)
    siggen.write(':OUTP ON')
    siggen.query('*OPC?')

    print(f'\nSweeping {len(powers)} points at {TEST_FREQ_HZ/1e9:.3f} GHz ...')
    for pwr in powers:
        siggen.write(f':POW {pwr:.1f} DBM')
        siggen.query('*OPC?')
        time.sleep(0.02)
        check_errors(siggen, 'SigGen')

        sa.write(':INIT:IMM')
        sa.query('*OPC?')
        check_errors(sa, 'SA')

        output_pwr = float(sa.query(':CALC:MARK1:Y?'))
        gain = output_pwr - pwr
        rows.append({'input_dbm': pwr, 'output_dbm': output_pwr, 'gain_db': gain})
        print(f'  Pin={pwr:+6.1f}  Pout={output_pwr:+6.2f}  G={gain:+5.2f} dB')

    # P1dB
    gains = [r['gain_db'] for r in rows]
    ss_gain = gains[0]
    p1db = next((rows[i]['input_dbm'] for i, g in enumerate(gains)
                 if g < ss_gain - 1.0), None)
    if p1db:
        print(f'\nP1dB ≈ {p1db:.1f} dBm input')
    else:
        print('\nP1dB not reached — extend sweep range')

    # Save
    ts = datetime.now().strftime('%Y%m%d_%H%M%S')
    fname = f'rf_sweep_{ts}.csv'
    with open(fname, 'w', newline='') as f:
        w = csv.DictWriter(f, fieldnames=['input_dbm', 'output_dbm', 'gain_db'])
        w.writeheader(); w.writerows(rows)
    print(f'Saved {len(rows)} points → {fname}')

except Exception as e:
    print(f'\nAborted: {e}')

finally:
    try: siggen.write(':OUTP OFF')
    except: pass
    try: psu.write(':OUTP OFF')
    except: pass
    siggen.close(); sa.close(); psu.close(); rm.close()
    print('Done.')

Troubleshooting

SA marker reads unexpectedly low (−100 dBm or similar)

The marker X position drifted from the carrier after a span change or preset. Re-send :CALC:MARK1:X {TEST_FREQ_HZ} after every SA configuration command that changes the frequency axis. Alternatively, use :CALC:MARK1:MAX to snap the marker to the peak before reading Y.

Signal generator power is not accurate at high frequencies

Most signal generators have a power leveling flatness spec. At 2.4 GHz you may see ±1–2 dB deviation from the set level depending on the instrument. Use an external power meter for the input power reference if accuracy is critical, or apply a calibration offset table.

*OPC? times out on the spectrum analyzer

The SA sweep time depends on span, RBW, and averaging count. A 1 GHz span at 1 kHz RBW can take tens of seconds. Increase sa.timeout for the sweep and *OPC? calls only:

sa.timeout = 60000    # 60 seconds for the sweep
sa.write(':INIT:IMM')
sa.query('*OPC?')
sa.timeout = 15000    # restore

PSU current limit trips during the sweep

The DUT is drawing more current than BIAS_CURR allows. The PSU output drops, changing DUT operating point and invalidating the measurement. Check the DUT datasheet for maximum current and increase the limit, or add a compliance check after enabling the output.

Gain varies significantly run to run

Common causes: insufficient settle time (increase time.sleep), temperature drift (allow 30 min warm-up), or cable flexure (use phase-stable RF cables and avoid moving them between measurements).

Next Steps