Building an Automated RF Test System
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.
Contents
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.
| Role | Instrument | Connection | VISA Address |
|---|---|---|---|
| Signal source | Keysight 33622A (or any CW generator) | LAN / HiSLIP | TCPIP0::192.168.1.10::hislip0::INSTR |
| Spectrum analyzer | Keysight N9020B MXA (or X-Series) | LAN / HiSLIP | TCPIP0::192.168.1.11::hislip0::INSTR |
| Bias supply | Keysight E36312A | USB | USB0::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.
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?')
*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')
: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.')
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).